Merge origin/main into main

Integrate upstream updates while preserving the local analytics dashboards and schema-only migration changes.

Combine user account analysis with upstream user/group usage statistics in separate tabs, retain all migration versions, and keep the deleted audit document removed.

Validation: gateway all-target cargo check, frontend type check and 57 focused tests, 48 migration tests, schema composition checks, and diff whitespace checks.
This commit is contained in:
elky
2026-10-02 11:57:18 +08:00
343 changed files with 27929 additions and 2549 deletions
@@ -82,4 +82,35 @@ describe('adminApi analytics cache options', () => {
})
expect(getMock).toHaveBeenNthCalledWith(4, '/api/admin/stats/errors/distribution', { params })
})
it('requests the user group leaderboard with scoped cache parameters', async () => {
const groupParams = {
...params,
metric: 'cost' as const,
offset: 10,
limit: 10,
include_inactive: true,
}
getMock.mockResolvedValueOnce({
data: { items: [], total: 0, metric: 'cost', attribution: 'current_membership' },
})
await expect(adminApi.getLeaderboardUserGroups(groupParams)).resolves.toMatchObject({
attribution: 'current_membership',
})
expect(buildCacheKeyMock).toHaveBeenCalledWith(
'admin:stats:leaderboard:user-groups',
groupParams
)
expect(cachedRequestMock).toHaveBeenCalledWith(
'admin:stats:leaderboard:user-groups',
expect.any(Function),
20 * 1000
)
expect(getMock).toHaveBeenCalledWith('/api/admin/stats/leaderboard/user-groups', {
params: groupParams,
})
})
})
@@ -0,0 +1,76 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
const { getMock, patchMock } = vi.hoisted(() => ({ getMock: vi.fn(), patchMock: vi.fn() }))
vi.mock('@/api/client', () => ({ default: { get: getMock, patch: patchMock } }))
import { getPoolOverview, listPoolKeys, listPoolScores } from '@/api/endpoints/pool'
import { getProvider, updateProvider } from '@/api/endpoints/providers'
import { cache } from '@/utils/cache'
const options = { cacheTtlMs: 30_000 }
const provider = { id: 'codex', pool_advanced: { reserve_minimum_quota: false } }
function deferred<T>() {
let resolve!: (value: T) => void
const promise = new Promise<T>((resolvePromise) => { resolve = resolvePromise })
return { promise, resolve }
}
beforeEach(() => {
cache.clear()
getMock.mockReset()
patchMock.mockReset()
patchMock.mockResolvedValue({ data: provider })
})
describe('provider pool settings cache invalidation', () => {
it('refreshes every cached key page, score page and overview after changing pool settings', async () => {
getMock.mockResolvedValue({ data: { version: 'old' } })
await getPoolOverview(options)
await listPoolKeys('codex', {}, options)
await listPoolKeys('codex', { page: 2, status: 'quota_exhausted' }, options)
await listPoolScores('codex', {}, options)
await listPoolKeys('codex-other', {}, options)
await updateProvider('codex', { pool_advanced: { reserve_minimum_quota: false } })
getMock.mockResolvedValue({ data: { version: 'new' } })
await expect(getPoolOverview(options)).resolves.toEqual({ version: 'new' })
await expect(listPoolKeys('codex', {}, options)).resolves.toEqual({ version: 'new' })
await expect(listPoolKeys('codex', { page: 2, status: 'quota_exhausted' }, options))
.resolves.toEqual({ version: 'new' })
await expect(listPoolScores('codex', {}, options)).resolves.toEqual({ version: 'new' })
await expect(listPoolKeys('codex-other', {}, options)).resolves.toEqual({ version: 'old' })
})
it('does not reuse or cache a key request started before the settings were saved', async () => {
const oldResponse = deferred<{ data: { version: string } }>()
const newResponse = deferred<{ data: { version: string } }>()
getMock.mockReturnValueOnce(oldResponse.promise).mockReturnValueOnce(newResponse.promise)
const oldRequest = listPoolKeys('codex', { page: 2 }, options)
await updateProvider('codex', { pool_advanced: { reserve_minimum_quota: false } })
const newRequest = listPoolKeys('codex', { page: 2 }, options)
expect(getMock).toHaveBeenCalledTimes(2)
oldResponse.resolve({ data: { version: 'old' } })
await oldRequest
const deduped = listPoolKeys('codex', { page: 2 }, options)
expect(getMock).toHaveBeenCalledTimes(2)
newResponse.resolve({ data: { version: 'new' } })
await expect(newRequest).resolves.toEqual({ version: 'new' })
await expect(deduped).resolves.toEqual({ version: 'new' })
await expect(listPoolKeys('codex', { page: 2 }, options)).resolves.toEqual({ version: 'new' })
})
it('does not reuse a provider detail request started before a successful save', async () => {
const oldResponse = deferred<{ data: typeof provider }>()
getMock.mockReturnValueOnce(oldResponse.promise).mockResolvedValueOnce({ data: provider })
const oldRequest = getProvider('codex')
await updateProvider('codex', { pool_advanced: { reserve_minimum_quota: false } })
const newRequest = getProvider('codex')
expect(getMock).toHaveBeenCalledTimes(2)
oldResponse.resolve({ data: { ...provider, pool_advanced: { reserve_minimum_quota: true } } })
await oldRequest
await expect(newRequest).resolves.toMatchObject(provider)
})
})
+23 -1
View File
@@ -17,7 +17,7 @@ vi.mock('@/utils/cache', () => ({
cachedRequest: cachedRequestMock,
}))
import { usersApi } from '@/api/users'
import { buildUserBatchBalanceAdjustmentPayload, usersApi } from '@/api/users'
describe('usersApi admin list query', () => {
beforeEach(() => {
@@ -104,3 +104,25 @@ describe('usersApi admin list query', () => {
expect(getMock).toHaveBeenCalledWith('/api/admin/users/target-user/api-keys')
})
})
describe('user batch wallet balance payload', () => {
it('builds an addition payload from a valid positive amount', () => {
expect(buildUserBatchBalanceAdjustmentPayload('add', '12.5')).toEqual({
operation: 'add',
amount: 12.5,
})
})
it('builds a deduction payload from a valid positive amount', () => {
expect(buildUserBatchBalanceAdjustmentPayload('deduct', 4)).toEqual({
operation: 'deduct',
amount: 4,
})
})
it('rejects zero, blank, and non-finite amounts', () => {
expect(buildUserBatchBalanceAdjustmentPayload('add', '0')).toBeNull()
expect(buildUserBatchBalanceAdjustmentPayload('deduct', '')).toBeNull()
expect(buildUserBatchBalanceAdjustmentPayload('deduct', '1e999')).toBeNull()
})
})
+34
View File
@@ -709,6 +709,8 @@ export interface LeaderboardItem {
requests: number
tokens: number
cost: number
member_count?: number
active_member_count?: number
}
export interface LeaderboardResponse {
@@ -717,6 +719,7 @@ export interface LeaderboardResponse {
metric: string
start_date?: string | null
end_date?: string | null
attribution?: 'current_membership'
}
export interface CostForecastResponse {
@@ -1298,6 +1301,7 @@ export const adminApi = {
model?: string
include_inactive?: boolean
exclude_admin?: boolean
user_group_id?: string
}): Promise<LeaderboardResponse> {
const cacheKey = buildCacheKey('admin:stats:leaderboard:users', params)
return cachedRequest(
@@ -1312,6 +1316,35 @@ export const adminApi = {
)
},
async getLeaderboardUserGroups(params?: {
start_date?: string
end_date?: string
preset?: string
timezone?: string
tz_offset_minutes?: number
metric?: 'requests' | 'tokens' | 'cost'
order?: 'asc' | 'desc'
limit?: number
offset?: number
provider_name?: string
model?: string
include_inactive?: boolean
exclude_admin?: boolean
}): Promise<LeaderboardResponse> {
const cacheKey = buildCacheKey('admin:stats:leaderboard:user-groups', params)
return cachedRequest(
cacheKey,
async () => {
const response = await apiClient.get<LeaderboardResponse>(
'/api/admin/stats/leaderboard/user-groups',
{ params }
)
return response.data
},
20 * 1000
)
},
async getLeaderboardApiKeys(params?: {
start_date?: string
end_date?: string
@@ -1595,6 +1628,7 @@ export const adminApi = {
timezone?: string
tz_offset_minutes?: number
user_id?: string
user_group_id?: string
model?: string
provider_name?: string
},
+1
View File
@@ -219,6 +219,7 @@ export interface RequestDetail {
has_format_conversion?: boolean | null
model: string
target_model?: string | null // 映射后的目标模型名
response_model?: string | null // 上游响应体实际返回的模型名
requested_reasoning_effort?: string | null
reasoning_effort?: string | null
service_tier?: string | null
+1 -1
View File
@@ -376,7 +376,7 @@ function jsonValueContainsAgentIdentity(value: unknown): boolean {
export interface DeviceAuthorizeRequest {
start_url?: string
region?: string
auth_type?: 'builder_id' | 'identity_center' | 'google' | 'github' | 'browser'
auth_type?: 'builder_id' | 'identity_center' | 'google' | 'github' | 'browser' | 'device'
login_option?: 'google' | 'github' | 'default'
redirect_uri?: string
proxy_node_id?: string
+10 -1
View File
@@ -1,5 +1,5 @@
import client from '../client'
import { buildCacheKey, cachedRequest, dedupedRequest } from '@/utils/cache'
import { buildCacheKey, cache, cachedRequest, dedupedRequest } from '@/utils/cache'
import type {
ClaudeCodeAdvancedConfig,
FailoverRulesConfig,
@@ -142,6 +142,15 @@ export async function updateProvider(
requestOptions?: ProviderRequestOptions,
): Promise<ProviderWithEndpointsSummary> {
const response = await client.patch<ProviderWithEndpointsSummary>(`/api/admin/providers/${providerId}`, data, requestOptions)
cache.delete(`providers:detail:${providerId}`)
if ('pool_advanced' in data) {
cache.delete('pool:overview')
for (const kind of ['keys', 'scores']) {
const prefix = `pool:${kind}:${providerId}`
cache.delete(prefix)
cache.deleteByPrefix(`${prefix}:`)
}
}
return normalizeProviderSummary(response.data)
}
+35 -1
View File
@@ -462,6 +462,23 @@ export interface GrokUpstreamMetadata {
account_user_id?: string | null
}
export interface XaiUpstreamMetadata {
updated_at?: number
subscription_title?: string
usage_percentage?: number
remaining_percentage?: number
usage_label?: string
usage_limit?: number
current_usage?: number
remaining?: number
next_reset_at?: number
prepaid_balance?: number
on_demand_cap?: number
on_demand_used?: number
on_demand_remaining?: number
period_type?: string
}
export interface GeminiCliTierMetadata {
id?: string | null
tierType?: string | null
@@ -512,14 +529,29 @@ export interface GeminiCliUpstreamMetadata {
quota_by_model?: Record<string, GeminiCliModelQuota> | null
}
export interface ClaudeCodeUpstreamMetadata {
updated_at?: number
five_hour_used_percent?: number
five_hour_reset_at?: number
seven_day_used_percent?: number
seven_day_reset_at?: number
seven_day_sonnet_used_percent?: number
seven_day_sonnet_reset_at?: number
seven_day_fable_used_percent?: number
seven_day_fable_reset_at?: number
reset_credits?: QuotaResetCreditsSnapshot
}
export interface UpstreamMetadata {
codex?: CodexUpstreamMetadata
claude_code?: ClaudeCodeUpstreamMetadata
antigravity?: AntigravityUpstreamMetadata
kiro?: KiroUpstreamMetadata
windsurf?: WindsurfUpstreamMetadata
chatgpt_web?: ChatGPTWebUpstreamMetadata
grok?: GrokUpstreamMetadata
gemini_cli?: GeminiCliUpstreamMetadata
xai?: XaiUpstreamMetadata
}
// 按格式的健康度数据
@@ -758,7 +790,7 @@ export interface HealthRelatedMonitorResponse {
related_providers: HealthRelatedMonitor[]
}
export type ProviderType = 'custom' | 'claude_code' | 'codex' | 'chatgpt_web' | 'gemini_cli' | 'antigravity' | 'kiro' | 'grok' | 'windsurf' | 'vertex_ai'
export type ProviderType = 'custom' | 'claude_code' | 'codex' | 'chatgpt_web' | 'gemini_cli' | 'antigravity' | 'kiro' | 'grok' | 'xai' | 'windsurf' | 'vertex_ai'
export interface ClaudeCodeAdvancedConfig {
// 会话数量控制:null/undefined 表示不限制
@@ -804,6 +836,8 @@ export interface PoolAdvancedConfig {
sticky_session_ttl_seconds?: number | null
load_threshold_percent?: number | null
skip_exhausted_accounts?: boolean | null
// Codex only: treat remaining quota <= 1% as exhausted (default false).
reserve_minimum_quota?: boolean
// 旧字段(兼容读取)
lru_enabled?: boolean
scheduling_mode?: 'lru' | 'multi_score' | null
+3
View File
@@ -60,6 +60,7 @@ export interface UsageRecordDetail {
reasoning_effort?: string | null
service_tier?: string | null
actual_service_tier?: string | null
response_model?: string | null
input_tokens: number
effective_input_tokens?: number
output_tokens: number
@@ -392,6 +393,7 @@ export const meApi = {
has_format_conversion?: boolean | null
has_fallback?: boolean | null
target_model?: string | null
response_model?: string | null
request_type?: string | null
requested_reasoning_effort?: string | null
reasoning_effort?: string | null
@@ -438,6 +440,7 @@ export const meApi = {
has_format_conversion?: boolean | null
has_fallback?: boolean | null
target_model?: string | null
response_model?: string | null
request_type?: string | null
requested_reasoning_effort?: string | null
reasoning_effort?: string | null
+4
View File
@@ -20,6 +20,7 @@ export interface UsageRecord {
reasoning_effort?: string | null
service_tier?: string | null
actual_service_tier?: string | null
response_model?: string | null
input_tokens: number
effective_input_tokens?: number
output_tokens: number
@@ -136,6 +137,7 @@ export interface UsageFilters {
from?: string
to?: string
user_id?: string // UUID
user_group_id?: string // UUID
provider_id?: string // UUID
model?: string
search?: string
@@ -641,6 +643,7 @@ export const usageApi = {
has_format_conversion?: boolean | null
has_fallback?: boolean | null
target_model?: string | null
response_model?: string | null
request_type?: string | null
requested_reasoning_effort?: string | null
reasoning_effort?: string | null
@@ -712,6 +715,7 @@ export const usageApi = {
has_format_conversion?: boolean | null
has_fallback?: boolean | null
target_model?: string | null
response_model?: string | null
request_type?: string | null
requested_reasoning_effort?: string | null
reasoning_effort?: string | null
+9
View File
@@ -19,6 +19,7 @@ export interface UsageRecord {
model: string
target_model?: string | null // 映射后的目标模型名(若无映射则为空)
model_version?: string | null // Provider 返回的实际模型版本(列表轻量字段)
response_model?: string | null // 上游响应体实际返回的模型名
request_type?: string | null // 由请求语义识别出的操作类型
requested_reasoning_effort?: string | null // 用户请求侧 reasoning 级别,用于展示转换关系
reasoning_effort?: string | null // 从发送给 Provider 的请求体提取的 reasoning 级别
@@ -65,5 +66,13 @@ export interface UsageRecord {
response_time_updated_at?: string | null
has_fallback?: boolean
has_retry?: boolean
/**
* 是否存在被调度跳过的候选(候选在调度阶段即被判定不可用,从未向上游发起请求)。
* 与 has_fallback 的区别:has_fallback 代表"更靠前的候选真的失败了",
* 本字段代表"更靠前的候选压根没被发出去",用于解释"无报错却换了提供商"。
*/
has_skipped_candidate?: boolean
/** 被跳过候选的原因列表(后端已按候选顺序去重) */
skipped_candidate_reasons?: string[]
image_progress?: ImageProgress | null
}
+33 -2
View File
@@ -120,9 +120,28 @@ export interface UserBatchRolePayload {
role: UserRole
}
export type UserBatchAction = 'enable' | 'disable' | 'update_access_control' | 'update_role'
export type UserBatchBalanceOperation = 'add' | 'deduct'
export type UserBatchActionPayload = UserBatchAccessControlPayload | UserBatchRolePayload
export interface UserBatchBalanceAdjustmentPayload {
operation: UserBatchBalanceOperation
amount: number
}
export function buildUserBatchBalanceAdjustmentPayload(
operation: UserBatchBalanceOperation,
amountInput: string | number,
): UserBatchBalanceAdjustmentPayload | null {
const amount = typeof amountInput === 'number' ? amountInput : Number(amountInput.trim())
if (!Number.isFinite(amount) || amount <= 0) return null
return { operation, amount }
}
export type UserBatchAction = 'enable' | 'disable' | 'update_access_control' | 'update_role' | 'adjust_wallet_balance'
export type UserBatchActionPayload =
| UserBatchAccessControlPayload
| UserBatchRolePayload
| UserBatchBalanceAdjustmentPayload
export interface UserBatchToggleActionRequest {
selection: UserBatchSelection
@@ -142,10 +161,18 @@ export interface UserBatchRoleActionRequest {
payload: UserBatchRolePayload
}
export interface UserBatchBalanceActionRequest {
selection: UserBatchSelection
action: 'adjust_wallet_balance'
payload: UserBatchBalanceAdjustmentPayload
idempotency_key: string
}
export type UserBatchActionRequest =
| UserBatchToggleActionRequest
| UserBatchAccessControlActionRequest
| UserBatchRoleActionRequest
| UserBatchBalanceActionRequest
export interface UserBatchActionFailure {
user_id: string
@@ -157,6 +184,10 @@ export interface UserBatchActionResponse {
success: number
failed: number
failures: UserBatchActionFailure[]
interrupted?: boolean
completed_user_ids?: string[]
uncertain_user_ids?: string[]
unprocessed_user_ids?: string[]
warnings?: UserBatchSelectionWarning[]
action?: string
modified_fields?: string[]