This commit is contained in:
张成
2026-04-01 13:40:27 +08:00
parent d03916290a
commit 1d22fb28e2
6 changed files with 358 additions and 17 deletions

View File

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

View File

@@ -64,7 +64,7 @@
</Form> </Form>
</Modal> </Modal>
<Modal v-model="plainModal" title="请立即保存 Token 明文" width="560" :closable="false"> <Modal v-model="plainModal" :title="plainModalTitle" width="560" :closable="false">
<Alert type="error">仅此一次展示关闭后无法再次查看明文</Alert> <Alert type="error">仅此一次展示关闭后无法再次查看明文</Alert>
<Input type="textarea" :rows="4" v-model="plainToken" readonly /> <Input type="textarea" :rows="4" v-model="plainToken" readonly />
<div slot="footer"> <div slot="footer">
@@ -92,6 +92,7 @@ export default {
}, },
createModal: false, createModal: false,
plainModal: false, plainModal: false,
plainModalTitle: '请立即保存 Token 明文',
plainToken: '', plainToken: '',
saving: false, saving: false,
createForm: {}, createForm: {},
@@ -110,18 +111,34 @@ export default {
{ {
title: '操作', title: '操作',
key: 'a', key: 'a',
width: 100, width: 178,
render: (h, p) => render: (h, p) => {
const btns = []
if (p.row.status === 'active') {
btns.push(
h(
'Button',
{
props: { type: 'warning', size: 'small' },
on: { click: () => this.doRegenerate(p.row) },
},
'重新生成'
)
)
}
btns.push(
h( h(
'Button', 'Button',
{ {
props: { type: 'error', size: 'small' }, props: { type: 'error', size: 'small' },
on: { class: { ml8: btns.length > 0 },
click: () => this.doRevoke(p.row), on: { click: () => this.doRevoke(p.row) },
},
}, },
'吊销' '吊销'
), )
)
return h('div', btns)
},
}, },
] ]
}, },
@@ -183,6 +200,7 @@ export default {
if (res && res.code === 0) { if (res && res.code === 0) {
if (res.data.warn) this.$Message.warning(res.data.warn) if (res.data.warn) this.$Message.warning(res.data.warn)
this.createModal = false this.createModal = false
this.plainModalTitle = '请立即保存 Token 明文'
this.plainToken = res.data.plain_token this.plainToken = res.data.plain_token
this.plainModal = true this.plainModal = true
this.load(1) this.load(1)
@@ -193,6 +211,24 @@ export default {
this.saving = false this.saving = false
} }
}, },
doRegenerate(row) {
this.$Modal.confirm({
title: '重新生成 Token',
content: '旧密钥将立即失效,确定继续?',
onOk: async () => {
const res = await tokenServer.regenerate({ id: row.id })
if (res && res.code === 0) {
if (res.data.warn) this.$Message.warning(res.data.warn)
this.plainModalTitle = '请保存重新生成后的 Token 明文'
this.plainToken = res.data.plain_token
this.plainModal = true
this.load(1)
} else {
this.$Message.error((res && res.message) || '失败')
}
},
})
},
doRevoke(row) { doRevoke(row) {
this.$Modal.confirm({ this.$Modal.confirm({
title: '吊销 Token', title: '吊销 Token',
@@ -245,4 +281,8 @@ export default {
margin-top: 12px; margin-top: 12px;
text-align: right; text-align: right;
} }
.ml8 {
margin-left: 8px;
}
</style> </style>

View File

@@ -56,10 +56,51 @@
<Option value="disabled">禁用</Option> <Option value="disabled">禁用</Option>
</Select> </Select>
</FormItem> </FormItem>
<FormItem v-if="!form.id" label="API Token">
<Checkbox v-model="form.auto_create_token">保存时自动创建默认 Token明文仅展示一次</Checkbox>
</FormItem>
</Form>
</Modal>
<Modal v-model="token_plain_modal" :title="token_plain_title" width="560" :closable="false">
<Alert type="error">关闭后无法再次查看明文请复制到安全位置</Alert>
<Input v-model="token_plain_text" type="textarea" :rows="4" readonly />
<div slot="footer">
<Button type="primary" @click="token_plain_modal = false">已保存</Button>
</div>
</Modal>
<Modal
v-model="create_token_modal"
title="生成 API Token"
width="560"
:loading="create_token_saving"
@on-ok="submit_create_token"
>
<p v-if="create_token_target_user" class="text-muted mb12">
用户{{ create_token_target_user.name || ('#' + create_token_target_user.id) }}
· ID {{ create_token_target_user.id }}
</p>
<Form :label-width="100">
<FormItem label="名称"><Input v-model="create_token_form.token_name" placeholder="default" /></FormItem>
<FormItem label="过期时间"><Input v-model="create_token_form.expire_at" placeholder="YYYY-MM-DD 23:59:59" /></FormItem>
</Form> </Form>
</Modal> </Modal>
<Modal v-model="detailVisible" title="用户详情" width="800" footer-hide> <Modal v-model="detailVisible" title="用户详情" width="800" footer-hide>
<div v-if="detail && detail.user" class="detail-user-bar mb12">
<span class="detail-user-item">ID {{ detail.user.id }}</span>
<span class="detail-user-item">{{ detail.user.name || '—' }}</span>
<span class="detail-user-item">{{ detail.user.mobile || '—' }}</span>
<Tag>{{ detail.user.status }}</Tag>
<Button
type="primary"
size="small"
class="ml12"
:disabled="detail.user.status !== 'active'"
@click="open_create_token_for_user(detail.user)"
>生成 Token</Button>
</div>
<p v-if="detail" class="mb8">Token 数量{{ detail.tokenCount }}</p> <p v-if="detail" class="mb8">Token 数量{{ detail.tokenCount }}</p>
<p v-if="detail && detail.tokens && detail.tokens.length" class="sub-title">API Token</p> <p v-if="detail && detail.tokens && detail.tokens.length" class="sub-title">API Token</p>
<Table v-if="detail && detail.tokens && detail.tokens.length" :columns="tokenCols" :data="detail.tokens" <Table v-if="detail && detail.tokens && detail.tokens.length" :columns="tokenCols" :data="detail.tokens"
@@ -70,7 +111,15 @@
</Modal> </Modal>
<Modal v-model="tokenListVisible" :title="tokenListTitle" width="820" footer-hide> <Modal v-model="tokenListVisible" :title="tokenListTitle" width="820" footer-hide>
<p v-if="tokenListRows.length" class="text-muted mb8">点击表格某一行查看该 Token 详情明文不可查需重新创建</p> <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 <Table v-if="tokenListRows.length" :columns="tokenCols" :data="tokenListRows" size="small" border highlight-row
@on-row-click="onTokenRowClick" /> @on-row-click="onTokenRowClick" />
<p v-else class="text-muted">暂无 Token</p> <p v-else class="text-muted">暂无 Token</p>
@@ -92,6 +141,7 @@
<script> <script>
import userServer from '@/api/subscription/user_server.js' import userServer from '@/api/subscription/user_server.js'
import tokenServer from '@/api/subscription/token_server.js'
import { downloadCsvFromRows } from '@/utils/csvExport.js' import { downloadCsvFromRows } from '@/utils/csvExport.js'
export default { export default {
@@ -118,6 +168,14 @@ export default {
tokenListUserName: '', tokenListUserName: '',
tokenDetailVisible: false, tokenDetailVisible: false,
selectedToken: null, selectedToken: null,
token_plain_modal: false,
token_plain_title: '',
token_plain_text: '',
create_token_modal: false,
create_token_saving: false,
create_token_target_user: null,
create_token_form: { token_name: 'default', expire_at: '' },
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 },
@@ -160,7 +218,7 @@ export default {
{ {
title: '操作', title: '操作',
key: 'a', key: 'a',
width: 240, width: 312,
render: (h, p) => { render: (h, p) => {
return h('div', [ return h('div', [
h( h(
@@ -180,6 +238,20 @@ export default {
}, },
'详情' '详情'
), ),
h(
'Button',
{
props: {
type: 'primary',
size: 'small',
ghost: true,
disabled: p.row.status !== 'active',
},
class: { ml8: true },
on: { click: () => this.open_create_token_for_user(p.row) },
},
'生成Token'
),
h( h(
'Dropdown', 'Dropdown',
{ {
@@ -220,6 +292,24 @@ export default {
{ title: '状态', key: 'status', width: 90 }, { title: '状态', key: 'status', width: 90 },
{ title: '过期', key: 'expire_at', minWidth: 150 }, { title: '过期', key: 'expire_at', minWidth: 150 },
{ title: '最后使用', key: 'last_used_at', minWidth: 150 }, { title: '最后使用', key: 'last_used_at', minWidth: 150 },
{
title: '操作',
key: 'tok_op',
width: 120,
render: (h, p) => {
if (p.row.status !== 'active') {
return h('span', { class: { 'text-muted': true } }, '—')
}
return h(
'Button',
{
props: { type: 'warning', size: 'small' },
on: { click: () => this.do_regenerate_token(p.row) },
},
'重新生成'
)
},
},
] ]
}, },
tokenListTitle() { tokenListTitle() {
@@ -252,8 +342,16 @@ export default {
openEdit(row) { openEdit(row) {
if (row) { if (row) {
this.form = { ...row } this.form = { ...row }
delete this.form.auto_create_token
} else { } else {
this.form = { name: '', mobile: '', email: '', company_name: '', status: 'active' } this.form = {
name: '',
mobile: '',
email: '',
company_name: '',
status: 'active',
auto_create_token: true,
}
} }
this.modal = true this.modal = true
}, },
@@ -269,9 +367,17 @@ export default {
? await userServer.edit(this.form) ? await userServer.edit(this.form)
: await userServer.add(this.form) : await userServer.add(this.form)
if (res && res.code === 0) { if (res && res.code === 0) {
this.$Message.success('保存成功')
this.modal = false this.modal = false
this.load(1) this.load(1)
const data = res.data || {}
if (!this.form.id) {
if (data.token_error) this.$Message.error(data.token_error)
if (data.token_warn) this.$Message.warning(data.token_warn)
if (data.plain_token) {
this.show_token_plain('请保存新建用户的 Token 明文', data.plain_token)
}
}
this.$Message.success('保存成功')
} else { } else {
this.$Message.error((res && res.message) || '保存失败') this.$Message.error((res && res.message) || '保存失败')
} }
@@ -289,11 +395,60 @@ export default {
this.$Message.error((res && res.message) || '加载详情失败') this.$Message.error((res && res.message) || '加载详情失败')
} }
}, },
default_token_expire_input() {
const d = new Date()
d.setFullYear(d.getFullYear() + 1)
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(
2,
'0'
)} 23:59:59`
},
open_create_token_for_user(user_row) {
if (!user_row || user_row.status !== 'active') {
this.$Message.warning('仅状态为「正常」的用户可创建 Token')
return
}
this.create_token_target_user = user_row
this.create_token_form = {
token_name: 'default',
expire_at: this.default_token_expire_input(),
}
this.create_token_modal = true
},
submit_create_token() {
const row = this.create_token_target_user
if (!row) return false
this.create_token_saving = true
this._submit_create_token()
return false
},
async _submit_create_token() {
const row = this.create_token_target_user
try {
const res = await tokenServer.create({
user_id: row.id,
token_name: this.create_token_form.token_name || 'default',
expire_at: this.create_token_form.expire_at,
})
if (res && res.code === 0) {
if (res.data.warn) this.$Message.warning(res.data.warn)
this.create_token_modal = false
this.show_token_plain('请保存新建 Token 明文', res.data.plain_token)
await this.reload_user_token_views(row.id)
this.$Message.success('已创建 Token')
} else {
this.$Message.error((res && res.message) || '创建失败')
}
} finally {
this.create_token_saving = false
}
},
async openTokenList(row) { async openTokenList(row) {
const res = await userServer.detail(row.id) const res = await userServer.detail(row.id)
if (res && res.code === 0) { if (res && res.code === 0) {
this.tokenListRows = res.data.tokens || [] this.tokenListRows = res.data.tokens || []
this.tokenListUserName = row.name || String(row.id) this.tokenListUserName = row.name || String(row.id)
this.tokenList_target_user = res.data.user || row
this.tokenListVisible = true this.tokenListVisible = true
} else { } else {
this.$Message.error((res && res.message) || '加载 Token 失败') this.$Message.error((res && res.message) || '加载 Token 失败')
@@ -303,6 +458,35 @@ export default {
this.selectedToken = row this.selectedToken = row
this.tokenDetailVisible = true this.tokenDetailVisible = true
}, },
show_token_plain(title, text) {
this.token_plain_title = title
this.token_plain_text = text || ''
this.token_plain_modal = true
},
async reload_user_token_views(user_id) {
const res = await userServer.detail(user_id)
if (res && res.code === 0) {
if (this.detailVisible) this.detail = res.data
if (this.tokenListVisible) this.tokenListRows = res.data.tokens || []
this.load(1)
}
},
do_regenerate_token(row) {
this.$Modal.confirm({
title: '重新生成 Token',
content: '旧密钥将立即失效,确定继续?',
onOk: async () => {
const res = await tokenServer.regenerate({ id: row.id })
if (res && res.code === 0) {
if (res.data.warn) this.$Message.warning(res.data.warn)
this.show_token_plain('请保存重新生成后的 Token 明文', res.data.plain_token)
await this.reload_user_token_views(row.user_id)
} else {
this.$Message.error((res && res.message) || '操作失败')
}
},
})
},
doDel(row) { doDel(row) {
this.$Modal.confirm({ this.$Modal.confirm({
title: '删除用户', title: '删除用户',
@@ -371,6 +555,10 @@ export default {
margin-left: 8px; margin-left: 8px;
} }
.ml12 {
margin-left: 12px;
}
.table-page-bar { .table-page-bar {
margin-top: 12px; margin-top: 12px;
text-align: right; text-align: right;
@@ -407,4 +595,17 @@ export default {
width: 88px; width: 88px;
color: #808695; color: #808695;
} }
.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>

View File

@@ -55,6 +55,27 @@ module.exports = {
}); });
ctx.success({ id: row.id, status: row.status }); ctx.success({ id: row.id, status: row.status });
}, },
"POST /biz_token/regenerate": async (ctx) => {
const body = ctx.getBody();
const result = await tokenLogic.regenerateToken(body);
await audit.logAudit({
admin_user_id: audit.pickAdminId(ctx),
biz_user_id: result.row.user_id,
action: "biz_token.regenerate",
resource_type: "biz_api_token",
resource_id: result.row.id,
detail: { token_name: result.row.token_name },
});
ctx.success({
id: result.row.id,
user_id: result.row.user_id,
plan_id: result.row.plan_id,
token_name: result.row.token_name,
expire_at: result.row.expire_at,
plain_token: result.plain_token,
warn: result.warn,
});
},
"POST /biz_token/export": async (ctx) => { "POST /biz_token/export": async (ctx) => {
const body = ctx.getBody(); const body = ctx.getBody();
const param = body.param || body; const param = body.param || body;

View File

@@ -46,7 +46,40 @@ module.exports = {
resource_id: row.id, resource_id: row.id,
detail: { name: row.name }, detail: { name: row.name },
}); });
ctx.success(row);
const out = row.get({ plain: true });
let plain_token = null;
let token_warn = null;
let token_error = null;
const auto_token = body.auto_create_token !== false;
if (auto_token && row.status === "active") {
try {
const result = await tokenLogic.createToken({
user_id: row.id,
token_name: body.initial_token_name || "default",
expire_at: body.initial_token_expire_at || tokenLogic.defaultTokenExpireAt(),
});
await audit.logAudit({
admin_user_id: audit.pickAdminId(ctx),
biz_user_id: row.id,
action: "biz_token.create",
resource_type: "biz_api_token",
resource_id: result.row.id,
detail: { token_name: result.row.token_name, via: "biz_user.add" },
});
plain_token = result.plain_token;
token_warn = result.warn;
} catch (e) {
token_error = e.message || String(e);
}
}
ctx.success({
...out,
plain_token,
token_warn,
token_error,
});
}, },
"POST /biz_user/edit": async (ctx) => { "POST /biz_user/edit": async (ctx) => {
const body = ctx.getBody(); const body = ctx.getBody();

View File

@@ -13,6 +13,13 @@ function generatePlainToken() {
return `waw_${crypto.randomBytes(24).toString("hex")}`; return `waw_${crypto.randomBytes(24).toString("hex")}`;
} }
/** 默认 Token 过期时间:一年后当日 23:59:59 */
function defaultTokenExpireAt() {
const d = new Date();
d.setFullYear(d.getFullYear() + 1);
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")} 23:59:59`;
}
/** 当前时间在 [start,end] 内且 status=active 的订阅 */ /** 当前时间在 [start,end] 内且 status=active 的订阅 */
async function findActiveSubscriptionForUser(userId) { async function findActiveSubscriptionForUser(userId) {
const now = new Date(); const now = new Date();
@@ -72,6 +79,39 @@ async function revokeToken(body) {
return row; return row;
} }
/**
* 保留同一条 Token 记录,仅更换密钥(旧明文立即失效)
*/
async function regenerateToken(body) {
const id = body.id;
if (id == null) throw new Error("缺少 id");
const row = await baseModel.biz_api_token.findByPk(id);
if (!row) throw new Error("Token 不存在");
if (row.status !== "active") throw new Error("仅可对状态为 active 的 Token 重新生成密钥");
const u = await baseModel.biz_user.findByPk(row.user_id);
if (!u) throw new Error("用户不存在");
if (u.status !== "active") throw new Error("用户已禁用,无法轮换密钥");
const sub = await findActiveSubscriptionForUser(row.user_id);
const plan_id = sub ? sub.plan_id : null;
const plain = generatePlainToken();
const token_hash = hashPlainToken(plain);
await row.update({
token_hash,
plan_id,
});
await row.reload();
return {
row,
plain_token: plain,
warn: sub ? null : "当前无生效中的订阅,鉴权将失败",
};
}
async function revokeAllForUser(userId) { async function revokeAllForUser(userId) {
if (userId == null) throw new Error("缺少 user_id"); if (userId == null) throw new Error("缺少 user_id");
const [n] = await baseModel.biz_api_token.update( const [n] = await baseModel.biz_api_token.update(
@@ -84,8 +124,10 @@ async function revokeAllForUser(userId) {
module.exports = { module.exports = {
hashPlainToken, hashPlainToken,
createToken, createToken,
regenerateToken,
revokeToken, revokeToken,
revokeAllForUser, revokeAllForUser,
findActiveSubscriptionForUser, findActiveSubscriptionForUser,
defaultTokenExpireAt,
MAX_TOKENS_PER_USER, MAX_TOKENS_PER_USER,
}; };