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.
- package/README.md +261 -363
- package/config/index.js +4 -2
- package/core/App.js +35 -0
- package/core/Container.js +56 -29
- package/core/Database.js +58 -8
- package/core/EventBus.js +88 -0
- package/core/Lang.js +56 -0
- package/core/Repository.js +34 -2
- package/core/Task.js +87 -0
- package/core/errors.js +0 -5
- package/doc/00-README.md +208 -0
- package/doc/01-/346/240/270/345/277/203/347/261/273Controller-Service-Repository.md +432 -0
- package/doc/02-/345/223/215/345/272/224/344/270/216/351/224/231/350/257/257.md +255 -0
- package/doc/03-/345/256/211/345/205/250/346/250/241/345/235/227.md +264 -0
- package/doc/04-/345/255/230/345/202/250/344/270/216/347/274/223/345/255/230.md +157 -0
- package/doc/05-/345/267/245/345/205/267/344/270/216/346/240/241/351/252/214.md +309 -0
- package/doc/06-/345/272/224/347/224/250/347/224/237/345/221/275/345/221/250/346/234/237.md +207 -0
- package/doc/07-/344/272/213/344/273/266/347/263/273/347/273/237EventBus.md +324 -0
- package/doc/08-/345/256/232/346/227/266/344/273/273/345/212/241Task.md +262 -0
- package/doc/09-/345/233/275/351/231/205/345/214/226Lang.md +220 -0
- package/index.js +31 -2
- package/middleware/log.js +48 -31
- package/middleware/waf.js +4 -8
- package/package.json +21 -3
- package/response/code.js +0 -12
- package/response/response.js +8 -2
- package/security/keywords.js +2 -3
- package/utils/logger.js +60 -91
- package/utils/signal.js +21 -2
- package/USAGE.md +0 -533
- package/doc/Cache.md +0 -333
- package/doc/Common.md +0 -638
- package/doc/Controller.md +0 -223
- package/doc/Help.md +0 -390
- package/doc/QuickStart.md +0 -116
- package/doc/Repository.md +0 -560
- package/doc/Service.md +0 -240
- package/publish.bat +0 -4
- 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
|
|
2
|
-
import path from
|
|
3
|
-
import {
|
|
4
|
-
import {
|
|
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 =
|
|
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
|
-
*
|
|
24
|
-
*
|
|
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
|
-
|
|
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
|
-
|
|
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}
|
|
36
|
-
|
|
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
|
-
|
|
42
|
-
|
|
58
|
+
/** 手动清除指定组件缓存 */
|
|
59
|
+
invalidate(moduleName, fileName, type = this._type) {
|
|
60
|
+
this._componentCache.delete(`${type}.${moduleName}.${fileName}`);
|
|
61
|
+
}
|
|
43
62
|
|
|
44
|
-
|
|
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,
|
|
49
|
-
const
|
|
50
|
-
if (
|
|
51
|
-
throw new Error(
|
|
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
|
-
|
|
62
|
-
const
|
|
63
|
-
|
|
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
|
-
|
|
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
|
-
|
package/core/EventBus.js
ADDED
|
@@ -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;
|
package/core/Repository.js
CHANGED
|
@@ -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 = (
|
|
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 };
|