This commit is contained in:
张成
2026-04-01 15:02:45 +08:00
parent 38430c9244
commit 50bb0bc6ad
10 changed files with 257 additions and 2 deletions

View File

@@ -171,6 +171,18 @@ const definitions = {
required: ["user_id", "expire_at"], required: ["user_id", "expire_at"],
properties: { properties: {
user_id: { type: "integer", format: "int64" }, 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" }, token_name: { type: "string" },
expire_at: { 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/page"] = post("API Token 分页(不含 secret_cipher", "BizAdminPageRequest");
paths["/admin_api/biz_token/create"] = post("创建 Token返回 plain_token", "BizTokenCreateRequest"); 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/revoke"] = post("吊销 Token", "BizTokenIdRequest");
paths["/admin_api/biz_token/regenerate"] = post("重新生成密钥(返回新 plain_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_token/export"] = post("Token 导出", "BizAdminPageRequest");

View File

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

View File

@@ -3865,6 +3865,10 @@
"type": "integer", "type": "integer",
"format": "int64" "format": "int64"
}, },
"key": {
"type": "string",
"description": "账号唯一标识,可选"
},
"token_name": { "token_name": {
"type": "string" "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": { "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", "swagger": "2.0",

View File

@@ -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);
} }

View File

@@ -20,6 +20,8 @@ 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,

View File

@@ -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="用户">
@@ -91,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 明文',
@@ -113,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) },
}, },
'重新生成' '重新生成'
@@ -133,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) },
}, },
'吊销' '吊销'
@@ -191,6 +213,61 @@ 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 {

View File

@@ -77,6 +77,7 @@
· 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 label="过期时间"><Input v-model="create_token_form.expire_at" placeholder="YYYY-MM-DD 23:59:59" />
</FormItem> </FormItem>
@@ -429,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(),
} }
@@ -446,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,
}) })

View File

@@ -45,6 +45,21 @@ module.exports = {
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);

View File

@@ -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",

View File

@@ -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;
@@ -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) { 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");
@@ -130,6 +160,7 @@ async function revokeAllForUser(userId) {
module.exports = { module.exports = {
hashPlainToken, hashPlainToken,
createToken, createToken,
updateToken,
regenerateToken, regenerateToken,
revokeToken, revokeToken,
revokeAllForUser, revokeAllForUser,