This commit is contained in:
张成
2026-04-01 13:05:27 +08:00
parent 6f61287c70
commit e9fd55666f
18 changed files with 9301 additions and 251 deletions

View File

@@ -0,0 +1,104 @@
---
description: Node CoreKoa2+Sequelize框架集成、启动与控制器约定若项目含 framework/node-core-framework.js 则遵循本规范
alwaysApply: true
---
# Node Core 规范
若项目根目录(或约定路径)存在 **`framework/node-core-framework.js`**(或等价 Node Core 打包产物),后端启动与路由须按本节执行。
## 本仓库路径对照
| 规范表述 | 本仓库 |
|---------|--------|
| 框架入口 | `./framework/node-core-framework.js` |
| 框架配置 | `./config/framework.config.js` |
| 业务后台控制器目录 | `api/controller_admin`(前缀 `/admin_api`,勿在键名里重复写前缀) |
| 小程序/开放端控制器 | `api/controller_front`(前缀 `/api` |
| 手动注册/代理等自定义 | `api/controller_custom`(仅 `app.js` 的 `beforeInitApi` 内 `addRoutes` |
| 模型关联 | `./config/model.associations.js` |
| 白名单 | `./config/config.js` → `allowUrls` |
| `beforeInitApi` 实现体 | `./config/before_init_api.js`(由 `framework.config.js` 挂到 `init` 参数) |
| 管理端通用工具(非业务域) | `api/utils/query_helpers.js`(分页/筛选/导出)、`api/utils/biz_audit.js`(审计) |
| 跨端/复杂业务 | `api/service/*`(如 `biz_auth_verify`、`biz_proxy_service`、`biz_subscription_logic`、`biz_token_logic` |
---
# Framework 启动(`app.js`
## 入口流程
1. `require('./framework/node-core-framework.js')` → `Framework.init({ ... })`
2. **配置**:展开 `require('./config/framework.config.js')`,勿在 `app.js` 内重复写数据库、端口等业务配置。
3. **模型关联**:传入 `businessAssociations: require('./config/model.associations.js')`。
4. **`beforeInitApi`**:在 `Framework.init` **resolve 之前**执行;本仓库逻辑在 **`config/before_init_api.js`**,由 **`config/framework.config.js`** 导出并随 `...config` 传入 `init`**`app.js` 只负责 `Framework.init({ ...config, businessAssociations })`**。
- 动态转发路由:在 `beforeInitApi` 内 `require('./api/controller_custom/proxy_api')` 的 `buildProxyRoutes()`,再注册两套前缀:
- `framework.addRoutes('', proxyRoutes)` — 与 OpenAPI/Swagger 文档路径一致(如 `/admin/...`
- `framework.addRoutes('/api', proxyRoutes)` — 兼容 `/api/admin/...`
- **不要**把代理路由写在 `api/controller_front`;自定义注册放在 `api/controller_custom`。
5. **`await framework.start(config.port.node)`**:真正监听 HTTP。
6. **定时任务**等依赖框架就绪的逻辑放在 `start()` 里、且 **`framework.start` 完成之后**再 `require`/`init`,例如 `middleware/schedule.js`。
## 错误处理
- `start()` 使用 `try/catch`;失败时打印错误并 `process.exit(1)`,避免进程无监听挂起。
## 修改启动行为时注意
- 新增 `addRoutes`:键格式与框架约定一致,如 `'POST /path': async (ctx) => { ... }`。
- 白名单路径在 `config/config.js` 的 `allowUrls`(框架层免 token与业务层如转发 Token 鉴权)是两段逻辑,改路由前缀时需同步 `allowUrls`。
---
## 路由体系
- **约定式控制器**`framework.config.js` 的 `apiPaths` 指向目录,扫描导出;单条路径 = `prefix` + 控制器内声明的路径。
- **内置系统管理**:后台接口固定前缀 **`/admin_api`**(由框架注册系统控制器;本仓库业务管理接口在 `api/controller_admin`,同前缀)。
- **手动路由**:仅在 **`beforeInitApi`** 内调用 `framework.addRoutes(prefix, routes)``routes` 键名格式与控制器相同。不要在 `Framework.init()` 已结束后再注册同类路由(会晚于静态路由流水线,易 404
## 鉴权与白名单
- `allowUrls`:路径**子串**匹配则免框架层 admin/applet token另有默认放行段以框架 `middleware` 为准)。
- `apiPaths` 项可设 `authType``admin` → 校验 **`admin-token`**`ctx.getAdminUserId()``applet` → **`applet-token`**`ctx.getPappletUserId()`)。
## 中间件时机
- `beforeInitApi(framework)`:在框架完成路由装配流水线中的**早阶段**执行,适合 `framework.addRoutes` 或与路由顺序相关的 `use`。
- `init` **之后**再往 `app` 追加的中间件通常位于路由之后,多数请求已被消费,一般仅兜底。
## 接口与命名(业务侧)
- 语义化 URL**优先 POST** 做变更类接口;路径片段可与项目统一为 **snake_case**(如 `/order/create`)。
- JSON 响应优先用 Context 扩展:`ctx.success` / `ctx.fail` / `ctx.tokenFail`;参数可用 `ctx.get`query+body 合并body 覆盖 query以框架实现为准
## 修改核心 / 打包注意
- 路由注册顺序以框架 `init` 内部为准;新增动态加载路径时若存在 webpack 打包,需与框架内 `__non_webpack_require__` 等行为一致。
## 控制器约定
### 导出形状
- 每个文件 `module.exports = { 'METHOD /path': handler, ... }`。
- **METHOD** 仅为 **`GET` 或 `POST`**,后接**一个空格**再写路径,例如:`POST /goods/create`、`GET /goods/detail/:id`。
- `handler` 建议为 **async** `(ctx, next) => { ... }`;需继续管道时 `await next()`。
### 管理端(本仓库)
- 文件在 **`api/controller_admin`**;对外完整路径 = **`/admin_api` + 键名中的路径**,键名里**不要**再写 `/admin_api`。
- 需登录:请求头 `admin-token: <jwt>`。
### 业务/开放端(本仓库)
- `api/controller_front``apiPaths` 中 `prefix: '/api'``authType: 'applet'`。
### 常见 Context API以框架为准
- 参数:`ctx.get('id')`、`ctx.getBody()`、`ctx.getQuery()`、`ctx.getPageSize()` 等。
- 响应:`ctx.success(data, msg)`、`ctx.fail(msg)`、`ctx.tokenFail()`、`ctx.json(code, message, data)`。
- 鉴权:`ctx.getAdminUserId()`、`ctx.getPappletUserId()`。
### 避免
- 不要用 **`PUT` / `DELETE`** 等作为导出键(框架只按 **GET/POST** 注册)。
- 不要把 **`addRoutes`** 散落到 `init` 完成后的随意位置;统一放 **`beforeInitApi`**。

9028
_docs/swagger.json Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -1,9 +1,21 @@
const stats = require("../service/biz_api_stats_service");
const Sequelize = require("sequelize");
const { Op } = Sequelize;
const baseModel = require("../../middleware/baseModel");
const { find_page } = require("../service/biz_query_helpers");
const { find_page } = require("../utils/query_helpers");
function build_date_where(start_date, end_date) {
const where = {};
if (start_date && end_date) {
where.call_date = { [Op.between]: [start_date, end_date] };
} else if (start_date) {
where.call_date = { [Op.gte]: start_date };
} else if (end_date) {
where.call_date = { [Op.lte]: end_date };
}
return where;
}
module.exports = {
/** 按用户查询调用统计 */
"POST /biz_api_stats/by_user": async (ctx) => {
const body = ctx.getBody();
const { user_id, start_date, end_date } = body;
@@ -11,11 +23,22 @@ module.exports = {
ctx.fail("缺少 user_id");
return;
}
const data = await stats.getStatsByUser(user_id, start_date, end_date);
ctx.success(data);
const where = { user_id, ...build_date_where(start_date, end_date) };
const rows = await baseModel.biz_api_call_log.findAll({
attributes: [
"api_path",
[baseModel.Sequelize.fn("COUNT", baseModel.Sequelize.col("id")), "call_count"],
[baseModel.Sequelize.fn("AVG", baseModel.Sequelize.col("response_time")), "avg_response_time"],
],
where,
group: ["api_path"],
order: [[baseModel.Sequelize.literal("call_count"), "DESC"]],
raw: true,
});
const total = rows.reduce((s, r) => s + Number(r.call_count), 0);
ctx.success({ total, rows });
},
/** 按接口路径查询调用统计 */
"POST /biz_api_stats/by_api": async (ctx) => {
const body = ctx.getBody();
const { api_path, start_date, end_date } = body;
@@ -23,19 +46,65 @@ module.exports = {
ctx.fail("缺少 api_path");
return;
}
const data = await stats.getStatsByApi(api_path, start_date, end_date);
ctx.success(data);
const where = { api_path, ...build_date_where(start_date, end_date) };
const rows = await baseModel.biz_api_call_log.findAll({
attributes: [
"user_id",
[baseModel.Sequelize.fn("COUNT", baseModel.Sequelize.col("id")), "call_count"],
[baseModel.Sequelize.fn("AVG", baseModel.Sequelize.col("response_time")), "avg_response_time"],
],
where,
group: ["user_id"],
order: [[baseModel.Sequelize.literal("call_count"), "DESC"]],
raw: true,
});
const total = rows.reduce((s, r) => s + Number(r.call_count), 0);
ctx.success({ total, rows });
},
/** 综合统计面板 */
"POST /biz_api_stats/summary": async (ctx) => {
const body = ctx.getBody();
const { start_date, end_date, top_limit } = body;
const data = await stats.getSummary(start_date, end_date, top_limit || 10);
ctx.success(data);
const dateWhere = build_date_where(start_date, end_date);
const Seq = baseModel.Sequelize;
const lim = top_limit || 10;
const totalResult = await baseModel.biz_api_call_log.count({ where: dateWhere });
const daily_trend = await baseModel.biz_api_call_log.findAll({
attributes: ["call_date", [Seq.fn("COUNT", Seq.col("id")), "call_count"]],
where: dateWhere,
group: ["call_date"],
order: [["call_date", "ASC"]],
raw: true,
});
const top_apis = await baseModel.biz_api_call_log.findAll({
attributes: ["api_path", [Seq.fn("COUNT", Seq.col("id")), "call_count"]],
where: dateWhere,
group: ["api_path"],
order: [[Seq.literal("call_count"), "DESC"]],
limit: lim,
raw: true,
});
const top_users = await baseModel.biz_api_call_log.findAll({
attributes: ["user_id", [Seq.fn("COUNT", Seq.col("id")), "call_count"]],
where: dateWhere,
group: ["user_id"],
order: [[Seq.literal("call_count"), "DESC"]],
limit: lim,
raw: true,
});
ctx.success({
total_calls: totalResult,
daily_trend,
top_apis,
top_users,
});
},
/** 调用日志分页列表 */
"POST /biz_api_call_log/page": async (ctx) => {
const body = ctx.getBody();
const { count, rows } = await find_page(baseModel.biz_api_call_log, "biz_api_call_log", body);

View File

@@ -1,5 +1,5 @@
const baseModel = require("../../middleware/baseModel");
const { find_page, find_for_export } = require("../service/biz_query_helpers");
const { find_page, find_for_export } = require("../utils/query_helpers");
module.exports = {
"POST /biz_audit_log/page": async (ctx) => {

View File

@@ -1,8 +1,48 @@
const dashboard = require("../service/biz_dashboard_service");
const Sequelize = require("sequelize");
const { Op } = Sequelize;
const baseModel = require("../../middleware/baseModel");
module.exports = {
"GET /biz_dashboard/summary": async (ctx) => {
const data = await dashboard.summary();
ctx.success(data);
const now = new Date();
const in7 = new Date(now.getTime() + 7 * 24 * 60 * 60 * 1000);
const [
userTotal,
userActive,
planActive,
subPending,
subActive,
subExpired,
tokenActive,
renewSoon,
] = await Promise.all([
baseModel.biz_user.count(),
baseModel.biz_user.count({ where: { status: "active" } }),
baseModel.biz_plan.count({ where: { status: "active" } }),
baseModel.biz_subscription.count({ where: { status: "pending" } }),
baseModel.biz_subscription.count({ where: { status: "active" } }),
baseModel.biz_subscription.count({ where: { status: "expired" } }),
baseModel.biz_api_token.count({ where: { status: "active" } }),
baseModel.biz_subscription.count({
where: {
status: "active",
end_time: { [Op.between]: [now, in7] },
},
}),
]);
ctx.success({
users: { total: userTotal, active: userActive },
plans: { active: planActive },
subscriptions: {
pending: subPending,
active: subActive,
expired: subExpired,
renew_within_7d: renewSoon,
},
tokens: { active: tokenActive },
server_time: now.toISOString(),
});
},
};

View File

@@ -1,5 +1,5 @@
const logic = require("../service/biz_subscription_logic");
const audit = require("../service/biz_audit_service");
const audit = require("../utils/biz_audit");

View File

@@ -1,6 +1,6 @@
const baseModel = require("../../middleware/baseModel");
const { find_page, find_for_export, normalize_for_write } = require("../service/biz_query_helpers");
const audit = require("../service/biz_audit_service");
const { find_page, find_for_export, normalize_for_write } = require("../utils/query_helpers");
const audit = require("../utils/biz_audit");
module.exports = {
"POST /biz_plan/page": async (ctx) => {

View File

@@ -1,7 +1,7 @@
const baseModel = require("../../middleware/baseModel");
const { find_page, find_for_export } = require("../service/biz_query_helpers");
const { find_page, find_for_export } = require("../utils/query_helpers");
const logic = require("../service/biz_subscription_logic");
const audit = require("../service/biz_audit_service");
const audit = require("../utils/biz_audit");
module.exports = {
"POST /biz_subscription/page": async (ctx) => {

View File

@@ -1,7 +1,7 @@
const baseModel = require("../../middleware/baseModel");
const { find_page, find_for_export } = require("../service/biz_query_helpers");
const { find_page, find_for_export } = require("../utils/query_helpers");
const tokenLogic = require("../service/biz_token_logic");
const audit = require("../service/biz_audit_service");
const audit = require("../utils/biz_audit");
module.exports = {
"POST /biz_token/page": async (ctx) => {

View File

@@ -1,5 +1,5 @@
const baseModel = require("../../middleware/baseModel");
const { find_page, find_for_export, normalize_for_write } = require("../service/biz_query_helpers");
const { find_page, find_for_export, normalize_for_write } = require("../utils/query_helpers");
module.exports = {
"POST /biz_usage/page": async (ctx) => {

View File

@@ -1,8 +1,8 @@
const Sequelize = require("sequelize");
const { find_for_export, normalize_for_write, build_search_where } = require("../service/biz_query_helpers");
const { find_for_export, normalize_for_write, build_search_where } = require("../utils/query_helpers");
const baseModel = require("../../middleware/baseModel");
const tokenLogic = require("../service/biz_token_logic");
const audit = require("../service/biz_audit_service");
const audit = require("../utils/biz_audit");
module.exports = {
"POST /biz_user/page": async (ctx) => {

View File

@@ -1,126 +0,0 @@
const baseModel = require("../../middleware/baseModel");
const { op } = baseModel;
/**
* 构建日期范围 where 条件
*/
function buildDateWhere(start_date, end_date) {
const where = {};
if (start_date && end_date) {
where.call_date = { [op.between]: [start_date, end_date] };
} else if (start_date) {
where.call_date = { [op.gte]: start_date };
} else if (end_date) {
where.call_date = { [op.lte]: end_date };
}
return where;
}
/**
* 按用户统计调用量(按接口路径分组)
* @param {number} user_id
* @param {string} [start_date] - YYYY-MM-DD
* @param {string} [end_date] - YYYY-MM-DD
*/
async function getStatsByUser(user_id, start_date, end_date) {
const where = { user_id, ...buildDateWhere(start_date, end_date) };
const rows = await baseModel.biz_api_call_log.findAll({
attributes: [
"api_path",
[baseModel.Sequelize.fn("COUNT", baseModel.Sequelize.col("id")), "call_count"],
[baseModel.Sequelize.fn("AVG", baseModel.Sequelize.col("response_time")), "avg_response_time"],
],
where,
group: ["api_path"],
order: [[baseModel.Sequelize.literal("call_count"), "DESC"]],
raw: true,
});
const total = rows.reduce((s, r) => s + Number(r.call_count), 0);
return { total, rows };
}
/**
* 按接口路径统计调用量(按用户分组)
* @param {string} api_path
* @param {string} [start_date]
* @param {string} [end_date]
*/
async function getStatsByApi(api_path, start_date, end_date) {
const where = { api_path, ...buildDateWhere(start_date, end_date) };
const rows = await baseModel.biz_api_call_log.findAll({
attributes: [
"user_id",
[baseModel.Sequelize.fn("COUNT", baseModel.Sequelize.col("id")), "call_count"],
[baseModel.Sequelize.fn("AVG", baseModel.Sequelize.col("response_time")), "avg_response_time"],
],
where,
group: ["user_id"],
order: [[baseModel.Sequelize.literal("call_count"), "DESC"]],
raw: true,
});
const total = rows.reduce((s, r) => s + Number(r.call_count), 0);
return { total, rows };
}
/**
* 综合统计面板总调用量、按天趋势、Top 接口、Top 用户
* @param {string} [start_date]
* @param {string} [end_date]
* @param {number} [top_limit=10]
*/
async function getSummary(start_date, end_date, top_limit = 10) {
const dateWhere = buildDateWhere(start_date, end_date);
const Seq = baseModel.Sequelize;
// 总调用量
const totalResult = await baseModel.biz_api_call_log.count({ where: dateWhere });
// 按天趋势
const daily_trend = await baseModel.biz_api_call_log.findAll({
attributes: [
"call_date",
[Seq.fn("COUNT", Seq.col("id")), "call_count"],
],
where: dateWhere,
group: ["call_date"],
order: [["call_date", "ASC"]],
raw: true,
});
// Top 接口
const top_apis = await baseModel.biz_api_call_log.findAll({
attributes: [
"api_path",
[Seq.fn("COUNT", Seq.col("id")), "call_count"],
],
where: dateWhere,
group: ["api_path"],
order: [[Seq.literal("call_count"), "DESC"]],
limit: top_limit,
raw: true,
});
// Top 用户
const top_users = await baseModel.biz_api_call_log.findAll({
attributes: [
"user_id",
[Seq.fn("COUNT", Seq.col("id")), "call_count"],
],
where: dateWhere,
group: ["user_id"],
order: [[Seq.literal("call_count"), "DESC"]],
limit: top_limit,
raw: true,
});
return {
total_calls: totalResult,
daily_trend,
top_apis,
top_users,
};
}
module.exports = { getStatsByUser, getStatsByApi, getSummary };

View File

@@ -1,47 +0,0 @@
const baseModel = require("../../middleware/baseModel");
const { op } = baseModel;
async function summary() {
const now = new Date();
const in7 = new Date(now.getTime() + 7 * 24 * 60 * 60 * 1000);
const [
userTotal,
userActive,
planActive,
subPending,
subActive,
subExpired,
tokenActive,
renewSoon,
] = await Promise.all([
baseModel.biz_user.count(),
baseModel.biz_user.count({ where: { status: "active" } }),
baseModel.biz_plan.count({ where: { status: "active" } }),
baseModel.biz_subscription.count({ where: { status: "pending" } }),
baseModel.biz_subscription.count({ where: { status: "active" } }),
baseModel.biz_subscription.count({ where: { status: "expired" } }),
baseModel.biz_api_token.count({ where: { status: "active" } }),
baseModel.biz_subscription.count({
where: {
status: "active",
end_time: { [op.between]: [now, in7] },
},
}),
]);
return {
users: { total: userTotal, active: userActive },
plans: { active: planActive },
subscriptions: {
pending: subPending,
active: subActive,
expired: subExpired,
renew_within_7d: renewSoon,
},
tokens: { active: tokenActive },
server_time: now.toISOString(),
};
}
module.exports = { summary };

View File

@@ -3,13 +3,6 @@ const logs = require("../../tool/logs_proxy");
/**
* 记录审计失败不影响主流程
* @param {object} p
* @param {number} [p.admin_user_id]
* @param {number} [p.biz_user_id]
* @param {string} p.action
* @param {string} [p.resource_type]
* @param {number} [p.resource_id]
* @param {object} [p.detail]
*/
async function logAudit(p) {
try {

View File

@@ -1,5 +1,5 @@
/**
* 列表筛选写入字段裁剪审计日志列表补列 admin 控制器直接配合 baseModel 使用
* 管理端列表筛选导出写入字段裁剪 controller_admin 配合 baseModel
*/
const Sequelize = require("sequelize");
const { Op } = Sequelize;

54
app.js
View File

@@ -1,60 +1,34 @@
/**
* 后端应用入口(模板项目
* 使用 node-core-framework 框架
* 后端应用入口 — node-core-framework约定见 .cursor/rules/node-core-framework.mdc
*/
const Framework = require('./framework/node-core-framework.js');
const config = require('./config/framework.config.js');
const businessAssociations = require('./config/model.associations.js');
// 定时任务(在 Framework 初始化后再加载)
const Framework = require("./framework/node-core-framework.js");
const config = require("./config/framework.config.js");
const businessAssociations = require("./config/model.associations.js");
async function start() {
try {
console.log('🚀 正在启动应用...\n');
console.log('⚙️ 正在初始化框架...');
console.log("🚀 正在启动应用...\n");
console.log("⚙️ 正在初始化框架...");
const framework = await Framework.init({
...config,
businessAssociations,
beforeInitApi: async (framework) => {
const { buildProxyRoutes } = require('./api/controller_custom/proxy_api');
// 从 swagger.json 动态注册 193 个转发路由到 /api 前缀
const proxyRoutes = buildProxyRoutes();
const n = Object.keys(proxyRoutes).length;
// 与 swagger 文档一致:/admin/...、/login/...
framework.addRoutes("", proxyRoutes);
// 兼容历史调用:/api/admin/...
framework.addRoutes("/api", proxyRoutes);
console.log(`📡 已注册 ${n} 个转发接口(文档路径 + /api 前缀各一套)`);
}
});
await framework.start(config.port.node);
const schedule = require('./middleware/schedule.js');
// if (config.env !== 'development') {
await schedule.init();
// }
const schedule = require("./middleware/schedule.js");
await schedule.init();
console.log(`\n📚 API 文档: http://localhost:${config.port.node}/api/docs`);
} catch (error) {
console.error('\n' + '='.repeat(60));
console.error('❌ 应用启动失败!');
console.error('='.repeat(60));
console.error('错误信息:', error.message);
console.error('错误堆栈:', error.stack);
console.error('='.repeat(60) + '\n');
console.error("\n" + "=".repeat(60));
console.error("❌ 应用启动失败!");
console.error("=".repeat(60));
console.error("错误信息:", error.message);
console.error("错误堆栈:", error.stack);
console.error("=".repeat(60) + "\n");
process.exit(1);
}
}

11
config/before_init_api.js Normal file
View File

@@ -0,0 +1,11 @@
/**
* Framework.init 的 beforeInitApi在 _setupRoutes 之前注册手动路由(规范见 .cursor/rules/node-core-framework.mdc
*/
module.exports = async function beforeInitApi(framework) {
const { buildProxyRoutes } = require("../api/controller_custom/proxy_api");
const proxy_routes = buildProxyRoutes();
const n = Object.keys(proxy_routes).length;
framework.addRoutes("", proxy_routes);
framework.addRoutes("/api", proxy_routes);
console.log(`📡 已注册 ${n} 个转发接口(文档路径 + /api 前缀各一套)`);
};

View File

@@ -3,8 +3,9 @@
* 基于 node-core-framework 的配置
*/
const baseConfig = require('./config.js');
const customSchemas = require('./custom.schemas.js');
const baseConfig = require("./config.js");
const customSchemas = require("./custom.schemas.js");
const beforeInitApi = require("./before_init_api");
module.exports = {
// ===== 必需配置 =====
@@ -119,6 +120,9 @@ module.exports = {
// 自定义 Swagger Schemas
customSchemas: customSchemas
customSchemas: customSchemas,
// 路由流水线早期回调:注册转发等 addRoutes勿在 init 之后散落注册)
beforeInitApi,
};