diff --git a/app/api/detail-generator/jobs/[id]/recover/route.ts b/app/api/detail-generator/jobs/[id]/recover/route.ts
new file mode 100644
index 0000000..51d3a09
--- /dev/null
+++ b/app/api/detail-generator/jobs/[id]/recover/route.ts
@@ -0,0 +1,11 @@
+import { DetailGeneratorError, detailGeneratorRoute } from '@/lib/detail-generator/access'
+import { recoverDetailTextJob } from '@/lib/detail-generator/recovery'
+import { assertDetailId, parseDetailJson } from '@/lib/detail-generator/validation'
+export const runtime = 'nodejs'
+export const dynamic = 'force-dynamic'
+export async function POST(request: Request, { params }: { params: { id: string } }) {
+ return detailGeneratorRoute(async userId => {
+ if (request.headers.get('content-type')?.split(';')[0].trim().toLowerCase() !== 'application/json') throw new DetailGeneratorError('恢复操作须使用 JSON 请求', 415, 'content_type_required')
+ return recoverDetailTextJob(userId, assertDetailId(params.id), await parseDetailJson(request))
+ })
+}
diff --git a/components/admin/AiModelManagementPanel.test.tsx b/components/admin/AiModelManagementPanel.test.tsx
index fa33e24..b07731f 100644
--- a/components/admin/AiModelManagementPanel.test.tsx
+++ b/components/admin/AiModelManagementPanel.test.tsx
@@ -83,6 +83,39 @@ test('浏览器没有 randomUUID 时仍能提交一个有效 UUID 的联合测
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 next = jointConfiguration(), model = next.providers[0].models[2]
+ const requestId = 'a1111111-1111-4111-8111-111111111111'
+ model.capabilities.protocols = { analysis: { id: 'openai-chat', path: '/v1/chat/completions' }, copy: { id: 'openai-chat', path: '/v1/chat/completions' } }
+ for (const stage of ['analysis', 'copy'] as const) model.verification[stage] = { status: 'unknown', id: `text-${stage}`, requestId, checkedAt: null, message: '原结果待核查', purpose: 'analysis-copy', canResume: true, canReconcile: true }
+ global.fetch = jest.fn(async () => response(next)) as typeof fetch
+ render()
+ const row = await screen.findByLabelText('new-vision-model · 现有上游', { selector: 'div' })
+ fireEvent.click(within(row).getByRole('button', { name: '查询 new-vision-model · 现有上游 的分析与文案结果' }))
+ await waitFor(() => expect((global.fetch as jest.Mock).mock.calls.some(([url]) => url.endsWith('/refresh'))).toBe(true))
+ expect((global.fetch as jest.Mock).mock.calls.some(([url]) => url.endsWith('/verify'))).toBe(false)
+ expect(screen.queryByRole('dialog')).not.toBeInTheDocument()
+ fireEvent.click(within(row).getByRole('button', { name: 'new-vision-model · 现有上游 分析与文案测试详情' }))
+ fireEvent.click(within(row).getByRole('button', { name: '核查 new-vision-model 的原请求凭证' }))
+ const field = screen.getByRole('textbox', { name: '客户请求编号' })
+ expect(field).toHaveValue(requestId)
+ expect(field).toHaveAttribute('readonly')
+ expect(screen.getByText(/未留存不代表执行失败/)).toBeVisible()
+})
+
+test.each([true, false])('展开联合结果时只合并相同请求号,保留不同请求的来源:shared=%s', async shared => {
+ const next = jointConfiguration(), model = next.providers[0].models[2]
+ const first = 'a1111111-1111-4111-8111-111111111111', second = shared ? first : 'b1111111-1111-4111-8111-111111111111'
+ model.verification.analysis = { ...model.verification.analysis, status: 'passed', requestId: first, checkedAt: '2026-10-16T02:00:00Z' }
+ model.verification.copy = { ...model.verification.copy, status: 'passed', requestId: second, checkedAt: '2026-10-16T02:00:00Z' }
+ global.fetch = jest.fn(async () => response(next)) as typeof fetch
+ render()
+ const row = await screen.findByLabelText('new-vision-model · 现有上游', { selector: 'div' })
+ fireEvent.click(within(row).getByRole('button', { name: 'new-vision-model · 现有上游 分析与文案测试详情' }))
+ expect(within(row).getAllByText(`请求编号:${first}`)).toHaveLength(1)
+ if (!shared) expect(within(row).getByText(`请求编号:${second}`)).toBeVisible()
+})
test('编号生成失败不会提交、刷新或伪造未知状态,恢复后可正常重试', async () => {
const descriptor = Object.getOwnPropertyDescriptor(globalThis, 'crypto')!, original = globalThis.crypto
Object.defineProperty(globalThis, 'crypto', { configurable: true, value: {} })
diff --git a/components/admin/AiModelManagementPanel.tsx b/components/admin/AiModelManagementPanel.tsx
index 1b89a27..29b826b 100644
--- a/components/admin/AiModelManagementPanel.tsx
+++ b/components/admin/AiModelManagementPanel.tsx
@@ -32,7 +32,13 @@ function needsReferenceAdaptation(model: AiModelCatalogEntry) {
}
interface ProviderForm { id: string | null; revision: number; name: string; baseUrl: string; apiKey: string }
-interface RecoveryForm { providerId: string; verificationId: string; modelId: string; outcome: 'not_executed' | 'failed' | 'completed'; reference: string; note: string; confirmed: boolean; canFetchImage: boolean; requestId: string }
+interface RecoveryForm { providerId: string; verificationId: string; modelId: string; outcome: 'not_executed' | 'failed' | 'completed'; reference: string; note: string; confirmed: boolean; canFetchOriginal: boolean; requestId: string; requestIdLocked: boolean }
+function recoveryFormFor(provider: AiModelProvider, model: AiModelCatalogEntry, purpose: AiModelTestPurpose, result: AiModelVerificationSummary): RecoveryForm {
+ const protocol = model.capabilities.protocols?.[purpose === 'analysis-copy' ? 'analysis' : purpose]
+ return { providerId: provider.id, verificationId: result.id!, modelId: model.id, outcome: 'not_executed', reference: '', note: '', confirmed: false,
+ canFetchOriginal: provider.enabled && provider.hasApiKey && model.present && ['openai-chat', 'anthropic-messages', 'openai-image-generate', 'openai-image-edit'].includes(protocol?.id || ''),
+ requestId: result.requestId || '', requestIdLocked: !!result.requestId }
+}
const pendingStorageKey = 'ai-model-pending-submissions-v1'
class ModelRequestError extends Error { constructor(message: string, readonly status: number) { super(message) } }
@@ -258,9 +264,9 @@ export default function AiModelManagementPanel({ onStateChange }: { onStateChang
void run('reconcile', () => request(`/providers/${encodeURIComponent(recovery.providerId)}/verifications/${encodeURIComponent(recovery.verificationId)}/reconcile`, { method: 'POST', body: JSON.stringify({ outcome: recovery.outcome, reference: recovery.reference, note: recovery.note, confirmed: recovery.confirmed }) }), '核查已记录,可以重新测试', { preserveDraft: true })
}}>
核查原请求 · {recovery.modelId}
- {recovery.canFetchImage &&
-
有客户请求编号时,可取回原图并校验,不会重新生成。
-
+ {recovery.canFetchOriginal &&
+
取回并校验原响应,不会重新调用模型。未留存不代表执行失败。
+
}
先在上游核查并确认请求已结束。此操作不会通过接口测试。
@@ -314,6 +320,8 @@ export default function AiModelManagementPanel({ onStateChange }: { onStateChang
const result = resultStage ? model.verification[resultStage] : null
const protocol = resultStage ? model.capabilities.protocols?.[resultStage] : null
const detailsId = `test-details-${model.key}-${selectedStage?.id}`
+ const sharedRequestId = result?.requestId && testedStages.length > 1 && testedStages.every(stage => model.verification[stage].requestId === result.requestId) ? result.requestId : null
+ const sharedCheckedAt = result?.checkedAt && testedStages.length > 1 && testedStages.every(stage => model.verification[stage].checkedAt === result.checkedAt) ? result.checkedAt : null
const canInspect = !!protocol || testedStages.some(stage => { const value = model.verification[stage]; return !!(value.message || value.checkedAt || value.requestId) })
const retest = testedStages.some(stage => { const value = model.verification[stage]; return value.status === 'passed' || value.status === 'failed' || value.status === 'stale' || value.reconciled })
const unsettled = Object.values(model.verification).some((entry) => entry.status === 'running' || entry.status === 'unknown')
@@ -330,7 +338,7 @@ export default function AiModelManagementPanel({ onStateChange }: { onStateChang
const testStatusId = `test-status-${model.key}`
const blockedReasonId = `test-blocked-${model.key}`
const showBlockedReason = !!blockedReason && !testing && !attempt && !unsettled
- return
+ return
{model.id}
{capabilityLabels[model.capabilities.kind]} · {provider.name}{!provider.enabled ? ' · 上游已停用' : ''}{!model.present ? ' · 已离开上游目录' : ''}
{capabilityReason}
{selectedStage && result ? <>
-
-