Compare commits
7 Commits
2d900ef2ac
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
50bb0bc6ad | ||
|
|
38430c9244 | ||
|
|
4c724143c0 | ||
|
|
084c437096 | ||
|
|
09368d2a95 | ||
|
|
f810f60e3f | ||
|
|
a934d5b239 |
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;
|
||||||
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,6 +7,10 @@ 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);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,6 +67,7 @@
|
|||||||
<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>
|
||||||
@@ -90,6 +99,9 @@ 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 明文',
|
plainModalTitle: '请立即保存 Token 明文',
|
||||||
@@ -104,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 },
|
||||||
@@ -111,15 +124,26 @@ export default {
|
|||||||
{
|
{
|
||||||
title: '操作',
|
title: '操作',
|
||||||
key: 'a',
|
key: 'a',
|
||||||
width: 178,
|
width: 248,
|
||||||
render: (h, p) => {
|
render: (h, p) => {
|
||||||
const btns = []
|
const btns = []
|
||||||
|
btns.push(
|
||||||
|
h(
|
||||||
|
'Button',
|
||||||
|
{
|
||||||
|
props: { type: 'primary', size: 'small', ghost: true },
|
||||||
|
on: { click: () => this.openEdit(p.row) },
|
||||||
|
},
|
||||||
|
'编辑'
|
||||||
|
)
|
||||||
|
)
|
||||||
if (p.row.status === 'active') {
|
if (p.row.status === 'active') {
|
||||||
btns.push(
|
btns.push(
|
||||||
h(
|
h(
|
||||||
'Button',
|
'Button',
|
||||||
{
|
{
|
||||||
props: { type: 'warning', size: 'small' },
|
props: { type: 'warning', size: 'small' },
|
||||||
|
class: { ml8: true },
|
||||||
on: { click: () => this.doRegenerate(p.row) },
|
on: { click: () => this.doRegenerate(p.row) },
|
||||||
},
|
},
|
||||||
'重新生成'
|
'重新生成'
|
||||||
@@ -131,7 +155,7 @@ export default {
|
|||||||
'Button',
|
'Button',
|
||||||
{
|
{
|
||||||
props: { type: 'error', size: 'small' },
|
props: { type: 'error', size: 'small' },
|
||||||
class: { ml8: btns.length > 0 },
|
class: { ml8: true },
|
||||||
on: { click: () => this.doRevoke(p.row) },
|
on: { click: () => this.doRevoke(p.row) },
|
||||||
},
|
},
|
||||||
'吊销'
|
'吊销'
|
||||||
@@ -176,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() {
|
||||||
@@ -189,11 +213,67 @@ 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,
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -70,79 +70,37 @@
|
|||||||
</div>
|
</div>
|
||||||
</Modal>
|
</Modal>
|
||||||
|
|
||||||
<Modal
|
<Modal v-model="create_token_modal" title="生成 API Token" width="560" :loading="create_token_saving"
|
||||||
v-model="create_token_modal"
|
@on-ok="submit_create_token">
|
||||||
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">
|
<p v-if="create_token_target_user" class="text-muted mb12">
|
||||||
用户:{{ create_token_target_user.name || ('#' + create_token_target_user.id) }}
|
用户:{{ create_token_target_user.name || ('#' + create_token_target_user.id) }}
|
||||||
· ID {{ create_token_target_user.id }}
|
· ID {{ create_token_target_user.id }}
|
||||||
</p>
|
</p>
|
||||||
<Form :label-width="100">
|
<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.token_name" placeholder="default" /></FormItem>
|
||||||
<FormItem label="过期时间"><Input v-model="create_token_form.expire_at" placeholder="YYYY-MM-DD 23:59:59" /></FormItem>
|
<FormItem label="过期时间"><Input v-model="create_token_form.expire_at" placeholder="YYYY-MM-DD 23:59:59" />
|
||||||
|
</FormItem>
|
||||||
</Form>
|
</Form>
|
||||||
</Modal>
|
</Modal>
|
||||||
|
|
||||||
<Modal v-model="detailVisible" title="用户详情" width="920" footer-hide>
|
<Modal v-model="detailVisible" title="用户详情" width="960" footer-hide>
|
||||||
<div v-if="detail && detail.user" class="detail-user-bar mb12">
|
<template v-if="detail && detail.user">
|
||||||
<span class="detail-user-item">ID {{ detail.user.id }}</span>
|
<p class="detail-one-line mb12">
|
||||||
<span class="detail-user-item">{{ detail.user.name || '—' }}</span>
|
<strong>{{ detail.user.name || '—' }}</strong>
|
||||||
<span class="detail-user-item">{{ detail.user.mobile || '—' }}</span>
|
<span class="text-muted"> · ID {{ detail.user.id }} · {{ detail.user.mobile || '—' }} · {{ detail.user.status
|
||||||
<Tag>{{ detail.user.status }}</Tag>
|
}}</span>
|
||||||
<Button
|
<Button type="primary" size="small" class="ml12" :disabled="detail.user.status !== 'active'"
|
||||||
type="primary"
|
@click="open_create_token_for_user(detail.user)">生成 Token</Button>
|
||||||
size="small"
|
</p>
|
||||||
class="ml12"
|
<p class="sub-title">Token({{ (detail.tokens && detail.tokens.length) || 0 }})</p>
|
||||||
:disabled="detail.user.status !== 'active'"
|
<Table :columns="tokenCols" :data="detail.tokens || []" size="small" border
|
||||||
@click="open_create_token_for_user(detail.user)"
|
class="mb16 token-table-in-detail" />
|
||||||
>生成 Token</Button>
|
<p class="sub-title">订阅</p>
|
||||||
</div>
|
<Table v-if="detail.subscriptions && detail.subscriptions.length" :columns="subCols"
|
||||||
<p v-if="detail" class="mb8">Token 数量:{{ detail.tokenCount }}</p>
|
:data="detail.subscriptions" size="small" border />
|
||||||
<p v-if="detail && detail.tokens && detail.tokens.length" class="sub-title">API Token</p>
|
<p v-else class="text-muted">暂无订阅</p>
|
||||||
<Table v-if="detail && detail.tokens && detail.tokens.length" :columns="tokenCols" :data="detail.tokens"
|
</template>
|
||||||
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="980" footer-hide>
|
|
||||||
<div v-if="tokenList_target_user" class="mb12">
|
|
||||||
<Button
|
|
||||||
type="primary"
|
|
||||||
size="small"
|
|
||||||
:disabled="tokenList_target_user.status !== 'active'"
|
|
||||||
@click="open_create_token_for_user(tokenList_target_user)"
|
|
||||||
>生成 Token</Button>
|
|
||||||
<span class="text-muted ml10">点击行查看详情;列表「明文」来自服务端加密存储(吊销后不可查看)</span>
|
|
||||||
</div>
|
|
||||||
<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="600" 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 class="token-plain-block">
|
|
||||||
<p><span class="label">明文</span></p>
|
|
||||||
<template v-if="selectedToken.plain_token">
|
|
||||||
<Input type="textarea" :rows="3" :value="selectedToken.plain_token" readonly class="token-plain-ta" />
|
|
||||||
<Button type="primary" size="small" class="mt8" @click="copy_text(selectedToken.plain_token)">复制全文</Button>
|
|
||||||
</template>
|
|
||||||
<p v-else class="text-muted mb0">无存储明文(旧数据或已吊销);可对 active 记录「重新生成」后查看</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</Modal>
|
</Modal>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
@@ -171,11 +129,6 @@ export default {
|
|||||||
},
|
},
|
||||||
detailVisible: false,
|
detailVisible: false,
|
||||||
detail: null,
|
detail: null,
|
||||||
tokenListVisible: false,
|
|
||||||
tokenListRows: [],
|
|
||||||
tokenListUserName: '',
|
|
||||||
tokenDetailVisible: false,
|
|
||||||
selectedToken: null,
|
|
||||||
token_plain_modal: false,
|
token_plain_modal: false,
|
||||||
token_plain_title: '',
|
token_plain_title: '',
|
||||||
token_plain_text: '',
|
token_plain_text: '',
|
||||||
@@ -183,7 +136,6 @@ export default {
|
|||||||
create_token_saving: false,
|
create_token_saving: false,
|
||||||
create_token_target_user: null,
|
create_token_target_user: null,
|
||||||
create_token_form: { token_name: 'default', expire_at: '' },
|
create_token_form: { token_name: 'default', expire_at: '' },
|
||||||
tokenList_target_user: null,
|
|
||||||
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 },
|
||||||
@@ -215,11 +167,11 @@ 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'
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -294,24 +246,31 @@ 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: '明文',
|
title: '明文',
|
||||||
key: 'plain_token',
|
key: 'plain_token',
|
||||||
minWidth: 200,
|
minWidth: 280,
|
||||||
render: (h, p) => {
|
render: (h, p) => {
|
||||||
const v = p.row.plain_token
|
const v = p.row.plain_token
|
||||||
if (!v) {
|
if (!v) {
|
||||||
return h('span', { class: { 'text-muted': true } }, '—')
|
return h('span', { class: { 'text-muted': true } }, '—')
|
||||||
}
|
}
|
||||||
const snip = v.length > 28 ? `${v.slice(0, 28)}…` : v
|
return h('div', { class: 'plain-cell' }, [
|
||||||
return h('div', { class: 'plain-token-row' }, [
|
h('Input', {
|
||||||
h('span', { class: 'plain-token-snip', attrs: { title: v } }, snip),
|
props: {
|
||||||
|
type: 'textarea',
|
||||||
|
value: v,
|
||||||
|
rows: 2,
|
||||||
|
readonly: true,
|
||||||
|
},
|
||||||
|
class: 'plain-cell-input',
|
||||||
|
}),
|
||||||
h(
|
h(
|
||||||
'Button',
|
'Button',
|
||||||
{
|
{
|
||||||
@@ -331,7 +290,7 @@ export default {
|
|||||||
{
|
{
|
||||||
title: '操作',
|
title: '操作',
|
||||||
key: 'tok_op',
|
key: 'tok_op',
|
||||||
width: 120,
|
width: 96,
|
||||||
render: (h, p) => {
|
render: (h, p) => {
|
||||||
if (p.row.status !== 'active') {
|
if (p.row.status !== 'active') {
|
||||||
return h('span', { class: { 'text-muted': true } }, '—')
|
return h('span', { class: { 'text-muted': true } }, '—')
|
||||||
@@ -340,7 +299,12 @@ export default {
|
|||||||
'Button',
|
'Button',
|
||||||
{
|
{
|
||||||
props: { type: 'warning', size: 'small' },
|
props: { type: 'warning', size: 'small' },
|
||||||
on: { click: () => this.do_regenerate_token(p.row) },
|
on: {
|
||||||
|
click: (e) => {
|
||||||
|
e.stopPropagation()
|
||||||
|
this.do_regenerate_token(p.row)
|
||||||
|
},
|
||||||
|
},
|
||||||
},
|
},
|
||||||
'重新生成'
|
'重新生成'
|
||||||
)
|
)
|
||||||
@@ -348,10 +312,6 @@ export default {
|
|||||||
},
|
},
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
tokenListTitle() {
|
|
||||||
const name = this.tokenListUserName || ''
|
|
||||||
return name ? `Token 列表 — ${name}` : 'Token 列表'
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
mounted() {
|
mounted() {
|
||||||
this.load(1)
|
this.load(1)
|
||||||
@@ -470,6 +430,7 @@ export default {
|
|||||||
}
|
}
|
||||||
this.create_token_target_user = user_row
|
this.create_token_target_user = user_row
|
||||||
this.create_token_form = {
|
this.create_token_form = {
|
||||||
|
key: '',
|
||||||
token_name: 'default',
|
token_name: 'default',
|
||||||
expire_at: this.default_token_expire_input(),
|
expire_at: this.default_token_expire_input(),
|
||||||
}
|
}
|
||||||
@@ -487,6 +448,7 @@ export default {
|
|||||||
try {
|
try {
|
||||||
const res = await tokenServer.create({
|
const res = await tokenServer.create({
|
||||||
user_id: row.id,
|
user_id: row.id,
|
||||||
|
key: this.create_token_form.key || null,
|
||||||
token_name: this.create_token_form.token_name || 'default',
|
token_name: this.create_token_form.token_name || 'default',
|
||||||
expire_at: this.create_token_form.expire_at,
|
expire_at: this.create_token_form.expire_at,
|
||||||
})
|
})
|
||||||
@@ -503,21 +465,6 @@ export default {
|
|||||||
this.create_token_saving = false
|
this.create_token_saving = false
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
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.tokenList_target_user = res.data.user || row
|
|
||||||
this.tokenListVisible = true
|
|
||||||
} else {
|
|
||||||
this.$Message.error((res && res.message) || '加载 Token 失败')
|
|
||||||
}
|
|
||||||
},
|
|
||||||
onTokenRowClick(row) {
|
|
||||||
this.selectedToken = row
|
|
||||||
this.tokenDetailVisible = true
|
|
||||||
},
|
|
||||||
show_token_plain(title, text) {
|
show_token_plain(title, text) {
|
||||||
this.token_plain_title = title
|
this.token_plain_title = title
|
||||||
this.token_plain_text = text || ''
|
this.token_plain_text = text || ''
|
||||||
@@ -527,7 +474,6 @@ export default {
|
|||||||
const res = await userServer.detail(user_id)
|
const res = await userServer.detail(user_id)
|
||||||
if (res && res.code === 0) {
|
if (res && res.code === 0) {
|
||||||
if (this.detailVisible) this.detail = res.data
|
if (this.detailVisible) this.detail = res.data
|
||||||
if (this.tokenListVisible) this.tokenListRows = res.data.tokens || []
|
|
||||||
this.load(1)
|
this.load(1)
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -650,59 +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-plain-block {
|
.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;
|
margin-top: 12px;
|
||||||
padding-top: 12px;
|
|
||||||
border-top: 1px solid #e8eaec;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.token-plain-block .label {
|
.detail-one-line {
|
||||||
margin-bottom: 6px;
|
line-height: 32px;
|
||||||
}
|
|
||||||
|
|
||||||
.token-plain-ta :deep(textarea) {
|
|
||||||
font-family: monospace;
|
|
||||||
font-size: 12px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.mb0 {
|
|
||||||
margin-bottom: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.mt8 {
|
|
||||||
margin-top: 8px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.plain-token-row {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 6px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.plain-token-snip {
|
|
||||||
flex: 1;
|
|
||||||
min-width: 0;
|
|
||||||
font-family: monospace;
|
|
||||||
font-size: 12px;
|
|
||||||
word-break: break-all;
|
|
||||||
}
|
|
||||||
|
|
||||||
.detail-user-bar {
|
|
||||||
display: flex;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
align-items: center;
|
|
||||||
gap: 8px 12px;
|
|
||||||
padding-bottom: 8px;
|
|
||||||
border-bottom: 1px solid #e8eaec;
|
|
||||||
}
|
|
||||||
|
|
||||||
.detail-user-item {
|
|
||||||
color: #515a6e;
|
|
||||||
}
|
}
|
||||||
</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) });
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -38,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);
|
||||||
|
|||||||
@@ -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,6 +23,10 @@ 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,
|
||||||
@@ -42,6 +51,6 @@ module.exports = (db) => {
|
|||||||
underscored: true,
|
underscored: true,
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
// biz_api_token.sync({ force: true });
|
//biz_api_token.sync({ force: true });
|
||||||
return biz_api_token;
|
return biz_api_token;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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,
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ 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 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;
|
||||||
|
|
||||||
@@ -11,7 +12,7 @@ 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 */
|
/** 默认 Token 过期时间:一年后当日 23:59:59 */
|
||||||
@@ -36,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("用户不存在");
|
||||||
@@ -60,6 +61,7 @@ async function createToken(body) {
|
|||||||
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,
|
secret_cipher,
|
||||||
status: "active",
|
status: "active",
|
||||||
@@ -73,6 +75,35 @@ 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");
|
||||||
@@ -129,6 +160,7 @@ async function revokeAllForUser(userId) {
|
|||||||
module.exports = {
|
module.exports = {
|
||||||
hashPlainToken,
|
hashPlainToken,
|
||||||
createToken,
|
createToken,
|
||||||
|
updateToken,
|
||||||
regenerateToken,
|
regenerateToken,
|
||||||
revokeToken,
|
revokeToken,
|
||||||
revokeAllForUser,
|
revokeAllForUser,
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
Reference in New Issue
Block a user