这是第一次基于http域名成功的部署版本0718v1,修改了http与https都兼容的认证方式

This commit is contained in:
baozaotumao2025
2026-07-18 23:18:16 +08:00
parent 1b90e552a5
commit 789d4fc4e9
11 changed files with 62 additions and 20 deletions
+2
View File
@@ -0,0 +1,2 @@
VITE_API_BASE_URL=http://safeapi.mcp123.qzz.io:8881/api/v1
VITE_LOG_LEVEL=warn
BIN
View File
Binary file not shown.
+2 -2
View File
@@ -24,9 +24,9 @@
- `POST /runs/{id}/results/{execution_id}/retry` 无请求体,沿用原 Run 的测试范围和自动仲裁配置,并以相同 `execution_id` 替换旧结果;仅 `completed / completed_with_errors` 状态允许提交。 - `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 时也重新获取详情。 - `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,避免不确定是否受理时重复提交。 - 单条重试返回 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/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 和原 key409 保留凭据并等待 `refresh_after` - 网络错误和 5xx 最多尝试 3 次,且一直复用原 refresh token 和原 key409 保留凭据并等待 `refresh_after`
- 普通业务请求首次 401 共享 single-flight 续约,成功后仅重放一次;只有 refresh 本身返回 401 才清除会话。 - 普通业务请求首次 401 共享 single-flight 续约,成功后仅重放一次;只有 refresh 本身返回 401 才清除会话。
- HTTP 204 按无响应体处理;403/404/409/422/502 映射为可操作的用户消息。 - HTTP 204 按无响应体处理;403/404/409/422/502 映射为可操作的用户消息。
+2
View File
@@ -4,6 +4,8 @@
UI 页面只调用 `services/`service 先用 `schemas/` 中的 Zod schema 校验请求,再由 `api/client.ts` 发起请求并用 Zod 校验响应。跨字段业务不变量也在 schema 层完成。错误统一进入 `errors/`,日志进入 `logging/` 并在输出前递归脱敏。 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 ```text
pages/components → services → schemas + api client → FastAPI pages/components → services → schemas + api client → FastAPI
+2 -1
View File
@@ -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 ```bash
pnpm test pnpm test
@@ -15,6 +15,7 @@ pnpm build
- 前端不包含 `TARGET_API_KEY``JUDGE_API_KEY`,也不应在 Vite 变量中暴露它们。 - 前端不包含 `TARGET_API_KEY``JUDGE_API_KEY`,也不应在 Vite 变量中暴露它们。
- 完整认证对象保存在 `localStorage` 以支持多标签页原子轮换,不分开写入 access/refresh token;生产环境必须使用 HTTPS 并保持严格 CSP,降低 XSS 窃取风险。 - 完整认证对象保存在 `localStorage` 以支持多标签页原子轮换,不分开写入 access/refresh token;生产环境必须使用 HTTPS 并保持严格 CSP,降低 XSS 窃取风险。
- 续约的待处理 refresh token 和 `Idempotency-Key` 作为一组保存;网络/5xx 失败后不换 key,refresh 401 或用户主动退出才清理凭据。 - 续约的待处理 refresh token 和 `Idempotency-Key` 作为一组保存;网络/5xx 失败后不换 key,refresh 401 或用户主动退出才清理凭据。
- HTTP 部署缺少 `crypto.randomUUID()` 时使用 Web Crypto `getRandomValues()` 生成标准 UUID v4;若浏览器连安全随机源也不支持则明确失败,绝不降级为 `Math.random()`
- logger 对 password、token、API key、Authorization 等键及嵌套数据递归脱敏。 - logger 对 password、token、API key、Authorization 等键及嵌套数据递归脱敏。
- UI 权限控制只用于交互,真实授权必须由后端执行。 - UI 权限控制只用于交互,真实授权必须由后端执行。
- 生产环境应使用 HTTPS,并将后端 CORS 限制为实际前端域名。 - 生产环境应使用 HTTPS,并将后端 CORS 限制为实际前端域名。
+2 -1
View File
@@ -1,4 +1,5 @@
import { normalizeError } from '../errors' import { normalizeError } from '../errors'
import { createIdempotencyKey } from '../idempotencyKey'
import { logger } from '../logging' import { logger } from '../logging'
import { tokenSchema, type AuthSession } from '../schemas/auth' import { tokenSchema, type AuthSession } from '../schemas/auth'
import { activity } from './activity' import { activity } from './activity'
@@ -15,7 +16,7 @@ type PendingRefresh = { refreshToken: string; idempotencyKey: string }
const pendingFor = (refreshToken: string): PendingRefresh => { const pendingFor = (refreshToken: string): PendingRefresh => {
const saved = JSON.parse(localStorage.getItem(pendingKey) || 'null') as PendingRefresh | null const saved = JSON.parse(localStorage.getItem(pendingKey) || 'null') as PendingRefresh | null
if (saved?.refreshToken === refreshToken) return saved if (saved?.refreshToken === refreshToken) return saved
const pending = { refreshToken, idempotencyKey: crypto.randomUUID() } const pending = { refreshToken, idempotencyKey: createIdempotencyKey() }
localStorage.setItem(pendingKey, JSON.stringify(pending)) localStorage.setItem(pendingKey, JSON.stringify(pending))
return pending return pending
} }
+11
View File
@@ -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)}`
}
+3 -5
View File
@@ -1,4 +1,4 @@
import { useEffect, useMemo, useState } from 'react' import { useMemo, useState } from 'react'
import { useNavigate, useParams } from 'react-router-dom' import { useNavigate, useParams } from 'react-router-dom'
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { Alert, Button, Card, Empty, Input, Modal, Progress, Select, Steps, Tabs, Tag, message } from 'antd' 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) useFinalResultsSync(Boolean(run.data?.terminal), results.refetch)
const report = useQuery({ queryKey: ['report', id], queryFn: () => services.reports.get(id), enabled: Boolean(run.data?.terminal) }) const report = useQuery({ queryKey: ['report', id], queryFn: () => services.reports.get(id), enabled: Boolean(run.data?.terminal) })
const refresh = () => void queryClient.invalidateQueries({ queryKey: ['run', id] }) const refresh = () => void queryClient.invalidateQueries({ queryKey: ['run', id] })
useEffect(() => { const activeRetryingExecutionId = run.data?.terminal ? null : retryingExecutionId
if (retryingExecutionId && run.data?.terminal) setRetryingExecutionId(null)
}, [retryingExecutionId, run.data?.terminal])
const retryResult = useMutation({ const retryResult = useMutation({
mutationFn: (executionId: string) => services.runs.retryResult(id, executionId), mutationFn: (executionId: string) => services.runs.retryResult(id, executionId),
onSuccess: (response) => { 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> <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> </Card>
<Tabs items={[ <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 ? '暂无报告' : '运行结束后生成报告'} /> }, { key: 'report', label: '汇总报告', children: report.data ? <ReportSummary summary={report.data.summary} /> : <Empty description={data.terminal ? '暂无报告' : '运行结束后生成报告'} /> },
]} /> ]} />
</> </>
+2 -3
View File
@@ -1,12 +1,11 @@
import type { ApiClient } from '../api/client' import type { ApiClient } from '../api/client'
import { createIdempotencyKey } from '../idempotencyKey'
import { resultListSchema, retryErrorsSchema, retryResultSchema, runDetailSchema, runListSchema, runResponseSchema, startRunSchema, resumeRunSchema, type StartRunInput } from '../schemas/runs' import { resultListSchema, retryErrorsSchema, retryResultSchema, runDetailSchema, runListSchema, runResponseSchema, startRunSchema, resumeRunSchema, type StartRunInput } from '../schemas/runs'
const idempotencyKey = () => crypto.randomUUID()
export const runService = (api: ApiClient) => ({ export const runService = (api: ApiClient) => ({
list: (limit = 50) => api.request(`/runs?limit=${limit}`, { schema: runListSchema }), list: (limit = 50) => api.request(`/runs?limit=${limit}`, { schema: runListSchema }),
get: (id: number) => api.request(`/runs/${id}`, { schema: runDetailSchema }), 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 }), 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 }), cancel: (id: number) => api.request(`/runs/${id}`, { method: 'PATCH', body: { status: 'cancelled' }, schema: runDetailSchema }),
remove: (id: number) => api.request(`/runs/${id}`, { method: 'DELETE' }), remove: (id: number) => api.request(`/runs/${id}`, { method: 'DELETE' }),
+10 -8
View File
@@ -147,7 +147,7 @@ describe('page mounting and route security', () => {
expect(mocks.resume).toHaveBeenCalledWith(1) expect(mocks.resume).toHaveBeenCalledWith(1)
expect(await screen.findByText('运行已恢复,将跳过 60 条已有结果')).toBeInTheDocument() expect(await screen.findByText('运行已恢复,将跳过 60 条已有结果')).toBeInTheDocument()
}) }, 20_000)
it('only retries runs completed with errors and reports the queued count', async () => { 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 }) 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(mocks.retryErrors).toHaveBeenCalledWith(1)
expect(await screen.findByText('已重新提交 5 条错误样例')).toBeInTheDocument() expect(await screen.findByText('已重新提交 5 条错误样例')).toBeInTheDocument()
}) }, 20_000)
it('confirms and retries one result, then locks every result retry button', async () => { 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([ 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: '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') mount('/runs/1')
await userEvent.click(await screen.findByRole('button', { name: '重新执行 R0049' })) 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' })) await userEvent.click(screen.getByRole('button', { name: 'OK' }))
expect(mocks.retryResult).toHaveBeenCalledWith(1, 'R0049') 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() expect(screen.getByRole('button', { name: '重新执行 R0050' })).toBeDisabled()
}) }, 20_000)
it('keeps a tracked retry visible when its verdict no longer matches the filter', async () => { it('keeps a tracked retry visible when its verdict no longer matches the filter', async () => {
mocks.results.mockResolvedValueOnce([ mocks.results.mockResolvedValueOnce([
@@ -194,7 +196,7 @@ describe('page mounting and route security', () => {
expect(await screen.findByText('结论:通过')).toBeInTheDocument() expect(await screen.findByText('结论:通过')).toBeInTheDocument()
expect(screen.getAllByText('仲裁结论:人工复核')).not.toHaveLength(0) expect(screen.getAllByText('仲裁结论:人工复核')).not.toHaveLength(0)
expect(screen.getByText('正在跟踪 R0079;即使不符合当前筛选也会置顶显示。')).toBeInTheDocument() expect(screen.getByText('正在跟踪 R0079;即使不符合当前筛选也会置顶显示。')).toBeInTheDocument()
}) }, 20_000)
it('refreshes run details after an action conflict', async () => { it('refreshes run details after an action conflict', async () => {
mocks.run.mockResolvedValueOnce({ ...run, status: 'failed' }) mocks.run.mockResolvedValueOnce({ ...run, status: 'failed' })
@@ -206,5 +208,5 @@ describe('page mounting and route security', () => {
expect(await screen.findByText('当前状态不允许恢复')).toBeInTheDocument() expect(await screen.findByText('当前状态不允许恢复')).toBeInTheDocument()
expect(mocks.run).toHaveBeenCalledTimes(2) expect(mocks.run).toHaveBeenCalledTimes(2)
}) }, 20_000)
}) })
+26
View File
@@ -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('当前浏览器不支持安全随机数')
})
})