chanjs 2.7.7 → 2.7.10

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 (39) hide show
  1. package/README.md +261 -363
  2. package/config/index.js +4 -2
  3. package/core/App.js +35 -0
  4. package/core/Container.js +56 -29
  5. package/core/Database.js +58 -8
  6. package/core/EventBus.js +88 -0
  7. package/core/Lang.js +56 -0
  8. package/core/Repository.js +34 -2
  9. package/core/Task.js +87 -0
  10. package/core/errors.js +0 -5
  11. package/doc/00-README.md +208 -0
  12. package/doc/01-/346/240/270/345/277/203/347/261/273Controller-Service-Repository.md +432 -0
  13. package/doc/02-/345/223/215/345/272/224/344/270/216/351/224/231/350/257/257.md +255 -0
  14. package/doc/03-/345/256/211/345/205/250/346/250/241/345/235/227.md +264 -0
  15. package/doc/04-/345/255/230/345/202/250/344/270/216/347/274/223/345/255/230.md +157 -0
  16. package/doc/05-/345/267/245/345/205/267/344/270/216/346/240/241/351/252/214.md +309 -0
  17. package/doc/06-/345/272/224/347/224/250/347/224/237/345/221/275/345/221/250/346/234/237.md +207 -0
  18. package/doc/07-/344/272/213/344/273/266/347/263/273/347/273/237EventBus.md +324 -0
  19. package/doc/08-/345/256/232/346/227/266/344/273/273/345/212/241Task.md +262 -0
  20. package/doc/09-/345/233/275/351/231/205/345/214/226Lang.md +220 -0
  21. package/index.js +31 -2
  22. package/middleware/log.js +48 -31
  23. package/middleware/waf.js +4 -8
  24. package/package.json +21 -3
  25. package/response/code.js +0 -12
  26. package/response/response.js +8 -2
  27. package/security/keywords.js +2 -3
  28. package/utils/logger.js +60 -91
  29. package/utils/signal.js +21 -2
  30. package/USAGE.md +0 -533
  31. package/doc/Cache.md +0 -333
  32. package/doc/Common.md +0 -638
  33. package/doc/Controller.md +0 -223
  34. package/doc/Help.md +0 -390
  35. package/doc/QuickStart.md +0 -116
  36. package/doc/Repository.md +0 -560
  37. package/doc/Service.md +0 -240
  38. package/publish.bat +0 -4
  39. package/todo.md +0 -1
@@ -0,0 +1,309 @@
1
+ # 05 · 工具与校验:logger / createLogger / Paths / 文件安全 / validate / utils 工具集
2
+
3
+ 日常开发最常用的小工具。导出方式分两类:
4
+ - **根包直接导出**:`logger`(默认导出)、`createLogger`、`Paths`、`validate`、`validateAll`。
5
+ - **`utils` 命名空间导出**:`import { getIp, request, tree, ... } from "chanjs/utils/index.js"`,
6
+ 或集中用 `import { utils } from "chanjs"` 后 `utils.getIp(...)`。
7
+
8
+ ---
9
+
10
+ ## 一、日志 `logger` / `createLogger`
11
+
12
+ 文件:`utils/logger.js`。统一日志,**dev 彩色控制台、prd JSON 单行**(方便日志采集)。
13
+
14
+ ### 1.1 默认实例 `logger`
15
+
16
+ ```js
17
+ import logger from "chanjs"; // 注意是默认导出
18
+
19
+ logger.info("服务启动");
20
+ logger.warn("缓存未命中");
21
+ logger.error("数据库连接失败", err); // 第二个参数传 Error 会自动解析堆栈
22
+ logger.debug("调试信息", { userId: 1 });
23
+ ```
24
+
25
+ | 方法 | 级别 | 说明 |
26
+ |---|---|---|
27
+ | `trace` | 0 | 最细 |
28
+ | `debug` | 1 | 调试(dev 默认开启)|
29
+ | `info` | 2 | 常规信息(prd 默认开启)|
30
+ | `warn` | 3 | 警告 |
31
+ | `error` | 4 | 错误(自动走 stderr)|
32
+ | `fatal` | 5 | 致命 |
33
+
34
+ **级别控制**:环境变量 `LOG_LEVEL`(dev 默认 `debug`,prd 默认 `info`)。低于该级别的日志不输出。
35
+ **输出目标**:基于 pino,dev 走 pino-pretty 彩色控制台;prd 输出纯 JSON 到 stdout,由 pm2 统一捕获落盘(框架不直接写日志文件)。
36
+
37
+ ### 1.2 自定义标签实例 `createLogger(name)`
38
+
39
+ 框架内部统一用默认 `logger`(标签 `Chan`)。**业务代码建议用 `createLogger` 自建带标签实例**,方便区分来源:
40
+
41
+ ```js
42
+ import { createLogger } from "chanjs";
43
+
44
+ const log = createLogger("Member"); // 标签 Member
45
+ log.info("用户登录", { userId: 1 });
46
+ // 输出(pino-pretty 彩色):[2026-08-01 12:00:00.123 INFO]: 用户登录 {"userId":1}
47
+ ```
48
+
49
+ > 📌 约定:框架内部统一 `import logger`,业务侧用 `createLogger("模块名")` 区分来源。dev 环境下 `module` 字段已被 pino-pretty 隐藏以精简输出(prod 仍保留便于聚合)。
50
+
51
+ ### 1.3 传 Error 自动解析
52
+
53
+ ```js
54
+ try { /* ... */ }
55
+ catch (err) {
56
+ logger.error("操作失败", err); // 自动输出 message + 堆栈文件:行号
57
+ }
58
+ ```
59
+
60
+ ---
61
+
62
+ ## 二、路径 `Paths`
63
+
64
+ ```js
65
+ import { Paths } from "chanjs";
66
+
67
+ Paths.rootPath; // 项目根目录(process.cwd())
68
+ Paths.appPath; // app
69
+ Paths.configPath; // config
70
+ Paths.publicPath; // public
71
+ Paths.modulesPath; // app/modules
72
+ Paths.commonPath; // app/common
73
+ Paths.helperPath; // app/helper
74
+ Paths.extendPath; // app/extend
75
+ ```
76
+
77
+ > 框架在 `Chan` 构造时已注入 `this.paths = Paths`,业务里通过 `this.app.paths` 访问,避免到处拼相对路径。
78
+ > 各路径均为 getter 动态计算(基于 `rootPath`),改 `rootPath` 会同步生效。
79
+
80
+ ---
81
+
82
+ ## 三、文件安全 `safePath`(防路径穿越)
83
+
84
+ 文件:`utils/file.js`。**仅通过子模块深导入使用**:`import { safePath } from "chanjs/utils/file.js"`。
85
+ (它未挂在 `utils` 命名空间里,需直接指向子模块文件。)
86
+
87
+ ```js
88
+ import { safePath } from "chanjs/utils/file.js";
89
+
90
+ const safe = safePath(userInputPath); // 防御目录穿越(../),合法返回绝对路径,非法返回 null
91
+ ```
92
+
93
+ | 方法 | 说明 |
94
+ |---|---|
95
+ | `safePath(input)` | 以项目根目录为基准规范化路径,阻断 `../` 穿越;合法返回绝对路径,非法(越界/绝对路径)返回 `null` |
96
+
97
+ > 文件上传/在线编辑(如 ChanCMS 的 `vip` 模块)任何由用户输入拼出来的文件路径,都必须过 `safePath`,
98
+ > 否则用户可读取 `/etc/passwd` 等任意文件。**复用框架提供的安全函数,不要自己拼路径。**
99
+
100
+ ---
101
+
102
+ ## 四、参数校验 `validate` / `validateAll`(基于 zod)
103
+
104
+ 文件:`middleware/validate.js`。用 **zod** 做声明式校验,校验通过的参数统一挂到 `req.validated`。
105
+ (校验失败自动抛 `ValidationError`,由全局错误处理器返回,你**不用手写 if 判断**。)
106
+
107
+ ### 4.1 `validate(schema, source="body")` —— 校验单一来源
108
+
109
+ ```js
110
+ import { validate } from "chanjs";
111
+ import { z } from "zod";
112
+
113
+ const createRule = z.object({
114
+ title: z.string().min(1, "标题必填"),
115
+ content: z.string().min(5, "内容至少5字"),
116
+ status: z.number().int().optional(),
117
+ });
118
+
119
+ router.post("/article/create", validate(createRule), ctrl.create.bind(ctrl));
120
+ ```
121
+
122
+ 在 Controller 里取校验后的值:
123
+
124
+ ```js
125
+ async create(req, res) {
126
+ const data = req.validated; // 已通过校验、且被类型转换过的数据
127
+ }
128
+ ```
129
+
130
+ | 参数 | 类型 | 说明 |
131
+ |---|---|---|
132
+ | `schema` | zod schema | 校验规则 |
133
+ | `source` | `"body"\|"query"\|"params"` | 校验来源,默认 `body` |
134
+
135
+ > ⚠️ Express 5 的 `req.query` 是只读 getter,框架内部用 `defineProperty` 处理过,
136
+ > 统一用 `req.validated` 最稳(不要用 `req.query` 读校验后的值)。
137
+
138
+ ### 4.2 `validateAll(schemas)` —— 多来源一起校验
139
+
140
+ ```js
141
+ const listRule = {
142
+ query: z.object({ current: z.coerce.number().int().min(1).default(1),
143
+ pageSize: z.coerce.number().int().max(100).default(10) }),
144
+ params: z.object({ cateId: z.coerce.number() }),
145
+ };
146
+
147
+ router.get("/cate/:cateId", validateAll(listRule), ctrl.list.bind(ctrl));
148
+
149
+ // Controller 里:
150
+ async list(req, res) {
151
+ const { query, params } = req.validated; // { query:{current,pageSize}, params:{cateId} }
152
+ }
153
+ ```
154
+
155
+ > `validateAll` 会把 `body/query/params` 各自校验后聚合成 `req.validated = { body, query, params }`,
156
+ > **不会用单一来源覆盖其他来源**(旧版有此 bug,已修复)。
157
+
158
+ ### 4.3 常用 zod 写法速查
159
+
160
+ ```js
161
+ import { z } from "zod";
162
+
163
+ z.string().min(1) // 非空字符串
164
+ z.string().email() // 邮箱
165
+ z.number().int().positive() // 正整数
166
+ z.coerce.number() // 把字符串"5"转成数字 5(表单常用)
167
+ z.boolean().optional() // 可选布尔
168
+ z.array(z.string()) // 字符串数组
169
+ z.object({ a: z.string() }) // 嵌套对象
170
+ z.enum(["on","off"]) // 枚举
171
+ ```
172
+
173
+ ---
174
+
175
+ ## 五、`utils` 工具集(全部方法)
176
+
177
+ 以下全部可从 `chanjs/utils/index.js` 具名导入,或经 `import { utils } from "chanjs"` 后通过 `utils.xxx` 调用(除 `safePath` 见第三节)。
178
+
179
+ ### 5.1 IP 与网络
180
+
181
+ | 函数 | 签名 | 返回 | 说明 |
182
+ |---|---|---|---|
183
+ | `getIp` | `getIp(req)` | string | 取客户端真实 IP(Express 专用)。优先 `cf-connecting-ip`(需 `CF_ENABLED=true` 且带 `cf-ray`),其次 `req.ip`(已融合代理链),兜底 `0.0.0.0`。自动处理 IPv6 映射/IPv6 回环 |
184
+ | `request` | `request(url, options?)` | Promise<{success,data,error?}> | 带超时/重试/SSRF 防护/大小限制的 HTTP 请求封装。`options`: `timeout`(默认 10000)、`retry`(默认 0)、`parseJson`(默认 true)、`responseType:"arraybuffer"`、`maxResponseBytes`(默认 10MB)。拦截私网/回环地址(防 SSRF)|
185
+
186
+ ```js
187
+ import { utils } from "chanjs";
188
+
189
+ const ip = utils.getIp(req);
190
+ const { success, data, error } = await utils.request("https://api.example.com/x", { timeout: 8000, retry: 2 });
191
+ ```
192
+
193
+ ### 5.2 时间格式化
194
+
195
+ | 函数 | 签名 | 返回 | 说明 |
196
+ |---|---|---|---|
197
+ | `formatTime` | `formatTime(timestamp, format="YYYY-MM-DD HH:mm:ss")` | string | 时间戳/日期串/Date 格式化为字符串。非法时间原样返回 |
198
+ | `formatDateFields` | `formatDateFields(data, fields)` | 原类型 | 批量格式化对象/数组里指定日期字段(`fields` 数组)。非日期字段值不改动 |
199
+
200
+ ```js
201
+ import { formatTime, formatDateFields } from "chanjs/utils/index.js";
202
+
203
+ formatTime(Date.now()); // "2026-08-13 17:30:00"
204
+ formatDateFields(row, ["created_at","updated_at"]);
205
+ ```
206
+
207
+ ### 5.3 树形结构
208
+
209
+ | 函数 | 签名 | 返回 | 说明 |
210
+ |---|---|---|---|
211
+ | `tree` | `tree(list, rootPid=0, {maxDepth=20,idKey="id",pidKey="pid"})` | Array | 扁平数组转树(预建 Map 优化);节点自带 `children`/`level`,无孩子则删 `children`。含循环引用/深度保护 |
212
+ | `treeById` | `treeById(targetId, list, {maxDepth=20,idKey="id",pidKey="pid"})` | Array | 按节点 ID 向上追溯完整父级路径 `[根...父...目标]` |
213
+
214
+ ```js
215
+ const treeData = tree(categoryList, 0, { idKey: "id", pidKey: "pid" });
216
+ const path = treeById(25, categoryList); // [根分类, ..., 目标分类]
217
+ ```
218
+
219
+ ### 5.4 数据解析
220
+
221
+ | 函数 | 签名 | 返回 | 说明 |
222
+ |---|---|---|---|
223
+ | `arrToObj` | `arrToObj(arr, keyField="config_key", valueField="config_value")` | object | 对象数组转键值映射(如配置列表)。入参非数组返回 `{}` |
224
+ | `getChildrenId` | `getChildrenId(py, source)` | `{ cate, id }` | 按拼音/ID 在分类数组中匹配,返回 `{ cate, id }`(找不到 `id` 为 `""`)|
225
+ | `filterFields` | `filterFields(data, fields)` | Array | 从对象数组筛选指定字段,返回全新数组(不改动原数据)。`data` 非数组返回 `[]` |
226
+
227
+ ```js
228
+ const map = arrToObj(configList); // { site_name: "xxx", ... }
229
+ const picked = filterFields(rows, ["id","title"]); // 只保留两列
230
+ ```
231
+
232
+ ### 5.5 HTML 处理
233
+
234
+ | 函数 | 签名 | 返回 | 说明 |
235
+ |---|---|---|---|
236
+ | `htmlEncode` | `htmlEncode(str)` | string | 标准 HTML 实体编码(`& < > " '`),适配 art-template `{{ }}` |
237
+ | `htmlDecode` | `htmlDecode(str)` | string | HTML 实体解码(命名/十进制/十六进制)|
238
+ | `escapeScript` | `escapeScript(str)` | string | 危险标签(script/iframe/object/embed/form)二次转义,适配 art-template `{{@ }}` 防 XSS |
239
+ | `filterImgFromStr` | `filterImgFromStr(str)` | string[] | 从 HTML 源码批量提取全部 `img` 的 `src` 列表 |
240
+
241
+ ```js
242
+ import { htmlEncode, escapeScript, filterImgFromStr } from "chanjs/utils/index.js";
243
+
244
+ const safe = htmlEncode(userInput);
245
+ const imgs = filterImgFromStr(articleHtml); // ["/uploads/a.jpg", ...]
246
+ ```
247
+
248
+ ### 5.6 文件操作(`chanjs/utils/file.js`)
249
+
250
+ 除 `safePath`(第三节)外,文件工具均通过子模块导入:
251
+
252
+ | 函数 | 签名 | 返回 | 说明 |
253
+ |---|---|---|---|
254
+ | `delImg` | `delImg(filePath)` | boolean | 删除相对根目录的文件(经 `safePath` 防穿越);不存在返回 `false` |
255
+ | `readFileContent` | `readFileContent(filePath)` | string | 读文本文件内容(utf8);非法路径/不存在/非文件抛异常 |
256
+ | `saveFileContent` | `saveFileContent(filePath, content)` | void | 写/覆写文件,目录自动递归创建;非法路径抛异常 |
257
+ | `getFolders` | `getFolders(folderPath)` | string[] | 取目录下一级文件夹名(过滤隐藏目录)|
258
+ | `getHtmlFilesSync` | `getHtmlFilesSync(folderPath)` | string[] | 同步取目录下一级全部 `.html` 文件名 |
259
+
260
+ ```js
261
+ import { delImg, readFileContent, saveFileContent, getFolders, getHtmlFilesSync } from "chanjs/utils/file.js";
262
+
263
+ const htmls = getHtmlFilesSync("public/themes/default");
264
+ saveFileContent("public/x.txt", "hello"); // 自动建目录
265
+ ```
266
+
267
+ ### 5.7 分页 HTML `pages`
268
+
269
+ | 函数 | 签名 | 返回 | 说明 |
270
+ |---|---|---|---|
271
+ | `pages` | `pages(current, total, pageSize, href, query="")` | string | 生成 Tailwind 风格分页 `<li>` 片段(不含外层 `<ul>`)。`totalPage<=1` 返回空串 |
272
+
273
+ ```js
274
+ import { pages } from "chanjs/utils/index.js";
275
+
276
+ const html = `<ul>${pages(page, total, 10, "/news/", "")}</ul>`;
277
+ // 链接形如 /news/1.html /news/2.html ...
278
+ ```
279
+
280
+ ---
281
+
282
+ ## 六、工具模块速查
283
+
284
+ | 导出 | 类型 | 一句话用途 |
285
+ |---|---|---|
286
+ | `logger`(默认) | 实例 | 框架级日志(标签 Chan)|
287
+ | `createLogger(name)` | 函数 | 业务自建带标签日志 |
288
+ | `Paths` | 对象 | 项目目录常量(root/app/config/public/modules/common/helper/extend)|
289
+ | `safePath` | 函数 | 防路径穿越(深导入 `utils/file.js`)|
290
+ | `validate` | 函数 | 单来源 zod 校验中间件 |
291
+ | `validateAll` | 函数 | 多来源 zod 校验中间件 |
292
+ | `getIp` / `request` | 函数 | 取真实 IP / 防 SSRF 的 HTTP 请求 |
293
+ | `formatTime` / `formatDateFields` | 函数 | 时间格式化 |
294
+ | `tree` / `treeById` | 函数 | 扁平数组转树 / 向上追溯路径 |
295
+ | `arrToObj` / `getChildrenId` / `filterFields` | 函数 | 数据解析 |
296
+ | `htmlEncode` / `htmlDecode` / `escapeScript` / `filterImgFromStr` | 函数 | HTML 编解码/取图 |
297
+ | `delImg` / `readFileContent` / `saveFileContent` / `getFolders` / `getHtmlFilesSync` | 函数 | 文件操作(深导入 `utils/file.js`)|
298
+ | `pages` | 函数 | 分页 HTML 片段 |
299
+
300
+ ---
301
+
302
+ ## 七、常见坑
303
+
304
+ 1. **`logger` 是默认导出**:`import logger from "chanjs"`(无花括号);`createLogger` 才是具名导出。
305
+ 2. **忘了 `await store` 但 `cache` 同步**:`store` 异步要 `await`,`cache` 同步不要 `await`。
306
+ 3. **校验后读 `req.body` 而非 `req.validated`**:Express 5 下 `req.query` 只读,框架已用 `req.validated` 统一承载,优先用它。
307
+ 4. **zod 数字不 coerce**:表单传来的是字符串,`z.number()` 会失败,记得 `z.coerce.number()`。
308
+ 5. **`safePath` 不是根导出也不是 utils 成员**:必须用 `import { safePath } from "chanjs/utils/file.js"` 深导入;任何由用户输入拼出来的文件路径都得过它。
309
+ 6. **`utils` 里没有 `pathGuard`**:那是旧文档臆造的,本框架没有该中间件,路径安全请用 `safePath`。
@@ -0,0 +1,207 @@
1
+ # 06 · 应用生命周期与启动:Chan 类 / beforeStart / loader / registry / dbManager
2
+
3
+ 想理解"程序从启动到监听端口"全过程,或要写启动钩子、访问全局实例,看这篇。
4
+
5
+ ---
6
+
7
+ ## 一、`Chan` 应用核心类
8
+
9
+ 文件:`core/App.js`。整个应用的入口对象。你的 `app.js` 大致长这样:
10
+
11
+ ```js
12
+ import Chan from "chanjs"; // 默认导出是 Chan 类
13
+
14
+ const app = new Chan();
15
+
16
+ app.beforeStart(async () => { // 启动前钩子
17
+ console.log("准备就绪");
18
+ });
19
+
20
+ app.start() // 异步:自动加载 配置/DB/路由/中间件/错误处理器
21
+ .then(() => app.run(port => console.log("监听", port)))
22
+ .catch(err => { console.error(err); process.exit(1); });
23
+ ```
24
+
25
+ ### 1.1 构造与实例属性
26
+
27
+ ```js
28
+ const app = new Chan();
29
+ app.app; // express 实例
30
+ app.router; // 顶层 express.Router()
31
+ app.dbManager; // 数据库管理器(多连接,见 1.4)
32
+ app.db; // 默认数据库连接(knex 实例),#loadDb 成功后赋值
33
+ app.config; // 配置对象(#loadConfig 后)
34
+ app.paths; // Paths 实例
35
+ app.hooks; // 启动钩子数组
36
+ app.server; // http.Server 实例(run 后)
37
+ app.event; // 事件总线单例(EventBus,详见 07)
38
+ app.task; // 定时任务管理器(Task,详见 08)
39
+ app.lang; // i18n 实例(i18next,详见 09)
40
+ ```
41
+
42
+ ### 1.2 关键方法
43
+
44
+ | 方法 | 签名 | 说明 |
45
+ |---|---|---|
46
+ | `start()` | `async start()` | 完整启动流程(见 1.3)|
47
+ | `run(cb?)` | `run((port)=>void)` | 启动 HTTP 监听,默认端口 `config.PORT \|\| 3000`;监听成功后调用 `cb(port)` |
48
+ | `beforeStart(fn)` | `beforeStart(fn)` | 注册启动前钩子(fn 必须是函数,否则抛 `AppError("PARAM_INVALID")`)|
49
+ | `shutdown()` | `async shutdown()` | 优雅停机:关闭 server → `task.stopAll()` → `store.close()` → `dbManager.closeAll()` → `event.destroy()` → `setApp(null)` |
50
+
51
+ > ⚠️ `beforeStart(fn)` 的参数**必须是函数**:框架会 `if (typeof fn !== "function") throw new AppError("PARAM_INVALID", "...", 400)`。
52
+ > 写 `app.beforeStart(myInit())`(带括号)会传入"函数执行结果"而非函数本身,触发报错。正确:`app.beforeStart(myInit)`。
53
+
54
+ ### 1.3 `start()` 内部顺序(理解即可)
55
+
56
+ ```mermaid
57
+ graph TD
58
+ A["start()"] --> B["loadConfig 加载配置"]
59
+ B --> C["initLang i18n 初始化"]
60
+ C --> D["initStore 初始化存储 Redis/内存"]
61
+ D --> E["loadDb 连接数据库 + ping 校验"]
62
+ E --> F["setApp(this) 注册全局实例"]
63
+ F --> G["registerCoreMiddleware 核心中间件"]
64
+ G --> H["setupApp trust proxy 配置"]
65
+ H --> I["loadModuleRouter + loadCommonRouter 加载路由"]
66
+ I --> J["mountRouter 挂载顶层 router"]
67
+ J --> K["registerErrorHandler 全局错误处理器"]
68
+ K --> L["runHooks 执行 beforeStart 钩子"]
69
+ L --> M["run() 监听端口"]
70
+ ```
71
+
72
+ > 关键顺序:**先 `setApp` 注册全局实例**,之后业务组件的 `this.app` 才能拿到值。
73
+ > 数据库采用**宽松策略**:连接失败时仅**打印 error 日志并继续启动**(不会中断进程、也不会自动重连),
74
+ > 这样开发期能先起服务。但 `this.db` 可能为 `null`,业务里访问前请用 `if (!this.db)` 兜底或确保配置正确。
75
+
76
+ ---
77
+
78
+ ## 二、`beforeStart` 启动钩子
79
+
80
+ 适合放:建表检测、初始化管理员、预热缓存、外部服务探活等"启动时必须完成"的逻辑。
81
+
82
+ ```js
83
+ app.beforeStart(async () => {
84
+ // 例:确保默认管理员存在
85
+ const exist = await adminRepo.exists({ username: "admin" });
86
+ if (!exist.data.exists) {
87
+ await adminRepo.insert({ username: "admin", role: "super" });
88
+ }
89
+ });
90
+ ```
91
+
92
+ - 多个 `beforeStart` 按注册顺序串行执行。
93
+ - 抛错会中断启动(进程退出),所以钩子里要做必要容错。
94
+
95
+ ---
96
+
97
+ ## 三、`loader` —— 配置与组件加载
98
+
99
+ 文件:`core/loader.js`。框架在 `start()` 内部调用,业务一般不直接用,但了解有帮助:
100
+
101
+ ```js
102
+ import { loader } from "chanjs";
103
+
104
+ const config = await loader.loadConfig(); // 读取 config/index.js 合并成配置对象
105
+ ```
106
+
107
+ `loader` 命名空间提供:
108
+
109
+ | 方法 | 说明 |
110
+ |---|---|
111
+ | `loadController(name)` | 加载某模块全部 Controller(自动 bind 实例方法防 this 丢失),返回 `{ 控制器名: 实例 }` |
112
+ | `loadConfig()` | 加载合并配置(读 `config/index.js`)|
113
+ | `importFile(filepath)` | 动态导入单个文件(成功返回模块 default 或模块本身,失败/IO 异常返回 `null`)|
114
+ | `loaderSort(modules)` | 模块排序(`web` 强制后置)|
115
+
116
+ > 多数业务场景你直接 `new MyService()` / `new MyController()` 即可,不必走 loader。
117
+ > loader 主要在框架自动装配或插件化场景使用。
118
+
119
+ ---
120
+
121
+ ## 四、`registry` —— 全局实例注册表
122
+
123
+ 文件:`core/registry.js`。从"多实例注册表"简化为**单例**(模块级 `_current`)。
124
+
125
+ ```js
126
+ import { setApp, getApp } from "chanjs";
127
+
128
+ setApp(appInstance); // start() 内部已调用
129
+ const app = getApp(); // 任意地方拿到全局 Chan 实例
130
+ ```
131
+
132
+ `BaseComponent` 的 `this.app` 本质上就是 `getApp()`。所以你在 Service/Repository/Controller 里
133
+ `this.app.db`、`this.app.config` 能直接用,是因为 `start()` 阶段已经 `setApp(this)`。
134
+
135
+ > 注意:当前为单例实现,不要期望同时持有多个 App 实例。
136
+
137
+ ---
138
+
139
+ ## 五、`dbManager` —— 多数据库连接管理器
140
+
141
+ 文件:`core/Database.js`。`app.dbManager` 是 `DatabaseManager` 实例,统一管理多 Knex 连接、心跳、慢查询监控。
142
+
143
+ | 方法 | 签名 | 返回 | 说明 |
144
+ |---|---|---|---|
145
+ | `add` | `add(name, config, { isDefault=false })` | Knex | 注册一个连接;首个或非默认但 `isDefault=true` 自动设为默认库 |
146
+ | `get` | `get(name=默认)` | Knex | 取指定连接;不存在**抛异常** |
147
+ | `ping` | `async ping(name=默认)` | boolean | `SELECT 1` 心跳校验,更新健康缓存;启动时及运行时按需调用 |
148
+ | `closeAll` | `async closeAll()` | `Array<{name,success,error?}>` | 批量销毁全部连接,单个失败不阻断其它 |
149
+ | `setSlowThreshold` | `setSlowThreshold(ms)` | void | 运行时调整慢查询阈值(传入 >0 才生效)|
150
+ | `markDown` | `markDown(name=默认)` | void | 运行时主动标记连接不可用(error-handler 捕获 DB 异常时调用)|
151
+ | `markUp` | `markUp(name=默认)` | void | 运行时标记连接恢复可用(error-handler 捕获成功响应时调用)|
152
+
153
+ **慢查询监控**:默认阈值 `SLOW_THRESHOLD = 200`(ms,来自 `config/index.js`),超过则该条 SQL 记 `warn` 日志(含连接名、耗时、SQL);查询失败记 `error`(含失败 SQL)。按连接隔离,多库互不影响。
154
+
155
+ ```js
156
+ // 业务里运行时调大阈值(如某库大查询较多,避免误报)
157
+ app.dbManager.setSlowThreshold(500);
158
+
159
+ // 取指定库连接(对应 Repository 构造第二参 dbName)
160
+ const otherDb = app.dbManager.get("logs");
161
+ ```
162
+
163
+ ---
164
+
165
+ ## 六、配置对象 `config` 里有什么
166
+
167
+ `app.config`(即 `loadConfig()` 的结果)常见字段:
168
+
169
+ | 字段 | 说明 |
170
+ |---|---|
171
+ | `PORT` | HTTP 监听端口(默认 3000)|
172
+ | `db` | 数据库连接配置数组(支持多库,每项含 `key`/`client`/`connection` 等)|
173
+ | `REDIS_ENABLED` / `REDIS` | Redis 配置 |
174
+ | `JWT_SECRET` | JWT 签名密钥 |
175
+ | `LOCALE` | i18n 默认语言(默认 `zh-CN`)|
176
+ | `LIMIT_MAX` | Repository 单页上限(默认 300)|
177
+ | `SLOW_THRESHOLD` | 数据库慢查询阈值(ms,默认 200),超过记 warn 日志 |
178
+ | `SHUTDOWN_TIMEOUT` | 优雅停机强制退出超时(ms,默认 5000)|
179
+ | `BODY_LIMIT` | 请求体上限(默认 `10mb`)|
180
+ | `HOOK_TIMEOUT` | 单条 beforeStart 钩子超时阈值(ms,默认 10000)|
181
+ | 其它业务自定义项 | 如站点域名、上传方式等 |
182
+
183
+ ```js
184
+ // 在业务里取
185
+ this.config.JWT_SECRET;
186
+ this.config.PORT;
187
+ ```
188
+
189
+ ---
190
+
191
+ ## 七、优雅停机
192
+
193
+ 进程收到 `SIGINT` / `SIGTERM` / `SIGQUIT` 时,框架(`utils/signal.js`)自动调用 `shutdown()`:
194
+ 停止接收新请求 → `task.stopAll()` 停止定时任务 → `store.close()` 关缓存 → `dbManager.closeAll()` 关库 →
195
+ `event.destroy()` 清监听器 → `setApp(null)`。整个流程带 `SHUTDOWN_TIMEOUT` 超时强退保护。
196
+ 你一般无需手动处理,除非有自定义资源(如 WebSocket)要清理,可在 `beforeStart` 里监听信号或注册自己的清理逻辑。
197
+
198
+ ---
199
+
200
+ ## 八、常见坑
201
+
202
+ 1. **`beforeStart` 传了调用结果而非函数**:`app.beforeStart(init())` ❌ → `app.beforeStart(init)` ✅。
203
+ 2. **在 `start()` 前用 `this.app.db`**:`db` 在 `#loadDb` 之后才有值,启动钩子/路由处理函数里才安全。
204
+ 3. **忘记 `await app.start()`**:`start()` 是异步的,必须 `.then` 或 `await` 后再 `run()`。
205
+ 4. **硬编码端口**:用 `config.PORT`,别写死 3000,方便多环境部署。
206
+ 5. **以为数据库连不上会中断启动**:框架是"打印错误并继续",不会自动重连也不会中止;修 `.env` 的 DB 配置即可。
207
+ 6. **`dbManager.get(name)` 不存在会抛异常**:取非默认库前确认 `config.db` 里配了该 `key`。