chanjs 2.7.13 → 2.7.16
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.
- package/core/Database.js +19 -5
- package/core/EventBus.js +12 -2
- package/core/Repository.js +64 -16
- package/core/bootstrap/error-handler.js +5 -6
- package/core/bootstrap/middleware.js +3 -0
- package/core/bootstrap/router-loader.js +23 -2
- package/core/loader.js +0 -11
- package/doc/06-/345/272/224/347/224/250/347/224/237/345/221/275/345/221/250/346/234/237.md +0 -1
- package/middleware/health.js +56 -0
- package/middleware/index.js +1 -0
- package/middleware/static.js +8 -3
- package/middleware/waf.js +18 -4
- package/package.json +9 -4
- package/security/rate-limit.js +4 -6
- package/storage/cache.js +11 -0
- package/storage/redis.js +26 -0
- package/storage/store.js +17 -0
package/core/Database.js
CHANGED
|
@@ -50,22 +50,25 @@ class DatabaseManager {
|
|
|
50
50
|
|
|
51
51
|
/**
|
|
52
52
|
* 挂载 knex 事件监控
|
|
53
|
-
* - query: 记录 SQL
|
|
53
|
+
* - query: 记录 SQL 执行起点(键用 __knexQueryUid 查询唯一 ID,避免同连接串行查询互相覆盖)
|
|
54
54
|
* - query-response: 计算耗时,超阈值记慢查询日志
|
|
55
55
|
* - query-error: 记录失败 SQL 与错误信息
|
|
56
56
|
* @private
|
|
57
57
|
*/
|
|
58
58
|
_attachMonitoring(name, conn) {
|
|
59
59
|
const startTimes = new Map();
|
|
60
|
+
// 容量保护:异常路径下残留的计时起点累积到上限即清空,防 Map 无界增长
|
|
61
|
+
const MAX_PENDING = 5000;
|
|
60
62
|
|
|
61
63
|
conn.on("query", (data) => {
|
|
62
|
-
startTimes.
|
|
64
|
+
if (startTimes.size >= MAX_PENDING) startTimes.clear();
|
|
65
|
+
startTimes.set(data.__knexQueryUid, Date.now());
|
|
63
66
|
});
|
|
64
67
|
|
|
65
68
|
conn.on("query-response", (_response, data) => {
|
|
66
|
-
const startedAt = startTimes.get(data.
|
|
69
|
+
const startedAt = startTimes.get(data.__knexQueryUid);
|
|
67
70
|
if (!startedAt) return;
|
|
68
|
-
startTimes.delete(data.
|
|
71
|
+
startTimes.delete(data.__knexQueryUid);
|
|
69
72
|
|
|
70
73
|
const elapsed = Date.now() - startedAt;
|
|
71
74
|
if (elapsed >= this._slowThreshold) {
|
|
@@ -74,7 +77,7 @@ class DatabaseManager {
|
|
|
74
77
|
});
|
|
75
78
|
|
|
76
79
|
conn.on("query-error", (err, data) => {
|
|
77
|
-
startTimes.delete(data?.
|
|
80
|
+
startTimes.delete(data?.__knexQueryUid);
|
|
78
81
|
logger.error(`[Database] 查询失败 ${name}: ${data?.sql || ""}`, err);
|
|
79
82
|
});
|
|
80
83
|
}
|
|
@@ -138,6 +141,17 @@ class DatabaseManager {
|
|
|
138
141
|
markUp(name = this._defaultName) {
|
|
139
142
|
this._health.set(name, true);
|
|
140
143
|
}
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* 健康快照(/health 端点用):读缓存状态,不做实时 IO
|
|
147
|
+
* @returns {{name:string, healthy:boolean}[]}
|
|
148
|
+
*/
|
|
149
|
+
healthSnapshot() {
|
|
150
|
+
return [...this._connections.keys()].map(name => ({
|
|
151
|
+
name,
|
|
152
|
+
healthy: this._health.get(name) ?? false,
|
|
153
|
+
}));
|
|
154
|
+
}
|
|
141
155
|
}
|
|
142
156
|
|
|
143
157
|
export default DatabaseManager;
|
package/core/EventBus.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { EventEmitter } from "events";
|
|
2
|
+
import logger from "../utils/logger.js";
|
|
2
3
|
|
|
3
4
|
/**
|
|
4
5
|
* 事件总线:基于 Node 内置 EventEmitter 的轻量封装。
|
|
@@ -40,12 +41,21 @@ export class EventBus {
|
|
|
40
41
|
}
|
|
41
42
|
|
|
42
43
|
/**
|
|
43
|
-
*
|
|
44
|
+
* 触发事件(同步逐个执行监听器,单个异常被隔离记录,不中断后续监听器)
|
|
44
45
|
* @param {string} event - 事件名
|
|
45
46
|
* @param {...any} args - 传递给监听器的参数
|
|
47
|
+
* @returns {boolean} 是否存在监听器
|
|
46
48
|
*/
|
|
47
49
|
emit(event, ...args) {
|
|
48
|
-
this._emitter.
|
|
50
|
+
const listeners = this._emitter.listeners(event);
|
|
51
|
+
for (const fn of listeners) {
|
|
52
|
+
try {
|
|
53
|
+
fn(...args);
|
|
54
|
+
} catch (err) {
|
|
55
|
+
logger.error(`[EventBus] 监听器执行异常 event:${event}: ${err.message}`, err);
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
return listeners.length > 0;
|
|
49
59
|
}
|
|
50
60
|
|
|
51
61
|
/**
|
package/core/Repository.js
CHANGED
|
@@ -107,6 +107,9 @@ class Repository extends BaseComponent {
|
|
|
107
107
|
this._dbName = dbName;
|
|
108
108
|
this._customDb = null;
|
|
109
109
|
this._dateFields = Array.isArray(opts.dateFields) ? opts.dateFields : [];
|
|
110
|
+
// join() 联表白名单:声明后 joinTable 必须在列,防未来某处把用户输入透传成任意表读取;
|
|
111
|
+
// 未声明保持原行为(仅正则格式校验),存量业务零破坏
|
|
112
|
+
this._allowJoinTables = Array.isArray(opts.allowJoinTables) ? opts.allowJoinTables : null;
|
|
110
113
|
typeof this.on === 'function' && this.on();
|
|
111
114
|
}
|
|
112
115
|
|
|
@@ -127,6 +130,17 @@ class Repository extends BaseComponent {
|
|
|
127
130
|
if (!this.db) throw new Error("Database connection not available");
|
|
128
131
|
}
|
|
129
132
|
|
|
133
|
+
/** 当前连接的数据库客户端类型(knex client:mysql2 / pg / better-sqlite3 ...) */
|
|
134
|
+
get _clientType() {
|
|
135
|
+
return this.db?.client?.config?.client || "";
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/** PostgreSQL 的 INSERT 默认不回传自增主键,必须显式 RETURNING 才能拿到 insertId */
|
|
139
|
+
get _needReturning() {
|
|
140
|
+
const c = this._clientType;
|
|
141
|
+
return c === "pg" || c === "postgres" || c === "pg-query-stream";
|
|
142
|
+
}
|
|
143
|
+
|
|
130
144
|
/**
|
|
131
145
|
* 统一防御守卫:数据库可用性 + 查询条件合法性(fail-closed)。
|
|
132
146
|
* 查询/统计/存在性等方法的入口统一先过此守卫,替代各方法开头的 `_checkDB()+guardInvalidQuery()` 重复样板。
|
|
@@ -197,8 +211,11 @@ class Repository extends BaseComponent {
|
|
|
197
211
|
async insert(data={}) {
|
|
198
212
|
this._checkDB();
|
|
199
213
|
if (!Object.keys(data).length) return { success:false, code:CODE_PARAM_MISSING, msg:"参数缺失", data:{} };
|
|
200
|
-
const
|
|
201
|
-
|
|
214
|
+
const query = this.db(this.tableName).insert(this.#formatDate(data));
|
|
215
|
+
if (this._needReturning) query.returning("id"); // pg 需 RETURNING 才有 insertId;mysql/sqlite 的 res[0] 天然是自增 id
|
|
216
|
+
const raw = await query;
|
|
217
|
+
const insertId = this._needReturning ? (raw[0]?.id ?? raw[0]) : raw[0]; // pg returning 返回 [{id:N}]
|
|
218
|
+
return { success:true, code:CODE_OK, msg:"插入成功", data:{ insertId, affectedRows:1 } };
|
|
202
219
|
}
|
|
203
220
|
|
|
204
221
|
/** 批量插入 */
|
|
@@ -206,8 +223,11 @@ class Repository extends BaseComponent {
|
|
|
206
223
|
this._checkDB();
|
|
207
224
|
if (!records.length) return { success:false, code:CODE_PARAM_MISSING, msg:"参数缺失", data:{} };
|
|
208
225
|
const list = records.map(r => this.#formatDate(r));
|
|
209
|
-
const
|
|
210
|
-
|
|
226
|
+
const query = this.db(this.tableName).insert(list);
|
|
227
|
+
if (this._needReturning) query.returning("id");
|
|
228
|
+
const raw = await query;
|
|
229
|
+
const insertId = this._needReturning ? (raw[0]?.id ?? raw[0]) : raw[0];
|
|
230
|
+
return { success:true, code:CODE_OK, msg:"批量插入成功", data:{ insertId, affectedRows:records.length } };
|
|
211
231
|
}
|
|
212
232
|
|
|
213
233
|
/** 条件删除 */
|
|
@@ -236,8 +256,14 @@ class Repository extends BaseComponent {
|
|
|
236
256
|
return { success:true, code:CODE_OK, msg:"删除成功", data:{ affectedRows:rows } };
|
|
237
257
|
}
|
|
238
258
|
|
|
239
|
-
/**
|
|
240
|
-
|
|
259
|
+
/**
|
|
260
|
+
* 更新内部原语(私有方法):子类无法覆写。
|
|
261
|
+
* 历史教训:此前 updateById 直接调 this.update({query,data}),
|
|
262
|
+
* 业务 Service 普遍以扁平 body 形状覆写 update(body),多态派发回子类后
|
|
263
|
+
* {query,data} 被解构成 id=undefined → 更新静默失效(无 SQL、仍返回旧数据)。
|
|
264
|
+
* 框架内部路径必须走本私有方法,杜绝该类命名冲突。
|
|
265
|
+
*/
|
|
266
|
+
async #updateRaw(query, data) {
|
|
241
267
|
this._checkDB();
|
|
242
268
|
if (!query || !data || !Object.keys(query).length || !Object.keys(data).length) {
|
|
243
269
|
return { success:false, code:CODE_PARAM_INVALID, msg:"参数无效", data:{} };
|
|
@@ -250,10 +276,16 @@ class Repository extends BaseComponent {
|
|
|
250
276
|
return { success:true, code:CODE_OK, msg:"更新成功", data:{ affectedRows:rows } };
|
|
251
277
|
}
|
|
252
278
|
|
|
253
|
-
/**
|
|
279
|
+
/** 按条件更新记录 */
|
|
280
|
+
async update({ query, data }={}) {
|
|
281
|
+
return this.#updateRaw(query, data);
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
/** 根据ID更新,返回更新后完整数据;失败不再被忽略,直接透传错误体 */
|
|
254
285
|
async updateById(id, data={}) {
|
|
255
286
|
if (!id || !Object.keys(data).length) return { success:false, code:CODE_PARAM_INVALID, msg:"参数无效", data:{} };
|
|
256
|
-
await this
|
|
287
|
+
const res = await this.#updateRaw({ id }, data);
|
|
288
|
+
if (!res.success) return res;
|
|
257
289
|
return this.findById(id);
|
|
258
290
|
}
|
|
259
291
|
|
|
@@ -267,6 +299,11 @@ class Repository extends BaseComponent {
|
|
|
267
299
|
if (!item.query || !Object.keys(item.query).length) {
|
|
268
300
|
return { success:false, code:CODE_PARAM_INVALID, msg:"批量更新条件不能为空", data:{} };
|
|
269
301
|
}
|
|
302
|
+
// 防御纵深:与 update 同口径,条件字段全部非法时 fail-closed 拒绝,
|
|
303
|
+
// 避免生成无 WHERE 的全表更新
|
|
304
|
+
if (!Object.keys(item.query).some(f => SORT_FIELD_REGEX.test(f))) {
|
|
305
|
+
return { success:false, code:CODE_PARAM_INVALID, msg:"批量更新条件非法", data:{} };
|
|
306
|
+
}
|
|
270
307
|
}
|
|
271
308
|
const trx = await this.db.transaction();
|
|
272
309
|
let total = 0;
|
|
@@ -396,6 +433,11 @@ class Repository extends BaseComponent {
|
|
|
396
433
|
logger.warn(`[Repository] 非法联表/字段:${joinTable}/${localField}/${foreignField}`);
|
|
397
434
|
return { success: false, code: CODE_PARAM_INVALID, msg: "非法联表或字段", data: null };
|
|
398
435
|
}
|
|
436
|
+
// 表白名单校验(构造时声明 allowJoinTables 才启用)
|
|
437
|
+
if (this._allowJoinTables && !this._allowJoinTables.includes(joinTable)) {
|
|
438
|
+
logger.warn(`[Repository] 联表不在白名单:${joinTable}`);
|
|
439
|
+
return { success: false, code: CODE_PARAM_INVALID, msg: "非法联表", data: null };
|
|
440
|
+
}
|
|
399
441
|
const err = this._guard({ query });
|
|
400
442
|
if (err) return err;
|
|
401
443
|
const select = fields.length
|
|
@@ -409,23 +451,29 @@ class Repository extends BaseComponent {
|
|
|
409
451
|
return { success:true, code:CODE_OK, msg:"查询成功", data: await q };
|
|
410
452
|
}
|
|
411
453
|
|
|
412
|
-
/** 统计表总数量 +
|
|
454
|
+
/** 统计表总数量 + 今日新增数量(今日列探测失败降级为 0,不阻断总数统计) */
|
|
413
455
|
async stats() {
|
|
414
456
|
this._checkDB();
|
|
415
457
|
const createKey = this._dateFields.find(f => /created_at|createdAt/.test(f)) || "created_at";
|
|
416
|
-
const
|
|
417
|
-
today
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
458
|
+
const totalRes = await this.db(this.tableName).count('* as count').first();
|
|
459
|
+
let today = 0;
|
|
460
|
+
try {
|
|
461
|
+
const todayStart = new Date();
|
|
462
|
+
todayStart.setHours(0,0,0,0);
|
|
463
|
+
const todayRes = await this.db(this.tableName)
|
|
464
|
+
.where(createKey, '>=', todayStart).count('* as count').first();
|
|
465
|
+
today = Number(todayRes?.count || 0);
|
|
466
|
+
} catch (err) {
|
|
467
|
+
// 表无 created_at 列等情况:跳过今日统计而非整体报错
|
|
468
|
+
logger.warn(`[Repository] stats 今日统计失败(表 ${this.tableName} 列 ${createKey}?):${err.message}`);
|
|
469
|
+
}
|
|
422
470
|
return {
|
|
423
471
|
success:true,
|
|
424
472
|
code:CODE_OK,
|
|
425
473
|
msg:"统计成功",
|
|
426
474
|
data: {
|
|
427
475
|
total: Number(totalRes?.count || 0),
|
|
428
|
-
today
|
|
476
|
+
today
|
|
429
477
|
}
|
|
430
478
|
};
|
|
431
479
|
}
|
|
@@ -49,12 +49,11 @@ export function registerErrorHandler(chan) {
|
|
|
49
49
|
if (!isAppError(err)) err = wrapDbError(err);
|
|
50
50
|
|
|
51
51
|
// 连接类故障实时标记:后续未登录请求可直接返回「数据库未连接」,
|
|
52
|
-
// 而非伪装成 token 缺失(详见 auth 中间件 isDown
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
)
|
|
57
|
-
if (isDbConnection) {
|
|
52
|
+
// 而非伪装成 token 缺失(详见 auth 中间件 isDown 短路)。
|
|
53
|
+
// 仅认结构化信号:wrapDbError 已把连接拒绝/超时/中断等统一归类为 6xxx AppError,
|
|
54
|
+
// 不再做错误消息正则匹配,避免文案改动导致健康状态失真。
|
|
55
|
+
const isDbDown = isAppError(err) && err.code >= 6000;
|
|
56
|
+
if (isDbDown) {
|
|
58
57
|
chan.dbManager?.markDown?.();
|
|
59
58
|
} else if (isAppError(err) && DB_RECOVERY_CODES.has(err.code)) {
|
|
60
59
|
// 仅明确经过 DB 查询的业务错误(查无记录/唯一冲突/业务失败)才推断 DB 已恢复,
|
|
@@ -2,6 +2,7 @@ import {
|
|
|
2
2
|
Cors, body, cookie, favicon, header,
|
|
3
3
|
staticMw, template, waf, wafBody, log,
|
|
4
4
|
} from "../../middleware/index.js";
|
|
5
|
+
import { health } from "../../middleware/health.js";
|
|
5
6
|
import { BODY_LIMIT } from "../../config/index.js";
|
|
6
7
|
|
|
7
8
|
/**
|
|
@@ -14,6 +15,8 @@ export async function registerCoreMiddleware(chan) {
|
|
|
14
15
|
|
|
15
16
|
// 1. WAF 前置(IP/封禁/限流/路径与 query 检查,不依赖请求体)
|
|
16
17
|
await waf(app, cfg.waf ?? { enabled: false });
|
|
18
|
+
// 1.5 健康检查端点(默认开启,config.health = {enabled,path} 可调;浅模式零 IO)
|
|
19
|
+
app.use(health(chan, cfg.health ?? {}));
|
|
17
20
|
// 2. Favicon
|
|
18
21
|
favicon(app);
|
|
19
22
|
// 3. 静态资源
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import fs from "fs";
|
|
2
2
|
import path from "path";
|
|
3
3
|
import { Router } from "express";
|
|
4
|
-
import {
|
|
4
|
+
import { importFile } from "../loader.js";
|
|
5
5
|
import logger from "../../utils/logger.js";
|
|
6
6
|
|
|
7
7
|
/**
|
|
@@ -23,8 +23,22 @@ async function registerRouterFile(filePath, app, router, config) {
|
|
|
23
23
|
}
|
|
24
24
|
}
|
|
25
25
|
|
|
26
|
+
/**
|
|
27
|
+
* 统计 Express 全局路由栈层数(用于检测模块是否真的挂载了路由)
|
|
28
|
+
* Express 5 的 app.router 为 Router 实例,stack 可访问;异常环境返回 null 跳过检测
|
|
29
|
+
*/
|
|
30
|
+
function countAppLayers(app) {
|
|
31
|
+
try {
|
|
32
|
+
return app?.router?.stack?.length ?? null;
|
|
33
|
+
} catch {
|
|
34
|
+
return null;
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
26
38
|
/**
|
|
27
39
|
* 加载 modules 业务模块路由,按配置顺序挂载
|
|
40
|
+
* 顺序完全遵循 config.modules 声明顺序(框架不再隐式重排),
|
|
41
|
+
* 含动态兜底路由的模块(如 web)应自行在配置中放在末位。
|
|
28
42
|
* @param {object} chan 框架实例
|
|
29
43
|
*/
|
|
30
44
|
export async function loadModuleRouter(chan) {
|
|
@@ -32,13 +46,20 @@ export async function loadModuleRouter(chan) {
|
|
|
32
46
|
const modulesDir = paths.modulesPath;
|
|
33
47
|
if (!fs.existsSync(modulesDir)) return;
|
|
34
48
|
|
|
35
|
-
const moduleNames =
|
|
49
|
+
const moduleNames = config.modules ?? [];
|
|
36
50
|
for (const name of moduleNames) {
|
|
37
51
|
const routeFile = path.join(modulesDir, name, "router.js");
|
|
38
52
|
// 每个模块使用独立的子 Router 实例,防止模块内 router.use(auth()) 等无路径前缀
|
|
39
53
|
// 的中间件污染全局,导致未匹配路径(如 404)被提前拦截成鉴权错误。
|
|
40
54
|
const subRouter = Router();
|
|
55
|
+
const layersBefore = countAppLayers(app);
|
|
41
56
|
await registerRouterFile(routeFile, app, subRouter, config);
|
|
57
|
+
// 挂载责任在模块作者(app.use(prefix, router)),但静默失效代价太高:
|
|
58
|
+
// 注册前后全局路由栈无增长即视为忘挂载,启动期直接告警。
|
|
59
|
+
const layersAfter = countAppLayers(app);
|
|
60
|
+
if (layersBefore !== null && layersAfter !== null && layersAfter === layersBefore) {
|
|
61
|
+
logger.warn(`[RouterLoader] 模块 "${name}" 未向 app 注册任何路由(缺少 app.use(prefix, router)?),该模块路由未生效`);
|
|
62
|
+
}
|
|
42
63
|
}
|
|
43
64
|
}
|
|
44
65
|
|
package/core/loader.js
CHANGED
|
@@ -30,17 +30,6 @@ export async function importFile(filepath) {
|
|
|
30
30
|
}
|
|
31
31
|
}
|
|
32
32
|
|
|
33
|
-
/**
|
|
34
|
-
* 模块排序:web模块强制后置
|
|
35
|
-
* @param {string[]} modules
|
|
36
|
-
* @returns {string[]}
|
|
37
|
-
*/
|
|
38
|
-
export function loaderSort(modules = []) {
|
|
39
|
-
const webIdx = modules.indexOf("web");
|
|
40
|
-
if (webIdx > -1) modules.push(modules.splice(webIdx, 1)[0]);
|
|
41
|
-
return modules;
|
|
42
|
-
}
|
|
43
|
-
|
|
44
33
|
/**
|
|
45
34
|
* 加载根配置 index.js
|
|
46
35
|
*/
|
|
@@ -111,7 +111,6 @@ const config = await loader.loadConfig(); // 读取 config/index.js 合并成
|
|
|
111
111
|
| `loadController(name)` | 加载某模块全部 Controller(自动 bind 实例方法防 this 丢失),返回 `{ 控制器名: 实例 }` |
|
|
112
112
|
| `loadConfig()` | 加载合并配置(读 `config/index.js`)|
|
|
113
113
|
| `importFile(filepath)` | 动态导入单个文件(成功返回模块 default 或模块本身,失败/IO 异常返回 `null`)|
|
|
114
|
-
| `loaderSort(modules)` | 模块排序(`web` 强制后置)|
|
|
115
114
|
|
|
116
115
|
> 多数业务场景你直接 `new MyService()` / `new MyController()` 即可,不必走 loader。
|
|
117
116
|
> loader 主要在框架自动装配或插件化场景使用。
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import logger from "../utils/logger.js";
|
|
2
|
+
import { store } from "../storage/index.js";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* 内置健康检查端点(默认开启)
|
|
6
|
+
*
|
|
7
|
+
* 设计:浅模式零 IO —— 只读进程内已有状态(运行时长 / DB 健康缓存 / 存储层诊断),
|
|
8
|
+
* 不做实时 ping,可被监控系统高频探活而不打数据库。
|
|
9
|
+
* DB 健康缓存由 error-handler 在真实查询成功/失败时维护(markUp/markDown)。
|
|
10
|
+
*
|
|
11
|
+
* 配置(config/index.js):
|
|
12
|
+
* health: { enabled: true, path: '/health' } // 默认值,可整体关闭或改路径
|
|
13
|
+
*
|
|
14
|
+
* 响应示例:
|
|
15
|
+
* { "success":true, "code":0, "msg":"ok", "data":{
|
|
16
|
+
* "status":"ok", // ok=全部连接健康;degraded=有连接标记为不可用
|
|
17
|
+
* "uptime":12345,
|
|
18
|
+
* "db":[{ "name":"default", "healthy":true }],
|
|
19
|
+
* "store":{ "mode":"memory", ... }
|
|
20
|
+
* } }
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
const DEFAULT_PATH = "/health";
|
|
24
|
+
|
|
25
|
+
export const health = (chan, opts = {}) => {
|
|
26
|
+
if (opts.enabled === false) return;
|
|
27
|
+
const path = opts.path || DEFAULT_PATH;
|
|
28
|
+
const startedAt = Date.now();
|
|
29
|
+
|
|
30
|
+
return (req, res, next) => {
|
|
31
|
+
if (req.path !== path) return next();
|
|
32
|
+
try {
|
|
33
|
+
const db = chan.dbManager?.healthSnapshot?.() ?? [];
|
|
34
|
+
const degraded = db.some(d => !d.healthy);
|
|
35
|
+
res.status(200).json({
|
|
36
|
+
success: true,
|
|
37
|
+
code: 0,
|
|
38
|
+
msg: degraded ? "degraded" : "ok",
|
|
39
|
+
data: {
|
|
40
|
+
status: degraded ? "degraded" : "ok",
|
|
41
|
+
uptime: Math.floor((Date.now() - startedAt) / 1000),
|
|
42
|
+
db,
|
|
43
|
+
store: store.getInfo?.() ?? null,
|
|
44
|
+
},
|
|
45
|
+
});
|
|
46
|
+
} catch (err) {
|
|
47
|
+
logger.error(`[Health] 健康检查异常:${err.message}`);
|
|
48
|
+
res.status(200).json({
|
|
49
|
+
success: true,
|
|
50
|
+
code: 0,
|
|
51
|
+
msg: "degraded",
|
|
52
|
+
data: { status: "degraded", uptime: Math.floor((Date.now() - startedAt) / 1000), db: [], store: null },
|
|
53
|
+
});
|
|
54
|
+
}
|
|
55
|
+
};
|
|
56
|
+
};
|
package/middleware/index.js
CHANGED
package/middleware/static.js
CHANGED
|
@@ -5,15 +5,20 @@ import logger from "../utils/logger.js";
|
|
|
5
5
|
/**
|
|
6
6
|
* 批量挂载静态资源中间件,内置目录安全校验
|
|
7
7
|
* @param {express.Application} app Express实例
|
|
8
|
-
* @param {Array<{prefix:string;dir:string;maxAge?:number}>} statics 静态目录配置数组
|
|
8
|
+
* @param {Array<{prefix:string;dir:string;maxAge?:number;immutable?:boolean}>} statics 静态目录配置数组
|
|
9
|
+
* immutable:true 输出 Cache-Control: max-age=..., immutable —— 仅用于构建期带内容 hash 的产物目录
|
|
9
10
|
*/
|
|
10
11
|
export const staticMw = async (app, statics) => {
|
|
11
12
|
if (!Array.isArray(statics) || !statics.length) return;
|
|
12
|
-
statics.forEach(({ prefix, dir, maxAge }) => {
|
|
13
|
+
statics.forEach(({ prefix, dir, maxAge, immutable }) => {
|
|
13
14
|
if (!safePath(dir)) {
|
|
14
15
|
logger.error(`[Static] 不安全目录拦截:${dir}`);
|
|
15
16
|
return;
|
|
16
17
|
}
|
|
17
|
-
app.use(prefix, express.static(dir, {
|
|
18
|
+
app.use(prefix, express.static(dir, {
|
|
19
|
+
maxAge: maxAge ?? 0,
|
|
20
|
+
dotfiles: "deny",
|
|
21
|
+
...(immutable ? { immutable: true } : {}),
|
|
22
|
+
}));
|
|
18
23
|
});
|
|
19
24
|
};
|
package/middleware/waf.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { getIp } from "../utils/ip.js";
|
|
2
|
-
import { checkKeywords } from "../security/checker.js";
|
|
2
|
+
import { checkKeywords, isIgnored } from "../security/checker.js";
|
|
3
3
|
import { filterXSS } from "../security/xss-filter.js";
|
|
4
4
|
import { createRateLimitMiddleware } from "../security/rate-limit.js";
|
|
5
5
|
import { CODE_BLOCKED } from "../response/code.js";
|
|
@@ -14,6 +14,9 @@ const WAF_PATH_WHITELIST = [
|
|
|
14
14
|
"/sitemap.xml",
|
|
15
15
|
];
|
|
16
16
|
|
|
17
|
+
// waf 配置合法键集合:启动期校验,未知键打 warn,防配置漂移静默失效
|
|
18
|
+
const WAF_KNOWN_KEYS = new Set(["enabled", "rateLimit", "ignorePaths"]);
|
|
19
|
+
|
|
17
20
|
const TRUSTED_IPS = new Set(["127.0.0.1", "::1"]);
|
|
18
21
|
|
|
19
22
|
// URL/query 关键词检测保留的分类——只保留真正危险、几乎不会在正常 URL 中出现的攻击特征。
|
|
@@ -64,7 +67,17 @@ const runRateLimit = (rateLimit, req, res) =>
|
|
|
64
67
|
* 前置WAF中间件(body前)
|
|
65
68
|
*/
|
|
66
69
|
const createWafMiddleware = wafConfig => {
|
|
67
|
-
|
|
70
|
+
// ignorePaths 统一读 waf 层级(限流跳过 + 关键词检测跳过共用一份),
|
|
71
|
+
// 不再从 rateLimit 子对象读取,避免两级同名配置断链静默失效
|
|
72
|
+
const ignorePaths = Array.isArray(wafConfig.ignorePaths) ? wafConfig.ignorePaths : [];
|
|
73
|
+
const rateLimit = createRateLimitMiddleware({ ...(wafConfig.rateLimit ?? {}), ignorePaths });
|
|
74
|
+
|
|
75
|
+
// 启动期配置自检:未知键提示,防「配置写了但框架不认」的漂移
|
|
76
|
+
for (const key of Object.keys(wafConfig ?? {})) {
|
|
77
|
+
if (!WAF_KNOWN_KEYS.has(key)) {
|
|
78
|
+
logger.warn(`[waf] 未识别的配置项 "${key}",已忽略(合法键:${[...WAF_KNOWN_KEYS].join("/")})`);
|
|
79
|
+
}
|
|
80
|
+
}
|
|
68
81
|
|
|
69
82
|
return async (req, res, next) => {
|
|
70
83
|
try {
|
|
@@ -72,7 +85,7 @@ const createWafMiddleware = wafConfig => {
|
|
|
72
85
|
|
|
73
86
|
const clientIp = getIp(req);
|
|
74
87
|
const path = req.path || "";
|
|
75
|
-
const whitePath = isWhitelistedPath(path);
|
|
88
|
+
const whitePath = isWhitelistedPath(path) || isIgnored(path, ignorePaths);
|
|
76
89
|
|
|
77
90
|
// 可信IP直接放行,仅做query XSS过滤
|
|
78
91
|
if (isTrustedIp(clientIp)) {
|
|
@@ -122,11 +135,12 @@ const createWafMiddleware = wafConfig => {
|
|
|
122
135
|
* Body层WAF中间件(body解析后)
|
|
123
136
|
*/
|
|
124
137
|
const createWafBodyMiddleware = wafConfig => {
|
|
138
|
+
const ignorePaths = Array.isArray(wafConfig.ignorePaths) ? wafConfig.ignorePaths : [];
|
|
125
139
|
return async (req, res, next) => {
|
|
126
140
|
try {
|
|
127
141
|
if (!wafConfig.enabled) return next();
|
|
128
142
|
const path = req.path || "";
|
|
129
|
-
if (isWhitelistedPath(path)) return next();
|
|
143
|
+
if (isWhitelistedPath(path) || isIgnored(path, ignorePaths)) return next();
|
|
130
144
|
|
|
131
145
|
const clientIp = getIp(req);
|
|
132
146
|
const contentType = req.headers["content-type"]?.toLowerCase() || "";
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"type": "module",
|
|
3
3
|
"name": "chanjs",
|
|
4
|
-
"version": "2.7.
|
|
4
|
+
"version": "2.7.16",
|
|
5
5
|
"description": "chanjs基于 Node.js + Express 5 的标准 HMVC 框架(NHMVC),纯 JavaScript(ESM)开发。",
|
|
6
6
|
"main": "index.js",
|
|
7
7
|
"module": "index.js",
|
|
@@ -16,6 +16,9 @@
|
|
|
16
16
|
"engines": {
|
|
17
17
|
"node": ">=22.18.0"
|
|
18
18
|
},
|
|
19
|
+
"scripts": {
|
|
20
|
+
"test": "node --test"
|
|
21
|
+
},
|
|
19
22
|
"author": "明空",
|
|
20
23
|
"license": "ISC",
|
|
21
24
|
"files": [
|
|
@@ -39,19 +42,21 @@
|
|
|
39
42
|
"express": "^5.2.1",
|
|
40
43
|
"express-art-template": "^1.0.1",
|
|
41
44
|
"i18next": "^24.2.0",
|
|
45
|
+
"ioredis": "^5.4.6",
|
|
42
46
|
"jsonwebtoken": "^9.0.3",
|
|
43
47
|
"knex": "^3.2.10",
|
|
44
48
|
"marked": "^18.0.3",
|
|
45
49
|
"mysql2": "^3.22.3",
|
|
46
50
|
"node-cron": "^3.0.3",
|
|
51
|
+
"pg": "^8.23.0",
|
|
47
52
|
"pino": "^9.5.0",
|
|
48
53
|
"pino-http": "^10.3.0",
|
|
49
54
|
"serve-favicon": "^2.5.1",
|
|
50
|
-
"xss": "^1.0.15"
|
|
51
|
-
"ioredis": "^5.4.6"
|
|
55
|
+
"xss": "^1.0.15"
|
|
52
56
|
},
|
|
53
57
|
"devDependencies": {
|
|
54
|
-
"pino-pretty": "^11.3.0"
|
|
58
|
+
"pino-pretty": "^11.3.0",
|
|
59
|
+
"zod": "^4.4.3"
|
|
55
60
|
},
|
|
56
61
|
"peerDependencies": {
|
|
57
62
|
"zod": "^4.4.3"
|
package/security/rate-limit.js
CHANGED
|
@@ -40,13 +40,11 @@ export const createRateLimitMiddleware = cfg => {
|
|
|
40
40
|
const ip = getIp(req) || "unknown";
|
|
41
41
|
const uid = req.user?.uid;
|
|
42
42
|
const ipKey = `${KEY_PREFIX}ip:${ip}`;
|
|
43
|
-
|
|
43
|
+
// 空字符串为占位 key(批量接口跳过),Redis 模式下单条 Lua 完成双维度计数,一次往返
|
|
44
|
+
const uidKey = uid ? `${KEY_PREFIX}uid:${uid}` : "";
|
|
44
45
|
|
|
45
|
-
//
|
|
46
|
-
const [ipCount, uidCount] = await
|
|
47
|
-
store.incrAndExpire(ipKey, windowMs),
|
|
48
|
-
uidKey ? store.incrAndExpire(uidKey, windowMs) : 0,
|
|
49
|
-
]);
|
|
46
|
+
// 计数自增(批量原子完成限流判定,存储异常 fail-close 拦截)
|
|
47
|
+
const [ipCount, uidCount] = await store.incrAndExpireBatch([ipKey, uidKey], windowMs);
|
|
50
48
|
|
|
51
49
|
if (ipCount > max || uidCount > max) {
|
|
52
50
|
const dim = ipCount > max ? "IP" : "UID";
|
package/storage/cache.js
CHANGED
|
@@ -72,6 +72,17 @@ class Cache {
|
|
|
72
72
|
return this._incrInternal(key, ttlMs, true);
|
|
73
73
|
}
|
|
74
74
|
|
|
75
|
+
/**
|
|
76
|
+
* 批量限流自增,与 Redis 后端 incrAndExpireBatch 接口对齐
|
|
77
|
+
* 内存操作无网络往返,顺序执行即可;空字符串 key 跳过返回 0
|
|
78
|
+
* @param {string[]} keys 计数key数组
|
|
79
|
+
* @param {number} ttlMs 窗口过期时间
|
|
80
|
+
* @returns {number[]} 与 keys 顺序对应的计数值数组
|
|
81
|
+
*/
|
|
82
|
+
incrAndExpireBatch(keys, ttlMs) {
|
|
83
|
+
return keys.map(k => (k === "" ? 0 : this.incrAndExpire(k, ttlMs)));
|
|
84
|
+
}
|
|
85
|
+
|
|
75
86
|
_incrInternal(key, ttlMs, refreshTTL) {
|
|
76
87
|
const now = Date.now();
|
|
77
88
|
const item = this.map.get(key);
|
package/storage/redis.js
CHANGED
|
@@ -156,6 +156,32 @@ class RedisBackend {
|
|
|
156
156
|
);
|
|
157
157
|
}
|
|
158
158
|
|
|
159
|
+
/**
|
|
160
|
+
* 批量限流自增(IP+UID 双维度一次往返)
|
|
161
|
+
* 单条 Lua 脚本内完成全部 key 的 INCR+PEXPIRE,原子且省 RTT;
|
|
162
|
+
* 空字符串 key 跳过(占位用),返回数组与传入 keys 一一对应
|
|
163
|
+
* @param {string[]} keys 计数key数组
|
|
164
|
+
* @param {number} ttlMs 窗口过期时间
|
|
165
|
+
* @returns {Promise<number[]>} 与 keys 顺序对应的计数值数组
|
|
166
|
+
*/
|
|
167
|
+
async incrAndExpireBatch(keys, ttlMs) {
|
|
168
|
+
const cli = await this._getClient();
|
|
169
|
+
const script = `
|
|
170
|
+
local r = {}
|
|
171
|
+
for i, k in ipairs(KEYS) do
|
|
172
|
+
if k ~= '' then
|
|
173
|
+
local c = redis.call('INCR', k)
|
|
174
|
+
if c == 1 then redis.call('PEXPIRE', k, ARGV[1]) end
|
|
175
|
+
r[i] = c
|
|
176
|
+
else
|
|
177
|
+
r[i] = 0
|
|
178
|
+
end
|
|
179
|
+
end
|
|
180
|
+
return r
|
|
181
|
+
`;
|
|
182
|
+
return cli.eval(script, keys.length, ...keys, ttlMs);
|
|
183
|
+
}
|
|
184
|
+
|
|
159
185
|
/**
|
|
160
186
|
* 判断key是否存在
|
|
161
187
|
* @param {string} key 键名
|
package/storage/store.js
CHANGED
|
@@ -44,6 +44,14 @@ class MemoryAdapter {
|
|
|
44
44
|
*/
|
|
45
45
|
incrAndExpire(key, ttlMs) { return this._cache.incrAndExpire(key, ttlMs); }
|
|
46
46
|
|
|
47
|
+
/**
|
|
48
|
+
* 批量限流自增(IP+UID 双维度一次完成),与 Redis 后端接口对齐
|
|
49
|
+
* @param {string[]} keys 键数组
|
|
50
|
+
* @param {number} ttlMs 过期时间(毫秒)
|
|
51
|
+
* @returns {number[]} 与 keys 顺序对应的计数值
|
|
52
|
+
*/
|
|
53
|
+
incrAndExpireBatch(keys, ttlMs) { return this._cache.incrAndExpireBatch(keys, ttlMs); }
|
|
54
|
+
|
|
47
55
|
/** 判断 key 是否存在 */
|
|
48
56
|
exists(key) { return this._cache.has(key); }
|
|
49
57
|
|
|
@@ -197,6 +205,15 @@ class Store {
|
|
|
197
205
|
*/
|
|
198
206
|
incrAndExpire(key, ttlMs) { return this._safeExec("incrAndExpire", [key, ttlMs], 0); }
|
|
199
207
|
|
|
208
|
+
/**
|
|
209
|
+
* 批量限流自增:Redis 模式下单条 Lua 完成全部 key(一次往返、原子),
|
|
210
|
+
* 内存模式顺序执行。keys 中空字符串为占位跳过,返回 0。
|
|
211
|
+
* @param {string[]} keys 计数键数组
|
|
212
|
+
* @param {number} ttlMs 窗口过期毫秒
|
|
213
|
+
* @returns {Promise<number[]>}
|
|
214
|
+
*/
|
|
215
|
+
incrAndExpireBatch(keys, ttlMs) { return this._safeExec("incrAndExpireBatch", [keys, ttlMs], keys.map(() => 0)); }
|
|
216
|
+
|
|
200
217
|
/** 判断 key 是否存在;异常返回 false */
|
|
201
218
|
exists(key) { return this._safeExec("exists", [key], false); }
|
|
202
219
|
|