feat(usage): 在 /admin/usage 支持查看已删除用户的历史使用情况

用户软删除后使用记录仍在,但身份(邮箱)被 ent 软删除拦截器隐藏。本次在
三条管理员只读路径定点穿透软删除过滤,并把删除状态传播到前端标记,零新表/
迁移/回填:

- 后端穿透:富化 usage 日志(loadUsers)、用户搜索(ListWithFilters +
  UserListFilters.IncludeDeleted)、点击详情(GetByIDIncludeDeleted /
  GetUserIncludeDeleted + getById ?include_deleted 分支)
- 状态传播:service.User / dto.User 新增 DeletedAt;SearchUsers 标记 deleted
- 前端:表格与余额弹窗展示"已删除"徽标、筛选下拉标注并排序、点击走
  include_deleted;新增 i18n admin.usage.userDeletedBadge
- 安全:普通用户 usage 仅查本人(无 PII 泄漏);主用户列表与默认 getById
  行为不变(已删用户仍 404);仅 admin 搜索设 IncludeDeleted

后端 build / 三态 vet / unit 全量 / 仓储集成全绿;前端 typecheck / vitest /
改动文件 eslint 全清。

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
DaydreamCoding
2026-05-30 17:52:18 +08:00
co-authored by Claude Opus 4.7
parent f18451e56f
commit b60d8bb4cc
39 changed files with 727 additions and 19 deletions
+1
View File
@@ -27,6 +27,7 @@ export interface AdminUsageStatsResponse {
export interface SimpleUser {
id: number
email: string
deleted: boolean
}
export interface SimpleApiKey {
+4 -2
View File
@@ -100,10 +100,12 @@ export async function list(
/**
* Get user by ID
* @param id - User ID
* @param includeDeleted - Whether to include soft-deleted users
* @returns User details
*/
export async function getById(id: number): Promise<AdminUser> {
const { data } = await apiClient.get<AdminUser>(`/admin/users/${id}`)
export async function getById(id: number, includeDeleted = false): Promise<AdminUser> {
const url = includeDeleted ? `/admin/users/${id}?include_deleted=true` : `/admin/users/${id}`
const { data } = await apiClient.get<AdminUser>(url)
return data
}
@@ -35,7 +35,7 @@
@click="selectUser(u)"
class="w-full px-4 py-2 text-left hover:bg-gray-100 dark:hover:bg-gray-700"
>
<span>{{ u.email }}</span>
<span>{{ u.email }}<span v-if="u.deleted" class="ml-1 text-xs text-gray-400">({{ t('admin.usage.userDeletedBadge') }})</span></span>
<span class="ml-2 text-xs text-gray-400">#{{ u.id }}</span>
</button>
</div>
@@ -255,7 +255,8 @@ const debounceUserSearch = () => {
return
}
try {
userResults.value = await adminAPI.usage.searchUsers(userKeyword.value)
const results = await adminAPI.usage.searchUsers(userKeyword.value)
userResults.value = results.sort((a, b) => Number(a.deleted) - Number(b.deleted))
} catch {
userResults.value = []
}
@@ -21,6 +21,9 @@
{{ row.user.email }}
</button>
<span v-else class="font-medium text-gray-900 dark:text-white">-</span>
<span v-if="row.user?.deleted_at" class="ml-1 inline-flex items-center rounded px-1 py-px text-[10px] font-medium leading-tight bg-rose-100 text-rose-600 ring-1 ring-inset ring-rose-200 dark:bg-rose-500/20 dark:text-rose-400 dark:ring-rose-500/30">
{{ t('admin.usage.userDeletedBadge') }}
</span>
<span class="ml-1 text-gray-500 dark:text-gray-400">#{{ row.user_id }}</span>
</div>
</template>
@@ -0,0 +1,168 @@
import { describe, expect, it, vi, beforeEach, afterEach } from 'vitest'
import { mount, flushPromises } from '@vue/test-utils'
import UsageFilters from '../UsageFilters.vue'
// --- i18n messages (only what UsageFilters needs) ---
const messages: Record<string, string> = {
'admin.usage.userDeletedBadge': 'deleted',
'admin.usage.userFilter': 'User',
'admin.usage.searchUserPlaceholder': 'Search user...',
'usage.apiKeyFilter': 'API Key',
'admin.usage.searchApiKeyPlaceholder': 'Search API key...',
'usage.model': 'Model',
'admin.usage.allModels': 'All Models',
'admin.usage.account': 'Account',
'admin.usage.searchAccountPlaceholder': 'Search account...',
'usage.type': 'Type',
'admin.usage.allTypes': 'All Types',
'usage.ws': 'WS',
'usage.stream': 'Stream',
'usage.sync': 'Sync',
'admin.usage.billingType': 'Billing Type',
'admin.usage.allBillingTypes': 'All Billing Types',
'admin.usage.billingTypeBalance': 'Balance',
'admin.usage.billingTypeSubscription': 'Subscription',
'admin.usage.billingMode': 'Billing Mode',
'admin.usage.allBillingModes': 'All Billing Modes',
'admin.usage.billingModeToken': 'Token',
'admin.usage.billingModePerRequest': 'Per Request',
'admin.usage.billingModeImage': 'Image',
'admin.usage.group': 'Group',
'admin.usage.allGroups': 'All Groups',
'common.refresh': 'Refresh',
'common.reset': 'Reset',
'admin.usage.cleanup.button': 'Cleanup',
'usage.exportExcel': 'Export',
}
// Mock vue-i18n
vi.mock('vue-i18n', async () => {
const actual = await vi.importActual<typeof import('vue-i18n')>('vue-i18n')
return {
...actual,
useI18n: () => ({
t: (key: string) => messages[key] ?? key,
}),
}
})
// Mock the admin API module — we control searchUsers return value per test
const mockSearchUsers = vi.fn()
const mockSearchApiKeys = vi.fn().mockResolvedValue([])
vi.mock('@/api/admin', () => ({
adminAPI: {
usage: {
searchUsers: (...args: any[]) => mockSearchUsers(...args),
searchApiKeys: (...args: any[]) => mockSearchApiKeys(...args),
},
groups: {
list: vi.fn().mockResolvedValue({ items: [] }),
},
dashboard: {
getModelStats: vi.fn().mockResolvedValue({ models: [] }),
},
accounts: {
list: vi.fn().mockResolvedValue({ items: [] }),
},
},
}))
// Default props helper
const defaultFilters = () => ({
user_id: undefined,
api_key_id: undefined,
account_id: undefined,
model: null,
request_type: null,
billing_type: null,
billing_mode: null,
group_id: null,
start_date: '',
end_date: '',
})
function mountFilters(filters = defaultFilters()) {
return mount(UsageFilters, {
props: {
modelValue: filters,
exporting: false,
startDate: '2026-05-01',
endDate: '2026-05-28',
showActions: false,
},
global: {
stubs: {
Select: true,
Teleport: true,
},
},
})
}
describe('UsageFilters — user search dropdown', () => {
beforeEach(() => {
vi.useFakeTimers()
mockSearchUsers.mockReset()
mockSearchApiKeys.mockResolvedValue([])
})
afterEach(() => {
vi.useRealTimers()
})
it('(a) labels deleted users with the i18n badge and (b) sorts active users before deleted ones, (c) selection sets user_id', async () => {
// Arrange: mock returns deleted FIRST (proves sorting re-orders to active-first)
mockSearchUsers.mockResolvedValue([
{ id: 2, email: 'gone@test.com', deleted: true },
{ id: 1, email: 'active@test.com', deleted: false },
])
const wrapper = mountFilters()
// Trigger focus (sets showUserDropdown = true) then input (fires debounceUserSearch)
const input = wrapper.find('input[type="text"]')
await input.trigger('focus')
await input.setValue('test')
await input.trigger('input')
// Advance debounce timer (300ms) then flush the resolved promise
vi.advanceTimersByTime(300)
await flushPromises()
// --- (b) Sort: active user should appear BEFORE deleted user ---
// Check the underlying component state via rendered DOM order
const buttons = wrapper.findAll('.usage-filter-dropdown button[type="button"]')
const emailTexts = buttons.map((b) => b.text())
// active@test.com should be listed first
const activeIdx = emailTexts.findIndex((t) => t.includes('active@test.com'))
const deletedIdx = emailTexts.findIndex((t) => t.includes('gone@test.com'))
expect(activeIdx).toBeGreaterThanOrEqual(0)
expect(deletedIdx).toBeGreaterThanOrEqual(0)
expect(activeIdx).toBeLessThan(deletedIdx)
// --- (a) Label: deleted user's button shows the badge text ---
const deletedButton = buttons[deletedIdx]
expect(deletedButton.text()).toContain('deleted')
// active user's button does NOT show the badge text
const activeButton = buttons[activeIdx]
expect(activeButton.text()).not.toContain('deleted')
// --- (c) Selection: clicking active user button sets filters.user_id ---
await activeButton.trigger('click')
await flushPromises()
// The component emits 'update:modelValue' or modifies filters.user_id via toRef
// selectUser sets filters.value.user_id = u.id and emits 'change'
const changeEmits = wrapper.emitted('change')
expect(changeEmits).toBeTruthy()
expect(changeEmits!.length).toBeGreaterThan(0)
// Also confirm user_id was set by checking the emitted change came through
// (the component uses toRef so modelValue is mutated in place and 'change' is emitted)
expect(wrapper.props('modelValue').user_id).toBe(1)
})
})
@@ -5,6 +5,7 @@ import { nextTick } from 'vue'
import UsageTable from '../UsageTable.vue'
const messages: Record<string, string> = {
'admin.usage.userDeletedBadge': 'Deleted',
'usage.costDetails': 'Cost Breakdown',
'admin.usage.inputCost': 'Input Cost',
'admin.usage.outputCost': 'Output Cost',
@@ -321,3 +322,91 @@ describe('admin UsageTable tooltip', () => {
expect(text).not.toContain('(2K)')
})
})
// A DataTable stub that also renders cell-user, so the deleted badge can be asserted.
const DataTableStubWithUser = {
props: ['data'],
template: `
<div>
<div v-for="row in data" :key="row.request_id">
<slot name="cell-user" :row="row" />
<slot name="cell-model" :row="row" :value="row.model" />
<slot name="cell-billing_mode" :row="row" />
<slot name="cell-tokens" :row="row" />
<slot name="cell-cost" :row="row" />
</div>
</div>
`,
}
describe('admin UsageTable deleted-user badge', () => {
it('renders deleted badge for a soft-deleted user row', () => {
const row = {
request_id: 'req-deleted-user-1',
model: 'claude-3',
user_id: 2,
user: { id: 2, email: 'd@test.com', deleted_at: '2026-05-28T00:00:00Z' },
actual_cost: 0,
total_cost: 0,
input_cost: 0,
output_cost: 0,
rate_multiplier: 1,
input_tokens: 1,
output_tokens: 1,
}
const wrapper = mount(UsageTable, {
props: {
data: [row],
loading: false,
columns: [{ key: 'user', label: 'User' }],
},
global: {
stubs: {
DataTable: DataTableStubWithUser,
EmptyState: true,
Icon: true,
Teleport: true,
},
},
})
expect(wrapper.text()).toContain('Deleted')
expect(wrapper.text()).toContain('d@test.com')
})
it('does NOT render deleted badge for an active user row', () => {
const row = {
request_id: 'req-active-user-1',
model: 'claude-3',
user_id: 3,
user: { id: 3, email: 'active@test.com', deleted_at: null },
actual_cost: 0,
total_cost: 0,
input_cost: 0,
output_cost: 0,
rate_multiplier: 1,
input_tokens: 1,
output_tokens: 1,
}
const wrapper = mount(UsageTable, {
props: {
data: [row],
loading: false,
columns: [{ key: 'user', label: 'User' }],
},
global: {
stubs: {
DataTable: DataTableStubWithUser,
EmptyState: true,
Icon: true,
Teleport: true,
},
},
})
expect(wrapper.text()).not.toContain('Deleted')
expect(wrapper.text()).toContain('active@test.com')
})
})
@@ -13,6 +13,9 @@
<div class="min-w-0 flex-1">
<div class="flex items-center gap-2">
<p class="truncate font-medium text-gray-900 dark:text-white">{{ user.email }}</p>
<span v-if="user.deleted_at" class="flex-shrink-0 inline-flex items-center rounded px-1 py-px text-[10px] font-medium leading-tight bg-rose-100 text-rose-600 ring-1 ring-inset ring-rose-200 dark:bg-rose-500/20 dark:text-rose-400 dark:ring-rose-500/30">
{{ t('admin.usage.userDeletedBadge') }}
</span>
<span
v-if="user.username"
class="flex-shrink-0 rounded bg-primary-50 px-1.5 py-0.5 text-xs text-primary-600 dark:bg-primary-900/20 dark:text-primary-400"
+1
View File
@@ -4524,6 +4524,7 @@ export default {
ipAddress: 'IP',
clickToViewBalance: 'Click to view balance history',
failedToLoadUser: 'Failed to load user info',
userDeletedBadge: 'Deleted',
cleanup: {
button: 'Cleanup',
title: 'Cleanup Usage Records',
+1
View File
@@ -4677,6 +4677,7 @@ export default {
ipAddress: 'IP',
clickToViewBalance: '点击查看充值记录',
failedToLoadUser: '加载用户信息失败',
userDeletedBadge: '已删除',
cleanup: {
button: '清理',
title: '清理使用记录',
+1
View File
@@ -97,6 +97,7 @@ export interface User {
last_active_at?: string | null
created_at: string
updated_at: string
deleted_at?: string | null
}
export interface AdminUser extends User {
+1 -1
View File
@@ -194,7 +194,7 @@ const breakdownFilters = computed(() => {
const handleUserClick = async (userId: number) => {
try {
const user = await adminAPI.users.getById(userId)
const user = await adminAPI.users.getById(userId, true)
balanceHistoryUser.value = user
showBalanceHistoryModal.value = true
} catch {
@@ -84,6 +84,10 @@ vi.mock('vue-router', () => ({
const AppLayoutStub = { template: '<div><slot /></div>' }
const UsageFiltersStub = { template: '<div><slot name="after-reset" /></div>' }
const UsageTableStub = {
emits: ['userClick'],
template: '<div data-test="usage-table"><button class="user-click" @click="$emit(\'userClick\', 2)">user</button></div>',
}
const ModelDistributionChartStub = {
props: ['metric'],
emits: ['update:metric'],
@@ -194,3 +198,59 @@ describe('admin UsageView distribution metric toggles', () => {
expect(getSnapshotV2).toHaveBeenCalledTimes(1)
})
})
describe('admin UsageView handleUserClick', () => {
beforeEach(() => {
vi.useFakeTimers()
list.mockReset()
getStats.mockReset()
getSnapshotV2.mockReset()
getById.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: [] })
})
afterEach(() => {
vi.useRealTimers()
})
it('opens user via include_deleted when clicking a usage row user', async () => {
getById.mockResolvedValue({ id: 2, email: 'd@test.com', deleted_at: '2026-05-28T00:00:00Z' })
const wrapper = mount(UsageView, {
global: {
stubs: {
AppLayout: AppLayoutStub,
UsageStatsCards: true,
UsageFilters: UsageFiltersStub,
UsageTable: UsageTableStub,
UsageExportProgress: true,
UsageCleanupDialog: true,
UserBalanceHistoryModal: true,
AuditLogModal: true,
Pagination: true,
Select: true,
DateRangePicker: true,
Icon: true,
TokenUsageTrend: true,
ModelDistributionChart: true,
GroupDistributionChart: true,
EndpointDistributionChart: true,
},
},
})
vi.advanceTimersByTime(120)
await flushPromises()
await wrapper.find('[data-test="usage-table"] .user-click').trigger('click')
await flushPromises()
expect(getById).toHaveBeenCalledWith(2, true)
})
})