mirror of
https://github.com/fawney19/Aether.git
synced 2026-10-07 18:07:47 +08:00
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:
@@ -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
|
||||
|
||||
+2
-2
@@ -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',
|
||||
})
|
||||
|
||||
+130
@@ -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,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: {
|
||||
|
||||
Reference in New Issue
Block a user