From 50bb0bc6addc65c71ea31b09046b8fff0d9c37c0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=BC=A0=E6=88=90?= Date: Wed, 1 Apr 2026 15:02:45 +0800 Subject: [PATCH] 1 --- _docs/merge_biz_into_swagger.js | 13 +++ _docs/sql/sys_menu_subscription_api_token.sql | 34 ++++++++ _docs/swagger.json | 71 ++++++++++++++++ admin/src/api/subscription/token_server.js | 4 + admin/src/router/component-map.js | 2 + admin/src/views/subscription/tokens.vue | 81 ++++++++++++++++++- admin/src/views/subscription/users.vue | 3 + api/controller_admin/biz_token.js | 15 ++++ api/model/biz_api_token.js | 5 ++ api/service/biz_token_logic.js | 31 +++++++ 10 files changed, 257 insertions(+), 2 deletions(-) create mode 100644 _docs/sql/sys_menu_subscription_api_token.sql diff --git a/_docs/merge_biz_into_swagger.js b/_docs/merge_biz_into_swagger.js index e9642c3..6cc57c4 100644 --- a/_docs/merge_biz_into_swagger.js +++ b/_docs/merge_biz_into_swagger.js @@ -171,6 +171,18 @@ const definitions = { 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" }, }, @@ -306,6 +318,7 @@ paths["/admin_api/biz_subscription/export"] = post("订阅导出 CSV 数据", "B 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"); diff --git a/_docs/sql/sys_menu_subscription_api_token.sql b/_docs/sql/sys_menu_subscription_api_token.sql new file mode 100644 index 0000000..4a57bb4 --- /dev/null +++ b/_docs/sql/sys_menu_subscription_api_token.sql @@ -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 +); diff --git a/_docs/swagger.json b/_docs/swagger.json index 15a6981..a28626a 100644 --- a/_docs/swagger.json +++ b/_docs/swagger.json @@ -3865,6 +3865,10 @@ "type": "integer", "format": "int64" }, + "key": { + "type": "string", + "description": "账号唯一标识,可选" + }, "token_name": { "type": "string" }, @@ -4085,6 +4089,29 @@ ] } } + }, + "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" + } + } } }, "info": { @@ -10492,6 +10519,50 @@ } } } + }, + "/admin_api/biz_api_call_log/export": { + "post": { + "tags": [ + "管理端-业务订阅" + ], + "summary": "API 调用明细导出", + "parameters": [ + { + "in": "body", + "name": "body", + "schema": { + "$ref": "#/definitions/BizAdminPageRequest" + } + } + ], + "responses": { + "200": { + "description": "框架统一包装;成功时 code=0,业务数据在 data" + } + } + } + }, + "/admin_api/biz_token/edit": { + "post": { + "tags": [ + "管理端-业务订阅" + ], + "summary": "编辑 Token(名称/key/过期时间,不改密钥)", + "parameters": [ + { + "in": "body", + "name": "body", + "schema": { + "$ref": "#/definitions/BizTokenEditRequest" + } + } + ], + "responses": { + "200": { + "description": "框架统一包装;成功时 code=0,业务数据在 data" + } + } + } } }, "swagger": "2.0", diff --git a/admin/src/api/subscription/token_server.js b/admin/src/api/subscription/token_server.js index af5784c..12c7621 100644 --- a/admin/src/api/subscription/token_server.js +++ b/admin/src/api/subscription/token_server.js @@ -7,6 +7,10 @@ class TokenServer { return window.framework.http.post("/biz_token/create", row); } + async edit(row) { + return window.framework.http.post("/biz_token/edit", row); + } + async revoke(row) { return window.framework.http.post("/biz_token/revoke", row); } diff --git a/admin/src/router/component-map.js b/admin/src/router/component-map.js index 3ad98d4..2430afc 100644 --- a/admin/src/router/component-map.js +++ b/admin/src/router/component-map.js @@ -20,6 +20,8 @@ const componentMap = { 'subscription/plan': SubscriptionPlans, 'subscription/subscription': SubscriptionRecords, 'subscription/token': SubscriptionTokens, + /** 与 biz_api_token 管理页同一视图,便于菜单 component 语义对应 */ + 'subscription/biz_api_token': SubscriptionTokens, 'subscription/payment': SubscriptionPayment, 'subscription/usage': SubscriptionUsage, 'subscription/audit': SubscriptionAuditLog, diff --git a/admin/src/views/subscription/tokens.vue b/admin/src/views/subscription/tokens.vue index d23daec..4479b42 100644 --- a/admin/src/views/subscription/tokens.vue +++ b/admin/src/views/subscription/tokens.vue @@ -52,6 +52,14 @@ + +
+ + + +
+
+
@@ -91,6 +99,9 @@ export default { seachOption: { key: 'user_id', value: '' }, pageOption: { page: 1, pageSize: 20, total: 0 }, }, + editModal: false, + editSaving: false, + editForm: {}, createModal: false, plainModal: false, plainModalTitle: '请立即保存 Token 明文', @@ -113,15 +124,26 @@ export default { { title: '操作', key: 'a', - width: 178, + width: 248, render: (h, p) => { const btns = [] + btns.push( + h( + 'Button', + { + props: { type: 'primary', size: 'small', ghost: true }, + on: { click: () => this.openEdit(p.row) }, + }, + '编辑' + ) + ) if (p.row.status === 'active') { btns.push( h( 'Button', { props: { type: 'warning', size: 'small' }, + class: { ml8: true }, on: { click: () => this.doRegenerate(p.row) }, }, '重新生成' @@ -133,7 +155,7 @@ export default { 'Button', { props: { type: 'error', size: 'small' }, - class: { ml8: btns.length > 0 }, + class: { ml8: true }, on: { click: () => this.doRevoke(p.row) }, }, '吊销' @@ -191,6 +213,61 @@ export default { this._submitCreate() return false }, + openEdit(row) { + this.editForm = { + id: row.id, + key: row.key != null ? String(row.key) : '', + token_name: row.token_name || '', + expire_at: row.expire_at + ? typeof row.expire_at === 'string' + ? row.expire_at + : this._fmt_expire(row.expire_at) + : '', + } + this.editModal = true + }, + _fmt_expire(d) { + if (!d) return '' + const dt = new Date(d) + if (Number.isNaN(dt.getTime())) return String(d) + const pad = (n) => String(n).padStart(2, '0') + return `${dt.getFullYear()}-${pad(dt.getMonth() + 1)}-${pad(dt.getDate())} ${pad(dt.getHours())}:${pad( + dt.getMinutes() + )}:${pad(dt.getSeconds())}` + }, + submitEdit() { + if (!this.editForm.id) return false + if (!this.editForm.token_name || !String(this.editForm.token_name).trim()) { + this.$Message.warning('请填写名称') + return false + } + if (!this.editForm.expire_at || !String(this.editForm.expire_at).trim()) { + this.$Message.warning('请填写过期时间') + return false + } + this.editSaving = true + this._submitEdit() + return false + }, + async _submitEdit() { + try { + const res = await tokenServer.edit({ + id: this.editForm.id, + key: this.editForm.key === '' ? null : this.editForm.key, + token_name: this.editForm.token_name, + expire_at: this.editForm.expire_at, + }) + if (res && res.code === 0) { + this.editModal = false + this.$Message.success('已保存') + this.load(1) + } else { + this.$Message.error((res && res.message) || '保存失败') + } + } finally { + this.editSaving = false + } + }, async _submitCreate() { const uid = this.createForm.user_id try { diff --git a/admin/src/views/subscription/users.vue b/admin/src/views/subscription/users.vue index f48aa25..01da5f0 100644 --- a/admin/src/views/subscription/users.vue +++ b/admin/src/views/subscription/users.vue @@ -77,6 +77,7 @@ · ID {{ create_token_target_user.id }}

+ @@ -429,6 +430,7 @@ export default { } this.create_token_target_user = user_row this.create_token_form = { + key: '', token_name: 'default', expire_at: this.default_token_expire_input(), } @@ -446,6 +448,7 @@ export default { 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, }) diff --git a/api/controller_admin/biz_token.js b/api/controller_admin/biz_token.js index b1490b8..c15844e 100644 --- a/api/controller_admin/biz_token.js +++ b/api/controller_admin/biz_token.js @@ -45,6 +45,21 @@ module.exports = { warn: result.warn, }); }, + "POST /biz_token/edit": async (ctx) => { + const body = ctx.getBody(); + const row = await tokenLogic.updateToken(body); + await audit.logAudit({ + admin_user_id: audit.pickAdminId(ctx), + biz_user_id: row.user_id, + action: "biz_token.edit", + resource_type: "biz_api_token", + resource_id: row.id, + detail: { token_name: row.token_name, key: row.key }, + }); + const plain = row.get ? row.get({ plain: true }) : { ...row }; + delete plain.secret_cipher; + ctx.success(plain); + }, "POST /biz_token/revoke": async (ctx) => { const body = ctx.getBody(); const row = await tokenLogic.revokeToken(body); diff --git a/api/model/biz_api_token.js b/api/model/biz_api_token.js index 7e78965..a302d9b 100644 --- a/api/model/biz_api_token.js +++ b/api/model/biz_api_token.js @@ -1,5 +1,10 @@ const Sequelize = require("sequelize"); +/** + * 业务 API Token(管理端页面:admin/src/views/subscription/tokens.vue) + * 动态路由 component 与 admin/src/router/component-map.js 中 + * subscription/token 或 subscription/biz_api_token 对应。 + */ module.exports = (db) => { const biz_api_token = db.define( "biz_api_token", diff --git a/api/service/biz_token_logic.js b/api/service/biz_token_logic.js index 5a6d7b1..15a4bc4 100644 --- a/api/service/biz_token_logic.js +++ b/api/service/biz_token_logic.js @@ -3,6 +3,7 @@ const Sequelize = require("sequelize"); const op = Sequelize.Op; const baseModel = require("../../middleware/baseModel"); const biz_token_secret_cipher = require("../utils/biz_token_secret_cipher"); +const { normalize_for_write } = require("../utils/query_helpers"); const MAX_TOKENS_PER_USER = 5; @@ -74,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) { const id = body.id; if (id == null) throw new Error("缺少 id"); @@ -130,6 +160,7 @@ async function revokeAllForUser(userId) { module.exports = { hashPlainToken, createToken, + updateToken, regenerateToken, revokeToken, revokeAllForUser,