diff --git a/components/admin/AiModelManagementPanel.test.tsx b/components/admin/AiModelManagementPanel.test.tsx index c81fdfa..d71e5f5 100644 --- a/components/admin/AiModelManagementPanel.test.tsx +++ b/components/admin/AiModelManagementPanel.test.tsx @@ -24,6 +24,41 @@ function jointConfiguration() { next.providers[0].models[2].capabilities.combinedTextTest = true return next } +test('浏览器没有 randomUUID 时仍能提交一个有效 UUID 的联合测试', async () => { + const original = Object.getOwnPropertyDescriptor(globalThis.crypto, 'randomUUID') + Object.defineProperty(globalThis.crypto, 'randomUUID', { configurable: true, value: undefined }) + try { + const next = jointConfiguration() + global.fetch = jest.fn(async () => response(next)) as typeof fetch + render() + fireEvent.click(await screen.findByRole('button', { name: '测试 new-vision-model · 现有上游 的分析与文案' })) + fireEvent.click(within(await screen.findByRole('dialog')).getByRole('button', { name: '开始测试' })) + await waitFor(() => expect((global.fetch as jest.Mock).mock.calls.filter(([url]) => url.endsWith('/verify'))).toHaveLength(1)) + const call = (global.fetch as jest.Mock).mock.calls.find(([url]) => url.endsWith('/verify')) + expect(JSON.parse(call[1].body)).toMatchObject({ stage: 'analysis-copy', idempotencyKey: expect.stringMatching(/^[a-f0-9]{8}-[a-f0-9]{4}-4[a-f0-9]{3}-[89ab][a-f0-9]{3}-[a-f0-9]{12}$/) }) + expect(screen.queryByRole('alert')).not.toBeInTheDocument() + } finally { if (original) Object.defineProperty(globalThis.crypto, 'randomUUID', original); else delete (globalThis.crypto as any).randomUUID } +}) +test('编号生成失败不会提交、刷新或伪造未知状态,恢复后可正常重试', async () => { + const descriptor = Object.getOwnPropertyDescriptor(globalThis, 'crypto')!, original = globalThis.crypto + Object.defineProperty(globalThis, 'crypto', { configurable: true, value: {} }) + try { + const next = jointConfiguration() + global.fetch = jest.fn(async () => response(next)) as typeof fetch + render() + const button = await screen.findByRole('button', { name: '测试 new-vision-model · 现有上游 的分析与文案' }) + fireEvent.click(button) + fireEvent.click(within(await screen.findByRole('dialog')).getByRole('button', { name: '开始测试' })) + expect(await screen.findByRole('alert')).toHaveTextContent('当前浏览器无法生成请求编号,请更新浏览器后再试。测试尚未提交。') + expect(global.fetch).toHaveBeenCalledTimes(1) + expect(screen.queryByRole('button', { name: /核查 new-vision-model/ })).not.toBeInTheDocument() + expect(button).toBeEnabled() + Object.defineProperty(globalThis, 'crypto', { configurable: true, value: original }) + fireEvent.click(button) + fireEvent.click(within(await screen.findByRole('dialog')).getByRole('button', { name: '开始测试' })) + await waitFor(() => expect((global.fetch as jest.Mock).mock.calls.filter(([url]) => url.endsWith('/verify'))).toHaveLength(1)) + } finally { Object.defineProperty(globalThis, 'crypto', descriptor) } +}) test('多模态模型一次确认、一次请求,通过后两项可选但不会自动开放', async () => { const next = jointConfiguration(), passed = jointConfiguration() for (const stage of ['analysis', 'copy'] as const) passed.providers[0].models[2].verification[stage] = { status: 'passed', id: `joint-${stage}`, checkedAt: null, requestId: 'one-upstream-request', message: '该用途已通过', purpose: 'analysis-copy' } diff --git a/components/admin/AiModelManagementPanel.tsx b/components/admin/AiModelManagementPanel.tsx index c352c9d..adbe136 100644 --- a/components/admin/AiModelManagementPanel.tsx +++ b/components/admin/AiModelManagementPanel.tsx @@ -3,6 +3,7 @@ import { useEffect, useMemo, useRef, useState } from 'react' import { Info, Plus, RefreshCw, Save, Search } from 'lucide-react' import { useConfirmActionDialog } from '@/components/ConfirmActionDialog' +import { createBrowserUuid } from '@/lib/browser-uuid' import { AI_MODEL_TEST_PURPOSES, aiModelTestStages, type AiModelAdminState, type AiModelCatalogEntry, type AiModelProvider, type AiModelStage, type AiModelTestPurpose } from '@/lib/ai-model-types' const stages: Array<{ id: AiModelStage; label: string; shortLabel: string }> = [ @@ -183,7 +184,7 @@ export default function AiModelManagementPanel({ onStateChange }: { onStateChang setBusy(`verify:${model.key}:${stage}`); setError(''); setNotice('') let idempotencyKey = '' try { - idempotencyKey = crypto.randomUUID() + idempotencyKey = createBrowserUuid() uncertainAttempts.current.set(model.key, { stage, previousId: savedModel.verification[attemptedStages[0]].id, requestId: idempotencyKey }); rememberAttempts() const next = await request(`/providers/${encodeURIComponent(provider.id)}/models/${encodeURIComponent(model.key)}/verify`, { method: 'POST', body: JSON.stringify({ stage, revision: savedProvider.revision, idempotencyKey }) }) uncertainAttempts.current.delete(model.key); rememberAttempts() @@ -191,6 +192,10 @@ export default function AiModelManagementPanel({ onStateChange }: { onStateChang } catch (cause) { // Refresh state after a lost response; never repeat a potentially billable request. const message = cause instanceof Error ? cause.message : '测试请求未完成' + if (!idempotencyKey) { + setError(`${message}。测试尚未提交。`) + return + } if (idempotencyKey && (!(cause instanceof ModelRequestError) || cause.status >= 500)) uncertainAttempts.current.set(model.key, { stage, previousId: savedModel.verification[attemptedStages[0]].id, requestId: idempotencyKey }) else uncertainAttempts.current.delete(model.key) rememberAttempts() diff --git a/docs/ai-model-browser-uuid-acceptance-2026-10-14.md b/docs/ai-model-browser-uuid-acceptance-2026-10-14.md new file mode 100644 index 0000000..09e4941 --- /dev/null +++ b/docs/ai-model-browser-uuid-acceptance-2026-10-14.md @@ -0,0 +1,29 @@ +# 模型测试请求编号修复验收 + +日期:2026-10-14。范围:AI 模型管理面板点击测试时的浏览器请求编号生成和提交前错误提示。 + +## 修复 + +- 使用浏览器原生 `crypto.getRandomValues()` 生成 UUID v4,兼容 HTTPS 和内网 HTTP;无新增依赖,不使用时间戳或 `Math.random()`。 +- 编号生成失败时明确提示“测试尚未提交”,不发送测试请求,不创建未知提交记录,不误导管理员核查未发生的模型调用。 +- 请求发出后的中断核查、联合测试、按用途开放和服务端超级管理员限制沿用现有流程。 + +## 验证证据 + +- 隔离发布预览来自 `21b6eb767c9fb1d140e69d763399381eb308d770` 的 Git 存档,仅覆盖本次修复;未混入详情页知识库等工作区改动。 +- 13 个相关测试文件、245 项测试全部通过,涵盖 UUID 格式与唯一性、版本和变体位、缺少安全随机数时拒绝提交、恢复后重试、异常核查和服务端权限。 +- 独立 HTTP 测试页由浏览器实际运行同一份 `lib/browser-uuid.ts` 转译代码。原生结果:`isSecureContext=false`、`crypto.randomUUID=undefined`、`crypto.getRandomValues=function`;100 个编号全部符合 UUID v4,且互不重复。无登录、密钥或模型调用。 +- HTTP 测试源码的 LF 规范化 SHA-256:`ba99717287a1d1c372871867ff0e56df5aa4090e5b02f1f32637d5153dcd9d4e`。 +- 本地隔离账号通过页面实际点击 Gemini `gemini-3-flash` 联合测试,分析、文案均通过;新增一次上游调用;相同请求编号再次提交没有新增调用。 +- 上游请求编号:`23e9e874-8383-4ded-8198-3e94ba13db6c`;联合执行编号:`a736553d-486f-4680-a458-0507671c2b86`。生产模型测试记录未改动。 +- 桌面 1440px、手机 390px 无横向溢出;确认、取消和完成状态正常,手机端取消未调用模型。截图保存在忽略目录 `output/ai-browser-uuid-20261014/`。 + +## Impeccable 范围审查 + +Operate 模式,保留现有界面。范围评分 19/20:可访问性 3、性能 4、响应式 4、主题一致性 4、实现完整性 4。新增错误提示使用现有 `role=alert`;不新增样式、动画、依赖或权限入口。面板检测器结果为 0 项,不代表整个管理后台完成全面 WCAG 审计。 + +## 限制与发布核对 + +浏览器自动化对 HTTP 别名地址的 DOM/截图读取超时;独立测试页的浏览器原生回执已收取,管理面板实际点击在本地回环地址完成。不能据此宣称已在生产 HTTP 页面实际点击模型。 + +发布使用干净的 Git 提交构建 Linux 镜像,无数据库迁移。上线后核对部署提交、健康检查、普通用户拒绝访问、模型配置与验证记录不变;生产验收不额外调用模型。上一版本镜像保留用于回滚,当前镜像传输包在校验后清理。 diff --git a/lib/__tests__/browser-uuid.test.ts b/lib/__tests__/browser-uuid.test.ts new file mode 100644 index 0000000..4711946 --- /dev/null +++ b/lib/__tests__/browser-uuid.test.ts @@ -0,0 +1,20 @@ +/** @jest-environment node */ +import { createBrowserUuid } from '@/lib/browser-uuid' +import { z } from 'zod' +const cryptoDescriptor = Object.getOwnPropertyDescriptor(globalThis, 'crypto')! +afterEach(() => Object.defineProperty(globalThis, 'crypto', cryptoDescriptor)) +test('generates valid UUID v4 values without relying on randomUUID', () => { + const original = globalThis.crypto + Object.defineProperty(globalThis, 'crypto', { configurable: true, value: { getRandomValues: original.getRandomValues.bind(original), randomUUID: undefined } }) + const ids = Array.from({ length: 100 }, () => createBrowserUuid()) + expect(new Set(ids).size).toBe(100) + for (const id of ids) { expect(id).toMatch(/^[a-f0-9]{8}-[a-f0-9]{4}-4[a-f0-9]{3}-[89ab][a-f0-9]{3}-[a-f0-9]{12}$/); expect(z.string().uuid().safeParse(id).success).toBe(true) } +}) +test('sets the UUID version and variant without discarding the remaining random bits', () => { + Object.defineProperty(globalThis, 'crypto', { configurable: true, value: { getRandomValues: (bytes: Uint8Array) => bytes.fill(255) } }) + expect(createBrowserUuid()).toBe('ffffffff-ffff-4fff-bfff-ffffffffffff') +}) +test.each([undefined, {}])('missing secure randomness reports a clear error and never creates a weak identifier', value => { + Object.defineProperty(globalThis, 'crypto', { configurable: true, value }) + expect(createBrowserUuid).toThrow('当前浏览器无法生成请求编号,请更新浏览器后再试') +}) diff --git a/lib/browser-uuid.ts b/lib/browser-uuid.ts new file mode 100644 index 0000000..c1c0dd6 --- /dev/null +++ b/lib/browser-uuid.ts @@ -0,0 +1,10 @@ +/** Crypto.getRandomValues is also available on the platform's HTTP LAN origin. */ +export function createBrowserUuid(): string { + const cryptoApi = globalThis.crypto + if (typeof cryptoApi?.getRandomValues !== 'function') throw new Error('当前浏览器无法生成请求编号,请更新浏览器后再试') + const bytes = cryptoApi.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)}` +}