feat: add Sora admin backend and fix type inconsistencies
Some checks failed
CI / test (push) Has been cancelled
CI / golangci-lint (push) Has been cancelled
Security Scan / backend-security (push) Has been cancelled
Security Scan / frontend-security (push) Has been cancelled

Backend changes:
- Add SoraHandler for admin Sora management APIs
  - GET /api/v1/admin/sora/stats - system statistics
  - GET /api/v1/admin/sora/users - user storage stats
  - GET /api/v1/admin/sora/generations - generation records
  - DELETE /api/v1/admin/sora/users/:id/storage - clear user storage
- Add sora_storage_quota_bytes to AdminUser DTO
- Add SoraStorageQuotaBytes to UpdateUserInput for admin user updates
- Add comprehensive tests for SoraHandler

Frontend changes:
- Add soraAdminAPI for Sora management
- Add sora_storage_quota_bytes and sora_storage_used_bytes to AdminUser type
- Add Sora storage quota field to UserEditModal (GB unit)
- Fix UsageLog type: add media_type, fix duration_ms to optional
- Fix AdminUsageLog type: add channel_id, billing_tier

Test fixes:
- Add window.matchMedia mock to AccountUsageCell.spec.ts
- Add tlsFingerprintProfileAPI mock to EditAccountModal.spec.ts
- Fix loadTLSProfiles function order in EditAccountModal.vue
- Fix translation key references in AccountStatusIndicator.spec.ts
This commit is contained in:
User
2026-04-16 09:20:23 +08:00
parent eb5d32553d
commit 2d59b9ebfc
18 changed files with 564 additions and 39 deletions

View File

@@ -27,6 +27,7 @@ import backupAPI from './backup'
import tlsFingerprintProfileAPI from './tlsFingerprintProfile'
import channelsAPI from './channels'
import adminPaymentAPI from './payment'
import soraAdminAPI from './sora'
/**
* Unified admin API object for convenient access
@@ -55,7 +56,8 @@ export const adminAPI = {
backup: backupAPI,
tlsFingerprintProfiles: tlsFingerprintProfileAPI,
channels: channelsAPI,
payment: adminPaymentAPI
payment: adminPaymentAPI,
sora: soraAdminAPI
}
export {
@@ -82,7 +84,8 @@ export {
backupAPI,
tlsFingerprintProfileAPI,
channelsAPI,
adminPaymentAPI
adminPaymentAPI,
soraAdminAPI
}
export default adminAPI

View File

@@ -0,0 +1,95 @@
/**
* Admin Sora API
* 管理员 Sora 统计和用户配额管理接口
*/
import { apiClient } from '../client'
import type { BasePaginationResponse } from '@/types'
export interface SoraUserStats {
user_id: number
username: string
email: string
quota_bytes: number
used_bytes: number
available_bytes: number
quota_source: string
generations_count: number
active_count: number
total_file_size_bytes: number
}
export interface SoraSystemStats {
total_users: number
total_generations: number
total_storage_bytes: number
active_generations: number
by_status: Record<string, number>
by_model: Record<string, number>
}
export interface SoraGenerationAdmin {
id: number
user_id: number
username: string
email: string
model: string
prompt: string
media_type: string
status: string
storage_type: string
media_url: string
file_size_bytes: number
error_message: string
created_at: string
completed_at: string | null
}
const soraAdminAPI = {
/**
* 获取 Sora 系统统计
*/
async getSystemStats(): Promise<SoraSystemStats> {
const { data } = await apiClient.get<{ data: SoraSystemStats }>('/admin/sora/stats')
return data.data
},
/**
* 获取用户 Sora 使用统计列表
*/
async listUserStats(params?: {
page?: number
page_size?: number
search?: string
sort_by?: string
sort_order?: string
}): Promise<BasePaginationResponse<SoraUserStats>> {
const { data } = await apiClient.get<BasePaginationResponse<SoraUserStats>>('/admin/sora/users', { params })
return data
},
/**
* 获取 Sora 生成记录列表(管理员视角)
*/
async listGenerations(params?: {
page?: number
page_size?: number
user_id?: number
status?: string
model?: string
sort_by?: string
sort_order?: string
}): Promise<BasePaginationResponse<SoraGenerationAdmin>> {
const { data } = await apiClient.get<BasePaginationResponse<SoraGenerationAdmin>>('/admin/sora/generations', { params })
return data
},
/**
* 删除用户的 Sora 存储空间(管理员操作)
*/
async clearUserStorage(userId: number): Promise<void> {
await apiClient.delete(`/admin/sora/users/${userId}/storage`)
},
}
export default soraAdminAPI

View File

@@ -2294,6 +2294,16 @@ const syncFormFromAccount = (newAccount: Account | null) => {
editApiKey.value = ''
}
// loadTLSProfiles must be defined before the watch that uses it
const loadTLSProfiles = async () => {
try {
const profiles = await adminAPI.tlsFingerprintProfiles.list()
tlsFingerprintProfiles.value = profiles.map(p => ({ id: p.id, name: p.name }))
} catch {
tlsFingerprintProfiles.value = []
}
}
watch(
[() => props.show, () => props.account],
([show, newAccount], [wasShow, previousAccount]) => {
@@ -2308,15 +2318,6 @@ watch(
{ immediate: true }
)
const loadTLSProfiles = async () => {
try {
const profiles = await adminAPI.tlsFingerprintProfiles.list()
tlsFingerprintProfiles.value = profiles.map(p => ({ id: p.id, name: p.name }))
} catch {
tlsFingerprintProfiles.value = []
}
}
// Model mapping helpers
const addModelMapping = () => {
modelMappings.value.push({ from: '', to: '' })

View File

@@ -122,7 +122,7 @@ describe('AccountStatusIndicator', () => {
}
})
expect(wrapper.text()).toContain('account.creditsExhausted')
expect(wrapper.text()).toContain('admin.accounts.status.creditsExhausted')
})
it('模型限流 + overages 启用 + AICredits key 生效 → 普通限流样式(积分耗尽,无 ⚡)', () => {
@@ -157,6 +157,6 @@ describe('AccountStatusIndicator', () => {
expect(wrapper.text()).toContain('CSon45')
expect(wrapper.text()).not.toContain('⚡')
// AICredits 积分耗尽状态应显示
expect(wrapper.text()).toContain('account.creditsExhausted')
expect(wrapper.text()).toContain('admin.accounts.status.creditsExhausted')
})
})

View File

@@ -3,6 +3,21 @@ import { flushPromises, mount } from '@vue/test-utils'
import AccountUsageCell from '../AccountUsageCell.vue'
import type { Account } from '@/types'
// Mock window.matchMedia for responsive hooks
Object.defineProperty(window, 'matchMedia', {
writable: true,
value: vi.fn().mockImplementation((query: string) => ({
matches: false,
media: query,
onchange: null,
addListener: vi.fn(),
removeListener: vi.fn(),
addEventListener: vi.fn(),
removeEventListener: vi.fn(),
dispatchEvent: vi.fn(),
})),
})
const { getUsage } = vi.hoisted(() => ({
getUsage: vi.fn()
}))
@@ -193,7 +208,7 @@ describe('AccountUsageCell', () => {
await flushPromises()
expect(getUsage).toHaveBeenCalledWith(2000)
expect(getUsage).toHaveBeenCalledWith(2000, undefined)
expect(wrapper.text()).toContain('5h|15|300')
expect(wrapper.text()).toContain('7d|77|300')
})
@@ -254,7 +269,7 @@ describe('AccountUsageCell', () => {
await flushPromises()
expect(getUsage).toHaveBeenCalledWith(2001)
expect(getUsage).toHaveBeenCalledWith(2001, undefined)
// 单一数据源:始终使用 /usage API 返回值,忽略 codex 快照
expect(wrapper.text()).toContain('5h|18|900')
expect(wrapper.text()).toContain('7d|36|900')
@@ -325,7 +340,7 @@ describe('AccountUsageCell', () => {
// 手动刷新再拉一次
expect(getUsage).toHaveBeenCalledTimes(2)
expect(getUsage).toHaveBeenCalledWith(2010)
expect(getUsage).toHaveBeenCalledWith(2010, undefined)
// 单一数据源:始终使用 /usage API 值
expect(wrapper.text()).toContain('5h|18|900')
})
@@ -380,7 +395,7 @@ describe('AccountUsageCell', () => {
await flushPromises()
expect(getUsage).toHaveBeenCalledWith(2002)
expect(getUsage).toHaveBeenCalledWith(2002, undefined)
expect(wrapper.text()).toContain('5h|0|27700')
expect(wrapper.text()).toContain('7d|0|27700')
})
@@ -512,7 +527,7 @@ describe('AccountUsageCell', () => {
await flushPromises()
expect(getUsage).toHaveBeenCalledWith(2004)
expect(getUsage).toHaveBeenCalledWith(2004, undefined)
expect(wrapper.text()).toContain('5h|100|106540000')
expect(wrapper.text()).toContain('7d|100|106540000')
})

View File

@@ -34,6 +34,12 @@ vi.mock('@/api/admin/accounts', () => ({
getAntigravityDefaultModelMapping: vi.fn()
}))
vi.mock('@/api/admin/tlsFingerprintProfile', () => ({
tlsFingerprintProfileAPI: {
list: vi.fn().mockResolvedValue({ items: [] })
}
}))
vi.mock('vue-i18n', async () => {
const actual = await vi.importActual<typeof import('vue-i18n')>('vue-i18n')
return {

View File

@@ -37,6 +37,11 @@
<label class="input-label">{{ t('admin.users.columns.concurrency') }}</label>
<input v-model.number="form.concurrency" type="number" class="input" />
</div>
<div>
<label class="input-label">{{ t('admin.users.soraStorageQuota') }}</label>
<input v-model.number="form.soraStorageQuotaGB" type="number" min="0" step="0.1" class="input" />
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">{{ t('admin.users.soraStorageQuotaHint') }}</p>
</div>
<UserAttributeForm v-model="form.customAttributes" :user-id="user?.id" />
</form>
<template #footer>
@@ -66,11 +71,11 @@ const emit = defineEmits(['close', 'success'])
const { t } = useI18n(); const appStore = useAppStore(); const { copyToClipboard } = useClipboard()
const submitting = ref(false); const passwordCopied = ref(false)
const form = reactive({ email: '', password: '', username: '', notes: '', concurrency: 1, customAttributes: {} as UserAttributeValuesMap })
const form = reactive({ email: '', password: '', username: '', notes: '', concurrency: 1, soraStorageQuotaGB: 0, customAttributes: {} as UserAttributeValuesMap })
watch(() => props.user, (u) => {
if (u) {
Object.assign(form, { email: u.email, password: '', username: u.username || '', notes: u.notes || '', concurrency: u.concurrency, customAttributes: {} })
Object.assign(form, { email: u.email, password: '', username: u.username || '', notes: u.notes || '', concurrency: u.concurrency, soraStorageQuotaGB: Math.round((u.sora_storage_quota_bytes || 0) / (1024 * 1024 * 1024) * 10) / 10, customAttributes: {} })
passwordCopied.value = false
}
}, { immediate: true })
@@ -97,7 +102,7 @@ const handleUpdateUser = async () => {
}
submitting.value = true
try {
const data: any = { email: form.email, username: form.username, notes: form.notes, concurrency: form.concurrency }
const data: any = { email: form.email, username: form.username, notes: form.notes, concurrency: form.concurrency, sora_storage_quota_bytes: Math.round(form.soraStorageQuotaGB * 1024 * 1024 * 1024) }
if (form.password.trim()) data.password = form.password.trim()
await adminAPI.users.update(props.user.id, data)
if (Object.keys(form.customAttributes).length > 0) await adminAPI.userAttributes.updateUserAttributeValues(props.user.id, form.customAttributes)

View File

@@ -45,6 +45,10 @@ export interface AdminUser extends User {
group_rates?: Record<number, number>
// 当前并发数(仅管理员列表接口返回)
current_concurrency?: number
// Sora 存储配额单位字节0 表示使用分组或系统默认配额)
sora_storage_quota_bytes: number
// Sora 存储已用字节
sora_storage_used_bytes: number
}
export interface LoginRequest {
@@ -1016,12 +1020,13 @@ export interface UsageLog {
request_type?: UsageRequestType
stream: boolean
openai_ws_mode?: boolean
duration_ms: number
duration_ms: number | null
first_token_ms: number | null
// 图片生成字段
image_count: number
image_size: string | null
media_type?: string | null
// User-Agent
user_agent: string | null
@@ -1049,6 +1054,12 @@ export interface AdminUsageLog extends UsageLog {
upstream_model?: string | null
model_mapping_chain?: string | null
// 渠道 ID
channel_id?: number | null
// 计费层级标签per_request/image 模式)
billing_tier?: string | null
// 账号计费倍率(仅管理员可见)
account_rate_multiplier?: number | null