feat: add capability-aware AI model administration

This commit is contained in:
WuYongJie 2026-10-13 09:59:04 +08:00
parent 56c9dc1f2d
commit e5a20753b6
49 changed files with 2352 additions and 126 deletions

View File

@ -511,6 +511,13 @@
"description": "详情页复用的 detailPrimaryClass,用于分析、确认生成、继续生成与导出;保持既有 Action Blue,悬停变体仅限该控件。",
"html": "<button class=\"ds-detail-button-primary\" type=\"button\">确认大纲并生成</button>",
"css": ".ds-detail-button-primary{display:inline-flex;min-height:44px;flex-shrink:0;align-items:center;justify-content:center;gap:8px;white-space:nowrap;border:0;border-radius:8px;background:#1677ff;padding:0 16px;color:#fff;font:600 14px/20px 'Space Grotesk','Noto Sans SC',ui-sans-serif,system-ui,sans-serif;cursor:pointer;transition:color .15s,background-color .15s,border-color .15s}.ds-detail-button-primary:hover{background:#0964de}.ds-detail-button-primary:focus-visible{outline:none;box-shadow:0 0 0 2px #fff,0 0 0 4px #1677ff}.ds-detail-button-primary:disabled{cursor:not-allowed;opacity:.5}"
},
{
"name": "AI Model Management Controls",
"kind": "custom",
"description": "仅记录 AiModelManagementPanel 的三个阶段默认模型、目录搜索/能力/开放状态筛选及管理员人工兼容确认控件;可用用途由服务端能力决定。提取现有 fieldClass、buttonClass、primaryClass、checkboxClass;目录每页 20 行,手机搜索整行、两个筛选器各半行;不改变全局令牌。",
"html": "<section class=\"ds-ai-model-controls\" aria-label=\"AI 模型接入控件\"><div class=\"ds-ai-model-actions\"><button type=\"button\" class=\"ds-ai-model-button\">撤销</button><button type=\"button\" class=\"ds-ai-model-button ds-ai-model-primary\">保存配置</button></div><fieldset class=\"ds-ai-model-defaults\"><legend class=\"ds-ai-model-sr\">阶段默认模型</legend><label class=\"ds-ai-model-label\">资料分析默认模型<select class=\"ds-ai-model-field\"><option>未设置</option></select></label><label class=\"ds-ai-model-label\">卖点文案默认模型<select class=\"ds-ai-model-field\"><option>未设置</option></select></label><label class=\"ds-ai-model-label\">图片生成默认模型<select class=\"ds-ai-model-field\"><option>未设置</option></select></label></fieldset><div class=\"ds-ai-model-filters\"><label class=\"ds-ai-model-search\"><span class=\"ds-ai-model-sr\">搜索模型或上游</span><svg aria-hidden=\"true\" viewBox=\"0 0 24 24\"><circle cx=\"11\" cy=\"11\" r=\"8\"/><path d=\"m21 21-4.3-4.3\"/></svg><input class=\"ds-ai-model-field\" placeholder=\"搜索模型或上游\"></label><select class=\"ds-ai-model-field\" aria-label=\"按能力筛选模型\"><option>全部能力</option><option>对话</option><option>视觉理解</option><option>生图</option><option>其他</option><option>待核验</option></select><select class=\"ds-ai-model-field\" aria-label=\"按状态筛选模型\"><option>全部状态</option><option>已开放</option><option>未开放</option><option>兼容待确认</option><option>已离开目录</option></select></div><p class=\"ds-ai-model-hint\">用户可自行选择已开放的模型;不支持的用途不可勾选。</p><label class=\"ds-ai-model-confirm\"><input type=\"checkbox\" class=\"ds-ai-model-checkbox\">管理员已确认</label></section>",
"css": ".ds-ai-model-controls{min-width:0;background:#fff;color:#101828;font-family:'Space Grotesk','Noto Sans SC',ui-sans-serif,system-ui,sans-serif}.ds-ai-model-actions{display:flex;flex-wrap:wrap;justify-content:flex-end;gap:8px;margin-bottom:16px}.ds-ai-model-button{display:inline-flex;min-height:44px;flex-shrink:0;align-items:center;justify-content:center;gap:8px;border:1px solid #cfd8e3;border-radius:8px;background:#fff;padding:0 12px;color:#344054;font:500 14px/20px 'Space Grotesk','Noto Sans SC',ui-sans-serif,system-ui,sans-serif;transition:color .15s,background-color .15s,border-color .15s}.ds-ai-model-button:hover{background:#f8fafc}.ds-ai-model-primary{border-color:#0050cb;background:#0050cb;color:#fff}.ds-ai-model-primary:hover{background:#003da3}.ds-ai-model-button:focus-visible{outline:none;box-shadow:0 0 0 2px #fff,0 0 0 4px #1677ff}.ds-ai-model-button:disabled{cursor:not-allowed;opacity:.5}.ds-ai-model-defaults{display:grid;min-width:0;gap:12px;margin:0 0 16px;border:0;padding:0}.ds-ai-model-label{min-width:0;color:#101828;font-size:14px;line-height:20px;font-weight:500}.ds-ai-model-label .ds-ai-model-field{margin-top:6px}.ds-ai-model-field{box-sizing:border-box;min-height:44px;width:100%;min-width:0;border:1px solid #cfd8e3;border-radius:8px;background:#fff;padding:8px 12px;color:#101828;font:inherit;font-size:14px;line-height:20px}.ds-ai-model-field::placeholder{color:#66758b}.ds-ai-model-field:focus-visible{outline:none;box-shadow:0 0 0 2px #1677ff}.ds-ai-model-field:disabled{background:#f8fafc;color:#66758b}.ds-ai-model-filters{display:grid;min-width:0;grid-template-columns:repeat(2,minmax(0,1fr));gap:8px}.ds-ai-model-search{position:relative;grid-column:span 2;min-width:0}.ds-ai-model-search .ds-ai-model-field{padding-left:36px}.ds-ai-model-search svg{position:absolute;left:12px;top:14px;width:16px;height:16px;fill:none;stroke:#66758b;stroke-width:2;stroke-linecap:round;stroke-linejoin:round;pointer-events:none}.ds-ai-model-hint{margin:16px 0;color:#66758b;font-size:12px;line-height:16px}.ds-ai-model-confirm{display:inline-flex;min-height:44px;align-items:center;gap:8px;font-size:12px;line-height:16px}.ds-ai-model-checkbox{width:16px;height:16px;flex-shrink:0;accent-color:#0050cb}.ds-ai-model-checkbox:focus-visible{outline:none;box-shadow:0 0 0 2px #fff,0 0 0 4px #1677ff}.ds-ai-model-checkbox:disabled{cursor:not-allowed}.ds-ai-model-sr{position:absolute;width:1px;height:1px;overflow:hidden;clip:rect(0,0,0,0);white-space:nowrap}@media(min-width:640px){.ds-ai-model-defaults{grid-template-columns:repeat(3,minmax(0,1fr))}.ds-ai-model-filters{display:flex;flex-wrap:wrap}.ds-ai-model-search{flex:1 1 192px}.ds-ai-model-filters>select{width:auto;flex:none}}"
}
],
"narrative": {

View File

@ -350,6 +350,10 @@ components:
- **Primary Button:** 最小高度 44px、8px 圆角、`#1677ff` 底与白字,左右 16px 内边距;hover 为 `#0964de`。两种按钮的 focus-visible 均为 2px 蓝环与 2px 白色间隔,disabled 降为 50% 不透明度并显示不可用指针。
- **Surface Contract:** 详情页的 Operate 模式、分阶段构图、750px 画布、编辑范围、响应式与验收边界记录在 `.impeccable/surfaces/app-assets-detail-generator-page-tsx.md`,不提升为全项目规则。
### AI Model Management Workbench
`components/admin/AiModelManagementPanel.tsx` 是 `/admin` 超级管理员工作台的第四个操作分区,沿用现有 232px 二级导航和白色工作面。上游连接行之后是资料分析、卖点文案、图片生成三个默认模型与目录;目录按模型或上游搜索、能力类型及开放状态筛选,每页 20 行,在 1280px 起对齐为“模型 / 上游、开放给用户、用户可选用途、接口兼容”四列。模型能力由服务端提供,类型用短文字标识;不适配用途禁勾,未知标“待核验”,已识别但尚无适配用途标“待适配”,人工兼容确认不能越过能力限制。用户可在后台开放的阶段模型中自主选择;取消最后用途同步关闭开放,并清空受影响默认值。手机默认项纵向排列,搜索独占整行,两个筛选器各占半行;按钮、字段和勾选标签最小高度为 44px,字段与按钮使用 8px 圆角,保存主按钮沿用深蓝变体(`#0050cb`,hover `#003da3`)。API Key 只显示保存状态,密码字段只用于新增或替换;当前服务端连接必须显式迁入,目录同步只读,接口兼容由管理员确认,不表述为测试通过。错误与保存反馈靠近操作,切换分区和离开页面保护未保存修改;本表面的任务构图与本地验收边界记录在 `.impeccable/surfaces/components-admin-aimodelmanagementpanel-tsx.md`。
## Do's and Don'ts
### Do:

View File

@ -118,7 +118,9 @@ RUN test -s "/app/public/brand/header-logo.png" && \
RUN test -f "/app/prisma/migrations/20261008_permission_group_department_identity/migration.sql" && \
test -f "/app/prisma/migrations/20261008_team_function_configs/migration.sql" && \
test -f "/app/prisma/migrations/20261009_material_purge_outbox/migration.sql" && \
test -f "/app/prisma/migrations/20261009_material_purge_reference_indexes/migration.sql"
test -f "/app/prisma/migrations/20261009_material_purge_reference_indexes/migration.sql" && \
test -f "/app/prisma/migrations/20261012_ai_model_registry/migration.sql" && \
test -f "/app/prisma/migrations/20261013_ai_model_capabilities/migration.sql"
# 这两个动态路由负责兜底访问本地上传文件;如果它们在构建上下文里丢失,
# 线上音频/视频就会直接 404。这里提前失败,避免再把缺路由的镜像发出去。
@ -261,6 +263,8 @@ COPY --from=builder --chown=root:root /app/prisma/migrations/20260918_add_perfor
COPY --from=builder --chown=root:root /app/prisma/migrations/20260923_add_certificate_report_metadata ./prisma/migrations/20260923_add_certificate_report_metadata
COPY --from=builder --chown=root:root /app/prisma/migrations/20260924_add_certificate_tested_on ./prisma/migrations/20260924_add_certificate_tested_on
COPY --from=builder --chown=root:root /app/prisma/migrations/20261012_detail_generator ./prisma/migrations/20261012_detail_generator
COPY --from=builder --chown=root:root /app/prisma/migrations/20261012_ai_model_registry ./prisma/migrations/20261012_ai_model_registry
COPY --from=builder --chown=root:root /app/prisma/migrations/20261013_ai_model_capabilities ./prisma/migrations/20261013_ai_model_capabilities
COPY --from=builder --chown=nextjs:nodejs /app/lib ./lib
COPY --from=builder --chown=nextjs:nodejs /app/scripts/public-traffic-rollup-from-nginx.py ./scripts/public-traffic-rollup-from-nginx.py
COPY --from=builder --chown=nextjs:nodejs /app/scripts/collapse-performance-survey-template-versions.mjs ./scripts/collapse-performance-survey-template-versions.mjs

View File

@ -0,0 +1,5 @@
import { aiModelAdminRoute } from '@/lib/ai-model-access'
import { configureAiModels } from '@/lib/ai-model-service'
import { parseDetailJson } from '@/lib/detail-generator/validation'
export const runtime = 'nodejs'
export async function PUT(request: Request) { return aiModelAdminRoute(async userId => configureAiModels(userId, await parseDetailJson(request))) }

View File

@ -0,0 +1,4 @@
import { aiModelAdminRoute } from '@/lib/ai-model-access'
import { importAiModelEnvironment } from '@/lib/ai-model-service'
export const runtime = 'nodejs'
export async function POST() { return aiModelAdminRoute(userId => importAiModelEnvironment(userId)) }

View File

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

View File

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

View File

@ -0,0 +1,5 @@
import { aiModelAdminRoute } from '@/lib/ai-model-access'
import { createAiModelProvider } from '@/lib/ai-model-service'
import { parseDetailJson } from '@/lib/detail-generator/validation'
export const runtime = 'nodejs'
export async function POST(request: Request) { return aiModelAdminRoute(async userId => createAiModelProvider(userId, await parseDetailJson(request))) }

View File

@ -0,0 +1,5 @@
import { aiModelAdminRoute } from '@/lib/ai-model-access'
import { getAiModelAdminState } from '@/lib/ai-model-service'
export const runtime = 'nodejs'
export const dynamic = 'force-dynamic'
export async function GET() { return aiModelAdminRoute(() => getAiModelAdminState()) }

View File

@ -4,8 +4,10 @@ import Select from '@/components/ui/Select'
import { useCallback, useEffect, useState } from 'react'
import { parseFetchError } from '@/lib/error-handler'
import { BellRing, Copy, Eye, EyeOff, KeyRound, ShieldCheck, Webhook } from 'lucide-react'
import { BellRing, Copy, Eye, EyeOff, KeyRound, ShieldCheck, Webhook, Cpu } from 'lucide-react'
import NotificationCenterPanel from '@/components/admin/NotificationCenterPanel'
import AiModelManagementPanel from '@/components/admin/AiModelManagementPanel'
import { useConfirmActionDialog } from '@/components/ConfirmActionDialog'
interface SuperAdmin {
id: string
@ -169,7 +171,7 @@ interface SuperAdminsTabProps {
showToast: (message: string, type?: 'success' | 'error') => void
}
type SuperAdminWorkspace = 'access' | 'notificationCenter' | 'outbound'
type SuperAdminWorkspace = 'access' | 'notificationCenter' | 'outbound' | 'aiModels'
const SUPER_ADMIN_WORKSPACES: Array<{
id: SuperAdminWorkspace
@ -180,10 +182,13 @@ const SUPER_ADMIN_WORKSPACES: Array<{
{ id: 'access', label: '权限与凭证', description: '超级管理员与素材平台 API Key', icon: ShieldCheck },
{ id: 'notificationCenter', label: '统一通知中心', description: '项目、花名、测试与员工单聊', icon: BellRing },
{ id: 'outbound', label: '素材外发', description: 'Webhook 目标与自动上架投递', icon: Webhook },
{ id: 'aiModels', label: 'AI 模型接入', description: '上游连接、模型用途与默认模型', icon: Cpu },
]
export default function SuperAdminsTab({ showToast }: SuperAdminsTabProps) {
const [activeWorkspace, setActiveWorkspace] = useState<SuperAdminWorkspace>('notificationCenter')
const [aiModelsState, setAiModelsState] = useState({ dirty: false, busy: false })
const { confirmAction, confirmActionDialog } = useConfirmActionDialog()
const [superAdmins, setSuperAdmins] = useState<SuperAdmin[]>([])
const [loading, setLoading] = useState(true)
const [count, setCount] = useState(0)
@ -213,6 +218,15 @@ export default function SuperAdminsTab({ showToast }: SuperAdminsTabProps) {
const [notificationDeliveryStatusFilter, setNotificationDeliveryStatusFilter] = useState('all')
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 result = await confirmAction({ title: '离开模型接入?', description: '当前修改尚未保存,离开后将丢失。', confirmLabel: '放弃修改并离开', cancelLabel: '继续编辑', tone: 'danger' })
if (!result.confirmed) return
}
setActiveWorkspace(next)
}
// 加载超级管理员列表
const loadSuperAdmins = useCallback(async () => {
try {
@ -687,6 +701,7 @@ export default function SuperAdminsTab({ showToast }: SuperAdminsTabProps) {
return (
<div className="overflow-hidden rounded-[12px] border border-[#e2e8f0] bg-white">
{confirmActionDialog}
<div className="border-b border-[#e2e8f0] px-5 py-5 sm:px-6">
<h2 className="text-[20px] font-semibold tracking-[-0.02em] text-[#101828]">
超级管理员工作台
@ -698,7 +713,7 @@ export default function SuperAdminsTab({ showToast }: SuperAdminsTabProps) {
<div className="grid min-w-0 lg:grid-cols-[232px_minmax(0,1fr)]">
<nav aria-label="超级管理员工作台菜单" className="border-b border-[#e2e8f0] bg-[#f8fafc] p-2 lg:border-b-0 lg:border-r lg:p-3">
<div className="grid grid-cols-3 gap-1 lg:grid-cols-1">
<div className="grid grid-cols-2 gap-1 sm:grid-cols-4 lg:grid-cols-1">
{SUPER_ADMIN_WORKSPACES.map((workspace) => {
const Icon = workspace.icon
const active = activeWorkspace === workspace.id
@ -707,7 +722,8 @@ export default function SuperAdminsTab({ showToast }: SuperAdminsTabProps) {
key={workspace.id}
type="button"
aria-current={active ? 'page' : undefined}
onClick={() => setActiveWorkspace(workspace.id)}
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
? 'bg-[#e6f4ff] text-[#0050cb]'
@ -728,6 +744,7 @@ export default function SuperAdminsTab({ showToast }: SuperAdminsTabProps) {
</nav>
<div className="min-w-0">
{activeWorkspace === 'aiModels' && <AiModelManagementPanel onStateChange={setAiModelsState} />}
{activeWorkspace === 'access' && (
<div className="space-y-6 p-4 sm:p-6">
<div className="border-b border-[#e2e8f0] pb-4">

View File

@ -0,0 +1,243 @@
import { fireEvent, render, screen, waitFor, within } from '@testing-library/react'
import AiModelManagementPanel from './AiModelManagementPanel'
import type { AiModelAdminState } from '@/lib/ai-model-types'
function response(body: unknown, status = 200) { return { ok: status < 400, status, json: async () => body } as Response }
const initial = (): AiModelAdminState => ({ providers: [], revision: 0, defaults: { analysis: null, copy: null, image: null }, encryptionConfigured: true, legacyAvailable: true })
const configured = (): AiModelAdminState => ({
...initial(), revision: 2, legacyAvailable: false,
providers: [{ id: 'provider-1', name: '现有上游', baseUrl: 'https://api.example.com', enabled: true, credentialSource: 'environment', hasApiKey: true, revision: 3, syncedAt: null, models: [
{ key: 'model-text', id: 'gemini-2.5-flash', enabled: true, stages: ['analysis', 'copy'], present: true, compatibility: 'confirmed', capabilities: { kind: 'vision', allowedStages: ['analysis', 'copy'], source: 'verified', reason: '支持图片理解与文字输出' } },
{ key: 'model-image', id: 'nano-banana-2', enabled: true, stages: ['image'], present: true, compatibility: 'confirmed', capabilities: { kind: 'image', allowedStages: ['image'], source: 'verified', reason: '支持图片生成' } },
{ key: 'model-new', id: 'new-vision-model', enabled: false, stages: [], present: true, compatibility: 'unconfirmed', capabilities: { kind: 'vision', allowedStages: ['analysis', 'copy'], source: 'upstream', reason: '上游声明图片输入、文字输出' } },
{ key: 'model-missing', id: 'removed-model', enabled: false, stages: [], present: false, compatibility: 'unconfirmed', capabilities: { kind: 'unknown', allowedStages: [], source: 'unknown', reason: '目录未提供能力信息,待核验' } },
] }],
defaults: { analysis: 'model-text', copy: 'model-text', image: 'model-image' },
})
afterEach(() => jest.restoreAllMocks())
test('显式迁入当前连接并同步上游目录,使用服务器版本号', async () => {
const next = configured()
global.fetch = jest.fn(async (url: string, init: RequestInit = {}) => {
if (url.endsWith('/import-environment')) return response(next)
if (url.endsWith('/sync')) return response({ ...next, providers: next.providers.map((provider) => ({ ...provider, revision: 4, syncedAt: '2026-10-12T02:00:00Z' })) })
return response(initial())
}) as typeof fetch
render(<AiModelManagementPanel />)
fireEvent.click(await screen.findByRole('button', { name: '迁入当前连接' }))
expect(await screen.findByText('gemini-2.5-flash')).toBeInTheDocument()
expect(screen.queryByRole('button', { name: '迁入当前连接' })).not.toBeInTheDocument()
fireEvent.click(screen.getByRole('button', { name: '同步 现有上游 的模型目录' }))
await screen.findByText('模型目录已同步')
const call = (global.fetch as jest.Mock).mock.calls.find(([url]) => url.endsWith('/sync'))
expect(call[1].method).toBe('POST')
expect(JSON.parse(call[1].body)).toEqual({ revision: 3 })
expect((global.fetch as jest.Mock).mock.calls.every(([url]) => !url.includes('generate') && !url.includes('chat'))).toBe(true)
})
test('搜索与状态筛选仅影响显示,配置保存包含全部模型和各版本号', async () => {
const next = configured()
global.fetch = jest.fn(async (_url: string, init: RequestInit = {}) => response(init.method === 'PUT' ? { ...next, revision: 3 } : next)) as typeof fetch
render(<AiModelManagementPanel />)
await screen.findByText('new-vision-model')
fireEvent.change(screen.getByLabelText('搜索模型或上游'), { target: { value: 'new-vision' } })
expect(screen.queryByText('removed-model')).not.toBeInTheDocument()
const model = screen.getByLabelText('new-vision-model · 现有上游', { selector: 'div' })
expect(within(model).getByRole('checkbox', { name: '开放 new-vision-model · 现有上游 给用户' })).toBeDisabled()
fireEvent.click(within(model).getByRole('checkbox', { name: '确认 new-vision-model · 现有上游 接口兼容' }))
fireEvent.click(within(model).getByRole('checkbox', { name: 'new-vision-model · 现有上游 用于资料分析' }))
fireEvent.click(within(model).getByRole('checkbox', { name: '开放 new-vision-model · 现有上游 给用户' }))
fireEvent.change(screen.getByLabelText('资料分析默认模型', { exact: false }), { target: { value: 'model-new' } })
expect(screen.getByRole('button', { name: '同步 现有上游 的模型目录' })).toBeDisabled()
fireEvent.click(screen.getByRole('button', { name: '保存配置' }))
await screen.findByText('模型配置已保存')
const call = (global.fetch as jest.Mock).mock.calls.find(([, init]) => init.method === 'PUT')
const body = JSON.parse(call[1].body)
expect(body.revision).toBe(2)
expect(body.defaults.analysis).toBe('model-new')
expect(body.providers[0].revision).toBe(3)
expect(body.providers[0].models).toHaveLength(4)
expect(body.providers[0].models.find((entry: any) => entry.key === 'model-new')).toEqual({ key: 'model-new', enabled: true, stages: ['analysis'], compatibility: 'confirmed' })
expect(JSON.stringify(body)).not.toContain('apiKey')
fireEvent.change(screen.getByLabelText('搜索模型或上游'), { target: { value: '' } })
fireEvent.change(screen.getByLabelText('按状态筛选模型'), { target: { value: 'missing' } })
expect(screen.getByText('removed-model')).toBeInTheDocument()
expect(screen.queryByText('nano-banana-2')).not.toBeInTheDocument()
})
test('编辑密钥不回显,留空保存不发送 apiKey', async () => {
const next = configured()
global.fetch = jest.fn(async () => response(next)) as typeof fetch
render(<AiModelManagementPanel />)
fireEvent.click(await screen.findByRole('button', { name: '编辑 现有上游' }))
const password = screen.getByLabelText('API Key')
expect(password).toHaveAttribute('type', 'password')
expect(password).toHaveValue('')
expect(password).toHaveAttribute('placeholder', '留空保留当前密钥')
fireEvent.change(screen.getByLabelText('上游名称'), { target: { value: '更新后的名称' } })
fireEvent.click(screen.getByRole('button', { name: '保存上游' }))
await screen.findByText('上游已保存')
const call = (global.fetch as jest.Mock).mock.calls.find(([, init]) => init.method === 'PATCH')
expect(JSON.parse(call[1].body)).toEqual({ name: '更新后的名称', baseUrl: 'https://api.example.com', revision: 3 })
expect(screen.queryByLabelText('API Key')).not.toBeInTheDocument()
})
test('新增上游保存失败保留输入,就近反馈错误,取消清除密钥', async () => {
global.fetch = jest.fn(async (_url: string, init: RequestInit = {}) => response(init.method === 'POST' ? { error: '该 API 地址已存在,请编辑已有上游' } : initial(), init.method === 'POST' ? 409 : 200)) as typeof fetch
render(<AiModelManagementPanel />)
fireEvent.click(await screen.findByRole('button', { name: '新增上游' }))
fireEvent.change(screen.getByLabelText('上游名称'), { target: { value: '新上游' } })
fireEvent.change(screen.getByLabelText('API 地址'), { target: { value: 'https://new.example.com' } })
fireEvent.change(screen.getByLabelText('API Key'), { target: { value: 'replace-only-test-key' } })
fireEvent.submit(screen.getByRole('form', { name: '新增上游连接' }))
expect(await screen.findByRole('alert')).toHaveTextContent('该 API 地址已存在,请编辑已有上游')
expect(screen.getByLabelText('API Key')).toHaveValue('replace-only-test-key')
expect(screen.queryByText('replace-only-test-key')).not.toBeInTheDocument()
fireEvent.click(screen.getByRole('button', { name: '取消' }))
expect(screen.queryByLabelText('API Key')).not.toBeInTheDocument()
fireEvent.click(screen.getByRole('button', { name: '新增上游' }))
expect(screen.getByLabelText('API Key')).toHaveValue('')
})
test('目录同步进行时禁止重复请求和配置修改', async () => {
let finish!: (value: Response) => void
global.fetch = jest.fn((url: string) => url.endsWith('/sync') ? new Promise<Response>((resolve) => { finish = resolve }) : Promise.resolve(response(configured()))) as typeof fetch
render(<AiModelManagementPanel />)
const sync = await screen.findByRole('button', { name: '同步 现有上游 的模型目录' })
fireEvent.click(sync); fireEvent.click(sync)
expect(sync).toBeDisabled()
expect(screen.getByRole('checkbox', { name: '确认 new-vision-model · 现有上游 接口兼容' })).toBeDisabled()
expect((global.fetch as jest.Mock).mock.calls.filter(([url]) => url.endsWith('/sync'))).toHaveLength(1)
finish(response(configured()))
await waitFor(() => expect(sync).toBeEnabled())
})
test('缺少加密配置时禁止新增密钥,仍可显式迁入服务端连接', async () => {
global.fetch = jest.fn(async () => response({ ...initial(), encryptionConfigured: false })) as typeof fetch
render(<AiModelManagementPanel />)
expect(await screen.findByRole('button', { name: '新增上游' })).toBeDisabled()
expect(screen.getByRole('button', { name: '迁入当前连接' })).toBeEnabled()
expect(screen.getByText('服务端尚未配置密钥加密,暂不能新增或替换 API Key。')).toBeInTheDocument()
})
test('目录按 20 条分页,搜索回第一页,保存仍包含不可见模型', async () => {
const next = configured()
next.providers[0].models = Array.from({ length: 45 }, (_, index) => ({ key: `key-${index}`, id: `catalog-model-${String(index).padStart(2, '0')}`, enabled: false, stages: [], present: true, compatibility: 'unconfirmed', capabilities: { kind: 'vision', allowedStages: ['analysis', 'copy'], source: 'upstream', reason: '支持图片理解与文字输出' } }))
next.defaults = { analysis: null, copy: null, image: null }
global.fetch = jest.fn(async () => response(next)) as typeof fetch
render(<AiModelManagementPanel />)
await screen.findByText('45 个模型 · 第 1 / 3 页')
expect(screen.getByLabelText('catalog-model-00 · 现有上游', { selector: 'div' })).toBeInTheDocument()
expect(screen.queryByLabelText('catalog-model-20 · 现有上游', { selector: 'div' })).not.toBeInTheDocument()
fireEvent.click(screen.getByRole('button', { name: '下一页' }))
expect(screen.getByLabelText('catalog-model-20 · 现有上游', { selector: 'div' })).toBeInTheDocument()
fireEvent.change(screen.getByLabelText('搜索模型或上游'), { target: { value: 'catalog-model-44' } })
expect(screen.getByText('1 个模型 · 第 1 / 1 页')).toBeInTheDocument()
fireEvent.click(screen.getByRole('checkbox', { name: '确认 catalog-model-44 · 现有上游 接口兼容' }))
fireEvent.click(screen.getByRole('button', { name: '保存配置' }))
await screen.findByText('模型配置已保存')
const call = (global.fetch as jest.Mock).mock.calls.find(([, init]) => init.method === 'PUT')
expect(JSON.parse(call[1].body).providers[0].models).toHaveLength(45)
})
test('按能力筛选未开放模型,纯对话不能分配生图或图片分析', async () => {
const next = configured()
next.providers[0].models[2] = { ...next.providers[0].models[2], id: 'text-chat-only', capabilities: { kind: 'text', allowedStages: ['copy'], source: 'upstream', reason: '仅支持文字输入与文字输出' } }
global.fetch = jest.fn(async () => response(next)) as typeof fetch
render(<AiModelManagementPanel />)
fireEvent.change(await screen.findByLabelText('按能力筛选模型'), { target: { value: 'text' } })
expect(screen.getByText('text-chat-only')).toBeInTheDocument()
expect(screen.queryByText('nano-banana-2')).not.toBeInTheDocument()
expect(screen.queryByText('new-vision-model')).not.toBeInTheDocument()
const image = screen.getByRole('checkbox', { name: 'text-chat-only · 现有上游 用于图片生成' })
expect(image).toBeDisabled()
expect(image).toHaveAccessibleDescription('仅支持文字输入与文字输出')
expect(image.closest('label')).toHaveAttribute('title', '不支持图片输出')
expect(screen.getByRole('checkbox', { name: 'text-chat-only · 现有上游 用于资料分析' })).toBeDisabled()
expect(screen.getByRole('checkbox', { name: 'text-chat-only · 现有上游 用于卖点文案' })).toBeEnabled()
fireEvent.click(screen.getByRole('checkbox', { name: '确认 text-chat-only · 现有上游 接口兼容' }))
expect(image).toBeDisabled()
expect(screen.getByRole('checkbox', { name: '开放 text-chat-only · 现有上游 给用户' })).toBeDisabled()
fireEvent.click(screen.getByRole('checkbox', { name: 'text-chat-only · 现有上游 用于卖点文案' }))
fireEvent.click(screen.getByRole('checkbox', { name: '开放 text-chat-only · 现有上游 给用户' }))
const analysis = screen.getByLabelText('资料分析默认模型', { exact: false })
const copy = screen.getByLabelText('卖点文案默认模型', { exact: false })
expect(within(analysis).queryByRole('option', { name: 'text-chat-only · 现有上游' })).not.toBeInTheDocument()
expect(within(copy).getByRole('option', { name: 'text-chat-only · 现有上游' })).toBeInTheDocument()
})
test('能力待核验模型不能通过人工兼容确认解锁任何用途', async () => {
const next = configured()
next.providers[0].models[2].capabilities = { kind: 'unknown', allowedStages: [], source: 'unknown', reason: '目录未提供能力信息,待核验' }
global.fetch = jest.fn(async () => response(next)) as typeof fetch
render(<AiModelManagementPanel />)
fireEvent.click(await screen.findByRole('checkbox', { name: '确认 new-vision-model · 现有上游 接口兼容' }))
for (const label of ['资料分析', '卖点文案', '图片生成']) expect(screen.getByRole('checkbox', { name: `new-vision-model · 现有上游 用于${label}` })).toBeDisabled()
expect(screen.getByRole('checkbox', { name: '开放 new-vision-model · 现有上游 给用户' })).toBeDisabled()
fireEvent.change(screen.getByLabelText('按能力筛选模型'), { target: { value: 'unknown' } })
expect(screen.getByText('new-vision-model')).toBeInTheDocument()
expect(screen.queryByText('gemini-2.5-flash')).not.toBeInTheDocument()
})
test('已识别生图能力但协议未适配时显示待适配,不误称无法生图', async () => {
const next = configured()
next.providers[0].models[2] = { ...next.providers[0].models[2], id: 'image-other-protocol', capabilities: { kind: 'image', allowedStages: [], source: 'upstream', reason: '具备图片输出能力,当前参考图协议待适配' } }
global.fetch = jest.fn(async () => response(next)) as typeof fetch
render(<AiModelManagementPanel />)
const image = await screen.findByRole('checkbox', { name: 'image-other-protocol · 现有上游 用于图片生成' })
expect(image).toBeDisabled()
expect(image.closest('label')).toHaveAttribute('title', '当前详情页未适配此用途')
expect(image).toHaveAccessibleDescription('具备图片输出能力,当前参考图协议待适配')
expect(screen.getByText('生图 · 待适配')).toBeInTheDocument()
fireEvent.change(screen.getByLabelText('按能力筛选模型'), { target: { value: 'image' } })
expect(screen.getByText('image-other-protocol')).toBeInTheDocument()
expect(screen.queryByText('gemini-2.5-flash')).not.toBeInTheDocument()
})
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('checkbox', { name: '开放 nano-banana-2 · 现有上游 给用户' })).not.toBeChecked()
expect(screen.getByRole('checkbox', { name: '开放 nano-banana-2 · 现有上游 给用户' })).toBeDisabled()
expect(screen.getByLabelText('图片生成默认模型', { exact: false })).toHaveValue('')
expect(screen.getByLabelText('资料分析默认模型', { exact: false })).toHaveValue('model-text')
fireEvent.click(screen.getByRole('button', { name: '保存配置' }))
await screen.findByText('模型配置已保存')
const call = (global.fetch as jest.Mock).mock.calls.find(([, init]) => init.method === 'PUT')
const body = JSON.parse(call[1].body)
expect(body.defaults).toEqual({ analysis: 'model-text', copy: 'model-text', image: null })
expect(body.providers[0].models.find((entry: any) => entry.key === 'model-image')).toMatchObject({ enabled: false, stages: [] })
expect(JSON.stringify(body)).not.toContain('capabilities')
})
test('关闭开放清空关联默认值,撤销可恢复;取消单一用途保留其它开放用途', async () => {
global.fetch = jest.fn(async () => response(configured())) as typeof fetch
render(<AiModelManagementPanel />)
fireEvent.click(await screen.findByRole('checkbox', { name: '开放 gemini-2.5-flash · 现有上游 给用户' }))
expect(screen.getByLabelText('资料分析默认模型', { exact: false })).toHaveValue('')
expect(screen.getByLabelText('卖点文案默认模型', { exact: false })).toHaveValue('')
fireEvent.click(screen.getByRole('button', { name: '撤销' }))
expect(screen.getByRole('checkbox', { name: '开放 gemini-2.5-flash · 现有上游 给用户' })).toBeChecked()
expect(screen.getByLabelText('资料分析默认模型', { exact: false })).toHaveValue('model-text')
fireEvent.click(screen.getByRole('checkbox', { name: 'gemini-2.5-flash · 现有上游 用于资料分析' }))
expect(screen.getByRole('checkbox', { name: '开放 gemini-2.5-flash · 现有上游 给用户' })).toBeChecked()
expect(screen.getByLabelText('资料分析默认模型', { exact: false })).toHaveValue('')
expect(screen.getByLabelText('卖点文案默认模型', { exact: false })).toHaveValue('model-text')
})
test('旧配置中能力不符的用途允许取消,取消后不能重新选入', async () => {
const next = configured()
next.providers[0].models[0].stages.push('image')
global.fetch = jest.fn(async () => response(next)) as typeof fetch
render(<AiModelManagementPanel />)
const image = await screen.findByRole('checkbox', { name: 'gemini-2.5-flash · 现有上游 用于图片生成' })
expect(image).toBeChecked()
expect(image).toBeEnabled()
fireEvent.click(image)
expect(image).not.toBeChecked()
expect(image).toBeDisabled()
expect(screen.getByRole('checkbox', { name: '开放 gemini-2.5-flash · 现有上游 给用户' })).toBeChecked()
})

View File

@ -0,0 +1,185 @@
'use client'
import { useEffect, useMemo, useRef, useState } from 'react'
import { Plus, RefreshCw, Save, Search } from 'lucide-react'
import type { AiModelAdminState, AiModelCatalogEntry, AiModelProvider, AiModelStage } from '@/lib/ai-model-types'
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 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'
const capabilityLabels = { text: '对话', vision: '视觉理解', image: '生图', other: '其他', unknown: '待核验' } as const
interface ProviderForm { id: string | null; revision: number; name: string; baseUrl: string; apiKey: string }
async function request(path = '', init: RequestInit = {}): Promise<AiModelAdminState> {
const response = await fetch(`/api/admin/ai-models${path}`, { ...init, cache: 'no-store', headers: { 'Content-Type': 'application/json', ...init.headers } })
const body = await response.json().catch(() => ({}))
if (!response.ok) throw new Error(typeof body.error === 'string' ? body.error : body.error?.message || '操作未完成,请重试')
return body as AiModelAdminState
}
function usable(provider: AiModelProvider, model: AiModelCatalogEntry, stage: AiModelStage) {
return provider.enabled && provider.hasApiKey && model.present && model.enabled && model.compatibility === 'confirmed' && model.stages.includes(stage) && model.capabilities.allowedStages.includes(stage)
}
export default function AiModelManagementPanel({ onStateChange }: { onStateChange?: (state: { dirty: boolean; busy: boolean }) => void }) {
const [state, setState] = useState<AiModelAdminState | null>(null)
const [draft, setDraft] = useState<AiModelAdminState | null>(null)
const [loading, setLoading] = useState(true)
const [busy, setBusy] = useState('')
const [error, setError] = useState('')
const [notice, setNotice] = useState('')
const [formError, setFormError] = useState('')
const [form, setForm] = useState<ProviderForm | null>(null)
const [search, setSearch] = useState('')
const [capabilityFilter, setCapabilityFilter] = useState('all')
const [statusFilter, setStatusFilter] = useState('all')
const [page, setPage] = useState(1)
const busyRef = useRef(false)
const dirty = !!state && !!draft && JSON.stringify(state) !== JSON.stringify(draft)
function accept(next: AiModelAdminState) { setState(next); setDraft(next) }
useEffect(() => {
let cancelled = false
request().then((next) => { if (!cancelled) accept(next) }).catch((cause) => { if (!cancelled) setError(cause.message) }).finally(() => { if (!cancelled) setLoading(false) })
return () => { cancelled = true }
}, [])
useEffect(() => {
onStateChange?.({ dirty: dirty || !!form, busy: !!busy })
}, [dirty, form, busy, onStateChange])
useEffect(() => () => { onStateChange?.({ dirty: false, busy: false }) }, [onStateChange])
useEffect(() => {
if (!dirty && !form) return
const prevent = (event: BeforeUnloadEvent) => { event.preventDefault(); event.returnValue = '' }
window.addEventListener('beforeunload', prevent)
return () => window.removeEventListener('beforeunload', prevent)
}, [dirty, form])
async function run(identity: string, action: () => Promise<AiModelAdminState>, success: string, closeForm = false) {
if (busyRef.current) return
busyRef.current = true; setBusy(identity); setError(''); setFormError(''); setNotice('')
try {
accept(await action())
setNotice(success)
if (closeForm) setForm(null)
} catch (cause) {
const message = cause instanceof Error ? cause.message : '操作未完成,请重试'
if (identity === 'provider-save') setFormError(message)
else setError(message)
} finally { busyRef.current = false; setBusy('') }
}
function updateModel(providerId: string, key: string, change: Partial<AiModelCatalogEntry>) {
setDraft((current) => {
if (!current) return current
const defaults = { ...current.defaults }
const providers = current.providers.map((provider) => provider.id !== providerId ? provider : {
...provider,
models: provider.models.map((model) => {
if (model.key !== key) return model
const updated = { ...model, ...change }
if (!updated.stages.some((stage) => updated.capabilities.allowedStages.includes(stage))) updated.enabled = false
for (const stage of stages) if (defaults[stage.id] === key && !usable(provider, updated, stage.id)) defaults[stage.id] = null
return updated
}),
})
return { ...current, providers, defaults }
})
setError(''); setNotice('')
}
function saveProvider(event: React.FormEvent<HTMLFormElement>) {
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)
}
const rows = useMemo(() => (draft?.providers || []).flatMap((provider) => provider.models.map((model) => ({ provider, model }))), [draft])
const visibleRows = rows.filter(({ provider, model }) => {
const query = search.trim().toLowerCase()
if (query && !`${model.id} ${provider.name}`.toLowerCase().includes(query)) return false
if (capabilityFilter !== 'all' && model.capabilities.kind !== capabilityFilter) return false
if (statusFilter === 'enabled' && !model.enabled) return false
if (statusFilter === 'disabled' && model.enabled) return false
if (statusFilter === 'missing' && model.present) return false
if (statusFilter === 'unconfirmed' && model.compatibility !== 'unconfirmed') return false
return true
})
const providerBlocked = !!busy || dirty || !!form
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)
if (loading) return <section aria-label="正在加载 AI 模型接入" className="space-y-4 p-4 sm:p-6"><div className="h-6 w-36 motion-safe:animate-pulse rounded bg-[#e8edf3]" /><div className="h-24 motion-safe:animate-pulse rounded-lg bg-[#eef2f6]" /><div className="h-44 motion-safe:animate-pulse rounded-lg bg-[#eef2f6]" /></section>
if (!draft) return <section className="p-4 sm:p-6"><p role="alert" className="mb-3 text-sm text-[#b42318]">{error || 'AI 模型配置未能加载'}</p><button type="button" className={buttonClass} disabled={!!busy} onClick={() => void run('reload', () => request(), '已重新加载')}>重新加载</button></section>
return <section aria-label="AI 模型接入" className="min-w-0 space-y-6 p-4 text-[#101828] selection:bg-[#dbeafe] selection:text-[#101828] sm:p-6">
<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">
{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>}
<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>}
<div className="divide-y divide-[#e2e8f0] border-y border-[#e2e8f0]">
{draft.providers.map((provider) => <div key={provider.id} className="flex min-w-0 flex-wrap items-center justify-between gap-3 py-3">
<div className="min-w-0 flex-1 basis-48"><div className="flex flex-wrap items-center gap-2"><span className="break-words text-sm font-semibold">{provider.name}</span><span className={`text-xs ${provider.enabled ? 'text-[#087052]' : 'text-[#66758b]'}`}>{provider.enabled ? '已启用' : '已停用'}</span></div><p className="mt-1 break-all text-xs text-[#66758b]">{provider.baseUrl}</p><p className="mt-1 text-xs text-[#66758b]">{provider.hasApiKey ? (provider.credentialSource === 'environment' ? '使用服务端密钥' : '密钥已安全保存') : '未配置密钥'} · {provider.models.filter((model) => model.present).length} 个目录模型{provider.syncedAt ? ` · 同步于 ${new Date(provider.syncedAt).toLocaleString('zh-CN', { month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit' })}` : ' · 尚未同步'}</p></div>
<div className="flex flex-wrap gap-2"><button type="button" className={buttonClass} disabled={providerBlocked} aria-label={`编辑 ${provider.name}`} onClick={() => { setForm({ id: provider.id, revision: provider.revision, name: provider.name, baseUrl: provider.baseUrl, apiKey: '' }); setFormError(''); setNotice('') }}>编辑</button><button type="button" className={buttonClass} disabled={providerBlocked || !provider.hasApiKey} aria-label={`同步 ${provider.name} 的模型目录`} onClick={() => void run(`sync:${provider.id}`, () => request(`/providers/${encodeURIComponent(provider.id)}/sync`, { method: 'POST', body: JSON.stringify({ revision: provider.revision }) }), '模型目录已同步')}><RefreshCw className="h-4 w-4" aria-hidden="true" />{busy === `sync:${provider.id}` ? '同步中…' : '同步目录'}</button><button type="button" className={buttonClass} disabled={providerBlocked} aria-label={`${provider.enabled ? '停用' : '启用'} ${provider.name}`} onClick={() => void run(`toggle:${provider.id}`, () => request(`/providers/${encodeURIComponent(provider.id)}`, { method: 'PATCH', body: JSON.stringify({ enabled: !provider.enabled, revision: provider.revision }) }), provider.enabled ? '上游已停用' : '上游已启用')}>{provider.enabled ? '停用' : '启用'}</button></div>
</div>)}
</div>
{form && <form aria-label={form.id ? '编辑上游连接' : '新增上游连接'} onSubmit={saveProvider} className="mt-4 space-y-3 border-b border-[#e2e8f0] pb-4">
<div className="grid gap-3 sm:grid-cols-2"><label className="space-y-1.5 text-sm font-medium">上游名称<input autoFocus required maxLength={100} className={fieldClass} disabled={!!busy} value={form.name} onChange={(event) => setForm({ ...form, name: event.target.value })} /></label><label className="space-y-1.5 text-sm font-medium">API 地址<input type="url" required maxLength={2048} placeholder="https://api.example.com" className={fieldClass} disabled={!!busy} value={form.baseUrl} onChange={(event) => setForm({ ...form, baseUrl: event.target.value })} /></label></div>
<label className="block space-y-1.5 text-sm font-medium">API Key<input type="password" autoComplete="new-password" required={!form.id} className={fieldClass} disabled={!!busy || !draft.encryptionConfigured} value={form.apiKey} placeholder={form.id ? '留空保留当前密钥' : '仅保存到服务端'} onChange={(event) => setForm({ ...form, apiKey: event.target.value })} /></label>
{formError && <p role="alert" className="break-words text-sm text-[#b42318]">{formError}</p>}
<div className="flex justify-end gap-2"><button type="button" className={buttonClass} disabled={!!busy} onClick={() => { setForm(null); setFormError('') }}>取消</button><button type="submit" className={primaryClass} disabled={!!busy}>{busy === 'provider-save' ? '保存中…' : '保存上游'}</button></div>
</form>}
</section>
<section aria-label="用户可选模型" className="space-y-4">
<div className="flex flex-wrap items-center justify-between gap-3"><h4 className="text-sm font-semibold text-[#344054]">用户可选模型</h4><div className="flex items-center gap-2">{dirty && <span role="status" className="text-xs text-[#66758b]">有未保存修改</span>}<button type="button" className={buttonClass} disabled={!dirty || !!busy || !!form} onClick={() => { setDraft(state); setError(''); setNotice('') }}>撤销</button><button type="button" className={primaryClass} disabled={!dirty || !!busy || !!form} onClick={() => void run('configuration', () => request('/configuration', { method: 'PUT', body: JSON.stringify({ revision: draft.revision, defaults: draft.defaults, providers: draft.providers.map((provider) => ({ id: provider.id, revision: provider.revision, models: provider.models.map(({ key, enabled, stages: roles, compatibility }) => ({ key, enabled, stages: roles, compatibility })) })) }) }), '模型配置已保存')}><Save className="h-4 w-4" aria-hidden="true" />{busy === 'configuration' ? '保存中…' : '保存配置'}</button></div></div>
<fieldset disabled={!!busy || !!form} className="grid min-w-0 gap-3 sm:grid-cols-3">
<legend className="sr-only">阶段默认模型</legend>
{stages.map((stage) => {
const options = rows.filter(({ provider, model }) => usable(provider, model, stage.id))
const selected = draft.defaults[stage.id]
const unavailable = !!selected && !options.some(({ model }) => model.key === selected)
const selectedRow = rows.find(({ model }) => model.key === selected)
return <label key={stage.id} className="min-w-0 space-y-1.5 text-sm font-medium">{stage.label}默认模型<select className={fieldClass} value={selected || ''} onChange={(event) => { setDraft({ ...draft, defaults: { ...draft.defaults, [stage.id]: event.target.value || null } }); setError(''); setNotice('') }}><option value="">未设置</option>{unavailable && <option value={selected!}>{selectedRow?.model.id || '原默认模型'} · 当前不可用</option>}{options.map(({ provider, model }) => <option key={model.key} value={model.key}>{model.id} · {provider.name}</option>)}</select>{(!selected || unavailable) && <span className="block text-xs font-normal text-[#854a0e]">{unavailable ? '请重新选择可用模型' : '尚未设置,详情页生成暂不可用'}</span>}</label>
})}
</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="unconfirmed">兼容待确认</option><option value="missing">已离开目录</option></select></div>
<p className="text-xs text-[#66758b]">用户可自行选择已开放的模型;不支持的用途不可勾选。</p>
<div className="hidden grid-cols-[minmax(0,1fr)_100px_220px_156px] 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 }) => <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)_100px_220px_156px] 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.compatibility !== 'confirmed' || !model.stages.some((stage) => model.capabilities.allowedStages.includes(stage)))} aria-label={`开放 ${model.id} · ${provider.name} 给用户`} onChange={(event) => updateModel(provider.id, model.key, { enabled: event.target.checked })} />开放</label>
<fieldset disabled={!!busy || !!form} className="flex min-w-0 flex-wrap gap-x-3"><legend className="sr-only">{model.id} 的用户可选用途</legend>{stages.map((stage) => {
const supported = model.capabilities.allowedStages.includes(stage.id)
const checked = model.stages.includes(stage.id)
const reason = model.capabilities.kind === 'unknown' ? '能力待核验,暂不能开放此用途' : stage.id === 'image' && (model.capabilities.kind === 'text' || model.capabilities.kind === 'vision') ? '不支持图片输出' : '当前详情页未适配此用途'
return <label key={stage.id} title={supported ? undefined : reason} className={`inline-flex min-h-11 items-center gap-1.5 text-xs ${!supported ? 'text-[#66758b]' : ''}`}><input type="checkbox" className={checkboxClass} aria-label={`${model.id} · ${provider.name} 用于${stage.label}`} aria-describedby={`capability-${model.key}`} checked={checked} disabled={!supported && !checked} onChange={(event) => { if (!event.target.checked || supported) updateModel(provider.id, model.key, { stages: event.target.checked ? [...model.stages, stage.id] : model.stages.filter((value) => value !== stage.id) }) }} />{stage.shortLabel}</label>
})}</fieldset>
<label className="inline-flex min-h-11 items-center gap-2 text-xs"><input type="checkbox" className={checkboxClass} disabled={!!busy || !!form} checked={model.compatibility === 'confirmed'} aria-label={`确认 ${model.id} · ${provider.name} 接口兼容`} onChange={(event) => updateModel(provider.id, model.key, { compatibility: event.target.checked ? 'confirmed' : 'unconfirmed', ...(!event.target.checked ? { enabled: false } : {}) })} />管理员已确认</label>
</div>)}
</div>
<nav aria-label="模型目录分页" className="flex flex-wrap items-center justify-between gap-3 text-sm"><span className="text-xs text-[#66758b]">{visibleRows.length} 个模型 · 第 {currentPage} / {pageCount} 页</span><div className="flex gap-2"><button type="button" className={buttonClass} disabled={currentPage <= 1} onClick={() => setPage(currentPage - 1)}>上一页</button><button type="button" className={buttonClass} disabled={currentPage >= pageCount} onClick={() => setPage(currentPage + 1)}>下一页</button></div></nav>
</>}
</section>
</section>
}

View File

@ -17,7 +17,7 @@ const config: DetailConfig = {
const createProject = (): DetailProject => ({
id: 'project-a', ownerId: 'superadmin-a', revision: 3,
createdAt: '2026-10-12T00:00:00Z', updatedAt: '2026-10-12T00:00:00Z',
document: { ...newDetailDocument(), name: '座椅项目', product: { ...newDetailDocument().product, name: '橡木座椅', facts: '材质:橡木', assetIds: ['product-image'] }, brand: { ...newDetailDocument().brand, name: '品牌甲' } },
document: { ...newDetailDocument(DETAIL_MODEL_DEFAULTS), name: '座椅项目', product: { ...newDetailDocument().product, name: '橡木座椅', facts: '材质:橡木', assetIds: ['product-image'] }, brand: { ...newDetailDocument().brand, name: '品牌甲' } },
})
const createJob = (status: DetailJob['status']): DetailJob => ({ id: 'job-a', projectId: 'project-a', ownerId: 'superadmin-a', kind: 'generate', status, payload: {}, checkpoint: {}, progress: 0, error: null, requestId: null, createdAt: '2026-10-12T00:00:00Z', updatedAt: '2026-10-12T00:00:00Z' })
function response(body: unknown, status = 200) { return { ok: status >= 200 && status < 300, status, json: async () => body } as Response }
@ -51,6 +51,58 @@ test('模型未配置时仍可准备资料,不能发起模型调用', async ()
expect(fetchMock.mock.calls.some(([, init]) => init?.method === 'POST')).toBe(false)
})
test('新项目使用管理员阶段默认值,模型选择显示原模型和上游名称', async () => {
const dynamic = { ...config, defaults: { analysis: 'model-a', copy: 'model-c', image: 'model-i' }, models: { analysis: ['model-a'], copy: ['model-c'], image: ['model-i'] }, modelOptions: {
analysis: [{ value: 'model-a', label: 'vision-analysis', providerName: '上游甲' }], copy: [{ value: 'model-c', label: 'copy-text', providerName: '上游乙' }], image: [{ value: 'model-i', label: 'image-reference', providerName: '上游甲' }],
} }
global.fetch = jest.fn(async () => response({ config: dynamic, projects: [] })) as typeof fetch
render(<DetailGeneratorWorkspace />)
const analysis = await screen.findByLabelText('资料分析', { exact: true })
expect(analysis).toHaveValue('model-a')
expect(screen.getByLabelText('卖点文案', { exact: true })).toHaveValue('model-c')
expect(screen.getByLabelText('图片生成', { exact: true })).toHaveValue('model-i')
expect(within(analysis).getByRole('option', { name: 'vision-analysis · 上游甲' })).toBeInTheDocument()
})
test('用户可在管理员开放范围内自主更换模型,保留其他阶段选择', async () => {
const dynamic = { ...config, defaults: { analysis: 'model-a', copy: 'model-c', image: 'model-i' }, models: { analysis: ['model-a'], copy: ['model-c'], image: ['model-i', 'model-i-other'] }, modelOptions: {
analysis: [{ value: 'model-a', label: 'vision-analysis', providerName: '上游甲' }], copy: [{ value: 'model-c', label: 'copy-text', providerName: '上游乙' }], image: [{ value: 'model-i', label: 'image-default', providerName: '上游甲' }, { value: 'model-i-other', label: 'image-alternative', providerName: '上游乙' }],
} }
global.fetch = jest.fn(async () => response({ config: dynamic, projects: [] })) as typeof fetch
render(<DetailGeneratorWorkspace />)
const image = await screen.findByLabelText('图片生成', { exact: true })
expect(screen.getByText('模型选择', { selector: 'summary' })).toBeInTheDocument()
expect(within(image).getAllByRole('option').map((option) => (option as HTMLOptionElement).value)).toEqual(['model-i', 'model-i-other'])
expect(within(image).queryByRole('option', { name: 'copy-text · 上游乙' })).not.toBeInTheDocument()
fireEvent.change(image, { target: { value: 'model-i-other' } })
expect(image).toHaveValue('model-i-other')
expect(screen.getByLabelText('资料分析', { exact: true })).toHaveValue('model-a')
expect(screen.getByLabelText('卖点文案', { exact: true })).toHaveValue('model-c')
expect((global.fetch as jest.Mock).mock.calls.some(([, init]) => init?.method === 'POST')).toBe(false)
})
test('旧项目模型已停用时保留原选择和文字,可编辑导出但不重新调用模型', async () => {
const project = createProject()
project.document.approvedAt = '2026-10-12T01:00:00Z'
project.document.analysis = { sellingPoints: ['橡木'], warnings: [], screens: [{ id: 'screen-a', title: '橡木', body: '', imagePrompt: '橡木座椅' }] }
project.document.screens = [{ id: 'screen-a', title: '橡木', body: '', imagePrompt: '橡木座椅', width: 750, height: 1000, backgroundColor: '#ffffff', texts: [], image: { assetId: 'product-image', x: 0, y: 0, width: 750, height: 1000 } }]
const fetchMock = jest.fn(async (url: string) => url === '/api/detail-generator' ? response({ config: { ...config, models: { analysis: [], copy: [], image: ['new-image'] } }, projects: [project] }) : response({ project, assets: [], jobs: [] }))
global.fetch = fetchMock as typeof fetch
render(<DetailGeneratorWorkspace />)
fireEvent.click(await screen.findByRole('button', { name: /座椅项目/ }))
expect(await screen.findByRole('button', { name: '重新生成此屏' })).toBeDisabled()
expect(screen.getByRole('button', { name: '导出' })).toBeEnabled()
expect(screen.getByLabelText('替换当前屏图片')).toBeEnabled()
expect(screen.getByText('项目模型当前不可用。可修改模型选择,已有内容仍可编辑和导出。')).toBeInTheDocument()
expect(fetchMock.mock.calls.some(([, init]: any) => init?.method === 'POST')).toBe(false)
})
test('已显式迁入且仍允许的 legacy 模型保留原选择并可执行分析', async () => {
await openProject(createProject(), [], undefined, { ...config, models: { analysis: ['model-a'], copy: ['model-a'], image: ['model-i'] }, legacyModels: { analysis: ['gemini-2.5-flash'], copy: ['gemini-2.5-flash'], image: ['nano-banana-2'] } })
expect(screen.getByLabelText('资料分析', { exact: true })).toHaveValue('gemini-2.5-flash')
expect(screen.getByRole('button', { name: '分析卖点与大纲' })).toBeEnabled()
})
test('保存遇到版本冲突时保留用户未保存的产品信息', async () => {
const fetchMock = await openProject(createProject(), [], (_url, init) => init.method === 'PATCH' ? response({ error: '项目已被更新,请重新打开后合并修改' }, 409) : undefined)
fireEvent.change(screen.getByLabelText('已确认的产品信息'), { target: { value: '新增真实参数:座高 45cm' } })
@ -105,6 +157,34 @@ test('分析完成后必须确认大纲才能生成,确认后分别调用审
expect(JSON.parse(String(generation?.[1]?.body)).idempotencyKey).toEqual(expect.any(String))
})
test('确认大纲前更换图片模型,先保存选择再提交生成', async () => {
let project = createProject()
project.document.analysis = { sellingPoints: ['橡木材质'], warnings: [], screens: [{ id: 'screen-a', title: '橡木座椅', body: '自然质感', imagePrompt: '浅色环境中的橡木座椅,不生成文字' }] }
const fetchMock = jest.fn(async (url: string, init: RequestInit = {}) => {
if (url === '/api/detail-generator') return response({ config: { ...config, models: { ...config.models, image: ['nano-banana-2', 'model-new-image'] } }, projects: [project] })
if (init.method === 'PATCH') { project = { ...project, revision: 4, document: JSON.parse(String(init.body)).document }; return response({ project }) }
if (url.endsWith('/approve')) return response({ project: { ...project, revision: 5, document: { ...project.document, approvedAt: '2026-10-12T01:00:00Z' } } })
if (url.endsWith('/generate')) return response({ job: createJob('queued') })
return response({ project, assets: [], jobs: [] })
})
global.fetch = fetchMock as typeof fetch
render(<DetailGeneratorWorkspace />)
fireEvent.click(await screen.findByRole('button', { name: /座椅项目/ }))
await screen.findByRole('button', { name: '确认大纲并生成' })
fireEvent.change(await screen.findByLabelText('图片生成', { exact: true }), { target: { value: 'model-new-image' } })
fireEvent.click(screen.getByRole('button', { name: '确认大纲并生成' }))
await waitFor(() => expect(fetchMock.mock.calls.some(([url]) => url.endsWith('/generate'))).toBe(true))
const writes = fetchMock.mock.calls.filter((call): call is [string, RequestInit] => !!call[1] && (call[1].method === 'PATCH' || call[1].method === 'POST'))
expect(writes.map(([url]) => url)).toEqual(['/api/detail-generator/projects/project-a', '/api/detail-generator/projects/project-a/approve', '/api/detail-generator/projects/project-a/generate'])
const saveCall = writes.find(([, init]) => init.method === 'PATCH')
const approveCall = writes.find(([url]) => url.endsWith('/approve'))
expect(saveCall).toBeDefined()
expect(approveCall).toBeDefined()
if (!saveCall || !approveCall) throw new Error('模型选择必须先保存,再确认大纲')
expect(JSON.parse(String(saveCall[1].body)).document.models.image).toBe('model-new-image')
expect(JSON.parse(String(approveCall[1].body)).revision).toBe(4)
})
test('结果未知时停止生成与导出操作,不自动重新请求模型', async () => {
const project = createProject()
project.document.approvedAt = '2026-10-12T01:00:00Z'

View File

@ -5,10 +5,10 @@ import { ArrowLeft, Check, Download, Loader2, Plus, Save, WandSparkles } from 'l
import Link from 'next/link'
import type { DetailAsset, DetailAssetKind, DetailBrandProfileRecord, DetailConfig, DetailDocument, DetailExportFormat, DetailJob, DetailProject, DetailScreen } from '@/lib/detail-generator/types'
import { reflowDetailScreen } from '@/lib/detail-generator/layout'
import DetailProductForm, { detailButtonClass, detailPrimaryClass } from './DetailProductForm'
import DetailProductForm, { DetailModelOptions, detailButtonClass, detailPrimaryClass } from './DetailProductForm'
import DetailOutlineEditor from './DetailOutlineEditor'
import DetailScreenEditor from './DetailScreenEditor'
import { actionKey, assetUrl, DetailClientError, detailRequest, newDetailDocument } from './client'
import { actionKey, assetUrl, DetailClientError, detailModelsAvailable, detailRequest, newDetailDocument } from './client'
import { useUnsavedDetailGuard } from './useUnsavedDetailGuard'
interface ProjectDetail { project: DetailProject; assets: DetailAsset[]; jobs: DetailJob[] }
@ -41,6 +41,8 @@ export default function DetailGeneratorWorkspace() {
const currentProjectId = project?.id
const unknownJob = jobs.find((job) => job.status === 'unknown')
const unavailable = busy || !!activeJob || locked
const analysisAvailable = detailModelsAvailable(config, draft.models, ['analysis', 'copy'])
const imageAvailable = detailModelsAvailable(config, draft.models, ['image'])
const updateProject = useCallback((next: DetailProject) => {
pendingCanvasRef.current.clear()
@ -79,6 +81,7 @@ export default function DetailGeneratorWorkspace() {
const result = await detailRequest<{ config: DetailConfig; projects: DetailProject[] }>('')
if (cancelled) return
setConfig(result.config)
if (!selectedRef.current) setDraft(newDetailDocument(result.config.defaults))
setProjects(result.projects)
const requested = new URLSearchParams(window.location.search).get('project')
if (requested && result.projects.some((item) => item.id === requested)) await selectProject(requested)
@ -144,6 +147,7 @@ export default function DetailGeneratorWorkspace() {
}
function analyze() {
if (!analysisAvailable) return
void run(async () => {
if (!draft.product.name.trim()) throw new Error('请填写产品名称')
if (!draft.product.facts.trim() && !draft.product.documentAssetIds.length) throw new Error('请填写真实产品信息或上传产品资料')
@ -155,10 +159,11 @@ export default function DetailGeneratorWorkspace() {
}
function approveAndGenerate() {
if (!imageAvailable) return
void run(async () => {
if (!draft.analysis?.screens.length) throw new Error('请至少保留一屏大纲')
if (draft.analysis.screens.some((screen) => !screen.title.trim() || !screen.imagePrompt.trim())) throw new Error('请补全各屏标题与画面要求')
const saved = project || await saveDocument()
const saved = dirty || !project ? await saveDocument() : project
const result = await detailRequest<{ project: DetailProject }>(`/projects/${encodeURIComponent(saved.id)}/approve`, { method: 'POST', body: JSON.stringify({ analysis: draft.analysis, revision: saved.revision }) })
updateProject(result.project)
await queueJob(result.project, '/generate', 'generate')
@ -256,7 +261,7 @@ export default function DetailGeneratorWorkspace() {
})
}
function regenerate(screenId: string) { void run(async () => { if (project) await queueJob(project, `/screens/${encodeURIComponent(screenId)}`, `regenerate:${screenId}`) }) }
function regenerate(screenId: string) { if (imageAvailable) void run(async () => { if (project) await queueJob(project, `/screens/${encodeURIComponent(screenId)}`, `regenerate:${screenId}`) }) }
function recordPendingText(screenId: string, layerId: string, text: string) {
const pending = pendingCanvasRef.current.get(screenId) || new Map<string, string>()
pending.set(layerId, text)
@ -280,7 +285,7 @@ export default function DetailGeneratorWorkspace() {
function exportProject(format: DetailExportFormat) { void run(async () => { if (project) await queueJob(project, '/exports', `export:${format}`, { format }) }) }
function newProject() {
pendingCanvasRef.current.clear()
setProject(null); selectedRef.current = null; setDraft(newDetailDocument()); setAssets([]); setJobs([]); setDirty(false); setMatches(null); setError(''); setNotice('')
setProject(null); selectedRef.current = null; setDraft(newDetailDocument(config?.defaults)); setAssets([]); setJobs([]); setDirty(false); setMatches(null); setError(''); setNotice('')
window.history.replaceState(null, '', '/assets/detail-generator')
}
@ -294,15 +299,17 @@ export default function DetailGeneratorWorkspace() {
<div className="mb-5 flex flex-wrap items-center justify-between gap-3"><div className="flex items-center gap-3"><h1 className="text-xl font-bold tracking-[-0.02em]">详情页生成</h1><span className="rounded-md bg-[#edf5ff] px-2 py-1 text-xs font-medium text-[#176fc1]">内测</span></div><div className="flex items-center gap-2">{dirty && <span role="status" className="hidden text-xs text-[#66758b] sm:inline">有未保存修改</span>}<button type="button" className={detailButtonClass} disabled={unavailable || !dirty} onClick={() => void run(async () => { await saveDocument(); setNotice('项目已保存') })}><Save className="h-4 w-4" aria-hidden="true" />保存</button><button type="button" className={detailButtonClass} disabled={unavailable || dirty} onClick={newProject}><Plus className="h-4 w-4" aria-hidden="true" />新建项目</button></div></div>
{(error || notice) && <div role={error ? 'alert' : 'status'} className={`mb-4 flex flex-wrap items-center gap-3 rounded-lg px-3 py-2.5 text-sm ${error ? 'bg-[#fff0ee] text-[#b42318]' : 'bg-[#e9f8f2] text-[#087052]'}`}><span className="min-w-0 flex-1 break-words">{error || notice}</span>{error && <button type="button" onClick={() => setError('')} className="min-h-9 rounded-md px-2 font-medium focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#1677ff]">关闭</button>}</div>}
{!config?.configured && <p role="status" className="mb-4 text-sm text-[#854a0e]">模型服务尚未配置。可先准备项目资料。</p>}
{config?.configured && (!analysisAvailable || !imageAvailable) && <p role="status" className="mb-4 text-sm text-[#854a0e]">项目模型当前不可用。可修改模型选择,已有内容仍可编辑和导出。</p>}
<div className="grid min-w-0 gap-5 lg:grid-cols-[200px_minmax(0,1fr)]">
<aside aria-label="我的详情页项目" className="min-w-0"><h2 className="mb-2 text-sm font-semibold text-[#4f6077]">我的项目</h2><div className="flex gap-2 overflow-x-auto pb-2 lg:max-h-[calc(100vh-210px)] lg:flex-col lg:overflow-y-auto lg:pb-0">{!projects.length && <p className="py-3 text-sm text-[#66758b]">还没有项目</p>}{projects.map((item) => <button type="button" key={item.id} disabled={unavailable || dirty} aria-current={project?.id === item.id ? 'page' : undefined} onClick={() => void selectProject(item.id)} className={`min-h-14 w-40 shrink-0 rounded-lg px-3 py-2 text-left focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#1677ff] disabled:cursor-not-allowed disabled:opacity-60 lg:w-full ${project?.id === item.id ? 'bg-[#e6f4ff] text-[#0050cb]' : 'text-[#344054] hover:bg-[#eef2f6]'}`}><span className="block truncate text-sm font-medium" title={item.document.name}>{item.document.name}</span><span className="mt-1 block truncate text-xs text-[#66758b]">{item.document.brand.name || '品牌未填写'}{item.document.screens.length ? ` · ${item.document.screens.length} 屏` : ' · 草稿'}</span></button>)}</div></aside>
<div className="min-w-0 rounded-xl border border-[#e2e8f0] bg-white p-4 sm:p-5">
{activeJob && <div role="status" aria-live="polite" className="mb-5 flex items-center gap-2 border-b border-[#e2e8f0] pb-4 text-sm text-[#176fc1]"><Loader2 className="h-4 w-4 motion-safe:animate-spin" aria-hidden="true" />{jobLabel[activeJob.kind]}{activeJob.status === 'queued' ? ' · 等待执行' : ' · 进行中'}{activeJob.kind === 'generate' && draft.analysis ? ` · ${draft.screens.filter((screen) => screen.image).length}/${draft.analysis.screens.length} 屏` : ''}</div>}
{unknownJob && <div role="alert" className="mb-5 rounded-lg bg-[#fffaeb] p-3 text-sm text-[#854a0e]"><p className="font-semibold">模型结果待确认</p><p className="mt-1">{unknownJob.error || '请求已经发出,结果尚未确认。请勿重复生成。'}</p>{unknownJob.requestId && <p className="mt-2 break-all text-xs">请求编号:{unknownJob.requestId}</p>}</div>}
{latestFailure && !activeJob && !unknownJob && <p role="alert" className="mb-4 rounded-lg bg-[#fff0ee] p-3 text-sm text-[#b42318]">{jobLabel[latestFailure.kind]}未完成:{latestFailure.error || '请检查项目资料后重试'}</p>}
{!draft.analysis && !draft.approvedAt ? <><DetailProductForm document={draft} config={config} assets={assets} busy={unavailable} onChange={changeDocument} onUpload={upload} onRemoveAsset={removeAsset} onFindMaterials={findMaterials} onSaveBrand={saveBrand} />{matches && <section aria-label="匹配的产品素材" className="mt-5 border-t border-[#e2e8f0] pt-4"><div className="mb-3 flex items-center justify-between"><h2 className="text-sm font-semibold">匹配素材</h2><button type="button" onClick={() => setMatches(null)} className="min-h-9 rounded-md px-2 text-sm text-[#66758b] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#1677ff]">收起</button></div>{!matches.length && <p className="text-sm text-[#66758b]">没有找到当前可用的素材,可直接上传产品图。</p>}{matches.map((match) => <div key={`${match.materialId}:${match.skuCode}`} className="mb-4"><p className="mb-2 text-sm font-medium">{match.variant || match.skuCode || match.productModel}</p><div className="flex flex-wrap gap-2">{match.assets.map((asset) => <button type="button" key={`${asset.source}:${asset.id}`} disabled={unavailable} onClick={() => importMaterial(match, asset)} className="w-28 rounded-lg border border-[#e2e8f0] p-2 text-left hover:border-[#1677ff] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#1677ff] disabled:opacity-50"><img src={asset.previewUrl} alt={asset.name} className="h-20 w-full object-contain" /><span className="mt-1 block truncate text-xs text-[#4f6077]">{asset.name}</span><span className="mt-1 block text-xs font-medium text-[#176fc1]">使用图片</span></button>)}</div></div>)}</section>}<div className="mt-6 flex justify-end border-t border-[#e2e8f0] pt-4"><button type="button" className={detailPrimaryClass} disabled={unavailable || !config?.configured || !!unknownJob} onClick={analyze}>{busy ? <Loader2 className="h-4 w-4 motion-safe:animate-spin" aria-hidden="true" /> : <WandSparkles className="h-4 w-4" aria-hidden="true" />}分析卖点与大纲</button></div></> : !draft.approvedAt && draft.analysis ? <><DetailOutlineEditor analysis={draft.analysis} busy={unavailable} onChange={(analysis) => changeDocument({ ...draft, analysis })} /><div className="mt-5 flex flex-wrap items-center justify-between gap-3 border-t border-[#e2e8f0] pt-4"><button type="button" className={detailButtonClass} disabled={unavailable} onClick={() => changeDocument({ ...draft, analysis: null })}><ArrowLeft className="h-4 w-4" aria-hidden="true" />修改资料</button><button type="button" className={detailPrimaryClass} disabled={unavailable || !config?.configured || !!unknownJob} onClick={approveAndGenerate}><Check className="h-4 w-4" aria-hidden="true" />确认大纲并生成</button></div></> : <><DetailScreenEditor screens={draft.screens} configured={config?.configured === true} complete={draft.screens.length === draft.analysis?.screens.length && draft.screens.every((screen) => !!screen.image)} busy={unavailable || !!unknownJob} dirty={dirty} onChange={changeScreen} onUnsavedEdit={() => setDirty(true)} onPendingTextChange={recordPendingText} onSave={saveScreen} onReplaceImage={replaceImage} onRegenerate={regenerate} onExport={exportProject} />{draft.screens.filter((screen) => screen.image).length < (draft.analysis?.screens.length || 1) && !activeJob && <div className="mt-4 flex justify-center"><button type="button" className={detailPrimaryClass} disabled={unavailable || !config?.configured || !!unknownJob} onClick={() => void run(async () => { if (project) await queueJob(project, '/generate', 'generate') })}><WandSparkles className="h-4 w-4" aria-hidden="true" />{draft.screens.some((screen) => screen.image) ? '继续生成剩余屏' : '生成详情页'}</button></div>}</>}
{draft.analysis && <div className="mb-5"><DetailModelOptions document={draft} config={config} busy={unavailable} onChange={changeDocument} /></div>}
{!draft.analysis && !draft.approvedAt ? <><DetailProductForm document={draft} config={config} assets={assets} busy={unavailable} onChange={changeDocument} onUpload={upload} onRemoveAsset={removeAsset} onFindMaterials={findMaterials} onSaveBrand={saveBrand} />{matches && <section aria-label="匹配的产品素材" className="mt-5 border-t border-[#e2e8f0] pt-4"><div className="mb-3 flex items-center justify-between"><h2 className="text-sm font-semibold">匹配素材</h2><button type="button" onClick={() => setMatches(null)} className="min-h-9 rounded-md px-2 text-sm text-[#66758b] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#1677ff]">收起</button></div>{!matches.length && <p className="text-sm text-[#66758b]">没有找到当前可用的素材,可直接上传产品图。</p>}{matches.map((match) => <div key={`${match.materialId}:${match.skuCode}`} className="mb-4"><p className="mb-2 text-sm font-medium">{match.variant || match.skuCode || match.productModel}</p><div className="flex flex-wrap gap-2">{match.assets.map((asset) => <button type="button" key={`${asset.source}:${asset.id}`} disabled={unavailable} onClick={() => importMaterial(match, asset)} className="w-28 rounded-lg border border-[#e2e8f0] p-2 text-left hover:border-[#1677ff] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#1677ff] disabled:opacity-50"><img src={asset.previewUrl} alt={asset.name} className="h-20 w-full object-contain" /><span className="mt-1 block truncate text-xs text-[#4f6077]">{asset.name}</span><span className="mt-1 block text-xs font-medium text-[#176fc1]">使用图片</span></button>)}</div></div>)}</section>}<div className="mt-6 flex justify-end border-t border-[#e2e8f0] pt-4"><button type="button" className={detailPrimaryClass} disabled={unavailable || !analysisAvailable || !!unknownJob} onClick={analyze}>{busy ? <Loader2 className="h-4 w-4 motion-safe:animate-spin" aria-hidden="true" /> : <WandSparkles className="h-4 w-4" aria-hidden="true" />}分析卖点与大纲</button></div></> : !draft.approvedAt && draft.analysis ? <><DetailOutlineEditor analysis={draft.analysis} busy={unavailable} onChange={(analysis) => changeDocument({ ...draft, analysis })} /><div className="mt-5 flex flex-wrap items-center justify-between gap-3 border-t border-[#e2e8f0] pt-4"><button type="button" className={detailButtonClass} disabled={unavailable} onClick={() => changeDocument({ ...draft, analysis: null })}><ArrowLeft className="h-4 w-4" aria-hidden="true" />修改资料</button><button type="button" className={detailPrimaryClass} disabled={unavailable || !imageAvailable || !!unknownJob} onClick={approveAndGenerate}><Check className="h-4 w-4" aria-hidden="true" />确认大纲并生成</button></div></> : <><DetailScreenEditor screens={draft.screens} configured={imageAvailable} complete={draft.screens.length === draft.analysis?.screens.length && draft.screens.every((screen) => !!screen.image)} busy={unavailable || !!unknownJob} dirty={dirty} onChange={changeScreen} onUnsavedEdit={() => setDirty(true)} onPendingTextChange={recordPendingText} onSave={saveScreen} onReplaceImage={replaceImage} onRegenerate={regenerate} onExport={exportProject} />{draft.screens.filter((screen) => screen.image).length < (draft.analysis?.screens.length || 1) && !activeJob && <div className="mt-4 flex justify-center"><button type="button" className={detailPrimaryClass} disabled={unavailable || !imageAvailable || !!unknownJob} onClick={() => void run(async () => { if (project) await queueJob(project, '/generate', 'generate') })}><WandSparkles className="h-4 w-4" aria-hidden="true" />{draft.screens.some((screen) => screen.image) ? '继续生成剩余屏' : '生成详情页'}</button></div>}</>}
{!!exportJobs.length && <div aria-label="导出文件" className="mt-5 flex flex-wrap gap-2 border-t border-[#e2e8f0] pt-4">{exportJobs.map((job) => <a key={job.id} href={assetUrl(String(job.checkpoint.exportAssetId))} download className={detailButtonClass}><Download className="h-4 w-4" aria-hidden="true" />下载{exportLabel[job.payload.format as DetailExportFormat] || '导出文件'}</a>)}</div>}
{dirty && !unavailable && <div className="mt-4 flex justify-end"><button type="button" className="min-h-9 rounded-md px-2 text-xs text-[#66758b] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#1677ff]" onClick={() => { pendingCanvasRef.current.clear(); setDraft(project?.document || newDetailDocument()); setDirty(false); setError('') }}>放弃未保存修改</button></div>}
{dirty && !unavailable && <div className="mt-4 flex justify-end"><button type="button" className="min-h-9 rounded-md px-2 text-xs text-[#66758b] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#1677ff]" onClick={() => { pendingCanvasRef.current.clear(); setDraft(project?.document || newDetailDocument(config?.defaults)); setDirty(false); setError('') }}>放弃未保存修改</button></div>}
</div>
</div>
</main>

View File

@ -79,10 +79,25 @@ export default function DetailProductForm({ document, config, assets, busy, onCh
<div className="flex flex-wrap gap-x-6 gap-y-3">{([{ key: 'primaryColor', label: '品牌色' }, { key: 'backgroundColor', label: '背景色' }, { key: 'textColor', label: '文字色' }] as const).map((color) => <label key={color.key} className="inline-flex min-h-11 items-center gap-2 text-sm"><input type="color" aria-label={color.label} value={document.brand[color.key]} onChange={(event) => brandChange(color.key, event.target.value)} className="h-8 w-8 cursor-pointer rounded border border-[#cfd8e3] bg-white focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#1677ff]" />{color.label}</label>)}</div>
<button type="button" className={detailButtonClass} disabled={!document.brand.name.trim()} onClick={onSaveBrand}>保存品牌草案</button>
</fieldset>
<details className="border-t border-[#e2e8f0] pt-4">
<summary className="cursor-pointer text-sm font-medium text-[#4f6077] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#1677ff]">高级选项</summary>
<div className="mt-4 grid gap-4 sm:grid-cols-3">{([{ key: 'analysis', label: '资料分析' }, { key: 'copy', label: '卖点文案' }, { key: 'image', label: '图片生成' }] as const).map((stage) => <label key={stage.key} className="space-y-1.5 text-sm font-medium">{stage.label}<select className={detailFieldClass} disabled={busy} value={document.models[stage.key]} onChange={(event) => onChange({ ...document, models: { ...document.models, [stage.key]: event.target.value } })}>{(config?.models[stage.key] || [document.models[stage.key]]).map((model) => <option key={model} value={model}>{model}</option>)}</select></label>)}</div>
</details>
<DetailModelOptions document={document} config={config} busy={busy} onChange={onChange} />
</div>
)
}
export function DetailModelOptions({ document, config, busy, onChange }: {
document: DetailDocument
config: DetailConfig | null
busy: boolean
onChange: (document: DetailDocument) => void
}) {
return <details className="border-t border-[#e2e8f0] pt-4">
<summary className="cursor-pointer text-sm font-medium text-[#4f6077] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#1677ff]">模型选择</summary>
<div className="mt-4 grid gap-4 sm:grid-cols-3">{([{ key: 'analysis', label: '资料分析' }, { key: 'copy', label: '卖点文案' }, { key: 'image', label: '图片生成' }] as const).map((stage) => {
const current = document.models[stage.key]
const options = config?.modelOptions?.[stage.key] || (config?.models[stage.key] || []).map((model) => ({ value: model, label: model, providerName: '' }))
const legacy = config?.legacyModels?.[stage.key]?.includes(current)
const unavailable = !!current && !options.some((option) => option.value === current) && !legacy
return <label key={stage.key} className="min-w-0 space-y-1.5 text-sm font-medium">{stage.label}<select className={detailFieldClass} disabled={busy} value={current} onChange={(event) => onChange({ ...document, models: { ...document.models, [stage.key]: event.target.value } })}>{!current && <option value="">{options.length ? '请选择模型' : '暂无开放模型'}</option>}{current && !options.some((option) => option.value === current) && <option value={current}>{current}{unavailable ? ' · 当前不可用' : ' · 原项目选择'}</option>}{options.map((option) => <option key={option.value} value={option.value}>{option.label}{option.providerName ? ` · ${option.providerName}` : ''}</option>)}</select>{unavailable && <span className="block text-xs font-normal text-[#854a0e]">请选择可用模型后再生成</span>}</label>
})}</div>
</details>
}

View File

@ -1,4 +1,4 @@
import { DETAIL_FONT_FAMILY, DETAIL_MODEL_DEFAULTS, type DetailDocument } from '@/lib/detail-generator/types'
import { DETAIL_FONT_FAMILY, type DetailConfig, type DetailDocument } from '@/lib/detail-generator/types'
export class DetailClientError extends Error {
constructor(message: string, readonly status: number) { super(message) }
@ -15,19 +15,23 @@ export async function detailRequest<T>(path: string, init: RequestInit = {}): Pr
return data as T
}
export function newDetailDocument(): DetailDocument {
export function newDetailDocument(defaults?: DetailDocument['models']): DetailDocument {
return {
version: 1,
name: '',
product: { identifierType: 'sku', identifier: '', variant: '', name: '', facts: '', assetIds: [], referenceAssetIds: [], documentAssetIds: [] },
brand: { name: '', tone: '', primaryColor: '#1677ff', backgroundColor: '#ffffff', textColor: '#101828', fontFamily: DETAIL_FONT_FAMILY, status: 'draft' },
models: { ...DETAIL_MODEL_DEFAULTS },
models: defaults ? { ...defaults } : { analysis: '', copy: '', image: '' },
analysis: null,
approvedAt: null,
screens: [],
}
}
export function detailModelsAvailable(config: DetailConfig | null, selected: DetailDocument['models'], stages: Array<keyof DetailDocument['models']>): boolean {
return config?.configured === true && stages.every((stage) => !!selected[stage] && (config.models[stage].includes(selected[stage]) || config.legacyModels?.[stage]?.includes(selected[stage])))
}
export function assetUrl(id: string) { return `/api/detail-generator/assets/${encodeURIComponent(id)}` }
export function actionKey(): string {

View File

@ -0,0 +1,35 @@
# AI 模型接入管理
入口:`/admin` → **超级管理员** → **AI 模型接入**。菜单和管理接口仅供当前数据库认定的在用、未归档超级管理员使用;撤权后的旧会话不能继续操作或请求上游。
## 管理流程
1. 已有详情页上游可用“迁入当前连接”显式转为数据库配置;迁入操作只保存配置,不调用模型。凭据继续引用固定的服务端 `DETAIL_GENERATOR_API_KEY`,不复制到浏览器。
2. 新增上游填写名称、HTTPS API 地址和 API Key。新增或替换凭据需已有专用 `API_KEY_ENCRYPTION_KEY`,最少 32 字节。凭据使用 AES-256-GCM 加密,接口只返回配置状态;编辑时密钥留空保持现状。
3. “同步目录”请求上游 `/v1/models`。当前上游还读取其公开的 `/api/v2/catalog/models` 结构化能力目录,只保留当前 Key 目录内的模型。新发现模型默认未启用;重复同步保留仍符合能力限制的人工用途,上游已移除的模型不可用。
4. 先按对话、多模态、生图、其他及待核验筛选,再勾选允许用户使用的用途并启用。用途由服务端能力证据限制:纯对话不能勾选生图;无图片输入能力不能勾选资料分析;仅支持文生图、专用路由或异步接口的模型不能直接用于当前产品参考图生图。
5. 为三个阶段选择默认模型并保存。用户可在详情页“模型选择”中自行选择管理员开放的模型,默认值只是新项目的初始选择。不同上游的同名模型以独立编号区分,实际请求仍使用上游原生模型 ID。详情页正式验收前仍只向超级管理员开放。
管理员“确认接口兼容”是配置确认,不能增加模型的服务端能力。后端在保存、默认值、用户候选列表及每次模型执行前共同复核,直接提交请求也无法越界。上游能力停用后,旧项目和已排队任务不能继续调用该能力,已有内容仍可编辑、保存及导出。
## 当前适配协议
| 用途 | 输入与接口 | 输出要求 |
| --- | --- | --- |
| 资料分析 | `/chat/completions`,文本和 `image_url` 产品图 | 可用的文本分析 |
| 卖点文案 | `/chat/completions`,文本 | 可解析且符合项目校验的大纲 JSON |
| 图片生成 | `/images/edits`,multipart 产品及参考图片 | `data[0].b64_json` 中的有效图片 |
音频、视频及图片处理工具归为其他;专用接口需要另行开发适配后才能分配给详情页阶段。模型目录里的名称本身不证明适配能力。能力以明确的输入、输出、默认接口协议为依据;没有元数据时只保留精确上游地址和模型的既有真实验证记录(Gemini 2.5 Flash 分析/文案、Nano Banana 2 参考图生图),不把同名其他上游视为已验证。上游明确停用、撤销模态或接口时优先拒绝。目录同步不等于真实生成验收,不自动批量发送付费测试。
## 安全与数据
- 只接受 HTTPS 标准端口、无嵌入凭据或查询参数的服务地址;解析的全部 IP 都须为公网地址,并将已检查地址固定到实际 HTTPS 连接。拒绝跳转,防止 API Key 被带到其他地址。
- 修改 API 地址必须同时填写该服务的密钥,并清除原地址的目录兼容配置。密钥不会进入响应、审计元数据或项目快照。
- 上游、模型目录、阶段默认值及审计记录保存在 `ai_model_providers`、`ai_model_catalog`、`ai_model_configuration`、`ai_model_audit`。配置事务串行并核对版本,旧配置不能覆盖新修改。
- `capability_metadata` 只持久化服务端归一化的能力证据,管理接口不接受客户端修改。公开能力目录无密钥请求,使用与私有请求相同的公网 DNS 固定、拒绝重定向和限时保护;公开目录限 8 MiB,私有模型列表保持 2 MiB。同步任一目录失败时不保存部分结果。
- 详情项目保存可信的上游及模型绑定,模型执行前重新核对可用性、权限和绑定。旧裸模型名只对应显式迁入的原服务,不能静默改到另一供应商。
- 缺少阶段默认模型时暂停生成;已有项目仍可修改文案、保存和导出。
- 受控迁移:`20261012_ai_model_registry` 新增表,`20261013_ai_model_capabilities` 仅新增可空的能力证据列;不包含真实凭据或自动上游导入。旧行无能力证据时默认不可开放,只有准确匹配的既有实际验证可用;同步后恢复有证据的候选范围。生产发布仍遵循标准备份、受控迁移及精确提交验证;禁止对生产运行 `prisma db push`。
本地真实 HTTP 与隔离数据库验收脚本:`scripts/verify-ai-model-admin.ts`,仅允许 `127.0.0.1:55441/ai_model_test`。目录同步为只读查询,脚本不提交生成任务。

View File

@ -14,7 +14,7 @@
## 启用配置
默认关闭。发布前先完成项目标准备份和受控迁移,应用 `20261012_detail_generator`;禁止对生产使用 `prisma db push`。
默认关闭。发布前先完成项目标准备份和受控迁移,应用 `20261012_detail_generator`、`20261012_ai_model_registry` 及 `20261013_ai_model_capabilities`;禁止对生产使用 `prisma db push`。
```dotenv
DETAIL_GENERATOR_ENABLED=true
@ -27,13 +27,9 @@ DETAIL_GENERATOR_STORAGE_DIR=/app/detail-generator-private
可选 `DETAIL_RENDERER_NODE`、`DETAIL_RENDERER_ENTRY`、`DETAIL_FONT_DIR` 分别指定独立 Node、CLI 和字体目录。完整 Noto Sans SC 字体及 OFL 许可位于 `public/detail-generator/fonts`;浏览器、渲染和 PSD 使用同一字体文件与真实 PostScript 名称。使用 Photoshop 的设备也需要安装这套字体。
默认分析/文案模型为 `gemini-2.5-flash`,生图为 `nano-banana-2`。需要额外模型时,先完成对应输入、输出及错误状态验证,再通过 `DETAIL_GENERATOR_VALIDATED_MODELS_JSON` 配置三个阶段的允许列表:
模型连接、目录、用途及三个阶段默认值由数据库管理,入口为 `/admin` → 超级管理员 → **AI 模型接入**,详见 [管理说明](ai-model-management.md)。现有环境配置需通过“迁入当前连接”显式导入,保留原分析/文案 `gemini-2.5-flash` 与生图 `nano-banana-2` 的配置及旧项目绑定;`DETAIL_GENERATOR_VALIDATED_MODELS_JSON` 仅用于此次旧配置迁入,不再驱动运行时菜单。
```json
{"analysis":["gemini-2.5-flash"],"copy":["gemini-2.5-flash"],"image":["nano-banana-2"]}
```
仅在列表中出现不代表兼容;不能直接把网关 `/models` 结果全部开放给用户。密钥只存在于服务端,不写入 Git、项目文档、浏览器或任务记录。
同步目录查询 `/models` 和已适配的公开能力目录,新模型默认关闭;服务端先根据明确的输入、输出及接口能力限定用途,管理员只能在这个范围内选择向用户开放的模型。纯对话模型不能开放生图,未知能力不能通过人工确认解锁。用户在“模型选择”中自行选择已开放的模型;默认值不锁死用户选择。缺少可用默认值时可保存项目资料及编辑、导出已有内容,生成保持停用。新增上游或替换 API Key 使用项目专用 `API_KEY_ENCRYPTION_KEY` 加密,密钥不写入 Git、项目文档、浏览器或任务记录。
## 任务与故障处理

View File

@ -2,7 +2,7 @@
## 结论
当前应用发布工具验证三十三份目标 migration。普通部署脚本**只读验证,不会自动修改数据库**。只有独立 migrator 在同时拿到精确 Git 提交、数据库身份和备份凭证后,才允许执行 `prisma migrate deploy`。目标名称以该精确提交的 `TARGET_MIGRATIONS` 为准,不能沿用历史批次的数量或凭证。
当前应用发布工具验证三十五份目标 migration。普通部署脚本**只读验证,不会自动修改数据库**。只有独立 migrator 在同时拿到精确 Git 提交、数据库身份和备份凭证后,才允许执行 `prisma migrate deploy`。目标名称以该精确提交的 `TARGET_MIGRATIONS` 为准,不能沿用历史批次的数量或凭证。
目标 migration:
@ -39,9 +39,15 @@
- `20261009_material_purge_outbox`
- `20261009_material_purge_reference_indexes`
- `20261012_detail_generator`
- `20261012_ai_model_registry`
- `20261013_ai_model_capabilities`
`20261012_detail_generator` 仅新增个人详情页项目、私有文件、持久任务和品牌风格草案四张表及其约束/索引,不回填素材库或修改用户权限。正式验收前,启用配置仍只开放给当前有效超级管理员;私有文件目录独立持久挂载。
`20261012_ai_model_registry` 仅新增模型上游、目录、默认配置、审计四张表及约束/索引;`20261013_ai_model_capabilities` 仅新增目录的可空 `capability_metadata` JSONB 列。受控迁移核对列类型、空值限制、安全默认值、模型唯一性、原连接唯一性、引用限制和凭据完整性约束;切流前复核这两份 SQL 的精确校验和及四表字段。迁移不写入真实密钥、不自动导入或开放模型。
部署后由当前有效超级管理员显式执行“迁入当前连接”,再“同步目录”。迁入继续引用现有 `DETAIL_GENERATOR_API_KEY` 和精确原地址,保留已验证的 Gemini 2.5 Flash 分析/文案及 Nano Banana 2 生图默认;同步不会开放新发现模型,也不调用付费生成。应核对迁入审计、目录数量、三阶段默认和普通用户拒绝状态后完成验收。现有环境连接不依赖加密主密钥;新增上游或替换密钥才要求已配置至少 32 字节的专用 `API_KEY_ENCRYPTION_KEY`,不要替换已有主密钥或把凭据写进迁移、命令参数、日志。
已有标准 drain 能力和稳定代理的生产实例,继续使用当前 `direct` 发布策略。维护、备份和迁移期间同时持有发布锁与代理 reconcile 锁,验证三个入口 503、作业及连接排空、旧实例停止后再取备份。完成迁移审计才交接给同 SHA 的标准发布 payload;交接前释放锁,避免 payload 重取时自锁。恢复旧实例时按其实际 IP 生成恢复代理配置,保留原配置并验证三个入口的旧 SHA。
`20260923_add_certificate_report_metadata` 仅为 `materials` 增加五个可空字段:报告类别、证书到期日、证书编号、证书使用说明和检测报告使用规则说明。不回填或修改已有素材行;旧报告需要编辑时再补录类别和证书信息。
@ -73,7 +79,7 @@
1. 构建并固定同一 exact SHA/digest 的 runner 与 migrator;
2. legacy bootstrap `Audit`;
3. legacy bootstrap `Apply`,外部验收 503,确认旧容器停止且保留;
4. 在同一维护窗内运行本文的真实备份、隔离恢复、三十三条目标 migration 和迁移后审计;
4. 在同一维护窗内运行本文的真实备份、隔离恢复、三十五条目标 migration 和迁移后审计;
5. 仅在所有门禁通过后,运行普通同 SHA 应用部署。
顺序不得改为“先 migration,后 bootstrap”。本文备份工具的生产前后摘要若有变化,说明维护窗内仍可能存在写入,会直接失败且不生成 receipt。

View File

@ -0,0 +1,87 @@
/** @jest-environment node */
import { aiModelCapabilityFingerprint, getAiModelCapabilities, mergeAiModelCapabilityMetadata, parseAiModelCapabilityMetadata, parseAiModelPublicCapabilityCatalog } from '@/lib/ai-model-capabilities'
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 base = 'https://api.vip98k.com/v1'
const generic = 'https://example.com/v1'
const metadata = (input: string[], output: string[], endpoints: string[]) => parseAiModelCapabilityMetadata({ input_modalities: input, output_modalities: output, endpoints })!
const invocation = (path = '/v1/chat/completions', headers = {}) => ({ method: 'POST', path, content_type: path.includes('/images/') ? 'multipart/form-data' : 'application/json', headers })
const model = (overrides = {}) => ({ id: 'catalog-model', type: 'model', modality: 'text', available: true, status: 'available', default_service_scope_id: 'scope-default', selection_required: false, service_offers: [{ is_default: true, service_scope: { id: 'scope-default' }, invocation: invocation() }], ...overrides })
const publicMetadata = (entry: ReturnType<typeof model>, section = 'chat') => parseAiModelPublicCapabilityCatalog({ code: 0, data: { schema_version: '2.2', sections: [{ id: section, models: [entry] }] } }).get(entry.id)!
test('a plain conversation model cannot analyze product images or generate images', () => {
const capability = getAiModelCapabilities(generic, 'nano-banana-image-sounding-name', metadata(['text'], ['text'], ['/chat/completions']))
expect(capability).toMatchObject({ kind: 'text', allowedStages: ['copy'], source: 'upstream' })
})
test('image understanding and image output require separate interfaces', () => {
expect(getAiModelCapabilities(generic, 'vision', metadata(['text', 'image'], ['text'], ['/chat/completions'])).allowedStages).toEqual(['analysis', 'copy'])
expect(getAiModelCapabilities(generic, 'generator', metadata(['text'], ['image'], ['/images/generations']))).toMatchObject({ kind: 'image', allowedStages: [] })
expect(getAiModelCapabilities(generic, 'editor', metadata(['text', 'image'], ['image'], ['/images/edits']))).toMatchObject({ kind: 'image', allowedStages: ['image'] })
})
test('audio transcription is not a copy-writing model just because it returns text over chat', () => {
expect(getAiModelCapabilities(generic, 'asr', metadata(['audio'], ['text'], ['/chat/completions']))).toMatchObject({ kind: 'other', allowedStages: [] })
})
test('model names and arbitrary providers cannot inherit verified abilities', () => {
expect(getAiModelCapabilities(generic, 'nano-banana-2')).toMatchObject({ source: 'unknown', allowedStages: [] })
expect(getAiModelCapabilities(base, 'nano-banana-unknown')).toMatchObject({ source: 'unknown', allowedStages: [] })
expect(getAiModelCapabilities(base, 'nano-banana-2').allowedStages).toEqual(['image'])
})
test('public catalog classifies models dynamically without guessing names', () => {
expect(getAiModelCapabilities(base, 'catalog-model', publicMetadata(model()))).toMatchObject({ kind: 'text', allowedStages: ['copy'], source: 'upstream' })
expect(getAiModelCapabilities(base, 'catalog-model', publicMetadata(model({ modality: 'multimodal' })))).toMatchObject({ kind: 'vision', allowedStages: ['copy'] })
})
test('public image processing tools do not appear as generative image models', () => {
const entry = model({ type: 'image', modality: 'image', capabilities: ['media.tasks'], input_modes: ['file'], output_modes: ['image'] })
expect(getAiModelCapabilities(base, entry.id, publicMetadata(entry, 'task'))).toMatchObject({ kind: 'other', allowedStages: [] })
})
test('a declared specialist tool is not opened merely because it shares a chat or image protocol', () => {
const evidence = { ...metadata(['text', 'image'], ['text', 'image'], ['/chat/completions', '/images/edits']), kindHint: 'other' as const }
expect(getAiModelCapabilities(generic, 'tool', evidence)).toMatchObject({ kind: 'other', allowedStages: [] })
expect(getAiModelCapabilities(base, 'nano-banana-2', evidence).allowedStages).toEqual([])
})
test.each(['header', 'selection', 'non-default'])('route-specific or non-default capability cannot be opened with the default adapter: %s', reason => {
const offer = { is_default: reason !== 'non-default', service_scope: { id: 'scope-default' }, invocation: invocation('/v1/chat/completions', reason === 'header' ? { 'X-Relay-Route-Option': 'route' } : {}) }
const entry = model({ selection_required: reason === 'selection', invocation: invocation(), service_offers: [offer] })
expect(getAiModelCapabilities(base, entry.id, publicMetadata(entry)).allowedStages).toEqual([])
})
test('non-default image editing offers do not override the selected image generation route', () => {
const entry = model({ type: 'image', modality: 'image', capabilities: ['text_to_image', 'openai.images.edits'], input_modes: ['text', 'image'], output_modes: ['image'], service_offers: [
{ is_default: true, service_scope: { id: 'scope-default' }, invocation: invocation('/v1/images/generations') },
{ is_default: false, service_scope: { id: 'scope-other' }, invocation: invocation('/v1/images/edits') },
] })
expect(getAiModelCapabilities(base, entry.id, publicMetadata(entry, 'tool'))).toMatchObject({ kind: 'image', allowedStages: [] })
})
test('missing public modality details preserve exact verified image-input support', () => {
const entry = model({ id: 'gemini-2.5-flash' })
expect(getAiModelCapabilities(base, entry.id, publicMetadata(entry))).toMatchObject({ kind: 'vision', allowedStages: ['analysis', 'copy'], source: 'verified' })
})
test.each(['input', 'output', 'endpoint', 'unavailable'])('explicit withdrawal overrides exact verified evidence: %s', reason => {
const current = metadata(reason === 'input' ? ['text'] : ['text', 'image'], reason === 'output' ? ['text'] : ['image'], reason === 'endpoint' ? ['/images/generations'] : ['/images/edits'])
if (reason === 'unavailable') current.unavailable = true
expect(getAiModelCapabilities(base, 'nano-banana-2', current).allowedStages).toEqual([])
})
test('a disabled selected offer and public negative override positive private catalog metadata', () => {
const entry = model({ id: 'gemini-2.5-flash', service_offers: [{ is_default: true, available: false, service_scope: { id: 'scope-default' }, invocation: invocation() }] })
const merged = mergeAiModelCapabilityMetadata(metadata(['text', 'image'], ['text'], ['/chat/completions']), publicMetadata(entry))
expect(getAiModelCapabilities(base, entry.id, merged).allowedStages).toEqual([])
})
test('metadata fingerprint is stable after PostgreSQL JSONB reorders properties', () => {
const value = metadata(['image', 'text'], ['text'], ['/chat/completions'])
expect(aiModelCapabilityFingerprint(value)).toBe(aiModelCapabilityFingerprint(Object.fromEntries(Object.entries(value).reverse())))
})
test('partial capability declarations and unexpected public schemas fail before synchronization writes', () => {
expect(() => parseAiModelCapabilityMetadata({ input_modalities: ['text'] })).toThrow('能力字段不完整')
expect(() => parseAiModelPublicCapabilityCatalog({ code: 0, data: { schema_version: '3', sections: [] } })).toThrow('能力目录格式不兼容')
})
test('standard catalog unavailable status cannot be overridden by positive protocol metadata', () => {
const unavailable = parseAiModelCapabilityMetadata({ available: false, input_modalities: ['text', 'image'], output_modalities: ['image'], endpoints: ['/images/edits'] })
expect(getAiModelCapabilities(base, 'nano-banana-2', unavailable).allowedStages).toEqual([])
})
test('an ID-only private model entry can still withdraw a publicly available capability', () => {
const unavailable = parseAiModelCapabilityMetadata({ id: 'gemini-2.5-flash', object: 'model', available: false })
const merged = mergeAiModelCapabilityMetadata(unavailable, publicMetadata(model({ id: 'gemini-2.5-flash' })))
expect(getAiModelCapabilities(base, 'gemini-2.5-flash', merged)).toMatchObject({ kind: 'text', allowedStages: [] })
})

View File

@ -0,0 +1,149 @@
/** @jest-environment node */
import { EventEmitter } from 'node:events'
import { lookup } from 'node:dns/promises'
import { request as httpsRequest } from 'node:https'
import { getServerSession } from 'next-auth'
import { prisma } from '@/lib/prisma'
import { isSuperAdmin } from '@/lib/department-permission'
import { AiModelError, assertAiModelAdmin } from '@/lib/ai-model-access'
import { decryptAiModelKey, encryptAiModelKey, isAiModelEncryptionConfigured } from '@/lib/ai-model-crypto'
import { normalizeAiModelBaseUrl, requestAiModel, requestAiModelCapabilityCatalog, resolveAiModelPublicAddresses } from '@/lib/ai-model-http'
import { GET } from '@/app/api/admin/ai-models/route'
import { POST } from '@/app/api/admin/ai-models/providers/route'
jest.mock('node:dns/promises', () => ({ lookup: jest.fn() }))
jest.mock('node:https', () => ({ request: 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: { user: { findUnique: jest.fn() } } }))
jest.mock('@/lib/ai-model-service', () => ({ getAiModelAdminState: jest.fn().mockResolvedValue({ providers: [] }), createAiModelProvider: jest.fn() }))
const originalKey = process.env.API_KEY_ENCRYPTION_KEY
beforeEach(() => {
jest.clearAllMocks()
process.env.API_KEY_ENCRYPTION_KEY = 'synthetic-ai-encryption-key-32-bytes-minimum'
;(lookup as jest.Mock).mockResolvedValue([{ address: '93.184.216.34', family: 4 }])
;(getServerSession as jest.Mock).mockResolvedValue({ user: { id: 'admin', role: 'USER', isSuperAdmin: true } })
;(isSuperAdmin as jest.Mock).mockResolvedValue(true)
;(prisma.user.findUnique as jest.Mock).mockResolvedValue({ isSuperAdmin: true, status: 'active', isActive: true, archivedAt: null })
})
afterAll(() => { if (originalKey === undefined) delete process.env.API_KEY_ENCRYPTION_KEY; else process.env.API_KEY_ENCRYPTION_KEY = originalKey })
test.each([
'http://example.com/v1', 'https://user:password@example.com/v1', 'https://example.com:8443/v1',
'https://example.com/v1?token=hidden', 'https://example.com/v1#hidden', 'https://localhost/v1',
'https://127.0.0.1/v1', 'https://2130706433/v1', 'https://[::1]/v1', 'https://[::ffff:127.0.0.1]/v1',
'https://example.local/v1', 'https://example.internal/v1',
])('rejects unsafe provider URL %s', value => { expect(() => normalizeAiModelBaseUrl(value)).toThrow(AiModelError) })
test.each(['10.0.0.1', '169.254.169.254', '192.168.58.20', '100.64.0.1', '::ffff:7f00:1', 'fe80::1', '2001:db8::1'])('rejects mixed DNS including %s before any socket', async address => {
const family = address.includes(':') ? 6 : 4
;(lookup as jest.Mock).mockResolvedValue([{ address: '93.184.216.34', family: 4 }, { address, family }])
await expect(requestAiModel('https://models.example.com/v1', 'synthetic-provider-key', '/models')).rejects.toMatchObject({ code: 'unsafe_provider_url' })
expect(httpsRequest).not.toHaveBeenCalled()
})
test('DNS is resolved once and the HTTPS lookup uses only validated addresses', async () => {
let requestOptions: any
;(httpsRequest as jest.Mock).mockImplementation((_target, options, callback) => {
requestOptions = options
const request = new EventEmitter() as any
request.end = () => queueMicrotask(() => {
const response = new EventEmitter() as any; response.statusCode = 200; response.headers = {}; response.destroy = jest.fn()
callback(response); response.emit('data', Buffer.from('{"data":[]}')); response.emit('end')
})
request.destroy = jest.fn()
return request
})
const response = await requestAiModel('https://models.example.com', 'synthetic-provider-key', '/models')
expect(await response.json()).toEqual({ data: [] })
expect(lookup).toHaveBeenCalledTimes(1)
const resolved = jest.fn(); requestOptions.lookup('models.example.com', { all: false }, resolved)
expect(resolved).toHaveBeenCalledWith(null, '93.184.216.34', 4)
expect(requestOptions.agent).toBe(false)
})
test('revocation during DNS wait runs the fresh guard and creates zero sockets', async () => {
;(lookup as jest.Mock).mockImplementation(async () => { ;(isSuperAdmin as jest.Mock).mockResolvedValue(false); return [{ address: '93.184.216.34', family: 4 }] })
await expect(requestAiModel('https://models.example.com', 'synthetic-provider-key', '/models', undefined, undefined, () => assertAiModelAdmin('admin').then(() => undefined))).rejects.toMatchObject({ status: 403 })
expect(httpsRequest).not.toHaveBeenCalled()
})
test.each([204, 302, 200])('handles empty response / redirect / excessive body safely: HTTP %s', async status => {
;(httpsRequest as jest.Mock).mockImplementation((_target, _options, callback) => {
const request = new EventEmitter() as any; request.destroy = jest.fn()
request.end = () => queueMicrotask(() => {
const response = new EventEmitter() as any; response.statusCode = status; response.headers = {}; response.destroy = jest.fn()
callback(response)
if (status === 200) response.emit('data', Buffer.alloc(2 * 1024 * 1024 + 1))
response.emit('end')
})
return request
})
const promise = requestAiModel('https://models.example.com/v1', 'synthetic-provider-key', '/models')
if (status === 204) expect((await promise).status).toBe(204)
else await expect(promise).rejects.toThrow(status === 302 ? '重定向' : 'exceeds limit')
})
test('credential encryption is randomized, authenticated and requires a dedicated master key', () => {
const first = encryptAiModelKey('synthetic-upstream-secret'); const second = encryptAiModelKey('synthetic-upstream-secret')
expect(first.secretCiphertext).not.toBe(second.secretCiphertext)
expect(JSON.stringify(first)).not.toContain('synthetic-upstream-secret')
expect(decryptAiModelKey(first)).toBe('synthetic-upstream-secret')
const tampered = { ...first, secretAuthTag: Buffer.alloc(16).toString('base64') }
expect(() => decryptAiModelKey(tampered)).toThrow('无法解密')
delete process.env.API_KEY_ENCRYPTION_KEY
expect(isAiModelEncryptionConfigured()).toBe(false)
expect(() => encryptAiModelKey('test')).toThrow('API_KEY_ENCRYPTION_KEY')
})
test('the final user-state lookup rejects revocation even if the earlier role read allowed it', async () => {
;(prisma.user.findUnique as jest.Mock).mockResolvedValue({ isSuperAdmin: false, status: 'active', isActive: true, archivedAt: null })
await expect(assertAiModelAdmin('admin')).rejects.toMatchObject({ status: 403, code: 'forbidden' })
})
test.each([
{ isSuperAdmin: true, status: 'suspended', archivedAt: null },
{ isSuperAdmin: true, status: 'active', archivedAt: new Date() },
{ isSuperAdmin: true, status: 'pending', archivedAt: null },
])('rejects inactive super administrators before reading configuration: %o', async state => {
;(prisma.user.findUnique as jest.Mock).mockResolvedValue(state)
expect((await GET()).status).toBe(403)
expect(httpsRequest).not.toHaveBeenCalled()
})
test('unauthenticated and ordinary-admin requests fail before parsing bodies or network access', async () => {
;(getServerSession as jest.Mock).mockResolvedValue(null)
expect((await GET()).status).toBe(401)
;(getServerSession as jest.Mock).mockResolvedValue({ user: { id: 'admin', role: 'ADMIN', isSuperAdmin: true } })
;(isSuperAdmin as jest.Mock).mockResolvedValue(false)
const request = new Request('https://platform.test/api/admin/ai-models/providers', { method: 'POST', body: 'not-json' })
expect((await POST(request)).status).toBe(403)
expect(httpsRequest).not.toHaveBeenCalled()
})
test('public DNS resolver refuses an empty result', async () => {
await expect(resolveAiModelPublicAddresses('https://models.example.com', async () => [])).rejects.toMatchObject({ code: 'unsafe_provider_url' })
})
test('public capability discovery has a fixed endpoint and carries no private Authorization header', async () => {
let targetUrl = '', sentHeaders: any
;(httpsRequest as jest.Mock).mockImplementation((target, options, callback) => {
targetUrl = String(target); sentHeaders = options.headers
const request = new EventEmitter() as any; request.destroy = jest.fn()
request.end = () => queueMicrotask(() => { const response = new EventEmitter() as any; response.statusCode = 200; response.headers = {}; response.destroy = jest.fn(); callback(response); response.emit('data', Buffer.from('{}')); response.emit('end') })
return request
})
await requestAiModelCapabilityCatalog('https://api.vip98k.com/v1')
expect(targetUrl).toBe('https://api.vip98k.com/api/v2/catalog/models')
expect(sentHeaders).toEqual({})
await expect(requestAiModelCapabilityCatalog('https://other.example.com/v1')).rejects.toMatchObject({ code: 'capability_catalog_unavailable' })
expect(httpsRequest).toHaveBeenCalledTimes(1)
})
test('public capability discovery rejects protected DNS and excessive response bodies', async () => {
;(lookup as jest.Mock).mockResolvedValueOnce([{ address: '192.168.1.1', family: 4 }])
await expect(requestAiModelCapabilityCatalog('https://api.vip98k.com/v1')).rejects.toMatchObject({ code: 'unsafe_provider_url' })
expect(httpsRequest).not.toHaveBeenCalled()
;(httpsRequest as jest.Mock).mockImplementation((_target, _options, callback) => {
const request = new EventEmitter() as any; request.destroy = jest.fn()
request.end = () => queueMicrotask(() => { const response = new EventEmitter() as any; response.statusCode = 200; response.headers = { 'content-length': String(8 * 1024 * 1024 + 1) }; response.destroy = jest.fn(); callback(response) })
return request
})
await expect(requestAiModelCapabilityCatalog('https://api.vip98k.com/v1')).rejects.toThrow('exceeds limit')
})

View File

@ -0,0 +1,172 @@
/** @jest-environment node */
import { prisma } from '@/lib/prisma'
import { AiModelError } from '@/lib/ai-model-access'
import { isSuperAdmin } from '@/lib/department-permission'
import { configureAiModels, editAiModelProvider, getAiModelAdminState, getAiModelRuntimeState, parseAiModelCatalog, resolveAiModel, syncAiModelProvider } from '@/lib/ai-model-service'
import { encryptAiModelKey } from '@/lib/ai-model-crypto'
import { requestAiModel, requestAiModelCapabilityCatalog } from '@/lib/ai-model-http'
import { parseAiModelCapabilityMetadata } from '@/lib/ai-model-capabilities'
import { aiModelAudit, getAiModelConfiguration, getAiModelProviderRow, listAiModelProviderRows, listAiModelRows, saveAiModelDefaults, type AiModelProviderRow, type AiModelRow } from '@/lib/ai-model-repository'
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() } } }))
jest.mock('@/lib/ai-model-http', () => ({ ...jest.requireActual('@/lib/ai-model-http'), requestAiModel: jest.fn(), requestAiModelCapabilityCatalog: jest.fn() }))
const mockDb = { $executeRawUnsafe: jest.fn() }
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(),
}))
const originalEnv = { ...process.env }
const legacyId = '11111111-1111-4111-8111-111111111111'
const secondId = '22222222-2222-4222-8222-222222222222'
const legacyModelKey = '33333333-3333-4333-8333-333333333333'
const secondModelKey = '44444444-4444-4444-8444-444444444444'
let providers: AiModelProviderRow[]
let models: AiModelRow[]
beforeEach(() => {
jest.clearAllMocks()
process.env.API_KEY_ENCRYPTION_KEY = 'synthetic-ai-encryption-key-32-bytes-minimum'
process.env.DETAIL_GENERATOR_API_KEY = 'synthetic-legacy-secret'
process.env.DETAIL_GENERATOR_BASE_URL = 'https://legacy.example.com/v1'
providers = [
{ id: legacyId, name: '原上游', baseUrl: 'https://legacy.example.com/v1', enabled: true, credentialSource: 'environment', secretCiphertext: null, secretNonce: null, secretAuthTag: null, legacyBinding: true, revision: 1, syncedAt: null },
{ id: secondId, name: '第二上游', baseUrl: 'https://new.example.com/v1', enabled: true, credentialSource: 'encrypted', ...encryptAiModelKey('synthetic-second-secret'), legacyBinding: false, revision: 1, syncedAt: null },
]
models = [
{ key: legacyModelKey, providerId: legacyId, id: 'vendor/shared-model', enabled: true, present: true, compatibility: 'confirmed', stages: ['analysis', 'copy', 'image'], capabilityMetadata: parseAiModelCapabilityMetadata({ input_modalities: ['text', 'image'], output_modalities: ['text', 'image'], endpoints: ['/chat/completions', '/images/edits'] }) },
{ key: secondModelKey, providerId: secondId, id: 'vendor/shared-model', enabled: true, present: true, compatibility: 'confirmed', stages: ['analysis', 'copy', 'image'], capabilityMetadata: parseAiModelCapabilityMetadata({ input_modalities: ['text', 'image'], output_modalities: ['text', 'image'], endpoints: ['/chat/completions', '/images/edits'] }) },
]
;(isSuperAdmin as jest.Mock).mockResolvedValue(true)
;(prisma.user.findUnique as jest.Mock).mockResolvedValue({ isSuperAdmin: true, status: 'active', isActive: true, archivedAt: null })
;(listAiModelProviderRows as jest.Mock).mockImplementation(async () => providers)
;(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 } })
;(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 })
test('same raw model names remain isolated by catalog UUID and legacy names bind only their explicit imported provider', async () => {
expect((await resolveAiModel('analysis', legacyModelKey)).binding.providerId).toBe(legacyId)
expect((await resolveAiModel('analysis', secondModelKey)).binding.providerId).toBe(secondId)
expect((await resolveAiModel('analysis', 'vendor/shared-model')).binding.providerId).toBe(legacyId)
providers[0].legacyBinding = false
await expect(resolveAiModel('analysis', 'vendor/shared-model')).rejects.toMatchObject({ code: 'model_unavailable' })
})
test.each(['disabled-provider', 'disabled-model', 'missing-model', 'unconfirmed', 'wrong-stage'])('rejects unavailable runtime selection: %s', async reason => {
if (reason === 'disabled-provider') providers[0].enabled = false
if (reason === 'disabled-model') models[0].enabled = false
if (reason === 'missing-model') models[0].present = false
if (reason === 'unconfirmed') models[0].compatibility = 'unconfirmed'
if (reason === 'wrong-stage') models[0].stages = ['copy']
await expect(resolveAiModel('analysis', legacyModelKey)).rejects.toMatchObject({ code: 'model_unavailable' })
await expect(configureAiModels('admin', { revision: 1, providers: [], defaults: { analysis: legacyModelKey, copy: null, image: null } })).rejects.toMatchObject({ code: 'default_unavailable' })
expect(saveAiModelDefaults).not.toHaveBeenCalled()
})
test('changed connection revisions stop existing bindings before any model request', async () => {
const binding = (await resolveAiModel('image', secondModelKey)).binding
providers[1].revision++
await expect(resolveAiModel('image', secondModelKey, binding)).rejects.toMatchObject({ code: 'model_binding_changed' })
expect(requestAiModel).not.toHaveBeenCalled()
})
test('environment URL drift cannot send a replacement environment key to the original imported endpoint', async () => {
process.env.DETAIL_GENERATOR_BASE_URL = 'https://other.example.com/v1'
await expect(resolveAiModel('image', legacyModelKey)).rejects.toMatchObject({ code: 'model_unavailable' })
expect((await getAiModelRuntimeState()).legacyModels.image).toEqual([])
})
test('legacy imported endpoint remains immutable even when a new API key is supplied', async () => {
await expect(editAiModelProvider('admin', legacyId, { revision: 1, baseUrl: 'https://other.example.com/v1', apiKey: 'synthetic-new-secret' })).rejects.toThrow('保留原服务地址')
expect(mockDb.$executeRawUnsafe).not.toHaveBeenCalled()
})
test('admin DTO strips all encrypted material and does not return plaintext or raw environment variables', async () => {
const state = await getAiModelAdminState()
expect(state.providers[0].hasApiKey).toBe(true)
expect(state.providers[1].hasApiKey).toBe(true)
const serialized = JSON.stringify(state)
for (const value of ['synthetic-legacy-secret', 'synthetic-second-secret', 'secretCiphertext', 'secretNonce', 'secretAuthTag', providers[1].secretCiphertext!]) expect(serialized).not.toContain(value)
})
test('uninitialized defaults produce no hardcoded menu or automatic default choice', async () => {
;(getAiModelConfiguration as jest.Mock).mockResolvedValue({ revision: 0, defaults: { analysis: null, copy: null, image: null } })
const state = await getAiModelRuntimeState()
expect(state.configured).toBe(false)
expect(state.defaults).toEqual({ analysis: '', copy: '', image: '' })
expect(state.models.analysis).toEqual([legacyModelKey, secondModelKey])
expect(state.modelOptions.analysis.map(value => value.label)).toEqual(['vendor/shared-model', 'vendor/shared-model'])
})
test('directory synchronization preserves manual compatibility while adding new models disabled', async () => {
await syncAiModelProvider('admin', legacyId, { revision: 1 })
const statements = mockDb.$executeRawUnsafe.mock.calls.map(call => String(call[0]))
const upserts = statements.filter(sql => sql.startsWith('INSERT INTO ai_model_catalog'))
expect(upserts).toHaveLength(2)
expect(upserts.every(sql => !sql.includes('stages=') && !sql.includes('enabled=') && !sql.includes('compatibility='))).toBe(true)
expect(statements.some(sql => sql.includes('SET present=FALSE'))).toBe(true)
expect(aiModelAudit).toHaveBeenCalledWith(expect.anything(), 'admin', 'provider.sync', legacyId, expect.objectContaining({ addedCount: 1 }))
})
test('concurrent provider updates during catalog response reject all catalog writes', async () => {
;(requestAiModel as jest.Mock).mockImplementation(async (_base, _key, _endpoint, _body, _idempotency, beforeRequest) => { await beforeRequest(); providers[0].revision++; return Response.json({ data: [{ id: 'new' }] }) })
await expect(syncAiModelProvider('admin', legacyId, { revision: 1 })).rejects.toMatchObject({ code: 'revision_conflict' })
expect(mockDb.$executeRawUnsafe).not.toHaveBeenCalled()
})
test('an upstream credential echo in model IDs is rejected before persistence or public DTO response', async () => {
;(requestAiModel as jest.Mock).mockResolvedValue(Response.json({ data: [{ id: 'synthetic-legacy-secret' }] }))
await expect(syncAiModelProvider('admin', legacyId, { revision: 1 })).rejects.toMatchObject({ code: 'catalog_invalid' })
expect(mockDb.$executeRawUnsafe).not.toHaveBeenCalled()
expect(aiModelAudit).not.toHaveBeenCalled()
})
test.each([{ data: [{ id: '\nunsafe' }] }, { data: [{ id: 1 }] }, { data: 'incorrect' }, { data: [{ id: '' }] }])('invalid model catalogs are rejected: %o', value => {
expect(() => parseAiModelCatalog(value)).toThrow(AiModelError)
})
test('manual confirmation cannot enable image generation for a conversation model', async () => {
models[0].capabilityMetadata = parseAiModelCapabilityMetadata({ input_modalities: ['text'], output_modalities: ['text'], endpoints: ['/chat/completions'] })
const input = { revision: 1, defaults: { analysis: null, copy: null, image: null }, providers: [{ id: legacyId, revision: 1, models: [{ key: legacyModelKey, enabled: true, compatibility: 'confirmed', stages: ['image'] }] }] }
await expect(configureAiModels('admin', input)).rejects.toMatchObject({ code: 'model_capability_unsupported' })
expect(mockDb.$executeRawUnsafe).not.toHaveBeenCalled()
})
test('forged read-only capability metadata is rejected before writes', async () => {
await expect(configureAiModels('admin', { revision: 1, defaults: { analysis: null, copy: null, image: null }, providers: [{ id: legacyId, revision: 1, models: [{ key: legacyModelKey, enabled: true, compatibility: 'confirmed', stages: ['image'], capabilities: { allowedStages: ['image'] } }] }] })).rejects.toThrow('格式无效')
expect(mockDb.$executeRawUnsafe).not.toHaveBeenCalled()
})
test('stale enabled rows are filtered from menus, defaults and old project execution after capability withdrawal', async () => {
const binding = (await resolveAiModel('image', legacyModelKey)).binding
models[0].capabilityMetadata = parseAiModelCapabilityMetadata({ input_modalities: ['text'], output_modalities: ['text'], endpoints: ['/chat/completions'] })
const state = await getAiModelRuntimeState()
expect(state.models.image).not.toContain(legacyModelKey)
expect(state.defaults.image).toBe('')
await expect(resolveAiModel('image', legacyModelKey, binding)).rejects.toMatchObject({ code: 'model_unavailable' })
await expect(configureAiModels('admin', { revision: 1, providers: [], defaults: { analysis: null, copy: null, image: legacyModelKey } })).rejects.toMatchObject({ code: 'default_unavailable' })
expect(requestAiModel).not.toHaveBeenCalled()
})
test('admin read removes invalid old stages without writes or raw capability metadata exposure', async () => {
models[0].capabilityMetadata = null
const state = await getAiModelAdminState()
expect(state.providers[0].models[0]).toMatchObject({ enabled: false, stages: [], capabilities: { kind: 'unknown', allowedStages: [] } })
expect(JSON.stringify(state)).not.toContain('capabilityMetadata')
expect(mockDb.$executeRawUnsafe).not.toHaveBeenCalled()
})
test('typed public catalog is restricted to models authorized by the private directory', async () => {
process.env.DETAIL_GENERATOR_BASE_URL = providers[0].baseUrl = 'https://api.vip98k.com/v1'
;(requestAiModel as jest.Mock).mockResolvedValue(Response.json({ data: [{ id: 'allowed-model' }] }))
;(requestAiModelCapabilityCatalog as jest.Mock).mockResolvedValue(Response.json({ code: 0, data: { schema_version: '2.2', sections: [{ id: 'chat', models: ['allowed-model', 'unauthorized-model'].map(id => ({ id, type: 'model', modality: 'text', invocation: { method: 'POST', path: '/v1/chat/completions', content_type: 'application/json', headers: {} } })) }] } }))
await syncAiModelProvider('admin', legacyId, { revision: 1 })
const inserts = mockDb.$executeRawUnsafe.mock.calls.filter(call => String(call[0]).startsWith('INSERT INTO ai_model_catalog'))
expect(inserts).toHaveLength(1)
expect(inserts[0][3]).toBe('allowed-model')
expect(JSON.parse(inserts[0][4])).toMatchObject({ inputModalities: ['text'], outputModalities: ['text'], endpoints: ['/chat/completions'] })
expect(requestAiModelCapabilityCatalog).toHaveBeenCalledWith('https://api.vip98k.com/v1', expect.any(Function))
})
test('failure of public capability discovery aborts both catalog and capability writes', async () => {
process.env.DETAIL_GENERATOR_BASE_URL = providers[0].baseUrl = 'https://api.vip98k.com/v1'
;(requestAiModelCapabilityCatalog as jest.Mock).mockResolvedValue(new Response('', { status: 503 }))
await expect(syncAiModelProvider('admin', legacyId, { revision: 1 })).rejects.toMatchObject({ code: 'capability_catalog_failed' })
expect(mockDb.$executeRawUnsafe).not.toHaveBeenCalled()
})
test('identical capabilities do not bump provider revision after JSONB field reordering', async () => {
const declaration = { input_modalities: ['text'], output_modalities: ['text'], endpoints: ['/chat/completions'] }
models = [{ ...models[0], capabilityMetadata: Object.fromEntries(Object.entries(parseAiModelCapabilityMetadata(declaration, providers[0].baseUrl + '/models')!).reverse()) }]
;(requestAiModel as jest.Mock).mockResolvedValue(Response.json({ data: [{ id: models[0].id, ...declaration }] }))
await syncAiModelProvider('admin', legacyId, { revision: 1 })
const revisionUpdate = mockDb.$executeRawUnsafe.mock.calls.find(call => String(call[0]).includes('synced_at=NOW()'))
expect(revisionUpdate?.[2]).toBe(0)
})

View File

@ -10,11 +10,23 @@ import { prisma } from '@/lib/prisma'
import { detailPublicJob, type StoredDetailJob } from '@/lib/detail-generator/repository'
import { GET as getCapabilities } from '@/app/api/detail-generator/capabilities/route'
import sharp from 'sharp'
import { getAiModelRuntimeState } from '@/lib/ai-model-service'
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() } } }))
jest.mock('@/lib/ai-model-service', () => ({
captureAiModelBindings: jest.fn(), getAiModelRuntimeState: jest.fn(),
resolveAiModel: jest.fn(async (stage, selection) => {
if (selection.startsWith('unvalidated')) throw new (require('@/lib/detail-generator/access').DetailGeneratorError)('所选模型未确认兼容')
return { apiKey: 'unit-test-key', binding: { providerId: '11111111-1111-4111-8111-111111111111', modelKey: '22222222-2222-4222-8222-222222222222', modelId: selection, providerName: '测试上游', baseUrl: 'https://model.example.test/v1', revision: 1 } }
}),
}))
jest.mock('@/lib/ai-model-http', () => ({ requestAiModel: jest.fn(async (baseUrl, apiKey, endpoint, body, idempotencyKey, beforeRequest) => {
await beforeRequest()
return global.fetch(baseUrl + endpoint, { method: 'POST', headers: { Authorization: `Bearer ${apiKey}`, 'Idempotency-Key': idempotencyKey }, body })
}) }))
const originalFetch = global.fetch
const originalEnv = { ...process.env }
@ -31,7 +43,9 @@ beforeEach(() => {
process.env.DETAIL_GENERATOR_BASE_URL = 'https://model.example.test/v1'
delete process.env.DETAIL_GENERATOR_VALIDATED_MODELS_JSON
global.fetch = jest.fn()
;(prisma.user.findUnique as jest.Mock).mockResolvedValue({ status: 'active', isActive: true, archivedAt: null })
;(isSuperAdmin as jest.Mock).mockResolvedValue(true)
;(getAiModelRuntimeState as jest.Mock).mockResolvedValue({ models: { analysis: ['analysis-catalog-key'], copy: ['copy-catalog-key'], image: ['image-catalog-key'] } })
;(prisma.user.findUnique as jest.Mock).mockResolvedValue({ isSuperAdmin: true, status: 'active', isActive: true, archivedAt: null })
})
afterAll(() => { global.fetch = originalFetch; process.env = originalEnv })
@ -95,7 +109,7 @@ describe('provider outcomes and verified model selection', () => {
const subject = await sharp({ create: { width: 64, height: 64, channels: 4, background: { r: 120, g: 70, b: 40, alpha: 1 } } }).png().toBuffer()
const image = await sharp({ create: { width: 128, height: 128, channels: 4, background: { r: 0, g: 0, b: 0, alpha: 0 } } }).composite([{ input: subject, left: 32, top: 32 }]).png().toBuffer()
;(global.fetch as jest.Mock).mockResolvedValue(Response.json({ choices: [{ message: { content: '{"facts":[],"warnings":[]}' } }] }))
await analyzeDetailFacts(fixture(), [{ buffer: image, mimeType: 'image/png' }], '', 'white-reference-request')
await analyzeDetailFacts(fixture(), [{ buffer: image, mimeType: 'image/png' }], '', 'white-reference-request', 'owner')
const request = (global.fetch as jest.Mock).mock.calls[0][1]
const body = JSON.parse(request.body)
const url = body.messages[0].content.find((part: any) => part.type === 'image_url').image_url.url
@ -109,7 +123,7 @@ describe('provider outcomes and verified model selection', () => {
})
test('503 unknown is recorded once with safe request ID and never retried', async () => {
;(global.fetch as jest.Mock).mockResolvedValue(new Response(JSON.stringify({ error: { code: 'request_outcome_unknown', message: 'upstream-secret' } }), { status: 503, headers: { 'x-request-id': 'provider-request-1' } }))
await expect(analyzeDetailFacts(fixture(), [], '真实产品资料', 'same-request-12345')).rejects.toMatchObject({ outcomeUnknown: true, requestId: 'provider-request-1' })
await expect(analyzeDetailFacts(fixture(), [], '真实产品资料', 'same-request-12345', 'owner')).rejects.toMatchObject({ outcomeUnknown: true, requestId: 'provider-request-1' })
expect(global.fetch).toHaveBeenCalledTimes(1)
const [, request] = (global.fetch as jest.Mock).mock.calls[0]
expect(request.headers['Idempotency-Key']).toBe('same-request-12345')
@ -117,29 +131,34 @@ describe('provider outcomes and verified model selection', () => {
})
test('transport loss is unknown and 400 is a known failure without exposing upstream text', async () => {
;(global.fetch as jest.Mock).mockRejectedValueOnce(new Error('unit-test-key leaked in transport'))
await expect(analyzeDetailFacts(fixture(), [], '', 'transport-request-1')).rejects.toMatchObject({ outcomeUnknown: true })
await expect(analyzeDetailFacts(fixture(), [], '', 'transport-request-1', 'owner')).rejects.toMatchObject({ outcomeUnknown: true })
;(global.fetch as jest.Mock).mockResolvedValueOnce(new Response(JSON.stringify({ error: { message: 'unit-test-key' } }), { status: 400 }))
try { await analyzeDetailFacts(fixture(), [], '', 'rejected-request-1'); throw new Error('expected error') } catch (error) {
try { await analyzeDetailFacts(fixture(), [], '', 'rejected-request-1', 'owner'); throw new Error('expected error') } catch (error) {
expect(error).toBeInstanceOf(DetailProviderError)
expect(error).toMatchObject({ outcomeUnknown: false })
expect(String(error)).not.toContain('unit-test-key')
}
expect(global.fetch).toHaveBeenCalledTimes(2)
})
test('unverified models cannot dispatch and explicit validation list stays stage-specific', async () => {
const document = fixture(); document.models.image = 'unvalidated-image'
await expect(analyzeDetailFacts(document, [], '', 'request-unverified')).rejects.toThrow('已验证')
test('unconfirmed analysis models cannot dispatch and database catalog controls choices', async () => {
const document = fixture(); document.models.analysis = 'unvalidated-analysis'
await expect(analyzeDetailFacts(document, [], '', 'request-unverified', 'owner')).rejects.toThrow('未确认兼容')
expect(global.fetch).not.toHaveBeenCalled()
process.env.DETAIL_GENERATOR_VALIDATED_MODELS_JSON = JSON.stringify({ analysis: ['verified-analyzer'], image: ['verified-imager'] })
expect(getDetailModels()).toEqual({ analysis: ['gemini-2.5-flash', 'verified-analyzer'], copy: ['gemini-2.5-flash'], image: ['nano-banana-2', 'verified-imager'] })
expect(await getDetailModels()).toEqual({ analysis: ['analysis-catalog-key'], copy: ['copy-catalog-key'], image: ['image-catalog-key'] })
})
test('outline validates schema and assigns independent screen identities', async () => {
;(global.fetch as jest.Mock).mockResolvedValue(new Response(JSON.stringify({ choices: [{ message: { content: JSON.stringify({ sellingPoints: ['尺寸已提供'], warnings: ['材质待确认'], screens: [{ title: '产品展示', body: '500×500×800mm', imagePrompt: '准确展示产品' }, { title: '使用场景', body: '', imagePrompt: '同一产品居家场景' }] }) } }] })))
const result = await composeDetailOutline(fixture(), '原始事实', 'outline-request-1')
const result = await composeDetailOutline(fixture(), '原始事实', 'outline-request-1', 'owner')
expect(result.screens).toHaveLength(2)
expect(result.screens[0].id).not.toBe(result.screens[1].id)
expect(result.warnings).toContain('材质待确认')
})
test('literal key echoed in model content or request IDs is never saved or disclosed', async () => {
;(global.fetch as jest.Mock).mockResolvedValue(Response.json({ choices: [{ message: { content: 'unit-test-key' } }] }, { headers: { 'x-request-id': 'unit-test-key' } }))
await expect(analyzeDetailFacts(fixture(), [], '', 'key-echo-request', 'owner')).rejects.toMatchObject({ outcomeUnknown: true, requestId: null })
expect(global.fetch).toHaveBeenCalledTimes(1)
})
test('chunked provider responses obey actual byte limit', async () => {
const stream = new ReadableStream<Uint8Array>({ start(controller) { controller.enqueue(new Uint8Array(16)); controller.enqueue(new Uint8Array(16)); controller.close() } })
await expect(readDetailResponseBytes(new Response(stream), 20)).rejects.toThrow('响应过大')

View File

@ -16,7 +16,8 @@ jest.mock('@/lib/detail-generator/render', () => ({ renderDetailExport: jest.fn(
jest.mock('@/lib/detail-generator/provider', () => ({ ...jest.requireActual('@/lib/detail-generator/provider'), analyzeDetailFacts: jest.fn(), composeDetailOutline: jest.fn(), generateDetailImage: jest.fn() }))
const makeDocument = (): DetailDocument => ({ version: 1, name: '产品', product: { identifierType: 'sku', identifier: 'SKU1', variant: '白色', name: '产品', facts: '真实尺寸', assetIds: ['asset-1'], referenceAssetIds: [], documentAssetIds: ['doc-1'] }, brand: { name: '品牌', tone: '真实', primaryColor: '#111111', backgroundColor: '#FFFFFF', textColor: '#111111', fontFamily: 'Noto Sans SC', status: 'draft' }, models: { analysis: 'gemini-2.5-flash', copy: 'gemini-2.5-flash', image: 'nano-banana-2' }, analysis: null, approvedAt: null, screens: [] })
const makeJob = (kind: StoredDetailJob['kind'] = 'analyze'): StoredDetailJob => ({ id: 'job-1', projectId: 'project-1', ownerId: 'owner-1', kind, status: 'running', payload: {}, checkpoint: {}, fingerprint: 'fingerprint', idempotencyKey: 'request-id', projectRevision: 1, leaseToken: 'lease-1', leaseExpiresAt: new Date(Date.now() + 10000), progress: 0, error: null, requestId: null, createdAt: new Date().toISOString(), updatedAt: new Date().toISOString() })
const bindings = Object.fromEntries(['analysis', 'copy', 'image'].map(stage => [stage, { providerId: '11111111-1111-4111-8111-111111111111', modelKey: '22222222-2222-4222-8222-222222222222', modelId: stage, providerName: '测试上游', baseUrl: 'https://model.example.test/v1', revision: 1 }]))
const makeJob = (kind: StoredDetailJob['kind'] = 'analyze'): StoredDetailJob => ({ id: 'job-1', projectId: 'project-1', ownerId: 'owner-1', kind, status: 'running', payload: { modelBindings: bindings }, checkpoint: {}, fingerprint: 'fingerprint', idempotencyKey: 'request-id', projectRevision: 1, leaseToken: 'lease-1', leaseExpiresAt: new Date(Date.now() + 10000), progress: 0, error: null, requestId: null, createdAt: new Date().toISOString(), updatedAt: new Date().toISOString() })
let document: DetailDocument
const originalEnabled = process.env.DETAIL_GENERATOR_ENABLED
afterAll(() => { if (originalEnabled === undefined) delete process.env.DETAIL_GENERATOR_ENABLED; else process.env.DETAIL_GENERATOR_ENABLED = originalEnabled })
@ -50,8 +51,8 @@ test('uploaded product text reaches analysis and copy; saved facts checkpoint re
;(analyzeDetailFacts as jest.Mock).mockResolvedValue('已核对事实')
;(composeDetailOutline as jest.Mock).mockResolvedValue({ sellingPoints: ['真实卖点'], warnings: [], screens: [] })
await processDetailJob(makeJob())
expect(analyzeDetailFacts).toHaveBeenCalledWith(expect.anything(), expect.anything(), expect.stringContaining('文档中的原始产品事实'), 'job-1-product-analysis')
expect(composeDetailOutline).toHaveBeenCalledWith(expect.anything(), expect.stringContaining('文档中的原始产品事实'), 'job-1-outline')
expect(analyzeDetailFacts).toHaveBeenCalledWith(expect.anything(), expect.anything(), expect.stringContaining('文档中的原始产品事实'), 'job-1-product-analysis', 'owner-1', bindings.analysis)
expect(composeDetailOutline).toHaveBeenCalledWith(expect.anything(), expect.stringContaining('文档中的原始产品事实'), 'job-1-outline', 'owner-1', bindings.copy)
expect(saveDetailJobDocument).toHaveBeenCalledWith('job-1', 'lease-1', expect.objectContaining({ approvedAt: null }), expect.objectContaining({ dispatchPending: false }), 100)
jest.clearAllMocks()
const resumed = makeJob(); resumed.checkpoint = { facts: '已核对事实', dispatchPending: false }
@ -82,7 +83,7 @@ test('remaining-screen generation skips completed imagery and stops immediately
;(generateDetailImage as jest.Mock).mockRejectedValue(new DetailProviderError('生成结果未知', true))
await processDetailJob(makeJob('generate'))
expect(generateDetailImage).toHaveBeenCalledTimes(1)
expect(generateDetailImage).toHaveBeenCalledWith(expect.anything(), '场景', expect.anything(), 'job-1-screen-second')
expect(generateDetailImage).toHaveBeenCalledWith(expect.anything(), '场景', expect.anything(), 'job-1-screen-second', 'owner-1', bindings.image)
expect(saveDetailJobAsset).not.toHaveBeenCalled()
expect(finishDetailJob).toHaveBeenCalledWith('job-1', 'lease-1', 'unknown', expect.objectContaining({ stage: 'screen-second' }), '生成结果未知', null)
})

View File

@ -5,6 +5,7 @@ import { attachDetailAsset, createDetailProject, findDetailProject, updateDetail
import type { DetailDocument } from '@/lib/detail-generator/types'
import { deflateRawSync } from 'zlib'
import AdmZip from 'adm-zip'
import { captureAiModelBindings, resolveAiModel } from '@/lib/ai-model-service'
jest.mock('next-auth', () => ({ getServerSession: jest.fn() }))
jest.mock('@/lib/auth', () => ({ authOptions: {} }))
@ -14,6 +15,7 @@ jest.mock('@/lib/detail-generator/brand-profiles', () => ({ listDetailBrandProfi
jest.mock('@/lib/detail-generator/materials', () => ({ readDetailMaterialImage: jest.fn() }))
jest.mock('@/lib/detail-generator/repository', () => ({ assertDetailDocumentAssets: jest.fn(), attachDetailAsset: jest.fn(), createDetailProject: jest.fn(), detailPublicAsset: jest.fn(), detailPublicJob: jest.fn(), enqueueDetailJob: jest.fn(), findDetailProject: jest.fn(), listDetailAssets: jest.fn(), listDetailJobs: jest.fn(), listDetailProjects: jest.fn(), updateDetailProject: jest.fn() }))
jest.mock('@/lib/detail-generator/storage', () => ({ saveDetailFile: jest.fn(), deleteDetailFile: jest.fn() }))
jest.mock('@/lib/ai-model-service', () => ({ captureAiModelBindings: jest.fn().mockResolvedValue({}), getAiModelRuntimeState: jest.fn(), resolveAiModel: jest.fn().mockResolvedValue({}) }))
const fixture = (): DetailDocument => ({ version: 1, name: '真实产品', product: { identifierType: 'sku', identifier: 'SKU-001', variant: '', name: '产品', facts: '用户提供事实', assetIds: [], referenceAssetIds: [], documentAssetIds: [] }, brand: { name: '品牌', tone: '简洁', primaryColor: '#111111', backgroundColor: '#FFFFFF', textColor: '#111111', fontFamily: 'Noto Sans SC', status: 'draft' }, models: { analysis: 'gemini-2.5-flash', copy: 'gemini-2.5-flash', image: 'nano-banana-2' }, analysis: null, approvedAt: null, screens: [] })
let document: DetailDocument
@ -37,6 +39,25 @@ test('client-provided approval cannot make a draft generate-ready', async () =>
expect(createDetailProject).not.toHaveBeenCalled()
})
test('empty model selections can be saved as a product draft without a model connection', async () => {
const draft = fixture(); draft.models = { analysis: '', copy: '', image: '' }
await newDetailProject('owner-1', draft)
expect(createDetailProject).toHaveBeenCalledWith('owner-1', expect.objectContaining({ models: draft.models }))
expect(captureAiModelBindings).not.toHaveBeenCalled()
expect(resolveAiModel).not.toHaveBeenCalled()
})
test('unchanged unavailable models do not prevent ordinary editing and client bindings cannot replace server snapshots', async () => {
const currentBinding = { providerId: '11111111-1111-4111-8111-111111111111', modelKey: '22222222-2222-4222-8222-222222222222', modelId: 'original', providerName: '原上游', baseUrl: 'https://original.example.com/v1', revision: 1 }
document.modelBindings = { analysis: currentBinding, copy: currentBinding, image: currentBinding }
const edited = structuredClone(document); edited.name = '仅编辑项目名称'
const forgedBinding = { ...currentBinding, baseUrl: 'https://other.example.com/v1', revision: 200 }
edited.modelBindings = { analysis: forgedBinding, copy: forgedBinding, image: forgedBinding }
await editDetailProject('owner-1', 'project-1', { revision: 3, document: edited })
expect(updateDetailProject).toHaveBeenCalledWith('project-1', 'owner-1', 3, expect.objectContaining({ modelBindings: document.modelBindings, name: edited.name }))
expect(resolveAiModel).not.toHaveBeenCalled()
})
test('changing product facts invalidates the old product imagery and approval', async () => {
const edited = fixture(); edited.product.facts = '已修正产品事实'
await editDetailProject('owner-1', 'project-1', { revision: 3, document: edited })

30
lib/ai-model-access.ts Normal file
View File

@ -0,0 +1,30 @@
import { getServerSession } from 'next-auth'
import { NextResponse } from 'next/server'
import { authOptions } from '@/lib/auth'
import { isSuperAdmin } from '@/lib/department-permission'
import { prisma } from '@/lib/prisma'
import { DetailGeneratorError } from '@/lib/detail-generator/access'
export class AiModelError extends DetailGeneratorError {
constructor(message: string, status = 400, code = 'invalid_request') { super(message, status, code); this.name = 'AiModelError' }
}
export async function assertAiModelAdmin(userId: string | null | undefined): Promise<string> {
if (!userId) throw new AiModelError('请先登录', 401, 'unauthenticated')
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')
return userId
}
export async function aiModelAdminRoute(action: (userId: string) => Promise<unknown>): Promise<NextResponse> {
try {
const session = await getServerSession(authOptions)
const userId = await assertAiModelAdmin((session?.user as { id?: string } | undefined)?.id)
return NextResponse.json(await action(userId), { headers: { 'Cache-Control': 'private, no-store' } })
} catch (error) {
const known = error instanceof AiModelError || error instanceof DetailGeneratorError
return NextResponse.json({ error: known ? error.message : 'AI 模型操作暂未完成,请稍后重试', code: known ? error.code : 'internal_error' }, { status: known ? error.status : 500, headers: { 'Cache-Control': 'private, no-store' } })
}
}

View File

@ -0,0 +1,117 @@
import type { AiModelCapabilities, AiModelStage } from './ai-model-types'
import { AiModelError } from './ai-model-access'
/** Server-owned evidence. Never accepted from configuration writes or inferred from model names. */
export interface AiModelCapabilityMetadata {
version: 1
inputModalities: string[]
outputModalities: string[]
endpoints: string[]
sourceUrl: string | null
kindHint?: AiModelCapabilities['kind']
inputDeclared?: boolean
outputDeclared?: boolean
endpointsDeclared?: boolean
unavailable?: boolean
}
function object(value: unknown): Record<string, unknown> | null { return value && typeof value === 'object' && !Array.isArray(value) ? value as Record<string, unknown> : null }
function strings(value: unknown): string[] | null {
return Array.isArray(value) && value.length <= 20 && value.every(item => typeof item === 'string' && item.length <= 100) ? Array.from(new Set(value as string[])).sort() : null
}
/** Explicit metadata contract used by standard model directories. Missing fields stay unknown. */
export function parseAiModelCapabilityMetadata(value: unknown, sourceUrl: string | null = null): AiModelCapabilityMetadata | null {
const raw = object(value)
if (!raw) return null
const inputModalities = strings(raw.input_modalities)
const outputModalities = strings(raw.output_modalities)
const endpoints = strings(raw.endpoints)
if (!inputModalities || !outputModalities || !endpoints) {
if (['input_modalities', 'output_modalities', 'endpoints'].some(field => Object.prototype.hasOwnProperty.call(raw, field))) throw new AiModelError('上游模型能力字段不完整,未保存本次同步', 502, 'catalog_invalid')
if (raw.available === false) return { version: 1, inputModalities: [], outputModalities: [], endpoints: [], sourceUrl, unavailable: true }
return null
}
return { version: 1, inputModalities, outputModalities, endpoints, sourceUrl, inputDeclared: true, outputDeclared: true, endpointsDeclared: true, unavailable: raw.available === false }
}
export const AI_MODEL_PUBLIC_CATALOG_URL = 'https://api.vip98k.com/api/v2/catalog/models'
/** Known provider's public schema, joined only to IDs already authorized by its private /models response. */
export function parseAiModelPublicCapabilityCatalog(value: unknown): Map<string, AiModelCapabilityMetadata> {
const raw = object(value), data = object(raw?.data)
if (raw?.code !== 0 || data?.schema_version !== '2.2' || !Array.isArray(data.sections) || data.sections.length > 20) throw new AiModelError('上游能力目录格式不兼容', 502, 'capability_catalog_invalid')
const result = new Map<string, AiModelCapabilityMetadata>()
for (const sectionValue of data.sections) {
const section = object(sectionValue)
if (!section || !Array.isArray(section.models) || section.models.length > 5000) throw new AiModelError('上游能力目录格式不兼容', 502, 'capability_catalog_invalid')
for (const modelValue of section.models) {
const model = object(modelValue)
if (!model || typeof model.id !== 'string' || !model.id || model.id.length > 200 || result.has(model.id)) throw new AiModelError('上游能力目录模型编号无效或重复', 502, 'capability_catalog_invalid')
const offers = Array.isArray(model.service_offers) ? model.service_offers.map(object).filter((offer): offer is Record<string, unknown> => Boolean(offer)) : []
// Do not combine non-default service offers or route-specific protocols into the default request path.
const selected = offers.find(offer => offer.is_default === true && object(offer.service_scope)?.id === model.default_service_scope_id)
const invocation = object(offers.length ? selected?.invocation : model.invocation)
const rawInputModes = selected?.input_modes ?? model.input_modes, rawOutputModes = selected?.output_modes ?? model.output_modes
const inputModes = strings(rawInputModes), outputModes = strings(rawOutputModes)
if (rawInputModes !== undefined && inputModes === null || rawOutputModes !== undefined && outputModes === null) throw new AiModelError('上游能力目录模态字段无效', 502, 'capability_catalog_invalid')
const textModel = model.type === 'model' && ['text', 'multimodal'].includes(String(model.modality)) && section.id === 'chat'
const declaredCapabilities = strings(model.capabilities) || []
const imageModel = (model.type === 'image' || model.modality === 'image') && declaredCapabilities.some(capability => ['text_to_image', 'openai.images.generations', 'openai.images.edits'].includes(capability))
const normalizeModes = (modes: string[]) => Array.from(new Set(modes.map(mode => ['image_url', 'image_base64'].includes(mode) ? 'image' : mode === 'prompt' ? 'text' : mode))).sort()
const inputModalities = normalizeModes(inputModes || (textModel ? ['text'] : []))
const outputModalities = normalizeModes(outputModes || (textModel ? ['text'] : imageModel ? ['image'] : []))
const headers = object(invocation?.headers)
const endpoints: string[] = []
if (model.selection_required !== true && invocation?.method === 'POST' && headers && Object.keys(headers).length === 0) {
if (invocation.path === '/v1/chat/completions' && invocation.content_type === 'application/json') endpoints.push('/chat/completions')
if (invocation.path === '/v1/images/edits' && invocation.content_type === 'multipart/form-data') endpoints.push('/images/edits')
}
result.set(model.id, { version: 1, inputModalities, outputModalities, endpoints, sourceUrl: AI_MODEL_PUBLIC_CATALOG_URL,
inputDeclared: inputModes !== null, outputDeclared: outputModes !== null, endpointsDeclared: false,
unavailable: model.available === false || typeof model.status === 'string' && model.status !== 'available' || selected?.available === false || typeof selected?.status === 'string' && selected.status !== 'available',
kindHint: imageModel ? 'image' : textModel ? model.modality === 'multimodal' ? 'vision' : 'text' : 'other' })
}
}
if (result.size > 5000) throw new AiModelError('上游能力目录过大', 502, 'capability_catalog_invalid')
return result
}
function storedMetadata(value: unknown): AiModelCapabilityMetadata | null {
const raw = object(value)
if (!raw || raw.version !== 1) return null
const inputModalities = strings(raw.inputModalities), outputModalities = strings(raw.outputModalities), endpoints = strings(raw.endpoints)
if (!inputModalities || !outputModalities || !endpoints || raw.sourceUrl !== null && typeof raw.sourceUrl !== 'string') return null
return { version: 1, inputModalities, outputModalities, endpoints, sourceUrl: raw.sourceUrl as string | null,
inputDeclared: raw.inputDeclared === true, outputDeclared: raw.outputDeclared === true, endpointsDeclared: raw.endpointsDeclared === true, unavailable: raw.unavailable === true,
...(['text', 'vision', 'image', 'other', 'unknown'].includes(String(raw.kindHint)) ? { kindHint: raw.kindHint as AiModelCapabilities['kind'] } : {}) }
}
/** Stable across PostgreSQL JSONB property ordering, so unchanged sync does not invalidate active jobs. */
export function aiModelCapabilityFingerprint(value: unknown): string { return JSON.stringify(storedMetadata(value)) }
export function mergeAiModelCapabilityMetadata(primary: AiModelCapabilityMetadata | null, additional: AiModelCapabilityMetadata | null): AiModelCapabilityMetadata | null {
if (!primary || !additional) return primary || additional
if (primary.unavailable && !primary.inputDeclared && !primary.outputDeclared && !primary.endpointsDeclared) return { ...additional, unavailable: true }
return { ...primary,
inputModalities: additional.inputDeclared ? primary.inputModalities.filter(value => additional.inputModalities.includes(value)) : primary.inputModalities,
outputModalities: additional.outputDeclared ? primary.outputModalities.filter(value => additional.outputModalities.includes(value)) : primary.outputModalities,
unavailable: primary.unavailable === true || additional.unavailable === true }
}
export function getAiModelCapabilities(baseUrl: string, modelId: string, metadata?: unknown): AiModelCapabilities {
const evidence = storedMetadata(metadata)
// Missing public fields do not erase real acceptance evidence. Explicit capability withdrawal does.
const verified: AiModelStage[] = baseUrl === 'https://api.vip98k.com/v1' ? modelId === 'gemini-2.5-flash' ? ['analysis', 'copy'] : modelId === 'nano-banana-2' ? ['image'] : [] : []
const verifiedAllowed = verified.filter(stage => !evidence?.unavailable && evidence?.kindHint !== 'other' && (!evidence?.inputDeclared || evidence.inputModalities.includes('text') && (stage === 'copy' || evidence.inputModalities.includes('image'))) && (!evidence?.outputDeclared || evidence.outputModalities.includes(stage === 'image' ? 'image' : 'text')) && (!evidence?.endpointsDeclared || evidence.endpoints.some(endpoint => endpoint === (stage === 'image' ? '/images/edits' : '/chat/completions') || endpoint === (stage === 'image' ? '/v1/images/edits' : '/v1/chat/completions'))))
if (verifiedAllowed.length) return { kind: verifiedAllowed.includes('image') ? 'image' : verifiedAllowed.includes('analysis') ? 'vision' : 'text', allowedStages: verifiedAllowed, source: 'verified', reason: '依据本平台已完成的实际接口验证' }
if (!evidence) return { kind: 'unknown', allowedStages: [], source: 'unknown', reason: '上游未提供可核验能力,暂不能开放' }
const textOutput = evidence.outputModalities.includes('text'), imageOutput = evidence.outputModalities.includes('image'), imageInput = evidence.inputModalities.includes('image'), textInput = evidence.inputModalities.includes('text')
const chat = evidence.endpoints.includes('/chat/completions') || evidence.endpoints.includes('/v1/chat/completions')
const imageEdit = evidence.endpoints.includes('/images/edits') || evidence.endpoints.includes('/v1/images/edits')
const allowedStages: AiModelStage[] = []
if (!evidence.unavailable && evidence.kindHint !== 'other' && textInput && textOutput && chat) { if (imageInput) allowedStages.push('analysis'); allowedStages.push('copy') }
if (!evidence.unavailable && evidence.kindHint !== 'other' && textInput && imageOutput && imageInput && imageEdit) allowedStages.push('image')
const kind = evidence.kindHint || (imageOutput ? 'image' : textOutput && textInput ? imageInput ? 'vision' : 'text' : evidence.outputModalities.length ? 'other' : 'unknown')
return { kind, allowedStages, source: 'upstream', reason: evidence.unavailable ? '上游已停用该模型' : allowedStages.length ? '依据上游公布的输入、输出与接口能力' : kind === 'image' ? '图片模型尚不支持当前产品参考图接口' : kind === 'other' ? '当前详情页不支持该模型类型' : '上游能力尚不匹配当前详情页接口' }
}

26
lib/ai-model-crypto.ts Normal file
View File

@ -0,0 +1,26 @@
import { createCipheriv, createDecipheriv, createHash, randomBytes } from 'node:crypto'
import { AiModelError } from './ai-model-access'
export interface AiModelEncryptedKey { secretCiphertext: string; secretNonce: string; secretAuthTag: string }
export function isAiModelEncryptionConfigured(): boolean {
const secret = process.env.API_KEY_ENCRYPTION_KEY?.trim()
return Boolean(secret && Buffer.byteLength(secret, 'utf8') >= 32)
}
function encryptionKey(): Buffer {
if (!isAiModelEncryptionConfigured()) throw new AiModelError('请先在服务端配置 API_KEY_ENCRYPTION_KEY(至少 32 字节)', 503, 'encryption_not_configured')
// Domain separation prevents this credential store sharing ciphertext keys with platform API keys.
return createHash('sha256').update('gongxiangp2:ai-models:v1\0').update(process.env.API_KEY_ENCRYPTION_KEY!.trim()).digest()
}
export function encryptAiModelKey(value: string): AiModelEncryptedKey {
const nonce = randomBytes(12)
const cipher = createCipheriv('aes-256-gcm', encryptionKey(), nonce)
return { secretCiphertext: Buffer.concat([cipher.update(value, 'utf8'), cipher.final()]).toString('base64'), secretNonce: nonce.toString('base64'), secretAuthTag: cipher.getAuthTag().toString('base64') }
}
export function decryptAiModelKey(value: AiModelEncryptedKey): string {
const key = encryptionKey()
try {
const decipher = createDecipheriv('aes-256-gcm', key, Buffer.from(value.secretNonce, 'base64'))
decipher.setAuthTag(Buffer.from(value.secretAuthTag, 'base64'))
return Buffer.concat([decipher.update(Buffer.from(value.secretCiphertext, 'base64')), decipher.final()]).toString('utf8')
} catch { throw new AiModelError('模型密钥无法解密,请检查服务端加密配置或替换密钥', 503, 'credential_unavailable') }
}

83
lib/ai-model-http.ts Normal file
View File

@ -0,0 +1,83 @@
import { lookup } from 'node:dns/promises'
import { request as httpsRequest } from 'node:https'
import { isIP } from 'node:net'
import { Readable } from 'node:stream'
import { isPublicRoutableIp } from './proxy-media-security'
import { AiModelError } from './ai-model-access'
export function normalizeAiModelBaseUrl(value: string): string {
let url: URL
try { url = new URL(value.trim()) } catch { throw new AiModelError('请输入有效的模型服务地址') }
if (url.protocol !== 'https:' || (url.port && url.port !== '443') || url.username || url.password || url.search || url.hash || url.hostname.endsWith('.') || /%|\\/.test(value)) throw new AiModelError('模型服务须使用无账号、查询参数的 HTTPS 标准端口地址')
const hostname = url.hostname.replace(/^\[|\]$/g, '').toLowerCase()
if (hostname === 'localhost' || hostname.endsWith('.localhost') || hostname.endsWith('.local') || hostname.endsWith('.internal') || !hostname.includes('.') && !isIP(hostname) || isIP(hostname) && !isPublicRoutableIp(hostname)) throw new AiModelError('模型服务不能使用本机或内网地址', 400, 'unsafe_provider_url')
if (!/^\/(?:[a-zA-Z0-9._~-]+\/?)*$/.test(url.pathname) || url.pathname.split('/').some(part => part === '.' || part === '..')) throw new AiModelError('模型服务路径无效')
return url.href.replace(/\/+$/, '').replace(/\/v1$/, '') + '/v1'
}
export type AiModelResolver = (hostname: string) => Promise<Array<{ address: string; family: number }>>
const resolveAddresses: AiModelResolver = async hostname => isIP(hostname) ? [{ address: hostname, family: isIP(hostname) }] : lookup(hostname, { all: true, verbatim: true })
export async function resolveAiModelPublicAddresses(baseUrl: string, resolver: AiModelResolver = resolveAddresses): Promise<Array<{ address: string; family: number }>> {
const host = new URL(normalizeAiModelBaseUrl(baseUrl)).hostname.replace(/^\[|\]$/g, '')
let addresses: Array<{ address: string; family: number }>
let timeout: ReturnType<typeof setTimeout> | undefined
try {
addresses = await Promise.race([resolver(host), new Promise<never>((_resolve, reject) => { timeout = setTimeout(() => reject(new Error('DNS timeout')), 10000) })])
} catch { throw new AiModelError('模型服务域名无法解析', 502, 'provider_dns_failed') } finally { if (timeout) clearTimeout(timeout) }
if (!addresses.length || addresses.some(value => !isPublicRoutableIp(value.address) || isIP(value.address) !== value.family)) throw new AiModelError('模型服务解析到了受保护地址', 400, 'unsafe_provider_url')
return addresses
}
/** Fixed DNS lookup, original hostname TLS verification, no redirects and bounded streamed response. */
export async function requestAiModel(baseUrl: string, apiKey: string, endpoint: '/models' | '/chat/completions' | '/images/edits', body?: string | FormData, idempotencyKey?: string, beforeRequest?: () => Promise<void>): Promise<Response> {
const normalized = normalizeAiModelBaseUrl(baseUrl)
return requestAiModelUrl(normalized, new URL(normalized + endpoint), { Authorization: `Bearer ${apiKey}`, ...(idempotencyKey ? { 'Idempotency-Key': idempotencyKey } : {}) }, endpoint === '/models', body, beforeRequest)
}
/** This provider publishes a separate typed directory. Its public request never carries the private API key. */
export async function requestAiModelCapabilityCatalog(baseUrl: string, beforeRequest?: () => Promise<void>): Promise<Response> {
const normalized = normalizeAiModelBaseUrl(baseUrl)
if (normalized !== 'https://api.vip98k.com/v1') throw new AiModelError('该上游没有已适配的独立能力目录', 400, 'capability_catalog_unavailable')
return requestAiModelUrl(normalized, new URL('https://api.vip98k.com/api/v2/catalog/models'), {}, true, undefined, beforeRequest)
}
async function requestAiModelUrl(normalized: string, target: URL, headers: Record<string, string>, catalog: boolean, body?: string | FormData, beforeRequest?: () => Promise<void>): Promise<Response> {
const addresses = await resolveAiModelPublicAddresses(normalized)
let requestBody: Readable | null = null
if (typeof body === 'string') { headers['Content-Type'] = 'application/json'; headers['Content-Length'] = String(Buffer.byteLength(body)); requestBody = Readable.from([body]) }
else if (body) {
const formRequest = new Request(target, { method: 'POST', body })
headers['Content-Type'] = formRequest.headers.get('content-type')!
requestBody = Readable.fromWeb(formRequest.body as any)
}
if (beforeRequest) await beforeRequest()
return new Promise<Response>((resolve, reject) => {
let complete = false
const finish = (error: unknown, response?: Response) => { if (complete) return; complete = true; clearTimeout(timer); error ? reject(error) : resolve(response!) }
const req = httpsRequest(target, {
method: body ? 'POST' : 'GET', headers, agent: false,
lookup: ((_hostname: string, options: any, callback: any) => {
const selected = addresses.filter(address => !options?.family || address.family === options.family)
if (!selected.length) return callback(new Error('Validated address unavailable'))
if (options?.all) callback(null, selected)
else callback(null, selected[0].address, selected[0].family)
}) as any,
}, res => {
if ((res.statusCode || 0) >= 300 && (res.statusCode || 0) < 400) { res.destroy(); finish(new AiModelError('模型服务重定向已拒绝,请配置最终服务地址', 502, 'provider_redirect_rejected')); return }
const limit = catalog ? (target.pathname === '/api/v2/catalog/models' ? 8 : 2) * 1024 * 1024 : 32 * 1024 * 1024
const chunks: Buffer[] = []; let size = 0
if (Number(res.headers['content-length'] || 0) > limit) { res.destroy(); finish(new Error('Provider response exceeds limit')); return }
res.on('data', (chunk: Buffer) => { size += chunk.length; if (size > limit) { res.destroy(); finish(new Error('Provider response exceeds limit')) } else chunks.push(chunk) })
res.on('error', () => finish(new Error('Provider response interrupted')))
res.on('end', () => {
const responseHeaders = new Headers()
for (const name of ['content-type', 'x-request-id']) { const value = res.headers[name]; if (typeof value === 'string') responseHeaders.set(name, value) }
try { finish(null, new Response([204, 205, 304].includes(res.statusCode || 0) ? null : new Uint8Array(Buffer.concat(chunks)), { status: res.statusCode || 502, headers: responseHeaders })) } catch { finish(new Error('Provider response invalid')) }
})
})
const timer = setTimeout(() => { req.destroy(); finish(new Error('Provider request timed out')) }, catalog ? 20_000 : 180_000)
req.on('error', () => finish(new Error('Provider request failed')))
if (requestBody) { requestBody.on('error', () => { req.destroy(); finish(new Error('Provider request body failed')) }); requestBody.pipe(req) } else req.end()
})
}

View File

@ -0,0 +1,55 @@
import type { Prisma } from '@prisma/client'
import { prisma } from './prisma'
import { AiModelError } from './ai-model-access'
import type { AiModelEncryptedKey } from './ai-model-crypto'
import type { AiModelCatalogEntry, AiModelDefaults } from './ai-model-types'
export type AiModelDatabase = Prisma.TransactionClient | typeof prisma
export interface AiModelProviderRow {
id: string; name: string; baseUrl: string; enabled: boolean; credentialSource: 'encrypted' | 'environment';
secretCiphertext: string | null; secretNonce: string | null; secretAuthTag: string | null;
legacyBinding: boolean; revision: number; syncedAt: Date | null
}
export interface AiModelRow extends Omit<AiModelCatalogEntry, 'capabilities'> { providerId: string; capabilityMetadata?: unknown }
export interface AiModelConfigurationRow { revision: number; defaults: AiModelDefaults }
const providerColumns = `id, name, base_url AS "baseUrl", enabled, credential_source AS "credentialSource", secret_ciphertext AS "secretCiphertext", secret_nonce AS "secretNonce", secret_auth_tag AS "secretAuthTag", legacy_binding AS "legacyBinding", revision, synced_at AS "syncedAt"`
export async function listAiModelProviderRows(db: AiModelDatabase = prisma): Promise<AiModelProviderRow[]> {
return db.$queryRawUnsafe<AiModelProviderRow[]>(`SELECT ${providerColumns} FROM ai_model_providers ORDER BY created_at, id`)
}
export async function getAiModelProviderRow(id: string, db: AiModelDatabase = prisma): Promise<AiModelProviderRow> {
const rows = await db.$queryRawUnsafe<AiModelProviderRow[]>(`SELECT ${providerColumns} FROM ai_model_providers WHERE id = $1`, id)
if (!rows[0]) throw new AiModelError('模型服务不存在', 404, 'provider_not_found')
return rows[0]
}
export async function listAiModelRows(db: AiModelDatabase = prisma): Promise<AiModelRow[]> {
return db.$queryRawUnsafe<AiModelRow[]>(`SELECT id AS key, provider_id AS "providerId", model_id AS id, enabled, stages, present, compatibility, capability_metadata AS "capabilityMetadata" FROM ai_model_catalog ORDER BY model_id, id`)
}
export async function getAiModelConfiguration(db: AiModelDatabase = prisma): Promise<AiModelConfigurationRow> {
const rows = await db.$queryRawUnsafe<Array<{ revision: number; analysis: string | null; copy: string | null; image: string | null }>>(`SELECT revision, default_analysis AS analysis, default_copy AS copy, default_image AS image FROM ai_model_configuration WHERE id = 'detail-generator'`)
const row = rows[0]
return row ? { revision: row.revision, defaults: { analysis: row.analysis, copy: row.copy, image: row.image } } : { revision: 0, defaults: { analysis: null, copy: null, image: null } }
}
export async function aiModelTransaction<T>(action: (db: Prisma.TransactionClient) => Promise<T>): Promise<T> {
return prisma.$transaction(async db => {
await db.$queryRawUnsafe(`SELECT 1 FROM (SELECT pg_advisory_xact_lock(hashtext('ai-model-registry'))) AS registry_lock`)
return action(db)
}, { timeout: 20000 })
}
export function assertAiModelRevision(actual: number, expected: number): void {
if (actual !== expected) throw new AiModelError('配置已被修改,请刷新后再保存', 409, 'revision_conflict')
}
export async function insertAiModelProvider(db: AiModelDatabase, value: AiModelProviderRow): Promise<void> {
await db.$executeRawUnsafe(`INSERT INTO ai_model_providers (id,name,base_url,enabled,credential_source,secret_ciphertext,secret_nonce,secret_auth_tag,legacy_binding,revision) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10)`, value.id, value.name, value.baseUrl, value.enabled, value.credentialSource, value.secretCiphertext, value.secretNonce, value.secretAuthTag, value.legacyBinding, value.revision)
}
export async function updateAiModelProvider(db: AiModelDatabase, value: AiModelProviderRow): Promise<void> {
await db.$executeRawUnsafe(`UPDATE ai_model_providers SET name=$2,base_url=$3,enabled=$4,credential_source=$5,secret_ciphertext=$6,secret_nonce=$7,secret_auth_tag=$8,revision=revision+1,updated_at=NOW() WHERE id=$1`, value.id, value.name, value.baseUrl, value.enabled, value.credentialSource, value.secretCiphertext, value.secretNonce, value.secretAuthTag)
}
export async function saveAiModelDefaults(db: AiModelDatabase, defaults: AiModelDefaults): Promise<void> {
await db.$executeRawUnsafe(`INSERT INTO ai_model_configuration (id,default_analysis,default_copy,default_image,revision) VALUES ('detail-generator',$1,$2,$3,1) ON CONFLICT (id) DO UPDATE SET default_analysis=$1,default_copy=$2,default_image=$3,revision=ai_model_configuration.revision+1,updated_at=NOW()`, defaults.analysis, defaults.copy, defaults.image)
}
export async function aiModelAudit(db: AiModelDatabase, userId: string, action: string, providerId: string | null, details: Record<string, unknown>): Promise<void> {
// Callers supply metadata only. Keys and provider response bodies never enter the audit record.
await db.$executeRawUnsafe(`INSERT INTO ai_model_audit (id,actor_id,action,provider_id,details) VALUES (gen_random_uuid()::text,$1,$2,$3,$4::jsonb)`, userId, action, providerId, JSON.stringify(details))
}
export function encryptedAiModelFields(value: AiModelEncryptedKey | null) { return value || { secretCiphertext: null, secretNonce: null, secretAuthTag: null } }

225
lib/ai-model-service.ts Normal file
View File

@ -0,0 +1,225 @@
import { randomUUID } from 'node:crypto'
import { z } from 'zod'
import { AiModelError, assertAiModelAdmin } from './ai-model-access'
import { decryptAiModelKey, encryptAiModelKey, isAiModelEncryptionConfigured } from './ai-model-crypto'
import { normalizeAiModelBaseUrl, requestAiModel, requestAiModelCapabilityCatalog } from './ai-model-http'
import { aiModelCapabilityFingerprint, getAiModelCapabilities, mergeAiModelCapabilityMetadata, parseAiModelCapabilityMetadata, parseAiModelPublicCapabilityCatalog, type AiModelCapabilityMetadata } from './ai-model-capabilities'
import { aiModelAudit, aiModelTransaction, assertAiModelRevision, encryptedAiModelFields, getAiModelConfiguration, getAiModelProviderRow, insertAiModelProvider, listAiModelProviderRows, listAiModelRows, saveAiModelDefaults, updateAiModelProvider, type AiModelDatabase, type AiModelProviderRow, type AiModelRow } from './ai-model-repository'
import { AI_MODEL_STAGES, type AiModelAdminState, type AiModelBinding, type AiModelBindings, type AiModelConfigurationInput, type AiModelDefaults, type AiModelRuntimeState, type AiModelStage } from './ai-model-types'
import { DETAIL_MODEL_DEFAULTS } from './detail-generator/types'
const id = z.string().uuid()
const revision = z.number().int().min(0)
const key = z.string().trim().min(1).max(4096).regex(/^[\x21-\x7e]+$/)
const createSchema = z.object({ name: z.string().trim().min(1).max(100), baseUrl: z.string().trim().min(1).max(500), apiKey: key, enabled: z.boolean().optional() }).strict()
const updateSchema = z.object({ revision: revision.min(1), name: z.string().trim().min(1).max(100).optional(), baseUrl: z.string().trim().min(1).max(500).optional(), apiKey: key.optional(), enabled: z.boolean().optional() }).strict()
const defaultsSchema = z.object({ analysis: id.nullable(), copy: id.nullable(), image: id.nullable() }).strict()
const configurationSchema = z.object({ revision, defaults: defaultsSchema, providers: z.array(z.object({ id, revision: revision.min(1), models: z.array(z.object({ key: id, enabled: z.boolean(), stages: z.array(z.enum(AI_MODEL_STAGES)).max(3), compatibility: z.enum(['confirmed', 'unconfirmed']) }).strict()).max(5000) }).strict()).max(100) }).strict()
function parse<T>(schema: z.ZodType<T>, value: unknown): T { const parsed = schema.safeParse(value); if (!parsed.success) throw new AiModelError('提交的模型配置格式无效'); return parsed.data }
export function assertAiModelId(value: string): string { if (!id.safeParse(value).success) throw new AiModelError('模型服务编号无效', 404); return value }
function hasCredential(provider: AiModelProviderRow): boolean {
if (provider.credentialSource === 'environment') {
try { return Boolean(process.env.DETAIL_GENERATOR_API_KEY?.trim()) && provider.baseUrl === normalizeAiModelBaseUrl(process.env.DETAIL_GENERATOR_BASE_URL?.trim() || 'https://api.vip98k.com/v1') } catch { return false }
}
return Boolean(provider.secretCiphertext && provider.secretNonce && provider.secretAuthTag && isAiModelEncryptionConfigured())
}
function providerKey(provider: AiModelProviderRow): string {
if (provider.credentialSource === 'environment') { const value = process.env.DETAIL_GENERATOR_API_KEY?.trim(); if (value && hasCredential(provider)) return value }
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')
}
function modelAvailable(model: AiModelRow, provider: AiModelProviderRow, stage: AiModelStage): boolean { return provider.enabled && hasCredential(provider) && model.present && model.enabled && model.compatibility === 'confirmed' && model.stages.includes(stage) && getAiModelCapabilities(provider.baseUrl, model.id, model.capabilityMetadata).allowedStages.includes(stage) }
export async function getAiModelAdminState(): Promise<AiModelAdminState> {
const [providers, models, configuration] = await Promise.all([listAiModelProviderRows(), listAiModelRows(), getAiModelConfiguration()])
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 capabilities = getAiModelCapabilities(provider.baseUrl, model.id, capabilityMetadata)
const stages = model.stages.filter(stage => capabilities.allowedStages.includes(stage))
return { ...model, stages, enabled: model.enabled && stages.length > 0, capabilities }
}) })), ...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> {
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)
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 })
})
return getAiModelAdminState()
}
export async function editAiModelProvider(userId: string, providerId: string, body: unknown): Promise<AiModelAdminState> {
assertAiModelId(providerId); const input = parse(updateSchema, body)
const encrypted = input.apiKey ? encryptAiModelKey(input.apiKey) : null
await aiModelTransaction(async db => {
await assertAiModelAdmin(userId)
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('迁入的原连接须保留原服务地址;切换上游请新增服务并在项目中明确选择')
if (baseUrl !== current.baseUrl && !encrypted) throw new AiModelError('更改服务地址须同时填写该服务的密钥,避免把旧密钥发送到新地址')
const next = { ...current, ...(input.name !== undefined ? { name: input.name } : {}), ...(input.enabled !== undefined ? { enabled: input.enabled } : {}), baseUrl, ...(encrypted ? { ...encrypted, credentialSource: 'encrypted' as const } : {}) }
await updateAiModelProvider(db, next)
if (baseUrl !== current.baseUrl) await db.$executeRawUnsafe(`UPDATE ai_model_catalog SET present=FALSE,enabled=FALSE,stages=ARRAY[]::text[],compatibility='unconfirmed',capability_metadata=NULL WHERE provider_id=$1`, providerId)
await aiModelAudit(db, userId, 'provider.update', providerId, { name: next.name, baseUrl, enabled: next.enabled, credentialChanged: Boolean(encrypted), addressChanged: baseUrl !== current.baseUrl })
})
return getAiModelAdminState()
}
function environmentModels(): Record<AiModelStage, string[]> {
const output: Record<AiModelStage, string[]> = { analysis: [DETAIL_MODEL_DEFAULTS.analysis], copy: [DETAIL_MODEL_DEFAULTS.copy], image: [DETAIL_MODEL_DEFAULTS.image] }
const setting = process.env.DETAIL_GENERATOR_VALIDATED_MODELS_JSON
if (!setting) return output
let parsed: unknown; try { parsed = JSON.parse(setting) } catch { throw new AiModelError('原服务端模型配置格式无效', 503) }
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) throw new AiModelError('原服务端模型配置格式无效', 503)
for (const stage of AI_MODEL_STAGES) {
const values = (parsed as Record<string, unknown>)[stage]
if (values !== undefined && (!Array.isArray(values) || values.length > 5000 || values.some(value => typeof value !== 'string' || !/^[\w.:-]{1,120}$/.test(value)))) throw new AiModelError('原服务端模型配置格式无效', 503)
if (Array.isArray(values)) output[stage] = Array.from(new Set([...output[stage], ...values]))
}
return output
}
export async function importAiModelEnvironment(userId: string): Promise<AiModelAdminState> {
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 }
await aiModelTransaction(async db => {
await assertAiModelAdmin(userId)
if ((await listAiModelProviderRows(db)).some(value => value.legacyBinding)) throw new AiModelError('现有连接已迁入,请在列表中管理', 409, 'already_imported')
await insertAiModelProvider(db, provider)
const imported = new Map<string, string>()
for (const modelId of Array.from(new Set(AI_MODEL_STAGES.flatMap(stage => stages[stage])))) {
const modelKey = randomUUID(); imported.set(modelId, modelKey)
const allowed = getAiModelCapabilities(provider.baseUrl, modelId).allowedStages
const selected = AI_MODEL_STAGES.filter(stage => stages[stage].includes(modelId) && allowed.includes(stage))
await db.$executeRawUnsafe(`INSERT INTO ai_model_catalog (id,provider_id,model_id,enabled,stages,present,compatibility) VALUES ($1,$2,$3,$4,$5::text[],TRUE,$6)`, modelKey, provider.id, modelId, selected.length > 0, selected, selected.length ? 'confirmed' : 'unconfirmed')
}
const current = await getAiModelConfiguration(db)
const defaultFor = (stage: AiModelStage) => current.defaults[stage] || (getAiModelCapabilities(provider.baseUrl, DETAIL_MODEL_DEFAULTS[stage]).allowedStages.includes(stage) ? imported.get(DETAIL_MODEL_DEFAULTS[stage]) || null : null)
await saveAiModelDefaults(db, { analysis: defaultFor('analysis'), copy: defaultFor('copy'), image: defaultFor('image') })
await aiModelAudit(db, userId, 'provider.import-environment', provider.id, { modelCount: imported.size, credentialSource: 'environment', baseUrl: provider.baseUrl })
})
return getAiModelAdminState()
}
export function parseAiModelCatalog(value: unknown): string[] {
if (!value || typeof value !== 'object' || !Array.isArray((value as { data?: unknown }).data)) throw new AiModelError('上游模型目录格式不兼容', 502, 'catalog_invalid')
const data = (value as { data: unknown[] }).data
if (data.length > 5000) throw new AiModelError('上游模型目录过大', 502, 'catalog_invalid')
const models = data.map(entry => entry && typeof entry === 'object' ? (entry as { id?: unknown }).id : undefined)
if (models.some(model => typeof model !== 'string' || !model.length || model.length > 200 || model.trim() !== model || /[\x00-\x1f\x7f]/.test(model))) throw new AiModelError('上游模型编号格式无效', 502, 'catalog_invalid')
return Array.from(new Set(models as string[])).sort()
}
export async function syncAiModelProvider(userId: string, providerId: string, body: unknown): Promise<AiModelAdminState> {
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)
let response: Response
try { response = await requestAiModel(source.baseUrl, apiKey, '/models', undefined, undefined, async () => { const current = await getAiModelProviderRow(providerId); assertAiModelRevision(current.revision, source.revision); providerKey(current); await assertAiModelAdmin(userId) }) } catch (error) { if (error instanceof AiModelError) throw error; throw new AiModelError('上游模型目录连接失败,请检查服务地址和密钥', 502, 'provider_connection_failed') }
if (!response.ok) throw new AiModelError(`上游拒绝了目录请求(${response.status})`, 502, 'provider_rejected')
let value: unknown; try { value = await response.json() } catch { throw new AiModelError('上游模型目录不是有效 JSON', 502, 'catalog_invalid') }
const models = parseAiModelCatalog(value)
if (models.some(model => model.includes(apiKey))) throw new AiModelError('上游模型目录包含敏感内容,已拒绝保存', 502, 'catalog_invalid')
const metadata = new Map<string, AiModelCapabilityMetadata | null>()
for (const entry of (value as { data: Array<Record<string, unknown>> }).data) {
const normalized = parseAiModelCapabilityMetadata(entry, source.baseUrl + '/models')
if (metadata.has(entry.id as string) && JSON.stringify(metadata.get(entry.id as string)) !== JSON.stringify(normalized)) throw new AiModelError('上游同名模型能力冲突,未保存本次同步', 502, 'catalog_invalid')
metadata.set(entry.id as string, normalized)
}
if (source.baseUrl === 'https://api.vip98k.com/v1') {
let publicResponse: Response
try { publicResponse = await requestAiModelCapabilityCatalog(source.baseUrl, async () => { const current = await getAiModelProviderRow(providerId); assertAiModelRevision(current.revision, source.revision); await assertAiModelAdmin(userId) }) }
catch (error) { if (error instanceof AiModelError) throw error; throw new AiModelError('上游能力目录连接失败,未保存本次同步', 502, 'capability_catalog_failed') }
if (!publicResponse.ok) throw new AiModelError('上游能力目录请求失败,未保存本次同步', 502, 'capability_catalog_failed')
let publicValue: unknown
try { publicValue = await publicResponse.json() } catch { throw new AiModelError('上游能力目录不是有效 JSON', 502, 'capability_catalog_invalid') }
const capabilities = parseAiModelPublicCapabilityCatalog(publicValue)
for (const modelId of models) metadata.set(modelId, mergeAiModelCapabilityMetadata(metadata.get(modelId) || null, capabilities.get(modelId) || null))
}
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)
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))))
await db.$executeRawUnsafe(`UPDATE ai_model_catalog SET present=FALSE WHERE provider_id=$1`, providerId)
for (const modelId of models) await db.$executeRawUnsafe(`INSERT INTO ai_model_catalog (id,provider_id,model_id,capability_metadata) VALUES ($1,$2,$3,$4::jsonb) ON CONFLICT (provider_id,model_id) DO UPDATE SET present=TRUE,capability_metadata=EXCLUDED.capability_metadata`, randomUUID(), providerId, modelId, JSON.stringify(metadata.get(modelId) ?? null))
await db.$executeRawUnsafe(`UPDATE ai_model_providers SET synced_at=NOW(),revision=revision+$2,updated_at=NOW() WHERE id=$1`, providerId, catalogChanged ? 1 : 0)
await aiModelAudit(db, userId, 'provider.sync', providerId, { modelCount: models.length, addedCount: models.filter(model => !prior.some(entry => entry.id === model)).length, catalogChanged })
})
return getAiModelAdminState()
}
async function validateDefaults(db: AiModelDatabase, defaults: AiModelDefaults): Promise<void> {
const [providers, models] = await Promise.all([listAiModelProviderRows(db), listAiModelRows(db)])
for (const stage of AI_MODEL_STAGES) {
if (!defaults[stage]) continue
const model = models.find(value => value.key === defaults[stage]); const provider = model && providers.find(value => value.id === model.providerId)
if (!model || !provider || !modelAvailable(model, provider, stage)) throw new AiModelError('默认模型须来自已启用服务,且已确认对应阶段接口兼容', 400, 'default_unavailable')
}
}
export async function configureAiModels(userId: string, body: unknown): Promise<AiModelAdminState> {
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)
assertAiModelRevision((await getAiModelConfiguration(db)).revision, input.revision)
const prior = await listAiModelRows(db)
const changes: Array<{ key: string; providerId: string; enabled: boolean; stages: AiModelStage[]; compatibility: string }> = []
for (const provider of input.providers) {
const current = await getAiModelProviderRow(provider.id, db); assertAiModelRevision(current.revision, provider.revision)
if (new Set(provider.models.map(model => model.key)).size !== provider.models.length) throw new AiModelError('模型编号重复')
let changed = false
for (const model of provider.models) {
const existing = prior.find(value => value.key === model.key && value.providerId === provider.id)
if (!existing) throw new AiModelError('模型不属于当前服务')
const capabilities = getAiModelCapabilities(current.baseUrl, existing.id, existing.capabilityMetadata)
if (model.stages.some(stage => !capabilities.allowedStages.includes(stage))) throw new AiModelError('所选用途超出模型已确认的能力范围,管理员确认不能扩展模型能力', 400, 'model_capability_unsupported')
if (new Set(model.stages).size !== model.stages.length || model.enabled && (!model.stages.length || model.compatibility !== 'confirmed' || !existing.present)) throw new AiModelError('启用模型前须选择用途、确认接口兼容,并确认仍在上游目录中')
if (existing.enabled !== model.enabled || existing.compatibility !== model.compatibility || JSON.stringify([...existing.stages].sort()) !== JSON.stringify([...model.stages].sort())) { changed = true; changes.push({ ...model, providerId: provider.id }) }
await db.$executeRawUnsafe(`UPDATE ai_model_catalog SET enabled=$2,stages=$3::text[],compatibility=$4,updated_at=NOW() WHERE id=$1`, model.key, model.enabled, model.stages, model.compatibility)
}
if (changed) await db.$executeRawUnsafe(`UPDATE ai_model_providers SET revision=revision+1,updated_at=NOW() WHERE id=$1`, provider.id)
}
await validateDefaults(db, input.defaults)
await saveAiModelDefaults(db, input.defaults)
await aiModelAudit(db, userId, 'configuration.update', null, { defaults: input.defaults, changes, providers: input.providers.map(provider => ({ id: provider.id, modelCount: provider.models.length })) })
})
return getAiModelAdminState()
}
export async function getAiModelRuntimeState(): Promise<AiModelRuntimeState> {
const [providers, models, config] = await Promise.all([listAiModelProviderRows(), listAiModelRows(), getAiModelConfiguration()])
const result: AiModelRuntimeState = { configured: true, defaults: { analysis: '', copy: '', image: '' }, models: { analysis: [], copy: [], image: [] }, modelOptions: { analysis: [], copy: [], image: [] }, legacyModels: { analysis: [], copy: [], image: [] } }
for (const stage of AI_MODEL_STAGES) {
for (const model of models) {
const provider = providers.find(value => value.id === model.providerId)
if (!provider || !modelAvailable(model, provider, stage)) continue
result.models[stage].push(model.key)
result.modelOptions[stage].push({ value: model.key, label: model.id, providerName: provider.name })
if (provider.legacyBinding) result.legacyModels[stage].push(model.id)
}
result.defaults[stage] = config.defaults[stage] && result.models[stage].includes(config.defaults[stage]!) ? config.defaults[stage]! : ''
if (!result.defaults[stage]) result.configured = false
}
return result
}
export async function resolveAiModel(stage: AiModelStage, selection: string, expected?: AiModelBinding): Promise<{ binding: AiModelBinding; apiKey: string }> {
const [providers, models] = await Promise.all([listAiModelProviderRows(), listAiModelRows()])
const model = models.find(value => value.key === selection) || models.find(value => value.id === selection && providers.some(provider => provider.id === value.providerId && provider.legacyBinding))
const provider = model && providers.find(value => value.id === model.providerId)
if (!model || !provider || !modelAvailable(model, provider, stage)) throw new AiModelError('所选模型当前不可用,请检查超级管理员的模型配置', 400, 'model_unavailable')
const binding: AiModelBinding = { providerId: provider.id, modelKey: model.key, modelId: model.id, providerName: provider.name, baseUrl: provider.baseUrl, revision: provider.revision }
if (expected && (expected.providerId !== binding.providerId || expected.modelKey !== binding.modelKey || expected.modelId !== binding.modelId || expected.baseUrl !== binding.baseUrl || expected.revision !== binding.revision)) throw new AiModelError('模型连接配置已改变,请刷新模型选择后重新分析;当前任务已停止', 409, 'model_binding_changed')
return { binding, apiKey: providerKey(provider) }
}
export async function captureAiModelBindings(models: Record<AiModelStage, string>): Promise<AiModelBindings> {
const values = await Promise.all(AI_MODEL_STAGES.map(async stage => [stage, (await resolveAiModel(stage, models[stage])).binding] as const))
return Object.fromEntries(values) as AiModelBindings
}

51
lib/ai-model-types.ts Normal file
View File

@ -0,0 +1,51 @@
export const AI_MODEL_STAGES = ['analysis', 'copy', 'image'] as const
export type AiModelStage = typeof AI_MODEL_STAGES[number]
export type AiModelDefaults = Record<AiModelStage, string | null>
export interface AiModelCapabilities {
kind: 'text' | 'vision' | 'image' | 'other' | 'unknown'
allowedStages: AiModelStage[]
source: 'upstream' | 'verified' | 'unknown'
reason: string
}
export interface AiModelCatalogEntry {
key: string
id: string
enabled: boolean
stages: AiModelStage[]
present: boolean
compatibility: 'confirmed' | 'unconfirmed'
capabilities: AiModelCapabilities
}
export interface AiModelProvider {
id: string
name: string
baseUrl: string
enabled: boolean
credentialSource: 'encrypted' | 'environment'
hasApiKey: boolean
revision: number
syncedAt: string | null
models: AiModelCatalogEntry[]
}
export interface AiModelAdminState {
providers: AiModelProvider[]
defaults: AiModelDefaults
revision: number
encryptionConfigured: boolean
legacyAvailable: boolean
}
export interface AiModelConfigurationInput {
revision: number
defaults: AiModelDefaults
providers: Array<{ id: string; revision: number; models: Array<{ key: string; enabled: boolean; stages: AiModelStage[]; compatibility: 'confirmed' | 'unconfirmed' }> }>
}
export interface AiModelChoice { value: string; label: string; providerName: string }
export interface AiModelRuntimeState {
configured: boolean
models: Record<AiModelStage, string[]>
defaults: Record<AiModelStage, string>
modelOptions: Record<AiModelStage, AiModelChoice[]>
legacyModels: Record<AiModelStage, string[]>
}
export interface AiModelBinding { providerId: string; modelKey: string; modelId: string; providerName: string; baseUrl: string; revision: number }
export type AiModelBindings = Record<AiModelStage, AiModelBinding>

View File

@ -6,6 +6,7 @@ import { assertDetailDocumentAssets, checkpointDetailJob, claimDetailJob, findDe
import { deleteDetailFile, readDetailFile, saveDetailFile } from './storage'
import { renderDetailExport } from './render'
import { type DetailDocument, type DetailExportFormat } from './types'
import { detailModelBindingsSchema } from './validation'
async function inputImages(document: DetailDocument, ownerId: string, projectId: string): Promise<DetailProviderImage[]> {
const ids = [...document.product.assetIds, ...document.product.referenceAssetIds].slice(0, 8)
@ -72,6 +73,11 @@ export async function processDetailJob(current: StoredDetailJob): Promise<void>
const project = await findDetailProject(current.projectId, current.ownerId)
if (project.revision !== current.projectRevision) throw new DetailGeneratorError('项目版本发生变化,任务已停止', 409)
let document: DetailDocument = JSON.parse(JSON.stringify(project.document))
if (current.kind !== 'export') {
const parsedBindings = detailModelBindingsSchema.safeParse(current.payload.modelBindings)
if (!parsedBindings.success || !parsedBindings.data) throw new DetailGeneratorError('任务缺少模型连接快照,请重新提交', 409, 'model_binding_missing')
document.modelBindings = parsedBindings.data
}
await assertDetailDocumentAssets(document, current.projectId, current.ownerId)
if (checkpoint.dispatchPending === true) throw new DetailProviderError('模型请求结果未知,任务已暂停;请核查服务商记录', true)
if (checkpoint.stage === 'complete') {
@ -84,12 +90,12 @@ export async function processDetailJob(current: StoredDetailJob): Promise<void>
const suppliedText = await productText(document, current.ownerId, current.projectId)
let facts = typeof checkpoint.facts === 'string' ? checkpoint.facts : null
if (!facts) {
facts = await dispatch('product-analysis', 10, key => analyzeDetailFacts(document, images, suppliedText, key))
facts = await dispatch('product-analysis', 10, key => analyzeDetailFacts(document, images, suppliedText, key, current.ownerId, document.modelBindings!.analysis))
checkpoint = { ...checkpoint, facts, stage: 'selling-points', dispatchPending: false }
await persistCheckpoint(40)
}
// Document text is included in the copy step as well as the multimodal analysis.
const analysis = await dispatch('outline', 45, key => composeDetailOutline(document, `${facts}\n用户原始资料:${suppliedText}`, key))
const analysis = await dispatch('outline', 45, key => composeDetailOutline(document, `${facts}\n用户原始资料:${suppliedText}`, key, current.ownerId, document.modelBindings!.copy))
document = { ...document, analysis, approvedAt: null }
checkpoint = { ...checkpoint, stage: 'complete', dispatchPending: false }
await saveDetailJobDocument(current.id, token, document, checkpoint, 100)
@ -103,7 +109,7 @@ export async function processDetailJob(current: StoredDetailJob): Promise<void>
const screen = target[index]
if (completed.has(screen.id) || (current.kind === 'generate' && screen.image)) continue
const progress = Math.round(index / target.length * 90)
const buffer = await dispatch(`screen-${screen.id}`, progress, key => generateDetailImage(document, screen.imagePrompt, images, key))
const buffer = await dispatch(`screen-${screen.id}`, progress, key => generateDetailImage(document, screen.imagePrompt, images, key, current.ownerId, document.modelBindings!.image))
const metadata = await sharp(buffer).metadata()
const assetId = randomUUID()
const storageKey = await saveDetailFile(current.projectId, buffer, 'png')

View File

@ -1,7 +1,11 @@
import { randomUUID } from 'crypto'
import sharp from 'sharp'
import { DetailGeneratorError } from './access'
import { DETAIL_MAX_SCREENS, DETAIL_MODEL_DEFAULTS, type DetailAnalysis, type DetailDocument } from './types'
import { assertDetailGeneratorUser, DetailGeneratorError } from './access'
import { DETAIL_MAX_SCREENS, type DetailAnalysis, type DetailDocument } from './types'
import { captureAiModelBindings, getAiModelRuntimeState, resolveAiModel } from '@/lib/ai-model-service'
import { requestAiModel } from '@/lib/ai-model-http'
import type { AiModelBinding, AiModelStage } from '@/lib/ai-model-types'
import { assertAiModelAdmin } from '@/lib/ai-model-access'
import { detailAnalysisSchema } from './validation'
export interface DetailProviderImage { buffer: Buffer; mimeType: string }
@ -9,70 +13,46 @@ export class DetailProviderError extends Error {
constructor(message: string, readonly outcomeUnknown: boolean, readonly requestId: string | null = null) { super(message); this.name = 'DetailProviderError' }
}
export function getDetailModels(): { analysis: string[]; copy: string[]; image: string[] } {
const defaults: { analysis: string[]; copy: string[]; image: string[] } = { analysis: [DETAIL_MODEL_DEFAULTS.analysis], copy: [DETAIL_MODEL_DEFAULTS.copy], image: [DETAIL_MODEL_DEFAULTS.image] }
const setting = process.env.DETAIL_GENERATOR_VALIDATED_MODELS_JSON
if (!setting) return defaults
let parsed: unknown
try { parsed = JSON.parse(setting) } catch { throw new DetailGeneratorError('已验证模型配置格式无效', 503, 'configuration_invalid') }
if (!parsed || typeof parsed !== 'object') throw new DetailGeneratorError('已验证模型配置格式无效', 503, 'configuration_invalid')
for (const stage of ['analysis', 'copy', 'image'] as const) {
const values = (parsed as Record<string, unknown>)[stage]
if (values !== undefined && (!Array.isArray(values) || values.some(value => typeof value !== 'string' || !/^[\w.:-]{1,120}$/.test(value)))) {
throw new DetailGeneratorError('已验证模型配置格式无效', 503, 'configuration_invalid')
}
if (Array.isArray(values)) defaults[stage] = Array.from(new Set([...defaults[stage], ...values]))
}
return defaults
export async function getDetailModels(): Promise<{ analysis: string[]; copy: string[]; image: string[] }> {
return (await getAiModelRuntimeState()).models
}
export function assertDetailModels(models: DetailDocument['models']): void {
const allowed = getDetailModels()
for (const stage of ['analysis', 'copy', 'image'] as const) {
if (!allowed[stage].includes(models[stage])) throw new DetailGeneratorError('只能选择已验证兼容的模型')
}
export async function assertDetailModels(models: DetailDocument['models']): Promise<void> {
await captureAiModelBindings(models)
}
export function isDetailProviderConfigured(): boolean { return Boolean(process.env.DETAIL_GENERATOR_API_KEY?.trim()) }
export async function isDetailProviderConfigured(): Promise<boolean> { return (await getAiModelRuntimeState()).configured }
function providerConfig() {
const apiKey = process.env.DETAIL_GENERATOR_API_KEY?.trim()
if (!apiKey) throw new DetailGeneratorError('请先在服务端配置详情页模型密钥', 503, 'provider_not_configured')
const configured = process.env.DETAIL_GENERATOR_BASE_URL?.trim() || 'https://api.vip98k.com/v1'
let base: URL
try { base = new URL(configured) } catch { throw new DetailGeneratorError('模型服务地址配置无效', 503, 'configuration_invalid') }
if (base.protocol !== 'https:' || base.username || base.password || base.search || base.hash) throw new DetailGeneratorError('模型服务须使用 HTTPS 地址', 503, 'configuration_invalid')
const baseUrl = base.href.replace(/\/+$/, '').replace(/\/v1$/, '') + '/v1'
return { apiKey, baseUrl }
}
function requestIdFrom(response: Response, data: any): string | null {
function requestIdFrom(response: Response, data: any, apiKey?: string): string | null {
const value = response.headers.get('x-request-id') || data?.request_id || data?.error?.request_id
return typeof value === 'string' && /^[\w-]{1,120}$/.test(value) ? value : null
return typeof value === 'string' && /^[\w-]{1,120}$/.test(value) && (!apiKey || !value.includes(apiKey)) ? value : null
}
async function callProvider(endpoint: string, body: string | FormData, idempotencyKey: string): Promise<any> {
const { apiKey, baseUrl } = providerConfig()
const controller = new AbortController()
const timeout = setTimeout(() => controller.abort(), 180_000)
async function callProvider(stage: AiModelStage, selection: string, endpoint: '/chat/completions' | '/images/edits', body: string | FormData, idempotencyKey: string, ownerId: string, expected?: AiModelBinding): Promise<any> {
await assertDetailGeneratorUser(ownerId)
const { apiKey, binding } = await resolveAiModel(stage, selection, expected)
if (typeof body === 'string') body = JSON.stringify({ ...JSON.parse(body), model: binding.modelId })
else body.set('model', binding.modelId)
try {
const response = await fetch(`${baseUrl}${endpoint}`, {
method: 'POST', signal: controller.signal,
headers: { Authorization: `Bearer ${apiKey}`, 'Idempotency-Key': idempotencyKey, ...(typeof body === 'string' ? { 'Content-Type': 'application/json' } : {}) }, body,
const response = await requestAiModel(binding.baseUrl, apiKey, endpoint, body, idempotencyKey, async () => {
await resolveAiModel(stage, selection, binding)
await assertDetailGeneratorUser(ownerId)
await assertAiModelAdmin(ownerId)
})
const bytes = await readDetailResponseBytes(response, 32 * 1024 * 1024)
let data: any
try { data = JSON.parse(bytes.toString('utf8')) } catch { throw new DetailProviderError('模型响应格式无法确认,请核查请求结果', true, requestIdFrom(response, null)) }
const requestId = requestIdFrom(response, data)
try { data = JSON.parse(bytes.toString('utf8')) } catch { throw new DetailProviderError('模型响应格式无法确认,请核查请求结果', true, requestIdFrom(response, null, apiKey)) }
const requestId = requestIdFrom(response, data, apiKey)
if (!response.ok) {
const unknown = response.status >= 500 || data?.error?.code === 'request_outcome_unknown' || data?.code === 'request_outcome_unknown'
throw new DetailProviderError(unknown ? '模型请求结果未知,已暂停;请核查服务商请求记录,勿重复提交' : `模型服务拒绝了请求(${response.status})`, unknown, requestId)
}
if (bytes.includes(Buffer.from(apiKey))) throw new DetailProviderError('模型响应含敏感内容,已暂停;请核查服务商请求记录', true, requestId)
return data
} catch (error) {
if (error instanceof DetailProviderError || error instanceof DetailGeneratorError) throw error
throw new DetailProviderError('模型请求结果未知,已暂停;请核查服务商请求记录,勿重复提交', true)
} finally { clearTimeout(timeout) }
}
}
export async function readDetailResponseBytes(response: Response, maxBytes: number): Promise<Buffer> {
@ -99,19 +79,17 @@ async function imageContent(images: DetailProviderImage[]) {
})))
}
export async function analyzeDetailFacts(document: DetailDocument, images: DetailProviderImage[], extractedText: string, idempotencyKey: string): Promise<string> {
assertDetailModels(document.models)
export async function analyzeDetailFacts(document: DetailDocument, images: DetailProviderImage[], extractedText: string, idempotencyKey: string, ownerId: string, expected?: AiModelBinding): Promise<string> {
const prompt = `你是产品资料分析员。只提取用户明确提供的产品事实,图片只能判断可见外观,不得推测材质、尺寸、认证、价格或性能。识别冲突和缺失信息,不要编造。返回 JSON,字段 facts(事实字符串数组)、warnings(待确认字符串数组)。\n产品:${JSON.stringify(document.product)}\n用户资料:${extractedText.slice(0, 60000)}`
const data = await callProvider('/chat/completions', JSON.stringify({ model: document.models.analysis, messages: [{ role: 'user', content: [{ type: 'text', text: prompt }, ...await imageContent(images)] }], temperature: 0.2, max_tokens: 3000 }), idempotencyKey)
const data = await callProvider('analysis', document.models.analysis, '/chat/completions', JSON.stringify({ model: document.models.analysis, messages: [{ role: 'user', content: [{ type: 'text', text: prompt }, ...await imageContent(images)] }], temperature: 0.2, max_tokens: 3000 }), idempotencyKey, ownerId, expected)
const content = data?.choices?.[0]?.message?.content
if (typeof content !== 'string' || !content.trim()) throw new DetailProviderError('模型未返回可用的产品分析', false)
return content.slice(0, 30000)
}
export async function composeDetailOutline(document: DetailDocument, facts: string, idempotencyKey: string): Promise<DetailAnalysis> {
assertDetailModels(document.models)
export async function composeDetailOutline(document: DetailDocument, facts: string, idempotencyKey: string, ownerId: string, expected?: AiModelBinding): Promise<DetailAnalysis> {
const prompt = `根据给出的真实产品资料提炼卖点和电商详情页大纲,不添加未经提供的参数、认证、性能或促销承诺。缺少的信息写入 warnings,不用于卖点。每屏标题和正文使用中文;imagePrompt 描述不含文字、标志的场景,保持产品结构和外观。品牌是草案,不能声称已获品牌认可。返回严格 JSON:{"sellingPoints":["..."],"warnings":["..."],"screens":[{"title":"...","body":"...","imagePrompt":"..."}]},最多${DETAIL_MAX_SCREENS}屏,建议4至6屏。\n产品资料:${JSON.stringify(document.product)}\n分析:${facts}\n品牌风格草案:${JSON.stringify(document.brand)}`
const data = await callProvider('/chat/completions', JSON.stringify({ model: document.models.copy, messages: [{ role: 'user', content: prompt }], temperature: 0.3, max_tokens: 8000 }), idempotencyKey)
const data = await callProvider('copy', document.models.copy, '/chat/completions', JSON.stringify({ model: document.models.copy, messages: [{ role: 'user', content: prompt }], temperature: 0.3, max_tokens: 8000 }), idempotencyKey, ownerId, expected)
const content = data?.choices?.[0]?.message?.content
let value: any
try {
@ -124,8 +102,7 @@ export async function composeDetailOutline(document: DetailDocument, facts: stri
return parsed.data
}
export async function generateDetailImage(document: DetailDocument, prompt: string, images: DetailProviderImage[], idempotencyKey: string): Promise<Buffer> {
assertDetailModels(document.models)
export async function generateDetailImage(document: DetailDocument, prompt: string, images: DetailProviderImage[], idempotencyKey: string, ownerId: string, expected?: AiModelBinding): Promise<Buffer> {
if (!images.length) throw new DetailGeneratorError('请先上传或关联产品图片')
const body = new FormData()
body.set('model', document.models.image)
@ -135,7 +112,7 @@ export async function generateDetailImage(document: DetailDocument, prompt: stri
const image = await sharp(images[index].buffer, { limitInputPixels: 40_000_000 }).rotate().resize(1536, 1536, { fit: 'inside', withoutEnlargement: true }).png().toBuffer()
body.append(images.length === 1 ? 'image' : 'image[]', new Blob([new Uint8Array(image)], { type: 'image/png' }), `reference-${index + 1}.png`)
}
const data = await callProvider('/images/edits', body, idempotencyKey)
const data = await callProvider('image', document.models.image, '/images/edits', body, idempotencyKey, ownerId, expected)
const encoded = data?.data?.[0]?.b64_json
if (typeof encoded !== 'string' || !/^[A-Za-z0-9+/]+={0,2}$/.test(encoded) || encoded.length > 24 * 1024 * 1024) throw new DetailProviderError('模型未返回可保存的图片', false)
const buffer = Buffer.from(encoded, 'base64')

View File

@ -8,7 +8,8 @@ import sharp from 'sharp'
import { prisma } from '@/lib/prisma'
import { DetailGeneratorError } from './access'
import { listDetailBrandProfiles } from './brand-profiles'
import { assertDetailModels, getDetailModels, isDetailProviderConfigured, readDetailResponseBytes } from './provider'
import { readDetailResponseBytes } from './provider'
import { captureAiModelBindings, getAiModelRuntimeState, resolveAiModel } from '@/lib/ai-model-service'
import {
assertDetailDocumentAssets, attachDetailAsset, createDetailProject, detailPublicAsset, detailPublicJob,
enqueueDetailJob, findDetailProject, listDetailAssets, listDetailJobs, listDetailProjects, updateDetailProject,
@ -17,14 +18,15 @@ import { saveDetailFile, deleteDetailFile } from './storage'
import { detailAnalysisSchema, parseDetailDocument, parseDetailScreen, parseIdempotencyKey, parseRevision } from './validation'
import { readDetailMaterialImage } from './materials'
import { reflowDetailScreen } from './layout'
import { DETAIL_FONT_FAMILY, DETAIL_MODEL_DEFAULTS, DETAIL_WIDTH, type DetailAssetKind, type DetailConfig, type DetailDocument, type DetailScreen } from './types'
import { DETAIL_FONT_FAMILY, DETAIL_WIDTH, type DetailAssetKind, type DetailConfig, type DetailDocument, type DetailScreen } from './types'
export async function getDetailConfig(): Promise<DetailConfig> {
const [knowledge, brandProfiles] = await Promise.all([
const [knowledge, brandProfiles, modelConfig] = await Promise.all([
prisma.brandKnowledge.findMany({ distinct: ['brand'], select: { brand: true }, where: { brand: { not: '' } }, take: 100 }),
listDetailBrandProfiles(),
getAiModelRuntimeState(),
])
return { access: true, configured: isDetailProviderConfigured(), models: getDetailModels(), defaults: DETAIL_MODEL_DEFAULTS,
return { access: true, ...modelConfig,
brands: Array.from(new Set([...knowledge.map(value => value.brand), ...brandProfiles.map(value => value.profile.name)])).sort(), brandProfiles }
}
@ -37,7 +39,8 @@ export async function getDetailProject(ownerId: string, id: string) {
export async function newDetailProject(ownerId: string, input: unknown) {
const document = parseDetailDocument(input)
assertDetailModels(document.models)
document.modelBindings = await captureDraftModelBindings(document)
if (!document.modelBindings) delete document.modelBindings
if (document.product.assetIds.length || document.product.referenceAssetIds.length || document.product.documentAssetIds.length || document.brand.logoAssetId || document.screens.length || document.analysis || document.approvedAt) throw new DetailGeneratorError('新项目须从空白草案创建')
return createDetailProject(ownerId, document)
}
@ -45,7 +48,10 @@ export async function newDetailProject(ownerId: string, input: unknown) {
export async function editDetailProject(ownerId: string, id: string, body: Record<string, unknown>) {
const current = await findDetailProject(id, ownerId)
const document = parseDetailDocument(body.document)
assertDetailModels(document.models)
const modelsChanged = !isDeepStrictEqual(document.models, current.document.models)
if (modelsChanged) { document.modelBindings = await captureDraftModelBindings(document); if (!document.modelBindings) delete document.modelBindings }
else if (current.document.modelBindings) document.modelBindings = current.document.modelBindings
else delete document.modelBindings
document.approvedAt = current.document.approvedAt
const sourceChanged = !isDeepStrictEqual([document.product, document.brand, document.models], [current.document.product, current.document.brand, current.document.models])
if (sourceChanged || document.analysis === null) {
@ -59,6 +65,11 @@ export async function editDetailProject(ownerId: string, id: string, body: Recor
return updateDetailProject(id, ownerId, parseRevision(body.revision), document)
}
async function captureDraftModelBindings(document: DetailDocument) {
for (const stage of ['analysis', 'copy', 'image'] as const) if (document.models[stage]) await resolveAiModel(stage, document.models[stage])
return Object.values(document.models).every(Boolean) ? captureAiModelBindings(document.models) : undefined
}
export function createDetailScreen(outline: { id: string; title: string; body: string; imagePrompt: string }, document: DetailDocument): DetailScreen {
return reflowDetailScreen({
...outline, width: DETAIL_WIDTH, height: 1200, backgroundColor: document.brand.backgroundColor,
@ -103,8 +114,10 @@ export async function createDetailAction(ownerId: string, id: string, kind: 'ana
const payload: Record<string, unknown> = {}
await assertDetailDocumentAssets(document, id, ownerId)
if (kind !== 'export') {
assertDetailModels(document.models)
if (!isDetailProviderConfigured()) throw new DetailGeneratorError('请先在服务端配置模型密钥', 503, 'provider_not_configured')
// Analyze adopts a newly selected connection snapshot. Subsequent image tasks retain the analyzed identity.
const modelBindings = kind !== 'analyze' && document.modelBindings ? document.modelBindings : await captureAiModelBindings(document.models)
if (kind !== 'analyze' && document.modelBindings) for (const stage of ['analysis', 'copy', 'image'] as const) await resolveAiModel(stage, document.models[stage], modelBindings[stage])
payload.modelBindings = modelBindings
if (!document.product.assetIds.length) throw new DetailGeneratorError('请先上传或关联实际产品图片')
if (document.product.assetIds.length + document.product.referenceAssetIds.length > 8) throw new DetailGeneratorError('产品图与参考图合计最多 8 张,请移除多余图片')
if (!document.product.name.trim() || (!document.product.facts.trim() && !document.product.documentAssetIds.length)) throw new DetailGeneratorError('请补充产品名称和真实产品资料')

View File

@ -1,3 +1,4 @@
import type { AiModelBindings, AiModelChoice, AiModelStage } from '@/lib/ai-model-types'
export const DETAIL_WIDTH = 750
export const DETAIL_MAX_SCREENS = 12
export const DETAIL_FONT_FAMILY = 'Noto Sans SC'
@ -64,6 +65,7 @@ export interface DetailDocument {
product: DetailProduct
brand: DetailBrandProfile
models: { analysis: string; copy: string; image: string }
modelBindings?: AiModelBindings
analysis: DetailAnalysis | null
approvedAt: string | null
screens: DetailScreen[]
@ -109,5 +111,7 @@ export interface DetailConfig {
models: { analysis: string[]; copy: string[]; image: string[] }
brands: string[]
brandProfiles: DetailBrandProfileRecord[]
defaults: typeof DETAIL_MODEL_DEFAULTS
defaults: { analysis: string; copy: string; image: string }
modelOptions?: Record<AiModelStage, AiModelChoice[]>
legacyModels?: Record<AiModelStage, string[]>
}

View File

@ -7,6 +7,8 @@ const shortText = z.string().trim().max(200)
const color = z.string().regex(/^#[0-9a-fA-F]{6}$/, '颜色须为六位十六进制')
const imageLayer = z.object({ assetId: id, x: z.number().min(0).max(750), y: z.number().min(0).max(2400), width: z.number().min(1).max(750), height: z.number().min(1).max(2400) }).strict()
const imageArea = z.object({ x: z.number().min(0).max(750), y: z.number().min(0).max(2400), width: z.number().min(1).max(750), height: z.number().min(1).max(2400) }).strict()
const modelBinding = z.object({ providerId: id, modelKey: id, modelId: z.string().min(1).max(200), providerName: z.string().min(1).max(100), baseUrl: z.string().max(500), revision: z.number().int().min(1) }).strict()
export const detailModelBindingsSchema = z.object({ analysis: modelBinding, copy: modelBinding, image: modelBinding }).strict()
const textLayer = z.object({
id: z.string().min(1).max(100), text: z.string().max(3000), x: z.number().min(0).max(750), y: z.number().min(0).max(2400),
width: z.number().min(1).max(750), fontSize: z.number().min(12).max(120), lineHeight: z.number().min(1).max(2.5),
@ -33,7 +35,8 @@ export const detailDocumentSchema = z.object({
product: z.object({ identifierType: z.enum(['sku', 'spu', 'barcode']), identifier: shortText, variant: shortText, name: shortText, facts: z.string().max(30000),
assetIds: z.array(id).max(8), referenceAssetIds: z.array(id).max(8), documentAssetIds: z.array(id).max(8), }).strict(),
brand: z.object({ name: shortText, tone: z.string().max(2000), primaryColor: color, backgroundColor: color, textColor: color, fontFamily: z.literal(DETAIL_FONT_FAMILY), logoAssetId: id.optional(), status: z.literal('draft') }).strict(),
models: z.object({ analysis: z.string().min(1).max(120), copy: z.string().min(1).max(120), image: z.string().min(1).max(120) }).strict(),
models: z.object({ analysis: z.string().max(120), copy: z.string().max(120), image: z.string().max(120) }).strict(),
modelBindings: detailModelBindingsSchema.optional(),
analysis: detailAnalysisSchema.nullable(), approvedAt: z.string().datetime().nullable(), screens: z.array(detailScreenSchema).max(DETAIL_MAX_SCREENS),
}).strict().superRefine((doc, ctx) => {
if (new Set(doc.screens.map(screen => screen.id)).size !== doc.screens.length) ctx.addIssue({ code: 'custom', message: '屏幕编号重复' })

View File

@ -0,0 +1,51 @@
-- Additive, private server-side model registry. No credentials or production imports in migration.
CREATE TABLE ai_model_providers (
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
base_url TEXT NOT NULL,
enabled BOOLEAN NOT NULL DEFAULT FALSE,
credential_source TEXT NOT NULL CHECK (credential_source IN ('environment','encrypted')),
secret_ciphertext TEXT,
secret_nonce TEXT,
secret_auth_tag TEXT,
legacy_binding BOOLEAN NOT NULL DEFAULT FALSE,
revision INTEGER NOT NULL DEFAULT 1 CHECK (revision > 0),
synced_at TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
CONSTRAINT ai_model_credential_complete CHECK (
(credential_source = 'environment' AND secret_ciphertext IS NULL AND secret_nonce IS NULL AND secret_auth_tag IS NULL)
OR (credential_source = 'encrypted' AND secret_ciphertext IS NOT NULL AND secret_nonce IS NOT NULL AND secret_auth_tag IS NOT NULL)
)
);
CREATE UNIQUE INDEX ai_model_providers_legacy_binding_key ON ai_model_providers (legacy_binding) WHERE legacy_binding = TRUE;
CREATE TABLE ai_model_catalog (
id TEXT PRIMARY KEY,
provider_id TEXT NOT NULL REFERENCES ai_model_providers(id) ON DELETE RESTRICT,
model_id TEXT NOT NULL,
enabled BOOLEAN NOT NULL DEFAULT FALSE,
stages TEXT[] NOT NULL DEFAULT ARRAY[]::TEXT[] CHECK (stages <@ ARRAY['analysis','copy','image']::TEXT[]),
present BOOLEAN NOT NULL DEFAULT TRUE,
compatibility TEXT NOT NULL DEFAULT 'unconfirmed' CHECK (compatibility IN ('confirmed','unconfirmed')),
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
CONSTRAINT ai_model_enabled_compatible CHECK (NOT enabled OR (cardinality(stages)>0 AND compatibility='confirmed')),
CONSTRAINT ai_model_catalog_provider_model_key UNIQUE(provider_id,model_id)
);
CREATE TABLE ai_model_configuration (
id TEXT PRIMARY KEY CHECK (id = 'detail-generator'),
default_analysis TEXT REFERENCES ai_model_catalog(id) ON DELETE RESTRICT,
default_copy TEXT REFERENCES ai_model_catalog(id) ON DELETE RESTRICT,
default_image TEXT REFERENCES ai_model_catalog(id) ON DELETE RESTRICT,
revision INTEGER NOT NULL DEFAULT 1 CHECK (revision > 0),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE TABLE ai_model_audit (
id TEXT PRIMARY KEY,
actor_id TEXT NOT NULL,
action TEXT NOT NULL,
provider_id TEXT,
details JSONB NOT NULL DEFAULT '{}'::JSONB,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX ai_model_audit_created_idx ON ai_model_audit(created_at);

View File

@ -0,0 +1,2 @@
-- Server-normalized upstream capability evidence. Existing rows stay unknown until sync or exact verified evidence.
ALTER TABLE ai_model_catalog ADD COLUMN capability_metadata JSONB;

View File

@ -73,6 +73,67 @@ model User {
@@map("users")
}
model AiModelProvider {
id String @id
name String
baseUrl String @map("base_url")
enabled Boolean @default(false)
credentialSource String @map("credential_source")
secretCiphertext String? @map("secret_ciphertext")
secretNonce String? @map("secret_nonce")
secretAuthTag String? @map("secret_auth_tag")
legacyBinding Boolean @default(false) @map("legacy_binding")
revision Int @default(1)
syncedAt DateTime? @db.Timestamptz(6) @map("synced_at")
createdAt DateTime @default(now()) @db.Timestamptz(6) @map("created_at")
updatedAt DateTime @default(now()) @db.Timestamptz(6) @map("updated_at")
models AiModelCatalog[]
@@map("ai_model_providers")
}
model AiModelCatalog {
id String @id
providerId String @map("provider_id")
modelId String @map("model_id")
enabled Boolean @default(false)
stages String[] @default([])
present Boolean @default(true)
compatibility String @default("unconfirmed")
capabilityMetadata Json? @map("capability_metadata")
createdAt DateTime @default(now()) @db.Timestamptz(6) @map("created_at")
updatedAt DateTime @default(now()) @db.Timestamptz(6) @map("updated_at")
provider AiModelProvider @relation(fields: [providerId], references: [id], onDelete: Restrict)
analysisDefaults AiModelConfiguration[] @relation("AiModelDefaultAnalysis")
copyDefaults AiModelConfiguration[] @relation("AiModelDefaultCopy")
imageDefaults AiModelConfiguration[] @relation("AiModelDefaultImage")
@@unique([providerId, modelId], map: "ai_model_catalog_provider_model_key")
@@map("ai_model_catalog")
}
model AiModelConfiguration {
id String @id
defaultAnalysis String? @map("default_analysis")
defaultCopy String? @map("default_copy")
defaultImage String? @map("default_image")
revision Int @default(1)
updatedAt DateTime @default(now()) @db.Timestamptz(6) @map("updated_at")
analysisModel AiModelCatalog? @relation("AiModelDefaultAnalysis", fields: [defaultAnalysis], references: [id], onDelete: Restrict)
copyModel AiModelCatalog? @relation("AiModelDefaultCopy", fields: [defaultCopy], references: [id], onDelete: Restrict)
imageModel AiModelCatalog? @relation("AiModelDefaultImage", fields: [defaultImage], references: [id], onDelete: Restrict)
@@map("ai_model_configuration")
}
model AiModelAudit {
id String @id
actorId String @map("actor_id")
action String
providerId String? @map("provider_id")
details Json @default("{}")
createdAt DateTime @default(now()) @db.Timestamptz(6) @map("created_at")
@@index([createdAt], map: "ai_model_audit_created_idx")
@@map("ai_model_audit")
}
model DetailGeneratorProject {
id String @id
ownerId String @map("owner_id")

View File

@ -0,0 +1,35 @@
import test from 'node:test'
import assert from 'node:assert/strict'
import { assertAiModelRegistryEvidence } from './safe-production-migrate.mjs'
import { aiModelRegistryEvidence } from './fixtures/ai-model-registry-evidence.mjs'
test('AI registry migration gate accepts the actual additive PostgreSQL schema', () => {
assert.doesNotThrow(() => assertAiModelRegistryEvidence(aiModelRegistryEvidence()))
})
test('AI registry gate rejects missing capability evidence and wrong credential column types', () => {
const evidence = aiModelRegistryEvidence()
assert.throws(() => assertAiModelRegistryEvidence({ ...evidence, columns: evidence.columns.filter(row => row.column_name !== 'capability_metadata') }), /ai_model_catalog.capability_metadata/)
for (const [name, change] of [
['secret_nonce', { data_type: 'jsonb' }], ['secret_ciphertext', { is_nullable: 'NO' }],
['stages', { udt_name: '_int4' }], ['enabled', { column_default: 'true' }],
]) assert.throws(() => assertAiModelRegistryEvidence({ ...evidence, columns: evidence.columns.map(row => row.column_name === name ? { ...row, ...change } : row) }), /Database (column|default) mismatch/)
})
test('AI registry gate preserves catalog and original connection uniqueness', () => {
const evidence = aiModelRegistryEvidence()
for (const name of ['ai_model_providers_legacy_binding_key', 'ai_model_catalog_provider_model_key']) {
assert.throws(() => assertAiModelRegistryEvidence({ ...evidence, indexes: evidence.indexes.filter(row => row.indexname !== name) }), /index/)
assert.throws(() => assertAiModelRegistryEvidence({ ...evidence, indexes: evidence.indexes.map(row => row.indexname === name ? { ...row, indisvalid: false } : row) }), /index/)
}
assert.throws(() => assertAiModelRegistryEvidence({ ...evidence, indexes: evidence.indexes.map(row => row.indexname === 'ai_model_providers_legacy_binding_key' ? { ...row, predicate: '' } : row) }), /predicate mismatch/)
})
test('AI registry gate refuses missing, weakened or cascading credential and model constraints', () => {
const evidence = aiModelRegistryEvidence()
for (const name of ['ai_model_credential_complete', 'ai_model_enabled_compatible', 'ai_model_catalog_stages_check']) {
assert.throws(() => assertAiModelRegistryEvidence({ ...evidence, constraints: evidence.constraints.filter(row => row.constraint_name !== name) }), /constraint mismatch/)
assert.throws(() => assertAiModelRegistryEvidence({ ...evidence, constraints: evidence.constraints.map(row => row.constraint_name === name ? { ...row, expression: 'true' } : row) }), /expression mismatch/)
}
assert.throws(() => assertAiModelRegistryEvidence({ ...evidence, constraints: evidence.constraints.map(row => row.contype === 'f' ? { ...row, delete_action: 'c' } : row) }), /deletion mismatch/)
})

View File

@ -0,0 +1,41 @@
// Schema-only fixture captured from the two additive migrations in disposable PostgreSQL.
export function aiModelRegistryEvidence() {
const column = (table_name, column_name, data_type, is_nullable = 'NO', column_default = null, udt_name = null) => ({ table_name, column_name, data_type, is_nullable, column_default, udt_name })
const provider = 'ai_model_providers', catalog = 'ai_model_catalog', config = 'ai_model_configuration', audit = 'ai_model_audit'
const unique = (tablename, indexname, columns, predicate = '') => ({ tablename, indexname, columns, predicate, indisunique: true, indisprimary: false, indisvalid: true, indisready: true, access_method: 'btree' })
const foreign = (tablename, name, referenced_table) => ({ tablename, constraint_name: `${tablename}_${name}_fkey`, contype: 'f', convalidated: true, delete_action: 'r', columns: [name], referenced_table, referenced_columns: ['id'] })
const check = (tablename, constraint_name, columns, expression) => ({ tablename, constraint_name, columns, expression, contype: 'c', convalidated: true })
return {
columns: [
...['id', 'name', 'base_url', 'credential_source'].map(name => column(provider, name, 'text')),
...['secret_ciphertext', 'secret_nonce', 'secret_auth_tag'].map(name => column(provider, name, 'text', 'YES')),
...['enabled', 'legacy_binding'].map(name => column(provider, name, 'boolean', 'NO', 'false')),
column(provider, 'revision', 'integer', 'NO', '1'), column(provider, 'synced_at', 'timestamp with time zone', 'YES'),
...['created_at', 'updated_at'].map(name => column(provider, name, 'timestamp with time zone', 'NO', 'now()')),
...['id', 'provider_id', 'model_id'].map(name => column(catalog, name, 'text')),
column(catalog, 'compatibility', 'text', 'NO', "'unconfirmed'::text"), column(catalog, 'stages', 'ARRAY', 'NO', 'ARRAY[]::text[]', '_text'),
column(catalog, 'capability_metadata', 'jsonb', 'YES'), column(catalog, 'enabled', 'boolean', 'NO', 'false'), column(catalog, 'present', 'boolean', 'NO', 'true'),
...['created_at', 'updated_at'].map(name => column(catalog, name, 'timestamp with time zone', 'NO', 'now()')),
column(config, 'id', 'text'), ...['default_analysis', 'default_copy', 'default_image'].map(name => column(config, name, 'text', 'YES')),
column(config, 'revision', 'integer', 'NO', '1'), column(config, 'updated_at', 'timestamp with time zone', 'NO', 'now()'),
...['id', 'actor_id', 'action'].map(name => column(audit, name, 'text')), column(audit, 'provider_id', 'text', 'YES'),
column(audit, 'details', 'jsonb', 'NO', "'{}'::jsonb"), column(audit, 'created_at', 'timestamp with time zone', 'NO', 'now()'),
],
indexes: [
unique(provider, 'ai_model_providers_legacy_binding_key', ['legacy_binding'], '(legacy_binding = true)'),
unique(catalog, 'ai_model_catalog_provider_model_key', ['provider_id', 'model_id']),
{ tablename: audit, indexname: 'ai_model_audit_created_idx', indexdef: 'CREATE INDEX ai_model_audit_created_idx ON public.ai_model_audit USING btree (created_at)' },
],
constraints: [
foreign(catalog, 'provider_id', provider), ...['default_analysis', 'default_copy', 'default_image'].map(name => foreign(config, name, catalog)),
check(provider, 'ai_model_providers_credential_source_check', ['credential_source'], "credential_source = ANY (ARRAY['environment'::text, 'encrypted'::text])"),
check(provider, 'ai_model_providers_revision_check', ['revision'], 'revision > 0'),
check(provider, 'ai_model_credential_complete', ['credential_source', 'secret_ciphertext', 'secret_nonce', 'secret_auth_tag'], "credential_source = 'environment'::text AND secret_ciphertext IS NULL AND secret_nonce IS NULL AND secret_auth_tag IS NULL OR credential_source = 'encrypted'::text AND secret_ciphertext IS NOT NULL AND secret_nonce IS NOT NULL AND secret_auth_tag IS NOT NULL"),
check(catalog, 'ai_model_catalog_stages_check', ['stages'], "stages <@ ARRAY['analysis'::text, 'copy'::text, 'image'::text]"),
check(catalog, 'ai_model_catalog_compatibility_check', ['compatibility'], "compatibility = ANY (ARRAY['confirmed'::text, 'unconfirmed'::text])"),
check(catalog, 'ai_model_enabled_compatible', ['enabled', 'stages', 'compatibility'], "NOT enabled OR cardinality(stages) > 0 AND compatibility = 'confirmed'::text"),
check(config, 'ai_model_configuration_id_check', ['id'], "id = 'detail-generator'::text"),
check(config, 'ai_model_configuration_revision_check', ['revision'], 'revision > 0'),
],
}
}

View File

@ -1254,15 +1254,28 @@ verify_detail_generator_schema() {
local name="$1"
docker exec -i "$name" node <<'NODE'
const fs = require('fs'), crypto = require('crypto'), { PrismaClient } = require('@prisma/client');
const migration = '20261012_detail_generator';
const checksum = crypto.createHash('sha256').update(fs.readFileSync(`/app/prisma/migrations/${migration}/migration.sql`, 'utf8').replace(/\r\n/g, '\n')).digest('hex');
const migrations = ['20261012_detail_generator', '20261012_ai_model_registry', '20261013_ai_model_capabilities'];
const required = { detail_generator_projects: ['id','owner_id','document','revision','deleted_at'], detail_generator_assets: ['id','project_id','owner_id','storage_key','byte_size'], detail_generator_jobs: ['id','project_id','owner_id','status','idempotency_key','fingerprint','project_revision','payload','checkpoint','lease_token','lease_expires_at'], detail_generator_brand_profiles: ['id','name','profile','revision'] };
const modelFields = {
ai_model_providers: { id:'text', name:'text', base_url:'text', enabled:'boolean', credential_source:'text', secret_ciphertext:'text?', secret_nonce:'text?', secret_auth_tag:'text?', legacy_binding:'boolean', revision:'integer', synced_at:'timestamp with time zone?', created_at:'timestamp with time zone', updated_at:'timestamp with time zone' },
ai_model_catalog: { id:'text', provider_id:'text', model_id:'text', enabled:'boolean', stages:'ARRAY', present:'boolean', compatibility:'text', capability_metadata:'jsonb?', created_at:'timestamp with time zone', updated_at:'timestamp with time zone' },
ai_model_configuration: { id:'text', default_analysis:'text?', default_copy:'text?', default_image:'text?', revision:'integer', updated_at:'timestamp with time zone' },
ai_model_audit: { id:'text', actor_id:'text', action:'text', provider_id:'text?', details:'jsonb', created_at:'timestamp with time zone' },
};
const p = new PrismaClient({ log: [] });
(async () => {
const completed = await p.$queryRawUnsafe('SELECT checksum FROM "_prisma_migrations" WHERE migration_name=$1 AND finished_at IS NOT NULL AND rolled_back_at IS NULL', migration);
if (completed.length !== 1 || completed[0].checksum !== checksum) throw new Error('migration');
const columns = await p.$queryRawUnsafe("SELECT table_name,column_name FROM information_schema.columns WHERE table_schema='public' AND table_name IN ('detail_generator_projects','detail_generator_assets','detail_generator_jobs','detail_generator_brand_profiles')");
for (const migration of migrations) {
const checksum = crypto.createHash('sha256').update(fs.readFileSync(`/app/prisma/migrations/${migration}/migration.sql`, 'utf8').replace(/\r\n/g, '\n')).digest('hex');
const completed = await p.$queryRawUnsafe('SELECT checksum FROM "_prisma_migrations" WHERE migration_name=$1 AND finished_at IS NOT NULL AND rolled_back_at IS NULL', migration);
if (completed.length !== 1 || completed[0].checksum !== checksum) throw new Error('migration');
}
const columns = await p.$queryRawUnsafe("SELECT table_name,column_name,data_type,udt_name,is_nullable FROM information_schema.columns WHERE table_schema='public' AND table_name = ANY($1::text[])", [...Object.keys(required), ...Object.keys(modelFields)]);
for (const [table, names] of Object.entries(required)) for (const column of names) if (!columns.some(row => row.table_name === table && row.column_name === column)) throw new Error('schema');
for (const [table, fields] of Object.entries(modelFields)) for (const [column, definition] of Object.entries(fields)) {
const nullable = definition.endsWith('?'), type = nullable ? definition.slice(0, -1) : definition;
const row = columns.find(row => row.table_name === table && row.column_name === column);
if (!row || row.data_type !== type || row.is_nullable !== (nullable ? 'YES' : 'NO') || type === 'ARRAY' && row.udt_name !== '_text') throw new Error('model schema');
}
})().catch(() => { process.exitCode = 1; }).finally(() => p.$disconnect());
NODE
}
@ -1629,6 +1642,8 @@ const expectedMigrationNames = new Set([
'20261009_material_purge_outbox',
'20261009_material_purge_reference_indexes',
'20261012_detail_generator',
'20261012_ai_model_registry',
'20261013_ai_model_capabilities',
]);
if (migrations.length !== expectedMigrationNames.size
|| migrations.some((item) => !expectedMigrationNames.has(item && item.name)

View File

@ -2,6 +2,9 @@ import assert from 'node:assert/strict'
import { existsSync, readFileSync } from 'node:fs'
import { spawnSync } from 'node:child_process'
import test from 'node:test'
import { runInNewContext } from 'node:vm'
import { createHash } from 'node:crypto'
import { aiModelRegistryEvidence } from './fixtures/ai-model-registry-evidence.mjs'
const bash = process.platform === 'win32' && existsSync('C:/Program Files/Git/bin/bash.exe') ? 'C:/Program Files/Git/bin/bash.exe' : 'bash'
@ -77,6 +80,8 @@ test('migration deployment gate recognizes the latest approved schema migration'
assert.match(gate, /20260915_add_brand_knowledge_soft_delete/)
assert.match(gate, /20260918_add_performance_conversion_sync/)
assert.match(gate, /20261012_detail_generator/)
assert.match(gate, /20261012_ai_model_registry/)
assert.match(gate, /20261013_ai_model_capabilities/)
})
test('direct replacement runs database and dependency gates before switching traffic', () => {
@ -174,6 +179,46 @@ test('enabled containers bind private persistence and verify actual Node22 PSD/P
assert.doesNotMatch(runtime, /fetch\(|generateDetailImage|runDetailGeneratorWorker/)
})
test('AI model cutover gate reads both migration checksums and rejects missing or mistyped capability state', async () => {
const code = shellFunction('verify_detail_generator_schema').match(/<<'NODE'\n([\s\S]*?)\nNODE/)[1]
const columns = [
...aiModelRegistryEvidence().columns,
...Object.entries({
detail_generator_projects: ['id', 'owner_id', 'document', 'revision', 'deleted_at'],
detail_generator_assets: ['id', 'project_id', 'owner_id', 'storage_key', 'byte_size'],
detail_generator_jobs: ['id', 'project_id', 'owner_id', 'status', 'idempotency_key', 'fingerprint', 'project_revision', 'payload', 'checkpoint', 'lease_token', 'lease_expires_at'],
detail_generator_brand_profiles: ['id', 'name', 'profile', 'revision'],
}).flatMap(([table_name, names]) => names.map(column_name => ({ table_name, column_name }))),
]
const checksum = createHash('sha256').update('schema fixture\n').digest('hex')
const run = async (changedColumns = columns, missingMigration = '') => {
const state = { exitCode: 0 }, checked = []
class PrismaClient {
async $queryRawUnsafe(sql, name) {
if (sql.includes('_prisma_migrations')) { checked.push(name); return name === missingMigration ? [] : [{ checksum }] }
return changedColumns
}
async $disconnect() {}
}
await runInNewContext(code, { process: state, require: name => ({ fs: { readFileSync: () => 'schema fixture\r\n' }, crypto: { createHash }, '@prisma/client': { PrismaClient } })[name] })
return { ...state, checked }
}
const accepted = await run()
assert.equal(accepted.exitCode, 0)
assert.deepEqual(accepted.checked, ['20261012_detail_generator', '20261012_ai_model_registry', '20261013_ai_model_capabilities'])
for (const migration of ['20261012_ai_model_registry', '20261013_ai_model_capabilities']) assert.equal((await run(columns, migration)).exitCode, 1)
assert.equal((await run(columns.filter(row => row.column_name !== 'capability_metadata'))).exitCode, 1)
assert.equal((await run(columns.map(row => row.column_name === 'capability_metadata' ? { ...row, data_type: 'text' } : row))).exitCode, 1)
})
test('AI registry migration files are present in builder and immutable runner verification scope', () => {
const dockerfile = readFileSync(new URL('../Dockerfile', import.meta.url), 'utf8')
for (const migration of ['20261012_ai_model_registry', '20261013_ai_model_capabilities']) {
assert.ok(dockerfile.includes(`test -f "/app/prisma/migrations/${migration}/migration.sql"`))
assert.ok(dockerfile.includes(`COPY --from=builder --chown=root:root /app/prisma/migrations/${migration} ./prisma/migrations/${migration}`))
}
})
test('detail uploads keep multipart headroom without expanding all proxy routes', () => {
assert.match(deploySource(), /location ~ "\^\/api\/detail-generator\/projects\/\[0-9a-fA-F-\]\{36\}\/assets\\\$" \{\n\s+client_max_body_size 17m;\n\s+proxy_request_buffering off;/)
})

View File

@ -42,6 +42,8 @@ const TARGET_MIGRATIONS = Object.freeze([
'20261009_material_purge_outbox',
'20261009_material_purge_reference_indexes',
'20261012_detail_generator',
'20261012_ai_model_registry',
'20261013_ai_model_capabilities',
])
const VERIFIED_BACKUP_GENERATOR = 'scripts/create-verified-production-backup.mjs'
const VERIFIED_BACKUP_MAINTENANCE_CONFIRMATION = 'NO_WRITES_DURING_SNAPSHOT'
@ -347,7 +349,7 @@ async function queryEvidence(prisma, localMigrations) {
COALESCE(inet_server_port()::text, '') AS server_port`,
)
const columns = await prisma.$queryRawUnsafe(
`SELECT table_name, column_name, data_type, is_nullable, column_default
`SELECT table_name, column_name, data_type, udt_name, is_nullable, column_default
FROM information_schema.columns
WHERE table_schema = current_schema()
AND ((table_name = 'materials' AND column_name IN ('uploadId', 'transcode_lease_token', 'transcode_lease_expires_at'))
@ -356,6 +358,7 @@ async function queryEvidence(prisma, localMigrations) {
OR (table_name = 'material_permission_pack_items' AND column_name IN ('can_access_private'))
OR table_name IN ('material_permission_pack_departments', 'team_function_configs', 'team_function_config_audits', 'material_purge_jobs', 'material_purge_audits')
OR table_name IN ('detail_generator_projects', 'detail_generator_assets', 'detail_generator_jobs', 'detail_generator_brand_profiles')
OR table_name IN ('ai_model_providers', 'ai_model_catalog', 'ai_model_configuration', 'ai_model_audit')
OR (table_name = 'api_keys' AND column_name IN ('secret_ciphertext', 'secret_nonce', 'secret_auth_tag'))
OR (table_name = 'api_key_reveal_audits' AND column_name IN ('id', 'api_key_id', 'viewer_user_id', 'created_at'))
OR (table_name = 'system_settings' AND column_name IN ('headerLogoUrl', 'loginLogoUrl', 'logoUrl'))
@ -426,7 +429,8 @@ async function queryEvidence(prisma, localMigrations) {
'material_purge_jobs_material_id_key', 'material_purge_jobs_status_next_attempt_at_idx', 'material_purge_audits_job_id_created_at_idx',
'materials_purge_references_idx', 'material_assets_purge_references_idx', 'material_skus_purge_references_idx',
'brand_knowledge_purge_references_idx', 'system_settings_purge_references_idx', 'performance_work_items_purge_references_idx',
'work_review_versions_purge_references_idx', 'package_upload_jobs_purge_references_idx', 'task_detail_changes_purge_references_idx'
'work_review_versions_purge_references_idx', 'package_upload_jobs_purge_references_idx', 'task_detail_changes_purge_references_idx',
'ai_model_providers_legacy_binding_key', 'ai_model_catalog_provider_model_key', 'ai_model_audit_created_idx'
)
ORDER BY idx.relname`,
)
@ -435,6 +439,7 @@ async function queryEvidence(prisma, localMigrations) {
con.conname AS constraint_name,
con.contype::text AS contype,
con.convalidated,
con.confdeltype::text AS delete_action,
ref.relname AS referenced_table,
ARRAY(
SELECT att.attname
@ -468,7 +473,8 @@ async function queryEvidence(prisma, localMigrations) {
'team_function_configs_organization_unit_id_fkey', 'team_function_configs_lead_employee_profile_id_fkey',
'team_function_config_audits_config_id_fkey', 'team_function_configs_revision_check',
'material_purge_audits_job_id_fkey'
) OR (cls.relname IN ('material_logs', 'material_download_logs') AND ref.relname = 'materials'))
) OR cls.relname IN ('ai_model_providers', 'ai_model_catalog', 'ai_model_configuration')
OR (cls.relname IN ('material_logs', 'material_download_logs') AND ref.relname = 'materials'))
ORDER BY con.conname`,
)
const brandRows = await prisma.$queryRawUnsafe(
@ -870,6 +876,7 @@ export function assertTargetMigrationEvidence(localMigrations, evidence) {
}
assertOrganizationConfigurationEvidence(evidence)
assertDetailGeneratorEvidence(evidence)
assertAiModelRegistryEvidence(evidence)
assertMaterialPurgeEvidence(evidence)
assertMaterialPurgeReferenceEvidence(evidence)
}
@ -906,6 +913,51 @@ export function assertDetailGeneratorEvidence(evidence) {
}
}
export function assertAiModelRegistryEvidence(evidence) {
const columns = new Map(evidence.columns.map(row => [`${row.table_name}.${row.column_name}`, row]))
const required = {
ai_model_providers: { id: 'text', name: 'text', base_url: 'text', enabled: 'boolean', credential_source: 'text', secret_ciphertext: 'text?', secret_nonce: 'text?', secret_auth_tag: 'text?', legacy_binding: 'boolean', revision: 'integer', synced_at: 'timestamp with time zone?', created_at: 'timestamp with time zone', updated_at: 'timestamp with time zone' },
ai_model_catalog: { id: 'text', provider_id: 'text', model_id: 'text', enabled: 'boolean', stages: 'ARRAY', present: 'boolean', compatibility: 'text', capability_metadata: 'jsonb?', created_at: 'timestamp with time zone', updated_at: 'timestamp with time zone' },
ai_model_configuration: { id: 'text', default_analysis: 'text?', default_copy: 'text?', default_image: 'text?', revision: 'integer', updated_at: 'timestamp with time zone' },
ai_model_audit: { id: 'text', actor_id: 'text', action: 'text', provider_id: 'text?', details: 'jsonb', created_at: 'timestamp with time zone' },
}
for (const [table, fields] of Object.entries(required)) for (const [column, definition] of Object.entries(fields)) {
const nullable = definition.endsWith('?'), type = nullable ? definition.slice(0, -1) : definition
const row = columns.get(`${table}.${column}`)
if (!row || row.data_type !== type || row.is_nullable !== (nullable ? 'YES' : 'NO') || type === 'ARRAY' && row.udt_name !== '_text') fail(`Database column mismatch: ${table}.${column}`)
}
for (const [table, column, expected] of [
['ai_model_providers', 'enabled', 'false'], ['ai_model_providers', 'legacy_binding', 'false'],
['ai_model_catalog', 'enabled', 'false'], ['ai_model_catalog', 'present', 'true'],
['ai_model_catalog', 'compatibility', 'unconfirmed'],
]) if (normalizeColumnDefault(columns.get(`${table}.${column}`)?.column_default) !== expected) fail(`Database default mismatch: ${table}.${column}`)
const indexes = evidence.indexes || [], constraints = evidence.constraints || []
assertExactUniqueBtreeIndex(indexes.find(row => row.indexname === 'ai_model_catalog_provider_model_key'), 'ai_model_catalog', 'ai_model_catalog_provider_model_key', ['provider_id', 'model_id'])
const legacy = indexes.find(row => row.indexname === 'ai_model_providers_legacy_binding_key')
if (normalizeConstraintExpression(legacy?.predicate) !== 'legacy_binding=true') fail('Database index predicate mismatch: ai_model_providers_legacy_binding_key')
assertExactUniqueBtreeIndex(legacy && { ...legacy, predicate: '' }, 'ai_model_providers', 'ai_model_providers_legacy_binding_key', ['legacy_binding'])
assertExactBtreeIndex(indexes.find(row => row.indexname === 'ai_model_audit_created_idx')?.indexdef, 'ai_model_audit', ['created_at'])
for (const [table, column, referenced] of [
['ai_model_catalog', 'provider_id', 'ai_model_providers'],
...['default_analysis', 'default_copy', 'default_image'].map(column => ['ai_model_configuration', column, 'ai_model_catalog']),
]) {
const name = `${table}_${column}_fkey`, constraint = constraints.find(row => row.constraint_name === name)
assertExactForeignKeyConstraint(constraint, table, name, column, referenced, 'id')
if (constraint.delete_action !== 'r') fail(`Database foreign key deletion mismatch: ${name}`)
}
for (const [table, name, fields, expression] of [
['ai_model_providers', 'ai_model_providers_credential_source_check', ['credential_source'], "credential_source = ANY (ARRAY['environment', 'encrypted'])"],
['ai_model_providers', 'ai_model_providers_revision_check', ['revision'], 'revision > 0'],
['ai_model_providers', 'ai_model_credential_complete', ['credential_source', 'secret_ciphertext', 'secret_nonce', 'secret_auth_tag'], "credential_source = 'environment' AND secret_ciphertext IS NULL AND secret_nonce IS NULL AND secret_auth_tag IS NULL OR credential_source = 'encrypted' AND secret_ciphertext IS NOT NULL AND secret_nonce IS NOT NULL AND secret_auth_tag IS NOT NULL"],
['ai_model_catalog', 'ai_model_catalog_stages_check', ['stages'], "stages <@ ARRAY['analysis', 'copy', 'image']"],
['ai_model_catalog', 'ai_model_catalog_compatibility_check', ['compatibility'], "compatibility = ANY (ARRAY['confirmed', 'unconfirmed'])"],
['ai_model_catalog', 'ai_model_enabled_compatible', ['enabled', 'stages', 'compatibility'], "NOT enabled OR cardinality(stages) > 0 AND compatibility = 'confirmed'"],
['ai_model_configuration', 'ai_model_configuration_id_check', ['id'], "id = 'detail-generator'"],
['ai_model_configuration', 'ai_model_configuration_revision_check', ['revision'], 'revision > 0'],
]) assertExactCheckConstraint(constraints.find(row => row.constraint_name === name), table, name, fields, expression)
}
export function assertMaterialPurgeReferenceEvidence(evidence) {
const expected = materialPurgeReferenceExpectations()
for (const item of expected.indexes) {

View File

@ -6,6 +6,7 @@ import os from 'node:os'
import path from 'node:path'
import { spawnSync } from 'node:child_process'
import test from 'node:test'
import { aiModelRegistryEvidence } from './fixtures/ai-model-registry-evidence.mjs'
import {
assertBackupReceiptDocument,
@ -424,6 +425,8 @@ test('target evidence rejects nullable logo columns and structurally wrong index
['20261009_material_purge_outbox', { checksum: 'ae' }],
['20261009_material_purge_reference_indexes', { checksum: 'af' }],
['20261012_detail_generator', { checksum: 'ag' }],
['20261012_ai_model_registry', { checksum: 'ah' }],
['20261013_ai_model_capabilities', { checksum: 'ai' }],
])
const evidence = {
migrationRows: [
@ -697,6 +700,9 @@ test('target evidence rejects nullable logo columns and structurally wrong index
evidence.migrationRows.push({ migration_name: '20261009_material_purge_outbox', checksum: 'ae', finished_at: 'now', rolled_back_at: null })
evidence.migrationRows.push({ migration_name: '20261009_material_purge_reference_indexes', checksum: 'af', finished_at: 'now', rolled_back_at: null })
evidence.migrationRows.push({ migration_name: '20261012_detail_generator', checksum: 'ag', finished_at: 'now', rolled_back_at: null })
evidence.migrationRows.push({ migration_name: '20261012_ai_model_registry', checksum: 'ah', finished_at: 'now', rolled_back_at: null }, { migration_name: '20261013_ai_model_capabilities', checksum: 'ai', finished_at: 'now', rolled_back_at: null })
const aiModels = aiModelRegistryEvidence()
for (const field of ['columns', 'indexes', 'constraints']) evidence[field].push(...aiModels[field])
for (const [table, fields] of Object.entries({
detail_generator_projects: { id: 'text', owner_id: 'text', document: 'jsonb', revision: 'integer' },
detail_generator_assets: { id: 'text', project_id: 'text', owner_id: 'text', storage_key: 'text', byte_size: 'integer', mime_type: 'text' },
@ -1064,12 +1070,12 @@ test('container supply chain and migrator image are immutable while SOP keeps co
assert.match(sop, /-v "\$HOST_RESULT_DIR:\/audit"/)
assert.match(sop, /test -f "\$PREFLIGHT_AUDIT_FILE" && test ! -L "\$PREFLIGHT_AUDIT_FILE"/)
assert.doesNotMatch(sop, /-v \/root\/gongxiangp2-migration-audit:\/audit/)
assert.match(sop, /三十三份目标 migration/)
assert.match(sop, /三十三条目标 migration/)
assert.match(sop, /三十五份目标 migration/)
assert.match(sop, /三十五条目标 migration/)
const targetBlock = migratorSource.match(/const TARGET_MIGRATIONS = Object\.freeze\(\[([\s\S]*?)\]\)/)?.[1]
assert.ok(targetBlock, 'Migrator target list must be inspectable')
const targets = [...targetBlock.matchAll(/'([^']+)'/g)].map(match => match[1])
assert.equal(targets.length, 33)
assert.equal(targets.length, 35)
for (const migration of targets) assert.ok(sop.includes(`\`${migration}\``), `SOP must name ${migration}`)
const applyMigration = migratorSource.indexOf("['migrate', 'deploy'")

View File

@ -0,0 +1,110 @@
/** Local PostgreSQL + real HTTP acceptance. Catalog sync is read-only; never submits generation. */
import assert from 'node:assert/strict'
import { writeFile } from 'node:fs/promises'
import { PrismaClient } from '@prisma/client'
import { DETAIL_FONT_FAMILY } from '../lib/detail-generator/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:3120'
const p = new PrismaClient({ log: [] })
const evidence: Record<string, unknown> = { generationCalls: 0 }
async function session(index: number) {
const jar = new Map<string, string>()
const store = (response: Response) => { for (const cookie of response.headers.getSetCookie()) { const first = cookie.split(';')[0]; const split = first.indexOf('='); jar.set(first.slice(0, split), first.slice(split + 1)) } }
const csrf = await fetch(base + '/api/auth/csrf'); store(csrf)
const { csrfToken } = await csrf.json()
const response = await fetch(base + '/api/auth/callback/credentials', { method: 'POST', redirect: 'manual', headers: { 'Content-Type': 'application/x-www-form-urlencoded', Origin: base, Cookie: [...jar].map(([k, v]) => `${k}=${v}`).join('; ') }, body: new URLSearchParams({ csrfToken, email: `ai-admin-${index}@example.invalid`, password: 'AiAdminLocalTest-20261012', callbackUrl: base + '/admin', json: 'true' }) })
store(response); assert.equal(response.status, 200)
return [...jar].map(([k, v]) => `${k}=${v}`).join('; ')
}
async function request(cookie: string, path = '', method = 'GET', body?: unknown) {
const response = await fetch(base + '/api/admin/ai-models' + path, { method, redirect: 'manual', headers: { ...(cookie ? { Cookie: cookie } : {}), 'Content-Type': 'application/json' }, ...(body === undefined ? {} : { body: JSON.stringify(body) }) })
const text = await response.text()
if (process.env.DETAIL_GENERATOR_API_KEY) assert.ok(!text.includes(process.env.DETAIL_GENERATOR_API_KEY), 'No key in API response')
assert.ok(!text.includes('ai-admin-test-new-provider-key'), 'No replacement key in response')
return { status: response.status, data: text ? JSON.parse(text) : null }
}
function assertStatus(result: { status: number; data: any }, expected = 200) { assert.equal(result.status, expected, JSON.stringify(result.data)); return result.data }
async function main() {
const initialJobs = await p.detailGeneratorJob.count()
assert.equal((await request('')).status, 401)
const ordinary = await session(2)
for (const [path, method, body] of [['', 'GET', undefined], ['/import-environment', 'POST', undefined], ['/providers', 'POST', {}], ['/configuration', 'PUT', {}]] as const) assert.equal((await request(ordinary, path, method, body)).status, 403)
evidence.anonymous = 401; evidence.ordinaryAdmin = 403
const admin = await session(0)
let state = assertStatus(await request(admin))
const existing = state.providers.find((provider: any) => provider.credentialSource === 'environment')
if (!existing) state = assertStatus(await request(admin, '/import-environment', 'POST'))
const imported = state.providers.find((provider: any) => provider.credentialSource === 'environment')
assert.ok(imported?.hasApiKey)
assert.equal((await request(admin, '/import-environment', 'POST')).status, 409)
const again = assertStatus(await request(admin))
assert.equal(again.providers.filter((provider: any) => provider.credentialSource === 'environment').length, 1)
evidence.environmentImportIdempotent = true
const sync = await request(admin, `/providers/${imported.id}/sync`, 'POST', { revision: imported.revision })
state = assertStatus(sync)
const synced = state.providers.find((provider: any) => provider.id === imported.id)
assert.ok(synced.models.length >= 2)
assert.equal(new Set(synced.models.map((model: any) => model.id)).size, synced.models.length)
const known = new Set(['gemini-2.5-flash', 'nano-banana-2'])
for (const model of synced.models) if (!known.has(model.id)) { assert.equal(model.enabled, false); assert.equal(model.compatibility, 'unconfirmed') }
evidence.catalogModelCount = synced.models.filter((model: any) => model.present).length
evidence.newModelsInitiallyDisabled = true
assert.equal((await request(admin, `/providers/${imported.id}/sync`, 'POST', { revision: imported.revision })).status, 409)
evidence.staleRevisionRejected = true
assert.equal((await request(admin, '/providers', 'POST', { name: 'Unsafe fixture', baseUrl: 'https://127.0.0.1', apiKey: 'ai-admin-test-new-provider-key' })).status, 400)
evidence.privateAddressRejected = true
const created = await request(admin, '/providers', 'POST', { name: '本地验收上游', baseUrl: 'https://api.example.com/v1', apiKey: 'ai-admin-test-new-provider-key' })
state = assertStatus(created, created.status === 201 ? 201 : 200)
const newProvider = state.providers.find((provider: any) => provider.name === '本地验收上游')
assert.ok(newProvider.hasApiKey); assert.equal(newProvider.credentialSource, 'encrypted'); assert.equal(newProvider.enabled, false)
const renamed = assertStatus(await request(admin, `/providers/${newProvider.id}`, 'PATCH', { revision: newProvider.revision, name: '本地验收上游(已保存)' }))
assert.ok(renamed.providers.find((provider: any) => provider.id === newProvider.id).hasApiKey)
state = renamed
evidence.newKeyMaskedAndBlankPreserved = true
const defaultProvider = state.providers.find((provider: any) => provider.id === imported.id)
const selectable = defaultProvider.models.find((model: any) => model.id === 'gemini-2.5-pro')
assert.ok(selectable, 'Live catalog must include selected test model')
// Public protocol metadata allows copy only; confirmation cannot grant image understanding.
assert.ok(selectable.capabilities.allowedStages.includes('copy'))
selectable.enabled = true; selectable.stages = ['copy']; selectable.compatibility = 'confirmed'
const analyzer = defaultProvider.models.find((model: any) => model.id === 'gemini-2.5-flash')
assert.ok(analyzer.capabilities.allowedStages.includes('analysis'))
state.defaults.analysis = analyzer.key; state.defaults.copy = selectable.key
const input = { revision: state.revision, defaults: state.defaults, providers: state.providers.map((provider: any) => ({ id: provider.id, revision: provider.revision, models: provider.models.map(({ key, enabled, stages, compatibility }: any) => ({ key, enabled, stages, compatibility })) })) }
state = assertStatus(await request(admin, '/configuration', 'PUT', input))
assert.equal(state.defaults.copy, selectable.key)
assert.equal((await request(admin, '/configuration', 'PUT', input)).status, 409)
const detail = await fetch(base + '/api/detail-generator', { headers: { Cookie: admin } }).then(response => response.json())
assert.equal(detail.config.defaults.copy, selectable.key)
assert.ok(detail.config.modelOptions.copy.some((option: any) => option.value === selectable.key && option.label.includes('gemini-2.5-pro')))
assert.ok(!detail.config.modelOptions.analysis.some((option: any) => option.value === selectable.key))
evidence.dynamicDetailDefaults = true
const document = { version: 1, name: '本地配置联动验收', product: { identifierType: 'sku', identifier: 'LOCAL-AI-TEST', variant: '', name: '本地验收产品', facts: '', assetIds: [], referenceAssetIds: [], documentAssetIds: [] }, brand: { name: '本地验收', tone: '', primaryColor: '#1677ff', backgroundColor: '#ffffff', textColor: '#101828', fontFamily: DETAIL_FONT_FAMILY, status: 'draft' }, models: detail.config.defaults, analysis: null, approvedAt: null, screens: [] }
const projectResponse = await fetch(base + '/api/detail-generator', { method: 'POST', headers: { Cookie: admin, 'Content-Type': 'application/json' }, body: JSON.stringify({ document }) })
assert.equal(projectResponse.status, 200)
const project = (await projectResponse.json()).project
assert.equal(project.document.models.copy, selectable.key)
const active = state.providers.find((provider: any) => provider.id === imported.id)
assertStatus(await request(admin, `/providers/${active.id}`, 'PATCH', { revision: active.revision, enabled: false }))
const editResponse = await fetch(base + `/api/detail-generator/projects/${project.id}`, { method: 'PATCH', headers: { Cookie: admin, 'Content-Type': 'application/json' }, body: JSON.stringify({ revision: project.revision, document: { ...project.document, name: '停用上游后仍可保存文字' } }) })
assert.equal(editResponse.status, 200)
evidence.disabledProviderDoesNotBlockEditing = true
state = assertStatus(await request(admin))
const disabled = state.providers.find((provider: any) => provider.id === imported.id)
state = assertStatus(await request(admin, `/providers/${disabled.id}`, 'PATCH', { revision: disabled.revision, enabled: true }))
const revokedSession = await session(1)
const revoked = await p.user.findUniqueOrThrow({ where: { email: 'ai-admin-1@example.invalid' } })
try {
await p.user.update({ where: { id: revoked.id }, data: { isSuperAdmin: false } })
assert.equal((await request(revokedSession)).status, 403)
assert.equal((await request(revokedSession, `/providers/${imported.id}/sync`, 'POST', { revision: 1 })).status, 403)
} finally { await p.user.update({ where: { id: revoked.id }, data: { isSuperAdmin: true } }) }
evidence.oldSessionRevoked = true
assert.equal(await p.detailGeneratorJob.count(), initialJobs)
evidence.jobCountUnchanged = true
await writeFile('output/detail-generator/ai-admin-http-evidence.json', JSON.stringify(evidence, null, 2))
console.log(JSON.stringify(evidence))
}
main().catch(error => { console.error(error.message); process.exitCode = 1 }).finally(() => p.$disconnect())

View File

@ -0,0 +1,102 @@
/** Isolated local HTTP acceptance only. Never submits a generation request. */
import assert from 'node:assert/strict'
import { writeFile } from 'node:fs/promises'
import { PrismaClient } from '@prisma/client'
import type { AiModelAdminState } from '../lib/ai-model-types'
const database = new URL(process.env.DATABASE_URL || '')
assert.equal(database.hostname, '127.0.0.1')
assert.equal(database.port, '55441')
assert.equal(database.pathname, '/ai_model_test')
const base = 'http://localhost:3120'
const prisma = new PrismaClient({ log: [] })
const evidence: Record<string, unknown> = { generationCalls: 0 }
async function signIn(index: number) {
const jar = new Map<string, string>()
function store(response: Response) {
for (const cookie of response.headers.getSetCookie()) {
const first = cookie.split(';')[0], position = first.indexOf('=')
jar.set(first.slice(0, position), first.slice(position + 1))
}
}
const csrf = await fetch(base + '/api/auth/csrf'); store(csrf)
const { csrfToken } = await csrf.json()
const login = await fetch(base + '/api/auth/callback/credentials', {
method: 'POST', redirect: 'manual',
headers: { 'Content-Type': 'application/x-www-form-urlencoded', Origin: base, Cookie: [...jar].map(([key, value]) => `${key}=${value}`).join('; ') },
body: new URLSearchParams({ csrfToken, email: `ai-admin-${index}@example.invalid`, password: 'AiAdminLocalTest-20261012', callbackUrl: base + '/admin', json: 'true' }),
})
store(login); assert.equal(login.status, 200)
return [...jar].map(([key, value]) => `${key}=${value}`).join('; ')
}
async function request(cookie: string, path = '', method = 'GET', body?: unknown) {
const response = await fetch(base + '/api/admin/ai-models' + path, {
method, redirect: 'manual', headers: { Cookie: cookie, 'Content-Type': 'application/json' },
...(body === undefined ? {} : { body: JSON.stringify(body) }),
})
const text = await response.text()
if (process.env.DETAIL_GENERATOR_API_KEY) assert.ok(!text.includes(process.env.DETAIL_GENERATOR_API_KEY))
return { status: response.status, data: JSON.parse(text) }
}
function input(state: AiModelAdminState) {
return { revision: state.revision, defaults: state.defaults, providers: state.providers.map(provider => ({
id: provider.id, revision: provider.revision,
models: provider.models.map(({ key, enabled, stages, compatibility }) => ({ key, enabled, stages, compatibility })),
})) }
}
async function main() {
const beforeJobs = await prisma.detailGeneratorJob.count()
const admin = await signIn(0), ordinary = await signIn(2)
assert.equal((await request('')).status, 401)
assert.equal((await request(ordinary)).status, 403)
let state = (await request(admin)).data as AiModelAdminState
const imported = state.providers.find(provider => provider.credentialSource === 'environment')
assert.ok(imported)
const sync = await request(admin, `/providers/${imported.id}/sync`, 'POST', { revision: imported.revision })
assert.equal(sync.status, 200, JSON.stringify(sync.data))
state = sync.data
const provider = state.providers.find(value => value.id === imported.id)!
const present = provider.models.filter(model => model.present)
const repeat = await request(admin, `/providers/${provider.id}/sync`, 'POST', { revision: provider.revision })
assert.equal(repeat.status, 200, JSON.stringify(repeat.data))
assert.equal(repeat.data.providers.find((value: { id: string }) => value.id === provider.id).revision, provider.revision)
evidence.unchangedCatalogKeepsBindingRevision = true
evidence.catalogCount = present.length
evidence.classification = Object.fromEntries(['text', 'vision', 'image', 'other', 'unknown'].map(kind => [kind, present.filter(model => model.capabilities.kind === kind).length]))
assert.ok(present.some(model => model.capabilities.kind === 'image'))
assert.ok(present.some(model => model.capabilities.kind === 'text' || model.capabilities.kind === 'vision'))
const conversation = present.find(model => model.capabilities.allowedStages.includes('copy') && !model.capabilities.allowedStages.includes('image'))!
assert.ok(conversation)
const bad = input(state)
const badModel = bad.providers.find(value => value.id === provider.id)!.models.find(model => model.key === conversation.key)!
badModel.stages = ['image']; badModel.enabled = true; badModel.compatibility = 'confirmed'
assert.equal((await request(admin, '/configuration', 'PUT', bad)).status, 400)
assert.equal((await request(ordinary, '/configuration', 'PUT', bad)).status, 403)
evidence.conversationImageAssignmentDenied = true
const forged = input(state)
Object.assign(forged.providers[0].models[0], { capabilities: { allowedStages: ['image'], kind: 'image', source: 'verified', reason: '' } })
assert.equal((await request(admin, '/configuration', 'PUT', forged)).status, 400)
evidence.clientCapabilityForgeryDenied = true
const unchanged = (await request(admin)).data as AiModelAdminState
assert.equal(unchanged.revision, state.revision)
evidence.rejectedConfigurationDoesNotWrite = true
// Only existing administrator-open models are exposed to the detail generator.
const response = await fetch(base + '/api/detail-generator', { headers: { Cookie: admin } })
assert.equal(response.status, 200)
const { config } = await response.json()
for (const stage of ['analysis', 'copy', 'image'] as const) {
const expected = state.providers.flatMap(source => source.models.filter(model => source.enabled && source.hasApiKey && model.present && model.enabled && model.compatibility === 'confirmed' && model.stages.includes(stage) && model.capabilities.allowedStages.includes(stage)).map(model => model.key)).sort()
assert.deepEqual(config.models[stage].slice().sort(), expected)
}
evidence.userChoicesEqualAdministratorOpenCapabilityIntersection = true
const after = await prisma.detailGeneratorJob.count()
assert.equal(after, beforeJobs)
evidence.jobCountUnchanged = true
await writeFile('output/detail-generator/ai-capabilities-http-evidence.json', JSON.stringify(evidence, null, 2))
console.log(JSON.stringify(evidence))
}
main().catch(error => { console.error(error.message); process.exitCode = 1 }).finally(() => prisma.$disconnect())

View File

@ -14,6 +14,13 @@ import { runDetailGeneratorWorkerOnce } from '../lib/detail-generator/jobs'
import { readDetailFile } from '../lib/detail-generator/storage'
import { saveDetailBrandProfile } from '../lib/detail-generator/brand-profiles'
import { DETAIL_FONT_FAMILY, DETAIL_MODEL_DEFAULTS, type DetailDocument } from '../lib/detail-generator/types'
import { importAiModelEnvironment } from '../lib/ai-model-service'
import { parseAiModelCapabilityMetadata } from '../lib/ai-model-capabilities'
import dns from 'node:dns/promises'
import https from 'node:https'
import { syncBuiltinESMExports } from 'node:module'
import { EventEmitter } from 'node:events'
import { Writable } from 'node:stream'
async function main() {
const db = new URL(process.env.DATABASE_URL || '')
@ -36,6 +43,14 @@ async function main() {
await prisma.$executeRaw`DELETE FROM detail_generator_brand_profiles WHERE name='本地验收品牌'`
await assertDetailGeneratorUser(owners[0].id)
await assert.rejects(assertDetailGeneratorUser(owners[3].id), /超级管理员/)
if (!(await prisma.$queryRawUnsafe<Array<{ id: string }>>('SELECT id FROM ai_model_providers WHERE legacy_binding=TRUE')).length) await importAiModelEnvironment(owners[0].id)
// The guarded synthetic provider has explicit test evidence; it must not inherit real-provider capabilities by model name.
for (const stage of ['copy', 'image'] as const) {
const stages = stage === 'copy' ? ['analysis', 'copy'] : ['image']
const metadata = parseAiModelCapabilityMetadata({ input_modalities: ['text', 'image'], output_modalities: [stage === 'image' ? 'image' : 'text'], endpoints: [stage === 'image' ? '/images/edits' : '/chat/completions'] })
await prisma.$executeRawUnsafe(`UPDATE ai_model_catalog SET capability_metadata=$1::jsonb,stages=$2::text[],enabled=TRUE,compatibility='confirmed' WHERE model_id=$3 AND provider_id IN (SELECT id FROM ai_model_providers WHERE base_url='https://detail-provider.invalid/v1')`, JSON.stringify(metadata), stages, DETAIL_MODEL_DEFAULTS[stage])
}
await prisma.$executeRawUnsafe(`UPDATE ai_model_configuration SET default_analysis=(SELECT c.id FROM ai_model_catalog c JOIN ai_model_providers p ON p.id=c.provider_id WHERE p.legacy_binding=TRUE AND c.model_id=$1),default_copy=(SELECT c.id FROM ai_model_catalog c JOIN ai_model_providers p ON p.id=c.provider_id WHERE p.legacy_binding=TRUE AND c.model_id=$1),default_image=(SELECT c.id FROM ai_model_catalog c JOIN ai_model_providers p ON p.id=c.provider_id WHERE p.legacy_binding=TRUE AND c.model_id=$2) WHERE id='detail-generator'`, DETAIL_MODEL_DEFAULTS.analysis, DETAIL_MODEL_DEFAULTS.image)
const document = (): DetailDocument => ({
version: 1, name: '本地验收 · 产品详情页',
product: { identifierType: 'sku', identifier: 'TEST-SKU-001', variant: '验收款式', name: '本地验收产品',
@ -62,15 +77,14 @@ async function main() {
let imageCalls = 0
let returnUnknown = false
let revokeDuringAnalysis: string | null = null
const realFetch = globalThis.fetch
globalThis.fetch = async (input, init) => {
async function fakeProvider(input: string, init: { headers: Record<string, string>; body: Buffer }) {
assert.ok(String(input).startsWith('https://detail-provider.invalid/'), 'No live provider calls')
assert.ok(new Headers(init?.headers).get('Idempotency-Key'))
providerCalls++
if (returnUnknown) return Response.json({ error: { code: 'request_outcome_unknown' } }, { status: 503, headers: { 'x-request-id': 'test-unknown-request' } })
if (String(input).endsWith('/images/edits')) {
imageCalls++
assert.ok(init?.body instanceof FormData)
assert.ok(init.body.includes(Buffer.from('Content-Disposition: form-data;')), 'Actual streamed multipart reaches the fake HTTPS socket')
return Response.json({ data: [{ b64_json: image.toString('base64') }] })
}
const body = JSON.parse(String(init?.body))
@ -86,6 +100,27 @@ async function main() {
],
}) } }] })
}
// Test harness replaces the Node HTTPS socket and DNS only after the disposable DB guard.
// Production transport remains unchanged, including its public-address and dispatch checks.
const realLookup = dns.lookup
const realRequest = https.request
dns.lookup = (async hostname => { assert.equal(hostname, 'detail-provider.invalid'); return [{ address: '93.184.216.34', family: 4 }] }) as typeof dns.lookup
https.request = ((target: URL, options: any, onResponse: (response: any) => void) => {
assert.equal(target.hostname, 'detail-provider.invalid', 'No live provider sockets')
const chunks: Buffer[] = []
return new Writable({
write(chunk, _encoding, callback) { chunks.push(Buffer.from(chunk)); callback() },
final(callback) {
fakeProvider(target.href, { headers: options.headers, body: Buffer.concat(chunks) }).then(async response => {
const incoming = new EventEmitter() as EventEmitter & { statusCode: number; headers: Record<string, string>; destroy: () => void }
incoming.statusCode = response.status; incoming.headers = Object.fromEntries(response.headers.entries()); incoming.destroy = () => undefined
onResponse(incoming)
incoming.emit('data', Buffer.from(await response.arrayBuffer())); incoming.emit('end'); callback()
}).catch(callback)
},
})
}) as typeof https.request
syncBuiltinESMExports()
try {
const key = randomUUID()
const job = await createDetailAction(owners[0].id, project.id, 'analyze', { idempotencyKey: key })
@ -223,6 +258,6 @@ async function main() {
assert.equal(queue.length, 3)
console.log(JSON.stringify({ passed: ['DB super-admin gate', 'creator isolation', 'uploads/documents', 'analyze+approve+generate', 'human outline draft save+restore', 'blank analysis forgery denied', 'reset facts+reanalyze', 'approved source invalidation', 'idempotency', 'text edit without image call', 'brand draft snapshot+conflict', '4 export formats', 'single-screen regeneration', 'unknown no replay', 'revocation before next call', 'concurrent max2', 'expired dispatch no replay'], providerCalls, realProviderCalls: 0, previewEmail: owners[0].email, projectId: current.id, outlineProjectId: workflow.id, outlineEmail: owners[2].email }))
await writeFile('output/detail-generator/preview.json', JSON.stringify({ email: owners[0].email, password, projectId: current.id, outlineProjectId: workflow.id, outlineEmail: owners[2].email }))
} finally { globalThis.fetch = realFetch; await prisma.$disconnect() }
} finally { dns.lookup = realLookup; https.request = realRequest; syncBuiltinESMExports(); await prisma.$disconnect() }
}
main().catch(error => { console.error(error); process.exitCode = 1 })