Compare commits

...

26 Commits

Author SHA1 Message Date
张成
50bb0bc6ad 1 2026-04-01 15:02:45 +08:00
张成
38430c9244 1 2026-04-01 14:47:34 +08:00
张成
4c724143c0 1 2026-04-01 14:42:22 +08:00
张成
084c437096 1 2026-04-01 14:23:57 +08:00
张成
09368d2a95 1 2026-04-01 14:16:06 +08:00
张成
f810f60e3f 1 2026-04-01 13:55:48 +08:00
张成
a934d5b239 1 2026-04-01 13:54:13 +08:00
张成
2d900ef2ac 1 2026-04-01 13:42:29 +08:00
张成
1d22fb28e2 1 2026-04-01 13:40:27 +08:00
张成
d03916290a 1 2026-04-01 13:26:41 +08:00
张成
fa9abf83ae 1 2026-04-01 13:22:56 +08:00
张成
82432cdba8 1 2026-04-01 13:12:40 +08:00
张成
e9fd55666f 1 2026-04-01 13:05:27 +08:00
张成
6f61287c70 1 2026-04-01 11:03:40 +08:00
张成
30a909762e 1 2026-04-01 10:58:28 +08:00
张成
ce2521cadc 1 2026-04-01 10:53:49 +08:00
张成
03c5579c86 1 2026-04-01 10:53:26 +08:00
张成
c2205188d1 1 2026-04-01 10:46:19 +08:00
张成
433077f08a 1 2026-04-01 10:42:33 +08:00
张成
7199c6b5cf 1 2026-04-01 10:37:51 +08:00
张成
494555a6e1 1 2026-04-01 10:13:22 +08:00
张成
14f5d75d9d 1 2026-04-01 09:59:54 +08:00
张成
aac2d4a8d5 1 2026-03-27 15:18:33 +08:00
张成
c3aab075d9 1 2026-03-27 14:09:49 +08:00
张成
1f4b39d576 1 2026-03-27 13:30:53 +08:00
张成
2f04459492 1 2026-03-27 13:14:10 +08:00
74 changed files with 8271 additions and 1306 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`(仅 `build_search_where` / `normalize_for_write`;分页与导出在各 controller 内 `findAndCountAll` / `findAll`)、`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`**。

18
.vscode/launch.json vendored
View File

@@ -1,13 +1,13 @@
{
"configurations": [
{
"name": "Launch Program",
"program": "${workspaceFolder}/app.js",
"request": "launch",
"skipFiles": [
"<node_internals>/**"
],
"type": "node"
}
{
"name": "启动后端",
"program": "${workspaceFolder}/app.js",
"request": "launch",
"skipFiles": [
"<node_internals>/**"
],
"type": "node"
}
]
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,392 @@
/**
* 将管理端 / 开放鉴权接口合并进 swagger.json运行: node _docs/merge_biz_into_swagger.js
*/
const fs = require("fs");
const path = require("path");
const swaggerPath = path.join(__dirname, "swagger.json");
const doc = JSON.parse(fs.readFileSync(swaggerPath, "utf8"));
const TAG_ADMIN = ["管理端-业务订阅"];
const TAG_OPEN_AUTH = ["开放接口-鉴权"];
function res200() {
return { 200: { description: "框架统一包装;成功时 code=0业务数据在 data" } };
}
function post(summary, ref, tags = TAG_ADMIN) {
const params = [
{
in: "body",
name: "body",
schema: ref ? { $ref: `#/definitions/${ref}` } : { type: "object", description: "JSON 请求体" },
},
];
return { post: { tags, summary, parameters: params, responses: res200() } };
}
function postEmpty(summary, tags = TAG_ADMIN) {
return { post: { tags, summary, parameters: [], responses: res200() } };
}
function get(summary, queryList, tags = TAG_ADMIN) {
return {
get: {
tags,
summary,
parameters: queryList.map((q) => ({
in: "query",
name: q.n,
type: q.t || "string",
required: q.req !== false,
description: q.d || "",
})),
responses: res200(),
},
};
}
const definitions = {
BizAdminPageRequest: {
type: "object",
title: "BizAdminPageRequest",
description:
"通用分页/筛选body 可直接为 param或形如 { param: { pageOption, seachOption } }",
properties: {
param: {
type: "object",
properties: {
pageOption: {
type: "object",
properties: {
page: { type: "integer", example: 1 },
pageSize: { type: "integer", example: 20 },
},
},
seachOption: {
type: "object",
properties: {
key: { type: "string", description: "与模型字段名一致,如 user_id、status" },
value: { type: "string" },
},
},
},
},
},
},
BizIdRequest: {
type: "object",
title: "BizIdRequest",
properties: { id: { type: "integer", format: "int64" } },
},
BizUserRevokeTokensRequest: {
type: "object",
title: "BizUserRevokeTokensRequest",
properties: {
user_id: { type: "integer", format: "int64", description: "与 id 二选一" },
id: { type: "integer", format: "int64" },
},
},
BizUserAddRequest: {
type: "object",
title: "BizUserAddRequest",
description: "写入字段见 biz_user 模型;可选自动创建 Token",
properties: {
name: { type: "string" },
mobile: { type: "string" },
email: { type: "string" },
company_name: { type: "string" },
status: { type: "string", enum: ["active", "disabled"] },
auto_create_token: { type: "boolean", default: true },
initial_token_name: { type: "string" },
initial_token_expire_at: { type: "string", description: "如 2026-12-31 23:59:59" },
},
},
BizUserEditRequest: {
type: "object",
title: "BizUserEditRequest",
properties: {
id: { type: "integer", format: "int64" },
name: { type: "string" },
mobile: { type: "string" },
email: { type: "string" },
company_name: { type: "string" },
status: { type: "string", enum: ["active", "disabled"] },
},
required: ["id"],
},
BizSubscriptionOpenRequest: {
type: "object",
title: "BizSubscriptionOpenRequest",
required: ["user_id", "plan_id", "start_time", "end_time"],
properties: {
user_id: { type: "integer", format: "int64" },
plan_id: { type: "integer", format: "int64" },
start_time: { type: "string" },
end_time: { type: "string" },
status: { type: "string", enum: ["pending", "active", "expired", "cancelled"] },
renew_mode: { type: "string", enum: ["manual", "auto"] },
payment_channel: { type: "string", enum: ["offline", "pay_link"] },
payment_ref: { type: "string" },
},
},
BizSubscriptionUpgradeRequest: {
type: "object",
title: "BizSubscriptionUpgradeRequest",
required: ["subscription_id", "new_plan_id"],
properties: {
subscription_id: { type: "integer", format: "int64" },
new_plan_id: { type: "integer", format: "int64" },
start_time: { type: "string" },
end_time: { type: "string" },
},
},
BizSubscriptionRenewRequest: {
type: "object",
title: "BizSubscriptionRenewRequest",
required: ["subscription_id", "end_time"],
properties: {
subscription_id: { type: "integer", format: "int64" },
end_time: { type: "string" },
},
},
BizSubscriptionCancelRequest: {
type: "object",
title: "BizSubscriptionCancelRequest",
required: ["subscription_id"],
properties: { subscription_id: { type: "integer", format: "int64" } },
},
BizPaymentConfirmRequest: {
type: "object",
title: "BizPaymentConfirmRequest",
required: ["subscription_id"],
properties: {
subscription_id: { type: "integer", format: "int64" },
payment_ref: { type: "string" },
},
},
BizTokenCreateRequest: {
type: "object",
title: "BizTokenCreateRequest",
required: ["user_id", "expire_at"],
properties: {
user_id: { type: "integer", format: "int64" },
key: { type: "string", description: "账号唯一标识,可选" },
token_name: { type: "string" },
expire_at: { type: "string" },
},
},
BizTokenEditRequest: {
type: "object",
title: "BizTokenEditRequest",
required: ["id"],
properties: {
id: { type: "integer", format: "int64" },
key: { type: "string", description: "可空表示清空" },
token_name: { type: "string" },
expire_at: { type: "string" },
},
},
BizTokenIdRequest: {
type: "object",
title: "BizTokenIdRequest",
required: ["id"],
properties: { id: { type: "integer", format: "int64" } },
},
BizApiStatsByUserRequest: {
type: "object",
title: "BizApiStatsByUserRequest",
required: ["user_id"],
properties: {
user_id: { type: "integer", format: "int64" },
start_date: { type: "string", description: "DATEONLY YYYY-MM-DD" },
end_date: { type: "string" },
},
},
BizApiStatsByApiRequest: {
type: "object",
title: "BizApiStatsByApiRequest",
required: ["api_path"],
properties: {
api_path: { type: "string" },
start_date: { type: "string" },
end_date: { type: "string" },
},
},
BizApiStatsSummaryRequest: {
type: "object",
title: "BizApiStatsSummaryRequest",
properties: {
start_date: { type: "string" },
end_date: { type: "string" },
top_limit: { type: "integer", example: 10 },
},
},
BizUsageWriteRequest: {
type: "object",
title: "BizUsageWriteRequest",
description: "月度用量 biz_usage_monthly 字段",
properties: {
id: { type: "integer", format: "int64", description: "edit 必填" },
user_id: { type: "integer", format: "int64" },
plan_id: { type: "integer", format: "int64" },
stat_month: { type: "string", example: "2026-04" },
msg_count: { type: "integer" },
mass_count: { type: "integer" },
friend_count: { type: "integer" },
sns_count: { type: "integer" },
active_user_count: { type: "integer" },
api_call_count: { type: "integer" },
},
},
BizAuthVerifyRequest: {
type: "object",
title: "BizAuthVerifyRequest",
required: ["token"],
properties: {
token: { type: "string", description: "明文 API Token" },
feature: { type: "string", description: "swagger 路径对应 tags[0],用于套餐功能点" },
api_path: { type: "string", description: "请求的接口 path用于 allowed_apis 校验" },
usage_delta: {
type: "object",
description: "可选用量上报",
properties: {
msg: { type: "integer" },
mass: { type: "integer" },
friend: { type: "integer" },
sns: { type: "integer" },
active_user: { type: "integer" },
},
},
},
},
BizPlanWriteRequest: {
type: "object",
title: "BizPlanWriteRequest",
description: "biz_plan 表字段add 不需 idedit 需 id",
properties: {
id: { type: "integer", format: "int64" },
plan_code: { type: "string" },
plan_name: { type: "string" },
monthly_price: { type: "number" },
auth_fee: { type: "number" },
account_limit: { type: "integer" },
active_user_limit: { type: "integer" },
msg_quota: { type: "integer" },
mass_quota: { type: "integer" },
friend_quota: { type: "integer" },
sns_quota: { type: "integer" },
allowed_apis: { description: "JSON路径字符串数组", type: "array", items: { type: "string" } },
api_call_quota: { type: "integer" },
enabled_features: { type: "object", description: "JSON 功能点开关" },
status: { type: "string", enum: ["active", "inactive"] },
},
},
};
const paths = {};
paths["/admin_api/biz_api_call_log/page"] = post("API 调用明细分页", "BizAdminPageRequest");
paths["/admin_api/biz_api_call_log/export"] = post("API 调用明细导出", "BizAdminPageRequest");
paths["/admin_api/biz_api_stats/by_user"] = post("按用户统计接口调用", "BizApiStatsByUserRequest");
paths["/admin_api/biz_api_stats/by_api"] = post("按接口路径统计调用", "BizApiStatsByApiRequest");
paths["/admin_api/biz_api_stats/summary"] = post("调用量汇总与趋势", "BizApiStatsSummaryRequest");
paths["/admin_api/biz_audit_log/page"] = post("审计日志分页", "BizAdminPageRequest");
paths["/admin_api/biz_audit_log/export"] = post("审计日志导出", "BizAdminPageRequest");
paths["/admin_api/biz_dashboard/summary"] = get("订阅/用户/Token 看板汇总", [], TAG_ADMIN);
paths["/admin_api/biz_payment/confirm-offline"] = post("确认线下支付(订阅置 active", "BizPaymentConfirmRequest");
paths["/admin_api/biz_payment/confirm-link"] = post("确认链接支付", "BizPaymentConfirmRequest");
paths["/admin_api/biz_plan/page"] = post("套餐分页", "BizAdminPageRequest");
paths["/admin_api/biz_plan/add"] = post("新增套餐", "BizPlanWriteRequest");
paths["/admin_api/biz_plan/edit"] = post("编辑套餐", "BizPlanWriteRequest");
paths["/admin_api/biz_plan/del"] = post("删除套餐", "BizIdRequest");
paths["/admin_api/biz_plan/detail"] = get("套餐详情", [{ n: "id", req: true, d: "套餐 id" }]);
paths["/admin_api/biz_plan/all"] = get("套餐列表(下拉,最多 2000 条)", []);
paths["/admin_api/biz_plan/toggle"] = post("上下线切换", "BizIdRequest");
paths["/admin_api/biz_plan/export"] = post("套餐导出", "BizAdminPageRequest");
paths["/admin_api/biz_plan/proxy_api_catalog"] = postEmpty("转发接口目录(配置 allowed_apis");
paths["/admin_api/biz_subscription/page"] = post("订阅分页(含 user_name、plan_name", "BizAdminPageRequest");
paths["/admin_api/biz_subscription/detail"] = get("订阅详情", [{ n: "id", req: true }]);
paths["/admin_api/biz_subscription/by_user"] = get("某用户订阅列表", [
{ n: "user_id", req: true, d: "业务用户 id" },
]);
paths["/admin_api/biz_subscription/open"] = post("开通订阅", "BizSubscriptionOpenRequest");
paths["/admin_api/biz_subscription/upgrade"] = post("变更套餐/时间", "BizSubscriptionUpgradeRequest");
paths["/admin_api/biz_subscription/renew"] = post("续费(更新结束时间)", "BizSubscriptionRenewRequest");
paths["/admin_api/biz_subscription/cancel"] = post("取消订阅", "BizSubscriptionCancelRequest");
paths["/admin_api/biz_subscription/export"] = post("订阅导出 CSV 数据", "BizAdminPageRequest");
paths["/admin_api/biz_token/page"] = post("API Token 分页(不含 secret_cipher", "BizAdminPageRequest");
paths["/admin_api/biz_token/create"] = post("创建 Token返回 plain_token", "BizTokenCreateRequest");
paths["/admin_api/biz_token/edit"] = post("编辑 Token名称/key/过期时间,不改密钥)", "BizTokenEditRequest");
paths["/admin_api/biz_token/revoke"] = post("吊销 Token", "BizTokenIdRequest");
paths["/admin_api/biz_token/regenerate"] = post("重新生成密钥(返回新 plain_token", "BizTokenIdRequest");
paths["/admin_api/biz_token/export"] = post("Token 导出", "BizAdminPageRequest");
paths["/admin_api/biz_usage/page"] = post("月度用量分页", "BizAdminPageRequest");
paths["/admin_api/biz_usage/add"] = post("新增用量记录", "BizUsageWriteRequest");
paths["/admin_api/biz_usage/edit"] = post("编辑用量记录", "BizUsageWriteRequest");
paths["/admin_api/biz_usage/del"] = post("删除用量记录", "BizIdRequest");
paths["/admin_api/biz_usage/detail"] = get("用量详情", [{ n: "id", req: true }]);
paths["/admin_api/biz_usage/export"] = post("用量导出", "BizAdminPageRequest");
paths["/admin_api/biz_user/page"] = post("业务用户分页(含 token_count", "BizAdminPageRequest");
paths["/admin_api/biz_user/add"] = post("新增用户(可选自动创建 Token", "BizUserAddRequest");
paths["/admin_api/biz_user/edit"] = post("编辑用户", "BizUserEditRequest");
paths["/admin_api/biz_user/del"] = post("删除用户", "BizIdRequest");
paths["/admin_api/biz_user/detail"] = get("用户详情(含 subscriptions、tokens[].plain_token", [{ n: "id", req: true }]);
paths["/admin_api/biz_user/all"] = get("全部用户(下拉)", []);
paths["/admin_api/biz_user/disable"] = post("禁用用户", "BizIdRequest");
paths["/admin_api/biz_user/enable"] = post("启用用户", "BizIdRequest");
paths["/admin_api/biz_user/export"] = post("用户导出", "BizAdminPageRequest");
paths["/admin_api/biz_user/revoke_all_tokens"] = post("吊销用户下全部 Token", "BizUserRevokeTokensRequest");
paths["/admin_api/sys_file/upload_img"] = {
post: {
tags: TAG_ADMIN,
summary: "本地上传图片multipart/form-data",
consumes: ["multipart/form-data"],
parameters: [],
responses: res200(),
},
};
paths["/admin_api/sys_file/upload_oos_img"] = {
post: {
tags: TAG_ADMIN,
summary: "上传图片到 OSSmultipart",
consumes: ["multipart/form-data"],
parameters: [],
responses: res200(),
},
};
paths["/api/auth/verify"] = {
post: {
tags: TAG_OPEN_AUTH,
summary: "对外开放Token 鉴权校验(含订阅/套餐/用量等)",
parameters: [
{
in: "body",
name: "body",
schema: { $ref: "#/definitions/BizAuthVerifyRequest" },
},
],
responses: res200(),
},
};
Object.assign(doc.definitions, definitions);
Object.assign(doc.paths, paths);
const extraTags = [
{ name: "管理端-业务订阅", description: "Base URL + /admin_api需管理端登录" },
{ name: "开放接口-鉴权", description: "Base URL + /api如 /api/auth/verify" },
];
for (const t of extraTags) {
if (!doc.tags.some((x) => x.name === t.name)) {
doc.tags.push(t);
}
}
fs.writeFileSync(swaggerPath, JSON.stringify(doc, null, 4) + "\n", "utf8");
console.log("merged admin biz paths + definitions into swagger.json");

View File

@@ -1,102 +0,0 @@
-- WechatAdminWeb 订阅模块业务表MySQL 8+
-- 业务用户物理表名biz_user与 Sequelize 模型 biz_user、freezeTableName 一致)
-- 执行前请确认库名;与 api/model/biz_*.js 字段一致
SET NAMES utf8mb4;
-- 业务用户(与 sys_user 后台账号区分)
CREATE TABLE IF NOT EXISTS `biz_user` (
`id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
`name` VARCHAR(100) NOT NULL DEFAULT '',
`mobile` VARCHAR(20) NULL DEFAULT NULL,
`email` VARCHAR(120) NULL DEFAULT NULL,
`company_name` VARCHAR(200) NULL DEFAULT NULL,
`status` ENUM('active', 'disabled') NOT NULL DEFAULT 'active',
`created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
`updated_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
PRIMARY KEY (`id`),
KEY `idx_biz_user_mobile` (`mobile`),
KEY `idx_biz_user_status` (`status`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='业务用户';
-- 套餐
CREATE TABLE IF NOT EXISTS `biz_plans` (
`id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
`plan_code` VARCHAR(64) NOT NULL DEFAULT '',
`plan_name` VARCHAR(128) NOT NULL DEFAULT '',
`monthly_price` DECIMAL(12, 2) NOT NULL DEFAULT 0,
`auth_fee` DECIMAL(12, 2) NOT NULL DEFAULT 0,
`account_limit` INT NOT NULL DEFAULT 0,
`active_user_limit` INT NOT NULL DEFAULT 0,
`msg_quota` INT NOT NULL DEFAULT 0,
`mass_quota` INT NOT NULL DEFAULT 0,
`friend_quota` INT NOT NULL DEFAULT 0,
`sns_quota` INT NOT NULL DEFAULT 0,
`enabled_features` JSON NULL,
`status` ENUM('active', 'inactive') NOT NULL DEFAULT 'active',
`created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
`updated_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
PRIMARY KEY (`id`),
UNIQUE KEY `uk_biz_plans_code` (`plan_code`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='套餐';
-- 订阅实例
CREATE TABLE IF NOT EXISTS `biz_subscriptions` (
`id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
`user_id` BIGINT UNSIGNED NOT NULL,
`plan_id` BIGINT UNSIGNED NOT NULL,
`status` ENUM('pending', 'active', 'expired', 'cancelled') NOT NULL DEFAULT 'pending',
`start_time` DATETIME NOT NULL,
`end_time` DATETIME NOT NULL,
`renew_mode` ENUM('manual', 'auto') NOT NULL DEFAULT 'manual',
`payment_channel` ENUM('offline', 'pay_link') NULL DEFAULT NULL,
`payment_ref` VARCHAR(200) NULL DEFAULT NULL,
`created_at` DATETIME NOT NULL,
`updated_at` DATETIME NOT NULL,
PRIMARY KEY (`id`),
KEY `idx_biz_sub_user` (`user_id`),
KEY `idx_biz_sub_plan` (`plan_id`),
KEY `idx_biz_sub_status_end` (`status`, `end_time`),
CONSTRAINT `fk_biz_sub_user` FOREIGN KEY (`user_id`) REFERENCES `biz_user` (`id`) ON DELETE CASCADE ON UPDATE CASCADE,
CONSTRAINT `fk_biz_sub_plan` FOREIGN KEY (`plan_id`) REFERENCES `biz_plans` (`id`) ON DELETE RESTRICT ON UPDATE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='订阅';
-- API Token库内仅存 hashplan_id 冗余便于鉴权少联表)
CREATE TABLE IF NOT EXISTS `biz_api_tokens` (
`id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
`user_id` BIGINT UNSIGNED NOT NULL,
`plan_id` BIGINT UNSIGNED NULL DEFAULT NULL,
`token_name` VARCHAR(100) NOT NULL DEFAULT '',
`token_hash` VARCHAR(64) NOT NULL,
`status` ENUM('active', 'revoked', 'expired') NOT NULL DEFAULT 'active',
`expire_at` DATETIME NOT NULL,
`last_used_at` DATETIME NULL DEFAULT NULL,
`created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
`updated_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
PRIMARY KEY (`id`),
UNIQUE KEY `uk_biz_token_hash` (`token_hash`),
KEY `idx_biz_token_user` (`user_id`),
KEY `idx_biz_token_plan` (`plan_id`),
CONSTRAINT `fk_biz_token_user` FOREIGN KEY (`user_id`) REFERENCES `biz_user` (`id`) ON DELETE CASCADE ON UPDATE CASCADE,
CONSTRAINT `fk_biz_token_plan` FOREIGN KEY (`plan_id`) REFERENCES `biz_plans` (`id`) ON DELETE SET NULL ON UPDATE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='API Token';
-- 月用量
CREATE TABLE IF NOT EXISTS `biz_usage_monthly` (
`id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
`user_id` BIGINT UNSIGNED NOT NULL,
`plan_id` BIGINT UNSIGNED NOT NULL,
`stat_month` CHAR(7) NOT NULL COMMENT 'YYYY-MM',
`msg_count` INT NOT NULL DEFAULT 0,
`mass_count` INT NOT NULL DEFAULT 0,
`friend_count` INT NOT NULL DEFAULT 0,
`sns_count` INT NOT NULL DEFAULT 0,
`active_user_count` INT NOT NULL DEFAULT 0,
`created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
`updated_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
PRIMARY KEY (`id`),
UNIQUE KEY `uk_biz_usage_user_month` (`user_id`, `stat_month`),
KEY `idx_biz_usage_plan` (`plan_id`),
CONSTRAINT `fk_biz_usage_user` FOREIGN KEY (`user_id`) REFERENCES `biz_user` (`id`) ON DELETE CASCADE ON UPDATE CASCADE,
CONSTRAINT `fk_biz_usage_plan` FOREIGN KEY (`plan_id`) REFERENCES `biz_plans` (`id`) ON DELETE RESTRICT ON UPDATE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='月用量';

View File

@@ -1,60 +0,0 @@
-- =============================================================================
-- sys_menu 订阅模块菜单插入脚本(字段与 api/model/sys_menu.js 一致)
-- path不含斜杠与首页 path=home 等约定一致component 仍为 subscription/xxx 供前端映射
-- 执行前请备份。若已存在同名「订阅管理」父菜单,请先删除子菜单再删父级,或改下面名称。
-- 若数据库表另有 created_at / updated_at 等列,请在 INSERT 中补全或给默认值。
-- =============================================================================
-- -----------------------------------------------------------------------------
-- 方案一(推荐):一级目录「订阅管理」+ 子菜单parent_id 指向父记录 id
-- -----------------------------------------------------------------------------
INSERT INTO sys_menu
(name, parent_id, icon, path, type, model_id, form_id, component, api_path, is_show_menu, is_show, sort)
VALUES
(
'订阅管理',
0,
'ios-apps',
'subscription',
'菜单',
0,
0,
'',
'',
1,
1,
900
);
SET @sub_parent_id = LAST_INSERT_ID();
INSERT INTO sys_menu
(name, parent_id, icon, path, type, model_id, form_id, component, api_path, is_show_menu, is_show, sort)
VALUES
('运营看板', @sub_parent_id, 'ios-speedometer', 'subscription_dashboard', '页面', 0, 0, 'subscription/dashboard', '', 1, 1, 10),
('业务用户', @sub_parent_id, 'ios-people', 'subscription_user', '页面', 0, 0, 'subscription/user', '', 1, 1, 20),
('套餐管理', @sub_parent_id, 'ios-pricetags', 'subscription_plan', '页面', 0, 0, 'subscription/plan', '', 1, 1, 30),
('订阅列表', @sub_parent_id, 'ios-list', 'subscription_subscription', '页面', 0, 0, 'subscription/subscription', '', 1, 1, 40),
('API Token', @sub_parent_id, 'ios-key', 'subscription_token', '页面', 0, 0, 'subscription/token', '', 1, 1, 50),
('支付确认', @sub_parent_id, 'ios-cash', 'subscription_payment', '页面', 0, 0, 'subscription/payment', '', 1, 1, 60),
('月用量', @sub_parent_id, 'ios-analytics', 'subscription_usage', '页面', 0, 0, 'subscription/usage', '', 1, 1, 70),
('审计日志', @sub_parent_id, 'ios-paper', 'subscription_audit', '页面', 0, 0, 'subscription/audit', '', 1, 1, 80);
-- -----------------------------------------------------------------------------
-- 方案二(可选):全部挂在根节点 parent_id=0无父级目录与 component-map 仍一致)
-- 若已执行方案一,请勿再执行下面语句,避免重复菜单。
-- -----------------------------------------------------------------------------
/*
INSERT INTO sys_menu
(name, parent_id, icon, path, type, model_id, form_id, component, api_path, is_show_menu, is_show, sort)
VALUES
('运营看板', 0, 'ios-speedometer', 'subscription_dashboard', '页面', 0, 0, 'subscription/dashboard', '', 1, 1, 910),
('业务用户', 0, 'ios-people', 'subscription_user', '页面', 0, 0, 'subscription/user', '', 1, 1, 920),
('套餐管理', 0, 'ios-pricetags', 'subscription_plan', '页面', 0, 0, 'subscription/plan', '', 1, 1, 930),
('订阅列表', 0, 'ios-list', 'subscription_subscription', '页面', 0, 0, 'subscription/subscription', '', 1, 1, 940),
('API Token', 0, 'ios-key', 'subscription_token', '页面', 0, 0, 'subscription/token', '', 1, 1, 950),
('支付确认', 0, 'ios-cash', 'subscription_payment', '页面', 0, 0, 'subscription/payment', '', 1, 1, 960),
('月用量', 0, 'ios-analytics', 'subscription_usage', '页面', 0, 0, 'subscription/usage', '', 1, 1, 970),
('审计日志', 0, 'ios-paper', 'subscription_audit', '页面', 0, 0, 'subscription/audit', '', 1, 1, 980);
*/

View File

@@ -1,16 +0,0 @@
-- 审计日志(关键操作留痕)
SET NAMES utf8mb4;
CREATE TABLE IF NOT EXISTS `biz_audit_log` (
`id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
`admin_user_id` BIGINT UNSIGNED NULL DEFAULT NULL COMMENT '后台操作者 sys_user.id可空',
`biz_user_id` BIGINT UNSIGNED NULL DEFAULT NULL COMMENT '相关业务用户',
`action` VARCHAR(64) NOT NULL COMMENT '动作标识',
`resource_type` VARCHAR(64) NOT NULL DEFAULT '',
`resource_id` BIGINT UNSIGNED NULL DEFAULT NULL,
`detail` JSON NULL,
`created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (`id`),
KEY `idx_biz_audit_action` (`action`),
KEY `idx_biz_audit_created` (`created_at`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='订阅模块审计';

View File

@@ -1,29 +0,0 @@
-- =============================================================================
-- sys_menu 首页(字段与 api/model/sys_menu.js、现有库中菜单数据格式一致
--
-- 参考库中同类数据示例:
-- path首页为 home无前导 /);订阅子页为 /subscription/xxx
-- component与 admin/src/router/component-map.js 的 key 一致,可为 home/index 或 home/index.vue
-- iconMaterial 图标名如 md-home与系统页 system/sys_user.vue 等同风格)
-- =============================================================================
INSERT INTO sys_menu
(name, parent_id, icon, path, type, model_id, form_id, component, api_path, is_show_menu, is_show, sort)
VALUES
(
'首页',
0,
'md-home',
'home',
'页面',
0,
0,
'home/index.vue',
'',
1,
1,
0
);
-- 若已存在 name=首页 或 path=home 的记录,请先删除或改值后再执行,避免重复。
-- 若你希望 component 不带后缀,可将上面 'home/index.vue' 改为 'home/index'component-map 两种 key 均已注册)。

View File

@@ -1,36 +0,0 @@
-- =============================================================================
-- 若早期已按旧版 001 建表 biz_users而运行时报错查 biz_user可执行本脚本迁移表名。
-- 执行前备份数据库。若子表尚未创建,可跳过本脚本,直接 DROP biz_users 后重跑新版 001_biz_schema.sql。
-- =============================================================================
-- 若不存在 biz_users 则无需执行
-- 步骤:去外键引用 -> 重命名父表 ->(子表 FK 仍指向旧名时需重建MySQL 8 重命名父表后约束名可能需检查)
-- 1) 删除引用 biz_users 的外键(子表若已存在)
SET @db = DATABASE();
SET @sql = (
SELECT GROUP_CONCAT(CONCAT('ALTER TABLE `', TABLE_NAME, '` DROP FOREIGN KEY `', CONSTRAINT_NAME, '`') SEPARATOR '; ')
FROM information_schema.KEY_COLUMN_USAGE
WHERE TABLE_SCHEMA = @db
AND REFERENCED_TABLE_NAME = 'biz_users'
);
-- 若上面为空则无子表 FK可手动执行下面 RENAME
-- 手工示例(按实际约束名调整):
-- ALTER TABLE `biz_subscriptions` DROP FOREIGN KEY `fk_biz_sub_user`;
-- ALTER TABLE `biz_api_tokens` DROP FOREIGN KEY `fk_biz_token_user`;
-- ALTER TABLE `biz_usage_monthly` DROP FOREIGN KEY `fk_biz_usage_user`;
-- 2) 重命名业务用户表
-- RENAME TABLE `biz_users` TO `biz_user`;
-- 3) 重新添加外键(与 001_biz_schema.sql 一致)
-- ALTER TABLE `biz_subscriptions`
-- ADD CONSTRAINT `fk_biz_sub_user` FOREIGN KEY (`user_id`) REFERENCES `biz_user` (`id`) ON DELETE CASCADE ON UPDATE CASCADE;
-- ALTER TABLE `biz_api_tokens`
-- ADD CONSTRAINT `fk_biz_token_user` FOREIGN KEY (`user_id`) REFERENCES `biz_user` (`id`) ON DELETE CASCADE ON UPDATE CASCADE;
-- ALTER TABLE `biz_usage_monthly`
-- ADD CONSTRAINT `fk_biz_usage_user` FOREIGN KEY (`user_id`) REFERENCES `biz_user` (`id`) ON DELETE CASCADE ON UPDATE CASCADE;
-- 更简单做法无重要数据DROP 子表与 biz_users再执行新版 001_biz_schema.sql 全量重建。

View File

@@ -1,27 +0,0 @@
-- 已有库若 `created_at` / `updated_at` 无默认值,插入会失败(模型已关闭 Sequelize timestamps 且未声明时间字段时依赖库默认值)。
-- 按需对已有表执行(新库直接执行 001/003 即可,无需本文件)。
SET NAMES utf8mb4;
ALTER TABLE `biz_user`
MODIFY `created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
MODIFY `updated_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP;
ALTER TABLE `biz_plans`
MODIFY `created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
MODIFY `updated_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP;
ALTER TABLE `biz_subscriptions`
MODIFY `created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
MODIFY `updated_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP;
ALTER TABLE `biz_api_tokens`
MODIFY `created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
MODIFY `updated_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP;
ALTER TABLE `biz_usage_monthly`
MODIFY `created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
MODIFY `updated_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP;
ALTER TABLE `biz_audit_log`
MODIFY `created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP;

View File

@@ -1,6 +0,0 @@
订阅模块数据库脚本建议执行顺序:
1. 001_biz_schema.sql — 业务表(用户/套餐/订阅/Token/月用量)
2. 003_biz_audit.sql — 审计表 biz_audit_log
3. 002_biz_menu_seed.sql — 管理端菜单(按实际 sys_menu 表结构调整列后执行)
说明:若 002 与现有 sys_menu 字段不一致,请在库中对照 sys_menu 结构增删列后再插入。

View File

@@ -0,0 +1,3 @@
-- 调用日志:存储上游响应摘要(与 api/model/biz_api_call_log.js 中 response_body 一致)
ALTER TABLE `biz_api_call_log`
ADD COLUMN `response_body` TEXT NULL COMMENT '上游响应体 JSON 文本(截断存储)' AFTER `response_time`;

View File

@@ -0,0 +1,3 @@
-- token 绑定账号唯一标识 key供转发时自动拼到 query.key
ALTER TABLE biz_api_token
ADD COLUMN `key` VARCHAR(128) NULL COMMENT '账号唯一标识' AFTER token_name;

View File

@@ -0,0 +1,3 @@
-- 管理端可解密查看 Token 明文AES-GCM 密文),执行一次即可
ALTER TABLE biz_api_token
ADD COLUMN secret_cipher TEXT NULL COMMENT 'Token明文' AFTER token_hash;

99
_docs/sql/biz_plans.sql Normal file
View File

@@ -0,0 +1,99 @@
-- 套餐表 biz_plan与 api/model/biz_plan.js 一致tableName: biz_plan
-- MySQL 8+,字符集 utf8mb4
SET NAMES utf8mb4;
CREATE TABLE IF NOT EXISTS `biz_plan` (
`id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
`plan_code` VARCHAR(64) NOT NULL COMMENT '唯一编码',
`plan_name` VARCHAR(128) NOT NULL DEFAULT '' COMMENT '展示名称',
`monthly_price` DECIMAL(12, 2) NOT NULL DEFAULT 0.00 COMMENT '月价',
`auth_fee` DECIMAL(12, 2) NOT NULL DEFAULT 0.00 COMMENT '授权费',
`account_limit` INT NOT NULL DEFAULT 0 COMMENT '账号上限0 表示由业务解释',
`active_user_limit` INT NOT NULL DEFAULT 0 COMMENT '活跃用户数上限',
`msg_quota` INT NOT NULL DEFAULT 0 COMMENT '消息额度',
`mass_quota` INT NOT NULL DEFAULT 0 COMMENT '群发额度',
`friend_quota` INT NOT NULL DEFAULT 0 COMMENT '加好友额度',
`sns_quota` INT NOT NULL DEFAULT 0 COMMENT '朋友圈额度',
`enabled_features` JSON NULL COMMENT '功能点(JSON)null 表示不限制',
`allowed_apis` JSON NULL COMMENT '可访问接口路径 JSON 数组null 表示不限制',
`api_call_quota` INT NOT NULL DEFAULT 0 COMMENT '每月 API 转发总次数上限0 表示不限制',
`status` ENUM('active', 'inactive') NOT NULL DEFAULT 'active',
PRIMARY KEY (`id`),
UNIQUE KEY `uk_biz_plan_plan_code` (`plan_code`),
KEY `idx_biz_plan_status` (`status`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
-- ---------- 若已有旧表缺列,可单独执行(列已存在会报错,可忽略)----------
-- ALTER TABLE `biz_plan` ADD COLUMN `enabled_features` JSON NULL COMMENT '功能点(JSON)null 表示不限制';
-- ALTER TABLE `biz_plan` ADD COLUMN `allowed_apis` JSON NULL COMMENT '可访问接口路径 JSON 数组null 表示不限制';
-- ALTER TABLE `biz_plan` ADD COLUMN `api_call_quota` INT NOT NULL DEFAULT 0 COMMENT '每月 API 转发总次数上限0 表示不限制';
-- ---------- 示例数据可选plan_code 冲突则先删或改编码)----------
INSERT INTO `biz_plan` (
`plan_code`,
`plan_name`,
`monthly_price`,
`auth_fee`,
`account_limit`,
`active_user_limit`,
`msg_quota`,
`mass_quota`,
`friend_quota`,
`sns_quota`,
`enabled_features`,
`allowed_apis`,
`api_call_quota`,
`status`
) VALUES
(
'plan_junior',
'初级版',
299.00,
0.00,
3,
50,
3000,
100,
200,
100,
JSON_ARRAY('登录', '好友', '消息', '管理'),
JSON_ARRAY('/login/GetLoginStatus', '/login/DeviceLogin', '/message/SendText', '/friend/GetContactList'),
50000,
'active'
),
(
'plan_senior',
'高级版',
899.00,
0.00,
20,
500,
50000,
2000,
5000,
2000,
JSON_ARRAY(
'登录', '好友', '消息', '群聊', '朋友圈', '小程序', '管理',
'设备', '收藏', '视频号', '标签', '支付', '企业微信', '商店', '其他', 'Ws'
),
NULL,
500000,
'active'
),
(
'plan_custom',
'定制版',
0.00,
0.00,
9999,
9999,
0,
0,
0,
0,
NULL,
NULL,
0,
'active'
);

View File

@@ -0,0 +1,34 @@
-- 订阅模块API Tokenbiz_api_token管理端菜单
-- 说明:
-- 1) 若执行报错「表不存在」,请将 `sys_menus` 改为实际表名(常见为 `sys_menu` 或 `sys_menus`)。
-- 2) parent_id挂到「订阅」等父菜单下时改为实际父菜单 id顶层可填 0。
-- 3) component 必须与 admin/src/router/component-map.js 的 key 一致(二选一):
-- subscription/token 或 subscription/biz_api_token
INSERT INTO `sys_menu` (
`name`,
`parent_id`,
`icon`,
`path`,
`type`,
`model_id`,
`form_id`,
`component`,
`api_path`,
`is_show_menu`,
`is_show`,
`sort`
) VALUES (
'API Token',
0,
'md-key',
'/subscription/token',
'页面',
0,
0,
'subscription/token',
'',
1,
1,
45
);

File diff suppressed because it is too large Load Diff

View File

@@ -8,16 +8,18 @@
## 2. 套餐统计
| 套餐 | 接口数 |
| 套餐 | 接口数 |
|---|---:|
| 初级版 | 68 |
| 高级版 | 79 |
| 定制版 | 37 |
| 白标/OEM | 9 |
> 上表「接口数量」之和为 **193**,与 §1 接口总数一致(每个 `Method + Path` 仅归属一个套餐)。
## 3. 按模块统计
| 模块 | 接口数 | 初级版 | 高级版 | 定制版 | 白标/OEM |
| 模块 | 接口数 | 初级版 | 高级版 | 定制版 | 白标/OEM |
|---|---:|---:|---:|---:|---:|
| 企业微信 | 22 | 0 | 0 | 22 | 0 |
| 公众号/小程序 | 13 | 0 | 13 | 0 | 0 |
@@ -37,6 +39,9 @@
| 群管理 | 20 | 11 | 9 | 0 | 0 |
| 视频号 | 4 | 0 | 4 | 0 | 0 |
| 设备 | 4 | 4 | 0 | 0 | 0 |
| **合计** | **193** | **68** | **79** | **37** | **9** |
> **说明**:每行「接口数量」等于该行四个套餐列之和;合计行与 §1、§2 一致。
## 4. 全量接口明细

View File

@@ -77,10 +77,10 @@
新建迁移或 SQL 脚本(建议 `_docs/sql/``migrations/`,与团队习惯一致)包含:
- `biz_users``users`,注意`sys_user` 后台账号区分**建议业务客户表用前缀 `biz_`**,避免与系统用户混淆
- `biz_plans`
- `biz_user`与模型 `api/model/biz_user.js``tableName` 一致;`sys_user` 后台账号区分)
- `biz_plan`
- `biz_subscriptions`(字段含 `renew_mode``payment_channel``payment_ref` 等)
- `biz_api_tokens``token_hash``plan_id` 可选冗余)
- `biz_api_token``token_hash``plan_id` 可选冗余)
- `biz_usage_monthly``stat_month` YYYY-MM
### 3.2 Sequelize 模型文件
@@ -100,7 +100,7 @@
| 需求路径 | 实现前缀 |
|----------|----------|
| `POST /admin/users` | `POST /admin_api/users``/admin_api/biz_users` |
| `POST /admin/users` | `POST /admin_api/biz_user/page` 等(以实际 controller 为准) |
| `GET /admin/users` | `GET /admin_api/users` |
| `GET /admin/users/{id}` | `GET /admin_api/users/:id` |
| `PUT /admin/users/{id}` | `PUT /admin_api/users/:id` |

View File

@@ -0,0 +1,11 @@
class ApiCallLogServer {
async page(row) {
return window.framework.http.post("/biz_api_call_log/page", row);
}
async exportRows(row) {
return window.framework.http.post("/biz_api_call_log/export", row);
}
}
export default new ApiCallLogServer();

View File

@@ -26,6 +26,10 @@ class PlanServer {
async exportRows(row) {
return window.framework.http.post("/biz_plan/export", row);
}
async proxyApiCatalog() {
return window.framework.http.post("/biz_plan/proxy_api_catalog", {});
}
}
export default new PlanServer();

View File

@@ -7,10 +7,18 @@ class TokenServer {
return window.framework.http.post("/biz_token/create", row);
}
async edit(row) {
return window.framework.http.post("/biz_token/edit", row);
}
async revoke(row) {
return window.framework.http.post("/biz_token/revoke", row);
}
async regenerate(row) {
return window.framework.http.post("/biz_token/regenerate", row);
}
async exportRows(row) {
return window.framework.http.post("/biz_token/export", row);
}

View File

@@ -8,6 +8,7 @@ import SubscriptionTokens from '../views/subscription/tokens.vue'
import SubscriptionPayment from '../views/subscription/payment.vue'
import SubscriptionUsage from '../views/subscription/usage.vue'
import SubscriptionAuditLog from '../views/subscription/audit_log.vue'
import SubscriptionApiCallLog from '../views/subscription/api_call_log.vue'
const componentMap = {
// 与 sys_menu.component 一致:库中常见为 home/index 或 home/index.vue
@@ -19,9 +20,12 @@ const componentMap = {
'subscription/plan': SubscriptionPlans,
'subscription/subscription': SubscriptionRecords,
'subscription/token': SubscriptionTokens,
/** 与 biz_api_token 管理页同一视图,便于菜单 component 语义对应 */
'subscription/biz_api_token': SubscriptionTokens,
'subscription/payment': SubscriptionPayment,
'subscription/usage': SubscriptionUsage,
'subscription/audit': SubscriptionAuditLog,
'subscription/api_call_log': SubscriptionApiCallLog,
}
export default componentMap;

View File

@@ -0,0 +1,202 @@
<template>
<div class="content-view">
<div class="table-head-tool">
<Form ref="formInline" :model="param.seachOption" inline :label-width="80">
<FormItem label="筛选">
<Select v-model="param.seachOption.key" style="width: 140px" @on-change="onSearchKeyChange">
<Option value="user_id">user_id</Option>
<Option value="token_id">token_id</Option>
<Option value="api_path">api_path</Option>
<Option value="http_method">http_method</Option>
<Option value="status_code">status_code</Option>
<Option value="response_body">response_body</Option>
</Select>
<Input v-model="param.seachOption.value" class="ml10" style="width: 260px" placeholder="支持数值或路径片段" />
</FormItem>
<FormItem>
<Button type="primary" @click="load(1)">查询</Button>
<Dropdown trigger="click" class="ml10" @on-click="on_toolbar_more">
<Button type="default">更多 <Icon type="ios-arrow-down" /></Button>
<DropdownMenu slot="list">
<DropdownItem name="export">导出 CSV</DropdownItem>
</DropdownMenu>
</Dropdown>
</FormItem>
</Form>
</div>
<div class="table-body">
<Table :columns="columns" :data="rows" border stripe />
<Modal v-model="detailVisible" title="响应结果(日志内已截断)" width="760" footer-hide>
<Input v-model="detailBody" type="textarea" :rows="20" readonly class="resp-detail-ta" />
</Modal>
<div class="table-page-bar">
<Page
:total="total"
:current="param.pageOption.page"
:page-size="param.pageOption.pageSize"
show-total
@on-change="onPage"
@on-page-size-change="onSize"
/>
</div>
</div>
</div>
</template>
<script>
import apiCallLogServer from '@/api/subscription/api_call_log_server.js'
import { downloadCsvFromRows } from '@/utils/csvExport.js'
export default {
name: 'SubscriptionApiCallLog',
data() {
return {
detailVisible: false,
detailBody: '',
rows: [],
total: 0,
param: {
seachOption: { key: 'user_id', value: '' },
pageOption: { page: 1, pageSize: 20, total: 0 },
},
}
},
computed: {
columns() {
return [
{ title: 'ID', key: 'id', width: 80 },
{ title: '用户', key: 'user_id', width: 96 },
{ title: 'Token', key: 'token_id', width: 96 },
{ title: '接口路径', key: 'api_path', minWidth: 200, ellipsis: true, tooltip: true },
{ title: '方法', key: 'http_method', width: 88 },
{ title: 'HTTP状态', key: 'status_code', width: 96 },
{
title: '响应结果',
key: 'response_body',
minWidth: 220,
render: (h, p) => {
const s = p.row.response_body == null ? '' : String(p.row.response_body)
if (!s) return h('span', { class: 'muted' }, '—')
const short = s.length > 72 ? `${s.slice(0, 72)}` : s
return h('div', { class: 'resp-cell' }, [
h('span', { class: 'resp-preview', attrs: { title: s } }, short),
h(
'Button',
{
props: { type: 'primary', size: 'small' },
class: 'ml8',
on: {
click: (e) => {
e.stopPropagation()
this.open_response_detail(s)
},
},
},
'查看'
),
])
},
},
{ title: '耗时ms', key: 'response_time', width: 96 },
{ title: '统计日', key: 'call_date', width: 120 },
{ title: '创建时间', key: 'created_at', minWidth: 168 },
]
},
},
mounted() {
this.load(1)
},
methods: {
onSearchKeyChange() {
this.param.seachOption.value = ''
},
open_response_detail(text) {
this.detailBody = text
this.detailVisible = true
},
async load(page) {
if (page) this.param.pageOption.page = page
const res = await apiCallLogServer.page({ param: this.param })
if (res && res.code === 0) {
this.rows = res.data.rows || []
this.total = res.data.count || 0
} else {
this.$Message.error((res && res.message) || '加载失败')
}
},
onPage(p) {
this.param.pageOption.page = p
this.load()
},
onSize(s) {
this.param.pageOption.pageSize = s
this.load(1)
},
on_toolbar_more(name) {
if (name === 'export') this.doExport()
},
async doExport() {
const res = await apiCallLogServer.exportRows({ param: this.param })
if (res && res.code === 0 && res.data && res.data.rows) {
downloadCsvFromRows(res.data.rows, 'biz_api_call_log.csv')
this.$Message.success('已导出')
} else {
this.$Message.error((res && res.message) || '导出失败')
}
},
},
}
</script>
<style lang="less" scoped>
.content-view {
width: 100%;
max-width: 1720px;
margin: 0 auto;
padding: 26px 36px 36px;
box-sizing: border-box;
}
.table-head-tool {
margin-bottom: 16px;
}
.ml10 {
margin-left: 10px;
}
.table-page-bar {
margin-top: 12px;
text-align: right;
}
.muted {
color: #c5c8ce;
}
.resp-cell {
display: flex;
align-items: flex-start;
gap: 8px;
}
.resp-preview {
flex: 1;
min-width: 0;
font-size: 12px;
line-height: 1.4;
word-break: break-all;
}
.ml8 {
margin-left: 8px;
flex-shrink: 0;
}
.resp-detail-ta :deep(textarea) {
font-family: Consolas, 'Courier New', monospace;
font-size: 12px;
line-height: 1.45;
}
</style>

View File

@@ -11,8 +11,12 @@
</FormItem>
<FormItem>
<Button type="primary" @click="load(1)">查询</Button>
<Button type="default" @click="resetQuery" class="ml10">重置</Button>
<Button type="default" @click="doExport" class="ml10">导出 CSV</Button>
<Dropdown trigger="click" class="ml10" @on-click="on_toolbar_more">
<Button type="default">更多 <Icon type="ios-arrow-down" /></Button>
<DropdownMenu slot="list">
<DropdownItem name="export">导出 CSV</DropdownItem>
</DropdownMenu>
</Dropdown>
</FormItem>
</Form>
</div>
@@ -94,6 +98,9 @@ export default {
this.param.pageOption.pageSize = s
this.load(1)
},
on_toolbar_more(name) {
if (name === 'export') this.doExport()
},
async doExport() {
const res = await auditServer.exportRows({ param: this.param })
if (res && res.code === 0 && res.data && res.data.rows) {
@@ -107,10 +114,6 @@ export default {
this.$Message.error((res && res.message) || '导出失败')
}
},
resetQuery() {
this.param.seachOption = { key: 'action', value: '' }
this.load(1)
},
},
}
</script>

View File

@@ -1,11 +1,10 @@
<template>
<div class="content-view">
<div class="table-head-tool">
<div class="table-title-row">
<Button type="primary" @click="openEdit(null)">新增套餐</Button>
</div>
<Form ref="formInline" :model="param.seachOption" inline :label-width="80">
<FormItem>
<Button type="primary" @click="openEdit(null)">新增套餐</Button>
</FormItem>
<FormItem label="条件">
<Select v-model="param.seachOption.key" style="width: 140px">
<Option value="plan_code">编码</Option>
@@ -22,8 +21,12 @@
</FormItem>
<FormItem>
<Button type="primary" @click="load(1)">查询</Button>
<Button type="default" @click="resetQuery" class="ml10">重置</Button>
<Button type="default" @click="doExport" class="ml10">导出 CSV</Button>
<Dropdown trigger="click" class="ml10" @on-click="on_toolbar_more">
<Button type="default">更多 <Icon type="ios-arrow-down" /></Button>
<DropdownMenu slot="list">
<DropdownItem name="export">导出 CSV</DropdownItem>
</DropdownMenu>
</Dropdown>
</FormItem>
</Form>
</div>
@@ -42,7 +45,7 @@
</div>
</div>
<Modal v-model="modal" :title="form.id ? '编辑套餐' : '新增套餐'" width="720" :loading="saving" @on-ok="save">
<Modal v-model="modal" :title="form.id ? '编辑套餐' : '新增套餐'" width="900" :loading="saving" @on-ok="save">
<Form ref="formRef" :model="form" :rules="rules" :label-width="120">
<FormItem label="套餐编码" prop="plan_code">
<Input v-model="form.plan_code" :disabled="!!form.id" />
@@ -74,9 +77,65 @@
<FormItem label="朋友圈额度">
<Input v-model="form.sns_quota" type="number" />
</FormItem>
<FormItem label="API调用上限">
<Input v-model="form.api_call_quota" type="number" placeholder="0=不限制" />
</FormItem>
<FormItem label="功能点 JSON">
<Input v-model="featuresText" type="textarea" :rows="4" placeholder='如 {"msg":true} 或 ["msg","mass"]' />
</FormItem>
<FormItem label="API 权限">
<RadioGroup v-model="api_permission_mode">
<Radio label="all">不限制Swagger 内转发接口均可调用</Radio>
<Radio label="whitelist">仅允许勾选的接口路径白名单</Radio>
</RadioGroup>
<p class="form-hint">白名单与鉴权路径一致 /user/GetProfile目录来自 swagger.json</p>
</FormItem>
<FormItem v-if="api_permission_mode === 'whitelist'" label=" ">
<Alert v-if="selected_api_paths.length === 0" type="warning" show-icon class="mb12">
未勾选任何接口时保存后该套餐将无法调用任何转发 API
</Alert>
<div v-if="proxy_catalog.tags.length === 0" class="text-muted">暂无接口目录请检查 swagger可联系后台配置</div>
<div v-else class="api-catalog-wrap">
<div class="api-catalog-toolbar">
<Button type="default" size="small" @click="select_all_catalog">全选</Button>
<Button type="default" size="small" class="ml8" @click="clear_all_catalog">全不选</Button>
<span class="api-catalog-stats ml10">已选 {{ selected_api_paths.length }} / {{ catalog_path_count }}</span>
</div>
<Collapse v-model="api_collapse">
<Panel v-for="tag in proxy_catalog.tags" :key="tag" :name="tag">
{{ tag }}{{ count_selected_in_tag(tag) }}/{{ (proxy_catalog.groups[tag] || []).length }}
<div slot="content">
<div class="panel-actions mb8">
<Button type="text" size="small" @click="select_all_in_tag(tag)">本组全选</Button>
<Button type="text" size="small" @click="clear_tag(tag)">本组清空</Button>
</div>
<div class="api-check-grid">
<Checkbox
v-for="it in (proxy_catalog.groups[tag] || [])"
:key="it.path"
:value="selected_api_paths.includes(it.path)"
class="api-check-item"
@on-change="(v) => set_api_path_checked(it.path, v)"
>
<span class="api-path-text">{{ it.path }}</span>
<span class="api-meta">{{ it.methods.join(', ') }}</span>
<span v-if="it.summary" class="api-summary">{{ it.summary }}</span>
</Checkbox>
</div>
</div>
</Panel>
</Collapse>
<div v-if="orphan_api_paths.length" class="orphan-apis mt12">
<p class="sub-label">以下路径不在当前目录中(仍会计入白名单,可点 × 移除)</p>
<Tag
v-for="p in orphan_api_paths"
:key="p"
closable
@on-close="() => remove_orphan_path(p)"
>{{ p }}</Tag>
</div>
</div>
</FormItem>
<FormItem label="状态" prop="status">
<Select v-model="form.status" style="width: 100%">
<Option value="active">上线</Option>
@@ -106,6 +165,10 @@ export default {
saving: false,
form: {},
featuresText: '{}',
api_permission_mode: 'all',
selected_api_paths: [],
proxy_catalog: { items: [], groups: {}, tags: [] },
api_collapse: [],
rules: {
plan_code: [{ required: true, message: '必填', trigger: 'blur' }],
plan_name: [{ required: true, message: '必填', trigger: 'blur' }],
@@ -114,22 +177,78 @@ export default {
}
},
computed: {
catalog_path_set() {
return new Set((this.proxy_catalog.items || []).map((x) => x.path))
},
catalog_path_count() {
return (this.proxy_catalog.items || []).length
},
orphan_api_paths() {
return this.selected_api_paths.filter((p) => !this.catalog_path_set.has(p))
},
columns() {
return [
{ title: 'ID', key: 'id', width: 70 },
{ title: '编码', key: 'plan_code', width: 120 },
{ title: '名称', key: 'plan_name', minWidth: 140 },
{ title: '月费', key: 'monthly_price', width: 90 },
{ title: 'API调用上限', key: 'api_call_quota', width: 120, render: (h, p) => h('span', p.row.api_call_quota > 0 ? p.row.api_call_quota : '不限') },
{
title: 'API权限',
key: 'api_perm',
width: 108,
render: (h, p) => {
const a = p.row.allowed_apis
if (a == null || a === '') return h('span', '不限制')
let list = a
if (typeof list === 'string') {
try {
list = JSON.parse(list)
} catch (e) {
return h('span', '—')
}
}
if (!Array.isArray(list)) return h('span', '—')
if (list.length === 0) return h('span', { class: 'text-danger' }, '全禁')
return h('span', `${list.length} 项`)
},
},
{ title: '状态', key: 'status', width: 90 },
{
title: '操作',
key: 'a',
width: 260,
width: 180,
render: (h, p) =>
h('div', [
h('Button', { props: { type: 'info', size: 'small' }, on: { click: () => this.openEdit(p.row) } }, '编辑'),
h('Button', { props: { type: 'warning', size: 'small' }, class: { ml8: true }, on: { click: () => this.toggle(p.row) } }, '上下线'),
h('Button', { props: { type: 'error', size: 'small' }, class: { ml8: true }, on: { click: () => this.doDel(p.row) } }, '删除'),
h(
'Dropdown',
{
props: { trigger: 'click', transfer: true },
class: { ml8: true },
on: {
'on-click': (name) => {
if (name === 'toggle') this.toggle(p.row)
else if (name === 'del') this.doDel(p.row)
},
},
},
[
h(
'Button',
{ props: { size: 'small' } },
['更多 ', h('Icon', { props: { type: 'ios-arrow-down' } })]
),
h(
'DropdownMenu',
{ slot: 'list' },
[
h('DropdownItem', { props: { name: 'toggle' } }, '上下线'),
h('DropdownItem', { props: { name: 'del' } }, '删除'),
]
),
]
),
]),
},
]
@@ -137,8 +256,66 @@ export default {
},
mounted() {
this.load(1)
this.load_proxy_catalog()
},
methods: {
async load_proxy_catalog() {
try {
const res = await planServer.proxyApiCatalog()
if (res && res.code === 0 && res.data) {
this.proxy_catalog = {
items: res.data.items || [],
groups: res.data.groups || {},
tags: res.data.tags || [],
}
}
} catch (e) {
this.$Message.error('加载转发接口目录失败')
}
},
normalize_allowed_apis_raw(raw) {
if (raw == null || raw === '') return []
let list = raw
if (typeof list === 'string') {
try {
list = JSON.parse(list)
} catch (e) {
return []
}
}
return Array.isArray(list) ? [...list] : []
},
count_selected_in_tag(tag) {
const paths = new Set((this.proxy_catalog.groups[tag] || []).map((x) => x.path))
return this.selected_api_paths.filter((p) => paths.has(p)).length
},
set_api_path_checked(path, checked) {
const set = new Set(this.selected_api_paths)
if (checked) set.add(path)
else set.delete(path)
this.selected_api_paths = Array.from(set).sort((a, b) => a.localeCompare(b))
},
select_all_in_tag(tag) {
const set = new Set(this.selected_api_paths)
for (const it of this.proxy_catalog.groups[tag] || []) set.add(it.path)
this.selected_api_paths = Array.from(set).sort((a, b) => a.localeCompare(b))
},
clear_tag(tag) {
const rm = new Set((this.proxy_catalog.groups[tag] || []).map((x) => x.path))
this.selected_api_paths = this.selected_api_paths.filter((p) => !rm.has(p))
},
select_all_catalog() {
const set = new Set(this.selected_api_paths)
for (const it of this.proxy_catalog.items || []) set.add(it.path)
this.selected_api_paths = Array.from(set).sort((a, b) => a.localeCompare(b))
},
clear_all_catalog() {
const keep = new Set(this.orphan_api_paths)
this.selected_api_paths = Array.from(keep).sort((a, b) => a.localeCompare(b))
},
remove_orphan_path(path) {
this.selected_api_paths = this.selected_api_paths.filter((p) => p !== path)
},
async load(page) {
if (page) this.param.pageOption.page = page
const res = await planServer.page({ param: this.param })
@@ -166,6 +343,13 @@ export default {
: typeof row.enabled_features === 'string'
? row.enabled_features
: JSON.stringify(row.enabled_features, null, 2)
if (row.allowed_apis == null || row.allowed_apis === '') {
this.api_permission_mode = 'all'
this.selected_api_paths = []
} else {
this.api_permission_mode = 'whitelist'
this.selected_api_paths = this.normalize_allowed_apis_raw(row.allowed_apis)
}
} else {
this.form = {
plan_code: '',
@@ -178,10 +362,14 @@ export default {
mass_quota: 0,
friend_quota: 0,
sns_quota: 0,
api_call_quota: 0,
status: 'active',
}
this.featuresText = '{}'
this.api_permission_mode = 'all'
this.selected_api_paths = []
}
this.api_collapse = this.proxy_catalog.tags.length ? [...this.proxy_catalog.tags] : []
this.modal = true
},
save() {
@@ -202,7 +390,11 @@ export default {
return
}
}
const payload = { ...this.form, enabled_features }
let allowed_apis = null
if (this.api_permission_mode === 'whitelist') {
allowed_apis = [...this.selected_api_paths]
}
const payload = { ...this.form, enabled_features, allowed_apis }
try {
const res = this.form.id ? await planServer.edit(payload) : await planServer.add(payload)
if (res && res.code === 0) {
@@ -217,6 +409,9 @@ export default {
}
})
},
on_toolbar_more(name) {
if (name === 'export') this.doExport()
},
async toggle(row) {
const res = await planServer.toggle({ id: row.id })
if (res && res.code === 0) {
@@ -250,10 +445,6 @@ export default {
this.$Message.error((res && res.message) || '导出失败')
}
},
resetQuery() {
this.param.seachOption = { key: 'plan_code', value: '' }
this.load(1)
},
},
}
</script>
@@ -271,13 +462,6 @@ export default {
margin-bottom: 16px;
}
.table-title-row {
display: flex;
align-items: center;
justify-content: flex-end;
margin-bottom: 12px;
}
.ml10 {
margin-left: 10px;
}
@@ -290,4 +474,79 @@ export default {
margin-top: 12px;
text-align: right;
}
.form-hint {
color: #808695;
font-size: 12px;
margin-top: 6px;
}
.text-muted {
color: #808695;
}
.text-danger {
color: #ed4014;
}
.mb12 {
margin-bottom: 12px;
}
.mt12 {
margin-top: 12px;
}
.api-catalog-wrap {
max-height: 420px;
overflow: auto;
padding: 12px;
border: 1px solid #e8eaec;
border-radius: 4px;
}
.api-catalog-toolbar {
margin-bottom: 10px;
}
.api-catalog-stats {
color: #808695;
font-size: 12px;
}
.panel-actions .ivu-btn-text + .ivu-btn-text {
margin-left: 4px;
}
.api-check-grid {
display: flex;
flex-direction: column;
gap: 4px;
}
.api-path-text {
font-weight: 500;
margin-right: 8px;
}
.api-meta {
color: #808695;
font-size: 12px;
margin-right: 8px;
}
.api-summary {
color: #515a6e;
font-size: 12px;
}
.sub-label {
font-size: 12px;
color: #808695;
margin-bottom: 8px;
}
.orphan-apis .ivu-tag {
margin: 0 8px 8px 0;
}
</style>

View File

@@ -1,11 +1,10 @@
<template>
<div class="content-view">
<div class="table-head-tool">
<div class="table-title-row">
<Button type="primary" @click="openOpen">开通订阅</Button>
</div>
<Form ref="formInline" :model="param.seachOption" inline :label-width="80">
<FormItem>
<Button type="primary" @click="openOpen">开通订阅</Button>
</FormItem>
<FormItem label="筛选">
<Select v-model="param.seachOption.key" style="width: 120px" @on-change="onSearchKeyChange">
<Option value="user_id">用户</Option>
@@ -29,8 +28,12 @@
</FormItem>
<FormItem>
<Button type="primary" @click="load(1)">查询</Button>
<Button type="default" @click="resetQuery" class="ml10">重置</Button>
<Button type="default" @click="doExport" class="ml10">导出 CSV</Button>
<Dropdown trigger="click" class="ml10" @on-click="on_toolbar_more">
<Button type="default">更多 <Icon type="ios-arrow-down" /></Button>
<DropdownMenu slot="list">
<DropdownItem name="export">导出 CSV</DropdownItem>
</DropdownMenu>
</Dropdown>
</FormItem>
</Form>
</div>
@@ -85,9 +88,9 @@
</Form>
</Modal>
<Modal v-model="upgradeModal" title="升级套餐" :loading="saving" @on-ok="submitUpgrade">
<Modal v-model="upgradeModal" title="编辑订阅" :loading="saving" @on-ok="submitUpgrade">
<Form :label-width="100">
<FormItem label="套餐">
<FormItem label="套餐">
<Select v-model="upgradeForm.new_plan_id" filterable clearable placeholder="请选择" style="width: 100%">
<Option v-for="p in bizPlanOptions" :key="p.id" :value="p.id">{{ bizPlanLabel(p) }}</Option>
</Select>
@@ -129,21 +132,45 @@ export default {
columns() {
return [
{ title: 'ID', key: 'id', width: 70 },
{ title: '用户', key: 'user_id', width: 90 },
{ title: '套餐', key: 'plan_id', width: 90 },
{ title: '用户', key: 'user_name', minWidth: 160, ellipsis: true, tooltip: true },
{ title: '套餐', key: 'plan_name', minWidth: 160, ellipsis: true, tooltip: true },
{ title: '状态', key: 'status', width: 100 },
{ title: '开始', key: 'start_time', minWidth: 150 },
{ title: '结束', key: 'end_time', minWidth: 150 },
{
title: '操作',
key: 'a',
width: 200,
width: 120,
render: (h, p) =>
h('div', [
h('Button', { props: { size: 'small' }, on: { click: () => this.openRenew(p.row) } }, '续费'),
h('Button', { props: { size: 'small' }, class: { ml8: true }, on: { click: () => this.openUpgrade(p.row) } }, '升级'),
h('Button', { props: { type: 'error', size: 'small' }, class: { ml8: true }, on: { click: () => this.doCancel(p.row) } }, '取消'),
]),
h(
'Dropdown',
{
props: { trigger: 'click', transfer: true },
on: {
'on-click': (name) => {
if (name === 'renew') this.openRenew(p.row)
else if (name === 'edit') this.openUpgrade(p.row)
else if (name === 'cancel') this.doCancel(p.row)
},
},
},
[
h(
'Button',
{ props: { type: 'primary', size: 'small' } },
['操作 ', h('Icon', { props: { type: 'ios-arrow-down' } })]
),
h(
'DropdownMenu',
{ slot: 'list' },
[
h('DropdownItem', { props: { name: 'renew' } }, '续费'),
h('DropdownItem', { props: { name: 'edit' } }, '编辑订阅'),
h('DropdownItem', { props: { name: 'cancel' } }, '取消订阅'),
]
),
]
),
},
]
},
@@ -268,7 +295,7 @@ export default {
if (!this.currentRow) return false
const np = this.upgradeForm.new_plan_id
if (np === undefined || np === null || np === '') {
this.$Message.warning('请选择套餐')
this.$Message.warning('请选择套餐')
return false
}
this.saving = true
@@ -289,7 +316,7 @@ export default {
end_time: this.upgradeForm.end_time || undefined,
})
if (res && res.code === 0) {
this.$Message.success('已升级')
this.$Message.success('已保存')
this.upgradeModal = false
this.load(1)
} else {
@@ -314,6 +341,9 @@ export default {
},
})
},
on_toolbar_more(name) {
if (name === 'export') this.doExport()
},
async doExport() {
const res = await subscriptionsServer.exportRows({ param: this.param })
if (res && res.code === 0 && res.data && res.data.rows) {
@@ -323,10 +353,6 @@ export default {
this.$Message.error((res && res.message) || '导出失败')
}
},
resetQuery() {
this.param.seachOption = { key: 'user_id', value: '' }
this.load(1)
},
},
}
</script>
@@ -344,13 +370,6 @@ export default {
margin-bottom: 16px;
}
.table-title-row {
display: flex;
align-items: center;
justify-content: flex-end;
margin-bottom: 12px;
}
.ml10 {
margin-left: 10px;
}

View File

@@ -1,11 +1,10 @@
<template>
<div class="content-view">
<div class="table-head-tool">
<div class="table-title-row">
<Button type="primary" @click="openCreate">创建 Token</Button>
</div>
<Form ref="formInline" :model="param.seachOption" inline :label-width="80">
<FormItem>
<Button type="primary" @click="openCreate">创建 Token</Button>
</FormItem>
<FormItem label="筛选">
<Select v-model="param.seachOption.key" style="width: 120px" @on-change="onSearchKeyChange">
<Option value="user_id">用户</Option>
@@ -29,8 +28,12 @@
</FormItem>
<FormItem>
<Button type="primary" @click="load(1)">查询</Button>
<Button type="default" @click="resetQuery" class="ml10">重置</Button>
<Button type="default" @click="doExport" class="ml10">导出 CSV</Button>
<Dropdown trigger="click" class="ml10" @on-click="on_toolbar_more">
<Button type="default">更多 <Icon type="ios-arrow-down" /></Button>
<DropdownMenu slot="list">
<DropdownItem name="export">导出 CSV</DropdownItem>
</DropdownMenu>
</Dropdown>
</FormItem>
</Form>
</div>
@@ -49,6 +52,14 @@
</div>
</div>
<Modal v-model="editModal" title="编辑 Token" width="560" :loading="editSaving" @on-ok="submitEdit">
<Form :label-width="100">
<FormItem label="账号 key"><Input v-model="editForm.key" placeholder="账号唯一标识(可空)" /></FormItem>
<FormItem label="名称"><Input v-model="editForm.token_name" placeholder="名称" /></FormItem>
<FormItem label="过期时间"><Input v-model="editForm.expire_at" placeholder="YYYY-MM-DD HH:mm:ss" /></FormItem>
</Form>
</Modal>
<Modal v-model="createModal" title="创建 Token" width="560" :loading="saving" @on-ok="submitCreate">
<Form :label-width="100">
<FormItem label="用户">
@@ -56,12 +67,13 @@
<Option v-for="u in bizUserOptions" :key="u.id" :value="u.id">{{ bizUserLabel(u) }}</Option>
</Select>
</FormItem>
<FormItem label="账号 key"><Input v-model="createForm.key" placeholder="账号唯一标识(可选)" /></FormItem>
<FormItem label="名称"><Input v-model="createForm.token_name" placeholder="default" /></FormItem>
<FormItem label="过期时间"><Input v-model="createForm.expire_at" placeholder="2026-12-31 23:59:59" /></FormItem>
</Form>
</Modal>
<Modal v-model="plainModal" title="请立即保存 Token 明文" width="560" :closable="false">
<Modal v-model="plainModal" :title="plainModalTitle" width="560" :closable="false">
<Alert type="error">仅此一次展示关闭后无法再次查看明文</Alert>
<Input type="textarea" :rows="4" v-model="plainToken" readonly />
<div slot="footer">
@@ -87,8 +99,12 @@ export default {
seachOption: { key: 'user_id', value: '' },
pageOption: { page: 1, pageSize: 20, total: 0 },
},
editModal: false,
editSaving: false,
editForm: {},
createModal: false,
plainModal: false,
plainModalTitle: '请立即保存 Token 明文',
plainToken: '',
saving: false,
createForm: {},
@@ -100,6 +116,7 @@ export default {
{ title: 'ID', key: 'id', width: 70 },
{ title: '用户', key: 'user_id', width: 90 },
{ title: '套餐', key: 'plan_id', width: 90 },
{ title: 'Key', key: 'key', minWidth: 140 },
{ title: '名称', key: 'token_name', width: 120 },
{ title: '状态', key: 'status', width: 90 },
{ title: '过期', key: 'expire_at', minWidth: 150 },
@@ -107,18 +124,45 @@ export default {
{
title: '操作',
key: 'a',
width: 100,
render: (h, p) =>
h(
'Button',
{
props: { type: 'error', size: 'small' },
on: {
click: () => this.doRevoke(p.row),
width: 248,
render: (h, p) => {
const btns = []
btns.push(
h(
'Button',
{
props: { type: 'primary', size: 'small', ghost: true },
on: { click: () => this.openEdit(p.row) },
},
},
'吊销'
),
'编辑'
)
)
if (p.row.status === 'active') {
btns.push(
h(
'Button',
{
props: { type: 'warning', size: 'small' },
class: { ml8: true },
on: { click: () => this.doRegenerate(p.row) },
},
'重新生成'
)
)
}
btns.push(
h(
'Button',
{
props: { type: 'error', size: 'small' },
class: { ml8: true },
on: { click: () => this.doRevoke(p.row) },
},
'吊销'
)
)
return h('div', btns)
},
},
]
},
@@ -156,7 +200,7 @@ export default {
2,
'0'
)} 23:59:59`
this.createForm = { user_id: undefined, token_name: 'default', expire_at: fmt }
this.createForm = { user_id: undefined, key: '', token_name: 'default', expire_at: fmt }
this.createModal = true
},
submitCreate() {
@@ -169,17 +213,74 @@ export default {
this._submitCreate()
return false
},
openEdit(row) {
this.editForm = {
id: row.id,
key: row.key != null ? String(row.key) : '',
token_name: row.token_name || '',
expire_at: row.expire_at
? typeof row.expire_at === 'string'
? row.expire_at
: this._fmt_expire(row.expire_at)
: '',
}
this.editModal = true
},
_fmt_expire(d) {
if (!d) return ''
const dt = new Date(d)
if (Number.isNaN(dt.getTime())) return String(d)
const pad = (n) => String(n).padStart(2, '0')
return `${dt.getFullYear()}-${pad(dt.getMonth() + 1)}-${pad(dt.getDate())} ${pad(dt.getHours())}:${pad(
dt.getMinutes()
)}:${pad(dt.getSeconds())}`
},
submitEdit() {
if (!this.editForm.id) return false
if (!this.editForm.token_name || !String(this.editForm.token_name).trim()) {
this.$Message.warning('请填写名称')
return false
}
if (!this.editForm.expire_at || !String(this.editForm.expire_at).trim()) {
this.$Message.warning('请填写过期时间')
return false
}
this.editSaving = true
this._submitEdit()
return false
},
async _submitEdit() {
try {
const res = await tokenServer.edit({
id: this.editForm.id,
key: this.editForm.key === '' ? null : this.editForm.key,
token_name: this.editForm.token_name,
expire_at: this.editForm.expire_at,
})
if (res && res.code === 0) {
this.editModal = false
this.$Message.success('已保存')
this.load(1)
} else {
this.$Message.error((res && res.message) || '保存失败')
}
} finally {
this.editSaving = false
}
},
async _submitCreate() {
const uid = this.createForm.user_id
try {
const res = await tokenServer.create({
user_id: Number(uid),
key: this.createForm.key || null,
token_name: this.createForm.token_name || 'default',
expire_at: this.createForm.expire_at,
})
if (res && res.code === 0) {
if (res.data.warn) this.$Message.warning(res.data.warn)
this.createModal = false
this.plainModalTitle = '请立即保存 Token 明文'
this.plainToken = res.data.plain_token
this.plainModal = true
this.load(1)
@@ -190,6 +291,24 @@ export default {
this.saving = false
}
},
doRegenerate(row) {
this.$Modal.confirm({
title: '重新生成 Token',
content: '旧密钥将立即失效,确定继续?',
onOk: async () => {
const res = await tokenServer.regenerate({ id: row.id })
if (res && res.code === 0) {
if (res.data.warn) this.$Message.warning(res.data.warn)
this.plainModalTitle = '请保存重新生成后的 Token 明文'
this.plainToken = res.data.plain_token
this.plainModal = true
this.load(1)
} else {
this.$Message.error((res && res.message) || '失败')
}
},
})
},
doRevoke(row) {
this.$Modal.confirm({
title: '吊销 Token',
@@ -205,6 +324,9 @@ export default {
},
})
},
on_toolbar_more(name) {
if (name === 'export') this.doExport()
},
async doExport() {
const res = await tokenServer.exportRows({ param: this.param })
if (res && res.code === 0 && res.data && res.data.rows) {
@@ -214,10 +336,6 @@ export default {
this.$Message.error((res && res.message) || '导出失败')
}
},
resetQuery() {
this.param.seachOption = { key: 'user_id', value: '' }
this.load(1)
},
},
}
</script>
@@ -235,13 +353,6 @@ export default {
margin-bottom: 16px;
}
.table-title-row {
display: flex;
align-items: center;
justify-content: flex-end;
margin-bottom: 12px;
}
.ml10 {
margin-left: 10px;
}
@@ -250,4 +361,8 @@ export default {
margin-top: 12px;
text-align: right;
}
.ml8 {
margin-left: 8px;
}
</style>

View File

@@ -1,11 +1,10 @@
<template>
<div class="content-view">
<div class="table-head-tool">
<div class="table-title-row">
<Button type="primary" @click="openEdit(null)">新增</Button>
</div>
<Form ref="formInline" :model="param.seachOption" inline :label-width="80" class="usage-query-form">
<FormItem>
<Button type="primary" @click="openEdit(null)">新增</Button>
</FormItem>
<FormItem label="条件">
<Select v-model="param.seachOption.key" class="usage-select" @on-change="onSearchKeyChange">
<Option value="user_id">用户</Option>
@@ -41,8 +40,12 @@
</FormItem>
<FormItem>
<Button type="primary" @click="load(1)">查询</Button>
<Button type="default" @click="resetQuery" class="ml10">重置</Button>
<Button type="default" @click="doExport" class="ml10">导出 CSV</Button>
<Dropdown trigger="click" class="ml10" @on-click="on_toolbar_more">
<Button type="default">更多 <Icon type="ios-arrow-down" /></Button>
<DropdownMenu slot="list">
<DropdownItem name="export">导出 CSV</DropdownItem>
</DropdownMenu>
</Dropdown>
</FormItem>
</Form>
</div>
@@ -79,6 +82,7 @@
<FormItem label="friend_count"><Input v-model="form.friend_count" type="number" /></FormItem>
<FormItem label="sns_count"><Input v-model="form.sns_count" type="number" /></FormItem>
<FormItem label="active_user_count"><Input v-model="form.active_user_count" type="number" /></FormItem>
<FormItem label="api_call_count转发调用"><Input v-model="form.api_call_count" type="number" /></FormItem>
</Form>
</Modal>
</div>
@@ -122,6 +126,7 @@ export default {
{ title: 'friend', key: 'friend_count', minWidth: 72 },
{ title: 'sns', key: 'sns_count', minWidth: 72 },
{ title: 'active_user', key: 'active_user_count', minWidth: 100 },
{ title: 'api_calls', key: 'api_call_count', minWidth: 88 },
{
title: '操作',
key: 'a',
@@ -178,6 +183,7 @@ export default {
friend_count: 0,
sns_count: 0,
active_user_count: 0,
api_call_count: 0,
}
}
this.modal = true
@@ -221,6 +227,9 @@ export default {
},
})
},
on_toolbar_more(name) {
if (name === 'export') this.doExport()
},
async doExport() {
const res = await usageServer.exportRows({ param: this.param })
if (res && res.code === 0 && res.data && res.data.rows) {
@@ -230,10 +239,6 @@ export default {
this.$Message.error((res && res.message) || '导出失败')
}
},
resetQuery() {
this.param.seachOption = { key: 'stat_month', value: '' }
this.load(1)
},
},
}
</script>
@@ -278,13 +283,6 @@ export default {
margin-bottom: 16px;
}
.table-title-row {
display: flex;
align-items: center;
justify-content: flex-end;
margin-bottom: 14px;
}
.ml10 {
margin-left: 10px;
}

View File

@@ -1,11 +1,10 @@
<template>
<div class="content-view">
<div class="table-head-tool">
<div class="table-title-row">
<Button type="primary" @click="openEdit(null)">新增</Button>
</div>
<Form ref="formInline" :model="param.seachOption" inline :label-width="80">
<FormItem>
<Button type="primary" @click="openEdit(null)">新增</Button>
</FormItem>
<FormItem label="条件">
<Select v-model="param.seachOption.key" style="width: 140px">
<Option value="mobile">手机</Option>
@@ -17,8 +16,14 @@
</FormItem>
<FormItem>
<Button type="primary" @click="load(1)">查询</Button>
<Button type="default" @click="resetQuery" class="ml10">重置</Button>
<Button type="default" @click="doExport" class="ml10">导出 CSV</Button>
<Dropdown trigger="click" class="ml10" @on-click="on_toolbar_more">
<Button type="default">更多
<Icon type="ios-arrow-down" />
</Button>
<DropdownMenu slot="list">
<DropdownItem name="export">导出 CSV</DropdownItem>
</DropdownMenu>
</Dropdown>
</FormItem>
</Form>
</div>
@@ -51,19 +56,58 @@
<Option value="disabled">禁用</Option>
</Select>
</FormItem>
<FormItem v-if="!form.id" label="API Token">
<Checkbox v-model="form.auto_create_token">保存时自动创建默认 Token明文仅展示一次</Checkbox>
</FormItem>
</Form>
</Modal>
<Modal v-model="detailVisible" title="用户详情" width="720" footer-hide>
<p v-if="detail">Token 数量{{ detail.tokenCount }}</p>
<Table v-if="detail && detail.subscriptions" :columns="subCols" :data="detail.subscriptions" size="small"
border />
<Modal v-model="token_plain_modal" :title="token_plain_title" width="560" :closable="false">
<Alert type="error">关闭后无法再次查看明文请复制到安全位置</Alert>
<Input v-model="token_plain_text" type="textarea" :rows="4" readonly />
<div slot="footer">
<Button type="primary" @click="token_plain_modal = false">已保存</Button>
</div>
</Modal>
<Modal v-model="create_token_modal" title="生成 API Token" width="560" :loading="create_token_saving"
@on-ok="submit_create_token">
<p v-if="create_token_target_user" class="text-muted mb12">
用户{{ create_token_target_user.name || ('#' + create_token_target_user.id) }}
· ID {{ create_token_target_user.id }}
</p>
<Form :label-width="100">
<FormItem label="账号 key"><Input v-model="create_token_form.key" placeholder="账号唯一标识(可选)" /></FormItem>
<FormItem label="名称"><Input v-model="create_token_form.token_name" placeholder="default" /></FormItem>
<FormItem label="过期时间"><Input v-model="create_token_form.expire_at" placeholder="YYYY-MM-DD 23:59:59" />
</FormItem>
</Form>
</Modal>
<Modal v-model="detailVisible" title="用户详情" width="960" footer-hide>
<template v-if="detail && detail.user">
<p class="detail-one-line mb12">
<strong>{{ detail.user.name || '—' }}</strong>
<span class="text-muted"> · ID {{ detail.user.id }} · {{ detail.user.mobile || '—' }} · {{ detail.user.status
}}</span>
<Button type="primary" size="small" class="ml12" :disabled="detail.user.status !== 'active'"
@click="open_create_token_for_user(detail.user)">生成 Token</Button>
</p>
<p class="sub-title">Token{{ (detail.tokens && detail.tokens.length) || 0 }}</p>
<Table :columns="tokenCols" :data="detail.tokens || []" size="small" border
class="mb16 token-table-in-detail" />
<p class="sub-title">订阅</p>
<Table v-if="detail.subscriptions && detail.subscriptions.length" :columns="subCols"
:data="detail.subscriptions" size="small" border />
<p v-else class="text-muted">暂无订阅</p>
</template>
</Modal>
</div>
</template>
<script>
import userServer from '@/api/subscription/user_server.js'
import tokenServer from '@/api/subscription/token_server.js'
import { downloadCsvFromRows } from '@/utils/csvExport.js'
export default {
@@ -85,6 +129,13 @@ export default {
},
detailVisible: false,
detail: null,
token_plain_modal: false,
token_plain_title: '',
token_plain_text: '',
create_token_modal: false,
create_token_saving: false,
create_token_target_user: null,
create_token_form: { token_name: 'default', expire_at: '' },
subCols: [
{ title: 'ID', key: 'id', width: 80 },
{ title: '套餐ID', key: 'plan_id', width: 90 },
@@ -102,10 +153,32 @@ export default {
{ title: '手机', key: 'mobile', width: 130 },
{ title: '公司', key: 'company_name', minWidth: 140 },
{ title: '状态', key: 'status', width: 90 },
{
title: 'API Token',
key: 'token_count',
width: 100,
render: (h, p) => {
const n = p.row.token_count != null ? Number(p.row.token_count) : 0
return h(
'a',
{
class: 'table-action-link',
attrs: { href: 'javascript:void(0)' },
on: {
click: (e) => {
e.preventDefault()
this.showDetail(p.row)
},
},
},
n > 0 ? `${n}` : '0'
)
},
},
{
title: '操作',
key: 'a',
width: 380,
width: 312,
render: (h, p) => {
return h('div', [
h(
@@ -125,48 +198,120 @@ export default {
},
'详情'
),
p.row.status === 'disabled'
? h(
'Button',
{
props: { type: 'success', size: 'small' },
class: { ml8: true },
on: { click: () => this.doEnable(p.row) },
},
'启用'
)
: h(
'Button',
{
props: { type: 'warning', size: 'small' },
class: { ml8: true },
on: { click: () => this.doDisable(p.row) },
},
'禁用'
),
h(
'Button',
{
props: { type: 'default', size: 'small' },
props: {
type: 'primary',
size: 'small',
ghost: true,
disabled: p.row.status !== 'active',
},
class: { ml8: true },
on: { click: () => this.revokeAllTokens(p.row) },
on: { click: () => this.open_create_token_for_user(p.row) },
},
'吊销全部Token'
'生成Token'
),
h(
'Button',
'Dropdown',
{
props: { type: 'error', size: 'small' },
class: { ml8: true },
on: { click: () => this.doDel(p.row) },
props: { trigger: 'click', transfer: true },
on: {
'on-click': (name) => {
if (name === 'revoke_all') this.revokeAllTokens(p.row)
else if (name === 'del') this.doDel(p.row)
},
},
},
'删除'
[
h(
'Button',
{ props: { size: 'small' }, class: { ml8: true } },
['更多 ', h('Icon', { props: { type: 'ios-arrow-down' } })]
),
h(
'DropdownMenu',
{ slot: 'list' },
[
h('DropdownItem', { props: { name: 'revoke_all' } }, '吊销全部 Token'),
h('DropdownItem', { props: { name: 'del' } }, '删除'),
]
),
]
),
])
},
},
]
},
tokenCols() {
return [
{ title: 'ID', key: 'id', width: 56 },
{ title: '名称', key: 'token_name', width: 88 },
{ title: '套餐', key: 'plan_id', width: 64 },
{ title: '状态', key: 'status', width: 72 },
{ title: '过期', key: 'expire_at', minWidth: 128 },
{ title: '最后使用', key: 'last_used_at', minWidth: 128 },
{
title: '明文',
key: 'plain_token',
minWidth: 280,
render: (h, p) => {
const v = p.row.plain_token
if (!v) {
return h('span', { class: { 'text-muted': true } }, '—')
}
return h('div', { class: 'plain-cell' }, [
h('Input', {
props: {
type: 'textarea',
value: v,
rows: 2,
readonly: true,
},
class: 'plain-cell-input',
}),
h(
'Button',
{
props: { type: 'text', size: 'small' },
on: {
click: (e) => {
e.stopPropagation()
this.copy_text(v)
},
},
},
'复制'
),
])
},
},
{
title: '操作',
key: 'tok_op',
width: 96,
render: (h, p) => {
if (p.row.status !== 'active') {
return h('span', { class: { 'text-muted': true } }, '—')
}
return h(
'Button',
{
props: { type: 'warning', size: 'small' },
on: {
click: (e) => {
e.stopPropagation()
this.do_regenerate_token(p.row)
},
},
},
'重新生成'
)
},
},
]
},
},
mounted() {
this.load(1)
@@ -190,11 +335,43 @@ export default {
this.param.pageOption.pageSize = s
this.load(1)
},
copy_text(text) {
if (text == null || text === '') return
const s = String(text)
if (navigator.clipboard && navigator.clipboard.writeText) {
navigator.clipboard.writeText(s).then(() => this.$Message.success('已复制')).catch(() => this._copy_text_fallback(s))
} else {
this._copy_text_fallback(s)
}
},
_copy_text_fallback(s) {
const ta = document.createElement('textarea')
ta.value = s
ta.style.position = 'fixed'
ta.style.opacity = '0'
document.body.appendChild(ta)
ta.select()
try {
document.execCommand('copy')
this.$Message.success('已复制')
} catch (e) {
this.$Message.error('复制失败')
}
document.body.removeChild(ta)
},
openEdit(row) {
if (row) {
this.form = { ...row }
delete this.form.auto_create_token
} else {
this.form = { name: '', mobile: '', email: '', company_name: '', status: 'active' }
this.form = {
name: '',
mobile: '',
email: '',
company_name: '',
status: 'active',
auto_create_token: true,
}
}
this.modal = true
},
@@ -210,9 +387,17 @@ export default {
? await userServer.edit(this.form)
: await userServer.add(this.form)
if (res && res.code === 0) {
this.$Message.success('保存成功')
this.modal = false
this.load(1)
const data = res.data || {}
if (!this.form.id) {
if (data.token_error) this.$Message.error(data.token_error)
if (data.token_warn) this.$Message.warning(data.token_warn)
if (data.plain_token) {
this.show_token_plain('请保存新建用户的 Token 明文', data.plain_token)
}
}
this.$Message.success('保存成功')
} else {
this.$Message.error((res && res.message) || '保存失败')
}
@@ -230,30 +415,78 @@ export default {
this.$Message.error((res && res.message) || '加载详情失败')
}
},
doDisable(row) {
this.$Modal.confirm({
title: '禁用用户',
content: '确认禁用该用户?',
onOk: async () => {
const res = await userServer.disable({ id: row.id })
if (res && res.code === 0) {
this.$Message.success('已禁用')
this.load(1)
} else {
this.$Message.error((res && res.message) || '操作失败')
}
},
})
default_token_expire_input() {
const d = new Date()
d.setFullYear(d.getFullYear() + 1)
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(
2,
'0'
)} 23:59:59`
},
doEnable(row) {
open_create_token_for_user(user_row) {
if (!user_row || user_row.status !== 'active') {
this.$Message.warning('仅状态为「正常」的用户可创建 Token')
return
}
this.create_token_target_user = user_row
this.create_token_form = {
key: '',
token_name: 'default',
expire_at: this.default_token_expire_input(),
}
this.create_token_modal = true
},
submit_create_token() {
const row = this.create_token_target_user
if (!row) return false
this.create_token_saving = true
this._submit_create_token()
return false
},
async _submit_create_token() {
const row = this.create_token_target_user
try {
const res = await tokenServer.create({
user_id: row.id,
key: this.create_token_form.key || null,
token_name: this.create_token_form.token_name || 'default',
expire_at: this.create_token_form.expire_at,
})
if (res && res.code === 0) {
if (res.data.warn) this.$Message.warning(res.data.warn)
this.create_token_modal = false
this.show_token_plain('请保存新建 Token 明文', res.data.plain_token)
await this.reload_user_token_views(row.id)
this.$Message.success('已创建 Token')
} else {
this.$Message.error((res && res.message) || '创建失败')
}
} finally {
this.create_token_saving = false
}
},
show_token_plain(title, text) {
this.token_plain_title = title
this.token_plain_text = text || ''
this.token_plain_modal = true
},
async reload_user_token_views(user_id) {
const res = await userServer.detail(user_id)
if (res && res.code === 0) {
if (this.detailVisible) this.detail = res.data
this.load(1)
}
},
do_regenerate_token(row) {
this.$Modal.confirm({
title: '启用用户',
content: '确认重新启用该用户',
title: '重新生成 Token',
content: '旧密钥将立即失效,确定继续',
onOk: async () => {
const res = await userServer.enable({ id: row.id })
const res = await tokenServer.regenerate({ id: row.id })
if (res && res.code === 0) {
this.$Message.success('已启用')
this.load(1)
if (res.data.warn) this.$Message.warning(res.data.warn)
this.show_token_plain('请保存重新生成后的 Token 明文', res.data.plain_token)
await this.reload_user_token_views(row.user_id)
} else {
this.$Message.error((res && res.message) || '操作失败')
}
@@ -284,6 +517,9 @@ export default {
this.$Message.error((res && res.message) || '导出失败')
}
},
on_toolbar_more(name) {
if (name === 'export') this.doExport()
},
revokeAllTokens(row) {
this.$Modal.confirm({
title: '吊销全部 Token',
@@ -300,10 +536,6 @@ export default {
},
})
},
resetQuery() {
this.param.seachOption = { key: 'mobile', value: '' }
this.load(1)
},
},
}
</script>
@@ -321,13 +553,6 @@ export default {
margin-bottom: 16px;
}
.table-title-row {
display: flex;
align-items: center;
justify-content: flex-end;
margin-bottom: 12px;
}
.ml10 {
margin-left: 10px;
}
@@ -336,8 +561,60 @@ export default {
margin-left: 8px;
}
.ml12 {
margin-left: 12px;
}
.table-page-bar {
margin-top: 12px;
text-align: right;
}
.mb8 {
margin-bottom: 8px;
}
.mb16 {
margin-bottom: 16px;
}
.sub-title {
font-weight: 600;
margin: 10px 0 8px;
}
.text-muted {
color: #808695;
}
.table-action-link {
color: #2d8cf0;
cursor: pointer;
}
.table-action-link:hover {
text-decoration: underline;
}
.token-table-in-detail :deep(.plain-cell) {
display: flex;
flex-direction: column;
gap: 4px;
align-items: flex-start;
}
.token-table-in-detail :deep(.plain-cell-input textarea) {
font-family: monospace;
font-size: 11px;
line-height: 1.4;
min-width: 260px;
}
.mt12 {
margin-top: 12px;
}
.detail-one-line {
line-height: 32px;
}
</style>

View File

@@ -0,0 +1,139 @@
const Sequelize = require("sequelize");
const { Op } = Sequelize;
const baseModel = require("../../middleware/baseModel");
const { build_search_where } = 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;
if (!user_id) {
ctx.fail("缺少 user_id");
return;
}
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;
if (!api_path) {
ctx.fail("缺少 api_path");
return;
}
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 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 param = body.param || body;
const page_option = param.pageOption || {};
const seach_option = param.seachOption || {};
const page_num = parseInt(page_option.page, 10) || 1;
const page_size = parseInt(page_option.pageSize, 10) || 20;
const offset = (page_num - 1) * page_size;
const biz_api_call_log = baseModel.biz_api_call_log;
const where = build_search_where(biz_api_call_log, seach_option);
const { count, rows } = await biz_api_call_log.findAndCountAll({
where,
offset,
limit: page_size,
order: [["id", "DESC"]],
});
ctx.success({ rows, count });
},
"POST /biz_api_call_log/export": async (ctx) => {
const body = ctx.getBody();
const param = body.param || body;
const biz_api_call_log = baseModel.biz_api_call_log;
const where = build_search_where(biz_api_call_log, param.seachOption || {});
const rows = await biz_api_call_log.findAll({
where,
limit: 10000,
order: [["id", "DESC"]],
});
ctx.success({ rows });
},
};

View File

@@ -1,15 +1,43 @@
const crud = require("../service/biz_admin_crud");
const { getRequestBody } = crud;
const baseModel = require("../../middleware/baseModel");
const { build_search_where } = require("../utils/query_helpers");
module.exports = {
"POST /biz_audit_log/page": async (ctx) => {
const body = getRequestBody(ctx);
const data = await crud.page("biz_audit_log", body);
ctx.success({ rows: data.rows, count: data.count });
const body = ctx.getBody();
const param = body.param || body;
const page_option = param.pageOption || {};
const seach_option = param.seachOption || {};
const page_num = parseInt(page_option.page, 10) || 1;
const page_size = parseInt(page_option.pageSize, 10) || 20;
const offset = (page_num - 1) * page_size;
const biz_audit_log = baseModel.biz_audit_log;
const where = build_search_where(biz_audit_log, seach_option);
const tn = biz_audit_log.tableName;
const { count, rows } = await biz_audit_log.findAndCountAll({
where,
offset,
limit: page_size,
order: [["id", "DESC"]],
attributes: {
include: [[biz_audit_log.sequelize.col(`${tn}.created_at`), "created_at"]],
},
});
ctx.success({ rows, count });
},
"POST /biz_audit_log/export": async (ctx) => {
const body = getRequestBody(ctx);
const res = await crud.exportCsv("biz_audit_log", body);
ctx.success(res);
const body = ctx.getBody();
const param = body.param || body;
const biz_audit_log = baseModel.biz_audit_log;
const where = build_search_where(biz_audit_log, param.seachOption || {});
const tn = biz_audit_log.tableName;
const rows = await biz_audit_log.findAll({
where,
limit: 10000,
order: [["id", "DESC"]],
attributes: {
include: [[biz_audit_log.sequelize.col(`${tn}.created_at`), "created_at"]],
},
});
ctx.success({ rows });
},
};

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,19 +1,11 @@
const logic = require("../service/biz_subscription_logic");
const audit = require("../service/biz_audit_service");
const audit = require("../utils/biz_audit");
function getRequestBody(ctx) {
if (ctx.request && ctx.request.body && Object.keys(ctx.request.body).length > 0) {
return ctx.request.body;
}
if (typeof ctx.getBody === "function") {
return ctx.getBody() || {};
}
return {};
}
module.exports = {
"POST /biz_payment/confirm-offline": async (ctx) => {
const body = getRequestBody(ctx);
const body = ctx.getBody();
const row = await logic.confirmOfflinePayment(body);
await audit.logAudit({
admin_user_id: audit.pickAdminId(ctx),
@@ -25,7 +17,7 @@ module.exports = {
ctx.success(row);
},
"POST /biz_payment/confirm-link": async (ctx) => {
const body = getRequestBody(ctx);
const body = ctx.getBody();
const row = await logic.confirmLinkPayment(body);
await audit.logAudit({
admin_user_id: audit.pickAdminId(ctx),

View File

@@ -1,17 +1,31 @@
const crud = require("../service/biz_admin_crud");
const { getRequestBody } = crud;
const baseModel = require("../../middleware/baseModel");
const audit = require("../service/biz_audit_service");
const { build_search_where, normalize_for_write } = require("../utils/query_helpers");
const audit = require("../utils/biz_audit");
const proxy_api_catalog = require("../service/biz_proxy_api_catalog");
module.exports = {
"POST /biz_plan/page": async (ctx) => {
const body = getRequestBody(ctx);
const data = await crud.page("biz_plan", body);
ctx.success({ rows: data.rows, count: data.count });
const body = ctx.getBody();
const param = body.param || body;
const page_option = param.pageOption || {};
const seach_option = param.seachOption || {};
const page_num = parseInt(page_option.page, 10) || 1;
const page_size = parseInt(page_option.pageSize, 10) || 20;
const offset = (page_num - 1) * page_size;
const biz_plan = baseModel.biz_plan;
const where = build_search_where(biz_plan, seach_option);
const { count, rows } = await biz_plan.findAndCountAll({
where,
offset,
limit: page_size,
order: [["id", "DESC"]],
});
ctx.success({ rows, count });
},
"POST /biz_plan/add": async (ctx) => {
const body = getRequestBody(ctx);
const row = await crud.add("biz_plan", body);
const body = ctx.getBody();
const payload = normalize_for_write(baseModel.biz_plan, body, { for_create: true });
const row = await baseModel.biz_plan.create(payload);
await audit.logAudit({
admin_user_id: audit.pickAdminId(ctx),
action: "biz_plan.add",
@@ -22,8 +36,12 @@ module.exports = {
ctx.success(row);
},
"POST /biz_plan/edit": async (ctx) => {
const body = getRequestBody(ctx);
await crud.edit("biz_plan", body);
const body = ctx.getBody();
const id = body.id;
if (id === undefined || id === null || id === "") throw new Error("缺少 id");
const payload = normalize_for_write(baseModel.biz_plan, body, { for_create: false });
delete payload.id;
await baseModel.biz_plan.update(payload, { where: { id } });
await audit.logAudit({
admin_user_id: audit.pickAdminId(ctx),
action: "biz_plan.edit",
@@ -33,27 +51,34 @@ module.exports = {
ctx.success({});
},
"POST /biz_plan/del": async (ctx) => {
const body = getRequestBody(ctx);
await crud.del("biz_plan", body);
const body = ctx.getBody();
const id = body.id !== undefined ? body.id : body;
if (id === undefined || id === null || id === "") throw new Error("缺少 id");
await baseModel.biz_plan.destroy({ where: { id } });
await audit.logAudit({
admin_user_id: audit.pickAdminId(ctx),
action: "biz_plan.del",
resource_type: "biz_plan",
resource_id: body.id,
resource_id: id,
});
ctx.success({});
},
"GET /biz_plan/detail": async (ctx) => {
const q = ctx.query || {};
const row = await crud.detail("biz_plan", { id: q.id || q.ID });
const id = q.id || q.ID;
if (id === undefined || id === null || id === "") throw new Error("缺少 id");
const row = await baseModel.biz_plan.findByPk(id);
ctx.success(row);
},
"GET /biz_plan/all": async (ctx) => {
const rows = await crud.all("biz_plan");
const rows = await baseModel.biz_plan.findAll({
limit: 2000,
order: [["id", "DESC"]],
});
ctx.success(rows);
},
"POST /biz_plan/toggle": async (ctx) => {
const body = getRequestBody(ctx);
const body = ctx.getBody();
const id = body.id;
if (id == null) return ctx.fail("缺少 id");
const row = await baseModel.biz_plan.findByPk(id);
@@ -70,8 +95,19 @@ module.exports = {
ctx.success({ status: next });
},
"POST /biz_plan/export": async (ctx) => {
const body = getRequestBody(ctx);
const res = await crud.exportCsv("biz_plan", body);
ctx.success(res);
const body = ctx.getBody();
const param = body.param || body;
const biz_plan = baseModel.biz_plan;
const where = build_search_where(biz_plan, param.seachOption || {});
const rows = await biz_plan.findAll({
where,
limit: 10000,
order: [["id", "DESC"]],
});
ctx.success({ rows });
},
/** 转发接口目录(与 swagger 一致),用于配置套餐 allowed_apis */
"POST /biz_plan/proxy_api_catalog": async (ctx) => {
ctx.success(proxy_api_catalog.buildCatalog());
},
};

View File

@@ -1,18 +1,51 @@
const crud = require("../service/biz_admin_crud");
const { getRequestBody } = crud;
const baseModel = require("../../middleware/baseModel");
const { build_search_where } = require("../utils/query_helpers");
const logic = require("../service/biz_subscription_logic");
const audit = require("../service/biz_audit_service");
const audit = require("../utils/biz_audit");
function subscription_rows_with_names(instances) {
return instances.map((r) => {
const j = r.toJSON();
const u = j.biz_user;
const p = j.biz_plan;
const { biz_user, biz_plan, ...rest } = j;
return {
...rest,
user_name: u ? [u.name, u.mobile].filter(Boolean).join(" ").trim() : "",
plan_name: p ? String(p.plan_name || p.plan_code || "").trim() : "",
};
});
}
module.exports = {
"POST /biz_subscription/page": async (ctx) => {
const body = getRequestBody(ctx);
const data = await crud.page("biz_subscription", body);
ctx.success({ rows: data.rows, count: data.count });
const body = ctx.getBody();
const param = body.param || body;
const page_option = param.pageOption || {};
const seach_option = param.seachOption || {};
const page_num = parseInt(page_option.page, 10) || 1;
const page_size = parseInt(page_option.pageSize, 10) || 20;
const offset = (page_num - 1) * page_size;
const biz_subscription = baseModel.biz_subscription;
const where = build_search_where(biz_subscription, seach_option);
const { count, rows } = await biz_subscription.findAndCountAll({
where,
offset,
limit: page_size,
order: [["id", "DESC"]],
include: [
{ model: baseModel.biz_user, as: "biz_user", attributes: ["name", "mobile"] },
{ model: baseModel.biz_plan, as: "biz_plan", attributes: ["plan_name", "plan_code"] },
],
distinct: true,
});
ctx.success({ rows: subscription_rows_with_names(rows), count });
},
"GET /biz_subscription/detail": async (ctx) => {
const q = ctx.query || {};
const row = await crud.detail("biz_subscription", { id: q.id || q.ID });
const id = q.id || q.ID;
if (id === undefined || id === null || id === "") throw new Error("缺少 id");
const row = await baseModel.biz_subscription.findByPk(id);
ctx.success(row);
},
"GET /biz_subscription/by_user": async (ctx) => {
@@ -26,7 +59,7 @@ module.exports = {
ctx.success(rows);
},
"POST /biz_subscription/open": async (ctx) => {
const body = getRequestBody(ctx);
const body = ctx.getBody();
const row = await logic.openSubscription(body);
await audit.logAudit({
admin_user_id: audit.pickAdminId(ctx),
@@ -39,7 +72,7 @@ module.exports = {
ctx.success(row);
},
"POST /biz_subscription/upgrade": async (ctx) => {
const body = getRequestBody(ctx);
const body = ctx.getBody();
const row = await logic.upgradeSubscription(body);
await audit.logAudit({
admin_user_id: audit.pickAdminId(ctx),
@@ -51,7 +84,7 @@ module.exports = {
ctx.success(row);
},
"POST /biz_subscription/renew": async (ctx) => {
const body = getRequestBody(ctx);
const body = ctx.getBody();
const row = await logic.renewSubscription(body);
await audit.logAudit({
admin_user_id: audit.pickAdminId(ctx),
@@ -62,7 +95,7 @@ module.exports = {
ctx.success(row);
},
"POST /biz_subscription/cancel": async (ctx) => {
const body = getRequestBody(ctx);
const body = ctx.getBody();
const row = await logic.cancelSubscription(body);
await audit.logAudit({
admin_user_id: audit.pickAdminId(ctx),
@@ -73,8 +106,19 @@ module.exports = {
ctx.success(row);
},
"POST /biz_subscription/export": async (ctx) => {
const body = getRequestBody(ctx);
const res = await crud.exportCsv("biz_subscription", body);
ctx.success(res);
const body = ctx.getBody();
const param = body.param || body;
const biz_subscription = baseModel.biz_subscription;
const where = build_search_where(biz_subscription, param.seachOption || {});
const rows = await biz_subscription.findAll({
where,
limit: 10000,
order: [["id", "DESC"]],
include: [
{ model: baseModel.biz_user, as: "biz_user", attributes: ["name", "mobile"] },
{ model: baseModel.biz_plan, as: "biz_plan", attributes: ["plan_name", "plan_code"] },
],
});
ctx.success({ rows: subscription_rows_with_names(rows) });
},
};

View File

@@ -1,16 +1,30 @@
const crud = require("../service/biz_admin_crud");
const { getRequestBody } = crud;
const baseModel = require("../../middleware/baseModel");
const { build_search_where } = 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) => {
const body = getRequestBody(ctx);
const data = await crud.page("biz_api_token", body);
ctx.success({ rows: data.rows, count: data.count });
const body = ctx.getBody();
const param = body.param || body;
const page_option = param.pageOption || {};
const seach_option = param.seachOption || {};
const page_num = parseInt(page_option.page, 10) || 1;
const page_size = parseInt(page_option.pageSize, 10) || 20;
const offset = (page_num - 1) * page_size;
const biz_api_token = baseModel.biz_api_token;
const where = build_search_where(biz_api_token, seach_option);
const { count, rows } = await biz_api_token.findAndCountAll({
where,
offset,
limit: page_size,
order: [["id", "DESC"]],
attributes: { exclude: ["secret_cipher"] },
});
ctx.success({ rows, count });
},
"POST /biz_token/create": async (ctx) => {
const body = getRequestBody(ctx);
const body = ctx.getBody();
const result = await tokenLogic.createToken(body);
await audit.logAudit({
admin_user_id: audit.pickAdminId(ctx),
@@ -20,6 +34,55 @@ module.exports = {
resource_id: result.row.id,
detail: { token_name: result.row.token_name },
});
ctx.success({
id: result.row.id,
user_id: result.row.user_id,
plan_id: result.row.plan_id,
key: result.row.key,
token_name: result.row.token_name,
expire_at: result.row.expire_at,
plain_token: result.plain_token,
warn: result.warn,
});
},
"POST /biz_token/edit": async (ctx) => {
const body = ctx.getBody();
const row = await tokenLogic.updateToken(body);
await audit.logAudit({
admin_user_id: audit.pickAdminId(ctx),
biz_user_id: row.user_id,
action: "biz_token.edit",
resource_type: "biz_api_token",
resource_id: row.id,
detail: { token_name: row.token_name, key: row.key },
});
const plain = row.get ? row.get({ plain: true }) : { ...row };
delete plain.secret_cipher;
ctx.success(plain);
},
"POST /biz_token/revoke": async (ctx) => {
const body = ctx.getBody();
const row = await tokenLogic.revokeToken(body);
await audit.logAudit({
admin_user_id: audit.pickAdminId(ctx),
biz_user_id: row.user_id,
action: "biz_token.revoke",
resource_type: "biz_api_token",
resource_id: row.id,
});
ctx.success({ id: row.id, status: row.status });
},
"POST /biz_token/regenerate": async (ctx) => {
const body = ctx.getBody();
const result = await tokenLogic.regenerateToken(body);
await audit.logAudit({
admin_user_id: audit.pickAdminId(ctx),
biz_user_id: result.row.user_id,
action: "biz_token.regenerate",
resource_type: "biz_api_token",
resource_id: result.row.id,
detail: { token_name: result.row.token_name },
});
ctx.success({
id: result.row.id,
user_id: result.row.user_id,
@@ -30,21 +93,17 @@ module.exports = {
warn: result.warn,
});
},
"POST /biz_token/revoke": async (ctx) => {
const body = getRequestBody(ctx);
const row = await tokenLogic.revokeToken(body);
await audit.logAudit({
admin_user_id: audit.pickAdminId(ctx),
biz_user_id: row.user_id,
action: "biz_token.revoke",
resource_type: "biz_api_token",
resource_id: row.id,
});
ctx.success({ id: row.id, status: row.status });
},
"POST /biz_token/export": async (ctx) => {
const body = getRequestBody(ctx);
const res = await crud.exportCsv("biz_api_token", body);
ctx.success(res);
const body = ctx.getBody();
const param = body.param || body;
const biz_api_token = baseModel.biz_api_token;
const where = build_search_where(biz_api_token, param.seachOption || {});
const rows = await biz_api_token.findAll({
where,
limit: 10000,
order: [["id", "DESC"]],
attributes: { exclude: ["secret_cipher"] },
});
ctx.success({ rows });
},
};

View File

@@ -1,35 +1,64 @@
const crud = require("../service/biz_admin_crud");
const { getRequestBody } = crud;
const baseModel = require("../../middleware/baseModel");
const { build_search_where, normalize_for_write } = require("../utils/query_helpers");
module.exports = {
"POST /biz_usage/page": async (ctx) => {
const body = getRequestBody(ctx);
const data = await crud.page("biz_usage_monthly", body);
ctx.success({ rows: data.rows, count: data.count });
const body = ctx.getBody();
const param = body.param || body;
const page_option = param.pageOption || {};
const seach_option = param.seachOption || {};
const page_num = parseInt(page_option.page, 10) || 1;
const page_size = parseInt(page_option.pageSize, 10) || 20;
const offset = (page_num - 1) * page_size;
const biz_usage_monthly = baseModel.biz_usage_monthly;
const where = build_search_where(biz_usage_monthly, seach_option);
const { count, rows } = await biz_usage_monthly.findAndCountAll({
where,
offset,
limit: page_size,
order: [["id", "DESC"]],
});
ctx.success({ rows, count });
},
"POST /biz_usage/add": async (ctx) => {
const body = getRequestBody(ctx);
const row = await crud.add("biz_usage_monthly", body);
const body = ctx.getBody();
const payload = normalize_for_write(baseModel.biz_usage_monthly, body, { for_create: true });
const row = await baseModel.biz_usage_monthly.create(payload);
ctx.success(row);
},
"POST /biz_usage/edit": async (ctx) => {
const body = getRequestBody(ctx);
await crud.edit("biz_usage_monthly", body);
const body = ctx.getBody();
const id = body.id;
if (id === undefined || id === null || id === "") throw new Error("缺少 id");
const payload = normalize_for_write(baseModel.biz_usage_monthly, body, { for_create: false });
delete payload.id;
await baseModel.biz_usage_monthly.update(payload, { where: { id } });
ctx.success({});
},
"POST /biz_usage/del": async (ctx) => {
const body = getRequestBody(ctx);
await crud.del("biz_usage_monthly", body);
const body = ctx.getBody();
const id = body.id !== undefined ? body.id : body;
if (id === undefined || id === null || id === "") throw new Error("缺少 id");
await baseModel.biz_usage_monthly.destroy({ where: { id } });
ctx.success({});
},
"GET /biz_usage/detail": async (ctx) => {
const q = ctx.query || {};
const row = await crud.detail("biz_usage_monthly", { id: q.id || q.ID });
const id = q.id || q.ID;
if (id === undefined || id === null || id === "") throw new Error("缺少 id");
const row = await baseModel.biz_usage_monthly.findByPk(id);
ctx.success(row);
},
"POST /biz_usage/export": async (ctx) => {
const body = getRequestBody(ctx);
const res = await crud.exportCsv("biz_usage_monthly", body);
ctx.success(res);
const body = ctx.getBody();
const param = body.param || body;
const biz_usage_monthly = baseModel.biz_usage_monthly;
const where = build_search_where(biz_usage_monthly, param.seachOption || {});
const rows = await biz_usage_monthly.findAll({
where,
limit: 10000,
order: [["id", "DESC"]],
});
ctx.success({ rows });
},
};

View File

@@ -1,18 +1,54 @@
const crud = require("../service/biz_admin_crud");
const { getRequestBody } = crud;
const Sequelize = require("sequelize");
const { 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");
const biz_token_secret_cipher = require("../utils/biz_token_secret_cipher");
function map_tokens_for_admin_detail(token_rows) {
return token_rows.map((t) => {
const o = t.get ? t.get({ plain: true }) : { ...t };
const cipher = o.secret_cipher;
delete o.secret_cipher;
o.plain_token = biz_token_secret_cipher.decrypt_plain_from_storage(cipher);
return o;
});
}
module.exports = {
"POST /biz_user/page": async (ctx) => {
const body = getRequestBody(ctx);
const data = await crud.page("biz_user", body);
ctx.success({ rows: data.rows, count: data.count });
const body = ctx.getBody();
const param = body.param || body;
const pageOption = param.pageOption || {};
const seachOption = param.seachOption || {};
const pageNum = parseInt(pageOption.page, 10) || 1;
const pageSize = parseInt(pageOption.pageSize, 10) || 20;
const offset = (pageNum - 1) * pageSize;
const biz_user = baseModel.biz_user;
const where = build_search_where(biz_user, seachOption);
const { count, rows } = await biz_user.findAndCountAll({
where,
offset,
limit: pageSize,
order: [["id", "DESC"]],
attributes: {
include: [
[
Sequelize.literal(
`(SELECT COUNT(*) FROM biz_api_token WHERE biz_api_token.user_id = biz_user.id)`
),
"token_count",
],
],
},
});
ctx.success({ rows, count });
},
"POST /biz_user/add": async (ctx) => {
const body = getRequestBody(ctx);
const row = await crud.add("biz_user", body);
const body = ctx.getBody();
const biz_user = baseModel.biz_user;
const payload = normalize_for_write(biz_user, body, { for_create: true });
const row = await biz_user.create(payload);
await audit.logAudit({
admin_user_id: audit.pickAdminId(ctx),
biz_user_id: row.id,
@@ -21,11 +57,49 @@ module.exports = {
resource_id: row.id,
detail: { name: row.name },
});
ctx.success(row);
const out = row.get({ plain: true });
let plain_token = null;
let token_warn = null;
let token_error = null;
const auto_token = body.auto_create_token !== false;
if (auto_token && row.status === "active") {
try {
const result = await tokenLogic.createToken({
user_id: row.id,
token_name: body.initial_token_name || "default",
expire_at: body.initial_token_expire_at || tokenLogic.defaultTokenExpireAt(),
});
await audit.logAudit({
admin_user_id: audit.pickAdminId(ctx),
biz_user_id: row.id,
action: "biz_token.create",
resource_type: "biz_api_token",
resource_id: result.row.id,
detail: { token_name: result.row.token_name, via: "biz_user.add" },
});
plain_token = result.plain_token;
token_warn = result.warn;
} catch (e) {
token_error = e.message || String(e);
}
}
ctx.success({
...out,
plain_token,
token_warn,
token_error,
});
},
"POST /biz_user/edit": async (ctx) => {
const body = getRequestBody(ctx);
await crud.edit("biz_user", body);
const body = ctx.getBody();
const id = body.id;
if (id === undefined || id === null || id === "") throw new Error("缺少 id");
const biz_user = baseModel.biz_user;
const payload = normalize_for_write(biz_user, body, { for_create: false });
delete payload.id;
await biz_user.update(payload, { where: { id } });
await audit.logAudit({
admin_user_id: audit.pickAdminId(ctx),
biz_user_id: body.id,
@@ -36,8 +110,11 @@ module.exports = {
ctx.success({});
},
"POST /biz_user/del": async (ctx) => {
const body = getRequestBody(ctx);
await crud.del("biz_user", body);
const body = ctx.getBody();
const id = body.id !== undefined ? body.id : body;
if (id === undefined || id === null || id === "") throw new Error("缺少 id");
const biz_user = baseModel.biz_user;
await biz_user.destroy({ where: { id } });
await audit.logAudit({
admin_user_id: audit.pickAdminId(ctx),
biz_user_id: body.id,
@@ -50,7 +127,9 @@ module.exports = {
"GET /biz_user/detail": async (ctx) => {
const q = ctx.query || {};
const id = q.id || q.ID;
const user = await crud.detail("biz_user", { id });
if (id === undefined || id === null || id === "") throw new Error("缺少 id");
const biz_user = baseModel.biz_user;
const user = await biz_user.findByPk(id);
if (!user) {
return ctx.fail("用户不存在");
}
@@ -62,21 +141,33 @@ module.exports = {
const tokenCount = await baseModel.biz_api_token.count({
where: { user_id: id },
});
const tokens = await baseModel.biz_api_token.findAll({
where: { user_id: id },
order: [["id", "DESC"]],
limit: 200,
});
const tokens_out = map_tokens_for_admin_detail(tokens);
ctx.success({
user,
subscriptions,
tokenCount,
tokens: tokens_out,
});
},
"GET /biz_user/all": async (ctx) => {
const rows = await crud.all("biz_user");
const biz_user = baseModel.biz_user;
const rows = await biz_user.findAll({
limit: 2000,
order: [["id", "DESC"]],
});
ctx.success(rows);
},
"POST /biz_user/disable": async (ctx) => {
const body = getRequestBody(ctx);
const body = ctx.getBody();
const id = body.id;
if (id == null) return ctx.fail("缺少 id");
await baseModel.biz_user.update({ status: "disabled" }, { where: { id } });
const biz_user = baseModel.biz_user;
await biz_user.update({ status: "disabled" }, { where: { id } });
await audit.logAudit({
admin_user_id: audit.pickAdminId(ctx),
biz_user_id: id,
@@ -87,10 +178,11 @@ module.exports = {
ctx.success({});
},
"POST /biz_user/enable": async (ctx) => {
const body = getRequestBody(ctx);
const body = ctx.getBody();
const id = body.id;
if (id == null) return ctx.fail("缺少 id");
await baseModel.biz_user.update({ status: "active" }, { where: { id } });
const biz_user = baseModel.biz_user;
await biz_user.update({ status: "active" }, { where: { id } });
await audit.logAudit({
admin_user_id: audit.pickAdminId(ctx),
biz_user_id: id,
@@ -101,12 +193,19 @@ module.exports = {
ctx.success({});
},
"POST /biz_user/export": async (ctx) => {
const body = getRequestBody(ctx);
const res = await crud.exportCsv("biz_user", body);
ctx.success(res);
const body = ctx.getBody();
const param = body.param || body;
const biz_user = baseModel.biz_user;
const where = build_search_where(biz_user, param.seachOption || {});
const rows = await biz_user.findAll({
where,
limit: 10000,
order: [["id", "DESC"]],
});
ctx.success({ rows });
},
"POST /biz_user/revoke_all_tokens": async (ctx) => {
const body = getRequestBody(ctx);
const body = ctx.getBody();
const userId = body.user_id != null ? body.user_id : body.id;
if (userId == null) return ctx.fail("缺少 user_id");
const n = await tokenLogic.revokeAllForUser(userId);

View File

@@ -0,0 +1,98 @@
const swagger = require("../../_docs/swagger.json");
const auth = require("../service/biz_auth_verify");
const proxy = require("../service/biz_proxy_service");
/**
* 从 ctx 请求头中提取 Token不含 query
* - Authorization: Bearer <token>
* - Authorization: <token>(无 Bearer 前缀时整段作为 token
* - X-Api-Token / X-Token
*/
function extractToken(ctx) {
let x_token = ctx.headers['authorization'] || ''
if (x_token.startsWith("Bearer ")) {
x_token = x_token.slice(7).trim();
}
return x_token;
}
/**
* 提取 swagger tags 第一项作为 feature 名(用于套餐功能点校验)
*/
function pickFeature(spec) {
if (spec.tags && spec.tags.length > 0) {
return spec.tags[0];
}
return null;
}
/** 不参与转发的文档路径(与 framework 实际路由重叠或仅为说明) */
function should_skip_proxy_path(route_path) {
return (
route_path.startsWith("/admin_api") ||
route_path.startsWith("/api/auth")
);
}
/**
* 构建转发路由表(供 framework.addRoutes 注册)
*/
function buildProxyRoutes() {
const routes = {};
for (const [path, methods] of Object.entries(swagger.paths)) {
if (should_skip_proxy_path(path)) {
continue;
}
for (const [method, spec] of Object.entries(methods)) {
const routeKey = `${method.toUpperCase()} ${path}`;
routes[routeKey] = async (ctx) => {
// 1. 提取 Token
const token = extractToken(ctx);
if (!token) {
ctx.fail("缺少 Token");
return;
}
// 2. 鉴权Token + 用户 + 订阅 + 套餐功能点 + 接口权限 + 调用量
const feature = pickFeature(spec);
const authResult = await auth.verifyRequest({ token, feature, api_path: path });
if (!authResult.ok) {
ctx.fail(authResult.message || "鉴权失败");
return;
}
// 3. 组装 query并注入 token 对应 key上游要求参数名为 key
const query = { ...ctx.query };
if (!query.key && authResult.context && authResult.context.token_key) {
query.key = authResult.context.token_key;
}
// 4. 转发到上游
const result = await proxy.forwardRequest({
api_path: path,
method: method.toUpperCase(),
query,
body: ctx.getBody(),
headers: ctx.headers || {},
auth_ctx: authResult.context,
});
// 5. 根据上游 Success 字段决定响应方式
const upstream = result.data;
if (upstream && upstream.Code === 200) {
ctx.success(upstream);
} else {
ctx.fail(upstream && upstream.Text ? upstream.Text : "上游请求失败", upstream);
}
};
}
}
return routes;
}
module.exports = { buildProxyRoutes };

View File

@@ -1,18 +1,10 @@
const auth = require("../service/biz_auth_verify");
function getRequestBody(ctx) {
if (ctx.request && ctx.request.body && Object.keys(ctx.request.body).length > 0) {
return ctx.request.body;
}
if (typeof ctx.getBody === "function") {
return ctx.getBody() || {};
}
return {};
}
module.exports = {
"POST /auth/verify": async (ctx) => {
const body = getRequestBody(ctx);
const body = ctx.getBody();
const result = await auth.verifyRequest(body);
ctx.success(result);
},

View File

@@ -0,0 +1,59 @@
const Sequelize = require("sequelize");
module.exports = (db) => {
const biz_api_call_log = db.define(
"biz_api_call_log",
{
user_id: {
type: Sequelize.BIGINT.UNSIGNED,
allowNull: false,
},
token_id: {
type: Sequelize.BIGINT.UNSIGNED,
allowNull: false,
},
api_path: {
type: Sequelize.STRING(200),
allowNull: false,
},
http_method: {
type: Sequelize.STRING(10),
allowNull: false,
defaultValue: "POST",
},
status_code: {
type: Sequelize.INTEGER,
allowNull: false,
defaultValue: 0,
},
response_time: {
type: Sequelize.INTEGER,
allowNull: false,
defaultValue: 0,
},
response_body: {
type: Sequelize.TEXT,
allowNull: true,
},
call_date: {
type: Sequelize.DATEONLY,
allowNull: false,
},
created_at: {
type: Sequelize.DATE,
allowNull: false,
defaultValue: Sequelize.NOW,
},
},
{
tableName: "biz_api_call_log",
freezeTableName: true,
timestamps: false,
underscored: true,
}
);
//biz_api_call_log.sync({ force: true });
return biz_api_call_log;
};

View File

@@ -1,14 +1,15 @@
const Sequelize = require("sequelize");
/**
* 业务 API Token管理端页面admin/src/views/subscription/tokens.vue
* 动态路由 component 与 admin/src/router/component-map.js 中
* subscription/token 或 subscription/biz_api_token 对应。
*/
module.exports = (db) => {
const biz_api_token = db.define(
"biz_api_token",
{
id: {
type: Sequelize.BIGINT.UNSIGNED,
primaryKey: true,
autoIncrement: true,
},
user_id: {
type: Sequelize.BIGINT.UNSIGNED,
allowNull: false,
@@ -16,18 +17,25 @@ module.exports = (db) => {
plan_id: {
type: Sequelize.BIGINT.UNSIGNED,
allowNull: true,
comment: "冗余:鉴权时少联表",
},
token_name: {
type: Sequelize.STRING(100),
allowNull: false,
defaultValue: "",
},
key: {
type: Sequelize.STRING(128),
allowNull: true,
},
token_hash: {
type: Sequelize.STRING(64),
allowNull: false,
unique: true,
},
secret_cipher: {
type: Sequelize.TEXT,
allowNull: true,
},
status: {
type: Sequelize.ENUM("active", "revoked", "expired"),
allowNull: false,
@@ -37,12 +45,12 @@ module.exports = (db) => {
last_used_at: { type: Sequelize.DATE, allowNull: true },
},
{
tableName: "biz_api_tokens",
tableName: "biz_api_token",
freezeTableName: true,
timestamps: false,
underscored: true,
comment: "API Token",
}
);
// biz_api_token.sync({ alter: true });
//biz_api_token.sync({ force: true });
return biz_api_token;
};

View File

@@ -4,11 +4,7 @@ module.exports = (db) => {
const biz_audit_log = db.define(
"biz_audit_log",
{
id: {
type: Sequelize.BIGINT.UNSIGNED,
primaryKey: true,
autoIncrement: true,
},
admin_user_id: {
type: Sequelize.BIGINT.UNSIGNED,
allowNull: true,
@@ -34,12 +30,17 @@ module.exports = (db) => {
type: Sequelize.JSON,
allowNull: true,
},
// 表字段存在且非空无默认时,须由模型声明,否则 insert 缺列报错
created_at: {
type: Sequelize.DATE,
allowNull: false,
defaultValue: Sequelize.NOW,
},
},
{
tableName: "biz_audit_log",
timestamps: false,
underscored: true,
comment: "审计日志",
}
);
// biz_audit_log.sync({ alter: true });

View File

@@ -4,11 +4,7 @@ module.exports = (db) => {
const biz_plan = db.define(
"biz_plan",
{
id: {
type: Sequelize.BIGINT.UNSIGNED,
primaryKey: true,
autoIncrement: true,
},
plan_code: {
type: Sequelize.STRING(64),
allowNull: false,
@@ -38,7 +34,15 @@ module.exports = (db) => {
enabled_features: {
type: Sequelize.JSON,
allowNull: true,
comment: "JSON 功能点白名单",
},
allowed_apis: {
type: Sequelize.JSON,
allowNull: true,
},
api_call_quota: {
type: Sequelize.INTEGER,
allowNull: false,
defaultValue: 0,
},
status: {
type: Sequelize.ENUM("active", "inactive"),
@@ -47,12 +51,10 @@ module.exports = (db) => {
},
},
{
tableName: "biz_plans",
timestamps: false,
underscored: true,
comment: "套餐",
}
);
// biz_plan.sync({ alter: true });
// biz_plan.sync({ alter: true });
return biz_plan;
};

View File

@@ -4,11 +4,7 @@ module.exports = (db) => {
const biz_subscription = db.define(
"biz_subscription",
{
id: {
type: Sequelize.BIGINT.UNSIGNED,
primaryKey: true,
autoIncrement: true,
},
user_id: {
type: Sequelize.BIGINT.UNSIGNED,
allowNull: false,
@@ -42,7 +38,6 @@ module.exports = (db) => {
tableName: "biz_subscriptions",
timestamps: false,
underscored: true,
comment: "订阅",
}
);
// biz_subscription.sync({ alter: true });

View File

@@ -4,11 +4,7 @@ module.exports = (db) => {
const biz_usage_monthly = db.define(
"biz_usage_monthly",
{
id: {
type: Sequelize.BIGINT.UNSIGNED,
primaryKey: true,
autoIncrement: true,
},
user_id: {
type: Sequelize.BIGINT.UNSIGNED,
allowNull: false,
@@ -20,19 +16,23 @@ module.exports = (db) => {
stat_month: {
type: Sequelize.STRING(7),
allowNull: false,
comment: "YYYY-MM",
},
msg_count: { type: Sequelize.INTEGER, allowNull: false, defaultValue: 0 },
mass_count: { type: Sequelize.INTEGER, allowNull: false, defaultValue: 0 },
friend_count: { type: Sequelize.INTEGER, allowNull: false, defaultValue: 0 },
sns_count: { type: Sequelize.INTEGER, allowNull: false, defaultValue: 0 },
active_user_count: { type: Sequelize.INTEGER, allowNull: false, defaultValue: 0 },
api_call_count: {
type: Sequelize.INTEGER,
allowNull: false,
defaultValue: 0,
},
},
{
tableName: "biz_usage_monthly",
freezeTableName: true,
timestamps: false,
underscored: true,
comment: "月用量",
}
);
// biz_usage_monthly.sync({ alter: true });

View File

@@ -4,21 +4,15 @@ module.exports = (db) => {
const biz_user = db.define(
"biz_user",
{
id: {
type: Sequelize.BIGINT.UNSIGNED,
primaryKey: true,
autoIncrement: true,
},
name: {
type: Sequelize.STRING(100),
allowNull: false,
defaultValue: "",
comment: "姓名/称呼",
},
mobile: {
type: Sequelize.STRING(20),
allowNull: true,
comment: "手机号",
},
email: {
type: Sequelize.STRING(120),
@@ -27,7 +21,6 @@ module.exports = (db) => {
company_name: {
type: Sequelize.STRING(200),
allowNull: true,
comment: "公司名",
},
status: {
type: Sequelize.ENUM("active", "disabled"),
@@ -36,12 +29,10 @@ module.exports = (db) => {
},
},
{
// 与库表名一致:单数 biz_user与模型名一致避免部分环境下 tableName 未生效时落到默认表名 biz_user
tableName: "biz_user",
freezeTableName: true,
timestamps: false,
underscored: true,
comment: "业务用户",
}
);
// biz_user.sync({ alter: true });

View File

@@ -6,19 +6,16 @@ module.exports = (db) => {
type: Sequelize.STRING(100),
allowNull: false,
defaultValue: "",
comment: "控件名称",
},
module_key: {
type: Sequelize.STRING(100),
allowNull: false,
defaultValue: "",
comment: "组件key",
},
data_lenght: {
type: Sequelize.INTEGER(11),
allowNull: false,
defaultValue: "50",
comment: "数据长度",
},
});
};

View File

@@ -7,19 +7,16 @@ module.exports = (db) => {
type: Sequelize.STRING(100),
allowNull: false,
defaultValue: "",
comment: "表名",
},
operate: {
type: Sequelize.STRING(100),
allowNull: false,
defaultValue: "",
comment: "操作",
},
content: {
type: Sequelize.JSON,
allowNull: false,
defaultValue: "",
comment: "内容",
set(value) {
this.setDataValue("content", { value });
},

View File

@@ -8,27 +8,23 @@ module.exports = (db) => {
type: Sequelize.STRING(100),
allowNull: false,
defaultValue: "",
comment: "菜单名称",
},
// 父id
parent_id: {
type: Sequelize.INTEGER(11).UNSIGNED,
allowNull: true,
defaultValue: 0,
comment: "父id",
},
// 图标
icon: {
type: Sequelize.STRING(100),
allowNull: false,
defaultValue: "",
comment: "图标",
},
path: {
type: Sequelize.STRING(255),
allowNull: false,
defaultValue: "",
comment: "路径",
},
// 菜单类型 "菜单", "页面", "外链", "功能"
@@ -36,14 +32,12 @@ module.exports = (db) => {
type: Sequelize.STRING(255),
allowNull: false,
defaultValue: "页面",
comment: "菜单类型",
},
//模型id
model_id: {
type: Sequelize.INTEGER(11).UNSIGNED,
allowNull: true,
defaultValue: 0,
comment: "模型id",
},
//表单id
@@ -51,7 +45,6 @@ module.exports = (db) => {
type: Sequelize.INTEGER(11).UNSIGNED,
allowNull: true,
defaultValue: 0,
comment: "表单id",
},
// 组件地址
@@ -59,7 +52,6 @@ module.exports = (db) => {
type: Sequelize.STRING(100),
allowNull: false,
defaultValue: "",
comment: "组件地址",
},
// api地址
@@ -67,20 +59,17 @@ module.exports = (db) => {
type: Sequelize.STRING(100),
allowNull: false,
defaultValue: "",
comment: "api地址",
},
// 是否显示在菜单中
is_show_menu: {
type: Sequelize.INTEGER(1),
allowNull: false,
defaultValue: true,
comment: "是否显示在菜单中",
},
is_show: {
type: Sequelize.INTEGER(1),
allowNull: false,
defaultValue: true,
comment: "是否展示",
},
// 菜单类型
@@ -88,7 +77,6 @@ module.exports = (db) => {
type: Sequelize.INTEGER(11),
allowNull: false,
defaultValue: "0",
comment: "菜单类型",
},
});
};

View File

@@ -7,21 +7,18 @@ module.exports = (db) => {
type: Sequelize.STRING(100),
allowNull: false,
defaultValue: "",
comment: "字典key",
},
value: {
type: Sequelize.STRING(100),
allowNull: false,
defaultValue: "",
comment: "值",
},
remark: {
type: Sequelize.STRING(500),
allowNull: false,
defaultValue: "",
comment: "备注",
},
// 是否允许修改 0 允许1 不允许
@@ -29,7 +26,6 @@ module.exports = (db) => {
type: Sequelize.INTEGER(2),
allowNull: false,
defaultValue: 0,
comment: "是否允许修改",
},
});
};

View File

@@ -7,20 +7,17 @@ module.exports = (db) => {
type: Sequelize.STRING(100),
allowNull: false,
defaultValue: "",
comment: "角色名称",
},
// 0 普通角色 1 系统角色
type: {
type: Sequelize.INTEGER(1),
allowNull: false,
defaultValue: "0",
comment: "角色类型",
},
menus: {
type: Sequelize.JSON,
allowNull: false,
defaultValue: "",
comment: "权限菜单",
set(value) {
this.setDataValue('menus', { value });
},

View File

@@ -7,18 +7,15 @@ module.exports = (db) => {
type: Sequelize.STRING(100),
allowNull: false,
defaultValue: "",
comment: "名称",
},
password: {
type: Sequelize.STRING(100),
allowNull: false,
defaultValue: "",
comment: "密码",
},
roleId: {
type: Sequelize.INTEGER,
allowNull: false,
comment: "角色id",
},
});
};

View File

@@ -1,205 +0,0 @@
/**
* 订阅模块通用 CRUD与 admin 约定 POST /{model}/page|add|edit|del GET /{model}/detail|all
*/
const baseModel = require("../../middleware/baseModel");
const { op } = baseModel;
function getRequestBody(ctx) {
if (ctx.request && ctx.request.body && Object.keys(ctx.request.body).length > 0) {
return ctx.request.body;
}
if (typeof ctx.getBody === "function") {
return ctx.getBody() || {};
}
return {};
}
function getModel(modelName) {
const m = baseModel[modelName];
if (!m) {
throw new Error(`模型不存在: ${modelName}`);
}
return m;
}
function normalizeForWrite(model, data, { forCreate } = {}) {
const attrs = model.rawAttributes;
const out = {};
for (const k of Object.keys(data || {})) {
if (!attrs[k]) continue;
let v = data[k];
if (v === "") {
if (k === "id" && forCreate) continue;
if (k.endsWith("_id") || k === "id") {
v = null;
} else if (attrs[k].allowNull) {
v = null;
}
}
if (k === "enabled_features" && typeof v === "string" && v.trim() !== "") {
try {
v = JSON.parse(v);
} catch (e) {
/* 保持原字符串,由 Sequelize 或 DB 报错 */
}
}
out[k] = v;
}
if (forCreate && out.id !== undefined && (out.id === "" || out.id === null)) {
delete out.id;
}
return out;
}
function buildSearchWhere(model, seachOption) {
const key = seachOption && seachOption.key;
const raw = seachOption && seachOption.value;
if (!key || raw === undefined || raw === null) return {};
const str = String(raw).trim();
if (str === "") return {};
const attr = model.rawAttributes[key];
if (!attr) {
return { [key]: { [op.like]: `%${str}%` } };
}
const typeKey = attr.type && attr.type.key;
if (typeKey === "BOOLEAN") {
if (str === "true" || str === "1" || str === "是") return { [key]: true };
if (str === "false" || str === "0" || str === "否") return { [key]: false };
return {};
}
if (typeKey === "ENUM") {
return { [key]: str };
}
if (
typeKey === "INTEGER" ||
typeKey === "BIGINT" ||
typeKey === "FLOAT" ||
typeKey === "DOUBLE" ||
typeKey === "DECIMAL"
) {
const n = Number(str);
if (!Number.isNaN(n)) return { [key]: n };
return {};
}
if (typeKey === "DATE" || typeKey === "DATEONLY") {
return { [key]: str };
}
return { [key]: { [op.like]: `%${str}%` } };
}
/** 模型未声明但表中存在的列(列表/导出需要带出) */
function extraListAttributes(modelName, model) {
if (modelName === "biz_audit_log") {
const tn = model.tableName;
return {
attributes: {
include: [[model.sequelize.col(`${tn}.created_at`), "created_at"]],
},
};
}
return {};
}
async function page(modelName, body) {
const model = getModel(modelName);
const param = body.param || body;
const pageOption = param.pageOption || {};
const seachOption = param.seachOption || {};
const pageNum = parseInt(pageOption.page, 10) || 1;
const pageSize = parseInt(pageOption.pageSize, 10) || 20;
const offset = (pageNum - 1) * pageSize;
const where = buildSearchWhere(model, seachOption);
const { count, rows } = await model.findAndCountAll({
where,
offset,
limit: pageSize,
order: [["id", "DESC"]],
...extraListAttributes(modelName, model),
});
return { rows, count };
}
async function add(modelName, body) {
const model = getModel(modelName);
const payload = normalizeForWrite(model, body, { forCreate: true });
const row = await model.create(payload);
return row;
}
async function edit(modelName, body) {
const model = getModel(modelName);
const id = body.id;
if (id === undefined || id === null || id === "") {
throw new Error("缺少 id");
}
const payload = normalizeForWrite(model, body, { forCreate: false });
delete payload.id;
await model.update(payload, { where: { id } });
return {};
}
async function del(modelName, body) {
const model = getModel(modelName);
const id = body.id !== undefined ? body.id : body;
if (id === undefined || id === null || id === "") {
throw new Error("缺少 id");
}
await model.destroy({ where: { id } });
return {};
}
async function detail(modelName, query) {
const model = getModel(modelName);
const id = query && (query.id || query.ID);
if (id === undefined || id === null || id === "") {
throw new Error("缺少 id");
}
const row = await model.findByPk(id, extraListAttributes(modelName, model));
return row;
}
async function all(modelName) {
const model = getModel(modelName);
const rows = await model.findAll({
limit: 2000,
order: [["id", "DESC"]],
...extraListAttributes(modelName, model),
});
return rows;
}
async function exportCsv(modelName, body) {
const model = getModel(modelName);
const param = body.param || body;
const where = buildSearchWhere(model, param.seachOption || {});
const rows = await model.findAll({
where,
limit: 10000,
order: [["id", "DESC"]],
...extraListAttributes(modelName, model),
});
return { rows };
}
module.exports = {
page,
add,
edit,
del,
detail,
all,
exportCsv,
getRequestBody,
buildSearchWhere,
};

View File

@@ -29,11 +29,11 @@ function hasPositiveDelta(delta) {
}
/**
* 对外鉴权Token + 用户 + 有效订阅 + 功能点 + 可选用量上报与额度
* body: { token, feature?, usage_delta?: { msg?, mass?, ... } }
* 对外鉴权Token + 用户 + 有效订阅 + 功能点 + 接口权限 + API调用量 + 可选用量上报
* body: { token, feature?, api_path?, usage_delta?: { msg?, mass?, ... } }
*/
async function verifyRequest(body) {
const { token, feature } = body || {};
const { token, feature, api_path } = body || {};
if (!token) {
return { ok: false, error_code: "TOKEN_INVALID", message: "缺少 token" };
}
@@ -71,9 +71,26 @@ async function verifyRequest(body) {
return { ok: false, error_code: "FEATURE_NOT_ALLOWED", message: "功能未在套餐内" };
}
// 接口路径级权限校验
if (api_path) {
const apiCheck = usageSvc.checkApiPathAllowed(plan, api_path);
if (!apiCheck.ok) {
return { ok: false, error_code: apiCheck.error_code, message: apiCheck.message };
}
}
const statMonth = usageSvc.currentStatMonth();
let usageRow = await usageSvc.getOrCreateUsage(row.user_id, sub.plan_id, statMonth);
// API 调用次数配额校验
if (api_path) {
const callCheck = usageSvc.checkApiCallQuota(plan, usageRow);
if (!callCheck.ok) {
return { ok: false, error_code: callCheck.error_code, message: callCheck.message };
}
usageRow = await usageSvc.incrementApiCallCount(row.user_id, sub.plan_id, statMonth);
}
const delta = normalizeUsageDelta(body.usage_delta || body.usage_report);
if (hasPositiveDelta(delta)) {
const q = usageSvc.checkQuotaAfterDelta(plan, usageRow, delta);
@@ -92,6 +109,7 @@ async function verifyRequest(body) {
plan_id: sub.plan_id,
subscription_id: sub.id,
token_id: row.id,
token_key: row.key || "",
stat_month: statMonth,
usage_snapshot: {
msg_count: usageSvc.num(usageRow.msg_count),
@@ -99,6 +117,7 @@ async function verifyRequest(body) {
friend_count: usageSvc.num(usageRow.friend_count),
sns_count: usageSvc.num(usageRow.sns_count),
active_user_count: usageSvc.num(usageRow.active_user_count),
api_call_count: usageSvc.num(usageRow.api_call_count),
},
},
};

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

@@ -0,0 +1,54 @@
const swagger = require("../../_docs/swagger.json");
const HTTP_METHODS = new Set(["get", "post", "put", "delete", "patch", "head", "options"]);
/**
* 与 proxy_api.buildProxyRoutes 使用同一套 swagger.paths供套餐「接口白名单」勾选
* @returns {{ items: object[], groups: Record<string, object[]>, tags: string[] }}
*/
function buildCatalog() {
const byPath = new Map();
const paths = swagger.paths || {};
for (const [routePath, methods] of Object.entries(paths)) {
if (!methods || typeof methods !== "object") continue;
for (const [method, spec] of Object.entries(methods)) {
if (!HTTP_METHODS.has(method.toLowerCase())) continue;
if (!spec || typeof spec !== "object") continue;
const tag = (spec.tags && spec.tags[0]) || "其他";
const summary = spec.summary || spec.operationId || "";
if (!byPath.has(routePath)) {
byPath.set(routePath, {
path: routePath,
methods: new Set(),
summary: summary || "",
tag,
});
}
const row = byPath.get(routePath);
row.methods.add(method.toUpperCase());
}
}
const items = Array.from(byPath.values())
.map((x) => ({
path: x.path,
methods: Array.from(x.methods).sort(),
summary: x.summary || "",
tag: x.tag,
}))
.sort((a, b) => a.path.localeCompare(b.path));
/** @type {Record<string, object[]>} */
const groups = {};
for (const it of items) {
if (!groups[it.tag]) groups[it.tag] = [];
groups[it.tag].push(it);
}
const tags = Object.keys(groups).sort((a, b) => a.localeCompare(b, "zh-CN"));
return { items, groups, tags };
}
module.exports = { buildCatalog };

View File

@@ -0,0 +1,118 @@
const axios = require("axios");
const baseModel = require("../../middleware/baseModel");
const config = require("../../config/config");
const logs = require("../../tool/logs_proxy");
const upstreamBaseUrl = config.upstream_api_url || "http://127.0.0.1:8888";
/** 写入日志用:序列化响应并截断,避免 TEXT 过大 */
const RESPONSE_BODY_MAX_LEN = 16000;
function serialize_response_for_log(data) {
if (data === undefined || data === null) return "";
let s;
if (typeof data === "string") s = data;
else {
try {
s = JSON.stringify(data);
} catch (e) {
s = String(data);
}
}
if (s.length > RESPONSE_BODY_MAX_LEN) {
return `${s.slice(0, RESPONSE_BODY_MAX_LEN)}…[truncated]`;
}
return s;
}
/**
* 转发请求到上游并记录调用日志
* @param {object} params
* @param {string} params.api_path - 接口路径,如 /user/GetProfile
* @param {string} params.method - HTTP 方法
* @param {object} params.query - query 参数(透传)
* @param {object} params.body - body 参数(透传)
* @param {object} params.headers - 需要透传的请求头
* @param {object} params.auth_ctx - 鉴权上下文verifyRequest 返回的 context
* @returns {object} { status, data, headers }
*/
async function forwardRequest({ api_path, method, query, body, headers, auth_ctx = {} }) {
const url = `${upstreamBaseUrl}${api_path}`;
const start = Date.now();
let status_code = 0;
let resp_data = null;
let resp_headers = {};
try {
const forwardHeaders = {};
if (headers["content-type"]) forwardHeaders["content-type"] = headers["content-type"];
if (headers["user-agent"]) forwardHeaders["user-agent"] = headers["user-agent"];
const resp = await axios({
method: method.toLowerCase(),
url,
params: query,
data: body,
headers: forwardHeaders,
timeout: 30000,
validateStatus: () => true,
});
status_code = resp.status;
resp_data = resp.data;
resp_headers = resp.headers;
} catch (err) {
status_code = 502;
resp_data = { ok: false, error_code: "UPSTREAM_ERROR", message: err.message };
logs.error(`[proxy] 转发失败 ${api_path}`, err.message);
}
const response_time = Date.now() - start;
// 异步写调用日志,不阻塞响应
writeCallLog({
user_id: auth_ctx.user_id,
token_id: auth_ctx.token_id,
api_path,
http_method: method.toUpperCase(),
status_code,
response_time,
response_body: serialize_response_for_log(resp_data),
}).catch((e) => logs.error("[proxy] 写调用日志失败", e.message));
return { status: status_code, data: resp_data, headers: resp_headers };
}
/**
* 写入 API 调用日志
*/
async function writeCallLog({
user_id,
token_id,
api_path,
http_method,
status_code,
response_time,
response_body,
}) {
try {
const now = new Date();
const call_date = `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, "0")}-${String(now.getDate()).padStart(2, "0")}`;
await baseModel.biz_api_call_log.create({
user_id,
token_id,
api_path,
http_method,
status_code,
response_time,
response_body: response_body || null,
call_date,
created_at: now,
});
} catch (e) {
logs.error("[proxy] 写调用日志失败", e.message);
}
}
module.exports = { forwardRequest };

View File

@@ -1,6 +1,9 @@
const crypto = require("crypto");
const Sequelize = require("sequelize");
const op = Sequelize.Op;
const baseModel = require("../../middleware/baseModel");
const { op } = baseModel;
const biz_token_secret_cipher = require("../utils/biz_token_secret_cipher");
const { normalize_for_write } = require("../utils/query_helpers");
const MAX_TOKENS_PER_USER = 5;
@@ -9,7 +12,14 @@ function hashPlainToken(plain) {
}
function generatePlainToken() {
return `waw_${crypto.randomBytes(24).toString("hex")}`;
return `sk-${crypto.randomBytes(24).toString("hex")}`;
}
/** 默认 Token 过期时间:一年后当日 23:59:59 */
function defaultTokenExpireAt() {
const d = new Date();
d.setFullYear(d.getFullYear() + 1);
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")} 23:59:59`;
}
/** 当前时间在 [start,end] 内且 status=active 的订阅 */
@@ -27,7 +37,7 @@ async function findActiveSubscriptionForUser(userId) {
}
async function createToken(body) {
const { user_id, token_name, expire_at } = body;
const { user_id, token_name, expire_at, key } = body;
if (!user_id || !expire_at) throw new Error("缺少 user_id 或 expire_at");
const u = await baseModel.biz_user.findByPk(user_id);
if (!u) throw new Error("用户不存在");
@@ -45,12 +55,15 @@ async function createToken(body) {
const plain = generatePlainToken();
const token_hash = hashPlainToken(plain);
const secret_cipher = biz_token_secret_cipher.encrypt_plain_for_storage(plain);
const row = await baseModel.biz_api_token.create({
user_id,
plan_id,
token_name: token_name || "default",
key: key || null,
token_hash,
secret_cipher,
status: "active",
expire_at,
});
@@ -62,19 +75,83 @@ async function createToken(body) {
};
}
/**
* 管理端编辑:名称、账号 key、过期时间不改密钥
*/
async function updateToken(body) {
const id = body.id;
if (id == null || id === "") throw new Error("缺少 id");
const row = await baseModel.biz_api_token.findByPk(id);
if (!row) throw new Error("Token 不存在");
const payload = normalize_for_write(
baseModel.biz_api_token,
{
token_name: body.token_name,
key: body.key,
expire_at: body.expire_at,
},
{ for_create: false }
);
const patch = {};
if (payload.token_name !== undefined) patch.token_name = payload.token_name;
if (payload.key !== undefined) patch.key = payload.key;
if (payload.expire_at !== undefined) patch.expire_at = payload.expire_at;
if (Object.keys(patch).length === 0) throw new Error("没有可更新字段");
await row.update(patch);
await row.reload();
return row;
}
async function revokeToken(body) {
const id = body.id;
if (id == null) throw new Error("缺少 id");
const row = await baseModel.biz_api_token.findByPk(id);
if (!row) throw new Error("Token 不存在");
await row.update({ status: "revoked" });
await row.update({ status: "revoked", secret_cipher: null });
return row;
}
/**
* 保留同一条 Token 记录,仅更换密钥(旧明文立即失效)
*/
async function regenerateToken(body) {
const id = body.id;
if (id == null) throw new Error("缺少 id");
const row = await baseModel.biz_api_token.findByPk(id);
if (!row) throw new Error("Token 不存在");
if (row.status !== "active") throw new Error("仅可对状态为 active 的 Token 重新生成密钥");
const u = await baseModel.biz_user.findByPk(row.user_id);
if (!u) throw new Error("用户不存在");
if (u.status !== "active") throw new Error("用户已禁用,无法轮换密钥");
const sub = await findActiveSubscriptionForUser(row.user_id);
const plan_id = sub ? sub.plan_id : null;
const plain = generatePlainToken();
const token_hash = hashPlainToken(plain);
const secret_cipher = biz_token_secret_cipher.encrypt_plain_for_storage(plain);
await row.update({
token_hash,
plan_id,
secret_cipher,
});
await row.reload();
return {
row,
plain_token: plain,
warn: sub ? null : "当前无生效中的订阅,鉴权将失败",
};
}
async function revokeAllForUser(userId) {
if (userId == null) throw new Error("缺少 user_id");
const [n] = await baseModel.biz_api_token.update(
{ status: "revoked" },
{ status: "revoked", secret_cipher: null },
{ where: { user_id: userId, status: "active" } }
);
return n;
@@ -83,8 +160,11 @@ async function revokeAllForUser(userId) {
module.exports = {
hashPlainToken,
createToken,
updateToken,
regenerateToken,
revokeToken,
revokeAllForUser,
findActiveSubscriptionForUser,
defaultTokenExpireAt,
MAX_TOKENS_PER_USER,
};

View File

@@ -1,5 +1,6 @@
const Sequelize = require("sequelize");
const op = Sequelize.Op;
const baseModel = require("../../middleware/baseModel");
const { op } = baseModel;
function currentStatMonth(d = new Date()) {
const y = d.getFullYear();
@@ -35,6 +36,7 @@ async function getOrCreateUsage(userId, planId, statMonth) {
friend_count: 0,
sns_count: 0,
active_user_count: 0,
api_call_count: 0,
},
});
if (num(row.plan_id) !== num(planId)) {
@@ -100,11 +102,66 @@ async function ensureUsageRowsForCurrentMonth() {
return n;
}
/**
* 校验 API 调用次数是否超限
* @param {object} plan - 套餐记录(含 api_call_quota
* @param {object} usageRow - 当月用量记录(含 api_call_count
* @returns {{ ok: boolean, error_code?: string, message?: string }}
*/
function checkApiCallQuota(plan, usageRow) {
const quota = num(plan.api_call_quota);
if (quota <= 0) return { ok: true };
const used = num(usageRow.api_call_count) + 1;
if (used > quota) {
return { ok: false, error_code: "API_CALL_QUOTA_EXCEEDED", message: `当月 API 调用次数已达上限(${quota})` };
}
return { ok: true };
}
/**
* API 调用次数 +1
*/
async function incrementApiCallCount(userId, planId, statMonth) {
const row = await getOrCreateUsage(userId, planId, statMonth);
await row.update({ api_call_count: num(row.api_call_count) + 1 });
return row.reload();
}
/**
* 校验接口路径是否在套餐允许范围内
* @param {object} plan - 套餐记录(含 allowed_apis
* @param {string} apiPath - 当前请求的接口路径,如 /user/GetProfile
* @returns {{ ok: boolean, error_code?: string, message?: string }}
*/
function checkApiPathAllowed(plan, apiPath) {
const allowed = plan.allowed_apis;
// null / undefined不限制接口
if (allowed == null) return { ok: true };
let list = allowed;
if (typeof list === "string") {
try {
list = JSON.parse(list);
} catch {
return { ok: true };
}
}
if (!Array.isArray(list)) return { ok: true };
// 空数组:明确配置为「不允许任何转发接口」
if (list.length === 0) {
return { ok: false, error_code: "API_NOT_ALLOWED", message: "当前套餐未开放任何接口" };
}
if (list.includes(apiPath)) return { ok: true };
return { ok: false, error_code: "API_NOT_ALLOWED", message: `当前套餐不支持该接口: ${apiPath}` };
}
module.exports = {
currentStatMonth,
getOrCreateUsage,
applyDelta,
checkQuotaAfterDelta,
ensureUsageRowsForCurrentMonth,
checkApiCallQuota,
incrementApiCallCount,
checkApiPathAllowed,
num,
};

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 {
@@ -20,6 +13,7 @@ async function logAudit(p) {
resource_type: p.resource_type || "",
resource_id: p.resource_id != null ? p.resource_id : null,
detail: p.detail || null,
created_at: new Date(),
});
} catch (e) {
logs.error("[biz_audit] 写入失败", e);

View File

@@ -0,0 +1,51 @@
const crypto = require("crypto");
const config = require("../../config/config");
/**
* 管理端查看 Token 明文用AES-256-GCM密钥来自环境变量或 config.biz_token_enc_key。
*/
function get_key_32() {
const raw = process.env.BIZ_TOKEN_ENC_KEY || config.biz_token_enc_key;
if (!raw) {
return crypto.createHash("sha256").update("dev-biz-token-enc-set-BIZ_TOKEN_ENC_KEY", "utf8").digest();
}
return crypto.createHash("sha256").update(String(raw), "utf8").digest();
}
function encrypt_plain_for_storage(plain) {
if (plain == null || plain === "") return null;
const key = get_key_32();
const iv = crypto.randomBytes(12);
const cipher = crypto.createCipheriv("aes-256-gcm", key, iv);
const enc = Buffer.concat([cipher.update(String(plain), "utf8"), cipher.final()]);
const tag = cipher.getAuthTag();
const payload = {
v: 1,
iv: iv.toString("base64"),
tag: tag.toString("base64"),
data: enc.toString("base64"),
};
return Buffer.from(JSON.stringify(payload), "utf8").toString("base64");
}
function decrypt_plain_from_storage(stored) {
if (stored == null || stored === "") return null;
try {
const payload = JSON.parse(Buffer.from(stored, "base64").toString("utf8"));
if (payload.v !== 1) return null;
const key = get_key_32();
const iv = Buffer.from(payload.iv, "base64");
const tag = Buffer.from(payload.tag, "base64");
const data = Buffer.from(payload.data, "base64");
const decipher = crypto.createDecipheriv("aes-256-gcm", key, iv);
decipher.setAuthTag(tag);
return Buffer.concat([decipher.update(data), decipher.final()]).toString("utf8");
} catch {
return null;
}
}
module.exports = {
encrypt_plain_for_storage,
decrypt_plain_from_storage,
};

View File

@@ -0,0 +1,82 @@
/**
* 管理端:筛选条件、写入字段裁剪(分页/导出在各 controller 内直接 findAndCountAll / findAll
*/
const Sequelize = require("sequelize");
const { Op } = Sequelize;
function build_search_where(model, seach_option) {
const key = seach_option && seach_option.key;
const raw = seach_option && seach_option.value;
if (!key || raw === undefined || raw === null) return {};
const str = String(raw).trim();
if (str === "") return {};
const attr = model.rawAttributes[key];
if (!attr) {
return { [key]: { [Op.like]: `%${str}%` } };
}
const type_key = attr.type && attr.type.key;
if (type_key === "BOOLEAN") {
if (str === "true" || str === "1" || str === "是") return { [key]: true };
if (str === "false" || str === "0" || str === "否") return { [key]: false };
return {};
}
if (type_key === "ENUM") {
return { [key]: str };
}
if (
type_key === "INTEGER" ||
type_key === "BIGINT" ||
type_key === "FLOAT" ||
type_key === "DOUBLE" ||
type_key === "DECIMAL"
) {
const n = Number(str);
if (!Number.isNaN(n)) return { [key]: n };
return {};
}
if (type_key === "DATE" || type_key === "DATEONLY") {
return { [key]: str };
}
return { [key]: { [Op.like]: `%${str}%` } };
}
function normalize_for_write(model, data, { for_create } = {}) {
const attrs = model.rawAttributes;
const out = {};
for (const k of Object.keys(data || {})) {
if (!attrs[k]) continue;
let v = data[k];
if (v === "") {
if (k === "id" && for_create) continue;
if (k.endsWith("_id") || k === "id") {
v = null;
} else if (attrs[k].allowNull) {
v = null;
}
}
if (k === "enabled_features" && typeof v === "string" && v.trim() !== "") {
try {
v = JSON.parse(v);
} catch (e) {
/* 保持原字符串 */
}
}
out[k] = v;
}
if (for_create && out.id !== undefined && (out.id === "" || out.id === null)) {
delete out.id;
}
return out;
}
module.exports = {
build_search_where,
normalize_for_write,
};

44
app.js
View File

@@ -1,50 +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) => {
}
});
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

@@ -36,5 +36,11 @@ module.exports = {
"apiKey": "",
"baseUrl": "https://dashscope.aliyuncs.com/api/v1"
},
// 官方 API 上游地址(转发层目标)
"upstream_api_url": "http://113.44.162.180:7006",
/** 用于加密存储 Token 明文供管理端查看(生产请用环境变量 BIZ_TOKEN_ENC_KEY */
"biz_token_enc_key": process.env.BIZ_TOKEN_ENC_KEY || "wechat-admin-dev-token-enc-key-change-me",
}

View File

@@ -28,7 +28,43 @@ const baseConfig = {
"/sys_file/",
"/api/docs",
"api/swagger.json",
"/api/auth/verify"
"/api/auth/verify",
// 转发层路由白名单(框架不鉴权,由控制器内部做 Token 鉴权)
// 与 swagger 一致的无 /api 前缀路径
"/admin/",
"/applet/",
"/equipment/",
"/favor/",
"/finder/",
"/friend/",
"/group/",
"/label/",
"/login/",
"/message/",
"/other/",
"/pay/",
"/qy/",
"/shop/",
"/sns/",
"/user/",
"/ws/",
"/api/admin/",
"/api/applet/",
"/api/equipment/",
"/api/favor/",
"/api/finder/",
"/api/friend/",
"/api/group/",
"/api/label/",
"/api/login/",
"/api/message/",
"/api/other/",
"/api/pay/",
"/api/qy/",
"/api/shop/",
"/api/sns/",
"/api/user/",
"/api/ws/"
]
};

View File

@@ -39,5 +39,11 @@ module.exports = {
"apiKey": "",
"baseUrl": "https://dashscope.aliyuncs.com/api/v1"
},
// 官方 API 上游地址(转发层目标)
"upstream_api_url": "http://127.0.0.1:8888",
/** 生产环境务必设置环境变量 BIZ_TOKEN_ENC_KEY长随机串 */
"biz_token_enc_key": process.env.BIZ_TOKEN_ENC_KEY,
}

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,
};

View File

@@ -3,337 +3,7 @@
* @param {Object} models - 所有模型对象
*/
module.exports = (models) => {
// ========== 仓库主数据 ==========
// models["war_warehouse"].hasMany(models["war_laneway"], {
// foreignKey: "warehouse_id",
// sourceKey: "id",
// as: "laneways",
// });
// models["war_laneway"].belongsTo(models["war_warehouse"], {
// foreignKey: "warehouse_id",
// targetKey: "id",
// as: "warehouse",
// });
// models["war_warehouse"].hasMany(models["war_rack"], {
// foreignKey: "warehouse_id",
// sourceKey: "id",
// as: "racks",
// });
// models["war_rack"].belongsTo(models["war_warehouse"], {
// foreignKey: "warehouse_id",
// targetKey: "id",
// as: "warehouse",
// });
// models["war_warehouse"].hasMany(models["war_area"], {
// foreignKey: "warehouse_id",
// sourceKey: "id",
// as: "areas",
// });
// models["war_area"].belongsTo(models["war_warehouse"], {
// foreignKey: "warehouse_id",
// targetKey: "id",
// as: "warehouse",
// });
// models["war_warehouse"].hasMany(models["war_location"], {
// foreignKey: "warehouse_id",
// sourceKey: "id",
// as: "locations",
// });
// models["war_location"].belongsTo(models["war_warehouse"], {
// foreignKey: "warehouse_id",
// targetKey: "id",
// as: "warehouse",
// });
// // 货位从属(可选)
// models["war_area"].hasMany(models["war_location"], {
// foreignKey: "area_id",
// sourceKey: "id",
// as: "area_locations",
// });
// models["war_location"].belongsTo(models["war_area"], {
// foreignKey: "area_id",
// targetKey: "id",
// as: "area",
// });
// models["war_laneway"].hasMany(models["war_location"], {
// foreignKey: "laneway_id",
// sourceKey: "id",
// as: "laneway_locations",
// });
// models["war_location"].belongsTo(models["war_laneway"], {
// foreignKey: "laneway_id",
// targetKey: "id",
// as: "laneway",
// });
// models["war_rack"].hasMany(models["war_location"], {
// foreignKey: "rack_id",
// sourceKey: "id",
// as: "rack_locations",
// });
// models["war_location"].belongsTo(models["war_rack"], {
// foreignKey: "rack_id",
// targetKey: "id",
// as: "rack",
// });
// // ========== 托盘主数据 ==========
// models["try_tray_type"].hasMany(models["try_tray"], {
// foreignKey: "tray_type_id",
// sourceKey: "id",
// as: "trays",
// });
// models["try_tray"].belongsTo(models["try_tray_type"], {
// foreignKey: "tray_type_id",
// targetKey: "id",
// as: "tray_type",
// });
// // ========== 合作方与物料主数据 ==========
// models["par_customer"].hasMany(models["mat_material"], {
// foreignKey: "customer_id",
// sourceKey: "id",
// as: "materials",
// });
// models["mat_material"].belongsTo(models["par_customer"], {
// foreignKey: "customer_id",
// targetKey: "id",
// as: "customer",
// });
// models["par_supplier"].hasMany(models["mat_material"], {
// foreignKey: "supplier_id",
// sourceKey: "id",
// as: "materials",
// });
// models["mat_material"].belongsTo(models["par_supplier"], {
// foreignKey: "supplier_id",
// targetKey: "id",
// as: "supplier",
// });
// models["mat_type"].hasMany(models["mat_material"], {
// foreignKey: "material_type_id",
// sourceKey: "id",
// as: "materials",
// });
// models["mat_material"].belongsTo(models["mat_type"], {
// foreignKey: "material_type_id",
// targetKey: "id",
// as: "material_type",
// });
// models["mat_measure"].hasMany(models["mat_material"], {
// foreignKey: "measure_id",
// sourceKey: "id",
// as: "materials",
// });
// models["mat_material"].belongsTo(models["mat_measure"], {
// foreignKey: "measure_id",
// targetKey: "id",
// as: "measure",
// });
// // ========== 条码主数据 ==========
// models["mat_material"].hasMany(models["bar_code"], {
// foreignKey: "material_id",
// sourceKey: "id",
// as: "barcodes",
// });
// models["bar_code"].belongsTo(models["mat_material"], {
// foreignKey: "material_id",
// targetKey: "id",
// as: "material",
// });
// models["bar_type"].hasMany(models["bar_code"], {
// foreignKey: "bar_type_id",
// sourceKey: "id",
// as: "barcodes",
// });
// models["bar_code"].belongsTo(models["bar_type"], {
// foreignKey: "bar_type_id",
// targetKey: "id",
// as: "bar_type",
// });
// if (models["sys_user"]) {
// models["bar_code"].belongsTo(models["sys_user"], {
// foreignKey: "created_by_user_id",
// targetKey: "id",
// as: "created_by_user",
// });
// }
// // ========== 收货/发货业务 ==========
// models["rec_receiving"].belongsTo(models["war_warehouse"], {
// foreignKey: "warehouse_id",
// targetKey: "id",
// as: "warehouse",
// });
// models["rec_receiving"].hasMany(models["rec_receiving_item"], {
// foreignKey: "receiving_id",
// sourceKey: "id",
// as: "items",
// });
// models["rec_receiving_item"].belongsTo(models["rec_receiving"], {
// foreignKey: "receiving_id",
// targetKey: "id",
// as: "receiving",
// });
// models["rec_receiving_item"].belongsTo(models["mat_material"], {
// foreignKey: "material_id",
// targetKey: "id",
// as: "material",
// });
// models["rec_send"].belongsTo(models["war_warehouse"], {
// foreignKey: "warehouse_id",
// targetKey: "id",
// as: "warehouse",
// });
// models["rec_send"].hasMany(models["rec_send_item"], {
// foreignKey: "send_id",
// sourceKey: "id",
// as: "items",
// });
// models["rec_send_item"].belongsTo(models["rec_send"], {
// foreignKey: "send_id",
// targetKey: "id",
// as: "send",
// });
// models["rec_send_item"].belongsTo(models["mat_material"], {
// foreignKey: "material_id",
// targetKey: "id",
// as: "material",
// });
// // ========== 入库作业 ==========
// models["job_inbound"].belongsTo(models["war_warehouse"], {
// foreignKey: "warehouse_id",
// targetKey: "id",
// as: "warehouse",
// });
// models["job_inbound"].hasMany(models["job_inbound_item"], {
// foreignKey: "inbound_id",
// sourceKey: "id",
// as: "items",
// });
// models["job_inbound_item"].belongsTo(models["job_inbound"], {
// foreignKey: "inbound_id",
// targetKey: "id",
// as: "inbound",
// });
// models["job_inbound_item"].belongsTo(models["mat_material"], {
// foreignKey: "material_id",
// targetKey: "id",
// as: "material",
// });
// // ========== 出库作业 ==========
// models["job_outbound"].belongsTo(models["war_warehouse"], {
// foreignKey: "warehouse_id",
// targetKey: "id",
// as: "warehouse",
// });
// models["job_outbound"].hasMany(models["job_outbound_item"], {
// foreignKey: "outbound_id",
// sourceKey: "id",
// as: "items",
// });
// models["job_outbound_item"].belongsTo(models["job_outbound"], {
// foreignKey: "outbound_id",
// targetKey: "id",
// as: "outbound",
// });
// models["job_outbound_item"].belongsTo(models["mat_material"], {
// foreignKey: "material_id",
// targetKey: "id",
// as: "material",
// });
// // ========== 移库 ==========
// models["job_move"].belongsTo(models["war_warehouse"], {
// foreignKey: "warehouse_id",
// targetKey: "id",
// as: "warehouse",
// });
// models["job_move"].hasMany(models["job_move_item"], {
// foreignKey: "move_id",
// sourceKey: "id",
// as: "items",
// });
// models["job_move_item"].belongsTo(models["job_move"], {
// foreignKey: "move_id",
// targetKey: "id",
// as: "move",
// });
// models["job_move_item"].belongsTo(models["mat_material"], {
// foreignKey: "material_id",
// targetKey: "id",
// as: "material",
// });
// // ========== 调拨 ==========
// models["job_allocate"].belongsTo(models["war_warehouse"], {
// foreignKey: "from_warehouse_id",
// targetKey: "id",
// as: "from_warehouse",
// });
// models["job_allocate"].belongsTo(models["war_warehouse"], {
// foreignKey: "to_warehouse_id",
// targetKey: "id",
// as: "to_warehouse",
// });
// models["job_allocate"].hasMany(models["job_allocate_item"], {
// foreignKey: "allocate_id",
// sourceKey: "id",
// as: "items",
// });
// models["job_allocate_item"].belongsTo(models["job_allocate"], {
// foreignKey: "allocate_id",
// targetKey: "id",
// as: "allocate",
// });
// models["job_allocate_item"].belongsTo(models["mat_material"], {
// foreignKey: "material_id",
// targetKey: "id",
// as: "material",
// });
// // ========== 报损 ==========
// models["job_damage"].belongsTo(models["war_warehouse"], {
// foreignKey: "warehouse_id",
// targetKey: "id",
// as: "warehouse",
// });
// models["job_damage"].hasMany(models["job_damage_item"], {
// foreignKey: "damage_id",
// sourceKey: "id",
// as: "items",
// });
// models["job_damage_item"].belongsTo(models["job_damage"], {
// foreignKey: "damage_id",
// targetKey: "id",
// as: "damage",
// });
// models["job_damage_item"].belongsTo(models["mat_material"], {
// foreignKey: "material_id",
// targetKey: "id",
// as: "material",
// });
// // ========== 盘点 ==========
// models["job_count"].belongsTo(models["war_warehouse"], {
// foreignKey: "warehouse_id",
// targetKey: "id",
// as: "warehouse",
// });
// ========== 订阅模块biz_*==========
const m = models;

File diff suppressed because one or more lines are too long

View File

@@ -68,7 +68,8 @@
"load": "node __tests__/loadtest-both.js",
"api": " nodemon ./app.js ",
"serve": "cd ./admin&&npm run dev -- --port 9001 ",
"build": "cd ./admin&&npm run build "
"build": "cd ./admin&&npm run build ",
"migrate:plan_api": "node scripts/migrate_biz_plan_api_columns.js"
},
"license": "MIT"
}