chanjs 2.7.4 → 2.7.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (94) hide show
  1. package/USAGE.md +533 -0
  2. package/config/index.js +37 -6
  3. package/core/App.js +166 -0
  4. package/core/BaseComponent.js +27 -0
  5. package/core/Container.js +68 -0
  6. package/core/Controller.js +29 -0
  7. package/core/Database.js +93 -0
  8. package/core/Repository.js +323 -0
  9. package/core/Service.js +11 -0
  10. package/core/bootstrap/error-handler.js +101 -0
  11. package/core/bootstrap/hook-runner.js +64 -0
  12. package/core/bootstrap/middleware.js +35 -0
  13. package/core/bootstrap/router-loader.js +53 -0
  14. package/core/errors.js +251 -0
  15. package/core/loader.js +89 -0
  16. package/core/registry.js +17 -0
  17. package/doc/Cache.md +279 -106
  18. package/doc/Common.md +590 -134
  19. package/doc/Controller.md +166 -95
  20. package/doc/Help.md +299 -698
  21. package/doc/QuickStart.md +116 -0
  22. package/doc/Repository.md +560 -0
  23. package/doc/Service.md +201 -527
  24. package/index.js +61 -37
  25. package/middleware/body.js +17 -0
  26. package/middleware/cookie.js +7 -15
  27. package/middleware/cors.js +9 -27
  28. package/middleware/favicon.js +15 -17
  29. package/middleware/header.js +15 -16
  30. package/middleware/index.js +11 -11
  31. package/middleware/log.js +26 -56
  32. package/middleware/static.js +15 -28
  33. package/middleware/template.js +75 -115
  34. package/middleware/validate.js +79 -0
  35. package/middleware/waf.js +176 -197
  36. package/package.json +9 -2
  37. package/response/code.js +73 -0
  38. package/response/index.js +9 -6
  39. package/response/response.js +82 -236
  40. package/security/checker.js +26 -74
  41. package/security/index.js +4 -9
  42. package/security/jwt.js +84 -139
  43. package/security/keywords.js +33 -137
  44. package/security/rate-limit.js +38 -80
  45. package/security/sign.js +83 -176
  46. package/security/xss-filter.js +21 -53
  47. package/storage/cache.js +58 -198
  48. package/storage/index.js +3 -6
  49. package/storage/redis.js +124 -181
  50. package/storage/store.js +163 -188
  51. package/utils/data-parse.js +42 -186
  52. package/utils/file.js +73 -244
  53. package/utils/filter.js +22 -25
  54. package/utils/html.js +49 -33
  55. package/utils/index.js +20 -7
  56. package/utils/ip.js +31 -71
  57. package/utils/logger.js +117 -0
  58. package/utils/pages.js +55 -0
  59. package/utils/paths.js +18 -0
  60. package/utils/request.js +95 -136
  61. package/utils/signal.js +87 -0
  62. package/utils/time.js +33 -75
  63. package/utils/tree.js +112 -104
  64. package/App.js +0 -533
  65. package/base/Aop.js +0 -195
  66. package/base/Container.js +0 -161
  67. package/base/Controller.js +0 -65
  68. package/base/Database.js +0 -133
  69. package/base/Event.js +0 -61
  70. package/base/Repository.js +0 -644
  71. package/common/api.js +0 -35
  72. package/common/code.js +0 -52
  73. package/common/email.js +0 -191
  74. package/common/index.js +0 -5
  75. package/common/pages.js +0 -120
  76. package/common/utils.js +0 -73
  77. package/config/code.js +0 -166
  78. package/config/paths.js +0 -60
  79. package/doc/Aop.md +0 -269
  80. package/doc/Email.md +0 -114
  81. package/doc/Event.md +0 -232
  82. package/global/env.js +0 -11
  83. package/global/import.js +0 -39
  84. package/global/index.js +0 -8
  85. package/helper/index.js +0 -79
  86. package/loader/index.js +0 -6
  87. package/loader/loader.js +0 -138
  88. package/middleware/compress.js +0 -185
  89. package/middleware/setBody.js +0 -32
  90. package/realtime/index.js +0 -7
  91. package/realtime/sse.js +0 -424
  92. package/realtime/websocket.js +0 -540
  93. package/schedule/index.js +0 -6
  94. package/schedule/schedule.js +0 -491
@@ -0,0 +1,323 @@
1
+ import { BaseComponent } from "./BaseComponent.js";
2
+ import logger from "../utils/logger.js";
3
+ import {
4
+ CODE_OK,
5
+ CODE_NOT_FOUND,
6
+ CODE_PARAM_INVALID,
7
+ CODE_PARAM_MISSING,
8
+ } from "../response/code.js";
9
+
10
+ const SORT_FIELD_REGEX = /^[a-zA-Z_][a-zA-Z0-9_]*$/;
11
+ const OPERATORS = { $like:'like', $gt:'>', $gte:'>=', $lt:'<', $lte:'<=', $ne:'<>' };
12
+
13
+ /**
14
+ * 拼接查询条件,支持操作符/等值/数组in
15
+ * @param {import('knex').Knex.QueryBuilder} dbQuery
16
+ * @param {object} query
17
+ * @returns {import('knex').Knex.QueryBuilder}
18
+ */
19
+ function applyQuery(dbQuery, query) {
20
+ for (const [field, val] of Object.entries(query)) {
21
+ if (!SORT_FIELD_REGEX.test(field)) {
22
+ logger.warn(`[Repository] 非法查询字段:${field}`);
23
+ continue;
24
+ }
25
+ if (val && typeof val === 'object' && !Array.isArray(val)) {
26
+ const ops = Object.keys(val);
27
+ if (ops.length && ops.every(k => k in OPERATORS || k === '$in' || k === '$null' || k === '$notNull')) {
28
+ for (const [op, operand] of Object.entries(val)) {
29
+ if (op === '$in') {
30
+ dbQuery = dbQuery.whereIn(field, Array.isArray(operand) ? operand : [operand]);
31
+ } else if (OPERATORS[op]) {
32
+ dbQuery = dbQuery.where(field, OPERATORS[op], operand);
33
+ } else if (op === '$null') {
34
+ operand ? dbQuery.whereNull(field) : dbQuery.whereNotNull(field);
35
+ } else if (op === '$notNull') {
36
+ dbQuery.whereNotNull(field);
37
+ }
38
+ }
39
+ continue;
40
+ }
41
+ }
42
+ Array.isArray(val) ? dbQuery.whereIn(field, val) : dbQuery.where(field, val);
43
+ }
44
+ return dbQuery;
45
+ }
46
+
47
+ /**
48
+ * 应用字段白名单 + 排序白名单到 query
49
+ * @param {import('knex').Knex.QueryBuilder} q
50
+ * @param {string[]} fields
51
+ * @param {object} sort
52
+ * @returns {import('knex').Knex.QueryBuilder}
53
+ */
54
+ function applySelectAndSort(q, fields, sort) {
55
+ if (fields.length) {
56
+ const safeFields = fields.filter(f => SORT_FIELD_REGEX.test(f) || f === '*');
57
+ if (safeFields.length) q = q.select(safeFields);
58
+ }
59
+ if (sort && typeof sort === 'object') {
60
+ for (const [field, dir] of Object.entries(sort)) {
61
+ if (!SORT_FIELD_REGEX.test(field)) {
62
+ logger.warn(`[Repository] 非法排序字段:${field}`);
63
+ continue;
64
+ }
65
+ const d = ['asc','desc'].includes(String(dir).toLowerCase()) ? dir.toLowerCase() : 'asc';
66
+ q = q.orderBy(field, d);
67
+ }
68
+ }
69
+ return q;
70
+ }
71
+
72
+ /**
73
+ * 数据访问基类,封装通用CRUD操作
74
+ * 继承 BaseComponent 获得 app/config/db 快捷访问,不绑定组件加载职责
75
+ */
76
+ class Repository extends BaseComponent {
77
+ constructor(table=null, dbName=null, opts={}) {
78
+ super();
79
+ this.tableName = table;
80
+ this._dbName = dbName;
81
+ this._customDb = null;
82
+ this._dateFields = Array.isArray(opts.dateFields) ? opts.dateFields : [];
83
+ typeof this.on === 'function' && this.on();
84
+ }
85
+
86
+ /** 动态获取数据库连接(支持多库:指定 dbName 时取对应连接,否则取默认) */
87
+ get db() {
88
+ if (this._dbName) {
89
+ this._customDb ??= this.app?.dbManager?.get(this._dbName);
90
+ return this._customDb;
91
+ }
92
+ return this.app?.db ?? null;
93
+ }
94
+
95
+ /** 分页配置快捷读取 */
96
+ get limit() { return this.config?.LIMIT_MAX || 300; }
97
+
98
+ /** 前置数据库校验 */
99
+ _checkDB() {
100
+ if (!this.db) throw new Error("Database connection not available");
101
+ }
102
+
103
+ /** 统一构建查询实例(子类可重写以扩展过滤逻辑) */
104
+ _buildBaseQuery({ query = {}, sort = {}, fields = [] } = {}) {
105
+ this._checkDB();
106
+ let q = this.db(this.tableName);
107
+ if (Object.keys(query).length) q = applyQuery(q, query);
108
+ return applySelectAndSort(q, fields, sort);
109
+ }
110
+
111
+ /** 工具:日期字段自动转Date */
112
+ #formatDate(data) {
113
+ if (!data || typeof data !== 'object' || !this._dateFields.length) return data;
114
+ const row = {...data};
115
+ for (const k of this._dateFields) {
116
+ if (typeof row[k] === 'string') {
117
+ const dt = new Date(row[k]);
118
+ !isNaN(dt.getTime()) && (row[k] = dt);
119
+ }
120
+ }
121
+ return row;
122
+ }
123
+
124
+ /** 查询全部,默认上限1000条 */
125
+ async all({ query={}, sort={}, fields=[], limit=1000 }={}) {
126
+ const list = await this._buildBaseQuery({query,sort,fields}).limit(limit);
127
+ return { success:true, code:CODE_OK, msg:"查询成功", data: list };
128
+ }
129
+
130
+ /** 分页偏移查询 */
131
+ async find({ query={}, sort={}, fields=[], limit, offset }={}) {
132
+ let q = this._buildBaseQuery({query,sort,fields});
133
+ typeof offset === 'number' && (q = q.offset(offset));
134
+ typeof limit === 'number' && (q = q.limit(limit));
135
+ return { success:true, code:CODE_OK, msg:"查询成功", data: await q };
136
+ }
137
+
138
+ /** 查询单条记录 */
139
+ async findOne({ query={}, fields=[] }={}) {
140
+ const row = await this._buildBaseQuery({query,fields}).first();
141
+ if (!row) return { success:false, code:CODE_NOT_FOUND, msg:"记录不存在", data:null };
142
+ return { success:true, code:CODE_OK, msg:"查询成功", data: row };
143
+ }
144
+
145
+ /** 根据ID查询单条 */
146
+ async findById(id, { fields=[] }={}) {
147
+ return this.findOne({ query:{id}, fields });
148
+ }
149
+
150
+ /** 单条插入 */
151
+ async insert(data={}) {
152
+ this._checkDB();
153
+ if (!Object.keys(data).length) return { success:false, code:CODE_PARAM_MISSING, msg:"参数缺失", data:{} };
154
+ const res = await this.db(this.tableName).insert(this.#formatDate(data));
155
+ return { success:true, code:CODE_OK, msg:"插入成功", data:{ insertId:res[0], affectedRows:res.length } };
156
+ }
157
+
158
+ /** 批量插入 */
159
+ async insertMany(records=[]) {
160
+ this._checkDB();
161
+ if (!records.length) return { success:false, code:CODE_PARAM_MISSING, msg:"参数缺失", data:{} };
162
+ const list = records.map(r => this.#formatDate(r));
163
+ const res = await this.db(this.tableName).insert(list);
164
+ return { success:true, code:CODE_OK, msg:"批量插入成功", data:{ insertId:res[0], affectedRows:res.length } };
165
+ }
166
+
167
+ /** 条件删除 */
168
+ async del(query={}) {
169
+ this._checkDB();
170
+ if (!Object.keys(query).length) return { success:false, code:CODE_PARAM_MISSING, msg:"参数缺失", data:{} };
171
+ const rows = await applyQuery(this.db(this.tableName), query).del();
172
+ return { success:true, code:CODE_OK, msg:"删除成功", data:{ affectedRows:rows } };
173
+ }
174
+
175
+ /** 根据ID删除单条 */
176
+ async delById(id) {
177
+ return this.del({ id });
178
+ }
179
+
180
+ /** ID数组批量删除 */
181
+ async delMany(ids=[]) {
182
+ this._checkDB();
183
+ if (!ids.length) return { success:false, code:CODE_PARAM_MISSING, msg:"参数缺失", data:{} };
184
+ const rows = await this.db(this.tableName).whereIn('id', ids).del();
185
+ return { success:true, code:CODE_OK, msg:"删除成功", data:{ affectedRows:rows } };
186
+ }
187
+
188
+ /** 按条件更新记录 */
189
+ async update({ query, data }={}) {
190
+ this._checkDB();
191
+ if (!query || !data || !Object.keys(query).length || !Object.keys(data).length) {
192
+ return { success:false, code:CODE_PARAM_INVALID, msg:"参数无效", data:{} };
193
+ }
194
+ const rows = await applyQuery(this.db(this.tableName), query).update(this.#formatDate(data));
195
+ return { success:true, code:CODE_OK, msg:"更新成功", data:{ affectedRows:rows } };
196
+ }
197
+
198
+ /** 根据ID更新,返回更新后完整数据 */
199
+ async updateById(id, data={}) {
200
+ this._checkDB();
201
+ if (!id || !Object.keys(data).length) return { success:false, code:CODE_PARAM_INVALID, msg:"参数无效", data:{} };
202
+ await this.db(this.tableName).where({id}).update(this.#formatDate(data));
203
+ return this.findById(id);
204
+ }
205
+
206
+ /** 事务批量更新,失败自动回滚 */
207
+ async updateMany(updates=[]) {
208
+ this._checkDB();
209
+ if (!Array.isArray(updates) || !updates.length) {
210
+ return { success:false, code:CODE_PARAM_MISSING, msg:"参数缺失", data:{} };
211
+ }
212
+ for (const item of updates) {
213
+ if (!item.query || !Object.keys(item.query).length) {
214
+ return { success:false, code:CODE_PARAM_INVALID, msg:"批量更新条件不能为空", data:{} };
215
+ }
216
+ }
217
+ const trx = await this.db.transaction();
218
+ let total = 0;
219
+ try {
220
+ for (const { query, data } of updates) {
221
+ const row = await trx(this.tableName).where(query).update(this.#formatDate(data));
222
+ row === 0 && logger.info("[Repository] updateMany无匹配行", query);
223
+ total += row;
224
+ }
225
+ await trx.commit();
226
+ return { success:true, code:CODE_OK, msg:"批量更新成功", data:{ affectedRows:total } };
227
+ } catch (err) {
228
+ await trx.rollback().catch(e => logger.error("[Repository] 事务回滚失败", e.message));
229
+ logger.error("[Repository] 批量更新异常", err.message);
230
+ throw err;
231
+ }
232
+ }
233
+
234
+ /**
235
+ * 分页查询核心实现
236
+ * 私有方法,避免 query/list 子类重写时发生互相递归
237
+ * @param {object} params 分页参数
238
+ * @returns {Promise<{success:boolean,code:number,msg:string,data:object}>}
239
+ */
240
+ async _doPaginate({ current=1, pageSize=10, query={}, sort={}, field=[] }={}) {
241
+ this._checkDB();
242
+ const size = Math.min(Math.max(pageSize, 1), this.limit);
243
+ const offset = (current - 1) * size;
244
+ const countQ = applyQuery(this.db(this.tableName), query);
245
+ const [totalRow, list] = await Promise.all([
246
+ countQ.count("* as total").first(),
247
+ this._buildBaseQuery({query,sort,fields:field}).offset(offset).limit(size)
248
+ ]);
249
+ const total = Number(totalRow?.total ?? 0);
250
+ return {
251
+ success:true,
252
+ code:CODE_OK,
253
+ msg:"查询成功",
254
+ data: {
255
+ list,
256
+ total,
257
+ current,
258
+ pageSize: size,
259
+ totalPages: Math.ceil(total / size)
260
+ }
261
+ };
262
+ }
263
+
264
+ /** 标准分页查询 */
265
+ async query(params={}) {
266
+ return this._doPaginate(params);
267
+ }
268
+
269
+ /** 统计符合条件记录行数 */
270
+ async count(query={}) {
271
+ this._checkDB();
272
+ const res = await applyQuery(this.db(this.tableName), query).count("* as total").first();
273
+ return { success:true, code:CODE_OK, msg:"统计成功", data:{ count: Number(res?.total ?? 0) } };
274
+ }
275
+
276
+ /** 判断查询条件下记录是否存在 */
277
+ async exists(query={}) {
278
+ this._checkDB();
279
+ const row = await applyQuery(this.db(this.tableName), query).first();
280
+ return { success:true, code:CODE_OK, msg:"检查成功", data:{ exists: !!row } };
281
+ }
282
+
283
+ /** 联表join查询 */
284
+ async join({ joinTable, localField, foreignField, fields=[], query={}, sort={} }) {
285
+ this._checkDB();
286
+ if (!SORT_FIELD_REGEX.test(joinTable) || !SORT_FIELD_REGEX.test(localField) || !SORT_FIELD_REGEX.test(foreignField)) {
287
+ logger.warn(`[Repository] 非法联表/字段:${joinTable}/${localField}/${foreignField}`);
288
+ return { success: false, code: CODE_PARAM_INVALID, msg: "非法联表或字段", data: null };
289
+ }
290
+ const select = fields.length
291
+ ? fields.filter(f => SORT_FIELD_REGEX.test(f) || f === '*')
292
+ : [`${this.tableName}.*`];
293
+ let q = this.db(this.tableName)
294
+ .join(joinTable, `${this.tableName}.${localField}`, '=', `${joinTable}.${foreignField}`)
295
+ .select(select);
296
+ q = applyQuery(q, query);
297
+ q = applySelectAndSort(q, [], sort);
298
+ return { success:true, code:CODE_OK, msg:"查询成功", data: await q };
299
+ }
300
+
301
+ /** 统计表总数量 + 今日新增数量 */
302
+ async stats() {
303
+ this._checkDB();
304
+ const createKey = this._dateFields.find(f => /created_at|createdAt/.test(f)) || "created_at";
305
+ const today = new Date();
306
+ today.setHours(0,0,0,0);
307
+ const [totalRes, todayRes] = await Promise.all([
308
+ this.db(this.tableName).count('* as count').first(),
309
+ this.db(this.tableName).where(createKey, '>=', today).count('* as count').first()
310
+ ]);
311
+ return {
312
+ success:true,
313
+ code:CODE_OK,
314
+ msg:"统计成功",
315
+ data: {
316
+ total: Number(totalRes?.count || 0),
317
+ today: Number(todayRes?.count || 0)
318
+ }
319
+ };
320
+ }
321
+ }
322
+
323
+ export default Repository;
@@ -0,0 +1,11 @@
1
+ /**
2
+ * Service 基类 - 纯业务逻辑层(无表绑定、无 CRUD 方法)
3
+ * 业务子类注入多个 Repository 做跨表事务编排
4
+ */
5
+ import { Container } from "./Container.js";
6
+
7
+ export class Service extends Container {
8
+ constructor() {
9
+ super('service');
10
+ }
11
+ }
@@ -0,0 +1,101 @@
1
+ import { routeNotFound, serializeError, respondError } from "../../response/index.js";
2
+ import { CODE_NOT_FOUND, CODE_CONFLICT, CODE_BUSINESS_FAIL } from "../../response/code.js";
3
+ import {
4
+ AppError, isAppError, parseStack, describeError, wrapDbError,
5
+ } from "../errors.js";
6
+ import logger from "../../utils/logger.js";
7
+
8
+ // 仅这些业务码明确经过了 DB 查询(如查无记录、唯一冲突、业务处理失败),
9
+ // 才推断 DB 已恢复;鉴权(401/403)/参数(422)/限流(429) 等不查库的错误不触发 markUp
10
+ const DB_RECOVERY_CODES = new Set([CODE_NOT_FOUND, CODE_CONFLICT, CODE_BUSINESS_FAIL]);
11
+
12
+ // 安全开关:是否对外暴露 SQL / 堆栈等详情
13
+ const EXPOSE_ERR_DETAIL = process.env.EXPOSE_ERR_DETAIL === "true";
14
+ const IS_PROD = ["prd", "prod", "production"].includes(process.env.NODE_ENV || "dev");
15
+
16
+ /**
17
+ * 注册全局异常兜底中间件(ChanJS底层框架)
18
+ * @description
19
+ * 唯一统一错误出口,职责收敛为:日志 / 环境鉴权 / HTTP状态 / 脱敏,全项目所有报错只打印一次。
20
+ * 1. 404 兜底:路由未匹配时返回标准 404 JSON(须在业务路由之后注册)
21
+ * 2. 全局异常捕获:DB/底层异常经 wrapDbError 归类为友好 AppError,
22
+ * 响应层 serializeError 零 DB 感知,换数据库不用改响应代码
23
+ * 3. 区分浏览器HTML / API JSON:HTML返回框架内置静态错误页,接口返回标准JSON
24
+ * 4. 双重安全校验:EXPOSE_ERR_DETAIL && (!生产环境 || 携带合法Debug令牌),生产误开也不泄露堆栈/SQL
25
+ * @param {object} chan 框架核心实例 { app: Express }
26
+ */
27
+ export function registerErrorHandler(chan) {
28
+ const { app, config } = chan;
29
+
30
+ // ============ 404 兜底(路由未匹配) ============
31
+ // 使用 respondError 做 HTML/JSON 分流:页面请求返回框架内置 HTML 错误页,
32
+ // 接口请求返回标准 JSON;避免浏览器直接访问不存在路径时看到一段 JSON 文本。
33
+ app.use((req, res) => {
34
+ const notFound = routeNotFound(req);
35
+ respondError(res, req, {
36
+ httpStatus: 404,
37
+ code: notFound.code,
38
+ msg: notFound.msg,
39
+ data: notFound.data,
40
+ });
41
+ });
42
+
43
+ // ============ 全局异常统一出口 ============
44
+ app.use((err, req, res, next) => {
45
+ // 响应头已发送,转交Express默认错误处理
46
+ if (res.headersSent) return next(err);
47
+
48
+ // DB / 底层异常下沉转换:非业务错误先归类为友好 AppError
49
+ if (!isAppError(err)) err = wrapDbError(err);
50
+
51
+ // 连接类故障实时标记:后续未登录请求可直接返回「数据库未连接」,
52
+ // 而非伪装成 token 缺失(详见 auth 中间件 isDown 短路)
53
+ const isDbConnection = isAppError(err) && (
54
+ err.code >= 6000 ||
55
+ /数据库连接|数据库访问被拒绝|ECONNREFUSED|ETIMEDOUT|连接中断/.test(err.message || "")
56
+ );
57
+ if (isDbConnection) {
58
+ chan.dbManager?.markDown?.();
59
+ } else if (isAppError(err) && DB_RECOVERY_CODES.has(err.code)) {
60
+ // 仅明确经过 DB 查询的业务错误(查无记录/唯一冲突/业务失败)才推断 DB 已恢复,
61
+ // 鉴权/参数/限流等不查库的错误不触发 markUp,避免误标
62
+ chan.dbManager?.markUp?.();
63
+ }
64
+
65
+ const httpStatus = isAppError(err) ? err.httpStatus : 500;
66
+ const stackInfo = parseStack(err.stack);
67
+ // 全链路追踪信息(requestId 需业务/框架注入 req.id 或 x-request-id 头)
68
+ const requestId = req.headers["x-request-id"] || req.id || "-";
69
+ const userId = req.user?.id ?? "-";
70
+
71
+ // 日志分级:业务错误 4xx → warn,系统错误 5xx → error(唯一打印点,杜绝重复刷屏)
72
+ const logFn = httpStatus < 500 ? logger.warn : logger.error;
73
+ logFn(JSON.stringify({
74
+ type: err.name || "UnknownError",
75
+ message: describeError(err), // 递归解析 cause 根因,支持 AggregateError
76
+ file: stackInfo.file,
77
+ line: stackInfo.line,
78
+ requestId,
79
+ method: req.method,
80
+ url: req.originalUrl,
81
+ userId,
82
+ }));
83
+
84
+ // 双重安全校验:生产环境即使环境变量开了,也必须携带合法 Debug Token 才暴露堆栈/SQL
85
+ const exposeDetail = EXPOSE_ERR_DETAIL && (!IS_PROD || req.headers["x-debug-token"] === process.env.DEBUG_TOKEN);
86
+
87
+ if (isAppError(err)) {
88
+ // 统一通过 respondError 做 HTML/JSON 分流(复用 response 层纯函数,消除重复逻辑)
89
+ const serialized = serializeError(err, exposeDetail);
90
+ return respondError(res, req, {
91
+ httpStatus,
92
+ code: serialized.code,
93
+ msg: serialized.msg,
94
+ data: serialized.data ?? null,
95
+ });
96
+ }
97
+
98
+ // 兆底分支(理论上 wrapDbError 已转 AppError,这里防万一)
99
+ respondError(res, req, { httpStatus: 500, code: 500, msg: "服务器内部错误" });
100
+ });
101
+ }
@@ -0,0 +1,64 @@
1
+ import logger from "../../utils/logger.js";
2
+ import { describeError, errorExtraProps } from "../errors.js";
3
+ import { HOOK_TIMEOUT as DEFAULT_HOOK_TIMEOUT } from "../../config/index.js";
4
+
5
+ /**
6
+ * 带超时限制执行钩子,超时则 reject 跳过该钩子,不阻塞后续启动
7
+ * @param {Function} fn 钩子函数
8
+ * @param {number} timeout 超时毫秒
9
+ * @param {string} tag 钩子标识,用于日志
10
+ * @returns {Promise<void>}
11
+ */
12
+ async function runHookWithTimeout(fn, timeout, tag) {
13
+ let timer;
14
+ const timeoutPromise = new Promise((_, reject) => {
15
+ timer = setTimeout(
16
+ () => reject(new Error(`钩子 ${tag} 执行超时(${timeout}ms),已跳过`)),
17
+ timeout
18
+ );
19
+ });
20
+ try {
21
+ await Promise.race([fn(), timeoutPromise]);
22
+ } finally {
23
+ clearTimeout(timer);
24
+ }
25
+ }
26
+
27
+ /**
28
+ * 串行执行启动钩子,单钩子超时/报错不阻塞整体启动
29
+ * 仅支持格式:{ name: string, fn: () => Promise<any> }
30
+ * @param {object} chan 框架实例
31
+ */
32
+ export async function runHooks(chan) {
33
+ const { hooks = [], config = {} } = chan;
34
+ if (!hooks.length) return;
35
+
36
+ const hookTimeout = config.HOOK_TIMEOUT ?? DEFAULT_HOOK_TIMEOUT;
37
+
38
+ for (let i = 0; i < hooks.length; i++) {
39
+ const item = hooks[i];
40
+ const seq = i + 1;
41
+ // 支持两种格式:{ name, fn } 对象 或 纯函数(App.beforeStart 注册的是纯函数)
42
+ let name, fn;
43
+ if (typeof item === "function") {
44
+ fn = item;
45
+ name = null;
46
+ } else if (item && typeof item === "object") {
47
+ ({ name, fn } = item);
48
+ }
49
+ const hookTag = name || `hook#${seq}`;
50
+
51
+ // 非法钩子直接跳过
52
+ if (typeof fn !== "function") {
53
+ logger.warn(`[ChanHook][${hookTag}] 第${seq}项钩子fn非函数,跳过`);
54
+ continue;
55
+ }
56
+
57
+ try {
58
+ await runHookWithTimeout(fn, hookTimeout, hookTag);
59
+ } catch (err) {
60
+ const errLog = describeError(err) + errorExtraProps(err);
61
+ logger.error(`[ChanHook][${hookTag}] 第${seq}个钩子执行异常:\n${errLog}`, err);
62
+ }
63
+ }
64
+ }
@@ -0,0 +1,35 @@
1
+ import {
2
+ Cors, body, cookie, favicon, header,
3
+ staticMw, template, waf, wafBody, log,
4
+ } from "../../middleware/index.js";
5
+ import { BODY_LIMIT } from "../../config/index.js";
6
+
7
+ /**
8
+ * 注册核心中间件(顺序固化:不可随意调整)
9
+ * WAF 前置 → Favicon → 静态 → Cookie → Body 解析 → WAF Body 检查 → CORS → 日志 → 模板 → 响应头
10
+ * @param {object} chan 框架实例
11
+ */
12
+ export async function registerCoreMiddleware(chan) {
13
+ const { app, config: cfg } = chan;
14
+
15
+ // 1. WAF 前置(IP/封禁/限流/路径与 query 检查,不依赖请求体)
16
+ await waf(app, cfg.waf ?? { enabled: false });
17
+ // 2. Favicon
18
+ favicon(app);
19
+ // 3. 静态资源
20
+ staticMw(app, cfg.statics ?? []);
21
+ // 4. Cookie
22
+ cookie(app, cfg.cookieKey);
23
+ // 5. 请求体解析
24
+ body(app, cfg.BODY_LIMIT ?? BODY_LIMIT);
25
+ // 5.1 WAF Body 检查(须在 body 解析之后)
26
+ wafBody(app, cfg.waf ?? { enabled: false });
27
+ // 6. CORS
28
+ Cors(app, cfg.cors ?? {});
29
+ // 7. 请求日志
30
+ log(app, cfg.logger ?? {});
31
+ // 8. 模板渲染
32
+ template(app, { views: cfg.views ?? [], NODE_ENV: cfg.NODE_ENV ?? "dev" });
33
+ // 9. 响应头
34
+ header(app, { APP_NAME: cfg.APP_NAME ?? "ChanCMS", APP_VERSION: cfg.APP_VERSION ?? "1.0.0" });
35
+ }
@@ -0,0 +1,53 @@
1
+ import fs from "fs";
2
+ import path from "path";
3
+ import { Router } from "express";
4
+ import { loaderSort, importFile } from "../loader.js";
5
+ import logger from "../../utils/logger.js";
6
+
7
+ /**
8
+ * 加载路由公共逻辑:导入并执行路由注册函数
9
+ * @param {string} filePath 路由文件完整路径
10
+ * @param {object} app Express实例
11
+ * @param {object} router 顶层路由
12
+ * @param {object} config 全局配置
13
+ */
14
+ async function registerRouterFile(filePath, app, router, config) {
15
+ if (!fs.existsSync(filePath)) return;
16
+ try {
17
+ const register = await importFile(filePath);
18
+ if (typeof register === "function") {
19
+ await register(app, router, config);
20
+ }
21
+ } catch (err) {
22
+ logger.error(`[RouterLoader] 路由文件加载失败: ${filePath}`, err);
23
+ }
24
+ }
25
+
26
+ /**
27
+ * 加载 modules 业务模块路由,按配置顺序挂载
28
+ * @param {object} chan 框架实例
29
+ */
30
+ export async function loadModuleRouter(chan) {
31
+ const { paths, config, app, router } = chan;
32
+ const modulesDir = paths.modulesPath;
33
+ if (!fs.existsSync(modulesDir)) return;
34
+
35
+ const moduleNames = loaderSort(config.modules ?? []);
36
+ for (const name of moduleNames) {
37
+ const routeFile = path.join(modulesDir, name, "router.js");
38
+ // 每个模块使用独立的子 Router 实例,防止模块内 router.use(auth()) 等无路径前缀
39
+ // 的中间件污染全局,导致未匹配路径(如 404)被提前拦截成鉴权错误。
40
+ const subRouter = Router();
41
+ await registerRouterFile(routeFile, app, subRouter, config);
42
+ }
43
+ }
44
+
45
+ /**
46
+ * 加载项目根公共路由 router.js
47
+ * @param {object} chan 框架实例
48
+ */
49
+ export async function loadCommonRouter(chan) {
50
+ const { paths, app, router, config } = chan;
51
+ const commonRouteFile = path.join(paths.appPath, "router.js");
52
+ await registerRouterFile(commonRouteFile, app, router, config);
53
+ }