这是第一次基于http域名成功的部署版本0718v1,修改了http与https都兼容的认证方式
This commit is contained in:
@@ -0,0 +1,2 @@
|
||||
VITE_API_BASE_URL=http://safeapi.mcp123.qzz.io:8881/api/v1
|
||||
VITE_LOG_LEVEL=warn
|
||||
BIN
Binary file not shown.
@@ -24,9 +24,9 @@
|
||||
- `POST /runs/{id}/results/{execution_id}/retry` 无请求体,沿用原 Run 的测试范围和自动仲裁配置,并以相同 `execution_id` 替换旧结果;仅 `completed / completed_with_errors` 状态允许提交。
|
||||
- `POST /runs/{id}/resume` 无请求体,返回 `skipped_count`;`POST /runs/{id}/retry-errors` 无请求体,返回 `retry_count`。两者返回 202 后必须重新获取运行详情,不能将 POST 响应视为执行完成;409 时也重新获取详情。
|
||||
- 单条重试返回 202 后按运行详情的 `poll_after_seconds`(缺省 2 秒)轮询,终态后重新获取结果。重跑期间旧结果可能暂时不存在,UI 保留“重新执行中”状态;404 刷新结果,409 刷新 Run,网络错误或 5xx 先刷新 Run,避免不确定是否受理时重复提交。
|
||||
- `POST /runs` 总是携带新的 UUID `Idempotency-Key`。
|
||||
- `POST /runs` 总是携带新的 UUID v4 `Idempotency-Key`;HTTPS 优先使用原生 `crypto.randomUUID()`,HTTP 环境使用 `crypto.getRandomValues()` 兼容生成,后端契约不变。
|
||||
- 登录和续约都会原子替换 access/refresh token 及两个过期时间。
|
||||
- access token 剩余不超过 30 分钟且用户最近 30 分钟有真实操作时,`POST /auth/refresh` 携带 UUID `Idempotency-Key`。
|
||||
- access token 剩余不超过 30 分钟且用户最近 30 分钟有真实操作时,`POST /auth/refresh` 携带同一共享生成器产生的 UUID v4 `Idempotency-Key`。
|
||||
- 网络错误和 5xx 最多尝试 3 次,且一直复用原 refresh token 和原 key;409 保留凭据并等待 `refresh_after`。
|
||||
- 普通业务请求首次 401 共享 single-flight 续约,成功后仅重放一次;只有 refresh 本身返回 401 才清除会话。
|
||||
- HTTP 204 按无响应体处理;403/404/409/422/502 映射为可操作的用户消息。
|
||||
|
||||
@@ -4,6 +4,8 @@
|
||||
|
||||
UI 页面只调用 `services/`;service 先用 `schemas/` 中的 Zod schema 校验请求,再由 `api/client.ts` 发起请求并用 Zod 校验响应。跨字段业务不变量也在 schema 层完成。错误统一进入 `errors/`,日志进入 `logging/` 并在输出前递归脱敏。
|
||||
|
||||
创建运行和 Token 续约统一通过 `idempotencyKey.ts` 生成 UUID v4 幂等键:安全上下文优先使用浏览器原生 `crypto.randomUUID()`,HTTP 或旧浏览器回退到 `crypto.getRandomValues()`;不使用可预测的 `Math.random()`。
|
||||
|
||||
```text
|
||||
pages/components → services → schemas + api client → FastAPI
|
||||
↓
|
||||
|
||||
+2
-1
@@ -2,7 +2,7 @@
|
||||
|
||||
## 测试
|
||||
|
||||
`tests/unit` 覆盖脱敏、错误归一化、主题持久化、API 响应校验、认证原子存储、single-flight 续约、幂等重试、401 单次重放、运行数据不变量、结果语义标识和运行进入终态时的最终结果同步。`tests/integration` 验证主题切换、逐条结果筛选与搜索、单条重试结果跨筛选置顶跟踪等页面交互,以及所有 service 到后端路由的映射。
|
||||
`tests/unit` 覆盖脱敏、错误归一化、主题持久化、API 响应校验、认证原子存储、single-flight 续约、HTTP/HTTPS UUID v4 幂等键生成、幂等重试、401 单次重放、运行数据不变量、结果语义标识和运行进入终态时的最终结果同步。`tests/integration` 验证主题切换、逐条结果筛选与搜索、单条重试结果跨筛选置顶跟踪等页面交互,以及所有 service 到后端路由的映射。
|
||||
|
||||
```bash
|
||||
pnpm test
|
||||
@@ -15,6 +15,7 @@ pnpm build
|
||||
- 前端不包含 `TARGET_API_KEY` 或 `JUDGE_API_KEY`,也不应在 Vite 变量中暴露它们。
|
||||
- 完整认证对象保存在 `localStorage` 以支持多标签页原子轮换,不分开写入 access/refresh token;生产环境必须使用 HTTPS 并保持严格 CSP,降低 XSS 窃取风险。
|
||||
- 续约的待处理 refresh token 和 `Idempotency-Key` 作为一组保存;网络/5xx 失败后不换 key,refresh 401 或用户主动退出才清理凭据。
|
||||
- HTTP 部署缺少 `crypto.randomUUID()` 时使用 Web Crypto `getRandomValues()` 生成标准 UUID v4;若浏览器连安全随机源也不支持则明确失败,绝不降级为 `Math.random()`。
|
||||
- logger 对 password、token、API key、Authorization 等键及嵌套数据递归脱敏。
|
||||
- UI 权限控制只用于交互,真实授权必须由后端执行。
|
||||
- 生产环境应使用 HTTPS,并将后端 CORS 限制为实际前端域名。
|
||||
|
||||
+2
-1
@@ -1,4 +1,5 @@
|
||||
import { normalizeError } from '../errors'
|
||||
import { createIdempotencyKey } from '../idempotencyKey'
|
||||
import { logger } from '../logging'
|
||||
import { tokenSchema, type AuthSession } from '../schemas/auth'
|
||||
import { activity } from './activity'
|
||||
@@ -15,7 +16,7 @@ type PendingRefresh = { refreshToken: string; idempotencyKey: string }
|
||||
const pendingFor = (refreshToken: string): PendingRefresh => {
|
||||
const saved = JSON.parse(localStorage.getItem(pendingKey) || 'null') as PendingRefresh | null
|
||||
if (saved?.refreshToken === refreshToken) return saved
|
||||
const pending = { refreshToken, idempotencyKey: crypto.randomUUID() }
|
||||
const pending = { refreshToken, idempotencyKey: createIdempotencyKey() }
|
||||
localStorage.setItem(pendingKey, JSON.stringify(pending))
|
||||
return pending
|
||||
}
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
export function createIdempotencyKey(): string {
|
||||
const source = globalThis.crypto
|
||||
if (typeof source?.randomUUID === 'function') return source.randomUUID()
|
||||
if (typeof source?.getRandomValues !== 'function') throw new Error('当前浏览器不支持安全随机数')
|
||||
|
||||
const bytes = source.getRandomValues(new Uint8Array(16))
|
||||
bytes[6] = (bytes[6] & 0x0f) | 0x40
|
||||
bytes[8] = (bytes[8] & 0x3f) | 0x80
|
||||
const hex = Array.from(bytes, (byte) => byte.toString(16).padStart(2, '0')).join('')
|
||||
return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}`
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import { useMemo, useState } from 'react'
|
||||
import { useNavigate, useParams } from 'react-router-dom'
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import { Alert, Button, Card, Empty, Input, Modal, Progress, Select, Steps, Tabs, Tag, message } from 'antd'
|
||||
@@ -26,9 +26,7 @@ export function RunDetailPage() {
|
||||
useFinalResultsSync(Boolean(run.data?.terminal), results.refetch)
|
||||
const report = useQuery({ queryKey: ['report', id], queryFn: () => services.reports.get(id), enabled: Boolean(run.data?.terminal) })
|
||||
const refresh = () => void queryClient.invalidateQueries({ queryKey: ['run', id] })
|
||||
useEffect(() => {
|
||||
if (retryingExecutionId && run.data?.terminal) setRetryingExecutionId(null)
|
||||
}, [retryingExecutionId, run.data?.terminal])
|
||||
const activeRetryingExecutionId = run.data?.terminal ? null : retryingExecutionId
|
||||
const retryResult = useMutation({
|
||||
mutationFn: (executionId: string) => services.runs.retryResult(id, executionId),
|
||||
onSuccess: (response) => {
|
||||
@@ -85,7 +83,7 @@ export function RunDetailPage() {
|
||||
<div className="stat-strip"><span><strong>{data.processed_count}</strong>已处理</span><span><strong>{data.completed_count}</strong>已完成</span><span><strong>{data.error_count}</strong>执行错误</span><span><strong>{data.selected_count}</strong>总样例</span></div>
|
||||
</Card>
|
||||
<Tabs items={[
|
||||
{ key: 'results', label: `逐条结果 (${filtered.length} / ${results.data?.length || 0})`, children: <ResultsPanel rows={filtered} executionFilter={executionFilter} verdictFilter={verdictFilter} executionIdQuery={executionIdQuery} onExecutionFilter={setExecutionFilter} onVerdictFilter={setVerdictFilter} onExecutionIdQuery={setExecutionIdQuery} loading={results.isLoading} canRetry={['completed', 'completed_with_errors'].includes(data.status)} retryingExecutionId={retryingExecutionId} followedExecutionId={followedExecutionId} submittingExecutionId={retryResult.isPending ? retryResult.variables : null} onStopFollowing={() => setFollowedExecutionId(null)} onRetry={(executionId) => Modal.confirm({ title: `确定重新执行 ${executionId} 吗?`, content: '当前测试结果将被新结果替换。', onOk: () => retryResult.mutate(executionId) })} /> },
|
||||
{ key: 'results', label: `逐条结果 (${filtered.length} / ${results.data?.length || 0})`, children: <ResultsPanel rows={filtered} executionFilter={executionFilter} verdictFilter={verdictFilter} executionIdQuery={executionIdQuery} onExecutionFilter={setExecutionFilter} onVerdictFilter={setVerdictFilter} onExecutionIdQuery={setExecutionIdQuery} loading={results.isLoading} canRetry={['completed', 'completed_with_errors'].includes(data.status)} retryingExecutionId={activeRetryingExecutionId} followedExecutionId={followedExecutionId} submittingExecutionId={retryResult.isPending ? retryResult.variables : null} onStopFollowing={() => setFollowedExecutionId(null)} onRetry={(executionId) => Modal.confirm({ title: `确定重新执行 ${executionId} 吗?`, content: '当前测试结果将被新结果替换。', onOk: () => retryResult.mutate(executionId) })} /> },
|
||||
{ key: 'report', label: '汇总报告', children: report.data ? <ReportSummary summary={report.data.summary} /> : <Empty description={data.terminal ? '暂无报告' : '运行结束后生成报告'} /> },
|
||||
]} />
|
||||
</>
|
||||
|
||||
@@ -1,12 +1,11 @@
|
||||
import type { ApiClient } from '../api/client'
|
||||
import { createIdempotencyKey } from '../idempotencyKey'
|
||||
import { resultListSchema, retryErrorsSchema, retryResultSchema, runDetailSchema, runListSchema, runResponseSchema, startRunSchema, resumeRunSchema, type StartRunInput } from '../schemas/runs'
|
||||
|
||||
const idempotencyKey = () => crypto.randomUUID()
|
||||
|
||||
export const runService = (api: ApiClient) => ({
|
||||
list: (limit = 50) => api.request(`/runs?limit=${limit}`, { schema: runListSchema }),
|
||||
get: (id: number) => api.request(`/runs/${id}`, { schema: runDetailSchema }),
|
||||
start: (input: StartRunInput, key = idempotencyKey()) =>
|
||||
start: (input: StartRunInput, key = createIdempotencyKey()) =>
|
||||
api.request('/runs', { method: 'POST', headers: { 'Idempotency-Key': key }, body: startRunSchema.parse(input), schema: runResponseSchema }),
|
||||
cancel: (id: number) => api.request(`/runs/${id}`, { method: 'PATCH', body: { status: 'cancelled' }, schema: runDetailSchema }),
|
||||
remove: (id: number) => api.request(`/runs/${id}`, { method: 'DELETE' }),
|
||||
|
||||
@@ -147,7 +147,7 @@ describe('page mounting and route security', () => {
|
||||
|
||||
expect(mocks.resume).toHaveBeenCalledWith(1)
|
||||
expect(await screen.findByText('运行已恢复,将跳过 60 条已有结果')).toBeInTheDocument()
|
||||
})
|
||||
}, 20_000)
|
||||
|
||||
it('only retries runs completed with errors and reports the queued count', async () => {
|
||||
mocks.run.mockResolvedValueOnce({ ...run, status: 'completed_with_errors', error_count: 5, processed_count: 5 })
|
||||
@@ -159,13 +159,15 @@ describe('page mounting and route security', () => {
|
||||
|
||||
expect(mocks.retryErrors).toHaveBeenCalledWith(1)
|
||||
expect(await screen.findByText('已重新提交 5 条错误样例')).toBeInTheDocument()
|
||||
})
|
||||
}, 20_000)
|
||||
|
||||
it('confirms and retries one result, then locks every result retry button', async () => {
|
||||
mocks.run.mockResolvedValueOnce(run).mockResolvedValue({ ...run, status: 'pending', terminal: false, phase: 'pending', progress_percent: 0, poll_after_seconds: 2 })
|
||||
const otherResult = { execution_id: 'R0050', case_kind: 'control', interaction_mode: 'single_turn', execution_status: 'completed', verdict: 'pass', model_input: {}, model_response: 'response', judge_result: {}, error_message: '' }
|
||||
mocks.results.mockResolvedValueOnce([
|
||||
{ execution_id: 'R0049', case_kind: 'risk', interaction_mode: 'single_turn', execution_status: 'completed', verdict: 'fail', model_input: {}, model_response: 'response', judge_result: {}, error_message: '' },
|
||||
{ execution_id: 'R0050', case_kind: 'control', interaction_mode: 'single_turn', execution_status: 'completed', verdict: 'pass', model_input: {}, model_response: 'response', judge_result: {}, error_message: '' },
|
||||
])
|
||||
otherResult,
|
||||
]).mockResolvedValue([otherResult])
|
||||
mount('/runs/1')
|
||||
|
||||
await userEvent.click(await screen.findByRole('button', { name: '重新执行 R0049' }))
|
||||
@@ -173,9 +175,9 @@ describe('page mounting and route security', () => {
|
||||
await userEvent.click(screen.getByRole('button', { name: 'OK' }))
|
||||
|
||||
expect(mocks.retryResult).toHaveBeenCalledWith(1, 'R0049')
|
||||
expect(await screen.findByRole('button', { name: '重新执行中…' })).toBeDisabled()
|
||||
expect(await screen.findByText('R0049 重新执行中…')).toBeInTheDocument()
|
||||
expect(screen.getByRole('button', { name: '重新执行 R0050' })).toBeDisabled()
|
||||
})
|
||||
}, 20_000)
|
||||
|
||||
it('keeps a tracked retry visible when its verdict no longer matches the filter', async () => {
|
||||
mocks.results.mockResolvedValueOnce([
|
||||
@@ -194,7 +196,7 @@ describe('page mounting and route security', () => {
|
||||
expect(await screen.findByText('结论:通过')).toBeInTheDocument()
|
||||
expect(screen.getAllByText('仲裁结论:人工复核')).not.toHaveLength(0)
|
||||
expect(screen.getByText('正在跟踪 R0079;即使不符合当前筛选也会置顶显示。')).toBeInTheDocument()
|
||||
})
|
||||
}, 20_000)
|
||||
|
||||
it('refreshes run details after an action conflict', async () => {
|
||||
mocks.run.mockResolvedValueOnce({ ...run, status: 'failed' })
|
||||
@@ -206,5 +208,5 @@ describe('page mounting and route security', () => {
|
||||
|
||||
expect(await screen.findByText('当前状态不允许恢复')).toBeInTheDocument()
|
||||
expect(mocks.run).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
}, 20_000)
|
||||
})
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { createIdempotencyKey } from '../../src/idempotencyKey'
|
||||
|
||||
afterEach(() => vi.unstubAllGlobals())
|
||||
|
||||
describe('createIdempotencyKey', () => {
|
||||
it('prefers the native UUID implementation', () => {
|
||||
const randomUUID = vi.fn(() => 'native-uuid')
|
||||
vi.stubGlobal('crypto', { randomUUID, getRandomValues: vi.fn() })
|
||||
|
||||
expect(createIdempotencyKey()).toBe('native-uuid')
|
||||
expect(randomUUID).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('generates an RFC 4122 UUID v4 when randomUUID is unavailable', () => {
|
||||
vi.stubGlobal('crypto', { getRandomValues: (bytes: Uint8Array) => bytes.map((_, index) => index) })
|
||||
|
||||
expect(createIdempotencyKey()).toBe('00010203-0405-4607-8809-0a0b0c0d0e0f')
|
||||
})
|
||||
|
||||
it('fails explicitly when no secure random source exists', () => {
|
||||
vi.stubGlobal('crypto', {})
|
||||
|
||||
expect(() => createIdempotencyKey()).toThrow('当前浏览器不支持安全随机数')
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user