chanjs 2.7.11 → 2.7.13
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/Repository.js +91 -21
- package/package.json +2 -2
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/Repository.js
CHANGED
|
@@ -10,6 +10,14 @@ import {
|
|
|
10
10
|
const SORT_FIELD_REGEX = /^[a-zA-Z_][a-zA-Z0-9_]*$/;
|
|
11
11
|
const OPERATORS = { $like:'like', $gt:'>', $gte:'>=', $lt:'<', $lte:'<=', $ne:'<>' };
|
|
12
12
|
|
|
13
|
+
/**
|
|
14
|
+
* 分页/分页相关保留字,不可能是合法的 WHERE 过滤列。
|
|
15
|
+
* 某些 controller 会把整个 req.query(含 pageSize/current/page)透传给 Repository.query,
|
|
16
|
+
* 若不加拦截会被 applyQuery 当成过滤字段拼进 SQL(如 `where pageSize = '20'`),
|
|
17
|
+
* 既报错又泄露分页语义。统一在此剥离,避免每个接口各自兜底。
|
|
18
|
+
*/
|
|
19
|
+
const PAGINATION_KEYS = new Set(['current', 'page', 'pageNum', 'pageSize', 'limit', 'offset']);
|
|
20
|
+
|
|
13
21
|
/** 查询条件是否存在至少一个合法字段 */
|
|
14
22
|
const hasValidQueryField = query =>
|
|
15
23
|
Object.keys(query).some(f => SORT_FIELD_REGEX.test(f));
|
|
@@ -34,6 +42,7 @@ const guardInvalidQuery = query => {
|
|
|
34
42
|
*/
|
|
35
43
|
function applyQuery(dbQuery, query) {
|
|
36
44
|
for (const [field, val] of Object.entries(query)) {
|
|
45
|
+
if (PAGINATION_KEYS.has(field)) continue; // 分页参数不是过滤列,直接跳过
|
|
37
46
|
if (!SORT_FIELD_REGEX.test(field)) {
|
|
38
47
|
logger.warn(`[Repository] 非法查询字段:${field}`);
|
|
39
48
|
continue;
|
|
@@ -88,6 +97,8 @@ function applySelectAndSort(q, fields, sort) {
|
|
|
88
97
|
/**
|
|
89
98
|
* 数据访问基类,封装通用CRUD操作
|
|
90
99
|
* 继承 BaseComponent 获得 app/config/db 快捷访问,不绑定组件加载职责
|
|
100
|
+
* 防御设计:所有入口统一经 _guard 校验「数据库可用性 + 查询条件合法性」,
|
|
101
|
+
* 再进入 SQL 构造层,避免各方法重复书写 `_checkDB + guardInvalidQuery` 样板。
|
|
91
102
|
*/
|
|
92
103
|
class Repository extends BaseComponent {
|
|
93
104
|
constructor(table=null, dbName=null, opts={}) {
|
|
@@ -116,6 +127,19 @@ class Repository extends BaseComponent {
|
|
|
116
127
|
if (!this.db) throw new Error("Database connection not available");
|
|
117
128
|
}
|
|
118
129
|
|
|
130
|
+
/**
|
|
131
|
+
* 统一防御守卫:数据库可用性 + 查询条件合法性(fail-closed)。
|
|
132
|
+
* 查询/统计/存在性等方法的入口统一先过此守卫,替代各方法开头的 `_checkDB()+guardInvalidQuery()` 重复样板。
|
|
133
|
+
* 空 query(如 {})合法放行;仅 db 不可用时直接抛异常,保证后端配置错误尽早暴露。
|
|
134
|
+
* @param {object} [opts={}]
|
|
135
|
+
* @param {object} [opts.query={}] 查询条件;为空对象或缺省时仅校验数据库连接
|
|
136
|
+
* @returns {null | {success:false,code:number,msg:string,data:object}} 校验通过返回 null,否则返回错误体(调用方应 return 该值)
|
|
137
|
+
*/
|
|
138
|
+
_guard({ query = {} } = {}) {
|
|
139
|
+
this._checkDB();
|
|
140
|
+
return guardInvalidQuery(query);
|
|
141
|
+
}
|
|
142
|
+
|
|
119
143
|
/** 统一构建查询实例(子类可重写以扩展过滤逻辑) */
|
|
120
144
|
_buildBaseQuery({ query = {}, sort = {}, fields = [] } = {}) {
|
|
121
145
|
this._checkDB();
|
|
@@ -139,16 +163,16 @@ class Repository extends BaseComponent {
|
|
|
139
163
|
|
|
140
164
|
/** 查询全部,默认上限1000条 */
|
|
141
165
|
async all({ query={}, sort={}, fields=[], limit=1000 }={}) {
|
|
142
|
-
const
|
|
143
|
-
if (
|
|
166
|
+
const err = this._guard({ query });
|
|
167
|
+
if (err) return err;
|
|
144
168
|
const list = await this._buildBaseQuery({query,sort,fields}).limit(limit);
|
|
145
169
|
return { success:true, code:CODE_OK, msg:"查询成功", data: list };
|
|
146
170
|
}
|
|
147
171
|
|
|
148
172
|
/** 分页偏移查询 */
|
|
149
173
|
async find({ query={}, sort={}, fields=[], limit, offset }={}) {
|
|
150
|
-
const
|
|
151
|
-
if (
|
|
174
|
+
const err = this._guard({ query });
|
|
175
|
+
if (err) return err;
|
|
152
176
|
let q = this._buildBaseQuery({query,sort,fields});
|
|
153
177
|
typeof offset === 'number' && (q = q.offset(offset));
|
|
154
178
|
typeof limit === 'number' && (q = q.limit(limit));
|
|
@@ -157,8 +181,8 @@ class Repository extends BaseComponent {
|
|
|
157
181
|
|
|
158
182
|
/** 查询单条记录 */
|
|
159
183
|
async findOne({ query={}, fields=[] }={}) {
|
|
160
|
-
const
|
|
161
|
-
if (
|
|
184
|
+
const err = this._guard({ query });
|
|
185
|
+
if (err) return err;
|
|
162
186
|
const row = await this._buildBaseQuery({query,fields}).first();
|
|
163
187
|
if (!row) return { success:false, code:CODE_NOT_FOUND, msg:"记录不存在", data:null };
|
|
164
188
|
return { success:true, code:CODE_OK, msg:"查询成功", data: row };
|
|
@@ -226,11 +250,10 @@ class Repository extends BaseComponent {
|
|
|
226
250
|
return { success:true, code:CODE_OK, msg:"更新成功", data:{ affectedRows:rows } };
|
|
227
251
|
}
|
|
228
252
|
|
|
229
|
-
/** 根据ID
|
|
253
|
+
/** 根据ID更新,返回更新后完整数据(复用 update 走统一 applyQuery 校验) */
|
|
230
254
|
async updateById(id, data={}) {
|
|
231
|
-
this._checkDB();
|
|
232
255
|
if (!id || !Object.keys(data).length) return { success:false, code:CODE_PARAM_INVALID, msg:"参数无效", data:{} };
|
|
233
|
-
await this.
|
|
256
|
+
await this.update({ query: { id }, data });
|
|
234
257
|
return this.findById(id);
|
|
235
258
|
}
|
|
236
259
|
|
|
@@ -269,9 +292,8 @@ class Repository extends BaseComponent {
|
|
|
269
292
|
* @returns {Promise<{success:boolean,code:number,msg:string,data:object}>}
|
|
270
293
|
*/
|
|
271
294
|
async _doPaginate({ current=1, pageSize=10, query={}, sort={}, field=[] }={}) {
|
|
272
|
-
this.
|
|
273
|
-
|
|
274
|
-
if (guard) return guard;
|
|
295
|
+
const err = this._guard({ query });
|
|
296
|
+
if (err) return err;
|
|
275
297
|
// 页码边界保护:current<=0 会生成负 offset 让 MySQL 直接语法报错
|
|
276
298
|
const currentPage = Math.max(1, Math.floor(Number(current) || 1));
|
|
277
299
|
const size = Math.min(Math.max(pageSize, 1), this.limit);
|
|
@@ -301,20 +323,68 @@ class Repository extends BaseComponent {
|
|
|
301
323
|
return this._doPaginate(params);
|
|
302
324
|
}
|
|
303
325
|
|
|
326
|
+
/**
|
|
327
|
+
* 基于游标的深分页查询(keyset pagination),替代 offset 深分页。
|
|
328
|
+
*
|
|
329
|
+
* 适用场景:大表(数万行+)且需翻到很后面的分页。
|
|
330
|
+
* offset 深分页痛点:`LIMIT 100000, 20` 需先扫描并丢弃前 10 万行,越深越慢;
|
|
331
|
+
* keyset 直接用 `WHERE id > 上一页最后一条的 id ORDER BY id LIMIT 20` 精准定位,与页码无关,常数级耗时。
|
|
332
|
+
*
|
|
333
|
+
* 返回体不依赖 offset/totalPages,浏览器端用「加载更多」或「下一页」+ nextCursor 驱动。
|
|
334
|
+
*
|
|
335
|
+
* @param {object} [params]
|
|
336
|
+
* @param {object} [params.query={}] 过滤条件(同 query())
|
|
337
|
+
* @param {number} [params.pageSize=20] 每页条数(受 this.limit 上限约束)
|
|
338
|
+
* @param {string} [params.cursorField='id'] 游标字段,须为稳定唯一且可排序的列(默认主键 id;也支持 created_at 等,但注意并列值会导致漏数据)
|
|
339
|
+
* @param {string|number|null} [params.cursorValue=null] 上一页最后一条的 cursorField 值;null 表示首页
|
|
340
|
+
* @param {'asc'|'desc'} [params.direction='desc'] 排序方向(取新纪录通常 desc)
|
|
341
|
+
* @param {string[]} [params.fields=[]] 白名单字段
|
|
342
|
+
* @returns {Promise<{success:boolean,code:number,msg:string,data:{list:Array,nextCursor:number|string|null,hasMore:boolean,cursorField:string}}>}
|
|
343
|
+
*
|
|
344
|
+
* @example
|
|
345
|
+
* // 首页(取最新 20 条)
|
|
346
|
+
* const p1 = await repo.cursorPage({ query:{cid:5}, pageSize:20, direction:'desc' });
|
|
347
|
+
* const nextCursor = p1.data.nextCursor;
|
|
348
|
+
* // 下一页(携带上一页末尾游标,常数级耗时,即使翻到第 1 万页也一样快)
|
|
349
|
+
* const p2 = await repo.cursorPage({ query:{cid:5}, pageSize:20, direction:'desc', cursorValue:nextCursor });
|
|
350
|
+
*/
|
|
351
|
+
async cursorPage({ query={}, pageSize=20, cursorField='id', cursorValue=null, direction='desc', fields=[] }={}) {
|
|
352
|
+
const err = this._guard({ query });
|
|
353
|
+
if (err) return err;
|
|
354
|
+
// 游标字段与方向必须白名单校验,防 SQL 注入(排序字段会被直接拼进 ORDER BY)
|
|
355
|
+
if (!SORT_FIELD_REGEX.test(cursorField)) {
|
|
356
|
+
logger.warn(`[Repository] 非法游标字段:${cursorField}`);
|
|
357
|
+
return { success:false, code:CODE_PARAM_INVALID, msg:"非法排序字段", data:null };
|
|
358
|
+
}
|
|
359
|
+
const dir = String(direction).toLowerCase() === 'asc' ? 'asc' : 'desc';
|
|
360
|
+
const size = Math.min(Math.max(Number(pageSize) || 1, 1), this.limit);
|
|
361
|
+
|
|
362
|
+
let q = this._buildBaseQuery({ query, sort: { [cursorField]: dir }, fields });
|
|
363
|
+
// 游标定位:大于/小于上一页末尾的游标值,天然避免重复与跳跃
|
|
364
|
+
if (cursorValue !== null && cursorValue !== undefined && cursorValue !== "") {
|
|
365
|
+
q = dir === 'asc' ? q.where(cursorField, '>', cursorValue) : q.where(cursorField, '<', cursorValue);
|
|
366
|
+
}
|
|
367
|
+
const list = await q.limit(size);
|
|
368
|
+
|
|
369
|
+
const last = list.length ? list[list.length - 1] : null;
|
|
370
|
+
const nextCursor = last ? last[cursorField] : null;
|
|
371
|
+
// 恰好取满 size 条时无法确定是否还有更多(边界处需多取一条判断更严谨,这里退化为 hasMore=满页)
|
|
372
|
+
const hasMore = list.length === size;
|
|
373
|
+
return { success:true, code:CODE_OK, msg:"查询成功", data:{ list, nextCursor, hasMore, cursorField } };
|
|
374
|
+
}
|
|
375
|
+
|
|
304
376
|
/** 统计符合条件记录行数 */
|
|
305
377
|
async count(query={}) {
|
|
306
|
-
this.
|
|
307
|
-
|
|
308
|
-
if (guard) return guard;
|
|
378
|
+
const err = this._guard({ query });
|
|
379
|
+
if (err) return err;
|
|
309
380
|
const res = await applyQuery(this.db(this.tableName), query).count("* as total").first();
|
|
310
381
|
return { success:true, code:CODE_OK, msg:"统计成功", data:{ count: Number(res?.total ?? 0) } };
|
|
311
382
|
}
|
|
312
383
|
|
|
313
384
|
/** 判断查询条件下记录是否存在 */
|
|
314
385
|
async exists(query={}) {
|
|
315
|
-
this.
|
|
316
|
-
|
|
317
|
-
if (guard) return guard;
|
|
386
|
+
const err = this._guard({ query });
|
|
387
|
+
if (err) return err;
|
|
318
388
|
const row = await applyQuery(this.db(this.tableName), query).first();
|
|
319
389
|
return { success:true, code:CODE_OK, msg:"检查成功", data:{ exists: !!row } };
|
|
320
390
|
}
|
|
@@ -326,8 +396,8 @@ class Repository extends BaseComponent {
|
|
|
326
396
|
logger.warn(`[Repository] 非法联表/字段:${joinTable}/${localField}/${foreignField}`);
|
|
327
397
|
return { success: false, code: CODE_PARAM_INVALID, msg: "非法联表或字段", data: null };
|
|
328
398
|
}
|
|
329
|
-
const
|
|
330
|
-
if (
|
|
399
|
+
const err = this._guard({ query });
|
|
400
|
+
if (err) return err;
|
|
331
401
|
const select = fields.length
|
|
332
402
|
? fields.filter(f => SORT_FIELD_REGEX.test(f) || f === '*')
|
|
333
403
|
: [`${this.tableName}.*`];
|
|
@@ -361,4 +431,4 @@ class Repository extends BaseComponent {
|
|
|
361
431
|
}
|
|
362
432
|
}
|
|
363
433
|
|
|
364
|
-
export default Repository;
|
|
434
|
+
export default Repository;
|
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.13",
|
|
5
|
+
"description": "chanjs基于 Node.js + Express 5 的标准 HMVC 框架(NHMVC),纯 JavaScript(ESM)开发。",
|
|
6
6
|
"main": "index.js",
|
|
7
7
|
"module": "index.js",
|
|
8
8
|
"keywords": [
|