chanjs 2.7.13 → 2.7.15

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 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.set(data.__knexUid, Date.now());
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.__knexUid);
69
+ const startedAt = startTimes.get(data.__knexQueryUid);
67
70
  if (!startedAt) return;
68
- startTimes.delete(data.__knexUid);
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?.__knexUid);
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.emit(event, ...args);
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
  /**
@@ -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 res = await this.db(this.tableName).insert(this.#formatDate(data));
201
- return { success:true, code:CODE_OK, msg:"插入成功", data:{ insertId:res[0], affectedRows:1 } };
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 res = await this.db(this.tableName).insert(list);
210
- return { success:true, code:CODE_OK, msg:"批量插入成功", data:{ insertId:res[0], affectedRows:records.length } };
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
  /** 条件删除 */
@@ -267,6 +287,11 @@ class Repository extends BaseComponent {
267
287
  if (!item.query || !Object.keys(item.query).length) {
268
288
  return { success:false, code:CODE_PARAM_INVALID, msg:"批量更新条件不能为空", data:{} };
269
289
  }
290
+ // 防御纵深:与 update 同口径,条件字段全部非法时 fail-closed 拒绝,
291
+ // 避免生成无 WHERE 的全表更新
292
+ if (!Object.keys(item.query).some(f => SORT_FIELD_REGEX.test(f))) {
293
+ return { success:false, code:CODE_PARAM_INVALID, msg:"批量更新条件非法", data:{} };
294
+ }
270
295
  }
271
296
  const trx = await this.db.transaction();
272
297
  let total = 0;
@@ -396,6 +421,11 @@ class Repository extends BaseComponent {
396
421
  logger.warn(`[Repository] 非法联表/字段:${joinTable}/${localField}/${foreignField}`);
397
422
  return { success: false, code: CODE_PARAM_INVALID, msg: "非法联表或字段", data: null };
398
423
  }
424
+ // 表白名单校验(构造时声明 allowJoinTables 才启用)
425
+ if (this._allowJoinTables && !this._allowJoinTables.includes(joinTable)) {
426
+ logger.warn(`[Repository] 联表不在白名单:${joinTable}`);
427
+ return { success: false, code: CODE_PARAM_INVALID, msg: "非法联表", data: null };
428
+ }
399
429
  const err = this._guard({ query });
400
430
  if (err) return err;
401
431
  const select = fields.length
@@ -409,23 +439,29 @@ class Repository extends BaseComponent {
409
439
  return { success:true, code:CODE_OK, msg:"查询成功", data: await q };
410
440
  }
411
441
 
412
- /** 统计表总数量 + 今日新增数量 */
442
+ /** 统计表总数量 + 今日新增数量(今日列探测失败降级为 0,不阻断总数统计) */
413
443
  async stats() {
414
444
  this._checkDB();
415
445
  const createKey = this._dateFields.find(f => /created_at|createdAt/.test(f)) || "created_at";
416
- const today = new Date();
417
- today.setHours(0,0,0,0);
418
- const [totalRes, todayRes] = await Promise.all([
419
- this.db(this.tableName).count('* as count').first(),
420
- this.db(this.tableName).where(createKey, '>=', today).count('* as count').first()
421
- ]);
446
+ const totalRes = await this.db(this.tableName).count('* as count').first();
447
+ let today = 0;
448
+ try {
449
+ const todayStart = new Date();
450
+ todayStart.setHours(0,0,0,0);
451
+ const todayRes = await this.db(this.tableName)
452
+ .where(createKey, '>=', todayStart).count('* as count').first();
453
+ today = Number(todayRes?.count || 0);
454
+ } catch (err) {
455
+ // 表无 created_at 列等情况:跳过今日统计而非整体报错
456
+ logger.warn(`[Repository] stats 今日统计失败(表 ${this.tableName} 列 ${createKey}?):${err.message}`);
457
+ }
422
458
  return {
423
459
  success:true,
424
460
  code:CODE_OK,
425
461
  msg:"统计成功",
426
462
  data: {
427
463
  total: Number(totalRes?.count || 0),
428
- today: Number(todayRes?.count || 0)
464
+ today
429
465
  }
430
466
  };
431
467
  }
@@ -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
- const isDbConnection = isAppError(err) && (
54
- err.code >= 6000 ||
55
- /数据库连接|数据库访问被拒绝|ECONNREFUSED|ETIMEDOUT|连接中断/.test(err.message || "")
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 { loaderSort, importFile } from "../loader.js";
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 = loaderSort(config.modules ?? []);
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
+ };
@@ -13,3 +13,4 @@ export { staticMw } from "./static.js";
13
13
  export { template } from "./template.js";
14
14
  export { waf, wafBody } from "./waf.js";
15
15
  export { log } from "./log.js";
16
+ export { health } from "./health.js";
@@ -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, { maxAge: maxAge ?? 0, dotfiles: "deny" }));
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
- const rateLimit = createRateLimitMiddleware(wafConfig.rateLimit);
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.13",
4
+ "version": "2.7.15",
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"
@@ -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
- const uidKey = uid ? `${KEY_PREFIX}uid:${uid}` : null;
43
+ // 空字符串为占位 key(批量接口跳过),Redis 模式下单条 Lua 完成双维度计数,一次往返
44
+ const uidKey = uid ? `${KEY_PREFIX}uid:${uid}` : "";
44
45
 
45
- // 计数自增(一次操作完成限流判定,存储异常 fail-close 拦截)
46
- const [ipCount, uidCount] = await Promise.all([
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