From fe8952733a0a8997ebcb049c4d3e0320b82ec216 Mon Sep 17 00:00:00 2001 From: DaydreamCoding Date: Fri, 5 Jun 2026 10:19:41 +0800 Subject: [PATCH] =?UTF-8?q?fix(usage):=20=E7=AE=A1=E7=90=86=E7=AB=AF?= =?UTF-8?q?=E9=94=99=E8=AF=AF=E8=AF=B7=E6=B1=82=E9=A1=B5=E8=BF=87=E6=BB=A4?= =?UTF-8?q?=E4=B8=8E=E5=88=86=E5=88=97=E5=AE=8C=E5=96=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 错误请求标签补传 model/account_id/group_id 过滤(此前 loadAdminErrors 丢弃),admin handler 读取 model 查询参数走精确匹配 - 错误表格拆成 用户/API Key/账号 三独立列(上游行也显示用户),补 api_key_name/api_key_deleted, 已删除 key 显示红色「已删除」标记;i18n keyDeletedBadge 补入 errorLog 命名空间 Co-Authored-By: Claude Opus 4.8 (1M context) --- backend/internal/handler/admin/ops_handler.go | 6 ++ frontend/src/api/admin/ops.ts | 5 + frontend/src/i18n/locales/en.ts | 2 + frontend/src/i18n/locales/zh.ts | 2 + frontend/src/views/admin/UsageView.vue | 3 + .../views/admin/__tests__/UsageView.spec.ts | 65 ++++++++++++- .../admin/ops/components/OpsErrorLogTable.vue | 58 ++++++++---- .../__tests__/OpsErrorLogTable.spec.ts | 93 +++++++++++++++++++ 8 files changed, 215 insertions(+), 19 deletions(-) create mode 100644 frontend/src/views/admin/ops/components/__tests__/OpsErrorLogTable.spec.ts diff --git a/backend/internal/handler/admin/ops_handler.go b/backend/internal/handler/admin/ops_handler.go index 0ae93e65..b9558b97 100644 --- a/backend/internal/handler/admin/ops_handler.go +++ b/backend/internal/handler/admin/ops_handler.go @@ -110,6 +110,9 @@ func (h *OpsHandler) GetErrorLogs(c *gin.Context) { filter.Source = strings.TrimSpace(c.Query("error_source")) filter.Query = strings.TrimSpace(c.Query("q")) filter.UserQuery = strings.TrimSpace(c.Query("user_query")) + // Model 过滤:admin 走精确匹配(ModelFuzzy 默认 false,保持管理端语义)。 + // buildOpsErrorLogsWhere 以 COALESCE(requested_model, model) 比对。 + filter.Model = strings.TrimSpace(c.Query("model")) // Force request errors: client-visible status >= 400. // buildOpsErrorLogsWhere already applies this for non-upstream phase. @@ -227,6 +230,9 @@ func (h *OpsHandler) ListRequestErrors(c *gin.Context) { filter.Source = strings.TrimSpace(c.Query("error_source")) filter.Query = strings.TrimSpace(c.Query("q")) filter.UserQuery = strings.TrimSpace(c.Query("user_query")) + // Model 过滤:admin 走精确匹配(ModelFuzzy 默认 false,保持管理端语义)。 + // buildOpsErrorLogsWhere 以 COALESCE(requested_model, model) 比对。 + filter.Model = strings.TrimSpace(c.Query("model")) // Force request errors: client-visible status >= 400. // buildOpsErrorLogsWhere already applies this for non-upstream phase. diff --git a/frontend/src/api/admin/ops.ts b/frontend/src/api/admin/ops.ts index defdcf2e..a3a47c2c 100644 --- a/frontend/src/api/admin/ops.ts +++ b/frontend/src/api/admin/ops.ts @@ -907,6 +907,9 @@ export interface OpsErrorLog { user_id?: number | null user_email: string api_key_id?: number | null + // 关联 api_key 名称(后端 LEFT JOIN api_keys;软删保留 name,故已删 key 仍有原名)。 + api_key_name?: string + api_key_deleted?: boolean account_id?: number | null account_name: string group_id?: number | null @@ -1086,6 +1089,8 @@ export type OpsErrorListQueryParams = { account_id?: number | null user_id?: number api_key_id?: number + // 模型过滤:后端以 COALESCE(requested_model, model) 精确匹配(admin 路径)。 + model?: string phase?: string error_owner?: string diff --git a/frontend/src/i18n/locales/en.ts b/frontend/src/i18n/locales/en.ts index 504b5dec..940a21ac 100644 --- a/frontend/src/i18n/locales/en.ts +++ b/frontend/src/i18n/locales/en.ts @@ -4776,6 +4776,8 @@ export default { group: 'Group', user: 'User', userId: 'User ID', + apiKey: 'API Key', + keyDeletedBadge: 'Key Deleted', account: 'Account', accountId: 'Account ID', status: 'Status', diff --git a/frontend/src/i18n/locales/zh.ts b/frontend/src/i18n/locales/zh.ts index 423006d0..5ec4d688 100644 --- a/frontend/src/i18n/locales/zh.ts +++ b/frontend/src/i18n/locales/zh.ts @@ -4935,6 +4935,8 @@ export default { group: '分组', user: '用户', userId: '用户 ID', + apiKey: 'API Key', + keyDeletedBadge: 'Key 已删除', account: '账号', accountId: '账号 ID', status: '状态码', diff --git a/frontend/src/views/admin/UsageView.vue b/frontend/src/views/admin/UsageView.vue index 8bac1c50..4fb2caac 100644 --- a/frontend/src/views/admin/UsageView.vue +++ b/frontend/src/views/admin/UsageView.vue @@ -647,6 +647,9 @@ const loadAdminErrors = async () => { end_time: toRFC3339(filters.value.end_date, true), user_id: filters.value.user_id ?? undefined, api_key_id: filters.value.api_key_id ?? undefined, + account_id: filters.value.account_id ?? undefined, + group_id: filters.value.group_id ?? undefined, + model: filters.value.model || undefined, }) errRows.value = resp.items errTotal.value = resp.total diff --git a/frontend/src/views/admin/__tests__/UsageView.spec.ts b/frontend/src/views/admin/__tests__/UsageView.spec.ts index 8c644a75..1da0d09c 100644 --- a/frontend/src/views/admin/__tests__/UsageView.spec.ts +++ b/frontend/src/views/admin/__tests__/UsageView.spec.ts @@ -3,7 +3,7 @@ import { flushPromises, mount } from '@vue/test-utils' import UsageView from '../UsageView.vue' -const { list, getStats, getSnapshotV2, getById, getModelStats } = vi.hoisted(() => { +const { list, getStats, getSnapshotV2, getById, getModelStats, listErrorLogs } = vi.hoisted(() => { vi.stubGlobal('localStorage', { getItem: vi.fn(() => null), setItem: vi.fn(), @@ -16,6 +16,7 @@ const { list, getStats, getSnapshotV2, getById, getModelStats } = vi.hoisted(() getSnapshotV2: vi.fn(), getById: vi.fn(), getModelStats: vi.fn(), + listErrorLogs: vi.fn(), } }) @@ -55,6 +56,10 @@ vi.mock('@/api/admin/usage', () => ({ }, })) +vi.mock('@/api/admin/ops', () => ({ + listErrorLogs, +})) + vi.mock('@/stores/app', () => ({ useAppStore: () => ({ showError: vi.fn(), @@ -289,3 +294,61 @@ describe('admin UsageView handleUserClick', () => { expect(getById).toHaveBeenCalledWith(2, true) }) }) + +describe('admin UsageView errors tab filter forwarding', () => { + beforeEach(() => { + vi.useFakeTimers() + list.mockReset() + getStats.mockReset() + getSnapshotV2.mockReset() + getModelStats.mockReset() + listErrorLogs.mockReset() + + list.mockResolvedValue({ items: [], total: 0, pages: 0 }) + getStats.mockResolvedValue({ + total_requests: 0, total_input_tokens: 0, total_output_tokens: 0, + total_cache_tokens: 0, total_tokens: 0, total_cost: 0, total_actual_cost: 0, average_duration_ms: 0, + }) + getSnapshotV2.mockResolvedValue({ trend: [], models: [], groups: [] }) + getModelStats.mockResolvedValue({ models: [] }) + listErrorLogs.mockResolvedValue({ items: [], total: 0, pages: 0 }) + }) + + afterEach(() => { + vi.useRealTimers() + }) + + it('forwards model/account_id/group_id to listErrorLogs on the errors tab', async () => { + const wrapper = mount(UsageView, { + global: { stubs: { + AppLayout: AppLayoutStub, UsageStatsCards: true, UsageFilters: UsageFiltersStub, + UsageTable: true, UsageExportProgress: true, UsageCleanupDialog: true, + UserBalanceHistoryModal: true, AuditLogModal: true, Pagination: true, Select: true, + DateRangePicker: true, Icon: true, TokenUsageTrend: true, + ModelDistributionChart: true, GroupDistributionChart: true, EndpointDistributionChart: true, + OpsErrorLogTable: true, OpsErrorDetailModal: true, + } }, + }) + vi.advanceTimersByTime(120) + await flushPromises() + + // 模拟用户在过滤器里选择了模型/账户/分组 + const vm = wrapper.vm as any + vm.filters.model = 'gpt-5.3-codex' + vm.filters.account_id = 7 + vm.filters.group_id = 3 + await flushPromises() + + // 切换到「错误请求」标签(第二个 .tab 按钮)触发 loadAdminErrors + const tabs = wrapper.findAll('button.tab') + await tabs[1].trigger('click') + await flushPromises() + + expect(listErrorLogs).toHaveBeenCalledWith(expect.objectContaining({ + view: 'all', + model: 'gpt-5.3-codex', + account_id: 7, + group_id: 3, + })) + }) +}) diff --git a/frontend/src/views/admin/ops/components/OpsErrorLogTable.vue b/frontend/src/views/admin/ops/components/OpsErrorLogTable.vue index d779f7b1..becf6327 100644 --- a/frontend/src/views/admin/ops/components/OpsErrorLogTable.vue +++ b/frontend/src/views/admin/ops/components/OpsErrorLogTable.vue @@ -32,6 +32,12 @@ {{ t('admin.ops.errorLog.user') }} + + {{ t('admin.ops.errorLog.apiKey') }} + + + {{ t('admin.ops.errorLog.account') }} + {{ t('admin.ops.errorLog.status') }} @@ -45,7 +51,7 @@ - + {{ t('admin.ops.errorLog.noErrors') }} @@ -127,24 +133,40 @@ - - + - - + + + {{ log.user_email || '-' }} + + + - + + + + +
+ + {{ log.api_key_name || ('#' + log.api_key_id) }} + + + {{ t('admin.ops.errorLog.keyDeletedBadge') }} + +
+ - + + + + + + + {{ log.account_name || '-' }} + + + - diff --git a/frontend/src/views/admin/ops/components/__tests__/OpsErrorLogTable.spec.ts b/frontend/src/views/admin/ops/components/__tests__/OpsErrorLogTable.spec.ts new file mode 100644 index 00000000..3f23028f --- /dev/null +++ b/frontend/src/views/admin/ops/components/__tests__/OpsErrorLogTable.spec.ts @@ -0,0 +1,93 @@ +import { describe, it, expect, vi } from 'vitest' +import { mount } from '@vue/test-utils' +import OpsErrorLogTable from '../OpsErrorLogTable.vue' +import zhLocale from '@/i18n/locales/zh' +import enLocale from '@/i18n/locales/en' +import type { OpsErrorLog } from '@/api/admin/ops' + +vi.mock('vue-i18n', async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + useI18n: () => ({ t: (key: string) => key }), + } +}) + +const TooltipStub = { template: '
' } +const PaginationStub = { template: '
' } + +function mountTable(row: Partial) { + const base = { + id: 1, + created_at: '2026-06-05T23:59:50Z', + phase: 'upstream', + type: '', + error_owner: 'provider', + error_source: 'upstream_http', + severity: 'error', + status_code: 529, + platform: 'anthropic', + model: 'claude-opus-4-8', + resolved: false, + client_request_id: '', + request_id: 'req-1', + message: 'boom', + user_email: '', + account_name: '', + group_name: '', + ...row, + } as OpsErrorLog + + return mount(OpsErrorLogTable, { + props: { rows: [base], total: 1, loading: false, page: 1, pageSize: 20 }, + global: { stubs: { 'el-tooltip': TooltipStub, Pagination: PaginationStub } }, + }) +} + +describe('OpsErrorLogTable user/api-key/account columns', () => { + // 回归:上游错误行(phase=upstream, owner=provider)以前在单一「用户」列里只显示账号、 + // 丢失用户;现在用户/API Key/账号各占独立列,三者同时可见。 + it('renders user, api key and account in separate columns for an upstream row', () => { + const wrapper = mountTable({ + user_id: 2, + user_email: 'alice@test.com', + api_key_id: 5, + api_key_name: 'my-key', + account_id: 9, + account_name: 'acct-A', + }) + + const text = wrapper.text() + expect(text).toContain('alice@test.com') // 用户列(上游行也显示用户) + expect(text).toContain('my-key') // API Key 列 + expect(text).toContain('acct-A') // 账号列 + }) + + it('shows the deleted badge for a soft-deleted api key', () => { + const wrapper = mountTable({ + api_key_id: 5, + api_key_name: 'old-key', + api_key_deleted: true, + }) + + expect(wrapper.text()).toContain('old-key') + expect(wrapper.text()).toContain('admin.ops.errorLog.keyDeletedBadge') + }) +}) + +// 防回归:组件用 admin.ops.errorLog.* 命名空间。若 i18n 键写错命名空间(如误放到 +// errorDetail),真实 vue-i18n 会回退返回 key 本身 → 界面显示原始路径字符串。 +// 这里用真实 locale 校验键确实可解析(返回译文而非 key)。 +// 防回归:组件用 admin.ops.errorLog.* 命名空间。若键写错命名空间(如误放到 +// errorDetail),界面会显示原始路径字符串而非译文。vitest 的 vue-i18n 为 runtime-only +// (无消息编译器,t() 对任何键都回退返回 key),故直接校验 locale 对象的命名空间含这些键。 +describe('OpsErrorLogTable i18n keys exist in the errorLog namespace', () => { + const locales: Record = { zh: zhLocale, en: enLocale } + for (const [name, msgs] of Object.entries(locales)) { + it(`has apiKey & keyDeletedBadge for ${name}`, () => { + const errorLog = msgs?.admin?.ops?.errorLog + expect(errorLog?.apiKey).toBeTruthy() + expect(errorLog?.keyDeletedBadge).toBeTruthy() + }) + } +})