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
package/core/App.js CHANGED
@@ -12,12 +12,22 @@ import { loadModuleRouter, loadCommonRouter } from "./bootstrap/router-loader.js
12
12
  import { registerErrorHandler } from "./bootstrap/error-handler.js";
13
13
  import { runHooks } from "./bootstrap/hook-runner.js";
14
14
  import { loadDotEnv } from "../config/index.js";
15
+ import { event } from "./EventBus.js";
16
+ import { initLang } from "./Lang.js";
17
+ import Task from "./Task.js";
15
18
 
16
19
  // 初始化环境变量
17
20
  loadDotEnv();
18
21
 
19
22
  /**
20
23
  * Chan 应用核心类
24
+ *
25
+ * 聚合框架基础设施:
26
+ * - app/router:Express 实例与路由
27
+ * - dbManager:多连接数据库管理
28
+ * - event:全局事件总线(EventBus 单例)
29
+ * - task:定时任务管理器
30
+ * - lang:i18next 实例(启动时初始化)
21
31
  */
22
32
  export default class Chan {
23
33
  constructor() {
@@ -29,6 +39,14 @@ export default class Chan {
29
39
  this.paths = Paths;
30
40
  this.db = null;
31
41
  this.hooks = [];
42
+
43
+ // 框架基础设施(全局共享)事件总线单例,任意模块 import { event } from "chanjs" 即同一实例
44
+ this.event = event;
45
+ // 定时任务管理器,停机时由 signal.js 调用 stopAll()
46
+ this.task = new Task();
47
+ // i18next 实例,启动流程中初始化
48
+ this.lang = null;
49
+ // HTTP 服务实例,启动后赋值
32
50
  this.server = null;
33
51
  }
34
52
 
@@ -37,6 +55,9 @@ export default class Chan {
37
55
  await this.#loadConfig();
38
56
  logger.info("[Chan] 配置加载完成");
39
57
 
58
+ await this.#initLang();
59
+ logger.info("[Chan] i18n 初始化完成");
60
+
40
61
  await this.#initStore();
41
62
  logger.info("[Chan] 缓存初始化完成");
42
63
 
@@ -94,6 +115,20 @@ export default class Chan {
94
115
  this.config = await loadConfig();
95
116
  }
96
117
 
118
+ /**
119
+ * 初始化 i18n
120
+ * - 默认语言:优先配置 LOCALE,否则回退 zh-CN
121
+ * - 失败不阻断启动,降级为仅支持默认语言
122
+ */
123
+ async #initLang() {
124
+ const locale = this.config?.LOCALE || "zh-CN";
125
+ try {
126
+ this.lang = await initLang(locale);
127
+ } catch (err) {
128
+ logger.warn(`[Chan] i18n 初始化失败,降级默认语言:${err.message}`);
129
+ }
130
+ }
131
+
97
132
  async #initStore() {
98
133
  const cfg = this.config;
99
134
  try {
package/core/Container.js CHANGED
@@ -1,7 +1,8 @@
1
- import fs from 'fs';
2
- import path from 'path';
3
- import { Paths } from '../utils/paths.js';
4
- import { BaseComponent } from './BaseComponent.js';
1
+ import fs from "fs";
2
+ import path from "path";
3
+ import { pathToFileURL } from "url";
4
+ import { Paths } from "../utils/paths.js";
5
+ import { BaseComponent } from "./BaseComponent.js";
5
6
  import logger from "../utils/logger.js";
6
7
 
7
8
  const NAME_REGEX = /^[a-zA-Z][a-zA-Z0-9_-]*$/;
@@ -11,7 +12,7 @@ const NAME_REGEX = /^[a-zA-Z][a-zA-Z0-9_-]*$/;
11
12
  * 继承 BaseComponent 获得 app/config/db 访问,额外提供组件加载能力
12
13
  */
13
14
  export class Container extends BaseComponent {
14
- constructor(type = 'service') {
15
+ constructor(type = "service") {
15
16
  super();
16
17
  this._componentCache = new Map();
17
18
  this._type = type;
@@ -19,49 +20,75 @@ export class Container extends BaseComponent {
19
20
  }
20
21
 
21
22
  /**
22
- * 按需获取组件实例,自动缓存
23
- * @param {string} moduleName 模块名
24
- * @param {string} fileName 组件文件名(不含 .js)
23
+ * 按需获取组件实例
24
+ * - 成功:永久缓存(进程生命周期内不失效)
25
+ * - 缺失:不缓存,每次实时探测,文件新增后立即感知
26
+ * - 跨模块/跨类型:默认取本容器类型(controller/service),可传 type 覆盖,
27
+ * 例如 Controller 里取其他模块的 Service:await this.get("book", "Book", "service")
28
+ * @param {string} moduleName - 模块名
29
+ * @param {string} fileName - 组件文件名(不含 .js)
30
+ * @param {("controller"|"service")} [type=this._type] - 组件类型,默认本容器类型
31
+ * @returns {Promise<object|null>}
25
32
  */
26
- async get(moduleName, fileName) {
27
- const cacheKey = `${moduleName}.${fileName}`;
28
- if (this._componentCache.has(cacheKey)) return this._componentCache.get(cacheKey);
33
+ async get(moduleName, fileName, type = this._type) {
34
+ const cacheKey = `${type}.${moduleName}.${fileName}`;
35
+ const hit = this._componentCache.get(cacheKey);
29
36
 
37
+ // 命中成功缓存,直接返回
38
+ if (hit) return hit;
39
+
40
+ // 未命中,实时加载
41
+ let instance;
30
42
  try {
31
- const instance = await this._loadFile(moduleName, fileName);
32
- if (!instance) logger.info(`[Container] ${moduleName}/${this._type}/${fileName}.js 导出实例为空`);
33
- return instance;
43
+ instance = await this._loadFile(moduleName, fileName, type);
34
44
  } catch (err) {
35
- logger.error(`[Container] 加载组件失败 ${moduleName}/${fileName}:${err.message}`);
36
- return null;
45
+ logger.error(`[Container] 加载组件失败 ${moduleName}/${fileName}: ${err.message}`);
46
+ instance = null;
47
+ }
48
+
49
+ if (instance) {
50
+ // 成功才缓存,永久有效
51
+ this._componentCache.set(cacheKey, instance);
52
+ } else {
53
+ logger.info(`[Container] ${moduleName}/${type}/${fileName}.js 不存在`);
37
54
  }
55
+ return instance;
38
56
  }
39
57
 
40
- /** 物理加载文件、导入模块、安全校验 */
41
- async _loadFile(moduleName, fileName) {
42
- const cacheKey = `${moduleName}.${fileName}`;
58
+ /** 手动清除指定组件缓存 */
59
+ invalidate(moduleName, fileName, type = this._type) {
60
+ this._componentCache.delete(`${type}.${moduleName}.${fileName}`);
61
+ }
43
62
 
44
- if (!NAME_REGEX.test(moduleName) || !NAME_REGEX.test(fileName)) {
63
+ /** 清除全部缓存 */
64
+ invalidateAll() {
65
+ this._componentCache.clear();
66
+ }
67
+
68
+ /**
69
+ * 物理加载文件、导入模块、安全校验
70
+ * @private
71
+ */
72
+ async _loadFile(moduleName, fileName, type = this._type) {
73
+ if (!NAME_REGEX.test(moduleName) || !NAME_REGEX.test(fileName) || !NAME_REGEX.test(type)) {
45
74
  throw new Error(`非法名称 ${moduleName}/${fileName}`);
46
75
  }
47
76
 
48
- const filePath = path.resolve(this._baseDir, moduleName, this._type, `${fileName}.js`);
49
- const relativePath = path.relative(this._baseDir, filePath);
50
- if (relativePath.startsWith('..') || path.isAbsolute(relativePath)) {
51
- throw new Error(`路径越界拦截:${filePath}`);
77
+ const filePath = path.resolve(this._baseDir, moduleName, type, `${fileName}.js`);
78
+ const rel = path.relative(this._baseDir, filePath);
79
+ if (rel.startsWith("..") || path.isAbsolute(rel)) {
80
+ throw new Error(`路径越界: ${filePath}`);
52
81
  }
53
82
 
54
83
  try {
55
84
  await fs.promises.access(filePath, fs.constants.R_OK);
56
85
  } catch {
57
- logger.info(`[Container] 文件不存在:${moduleName}/${this._type}/${fileName}.js`);
58
86
  return null;
59
87
  }
60
88
 
61
- const mod = await import(`file://${filePath}`);
62
- const instance = mod.default;
63
- this._componentCache.set(cacheKey, instance);
64
- return instance;
89
+ // 统一 pathToFileURL(与 loader.js 一致,Windows/跨盘安全)
90
+ const mod = await import(pathToFileURL(filePath).href);
91
+ return mod.default;
65
92
  }
66
93
  }
67
94
 
package/core/Database.js CHANGED
@@ -2,36 +2,87 @@ import knex from "knex";
2
2
  import logger from "../utils/logger.js";
3
3
 
4
4
  const DEFAULT_NAME = "default";
5
+ const DEFAULT_SLOW_THRESHOLD = 200; // 慢查询阈值(ms),可运行时调整
5
6
 
6
7
  /**
7
- * 多Knex连接管理器,统一管理连接、心跳、批量销毁
8
+ * 多 Knex 连接管理器:统一管理连接、心跳、批量销毁、慢查询监控。
9
+ *
10
+ * - 连接注册:首个连接或 isDefault=true 自动设为默认
11
+ * - 健康缓存:error-handler 实时更新,鉴权层廉价区分「未登录」与「库挂了」
12
+ * - 慢查询:监听 knex query 事件,超过阈值记 warn 日志(含 SQL 与耗时)
13
+ * - 错误监控:监听 query-error 事件,记录失败 SQL 便于排查
8
14
  */
9
15
  class DatabaseManager {
10
16
  constructor() {
17
+ /** @type {Map<string, import("knex").Knex>} */
11
18
  this._connections = new Map();
12
19
  this._defaultName = DEFAULT_NAME;
13
- // 健康缓存:运行时由 error-handler 实时更新,用于鉴权层廉价区分「用户未登录」与「库挂了」
20
+ /** 健康缓存:运行时由 error-handler 实时更新 */
14
21
  this._health = new Map();
22
+ /** 慢查询阈值(ms),超过则记 warn 日志 */
23
+ this._slowThreshold = DEFAULT_SLOW_THRESHOLD;
24
+ }
25
+
26
+ /**
27
+ * 设置慢查询阈值(运行时动态调整)
28
+ * @param {number} ms - 阈值毫秒数
29
+ */
30
+ setSlowThreshold(ms) {
31
+ if (typeof ms === "number" && ms > 0) this._slowThreshold = ms;
15
32
  }
16
33
 
17
34
  /**
18
35
  * 添加数据库连接,首个连接 / isDefault=true 自动设为默认
19
- * @param {string} name 连接标识
20
- * @param {object} config Knex配置
21
- * @param {{isDefault?: boolean}} opts
22
- * @returns {knex.Knex}
36
+ * @param {string} name - 连接标识
37
+ * @param {object} config - Knex 配置
38
+ * @param {{isDefault?: boolean}} [opts]
39
+ * @returns {import("knex").Knex}
23
40
  */
24
41
  add(name, config, { isDefault = false } = {}) {
25
42
  const conn = knex(config);
26
43
  this._connections.set(name, conn);
27
44
  if (isDefault || this._connections.size === 1) this._defaultName = name;
45
+
46
+ // 挂载慢查询与错误监控(按连接隔离,互不影响)
47
+ this._attachMonitoring(name, conn);
28
48
  return conn;
29
49
  }
30
50
 
51
+ /**
52
+ * 挂载 knex 事件监控
53
+ * - query: 记录 SQL 执行起点
54
+ * - query-response: 计算耗时,超阈值记慢查询日志
55
+ * - query-error: 记录失败 SQL 与错误信息
56
+ * @private
57
+ */
58
+ _attachMonitoring(name, conn) {
59
+ const startTimes = new Map();
60
+
61
+ conn.on("query", (data) => {
62
+ startTimes.set(data.__knexUid, Date.now());
63
+ });
64
+
65
+ conn.on("query-response", (_response, data) => {
66
+ const startedAt = startTimes.get(data.__knexUid);
67
+ if (!startedAt) return;
68
+ startTimes.delete(data.__knexUid);
69
+
70
+ const elapsed = Date.now() - startedAt;
71
+ if (elapsed >= this._slowThreshold) {
72
+ logger.warn(`[Database] 慢查询 ${name} ${elapsed}ms: ${data.sql}`);
73
+ }
74
+ });
75
+
76
+ conn.on("query-error", (err, data) => {
77
+ startTimes.delete(data?.__knexUid);
78
+ logger.error(`[Database] 查询失败 ${name}: ${data?.sql || ""}`, err);
79
+ });
80
+ }
81
+
31
82
  /**
32
83
  * 获取指定连接,不存在抛异常
33
84
  * @param {string} [name=this._defaultName]
34
- * @returns {knex.Knex}
85
+ * @returns {import("knex").Knex}
35
86
  */
36
87
  get(name = this._defaultName) {
37
88
  const conn = this._connections.get(name);
@@ -90,4 +141,3 @@ class DatabaseManager {
90
141
  }
91
142
 
92
143
  export default DatabaseManager;
93
-
@@ -0,0 +1,88 @@
1
+ import { EventEmitter } from "events";
2
+
3
+ /**
4
+ * 事件总线:基于 Node 内置 EventEmitter 的轻量封装。
5
+ *
6
+ * 全局实例 event 供中间件/定时任务/工具函数使用;
7
+ * App 实例 this.event 指向同一全局实例;
8
+ * 需要隔离时 new EventBus() 创建独立实例。
9
+ *
10
+ * @example
11
+ * import { event } from "chanjs";
12
+ * const off = event.on("user.login", (uid) => { ... });
13
+ * event.emit("user.login", 1001);
14
+ * off(); // 取消监听
15
+ */
16
+ export class EventBus {
17
+ constructor() {
18
+ this._emitter = new EventEmitter();
19
+ this._emitter.setMaxListeners(50);
20
+ }
21
+
22
+ /**
23
+ * 注册监听器,返回取消函数
24
+ * @param {string} event - 事件名
25
+ * @param {(...args: any[]) => void} listener - 监听回调
26
+ * @returns {() => void} 取消监听函数
27
+ */
28
+ on(event, listener) {
29
+ this._emitter.on(event, listener);
30
+ return () => this._emitter.off(event, listener);
31
+ }
32
+
33
+ /**
34
+ * 只监听一次,触发后自动移除
35
+ * @param {string} event - 事件名
36
+ * @param {(...args: any[]) => void} listener - 监听回调
37
+ */
38
+ once(event, listener) {
39
+ this._emitter.once(event, listener);
40
+ }
41
+
42
+ /**
43
+ * 触发事件(同步执行全部监听器)
44
+ * @param {string} event - 事件名
45
+ * @param {...any} args - 传递给监听器的参数
46
+ */
47
+ emit(event, ...args) {
48
+ this._emitter.emit(event, ...args);
49
+ }
50
+
51
+ /**
52
+ * 移除指定监听器
53
+ * @param {string} event - 事件名
54
+ * @param {Function} listener - 要移除的回调引用
55
+ */
56
+ off(event, listener) {
57
+ this._emitter.off(event, listener);
58
+ }
59
+
60
+ /**
61
+ * 移除某事件的全部监听器
62
+ * @param {string} event - 事件名
63
+ */
64
+ removeAll(event) {
65
+ this._emitter.removeAllListeners(event);
66
+ }
67
+
68
+ /**
69
+ * 获取某事件的监听器数量
70
+ * @param {string} event - 事件名
71
+ * @returns {number}
72
+ */
73
+ count(event) {
74
+ return this._emitter.listenerCount(event);
75
+ }
76
+
77
+ /**
78
+ * 移除所有事件的全部监听器(优雅停机时调用,防内存泄漏)
79
+ */
80
+ destroy() {
81
+ this._emitter.removeAllListeners();
82
+ }
83
+ }
84
+
85
+ /** 全局共享实例:任何模块 import { event } from "chanjs" 即可用 */
86
+ export const event = new EventBus();
87
+
88
+ export default event;
package/core/Lang.js ADDED
@@ -0,0 +1,56 @@
1
+ import fs from "fs";
2
+ import path from "path";
3
+ import i18next from "i18next";
4
+ import { Paths } from "../utils/paths.js";
5
+
6
+ const FALLBACK = "zh-CN";
7
+ const LOCALE_REGEX = /^[a-zA-Z_-]+$/;
8
+
9
+ /**
10
+ * 初始化 i18n(基于 i18next)
11
+ *
12
+ * 启动时扫描 lang/ 目录,加载全部 locale 的 JSON 资源到内存。
13
+ * 运行时通过 i18next.t("key") 查找,O(1) 复杂度,零 I/O 开销。
14
+ *
15
+ * @param {string} [locale="zh-CN"] - 默认语言
16
+ * @returns {Promise<typeof i18next>} i18next 实例
17
+ *
18
+ * @example
19
+ * // lang/zh-CN/common.json: { "user.welcome": "欢迎,{{name}}" }
20
+ * i18next.t("user.welcome", { name: "张三" }); // → "欢迎,张三"
21
+ */
22
+ export async function initLang(locale = FALLBACK) {
23
+ const langDir = path.join(Paths.rootPath, "lang");
24
+ /** @type {Record<string, Record<string, object>>} resources[locale][namespace] */
25
+ const resources = {};
26
+
27
+ if (fs.existsSync(langDir)) {
28
+ for (const loc of fs.readdirSync(langDir)) {
29
+ if (!LOCALE_REGEX.test(loc)) continue;
30
+ const locDir = path.join(langDir, loc);
31
+ if (!fs.statSync(locDir).isDirectory()) continue;
32
+
33
+ resources[loc] = {};
34
+ for (const file of fs.readdirSync(locDir).filter(f => f.endsWith(".json"))) {
35
+ const ns = file.replace(".json", "");
36
+ try {
37
+ resources[loc][ns] = JSON.parse(fs.readFileSync(path.join(locDir, file), "utf-8"));
38
+ } catch {
39
+ // 单文件解析失败跳过,不阻断启动
40
+ }
41
+ }
42
+ }
43
+ }
44
+
45
+ await i18next.init({
46
+ lng: locale,
47
+ fallbackLng: FALLBACK,
48
+ resources,
49
+ defaultNS: "common",
50
+ interpolation: { escapeValue: false }, // 后端不转义
51
+ });
52
+
53
+ return i18next;
54
+ }
55
+
56
+ export default initLang;
@@ -10,6 +10,22 @@ 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
+ const hasValidQueryField = query =>
15
+ Object.keys(query).some(f => SORT_FIELD_REGEX.test(f));
16
+
17
+ /**
18
+ * 查询条件全部非法时 fail-closed 拒绝(与 del/update 防御一致),
19
+ * 避免 applyQuery 静默丢弃全部字段后退化成「无 WHERE 全表查询」导致数据泄露。
20
+ * @returns {null | {success:false,code:number,msg:string,data:{}}} 校验通过返回 null,否则返回错误体
21
+ */
22
+ const guardInvalidQuery = query => {
23
+ if (query && Object.keys(query).length && !hasValidQueryField(query)) {
24
+ return { success: false, code: CODE_PARAM_INVALID, msg: "查询条件非法", data: {} };
25
+ }
26
+ return null;
27
+ };
28
+
13
29
  /**
14
30
  * 拼接查询条件,支持操作符/等值/数组in
15
31
  * @param {import('knex').Knex.QueryBuilder} dbQuery
@@ -123,12 +139,16 @@ class Repository extends BaseComponent {
123
139
 
124
140
  /** 查询全部,默认上限1000条 */
125
141
  async all({ query={}, sort={}, fields=[], limit=1000 }={}) {
142
+ const guard = guardInvalidQuery(query);
143
+ if (guard) return guard;
126
144
  const list = await this._buildBaseQuery({query,sort,fields}).limit(limit);
127
145
  return { success:true, code:CODE_OK, msg:"查询成功", data: list };
128
146
  }
129
147
 
130
148
  /** 分页偏移查询 */
131
149
  async find({ query={}, sort={}, fields=[], limit, offset }={}) {
150
+ const guard = guardInvalidQuery(query);
151
+ if (guard) return guard;
132
152
  let q = this._buildBaseQuery({query,sort,fields});
133
153
  typeof offset === 'number' && (q = q.offset(offset));
134
154
  typeof limit === 'number' && (q = q.limit(limit));
@@ -137,6 +157,8 @@ class Repository extends BaseComponent {
137
157
 
138
158
  /** 查询单条记录 */
139
159
  async findOne({ query={}, fields=[] }={}) {
160
+ const guard = guardInvalidQuery(query);
161
+ if (guard) return guard;
140
162
  const row = await this._buildBaseQuery({query,fields}).first();
141
163
  if (!row) return { success:false, code:CODE_NOT_FOUND, msg:"记录不存在", data:null };
142
164
  return { success:true, code:CODE_OK, msg:"查询成功", data: row };
@@ -248,8 +270,12 @@ class Repository extends BaseComponent {
248
270
  */
249
271
  async _doPaginate({ current=1, pageSize=10, query={}, sort={}, field=[] }={}) {
250
272
  this._checkDB();
273
+ const guard = guardInvalidQuery(query);
274
+ if (guard) return guard;
275
+ // 页码边界保护:current<=0 会生成负 offset 让 MySQL 直接语法报错
276
+ const currentPage = Math.max(1, Math.floor(Number(current) || 1));
251
277
  const size = Math.min(Math.max(pageSize, 1), this.limit);
252
- const offset = (current - 1) * size;
278
+ const offset = (currentPage - 1) * size;
253
279
  const countQ = applyQuery(this.db(this.tableName), query);
254
280
  const [totalRow, list] = await Promise.all([
255
281
  countQ.count("* as total").first(),
@@ -263,7 +289,7 @@ class Repository extends BaseComponent {
263
289
  data: {
264
290
  list,
265
291
  total,
266
- current,
292
+ current: currentPage,
267
293
  pageSize: size,
268
294
  totalPages: Math.ceil(total / size)
269
295
  }
@@ -278,6 +304,8 @@ class Repository extends BaseComponent {
278
304
  /** 统计符合条件记录行数 */
279
305
  async count(query={}) {
280
306
  this._checkDB();
307
+ const guard = guardInvalidQuery(query);
308
+ if (guard) return guard;
281
309
  const res = await applyQuery(this.db(this.tableName), query).count("* as total").first();
282
310
  return { success:true, code:CODE_OK, msg:"统计成功", data:{ count: Number(res?.total ?? 0) } };
283
311
  }
@@ -285,6 +313,8 @@ class Repository extends BaseComponent {
285
313
  /** 判断查询条件下记录是否存在 */
286
314
  async exists(query={}) {
287
315
  this._checkDB();
316
+ const guard = guardInvalidQuery(query);
317
+ if (guard) return guard;
288
318
  const row = await applyQuery(this.db(this.tableName), query).first();
289
319
  return { success:true, code:CODE_OK, msg:"检查成功", data:{ exists: !!row } };
290
320
  }
@@ -296,6 +326,8 @@ class Repository extends BaseComponent {
296
326
  logger.warn(`[Repository] 非法联表/字段:${joinTable}/${localField}/${foreignField}`);
297
327
  return { success: false, code: CODE_PARAM_INVALID, msg: "非法联表或字段", data: null };
298
328
  }
329
+ const guard = guardInvalidQuery(query);
330
+ if (guard) return guard;
299
331
  const select = fields.length
300
332
  ? fields.filter(f => SORT_FIELD_REGEX.test(f) || f === '*')
301
333
  : [`${this.tableName}.*`];
package/core/Task.js ADDED
@@ -0,0 +1,87 @@
1
+ import cron from "node-cron";
2
+ import logger from "../utils/logger.js";
3
+
4
+ const NAME_REGEX = /^[a-zA-Z0-9_-]{1,50}$/;
5
+
6
+ /**
7
+ * 定时任务管理器:基于 node-cron 封装。
8
+ *
9
+ * - cron 表达式校验,非法表达式跳过注册并告警,不阻断应用启动
10
+ * - 回调异常自动捕获并记录日志,不会导致进程崩溃
11
+ * - 优雅停机时调用 stopAll() 清理全部定时器
12
+ *
13
+ * @example
14
+ * chan.task.add("clear-log", "0 3 * * *", async () => {
15
+ * await chan.db.raw("DELETE FROM logs WHERE created_at < NOW() - INTERVAL 7 DAY");
16
+ * });
17
+ */
18
+ export class Task {
19
+ constructor() {
20
+ /** @type {Map<string, import("node-cron").ScheduledTask>} */
21
+ this._tasks = new Map();
22
+ }
23
+
24
+ /**
25
+ * 注册定时任务
26
+ *
27
+ * 校验失败(任务名非法 / cron 表达式非法 / 任务已存在 / 回调非函数)
28
+ * 仅打印 error 日志并返回 null,不抛异常、不阻断应用启动。
29
+ *
30
+ * @param {string} name - 任务名(唯一标识)
31
+ * @param {string} expr - cron 表达式(5 段,如 "0 3 * * *")
32
+ * @param {(task: Task) => Promise<void>|void} fn - 任务回调
33
+ * @param {{ start?: boolean, timezone?: string }} [opts] - 选项
34
+ * @returns {import("node-cron").ScheduledTask|null} 任务实例,校验失败返回 null
35
+ */
36
+ add(name, expr, fn, { start = true, timezone } = {}) {
37
+ if (!NAME_REGEX.test(name)) {
38
+ logger.error(`[Task] 注册失败,非法任务名: ${name},已跳过`);
39
+ return null;
40
+ }
41
+ if (!cron.validate(expr)) {
42
+ logger.error(`[Task] 注册失败,任务 ${name} 非法 cron 表达式: ${expr},已跳过`);
43
+ return null;
44
+ }
45
+ if (this._tasks.has(name)) {
46
+ logger.error(`[Task] 注册失败,任务已存在: ${name},已跳过`);
47
+ return null;
48
+ }
49
+ if (typeof fn !== "function") {
50
+ logger.error(`[Task] 注册失败,任务 ${name} 回调必须为函数,已跳过`);
51
+ return null;
52
+ }
53
+
54
+ const task = cron.schedule(expr, async () => {
55
+ const t0 = Date.now();
56
+ try {
57
+ await fn(this);
58
+ logger.info(`[Task] ${name} 完成 (${Date.now() - t0}ms)`);
59
+ } catch (err) {
60
+ // 捕获异常防进程崩溃,记录日志供排查
61
+ logger.error(`[Task] ${name} 异常: ${err.message}`, err);
62
+ }
63
+ }, { timezone });
64
+
65
+ if (!start) task.stop();
66
+ this._tasks.set(name, task);
67
+ return task;
68
+ }
69
+
70
+ /** 启动指定任务 */
71
+ start(name) { this._tasks.get(name)?.start(); }
72
+
73
+ /** 停止指定任务 */
74
+ stop(name) { this._tasks.get(name)?.stop(); }
75
+
76
+ /** 获取全部任务名列表 */
77
+ list() { return [...this._tasks.keys()]; }
78
+
79
+ /** 优雅停机:停止全部任务并清空注册表 */
80
+ stopAll() {
81
+ for (const task of this._tasks.values()) task.stop();
82
+ this._tasks.clear();
83
+ logger.info("[Task] 全部定时任务已停止");
84
+ }
85
+ }
86
+
87
+ export default Task;
package/core/errors.js CHANGED
@@ -45,11 +45,6 @@ export class AppError extends Error {
45
45
  if (Error.captureStackTrace) Error.captureStackTrace(this, this.constructor);
46
46
  }
47
47
 
48
- /** 统一判断是否为业务错误实例(推荐使用独立导出函数 isAppError) */
49
- static isAppError(err) {
50
- return err instanceof AppError;
51
- }
52
-
53
48
  /** 自定义序列化 */
54
49
  toJSON() {
55
50
  const json = { name: this.name, message: this.message, code: this.code, httpStatus: this.httpStatus };