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 +157 -118
- package/core/Database.js +19 -5
- package/core/EventBus.js +12 -2
- package/core/Repository.js +130 -33
- 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 +10 -5
- 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/README.md
CHANGED
|
@@ -1,84 +1,102 @@
|
|
|
1
1
|
# ChanJS
|
|
2
2
|
|
|
3
|
-
基于 Node.js + Express 5
|
|
3
|
+
基于 Node.js + Express 5 构建的**标准 HMVC(NHMVC)后端框架**,原生 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
|
-
|
|
9
|
-
-
|
|
10
|
-
-
|
|
11
|
-
-
|
|
12
|
-
-
|
|
22
|
+
|
|
23
|
+
- 原生基于 Express 5+
|
|
24
|
+
- 最低运行环境 Node.js 22.18+
|
|
25
|
+
- 全量 ES Modules(import / export)
|
|
26
|
+
- **标准 HMVC 架构:模块自治 + 双通道跨模块协作**
|
|
27
|
+
- 约定优于配置,减少样板配置代码
|
|
13
28
|
|
|
14
29
|
### 基础设施
|
|
15
|
-
|
|
16
|
-
-
|
|
17
|
-
-
|
|
18
|
-
-
|
|
19
|
-
-
|
|
20
|
-
-
|
|
21
|
-
-
|
|
22
|
-
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
-
|
|
28
|
-
-
|
|
29
|
-
-
|
|
30
|
-
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
-
|
|
37
|
-
-
|
|
38
|
-
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
-
|
|
42
|
-
|
|
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
|
|
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 语言资源(zh‑CN/en‑US/...)
|
|
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
|
-
```
|
|
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. 日志系统(
|
|
110
|
+
### 1. 日志系统(Pino)
|
|
93
111
|
|
|
94
|
-
|
|
112
|
+
开发环境彩色控制台输出;生产环境输出 JSON 结构化日志,适配 PM2 采集。API 向下完全兼容。
|
|
95
113
|
|
|
96
|
-
```
|
|
114
|
+
```
|
|
97
115
|
import logger, { createLogger } from "chanjs";
|
|
98
116
|
|
|
99
|
-
//
|
|
117
|
+
// 全局日志实例
|
|
100
118
|
logger.info("启动完成");
|
|
101
|
-
logger.error("查询失败", err); //
|
|
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
|
|
126
|
+
请求日志由 `pino‑http` 自动接管,每个请求分配唯一 `requestId`,Controller 内可直接携带链路日志:
|
|
109
127
|
|
|
110
|
-
```
|
|
128
|
+
```
|
|
111
129
|
// 在 Controller 中
|
|
112
130
|
async getUser(req, res) {
|
|
113
|
-
req.log.info("查询用户详情"); //
|
|
114
|
-
// ...
|
|
131
|
+
req.log.info("查询用户详情"); // 日志自动附带 requestId
|
|
115
132
|
}
|
|
116
133
|
```
|
|
117
134
|
|
|
118
135
|
### 2. 事件总线(EventBus)
|
|
119
136
|
|
|
120
|
-
基于 Node
|
|
137
|
+
基于 Node.js 原生 EventEmitter 封装的全局单例事件中心,用于业务解耦。
|
|
121
138
|
|
|
122
|
-
```
|
|
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
|
-
>
|
|
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
|
|
158
|
+
基于 node‑cron 封装,内置 cron 表达式校验、任务异常捕获、优雅停机回收。
|
|
141
159
|
|
|
142
|
-
```
|
|
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
|
-
|
|
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
|
-
```
|
|
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
|
-
```
|
|
182
|
-
//
|
|
183
|
-
//
|
|
199
|
+
```
|
|
200
|
+
// 框架启动自动读取 config.db 注册数据库连接
|
|
201
|
+
// 业务代码内可通过 this.db / getApp().db 获取默认连接
|
|
184
202
|
|
|
185
|
-
//
|
|
186
|
-
chan.dbManager.setSlowThreshold(500);
|
|
203
|
+
// 运行时动态调整慢查询告警阈值(毫秒)
|
|
204
|
+
chan.dbManager.setSlowThreshold(500);
|
|
187
205
|
```
|
|
188
206
|
|
|
189
|
-
### 6.
|
|
207
|
+
### 6. 组件容器 · 双通道跨模块调用
|
|
190
208
|
|
|
191
|
-
Controller / Service
|
|
192
|
-
成功永久缓存;缺失不缓存(文件新增后立即感知)。
|
|
209
|
+
Controller / Service 继承容器基类,内置 `this.get()` 方法,通过「模块名 + 组件名」动态加载跨模块业务组件。
|
|
193
210
|
|
|
194
|
-
|
|
195
|
-
|
|
211
|
+
>
|
|
212
|
+
> 加载成功后实例永久缓存;组件文件缺失不缓存,新增文件无需重启即可识别。
|
|
213
|
+
|
|
214
|
+
```
|
|
215
|
+
// 同模块调用 Service
|
|
196
216
|
const cat = await this.get("book", "BookCategory");
|
|
197
217
|
|
|
198
|
-
//
|
|
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
|
-
-
|
|
204
|
-
-
|
|
205
|
-
-
|
|
206
|
-
- 跨模块协作优先用 `get`,避免手写 `../../` 相对路径 import。
|
|
223
|
+
- 返回实例或 null,异步调用必须 await
|
|
224
|
+
- 内置名称校验、路径越界安全防护
|
|
225
|
+
- 跨模块业务复用优先使用容器调用,摒弃超长相对路径 import
|
|
207
226
|
|
|
208
|
-
|
|
227
|
+
#### HMVC 标准实现说明
|
|
209
228
|
|
|
210
|
-
|
|
211
|
-
HMVC 的本质是**分层 + 模块自治 + 跨模块复用**。Chanjs 抓住本质,支持**双通道跨模块协作**:
|
|
229
|
+
传统教程常将「控制器嵌套发起子 HTTP 请求」当作 HMVC 的标准,该方式混淆了**实现手段**和架构本质。
|
|
212
230
|
|
|
213
|
-
|
|
214
|
-
|
|
231
|
+
>
|
|
232
|
+
> HMVC 的核心 = 分层解耦 + 模块自治 + 跨模块业务复用。
|
|
215
233
|
|
|
216
|
-
|
|
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
|
-
|
|
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
|
|
268
|
+
- pino‑http ^10.3.0
|
|
247
269
|
- i18next ^24.2.0
|
|
248
|
-
- node
|
|
249
|
-
- art
|
|
270
|
+
- node‑cron ^3.0.3
|
|
271
|
+
- art‑template ^4.13.4
|
|
250
272
|
- mysql2 ^3.22.3
|
|
251
273
|
- ioredis ^5.4.6
|
|
252
274
|
|
|
253
275
|
### 开发依赖
|
|
254
|
-
|
|
276
|
+
|
|
277
|
+
- pino‑pretty ^11.3.0
|
|
255
278
|
|
|
256
279
|
### 可选 Peer 依赖
|
|
257
|
-
- zod ^4.4.3(参数校验)
|
|
258
280
|
|
|
259
|
-
|
|
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.
|
|
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
|
@@ -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
|
|
152
|
-
if (
|
|
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
|
|
160
|
-
if (
|
|
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
|
|
170
|
-
if (
|
|
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
|
|
186
|
-
|
|
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
|
|
195
|
-
|
|
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.
|
|
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.
|
|
282
|
-
|
|
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.
|
|
316
|
-
|
|
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.
|
|
325
|
-
|
|
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
|
-
|
|
339
|
-
if (
|
|
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
|
|
356
|
-
today
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
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
|
|
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
|
-
|
|
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,8 +1,8 @@
|
|
|
1
1
|
{
|
|
2
2
|
"type": "module",
|
|
3
3
|
"name": "chanjs",
|
|
4
|
-
"version": "2.7.
|
|
5
|
-
"description": "chanjs基于
|
|
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"
|
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
|
|