chanjs 2.7.2 → 2.7.4
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/App.js +232 -16
- package/base/Aop.js +20 -3
- package/base/Container.js +80 -3
- package/base/Controller.js +38 -9
- package/base/Database.js +50 -0
- package/base/Event.js +12 -0
- package/base/{Service.js → Repository.js} +644 -539
- package/common/api.js +18 -8
- package/common/code.js +25 -15
- package/common/email.js +98 -17
- package/common/index.js +1 -1
- package/config/code.js +138 -82
- package/global/index.js +1 -1
- package/helper/index.js +43 -41
- package/index.js +19 -6
- package/loader/index.js +6 -0
- package/{helper → loader}/loader.js +41 -27
- package/middleware/compress.js +185 -0
- package/middleware/cors.js +36 -24
- package/middleware/header.js +5 -10
- package/middleware/index.js +1 -0
- package/middleware/log.js +27 -3
- package/middleware/setBody.js +9 -1
- package/middleware/static.js +2 -1
- package/middleware/template.js +139 -4
- package/middleware/waf.js +136 -76
- package/package.json +4 -4
- package/realtime/index.js +7 -0
- package/realtime/sse.js +424 -0
- package/realtime/websocket.js +540 -0
- package/response/index.js +12 -0
- package/response/response.js +258 -0
- package/schedule/index.js +6 -0
- package/schedule/schedule.js +491 -0
- package/{helper → security}/checker.js +23 -8
- package/security/index.js +14 -0
- package/{helper → security}/jwt.js +175 -107
- package/security/keywords.js +179 -0
- package/security/rate-limit.js +105 -0
- package/security/sign.js +210 -0
- package/security/xss-filter.js +63 -0
- package/storage/cache.js +258 -0
- package/storage/index.js +9 -0
- package/storage/redis.js +258 -0
- package/storage/store.js +266 -0
- package/{helper → utils}/file.js +106 -15
- package/{helper → utils}/filter.js +2 -1
- package/{helper → utils}/html.js +19 -1
- package/utils/index.js +34 -0
- package/{helper → utils}/ip.js +25 -16
- package/utils/request.js +172 -0
- package/{helper → utils}/time.js +1 -1
- package/utils/tree.js +121 -0
- package/common/category.js +0 -22
- package/common/sms.js +0 -104
- package/extend/art-template.js +0 -129
- package/extend/index.js +0 -6
- package/global/global.js +0 -63
- package/helper/cache.js +0 -187
- package/helper/keywords.js +0 -132
- package/helper/rate-limit.js +0 -116
- package/helper/request.js +0 -47
- package/helper/response.js +0 -180
- package/helper/sign.js +0 -96
- package/helper/tree.js +0 -77
- package/helper/xss-filter.js +0 -42
- /package/{helper → utils}/data-parse.js +0 -0
package/App.js
CHANGED
|
@@ -7,9 +7,13 @@ import DatabaseManager from "./base/Database.js";
|
|
|
7
7
|
|
|
8
8
|
// 引入配置和工具
|
|
9
9
|
import { Paths } from "./config/index.js";
|
|
10
|
-
import { loadConfig, loaderSort, loadController } from "./
|
|
11
|
-
import { Cors, setBody, setCookie, setFavicon, setHeader, setStatic, setTemplate, waf, log } from "./middleware/index.js";
|
|
12
|
-
import { notFoundResponse, parseDatabaseError, errorResponse } from "./
|
|
10
|
+
import { loadConfig, loaderSort, loadController } from "./loader/index.js";
|
|
11
|
+
import { Cors, setBody, setCookie, setFavicon, setHeader, setStatic, setTemplate, waf, compress, log } from "./middleware/index.js";
|
|
12
|
+
import { notFoundResponse, parseDatabaseError, errorResponse } from "./response/index.js";
|
|
13
|
+
import { store } from "./storage/index.js";
|
|
14
|
+
import { schedule } from "./schedule/schedule.js";
|
|
15
|
+
import { sse } from "./realtime/sse.js";
|
|
16
|
+
import { websocket } from "./realtime/websocket.js";
|
|
13
17
|
import { importFile } from "./global/import.js";
|
|
14
18
|
|
|
15
19
|
import "./global/index.js";
|
|
@@ -47,6 +51,10 @@ class Chan {
|
|
|
47
51
|
async start() {
|
|
48
52
|
//加载配置
|
|
49
53
|
await this.config();
|
|
54
|
+
//暴露 Paths 到 Chan 静态属性(DI:业务侧通过 Chan.paths 或 this.paths 访问)
|
|
55
|
+
Chan.paths = Paths;
|
|
56
|
+
//初始化 Redis 适配器(WAF 封禁 / Rate Limit 共享存储)
|
|
57
|
+
await this.loadRedis();
|
|
50
58
|
//加载数据库
|
|
51
59
|
await this.loadDB();
|
|
52
60
|
//加载扩展方法
|
|
@@ -59,8 +67,140 @@ class Chan {
|
|
|
59
67
|
await this.loadRouter();
|
|
60
68
|
await this.loadCommonRouter();
|
|
61
69
|
this.useRouter();
|
|
62
|
-
//404 500
|
|
70
|
+
//404 500 处理(放在最后,确保所有路由都已注册)
|
|
63
71
|
this.setErrorHandler();
|
|
72
|
+
//执行 beforeStart 钩子(用户自定义的初始化逻辑)
|
|
73
|
+
await this._runBeforeStart();
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* 注册 beforeStart 钩子
|
|
78
|
+
* @param {Function} fn - 钩子函数(支持 async)
|
|
79
|
+
* @description 在 start() 流程末尾、run() 之前执行
|
|
80
|
+
* 适合做:缓存预热、定时任务注册、健康检查初始化等
|
|
81
|
+
* @example
|
|
82
|
+
* chan.beforeStart(async () => {
|
|
83
|
+
* schedule.every('clean-log', 60000, cleanLogs);
|
|
84
|
+
* });
|
|
85
|
+
*/
|
|
86
|
+
beforeStart(fn) {
|
|
87
|
+
if (typeof fn !== 'function') {
|
|
88
|
+
throw new Error('beforeStart 参数必须是函数');
|
|
89
|
+
}
|
|
90
|
+
if (!this._beforeStartHooks) this._beforeStartHooks = [];
|
|
91
|
+
this._beforeStartHooks.push(fn);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* 执行 beforeStart 钩子
|
|
96
|
+
* @private
|
|
97
|
+
*/
|
|
98
|
+
async _runBeforeStart() {
|
|
99
|
+
if (!this._beforeStartHooks || !this._beforeStartHooks.length) return;
|
|
100
|
+
for (const fn of this._beforeStartHooks) {
|
|
101
|
+
try {
|
|
102
|
+
await fn();
|
|
103
|
+
} catch (err) {
|
|
104
|
+
console.error('[Chan] beforeStart 钩子执行失败:', err.message);
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* 优雅停机
|
|
111
|
+
* @async
|
|
112
|
+
* @description 关闭 HTTP 服务、定时任务、数据库连接、Redis
|
|
113
|
+
* 监听 SIGTERM / SIGINT 信号自动触发
|
|
114
|
+
* @returns {Promise<void>}
|
|
115
|
+
*/
|
|
116
|
+
async shutdown() {
|
|
117
|
+
if (this._shuttingDown) return;
|
|
118
|
+
this._shuttingDown = true;
|
|
119
|
+
console.log('[Chan] 开始优雅停机...');
|
|
120
|
+
|
|
121
|
+
// 5 秒强制退出兜底(原 30s 过长,连接已主动销毁,5s 足够收尾)
|
|
122
|
+
const forceExitTimer = setTimeout(() => {
|
|
123
|
+
console.error('[Chan] 优雅停机超时(5s),强制退出');
|
|
124
|
+
process.exit(1);
|
|
125
|
+
}, 5000);
|
|
126
|
+
forceExitTimer.unref?.();
|
|
127
|
+
|
|
128
|
+
try {
|
|
129
|
+
// 1. 停止接收新请求 + 主动销毁所有 keep-alive 连接
|
|
130
|
+
// 说明:server.close() 只停止 listen,会等所有现有连接关闭才回调,
|
|
131
|
+
// 浏览器默认 keep-alive 会导致 30s 超时,所以必须主动 destroy。
|
|
132
|
+
if (this.server) {
|
|
133
|
+
if (this._sockets && this._sockets.size > 0) {
|
|
134
|
+
for (const socket of this._sockets) {
|
|
135
|
+
try { socket.destroy(); } catch (e) { /* 忽略已关闭的 socket */ }
|
|
136
|
+
}
|
|
137
|
+
this._sockets.clear();
|
|
138
|
+
}
|
|
139
|
+
await new Promise(resolve => this.server.close(resolve));
|
|
140
|
+
console.log('[Chan] HTTP 服务已关闭');
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
// 2. 关闭实时通信(SSE / WebSocket,先于定时任务关闭,避免推送失败)
|
|
144
|
+
try {
|
|
145
|
+
sse.shutdown();
|
|
146
|
+
} catch (e) {
|
|
147
|
+
console.error('[Chan] 关闭 SSE 失败:', e.message);
|
|
148
|
+
}
|
|
149
|
+
try {
|
|
150
|
+
websocket.shutdown();
|
|
151
|
+
} catch (e) {
|
|
152
|
+
console.error('[Chan] 关闭 WebSocket 失败:', e.message);
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
// 3. 停止所有定时任务
|
|
156
|
+
try {
|
|
157
|
+
schedule.shutdown();
|
|
158
|
+
} catch (e) {
|
|
159
|
+
console.error('[Chan] 停止定时任务失败:', e.message);
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
// 4. 关闭数据库连接
|
|
163
|
+
try {
|
|
164
|
+
await this.dbManager.closeAll();
|
|
165
|
+
} catch (e) {
|
|
166
|
+
console.error('[Chan] 关闭数据库失败:', e.message);
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
// 5. 关闭 Redis / 存储适配层
|
|
170
|
+
try {
|
|
171
|
+
await store.close();
|
|
172
|
+
} catch (e) {
|
|
173
|
+
console.error('[Chan] 关闭存储适配层失败:', e.message);
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
console.log('[Chan] 优雅停机完成');
|
|
177
|
+
} finally {
|
|
178
|
+
clearTimeout(forceExitTimer);
|
|
179
|
+
process.exit(0);
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/**
|
|
184
|
+
* 注册进程信号监听器(自动触发优雅停机)
|
|
185
|
+
* @private
|
|
186
|
+
*/
|
|
187
|
+
_registerSignalHandlers() {
|
|
188
|
+
if (this._signalRegistered) return;
|
|
189
|
+
this._signalRegistered = true;
|
|
190
|
+
|
|
191
|
+
const handler = async (signal) => {
|
|
192
|
+
// 幂等守卫:首次信号触发停机,后续信号直接忽略,避免日志噪音
|
|
193
|
+
if (this._shuttingDown) {
|
|
194
|
+
// 第二次 ctrl+c:直接强制退出,让用户能快速中断
|
|
195
|
+
console.error(`[Chan] 再次收到 ${signal},强制退出`);
|
|
196
|
+
process.exit(1);
|
|
197
|
+
}
|
|
198
|
+
console.log(`[Chan] 收到 ${signal} 信号`);
|
|
199
|
+
await this.shutdown();
|
|
200
|
+
};
|
|
201
|
+
|
|
202
|
+
process.on('SIGTERM', () => handler('SIGTERM'));
|
|
203
|
+
process.on('SIGINT', () => handler('SIGINT'));
|
|
64
204
|
}
|
|
65
205
|
|
|
66
206
|
/**
|
|
@@ -74,6 +214,27 @@ class Chan {
|
|
|
74
214
|
Chan.config = config;
|
|
75
215
|
}
|
|
76
216
|
|
|
217
|
+
/**
|
|
218
|
+
* 初始化存储适配层
|
|
219
|
+
* @async
|
|
220
|
+
* @description
|
|
221
|
+
* 根据 config.REDIS_ENABLED 决定使用 Redis 还是内存模式
|
|
222
|
+
* - Redis 启用且连接成功 → 使用 Redis 后端
|
|
223
|
+
* - Redis 未启用或连接失败 → 降级到内存模式(Map+TTL,上限 10 万条)
|
|
224
|
+
* WAF 封禁状态、Rate Limit 计数都依赖此适配层
|
|
225
|
+
*/
|
|
226
|
+
async loadRedis() {
|
|
227
|
+
const config = Chan.config || {};
|
|
228
|
+
try {
|
|
229
|
+
await store.init({
|
|
230
|
+
REDIS_ENABLED: config.REDIS_ENABLED,
|
|
231
|
+
REDIS: config.REDIS,
|
|
232
|
+
});
|
|
233
|
+
} catch (err) {
|
|
234
|
+
console.warn(`[Chan] 存储适配层初始化失败,使用内存模式: ${err.message}`);
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
|
|
77
238
|
/**
|
|
78
239
|
* 加载数据库
|
|
79
240
|
* @async
|
|
@@ -85,12 +246,8 @@ class Chan {
|
|
|
85
246
|
for (const [index, item] of dbList.entries()) {
|
|
86
247
|
const key = item.key || String(index);
|
|
87
248
|
try {
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
console.error(`[DB] 数据库 ${key} 未找到数据库配置`);
|
|
91
|
-
return;
|
|
92
|
-
};
|
|
93
|
-
const connection = this.dbManager.add(key, dbConfig, { isDefault: index === 0 });
|
|
249
|
+
// 已通过 dbList.entries() 拿到 item,无需再做 if (!dbConfig) return 死代码判断
|
|
250
|
+
const connection = this.dbManager.add(key, item, { isDefault: index === 0 });
|
|
94
251
|
if (index === 0) {
|
|
95
252
|
Chan.db = this.dbManager.get();
|
|
96
253
|
}
|
|
@@ -172,6 +329,9 @@ class Chan {
|
|
|
172
329
|
await waf(this.app, wafConfig);
|
|
173
330
|
setFavicon(this.app);
|
|
174
331
|
setStatic(this.app, statics);
|
|
332
|
+
// 响应压缩中间件:放在 setStatic 之后(静态资源已由 express.static 处理流式响应)、Cors 之前
|
|
333
|
+
// 跳过 SSE / WebSocket 升级请求,仅对可压缩文本类型且 > 1KB 的响应体压缩
|
|
334
|
+
this.app.use(compress());
|
|
175
335
|
setCookie(this.app, cookieKey);
|
|
176
336
|
setBody(this.app, BODY_LIMIT);
|
|
177
337
|
Cors(this.app, cors);
|
|
@@ -184,9 +344,20 @@ class Chan {
|
|
|
184
344
|
* 应用配置
|
|
185
345
|
* @private
|
|
186
346
|
* @description 设置 Express 应用配置
|
|
347
|
+
* trust proxy 根据 config.PROXY 动态设置,避免默认 true 导致 X-Forwarded-For 伪造
|
|
187
348
|
*/
|
|
188
349
|
setApp() {
|
|
189
|
-
|
|
350
|
+
const proxyConfig = Chan.config?.PROXY;
|
|
351
|
+
if (proxyConfig === 'true' || proxyConfig === true) {
|
|
352
|
+
// 信任所有代理(仅在反代可信时使用)
|
|
353
|
+
this.app.set("trust proxy", true);
|
|
354
|
+
} else if (proxyConfig === 'loopback' || !proxyConfig) {
|
|
355
|
+
// 默认仅信任本机回环代理
|
|
356
|
+
this.app.set("trust proxy", "loopback");
|
|
357
|
+
} else {
|
|
358
|
+
// 其他值按字面传入(IP / CIDR / 跳数)
|
|
359
|
+
this.app.set("trust proxy", proxyConfig);
|
|
360
|
+
}
|
|
190
361
|
}
|
|
191
362
|
|
|
192
363
|
/**
|
|
@@ -204,8 +375,8 @@ class Chan {
|
|
|
204
375
|
* @description 配置全局错误处理中间件
|
|
205
376
|
*/
|
|
206
377
|
setErrorHandler() {
|
|
207
|
-
//404
|
|
208
|
-
this.app.use((req, res) => {
|
|
378
|
+
//404(标准 Express 中间件签名,保留 next 参数)
|
|
379
|
+
this.app.use((req, res, next) => {
|
|
209
380
|
res.status(404).json(notFoundResponse(req));
|
|
210
381
|
});
|
|
211
382
|
|
|
@@ -302,16 +473,61 @@ class Chan {
|
|
|
302
473
|
/**
|
|
303
474
|
* 准备启动
|
|
304
475
|
* @param {Function} [cb] - 回调函数
|
|
305
|
-
* @description
|
|
476
|
+
* @description 启动 HTTP 服务,并按配置挂载 WebSocket(SSE 由业务侧通过 sse.middleware() 注册路由)
|
|
306
477
|
*/
|
|
307
478
|
run(cb) {
|
|
308
|
-
this.app.listen(Chan.config.PORT, () => {
|
|
479
|
+
this.server = this.app.listen(Chan.config.PORT, () => {
|
|
309
480
|
console.log(`Server running on port ${Chan.config.PORT}`);
|
|
310
481
|
cb?.(Chan.config.PORT);
|
|
311
482
|
});
|
|
483
|
+
|
|
484
|
+
// 跟踪所有 HTTP socket,用于优雅停机时主动销毁(避免 keep-alive 卡住 server.close)
|
|
485
|
+
this._sockets = new Set();
|
|
486
|
+
this.server.on('connection', (socket) => {
|
|
487
|
+
this._sockets.add(socket);
|
|
488
|
+
socket.on('close', () => this._sockets.delete(socket));
|
|
489
|
+
});
|
|
490
|
+
|
|
491
|
+
// 按配置挂载 WebSocket(config.WEBSOCKET.enabled = true 时启用)
|
|
492
|
+
this._attachWebSocket();
|
|
493
|
+
// 注册 SIGTERM / SIGINT 信号处理,支持优雅停机
|
|
494
|
+
this._registerSignalHandlers();
|
|
495
|
+
}
|
|
496
|
+
|
|
497
|
+
/**
|
|
498
|
+
* 挂载 WebSocket 服务到 HTTP server
|
|
499
|
+
* @private
|
|
500
|
+
* @description 根据 config.WEBSOCKET 配置自动挂载,业务侧通过 Chan.websocket 使用
|
|
501
|
+
* @example
|
|
502
|
+
* // config.js
|
|
503
|
+
* module.exports = {
|
|
504
|
+
* WEBSOCKET: { enabled: true, path: '/ws', heartbeatInterval: 30000 }
|
|
505
|
+
* };
|
|
506
|
+
* // 业务侧
|
|
507
|
+
* import { websocket } from 'chanjs';
|
|
508
|
+
* websocket.on('chat', (socket, payload) => { ... });
|
|
509
|
+
*/
|
|
510
|
+
_attachWebSocket() {
|
|
511
|
+
const wsConfig = Chan.config?.WEBSOCKET || {};
|
|
512
|
+
if (!wsConfig.enabled) return;
|
|
513
|
+
|
|
514
|
+
try {
|
|
515
|
+
websocket.attach(this.server, {
|
|
516
|
+
path: wsConfig.path || '/ws',
|
|
517
|
+
heartbeatInterval: wsConfig.heartbeatInterval,
|
|
518
|
+
verifyClient: wsConfig.verifyClient,
|
|
519
|
+
onConnection: wsConfig.onConnection,
|
|
520
|
+
});
|
|
521
|
+
// 暴露到 Chan 静态属性,便于业务侧通过 import Chan 访问
|
|
522
|
+
Chan.websocket = websocket;
|
|
523
|
+
} catch (err) {
|
|
524
|
+
console.error('[Chan] WebSocket 挂载失败:', err.message);
|
|
525
|
+
}
|
|
312
526
|
}
|
|
313
527
|
}
|
|
314
528
|
|
|
315
|
-
global.Chan
|
|
529
|
+
// 不再使用 global.Chan 全局污染
|
|
530
|
+
// 业务侧通过 `import Chan from 'chanjs'` 获取同一个类引用(ESM 模块缓存保证单例)
|
|
531
|
+
// 框架内部直接使用类名 Chan(同一模块作用域)
|
|
316
532
|
|
|
317
533
|
export default Chan;
|
package/base/Aop.js
CHANGED
|
@@ -9,6 +9,8 @@ export class Aop {
|
|
|
9
9
|
static TYPES = new Set(['type', 'enabled']);
|
|
10
10
|
// 支持的切面类型(仅保留常用的3种)
|
|
11
11
|
static SUPPORTED_TYPES = new Set(['before', 'after', 'error']);
|
|
12
|
+
// 幂等标记 Symbol,避免 wrap 多次嵌套包装
|
|
13
|
+
static WRAP_FLAG = Symbol('aop.wrapped');
|
|
12
14
|
|
|
13
15
|
constructor() {
|
|
14
16
|
this.aspects = new Map();
|
|
@@ -66,6 +68,9 @@ export class Aop {
|
|
|
66
68
|
* @param {object} config - 切面配置 { methodName: [{ type: 'before', enabled: true, log: true }] }
|
|
67
69
|
* @returns {object} - 绑定后的实例对象
|
|
68
70
|
* @throws {TypeError} - 入参类型错误时抛出
|
|
71
|
+
* @description
|
|
72
|
+
* 幂等性:用 Symbol 标记已包装的方法,避免多次调用 wrap 导致嵌套
|
|
73
|
+
* 同一方法第二次 wrap 会跳过,防止切面函数被重复执行
|
|
69
74
|
*/
|
|
70
75
|
wrap(instance, config) {
|
|
71
76
|
if (typeof instance !== 'object' || instance === null) {
|
|
@@ -82,6 +87,12 @@ export class Aop {
|
|
|
82
87
|
return;
|
|
83
88
|
}
|
|
84
89
|
|
|
90
|
+
// 幂等检查:已包装过的方法直接跳过,避免多次 wrap 嵌套
|
|
91
|
+
if (originalMethod[Aop.WRAP_FLAG]) {
|
|
92
|
+
console.warn(`[AOP警告] 方法 ${methodName} 已包装过切面,跳过重复绑定`);
|
|
93
|
+
return;
|
|
94
|
+
}
|
|
95
|
+
|
|
85
96
|
// 统一规则为数组 + 过滤禁用规则 + 过滤不支持的类型
|
|
86
97
|
const ruleList = Array.isArray(rules) ? rules : [rules];
|
|
87
98
|
const validRules = ruleList.filter(rule => {
|
|
@@ -92,17 +103,17 @@ export class Aop {
|
|
|
92
103
|
if (validRules.length === 0) return;
|
|
93
104
|
|
|
94
105
|
// 包装原方法(仅保留 before/after/error 逻辑)
|
|
95
|
-
|
|
106
|
+
const wrapped = async (...args) => {
|
|
96
107
|
const ctx = instance;
|
|
97
108
|
let result;
|
|
98
109
|
|
|
99
110
|
try {
|
|
100
111
|
// 执行前置切面
|
|
101
112
|
await this._executeAspectByType(ctx, validRules, 'before', args, originalMethod);
|
|
102
|
-
|
|
113
|
+
|
|
103
114
|
// 执行原方法
|
|
104
115
|
result = await Reflect.apply(originalMethod, ctx, args);
|
|
105
|
-
|
|
116
|
+
|
|
106
117
|
// 执行后置切面(仅无异常时执行)
|
|
107
118
|
await this._executeAspectByType(ctx, validRules, 'after', args, originalMethod, result);
|
|
108
119
|
} catch (error) {
|
|
@@ -113,6 +124,12 @@ export class Aop {
|
|
|
113
124
|
|
|
114
125
|
return result;
|
|
115
126
|
};
|
|
127
|
+
|
|
128
|
+
// 打幂等标记,防止重复 wrap
|
|
129
|
+
wrapped[Aop.WRAP_FLAG] = true;
|
|
130
|
+
// 保留原方法名,便于调试
|
|
131
|
+
Object.defineProperty(wrapped, 'name', { value: originalMethod.name, configurable: true });
|
|
132
|
+
instance[methodName] = wrapped;
|
|
116
133
|
});
|
|
117
134
|
|
|
118
135
|
return instance;
|
package/base/Container.js
CHANGED
|
@@ -1,7 +1,31 @@
|
|
|
1
1
|
import fs from 'fs';
|
|
2
2
|
import path from 'path';
|
|
3
3
|
import { Paths } from '../config/paths.js';
|
|
4
|
+
import Chan from '../App.js';
|
|
4
5
|
|
|
6
|
+
/**
|
|
7
|
+
* 容器基类(依赖注入容器)
|
|
8
|
+
*
|
|
9
|
+
* ============================================================
|
|
10
|
+
* 设计说明
|
|
11
|
+
* ============================================================
|
|
12
|
+
* 不再使用 global.Chan 全局污染。
|
|
13
|
+
* 通过 import Chan 获取应用类引用,所有资源(config/db/paths 等)
|
|
14
|
+
* 都通过 Chan 类的静态属性动态访问,避免构造时快照导致的不同步问题。
|
|
15
|
+
*
|
|
16
|
+
* 子类(Controller/Repository)可通过以下方式获取应用资源:
|
|
17
|
+
* - this.app → Chan 类本身(静态属性容器)
|
|
18
|
+
* - this.app.config → 应用配置
|
|
19
|
+
* - this.app.db → 默认数据库连接
|
|
20
|
+
* - this.app.dbManager → 数据库连接管理器
|
|
21
|
+
* - this.app.paths → 路径配置(兼容 Chan.paths)
|
|
22
|
+
*
|
|
23
|
+
* ============================================================
|
|
24
|
+
* DI 方法
|
|
25
|
+
* ============================================================
|
|
26
|
+
* - get(moduleName, fileName) → 加载业务模块组件(service/controller)
|
|
27
|
+
* - getResource(name) → 获取应用级资源(config/db/dbManager/paths)
|
|
28
|
+
*/
|
|
5
29
|
export class Container {
|
|
6
30
|
constructor(type = 'service') {
|
|
7
31
|
this.map = new Map();
|
|
@@ -9,8 +33,41 @@ export class Container {
|
|
|
9
33
|
this.type = type;
|
|
10
34
|
// modules 目录路径
|
|
11
35
|
this.baseDir = Paths.modulesPath;
|
|
12
|
-
//
|
|
13
|
-
this.
|
|
36
|
+
// 应用类引用(DI 核心:通过它访问 config/db 等静态资源)
|
|
37
|
+
this.app = Chan;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* 获取应用级资源(显式 DI)
|
|
42
|
+
* @param {string} name - 资源名:'config' | 'db' | 'dbManager' | 'paths'
|
|
43
|
+
* @returns {*} 对应的资源
|
|
44
|
+
* @example
|
|
45
|
+
* const config = this.getResource('config');
|
|
46
|
+
* const db = this.getResource('db');
|
|
47
|
+
*/
|
|
48
|
+
getResource(name) {
|
|
49
|
+
return Chan[name];
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* config 快捷访问(getter 动态读取,避免快照不同步)
|
|
54
|
+
*/
|
|
55
|
+
get config() {
|
|
56
|
+
return Chan.config;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* db 快捷访问(getter 动态读取)
|
|
61
|
+
*/
|
|
62
|
+
get db() {
|
|
63
|
+
return Chan.db;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* paths 快捷访问(getter 动态读取)
|
|
68
|
+
*/
|
|
69
|
+
get paths() {
|
|
70
|
+
return Chan.paths || Paths;
|
|
14
71
|
}
|
|
15
72
|
|
|
16
73
|
/**
|
|
@@ -58,8 +115,19 @@ export class Container {
|
|
|
58
115
|
* @param {string} moduleName - 模块名 ,例如:'web','api'
|
|
59
116
|
* @param {string} fileName - 文件名
|
|
60
117
|
* @returns {Promise<any>} - 组件实例
|
|
118
|
+
* @description
|
|
119
|
+
* 安全改进:
|
|
120
|
+
* - 名称白名单校验(仅允许字母开头、字母数字下划线/横线),拒绝路径注入
|
|
121
|
+
* - 解析后路径必须仍在 baseDir 内,防止路径越界
|
|
122
|
+
* - 用 fs.promises.access 替代 existsSync,避免同步 IO 阻塞事件循环
|
|
61
123
|
*/
|
|
62
124
|
async loadFile(moduleName, fileName) {
|
|
125
|
+
// 模块/文件名白名单:字母开头,允许字母数字下划线横线
|
|
126
|
+
const NAME_REGEX = /^[a-zA-Z][a-zA-Z0-9_-]*$/;
|
|
127
|
+
if (!NAME_REGEX.test(moduleName) || !NAME_REGEX.test(fileName)) {
|
|
128
|
+
throw new Error(`非法模块/文件名: ${moduleName}/${fileName}`);
|
|
129
|
+
}
|
|
130
|
+
|
|
63
131
|
const filePath = path.resolve(
|
|
64
132
|
this.baseDir,
|
|
65
133
|
moduleName,
|
|
@@ -67,7 +135,16 @@ export class Container {
|
|
|
67
135
|
`${fileName}.js`
|
|
68
136
|
);
|
|
69
137
|
|
|
70
|
-
|
|
138
|
+
// 路径越界检查:解析后必须仍在 baseDir 内
|
|
139
|
+
const rel = path.relative(this.baseDir, filePath);
|
|
140
|
+
if (rel.startsWith('..') || path.isAbsolute(rel)) {
|
|
141
|
+
throw new Error(`路径越界: ${filePath}`);
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
// 异步检查文件存在性,避免同步 IO
|
|
145
|
+
try {
|
|
146
|
+
await fs.promises.access(filePath, fs.constants.R_OK);
|
|
147
|
+
} catch (e) {
|
|
71
148
|
console.log(`模块 ${moduleName} 下 ${this.type} 目录中未找到文件:${fileName}.js`);
|
|
72
149
|
return null;
|
|
73
150
|
}
|
package/base/Controller.js
CHANGED
|
@@ -1,9 +1,23 @@
|
|
|
1
|
-
import { success, fail } from "../
|
|
1
|
+
import { success, fail } from "../response/response.js";
|
|
2
2
|
import Container from "./Container.js";
|
|
3
3
|
|
|
4
4
|
/**
|
|
5
5
|
* 控制器基类
|
|
6
6
|
* 提供统一的响应格式和常用方法
|
|
7
|
+
*
|
|
8
|
+
* ============================================================
|
|
9
|
+
* 响应格式(三层 code 体系)
|
|
10
|
+
* ============================================================
|
|
11
|
+
* 所有响应都遵循:{ success, code, msg, data }
|
|
12
|
+
* - code: 0 成功 / 1xxx 业务 / 5xxx 系统 / 6xxx 数据库
|
|
13
|
+
* - HTTP Status 由网关/Express 控制,与业务 code 解耦
|
|
14
|
+
*
|
|
15
|
+
* 使用示例:
|
|
16
|
+
* this.success(data) // → code: 0
|
|
17
|
+
* this.success({ data, msg: '创建成功' }) // → code: 0
|
|
18
|
+
* this.fail({ msg: '栏目下存在文章' }) // → code: 1008 业务失败
|
|
19
|
+
* this.fail({ msg: '参数缺失', code: 1007 }) // → code: 1007
|
|
20
|
+
* this.fail('密码错误') // 字符串简写,→ code: 1008
|
|
7
21
|
*/
|
|
8
22
|
export default class Controller extends Container {
|
|
9
23
|
constructor() {
|
|
@@ -15,8 +29,8 @@ export default class Controller extends Container {
|
|
|
15
29
|
* 返回成功响应
|
|
16
30
|
* @param {Object} options - 响应选项
|
|
17
31
|
* @param {*} options.data - 响应数据
|
|
18
|
-
* @param {string} options.msg -
|
|
19
|
-
* @returns {Object}
|
|
32
|
+
* @param {string} [options.msg="操作成功"] - 响应消息
|
|
33
|
+
* @returns {Object} 标准成功响应,code = 0
|
|
20
34
|
*/
|
|
21
35
|
success({ data, msg = "操作成功" } = {}) {
|
|
22
36
|
return success({ data, msg });
|
|
@@ -24,13 +38,28 @@ export default class Controller extends Container {
|
|
|
24
38
|
|
|
25
39
|
/**
|
|
26
40
|
* 返回失败响应
|
|
27
|
-
* @param {Object} options -
|
|
28
|
-
* @param {string} options.msg -
|
|
29
|
-
* @param {*} options.data - 响应数据
|
|
30
|
-
* @param {number} options.code -
|
|
41
|
+
* @param {Object|string} options - 响应选项,或失败消息字符串(简写)
|
|
42
|
+
* @param {string} [options.msg="操作失败"] - 失败消息
|
|
43
|
+
* @param {*} [options.data={}] - 响应数据
|
|
44
|
+
* @param {number} [options.code=1008] - 业务 code,默认 1008(业务处理失败)
|
|
31
45
|
* @returns {Object} 标准失败响应
|
|
46
|
+
* @description
|
|
47
|
+
* 兼容两种调用方式:
|
|
48
|
+
* - 对象:this.fail({ msg: '...', code: 1007 })
|
|
49
|
+
* - 字符串简写:this.fail('密码错误') // 等价于 this.fail({ msg: '密码错误' })
|
|
50
|
+
*
|
|
51
|
+
* 默认 code = 1008(业务处理失败)
|
|
52
|
+
* 业务方可指定 code:
|
|
53
|
+
* - 1001 认证失败 / 1002 token 过期 / 1003 权限不足
|
|
54
|
+
* - 1004 资源不存在 / 1005 资源已存在
|
|
55
|
+
* - 1006 参数无效 / 1007 参数缺失
|
|
56
|
+
* - 1008 业务处理失败(通用兜底)
|
|
32
57
|
*/
|
|
33
|
-
fail(
|
|
34
|
-
|
|
58
|
+
fail(options = {}) {
|
|
59
|
+
// 字符串简写:this.fail('错误消息') → this.fail({ msg: '错误消息' })
|
|
60
|
+
if (typeof options === 'string') {
|
|
61
|
+
return fail({ msg: options });
|
|
62
|
+
}
|
|
63
|
+
return fail(options);
|
|
35
64
|
}
|
|
36
65
|
}
|
package/base/Database.js
CHANGED
|
@@ -78,6 +78,56 @@ class DatabaseManager {
|
|
|
78
78
|
}
|
|
79
79
|
}
|
|
80
80
|
|
|
81
|
+
/**
|
|
82
|
+
* 关闭所有数据库连接(优雅停机使用)
|
|
83
|
+
* 单个连接关闭失败不影响其他连接
|
|
84
|
+
* @returns {Promise<Array<{name:string, success:boolean, error?:string}>>} 每个连接的关闭结果
|
|
85
|
+
*/
|
|
86
|
+
async closeAll() {
|
|
87
|
+
const results = [];
|
|
88
|
+
for (const [name, conn] of this._connections) {
|
|
89
|
+
try {
|
|
90
|
+
await conn.destroy();
|
|
91
|
+
results.push({ name, success: true });
|
|
92
|
+
} catch (err) {
|
|
93
|
+
results.push({ name, success: false, error: err.message });
|
|
94
|
+
console.error(`[Database] 关闭连接 ${name} 失败:`, err.message);
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
this._connections.clear();
|
|
98
|
+
console.log(`[Database] 已关闭所有连接(共 ${results.length} 个)`);
|
|
99
|
+
return results;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* 心跳检查(单个连接)
|
|
104
|
+
* @param {string} [name] - 连接名称,默认检查默认连接
|
|
105
|
+
* @returns {Promise<boolean>} 是否健康
|
|
106
|
+
*/
|
|
107
|
+
async ping(name = this._defaultName) {
|
|
108
|
+
const conn = this._connections.get(name);
|
|
109
|
+
if (!conn) return false;
|
|
110
|
+
try {
|
|
111
|
+
// 用最简 SELECT 1 触发一次实际查询,验证连接可用
|
|
112
|
+
await conn.raw('SELECT 1');
|
|
113
|
+
return true;
|
|
114
|
+
} catch (err) {
|
|
115
|
+
console.error(`[Database] ping ${name} 失败:`, err.message);
|
|
116
|
+
return false;
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* 获取所有连接状态(健康检查 / 监控使用)
|
|
122
|
+
* @returns {Array<{name:string, isDefault:boolean}>} 连接列表
|
|
123
|
+
*/
|
|
124
|
+
getStatus() {
|
|
125
|
+
return Array.from(this._connections.keys()).map(name => ({
|
|
126
|
+
name,
|
|
127
|
+
isDefault: name === this._defaultName,
|
|
128
|
+
}));
|
|
129
|
+
}
|
|
130
|
+
|
|
81
131
|
}
|
|
82
132
|
|
|
83
133
|
export default DatabaseManager;
|
package/base/Event.js
CHANGED
|
@@ -1,8 +1,20 @@
|
|
|
1
1
|
import { EventEmitter } from 'events';
|
|
2
2
|
|
|
3
|
+
/**
|
|
4
|
+
* 默认最大监听器数量上限,防止业务侧泄漏过多监听器导致内存警告
|
|
5
|
+
*/
|
|
6
|
+
const DEFAULT_MAX_LISTENERS = 50;
|
|
7
|
+
|
|
3
8
|
export class Event extends EventEmitter {
|
|
4
9
|
constructor() {
|
|
5
10
|
super();
|
|
11
|
+
// 设置最大监听器数量,避免业务侧泄漏导致 process 警告
|
|
12
|
+
this.setMaxListeners(DEFAULT_MAX_LISTENERS);
|
|
13
|
+
// 兜底 error 监听器:未注册 error 监听时 EventEmitter 默认会抛出并崩溃进程
|
|
14
|
+
// 这里注册一个仅打印日志的 error 处理器,确保 unhandled error 不会让进程退出
|
|
15
|
+
this.on('error', (err) => {
|
|
16
|
+
console.error('[Event] 未处理的 error 事件:', err?.message || err);
|
|
17
|
+
});
|
|
6
18
|
}
|
|
7
19
|
|
|
8
20
|
/**
|