chanjs 2.7.3 → 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 +11 -3
- 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
package/core/App.js
ADDED
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
import express from "express";
|
|
2
|
+
import DatabaseManager from "./Database.js";
|
|
3
|
+
import { setApp } from "./registry.js";
|
|
4
|
+
import { Paths } from "../utils/paths.js";
|
|
5
|
+
import { loadConfig } from "./loader.js";
|
|
6
|
+
import { store } from "../storage/index.js";
|
|
7
|
+
import logger from "../utils/logger.js";
|
|
8
|
+
import { AppError, describeError, errorExtraProps } from "./errors.js";
|
|
9
|
+
import { register as registerShutdown, shutdown as runShutdown } from "../utils/signal.js";
|
|
10
|
+
import { registerCoreMiddleware } from "./bootstrap/middleware.js";
|
|
11
|
+
import { loadModuleRouter, loadCommonRouter } from "./bootstrap/router-loader.js";
|
|
12
|
+
import { registerErrorHandler } from "./bootstrap/error-handler.js";
|
|
13
|
+
import { runHooks } from "./bootstrap/hook-runner.js";
|
|
14
|
+
import { loadDotEnv } from "../config/index.js";
|
|
15
|
+
|
|
16
|
+
// 初始化环境变量
|
|
17
|
+
loadDotEnv();
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Chan 应用核心类
|
|
21
|
+
*/
|
|
22
|
+
export default class Chan {
|
|
23
|
+
constructor() {
|
|
24
|
+
this.app = express();
|
|
25
|
+
this.router = express.Router();
|
|
26
|
+
this.dbManager = new DatabaseManager();
|
|
27
|
+
|
|
28
|
+
this.config = null;
|
|
29
|
+
this.paths = Paths;
|
|
30
|
+
this.db = null;
|
|
31
|
+
this.hooks = [];
|
|
32
|
+
this.server = null;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** 完整启动流程 */
|
|
36
|
+
async start() {
|
|
37
|
+
await this.#loadConfig();
|
|
38
|
+
logger.info("[Chan] 配置加载完成");
|
|
39
|
+
|
|
40
|
+
await this.#initStore();
|
|
41
|
+
logger.info("[Chan] 缓存初始化完成");
|
|
42
|
+
|
|
43
|
+
await this.#loadDb();
|
|
44
|
+
logger.info("[Chan] 数据库加载完成");
|
|
45
|
+
|
|
46
|
+
setApp(this);
|
|
47
|
+
logger.info("[Chan] 全局应用实例注册完成");
|
|
48
|
+
|
|
49
|
+
await this.#regCoreMiddleware();
|
|
50
|
+
logger.info("[Chan] 核心中间件注册完成");
|
|
51
|
+
|
|
52
|
+
this.#setupApp();
|
|
53
|
+
await this.#loadRoutes();
|
|
54
|
+
this.#mountRouter();
|
|
55
|
+
logger.info("[Chan] 路由加载挂载完成");
|
|
56
|
+
|
|
57
|
+
this.#regErrHandler();
|
|
58
|
+
logger.info("[Chan] 全局错误中间件注册完成");
|
|
59
|
+
|
|
60
|
+
await this.#runHooks();
|
|
61
|
+
logger.info("[Chan] 启动钩子全部执行完毕");
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** 注册启动前置钩子 */
|
|
65
|
+
beforeStart(fn) {
|
|
66
|
+
if (typeof fn !== "function") {
|
|
67
|
+
throw new AppError("PARAM_INVALID", "beforeStart 参数必须为函数", 400);
|
|
68
|
+
}
|
|
69
|
+
this.hooks.push(fn);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** 执行优雅停机流程 */
|
|
73
|
+
async shutdown() {
|
|
74
|
+
await runShutdown(this);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* 启动 HTTP 监听服务
|
|
79
|
+
* @param {(port: number) => void} cb 启动成功回调
|
|
80
|
+
*/
|
|
81
|
+
run(cb) {
|
|
82
|
+
const port = Number(this.config.PORT) || 3000;
|
|
83
|
+
this.server = this.app.listen(port, () => {
|
|
84
|
+
logger.info(`[Chan] HTTP服务启动,监听端口:${port}`);
|
|
85
|
+
cb?.(port);
|
|
86
|
+
});
|
|
87
|
+
this.server.on("error", (err) => {
|
|
88
|
+
logger.error(`[Chan] HTTP服务启动失败: ${err.message}`, err);
|
|
89
|
+
});
|
|
90
|
+
registerShutdown(this);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
async #loadConfig() {
|
|
94
|
+
this.config = await loadConfig();
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
async #initStore() {
|
|
98
|
+
const cfg = this.config;
|
|
99
|
+
try {
|
|
100
|
+
await store.init({
|
|
101
|
+
REDIS_ENABLED: !!cfg?.REDIS_ENABLED,
|
|
102
|
+
REDIS: cfg?.REDIS ?? {}
|
|
103
|
+
});
|
|
104
|
+
if (!cfg?.REDIS_ENABLED) logger.info("[Store] Redis未开启,使用内存缓存");
|
|
105
|
+
} catch (err) {
|
|
106
|
+
logger.warn("[Store] Redis连接失败,自动降级内存缓存");
|
|
107
|
+
logger.error(`[Store] 异常信息:${describeError(err)}${errorExtraProps(err)}`, err);
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
async #loadDb() {
|
|
112
|
+
const dbList = this.config?.db ?? [];
|
|
113
|
+
if (!Array.isArray(dbList) || dbList.length === 0) return;
|
|
114
|
+
|
|
115
|
+
for (const [idx, dbCfg] of dbList.entries()) {
|
|
116
|
+
const dbKey = dbCfg.key ?? String(idx);
|
|
117
|
+
let conn;
|
|
118
|
+
try {
|
|
119
|
+
conn = this.dbManager.add(dbKey, dbCfg, { isDefault: idx === 0 });
|
|
120
|
+
// 首个成功连接即作为默认 this.db,避免 idx===0 失败时 this.db 恒为 null
|
|
121
|
+
if (!this.db) this.db = conn;
|
|
122
|
+
} catch (err) {
|
|
123
|
+
logger.error(`[DB] 数据库[${dbKey}]初始化失败:${describeError(err)}${errorExtraProps(err)}`, err);
|
|
124
|
+
continue;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
const ok = await this.dbManager.ping(dbKey);
|
|
128
|
+
ok
|
|
129
|
+
? logger.info(`[DB] 数据库[${dbKey}]连接正常`)
|
|
130
|
+
: logger.error(`[DB] ⚠️ 数据库[${dbKey}]连通性校验失败,请检查库服务、账号配置`);
|
|
131
|
+
}
|
|
132
|
+
logger.info(`[DB] 数据库初始化结束,共配置${dbList.length}个连接`);
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
async #regCoreMiddleware() {
|
|
136
|
+
await registerCoreMiddleware(this);
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* 配置 Express trust proxy
|
|
141
|
+
* 默认只信任本机回环;生产环境通过 TRUSTED_PROXIES 注入真实上游代理
|
|
142
|
+
* 切勿使用 true(信任任意来源)
|
|
143
|
+
*/
|
|
144
|
+
#setupApp() {
|
|
145
|
+
const env = process.env.TRUSTED_PROXIES;
|
|
146
|
+
const trustedProxies = env ? env.split(",").map(s => s.trim()).filter(Boolean) : ["loopback"];
|
|
147
|
+
this.app.set("trust proxy", trustedProxies);
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
async #loadRoutes() {
|
|
151
|
+
await loadModuleRouter(this);
|
|
152
|
+
await loadCommonRouter(this);
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
#mountRouter() {
|
|
156
|
+
this.app.use(this.router);
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
#regErrHandler() {
|
|
160
|
+
registerErrorHandler(this);
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
async #runHooks() {
|
|
164
|
+
await runHooks(this);
|
|
165
|
+
}
|
|
166
|
+
}
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
import fs from 'fs';
|
|
2
|
+
import path from 'path';
|
|
3
|
+
import { Paths } from '../utils/paths.js';
|
|
4
|
+
import { getApp } from './registry.js';
|
|
5
|
+
import logger from "../utils/logger.js";
|
|
6
|
+
|
|
7
|
+
const NAME_REGEX = /^[a-zA-Z][a-zA-Z0-9_-]*$/;
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* 组件容器:统一加载 controller/service
|
|
11
|
+
*/
|
|
12
|
+
export class Container {
|
|
13
|
+
constructor(type = 'service') {
|
|
14
|
+
this.cache = new Map();
|
|
15
|
+
this.type = type;
|
|
16
|
+
this.baseDir = Paths.modulesPath;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
get app() { return getApp(); }
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* 全局配置快捷访问
|
|
23
|
+
* 业务 Controller/Service/Repository 大量使用 `this.config.xxx` 读取配置项
|
|
24
|
+
* 统一从 app.config 取,未初始化时返回空对象避免 undefined.xxx 报错
|
|
25
|
+
*/
|
|
26
|
+
get config() {
|
|
27
|
+
return this.app?.config ?? {};
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* 获取组件,自动缓存
|
|
32
|
+
* @param {string} moduleName
|
|
33
|
+
* @param {string} fileName
|
|
34
|
+
*/
|
|
35
|
+
async get(moduleName, fileName) {
|
|
36
|
+
const cacheKey = `${moduleName}.${fileName}`;
|
|
37
|
+
if (this.cache.has(cacheKey)) return this.cache.get(cacheKey);
|
|
38
|
+
|
|
39
|
+
try {
|
|
40
|
+
const instance = await this.loadFile(moduleName, fileName);
|
|
41
|
+
if (!instance) logger.info(`[Container] ${moduleName}/${this.type}/${fileName}.js 导出实例为空`);
|
|
42
|
+
return instance;
|
|
43
|
+
} catch (err) {
|
|
44
|
+
logger.error(`[Container] 加载组件失败 ${moduleName}/${fileName}:${err.message}`);
|
|
45
|
+
return null;
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** 物理加载文件、导入模块、安全校验 */
|
|
50
|
+
async loadFile(moduleName, fileName) {
|
|
51
|
+
const cacheKey = `${moduleName}.${fileName}`;
|
|
52
|
+
|
|
53
|
+
if (!NAME_REGEX.test(moduleName) || !NAME_REGEX.test(fileName)) {
|
|
54
|
+
throw new Error(`非法名称 ${moduleName}/${fileName}`);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
const filePath = path.resolve(this.baseDir, moduleName, this.type, `${fileName}.js`);
|
|
58
|
+
const relativePath = path.relative(this.baseDir, filePath);
|
|
59
|
+
if (relativePath.startsWith('..') || path.isAbsolute(relativePath)) {
|
|
60
|
+
throw new Error(`路径越界拦截:${filePath}`);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
try {
|
|
64
|
+
await fs.promises.access(filePath, fs.constants.R_OK);
|
|
65
|
+
} catch {
|
|
66
|
+
logger.info(`[Container] 文件不存在:${moduleName}/${this.type}/${fileName}.js`);
|
|
67
|
+
return null;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
const mod = await import(`file://${filePath}`);
|
|
71
|
+
const instance = mod.default;
|
|
72
|
+
this.cache.set(cacheKey, instance);
|
|
73
|
+
return instance;
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export default Container;
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import { success, fail } from "../response/response.js";
|
|
2
|
+
import Container from "./Container.js";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* 控制器基类,继承组件容器,内置统一成功/失败响应封装
|
|
6
|
+
* 响应结构 { success, code, msg, data }
|
|
7
|
+
* 错误码规范:0成功 / 1xxx业务 / 5xxx系统 / 6xxx数据库
|
|
8
|
+
*/
|
|
9
|
+
export default class Controller extends Container {
|
|
10
|
+
constructor() {
|
|
11
|
+
super("controller");
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* 成功返回,默认code=0
|
|
16
|
+
* @param {{data?: any, msg?: string}} opts
|
|
17
|
+
*/
|
|
18
|
+
success({ data, msg = "操作成功" } = {}) {
|
|
19
|
+
return success({ data: data ?? {}, msg });
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* 失败返回,传字符串直接作为提示文案,默认code=1008
|
|
24
|
+
* @param {string | {msg?: string, code?: number}} opts
|
|
25
|
+
*/
|
|
26
|
+
fail(opts = {}) {
|
|
27
|
+
return typeof opts === "string" ? fail({ msg: opts }) : fail(opts);
|
|
28
|
+
}
|
|
29
|
+
}
|
package/core/Database.js
ADDED
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
import knex from "knex";
|
|
2
|
+
import logger from "../utils/logger.js";
|
|
3
|
+
|
|
4
|
+
const DEFAULT_NAME = "default";
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* 多Knex连接管理器,统一管理连接、心跳、批量销毁
|
|
8
|
+
*/
|
|
9
|
+
class DatabaseManager {
|
|
10
|
+
constructor() {
|
|
11
|
+
this._connections = new Map();
|
|
12
|
+
this._defaultName = DEFAULT_NAME;
|
|
13
|
+
// 健康缓存:运行时由 error-handler 实时更新,用于鉴权层廉价区分「用户未登录」与「库挂了」
|
|
14
|
+
this._health = new Map();
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* 添加数据库连接,首个连接 / isDefault=true 自动设为默认
|
|
19
|
+
* @param {string} name 连接标识
|
|
20
|
+
* @param {object} config Knex配置
|
|
21
|
+
* @param {{isDefault?: boolean}} opts
|
|
22
|
+
* @returns {knex.Knex}
|
|
23
|
+
*/
|
|
24
|
+
add(name, config, { isDefault = false } = {}) {
|
|
25
|
+
const conn = knex(config);
|
|
26
|
+
this._connections.set(name, conn);
|
|
27
|
+
if (isDefault || this._connections.size === 1) this._defaultName = name;
|
|
28
|
+
return conn;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* 获取指定连接,不存在抛异常
|
|
33
|
+
* @param {string} [name=this._defaultName]
|
|
34
|
+
* @returns {knex.Knex}
|
|
35
|
+
*/
|
|
36
|
+
get(name = this._defaultName) {
|
|
37
|
+
const conn = this._connections.get(name);
|
|
38
|
+
if (!conn) throw new Error(`Database connection "${name}" not found`);
|
|
39
|
+
return conn;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** 批量关闭全部连接,单个失败不阻断其他 */
|
|
43
|
+
async closeAll() {
|
|
44
|
+
const results = [];
|
|
45
|
+
for (const [name, conn] of this._connections) {
|
|
46
|
+
try {
|
|
47
|
+
await conn.destroy();
|
|
48
|
+
results.push({ name, success: true });
|
|
49
|
+
} catch (err) {
|
|
50
|
+
results.push({ name, success: false, error: err.message });
|
|
51
|
+
logger.error(`[Database] 关闭连接 ${name} 失败`, err);
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
this._connections.clear();
|
|
55
|
+
logger.info(`[Database] 全部连接关闭完成,总计 ${results.length} 个`);
|
|
56
|
+
return results;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* 心跳探测,SELECT 1 校验连通性(启动时 + 运行时按需调用)
|
|
61
|
+
* @param {string} [name=this._defaultName]
|
|
62
|
+
* @returns {boolean}
|
|
63
|
+
*/
|
|
64
|
+
async ping(name = this._defaultName) {
|
|
65
|
+
const conn = this._connections.get(name);
|
|
66
|
+
if (!conn) {
|
|
67
|
+
this._health.set(name, false);
|
|
68
|
+
return false;
|
|
69
|
+
}
|
|
70
|
+
try {
|
|
71
|
+
await conn.raw("SELECT 1");
|
|
72
|
+
this._health.set(name, true);
|
|
73
|
+
return true;
|
|
74
|
+
} catch (err) {
|
|
75
|
+
this._health.set(name, false);
|
|
76
|
+
logger.error(`[Database] ping ${name} 连通校验失败`, err);
|
|
77
|
+
return false;
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** 运行时主动标记连接不可用(error-handler 捕获 DB 异常时调用) */
|
|
82
|
+
markDown(name = this._defaultName) {
|
|
83
|
+
this._health.set(name, false);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/** 运行时标记连接恢复可用(error-handler 捕获业务成功响应时调用) */
|
|
87
|
+
markUp(name = this._defaultName) {
|
|
88
|
+
this._health.set(name, true);
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
export default DatabaseManager;
|
|
93
|
+
|
|
@@ -0,0 +1,327 @@
|
|
|
1
|
+
import Container from "./Container.js";
|
|
2
|
+
import logger from "../utils/logger.js";
|
|
3
|
+
import {
|
|
4
|
+
CODE_OK,
|
|
5
|
+
CODE_NOT_FOUND,
|
|
6
|
+
CODE_PARAM_INVALID,
|
|
7
|
+
CODE_PARAM_MISSING,
|
|
8
|
+
} from "../response/code.js";
|
|
9
|
+
|
|
10
|
+
const SORT_FIELD_REGEX = /^[a-zA-Z_][a-zA-Z0-9_]*$/;
|
|
11
|
+
const OPERATORS = { $like:'like', $gt:'>', $gte:'>=', $lt:'<', $lte:'<=', $ne:'<>', $in:'in' };
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* 拼接查询条件,支持操作符/等值/数组in
|
|
15
|
+
* @param {import('knex').Knex.QueryBuilder} dbQuery
|
|
16
|
+
* @param {object} query
|
|
17
|
+
* @returns {import('knex').Knex.QueryBuilder}
|
|
18
|
+
*/
|
|
19
|
+
function applyQuery(dbQuery, query) {
|
|
20
|
+
for (const [field, val] of Object.entries(query)) {
|
|
21
|
+
if (!SORT_FIELD_REGEX.test(field)) {
|
|
22
|
+
logger.warn(`[Repository] 非法查询字段:${field}`);
|
|
23
|
+
continue;
|
|
24
|
+
}
|
|
25
|
+
if (val && typeof val === 'object' && !Array.isArray(val)) {
|
|
26
|
+
const ops = Object.keys(val);
|
|
27
|
+
if (ops.length && ops.every(k => k in OPERATORS || k === '$null' || k === '$notNull')) {
|
|
28
|
+
for (const [op, operand] of Object.entries(val)) {
|
|
29
|
+
if (OPERATORS[op]) {
|
|
30
|
+
dbQuery = dbQuery.where(field, OPERATORS[op], operand);
|
|
31
|
+
} else if (op === '$in') {
|
|
32
|
+
dbQuery = dbQuery.whereIn(field, Array.isArray(operand) ? operand : [operand]);
|
|
33
|
+
} else if (op === '$null') {
|
|
34
|
+
operand ? dbQuery.whereNull(field) : dbQuery.whereNotNull(field);
|
|
35
|
+
} else if (op === '$notNull') {
|
|
36
|
+
dbQuery.whereNotNull(field);
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
continue;
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
Array.isArray(val) ? dbQuery.whereIn(field, val) : dbQuery.where(field, val);
|
|
43
|
+
}
|
|
44
|
+
return dbQuery;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* 应用字段白名单 + 排序白名单到 query
|
|
49
|
+
* @param {import('knex').Knex.QueryBuilder} q
|
|
50
|
+
* @param {string[]} fields
|
|
51
|
+
* @param {object} sort
|
|
52
|
+
* @returns {import('knex').Knex.QueryBuilder}
|
|
53
|
+
*/
|
|
54
|
+
function applySelectAndSort(q, fields, sort) {
|
|
55
|
+
if (fields.length) {
|
|
56
|
+
const safeFields = fields.filter(f => SORT_FIELD_REGEX.test(f) || f === '*');
|
|
57
|
+
if (safeFields.length) q = q.select(safeFields);
|
|
58
|
+
}
|
|
59
|
+
if (sort && typeof sort === 'object') {
|
|
60
|
+
for (const [field, dir] of Object.entries(sort)) {
|
|
61
|
+
if (!SORT_FIELD_REGEX.test(field)) {
|
|
62
|
+
logger.warn(`[Repository] 非法排序字段:${field}`);
|
|
63
|
+
continue;
|
|
64
|
+
}
|
|
65
|
+
const d = ['asc','desc'].includes(String(dir).toLowerCase()) ? dir.toLowerCase() : 'asc';
|
|
66
|
+
q = q.orderBy(field, d);
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
return q;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* 数据访问基类,封装通用CRUD操作
|
|
74
|
+
*/
|
|
75
|
+
class Repository extends Container {
|
|
76
|
+
constructor(table=null, dbName=null, opts={}) {
|
|
77
|
+
super('service');
|
|
78
|
+
this.tableName = table;
|
|
79
|
+
this._dbName = dbName;
|
|
80
|
+
this._customDb = null;
|
|
81
|
+
this._dateFields = Array.isArray(opts.dateFields) ? opts.dateFields : [];
|
|
82
|
+
typeof this.on === 'function' && this.on();
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** 动态获取数据库连接 */
|
|
86
|
+
get db() {
|
|
87
|
+
if (this._dbName) {
|
|
88
|
+
this._customDb ??= this.app.dbManager.get(this._dbName);
|
|
89
|
+
return this._customDb;
|
|
90
|
+
}
|
|
91
|
+
return this.app.db;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/** 分页配置快捷读取 */
|
|
95
|
+
get limit() { return this.config?.LIMIT_MAX || 300; }
|
|
96
|
+
|
|
97
|
+
/** 前置数据库校验 */
|
|
98
|
+
_checkDB() {
|
|
99
|
+
if (!this.db) throw new Error("Database connection not available");
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/** 统一构建查询实例(子类可重写以扩展过滤逻辑) */
|
|
103
|
+
_buildBaseQuery({ query = {}, sort = {}, fields = [] } = {}) {
|
|
104
|
+
this._checkDB();
|
|
105
|
+
let q = this.db(this.tableName);
|
|
106
|
+
if (Object.keys(query).length) q = applyQuery(q, query);
|
|
107
|
+
return applySelectAndSort(q, fields, sort);
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/** 工具:日期字段自动转Date */
|
|
111
|
+
#formatDate(data) {
|
|
112
|
+
if (!data || typeof data !== 'object' || !this._dateFields.length) return data;
|
|
113
|
+
const row = {...data};
|
|
114
|
+
for (const k of this._dateFields) {
|
|
115
|
+
if (typeof row[k] === 'string') {
|
|
116
|
+
const dt = new Date(row[k]);
|
|
117
|
+
!isNaN(dt.getTime()) && (row[k] = dt);
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
return row;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/** 查询全部,默认上限1000条 */
|
|
124
|
+
async all({ query={}, sort={}, fields=[], limit=1000 }={}) {
|
|
125
|
+
const list = await this._buildBaseQuery({query,sort,fields}).limit(limit);
|
|
126
|
+
return { success:true, code:CODE_OK, msg:"查询成功", data: list };
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/** 分页偏移查询 */
|
|
130
|
+
async find({ query={}, sort={}, fields=[], limit, offset }={}) {
|
|
131
|
+
let q = this._buildBaseQuery({query,sort,fields});
|
|
132
|
+
typeof offset === 'number' && (q = q.offset(offset));
|
|
133
|
+
typeof limit === 'number' && (q = q.limit(limit));
|
|
134
|
+
return { success:true, code:CODE_OK, msg:"查询成功", data: await q };
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/** 查询单条记录 */
|
|
138
|
+
async findOne({ query={}, fields=[] }={}) {
|
|
139
|
+
const row = await this._buildBaseQuery({query,fields}).first();
|
|
140
|
+
if (!row) return { success:false, code:CODE_NOT_FOUND, msg:"记录不存在", data:null };
|
|
141
|
+
return { success:true, code:CODE_OK, msg:"查询成功", data: row };
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/** 根据ID查询单条 */
|
|
145
|
+
async findById(id, { fields=[] }={}) {
|
|
146
|
+
return this.findOne({ query:{id}, fields });
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/** 单条插入 */
|
|
150
|
+
async insert(data={}) {
|
|
151
|
+
this._checkDB();
|
|
152
|
+
if (!Object.keys(data).length) return { success:false, code:CODE_PARAM_MISSING, msg:"参数缺失", data:{} };
|
|
153
|
+
const res = await this.db(this.tableName).insert(this.#formatDate(data));
|
|
154
|
+
return { success:true, code:CODE_OK, msg:"插入成功", data:{ insertId:res[0], affectedRows:res.length } };
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/** 批量插入 */
|
|
158
|
+
async insertMany(records=[]) {
|
|
159
|
+
this._checkDB();
|
|
160
|
+
if (!records.length) return { success:false, code:CODE_PARAM_MISSING, msg:"参数缺失", data:{} };
|
|
161
|
+
const list = records.map(r => this.#formatDate(r));
|
|
162
|
+
const res = await this.db(this.tableName).insert(list);
|
|
163
|
+
return { success:true, code:CODE_OK, msg:"批量插入成功", data:{ insertId:res[0], affectedRows:res.length } };
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/** 条件删除 */
|
|
167
|
+
async del(query={}) {
|
|
168
|
+
this._checkDB();
|
|
169
|
+
if (!Object.keys(query).length) return { success:false, code:CODE_PARAM_MISSING, msg:"参数缺失", data:{} };
|
|
170
|
+
const rows = await applyQuery(this.db(this.tableName), query).del();
|
|
171
|
+
return { success:true, code:CODE_OK, msg:"删除成功", data:{ affectedRows:rows } };
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/** 根据ID删除单条 */
|
|
175
|
+
async delById(id) {
|
|
176
|
+
return this.del({ id });
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/** ID数组批量删除 */
|
|
180
|
+
async delMany(ids=[]) {
|
|
181
|
+
this._checkDB();
|
|
182
|
+
if (!ids.length) return { success:false, code:CODE_PARAM_MISSING, msg:"参数缺失", data:{} };
|
|
183
|
+
const rows = await this.db(this.tableName).whereIn('id', ids).del();
|
|
184
|
+
return { success:true, code:CODE_OK, msg:"删除成功", data:{ affectedRows:rows } };
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
/** 按条件更新记录 */
|
|
188
|
+
async update({ query, data }={}) {
|
|
189
|
+
this._checkDB();
|
|
190
|
+
if (!query || !data || !Object.keys(query).length || !Object.keys(data).length) {
|
|
191
|
+
return { success:false, code:CODE_PARAM_INVALID, msg:"参数无效", data:{} };
|
|
192
|
+
}
|
|
193
|
+
const rows = await applyQuery(this.db(this.tableName), query).update(this.#formatDate(data));
|
|
194
|
+
return { success:true, code:CODE_OK, msg:"更新成功", data:{ affectedRows:rows } };
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
/** 根据ID更新,返回更新后完整数据 */
|
|
198
|
+
async updateById(id, data={}) {
|
|
199
|
+
this._checkDB();
|
|
200
|
+
if (!id || !Object.keys(data).length) return { success:false, code:CODE_PARAM_INVALID, msg:"参数无效", data:{} };
|
|
201
|
+
await this.db(this.tableName).where({id}).update(this.#formatDate(data));
|
|
202
|
+
return this.findById(id);
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
/** 事务批量更新,失败自动回滚 */
|
|
206
|
+
async updateMany(updates=[]) {
|
|
207
|
+
this._checkDB();
|
|
208
|
+
if (!Array.isArray(updates) || !updates.length) {
|
|
209
|
+
return { success:false, code:CODE_PARAM_MISSING, msg:"参数缺失", data:{} };
|
|
210
|
+
}
|
|
211
|
+
for (const item of updates) {
|
|
212
|
+
if (!item.query || !Object.keys(item.query).length) {
|
|
213
|
+
return { success:false, code:CODE_PARAM_INVALID, msg:"批量更新条件不能为空", data:{} };
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
const trx = await this.db.transaction();
|
|
217
|
+
let total = 0;
|
|
218
|
+
try {
|
|
219
|
+
for (const { query, data } of updates) {
|
|
220
|
+
const row = await trx(this.tableName).where(query).update(this.#formatDate(data));
|
|
221
|
+
row === 0 && logger.info("[Repository] updateMany无匹配行", query);
|
|
222
|
+
total += row;
|
|
223
|
+
}
|
|
224
|
+
await trx.commit();
|
|
225
|
+
return { success:true, code:CODE_OK, msg:"批量更新成功", data:{ affectedRows:total } };
|
|
226
|
+
} catch (err) {
|
|
227
|
+
await trx.rollback().catch(e => logger.error("[Repository] 事务回滚失败", e.message));
|
|
228
|
+
logger.error("[Repository] 批量更新异常", err.message);
|
|
229
|
+
throw err;
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
/**
|
|
234
|
+
* 分页查询核心实现
|
|
235
|
+
* 私有方法,避免 query/list 子类重写时发生互相递归
|
|
236
|
+
* @param {object} params 分页参数
|
|
237
|
+
* @returns {Promise<{success:boolean,code:number,msg:string,data:object}>}
|
|
238
|
+
*/
|
|
239
|
+
async _doPaginate({ current=1, pageSize=10, query={}, sort={}, field=[] }={}) {
|
|
240
|
+
this._checkDB();
|
|
241
|
+
const size = Math.min(Math.max(pageSize, 1), this.limit);
|
|
242
|
+
const offset = (current - 1) * size;
|
|
243
|
+
const countQ = applyQuery(this.db(this.tableName), query);
|
|
244
|
+
const [totalRow, list] = await Promise.all([
|
|
245
|
+
countQ.count("* as total").first(),
|
|
246
|
+
this._buildBaseQuery({query,sort,fields:field}).offset(offset).limit(size)
|
|
247
|
+
]);
|
|
248
|
+
const total = Number(totalRow?.total ?? 0);
|
|
249
|
+
return {
|
|
250
|
+
success:true,
|
|
251
|
+
code:CODE_OK,
|
|
252
|
+
msg:"查询成功",
|
|
253
|
+
data: {
|
|
254
|
+
list,
|
|
255
|
+
total,
|
|
256
|
+
current,
|
|
257
|
+
pageSize: size,
|
|
258
|
+
totalPages: Math.ceil(total / size)
|
|
259
|
+
}
|
|
260
|
+
};
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
/** 标准分页查询(业务侧常用别名) */
|
|
264
|
+
async query(params={}) {
|
|
265
|
+
return this._doPaginate(params);
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
/** 标准分页查询(list 别名,向后兼容) */
|
|
269
|
+
async list(params={}) {
|
|
270
|
+
return this._doPaginate(params);
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
/** 统计符合条件记录行数 */
|
|
274
|
+
async count(query={}) {
|
|
275
|
+
this._checkDB();
|
|
276
|
+
const res = await applyQuery(this.db(this.tableName), query).count("* as total").first();
|
|
277
|
+
return { success:true, code:CODE_OK, msg:"统计成功", data:{ count: Number(res?.total ?? 0) } };
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
/** 判断查询条件下记录是否存在 */
|
|
281
|
+
async exists(query={}) {
|
|
282
|
+
this._checkDB();
|
|
283
|
+
const row = await applyQuery(this.db(this.tableName), query).first();
|
|
284
|
+
return { success:true, code:CODE_OK, msg:"检查成功", data:{ exists: !!row } };
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
/** 联表join查询 */
|
|
288
|
+
async join({ joinTable, localField, foreignField, fields=[], query={}, sort={} }) {
|
|
289
|
+
this._checkDB();
|
|
290
|
+
if (!SORT_FIELD_REGEX.test(joinTable) || !SORT_FIELD_REGEX.test(localField) || !SORT_FIELD_REGEX.test(foreignField)) {
|
|
291
|
+
logger.warn(`[Repository] 非法联表/字段:${joinTable}/${localField}/${foreignField}`);
|
|
292
|
+
return { success: false, code: CODE_PARAM_INVALID, msg: "非法联表或字段", data: null };
|
|
293
|
+
}
|
|
294
|
+
const select = fields.length
|
|
295
|
+
? fields.filter(f => SORT_FIELD_REGEX.test(f) || f === '*')
|
|
296
|
+
: [`${this.tableName}.*`];
|
|
297
|
+
let q = this.db(this.tableName)
|
|
298
|
+
.join(joinTable, `${this.tableName}.${localField}`, '=', `${joinTable}.${foreignField}`)
|
|
299
|
+
.select(select);
|
|
300
|
+
q = applyQuery(q, query);
|
|
301
|
+
q = applySelectAndSort(q, [], sort);
|
|
302
|
+
return { success:true, code:CODE_OK, msg:"查询成功", data: await q };
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
/** 统计表总数量 + 今日新增数量 */
|
|
306
|
+
async stats() {
|
|
307
|
+
this._checkDB();
|
|
308
|
+
const createKey = this._dateFields.find(f => /created_at|createdAt/.test(f)) || "created_at";
|
|
309
|
+
const today = new Date();
|
|
310
|
+
today.setHours(0,0,0,0);
|
|
311
|
+
const [totalRes, todayRes] = await Promise.all([
|
|
312
|
+
this.db(this.tableName).count('* as count').first(),
|
|
313
|
+
this.db(this.tableName).where(createKey, '>=', today).count('* as count').first()
|
|
314
|
+
]);
|
|
315
|
+
return {
|
|
316
|
+
success:true,
|
|
317
|
+
code:CODE_OK,
|
|
318
|
+
msg:"统计成功",
|
|
319
|
+
data: {
|
|
320
|
+
total: Number(totalRes?.count || 0),
|
|
321
|
+
today: Number(todayRes?.count || 0)
|
|
322
|
+
}
|
|
323
|
+
};
|
|
324
|
+
}
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
export default Repository;
|