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.
Files changed (3) hide show
  1. package/README.md +157 -118
  2. package/core/Repository.js +91 -21
  3. package/package.json +2 -2
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
@@ -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 guard = guardInvalidQuery(query);
143
- if (guard) return guard;
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 guard = guardInvalidQuery(query);
151
- if (guard) return guard;
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 guard = guardInvalidQuery(query);
161
- if (guard) return guard;
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.db(this.tableName).where({id}).update(this.#formatDate(data));
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._checkDB();
273
- const guard = guardInvalidQuery(query);
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._checkDB();
307
- const guard = guardInvalidQuery(query);
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._checkDB();
316
- const guard = guardInvalidQuery(query);
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 guard = guardInvalidQuery(query);
330
- if (guard) return guard;
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.11",
5
- "description": "chanjs基于express5 js研发的轻量级mvc框架。",
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": [