feat: 交付管理端 API 密钥治理页面 (#46)

This commit is contained in:
ila
2026-08-25 10:34:56 +08:00
parent b1f8bcb0cf
commit d59c3400d9
6 changed files with 280 additions and 3 deletions
+4
View File
@@ -25,3 +25,7 @@ export const updateRoutePool = (id, data) => request({ url: `${base}/route-pools
export const listPortalUsers = () => request({ url: `${base}/users`, method: 'get' })
export const updatePortalUserStatus = (id, status) => request({ url: `${base}/users/${id}`, method: 'put', data: { status }})
export const listGenerations = () => request({ url: `${base}/generations`, method: 'get' })
export const listAPIKeys = params => request({ url: `${base}/api-keys`, method: 'get', params })
export const getAPIKey = id => request({ url: `${base}/api-keys/${id}`, method: 'get' })
export const revokeAPIKey = id => request({ url: `${base}/api-keys/${id}/revoke`, method: 'post' })
@@ -0,0 +1,39 @@
const statusLabels = { active: '有效', expired: '已过期', revoked: '已撤销' }
const statusTypes = { active: 'success', expired: 'warning', revoked: 'info' }
const actionLabels = {
'api_key.created': '用户创建密钥',
'api_key.renamed': '用户修改名称',
'api_key.revoked': '用户撤销密钥',
'api_key.admin_revoked': '管理员撤销密钥',
'openapi.generation.submit': '程序提交生成任务'
}
const resultLabels = { succeeded: '成功', failed: '失败', denied: '已拒绝' }
const resultTypes = { succeeded: 'success', failed: 'danger', denied: 'warning' }
export function apiKeyPageOf(response) {
const page = response && response.data
if (!page || !Array.isArray(page.items)) return { items: [], total: 0, page: 1, pageSize: 20 }
return {
items: page.items,
total: Number(page.total) || 0,
page: Number(page.page) || 1,
pageSize: Number(page.page_size) || 20
}
}
export function statusText(value) { return statusLabels[value] || value || '-' }
export function statusType(value) { return statusTypes[value] || 'info' }
export function actionText(value) { return actionLabels[value] || value || '-' }
export function resultText(value) { return resultLabels[value] || value || '-' }
export function resultType(value) { return resultTypes[value] || 'info' }
export function safeAuditSummary(summary) {
if (!summary || typeof summary !== 'object' || Array.isArray(summary)) return []
const rows = []
if (summary.source) rows.push(`来源:${summary.source}`)
if (summary.kind) rows.push(`类型:${summary.kind}`)
if (typeof summary.created === 'boolean') rows.push(summary.created ? '新建任务' : '幂等复用')
if (typeof summary.already_revoked === 'boolean') rows.push(summary.already_revoked ? '此前已撤销' : '本次撤销')
if (summary.operator_id) rows.push(`管理员 #${summary.operator_id}`)
return rows
}
@@ -0,0 +1,183 @@
<template>
<BasicLayout>
<template #wrapper>
<main class="chorus-page api-key-page">
<header class="page-heading">
<div>
<h1>API 密钥</h1>
<p>按用户、名称、前缀和状态检索密钥元数据;管理端不会显示完整密钥。</p>
</div>
<el-button :loading="loading" @click="load"><i class="ri-refresh-line" /> 刷新</el-button>
</header>
<div class="toolbar" aria-label="API 密钥筛选">
<el-input
v-model="keyword"
clearable
placeholder="用户编号 / 账号 / 密钥名称 / 前缀"
style="width: 320px"
aria-label="筛选 API 密钥"
@input="queueFilter"
@clear="applyFilters"
@keyup.enter="applyFilters"
/>
<el-select v-model="status" clearable placeholder="全部状态" style="width: 140px" aria-label="筛选密钥状态" @change="applyFilters">
<el-option label="有效" value="active" />
<el-option label="已过期" value="expired" />
<el-option label="已撤销" value="revoked" />
</el-select>
<el-button @click="resetFilters"><i class="ri-restart-line" /> 重置</el-button>
<span class="spacer" />
<span class="muted" aria-live="polite">共 {{ total }} 条</span>
</div>
<section class="table-panel" aria-label="API 密钥列表">
<el-result v-if="loadState === 'forbidden'" icon="warning" title="没有访问权限" sub-title="当前管理员角色没有 API 密钥查看权限。" />
<el-result v-else-if="loadState === 'error'" icon="error" title="API 密钥加载失败" sub-title="已有数据不会被修改,请检查服务状态后重试。">
<template #extra><el-button type="primary" @click="load">重试</el-button></template>
</el-result>
<el-empty v-else-if="!loading && rows.length === 0" :description="keyword || status ? '没有匹配的 API 密钥' : '暂无 API 密钥'" />
<el-table v-else v-loading="loading" :data="rows" border empty-text="暂无 API 密钥">
<el-table-column label="用户" min-width="170">
<template #default="{ row }">
<strong>{{ row.display_name || row.username || `用户 #${row.user_id}` }}</strong>
<span class="cell-secondary mono">{{ row.username || '-' }} · #{{ row.user_id }}</span>
</template>
</el-table-column>
<el-table-column label="名称 / 前缀" min-width="190">
<template #default="{ row }"><strong>{{ row.name }}</strong><span class="cell-secondary mono">{{ row.key_prefix }}</span></template>
</el-table-column>
<el-table-column label="状态" width="100"><template #default="{ row }"><el-tag :type="statusType(row.status)">{{ statusText(row.status) }}</el-tag></template></el-table-column>
<el-table-column label="最近使用" width="175"><template #default="{ row }">{{ formatTime(row.last_used_at) }}</template></el-table-column>
<el-table-column label="到期时间" width="175"><template #default="{ row }">{{ formatTime(row.expires_at) }}</template></el-table-column>
<el-table-column label="创建时间" width="175"><template #default="{ row }">{{ formatTime(row.created_at) }}</template></el-table-column>
<el-table-column label="操作" fixed="right" width="142">
<template #default="{ row }">
<el-button link type="primary" @click="openDetail(row)">详情</el-button>
<el-button v-if="row.status === 'active'" link type="danger" :loading="revoking === row.id" @click="confirmRevoke(row)">撤销</el-button>
</template>
</el-table-column>
</el-table>
<div v-if="loadState === 'ready' && total > pageSize" class="pagination-row">
<el-pagination
v-model:current-page="page"
v-model:page-size="pageSize"
background
layout="prev, pager, next"
:total="total"
@current-change="load"
/>
</div>
</section>
<el-dialog v-model="detailOpen" title="API 密钥详情" width="min(760px, 94vw)" :close-on-click-modal="false" @closed="clearDetail">
<div v-loading="detailLoading" class="detail-content">
<el-result v-if="detailError" icon="error" title="详情加载失败" sub-title="请关闭窗口后重试。" />
<template v-else-if="detail">
<div class="detail-heading"><div><strong>{{ detail.name }}</strong><span class="mono">{{ detail.key_prefix }}</span></div><el-tag :type="statusType(detail.status)">{{ statusText(detail.status) }}</el-tag></div>
<dl class="detail-list">
<dt>所属用户</dt><dd>{{ detail.display_name || detail.username || '-' }}({{ detail.username || '-' }} · #{{ detail.user_id }})</dd>
<dt>最近使用</dt><dd>{{ formatTime(detail.last_used_at) }}</dd>
<dt>到期时间</dt><dd>{{ formatTime(detail.expires_at) }}</dd>
<dt>撤销时间</dt><dd>{{ formatTime(detail.revoked_at) }}</dd>
<dt>创建时间</dt><dd>{{ formatTime(detail.created_at) }}</dd>
</dl>
<h2 class="audit-title">最近安全事件</h2>
<div class="audit-table-wrap">
<el-table :data="detail.recent_events || []" border empty-text="暂无安全事件">
<el-table-column label="事件" min-width="155"><template #default="{ row }">{{ actionText(row.action) }}</template></el-table-column>
<el-table-column label="结果" width="90"><template #default="{ row }"><el-tag :type="resultType(row.result)">{{ resultText(row.result) }}</el-tag></template></el-table-column>
<el-table-column label="摘要" min-width="180"><template #default="{ row }"><span>{{ safeAuditSummary(row.summary).join(' · ') || '-' }}</span><span v-if="row.error_code" class="cell-secondary danger-text mono">{{ row.error_code }}</span></template></el-table-column>
<el-table-column label="请求编号" min-width="150"><template #default="{ row }"><span class="mono request-id">{{ row.request_id }}</span></template></el-table-column>
<el-table-column label="时间" width="175"><template #default="{ row }">{{ formatTime(row.created_at) }}</template></el-table-column>
</el-table>
</div>
</template>
</div>
<template #footer><el-button @click="detailOpen=false">关闭</el-button></template>
</el-dialog>
</main>
</template>
</BasicLayout>
</template>
<script>
import { getAPIKey, listAPIKeys, revokeAPIKey } from '@/api/chorus'
import { formatTime } from '../shared'
import { actionText, apiKeyPageOf, resultText, resultType, safeAuditSummary, statusText, statusType } from './helpers'
export default {
name: 'ChorusAPIKeys',
data() {
return {
rows: [], total: 0, page: 1, pageSize: 20, keyword: '', status: '', loading: false,
loadState: 'ready', loadSerial: 0, filterTimer: null, detailOpen: false, detailLoading: false,
detailError: false, detail: null, detailSerial: 0, revoking: null
}
},
created() { this.load() },
beforeUnmount() { if (this.filterTimer) clearTimeout(this.filterTimer) },
methods: {
formatTime, statusText, statusType, actionText, resultText, resultType, safeAuditSummary,
async load() {
const serial = ++this.loadSerial
this.loading = true; this.loadState = 'ready'
try {
const response = await listAPIKeys({ keyword: this.keyword.trim(), status: this.status, page: this.page, page_size: this.pageSize })
if (serial !== this.loadSerial) return
const result = apiKeyPageOf(response)
this.rows = result.items; this.total = result.total; this.page = result.page; this.pageSize = result.pageSize
} catch (error) {
if (serial !== this.loadSerial) return
this.rows = []; this.total = 0
this.loadState = this.isForbidden(error) ? 'forbidden' : 'error'
} finally { if (serial === this.loadSerial) this.loading = false }
},
isForbidden(error) { return [401, 403].includes(Number(error && error.code)) || /unauthorized|forbidden|权限/i.test(String(error && error.message)) },
queueFilter() { if (this.filterTimer) clearTimeout(this.filterTimer); this.filterTimer = setTimeout(this.applyFilters, 300) },
applyFilters() { if (this.filterTimer) clearTimeout(this.filterTimer); this.filterTimer = null; this.page = 1; this.load() },
resetFilters() { this.keyword = ''; this.status = ''; this.applyFilters() },
async openDetail(row) {
const serial = ++this.detailSerial
this.detailOpen = true; this.detailLoading = true; this.detailError = false; this.detail = null
try {
const response = await getAPIKey(row.id)
if (serial === this.detailSerial) this.detail = response.data || null
} catch (_) {
if (serial === this.detailSerial) this.detailError = true
} finally { if (serial === this.detailSerial) this.detailLoading = false }
},
clearDetail() { this.detailSerial++; this.detail = null; this.detailError = false; this.detailLoading = false },
async confirmRevoke(row) {
try {
await this.$confirm(`确认撤销“${row.name}”(${row.key_prefix})?撤销后立即失效且无法恢复。`, '撤销 API 密钥', { confirmButtonText: '确认撤销', cancelButtonText: '取消', type: 'warning' })
} catch (_) { return }
this.revoking = row.id
try {
await revokeAPIKey(row.id)
this.$message.success('API 密钥已撤销')
await this.load()
if (this.detailOpen) await this.openDetail(row)
} finally { this.revoking = null }
}
}
}
</script>
<style lang="scss" scoped>
@use '../shared.scss';
.cell-secondary { display: block; margin-top: 3px; color: var(--el-text-color-secondary); font-size: 12px; line-height: 1.4; overflow-wrap: anywhere; }
.pagination-row { display: flex; justify-content: flex-end; padding: 12px; border-top: 1px solid var(--el-border-color-lighter); }
.detail-content { min-height: 180px; }
.detail-heading { display: flex; align-items: flex-start; justify-content: space-between; gap: 12px; padding: 12px; background: var(--el-fill-color-light); border: 1px solid var(--el-border-color-lighter); }
.detail-heading strong, .detail-heading span { display: block; }
.detail-heading .mono { margin-top: 4px; color: var(--el-text-color-secondary); }
.audit-title { margin: 20px 0 10px; font-size: 16px; }
.audit-table-wrap { max-width: 100%; overflow: hidden; }
.request-id { overflow-wrap: anywhere; }
@media (max-width: 760px) {
.pagination-row { justify-content: center; }
.detail-heading { align-items: center; }
.audit-table-wrap { overflow-x: auto; }
}
</style>
@@ -0,0 +1,31 @@
import fs from 'fs'
import path from 'path'
import { actionText, apiKeyPageOf, safeAuditSummary, statusText } from '@/views/chorus/api-keys/helpers'
describe('Chorus API key governance page', () => {
test('unwraps the paginated metadata contract', () => {
expect(apiKeyPageOf({ data: { items: [{ id: 7 }], total: 21, page: 2, page_size: 20 }})).toEqual({
items: [{ id: 7 }], total: 21, page: 2, pageSize: 20
})
expect(apiKeyPageOf({ data: [] })).toEqual({ items: [], total: 0, page: 1, pageSize: 20 })
})
test('renders only reviewed audit summary fields', () => {
const summary = safeAuditSummary({ source: 'admin', kind: 'image', already_revoked: false, operator_id: 3, prompt: 'do not show', token: 'secret' })
expect(summary).toEqual(['来源:admin', '类型:image', '本次撤销', '管理员 #3'])
expect(summary.join(' ')).not.toContain('do not show')
expect(summary.join(' ')).not.toContain('secret')
})
test('localizes key and audit states', () => {
expect(statusText('active')).toBe('有效')
expect(actionText('api_key.admin_revoked')).toBe('管理员撤销密钥')
})
test('does not render credential storage fields or a creation action', () => {
const source = fs.readFileSync(path.resolve(__dirname, '../../../src/views/chorus/api-keys/index.vue'), 'utf8')
expect(source).not.toMatch(/public_id|secret_hash/)
expect(source).not.toContain('创建 API 密钥')
expect(source).toContain('管理端不会显示完整密钥')
})
})
@@ -10,7 +10,8 @@ describe('Chorus navigation terminology', () => {
test.each([
['providers', '<h1>上游服务商</h1>', '<h1>Provider 配置</h1>'],
['templates', '<h1>提示词模板</h1>', '<h1>Prompt 模板</h1>'],
['health', '<h1>上游健康</h1>', '<h1>Provider 健康</h1>']
['health', '<h1>上游健康</h1>', '<h1>Provider 健康</h1>'],
['api-keys', '<h1>API 密钥</h1>', '<h1>API Key 管理</h1>']
])('%s uses the localized page heading', (view, localized, legacy) => {
const source = readView(view)
expect(source).toContain(localized)
+21 -2
View File
@@ -2,8 +2,8 @@
generated: true (请先修改 Gitea Wiki,禁止直接编辑本文件)
wiki_page: Local-Development-and-Verification
wiki_url: https://git.ilapage.cn/OPC/chorus/wiki/Local-Development-and-Verification.-
wiki_revision: 01d2c4aaf24e17d8b9f6cc90d59c1f7244941110
synchronized_at: 2026-08-25T01:53:09Z
wiki_revision: 31061a29cf4b73dee02b01d2bccd4a333c1abe2a
synchronized_at: 2026-08-25T02:30:21Z
<!-- gitea-wiki-mirror:end -->
# 本地开发与验证
@@ -530,3 +530,22 @@ go -C admin test -count=1 -run '^(TestAdminAPIKeyGovernanceMySQL|TestChorusAPIMy
```
验证覆盖:生命周期事务审计、未知 Key 不写行、成功/拒绝/429 提交审计、summary 脱敏、`last_used_at` 节流、管理检索/详情、并发安全的幂等撤销、双审计和 JWT/Casbin 负向路径。管理 API 的真实 go-admin 鉴权失败沿用框架约定,可能返回 HTTP 200 且 JSON `code=401`;判断时必须同时检查响应 JSON,不能只看 HTTP 状态。
## #46 管理端 API 密钥治理页面
管理员登录后,从“Chorus 运营 → API 密钥”进入治理页面。列表仅展示终端用户、密钥名称、固定前缀、状态和时间元数据,支持按用户编号/账号/密钥名称/前缀与状态筛选,并使用服务端分页。页面和浏览器缓存中都不会取得完整密钥、`public_id` 或 `secret_hash`,也不提供管理员创建用户密钥的入口。
“详情”读取单条元数据和最近 20 条脱敏安全事件;摘要只显示已审核的来源、类型、幂等结果和管理员编号。“撤销”必须经过二次确认,成功后立即刷新列表;重复请求保持幂等,但已撤销密钥不能恢复。没有权限、加载失败、空结果和加载中均有独立页面状态。
前端回归命令:
```powershell
corepack pnpm --dir admin-ui lint
corepack pnpm --dir admin-ui exec vue-cli-service test:unit --runInBand
$env:NODE_OPTIONS = "--max-old-space-size=4096"
corepack pnpm --dir admin-ui build:prod
$env:NODE_OPTIONS = $null
```
浏览器验收需登录实际管理端,覆盖列表、筛选、详情、撤销确认的取消路径以及 375/768/1024/1440 四种视口;主页面不得横向溢出,表格在窄屏内自行滚动。真实撤销只对专门创建的合成测试密钥执行,不使用生产用户或真实凭据。