Compare commits

...

9 Commits

Author SHA1 Message Date
张成
50bb0bc6ad 1 2026-04-01 15:02:45 +08:00
张成
38430c9244 1 2026-04-01 14:47:34 +08:00
张成
4c724143c0 1 2026-04-01 14:42:22 +08:00
张成
084c437096 1 2026-04-01 14:23:57 +08:00
张成
09368d2a95 1 2026-04-01 14:16:06 +08:00
张成
f810f60e3f 1 2026-04-01 13:55:48 +08:00
张成
a934d5b239 1 2026-04-01 13:54:13 +08:00
张成
2d900ef2ac 1 2026-04-01 13:42:29 +08:00
张成
1d22fb28e2 1 2026-04-01 13:40:27 +08:00
28 changed files with 3039 additions and 99 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

File diff suppressed because it is too large Load Diff

View File

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

View File

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

View File

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

View File

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

View File

@@ -132,8 +132,8 @@ 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 },

View File

@@ -52,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="用户">
@@ -59,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">
@@ -90,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: {},
@@ -103,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 },
@@ -110,18 +124,45 @@ export default {
{
title: '操作',
key: 'a',
width: 100,
render: (h, p) =>
h(
'Button',
{
props: { type: 'error', size: 'small' },
on: {
click: () => this.doRevoke(p.row),
width: 248,
render: (h, p) => {
const btns = []
btns.push(
h(
'Button',
{
props: { type: 'primary', size: 'small', ghost: true },
on: { click: () => this.openEdit(p.row) },
},
},
'吊销'
),
'编辑'
)
)
if (p.row.status === 'active') {
btns.push(
h(
'Button',
{
props: { type: 'warning', size: 'small' },
class: { ml8: true },
on: { click: () => this.doRegenerate(p.row) },
},
'重新生成'
)
)
}
btns.push(
h(
'Button',
{
props: { type: 'error', size: 'small' },
class: { ml8: true },
on: { click: () => this.doRevoke(p.row) },
},
'吊销'
)
)
return h('div', btns)
},
},
]
},
@@ -159,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() {
@@ -172,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)
@@ -193,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',
@@ -245,4 +361,8 @@ export default {
margin-top: 12px;
text-align: right;
}
.ml8 {
margin-left: 8px;
}
</style>

View File

@@ -56,42 +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="800" footer-hide>
<p v-if="detail" class="mb8">Token 数量{{ detail.tokenCount }}</p>
<p v-if="detail && detail.tokens && detail.tokens.length" class="sub-title">API Token</p>
<Table v-if="detail && detail.tokens && detail.tokens.length" :columns="tokenCols" :data="detail.tokens"
size="small" border highlight-row class="mb16" @on-row-click="onTokenRowClick" />
<p v-if="detail && detail.subscriptions && detail.subscriptions.length" class="sub-title">订阅记录</p>
<Table v-if="detail && detail.subscriptions" :columns="subCols" :data="detail.subscriptions" size="small"
border />
</Modal>
<Modal v-model="tokenListVisible" :title="tokenListTitle" width="820" footer-hide>
<p v-if="tokenListRows.length" class="text-muted mb8">点击表格某一行查看该 Token 详情明文不可查需重新创建</p>
<Table v-if="tokenListRows.length" :columns="tokenCols" :data="tokenListRows" size="small" border highlight-row
@on-row-click="onTokenRowClick" />
<p v-else class="text-muted">暂无 Token</p>
</Modal>
<Modal v-model="tokenDetailVisible" title="Token 详情" width="520" footer-hide>
<div v-if="selectedToken" class="token-detail">
<p><span class="label">ID</span>{{ selectedToken.id }}</p>
<p><span class="label">名称</span>{{ selectedToken.token_name }}</p>
<p><span class="label">用户</span>{{ selectedToken.user_id }}</p>
<p><span class="label">套餐</span>{{ selectedToken.plan_id != null ? selectedToken.plan_id : '—' }}</p>
<p><span class="label">状态</span>{{ selectedToken.status }}</p>
<p><span class="label">过期时间</span>{{ selectedToken.expire_at || '—' }}</p>
<p><span class="label">最后使用</span>{{ selectedToken.last_used_at || '—' }}</p>
<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 {
@@ -113,11 +129,13 @@ export default {
},
detailVisible: false,
detail: null,
tokenListVisible: false,
tokenListRows: [],
tokenListUserName: '',
tokenDetailVisible: false,
selectedToken: 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 },
@@ -149,18 +167,18 @@ export default {
on: {
click: (e) => {
e.preventDefault()
this.openTokenList(p.row)
this.showDetail(p.row)
},
},
},
n > 0 ? `${n} · 列表` : String(n)
n > 0 ? `${n} ` : '0'
)
},
},
{
title: '操作',
key: 'a',
width: 240,
width: 312,
render: (h, p) => {
return h('div', [
h(
@@ -180,6 +198,20 @@ export default {
},
'详情'
),
h(
'Button',
{
props: {
type: 'primary',
size: 'small',
ghost: true,
disabled: p.row.status !== 'active',
},
class: { ml8: true },
on: { click: () => this.open_create_token_for_user(p.row) },
},
'生成Token'
),
h(
'Dropdown',
{
@@ -214,18 +246,72 @@ export default {
},
tokenCols() {
return [
{ title: 'ID', key: 'id', width: 72 },
{ title: '名称', key: 'token_name', minWidth: 100 },
{ title: '套餐', key: 'plan_id', width: 80 },
{ title: '状态', key: 'status', width: 90 },
{ title: '过期', key: 'expire_at', minWidth: 150 },
{ title: '最后使用', key: 'last_used_at', minWidth: 150 },
{ title: 'ID', key: 'id', width: 56 },
{ title: '名称', key: 'token_name', width: 88 },
{ title: '套餐', key: 'plan_id', width: 64 },
{ title: '状态', key: 'status', width: 72 },
{ title: '过期', key: 'expire_at', minWidth: 128 },
{ title: '最后使用', key: 'last_used_at', minWidth: 128 },
{
title: '明文',
key: 'plain_token',
minWidth: 280,
render: (h, p) => {
const v = p.row.plain_token
if (!v) {
return h('span', { class: { 'text-muted': true } }, '—')
}
return h('div', { class: 'plain-cell' }, [
h('Input', {
props: {
type: 'textarea',
value: v,
rows: 2,
readonly: true,
},
class: 'plain-cell-input',
}),
h(
'Button',
{
props: { type: 'text', size: 'small' },
on: {
click: (e) => {
e.stopPropagation()
this.copy_text(v)
},
},
},
'复制'
),
])
},
},
{
title: '操作',
key: 'tok_op',
width: 96,
render: (h, p) => {
if (p.row.status !== 'active') {
return h('span', { class: { 'text-muted': true } }, '—')
}
return h(
'Button',
{
props: { type: 'warning', size: 'small' },
on: {
click: (e) => {
e.stopPropagation()
this.do_regenerate_token(p.row)
},
},
},
'重新生成'
)
},
},
]
},
tokenListTitle() {
const name = this.tokenListUserName || ''
return name ? `Token 列表 — ${name}` : 'Token 列表'
},
},
mounted() {
this.load(1)
@@ -249,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
},
@@ -269,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) || '保存失败')
}
@@ -289,19 +415,83 @@ export default {
this.$Message.error((res && res.message) || '加载详情失败')
}
},
async openTokenList(row) {
const res = await userServer.detail(row.id)
if (res && res.code === 0) {
this.tokenListRows = res.data.tokens || []
this.tokenListUserName = row.name || String(row.id)
this.tokenListVisible = true
} else {
this.$Message.error((res && res.message) || '加载 Token 失败')
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) {
if (res.data.warn) this.$Message.warning(res.data.warn)
this.create_token_modal = false
this.show_token_plain('请保存新建 Token 明文', res.data.plain_token)
await this.reload_user_token_views(row.id)
this.$Message.success('已创建 Token')
} else {
this.$Message.error((res && res.message) || '创建失败')
}
} finally {
this.create_token_saving = false
}
},
onTokenRowClick(row) {
this.selectedToken = row
this.tokenDetailVisible = true
show_token_plain(title, text) {
this.token_plain_title = title
this.token_plain_text = text || ''
this.token_plain_modal = true
},
async reload_user_token_views(user_id) {
const res = await userServer.detail(user_id)
if (res && res.code === 0) {
if (this.detailVisible) this.detail = res.data
this.load(1)
}
},
do_regenerate_token(row) {
this.$Modal.confirm({
title: '重新生成 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) || '操作失败')
}
},
})
},
doDel(row) {
this.$Modal.confirm({
@@ -371,6 +561,10 @@ export default {
margin-left: 8px;
}
.ml12 {
margin-left: 12px;
}
.table-page-bar {
margin-top: 12px;
text-align: right;
@@ -402,9 +596,25 @@ export default {
text-decoration: underline;
}
.token-detail .label {
display: inline-block;
width: 88px;
color: #808695;
.token-table-in-detail :deep(.plain-cell) {
display: flex;
flex-direction: column;
gap: 4px;
align-items: flex-start;
}
.token-table-in-detail :deep(.plain-cell-input textarea) {
font-family: monospace;
font-size: 11px;
line-height: 1.4;
min-width: 260px;
}
.mt12 {
margin-top: 12px;
}
.detail-one-line {
line-height: 32px;
}
</style>

View File

@@ -123,4 +123,17 @@ module.exports = {
});
ctx.success({ rows, count });
},
"POST /biz_api_call_log/export": async (ctx) => {
const body = ctx.getBody();
const param = body.param || body;
const biz_api_call_log = baseModel.biz_api_call_log;
const where = build_search_where(biz_api_call_log, param.seachOption || {});
const rows = await biz_api_call_log.findAll({
where,
limit: 10000,
order: [["id", "DESC"]],
});
ctx.success({ rows });
},
};

View File

@@ -3,6 +3,20 @@ const { build_search_where } = require("../utils/query_helpers");
const logic = require("../service/biz_subscription_logic");
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 = ctx.getBody();
@@ -19,8 +33,13 @@ module.exports = {
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, count });
ctx.success({ rows: subscription_rows_with_names(rows), count });
},
"GET /biz_subscription/detail": async (ctx) => {
const q = ctx.query || {};
@@ -95,7 +114,11 @@ module.exports = {
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 });
ctx.success({ rows: subscription_rows_with_names(rows) });
},
};

View File

@@ -19,6 +19,7 @@ module.exports = {
offset,
limit: page_size,
order: [["id", "DESC"]],
attributes: { exclude: ["secret_cipher"] },
});
ctx.success({ rows, count });
},
@@ -37,12 +38,28 @@ module.exports = {
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);
@@ -55,6 +72,27 @@ module.exports = {
});
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,
plan_id: result.row.plan_id,
token_name: result.row.token_name,
expire_at: result.row.expire_at,
plain_token: result.plain_token,
warn: result.warn,
});
},
"POST /biz_token/export": async (ctx) => {
const body = ctx.getBody();
const param = body.param || body;
@@ -64,6 +102,7 @@ module.exports = {
where,
limit: 10000,
order: [["id", "DESC"]],
attributes: { exclude: ["secret_cipher"] },
});
ctx.success({ rows });
},

View File

@@ -3,6 +3,17 @@ const { normalize_for_write, build_search_where } = require("../utils/query_help
const baseModel = require("../../middleware/baseModel");
const tokenLogic = require("../service/biz_token_logic");
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) => {
@@ -46,7 +57,40 @@ 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 = ctx.getBody();
@@ -101,13 +145,13 @@ module.exports = {
where: { user_id: id },
order: [["id", "DESC"]],
limit: 200,
attributes: ["id", "user_id", "plan_id", "token_name", "status", "expire_at", "last_used_at"],
});
const tokens_out = map_tokens_for_admin_detail(tokens);
ctx.success({
user,
subscriptions,
tokenCount,
tokens,
tokens: tokens_out,
});
},
"GET /biz_user/all": async (ctx) => {

View File

@@ -5,15 +5,18 @@ const proxy = require("../service/biz_proxy_service");
/**
* 从请求中提取 Token
* 支持 Authorization: Bearer xxx 和 query ?token=xxx
* 从 ctx 请求中提取 Token(不含 query
* - Authorization: Bearer <token>
* - Authorization: <token>(无 Bearer 前缀时整段作为 token
* - X-Api-Token / X-Token
*/
function extractToken(ctx) {
const authHeader = ctx.get("Authorization") || "";
if (authHeader.startsWith("Bearer ")) {
return authHeader.slice(7).trim();
let x_token = ctx.headers['authorization'] || ''
if (x_token.startsWith("Bearer ")) {
x_token = x_token.slice(7).trim();
}
return ctx.query.token || "";
return x_token;
}
/**
@@ -26,6 +29,14 @@ function pickFeature(spec) {
return null;
}
/** 不参与转发的文档路径(与 framework 实际路由重叠或仅为说明) */
function should_skip_proxy_path(route_path) {
return (
route_path.startsWith("/admin_api") ||
route_path.startsWith("/api/auth")
);
}
/**
* 构建转发路由表(供 framework.addRoutes 注册)
*/
@@ -33,6 +44,9 @@ 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}`;
@@ -51,10 +65,11 @@ function buildProxyRoutes() {
ctx.fail(authResult.message || "鉴权失败");
return;
}
// 3. 组装 query去掉 token 参数,避免泄露)
// 3. 组装 query并注入 token 对应 key上游要求参数名为 key
const query = { ...ctx.query };
delete query.token;
if (!query.key && authResult.context && authResult.context.token_key) {
query.key = authResult.context.token_key;
}
// 4. 转发到上游
const result = await proxy.forwardRequest({
@@ -68,7 +83,7 @@ function buildProxyRoutes() {
// 5. 根据上游 Success 字段决定响应方式
const upstream = result.data;
if (upstream && upstream.Success === true) {
if (upstream && upstream.Code === 200) {
ctx.success(upstream);
} else {
ctx.fail(upstream && upstream.Text ? upstream.Text : "上游请求失败", upstream);

View File

@@ -32,6 +32,10 @@ module.exports = (db) => {
allowNull: false,
defaultValue: 0,
},
response_body: {
type: Sequelize.TEXT,
allowNull: true,
},
call_date: {
type: Sequelize.DATEONLY,
allowNull: false,
@@ -49,5 +53,7 @@ module.exports = (db) => {
underscored: true,
}
);
//biz_api_call_log.sync({ force: true });
return biz_api_call_log;
};

View File

@@ -1,5 +1,10 @@
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",
@@ -18,11 +23,19 @@ module.exports = (db) => {
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,
@@ -38,6 +51,6 @@ module.exports = (db) => {
underscored: true,
}
);
// biz_api_token.sync({ force: true });
//biz_api_token.sync({ force: true });
return biz_api_token;
};

View File

@@ -30,6 +30,12 @@ module.exports = (db) => {
type: Sequelize.JSON,
allowNull: true,
},
// 表字段存在且非空无默认时,须由模型声明,否则 insert 缺列报错
created_at: {
type: Sequelize.DATE,
allowNull: false,
defaultValue: Sequelize.NOW,
},
},
{
tableName: "biz_audit_log",

View File

@@ -109,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),

View File

@@ -5,6 +5,26 @@ 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
@@ -57,6 +77,7 @@ async function forwardRequest({ api_path, method, query, body, headers, auth_ctx
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 };
@@ -65,7 +86,15 @@ async function forwardRequest({ api_path, method, query, body, headers, auth_ctx
/**
* 写入 API 调用日志
*/
async function writeCallLog({ user_id, token_id, api_path, http_method, status_code, response_time }) {
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")}`;
@@ -76,6 +105,7 @@ async function writeCallLog({ user_id, token_id, api_path, http_method, status_c
http_method,
status_code,
response_time,
response_body: response_body || null,
call_date,
created_at: now,
});

View File

@@ -2,6 +2,8 @@ const crypto = require("crypto");
const Sequelize = require("sequelize");
const op = Sequelize.Op;
const baseModel = require("../../middleware/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;
@@ -10,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 的订阅 */
@@ -28,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("用户不存在");
@@ -46,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,
});
@@ -63,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;
@@ -84,8 +160,11 @@ async function revokeAllForUser(userId) {
module.exports = {
hashPlainToken,
createToken,
updateToken,
regenerateToken,
revokeToken,
revokeAllForUser,
findActiveSubscriptionForUser,
defaultTokenExpireAt,
MAX_TOKENS_PER_USER,
};

View File

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

View File

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

View File

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

View File

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