fix: restore security hardening compatibility and validation

Restore authorized rule reveal, explicit full HTTP capture and retention, video task business fields, and valid payment URLs. Add opt-in credential preservation for trusted recovery, fix frontend type contracts and async races, and eliminate PostgreSQL test fixture resource leaks. Document audit coverage and successful fmt and CI-scoped Clippy checks.
This commit is contained in:
elky
2026-09-07 21:14:27 +08:00
parent a5c3699ae9
commit a90d564931
191 changed files with 6785 additions and 1643 deletions
@@ -42,12 +42,13 @@ describe('usage token normalization', () => {
})
it('does not subtract cache read tokens for Claude usage', () => {
expect(getEffectiveInputTokens({
const usage = {
input_tokens: 4941,
cache_creation_input_tokens: 687,
cache_read_input_tokens: 52873,
output_tokens: 973,
api_format: 'claude:messages',
})).toBe(4941)
}
expect(getEffectiveInputTokens(usage)).toBe(4941)
})
})
@@ -564,7 +564,7 @@ import Badge from '@/components/ui/badge.vue'
import Skeleton from '@/components/ui/skeleton.vue'
import JsonContentPanel from './JsonContentPanel.vue'
import { ChevronLeft, ChevronRight, ExternalLink } from 'lucide-vue-next'
import { requestTraceApi, type RequestTrace, type CandidateRecord, type ImageProgress } from '@/api/requestTrace'
import { requestTraceApi, type RequestTrace, type CandidateProxy, type CandidateRecord, type ImageProgress } from '@/api/requestTrace'
import { log } from '@/utils/logger'
import { safeExternalWebUrl } from '@/utils/navigationSecurity'
import { parseApiError } from '@/utils/errorParser'
@@ -722,8 +722,8 @@ const formatSize = (bytes: number): string => {
}
// 代理 timing 分阶段展示
const proxyTimingBreakdown = (proxy: Record<string, unknown>): string => {
const t = proxy.timing as Record<string, number | null | undefined> | undefined
const proxyTimingBreakdown = (proxy: CandidateProxy): string => {
const t = proxy.timing
if (!t) return ''
const parts: string[] = []
@@ -2112,7 +2112,8 @@ const navigateAttempt = (direction: number) => {
// 加载请求追踪数据
const isSilentRefresh = ref(false)
const loadTrace = async (silent = false) => {
if (!props.requestId || props.traceData) return
const requestId = props.requestId
if (!requestId || props.traceData) return
if (traceLoadInFlight) return traceLoadInFlight
traceLoadInFlight = (async () => {
@@ -2125,7 +2126,7 @@ const loadTrace = async (silent = false) => {
error.value = null
try {
internalTrace.value = await requestTraceApi.getRequestTrace(props.requestId, { attemptedOnly: true })
internalTrace.value = await requestTraceApi.getRequestTrace(requestId, { attemptedOnly: true })
} catch (err: unknown) {
if (isAxiosError(err) && err.response?.status === 404) {
internalTrace.value = null
@@ -54,12 +54,13 @@
</template>
<script setup lang="ts">
import type { TimeScatterChartData } from '@/components/charts/types'
import { computed, ref, onMounted, onBeforeUnmount, watch } from 'vue'
import Card from '@/components/ui/card.vue'
import ScatterChart from '@/components/charts/ScatterChart.vue'
import { cacheAnalysisApi, type IntervalTimelineResponse } from '@/api/cache'
import { meApi } from '@/api/me'
import type { ChartData, ChartOptions } from 'chart.js'
import type { ChartOptions } from 'chart.js'
import { log } from '@/utils/logger'
const props = withDefaults(defineProps<{
@@ -186,7 +187,7 @@ function formatModelName(model: string): string {
}
// 构建图表数据
const chartData = computed<ChartData<'scatter'>>(() => {
const chartData = computed<TimeScatterChartData>(() => {
if (!timelineData.value?.points) {
return { datasets: [] }
}
@@ -728,7 +728,7 @@
:detail="detail"
:view-mode="viewMode"
:data-source="dataSource"
:current-header-data="currentHeaderData"
:current-header-data="currentHeaderData ?? null"
:current-expand-depth="currentExpandDepth"
:has-provider-headers="hasProviderHeaders"
:header-stats="headerStats"
@@ -782,7 +782,7 @@
:detail="detail"
:view-mode="viewMode"
:data-source="dataSource"
:current-header-data="currentResponseHeaderData"
:current-header-data="currentResponseHeaderData ?? null"
:current-expand-depth="currentExpandDepth"
:has-provider-headers="hasProviderResponseHeaders"
:header-stats="responseHeaderStats"
@@ -1412,7 +1412,7 @@ const TIMELINE_MOUNT_DELAY_MS = 120
let loadDetailRequestId = 0
let bodyLoadRequestId = 0
let loadDetailInFlight = false
let timelineMountTimer: ReturnType<typeof setTimeout> | null = null
let timelineMountTimer: number | null = null
const fullRequestId = computed(() => detail.value?.request_id || detail.value?.id || '-')
const displayRequestId = computed(() => formatShortRequestId(fullRequestId.value))
@@ -1990,7 +1990,7 @@ const effectiveCacheCreationCost = computed(() => {
getNestedNumber(billingCostBreakdown.value, 'cache_creation_uncategorized_cost'),
getNestedNumber(billingCostBreakdown.value, 'cache_creation_ephemeral_5m_cost'),
getNestedNumber(billingCostBreakdown.value, 'cache_creation_ephemeral_1h_cost'),
].reduce((sum, value) => sum + (value ?? 0), 0)
].reduce<number>((sum, value) => sum + (value ?? 0), 0)
if (snapshotCost > 0) return snapshotCost
return toNumber(detail.value?.cache_creation_cost) ?? 0
})
@@ -115,10 +115,8 @@ interface DisplayLine extends JsonLine {
}
/** JSON data can be any serializable value: object, array, string, number, boolean, null */
type JsonValue = Record<string, unknown> | unknown[] | string | number | boolean | null | undefined
const props = defineProps<{
data: JsonValue
data: unknown
viewMode: 'formatted' | 'raw' | 'compare'
expandDepth: number
isDark: boolean
@@ -171,8 +171,8 @@ const props = withDefaults(defineProps<{
headerStats: { added: number; modified: number; removed: number; unchanged: number }
isDark: boolean
// 泛化 props:允许传入任意 header 对和标签,用于复用为响应头对比
clientHeaders?: Record<string, unknown>
providerHeaders?: Record<string, unknown>
clientHeaders?: Record<string, unknown> | null
providerHeaders?: Record<string, unknown> | null
clientLabel?: string
providerLabel?: string
emptyMessage?: string
@@ -189,7 +189,7 @@ async function loadUsers(search: string) {
const options = result.map((user) => ({
id: user.id,
username: user.username,
email: user.email,
email: user.email ?? '',
}))
if (!search.trim()) loadedInitialBatch = true
users.value = options
@@ -521,7 +521,7 @@ describe('HorizontalRequestTimeline', () => {
})
await nextTick()
const lastCall = onTraceState.mock.calls.at(-1)?.[0]
const lastCall = onTraceState.mock.calls[onTraceState.mock.calls.length - 1]?.[0]
expect(lastCall).toMatchObject({
finalStatus: 'streaming',
})
@@ -0,0 +1,130 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
import { createApp, defineComponent, h, nextTick, ref, type App } from 'vue'
import type { RequestDetail } from '@/api/dashboard'
import RequestDetailDrawer from '../RequestDetailDrawer.vue'
const apiMocks = vi.hoisted(() => ({ getRequestDetail: vi.fn() }))
vi.mock('@/api/dashboard', async (importOriginal) => {
const actual = await importOriginal<typeof import('@/api/dashboard')>()
return {
...actual,
dashboardApi: { ...actual.dashboardApi, getRequestDetail: apiMocks.getRequestDetail },
}
})
vi.mock('../HorizontalRequestTimeline.vue', () => ({ default: { render: () => null } }))
vi.mock('../JsonContentPanel.vue', async () => {
const { defineComponent, h } = await import('vue')
return {
default: defineComponent({
props: { data: { type: null, default: null } },
setup(props) {
return () => h('pre', { 'data-testid': 'captured-body' }, JSON.stringify(props.data))
},
}),
}
})
const mountedApps: Array<{ app: App, root: HTMLElement }> = []
afterEach(() => {
for (const { app, root } of mountedApps.splice(0)) {
app.unmount()
root.remove()
}
apiMocks.getRequestDetail.mockReset()
})
function buildDetail(captured: boolean): RequestDetail {
return {
id: 'usage-full-capture',
request_id: 'req-full-capture',
user: { id: 'user-1', username: 'test-user', email: '[email protected]' },
api_key: { id: 'key-1', name: 'test-key', display: 'test-key' },
provider: 'test-provider',
api_format: 'openai:chat',
model: 'test-model',
tokens: { input: 10, output: 20, total: 30 },
cost: { input: 0, output: 0, total: 0 },
request_type: 'chat',
is_stream: false,
status: 'completed',
status_code: 200,
response_time_ms: 10,
created_at: '2026-09-07T00:00:00Z',
request_headers: { 'content-type': 'application/json', authorization: '[redacted]' },
has_request_body: captured,
has_provider_request_body: false,
has_response_body: captured,
has_client_response_body: false,
}
}
async function openDrawer() {
const isOpen = ref(false)
const Host = defineComponent({
setup: () => () => h(RequestDetailDrawer, {
isOpen: isOpen.value,
requestId: 'usage-full-capture',
}),
})
const root = document.createElement('div')
document.body.appendChild(root)
const app = createApp(Host)
app.mount(root)
mountedApps.push({ app, root })
isOpen.value = true
await nextTick()
await vi.waitFor(() => {
expect(document.body.textContent).toContain('请求头')
})
}
function findTab(label: string) {
return [...document.body.querySelectorAll('button')]
.find(button => button.textContent?.trim() === label)
}
describe('RequestDetailDrawer body capture', () => {
it('keeps body tabs from shallow availability and loads full captures on demand', async () => {
const shallow = buildDetail(true)
const full: RequestDetail = {
...shallow,
request_body: { messages: [{ role: 'user', content: 'captured request text' }] },
response_body: { choices: [{ message: { role: 'assistant', content: 'captured response text' } }] },
}
apiMocks.getRequestDetail.mockImplementation(async (_requestId, options) => (
options?.includeBodies ? full : shallow
))
await openDrawer()
expect(findTab('请求体')).toBeDefined()
expect(findTab('响应体')).toBeDefined()
expect(apiMocks.getRequestDetail).toHaveBeenCalledTimes(1)
expect(apiMocks.getRequestDetail).toHaveBeenCalledWith('usage-full-capture', expect.objectContaining({ includeBodies: false }))
findTab('请求体')!.click()
await vi.waitFor(() => {
expect(document.body.querySelector('[data-testid="captured-body"]')?.textContent)
.toContain('captured request text')
})
expect(apiMocks.getRequestDetail).toHaveBeenLastCalledWith('usage-full-capture', { includeBodies: true })
findTab('响应体')!.click()
await nextTick()
expect(document.body.querySelector('[data-testid="captured-body"]')?.textContent)
.toContain('captured response text')
expect(apiMocks.getRequestDetail).toHaveBeenCalledTimes(2)
})
it('does not offer body tabs or fetch uncaptured bodies for basic records', async () => {
apiMocks.getRequestDetail.mockResolvedValue(buildDetail(false))
await openDrawer()
expect(findTab('请求体')).toBeUndefined()
expect(findTab('响应体')).toBeUndefined()
expect(apiMocks.getRequestDetail).toHaveBeenCalledTimes(1)
})
})
@@ -160,7 +160,7 @@ export function useUsageData(options: UseUsageDataOptions) {
}
// statsData may contain additional fields not declared in UsageStats
const statsRaw = statsData as Record<string, unknown>
const statsRaw = statsData
stats.value = {
total_requests: statsData.total_requests || 0,
total_tokens: statsData.total_tokens || 0,
@@ -187,7 +187,7 @@ export function useUsageData(options: UseUsageDataOptions) {
}
modelStats.value = modelData.map(item => {
const raw = item as Record<string, unknown>
const raw = item
return {
model: item.model,
request_count: item.request_count || 0,
@@ -422,7 +422,7 @@ export function useUsageData(options: UseUsageDataOptions) {
if (requestId !== loadRecordsRequestId) {
return
}
const nextRecords = (response.records || []) as UsageRecord[]
const nextRecords = (response.records || [])
currentRecords.value = mergeRecordStatus(currentRecords.value, nextRecords)
const totalKey = buildAdminRecordTotalKey(params)
applyAdminRecordTotal(totalKey, response.total ?? 0, response.total_is_estimated === true)
@@ -524,8 +524,8 @@ export function useUsageData(options: UseUsageDataOptions) {
// 确定是否需要保护 status(避免刷新把已知状态覆盖为 undefined 或回退)
const hasExistingStatus = typeof existing.status === 'string' && existing.status.length > 0
const hasNextStatus = typeof record.status === 'string' && record.status.length > 0
const currentRank = hasExistingStatus ? (statusPriority[existing.status] ?? -1) : -1
const nextRank = hasNextStatus ? (statusPriority[record.status] ?? -1) : -1
const currentRank = hasExistingStatus ? (statusPriority[existing.status ?? ''] ?? -1) : -1
const nextRank = hasNextStatus ? (statusPriority[record.status ?? ''] ?? -1) : -1
const existingUpdatedAtMs = parseUsageTimestampMs(existing.updated_at)
const nextUpdatedAtMs = parseUsageTimestampMs(record.updated_at)
const nextStatusSnapshotIsStale = existingUpdatedAtMs != null &&
+1 -69
View File
@@ -1,4 +1,4 @@
import type { ImageProgress } from '@/api/requestTrace'
export type { UsageRecord, RequestStatus } from '@/api/usageRecords'
// 统计数据状态
export interface UsageStatsState {
@@ -77,74 +77,6 @@ export interface ApiFormatStatsItem {
// 请求记录
// 请求状态类型
export type RequestStatus = 'pending' | 'streaming' | 'completed' | 'failed' | 'cancelled'
export interface UsageRecord {
id: string
user_id?: string
username?: string
user_email?: string
api_key?: {
id: string | null
name: string | null
display: string | null
} | null
provider?: string // 仅管理员可见
api_key_name?: string
provider_key_name?: string | null
rate_multiplier?: number
model: string
target_model?: string | null // 映射后的目标模型名(若无映射则为空)
model_version?: string | null // Provider 返回的实际模型版本(列表轻量字段)
request_type?: string | null // 由请求语义识别出的操作类型
requested_reasoning_effort?: string | null // 用户请求侧 reasoning 级别,用于展示转换关系
reasoning_effort?: string | null // 从发送给 Provider 的请求体提取的 reasoning 级别
service_tier?: string | null // 从发送给 Provider 的请求体提取的服务层级
actual_service_tier?: string | null // 响应侧审计事实,不用于 Fast 展示或计费
api_format?: string
endpoint_api_format?: string // 端点原生格式
has_format_conversion?: boolean // 是否发生了格式转换
input_tokens: number
effective_input_tokens?: number
output_tokens: number
reasoning_tokens?: number
cache_creation_input_tokens?: number
cache_creation_ephemeral_5m_input_tokens?: number
cache_creation_ephemeral_1h_input_tokens?: number
cache_read_input_tokens?: number
total_tokens: number
cost: number
actual_cost?: number
response_time_ms?: number | null
first_byte_time_ms?: number | null // 首字时间 (TTFB)
end_to_end_time_ms?: number | null // 客户端从请求进入网关到完成的总耗时
end_to_end_first_byte_time_ms?: number | null // 客户端从请求进入网关到首字节的耗时
is_stream: boolean
is_websocket?: boolean
websocket_transport?: string | null
usage_available?: boolean
usage_pricing_available?: boolean
input_audio_tokens?: number | null
output_audio_tokens?: number | null
upstream_is_stream?: boolean
client_requested_stream?: boolean
client_is_stream?: boolean
client_family?: string | null
client_ip?: string | null
user_agent?: string | null
request_path?: string | null
request_path_and_query?: string | null
status_code?: number
error_message?: string
status?: RequestStatus // 请求状态: pending, streaming, completed, failed
created_at: string
updated_at?: string | null
response_time_updated_at?: string | null
has_fallback?: boolean
has_retry?: boolean
image_progress?: ImageProgress | null
}
// 日期范围参数
export interface DateRangeParams {
start_date?: string
@@ -9,7 +9,7 @@ import {
describe('service tier facts', () => {
it('uses the final provider request tier for display and billing', () => {
const source = {
const source: Record<string, unknown> = {
service_tier: 'priority',
actual_service_tier: 'default',
settlement: {
@@ -29,7 +29,7 @@ describe('service tier facts', () => {
})
it('does not infer a tier from the provider response or settlement snapshot', () => {
const source = {
const source: Record<string, unknown> = {
actual_service_tier: 'flex',
settlement: {
settlement_snapshot: {