Compare commits
9 Commits
d03916290a
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
50bb0bc6ad | ||
|
|
38430c9244 | ||
|
|
4c724143c0 | ||
|
|
084c437096 | ||
|
|
09368d2a95 | ||
|
|
f810f60e3f | ||
|
|
a934d5b239 | ||
|
|
2d900ef2ac | ||
|
|
1d22fb28e2 |
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");
|
||||||
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;
|
||||||
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
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();
|
||||||
@@ -7,10 +7,18 @@ class TokenServer {
|
|||||||
return window.framework.http.post("/biz_token/create", row);
|
return window.framework.http.post("/biz_token/create", row);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async edit(row) {
|
||||||
|
return window.framework.http.post("/biz_token/edit", row);
|
||||||
|
}
|
||||||
|
|
||||||
async revoke(row) {
|
async revoke(row) {
|
||||||
return window.framework.http.post("/biz_token/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) {
|
async exportRows(row) {
|
||||||
return window.framework.http.post("/biz_token/export", 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 SubscriptionPayment from '../views/subscription/payment.vue'
|
||||||
import SubscriptionUsage from '../views/subscription/usage.vue'
|
import SubscriptionUsage from '../views/subscription/usage.vue'
|
||||||
import SubscriptionAuditLog from '../views/subscription/audit_log.vue'
|
import SubscriptionAuditLog from '../views/subscription/audit_log.vue'
|
||||||
|
import SubscriptionApiCallLog from '../views/subscription/api_call_log.vue'
|
||||||
|
|
||||||
const componentMap = {
|
const componentMap = {
|
||||||
// 与 sys_menu.component 一致:库中常见为 home/index 或 home/index.vue
|
// 与 sys_menu.component 一致:库中常见为 home/index 或 home/index.vue
|
||||||
@@ -19,9 +20,12 @@ const componentMap = {
|
|||||||
'subscription/plan': SubscriptionPlans,
|
'subscription/plan': SubscriptionPlans,
|
||||||
'subscription/subscription': SubscriptionRecords,
|
'subscription/subscription': SubscriptionRecords,
|
||||||
'subscription/token': SubscriptionTokens,
|
'subscription/token': SubscriptionTokens,
|
||||||
|
/** 与 biz_api_token 管理页同一视图,便于菜单 component 语义对应 */
|
||||||
|
'subscription/biz_api_token': SubscriptionTokens,
|
||||||
'subscription/payment': SubscriptionPayment,
|
'subscription/payment': SubscriptionPayment,
|
||||||
'subscription/usage': SubscriptionUsage,
|
'subscription/usage': SubscriptionUsage,
|
||||||
'subscription/audit': SubscriptionAuditLog,
|
'subscription/audit': SubscriptionAuditLog,
|
||||||
|
'subscription/api_call_log': SubscriptionApiCallLog,
|
||||||
}
|
}
|
||||||
|
|
||||||
export default componentMap;
|
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>
|
||||||
@@ -132,8 +132,8 @@ export default {
|
|||||||
columns() {
|
columns() {
|
||||||
return [
|
return [
|
||||||
{ title: 'ID', key: 'id', width: 70 },
|
{ title: 'ID', key: 'id', width: 70 },
|
||||||
{ title: '用户', key: 'user_id', width: 90 },
|
{ title: '用户', key: 'user_name', minWidth: 160, ellipsis: true, tooltip: true },
|
||||||
{ title: '套餐', key: 'plan_id', width: 90 },
|
{ title: '套餐', key: 'plan_name', minWidth: 160, ellipsis: true, tooltip: true },
|
||||||
{ title: '状态', key: 'status', width: 100 },
|
{ title: '状态', key: 'status', width: 100 },
|
||||||
{ title: '开始', key: 'start_time', minWidth: 150 },
|
{ title: '开始', key: 'start_time', minWidth: 150 },
|
||||||
{ title: '结束', key: 'end_time', minWidth: 150 },
|
{ title: '结束', key: 'end_time', minWidth: 150 },
|
||||||
|
|||||||
@@ -52,6 +52,14 @@
|
|||||||
</div>
|
</div>
|
||||||
</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">
|
<Modal v-model="createModal" title="创建 Token" width="560" :loading="saving" @on-ok="submitCreate">
|
||||||
<Form :label-width="100">
|
<Form :label-width="100">
|
||||||
<FormItem label="用户">
|
<FormItem label="用户">
|
||||||
@@ -59,12 +67,13 @@
|
|||||||
<Option v-for="u in bizUserOptions" :key="u.id" :value="u.id">{{ bizUserLabel(u) }}</Option>
|
<Option v-for="u in bizUserOptions" :key="u.id" :value="u.id">{{ bizUserLabel(u) }}</Option>
|
||||||
</Select>
|
</Select>
|
||||||
</FormItem>
|
</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.token_name" placeholder="default" /></FormItem>
|
||||||
<FormItem label="过期时间"><Input v-model="createForm.expire_at" placeholder="2026-12-31 23:59:59" /></FormItem>
|
<FormItem label="过期时间"><Input v-model="createForm.expire_at" placeholder="2026-12-31 23:59:59" /></FormItem>
|
||||||
</Form>
|
</Form>
|
||||||
</Modal>
|
</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>
|
<Alert type="error">仅此一次展示,关闭后无法再次查看明文。</Alert>
|
||||||
<Input type="textarea" :rows="4" v-model="plainToken" readonly />
|
<Input type="textarea" :rows="4" v-model="plainToken" readonly />
|
||||||
<div slot="footer">
|
<div slot="footer">
|
||||||
@@ -90,8 +99,12 @@ export default {
|
|||||||
seachOption: { key: 'user_id', value: '' },
|
seachOption: { key: 'user_id', value: '' },
|
||||||
pageOption: { page: 1, pageSize: 20, total: 0 },
|
pageOption: { page: 1, pageSize: 20, total: 0 },
|
||||||
},
|
},
|
||||||
|
editModal: false,
|
||||||
|
editSaving: false,
|
||||||
|
editForm: {},
|
||||||
createModal: false,
|
createModal: false,
|
||||||
plainModal: false,
|
plainModal: false,
|
||||||
|
plainModalTitle: '请立即保存 Token 明文',
|
||||||
plainToken: '',
|
plainToken: '',
|
||||||
saving: false,
|
saving: false,
|
||||||
createForm: {},
|
createForm: {},
|
||||||
@@ -103,6 +116,7 @@ export default {
|
|||||||
{ title: 'ID', key: 'id', width: 70 },
|
{ title: 'ID', key: 'id', width: 70 },
|
||||||
{ title: '用户', key: 'user_id', width: 90 },
|
{ title: '用户', key: 'user_id', width: 90 },
|
||||||
{ title: '套餐', key: 'plan_id', width: 90 },
|
{ title: '套餐', key: 'plan_id', width: 90 },
|
||||||
|
{ title: 'Key', key: 'key', minWidth: 140 },
|
||||||
{ title: '名称', key: 'token_name', width: 120 },
|
{ title: '名称', key: 'token_name', width: 120 },
|
||||||
{ title: '状态', key: 'status', width: 90 },
|
{ title: '状态', key: 'status', width: 90 },
|
||||||
{ title: '过期', key: 'expire_at', minWidth: 150 },
|
{ title: '过期', key: 'expire_at', minWidth: 150 },
|
||||||
@@ -110,18 +124,45 @@ export default {
|
|||||||
{
|
{
|
||||||
title: '操作',
|
title: '操作',
|
||||||
key: 'a',
|
key: 'a',
|
||||||
width: 100,
|
width: 248,
|
||||||
render: (h, p) =>
|
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(
|
h(
|
||||||
'Button',
|
'Button',
|
||||||
{
|
{
|
||||||
props: { type: 'error', size: 'small' },
|
props: { type: 'error', size: 'small' },
|
||||||
on: {
|
class: { ml8: true },
|
||||||
click: () => this.doRevoke(p.row),
|
on: { click: () => this.doRevoke(p.row) },
|
||||||
},
|
|
||||||
},
|
},
|
||||||
'吊销'
|
'吊销'
|
||||||
),
|
)
|
||||||
|
)
|
||||||
|
return h('div', btns)
|
||||||
|
},
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
@@ -159,7 +200,7 @@ export default {
|
|||||||
2,
|
2,
|
||||||
'0'
|
'0'
|
||||||
)} 23:59:59`
|
)} 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
|
this.createModal = true
|
||||||
},
|
},
|
||||||
submitCreate() {
|
submitCreate() {
|
||||||
@@ -172,17 +213,74 @@ export default {
|
|||||||
this._submitCreate()
|
this._submitCreate()
|
||||||
return false
|
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() {
|
async _submitCreate() {
|
||||||
const uid = this.createForm.user_id
|
const uid = this.createForm.user_id
|
||||||
try {
|
try {
|
||||||
const res = await tokenServer.create({
|
const res = await tokenServer.create({
|
||||||
user_id: Number(uid),
|
user_id: Number(uid),
|
||||||
|
key: this.createForm.key || null,
|
||||||
token_name: this.createForm.token_name || 'default',
|
token_name: this.createForm.token_name || 'default',
|
||||||
expire_at: this.createForm.expire_at,
|
expire_at: this.createForm.expire_at,
|
||||||
})
|
})
|
||||||
if (res && res.code === 0) {
|
if (res && res.code === 0) {
|
||||||
if (res.data.warn) this.$Message.warning(res.data.warn)
|
if (res.data.warn) this.$Message.warning(res.data.warn)
|
||||||
this.createModal = false
|
this.createModal = false
|
||||||
|
this.plainModalTitle = '请立即保存 Token 明文'
|
||||||
this.plainToken = res.data.plain_token
|
this.plainToken = res.data.plain_token
|
||||||
this.plainModal = true
|
this.plainModal = true
|
||||||
this.load(1)
|
this.load(1)
|
||||||
@@ -193,6 +291,24 @@ export default {
|
|||||||
this.saving = false
|
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) {
|
doRevoke(row) {
|
||||||
this.$Modal.confirm({
|
this.$Modal.confirm({
|
||||||
title: '吊销 Token',
|
title: '吊销 Token',
|
||||||
@@ -245,4 +361,8 @@ export default {
|
|||||||
margin-top: 12px;
|
margin-top: 12px;
|
||||||
text-align: right;
|
text-align: right;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.ml8 {
|
||||||
|
margin-left: 8px;
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -56,42 +56,58 @@
|
|||||||
<Option value="disabled">禁用</Option>
|
<Option value="disabled">禁用</Option>
|
||||||
</Select>
|
</Select>
|
||||||
</FormItem>
|
</FormItem>
|
||||||
|
<FormItem v-if="!form.id" label="API Token">
|
||||||
|
<Checkbox v-model="form.auto_create_token">保存时自动创建默认 Token(明文仅展示一次)</Checkbox>
|
||||||
|
</FormItem>
|
||||||
</Form>
|
</Form>
|
||||||
</Modal>
|
</Modal>
|
||||||
|
|
||||||
<Modal v-model="detailVisible" title="用户详情" width="800" footer-hide>
|
<Modal v-model="token_plain_modal" :title="token_plain_title" width="560" :closable="false">
|
||||||
<p v-if="detail" class="mb8">Token 数量:{{ detail.tokenCount }}</p>
|
<Alert type="error">关闭后无法再次查看明文,请复制到安全位置。</Alert>
|
||||||
<p v-if="detail && detail.tokens && detail.tokens.length" class="sub-title">API Token</p>
|
<Input v-model="token_plain_text" type="textarea" :rows="4" readonly />
|
||||||
<Table v-if="detail && detail.tokens && detail.tokens.length" :columns="tokenCols" :data="detail.tokens"
|
<div slot="footer">
|
||||||
size="small" border highlight-row class="mb16" @on-row-click="onTokenRowClick" />
|
<Button type="primary" @click="token_plain_modal = false">已保存</Button>
|
||||||
<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>
|
|
||||||
</div>
|
</div>
|
||||||
</Modal>
|
</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>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
import userServer from '@/api/subscription/user_server.js'
|
import userServer from '@/api/subscription/user_server.js'
|
||||||
|
import tokenServer from '@/api/subscription/token_server.js'
|
||||||
import { downloadCsvFromRows } from '@/utils/csvExport.js'
|
import { downloadCsvFromRows } from '@/utils/csvExport.js'
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
@@ -113,11 +129,13 @@ export default {
|
|||||||
},
|
},
|
||||||
detailVisible: false,
|
detailVisible: false,
|
||||||
detail: null,
|
detail: null,
|
||||||
tokenListVisible: false,
|
token_plain_modal: false,
|
||||||
tokenListRows: [],
|
token_plain_title: '',
|
||||||
tokenListUserName: '',
|
token_plain_text: '',
|
||||||
tokenDetailVisible: false,
|
create_token_modal: false,
|
||||||
selectedToken: null,
|
create_token_saving: false,
|
||||||
|
create_token_target_user: null,
|
||||||
|
create_token_form: { token_name: 'default', expire_at: '' },
|
||||||
subCols: [
|
subCols: [
|
||||||
{ title: 'ID', key: 'id', width: 80 },
|
{ title: 'ID', key: 'id', width: 80 },
|
||||||
{ title: '套餐ID', key: 'plan_id', width: 90 },
|
{ title: '套餐ID', key: 'plan_id', width: 90 },
|
||||||
@@ -149,18 +167,18 @@ export default {
|
|||||||
on: {
|
on: {
|
||||||
click: (e) => {
|
click: (e) => {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
this.openTokenList(p.row)
|
this.showDetail(p.row)
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
n > 0 ? `${n} · 列表` : String(n)
|
n > 0 ? `${n} 条` : '0'
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: '操作',
|
title: '操作',
|
||||||
key: 'a',
|
key: 'a',
|
||||||
width: 240,
|
width: 312,
|
||||||
render: (h, p) => {
|
render: (h, p) => {
|
||||||
return h('div', [
|
return h('div', [
|
||||||
h(
|
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(
|
h(
|
||||||
'Dropdown',
|
'Dropdown',
|
||||||
{
|
{
|
||||||
@@ -214,17 +246,71 @@ export default {
|
|||||||
},
|
},
|
||||||
tokenCols() {
|
tokenCols() {
|
||||||
return [
|
return [
|
||||||
{ title: 'ID', key: 'id', width: 72 },
|
{ title: 'ID', key: 'id', width: 56 },
|
||||||
{ title: '名称', key: 'token_name', minWidth: 100 },
|
{ title: '名称', key: 'token_name', width: 88 },
|
||||||
{ title: '套餐', key: 'plan_id', width: 80 },
|
{ title: '套餐', key: 'plan_id', width: 64 },
|
||||||
{ title: '状态', key: 'status', width: 90 },
|
{ title: '状态', key: 'status', width: 72 },
|
||||||
{ title: '过期', key: 'expire_at', minWidth: 150 },
|
{ title: '过期', key: 'expire_at', minWidth: 128 },
|
||||||
{ title: '最后使用', key: 'last_used_at', minWidth: 150 },
|
{ 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,
|
||||||
},
|
},
|
||||||
tokenListTitle() {
|
class: 'plain-cell-input',
|
||||||
const name = this.tokenListUserName || ''
|
}),
|
||||||
return name ? `Token 列表 — ${name}` : 'Token 列表'
|
h(
|
||||||
|
'Button',
|
||||||
|
{
|
||||||
|
props: { type: 'text', size: 'small' },
|
||||||
|
on: {
|
||||||
|
click: (e) => {
|
||||||
|
e.stopPropagation()
|
||||||
|
this.copy_text(v)
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
'复制'
|
||||||
|
),
|
||||||
|
])
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '操作',
|
||||||
|
key: 'tok_op',
|
||||||
|
width: 96,
|
||||||
|
render: (h, p) => {
|
||||||
|
if (p.row.status !== 'active') {
|
||||||
|
return h('span', { class: { 'text-muted': true } }, '—')
|
||||||
|
}
|
||||||
|
return h(
|
||||||
|
'Button',
|
||||||
|
{
|
||||||
|
props: { type: 'warning', size: 'small' },
|
||||||
|
on: {
|
||||||
|
click: (e) => {
|
||||||
|
e.stopPropagation()
|
||||||
|
this.do_regenerate_token(p.row)
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
'重新生成'
|
||||||
|
)
|
||||||
|
},
|
||||||
|
},
|
||||||
|
]
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
mounted() {
|
mounted() {
|
||||||
@@ -249,11 +335,43 @@ export default {
|
|||||||
this.param.pageOption.pageSize = s
|
this.param.pageOption.pageSize = s
|
||||||
this.load(1)
|
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) {
|
openEdit(row) {
|
||||||
if (row) {
|
if (row) {
|
||||||
this.form = { ...row }
|
this.form = { ...row }
|
||||||
|
delete this.form.auto_create_token
|
||||||
} else {
|
} 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
|
this.modal = true
|
||||||
},
|
},
|
||||||
@@ -269,9 +387,17 @@ export default {
|
|||||||
? await userServer.edit(this.form)
|
? await userServer.edit(this.form)
|
||||||
: await userServer.add(this.form)
|
: await userServer.add(this.form)
|
||||||
if (res && res.code === 0) {
|
if (res && res.code === 0) {
|
||||||
this.$Message.success('保存成功')
|
|
||||||
this.modal = false
|
this.modal = false
|
||||||
this.load(1)
|
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 {
|
} else {
|
||||||
this.$Message.error((res && res.message) || '保存失败')
|
this.$Message.error((res && res.message) || '保存失败')
|
||||||
}
|
}
|
||||||
@@ -289,19 +415,83 @@ export default {
|
|||||||
this.$Message.error((res && res.message) || '加载详情失败')
|
this.$Message.error((res && res.message) || '加载详情失败')
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
async openTokenList(row) {
|
default_token_expire_input() {
|
||||||
const res = await userServer.detail(row.id)
|
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 && res.code === 0) {
|
||||||
this.tokenListRows = res.data.tokens || []
|
if (res.data.warn) this.$Message.warning(res.data.warn)
|
||||||
this.tokenListUserName = row.name || String(row.id)
|
this.create_token_modal = false
|
||||||
this.tokenListVisible = true
|
this.show_token_plain('请保存新建 Token 明文', res.data.plain_token)
|
||||||
|
await this.reload_user_token_views(row.id)
|
||||||
|
this.$Message.success('已创建 Token')
|
||||||
} else {
|
} else {
|
||||||
this.$Message.error((res && res.message) || '加载 Token 失败')
|
this.$Message.error((res && res.message) || '创建失败')
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
this.create_token_saving = false
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
onTokenRowClick(row) {
|
show_token_plain(title, text) {
|
||||||
this.selectedToken = row
|
this.token_plain_title = title
|
||||||
this.tokenDetailVisible = true
|
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) {
|
doDel(row) {
|
||||||
this.$Modal.confirm({
|
this.$Modal.confirm({
|
||||||
@@ -371,6 +561,10 @@ export default {
|
|||||||
margin-left: 8px;
|
margin-left: 8px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.ml12 {
|
||||||
|
margin-left: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
.table-page-bar {
|
.table-page-bar {
|
||||||
margin-top: 12px;
|
margin-top: 12px;
|
||||||
text-align: right;
|
text-align: right;
|
||||||
@@ -402,9 +596,25 @@ export default {
|
|||||||
text-decoration: underline;
|
text-decoration: underline;
|
||||||
}
|
}
|
||||||
|
|
||||||
.token-detail .label {
|
.token-table-in-detail :deep(.plain-cell) {
|
||||||
display: inline-block;
|
display: flex;
|
||||||
width: 88px;
|
flex-direction: column;
|
||||||
color: #808695;
|
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>
|
</style>
|
||||||
|
|||||||
@@ -123,4 +123,17 @@ module.exports = {
|
|||||||
});
|
});
|
||||||
ctx.success({ rows, count });
|
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 });
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -3,6 +3,20 @@ const { build_search_where } = require("../utils/query_helpers");
|
|||||||
const logic = require("../service/biz_subscription_logic");
|
const logic = require("../service/biz_subscription_logic");
|
||||||
const audit = require("../utils/biz_audit");
|
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 = {
|
module.exports = {
|
||||||
"POST /biz_subscription/page": async (ctx) => {
|
"POST /biz_subscription/page": async (ctx) => {
|
||||||
const body = ctx.getBody();
|
const body = ctx.getBody();
|
||||||
@@ -19,8 +33,13 @@ module.exports = {
|
|||||||
offset,
|
offset,
|
||||||
limit: page_size,
|
limit: page_size,
|
||||||
order: [["id", "DESC"]],
|
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) => {
|
"GET /biz_subscription/detail": async (ctx) => {
|
||||||
const q = ctx.query || {};
|
const q = ctx.query || {};
|
||||||
@@ -95,7 +114,11 @@ module.exports = {
|
|||||||
where,
|
where,
|
||||||
limit: 10000,
|
limit: 10000,
|
||||||
order: [["id", "DESC"]],
|
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) });
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ module.exports = {
|
|||||||
offset,
|
offset,
|
||||||
limit: page_size,
|
limit: page_size,
|
||||||
order: [["id", "DESC"]],
|
order: [["id", "DESC"]],
|
||||||
|
attributes: { exclude: ["secret_cipher"] },
|
||||||
});
|
});
|
||||||
ctx.success({ rows, count });
|
ctx.success({ rows, count });
|
||||||
},
|
},
|
||||||
@@ -37,12 +38,28 @@ module.exports = {
|
|||||||
id: result.row.id,
|
id: result.row.id,
|
||||||
user_id: result.row.user_id,
|
user_id: result.row.user_id,
|
||||||
plan_id: result.row.plan_id,
|
plan_id: result.row.plan_id,
|
||||||
|
key: result.row.key,
|
||||||
token_name: result.row.token_name,
|
token_name: result.row.token_name,
|
||||||
expire_at: result.row.expire_at,
|
expire_at: result.row.expire_at,
|
||||||
plain_token: result.plain_token,
|
plain_token: result.plain_token,
|
||||||
warn: result.warn,
|
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) => {
|
"POST /biz_token/revoke": async (ctx) => {
|
||||||
const body = ctx.getBody();
|
const body = ctx.getBody();
|
||||||
const row = await tokenLogic.revokeToken(body);
|
const row = await tokenLogic.revokeToken(body);
|
||||||
@@ -55,6 +72,27 @@ module.exports = {
|
|||||||
});
|
});
|
||||||
ctx.success({ id: row.id, status: row.status });
|
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) => {
|
"POST /biz_token/export": async (ctx) => {
|
||||||
const body = ctx.getBody();
|
const body = ctx.getBody();
|
||||||
const param = body.param || body;
|
const param = body.param || body;
|
||||||
@@ -64,6 +102,7 @@ module.exports = {
|
|||||||
where,
|
where,
|
||||||
limit: 10000,
|
limit: 10000,
|
||||||
order: [["id", "DESC"]],
|
order: [["id", "DESC"]],
|
||||||
|
attributes: { exclude: ["secret_cipher"] },
|
||||||
});
|
});
|
||||||
ctx.success({ rows });
|
ctx.success({ rows });
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -3,6 +3,17 @@ const { normalize_for_write, build_search_where } = require("../utils/query_help
|
|||||||
const baseModel = require("../../middleware/baseModel");
|
const baseModel = require("../../middleware/baseModel");
|
||||||
const tokenLogic = require("../service/biz_token_logic");
|
const tokenLogic = require("../service/biz_token_logic");
|
||||||
const audit = require("../utils/biz_audit");
|
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 = {
|
module.exports = {
|
||||||
"POST /biz_user/page": async (ctx) => {
|
"POST /biz_user/page": async (ctx) => {
|
||||||
@@ -46,7 +57,40 @@ module.exports = {
|
|||||||
resource_id: row.id,
|
resource_id: row.id,
|
||||||
detail: { name: row.name },
|
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) => {
|
"POST /biz_user/edit": async (ctx) => {
|
||||||
const body = ctx.getBody();
|
const body = ctx.getBody();
|
||||||
@@ -101,13 +145,13 @@ module.exports = {
|
|||||||
where: { user_id: id },
|
where: { user_id: id },
|
||||||
order: [["id", "DESC"]],
|
order: [["id", "DESC"]],
|
||||||
limit: 200,
|
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({
|
ctx.success({
|
||||||
user,
|
user,
|
||||||
subscriptions,
|
subscriptions,
|
||||||
tokenCount,
|
tokenCount,
|
||||||
tokens,
|
tokens: tokens_out,
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
"GET /biz_user/all": async (ctx) => {
|
"GET /biz_user/all": async (ctx) => {
|
||||||
|
|||||||
@@ -5,15 +5,18 @@ const proxy = require("../service/biz_proxy_service");
|
|||||||
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 从请求中提取 Token
|
* 从 ctx 请求头中提取 Token(不含 query)
|
||||||
* 支持 Authorization: Bearer xxx 和 query ?token=xxx
|
* - Authorization: Bearer <token>
|
||||||
|
* - Authorization: <token>(无 Bearer 前缀时整段作为 token)
|
||||||
|
* - X-Api-Token / X-Token
|
||||||
*/
|
*/
|
||||||
function extractToken(ctx) {
|
function extractToken(ctx) {
|
||||||
const authHeader = ctx.get("Authorization") || "";
|
|
||||||
if (authHeader.startsWith("Bearer ")) {
|
let x_token = ctx.headers['authorization'] || ''
|
||||||
return authHeader.slice(7).trim();
|
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;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 不参与转发的文档路径(与 framework 实际路由重叠或仅为说明) */
|
||||||
|
function should_skip_proxy_path(route_path) {
|
||||||
|
return (
|
||||||
|
route_path.startsWith("/admin_api") ||
|
||||||
|
route_path.startsWith("/api/auth")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 构建转发路由表(供 framework.addRoutes 注册)
|
* 构建转发路由表(供 framework.addRoutes 注册)
|
||||||
*/
|
*/
|
||||||
@@ -33,6 +44,9 @@ function buildProxyRoutes() {
|
|||||||
const routes = {};
|
const routes = {};
|
||||||
|
|
||||||
for (const [path, methods] of Object.entries(swagger.paths)) {
|
for (const [path, methods] of Object.entries(swagger.paths)) {
|
||||||
|
if (should_skip_proxy_path(path)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
for (const [method, spec] of Object.entries(methods)) {
|
for (const [method, spec] of Object.entries(methods)) {
|
||||||
const routeKey = `${method.toUpperCase()} ${path}`;
|
const routeKey = `${method.toUpperCase()} ${path}`;
|
||||||
|
|
||||||
@@ -51,10 +65,11 @@ function buildProxyRoutes() {
|
|||||||
ctx.fail(authResult.message || "鉴权失败");
|
ctx.fail(authResult.message || "鉴权失败");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
// 3. 组装 query,并注入 token 对应 key(上游要求参数名为 key)
|
||||||
// 3. 组装 query(去掉 token 参数,避免泄露)
|
|
||||||
const query = { ...ctx.query };
|
const query = { ...ctx.query };
|
||||||
delete query.token;
|
if (!query.key && authResult.context && authResult.context.token_key) {
|
||||||
|
query.key = authResult.context.token_key;
|
||||||
|
}
|
||||||
|
|
||||||
// 4. 转发到上游
|
// 4. 转发到上游
|
||||||
const result = await proxy.forwardRequest({
|
const result = await proxy.forwardRequest({
|
||||||
@@ -68,7 +83,7 @@ function buildProxyRoutes() {
|
|||||||
|
|
||||||
// 5. 根据上游 Success 字段决定响应方式
|
// 5. 根据上游 Success 字段决定响应方式
|
||||||
const upstream = result.data;
|
const upstream = result.data;
|
||||||
if (upstream && upstream.Success === true) {
|
if (upstream && upstream.Code === 200) {
|
||||||
ctx.success(upstream);
|
ctx.success(upstream);
|
||||||
} else {
|
} else {
|
||||||
ctx.fail(upstream && upstream.Text ? upstream.Text : "上游请求失败", upstream);
|
ctx.fail(upstream && upstream.Text ? upstream.Text : "上游请求失败", upstream);
|
||||||
|
|||||||
@@ -32,6 +32,10 @@ module.exports = (db) => {
|
|||||||
allowNull: false,
|
allowNull: false,
|
||||||
defaultValue: 0,
|
defaultValue: 0,
|
||||||
},
|
},
|
||||||
|
response_body: {
|
||||||
|
type: Sequelize.TEXT,
|
||||||
|
allowNull: true,
|
||||||
|
},
|
||||||
call_date: {
|
call_date: {
|
||||||
type: Sequelize.DATEONLY,
|
type: Sequelize.DATEONLY,
|
||||||
allowNull: false,
|
allowNull: false,
|
||||||
@@ -49,5 +53,7 @@ module.exports = (db) => {
|
|||||||
underscored: true,
|
underscored: true,
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
|
//biz_api_call_log.sync({ force: true });
|
||||||
return biz_api_call_log;
|
return biz_api_call_log;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,5 +1,10 @@
|
|||||||
const Sequelize = require("sequelize");
|
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) => {
|
module.exports = (db) => {
|
||||||
const biz_api_token = db.define(
|
const biz_api_token = db.define(
|
||||||
"biz_api_token",
|
"biz_api_token",
|
||||||
@@ -18,11 +23,19 @@ module.exports = (db) => {
|
|||||||
allowNull: false,
|
allowNull: false,
|
||||||
defaultValue: "",
|
defaultValue: "",
|
||||||
},
|
},
|
||||||
|
key: {
|
||||||
|
type: Sequelize.STRING(128),
|
||||||
|
allowNull: true,
|
||||||
|
},
|
||||||
token_hash: {
|
token_hash: {
|
||||||
type: Sequelize.STRING(64),
|
type: Sequelize.STRING(64),
|
||||||
allowNull: false,
|
allowNull: false,
|
||||||
unique: true,
|
unique: true,
|
||||||
},
|
},
|
||||||
|
secret_cipher: {
|
||||||
|
type: Sequelize.TEXT,
|
||||||
|
allowNull: true,
|
||||||
|
},
|
||||||
status: {
|
status: {
|
||||||
type: Sequelize.ENUM("active", "revoked", "expired"),
|
type: Sequelize.ENUM("active", "revoked", "expired"),
|
||||||
allowNull: false,
|
allowNull: false,
|
||||||
|
|||||||
@@ -30,6 +30,12 @@ module.exports = (db) => {
|
|||||||
type: Sequelize.JSON,
|
type: Sequelize.JSON,
|
||||||
allowNull: true,
|
allowNull: true,
|
||||||
},
|
},
|
||||||
|
// 表字段存在且非空无默认时,须由模型声明,否则 insert 缺列报错
|
||||||
|
created_at: {
|
||||||
|
type: Sequelize.DATE,
|
||||||
|
allowNull: false,
|
||||||
|
defaultValue: Sequelize.NOW,
|
||||||
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
tableName: "biz_audit_log",
|
tableName: "biz_audit_log",
|
||||||
|
|||||||
@@ -109,6 +109,7 @@ async function verifyRequest(body) {
|
|||||||
plan_id: sub.plan_id,
|
plan_id: sub.plan_id,
|
||||||
subscription_id: sub.id,
|
subscription_id: sub.id,
|
||||||
token_id: row.id,
|
token_id: row.id,
|
||||||
|
token_key: row.key || "",
|
||||||
stat_month: statMonth,
|
stat_month: statMonth,
|
||||||
usage_snapshot: {
|
usage_snapshot: {
|
||||||
msg_count: usageSvc.num(usageRow.msg_count),
|
msg_count: usageSvc.num(usageRow.msg_count),
|
||||||
|
|||||||
@@ -5,6 +5,26 @@ const logs = require("../../tool/logs_proxy");
|
|||||||
|
|
||||||
const upstreamBaseUrl = config.upstream_api_url || "http://127.0.0.1:8888";
|
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 {object} params
|
||||||
@@ -57,6 +77,7 @@ async function forwardRequest({ api_path, method, query, body, headers, auth_ctx
|
|||||||
http_method: method.toUpperCase(),
|
http_method: method.toUpperCase(),
|
||||||
status_code,
|
status_code,
|
||||||
response_time,
|
response_time,
|
||||||
|
response_body: serialize_response_for_log(resp_data),
|
||||||
}).catch((e) => logs.error("[proxy] 写调用日志失败", e.message));
|
}).catch((e) => logs.error("[proxy] 写调用日志失败", e.message));
|
||||||
|
|
||||||
return { status: status_code, data: resp_data, headers: resp_headers };
|
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 调用日志
|
* 写入 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 {
|
try {
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
const call_date = `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, "0")}-${String(now.getDate()).padStart(2, "0")}`;
|
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,
|
http_method,
|
||||||
status_code,
|
status_code,
|
||||||
response_time,
|
response_time,
|
||||||
|
response_body: response_body || null,
|
||||||
call_date,
|
call_date,
|
||||||
created_at: now,
|
created_at: now,
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -2,6 +2,8 @@ const crypto = require("crypto");
|
|||||||
const Sequelize = require("sequelize");
|
const Sequelize = require("sequelize");
|
||||||
const op = Sequelize.Op;
|
const op = Sequelize.Op;
|
||||||
const baseModel = require("../../middleware/baseModel");
|
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;
|
const MAX_TOKENS_PER_USER = 5;
|
||||||
|
|
||||||
@@ -10,7 +12,14 @@ function hashPlainToken(plain) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function generatePlainToken() {
|
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 的订阅 */
|
/** 当前时间在 [start,end] 内且 status=active 的订阅 */
|
||||||
@@ -28,7 +37,7 @@ async function findActiveSubscriptionForUser(userId) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function createToken(body) {
|
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");
|
if (!user_id || !expire_at) throw new Error("缺少 user_id 或 expire_at");
|
||||||
const u = await baseModel.biz_user.findByPk(user_id);
|
const u = await baseModel.biz_user.findByPk(user_id);
|
||||||
if (!u) throw new Error("用户不存在");
|
if (!u) throw new Error("用户不存在");
|
||||||
@@ -46,12 +55,15 @@ async function createToken(body) {
|
|||||||
|
|
||||||
const plain = generatePlainToken();
|
const plain = generatePlainToken();
|
||||||
const token_hash = hashPlainToken(plain);
|
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({
|
const row = await baseModel.biz_api_token.create({
|
||||||
user_id,
|
user_id,
|
||||||
plan_id,
|
plan_id,
|
||||||
token_name: token_name || "default",
|
token_name: token_name || "default",
|
||||||
|
key: key || null,
|
||||||
token_hash,
|
token_hash,
|
||||||
|
secret_cipher,
|
||||||
status: "active",
|
status: "active",
|
||||||
expire_at,
|
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) {
|
async function revokeToken(body) {
|
||||||
const id = body.id;
|
const id = body.id;
|
||||||
if (id == null) throw new Error("缺少 id");
|
if (id == null) throw new Error("缺少 id");
|
||||||
const row = await baseModel.biz_api_token.findByPk(id);
|
const row = await baseModel.biz_api_token.findByPk(id);
|
||||||
if (!row) throw new Error("Token 不存在");
|
if (!row) throw new Error("Token 不存在");
|
||||||
await row.update({ status: "revoked" });
|
await row.update({ status: "revoked", secret_cipher: null });
|
||||||
return row;
|
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) {
|
async function revokeAllForUser(userId) {
|
||||||
if (userId == null) throw new Error("缺少 user_id");
|
if (userId == null) throw new Error("缺少 user_id");
|
||||||
const [n] = await baseModel.biz_api_token.update(
|
const [n] = await baseModel.biz_api_token.update(
|
||||||
{ status: "revoked" },
|
{ status: "revoked", secret_cipher: null },
|
||||||
{ where: { user_id: userId, status: "active" } }
|
{ where: { user_id: userId, status: "active" } }
|
||||||
);
|
);
|
||||||
return n;
|
return n;
|
||||||
@@ -84,8 +160,11 @@ async function revokeAllForUser(userId) {
|
|||||||
module.exports = {
|
module.exports = {
|
||||||
hashPlainToken,
|
hashPlainToken,
|
||||||
createToken,
|
createToken,
|
||||||
|
updateToken,
|
||||||
|
regenerateToken,
|
||||||
revokeToken,
|
revokeToken,
|
||||||
revokeAllForUser,
|
revokeAllForUser,
|
||||||
findActiveSubscriptionForUser,
|
findActiveSubscriptionForUser,
|
||||||
|
defaultTokenExpireAt,
|
||||||
MAX_TOKENS_PER_USER,
|
MAX_TOKENS_PER_USER,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ async function logAudit(p) {
|
|||||||
resource_type: p.resource_type || "",
|
resource_type: p.resource_type || "",
|
||||||
resource_id: p.resource_id != null ? p.resource_id : null,
|
resource_id: p.resource_id != null ? p.resource_id : null,
|
||||||
detail: p.detail || null,
|
detail: p.detail || null,
|
||||||
|
created_at: new Date(),
|
||||||
});
|
});
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
logs.error("[biz_audit] 写入失败", 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,
|
||||||
|
};
|
||||||
@@ -39,5 +39,8 @@ module.exports = {
|
|||||||
|
|
||||||
// 官方 API 上游地址(转发层目标)
|
// 官方 API 上游地址(转发层目标)
|
||||||
"upstream_api_url": "http://113.44.162.180:7006",
|
"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",
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -42,5 +42,8 @@ module.exports = {
|
|||||||
|
|
||||||
// 官方 API 上游地址(转发层目标)
|
// 官方 API 上游地址(转发层目标)
|
||||||
"upstream_api_url": "http://127.0.0.1:8888",
|
"upstream_api_url": "http://127.0.0.1:8888",
|
||||||
|
|
||||||
|
/** 生产环境务必设置环境变量 BIZ_TOKEN_ENC_KEY(长随机串) */
|
||||||
|
"biz_token_enc_key": process.env.BIZ_TOKEN_ENC_KEY,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user