chanjs 2.7.12 → 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/README.md CHANGED
@@ -1,84 +1,102 @@
1
1
  # ChanJS
2
2
 
3
- 基于 Node.js + Express 5 的标准 HMVC 框架(NHMVC),纯 JavaScript(ESM)开发。模块自治 + 跨模块双通道协作,约定优于配置,开箱即用。
3
+ 基于 Node.js + Express 5 构建的**标准 HMVCNHMVC)后端框架**,原生 ESM JavaScript 开发。
4
+ 以**模块自治 + 双通道跨模块协作**为核心,约定优于配置,开箱即用,拒绝冗余复杂。
5
+
6
+ ## 设计哲学
7
+
8
+ >
9
+ > **大道至简。**
10
+ > 优秀的开发工具,应当化繁为简。ChanJS 摒弃过度设计与沉重抽象,回归纯粹 JavaScript,用最少的心智负担交付稳定高效的业务能力。
11
+
12
+ ## 核心亮点
13
+
14
+ - 🛡️ **安全可控**:内置多层防护能力,降低业务安全开发成本
15
+ - ⚡ **高性能**:高性能日志、按需组件加载、慢查询监控
16
+ - 🪶 **轻量精简**:无多余抽象、低侵入、上手门槛低
17
+ - ✅ **务实好用**:聚焦后端业务高频场景,配套完整基础设施
4
18
 
5
19
  ## 特性
6
20
 
7
21
  ### 核心架构
8
- - Express 5+ 原生
9
- - Node.js 22.18+
10
- - ES Modules(import / export)
11
- - 标准 HMVC 架构(模块自治 + 跨模块双通道协作)
12
- - 约定优于配置
22
+
23
+ - 原生基于 Express 5+
24
+ - 最低运行环境 Node.js 22.18+
25
+ - 全量 ES Modules(import / export)
26
+ - **标准 HMVC 架构:模块自治 + 双通道跨模块协作**
27
+ - 约定优于配置,减少样板配置代码
13
28
 
14
29
  ### 基础设施
15
- - **日志**:[pino](https://github.com/pinojs/pino) 高性能结构化日志
16
- - **请求日志**:pino-http,自动生成 requestId 贯穿全链路
17
- - **国际化**:[i18next](https://www.i18next.com/) 多语言支持
18
- - **事件总线**:EventBus,基于 Node 内置 EventEmitter
19
- - **定时任务**:Task,基于 [node-cron](https://github.com/node-cron/node-cron)
20
- - **数据库**:[Knex](https://knexjs.org/) 查询构建器,慢查询监控
21
- - **组件容器**:按需动态加载 controller/service,成功永久缓存、缺失不缓存
22
- - **优雅停机**:统一信号处理,资源按序释放
23
-
24
- ### 安全能力
25
- - WAF 防火墙
26
- - XSS 防护
27
- - 关键词过滤
28
- - 请求限流
29
- - 路由白名单
30
- - Cookie 安全
31
-
32
- ### 中间件生态
33
- - CORS
34
- - 请求解析(body / cookie)
35
- - 静态资源
36
- - favicon
37
- - 请求头注入
38
- - Art-template 模板引擎
39
-
40
- ### 开发体验
41
- - 多环境配置(.env.dev / .env.prd)
42
- - 统一响应(success / fail)
30
+
31
+ - **高性能日志**:Pino 结构化日志输出
32
+ - **全链路请求追踪**:pino‑http,自动注入唯一 `requestId`
33
+ - **国际化多语言**:i18next,内存高速语言查找
34
+ - **全局事件总线**:轻量封装 Node.js EventEmitter,解耦业务事件
35
+ - **定时任务调度**:node‑cron,自带异常捕获、停机安全回收
36
+ - **数据库层**:Knex 查询构建器,内置慢查询监控告警
37
+ - **按需组件容器**:Controller/Service 动态懒加载,成功实例永久缓存;缺失模块不缓存,修改文件即时生效
38
+ - **优雅停机**:统一信号监听,资源有序释放,超时强制退出
39
+
40
+ ### 内置安全能力
41
+
42
+ - WAF 基础防火墙
43
+ - XSS 请求防护
44
+ - 敏感关键词过滤
45
+ - 接口请求限流
46
+ - 路由访问白名单
47
+ - Cookie 安全加固
48
+
49
+ ### 开箱即用中间件生态
50
+
51
+ - CORS 跨域处理
52
+ - Body / Cookie 请求解析
53
+ - 静态资源托管
54
+ - favicon 快捷支持
55
+ - 自定义响应头注入
56
+ - Art‑template 模板引擎渲染
57
+
58
+ ### 优秀开发体验
59
+
60
+ - 多环境配置隔离(`.env.dev` / `.env.prd`)
61
+ - 标准化统一返回体(success / fail)
43
62
  - Zod 参数校验中间件
44
- - 全局异常处理
45
- - 工具函数库
63
+ - 全局异常捕获兜底
64
+ - 内置通用工具函数库
46
65
 
47
66
  ## 目录结构
48
67
 
49
68
  ```
50
69
  |- app/
51
- | |- common/ # 公共路由
52
- | |- helper/ # 辅助函数
53
- | |- middleware/ # 应用中间件
54
- | |- modules/ # 业务模块
55
- | | |- <module>/
70
+ | |- common/ # 公共业务路由
71
+ | |- helper/ # 全局辅助工具函数
72
+ | |- middleware/ # 应用级中间件
73
+ | |- modules/ # 业务模块根目录
74
+ | | |- <module>/ # 单个业务模块
56
75
  | | |- controller/
57
76
  | | |- service/
58
77
  | | |- middleware/
59
78
  | | |- router.js
60
- | |- router.js
61
- |- config/ # 框架常量与环境加载
62
- |- data/ # 运行数据
63
- |- doc/ # 文档
64
- |- lang/ # i18n 资源(zh-CN/en-US/...)
65
- |- public/ # 静态资源
66
- |- view/ # 模板视图
67
- |- app.js # 业务入口
68
- |- .env.dev / .env.prd # 环境变量
69
- |- pm2.json # 进程管理
79
+ | |- router.js # 根路由聚合
80
+ |- config/ # 框架配置、环境变量加载
81
+ |- data/ # 运行时持久化数据目录
82
+ |- doc/ # 项目文档
83
+ |- lang/ # i18n 语言资源(zhCN/enUS/...)
84
+ |- public/ # 前端静态资源
85
+ |- view/ # 视图模板文件
86
+ |- app.js # 项目业务入口
87
+ |- .env.dev / .env.prd # 环境配置文件
88
+ |- pm2.json # PM2 进程部署配置
70
89
  ```
71
90
 
72
91
  ## 快速开始
73
92
 
74
- ```javascript
93
+ ```
75
94
  import Chan from "chanjs";
76
95
 
77
96
  const chan = new Chan();
78
97
 
79
- // 注册启动前置钩子
98
+ // 注册启动前置钩子(事件、定时任务等初始化)
80
99
  chan.beforeStart(() => {
81
- // 此处可注册事件监听、定时任务等
82
100
  });
83
101
 
84
102
  await chan.start(); // 加载配置、i18n、数据库、中间件、路由
@@ -87,65 +105,65 @@ chan.run((port) => { // 启动 HTTP 服务
87
105
  });
88
106
  ```
89
107
 
90
- ## 核心能力
108
+ ## 核心能力详解
91
109
 
92
- ### 1. 日志系统(pino
110
+ ### 1. 日志系统(Pino
93
111
 
94
- dev 环境彩色输出,prod 环境输出 JSON(pm2 捕获 stdout)。API 完全兼容旧用法。
112
+ 开发环境彩色控制台输出;生产环境输出 JSON 结构化日志,适配 PM2 采集。API 向下完全兼容。
95
113
 
96
- ```javascript
114
+ ```
97
115
  import logger, { createLogger } from "chanjs";
98
116
 
99
- // 全局日志
117
+ // 全局日志实例
100
118
  logger.info("启动完成");
101
- logger.error("查询失败", err); // 自动分离 Error 对象
119
+ logger.error("查询失败", err); // 自动识别 Error 对象
102
120
 
103
- // 带模块标签的子日志
121
+ // 创建带业务标签的子日志
104
122
  const dbLog = createLogger("DB");
105
123
  dbLog.warn("慢查询");
106
124
  ```
107
125
 
108
- 请求日志由 pino-http 自动处理,每个请求生成唯一 `requestId`,业务中通过 `req.log` 输出带 requestId 的日志:
126
+ 请求日志由 `pinohttp` 自动接管,每个请求分配唯一 `requestId`,Controller 内可直接携带链路日志:
109
127
 
110
- ```javascript
128
+ ```
111
129
  // 在 Controller 中
112
130
  async getUser(req, res) {
113
- req.log.info("查询用户详情"); // 自动携带 requestId
114
- // ...
131
+ req.log.info("查询用户详情"); // 日志自动附带 requestId
115
132
  }
116
133
  ```
117
134
 
118
135
  ### 2. 事件总线(EventBus)
119
136
 
120
- 基于 Node 内置 EventEmitter 的轻量封装,全局单例。
137
+ 基于 Node.js 原生 EventEmitter 封装的全局单例事件中心,用于业务解耦。
121
138
 
122
- ```javascript
139
+ ```
123
140
  import { event, EventBus } from "chanjs";
124
141
 
125
- // 全局实例
142
+ // 使用全局事件实例
126
143
  const off = event.on("user.login", (uid) => {
127
144
  logger.info(`用户 ${uid} 登录`);
128
145
  });
129
146
  event.emit("user.login", 1001);
130
- off(); // 取消监听
147
+ off(); // 解绑监听
131
148
 
132
- // 独立实例(隔离场景)
149
+ // 创建独立隔离的事件实例
133
150
  const localBus = new EventBus();
134
151
  ```
135
152
 
136
- > 详见 [doc/07-事件系统EventBus.md](./doc/07-事件系统EventBus.md)
153
+ >
154
+ > 详细文档:[doc/07‑事件系统EventBus.md](./doc/07%E2%80%91%E4%BA%8B%E4%BB%B6%E7%B3%BB%E7%BB%9FEventBus.md)
137
155
 
138
156
  ### 3. 定时任务(Task)
139
157
 
140
- 基于 node-cron 封装,支持 cron 表达式校验、异常自动捕获、优雅停机。
158
+ 基于 nodecron 封装,内置 cron 表达式校验、任务异常捕获、优雅停机回收。
141
159
 
142
- ```javascript
160
+ ```
143
161
  import Chan from "chanjs";
144
162
 
145
163
  const chan = new Chan();
146
164
 
147
165
  chan.beforeStart(() => {
148
- // 注册定时任务(启动时自动开始)
166
+ // 注册定时任务,框架启动后自动运行
149
167
  chan.task.add("clear-log", "0 3 * * *", async () => {
150
168
  await chan.db.raw("DELETE FROM logs WHERE created_at < NOW() - INTERVAL 7 DAY");
151
169
  });
@@ -157,7 +175,7 @@ chan.run();
157
175
 
158
176
  ### 4. 国际化(i18next)
159
177
 
160
- 启动时扫描 `lang/` 目录加载全部语言资源到内存,运行时 O(1) 查找。
178
+ 服务启动时一次性扫描加载 `lang/` 全部语言资源至内存,运行时 O(1) 快速读取翻译文本。
161
179
 
162
180
  ```
163
181
  lang/
@@ -167,7 +185,7 @@ lang/
167
185
  common.json # { "user.welcome": "Welcome, {{name}}" }
168
186
  ```
169
187
 
170
- ```javascript
188
+ ```
171
189
  import { initLang } from "chanjs";
172
190
 
173
191
  const i18n = await initLang("zh-CN");
@@ -176,86 +194,107 @@ i18n.t("user.welcome", { name: "张三" }); // → "欢迎,张三"
176
194
 
177
195
  ### 5. 数据库(Knex + 慢查询监控)
178
196
 
179
- 多连接管理,自动慢查询与错误监控。
197
+ 支持多数据库连接管理,自动开启慢查询与 SQL 异常监控。
180
198
 
181
- ```javascript
182
- // 框架启动时根据 config.db 自动注册连接
183
- // 业务中通过 this.db getApp().db 访问默认连接
199
+ ```
200
+ // 框架启动自动读取 config.db 注册数据库连接
201
+ // 业务代码内可通过 this.db / getApp().db 获取默认连接
184
202
 
185
- // 运行时调整慢查询阈值
186
- chan.dbManager.setSlowThreshold(500); // 500ms
203
+ // 运行时动态调整慢查询告警阈值(毫秒)
204
+ chan.dbManager.setSlowThreshold(500);
187
205
  ```
188
206
 
189
- ### 6. 组件容器(跨模块按需获取)
207
+ ### 6. 组件容器 · 双通道跨模块调用
190
208
 
191
- Controller / Service 都继承 `Container`,自带 `this.get()`,按「模块名 + 文件名」动态加载组件。
192
- 成功永久缓存;缺失不缓存(文件新增后立即感知)。
209
+ Controller / Service 继承容器基类,内置 `this.get()` 方法,通过「模块名 + 组件名」动态加载跨模块业务组件。
193
210
 
194
- ```javascript
195
- // 同模块获取 Service(Service 容器默认 type=service)
211
+ >
212
+ > 加载成功后实例永久缓存;组件文件缺失不缓存,新增文件无需重启即可识别。
213
+
214
+ ```
215
+ // 同模块调用 Service
196
216
  const cat = await this.get("book", "BookCategory");
197
217
 
198
- // 跨模块获取其他模块的 Service(Controller 容器默认 type=controller,取 service 需传第三参)
218
+ // 跨模块调用 Service(推荐,HMVC 双通道‑服务通道)
199
219
  const book = await this.get("book", "Book", "service");
200
220
  const special = await this.get("cms", "Special", "service");
201
221
  ```
202
222
 
203
- - 存在返回实例并永久缓存;缺失返回 `null`,下次实时感知。
204
- - 自带非法名称 / 路径越界安全校验。
205
- - 异步方法,需 `await`。
206
- - 跨模块协作优先用 `get`,避免手写 `../../` 相对路径 import。
223
+ - 返回实例或 null,异步调用必须 await
224
+ - 内置名称校验、路径越界安全防护
225
+ - 跨模块业务复用优先使用容器调用,摒弃超长相对路径 import
207
226
 
208
- ### HMVC 定位(标准实现)
227
+ #### HMVC 标准实现说明
209
228
 
210
- 教科书把「Controller 嵌套调用 Controller + 运行时子请求」当作 HMVC 的标准,其实是把**实现手段**当成了标准,是片面的。
211
- HMVC 的本质是**分层 + 模块自治 + 跨模块复用**。Chanjs 抓住本质,支持**双通道跨模块协作**:
229
+ 传统教程常将「控制器嵌套发起子 HTTP 请求」当作 HMVC 的标准,该方式混淆了**实现手段**和架构本质。
212
230
 
213
- - `await this.get("模块", "Controller")` —— 获取其他模块的 Controller(默认本容器类型)
214
- - `await this.get("模块", "Service", "service")` —— 获取其他模块的 Service(推荐路径)
231
+ >
232
+ > HMVC 的核心 = 分层解耦 + 模块自治 + 跨模块业务复用。
215
233
 
216
- 因此 Chanjs 是更贴合 HMVC 本质的**标准 HMVC 实现**,教科书把手段当标准才是过时的变体。
234
+ ChanJS 提供双通道协作模式,回归 HMVC 本质:
235
+
236
+ 1. 控制器通道:`await this.get("模块", "Controller")`
237
+ 2. 服务通道(推荐):`await this.get("模块", "Service", "service")`
217
238
 
218
239
  ### 7. 优雅停机
219
240
 
220
- 统一处理 SIGTERM / SIGINT / SIGQUIT,按序释放资源:
241
+ 统一监听 `SIGTERM / SIGINT / SIGQUIT` 退出信号,按顺序逐级释放资源:
221
242
 
222
243
  ```
223
244
  HTTP 服务 → 定时任务 → 缓存存储 → 数据库连接 → 事件总线
224
245
  ```
225
246
 
226
- 单个资源关闭失败不阻断其他资源,超时强制退出。
247
+ 单个资源关闭失败不会阻断其余回收流程,超时后进程强制退出。
248
+
249
+ ## 环境变量配置
227
250
 
228
- ## 环境变量
251
+ | 变量 | 说明 | 默认值 |
252
+ | --- | --- | --- |
253
+ | `NODE_ENV` | 运行环境标识(dev/prd) | dev |
254
+ | `PORT` | HTTP 监听端口 | 3000 |
255
+ | `LOCALE` | 默认语言 | zh‑CN |
256
+ | `LOG_LEVEL` | 日志输出级别 | dev:debug / prd:info |
257
+ | `TRUSTED_PROXIES` | 信任反向代理网段 | loopback |
258
+ | `SHUTDOWN_TIMEOUT` | 优雅停机超时时间(ms) | 5000 |
259
+ | `REDIS_ENABLED` | 是否开启 Redis | false |
229
260
 
230
- | 变量 | 说明 | 默认 |
231
- |------|------|------|
232
- | `NODE_ENV` | 环境(dev/prd) | dev |
233
- | `PORT` | HTTP 端口 | 3000 |
234
- | `LOCALE` | 默认语言 | zh-CN |
235
- | `LOG_LEVEL` | 日志级别 | dev:debug / prd:info |
236
- | `TRUSTED_PROXIES` | 信任代理 | loopback |
237
- | `SHUTDOWN_TIMEOUT` | 停机超时(ms) | 5000 |
238
- | `REDIS_ENABLED` | 启用 Redis | false |
261
+ ## 项目依赖清单
239
262
 
240
- ## 依赖
263
+ ### 运行依赖
241
264
 
242
- ### 运行时依赖
243
265
  - express ^5.2.1
244
266
  - knex ^3.2.10
245
267
  - pino ^9.5.0
246
- - pino-http ^10.3.0
268
+ - pinohttp ^10.3.0
247
269
  - i18next ^24.2.0
248
- - node-cron ^3.0.3
249
- - art-template ^4.13.4
270
+ - nodecron ^3.0.3
271
+ - arttemplate ^4.13.4
250
272
  - mysql2 ^3.22.3
251
273
  - ioredis ^5.4.6
252
274
 
253
275
  ### 开发依赖
254
- - pino-pretty ^11.3.0
276
+
277
+ - pino‑pretty ^11.3.0
255
278
 
256
279
  ### 可选 Peer 依赖
257
- - zod ^4.4.3(参数校验)
258
280
 
259
- ## License
281
+ - zod ^4.4.3(接口参数校验)
282
+
283
+ ## Hono 高性能版本
284
+
285
+ [chanjs‑hono](https://www.npmjs.com/package/chanjs%E2%80%91hono) 为 ChanJS 衍生高性能分支,基于 Hono,API 保持一致,性能更强。
286
+
287
+ ```
288
+ npm install chanjs-hono
289
+ ```
290
+
291
+ ## 文档 & 生态资源
292
+
293
+ - ChanJS官网文档:[ChanJS](https://chancms.top/)
294
+ - ChanJS-Hono官网文档:[ChanJS-Hono](https://chancms.top/)
295
+ - ChanJS-cli官方脚手架:[ChanJS-cli](https://chancms.top/)
296
+ - ChanCMS官方开源 CMS:[ChanCMS](https://chancms.top/)
297
+
298
+ ## 开源协议
260
299
 
261
300
  ISC
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
  /**
@@ -97,6 +97,8 @@ function applySelectAndSort(q, fields, sort) {
97
97
  /**
98
98
  * 数据访问基类,封装通用CRUD操作
99
99
  * 继承 BaseComponent 获得 app/config/db 快捷访问,不绑定组件加载职责
100
+ * 防御设计:所有入口统一经 _guard 校验「数据库可用性 + 查询条件合法性」,
101
+ * 再进入 SQL 构造层,避免各方法重复书写 `_checkDB + guardInvalidQuery` 样板。
100
102
  */
101
103
  class Repository extends BaseComponent {
102
104
  constructor(table=null, dbName=null, opts={}) {
@@ -105,6 +107,9 @@ class Repository extends BaseComponent {
105
107
  this._dbName = dbName;
106
108
  this._customDb = null;
107
109
  this._dateFields = Array.isArray(opts.dateFields) ? opts.dateFields : [];
110
+ // join() 联表白名单:声明后 joinTable 必须在列,防未来某处把用户输入透传成任意表读取;
111
+ // 未声明保持原行为(仅正则格式校验),存量业务零破坏
112
+ this._allowJoinTables = Array.isArray(opts.allowJoinTables) ? opts.allowJoinTables : null;
108
113
  typeof this.on === 'function' && this.on();
109
114
  }
110
115
 
@@ -125,6 +130,30 @@ class Repository extends BaseComponent {
125
130
  if (!this.db) throw new Error("Database connection not available");
126
131
  }
127
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
+
144
+ /**
145
+ * 统一防御守卫:数据库可用性 + 查询条件合法性(fail-closed)。
146
+ * 查询/统计/存在性等方法的入口统一先过此守卫,替代各方法开头的 `_checkDB()+guardInvalidQuery()` 重复样板。
147
+ * 空 query(如 {})合法放行;仅 db 不可用时直接抛异常,保证后端配置错误尽早暴露。
148
+ * @param {object} [opts={}]
149
+ * @param {object} [opts.query={}] 查询条件;为空对象或缺省时仅校验数据库连接
150
+ * @returns {null | {success:false,code:number,msg:string,data:object}} 校验通过返回 null,否则返回错误体(调用方应 return 该值)
151
+ */
152
+ _guard({ query = {} } = {}) {
153
+ this._checkDB();
154
+ return guardInvalidQuery(query);
155
+ }
156
+
128
157
  /** 统一构建查询实例(子类可重写以扩展过滤逻辑) */
129
158
  _buildBaseQuery({ query = {}, sort = {}, fields = [] } = {}) {
130
159
  this._checkDB();
@@ -148,16 +177,16 @@ class Repository extends BaseComponent {
148
177
 
149
178
  /** 查询全部,默认上限1000条 */
150
179
  async all({ query={}, sort={}, fields=[], limit=1000 }={}) {
151
- const guard = guardInvalidQuery(query);
152
- if (guard) return guard;
180
+ const err = this._guard({ query });
181
+ if (err) return err;
153
182
  const list = await this._buildBaseQuery({query,sort,fields}).limit(limit);
154
183
  return { success:true, code:CODE_OK, msg:"查询成功", data: list };
155
184
  }
156
185
 
157
186
  /** 分页偏移查询 */
158
187
  async find({ query={}, sort={}, fields=[], limit, offset }={}) {
159
- const guard = guardInvalidQuery(query);
160
- if (guard) return guard;
188
+ const err = this._guard({ query });
189
+ if (err) return err;
161
190
  let q = this._buildBaseQuery({query,sort,fields});
162
191
  typeof offset === 'number' && (q = q.offset(offset));
163
192
  typeof limit === 'number' && (q = q.limit(limit));
@@ -166,8 +195,8 @@ class Repository extends BaseComponent {
166
195
 
167
196
  /** 查询单条记录 */
168
197
  async findOne({ query={}, fields=[] }={}) {
169
- const guard = guardInvalidQuery(query);
170
- if (guard) return guard;
198
+ const err = this._guard({ query });
199
+ if (err) return err;
171
200
  const row = await this._buildBaseQuery({query,fields}).first();
172
201
  if (!row) return { success:false, code:CODE_NOT_FOUND, msg:"记录不存在", data:null };
173
202
  return { success:true, code:CODE_OK, msg:"查询成功", data: row };
@@ -182,8 +211,11 @@ class Repository extends BaseComponent {
182
211
  async insert(data={}) {
183
212
  this._checkDB();
184
213
  if (!Object.keys(data).length) return { success:false, code:CODE_PARAM_MISSING, msg:"参数缺失", data:{} };
185
- const res = await this.db(this.tableName).insert(this.#formatDate(data));
186
- 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 } };
187
219
  }
188
220
 
189
221
  /** 批量插入 */
@@ -191,8 +223,11 @@ class Repository extends BaseComponent {
191
223
  this._checkDB();
192
224
  if (!records.length) return { success:false, code:CODE_PARAM_MISSING, msg:"参数缺失", data:{} };
193
225
  const list = records.map(r => this.#formatDate(r));
194
- const res = await this.db(this.tableName).insert(list);
195
- 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 } };
196
231
  }
197
232
 
198
233
  /** 条件删除 */
@@ -235,11 +270,10 @@ class Repository extends BaseComponent {
235
270
  return { success:true, code:CODE_OK, msg:"更新成功", data:{ affectedRows:rows } };
236
271
  }
237
272
 
238
- /** 根据ID更新,返回更新后完整数据 */
273
+ /** 根据ID更新,返回更新后完整数据(复用 update 走统一 applyQuery 校验) */
239
274
  async updateById(id, data={}) {
240
- this._checkDB();
241
275
  if (!id || !Object.keys(data).length) return { success:false, code:CODE_PARAM_INVALID, msg:"参数无效", data:{} };
242
- await this.db(this.tableName).where({id}).update(this.#formatDate(data));
276
+ await this.update({ query: { id }, data });
243
277
  return this.findById(id);
244
278
  }
245
279
 
@@ -253,6 +287,11 @@ class Repository extends BaseComponent {
253
287
  if (!item.query || !Object.keys(item.query).length) {
254
288
  return { success:false, code:CODE_PARAM_INVALID, msg:"批量更新条件不能为空", data:{} };
255
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
+ }
256
295
  }
257
296
  const trx = await this.db.transaction();
258
297
  let total = 0;
@@ -278,9 +317,8 @@ class Repository extends BaseComponent {
278
317
  * @returns {Promise<{success:boolean,code:number,msg:string,data:object}>}
279
318
  */
280
319
  async _doPaginate({ current=1, pageSize=10, query={}, sort={}, field=[] }={}) {
281
- this._checkDB();
282
- const guard = guardInvalidQuery(query);
283
- if (guard) return guard;
320
+ const err = this._guard({ query });
321
+ if (err) return err;
284
322
  // 页码边界保护:current<=0 会生成负 offset 让 MySQL 直接语法报错
285
323
  const currentPage = Math.max(1, Math.floor(Number(current) || 1));
286
324
  const size = Math.min(Math.max(pageSize, 1), this.limit);
@@ -310,20 +348,68 @@ class Repository extends BaseComponent {
310
348
  return this._doPaginate(params);
311
349
  }
312
350
 
351
+ /**
352
+ * 基于游标的深分页查询(keyset pagination),替代 offset 深分页。
353
+ *
354
+ * 适用场景:大表(数万行+)且需翻到很后面的分页。
355
+ * offset 深分页痛点:`LIMIT 100000, 20` 需先扫描并丢弃前 10 万行,越深越慢;
356
+ * keyset 直接用 `WHERE id > 上一页最后一条的 id ORDER BY id LIMIT 20` 精准定位,与页码无关,常数级耗时。
357
+ *
358
+ * 返回体不依赖 offset/totalPages,浏览器端用「加载更多」或「下一页」+ nextCursor 驱动。
359
+ *
360
+ * @param {object} [params]
361
+ * @param {object} [params.query={}] 过滤条件(同 query())
362
+ * @param {number} [params.pageSize=20] 每页条数(受 this.limit 上限约束)
363
+ * @param {string} [params.cursorField='id'] 游标字段,须为稳定唯一且可排序的列(默认主键 id;也支持 created_at 等,但注意并列值会导致漏数据)
364
+ * @param {string|number|null} [params.cursorValue=null] 上一页最后一条的 cursorField 值;null 表示首页
365
+ * @param {'asc'|'desc'} [params.direction='desc'] 排序方向(取新纪录通常 desc)
366
+ * @param {string[]} [params.fields=[]] 白名单字段
367
+ * @returns {Promise<{success:boolean,code:number,msg:string,data:{list:Array,nextCursor:number|string|null,hasMore:boolean,cursorField:string}}>}
368
+ *
369
+ * @example
370
+ * // 首页(取最新 20 条)
371
+ * const p1 = await repo.cursorPage({ query:{cid:5}, pageSize:20, direction:'desc' });
372
+ * const nextCursor = p1.data.nextCursor;
373
+ * // 下一页(携带上一页末尾游标,常数级耗时,即使翻到第 1 万页也一样快)
374
+ * const p2 = await repo.cursorPage({ query:{cid:5}, pageSize:20, direction:'desc', cursorValue:nextCursor });
375
+ */
376
+ async cursorPage({ query={}, pageSize=20, cursorField='id', cursorValue=null, direction='desc', fields=[] }={}) {
377
+ const err = this._guard({ query });
378
+ if (err) return err;
379
+ // 游标字段与方向必须白名单校验,防 SQL 注入(排序字段会被直接拼进 ORDER BY)
380
+ if (!SORT_FIELD_REGEX.test(cursorField)) {
381
+ logger.warn(`[Repository] 非法游标字段:${cursorField}`);
382
+ return { success:false, code:CODE_PARAM_INVALID, msg:"非法排序字段", data:null };
383
+ }
384
+ const dir = String(direction).toLowerCase() === 'asc' ? 'asc' : 'desc';
385
+ const size = Math.min(Math.max(Number(pageSize) || 1, 1), this.limit);
386
+
387
+ let q = this._buildBaseQuery({ query, sort: { [cursorField]: dir }, fields });
388
+ // 游标定位:大于/小于上一页末尾的游标值,天然避免重复与跳跃
389
+ if (cursorValue !== null && cursorValue !== undefined && cursorValue !== "") {
390
+ q = dir === 'asc' ? q.where(cursorField, '>', cursorValue) : q.where(cursorField, '<', cursorValue);
391
+ }
392
+ const list = await q.limit(size);
393
+
394
+ const last = list.length ? list[list.length - 1] : null;
395
+ const nextCursor = last ? last[cursorField] : null;
396
+ // 恰好取满 size 条时无法确定是否还有更多(边界处需多取一条判断更严谨,这里退化为 hasMore=满页)
397
+ const hasMore = list.length === size;
398
+ return { success:true, code:CODE_OK, msg:"查询成功", data:{ list, nextCursor, hasMore, cursorField } };
399
+ }
400
+
313
401
  /** 统计符合条件记录行数 */
314
402
  async count(query={}) {
315
- this._checkDB();
316
- const guard = guardInvalidQuery(query);
317
- if (guard) return guard;
403
+ const err = this._guard({ query });
404
+ if (err) return err;
318
405
  const res = await applyQuery(this.db(this.tableName), query).count("* as total").first();
319
406
  return { success:true, code:CODE_OK, msg:"统计成功", data:{ count: Number(res?.total ?? 0) } };
320
407
  }
321
408
 
322
409
  /** 判断查询条件下记录是否存在 */
323
410
  async exists(query={}) {
324
- this._checkDB();
325
- const guard = guardInvalidQuery(query);
326
- if (guard) return guard;
411
+ const err = this._guard({ query });
412
+ if (err) return err;
327
413
  const row = await applyQuery(this.db(this.tableName), query).first();
328
414
  return { success:true, code:CODE_OK, msg:"检查成功", data:{ exists: !!row } };
329
415
  }
@@ -335,8 +421,13 @@ class Repository extends BaseComponent {
335
421
  logger.warn(`[Repository] 非法联表/字段:${joinTable}/${localField}/${foreignField}`);
336
422
  return { success: false, code: CODE_PARAM_INVALID, msg: "非法联表或字段", data: null };
337
423
  }
338
- const guard = guardInvalidQuery(query);
339
- if (guard) return guard;
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
+ }
429
+ const err = this._guard({ query });
430
+ if (err) return err;
340
431
  const select = fields.length
341
432
  ? fields.filter(f => SORT_FIELD_REGEX.test(f) || f === '*')
342
433
  : [`${this.tableName}.*`];
@@ -348,26 +439,32 @@ class Repository extends BaseComponent {
348
439
  return { success:true, code:CODE_OK, msg:"查询成功", data: await q };
349
440
  }
350
441
 
351
- /** 统计表总数量 + 今日新增数量 */
442
+ /** 统计表总数量 + 今日新增数量(今日列探测失败降级为 0,不阻断总数统计) */
352
443
  async stats() {
353
444
  this._checkDB();
354
445
  const createKey = this._dateFields.find(f => /created_at|createdAt/.test(f)) || "created_at";
355
- const today = new Date();
356
- today.setHours(0,0,0,0);
357
- const [totalRes, todayRes] = await Promise.all([
358
- this.db(this.tableName).count('* as count').first(),
359
- this.db(this.tableName).where(createKey, '>=', today).count('* as count').first()
360
- ]);
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
+ }
361
458
  return {
362
459
  success:true,
363
460
  code:CODE_OK,
364
461
  msg:"统计成功",
365
462
  data: {
366
463
  total: Number(totalRes?.count || 0),
367
- today: Number(todayRes?.count || 0)
464
+ today
368
465
  }
369
466
  };
370
467
  }
371
468
  }
372
469
 
373
- export default Repository;
470
+ export default Repository;
@@ -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,8 +1,8 @@
1
1
  {
2
2
  "type": "module",
3
3
  "name": "chanjs",
4
- "version": "2.7.12",
5
- "description": "chanjs基于express5 js研发的轻量级mvc框架。",
4
+ "version": "2.7.15",
5
+ "description": "chanjs基于 Node.js + Express 5 的标准 HMVC 框架(NHMVC),纯 JavaScript(ESM)开发。",
6
6
  "main": "index.js",
7
7
  "module": "index.js",
8
8
  "keywords": [
@@ -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