chanjs 2.7.4 → 2.7.5
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/USAGE.md +533 -0
- package/config/index.js +37 -6
- package/core/App.js +166 -0
- package/core/Container.js +77 -0
- package/core/Controller.js +29 -0
- package/core/Database.js +93 -0
- package/core/Repository.js +327 -0
- package/core/Service.js +11 -0
- package/core/bootstrap/error-handler.js +104 -0
- package/core/bootstrap/hook-runner.js +64 -0
- package/core/bootstrap/middleware.js +35 -0
- package/core/bootstrap/router-loader.js +53 -0
- package/core/errors.js +224 -0
- package/core/loader.js +89 -0
- package/core/registry.js +17 -0
- package/doc/Cache.md +279 -106
- package/doc/Common.md +590 -134
- package/doc/Controller.md +166 -95
- package/doc/Help.md +299 -698
- package/doc/QuickStart.md +116 -0
- package/doc/Repository.md +560 -0
- package/doc/Service.md +201 -527
- package/index.js +75 -37
- package/middleware/body.js +17 -0
- package/middleware/cookie.js +7 -15
- package/middleware/cors.js +9 -27
- package/middleware/favicon.js +7 -17
- package/middleware/header.js +15 -16
- package/middleware/index.js +11 -11
- package/middleware/log.js +26 -56
- package/middleware/static.js +15 -28
- package/middleware/template.js +75 -115
- package/middleware/validate.js +79 -0
- package/middleware/waf.js +174 -197
- package/package.json +9 -2
- package/response/code.js +73 -0
- package/response/index.js +9 -6
- package/response/response.js +82 -236
- package/security/checker.js +26 -74
- package/security/index.js +4 -9
- package/security/jwt.js +69 -142
- package/security/keywords.js +32 -136
- package/security/rate-limit.js +38 -80
- package/security/sign.js +83 -176
- package/security/xss-filter.js +21 -53
- package/storage/cache.js +57 -196
- package/storage/index.js +3 -6
- package/storage/redis.js +123 -181
- package/storage/store.js +163 -188
- package/utils/data-parse.js +42 -186
- package/utils/file.js +73 -244
- package/utils/filter.js +22 -25
- package/utils/html.js +49 -33
- package/utils/index.js +21 -7
- package/utils/ip.js +31 -71
- package/utils/logger.js +117 -0
- package/utils/pages.js +55 -0
- package/utils/paths.js +18 -0
- package/utils/request.js +94 -136
- package/utils/signal.js +87 -0
- package/utils/time.js +33 -75
- package/utils/tree.js +112 -104
- package/App.js +0 -533
- package/base/Aop.js +0 -195
- package/base/Container.js +0 -161
- package/base/Controller.js +0 -65
- package/base/Database.js +0 -133
- package/base/Event.js +0 -61
- package/base/Repository.js +0 -644
- package/common/api.js +0 -35
- package/common/code.js +0 -52
- package/common/email.js +0 -191
- package/common/index.js +0 -5
- package/common/pages.js +0 -120
- package/common/utils.js +0 -73
- package/config/code.js +0 -166
- package/config/paths.js +0 -60
- package/doc/Aop.md +0 -269
- package/doc/Email.md +0 -114
- package/doc/Event.md +0 -232
- package/global/env.js +0 -11
- package/global/import.js +0 -39
- package/global/index.js +0 -8
- package/helper/index.js +0 -79
- package/loader/index.js +0 -6
- package/loader/loader.js +0 -138
- package/middleware/compress.js +0 -185
- package/middleware/setBody.js +0 -32
- package/realtime/index.js +0 -7
- package/realtime/sse.js +0 -424
- package/realtime/websocket.js +0 -540
- package/schedule/index.js +0 -6
- package/schedule/schedule.js +0 -491
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
import { routeNotFound, serializeError, buildErrorHtml, respondError } from "../../response/index.js";
|
|
2
|
+
import { CODE_NOT_FOUND, CODE_CONFLICT, CODE_BUSINESS_FAIL } from "../../response/code.js";
|
|
3
|
+
import {
|
|
4
|
+
AppError, isAppError, parseStack, describeError, wrapDbError,
|
|
5
|
+
} from "../errors.js";
|
|
6
|
+
import logger from "../../utils/logger.js";
|
|
7
|
+
|
|
8
|
+
// 仅这些业务码明确经过了 DB 查询(如查无记录、唯一冲突、业务处理失败),
|
|
9
|
+
// 才推断 DB 已恢复;鉴权(401/403)/参数(422)/限流(429) 等不查库的错误不触发 markUp
|
|
10
|
+
const DB_RECOVERY_CODES = new Set([CODE_NOT_FOUND, CODE_CONFLICT, CODE_BUSINESS_FAIL]);
|
|
11
|
+
|
|
12
|
+
// 安全开关:是否对外暴露 SQL / 堆栈等详情
|
|
13
|
+
const EXPOSE_ERR_DETAIL = process.env.EXPOSE_ERR_DETAIL === "true";
|
|
14
|
+
const IS_PROD = ["prd", "prod", "production"].includes(process.env.NODE_ENV || "dev");
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* 注册全局异常兜底中间件(ChanJS底层框架)
|
|
18
|
+
* @description
|
|
19
|
+
* 唯一统一错误出口,职责收敛为:日志 / 环境鉴权 / HTTP状态 / 脱敏,全项目所有报错只打印一次。
|
|
20
|
+
* 1. 404 兜底:路由未匹配时返回标准 404 JSON(须在业务路由之后注册)
|
|
21
|
+
* 2. 全局异常捕获:DB/底层异常经 wrapDbError 归类为友好 AppError,
|
|
22
|
+
* 响应层 serializeError 零 DB 感知,换数据库不用改响应代码
|
|
23
|
+
* 3. 区分浏览器HTML / API JSON:HTML返回框架内置静态错误页,接口返回标准JSON
|
|
24
|
+
* 4. 双重安全校验:EXPOSE_ERR_DETAIL && (!生产环境 || 携带合法Debug令牌),生产误开也不泄露堆栈/SQL
|
|
25
|
+
* @param {object} chan 框架核心实例 { app: Express }
|
|
26
|
+
*/
|
|
27
|
+
export function registerErrorHandler(chan) {
|
|
28
|
+
const { app, config } = chan;
|
|
29
|
+
|
|
30
|
+
// ============ 404 兜底(路由未匹配) ============
|
|
31
|
+
// 使用 respondError 做 HTML/JSON 分流:页面请求返回框架内置 HTML 错误页,
|
|
32
|
+
// 接口请求返回标准 JSON;避免浏览器直接访问不存在路径时看到一段 JSON 文本。
|
|
33
|
+
app.use((req, res) => {
|
|
34
|
+
const notFound = routeNotFound(req);
|
|
35
|
+
respondError(res, req, {
|
|
36
|
+
httpStatus: 404,
|
|
37
|
+
code: notFound.code,
|
|
38
|
+
msg: notFound.msg,
|
|
39
|
+
data: notFound.data,
|
|
40
|
+
});
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
// ============ 全局异常统一出口 ============
|
|
44
|
+
app.use((err, req, res, next) => {
|
|
45
|
+
// 响应头已发送,转交Express默认错误处理
|
|
46
|
+
if (res.headersSent) return next(err);
|
|
47
|
+
|
|
48
|
+
// DB / 底层异常下沉转换:非业务错误先归类为友好 AppError
|
|
49
|
+
if (!isAppError(err)) err = wrapDbError(err);
|
|
50
|
+
|
|
51
|
+
// 连接类故障实时标记:后续未登录请求可直接返回「数据库未连接」,
|
|
52
|
+
// 而非伪装成 token 缺失(详见 auth 中间件 isDown 短路)
|
|
53
|
+
const isDbConnection = isAppError(err) && (
|
|
54
|
+
err.code >= 6000 ||
|
|
55
|
+
/数据库连接|数据库访问被拒绝|ECONNREFUSED|ETIMEDOUT|连接中断/.test(err.message || "")
|
|
56
|
+
);
|
|
57
|
+
if (isDbConnection) {
|
|
58
|
+
chan.dbManager?.markDown?.();
|
|
59
|
+
} else if (isAppError(err) && DB_RECOVERY_CODES.has(err.code)) {
|
|
60
|
+
// 仅明确经过 DB 查询的业务错误(查无记录/唯一冲突/业务失败)才推断 DB 已恢复,
|
|
61
|
+
// 鉴权/参数/限流等不查库的错误不触发 markUp,避免误标
|
|
62
|
+
chan.dbManager?.markUp?.();
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
const httpStatus = isAppError(err) ? err.httpStatus : 500;
|
|
66
|
+
const stackInfo = parseStack(err.stack);
|
|
67
|
+
// 全链路追踪信息(requestId 需业务/框架注入 req.id 或 x-request-id 头)
|
|
68
|
+
const requestId = req.headers["x-request-id"] || req.id || "-";
|
|
69
|
+
const userId = req.user?.id ?? "-";
|
|
70
|
+
|
|
71
|
+
// 日志分级:业务错误 4xx → warn,系统错误 5xx → error(唯一打印点,杜绝重复刷屏)
|
|
72
|
+
const logFn = httpStatus < 500 ? logger.warn : logger.error;
|
|
73
|
+
logFn(JSON.stringify({
|
|
74
|
+
type: err.name || "UnknownError",
|
|
75
|
+
message: describeError(err), // 递归解析 cause 根因,支持 AggregateError
|
|
76
|
+
file: stackInfo.file,
|
|
77
|
+
line: stackInfo.line,
|
|
78
|
+
requestId,
|
|
79
|
+
method: req.method,
|
|
80
|
+
url: req.originalUrl,
|
|
81
|
+
userId,
|
|
82
|
+
}));
|
|
83
|
+
|
|
84
|
+
// 双重安全校验:生产环境即使环境变量开了,也必须携带合法 Debug Token 才暴露堆栈/SQL
|
|
85
|
+
const exposeDetail = EXPOSE_ERR_DETAIL && (!IS_PROD || req.headers["x-debug-token"] === process.env.DEBUG_TOKEN);
|
|
86
|
+
|
|
87
|
+
// 统一判断并返回内置HTML页面(仅浏览器页面访问;API 请求一律 JSON)
|
|
88
|
+
const sendHtml = (status, message, bizCode) => {
|
|
89
|
+
if (req.xhr || req.accepts?.(["json", "html"]) === "json") return false;
|
|
90
|
+
if (!req.accepts?.("html")) return false;
|
|
91
|
+
res.status(status).type("html").send(buildErrorHtml(status, message, bizCode, req));
|
|
92
|
+
return true;
|
|
93
|
+
};
|
|
94
|
+
|
|
95
|
+
if (isAppError(err)) {
|
|
96
|
+
if (sendHtml(httpStatus, err.message, err.code)) return;
|
|
97
|
+
return res.status(httpStatus).json(serializeError(err, exposeDetail));
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
// 兜底分支(理论上 wrapDbError 已转 AppError,这里防万一)
|
|
101
|
+
if (sendHtml(500, "服务器内部错误", 500)) return;
|
|
102
|
+
res.status(500).json(serializeError(err, exposeDetail));
|
|
103
|
+
});
|
|
104
|
+
}
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import logger from "../../utils/logger.js";
|
|
2
|
+
import { describeError, errorExtraProps } from "../errors.js";
|
|
3
|
+
import { HOOK_TIMEOUT as DEFAULT_HOOK_TIMEOUT } from "../../config/index.js";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* 带超时限制执行钩子,超时则 reject 跳过该钩子,不阻塞后续启动
|
|
7
|
+
* @param {Function} fn 钩子函数
|
|
8
|
+
* @param {number} timeout 超时毫秒
|
|
9
|
+
* @param {string} tag 钩子标识,用于日志
|
|
10
|
+
* @returns {Promise<void>}
|
|
11
|
+
*/
|
|
12
|
+
async function runHookWithTimeout(fn, timeout, tag) {
|
|
13
|
+
let timer;
|
|
14
|
+
const timeoutPromise = new Promise((_, reject) => {
|
|
15
|
+
timer = setTimeout(
|
|
16
|
+
() => reject(new Error(`钩子 ${tag} 执行超时(${timeout}ms),已跳过`)),
|
|
17
|
+
timeout
|
|
18
|
+
);
|
|
19
|
+
});
|
|
20
|
+
try {
|
|
21
|
+
await Promise.race([fn(), timeoutPromise]);
|
|
22
|
+
} finally {
|
|
23
|
+
clearTimeout(timer);
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* 串行执行启动钩子,单钩子超时/报错不阻塞整体启动
|
|
29
|
+
* 仅支持格式:{ name: string, fn: () => Promise<any> }
|
|
30
|
+
* @param {object} chan 框架实例
|
|
31
|
+
*/
|
|
32
|
+
export async function runHooks(chan) {
|
|
33
|
+
const { hooks = [], config = {} } = chan;
|
|
34
|
+
if (!hooks.length) return;
|
|
35
|
+
|
|
36
|
+
const hookTimeout = config.HOOK_TIMEOUT ?? DEFAULT_HOOK_TIMEOUT;
|
|
37
|
+
|
|
38
|
+
for (let i = 0; i < hooks.length; i++) {
|
|
39
|
+
const item = hooks[i];
|
|
40
|
+
const seq = i + 1;
|
|
41
|
+
// 支持两种格式:{ name, fn } 对象 或 纯函数(App.beforeStart 注册的是纯函数)
|
|
42
|
+
let name, fn;
|
|
43
|
+
if (typeof item === "function") {
|
|
44
|
+
fn = item;
|
|
45
|
+
name = null;
|
|
46
|
+
} else if (item && typeof item === "object") {
|
|
47
|
+
({ name, fn } = item);
|
|
48
|
+
}
|
|
49
|
+
const hookTag = name || `hook#${seq}`;
|
|
50
|
+
|
|
51
|
+
// 非法钩子直接跳过
|
|
52
|
+
if (typeof fn !== "function") {
|
|
53
|
+
logger.warn(`[ChanHook][${hookTag}] 第${seq}项钩子fn非函数,跳过`);
|
|
54
|
+
continue;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
try {
|
|
58
|
+
await runHookWithTimeout(fn, hookTimeout, hookTag);
|
|
59
|
+
} catch (err) {
|
|
60
|
+
const errLog = describeError(err) + errorExtraProps(err);
|
|
61
|
+
logger.error(`[ChanHook][${hookTag}] 第${seq}个钩子执行异常:\n${errLog}`, err);
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import {
|
|
2
|
+
Cors, body, cookie, favicon, header,
|
|
3
|
+
staticMw, template, waf, wafBody, log,
|
|
4
|
+
} from "../../middleware/index.js";
|
|
5
|
+
import { BODY_LIMIT } from "../../config/index.js";
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* 注册核心中间件(顺序固化:不可随意调整)
|
|
9
|
+
* WAF 前置 → Favicon → 静态 → Cookie → Body 解析 → WAF Body 检查 → CORS → 日志 → 模板 → 响应头
|
|
10
|
+
* @param {object} chan 框架实例
|
|
11
|
+
*/
|
|
12
|
+
export async function registerCoreMiddleware(chan) {
|
|
13
|
+
const { app, config: cfg } = chan;
|
|
14
|
+
|
|
15
|
+
// 1. WAF 前置(IP/封禁/限流/路径与 query 检查,不依赖请求体)
|
|
16
|
+
await waf(app, cfg.waf ?? { enabled: false });
|
|
17
|
+
// 2. Favicon
|
|
18
|
+
favicon(app);
|
|
19
|
+
// 3. 静态资源
|
|
20
|
+
staticMw(app, cfg.statics ?? []);
|
|
21
|
+
// 4. Cookie
|
|
22
|
+
cookie(app, cfg.cookieKey);
|
|
23
|
+
// 5. 请求体解析
|
|
24
|
+
body(app, cfg.BODY_LIMIT ?? BODY_LIMIT);
|
|
25
|
+
// 5.1 WAF Body 检查(须在 body 解析之后)
|
|
26
|
+
wafBody(app, cfg.waf ?? { enabled: false });
|
|
27
|
+
// 6. CORS
|
|
28
|
+
Cors(app, cfg.cors ?? {});
|
|
29
|
+
// 7. 请求日志
|
|
30
|
+
log(app, cfg.logger ?? {});
|
|
31
|
+
// 8. 模板渲染
|
|
32
|
+
template(app, { views: cfg.views ?? [], NODE_ENV: cfg.NODE_ENV ?? "dev" });
|
|
33
|
+
// 9. 响应头
|
|
34
|
+
header(app, { APP_NAME: cfg.APP_NAME ?? "ChanCMS", APP_VERSION: cfg.APP_VERSION ?? "1.0.0" });
|
|
35
|
+
}
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import fs from "fs";
|
|
2
|
+
import path from "path";
|
|
3
|
+
import { Router } from "express";
|
|
4
|
+
import { loaderSort, importFile } from "../loader.js";
|
|
5
|
+
import logger from "../../utils/logger.js";
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* 加载路由公共逻辑:导入并执行路由注册函数
|
|
9
|
+
* @param {string} filePath 路由文件完整路径
|
|
10
|
+
* @param {object} app Express实例
|
|
11
|
+
* @param {object} router 顶层路由
|
|
12
|
+
* @param {object} config 全局配置
|
|
13
|
+
*/
|
|
14
|
+
async function registerRouterFile(filePath, app, router, config) {
|
|
15
|
+
if (!fs.existsSync(filePath)) return;
|
|
16
|
+
try {
|
|
17
|
+
const register = await importFile(filePath);
|
|
18
|
+
if (typeof register === "function") {
|
|
19
|
+
await register(app, router, config);
|
|
20
|
+
}
|
|
21
|
+
} catch (err) {
|
|
22
|
+
logger.error(`[RouterLoader] 路由文件加载失败: ${filePath}`, err);
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* 加载 modules 业务模块路由,按配置顺序挂载
|
|
28
|
+
* @param {object} chan 框架实例
|
|
29
|
+
*/
|
|
30
|
+
export async function loadModuleRouter(chan) {
|
|
31
|
+
const { paths, config, app, router } = chan;
|
|
32
|
+
const modulesDir = paths.modulesPath;
|
|
33
|
+
if (!fs.existsSync(modulesDir)) return;
|
|
34
|
+
|
|
35
|
+
const moduleNames = loaderSort(config.modules ?? []);
|
|
36
|
+
for (const name of moduleNames) {
|
|
37
|
+
const routeFile = path.join(modulesDir, name, "router.js");
|
|
38
|
+
// 每个模块使用独立的子 Router 实例,防止模块内 router.use(auth()) 等无路径前缀
|
|
39
|
+
// 的中间件污染全局,导致未匹配路径(如 404)被提前拦截成鉴权错误。
|
|
40
|
+
const subRouter = Router();
|
|
41
|
+
await registerRouterFile(routeFile, app, subRouter, config);
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* 加载项目根公共路由 router.js
|
|
47
|
+
* @param {object} chan 框架实例
|
|
48
|
+
*/
|
|
49
|
+
export async function loadCommonRouter(chan) {
|
|
50
|
+
const { paths, app, router, config } = chan;
|
|
51
|
+
const commonRouteFile = path.join(paths.appPath, "router.js");
|
|
52
|
+
await registerRouterFile(commonRouteFile, app, router, config);
|
|
53
|
+
}
|
package/core/errors.js
ADDED
|
@@ -0,0 +1,224 @@
|
|
|
1
|
+
import {
|
|
2
|
+
CODE_AUTH_FAILED, CODE_TOKEN_EXPIRED, CODE_FORBIDDEN, CODE_NOT_FOUND,
|
|
3
|
+
CODE_CONFLICT, CODE_PARAM_INVALID, CODE_PARAM_MISSING, CODE_BUSINESS_FAIL,
|
|
4
|
+
CODE_RATE_LIMIT, CODE_BLOCKED, CODE_SYSTEM_ERROR, CODE_SERVICE_BUSY,
|
|
5
|
+
CODE_DB_CONNECTION_ERROR, CODE_DB_ACCESS_DENIED, CODE_DB_OPERATION_TIMEOUT,
|
|
6
|
+
} from "../response/code.js";
|
|
7
|
+
|
|
8
|
+
/** 全局错误配置中心 */
|
|
9
|
+
const ERROR_CONFIG = {
|
|
10
|
+
AuthError: { code: CODE_AUTH_FAILED, httpStatus: 401, defaultMsg: '认证失败' },
|
|
11
|
+
TokenExpiredError: { code: CODE_TOKEN_EXPIRED, httpStatus: 401, defaultMsg: '令牌已过期' },
|
|
12
|
+
ForbiddenError: { code: CODE_FORBIDDEN, httpStatus: 403, defaultMsg: '权限不足' },
|
|
13
|
+
NotFoundError: { code: CODE_NOT_FOUND, httpStatus: 404, defaultMsg: '资源不存在' },
|
|
14
|
+
ConflictError: { code: CODE_CONFLICT, httpStatus: 409, defaultMsg: '资源已存在' },
|
|
15
|
+
ValidationError: { code: CODE_PARAM_INVALID, httpStatus: 422, defaultMsg: '参数无效', extraProps: ['fields'] },
|
|
16
|
+
ParamMissingError: { code: CODE_PARAM_MISSING, httpStatus: 400, defaultMsg: '参数缺失' },
|
|
17
|
+
BusinessError: { code: CODE_BUSINESS_FAIL, httpStatus: 400, defaultMsg: '业务处理失败' },
|
|
18
|
+
RateLimitError: { code: CODE_RATE_LIMIT, httpStatus: 429, defaultMsg: '请求过于频繁', extraProps: ['retryAfter'] },
|
|
19
|
+
BlockedError: { code: CODE_BLOCKED, httpStatus: 403, defaultMsg: '访问已被限制', extraProps: ['retryAfter'] },
|
|
20
|
+
SystemError: { code: CODE_SYSTEM_ERROR, httpStatus: 500, defaultMsg: '系统内部错误' },
|
|
21
|
+
ServiceBusyError: { code: CODE_SERVICE_BUSY, httpStatus: 503, defaultMsg: '服务繁忙' },
|
|
22
|
+
DbConnectionError: { code: CODE_DB_CONNECTION_ERROR, httpStatus: 503, defaultMsg: '数据库连接失败' },
|
|
23
|
+
DbAccessDeniedError:{ code: CODE_DB_ACCESS_DENIED, httpStatus: 503, defaultMsg: '数据库访问被拒绝' },
|
|
24
|
+
DbTimeoutError: { code: CODE_DB_OPERATION_TIMEOUT, httpStatus: 503, defaultMsg: '数据库操作超时' },
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
const MAX_RECURSIVE_DEPTH = 10;
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* 业务错误基类
|
|
31
|
+
*/
|
|
32
|
+
export class AppError extends Error {
|
|
33
|
+
/**
|
|
34
|
+
* @param {number} code 业务码
|
|
35
|
+
* @param {string} msg 提示文案
|
|
36
|
+
* @param {number} httpStatus HTTP状态码
|
|
37
|
+
* @param {Error|unknown} [cause] 底层原始错误
|
|
38
|
+
*/
|
|
39
|
+
constructor(code, msg, httpStatus = 400, cause = null) {
|
|
40
|
+
super(msg, { cause });
|
|
41
|
+
this.name = 'AppError';
|
|
42
|
+
this.code = code;
|
|
43
|
+
this.httpStatus = httpStatus;
|
|
44
|
+
this.meta = {};
|
|
45
|
+
if (Error.captureStackTrace) Error.captureStackTrace(this, this.constructor);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** 统一判断是否为业务错误实例 */
|
|
49
|
+
static isAppError(err) {
|
|
50
|
+
return err instanceof AppError;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** 自定义序列化 */
|
|
54
|
+
toJSON() {
|
|
55
|
+
const json = { name: this.name, message: this.message, code: this.code, httpStatus: this.httpStatus };
|
|
56
|
+
for (const key of Object.getOwnPropertyNames(this)) {
|
|
57
|
+
const val = this[key];
|
|
58
|
+
if (val === undefined || val === null) continue;
|
|
59
|
+
if (typeof val === 'function' || typeof val === 'symbol') continue;
|
|
60
|
+
json[key] = val;
|
|
61
|
+
}
|
|
62
|
+
return json;
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** 动态生成业务错误 Class */
|
|
67
|
+
function createErrorClass(className) {
|
|
68
|
+
const cfg = ERROR_CONFIG[className];
|
|
69
|
+
if (!cfg) throw new Error(`错误类型[${className}]未在ERROR_CONFIG中配置`);
|
|
70
|
+
|
|
71
|
+
return class extends AppError {
|
|
72
|
+
constructor(msgOrOpts, ...rest) {
|
|
73
|
+
const opts = (msgOrOpts && typeof msgOrOpts === 'object') ? msgOrOpts : { msg: msgOrOpts, ...Object.fromEntries(cfg.extraProps?.map((k, i) => [k, rest[i]]) ?? []), cause: rest[cfg.extraProps?.length] };
|
|
74
|
+
super(cfg.code, opts.msg ?? cfg.defaultMsg, cfg.httpStatus, opts.cause ?? null);
|
|
75
|
+
this.name = className;
|
|
76
|
+
for (const [k, v] of Object.entries(opts)) {
|
|
77
|
+
if (k !== 'msg' && k !== 'cause' && v !== undefined) this[k] = v;
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
};
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// 业务错误类导出
|
|
84
|
+
export const AuthError = createErrorClass('AuthError');
|
|
85
|
+
export const TokenExpiredError = createErrorClass('TokenExpiredError');
|
|
86
|
+
export const ForbiddenError = createErrorClass('ForbiddenError');
|
|
87
|
+
export const NotFoundError = createErrorClass('NotFoundError');
|
|
88
|
+
export const ConflictError = createErrorClass('ConflictError');
|
|
89
|
+
export const ValidationError = createErrorClass('ValidationError');
|
|
90
|
+
export const ParamMissingError = createErrorClass('ParamMissingError');
|
|
91
|
+
export const BusinessError = createErrorClass('BusinessError');
|
|
92
|
+
export const RateLimitError = createErrorClass('RateLimitError');
|
|
93
|
+
export const BlockedError = createErrorClass('BlockedError');
|
|
94
|
+
export const SystemError = createErrorClass('SystemError');
|
|
95
|
+
export const ServiceBusyError = createErrorClass('ServiceBusyError');
|
|
96
|
+
export const DbConnectionError = createErrorClass('DbConnectionError');
|
|
97
|
+
export const DbAccessDeniedError= createErrorClass('DbAccessDeniedError');
|
|
98
|
+
export const DbTimeoutError = createErrorClass('DbTimeoutError');
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* 提取错误自定义附加属性,用于日志打印
|
|
102
|
+
*/
|
|
103
|
+
export function errorExtraProps(err) {
|
|
104
|
+
if (!err || !(err instanceof Error)) return "";
|
|
105
|
+
const parts = [];
|
|
106
|
+
for (const k of Object.getOwnPropertyNames(err)) {
|
|
107
|
+
if (k === "message" || k === "stack" || k === "name" || k === "cause" || k === "constructor") continue;
|
|
108
|
+
const val = err[k];
|
|
109
|
+
if (typeof val === 'function' || typeof val === 'symbol') continue;
|
|
110
|
+
try {
|
|
111
|
+
parts.push(`${k}=${typeof val === "string" ? val : JSON.stringify(val)}`);
|
|
112
|
+
} catch {
|
|
113
|
+
parts.push(`${k}=[unserializable]`);
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
return parts.length ? ` | ${parts.join("; ")}` : "";
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* 递归解析完整可读错误信息,内置递归深度限制防栈溢出
|
|
121
|
+
*/
|
|
122
|
+
export function describeError(err, depth = 0) {
|
|
123
|
+
if (!err) return "未知错误";
|
|
124
|
+
if (depth >= MAX_RECURSIVE_DEPTH) return `[递归深度超限(${MAX_RECURSIVE_DEPTH}),停止解析]`;
|
|
125
|
+
|
|
126
|
+
// 聚合错误批量展开子错误
|
|
127
|
+
if (err instanceof AggregateError && Array.isArray(err.errors) && err.errors.length) {
|
|
128
|
+
const reasons = err.errors
|
|
129
|
+
.map((e, i) => ` [${i + 1}] ${describeError(e, depth + 1)}`)
|
|
130
|
+
.join("\n");
|
|
131
|
+
return `聚合错误(AggregateError)共${err.errors.length}个原因:\n${reasons}`;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
const baseMsg = err.message || String(err);
|
|
135
|
+
let hint = baseMsg;
|
|
136
|
+
|
|
137
|
+
// 数据库/网络错误智能提示
|
|
138
|
+
if (/ECONNREFUSED/.test(baseMsg)) {
|
|
139
|
+
hint = `数据库连接被拒绝(ECONNREFUSED) — 检查数据库服务、地址端口配置: ${baseMsg}`;
|
|
140
|
+
} else if (/ENOTFOUND|EAI_AGAIN/.test(baseMsg)) {
|
|
141
|
+
hint = `数据库域名解析失败 — 检查DB_HOST环境变量: ${baseMsg}`;
|
|
142
|
+
} else if (/ECONNRESET|PROTOCOL_CONNECTION_LOST|ETIMEDOUT/.test(baseMsg)) {
|
|
143
|
+
hint = `数据库连接中断/超时 — 检查服务状态、连接池上限: ${baseMsg}`;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
const causeText = err.cause
|
|
147
|
+
? err.cause instanceof Error
|
|
148
|
+
? ` | cause: ${describeError(err.cause, depth + 1)}`
|
|
149
|
+
: ` | cause: ${String(err.cause)}`
|
|
150
|
+
: "";
|
|
151
|
+
|
|
152
|
+
return `${hint}${causeText}${errorExtraProps(err)}`;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/** 全局判断业务错误 */
|
|
156
|
+
export function isAppError(err) {
|
|
157
|
+
return err instanceof AppError;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/**
|
|
161
|
+
* 判断是否为数据库连接类错误(code + message 双重检测)
|
|
162
|
+
*/
|
|
163
|
+
const CONNECTION_CODES = new Set([
|
|
164
|
+
"ECONNREFUSED", "ENOTFOUND", "EAI_AGAIN", "ETIMEDOUT",
|
|
165
|
+
"ECONNRESET", "PROTOCOL_CONNECTION_LOST", "PROTOCOL_ENQUEUE_AFTER_FATAL_ERROR",
|
|
166
|
+
]);
|
|
167
|
+
const CONNECTION_KEYWORDS = [
|
|
168
|
+
"econnrefused", "enotfound", "eai_again", "etimedout", "econnreset",
|
|
169
|
+
"connection lost", "connection closed", "pool is full", "too many connections",
|
|
170
|
+
"closed unexpectedly", "connect timeout", "handshake timeout",
|
|
171
|
+
];
|
|
172
|
+
|
|
173
|
+
function isDbConnectionError(err) {
|
|
174
|
+
if (CONNECTION_CODES.has(err?.code)) return true;
|
|
175
|
+
const msg = String(err?.message || "").toLowerCase();
|
|
176
|
+
return CONNECTION_KEYWORDS.some(kw => msg.includes(kw));
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/**
|
|
180
|
+
* DB / 底层异常统一拦截器:转换为框架标准 AppError 子类
|
|
181
|
+
* @param {Error} err
|
|
182
|
+
* @returns {AppError}
|
|
183
|
+
*/
|
|
184
|
+
export function wrapDbError(err) {
|
|
185
|
+
if (isAppError(err)) return err;
|
|
186
|
+
|
|
187
|
+
// 聚合错误(mysql2 批量操作)提取首个真实原因
|
|
188
|
+
let e = err;
|
|
189
|
+
if (err instanceof AggregateError && Array.isArray(err.errors) && err.errors.length) {
|
|
190
|
+
e = err.errors.find(x => x instanceof Error) ?? err.errors[0];
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
if (isDbConnectionError(e)) return new DbConnectionError("数据库连接失败,请稍后重试", err);
|
|
194
|
+
|
|
195
|
+
const driverCode = e?.code;
|
|
196
|
+
switch (driverCode) {
|
|
197
|
+
case "ER_DUP_ENTRY":
|
|
198
|
+
case "23505":
|
|
199
|
+
return new ConflictError("数据已存在", err);
|
|
200
|
+
case "ER_ACCESS_DENIED_ERROR":
|
|
201
|
+
return new DbAccessDeniedError("数据库访问被拒绝", err);
|
|
202
|
+
case "ER_NO_SUCH_TABLE":
|
|
203
|
+
return new SystemError("数据表不存在", err);
|
|
204
|
+
case "ER_BAD_FIELD_ERROR":
|
|
205
|
+
return new ParamMissingError("数据库字段错误", err);
|
|
206
|
+
case "ER_ROW_IS_REFERENCED_2":
|
|
207
|
+
return new ConflictError("存在关联数据,操作失败", err);
|
|
208
|
+
default:
|
|
209
|
+
return new SystemError("数据操作异常", err);
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
/** 解析 V8 堆栈,提取报错文件、行号 */
|
|
214
|
+
export function parseStack(stack) {
|
|
215
|
+
if (!stack || typeof stack !== "string") {
|
|
216
|
+
return { message: "无堆栈信息", file: "-", line: "-" };
|
|
217
|
+
}
|
|
218
|
+
const lines = stack.split("\n").filter(Boolean);
|
|
219
|
+
const message = lines[0];
|
|
220
|
+
const traceLine = lines[1] ?? "";
|
|
221
|
+
const match = traceLine.match(/\(([^:]+):(\d+):\d+\)/) || traceLine.match(/at\s+([^:]+):(\d+):\d+/);
|
|
222
|
+
if (match) return { message, file: match[1], line: match[2] };
|
|
223
|
+
return { message, file: traceLine.trim(), line: "-" };
|
|
224
|
+
}
|
package/core/loader.js
ADDED
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
import fs from "fs";
|
|
2
|
+
import fsp from "fs/promises";
|
|
3
|
+
import path from "path";
|
|
4
|
+
import { pathToFileURL } from "url";
|
|
5
|
+
import { Paths } from "../utils/paths.js";
|
|
6
|
+
import logger from "../utils/logger.js";
|
|
7
|
+
|
|
8
|
+
const BOUND_SYMBOL = Symbol("chanjs:bound");
|
|
9
|
+
const JS_SUFFIX = /\.js$/i;
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* 动态导入文件,捕获常见IO异常
|
|
13
|
+
* @param {string} filepath
|
|
14
|
+
* @returns {Promise<any|null>}
|
|
15
|
+
*/
|
|
16
|
+
export async function importFile(filepath) {
|
|
17
|
+
if (!filepath || typeof filepath !== "string") {
|
|
18
|
+
logger.error("[Loader] 文件路径非法");
|
|
19
|
+
return null;
|
|
20
|
+
}
|
|
21
|
+
try {
|
|
22
|
+
await fsp.access(filepath);
|
|
23
|
+
const mod = await import(pathToFileURL(filepath).href);
|
|
24
|
+
return mod.default ?? mod;
|
|
25
|
+
} catch (err) {
|
|
26
|
+
if (err.code === "ENOENT") logger.error(`[Loader] 文件不存在: ${filepath}`);
|
|
27
|
+
else if (err.code === "EACCES") logger.error(`[Loader] 无访问权限: ${filepath}`);
|
|
28
|
+
else logger.error(`[Loader] 导入失败 ${filepath}:`, err.message);
|
|
29
|
+
return null;
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* 模块排序:web模块强制后置
|
|
35
|
+
* @param {string[]} modules
|
|
36
|
+
* @returns {string[]}
|
|
37
|
+
*/
|
|
38
|
+
export function loaderSort(modules = []) {
|
|
39
|
+
const webIdx = modules.indexOf("web");
|
|
40
|
+
if (webIdx > -1) modules.push(modules.splice(webIdx, 1)[0]);
|
|
41
|
+
return modules;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* 加载根配置 index.js
|
|
46
|
+
*/
|
|
47
|
+
export async function loadConfig() {
|
|
48
|
+
const cfg = await importFile(path.join(Paths.configPath, "index.js"));
|
|
49
|
+
return cfg ?? {};
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* 加载指定模块全部Controller,自动绑定实例方法防this丢失
|
|
54
|
+
* @param {string} moduleName
|
|
55
|
+
* @returns {Record<string, any>}
|
|
56
|
+
*/
|
|
57
|
+
export async function loadController(moduleName) {
|
|
58
|
+
const ctrlDir = path.join(Paths.modulesPath, moduleName, "controller");
|
|
59
|
+
const ctrlMap = {};
|
|
60
|
+
|
|
61
|
+
if (!fs.existsSync(ctrlDir)) {
|
|
62
|
+
logger.error(`[Loader] 控制器目录不存在: ${ctrlDir}`);
|
|
63
|
+
return ctrlMap;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
const jsFiles = fs.readdirSync(ctrlDir).filter(f => JS_SUFFIX.test(f));
|
|
67
|
+
for (const file of jsFiles) {
|
|
68
|
+
const filePath = path.join(ctrlDir, file);
|
|
69
|
+
const ctrlName = file.replace(JS_SUFFIX, "");
|
|
70
|
+
const inst = await importFile(filePath);
|
|
71
|
+
if (!inst || typeof inst !== "object") continue;
|
|
72
|
+
|
|
73
|
+
// 原型方法批量bind,幂等标记避免重复绑定
|
|
74
|
+
const proto = Object.getPrototypeOf(inst);
|
|
75
|
+
if (!proto) continue;
|
|
76
|
+
Object.getOwnPropertyNames(proto).forEach(key => {
|
|
77
|
+
const fn = inst[key];
|
|
78
|
+
if (key === "constructor" || typeof fn !== "function" || fn[BOUND_SYMBOL]) return;
|
|
79
|
+
const boundFn = fn.bind(inst);
|
|
80
|
+
Object.defineProperty(boundFn, BOUND_SYMBOL, {
|
|
81
|
+
value: true, enumerable: false, writable: false, configurable: false
|
|
82
|
+
});
|
|
83
|
+
inst[key] = boundFn;
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
ctrlMap[ctrlName] = inst;
|
|
87
|
+
}
|
|
88
|
+
return ctrlMap;
|
|
89
|
+
}
|
package/core/registry.js
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 全局单应用实例注册表
|
|
3
|
+
* 全局仅维护单个Chan实例,多实例采用多进程方案
|
|
4
|
+
*/
|
|
5
|
+
let _current = null;
|
|
6
|
+
|
|
7
|
+
/** 设置全局应用实例 */
|
|
8
|
+
export function setApp(app) {
|
|
9
|
+
_current = app;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
/** 获取全局应用实例,未初始化返回null */
|
|
13
|
+
export function getApp() {
|
|
14
|
+
return _current;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export default { setApp, getApp };
|