fix(ai): complete verification activation and recovery workflows

This commit is contained in:
WuYongJie 2026-10-14 15:37:01 +08:00
parent 4da4ceaf40
commit efb8e1af3d
25 changed files with 867 additions and 125 deletions

72
app/admin/page.test.tsx Normal file
View File

@ -0,0 +1,72 @@
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
import { useSession } from 'next-auth/react'
import AdminPage from './page'
const push = jest.fn()
const router = { push }
const confirmAction = jest.fn()
jest.mock('next-auth/react', () => ({ useSession: jest.fn() }))
jest.mock('next/navigation', () => ({ useRouter: () => router }))
jest.mock('@/components/ConfirmActionDialog', () => ({ useConfirmActionDialog: () => ({ confirmAction, confirmActionDialog: null }) }))
jest.mock('@/components/board/MaterialsHeader', () => () => null)
jest.mock('@/components/DepartmentPermissionsTab', () => () => null)
jest.mock('@/components/admin/MaterialManagement', () => () => null)
jest.mock('@/components/admin/CategoryManagement', () => () => null)
jest.mock('@/components/admin/PublicTrafficTab', () => () => null)
jest.mock('@/components/admin/DataStatisticsTab', () => function TestDataStatisticsTab() { return <div>统计内容</div> })
jest.mock('@/components/SuperAdminsTab', () => function TestSuperAdminsTab({ onStateChange }: { onStateChange: (state: { dirty: boolean; busy: boolean }) => void }) { return <div><span>配置内容</span><button onClick={() => onStateChange({ dirty: true, busy: false })}>模拟配置草稿</button><button onClick={() => onStateChange({ dirty: true, busy: true })}>模拟保存中</button></div> })
beforeEach(() => { jest.clearAllMocks(); confirmAction.mockResolvedValue({ confirmed: false }) })
function session(role: string, isSuperAdmin: boolean) { (useSession as jest.Mock).mockReturnValue({ status: 'authenticated', data: { user: { id: 'user', role, isSuperAdmin } } }) }
test('super-admin flag grants the management entry independently of the ordinary role', async () => {
session('USER', true); render(<AdminPage />)
fireEvent.click(await screen.findByRole('button', { name: '超级管理员' }))
await screen.findByText('配置内容')
expect(push).not.toHaveBeenCalled()
})
test('ordinary administrators do not see the super-admin configuration entry', async () => {
session('ADMIN', false); render(<AdminPage />)
await screen.findByText('统计内容')
expect(screen.queryByRole('button', { name: '超级管理员' })).not.toBeInTheDocument()
})
test('ordinary users remain outside the admin page', async () => {
session('USER', false); render(<AdminPage />)
await waitFor(() => expect(push).toHaveBeenCalledWith('/'))
expect(screen.queryByRole('button', { name: '超级管理员' })).not.toBeInTheDocument()
})
test('changing top-level tabs cannot discard a configuration draft without the user choosing to leave', async () => {
session('ADMIN', true); render(<AdminPage />)
fireEvent.click(await screen.findByRole('button', { name: '超级管理员' }))
fireEvent.click(await screen.findByRole('button', { name: '模拟配置草稿' }))
fireEvent.click(screen.getByRole('button', { name: '数据统计' }))
await waitFor(() => expect(confirmAction).toHaveBeenCalledTimes(1))
expect(screen.getByText('配置内容')).toBeInTheDocument()
fireEvent.click(screen.getByRole('button', { name: '模拟保存中' }))
expect(screen.getByRole('button', { name: '数据统计' })).toBeDisabled()
})
test.each([false, true])('appearance navigation respects the unsaved configuration decision: confirmed=%s', async (confirmed) => {
session('ADMIN', true); render(<AdminPage />)
fireEvent.click(await screen.findByRole('button', { name: '超级管理员' }))
fireEvent.click(await screen.findByRole('button', { name: '模拟配置草稿' }))
confirmAction.mockResolvedValueOnce({ confirmed })
fireEvent.click(screen.getByRole('button', { name: '外观设置' }))
await waitFor(() => expect(confirmAction).toHaveBeenCalledTimes(1))
if (confirmed) {
await waitFor(() => expect(push).toHaveBeenCalledWith('/admin/settings'))
} else {
expect(push).not.toHaveBeenCalled()
expect(screen.getByText('配置内容')).toBeInTheDocument()
}
})
test('appearance navigation is blocked while a configuration save is running', async () => {
session('ADMIN', true); render(<AdminPage />)
fireEvent.click(await screen.findByRole('button', { name: '超级管理员' }))
fireEvent.click(await screen.findByRole('button', { name: '模拟保存中' }))
const appearance = screen.getByRole('button', { name: '外观设置' })
expect(appearance).toBeDisabled()
fireEvent.click(appearance)
expect(push).not.toHaveBeenCalled()
expect(confirmAction).not.toHaveBeenCalled()
})

View File

@ -11,6 +11,7 @@ import CategoryManagement from '@/components/admin/CategoryManagement'
import DataStatisticsTab from '@/components/admin/DataStatisticsTab'
import PublicTrafficTab from '@/components/admin/PublicTrafficTab'
import ToastBubble from '@/components/ToastBubble'
import { useConfirmActionDialog } from '@/components/ConfirmActionDialog'
// 类型定义
interface Material {
@ -91,13 +92,29 @@ export default function AdminPage() {
email?: string | null
} | undefined
const currentUserRole = sessionUser?.role || ''
const canAccessAdminPage = currentUserRole === 'ADMIN' || currentUserRole === 'SYSTEM_ADMIN' || currentUserRole === 'DEPT_ADMIN'
const canManageSystemTabs = currentUserRole === 'ADMIN' || currentUserRole === 'SYSTEM_ADMIN'
const canAccessAdminPage = sessionUser?.isSuperAdmin === true || currentUserRole === 'ADMIN' || currentUserRole === 'SYSTEM_ADMIN' || currentUserRole === 'DEPT_ADMIN'
const canManageSystemTabs = sessionUser?.isSuperAdmin === true || currentUserRole === 'ADMIN' || currentUserRole === 'SYSTEM_ADMIN'
const departmentScopeName = String(sessionUser?.managedDepartment || sessionUser?.department || '').trim() || null
const canSelectDepartmentInStats = canManageSystemTabs
// Tab状态
const [activeTab, setActiveTab] = useState<'materials' | 'categories' | 'dataStats' | 'permissions' | 'superAdmins' | 'publicTraffic'>('dataStats')
const [superAdminEditing, setSuperAdminEditing] = useState({ dirty: false, busy: false })
const { confirmAction, confirmActionDialog } = useConfirmActionDialog()
const confirmLeaveConfiguration = async () => {
if (activeTab !== 'superAdmins') return true
if (superAdminEditing.busy) return false
if (!superAdminEditing.dirty) return true
const result = await confirmAction({ title: '离开当前配置?', description: '当前修改尚未保存。', confirmLabel: '放弃修改并离开', cancelLabel: '继续编辑', tone: 'danger' })
return result.confirmed
}
const changeTab = async (next: typeof activeTab) => {
if (next === activeTab || !(await confirmLeaveConfiguration())) return
setActiveTab(next)
}
const openAppearanceSettings = async () => {
if (await confirmLeaveConfiguration()) router.push('/admin/settings')
}
const [loading, setLoading] = useState(true)
// 数据状态
@ -281,6 +298,7 @@ export default function AdminPage() {
return (
<div className="min-h-screen bg-[#fafafa]">
{confirmActionDialog}
<MaterialsHeader session={session} sectionLabel="治理与设置" />
{/* Toast通知 */}
@ -297,8 +315,9 @@ export default function AdminPage() {
{canManageSystemTabs && (
<button
type="button"
onClick={() => router.push('/admin/settings')}
className="inline-flex min-h-10 shrink-0 items-center gap-2 rounded-lg bg-[#1a1c1d] px-4 text-[13px] font-semibold text-white transition-opacity hover:opacity-80 focus:outline-none focus-visible:ring-2 focus-visible:ring-[#1a1c1d]/70 focus-visible:ring-offset-2 focus-visible:ring-offset-[#fafafa]"
disabled={activeTab === 'superAdmins' && superAdminEditing.busy}
onClick={() => void openAppearanceSettings()}
className="inline-flex min-h-10 shrink-0 items-center gap-2 rounded-lg bg-[#1a1c1d] px-4 text-[13px] font-semibold text-white transition-opacity hover:opacity-80 focus:outline-none focus-visible:ring-2 focus-visible:ring-[#1a1c1d]/70 focus-visible:ring-offset-2 focus-visible:ring-offset-[#fafafa] disabled:pointer-events-none disabled:opacity-50"
>
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24" aria-hidden="true">
<title>外观设置</title>
@ -313,7 +332,8 @@ export default function AdminPage() {
<div className="mb-8 flex gap-4 overflow-x-auto border-b border-[#e7e8ea] [&>button]:shrink-0">
<button
type="button"
onClick={() => setActiveTab('dataStats')}
disabled={activeTab === 'superAdmins' && superAdminEditing.busy}
onClick={() => void changeTab('dataStats')}
className={`pb-4 px-4 font-medium transition-colors ${
activeTab === 'dataStats'
? 'text-[#1a1c1d] border-b-2 border-[#1a1c1d]'
@ -327,7 +347,8 @@ export default function AdminPage() {
<>
<button
type="button"
onClick={() => setActiveTab('materials')}
disabled={activeTab === 'superAdmins' && superAdminEditing.busy}
onClick={() => void changeTab('materials')}
className={`pb-4 px-4 font-medium transition-colors ${
activeTab === 'materials'
? 'text-[#1a1c1d] border-b-2 border-[#1a1c1d]'
@ -339,7 +360,8 @@ export default function AdminPage() {
<button
type="button"
onClick={() => setActiveTab('categories')}
disabled={activeTab === 'superAdmins' && superAdminEditing.busy}
onClick={() => void changeTab('categories')}
className={`pb-4 px-4 font-medium transition-colors ${
activeTab === 'categories'
? 'text-[#1a1c1d] border-b-2 border-[#1a1c1d]'
@ -353,7 +375,8 @@ export default function AdminPage() {
<>
<button
type="button"
onClick={() => setActiveTab('permissions')}
disabled={activeTab === 'superAdmins' && superAdminEditing.busy}
onClick={() => void changeTab('permissions')}
className={`pb-4 px-4 font-medium transition-colors ${
activeTab === 'permissions'
? 'text-[#1a1c1d] border-b-2 border-[#1a1c1d]'
@ -365,7 +388,8 @@ export default function AdminPage() {
<button
type="button"
onClick={() => setActiveTab('superAdmins')}
disabled={activeTab === 'superAdmins' && superAdminEditing.busy}
onClick={() => void changeTab('superAdmins')}
className={`pb-4 px-4 font-medium transition-colors ${
activeTab === 'superAdmins'
? 'text-[#1a1c1d] border-b-2 border-[#1a1c1d]'
@ -377,7 +401,8 @@ export default function AdminPage() {
<button
type="button"
onClick={() => setActiveTab('publicTraffic')}
disabled={activeTab === 'superAdmins' && superAdminEditing.busy}
onClick={() => void changeTab('publicTraffic')}
className={`pb-4 px-4 font-medium transition-colors ${
activeTab === 'publicTraffic'
? 'text-[#1a1c1d] border-b-2 border-[#1a1c1d]'
@ -454,6 +479,7 @@ export default function AdminPage() {
{canManageSystemTabs && activeTab === 'superAdmins' && sessionUser?.isSuperAdmin && (
<SuperAdminsTab
showToast={showToast}
onStateChange={setSuperAdminEditing}
/>
)}

View File

@ -0,0 +1,7 @@
import { aiModelAdminRoute } from '@/lib/ai-model-access'
import { checkAiModelSubmission } from '@/lib/ai-model-recovery'
import { parseDetailJson } from '@/lib/detail-generator/validation'
export const runtime = 'nodejs'
export async function POST(request: Request, context: { params: { id: string; modelKey: string } }) {
return aiModelAdminRoute(async userId => checkAiModelSubmission(userId, context.params.id, context.params.modelKey, await parseDetailJson(request)))
}

View File

@ -0,0 +1,7 @@
import { aiModelAdminRoute } from '@/lib/ai-model-access'
import { reconcileAiModelVerification } from '@/lib/ai-model-recovery'
import { parseDetailJson } from '@/lib/detail-generator/validation'
export const runtime = 'nodejs'
export async function POST(request: Request, context: { params: { id: string; verificationId: string } }) {
return aiModelAdminRoute(async userId => reconcileAiModelVerification(userId, context.params.id, context.params.verificationId, await parseDetailJson(request)))
}

View File

@ -169,6 +169,7 @@ function getNotificationDeliveryStatusClassName(status: string) {
interface SuperAdminsTabProps {
showToast: (message: string, type?: 'success' | 'error') => void
onStateChange?: (state: { dirty: boolean; busy: boolean }) => void
}
type SuperAdminWorkspace = 'access' | 'notificationCenter' | 'outbound' | 'aiModels'
@ -185,9 +186,12 @@ const SUPER_ADMIN_WORKSPACES: Array<{
{ id: 'aiModels', label: 'AI 模型接入', description: '上游连接、模型用途与默认模型', icon: Cpu },
]
export default function SuperAdminsTab({ showToast }: SuperAdminsTabProps) {
export default function SuperAdminsTab({ showToast, onStateChange }: SuperAdminsTabProps) {
const [activeWorkspace, setActiveWorkspace] = useState<SuperAdminWorkspace>('notificationCenter')
const [aiModelsState, setAiModelsState] = useState({ dirty: false, busy: false })
const activeEditState = activeWorkspace === 'aiModels' ? aiModelsState : null
useEffect(() => { onStateChange?.({ dirty: activeEditState?.dirty || false, busy: activeEditState?.busy || false }) }, [activeEditState?.dirty, activeEditState?.busy, onStateChange])
useEffect(() => () => { onStateChange?.({ dirty: false, busy: false }) }, [onStateChange])
const { confirmAction, confirmActionDialog } = useConfirmActionDialog()
const [superAdmins, setSuperAdmins] = useState<SuperAdmin[]>([])
const [loading, setLoading] = useState(true)
@ -219,8 +223,9 @@ export default function SuperAdminsTab({ showToast }: SuperAdminsTabProps) {
const [retryingNotificationDeliveryId, setRetryingNotificationDeliveryId] = useState<string | null>(null)
const changeWorkspace = async (next: SuperAdminWorkspace) => {
if (next === activeWorkspace || (activeWorkspace === 'aiModels' && aiModelsState.busy)) return
if (activeWorkspace === 'aiModels' && aiModelsState.dirty) {
const editState = activeWorkspace === 'aiModels' ? aiModelsState : null
if (next === activeWorkspace || editState?.busy) return
if (editState?.dirty) {
const result = await confirmAction({ title: '离开模型接入?', description: '当前修改尚未保存,离开后将丢失。', confirmLabel: '放弃修改并离开', cancelLabel: '继续编辑', tone: 'danger' })
if (!result.confirmed) return
}
@ -722,7 +727,7 @@ export default function SuperAdminsTab({ showToast }: SuperAdminsTabProps) {
key={workspace.id}
type="button"
aria-current={active ? 'page' : undefined}
disabled={activeWorkspace === 'aiModels' && aiModelsState.busy}
disabled={(activeWorkspace === 'aiModels' && aiModelsState.busy)}
onClick={() => void changeWorkspace(workspace.id)}
className={`min-h-[58px] rounded-[8px] px-3 py-2.5 text-left transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#5a9ce6] ${
active

View File

@ -1,4 +1,4 @@
import { fireEvent, render, screen, waitFor, within } from '@testing-library/react'
import { act, fireEvent, render, screen, waitFor, within } from '@testing-library/react'
import AiModelManagementPanel from './AiModelManagementPanel'
import type { AiModelAdminState, AiModelVerificationSummary } from '@/lib/ai-model-types'
@ -17,6 +17,26 @@ const configured = (): AiModelAdminState => ({
})
afterEach(() => jest.restoreAllMocks())
beforeEach(() => sessionStorage.clear())
test('开放范围有未保存修改时仍可测试尚未开放的模型,结果保留原草稿', async () => {
const next = configured()
const passed = configured()
passed.providers[0].models[2].verification.analysis = { status: 'passed', id: 'verification-draft', checkedAt: '2026-10-14T01:00:00Z', requestId: 'request-draft', message: '测试已通过' }
global.fetch = jest.fn(async (url: string) => response(url.endsWith('/verify') ? passed : next)) as typeof fetch
render(<AiModelManagementPanel />)
fireEvent.click(await screen.findByRole('checkbox', { name: '开放 nano-banana-2 · 现有上游 给用户' }))
const testButton = screen.getByRole('button', { name: '测试 new-vision-model · 现有上游 的资料分析' })
expect(testButton).toBeEnabled()
fireEvent.click(testButton)
fireEvent.click(within(await screen.findByRole('dialog')).getByRole('button', { name: '开始测试' }))
await waitFor(() => expect(screen.getByRole('checkbox', { name: 'new-vision-model · 现有上游 用于资料分析' })).toBeEnabled())
expect(screen.getByRole('checkbox', { name: '开放 nano-banana-2 · 现有上游 给用户' })).not.toBeChecked()
expect(screen.getByLabelText('图片生成默认模型', { exact: false })).toHaveValue('')
expect(screen.getByRole('button', { name: '保存配置' })).toBeEnabled()
expect(screen.getByRole('checkbox', { name: '开放 new-vision-model · 现有上游 给用户' })).not.toBeChecked()
expect((global.fetch as jest.Mock).mock.calls.filter(([, init]) => init?.method === 'PUT')).toHaveLength(0)
})
test('显式迁入当前连接并同步上游目录,使用服务器版本号', async () => {
const next = configured()
@ -274,13 +294,12 @@ test('实际测试需明确确认费用,取消不调用模型;通过后仍
expect(screen.getByLabelText('new-vision-model · 现有上游 测试用途')).toHaveValue('analysis')
})
test('未保存配置禁止测试,撤销后允许;每次仅提交所选用途', async () => {
test('未保存开放设置不阻止测试;每次仅提交所选用途', async () => {
const next = configured()
global.fetch = jest.fn(async () => response(next)) as typeof fetch
render(<AiModelManagementPanel />)
fireEvent.click(await screen.findByRole('checkbox', { name: '开放 nano-banana-2 · 现有上游 给用户' }))
expect(screen.getByRole('button', { name: '测试 new-vision-model · 现有上游 的资料分析' })).toBeDisabled()
fireEvent.click(screen.getByRole('button', { name: '撤销' }))
expect(screen.getByRole('button', { name: '测试 new-vision-model · 现有上游 的资料分析' })).toBeEnabled()
fireEvent.change(screen.getByLabelText('new-vision-model · 现有上游 测试用途'), { target: { value: 'copy' } })
fireEvent.click(screen.getByRole('button', { name: '测试 new-vision-model · 现有上游 的卖点文案' }))
fireEvent.click(within(await screen.findByRole('dialog')).getByRole('button', { name: '开始测试' }))
@ -288,6 +307,78 @@ test('未保存配置禁止测试,撤销后允许;每次仅提交所选用
const call = (global.fetch as jest.Mock).mock.calls.find(([url]) => url.endsWith('/verify'))
expect(JSON.parse(call[1].body).stage).toBe('copy')
await waitFor(() => expect(screen.getByRole('button', { name: '测试 new-vision-model · 现有上游 的卖点文案' })).toBeEnabled())
expect(screen.getByRole('checkbox', { name: '开放 nano-banana-2 · 现有上游 给用户' })).not.toBeChecked()
})
test('测试刷新新状态不偷偷升级草稿版本,并发保存冲突继续保留修改', async () => {
const next = configured(), updated = configured()
updated.revision = 7
updated.providers[0].revision = 8
updated.providers[0].models[2].verification.analysis.status = 'passed'
global.fetch = jest.fn(async (url: string, init: RequestInit = {}) => {
if (url.endsWith('/verify')) return response(updated)
if (init.method === 'PUT') return response({ error: '配置已被其他管理员修改,请重新加载' }, 409)
return response(next)
}) as typeof fetch
render(<AiModelManagementPanel />)
fireEvent.click(await screen.findByRole('checkbox', { name: '开放 nano-banana-2 · 现有上游 给用户' }))
fireEvent.click(screen.getByRole('button', { name: '测试 new-vision-model · 现有上游 的资料分析' }))
fireEvent.click(within(await screen.findByRole('dialog')).getByRole('button', { name: '开始测试' }))
await waitFor(() => expect(screen.getByRole('checkbox', { name: 'new-vision-model · 现有上游 用于资料分析' })).toBeEnabled())
fireEvent.change(screen.getByLabelText('new-vision-model · 现有上游 测试用途'), { target: { value: 'copy' } })
fireEvent.click(screen.getByRole('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(2))
await waitFor(() => expect(screen.getByRole('button', { name: '保存配置' })).toBeEnabled())
const verifyCalls = (global.fetch as jest.Mock).mock.calls.filter(([url]) => url.endsWith('/verify'))
expect(JSON.parse(verifyCalls[1][1].body).revision).toBe(8)
fireEvent.click(screen.getByRole('button', { name: '保存配置' }))
await screen.findByText('配置已被其他管理员修改,请重新加载')
const save = (global.fetch as jest.Mock).mock.calls.find(([, init]) => init?.method === 'PUT')
expect(JSON.parse(save[1].body)).toMatchObject({ revision: 2, providers: [{ revision: 3 }] })
expect(screen.getByRole('checkbox', { name: '开放 nano-banana-2 · 现有上游 给用户' })).not.toBeChecked()
})
test.each(['disabled', 'credential'] as const)('测试无法执行时就近显示可恢复原因:%s', async (reason) => {
const next = configured()
if (reason === 'disabled') next.providers[0].enabled = false
else next.providers[0].hasApiKey = false
global.fetch = jest.fn(async () => response(next)) as typeof fetch
render(<AiModelManagementPanel />)
const button = await screen.findByRole('button', { name: '测试 new-vision-model · 现有上游 的资料分析' })
expect(button).toBeDisabled()
expect(button).toHaveAccessibleDescription(reason === 'disabled' ? '请先启用上游连接' : '请先配置上游密钥')
expect(within(screen.getByLabelText('new-vision-model · 现有上游', { selector: 'div' })).getByRole('status')).toHaveTextContent(reason === 'disabled' ? '请先启用上游连接' : '请先配置上游密钥')
})
test('有开放设置草稿时查询原异步任务不重新测试且保留草稿', async () => {
const next = configured(), passed = configured()
next.providers[0].models[2].verification.analysis = { status: 'unknown', id: 'resume-with-draft', checkedAt: null, requestId: 'known-request', message: '等待结果', canResume: true }
passed.providers[0].models[2].verification.analysis.status = 'passed'
global.fetch = jest.fn(async (url: string) => response(url.endsWith('/refresh') ? passed : next)) as typeof fetch
render(<AiModelManagementPanel />)
fireEvent.click(await screen.findByRole('checkbox', { name: '开放 nano-banana-2 · 现有上游 给用户' }))
const query = screen.getByRole('button', { name: '查询 new-vision-model · 现有上游 的资料分析结果' })
expect(query).toBeEnabled()
expect(screen.getByRole('button', { name: '刷新测试状态' })).toBeEnabled()
fireEvent.click(query)
await waitFor(() => expect(screen.getByRole('checkbox', { name: 'new-vision-model · 现有上游 用于资料分析' })).toBeEnabled())
expect(screen.getByRole('checkbox', { name: '开放 nano-banana-2 · 现有上游 给用户' })).not.toBeChecked()
expect(screen.getByRole('button', { name: '保存配置' })).toBeEnabled()
expect((global.fetch as jest.Mock).mock.calls.filter(([url]) => url.endsWith('/verify'))).toHaveLength(0)
expect((global.fetch as jest.Mock).mock.calls.filter(([url]) => url.endsWith('/refresh'))).toHaveLength(1)
})
test('编辑上游时阻止测试并说明先保存或取消,取消不发送任何付费请求', async () => {
global.fetch = jest.fn(async () => response(configured())) as typeof fetch
render(<AiModelManagementPanel />)
fireEvent.click(await screen.findByRole('button', { name: '编辑 现有上游' }))
const test = screen.getByRole('button', { name: '测试 new-vision-model · 现有上游 的资料分析' })
expect(test).toBeDisabled()
expect(test).toHaveAccessibleDescription('请先保存或取消上游编辑')
fireEvent.click(screen.getByRole('button', { name: '取消' }))
expect(test).toBeEnabled()
expect((global.fetch as jest.Mock).mock.calls.filter(([url]) => url.endsWith('/verify'))).toHaveLength(0)
})
test('测试处理中禁止重复请求;未知结果暂停整个模型并可只读刷新', async () => {
@ -327,9 +418,9 @@ test('付费请求响应丢失后仅查询状态,查询也失败则本地暂
render(<AiModelManagementPanel />)
fireEvent.click(await screen.findByRole('button', { name: '测试 new-vision-model · 现有上游 的资料分析' }))
fireEvent.click(within(await screen.findByRole('dialog')).getByRole('button', { name: '开始测试' }))
await screen.findByText('结果未知,暂停核查')
await screen.findByText('提交结果未确认,请核查')
expect(screen.getByRole('alert')).toHaveTextContent('网络中断;已停止提交,请核查测试状态。')
expect(screen.getByRole('button', { name: '测试 new-vision-model · 现有上游 的资料分析' })).toBeDisabled()
expect(screen.getByRole('button', { name: '核查 new-vision-model · 现有上游 的测试提交' })).toBeEnabled()
expect((global.fetch as jest.Mock).mock.calls.filter(([url]) => url.endsWith('/verify'))).toHaveLength(1)
expect(reads).toBe(2)
})
@ -342,12 +433,12 @@ test('响应丢失且 GET 仍为旧记录时保持暂停,手动刷新也不重
render(<AiModelManagementPanel />)
fireEvent.click(await screen.findByRole('button', { name: '测试 new-vision-model · 现有上游 的资料分析' }))
fireEvent.click(within(await screen.findByRole('dialog')).getByRole('button', { name: '开始测试' }))
await screen.findByText('结果未知,暂停核查')
await screen.findByText('提交结果未确认,请核查')
const call = (global.fetch as jest.Mock).mock.calls.find(([url]) => url.endsWith('/verify'))
expect(screen.getByText(`请求编号:${JSON.parse(call[1].body).idempotencyKey}`)).toBeInTheDocument()
fireEvent.click(screen.getByRole('button', { name: '刷新测试状态' }))
await screen.findByText('测试状态已刷新')
expect(screen.getByRole('button', { name: '测试 new-vision-model · 现有上游 的资料分析' })).toBeDisabled()
expect(screen.getByRole('button', { name: '核查 new-vision-model · 现有上游 的测试提交' })).toBeEnabled()
expect((global.fetch as jest.Mock).mock.calls.filter(([url]) => url.endsWith('/verify'))).toHaveLength(1)
})

View File

@ -8,7 +8,7 @@ import type { AiModelAdminState, AiModelCatalogEntry, AiModelProvider, AiModelSt
const stages: Array<{ id: AiModelStage; label: string; shortLabel: string }> = [
{ id: 'analysis', label: '资料分析', shortLabel: '分析' }, { id: 'copy', label: '卖点文案', shortLabel: '文案' }, { id: 'image', label: '图片生成', shortLabel: '生图' },
]
const fieldClass = 'min-h-11 w-full min-w-0 rounded-lg border border-[#cfd8e3] bg-white px-3 py-2 text-sm text-[#101828] placeholder:text-[#66758b] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#1677ff] disabled:bg-[#f8fafc] disabled:text-[#66758b]'
const fieldClass = 'min-h-11 w-full min-w-0 rounded-lg border border-[#cfd8e3] bg-white px-3 py-2 text-base text-[#101828] placeholder:text-[#66758b] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#1677ff] disabled:bg-[#f8fafc] disabled:text-[#66758b] sm:text-sm'
const buttonClass = 'inline-flex min-h-11 shrink-0 items-center justify-center gap-2 rounded-lg border border-[#cfd8e3] bg-white px-3 text-sm font-medium text-[#344054] transition-colors hover:bg-[#f8fafc] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#1677ff] focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50'
const primaryClass = `${buttonClass} !border-[#0050cb] !bg-[#0050cb] !text-white hover:!bg-[#003da3]`
const checkboxClass = 'h-4 w-4 shrink-0 accent-[#0050cb] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#1677ff] focus-visible:ring-offset-2 disabled:cursor-not-allowed'
@ -16,6 +16,8 @@ const capabilityLabels = { text: '对话', vision: '视觉理解', image: '生
const verificationLabels = { untested: '待测试', running: '测试中', passed: '已通过', failed: '未通过', unknown: '暂停核查', stale: '需重新测试' } as const
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 }
const pendingStorageKey = 'ai-model-pending-submissions-v1'
class ModelRequestError extends Error { constructor(message: string, readonly status: number) { super(message) } }
async function request(path = '', init: RequestInit = {}): Promise<AiModelAdminState> {
@ -39,6 +41,7 @@ export default function AiModelManagementPanel({ onStateChange }: { onStateChang
const [notice, setNotice] = useState('')
const [formError, setFormError] = useState('')
const [form, setForm] = useState<ProviderForm | null>(null)
const [recovery, setRecovery] = useState<RecoveryForm | null>(null)
const [search, setSearch] = useState('')
const [capabilityFilter, setCapabilityFilter] = useState('all')
const [statusFilter, setStatusFilter] = useState('all')
@ -49,44 +52,85 @@ export default function AiModelManagementPanel({ onStateChange }: { onStateChang
const uncertainAttempts = useRef(new Map<string, { stage: AiModelStage; previousId: string | null; requestId: string }>())
const { confirmAction, confirmActionDialog } = useConfirmActionDialog()
const dirty = !!state && !!draft && JSON.stringify(state) !== JSON.stringify(draft)
function rememberAttempts() {
try { sessionStorage.setItem(pendingStorageKey, JSON.stringify(Array.from(uncertainAttempts.current))) } catch { /* Server records remain authoritative if browser storage is unavailable. */ }
}
function accept(next: AiModelAdminState) {
const resolved = { ...next, providers: next.providers.map((provider) => ({ ...provider, models: provider.models.map((model) => {
function accept(next: AiModelAdminState, preserveDraft = false) {
const { submissionCheck, ...snapshot } = next
if (submissionCheck && uncertainAttempts.current.get(submissionCheck.modelKey)?.requestId === submissionCheck.idempotencyKey) uncertainAttempts.current.delete(submissionCheck.modelKey)
const resolved = { ...snapshot, providers: next.providers.map((provider) => ({ ...provider, models: provider.models.map((model) => {
const attempt = uncertainAttempts.current.get(model.key)
if (!attempt) return model
const result = model.verification[attempt.stage]
if (result.id && result.id !== attempt.previousId) { uncertainAttempts.current.delete(model.key); return model }
return { ...model, verification: { ...model.verification, [attempt.stage]: { ...result, status: 'unknown' as const, requestId: attempt.requestId, canResume: false, message: '尚未获取本次测试记录,请核查请求编号后再操作' } } }
if (result.submissionKey === attempt.requestId) { uncertainAttempts.current.delete(model.key); return model }
return { ...model, verification: { ...model.verification, [attempt.stage]: { ...result, status: 'unknown' as const, requestId: attempt.requestId, canResume: false, canReconcile: false, message: '尚未获取本次测试记录,请核查提交后再操作' } } }
}) })) }
setState(resolved); setDraft(resolved)
rememberAttempts()
setState(resolved)
setDraft((current) => {
if (!preserveDraft || !dirty || !current) return resolved
// Test results update independently of pending configuration. Keep original
// revisions so a concurrent configuration save still produces a conflict.
return { ...resolved, revision: current.revision, defaults: current.defaults, providers: resolved.providers.map((provider) => {
const previous = current.providers.find((entry) => entry.id === provider.id)
if (!previous) return provider
return { ...provider, revision: previous.revision, models: provider.models.map((model) => {
const pending = previous.models.find((entry) => entry.key === model.key)
return pending ? { ...model, enabled: pending.enabled, stages: [...pending.stages] } : model
}) }
}) }
})
}
const acceptRef = useRef(accept)
acceptRef.current = accept
useEffect(() => {
let cancelled = false
request().then((next) => { if (!cancelled) accept(next) }).catch((cause) => { if (!cancelled) setError(cause.message) }).finally(() => { if (!cancelled) setLoading(false) })
try {
const saved = JSON.parse(sessionStorage.getItem(pendingStorageKey) || '[]')
if (Array.isArray(saved)) for (const entry of saved.slice(0, 20)) if (Array.isArray(entry) && typeof entry[0] === 'string' && stages.some(stage => stage.id === entry[1]?.stage) && /^[a-f0-9-]{36}$/i.test(entry[1]?.requestId)) uncertainAttempts.current.set(entry[0], entry[1])
} catch { /* Ignore malformed browser state; no model calls are made here. */ }
request().then((next) => { if (!cancelled) acceptRef.current(next) }).catch((cause) => { if (!cancelled) setError(cause.message) }).finally(() => { if (!cancelled) setLoading(false) })
return () => { cancelled = true }
}, [])
const polling = busy.startsWith('verify:') || !!state?.providers.some(provider => provider.models.some(model => Object.values(model.verification).some(result => result.status === 'running')))
useEffect(() => {
onStateChange?.({ dirty: dirty || !!form, busy: !!busy })
}, [dirty, form, busy, onStateChange])
if (!polling) return
let cancelled = false
let timer: ReturnType<typeof setTimeout>
const poll = async () => {
try { const next = await request(); if (!cancelled) acceptRef.current(next, true) } catch { /* Keep the original request; never resend a billable operation. */ }
if (!cancelled) timer = setTimeout(poll, 3000)
}
timer = setTimeout(poll, 3000)
return () => { cancelled = true; clearTimeout(timer) }
}, [polling])
useEffect(() => {
onStateChange?.({ dirty: dirty || !!form || !!recovery, busy: !!busy })
}, [dirty, form, recovery, busy, onStateChange])
useEffect(() => () => { onStateChange?.({ dirty: false, busy: false }) }, [onStateChange])
useEffect(() => {
if (!dirty && !form && !busy.startsWith('verify:')) return
if (!dirty && !form && !recovery && !busy.startsWith('verify:')) return
const prevent = (event: BeforeUnloadEvent) => { event.preventDefault(); event.returnValue = '' }
window.addEventListener('beforeunload', prevent)
return () => window.removeEventListener('beforeunload', prevent)
}, [dirty, form, busy])
}, [dirty, form, recovery, busy])
async function run(identity: string, action: () => Promise<AiModelAdminState>, success: string, closeForm = false) {
async function run(identity: string, action: () => Promise<AiModelAdminState>, success: string, options: { closeForm?: boolean; preserveDraft?: boolean } = {}) {
if (busyRef.current) return
busyRef.current = true; setBusy(identity); setError(''); setFormError(''); setNotice('')
try {
accept(await action())
const next = await action()
accept(next, options.preserveDraft)
setNotice(success)
if (closeForm) setForm(null)
if (identity.startsWith('submission:')) setNotice(next.submissionCheck?.received ? '已找到原测试记录' : '原提交未被受理,已关闭,可以重新测试')
if (identity === 'reconcile') setRecovery(null)
if (options.closeForm) setForm(null)
} catch (cause) {
const message = cause instanceof Error ? cause.message : '操作未完成,请重试'
if (identity === 'provider-save') setFormError(message)
@ -117,11 +161,13 @@ export default function AiModelManagementPanel({ onStateChange }: { onStateChang
event.preventDefault()
if (!form) return
const body = { name: form.name.trim(), baseUrl: form.baseUrl.trim(), ...(form.apiKey.trim() ? { apiKey: form.apiKey.trim() } : {}), ...(form.id ? { revision: form.revision } : {}) }
void run('provider-save', () => request(form.id ? `/providers/${encodeURIComponent(form.id)}` : '/providers', { method: form.id ? 'PATCH' : 'POST', body: JSON.stringify(body) }), form.id ? '上游已保存' : '上游已添加,请同步模型目录', true)
void run('provider-save', () => request(form.id ? `/providers/${encodeURIComponent(form.id)}` : '/providers', { method: form.id ? 'PATCH' : 'POST', body: JSON.stringify(body) }), form.id ? '上游已保存' : '上游已添加,请同步模型目录', { closeForm: true })
}
async function testModel(provider: AiModelProvider, model: AiModelCatalogEntry, stage: AiModelStage) {
if (busyRef.current || dirty || form || !provider.enabled || !provider.hasApiKey || !model.present || !model.capabilities.allowedStages.includes(stage) || Object.values(model.verification).some((result) => result.status === 'running' || result.status === 'unknown')) return
const savedProvider = state?.providers.find((entry) => entry.id === provider.id)
const savedModel = savedProvider?.models.find((entry) => entry.key === model.key)
if (busyRef.current || form || !savedProvider?.enabled || !savedProvider.hasApiKey || !savedModel?.present || !savedModel.capabilities.allowedStages.includes(stage) || Object.values(savedModel.verification).some((result) => result.status === 'running' || result.status === 'unknown')) return
busyRef.current = true
setBusy(`confirm:${model.key}`)
setTestStages((current) => ({ ...current, [model.key]: stage }))
@ -132,13 +178,17 @@ export default function AiModelManagementPanel({ onStateChange }: { onStateChang
let idempotencyKey = ''
try {
idempotencyKey = crypto.randomUUID()
const next = await request(`/providers/${encodeURIComponent(provider.id)}/models/${encodeURIComponent(model.key)}/verify`, { method: 'POST', body: JSON.stringify({ stage, revision: provider.revision, idempotencyKey }) })
accept(next)
uncertainAttempts.current.set(model.key, { stage, previousId: savedModel.verification[stage].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()
accept(next, true)
} catch (cause) {
// Refresh state after a lost response; never repeat a potentially billable request.
const message = cause instanceof Error ? cause.message : '测试请求未完成'
if (idempotencyKey && (!(cause instanceof ModelRequestError) || cause.status >= 500)) uncertainAttempts.current.set(model.key, { stage, previousId: model.verification[stage].id, requestId: idempotencyKey })
try { accept(await request()) } catch {
if (idempotencyKey && (!(cause instanceof ModelRequestError) || cause.status >= 500)) uncertainAttempts.current.set(model.key, { stage, previousId: savedModel.verification[stage].id, requestId: idempotencyKey })
else uncertainAttempts.current.delete(model.key)
rememberAttempts()
try { accept(await request(), true) } catch {
const markUnknown = (current: AiModelAdminState | null) => current && ({ ...current, providers: current.providers.map((entry) => entry.id !== provider.id ? entry : { ...entry, models: entry.models.map((item) => item.key !== model.key ? item : { ...item, verification: { ...item.verification, [stage]: { ...item.verification[stage], status: 'unknown' as const, message: '未能获取测试结果,请刷新状态核查' } } }) }) })
setState(markUnknown); setDraft(markUnknown)
}
@ -158,7 +208,8 @@ export default function AiModelManagementPanel({ onStateChange }: { onStateChang
if (statusFilter === 'attention' && !Object.values(model.verification).some((result) => ['failed', 'unknown', 'running'].includes(result.status))) return false
return true
})
const providerBlocked = !!busy || dirty || !!form
const providerBlocked = !!busy || dirty || !!form || !!recovery
const statusBlocked = !!busy || !!form || !!recovery
const pageCount = Math.max(1, Math.ceil(visibleRows.length / 20))
const currentPage = Math.min(page, pageCount)
const pageRows = visibleRows.slice((currentPage - 1) * 20, currentPage * 20)
@ -171,13 +222,23 @@ export default function AiModelManagementPanel({ onStateChange }: { onStateChang
<div className="flex flex-wrap items-center justify-between gap-3">
<h3 className="text-base font-semibold">AI 模型接入</h3>
<div className="flex flex-wrap gap-2">
{needsStatusRefresh && <button type="button" className={buttonClass} disabled={providerBlocked} onClick={() => void run('refresh-status', () => request(), '测试状态已刷新')}><RefreshCw className="h-4 w-4" aria-hidden="true" />刷新测试状态</button>}
{needsStatusRefresh && <button type="button" className={buttonClass} disabled={statusBlocked} onClick={() => void run('refresh-status', () => request(), '测试状态已刷新', { preserveDraft: true })}><RefreshCw className="h-4 w-4" aria-hidden="true" />刷新测试状态</button>}
{draft.legacyAvailable && <button type="button" className={buttonClass} disabled={providerBlocked} onClick={() => void run('import', () => request('/import-environment', { method: 'POST' }), '当前服务端连接已迁入')} >{busy === 'import' ? '正在迁入…' : '迁入当前连接'}</button>}
<button type="button" className={buttonClass} disabled={providerBlocked || !draft.encryptionConfigured} onClick={() => { setForm({ id: null, revision: 0, name: '', baseUrl: '', apiKey: '' }); setFormError(''); setNotice('') }}><Plus className="h-4 w-4" aria-hidden="true" />新增上游</button>
</div>
</div>
{!draft.encryptionConfigured && <p className="text-sm text-[#854a0e]">服务端尚未配置密钥加密,暂不能新增或替换 API Key。</p>}
{(error || notice) && <p role={error ? 'alert' : 'status'} className={`break-words rounded-lg px-3 py-2.5 text-sm ${error ? 'bg-[#fff0ee] text-[#b42318]' : 'bg-[#e9f8f2] text-[#087052]'}`}>{error || notice}</p>}
{recovery && <form aria-label={`核查 ${recovery.modelId} 的原请求`} className="space-y-3 border-y border-[#e2e8f0] py-4" onSubmit={event => {
event.preventDefault()
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 })
}}>
<h4 className="break-all text-sm font-semibold">核查原请求 · {recovery.modelId}</h4>
<p className="text-xs text-[#66758b]">先在上游核查并确认请求已结束。此操作不会通过接口测试。</p>
<div className="grid gap-3 sm:grid-cols-2"><label className="space-y-1.5 text-sm">上游核查结论<select className={fieldClass} value={recovery.outcome} disabled={!!busy} onChange={event => setRecovery({ ...recovery, outcome: event.target.value as RecoveryForm['outcome'] })}><option value="not_executed">未执行</option><option value="failed">已失败</option><option value="completed">已完成,结果未取回</option></select></label><label className="space-y-1.5 text-sm">凭证编号<input autoFocus required minLength={3} maxLength={160} placeholder="工单或上游日志编号" className={fieldClass} value={recovery.reference} disabled={!!busy} onChange={event => setRecovery({ ...recovery, reference: event.target.value })} /></label></div>
<label className="block space-y-1.5 text-sm">核查说明<textarea required minLength={10} maxLength={500} rows={2} className={fieldClass} value={recovery.note} disabled={!!busy} placeholder="填写核查来源和结果,不要填写密钥" onChange={event => setRecovery({ ...recovery, note: event.target.value })} /></label>
<div className="flex flex-wrap items-center justify-between gap-3"><label className="inline-flex min-h-11 items-center gap-2 text-sm"><input type="checkbox" required className={checkboxClass} checked={recovery.confirmed} disabled={!!busy} onChange={event => setRecovery({ ...recovery, confirmed: event.target.checked })} />已核查,原请求已结束</label><div className="flex gap-2"><button type="button" className={buttonClass} disabled={!!busy} onClick={() => setRecovery(null)}>取消核查</button><button type="submit" className={primaryClass} disabled={!!busy || !recovery.confirmed || recovery.reference.trim().length < 3 || recovery.note.trim().length < 10}>{busy === 'reconcile' ? '记录中…' : '记录核查'}</button></div></div>
</form>}
<section aria-label="上游连接">
<h4 className="mb-2 text-sm font-semibold text-[#344054]">上游连接</h4>
{!draft.providers.length && !form && <p className="py-4 text-sm text-[#66758b]">{draft.legacyAvailable ? '迁入当前连接,或新增上游后同步模型目录。' : '新增上游后同步模型目录。'}</p>}
@ -208,20 +269,29 @@ export default function AiModelManagementPanel({ onStateChange }: { onStateChang
</fieldset>
{!!rows.length && <>
<div className="grid min-w-0 grid-cols-2 gap-2 sm:flex sm:flex-wrap"><label className="relative col-span-2 min-w-0 sm:flex-1 sm:basis-48"><span className="sr-only">搜索模型或上游</span><Search className="pointer-events-none absolute left-3 top-3.5 h-4 w-4 text-[#66758b]" aria-hidden="true" /><input className={`${fieldClass} !pl-9`} value={search} onChange={(event) => { setSearch(event.target.value); setPage(1) }} placeholder="搜索模型或上游" /></label><select aria-label="按能力筛选模型" className={`${fieldClass} sm:!w-auto sm:flex-none`} value={capabilityFilter} onChange={(event) => { setCapabilityFilter(event.target.value); setPage(1) }}><option value="all">全部能力</option>{Object.entries(capabilityLabels).map(([kind, label]) => <option key={kind} value={kind}>{label}</option>)}</select><select aria-label="按状态筛选模型" className={`${fieldClass} sm:!w-auto sm:flex-none`} value={statusFilter} onChange={(event) => { setStatusFilter(event.target.value); setPage(1) }}><option value="all">全部状态</option><option value="enabled">已开放</option><option value="disabled">未开放</option><option value="untested">待测试</option><option value="attention">待处理</option><option value="missing">已离开目录</option></select></div>
<p className="text-xs text-[#66758b]">先测试用途,再勾选开放并保存。{dirty ? ' 测试前请先保存或撤销修改。' : ''}</p>
<p className="text-xs text-[#66758b]">模型无需先开放即可测试。通过后勾选用途与开放,再保存配置。</p>
<div className="hidden grid-cols-[minmax(0,1fr)_88px_200px_248px] gap-3 border-b border-[#e2e8f0] bg-[#f8fafc] px-3 py-2 text-xs font-semibold text-[#4f6077] xl:grid" aria-hidden="true"><span>模型 / 上游</span><span>开放给用户</span><span>用户可选用途</span><span>实际测试</span></div>
<div className="divide-y divide-[#e2e8f0] border-b border-[#e2e8f0]" aria-label="模型目录">
{!visibleRows.length && <p role="status" className="px-3 py-5 text-sm text-[#66758b]">没有匹配模型</p>}
{pageRows.map(({ provider, model }) => {
const testableStages = stages.filter((stage) => model.capabilities.allowedStages.includes(stage.id))
const selectedStage = testableStages.find((stage) => ['unknown', 'running'].includes(model.verification[stage.id].status)) || testableStages.find((stage) => stage.id === testStages[model.key]) || testableStages.find((stage) => model.verification[stage.id].status !== 'passed') || testableStages[0]
const selectedStage = stages.find((stage) => ['unknown', 'running'].includes(model.verification[stage.id].status)) || testableStages.find((stage) => stage.id === testStages[model.key]) || testableStages.find((stage) => model.verification[stage.id].status !== 'passed') || testableStages[0]
const result = selectedStage ? model.verification[selectedStage.id] : null
const protocol = selectedStage ? model.capabilities.protocols?.[selectedStage.id] : null
const detailsId = `test-details-${model.key}-${selectedStage?.id}`
const canInspect = !!protocol || !!result && !!(result.message || result.checkedAt || result.requestId)
const unsettled = Object.values(model.verification).some((entry) => entry.status === 'running' || entry.status === 'unknown')
const testing = busy.startsWith(`verify:${model.key}:`)
const blockedTest = providerBlocked || !provider.enabled || !provider.hasApiKey || !model.present || !selectedStage || unsettled
const attempt = uncertainAttempts.current.get(model.key)
const blockedReason = busy ? testing ? '正在测试,请稍候' : '其他操作正在进行,请稍候'
: recovery ? '请先完成或取消原请求核查'
: form ? '请先保存或取消上游编辑'
: !provider.enabled ? '请先启用上游连接'
: !provider.hasApiKey ? '请先配置上游密钥'
: !model.present ? '模型已离开上游目录,请同步目录'
: unsettled ? '已有测试进行中或结果未知,请先查询状态'
: !selectedStage ? '该用途尚未适配' : ''
const testStatusId = `test-status-${model.key}`
return <div key={model.key} aria-label={`${model.id} · ${provider.name}`} className="grid min-w-0 gap-x-3 gap-y-1 px-3 py-3 sm:grid-cols-[minmax(0,1fr)_auto] xl:grid-cols-[minmax(0,1fr)_88px_200px_248px] xl:items-center">
<div className="min-w-0"><p className="break-all text-sm font-medium">{model.id}</p><p className="mt-1 break-words text-xs text-[#66758b]"><span title={model.capabilities.reason}>{capabilityLabels[model.capabilities.kind]}{model.capabilities.kind !== 'unknown' && !model.capabilities.allowedStages.length ? ' · 待适配' : ''}</span> · {provider.name}{!provider.enabled ? ' · 上游已停用' : ''}{!model.present ? ' · 已离开上游目录' : ''}</p><span id={`capability-${model.key}`} className="sr-only">{model.capabilities.reason}</span></div>
<label className="inline-flex min-h-11 items-center gap-2 text-sm"><input type="checkbox" className={checkboxClass} checked={model.enabled} disabled={!!busy || !!form || !model.enabled && (!model.present || !provider.enabled || !provider.hasApiKey || !model.stages.length || !model.stages.every((stage) => model.capabilities.allowedStages.includes(stage) && model.verification[stage].status === 'passed'))} aria-label={`开放 ${model.id} · ${provider.name} 给用户`} onChange={(event) => updateModel(provider.id, model.key, { enabled: event.target.checked })} />开放</label>
@ -236,18 +306,19 @@ export default function AiModelManagementPanel({ onStateChange }: { onStateChang
{selectedStage && result ? <>
<div className="flex min-w-0 items-center gap-2">
<select aria-label={`${model.id} · ${provider.name} 测试用途`} className={`${fieldClass} !px-2`} disabled={!!busy || unsettled} value={selectedStage.id} onChange={(event) => setTestStages((current) => ({ ...current, [model.key]: event.target.value as AiModelStage }))}>
{testableStages.map((stage) => <option key={stage.id} value={stage.id}>{stage.shortLabel} · {verificationLabels[model.verification[stage.id].status]}</option>)}
{(!testableStages.some(stage => stage.id === selectedStage.id) ? [selectedStage] : testableStages).map((stage) => <option key={stage.id} value={stage.id}>{stage.label}</option>)}
</select>
{result.canResume && result.id ? <button type="button" className={buttonClass} disabled={providerBlocked || !provider.enabled || !provider.hasApiKey} aria-label={`查询 ${model.id} · ${provider.name} 的${selectedStage.label}结果`} onClick={() => {
{attempt ? <button type="button" className={buttonClass} disabled={statusBlocked} aria-label={`核查 ${model.id} · ${provider.name} 的测试提交`} onClick={() => void run(`submission:${model.key}`, () => request(`/providers/${encodeURIComponent(provider.id)}/models/${encodeURIComponent(model.key)}/submission`, { method: 'POST', body: JSON.stringify({ stage: attempt.stage, idempotencyKey: attempt.requestId }) }), '', { preserveDraft: true })}>核查提交</button> : result.canResume && result.id ? <button type="button" className={buttonClass} disabled={statusBlocked || !provider.enabled || !provider.hasApiKey} aria-label={`查询 ${model.id} · ${provider.name} 的${selectedStage.label}结果`} onClick={() => {
setTestStages((current) => ({ ...current, [model.key]: selectedStage.id }))
void run(`result:${model.key}`, () => request(`/providers/${encodeURIComponent(provider.id)}/verifications/${encodeURIComponent(result.id!)}/refresh`, { method: 'POST' }), '')
}}>{busy === `result:${model.key}` ? '查询中…' : '查询结果'}</button> : <button type="button" className={buttonClass} disabled={blockedTest} aria-label={`测试 ${model.id} · ${provider.name} 的${selectedStage.label}`} onClick={() => void testModel(provider, model, selectedStage.id)}>{testing || result.status === 'running' ? '测试中…' : result.status === 'passed' || result.status === 'stale' ? '重测' : '测试'}</button>}
void run(`result:${model.key}`, () => request(`/providers/${encodeURIComponent(provider.id)}/verifications/${encodeURIComponent(result.id!)}/refresh`, { method: 'POST' }), '', { preserveDraft: true })
}}>{busy === `result:${model.key}` ? '查询中…' : '查询结果'}</button> : result.canReconcile && result.id ? <button type="button" className={buttonClass} disabled={statusBlocked} aria-label={`核查 ${model.id} · ${provider.name} 的${selectedStage.label}结果`} onClick={() => { setError(''); setNotice(''); setRecovery({ providerId: provider.id, verificationId: result.id!, modelId: model.id, outcome: 'not_executed', reference: '', note: '', confirmed: false }) }}>核查结果</button> : <button type="button" className={buttonClass} disabled={!!blockedReason} aria-describedby={testStatusId} aria-label={`测试 ${model.id} · ${provider.name} 的${selectedStage.label}`} onClick={() => void testModel(provider, model, selectedStage.id)}>{testing || result.status === 'running' ? '测试中…' : result.status === 'passed' || result.status === 'stale' || result.reconciled ? '重测' : '测试'}</button>}
{canInspect && <button type="button" className={`${buttonClass} !w-11 !px-0`} aria-label={`${model.id} · ${provider.name} ${selectedStage.label}测试详情`} aria-expanded={!!expandedDetails[detailsId]} aria-controls={detailsId} title="测试详情" onClick={() => setExpandedDetails((current) => ({ ...current, [detailsId]: !current[detailsId] }))}><Info className="h-4 w-4" aria-hidden="true" /></button>}
</div>
<p role="status" className={`text-xs ${result.status === 'passed' ? 'text-[#087052]' : result.status === 'failed' || result.status === 'unknown' ? 'text-[#b42318]' : 'text-[#66758b]'}`}>{testing ? '正在调用模型,请稍候' : result.status === 'unknown' ? result.canResume ? '等待上游结果,可查询' : '结果未知,暂停核查' : result.status === 'running' ? '测试进行中,可刷新状态' : `${selectedStage.shortLabel} · ${verificationLabels[result.status]}`}</p>
<p id={testStatusId} role="status" className={`text-xs ${blockedReason ? 'text-[#66758b]' : result.status === 'passed' ? 'text-[#087052]' : result.status === 'failed' || result.status === 'unknown' ? 'text-[#b42318]' : 'text-[#66758b]'}`}>{testing ? '正在调用模型,请稍候' : attempt ? '提交结果未确认,请核查' : result.status === 'unknown' ? result.canResume ? '等待上游结果,可查询' : '结果未知,暂停核查' : result.status === 'running' ? '测试进行中,自动更新状态' : result.reconciled ? '已核查,可重测' : blockedReason || verificationLabels[result.status]}</p>
{canInspect && <div id={detailsId} hidden={!expandedDetails[detailsId]} className="space-y-1 break-words pb-2 text-xs text-[#66758b]">
{protocol && <><p className="break-all">协议:{protocol.id}</p><p className="break-all">接口:{protocol.path}</p>{(protocol.scopeId || protocol.routeId || protocol.operationId) && <p className="break-all">路由:{[protocol.scopeId, protocol.routeId, protocol.operationId].filter(Boolean).join(' / ')}</p>}</>}
{result.message && <p>{result.message}</p>}{result.checkedAt && <p>{new Date(result.checkedAt).toLocaleString('zh-CN')}</p>}{result.requestId && <p className="break-all">请求编号:{result.requestId}</p>}
{result.taskId && <p className="break-all">上游任务:{result.taskId}</p>}
</div>}
</> : <p className="text-xs text-[#66758b]">{model.capabilities.kind === 'unknown' ? '能力待识别,暂不可测试' : '暂未接入详情页用途'}</p>}
</div>

View File

@ -0,0 +1,76 @@
import { act, fireEvent, render, screen, waitFor, within } from '@testing-library/react'
import AiModelManagementPanel from './AiModelManagementPanel'
import type { AiModelAdminState } from '@/lib/ai-model-types'
const snapshot = (): AiModelAdminState => ({ providers: [{ id: 'provider', name: '平台模型服务', baseUrl: 'https://models.example.com', enabled: true, hasApiKey: true, credentialSource: 'environment', revision: 1, syncedAt: null, models: [{ key: 'model', id: 'vision-model', present: true, enabled: false, stages: [], capabilities: { kind: 'vision', allowedStages: ['analysis'], source: 'upstream', reason: 'vision' }, verification: { analysis: { status: 'untested', id: null, checkedAt: null, requestId: null, message: null }, copy: { status: 'untested', id: null, checkedAt: null, requestId: null, message: null }, image: { status: 'untested', id: null, checkedAt: null, requestId: null, message: null } } }] }], revision: 1, defaults: { analysis: null, copy: null, image: null }, encryptionConfigured: true, legacyAvailable: false })
const response = (body: unknown) => ({ ok: true, status: 200, json: async () => body } as Response)
const testName = '测试 vision-model · 平台模型服务 的资料分析', intakeName = '核查 vision-model · 平台模型服务 的测试提交'
beforeEach(() => sessionStorage.clear())
afterEach(() => { jest.restoreAllMocks(); jest.useRealTimers() })
test('lost intake can be closed without repeating a paid call, then a new explicit test is available', async () => {
global.fetch = jest.fn(async (url: string, init?: RequestInit) => {
if (url.endsWith('/verify')) throw new Error('响应丢失')
if (url.endsWith('/submission')) return response({ ...snapshot(), submissionCheck: { modelKey: 'model', ...JSON.parse(init!.body as string), received: false } })
return response(snapshot())
}) as typeof fetch
render(<AiModelManagementPanel />)
fireEvent.click(await screen.findByRole('button', { name: testName }))
fireEvent.click(within(await screen.findByRole('dialog')).getByRole('button', { name: '开始测试' }))
fireEvent.click(await screen.findByRole('button', { name: intakeName }))
await screen.findByText('原提交未被受理,已关闭,可以重新测试')
expect(screen.getByRole('button', { name: testName })).toBeEnabled()
expect((global.fetch as jest.Mock).mock.calls.filter(([url]) => url.endsWith('/verify'))).toHaveLength(1)
expect(sessionStorage.getItem('ai-model-pending-submissions-v1')).toBe('[]')
})
test('reload restores lost intake, server-confirmed same submission clears it without creating a new call', async () => {
const key = '11111111-1111-4111-8111-111111111111'
sessionStorage.setItem('ai-model-pending-submissions-v1', JSON.stringify([['model', { stage: 'analysis', requestId: key, previousId: null }]]))
const known = snapshot(); known.providers[0].models[0].verification.analysis = { status: 'passed', id: 'verification', checkedAt: null, requestId: 'upstream', message: null, submissionKey: key }
global.fetch = jest.fn(async () => response(known)) as typeof fetch
render(<AiModelManagementPanel />)
await screen.findByText('已通过')
expect(screen.queryByRole('button', { name: intakeName })).not.toBeInTheDocument()
expect(screen.getByRole('checkbox', { name: 'vision-model · 平台模型服务 用于资料分析' })).toBeEnabled()
expect((global.fetch as jest.Mock).mock.calls.every(([, init]) => !init?.method)).toBe(true)
})
test('evidence-based recovery never enables or approves a model, keeps pending configuration', async () => {
const unknown = snapshot(); unknown.providers[0].models[0].verification.analysis = { status: 'unknown', id: 'verification', checkedAt: null, requestId: 'request', message: '结果未知', canReconcile: true }
const closed = snapshot(); closed.providers[0].models[0].verification.analysis = { ...unknown.providers[0].models[0].verification.analysis, status: 'failed', canReconcile: false, reconciled: true }
global.fetch = jest.fn(async (url: string) => response(url.endsWith('/reconcile') ? closed : unknown)) as typeof fetch
render(<AiModelManagementPanel />)
fireEvent.click(await screen.findByRole('button', { name: '核查 vision-model · 平台模型服务 的资料分析结果' }))
expect(screen.getByRole('button', { name: '记录核查' })).toBeDisabled()
fireEvent.change(screen.getByLabelText('凭证编号'), { target: { value: 'upstream-123' } })
fireEvent.change(screen.getByLabelText('核查说明'), { target: { value: '已核查上游日志,原请求失败并已结束。' } })
fireEvent.click(screen.getByRole('checkbox', { name: '已核查,原请求已结束' }))
fireEvent.click(screen.getByRole('button', { name: '记录核查' }))
await screen.findByText('已核查,可重测')
expect(screen.getByRole('checkbox', { name: 'vision-model · 平台模型服务 用于资料分析' })).toBeDisabled()
expect(screen.getByRole('checkbox', { name: '开放 vision-model · 平台模型服务 给用户' })).not.toBeChecked()
expect((global.fetch as jest.Mock).mock.calls.filter(([, init]) => init?.method === 'POST')).toHaveLength(1)
expect((global.fetch as jest.Mock).mock.calls.some(([url]) => url.endsWith('/verify'))).toBe(false)
})
test('a running request polls read-only state without resubmitting; unmount cancels polling', async () => {
jest.useFakeTimers()
const running = snapshot(); running.providers[0].models[0].verification.analysis.status = 'running'
global.fetch = jest.fn(async () => response(running)) as typeof fetch
const view = render(<AiModelManagementPanel />)
await screen.findByText('测试进行中,自动更新状态')
await act(async () => { await jest.advanceTimersByTimeAsync(3100) })
expect(global.fetch).toHaveBeenCalledTimes(2)
expect((global.fetch as jest.Mock).mock.calls.every(([, init]) => !init?.method)).toBe(true)
view.unmount()
await act(async () => { await jest.advanceTimersByTimeAsync(6000) })
expect(global.fetch).toHaveBeenCalledTimes(2)
})
test('withdrawn capability and disabled upstream still expose the original unknown recovery action', async () => {
const withdrawn = snapshot(); withdrawn.providers[0].enabled = false
const model = withdrawn.providers[0].models[0]; model.present = false; model.capabilities.allowedStages = []
model.verification.analysis = { status: 'unknown', id: 'old-verification', taskId: 'old-task', checkedAt: null, requestId: 'old-request', message: '原请求仍需核查', canReconcile: true, canResume: false }
global.fetch = jest.fn(async () => response(withdrawn)) as typeof fetch
render(<AiModelManagementPanel />)
const recovery = await screen.findByRole('button', { name: '核查 vision-model · 平台模型服务 的资料分析结果' })
expect(recovery).toBeEnabled()
fireEvent.click(recovery)
expect(screen.getByLabelText('凭证编号')).toBeEnabled()
expect((global.fetch as jest.Mock).mock.calls.every(([, init]) => !init?.method)).toBe(true)
})

View File

@ -4,11 +4,11 @@
## 管理流程
1. 已有详情页上游可用“迁入当前连接”显式转为数据库配置;迁入操作只保存配置,不调用模型。凭据继续引用固定的服务端 `DETAIL_GENERATOR_API_KEY`,不复制到浏览器。
1. 已有服务端模型连接可用“迁入当前连接”显式转为数据库配置,默认名称为“平台模型服务”(旧默认名“现有详情页上游”);这是连接名称,不是模型类别,也不限于详情页使用。迁入操作只保存配置,不调用模型。凭据继续引用固定的服务端 `DETAIL_GENERATOR_API_KEY`,不复制到浏览器。
2. 新增上游填写名称、HTTPS API 地址和 API Key。新增或替换凭据需已有专用 `API_KEY_ENCRYPTION_KEY`,最少 32 字节。凭据使用 AES-256-GCM 加密,接口只返回配置状态;编辑时密钥留空保持现状。
3. “同步目录”请求上游 `/v1/models`。当前接入的 `api.vip98k.com` 还读取其公开的 `/api/v2/catalog/models` 结构化能力目录,只保留当前 Key 有权使用的模型。模型数量取决于上游目录、Key 权限和同步结果,不固定为某个数量。新发现模型默认未开放;同步不会调用生成接口,也不会自动批量测试。
4. 搜索模型或上游,按对话、视觉理解、生图、语音合成、语音识别、音乐、视频、向量、图像工具、3D、其他或待识别筛选。分类由服务端读取能力及协议证据,不按模型名字猜测。类型已识别不代表当前详情页已接入该类型。
5. 在模型行选择**测试用途**,点击“测试”。确认“将实际调用模型,可能产生上游费用”后,只测试当前模型的当前用途。资料分析、卖点文案、图片生成分别记录结果;一个用途通过,不会替另一个用途背书。存在未保存的配置时,先保存或撤销,再测试。
5. 在模型行选择**测试用途**,点击“测试”。确认“将实际调用模型,可能产生上游费用”后,只测试当前模型的当前用途。资料分析、卖点文案、图片生成分别记录结果;一个用途通过,不会替另一个用途背书。模型无需先开放即可测试;用户开放范围与默认值的未保存修改不阻止测试,结果刷新后保留草稿及原版本。上游连接必须已启用且配置密钥;编辑连接期间须先保存或取消,测试按钮旁显示阻止原因。
6. 用途实际测试通过后,勾选允许用户使用的用途,再勾选“开放”。测试成功不会自动开放模型。为三个阶段选择默认模型,点击“保存配置”后生效;没有有效测试结果、能力不符或尚未适配的用途不能开放。
7. 用户可在详情页“模型选择”中自行选择管理员已开放的模型,默认值只是新项目的初始选择。不同上游的同名模型以独立编号区分,实际请求仍使用上游原生模型 ID。详情页正式验收前仍只向超级管理员开放。
@ -19,15 +19,18 @@
| 界面状态 | 含义与操作 |
| --- | --- |
| 待测试 | 已有可执行的用途适配,尚无该用途的实际测试结果。 |
| 测试中 | 测试正在执行,禁止重复提交;“刷新测试状态”只重新读取平台记录。 |
| 测试中 | 测试正在执行,自动只读刷新状态;禁止重复提交。 |
| 已通过 | 当前连接、密钥、模型能力、协议路由和适配版本对应的用途测试通过,可由管理员开放。 |
| 未通过 | 已得到明确失败结果;查看测试详情,处理原因后再显式测试。 |
| 暂停核查 | 请求是否完成尚不明确,禁止直接重测,避免重复计费。 |
| 已核查,可重测 | 有凭证证明原请求已结束;仍属于未通过,须重新进行实际测试。 |
| 需重新测试 | 连接、密钥、能力、路由或适配版本已改变,原测试结果不能继续作为开放依据。 |
异步生图先持久化上游任务编号,再查询任务结果。仅当已有任务编号且当前连接及路由仍匹配时,显示“查询结果”;该操作只查询同一个上游任务,不重新发送生成请求。没有已知任务编号时,保持暂停,按测试详情中的请求编号核查上游记录。
异步生图先持久化上游任务编号,再查询任务结果。仅当结果未知、已有任务编号且当前连接及路由仍匹配时,显示“查询结果”;该操作只查询同一个上游任务,不重新发送生成请求。不能查询的未知结果显示“核查结果”:管理员在上游确认原请求未执行、失败或已完成后,填写凭证编号、核查说明并确认原请求已结束。服务端锁定记录,保存审计并将它关闭为未通过;不会变成已通过或自动开放。进行中的请求不能核查关闭,仍可查询的原任务也不能人工跳过。连接不可用时可核查原请求,不需先重新启用上游。迟到的调用结果不会覆盖已关闭记录或写入虚假的通过审计。
浏览器丢失测试响应后只查询状态,不自动重发付费请求;如果状态仍是旧记录,当前页面保留暂停状态。重复的同一幂等请求也不会新建模型调用。真实测试与详情页生成共用执行额度:每人同时 1 个,全局同时 2 个。
浏览器在提交前将请求编号保存在当前浏览器会话,丢失响应或重新加载后先恢复核查,不自动重发付费请求。“核查提交”与服务端受理共用事务锁:找到原记录就读取结果;没有受理记录就关闭该请求编号,拒绝其之后迟到的原提交。该操作不新建用途验证记录,也不会遮住其他未结束的测试。再次测试必须重新确认费用并使用新编号。真实测试与详情页生成共用执行额度:每人同时 1 个,全局同时 2 个;所有结果未知的测试在核查结束前保留占用。
2026-10-14 修复测试入口及异常恢复流程。测试、查询和刷新与未保存的开放设置独立,读取结果保留草稿及原版本;上层管理菜单也保护未保存修改与正在执行的操作。用途选择器与状态分开显示。真实隔离验收中,Gemini 3 Flash 的资料分析与文案、Nano Banana 2 的参考图生图通过;恢复查询沿用同一上游任务和测试编号,无新增生成受理。补充的 Claude Sonnet 4.6 请求结果未知,保持未开放,未重发。证据保存在 `output/ai-workflow-20261014/`;这不表示所有目录模型均已通过实测。
模型行的详情按钮默认折叠。展开后可查看当前用途实际绑定的协议、接口路径,以及目录提供的服务范围、路由和操作标识;已有结果还显示测试时间、消息和请求编号。这里显示服务端绑定,不允许前端随意修改路由。
@ -50,7 +53,7 @@
- 只接受 HTTPS 标准端口、无嵌入凭据或查询参数的服务地址;解析的全部 IP 都须为公网地址,并将已检查地址固定到实际 HTTPS 连接。拒绝跳转,防止 API Key 被带到其他地址。
- 修改 API 地址必须同时填写该服务的密钥,并清除原地址的目录配置。验证上下文包括连接、密钥、能力、协议路由及适配版本;任何相关变化都会使旧通过记录失效。密钥不会进入响应、审计元数据或项目快照。
- 上游、模型目录、阶段默认值及审计记录保存在 `ai_model_providers`、`ai_model_catalog`、`ai_model_configuration`、`ai_model_audit`。用途测试记录及任务检查点保存在 `ai_model_verifications`。配置事务串行并核对版本,旧配置不能覆盖新修改。
- 调用上游前先持久化测试记录并复核数据库超级管理员权限。正在执行或结果未知的同一模型用途禁止重新提交;已知异步任务只恢复查询,不重新生成。
- 调用上游前先持久化随机参考图检查点并复核数据库超级管理员与账号在用状态。分析须识别随机颜色及位置;生图须保留参考图颜色及相对位置,不能仅凭非空图片通过。验证规则变化使旧通过记录需重新测试,但不会阻断原异步任务的查询。正在执行或结果未知的同一模型禁止切换用途重复提交;已知异步任务只恢复查询。输出图片按公司需求限制在 1440px 内。
- `capability_metadata` 只持久化服务端归一化的能力证据,管理接口不接受客户端修改。公开能力目录无密钥请求,使用与私有请求相同的公网 DNS 固定、拒绝重定向和限时保护;公开目录限 8 MiB,私有模型列表保持 2 MiB。同步任一目录失败时不保存部分结果。
- 详情项目保存可信的上游及模型绑定,模型执行前重新核对可用性、权限和绑定。旧裸模型名只对应显式迁入的原服务,不能静默改到另一供应商。
- 缺少阶段默认模型时暂停生成;已有项目仍可修改文案、保存和导出。

View File

@ -0,0 +1,20 @@
/** @jest-environment node */
import { assertDetailGeneratorUser } from '@/lib/detail-generator/access'
import { isSuperAdmin } from '@/lib/department-permission'
import { prisma } from '@/lib/prisma'
jest.mock('next-auth', () => ({ getServerSession: jest.fn() }))
jest.mock('@/lib/auth', () => ({ authOptions: {} }))
jest.mock('@/lib/department-permission', () => ({ isSuperAdmin: jest.fn() }))
jest.mock('@/lib/prisma', () => ({ prisma: { user: { findUnique: jest.fn() } } }))
const originalFlag = process.env.DETAIL_GENERATOR_ENABLED
beforeEach(() => { jest.clearAllMocks(); process.env.DETAIL_GENERATOR_ENABLED = 'true'; (isSuperAdmin as jest.Mock).mockResolvedValue(true) })
afterAll(() => { if (originalFlag === undefined) delete process.env.DETAIL_GENERATOR_ENABLED; else process.env.DETAIL_GENERATOR_ENABLED = originalFlag })
test('an old login cannot run generation when the database account is inactive even if status says active', async () => {
;(prisma.user.findUnique as jest.Mock).mockResolvedValue({ isActive: false, status: 'active', archivedAt: null })
await expect(assertDetailGeneratorUser('owner')).rejects.toMatchObject({ status: 403, code: 'account_inactive' })
})
test('revoked super administrator fails before opening a model execution account', async () => {
;(isSuperAdmin as jest.Mock).mockResolvedValue(false)
await expect(assertDetailGeneratorUser('owner')).rejects.toMatchObject({ status: 403, code: 'forbidden' })
expect(prisma.user.findUnique).not.toHaveBeenCalled()
})

View File

@ -0,0 +1,106 @@
/** @jest-environment node */
import { randomUUID } from 'node:crypto'
import { AiModelError, assertAiModelAdmin, assertAiModelAdminForWrite } from '@/lib/ai-model-access'
import { checkAiModelSubmission, reconcileAiModelVerification } from '@/lib/ai-model-recovery'
import { aiModelAudit } from '@/lib/ai-model-repository'
import { assertAiModelRecoveryTextSafe, resolveAiModelVerification } from '@/lib/ai-model-service'
import { aiModelVerificationSummary } from '@/lib/ai-model-verification-state'
import { createVerificationChallenge, validateAiModelVerificationOutput, verificationReferenceImage } from '@/lib/ai-model-verification-output'
jest.mock('next-auth', () => ({ getServerSession: jest.fn() }))
jest.mock('@/lib/auth', () => ({ authOptions: {} }))
jest.mock('@/lib/department-permission', () => ({ isSuperAdmin: jest.fn() }))
jest.mock('@/lib/prisma', () => ({ prisma: {} }))
jest.mock('@/lib/ai-model-access', () => ({ ...jest.requireActual('@/lib/ai-model-access'), assertAiModelAdmin: jest.fn(), assertAiModelAdminForWrite: jest.fn() }))
jest.mock('@/lib/ai-model-service', () => ({ getAiModelAdminState: jest.fn(async () => ({ providers: [] })), resolveAiModelVerification: jest.fn(), assertAiModelRecoveryTextSafe: jest.fn() }))
const mockDb = { $queryRawUnsafe: jest.fn(), $executeRawUnsafe: jest.fn() }
jest.mock('@/lib/ai-model-repository', () => ({ aiModelTransaction: jest.fn(async action => action(mockDb)), aiModelAudit: jest.fn() }))
const providerId = randomUUID(), modelKey = randomUUID(), request = { stage: 'analysis', idempotencyKey: randomUUID() }
const proof = { outcome: 'failed', reference: 'upstream-log-123', note: '核查上游日志,原请求已失败并停止执行。', confirmed: true }
let row: any, exists: boolean, closed: boolean, found: boolean
beforeEach(() => {
jest.clearAllMocks(); exists = true; closed = false; found = true
row = { id: randomUUID(), modelKey, stage: 'analysis', idempotencyKey: request.idempotencyKey, actorId: 'admin', status: 'unknown', taskId: null, evidence: {}, contextHash: 'a'.repeat(64), deadlineAt: new Date(Date.now() - 1), startedAt: new Date(), finishedAt: new Date(), requestId: null }
;(assertAiModelAdmin as jest.Mock).mockResolvedValue('admin')
;(assertAiModelAdminForWrite as jest.Mock).mockResolvedValue(undefined)
;(assertAiModelRecoveryTextSafe as jest.Mock).mockResolvedValue(undefined)
;(resolveAiModelVerification as jest.Mock).mockResolvedValue({ contextHash: row.contextHash })
mockDb.$queryRawUnsafe.mockImplementation(async sql => sql.includes('FROM ai_model_catalog WHERE id=') ? found ? [{ id: modelKey }] : [] : sql.includes('FROM ai_model_audit') ? closed ? [{ id: 'audit' }] : [] : exists ? [row] : [])
mockDb.$executeRawUnsafe.mockResolvedValue(1)
})
test('absent intake is closed by audit and never creates a verification or calls a model', async () => {
exists = false
await expect(checkAiModelSubmission('admin', providerId, modelKey, request)).resolves.toMatchObject({ submissionCheck: { received: false, idempotencyKey: request.idempotencyKey } })
expect(aiModelAudit).toHaveBeenCalledWith(mockDb, 'admin', 'model.verification-intake-cancelled', providerId, expect.objectContaining(request))
expect(mockDb.$executeRawUnsafe).not.toHaveBeenCalled()
expect(resolveAiModelVerification).not.toHaveBeenCalled()
})
test('existing intake is read without cancellation or repeat model calls', async () => {
await expect(checkAiModelSubmission('admin', providerId, modelKey, request)).resolves.toMatchObject({ submissionCheck: { received: true } })
expect(aiModelAudit).not.toHaveBeenCalled()
expect(resolveAiModelVerification).not.toHaveBeenCalled()
})
test('closing the same absent intake twice does not duplicate audit', async () => {
exists = false; closed = true
await checkAiModelSubmission('admin', providerId, modelKey, request)
expect(aiModelAudit).not.toHaveBeenCalled()
})
test('wrong provider is denied; another super administrator can inspect the original global intake', async () => {
found = false
await expect(checkAiModelSubmission('admin', providerId, modelKey, request)).rejects.toMatchObject({ status: 404 })
found = true; row.actorId = 'another-admin'
await expect(checkAiModelSubmission('admin', providerId, modelKey, request)).resolves.toMatchObject({ submissionCheck: { received: true } })
expect(aiModelAudit).not.toHaveBeenCalled()
})
test('unknown without checkpoint can be closed with evidence, never passed or enabled', async () => {
await reconcileAiModelVerification('admin', providerId, row.id, proof)
expect(mockDb.$executeRawUnsafe).toHaveBeenCalledWith(expect.stringContaining("status='failed'"), row.id, expect.stringContaining('upstream-log-123'))
expect(aiModelAudit).toHaveBeenCalledWith(mockDb, 'admin', 'model.verification-reconciled', providerId, expect.any(Object))
expect(mockDb.$executeRawUnsafe.mock.calls.some(([sql]) => /status='passed'|UPDATE ai_model_catalog/.test(sql))).toBe(false)
})
test.each([{ confirmed: false }, { note: '确认' }, { reference: '' }, { outcome: 'unknown' }, { compatibility: 'confirmed' }])('insufficient evidence cannot close an unknown request: %j', async change => {
await expect(reconcileAiModelVerification('admin', providerId, row.id, { ...proof, ...change })).rejects.toMatchObject({ status: 400 })
expect(mockDb.$executeRawUnsafe).not.toHaveBeenCalled()
})
test('live running cannot be reconciled and queryable original task cannot be manually closed', async () => {
row.status = 'running'; row.deadlineAt = new Date(Date.now() + 600000)
await expect(reconcileAiModelVerification('admin', providerId, row.id, proof)).rejects.toMatchObject({ code: 'verification_not_unknown' })
row.status = 'unknown'; row.taskId = 'original-task'
await expect(reconcileAiModelVerification('admin', providerId, row.id, proof)).rejects.toMatchObject({ code: 'verification_query_required' })
expect(mockDb.$executeRawUnsafe).not.toHaveBeenCalled()
})
test('changed connection allows recording upstream evidence but never regenerates', async () => {
row.taskId = 'original-task'
;(resolveAiModelVerification as jest.Mock).mockResolvedValue({ contextHash: 'changed' })
await reconcileAiModelVerification('admin', providerId, row.id, proof)
expect(mockDb.$executeRawUnsafe).toHaveBeenCalledTimes(1)
})
test('idempotent reconciliation cannot rewrite the original evidence', async () => {
row.evidence = { reconciliation: proof }
await reconcileAiModelVerification('admin', providerId, row.id, { ...proof, outcome: 'completed' })
expect(mockDb.$executeRawUnsafe).not.toHaveBeenCalled()
expect(aiModelAudit).not.toHaveBeenCalled()
})
test('revocation inside transaction and credential-bearing notes cause no write', async () => {
;(assertAiModelAdminForWrite as jest.Mock).mockRejectedValue(new AiModelError('已撤权', 403))
await expect(checkAiModelSubmission('admin', providerId, modelKey, request)).rejects.toMatchObject({ status: 403 })
;(assertAiModelRecoveryTextSafe as jest.Mock).mockRejectedValue(new AiModelError('包含密钥', 400))
await expect(reconcileAiModelVerification('admin', providerId, row.id, proof)).rejects.toMatchObject({ status: 400 })
expect(mockDb.$executeRawUnsafe).not.toHaveBeenCalled()
})
test('old passed rules require retest; task recovery preserves context and cannot be silently reapproved', () => {
row.status = 'passed'
expect(aiModelVerificationSummary([row], modelKey, 'analysis', row.contextHash).status).toBe('stale')
row.status = 'unknown'; row.taskId = 'old-task'
expect(aiModelVerificationSummary([row], modelKey, 'analysis', row.contextHash)).toMatchObject({ status: 'unknown', canResume: true, canReconcile: false })
expect(aiModelVerificationSummary([row], modelKey, 'analysis', 'changed')).toMatchObject({ status: 'unknown', canResume: false, canReconcile: true })
expect(aiModelVerificationSummary([row], modelKey, 'analysis', row.contextHash, false)).toMatchObject({ status: 'unknown', canResume: false, canReconcile: true })
})
test('randomized reference image validation rejects swapped layout and wrong image interpretation', async () => {
const challenge = createVerificationChallenge(), correct = await verificationReferenceImage(challenge)
expect(challenge.topLeft).not.toBe(challenge.bottomRight)
await expect(validateAiModelVerificationOutput('image', { image: correct }, challenge)).resolves.toMatchObject({ referenceLayoutMatched: true })
const swapped = { topLeft: challenge.bottomRight, bottomRight: challenge.topLeft }
await expect(validateAiModelVerificationOutput('image', { image: await verificationReferenceImage(swapped) }, challenge)).rejects.toMatchObject({ code: 'verification_output_invalid' })
await expect(validateAiModelVerificationOutput('analysis', { text: JSON.stringify(swapped) }, challenge)).rejects.toMatchObject({ code: 'verification_output_invalid' })
await expect(validateAiModelVerificationOutput('analysis', { text: JSON.stringify(challenge) }, challenge)).resolves.toMatchObject({ imageInputVerified: true })
})

View File

@ -11,6 +11,12 @@ import { normalizeAiModelBaseUrl, requestAiModel, requestAiModelCapabilityCatalo
import { GET } from '@/app/api/admin/ai-models/route'
import { POST } from '@/app/api/admin/ai-models/providers/route'
test('inactive account with an active status string cannot manage or test models', async () => {
;(isSuperAdmin as jest.Mock).mockResolvedValue(true)
;(prisma.user.findUnique as jest.Mock).mockResolvedValue({ isSuperAdmin: true, status: 'active', isActive: false, archivedAt: null })
await expect(assertAiModelAdmin('admin')).rejects.toMatchObject({ status: 403, code: 'account_inactive' })
})
jest.mock('node:dns/promises', () => ({ lookup: jest.fn() }))
jest.mock('node:https', () => ({ request: jest.fn() }))
jest.mock('next-auth', () => ({ getServerSession: jest.fn() }))

View File

@ -15,7 +15,7 @@ jest.mock('@/lib/department-permission', () => ({ isSuperAdmin: jest.fn() }))
jest.mock('@/lib/prisma', () => ({ prisma: { user: { findUnique: jest.fn() } } }))
jest.mock('@/lib/ai-model-http', () => ({ ...jest.requireActual('@/lib/ai-model-http'), requestAiModel: jest.fn(), requestAiModelCapabilityCatalog: jest.fn() }))
jest.mock('@/lib/ai-model-verification-state', () => ({ ...jest.requireActual('@/lib/ai-model-verification-state'), listAiModelVerifications: jest.fn() }))
const mockDb = { $executeRawUnsafe: jest.fn() }
const mockDb = { $executeRawUnsafe: jest.fn(), $queryRawUnsafe: jest.fn(async () => [{ isSuperAdmin: true, isActive: true, status: 'active', archivedAt: null }]) }
jest.mock('@/lib/ai-model-repository', () => ({
...jest.requireActual('@/lib/ai-model-repository'), aiModelTransaction: jest.fn(async callback => callback(mockDb)),
listAiModelProviderRows: jest.fn(), listAiModelRows: jest.fn(), getAiModelConfiguration: jest.fn(), getAiModelProviderRow: jest.fn(), aiModelAudit: jest.fn(), saveAiModelDefaults: jest.fn(), updateAiModelProvider: jest.fn(),
@ -46,7 +46,7 @@ beforeEach(() => {
;(listAiModelRows as jest.Mock).mockImplementation(async () => models)
;(getAiModelProviderRow as jest.Mock).mockImplementation(async id => ({ ...providers.find(provider => provider.id === id) }))
;(getAiModelConfiguration as jest.Mock).mockResolvedValue({ revision: 1, defaults: { analysis: legacyModelKey, copy: legacyModelKey, image: legacyModelKey } })
;(listAiModelVerifications as jest.Mock).mockResolvedValue(models.flatMap(model => model.stages.map(stage => ({ id: `${model.key}-${stage}`, modelKey: model.key, stage, status: 'passed', requestId: null, taskId: null, message: null, startedAt: new Date(), deadlineAt: new Date(Date.now() + 600000), finishedAt: new Date(), contextHash: aiModelVerificationContext(providers.find(provider => provider.id === model.providerId)!.baseUrl, model.id, model.capabilityMetadata, model.providerId === legacyId ? 'synthetic-legacy-secret' : 'synthetic-second-secret', getAiModelStageProtocol(model.capabilityMetadata, stage)!) }))))
;(listAiModelVerifications as jest.Mock).mockResolvedValue(models.flatMap(model => model.stages.map(stage => ({ id: `${model.key}-${stage}`, modelKey: model.key, stage, status: 'passed', evidence: { ruleVersion: 2 }, requestId: null, taskId: null, message: null, startedAt: new Date(), deadlineAt: new Date(Date.now() + 600000), finishedAt: new Date(), contextHash: aiModelVerificationContext(providers.find(provider => provider.id === model.providerId)!.baseUrl, model.id, model.capabilityMetadata, model.providerId === legacyId ? 'synthetic-legacy-secret' : 'synthetic-second-secret', getAiModelStageProtocol(model.capabilityMetadata, stage)!) }))))
;(requestAiModel as jest.Mock).mockImplementation(async (_base, _key, _endpoint, _body, _idempotency, beforeRequest) => { await beforeRequest(); return Response.json({ data: [{ id: 'vendor/shared-model' }, { id: 'brand/new-model' }] }) })
})
afterAll(() => { process.env = originalEnv })

View File

@ -6,12 +6,14 @@ import { callAiModelProtocol, AiModelProtocolError } from '@/lib/ai-model-protoc
import { resolveAiModelVerification } from '@/lib/ai-model-service'
import { prisma } from '@/lib/prisma'
import { detailTransactionLock } from '@/lib/detail-generator/repository'
import { aiModelAudit } from '@/lib/ai-model-repository'
import sharp from 'sharp'
jest.mock('@/lib/ai-model-access', () => { const actual = jest.requireActual('@/lib/ai-model-access'); return { ...actual, assertAiModelAdmin: jest.fn() } })
jest.mock('@/lib/ai-model-access', () => { const actual = jest.requireActual('@/lib/ai-model-access'); return { ...actual, assertAiModelAdmin: jest.fn(), assertAiModelAdminForWrite: jest.fn() } })
jest.mock('next-auth', () => ({ getServerSession: jest.fn() }))
jest.mock('@/lib/auth', () => ({ authOptions: {} }))
jest.mock('@/lib/department-permission', () => ({ isSuperAdmin: jest.fn() }))
jest.mock('@/lib/prisma', () => ({ prisma: { $executeRawUnsafe: jest.fn() } }))
jest.mock('@/lib/prisma', () => ({ prisma: { $executeRawUnsafe: jest.fn(), $queryRawUnsafe: jest.fn() } }))
jest.mock('@/lib/ai-model-service', () => ({ getAiModelAdminState: jest.fn(async () => ({ providers: [] })), resolveAiModelVerification: jest.fn() }))
jest.mock('@/lib/ai-model-protocol', () => ({ ...jest.requireActual('@/lib/ai-model-protocol'), callAiModelProtocol: jest.fn() }))
jest.mock('@/lib/detail-generator/repository', () => ({ detailTransactionLock: jest.fn() }))
@ -26,7 +28,7 @@ beforeEach(() => {
;(assertAiModelAdmin as jest.Mock).mockResolvedValue('admin')
;(resolveAiModelVerification as jest.Mock).mockResolvedValue({ provider: { baseUrl: 'https://models.example.com/v1' }, model: { id: 'model' }, protocol: { id: 'openai-chat', path: '/v1/chat/completions', headers: {}, supportsImageInput: true }, apiKey: 'synthetic-test-secret', contextHash: row.contextHash })
mockDb.$queryRawUnsafe.mockImplementation(async (sql: string, ...args: any[]) => {
if (sql.startsWith('INSERT')) { row.idempotencyKey = args[4]; return [row] }
if (sql.startsWith('INSERT')) { row.idempotencyKey = args[4]; row.evidence = JSON.parse(args[6]); return [row] }
if (sql.includes('idempotency_key=$3')) return duplicate ? [row] : []
if (sql.includes('FOR UPDATE')) return [row]
if (sql.includes('active_executions')) return [{ count: active, own: 0 }]
@ -35,7 +37,8 @@ beforeEach(() => {
})
mockDb.$executeRawUnsafe.mockResolvedValue(1)
;(prisma.$executeRawUnsafe as jest.Mock).mockResolvedValue(1)
;(callAiModelProtocol as jest.Mock).mockImplementation(async request => { await request.beforeRequest(); return { text: '{"topLeft":"red","bottomRight":"blue"}', requestId: 'request-1', summary: {} } })
;(prisma.$queryRawUnsafe as jest.Mock).mockResolvedValue([{ id: row.id }])
;(callAiModelProtocol as jest.Mock).mockImplementation(async request => { await request.beforeRequest(); return { text: JSON.stringify(row.evidence?.challenge || { topLeft: 'red', bottomRight: 'blue' }), requestId: 'request-1', summary: {} } })
})
test('a real image-understanding response persists passed evidence without enabling the model', async () => {
await verifyAiModel('admin', providerId, modelKey, input())
@ -59,7 +62,7 @@ test('verification respects active generation capacity', async () => {
expect(callAiModelProtocol).not.toHaveBeenCalled()
const capacity = mockDb.$queryRawUnsafe.mock.calls.find(([sql]) => sql.includes('active_executions'))!
expect(capacity[0]).toContain("status='queued' AND checkpoint->>'dispatchPending'='true'")
expect(capacity[0]).toContain("status='unknown' AND task_id IS NOT NULL")
expect(capacity[0]).toContain("status IN ('running','unknown')")
})
test('revocation before admission performs no call or insert', async () => {
;(assertAiModelAdmin as jest.Mock).mockRejectedValue(new AiModelError('forbidden', 403))
@ -131,3 +134,28 @@ test('output acceptance checks image interpretation and actual outline structure
await expect(validateAiModelVerificationOutput('copy', { text: '{"text":"ok"}' })).rejects.toMatchObject({ code: 'verification_output_invalid' })
await expect(validateAiModelVerificationOutput('copy', { text: '{"sellingPoints":["蓝色"],"warnings":[],"screens":[{"title":"蓝色产品","body":"型号TEST","imagePrompt":"蓝色产品,无文字"}]}' })).resolves.toMatchObject({ outlineSchemaVerified: true })
})
test('a closed absent submission cannot arrive late and create a paid call', async () => {
const original = mockDb.$queryRawUnsafe.getMockImplementation()!
mockDb.$queryRawUnsafe.mockImplementation(async (sql: string, ...args: any[]) => sql.includes('verification-intake-cancelled') ? [{ id: 'closed-intake' }] : original(sql, ...args))
await expect(verifyAiModel('admin', providerId, modelKey, input())).rejects.toMatchObject({ code: 'verification_submission_closed' })
expect(callAiModelProtocol).not.toHaveBeenCalled()
expect(mockDb.$queryRawUnsafe.mock.calls.some(([sql]) => sql.startsWith('INSERT'))).toBe(false)
})
test('a closed verification stops dispatch and a late response cannot append a false success audit', async () => {
;(prisma.$queryRawUnsafe as jest.Mock).mockResolvedValue([])
await verifyAiModel('admin', providerId, modelKey, input())
expect(mockDb.$executeRawUnsafe.mock.calls.some(([sql]) => sql.includes("status='passed'"))).toBe(false)
;(prisma.$queryRawUnsafe as jest.Mock).mockResolvedValue([{ id: row.id }])
mockDb.$executeRawUnsafe.mockImplementation(async sql => sql.includes("status='passed'") ? 0 : 1)
await verifyAiModel('admin', providerId, modelKey, input())
expect((aiModelAudit as jest.Mock).mock.calls.some(([, , action]) => action === 'model.verification-passed')).toBe(false)
})
test('a terminal decoded image that fails reference validation is failed, not an endless unknown task', async () => {
const image = await sharp({ create: { width: 128, height: 128, channels: 3, background: '#ffffff' } }).png().toBuffer()
;(callAiModelProtocol as jest.Mock).mockImplementation(async request => { await request.beforeRequest(); await request.onTaskSubmitted('task-ended'); return { image, requestId: 'known-result', summary: {} } })
row.stage = 'image'
await verifyAiModel('admin', providerId, modelKey, { ...input(), stage: 'image' })
expect(mockDb.$executeRawUnsafe.mock.calls.find(([sql]) => sql.includes('status=$2'))?.[2]).toBe('failed')
expect(callAiModelProtocol).toHaveBeenCalledTimes(1)
})

View File

@ -4,6 +4,7 @@ import { authOptions } from '@/lib/auth'
import { isSuperAdmin } from '@/lib/department-permission'
import { prisma } from '@/lib/prisma'
import { DetailGeneratorError } from '@/lib/detail-generator/access'
import type { AiModelDatabase } from './ai-model-repository'
export class AiModelError extends DetailGeneratorError {
constructor(message: string, status = 400, code = 'invalid_request') { super(message, status, code); this.name = 'AiModelError' }
@ -14,10 +15,17 @@ export async function assertAiModelAdmin(userId: string | null | undefined): Pro
if (!(await isSuperAdmin(userId))) throw new AiModelError('仅超级管理员可管理 AI 模型', 403, 'forbidden')
const user = await prisma.user.findUnique({ where: { id: userId }, select: { isSuperAdmin: true, status: true, isActive: true, archivedAt: true } })
if (!user?.isSuperAdmin) throw new AiModelError('仅超级管理员可管理 AI 模型', 403, 'forbidden')
if ((user.status || (user.isActive ? 'active' : 'pending')) !== 'active' || user.archivedAt) throw new AiModelError('账号当前不可用', 403, 'account_inactive')
if (!user.isActive || (user.status || 'active') !== 'active' || user.archivedAt) throw new AiModelError('账号当前不可用', 403, 'account_inactive')
return userId
}
export async function assertAiModelAdminForWrite(userId: string, db: AiModelDatabase): Promise<void> {
const rows = await db.$queryRawUnsafe<Array<{ isSuperAdmin: boolean; isActive: boolean; status: string | null; archivedAt: Date | null }>>('SELECT "isSuperAdmin","isActive",status,"archivedAt" FROM users WHERE id=$1 FOR SHARE', userId)
const user = rows[0]
if (!user?.isSuperAdmin) throw new AiModelError('仅超级管理员可管理 AI 模型', 403, 'forbidden')
if (!user.isActive || (user.status || 'active') !== 'active' || user.archivedAt) throw new AiModelError('账号当前不可用', 403, 'account_inactive')
}
export async function aiModelAdminRoute(action: (userId: string) => Promise<unknown>): Promise<NextResponse> {
try {
const session = await getServerSession(authOptions)

View File

@ -161,7 +161,7 @@ async function decodeImage(data: any, input: AiModelProtocolInput, taskId: strin
bytes = Buffer.from(await response.arrayBuffer())
} else throw new AiModelProtocolError('模型没有返回可保存的图片', false, null, taskId)
try {
const output = await sharp(bytes, { limitInputPixels: 40_000_000 }).rotate().resize(4096, 4096, { fit: 'inside', withoutEnlargement: true }).png().toBuffer()
const output = await sharp(bytes, { limitInputPixels: 40_000_000 }).rotate().resize(1440, 1440, { fit: 'inside', withoutEnlargement: true }).png().toBuffer()
if (output.length > 20 * 1024 * 1024) throw new Error('size')
return output
} catch { throw new AiModelProtocolError('模型图片无法解码或超出限制', false, null, taskId) }

57
lib/ai-model-recovery.ts Normal file
View File

@ -0,0 +1,57 @@
import { z } from 'zod'
import { AiModelError, assertAiModelAdmin, assertAiModelAdminForWrite } from './ai-model-access'
import { aiModelAudit, aiModelTransaction } from './ai-model-repository'
import { assertAiModelRecoveryTextSafe, getAiModelAdminState, resolveAiModelVerification } from './ai-model-service'
import { AI_MODEL_STAGES } from './ai-model-types'
import { AI_MODEL_VERIFICATION_COLUMNS, verificationEffectiveStatus, type AiModelVerificationRow } from './ai-model-verification-state'
const submissionSchema = z.object({ stage: z.enum(AI_MODEL_STAGES), idempotencyKey: z.string().uuid() }).strict()
const reconciliationSchema = z.object({ outcome: z.enum(['not_executed', 'failed', 'completed']), reference: z.string().trim().min(3).max(160), note: z.string().trim().min(10).max(500), confirmed: z.literal(true) }).strict()
/** Serialize intake checking against admission. Closing an absent submission prevents a late HTTP request from being admitted. */
export async function checkAiModelSubmission(userId: string, providerId: string, modelKey: string, body: unknown) {
const parsed = submissionSchema.safeParse(body)
if (!parsed.success) throw new AiModelError('提交核查参数无效')
await assertAiModelAdmin(userId)
const { stage, idempotencyKey } = parsed.data
const received = await aiModelTransaction(async db => {
await assertAiModelAdminForWrite(userId, db)
const models = await db.$queryRawUnsafe<Array<{ id: string }>>('SELECT id FROM ai_model_catalog WHERE id=$1 AND provider_id=$2', modelKey, providerId)
if (!models[0]) throw new AiModelError('模型不存在', 404)
const rows = await db.$queryRawUnsafe<AiModelVerificationRow[]>(`SELECT ${AI_MODEL_VERIFICATION_COLUMNS} FROM ai_model_verifications WHERE model_key=$1 AND stage=$2 AND idempotency_key=$3 FOR UPDATE`, modelKey, stage, idempotencyKey)
if (rows[0]) {
return true
}
const closed = await db.$queryRawUnsafe<Array<{ id: string }>>(`SELECT id FROM ai_model_audit WHERE action='model.verification-intake-cancelled' AND details->>'modelKey'=$1 AND details->>'stage'=$2 AND details->>'idempotencyKey'=$3`, modelKey, stage, idempotencyKey)
if (!closed[0]) await aiModelAudit(db, userId, 'model.verification-intake-cancelled', providerId, { modelKey, stage, idempotencyKey })
return false
})
return { ...await getAiModelAdminState(), submissionCheck: { modelKey, stage, idempotencyKey, received } }
}
/** Close an unqueryable unknown outcome with traceable upstream evidence. Never approves compatibility or enables a model. */
export async function reconcileAiModelVerification(userId: string, providerId: string, verificationId: string, body: unknown) {
const parsed = reconciliationSchema.safeParse(body)
if (!z.string().uuid().safeParse(verificationId).success || !parsed.success) throw new AiModelError('请填写核查结论、凭证编号及核查说明,并确认原请求已结束')
await assertAiModelAdmin(userId)
await assertAiModelRecoveryTextSafe(providerId, `${parsed.data.reference}\n${parsed.data.note}`)
await aiModelTransaction(async db => {
await assertAiModelAdminForWrite(userId, db)
const rows = await db.$queryRawUnsafe<AiModelVerificationRow[]>(`SELECT ${AI_MODEL_VERIFICATION_COLUMNS} FROM ai_model_verifications WHERE id=$1 AND model_key IN (SELECT id FROM ai_model_catalog WHERE provider_id=$2) FOR UPDATE`, verificationId, providerId)
const row = rows[0]
if (!row) throw new AiModelError('测试记录不存在', 404)
if (row.evidence?.reconciliation) return // Repeated close cannot rewrite the original evidence.
if (verificationEffectiveStatus(row) !== 'unknown') throw new AiModelError('仅结果未知的测试可核查;进行中的测试请等待', 409, 'verification_not_unknown')
if (row.taskId) {
let contextHash: string | null = null
try { contextHash = (await resolveAiModelVerification(providerId, row.modelKey, row.stage)).contextHash } catch (error) {
if (!(error instanceof AiModelError) || error.status >= 500) throw error
}
if (contextHash === row.contextHash) throw new AiModelError('原任务仍可查询,请使用查询结果;不能通过人工核查跳过', 409, 'verification_query_required')
}
const reconciliation = { ...parsed.data, actorId: userId, checkedAt: new Date().toISOString() }
await db.$executeRawUnsafe(`UPDATE ai_model_verifications SET status='failed',message='原请求核查已结束,可重新测试;核查不代表接口测试通过',evidence=evidence || $2::jsonb,finished_at=NOW() WHERE id=$1`, row.id, JSON.stringify({ reconciliation }))
await aiModelAudit(db, userId, 'model.verification-reconciled', providerId, { verificationId: row.id, modelKey: row.modelKey, stage: row.stage, reconciliation })
})
return getAiModelAdminState()
}

View File

@ -1,6 +1,6 @@
import { createHash, randomUUID } from 'node:crypto'
import { z } from 'zod'
import { AiModelError, assertAiModelAdmin } from './ai-model-access'
import { AiModelError, assertAiModelAdmin, assertAiModelAdminForWrite } from './ai-model-access'
import { decryptAiModelKey, encryptAiModelKey, isAiModelEncryptionConfigured } from './ai-model-crypto'
import { normalizeAiModelBaseUrl, requestAiModel, requestAiModelCapabilityCatalog } from './ai-model-http'
import { aiModelCapabilityFingerprint, getAiModelCapabilities, getAiModelStageProtocol, mergeAiModelCapabilityMetadata, parseAiModelCapabilityMetadata, parseAiModelPublicCapabilityCatalog, type AiModelCapabilityMetadata } from './ai-model-capabilities'
@ -29,6 +29,12 @@ function providerKey(provider: AiModelProviderRow): string {
else if (provider.secretCiphertext && provider.secretNonce && provider.secretAuthTag) return decryptAiModelKey({ secretCiphertext: provider.secretCiphertext, secretNonce: provider.secretNonce, secretAuthTag: provider.secretAuthTag })
throw new AiModelError('模型服务尚未配置可用密钥', 503, 'credential_unavailable')
}
/** Recovery notes are audit metadata, never a place to store credentials. */
export async function assertAiModelRecoveryTextSafe(providerId: string, text: string): Promise<void> {
const provider = await getAiModelProviderRow(providerId)
if (hasCredential(provider) && text.includes(providerKey(provider))) throw new AiModelError('核查记录不能包含模型密钥', 400, 'reconciliation_sensitive')
if (/\b(?:sk-[\w-]{12,}|Bearer\s+\S+|api[_-]?key\s*[:=]\s*\S+)/i.test(text)) throw new AiModelError('核查记录不能包含凭据', 400, 'reconciliation_sensitive')
}
function modelContext(model: AiModelRow, provider: AiModelProviderRow, stage: AiModelStage): string | null {
const protocol = getAiModelStageProtocol(model.capabilityMetadata, stage)
return protocol && hasCredential(provider) ? aiModelVerificationContext(provider.baseUrl, model.id, model.capabilityMetadata, providerKey(provider), protocol) : null
@ -43,16 +49,17 @@ export async function getAiModelAdminState(): Promise<AiModelAdminState> {
return { providers: providers.map(provider => ({ id: provider.id, name: provider.name, baseUrl: provider.baseUrl, enabled: provider.enabled, credentialSource: provider.credentialSource, hasApiKey: hasCredential(provider), revision: provider.revision, syncedAt: provider.syncedAt?.toISOString() || null, models: models.filter(model => model.providerId === provider.id).map(({ providerId: _providerId, capabilityMetadata, ...model }) => {
const storedCapabilities = getAiModelCapabilities(provider.baseUrl, model.id, capabilityMetadata)
const capabilities = model.present ? storedCapabilities : { ...storedCapabilities, allowedStages: [], protocols: {}, reason: '已从当前连接的模型目录下架,暂不可用' }
const verification = Object.fromEntries(AI_MODEL_STAGES.map(stage => [stage, aiModelVerificationSummary(verifications, model.key, stage, modelContext({ ...model, providerId: provider.id, capabilityMetadata }, provider, stage))])) as import('./ai-model-types').AiModelCatalogEntry['verification']
const verification = Object.fromEntries(AI_MODEL_STAGES.map(stage => [stage, aiModelVerificationSummary(verifications, model.key, stage, modelContext({ ...model, providerId: provider.id, capabilityMetadata }, provider, stage), provider.enabled && model.present)])) as import('./ai-model-types').AiModelCatalogEntry['verification']
return { ...model, capabilities, verification }
}) })), ...configuration, encryptionConfigured: isAiModelEncryptionConfigured(), legacyAvailable: Boolean(process.env.DETAIL_GENERATOR_API_KEY?.trim()) && !providers.some(provider => provider.legacyBinding) }
}
export async function createAiModelProvider(userId: string, body: unknown): Promise<AiModelAdminState> {
await assertAiModelAdmin(userId)
const input = parse(createSchema, body)
const provider: AiModelProviderRow = { id: randomUUID(), name: input.name, baseUrl: normalizeAiModelBaseUrl(input.baseUrl), enabled: input.enabled ?? false, credentialSource: 'encrypted', ...encryptAiModelKey(input.apiKey), legacyBinding: false, revision: 1, syncedAt: null }
await aiModelTransaction(async db => {
await assertAiModelAdmin(userId)
await assertAiModelAdminForWrite(userId, db)
if ((await listAiModelProviderRows(db)).length >= 100) throw new AiModelError('最多接入 100 个模型服务')
await insertAiModelProvider(db, provider)
await aiModelAudit(db, userId, 'provider.create', provider.id, { name: provider.name, baseUrl: provider.baseUrl, enabled: provider.enabled, credentialChanged: true })
@ -61,10 +68,11 @@ export async function createAiModelProvider(userId: string, body: unknown): Prom
}
export async function editAiModelProvider(userId: string, providerId: string, body: unknown): Promise<AiModelAdminState> {
await assertAiModelAdmin(userId)
assertAiModelId(providerId); const input = parse(updateSchema, body)
const encrypted = input.apiKey ? encryptAiModelKey(input.apiKey) : null
await aiModelTransaction(async db => {
await assertAiModelAdmin(userId)
await assertAiModelAdminForWrite(userId, db)
const current = await getAiModelProviderRow(providerId, db); assertAiModelRevision(current.revision, input.revision)
const baseUrl = input.baseUrl ? normalizeAiModelBaseUrl(input.baseUrl) : current.baseUrl
if (current.legacyBinding && baseUrl !== current.baseUrl) throw new AiModelError('迁入的原连接须保留原服务地址;切换上游请新增服务并在项目中明确选择')
@ -92,11 +100,12 @@ function environmentModels(): Record<AiModelStage, string[]> {
}
export async function importAiModelEnvironment(userId: string): Promise<AiModelAdminState> {
await assertAiModelAdmin(userId)
if (!process.env.DETAIL_GENERATOR_API_KEY?.trim()) throw new AiModelError('服务端没有可迁入的模型连接', 503)
const stages = environmentModels()
const provider: AiModelProviderRow = { id: randomUUID(), name: '现有详情页上游', baseUrl: normalizeAiModelBaseUrl(process.env.DETAIL_GENERATOR_BASE_URL?.trim() || 'https://api.vip98k.com/v1'), enabled: true, credentialSource: 'environment', ...encryptedAiModelFields(null), legacyBinding: true, revision: 1, syncedAt: null }
const provider: AiModelProviderRow = { id: randomUUID(), name: '平台模型服务', baseUrl: normalizeAiModelBaseUrl(process.env.DETAIL_GENERATOR_BASE_URL?.trim() || 'https://api.vip98k.com/v1'), enabled: true, credentialSource: 'environment', ...encryptedAiModelFields(null), legacyBinding: true, revision: 1, syncedAt: null }
await aiModelTransaction(async db => {
await assertAiModelAdmin(userId)
await assertAiModelAdminForWrite(userId, db)
if ((await listAiModelProviderRows(db)).some(value => value.legacyBinding)) throw new AiModelError('现有连接已迁入,请在列表中管理', 409, 'already_imported')
await insertAiModelProvider(db, provider)
const imported = new Map<string, string>()
@ -124,6 +133,7 @@ export function parseAiModelCatalog(value: unknown): string[] {
}
export async function syncAiModelProvider(userId: string, providerId: string, body: unknown): Promise<AiModelAdminState> {
await assertAiModelAdmin(userId)
assertAiModelId(providerId); const input = parse(z.object({ revision: revision.min(1) }).strict(), body)
const source = await getAiModelProviderRow(providerId); assertAiModelRevision(source.revision, input.revision)
const apiKey = providerKey(source)
@ -151,7 +161,7 @@ export async function syncAiModelProvider(userId: string, providerId: string, bo
}
if (Array.from(metadata.values()).some(evidence => JSON.stringify(evidence).includes(apiKey))) throw new AiModelError('上游能力目录包含敏感内容,已拒绝保存', 502, 'catalog_invalid')
await aiModelTransaction(async db => {
await assertAiModelAdmin(userId)
await assertAiModelAdminForWrite(userId, db)
const current = await getAiModelProviderRow(providerId, db); assertAiModelRevision(current.revision, source.revision)
const prior = (await listAiModelRows(db)).filter(model => model.providerId === providerId)
const catalogChanged = prior.some(model => model.present !== models.includes(model.id)) || models.some(model => !prior.some(entry => entry.id === model && aiModelCapabilityFingerprint(entry.capabilityMetadata) === aiModelCapabilityFingerprint(metadata.get(model))))
@ -173,10 +183,11 @@ async function validateDefaults(db: AiModelDatabase, defaults: AiModelDefaults):
}
export async function configureAiModels(userId: string, body: unknown): Promise<AiModelAdminState> {
await assertAiModelAdmin(userId)
const input: AiModelConfigurationInput = parse(configurationSchema, body)
if (new Set(input.providers.map(provider => provider.id)).size !== input.providers.length) throw new AiModelError('服务编号重复')
await aiModelTransaction(async db => {
await assertAiModelAdmin(userId)
await assertAiModelAdminForWrite(userId, db)
assertAiModelRevision((await getAiModelConfiguration(db)).revision, input.revision)
const prior = await listAiModelRows(db)
const verifications = await listAiModelVerifications(db)

View File

@ -10,6 +10,10 @@ export interface AiModelVerificationSummary {
requestId: string | null
message: string | null
canResume?: boolean
canReconcile?: boolean
reconciled?: boolean
taskId?: string | null
submissionKey?: string
}
export interface AiModelCapabilities {
kind: AiModelKind
@ -44,6 +48,7 @@ export interface AiModelAdminState {
revision: number
encryptionConfigured: boolean
legacyAvailable: boolean
submissionCheck?: { idempotencyKey: string; modelKey: string; stage: AiModelStage; received: boolean }
}
export interface AiModelConfigurationInput {
revision: number

View File

@ -0,0 +1,70 @@
import { randomInt, randomUUID } from 'node:crypto'
import sharp from 'sharp'
import { AiModelError } from './ai-model-access'
import { detailAnalysisSchema } from './detail-generator/validation'
import type { AiModelStage } from './ai-model-types'
export const AI_MODEL_VERIFICATION_RULE_VERSION = 2
const colors = { red: '#ff0000', blue: '#0000ff', green: '#00ff00', yellow: '#ffff00' } as const
export type VerificationChallenge = { topLeft: keyof typeof colors; bottomRight: keyof typeof colors }
const names = Object.keys(colors) as Array<keyof typeof colors>
export function createVerificationChallenge(): VerificationChallenge {
const topLeft = names[randomInt(names.length)]
const others = names.filter(name => name !== topLeft)
return { topLeft, bottomRight: others[randomInt(others.length)] }
}
export function readVerificationChallenge(value: unknown): VerificationChallenge {
const entry = value as VerificationChallenge | null
// Older, already-submitted tests used these two colors. Resume their existing task only.
if (!entry) return { topLeft: 'red', bottomRight: 'blue' }
if (!names.includes(entry.topLeft) || !names.includes(entry.bottomRight) || entry.topLeft === entry.bottomRight) throw new AiModelError('测试参考图检查点无效', 409)
return { topLeft: entry.topLeft, bottomRight: entry.bottomRight }
}
export async function verificationReferenceImage(challenge: VerificationChallenge): Promise<Buffer> {
return sharp({ create: { width: 512, height: 512, channels: 3, background: '#ffffff' } }).composite([
{ input: await sharp({ create: { width: 180, height: 180, channels: 3, background: colors[challenge.topLeft] } }).png().toBuffer(), left: 32, top: 32 },
{ input: await sharp({ create: { width: 180, height: 180, channels: 3, background: colors[challenge.bottomRight] } }).png().toBuffer(), left: 300, top: 300 },
]).png().toBuffer()
}
function parseJson(text: string | undefined): any {
if (!text) throw new AiModelError('模型未返回可验证的内容', 422, 'verification_output_invalid')
try { return JSON.parse(text.replace(/^\s*```(?:json)?\s*/i, '').replace(/\s*```\s*$/, '')) } catch { throw new AiModelError('模型未返回要求的 JSON 内容', 422, 'verification_output_invalid') }
}
async function referenceLayoutMatches(bytes: Buffer, expected: VerificationChallenge): Promise<boolean> {
const { data, info } = await sharp(bytes, { limitInputPixels: 40_000_000 }).flatten({ background: '#fff' }).removeAlpha().resize(96, 96, { fit: 'fill' }).raw().toBuffer({ resolveWithObject: true })
const hues = { red: 0, yellow: 60, green: 120, blue: 240 }
const found = { topLeft: { n: 0, x: 0, y: 0 }, bottomRight: { n: 0, x: 0, y: 0 } }
for (let y = 0; y < info.height; y++) for (let x = 0; x < info.width; x++) {
const offset = (y * info.width + x) * info.channels
const r = data[offset] / 255, g = data[offset + 1] / 255, b = data[offset + 2] / 255
const max = Math.max(r, g, b), min = Math.min(r, g, b), delta = max - min
if (max < .3 || delta / max < .45) continue
let hue = max === r ? 60 * ((g - b) / delta) : max === g ? 60 * ((b - r) / delta + 2) : 60 * ((r - g) / delta + 4)
hue = (hue + 360) % 360
for (const key of ['topLeft', 'bottomRight'] as const) {
const distance = Math.abs(hue - hues[expected[key]])
if (Math.min(distance, 360 - distance) > 28) continue
found[key].n++; found[key].x += (x + .5) / info.width; found[key].y += (y + .5) / info.height
}
}
const a = found.topLeft, b = found.bottomRight, minimum = info.width * info.height * .015
return a.n >= minimum && b.n >= minimum && a.x / a.n < .5 && a.y / a.n < .5 && b.x / b.n > .5 && b.y / b.n > .5
}
export async function validateAiModelVerificationOutput(stage: AiModelStage, result: { text?: string; image?: Buffer }, challenge: VerificationChallenge = { topLeft: 'red', bottomRight: 'blue' }): Promise<Record<string, unknown>> {
if (stage === 'analysis') {
const value = parseJson(result.text)
if (value.topLeft !== challenge.topLeft || value.bottomRight !== challenge.bottomRight) throw new AiModelError('图片内容识别未通过,不能开放资料分析', 422, 'verification_output_invalid')
return { imageInputVerified: true, colorPositionsMatched: true, textCharacters: result.text!.length }
}
if (stage === 'copy') {
const value = parseJson(result.text)
if (Array.isArray(value?.screens)) value.screens = value.screens.map((screen: any) => ({ ...screen, id: randomUUID() }))
if (!detailAnalysisSchema.safeParse(value).success || value.screens.length !== 1) throw new AiModelError('文案大纲结构未通过,不能开放卖点文案', 422, 'verification_output_invalid')
return { outlineSchemaVerified: true, screenCount: 1, textCharacters: result.text!.length }
}
if (!result.image) throw new AiModelError('模型未返回图片', 422, 'verification_output_invalid')
const image = sharp(result.image, { limitInputPixels: 40_000_000 }), metadata = await image.metadata(), stats = await image.stats()
if (!metadata.width || !metadata.height || metadata.width < 64 || metadata.height < 64 || !stats.channels.some(channel => channel.stdev > 1)) throw new AiModelError('模型返回的图片为空白或无效', 422, 'verification_output_invalid')
if (!(await referenceLayoutMatches(result.image, challenge))) throw new AiModelError('图片未保留参考图的颜色与相对位置,不能开放参考图生图', 422, 'verification_output_invalid')
return { referenceImageAccepted: true, referenceLayoutMatched: true, imageDecoded: true, width: metadata.width, height: metadata.height, bytes: result.image.length }
}

View File

@ -4,6 +4,7 @@ import type { AiModelDatabase } from './ai-model-repository'
import type { AiModelStage, AiModelVerificationSummary } from './ai-model-types'
import { aiModelCapabilityFingerprint } from './ai-model-capabilities'
import { AI_MODEL_ADAPTER_VERSION, type AiModelProtocol } from './ai-model-protocol'
import { AI_MODEL_VERIFICATION_RULE_VERSION } from './ai-model-verification-output'
export interface AiModelVerificationRow {
id: string; modelKey: string; stage: AiModelStage; contextHash: string; idempotencyKey: string; actorId: string;
@ -26,12 +27,13 @@ export function verificationEffectiveStatus(row: AiModelVerificationRow): AiMode
return row.status === 'running' && new Date(row.deadlineAt).getTime() <= Date.now() ? 'unknown' : row.status
}
export function aiModelVerificationSummary(rows: AiModelVerificationRow[], modelKey: string, stage: AiModelStage, contextHash: string | null): AiModelVerificationSummary {
export function aiModelVerificationSummary(rows: AiModelVerificationRow[], modelKey: string, stage: AiModelStage, contextHash: string | null, queryAvailable = true): AiModelVerificationSummary {
const row = rows.find(value => value.modelKey === modelKey && value.stage === stage)
if (!row) return { status: 'untested', id: null, checkedAt: null, requestId: null, message: null }
const effective = verificationEffectiveStatus(row)
const status = effective === 'unknown' ? 'unknown' : row.contextHash !== contextHash ? 'stale' : effective
const status = effective === 'unknown' ? 'unknown' : row.contextHash !== contextHash || effective === 'passed' && row.evidence?.ruleVersion !== AI_MODEL_VERIFICATION_RULE_VERSION ? 'stale' : effective
const canResume = Boolean(queryAvailable && row.taskId && row.contextHash === contextHash && effective === 'unknown')
return { status, id: row.id, checkedAt: row.finishedAt?.toISOString() || row.startedAt.toISOString(), requestId: row.requestId,
canResume: Boolean(row.taskId && row.contextHash === contextHash && ['unknown', 'running'].includes(effective)),
message: status === 'stale' ? '连接、路由或模型能力已改变,请重新测试' : status === 'unknown' ? '请求结果尚未确认,已暂停重复测试;请核查上游请求记录' : row.message }
canResume, canReconcile: effective === 'unknown' && !canResume, reconciled: Boolean(row.evidence?.reconciliation), taskId: row.taskId, submissionKey: row.idempotencyKey,
message: status === 'stale' ? '连接、路由或测试规则已改变,请重新测试' : status === 'unknown' ? '请求结果尚未确认,已暂停重复测试;请核查上游请求记录' : row.message }
}

View File

@ -1,14 +1,14 @@
import { randomUUID } from 'node:crypto'
import sharp from 'sharp'
import { z } from 'zod'
import { AiModelError, assertAiModelAdmin } from './ai-model-access'
import { AiModelError, assertAiModelAdmin, assertAiModelAdminForWrite } from './ai-model-access'
import { aiModelAudit, aiModelTransaction } from './ai-model-repository'
import { getAiModelAdminState, resolveAiModelVerification } from './ai-model-service'
import { AI_MODEL_ADAPTER_VERSION, callAiModelProtocol } from './ai-model-protocol'
import { AI_MODEL_STAGES, type AiModelStage } from './ai-model-types'
import { AI_MODEL_VERIFICATION_COLUMNS, type AiModelVerificationRow } from './ai-model-verification-state'
import { prisma } from './prisma'
import { detailAnalysisSchema } from './detail-generator/validation'
import { AI_MODEL_VERIFICATION_RULE_VERSION, createVerificationChallenge, readVerificationChallenge, validateAiModelVerificationOutput, verificationReferenceImage } from './ai-model-verification-output'
export { validateAiModelVerificationOutput } from './ai-model-verification-output'
import { detailTransactionLock } from './detail-generator/repository'
import type { AiModelDatabase } from './ai-model-repository'
@ -17,57 +17,32 @@ function safeId(value: unknown, secret: string): string | null { return typeof v
async function assertExecutionSlot(db: AiModelDatabase, actorId: string, resumeId: string | null = null): Promise<void> {
await detailTransactionLock(db, 'detail-generator-execution')
const rows = await db.$queryRawUnsafe<Array<{ count: number; own: number }>>(`SELECT COUNT(*)::int AS count,COUNT(*) FILTER (WHERE actor_id=$1)::int AS own FROM (
SELECT actor_id FROM ai_model_verifications WHERE ($2::text IS NULL OR id<>$2) AND (status='running' OR status='unknown' AND task_id IS NOT NULL)
SELECT actor_id FROM ai_model_verifications WHERE ($2::text IS NULL OR id<>$2) AND status IN ('running','unknown')
UNION ALL SELECT owner_id AS actor_id FROM detail_generator_jobs WHERE status='running' AND lease_expires_at>NOW()
OR status='queued' AND checkpoint->>'dispatchPending'='true' AND COALESCE(checkpoint #>> '{upstreamTask,id}', '') ~ '^[a-zA-Z0-9][a-zA-Z0-9_.:-]{0,199}$'
) AS active_executions`, actorId, resumeId)
if (rows[0]?.count >= 2 || rows[0]?.own >= 1) throw new AiModelError('已有生成或模型测试正在执行,请等待完成', 409, 'verification_busy')
}
async function referenceImage(): Promise<Buffer> {
return sharp({ create: { width: 512, height: 512, channels: 3, background: '#ffffff' } }).composite([
{ input: await sharp({ create: { width: 180, height: 180, channels: 3, background: '#ff0000' } }).png().toBuffer(), left: 32, top: 32 },
{ input: await sharp({ create: { width: 180, height: 180, channels: 3, background: '#0000ff' } }).png().toBuffer(), left: 300, top: 300 },
]).png().toBuffer()
}
function parseJson(text: string | undefined): any {
if (!text) throw new AiModelError('模型未返回可验证的内容', 422, 'verification_output_invalid')
try { return JSON.parse(text.replace(/^\s*```(?:json)?\s*/i, '').replace(/\s*```\s*$/, '')) } catch { throw new AiModelError('模型未返回要求的 JSON 内容', 422, 'verification_output_invalid') }
}
export async function validateAiModelVerificationOutput(stage: AiModelStage, result: { text?: string; image?: Buffer }): Promise<Record<string, unknown>> {
if (stage === 'analysis') {
const value = parseJson(result.text)
if (value.topLeft !== 'red' || value.bottomRight !== 'blue') throw new AiModelError('图片内容识别未通过,不能开放资料分析', 422, 'verification_output_invalid')
return { imageInputVerified: true, colorPositionsMatched: true, textCharacters: result.text!.length }
}
if (stage === 'copy') {
const value = parseJson(result.text)
if (Array.isArray(value?.screens)) value.screens = value.screens.map((screen: any) => ({ ...screen, id: randomUUID() }))
if (!detailAnalysisSchema.safeParse(value).success || value.screens.length !== 1) throw new AiModelError('文案大纲结构未通过,不能开放卖点文案', 422, 'verification_output_invalid')
return { outlineSchemaVerified: true, screenCount: 1, textCharacters: result.text!.length }
}
if (!result.image) throw new AiModelError('模型未返回图片', 422, 'verification_output_invalid')
const image = sharp(result.image, { limitInputPixels: 40_000_000 }), metadata = await image.metadata(), stats = await image.stats()
if (!metadata.width || !metadata.height || metadata.width < 64 || metadata.height < 64 || !stats.channels.some(channel => channel.stdev > 1)) throw new AiModelError('模型返回的图片为空白或无效', 422, 'verification_output_invalid')
return { referenceImageAccepted: true, imageDecoded: true, width: metadata.width, height: metadata.height, bytes: result.image.length }
}
async function executeVerification(row: AiModelVerificationRow, userId: string, providerId: string, resume: boolean): Promise<void> {
let sent = false, apiKey = '', knownTaskId = row.taskId
let sent = false, resultReceived = false, apiKey = '', knownTaskId = row.taskId
const beforeRequest = async () => {
await assertAiModelAdmin(userId)
const current = await resolveAiModelVerification(providerId, row.modelKey, row.stage)
if (current.contextHash !== row.contextHash) throw new AiModelError('连接或路由已改变,测试已停止', 409, 'verification_context_changed')
const pending = await prisma.$queryRawUnsafe<Array<{ id: string }>>("SELECT id FROM ai_model_verifications WHERE id=$1 AND status='running' AND deadline_at>NOW()", row.id)
if (!pending[0]) throw new AiModelError('原请求已暂停或核查结束,不能继续调用', 409, 'verification_closed')
sent = true
}
try {
const context = await resolveAiModelVerification(providerId, row.modelKey, row.stage)
apiKey = context.apiKey
if (context.contextHash !== row.contextHash) throw new AiModelError('验证对应的连接或路由已改变,不能继续此任务', 409, 'verification_context_changed')
const challenge = readVerificationChallenge(row.evidence?.challenge)
const prompt = row.stage === 'analysis' ? '观察图片:左上方方块与右下方方块分别是什么颜色?只输出 JSON:{"topLeft":"英文小写颜色","bottomRight":"英文小写颜色"}。不要根据文字猜测,必须读取图片。'
: row.stage === 'copy' ? '真实产品资料:型号TEST,颜色蓝色。不要编造其他规格。写一屏中文详情页,严格只输出 JSON:{"sellingPoints":["卖点"],"warnings":[],"screens":[{"title":"标题","body":"正文","imagePrompt":"无文字的场景描述"}]}。'
: '依据参考图片生成一张简洁的产品展示图,保留两个方块的颜色与相对位置,白色背景,不要加入文字或标志。'
const result = await callAiModelProtocol({ baseUrl: context.provider.baseUrl, apiKey: context.apiKey, modelId: context.model.id, protocol: context.protocol,
prompt, ...(row.stage !== 'copy' ? { images: [{ buffer: await referenceImage(), mimeType: 'image/png' }] } : {}), maxOutputTokens: row.stage === 'copy' ? 1200 : 1024,
prompt, ...(row.stage !== 'copy' ? { images: [{ buffer: await verificationReferenceImage(challenge), mimeType: 'image/png' }] } : {}), maxOutputTokens: row.stage === 'copy' ? 1200 : 1024,
idempotencyKey: row.idempotencyKey, beforeRequest, ...(resume ? { resumeTaskId: row.taskId! } : {}),
onTaskSubmitted: async taskId => {
if (!safeId(taskId, context.apiKey)) throw new AiModelError('上游任务编号无效', 502, 'verification_task_invalid')
@ -75,21 +50,23 @@ async function executeVerification(row: AiModelVerificationRow, userId: string,
const updated = await prisma.$executeRawUnsafe(`UPDATE ai_model_verifications SET task_id=$2 WHERE id=$1 AND status='running' AND (task_id IS NULL OR task_id=$2)`, row.id, taskId)
if (updated !== 1) throw new AiModelError('任务检查点未保存,已暂停后续请求', 409, 'verification_checkpoint_failed')
} })
const evidence = await validateAiModelVerificationOutput(row.stage, result)
resultReceived = true // The native protocol has returned a terminal, decoded result.
const evidence = await validateAiModelVerificationOutput(row.stage, result, challenge)
await assertAiModelAdmin(userId)
await aiModelTransaction(async db => {
await assertAiModelAdminForWrite(userId, db)
const current = await resolveAiModelVerification(providerId, row.modelKey, row.stage)
if (current.contextHash !== row.contextHash) throw new AiModelError('验证期间连接或路由已改变', 409, 'verification_context_changed')
await db.$executeRawUnsafe(`UPDATE ai_model_verifications SET status='passed',request_id=$2,message='实际接口测试通过',evidence=$3::jsonb,finished_at=NOW() WHERE id=$1 AND status='running'`, row.id, safeId(result.requestId, context.apiKey), JSON.stringify({ ...evidence, adapterVersion: AI_MODEL_ADAPTER_VERSION, protocol: context.protocol.id, scopeId: context.protocol.scopeId, routeId: context.protocol.routeId, operationId: context.protocol.operationId }))
await aiModelAudit(db, userId, 'model.verification-passed', providerId, { verificationId: row.id, modelKey: row.modelKey, stage: row.stage, evidence })
const updated = await db.$executeRawUnsafe(`UPDATE ai_model_verifications SET status='passed',request_id=$2,message='实际接口测试通过',evidence=evidence || $3::jsonb,finished_at=NOW() WHERE id=$1 AND status='running' AND deadline_at>NOW()`, row.id, safeId(result.requestId, context.apiKey), JSON.stringify({ ...evidence, ruleVersion: AI_MODEL_VERIFICATION_RULE_VERSION, adapterVersion: AI_MODEL_ADAPTER_VERSION, protocol: context.protocol.id, scopeId: context.protocol.scopeId, routeId: context.protocol.routeId, operationId: context.protocol.operationId }))
if (updated === 1) await aiModelAudit(db, userId, 'model.verification-passed', providerId, { verificationId: row.id, modelKey: row.modelKey, stage: row.stage, evidence })
})
} catch (error) {
const outcome = error as { outcomeUnknown?: boolean; taskId?: string; requestId?: string }
const unknown = typeof outcome.outcomeUnknown === 'boolean' ? (sent || Boolean(knownTaskId)) && outcome.outcomeUnknown : Boolean(knownTaskId) || sent && !(error instanceof AiModelError)
const unknown = !resultReceived && (typeof outcome.outcomeUnknown === 'boolean' ? (sent || Boolean(knownTaskId)) && outcome.outcomeUnknown : Boolean(knownTaskId) || sent && !(error instanceof AiModelError))
const message = unknown ? '请求结果尚未确认,请核查上游记录;禁止重复提交' : error instanceof AiModelError ? error.message : '模型测试未通过'
await aiModelTransaction(async db => {
await db.$executeRawUnsafe(`UPDATE ai_model_verifications SET status=$2,request_id=$3,task_id=COALESCE(task_id,$4),message=$5,finished_at=NOW() WHERE id=$1 AND status='running'`, row.id, unknown ? 'unknown' : 'failed', safeId(outcome.requestId, apiKey), safeId(outcome.taskId || knownTaskId, apiKey), message)
await aiModelAudit(db, userId, unknown ? 'model.verification-unknown' : 'model.verification-failed', providerId, { verificationId: row.id, modelKey: row.modelKey, stage: row.stage })
const updated = await db.$executeRawUnsafe(`UPDATE ai_model_verifications SET status=$2,request_id=$3,task_id=COALESCE(task_id,$4),message=$5,finished_at=NOW() WHERE id=$1 AND status='running'`, row.id, unknown ? 'unknown' : 'failed', safeId(outcome.requestId, apiKey), safeId(outcome.taskId || knownTaskId, apiKey), message)
if (updated === 1) await aiModelAudit(db, userId, unknown ? 'model.verification-unknown' : 'model.verification-failed', providerId, { verificationId: row.id, modelKey: row.modelKey, stage: row.stage })
})
}
}
@ -100,14 +77,16 @@ export async function verifyAiModel(userId: string, providerId: string, modelKey
await assertAiModelAdmin(userId)
const input = parsed.data, context = await resolveAiModelVerification(providerId, modelKey, input.stage, input.revision)
const created = await aiModelTransaction(async db => {
await assertAiModelAdmin(userId)
await assertAiModelAdminForWrite(userId, db)
const prior = await db.$queryRawUnsafe<AiModelVerificationRow[]>(`SELECT ${AI_MODEL_VERIFICATION_COLUMNS} FROM ai_model_verifications WHERE model_key=$1 AND stage=$2 AND idempotency_key=$3`, modelKey, input.stage, input.idempotencyKey)
if (prior[0]) return null // A repeated HTTP request never repeats a model call.
const closed = await db.$queryRawUnsafe<Array<{ id: string }>>(`SELECT id FROM ai_model_audit WHERE action='model.verification-intake-cancelled' AND details->>'modelKey'=$1 AND details->>'stage'=$2 AND details->>'idempotencyKey'=$3`, modelKey, input.stage, input.idempotencyKey)
if (closed[0]) throw new AiModelError('原提交已关闭,请重新确认测试并创建新请求', 409, 'verification_submission_closed')
await db.$executeRawUnsafe(`UPDATE ai_model_verifications SET status='unknown',message='进程或请求中断,结果需核查',finished_at=NOW() WHERE status='running' AND deadline_at<=NOW()`)
const active = await db.$queryRawUnsafe<Array<{ count: number }>>(`SELECT COUNT(*)::int AS count FROM ai_model_verifications WHERE model_key=$1 AND stage=$2 AND status IN ('running','unknown')`, modelKey, input.stage)
if (active[0]?.count) throw new AiModelError('该用途存在进行中或结果未知的请求,不能重复测试;请先核查原请求', 409, 'verification_unresolved')
const active = await db.$queryRawUnsafe<Array<{ count: number }>>(`SELECT COUNT(*)::int AS count FROM ai_model_verifications WHERE model_key=$1 AND status IN ('running','unknown')`, modelKey)
if (active[0]?.count) throw new AiModelError('该模型存在进行中或结果未知的请求,不能重复测试;请先核查原请求', 409, 'verification_unresolved')
await assertExecutionSlot(db, userId)
const rows = await db.$queryRawUnsafe<AiModelVerificationRow[]>(`INSERT INTO ai_model_verifications (id,model_key,stage,context_hash,idempotency_key,actor_id,status) VALUES ($1,$2,$3,$4,$5,$6,'running') RETURNING ${AI_MODEL_VERIFICATION_COLUMNS}`, randomUUID(), modelKey, input.stage, context.contextHash, input.idempotencyKey, userId)
const rows = await db.$queryRawUnsafe<AiModelVerificationRow[]>(`INSERT INTO ai_model_verifications (id,model_key,stage,context_hash,idempotency_key,actor_id,status,evidence) VALUES ($1,$2,$3,$4,$5,$6,'running',$7::jsonb) RETURNING ${AI_MODEL_VERIFICATION_COLUMNS}`, randomUUID(), modelKey, input.stage, context.contextHash, input.idempotencyKey, userId, JSON.stringify({ challenge: createVerificationChallenge() }))
await aiModelAudit(db, userId, 'model.verification-start', providerId, { verificationId: rows[0].id, modelKey, stage: input.stage })
return rows[0]
})
@ -120,6 +99,7 @@ export async function refreshAiModelVerification(userId: string, providerId: str
if (!z.string().uuid().safeParse(verificationId).success) throw new AiModelError('测试编号无效')
await assertAiModelAdmin(userId)
const row = await aiModelTransaction(async db => {
await assertAiModelAdminForWrite(userId, db)
const rows = await db.$queryRawUnsafe<AiModelVerificationRow[]>(`SELECT ${AI_MODEL_VERIFICATION_COLUMNS} FROM ai_model_verifications WHERE id=$1 FOR UPDATE`, verificationId)
const current = rows[0]
if (!current?.taskId || !['unknown','running'].includes(current.status)) throw new AiModelError('没有可以查询的上游任务', 409, 'verification_not_resumable')

View File

@ -20,7 +20,7 @@ export async function assertDetailGeneratorUser(userId: string | null | undefine
}
const user = await prisma.user.findUnique({ where: { id: userId }, select: { status: true, isActive: true, archivedAt: true } })
const status = user?.status || (user?.isActive ? 'active' : 'pending')
if (!user || status !== 'active' || user.archivedAt) throw new DetailGeneratorError('账号当前不可用,无法使用详情页生成', 403, 'account_inactive')
if (!user || !user.isActive || status !== 'active' || user.archivedAt) throw new DetailGeneratorError('账号当前不可用,无法使用详情页生成', 403, 'account_inactive')
return userId
}

View File

@ -161,7 +161,7 @@ export async function claimDetailJob(): Promise<StoredDetailJob | null> {
AND checkpoint->>'stage' LIKE 'screen-%'))`
await tx.$executeRawUnsafe(`UPDATE detail_generator_jobs SET status = CASE WHEN ${unresolved} THEN 'unknown' ELSE 'queued' END, error = CASE WHEN ${unresolved} THEN '执行中断,模型请求结果未知;请核查服务商记录,勿重复提交' ELSE NULL END, lease_token = NULL, lease_expires_at = NULL, updated_at = NOW() WHERE status = 'running' AND lease_expires_at < NOW()`)
const pendingTask = `checkpoint->>'dispatchPending' = 'true' AND COALESCE(checkpoint #>> '{upstreamTask,id}', '') ~ '^[a-zA-Z0-9][a-zA-Z0-9_.:-]{0,199}$'`
const activeVerification = `((status='running' AND deadline_at>NOW()) OR (status IN ('running','unknown') AND task_id IS NOT NULL))`
const activeVerification = `(status IN ('running','unknown'))`
const count = await tx.$queryRawUnsafe<Row[]>(`SELECT ((SELECT COUNT(*) FROM detail_generator_jobs WHERE (status='running' AND lease_expires_at>=NOW()) OR (status='queued' AND ${pendingTask})) + (SELECT COUNT(*) FROM ai_model_verifications WHERE ${activeVerification}))::int AS count`)
// A queued known upstream task already owns its slot. Let it poll even when both slots are occupied.
const candidates = await tx.$queryRawUnsafe<Row[]>(`SELECT ${jobColumns} FROM detail_generator_jobs j WHERE status = 'queued' AND ($1::boolean OR (${pendingTask})) AND (CASE WHEN jsonb_typeof(checkpoint->'pollNotBefore') = 'number' THEN (checkpoint->>'pollNotBefore')::numeric ELSE 0 END) <= EXTRACT(EPOCH FROM NOW()) * 1000 AND EXISTS (SELECT 1 FROM detail_generator_projects p WHERE p.id = j.project_id AND p.deleted_at IS NULL) AND NOT EXISTS (SELECT 1 FROM detail_generator_jobs other WHERE other.owner_id = j.owner_id AND other.status = 'running' AND other.lease_expires_at >= NOW()) AND NOT EXISTS (SELECT 1 FROM ai_model_verifications v WHERE v.actor_id=j.owner_id AND ${activeVerification}) ORDER BY created_at LIMIT 1 FOR UPDATE SKIP LOCKED`, Number(count[0]?.count) < 2)

View File

@ -0,0 +1,91 @@
/** Non-production native HTTP acceptance. Recovery fixtures never dispatch an upstream request. */
import assert from 'node:assert/strict'
import { randomUUID } from 'node:crypto'
import { mkdir, readFile, writeFile } from 'node:fs/promises'
import { PrismaClient } from '@prisma/client'
import { aiModelVerificationContext } from '../lib/ai-model-verification-state'
import { getAiModelStageProtocol } from '../lib/ai-model-capabilities'
import type { AiModelAdminState } from '../lib/ai-model-types'
const db = new URL(process.env.DATABASE_URL || '')
assert.equal(db.hostname, '127.0.0.1'); assert.equal(db.port, '55441'); assert.equal(db.pathname, '/ai_model_test')
const base = 'http://localhost:3125', output = 'D:/AI Code/gongxiangp2/output/ai-workflow-20261014', phase = process.argv[2] || 'recovery'
const prisma = new PrismaClient({ log: [] })
async function save(name: string, value: unknown) { await mkdir(output, { recursive: true }); await writeFile(`${output}/${name}.json`, JSON.stringify(value, null, 2)) }
async function session() {
const jar = new Map<string, string>()
const store = (response: Response) => { for (const cookie of response.headers.getSetCookie()) { const value = cookie.split(';')[0], at = value.indexOf('='); jar.set(value.slice(0, at), value.slice(at + 1)) } }
const csrf = await fetch(base + '/api/auth/csrf'); store(csrf)
const { csrfToken } = await csrf.json()
const cookie = () => Array.from(jar).map(([key, value]) => `${key}=${value}`).join('; ')
const signed = await fetch(base + '/api/auth/callback/credentials', { method: 'POST', redirect: 'manual', headers: { 'Content-Type': 'application/x-www-form-urlencoded', Origin: base, Cookie: cookie() }, body: new URLSearchParams({ csrfToken, email: 'ai-admin-0@example.invalid', password: 'AiAdminLocalTest-20261012', callbackUrl: base + '/admin', json: 'true' }) })
store(signed); assert.equal(signed.status, 200); return cookie()
}
async function main() {
const cookie = await session(), actor = await prisma.user.findUniqueOrThrow({ where: { email: 'ai-admin-0@example.invalid' } })
assert.ok(actor.isSuperAdmin && actor.isActive)
async function request(path = '', method = 'GET', body?: unknown) {
const response = await fetch(base + '/api/admin/ai-models' + path, { method, headers: { Cookie: cookie, 'Content-Type': 'application/json' }, ...(body === undefined ? {} : { body: JSON.stringify(body) }), signal: AbortSignal.timeout(30000) })
const raw = await response.text(); assert.ok(!process.env.DETAIL_GENERATOR_API_KEY || !raw.includes(process.env.DETAIL_GENERATOR_API_KEY), 'Credential leaked')
return { status: response.status, data: JSON.parse(raw) }
}
let state = (await request()).data as AiModelAdminState
const provider = state.providers.find(value => value.credentialSource === 'environment')!; assert.ok(provider)
if (phase === 'query-real') {
const model = provider.models.find(value => value.id === 'nano-banana-2')!, result = model?.verification.image
assert.ok(result?.id && result.taskId && result.status === 'passed')
const starts = await prisma.$queryRawUnsafe<Array<{ count: number }>>(`SELECT COUNT(*)::int AS count FROM ai_model_audit WHERE action='model.verification-start' AND details->>'verificationId'=$1`, result.id)
await save('real-query-checkpoint', { verificationId: result.id, taskId: result.taskId, simulation: 'QA-only interrupted result checkpoint; original successful upstream task retained' })
await prisma.$executeRawUnsafe(`UPDATE ai_model_verifications SET status='unknown',message='隔离验收:模拟结果落库中断,查询原上游任务' WHERE id=$1 AND actor_id=$2 AND model_key=$3 AND status='passed' AND task_id=$4`, result.id, actor.id, model.key, result.taskId)
const recovered = await request(`/providers/${provider.id}/verifications/${result.id}/refresh`, 'POST'); assert.equal(recovered.status, 200)
const after = recovered.data.providers.find((value: any) => value.id === provider.id).models.find((value: any) => value.key === model.key).verification.image
assert.equal(after.id, result.id); assert.equal(after.taskId, result.taskId); assert.equal(after.status, 'passed')
const finalStarts = await prisma.$queryRawUnsafe<Array<{ count: number }>>(`SELECT COUNT(*)::int AS count FROM ai_model_audit WHERE action='model.verification-start' AND details->>'verificationId'=$1`, result.id)
assert.equal(finalStarts[0].count, starts[0].count)
const evidence = { nativeHttp: true, sameVerificationId: result.id, sameUpstreamTaskId: result.taskId, status: after.status, newGenerationAdmissions: 0, productionTouched: false }
await save('real-query-acceptance', evidence); console.log(JSON.stringify(evidence)); return
}
if (phase === 'rename') {
if (provider.name === '现有详情页上游') { const result = await request(`/providers/${provider.id}`, 'PATCH', { name: '平台模型服务', revision: provider.revision }); assert.equal(result.status, 200); state = result.data }
await save('provider-name', { name: state.providers.find(value => value.id === provider.id)!.name, productionTouched: false }); return
}
if (phase === 'cleanup') {
const receipt = JSON.parse(await readFile(`${output}/recovery-fixture.json`, 'utf8'))
const models = await prisma.$queryRawUnsafe<Array<{ id: string; model_id: string }>>('SELECT id,model_id FROM ai_model_catalog WHERE id=$1 AND provider_id=$2', receipt.modelKey, provider.id)
assert.equal(models[0]?.model_id, receipt.modelId); assert.ok(receipt.modelId.startsWith('qa-workflow-'))
await prisma.$transaction(async tx => {
await tx.$executeRawUnsafe('DELETE FROM ai_model_verifications WHERE model_key=$1 AND actor_id=$2', receipt.modelKey, actor.id)
await tx.$executeRawUnsafe('DELETE FROM ai_model_catalog WHERE id=$1 AND model_id=$2', receipt.modelKey, receipt.modelId)
}); await save('recovery-fixture-cleanup', { modelKey: receipt.modelKey, productionTouched: false }); return
}
assert.equal(phase, 'recovery')
const source = await prisma.$queryRawUnsafe<Array<{ capability_metadata: unknown }>>('SELECT capability_metadata FROM ai_model_catalog WHERE provider_id=$1 AND model_id=$2', provider.id, 'gemini-3-flash')
assert.ok(source[0]); const metadata = source[0].capability_metadata, modelKey = randomUUID(), modelId = `qa-workflow-${randomUUID()}`
const protocol = getAiModelStageProtocol(metadata, 'analysis'); assert.ok(protocol)
const contextHash = aiModelVerificationContext(provider.baseUrl, modelId, metadata, process.env.DETAIL_GENERATOR_API_KEY!, protocol)
await prisma.$executeRawUnsafe(`INSERT INTO ai_model_catalog (id,provider_id,model_id,enabled,stages,present,capability_metadata) VALUES ($1,$2,$3,FALSE,ARRAY[]::text[],TRUE,$4::jsonb)`, modelKey, provider.id, modelId, JSON.stringify(metadata))
await save('recovery-fixture', { modelKey, modelId, providerId: provider.id, actorId: actor.id })
const before = await prisma.$queryRawUnsafe<Array<{ count: number }>>('SELECT COUNT(*)::int AS count FROM ai_model_verifications')
const absent = { stage: 'analysis', idempotencyKey: randomUUID() }
const checked = await request(`/providers/${provider.id}/models/${modelKey}/submission`, 'POST', absent)
assert.equal(checked.status, 200); assert.equal(checked.data.submissionCheck.received, false)
const late = await request(`/providers/${provider.id}/models/${modelKey}/verify`, 'POST', { ...absent, revision: provider.revision })
assert.equal(late.status, 409); assert.equal(late.data.code, 'verification_submission_closed')
const after = await prisma.$queryRawUnsafe<Array<{ count: number }>>('SELECT COUNT(*)::int AS count FROM ai_model_verifications'); assert.equal(after[0].count, before[0].count)
const id = randomUUID(), submissionKey = randomUUID()
await prisma.$executeRawUnsafe(`INSERT INTO ai_model_verifications (id,model_key,stage,context_hash,idempotency_key,actor_id,status) VALUES ($1,$2,'analysis',$3,$4,$5,'unknown')`, id, modelKey, contextHash, submissionKey, actor.id)
const known = await request(`/providers/${provider.id}/models/${modelKey}/submission`, 'POST', { stage: 'analysis', idempotencyKey: submissionKey }); assert.equal(known.data.submissionCheck.received, true)
const blocked = await request(`/providers/${provider.id}/models/${modelKey}/verify`, 'POST', { stage: 'copy', idempotencyKey: randomUUID(), revision: provider.revision }); assert.equal(blocked.data.code, 'verification_unresolved')
const proof = { outcome: 'failed', reference: 'qa-native-log-001', note: '隔离环境核查用例:原请求未调用上游,已结束。', confirmed: true }
const bad = await request(`/providers/${provider.id}/verifications/${id}/reconcile`, 'POST', { ...proof, confirmed: false }); assert.equal(bad.status, 400)
const ended = await request(`/providers/${provider.id}/verifications/${id}/reconcile`, 'POST', proof); assert.equal(ended.status, 200)
const model = ended.data.providers.find((value: any) => value.id === provider.id).models.find((value: any) => value.key === modelKey)
assert.equal(model.verification.analysis.status, 'failed'); assert.equal(model.verification.analysis.reconciled, true); assert.equal(model.enabled, false)
const cannotEnable = await request('/configuration', 'PUT', { revision: state.revision, defaults: { analysis: null, copy: null, image: null }, providers: [{ id: provider.id, revision: provider.revision, models: [{ key: modelKey, enabled: true, stages: ['analysis'] }] }] }); assert.equal(cannotEnable.data.code, 'model_verification_required')
const deniedState = await prisma.$queryRawUnsafe<Array<{ enabled: boolean }>>('SELECT enabled FROM ai_model_catalog WHERE id=$1', modelKey); assert.equal(deniedState[0].enabled, false)
await prisma.$executeRawUnsafe(`UPDATE ai_model_verifications SET status='unknown',evidence='{}'::jsonb,task_id='qa-queryable-original' WHERE id=$1`, id)
const queryOnly = await request(`/providers/${provider.id}/verifications/${id}/reconcile`, 'POST', proof); assert.equal(queryOnly.data.code, 'verification_query_required')
await prisma.$executeRawUnsafe(`UPDATE ai_model_verifications SET task_id=NULL WHERE id=$1`, id)
const evidence = { nativeHttp: true, productionTouched: false, lateSubmission: 409, differentStageBypass: 409, insufficientEvidence: 400, queryableTaskCannotClose: 409, manualRecoveryDoesNotPass: true, enableWithoutPass: 400, paidModelCalls: 0 }
await save('native-recovery-acceptance', evidence); console.log(JSON.stringify(evidence))
}
main().catch(error => { console.error(error instanceof Error ? error.message : 'Acceptance failed'); process.exitCode = 1 }).finally(() => prisma.$disconnect())