Merge upstream/main: v0.1.85-v0.1.86 updates
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

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
huangzhenpc
2026-02-25 18:39:59 +08:00
478 changed files with 70574 additions and 35853 deletions

View File

@@ -0,0 +1,208 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
import axios from 'axios'
import type { AxiosInstance } from 'axios'
// 需要在导入 client 之前设置 mock
vi.mock('@/i18n', () => ({
getLocale: () => 'zh-CN',
}))
describe('API Client', () => {
let apiClient: AxiosInstance
beforeEach(async () => {
localStorage.clear()
// 每次测试重新导入以获取干净的模块状态
vi.resetModules()
const mod = await import('@/api/client')
apiClient = mod.apiClient
})
afterEach(() => {
vi.restoreAllMocks()
})
// --- 请求拦截器 ---
describe('请求拦截器', () => {
it('自动附加 Authorization 头', async () => {
localStorage.setItem('auth_token', 'my-jwt-token')
// 拦截实际请求
const adapter = vi.fn().mockResolvedValue({
status: 200,
data: { code: 0, data: {} },
headers: {},
config: {},
statusText: 'OK',
})
apiClient.defaults.adapter = adapter
await apiClient.get('/test')
const config = adapter.mock.calls[0][0]
expect(config.headers.get('Authorization')).toBe('Bearer my-jwt-token')
})
it('无 token 时不附加 Authorization 头', async () => {
const adapter = vi.fn().mockResolvedValue({
status: 200,
data: { code: 0, data: {} },
headers: {},
config: {},
statusText: 'OK',
})
apiClient.defaults.adapter = adapter
await apiClient.get('/test')
const config = adapter.mock.calls[0][0]
expect(config.headers.get('Authorization')).toBeFalsy()
})
it('GET 请求自动附加 timezone 参数', async () => {
const adapter = vi.fn().mockResolvedValue({
status: 200,
data: { code: 0, data: {} },
headers: {},
config: {},
statusText: 'OK',
})
apiClient.defaults.adapter = adapter
await apiClient.get('/test')
const config = adapter.mock.calls[0][0]
expect(config.params).toHaveProperty('timezone')
})
it('POST 请求不附加 timezone 参数', async () => {
const adapter = vi.fn().mockResolvedValue({
status: 200,
data: { code: 0, data: {} },
headers: {},
config: {},
statusText: 'OK',
})
apiClient.defaults.adapter = adapter
await apiClient.post('/test', { foo: 'bar' })
const config = adapter.mock.calls[0][0]
expect(config.params?.timezone).toBeUndefined()
})
})
// --- 响应拦截器 ---
describe('响应拦截器', () => {
it('code=0 时解包 data 字段', async () => {
const adapter = vi.fn().mockResolvedValue({
status: 200,
data: { code: 0, data: { name: 'test' }, message: 'ok' },
headers: {},
config: {},
statusText: 'OK',
})
apiClient.defaults.adapter = adapter
const response = await apiClient.get('/test')
expect(response.data).toEqual({ name: 'test' })
})
it('code!=0 时拒绝并返回结构化错误', async () => {
const adapter = vi.fn().mockResolvedValue({
status: 200,
data: { code: 1001, message: '参数错误', data: null },
headers: {},
config: {},
statusText: 'OK',
})
apiClient.defaults.adapter = adapter
await expect(apiClient.get('/test')).rejects.toEqual(
expect.objectContaining({
code: 1001,
message: '参数错误',
})
)
})
})
// --- 401 Token 刷新 ---
describe('401 Token 刷新', () => {
it('无 refresh_token 时 401 清除 localStorage', async () => {
localStorage.setItem('auth_token', 'expired-token')
// 不设置 refresh_token
// Mock window.location
const originalLocation = window.location
Object.defineProperty(window, 'location', {
value: { ...originalLocation, pathname: '/dashboard', href: '/dashboard' },
writable: true,
})
const adapter = vi.fn().mockRejectedValue({
response: {
status: 401,
data: { code: 'TOKEN_EXPIRED', message: 'Token expired' },
},
config: {
url: '/test',
headers: { Authorization: 'Bearer expired-token' },
},
code: 'ERR_BAD_REQUEST',
})
apiClient.defaults.adapter = adapter
await expect(apiClient.get('/test')).rejects.toBeDefined()
expect(localStorage.getItem('auth_token')).toBeNull()
// 恢复 location
Object.defineProperty(window, 'location', {
value: originalLocation,
writable: true,
})
})
})
// --- 网络错误 ---
describe('网络错误', () => {
it('网络错误返回 status 0 的错误', async () => {
const adapter = vi.fn().mockRejectedValue({
code: 'ERR_NETWORK',
message: 'Network Error',
config: { url: '/test' },
// 没有 response
})
apiClient.defaults.adapter = adapter
await expect(apiClient.get('/test')).rejects.toEqual(
expect.objectContaining({
status: 0,
message: 'Network error. Please check your connection.',
})
)
})
})
// --- 请求取消 ---
describe('请求取消', () => {
it('取消的请求保持原始取消错误', async () => {
const source = axios.CancelToken.source()
const adapter = vi.fn().mockRejectedValue(
new axios.Cancel('Operation canceled')
)
apiClient.defaults.adapter = adapter
await expect(
apiClient.get('/test', { cancelToken: source.token })
).rejects.toBeDefined()
})
})
})

View File

@@ -15,7 +15,9 @@ import type {
AccountUsageStatsResponse,
TempUnschedulableStatus,
AdminDataPayload,
AdminDataImportResult
AdminDataImportResult,
CheckMixedChannelRequest,
CheckMixedChannelResponse
} from '@/types'
/**
@@ -50,6 +52,58 @@ export async function list(
return data
}
export interface AccountListWithEtagResult {
notModified: boolean
etag: string | null
data: PaginatedResponse<Account> | null
}
export async function listWithEtag(
page: number = 1,
pageSize: number = 20,
filters?: {
platform?: string
type?: string
status?: string
search?: string
},
options?: {
signal?: AbortSignal
etag?: string | null
}
): Promise<AccountListWithEtagResult> {
const headers: Record<string, string> = {}
if (options?.etag) {
headers['If-None-Match'] = options.etag
}
const response = await apiClient.get<PaginatedResponse<Account>>('/admin/accounts', {
params: {
page,
page_size: pageSize,
...filters
},
headers,
signal: options?.signal,
validateStatus: (status) => (status >= 200 && status < 300) || status === 304
})
const etagHeader = typeof response.headers?.etag === 'string' ? response.headers.etag : null
if (response.status === 304) {
return {
notModified: true,
etag: etagHeader,
data: null
}
}
return {
notModified: false,
etag: etagHeader,
data: response.data
}
}
/**
* Get account by ID
* @param id - Account ID
@@ -81,6 +135,16 @@ export async function update(id: number, updates: UpdateAccountRequest): Promise
return data
}
/**
* Check mixed-channel risk for account-group binding.
*/
export async function checkMixedChannelRisk(
payload: CheckMixedChannelRequest
): Promise<CheckMixedChannelResponse> {
const { data } = await apiClient.post<CheckMixedChannelResponse>('/admin/accounts/check-mixed-channel', payload)
return data
}
/**
* Delete account
* @param id - Account ID
@@ -165,10 +229,10 @@ export async function getUsage(id: number): Promise<AccountUsageInfo> {
/**
* Clear account rate limit status
* @param id - Account ID
* @returns Success confirmation
* @returns Updated account
*/
export async function clearRateLimit(id: number): Promise<{ message: string }> {
const { data } = await apiClient.post<{ message: string }>(
export async function clearRateLimit(id: number): Promise<Account> {
const { data } = await apiClient.post<Account>(
`/admin/accounts/${id}/clear-rate-limit`
)
return data
@@ -220,7 +284,7 @@ export async function generateAuthUrl(
*/
export async function exchangeCode(
endpoint: string,
exchangeData: { session_id: string; code: string; proxy_id?: number }
exchangeData: { session_id: string; code: string; state?: string; proxy_id?: number }
): Promise<Record<string, unknown>> {
const { data } = await apiClient.post<Record<string, unknown>>(endpoint, exchangeData)
return data
@@ -442,7 +506,8 @@ export async function getAntigravityDefaultModelMapping(): Promise<Record<string
*/
export async function refreshOpenAIToken(
refreshToken: string,
proxyId?: number | null
proxyId?: number | null,
endpoint: string = '/admin/openai/refresh-token'
): Promise<Record<string, unknown>> {
const payload: { refresh_token: string; proxy_id?: number } = {
refresh_token: refreshToken
@@ -450,15 +515,39 @@ export async function refreshOpenAIToken(
if (proxyId) {
payload.proxy_id = proxyId
}
const { data } = await apiClient.post<Record<string, unknown>>('/admin/openai/refresh-token', payload)
const { data } = await apiClient.post<Record<string, unknown>>(endpoint, payload)
return data
}
/**
* Validate Sora session token and exchange to access token
* @param sessionToken - Sora session token
* @param proxyId - Optional proxy ID
* @param endpoint - API endpoint path
* @returns Token information including access_token
*/
export async function validateSoraSessionToken(
sessionToken: string,
proxyId?: number | null,
endpoint: string = '/admin/sora/st2at'
): Promise<Record<string, unknown>> {
const payload: { session_token: string; proxy_id?: number } = {
session_token: sessionToken
}
if (proxyId) {
payload.proxy_id = proxyId
}
const { data } = await apiClient.post<Record<string, unknown>>(endpoint, payload)
return data
}
export const accountsAPI = {
list,
listWithEtag,
getById,
create,
update,
checkMixedChannelRisk,
delete: deleteAccount,
toggleStatus,
testAccount,
@@ -475,6 +564,7 @@ export const accountsAPI = {
generateAuthUrl,
exchangeCode,
refreshOpenAIToken,
validateSoraSessionToken,
batchCreate,
batchUpdateCredentials,
bulkUpdate,

View File

@@ -259,6 +259,40 @@ export interface OpsErrorDistributionResponse {
items: OpsErrorDistributionItem[]
}
export type OpsOpenAITokenStatsTimeRange = '30m' | '1h' | '1d' | '15d' | '30d'
export interface OpsOpenAITokenStatsItem {
model: string
request_count: number
avg_tokens_per_sec?: number | null
avg_first_token_ms?: number | null
total_output_tokens: number
avg_duration_ms: number
requests_with_first_token: number
}
export interface OpsOpenAITokenStatsResponse {
time_range: OpsOpenAITokenStatsTimeRange
start_time: string
end_time: string
platform?: string
group_id?: number | null
items: OpsOpenAITokenStatsItem[]
total: number
page?: number
page_size?: number
top_n?: number | null
}
export interface OpsOpenAITokenStatsParams {
time_range?: OpsOpenAITokenStatsTimeRange
platform?: string
group_id?: number | null
page?: number
page_size?: number
top_n?: number
}
export interface OpsSystemMetricsSnapshot {
id: number
created_at: string
@@ -816,6 +850,77 @@ export interface OpsAggregationSettings {
aggregation_enabled: boolean
}
export interface OpsRuntimeLogConfig {
level: 'debug' | 'info' | 'warn' | 'error'
enable_sampling: boolean
sampling_initial: number
sampling_thereafter: number
caller: boolean
stacktrace_level: 'none' | 'error' | 'fatal'
retention_days: number
source?: string
updated_at?: string
updated_by_user_id?: number
}
export interface OpsSystemLog {
id: number
created_at: string
level: string
component: string
message: string
request_id?: string
client_request_id?: string
user_id?: number | null
account_id?: number | null
platform?: string
model?: string
extra?: Record<string, any>
}
export type OpsSystemLogListResponse = PaginatedResponse<OpsSystemLog>
export interface OpsSystemLogQuery {
page?: number
page_size?: number
time_range?: '5m' | '30m' | '1h' | '6h' | '24h' | '7d' | '30d'
start_time?: string
end_time?: string
level?: string
component?: string
request_id?: string
client_request_id?: string
user_id?: number | null
account_id?: number | null
platform?: string
model?: string
q?: string
}
export interface OpsSystemLogCleanupRequest {
start_time?: string
end_time?: string
level?: string
component?: string
request_id?: string
client_request_id?: string
user_id?: number | null
account_id?: number | null
platform?: string
model?: string
q?: string
}
export interface OpsSystemLogSinkHealth {
queue_depth: number
queue_capacity: number
dropped_count: number
write_failed_count: number
written_count: number
avg_write_delay_ms: number
last_error?: string
}
export interface OpsErrorLog {
id: number
created_at: string
@@ -971,6 +1076,17 @@ export async function getErrorDistribution(
return data
}
export async function getOpenAITokenStats(
params: OpsOpenAITokenStatsParams,
options: OpsRequestOptions = {}
): Promise<OpsOpenAITokenStatsResponse> {
const { data } = await apiClient.get<OpsOpenAITokenStatsResponse>('/admin/ops/dashboard/openai-token-stats', {
params,
signal: options.signal
})
return data
}
export type OpsErrorListView = 'errors' | 'excluded' | 'all'
export type OpsErrorListQueryParams = {
@@ -1160,6 +1276,36 @@ export async function updateAlertRuntimeSettings(config: OpsAlertRuntimeSettings
return data
}
export async function getRuntimeLogConfig(): Promise<OpsRuntimeLogConfig> {
const { data } = await apiClient.get<OpsRuntimeLogConfig>('/admin/ops/runtime/logging')
return data
}
export async function updateRuntimeLogConfig(config: OpsRuntimeLogConfig): Promise<OpsRuntimeLogConfig> {
const { data } = await apiClient.put<OpsRuntimeLogConfig>('/admin/ops/runtime/logging', config)
return data
}
export async function resetRuntimeLogConfig(): Promise<OpsRuntimeLogConfig> {
const { data } = await apiClient.post<OpsRuntimeLogConfig>('/admin/ops/runtime/logging/reset')
return data
}
export async function listSystemLogs(params: OpsSystemLogQuery): Promise<OpsSystemLogListResponse> {
const { data } = await apiClient.get<OpsSystemLogListResponse>('/admin/ops/system-logs', { params })
return data
}
export async function cleanupSystemLogs(payload: OpsSystemLogCleanupRequest): Promise<{ deleted: number }> {
const { data } = await apiClient.post<{ deleted: number }>('/admin/ops/system-logs/cleanup', payload)
return data
}
export async function getSystemLogSinkHealth(): Promise<OpsSystemLogSinkHealth> {
const { data } = await apiClient.get<OpsSystemLogSinkHealth>('/admin/ops/system-logs/health')
return data
}
// Advanced settings (DB-backed)
export async function getAdvancedSettings(): Promise<OpsAdvancedSettings> {
const { data } = await apiClient.get<OpsAdvancedSettings>('/admin/ops/advanced-settings')
@@ -1188,6 +1334,7 @@ export const opsAPI = {
getLatencyHistogram,
getErrorTrend,
getErrorDistribution,
getOpenAITokenStats,
getConcurrencyStats,
getUserConcurrencyStats,
getAccountAvailabilityStats,
@@ -1226,10 +1373,16 @@ export const opsAPI = {
updateEmailNotificationConfig,
getAlertRuntimeSettings,
updateAlertRuntimeSettings,
getRuntimeLogConfig,
updateRuntimeLogConfig,
resetRuntimeLogConfig,
getAdvancedSettings,
updateAdvancedSettings,
getMetricThresholds,
updateMetricThresholds
updateMetricThresholds,
listSystemLogs,
cleanupSystemLogs,
getSystemLogSinkHealth
}
export default opsAPI

View File

@@ -7,6 +7,7 @@ import { apiClient } from '../client'
import type {
Proxy,
ProxyAccountSummary,
ProxyQualityCheckResult,
CreateProxyRequest,
UpdateProxyRequest,
PaginatedResponse,
@@ -143,6 +144,16 @@ export async function testProxy(id: number): Promise<{
return data
}
/**
* Check proxy quality across common AI targets
* @param id - Proxy ID
* @returns Quality check result
*/
export async function checkProxyQuality(id: number): Promise<ProxyQualityCheckResult> {
const { data } = await apiClient.post<ProxyQualityCheckResult>(`/admin/proxies/${id}/quality-check`)
return data
}
/**
* Get proxy usage statistics
* @param id - Proxy ID
@@ -248,6 +259,7 @@ export const proxiesAPI = {
delete: deleteProxy,
toggleStatus,
testProxy,
checkProxyQuality,
getStats,
getProxyAccounts,
batchCreate,