diff --git a/admin-ui/src/api/chorus/index.js b/admin-ui/src/api/chorus/index.js index 0acfb49..b76ba50 100644 --- a/admin-ui/src/api/chorus/index.js +++ b/admin-ui/src/api/chorus/index.js @@ -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' }) diff --git a/admin-ui/src/views/chorus/api-keys/helpers.js b/admin-ui/src/views/chorus/api-keys/helpers.js new file mode 100644 index 0000000..cc0339f --- /dev/null +++ b/admin-ui/src/views/chorus/api-keys/helpers.js @@ -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 +} diff --git a/admin-ui/src/views/chorus/api-keys/index.vue b/admin-ui/src/views/chorus/api-keys/index.vue new file mode 100644 index 0000000..fdc1d53 --- /dev/null +++ b/admin-ui/src/views/chorus/api-keys/index.vue @@ -0,0 +1,183 @@ + + + + + diff --git a/admin-ui/tests/unit/chorus/api-keys.spec.js b/admin-ui/tests/unit/chorus/api-keys.spec.js new file mode 100644 index 0000000..8cd482a --- /dev/null +++ b/admin-ui/tests/unit/chorus/api-keys.spec.js @@ -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('管理端不会显示完整密钥') + }) +}) diff --git a/admin-ui/tests/unit/chorus/navigation-copy.spec.js b/admin-ui/tests/unit/chorus/navigation-copy.spec.js index 3fa74bc..ec29eab 100644 --- a/admin-ui/tests/unit/chorus/navigation-copy.spec.js +++ b/admin-ui/tests/unit/chorus/navigation-copy.spec.js @@ -10,7 +10,8 @@ describe('Chorus navigation terminology', () => { test.each([ ['providers', '

上游服务商

', '

Provider 配置

'], ['templates', '

提示词模板

', '

Prompt 模板

'], - ['health', '

上游健康

', '

Provider 健康

'] + ['health', '

上游健康

', '

Provider 健康

'], + ['api-keys', '

API 密钥

', '

API Key 管理

'] ])('%s uses the localized page heading', (view, localized, legacy) => { const source = readView(view) expect(source).toContain(localized) diff --git a/docs/04-local-development-and-verification.md b/docs/04-local-development-and-verification.md index 185b625..1eca26f 100644 --- a/docs/04-local-development-and-verification.md +++ b/docs/04-local-development-and-verification.md @@ -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 # 本地开发与验证 @@ -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 四种视口;主页面不得横向溢出,表格在窄屏内自行滚动。真实撤销只对专门创建的合成测试密钥执行,不使用生产用户或真实凭据。