Compare commits
26 Commits
42907d0f21
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
50bb0bc6ad | ||
|
|
38430c9244 | ||
|
|
4c724143c0 | ||
|
|
084c437096 | ||
|
|
09368d2a95 | ||
|
|
f810f60e3f | ||
|
|
a934d5b239 | ||
|
|
2d900ef2ac | ||
|
|
1d22fb28e2 | ||
|
|
d03916290a | ||
|
|
fa9abf83ae | ||
|
|
82432cdba8 | ||
|
|
e9fd55666f | ||
|
|
6f61287c70 | ||
|
|
30a909762e | ||
|
|
ce2521cadc | ||
|
|
03c5579c86 | ||
|
|
c2205188d1 | ||
|
|
433077f08a | ||
|
|
7199c6b5cf | ||
|
|
494555a6e1 | ||
|
|
14f5d75d9d | ||
|
|
aac2d4a8d5 | ||
|
|
c3aab075d9 | ||
|
|
1f4b39d576 | ||
|
|
2f04459492 |
104
.cursor/rules/node-core-framework.mdc
Normal file
104
.cursor/rules/node-core-framework.mdc
Normal file
@@ -0,0 +1,104 @@
|
||||
---
|
||||
description: Node Core(Koa2+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`**。
|
||||
2
.vscode/launch.json
vendored
2
.vscode/launch.json
vendored
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"configurations": [
|
||||
{
|
||||
"name": "Launch Program",
|
||||
"name": "启动后端",
|
||||
"program": "${workspaceFolder}/app.js",
|
||||
"request": "launch",
|
||||
"skipFiles": [
|
||||
|
||||
3570
_docs/API接口清单-按模块.md
Normal file
3570
_docs/API接口清单-按模块.md
Normal file
File diff suppressed because it is too large
Load Diff
392
_docs/merge_biz_into_swagger.js
Normal file
392
_docs/merge_biz_into_swagger.js
Normal 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 不需 id,edit 需 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: "上传图片到 OSS(multipart)",
|
||||
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");
|
||||
@@ -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(库内仅存 hash;plan_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='月用量';
|
||||
@@ -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);
|
||||
*/
|
||||
@@ -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='订阅模块审计';
|
||||
@@ -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
|
||||
-- icon:Material 图标名如 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 均已注册)。
|
||||
@@ -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 全量重建。
|
||||
@@ -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;
|
||||
@@ -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 结构增删列后再插入。
|
||||
3
_docs/sql/biz_api_call_log_response_body.sql
Normal file
3
_docs/sql/biz_api_call_log_response_body.sql
Normal 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`;
|
||||
3
_docs/sql/biz_api_token_key.sql
Normal file
3
_docs/sql/biz_api_token_key.sql
Normal file
@@ -0,0 +1,3 @@
|
||||
-- token 绑定账号唯一标识 key(供转发时自动拼到 query.key)
|
||||
ALTER TABLE biz_api_token
|
||||
ADD COLUMN `key` VARCHAR(128) NULL COMMENT '账号唯一标识' AFTER token_name;
|
||||
3
_docs/sql/biz_api_token_secret_cipher.sql
Normal file
3
_docs/sql/biz_api_token_secret_cipher.sql
Normal 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
99
_docs/sql/biz_plans.sql
Normal 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'
|
||||
);
|
||||
34
_docs/sql/sys_menu_subscription_api_token.sql
Normal file
34
_docs/sql/sys_menu_subscription_api_token.sql
Normal file
@@ -0,0 +1,34 @@
|
||||
-- 订阅模块:API Token(biz_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
|
||||
);
|
||||
1625
_docs/swagger.json
1625
_docs/swagger.json
File diff suppressed because it is too large
Load Diff
@@ -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. 全量接口明细
|
||||
|
||||
@@ -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` |
|
||||
|
||||
11
admin/src/api/subscription/api_call_log_server.js
Normal file
11
admin/src/api/subscription/api_call_log_server.js
Normal 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();
|
||||
@@ -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();
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
202
admin/src/views/subscription/api_call_log.vue
Normal file
202
admin/src/views/subscription/api_call_log.vue
Normal 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>
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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) =>
|
||||
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' },
|
||||
on: {
|
||||
click: () => this.doRevoke(p.row),
|
||||
},
|
||||
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>
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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,44 +198,116 @@ export default {
|
||||
},
|
||||
'详情'
|
||||
),
|
||||
p.row.status === 'disabled'
|
||||
? h(
|
||||
h(
|
||||
'Button',
|
||||
{
|
||||
props: { type: 'success', size: 'small' },
|
||||
class: { ml8: true },
|
||||
on: { click: () => this.doEnable(p.row) },
|
||||
props: {
|
||||
type: 'primary',
|
||||
size: 'small',
|
||||
ghost: true,
|
||||
disabled: p.row.status !== 'active',
|
||||
},
|
||||
'启用'
|
||||
)
|
||||
: h(
|
||||
class: { ml8: true },
|
||||
on: { click: () => this.open_create_token_for_user(p.row) },
|
||||
},
|
||||
'生成Token'
|
||||
),
|
||||
h(
|
||||
'Dropdown',
|
||||
{
|
||||
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' },
|
||||
class: { ml8: true },
|
||||
on: { click: () => this.doDisable(p.row) },
|
||||
on: {
|
||||
click: (e) => {
|
||||
e.stopPropagation()
|
||||
this.do_regenerate_token(p.row)
|
||||
},
|
||||
'禁用'
|
||||
),
|
||||
h(
|
||||
'Button',
|
||||
{
|
||||
props: { type: 'default', size: 'small' },
|
||||
class: { ml8: true },
|
||||
on: { click: () => this.revokeAllTokens(p.row) },
|
||||
},
|
||||
'吊销全部Token'
|
||||
),
|
||||
h(
|
||||
'Button',
|
||||
{
|
||||
props: { type: 'error', size: 'small' },
|
||||
class: { ml8: true },
|
||||
on: { click: () => this.doDel(p.row) },
|
||||
},
|
||||
'删除'
|
||||
),
|
||||
])
|
||||
'重新生成'
|
||||
)
|
||||
},
|
||||
},
|
||||
]
|
||||
@@ -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 })
|
||||
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`
|
||||
},
|
||||
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) {
|
||||
this.$Message.success('已禁用')
|
||||
this.load(1)
|
||||
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) || '操作失败')
|
||||
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
|
||||
},
|
||||
doEnable(row) {
|
||||
this.$Modal.confirm({
|
||||
title: '启用用户',
|
||||
content: '确认重新启用该用户?',
|
||||
onOk: async () => {
|
||||
const res = await userServer.enable({ id: row.id })
|
||||
async reload_user_token_views(user_id) {
|
||||
const res = await userServer.detail(user_id)
|
||||
if (res && res.code === 0) {
|
||||
this.$Message.success('已启用')
|
||||
if (this.detailVisible) this.detail = res.data
|
||||
this.load(1)
|
||||
}
|
||||
},
|
||||
do_regenerate_token(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.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>
|
||||
|
||||
139
api/controller_admin/biz_api_stats.js
Normal file
139
api/controller_admin/biz_api_stats.js
Normal 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 });
|
||||
},
|
||||
};
|
||||
@@ -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 });
|
||||
},
|
||||
};
|
||||
|
||||
@@ -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(),
|
||||
});
|
||||
},
|
||||
};
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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());
|
||||
},
|
||||
};
|
||||
|
||||
@@ -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) });
|
||||
},
|
||||
};
|
||||
|
||||
@@ -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 });
|
||||
},
|
||||
};
|
||||
|
||||
@@ -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 });
|
||||
},
|
||||
};
|
||||
|
||||
@@ -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);
|
||||
|
||||
98
api/controller_custom/proxy_api.js
Normal file
98
api/controller_custom/proxy_api.js
Normal 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 };
|
||||
@@ -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);
|
||||
},
|
||||
|
||||
59
api/model/biz_api_call_log.js
Normal file
59
api/model/biz_api_call_log.js
Normal 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;
|
||||
};
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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,10 +51,8 @@ module.exports = (db) => {
|
||||
},
|
||||
},
|
||||
{
|
||||
tableName: "biz_plans",
|
||||
timestamps: false,
|
||||
underscored: true,
|
||||
comment: "套餐",
|
||||
}
|
||||
);
|
||||
// biz_plan.sync({ alter: true });
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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: "数据长度",
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
@@ -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 });
|
||||
},
|
||||
|
||||
@@ -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: "菜单类型",
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
@@ -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: "是否允许修改",
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
@@ -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 });
|
||||
},
|
||||
|
||||
@@ -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",
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
@@ -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),
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
@@ -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 };
|
||||
54
api/service/biz_proxy_api_catalog.js
Normal file
54
api/service/biz_proxy_api_catalog.js
Normal 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 };
|
||||
118
api/service/biz_proxy_service.js
Normal file
118
api/service/biz_proxy_service.js
Normal 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 };
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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);
|
||||
51
api/utils/biz_token_secret_cipher.js
Normal file
51
api/utils/biz_token_secret_cipher.js
Normal 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,
|
||||
};
|
||||
82
api/utils/query_helpers.js
Normal file
82
api/utils/query_helpers.js
Normal 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
44
app.js
@@ -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') {
|
||||
|
||||
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
11
config/before_init_api.js
Normal 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 前缀各一套)`);
|
||||
};
|
||||
@@ -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",
|
||||
}
|
||||
|
||||
|
||||
@@ -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/"
|
||||
]
|
||||
};
|
||||
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
|
||||
@@ -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
@@ -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"
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user