diff --git a/.impeccable/design.json b/.impeccable/design.json index 30a4dba..95a1bb8 100644 --- a/.impeccable/design.json +++ b/.impeccable/design.json @@ -511,6 +511,13 @@ "description": "详情页复用的 detailPrimaryClass,用于分析、确认生成、继续生成与导出;保持既有 Action Blue,悬停变体仅限该控件。", "html": "", "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": "
阶段默认模型

用户可自行选择已开放的模型;不支持的用途不可勾选。

", + "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": { diff --git a/DESIGN.md b/DESIGN.md index ff54dfa..8e31d61 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -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: diff --git a/Dockerfile b/Dockerfile index fc81509..43e1c2b 100644 --- a/Dockerfile +++ b/Dockerfile @@ -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 diff --git a/app/api/admin/ai-models/configuration/route.ts b/app/api/admin/ai-models/configuration/route.ts new file mode 100644 index 0000000..eead27a --- /dev/null +++ b/app/api/admin/ai-models/configuration/route.ts @@ -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))) } diff --git a/app/api/admin/ai-models/import-environment/route.ts b/app/api/admin/ai-models/import-environment/route.ts new file mode 100644 index 0000000..f53d765 --- /dev/null +++ b/app/api/admin/ai-models/import-environment/route.ts @@ -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)) } diff --git a/app/api/admin/ai-models/providers/[id]/route.ts b/app/api/admin/ai-models/providers/[id]/route.ts new file mode 100644 index 0000000..5a7e8a4 --- /dev/null +++ b/app/api/admin/ai-models/providers/[id]/route.ts @@ -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))) } diff --git a/app/api/admin/ai-models/providers/[id]/sync/route.ts b/app/api/admin/ai-models/providers/[id]/sync/route.ts new file mode 100644 index 0000000..b5ffc49 --- /dev/null +++ b/app/api/admin/ai-models/providers/[id]/sync/route.ts @@ -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))) } diff --git a/app/api/admin/ai-models/providers/route.ts b/app/api/admin/ai-models/providers/route.ts new file mode 100644 index 0000000..a547af9 --- /dev/null +++ b/app/api/admin/ai-models/providers/route.ts @@ -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))) } diff --git a/app/api/admin/ai-models/route.ts b/app/api/admin/ai-models/route.ts new file mode 100644 index 0000000..a9992ea --- /dev/null +++ b/app/api/admin/ai-models/route.ts @@ -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()) } diff --git a/components/SuperAdminsTab.tsx b/components/SuperAdminsTab.tsx index b254d96..04aa728 100644 --- a/components/SuperAdminsTab.tsx +++ b/components/SuperAdminsTab.tsx @@ -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('notificationCenter') + const [aiModelsState, setAiModelsState] = useState({ dirty: false, busy: false }) + const { confirmAction, confirmActionDialog } = useConfirmActionDialog() const [superAdmins, setSuperAdmins] = useState([]) 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(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 (
+ {confirmActionDialog}

超级管理员工作台 @@ -698,7 +713,7 @@ export default function SuperAdminsTab({ showToast }: SuperAdminsTabProps) {
+ {activeWorkspace === 'aiModels' && } {activeWorkspace === 'access' && (
diff --git a/components/admin/AiModelManagementPanel.test.tsx b/components/admin/AiModelManagementPanel.test.tsx new file mode 100644 index 0000000..9f3cc5d --- /dev/null +++ b/components/admin/AiModelManagementPanel.test.tsx @@ -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() + 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() + 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() + 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() + 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((resolve) => { finish = resolve }) : Promise.resolve(response(configured()))) as typeof fetch + render() + 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() + 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() + 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() + 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() + 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() + 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() + 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() + 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() + 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() +}) diff --git a/components/admin/AiModelManagementPanel.tsx b/components/admin/AiModelManagementPanel.tsx new file mode 100644 index 0000000..9e58391 --- /dev/null +++ b/components/admin/AiModelManagementPanel.tsx @@ -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 { + 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(null) + const [draft, setDraft] = useState(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(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, 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) { + 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) { + 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
+ if (!draft) return

{error || 'AI 模型配置未能加载'}

+ + return
+
+

AI 模型接入

+
+ {draft.legacyAvailable && } + +
+
+ {!draft.encryptionConfigured &&

服务端尚未配置密钥加密,暂不能新增或替换 API Key。

} + {(error || notice) &&

{error || notice}

} +
+

上游连接

+ {!draft.providers.length && !form &&

{draft.legacyAvailable ? '迁入当前连接,或新增上游后同步模型目录。' : '新增上游后同步模型目录。'}

} +
+ {draft.providers.map((provider) =>
+
{provider.name}{provider.enabled ? '已启用' : '已停用'}

{provider.baseUrl}

{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' })}` : ' · 尚未同步'}

+
+
)} +
+ {form &&
+
+ + {formError &&

{formError}

} +
+
} +
+
+

用户可选模型

{dirty && 有未保存修改}
+
+ 阶段默认模型 + {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 + })} +
+ {!!rows.length && <> +
+

用户可自行选择已开放的模型;不支持的用途不可勾选。

+ +
+ {!visibleRows.length &&

没有匹配模型

} + {pageRows.map(({ provider, model }) =>
+

{model.id}

{capabilityLabels[model.capabilities.kind]}{model.capabilities.kind !== 'unknown' && !model.capabilities.allowedStages.length ? ' · 待适配' : ''} · {provider.name}{!provider.enabled ? ' · 上游已停用' : ''}{!model.present ? ' · 已离开上游目录' : ''}

{model.capabilities.reason}
+ +
{model.id} 的用户可选用途{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 + })}
+ +
)} +
+ + } +
+
+} diff --git a/components/detail-generator/DetailGeneratorWorkspace.test.tsx b/components/detail-generator/DetailGeneratorWorkspace.test.tsx index c443704..baf268c 100644 --- a/components/detail-generator/DetailGeneratorWorkspace.test.tsx +++ b/components/detail-generator/DetailGeneratorWorkspace.test.tsx @@ -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() + 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() + 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() + 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() + 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' diff --git a/components/detail-generator/DetailGeneratorWorkspace.tsx b/components/detail-generator/DetailGeneratorWorkspace.tsx index 9dafd2e..cc4f73a 100644 --- a/components/detail-generator/DetailGeneratorWorkspace.tsx +++ b/components/detail-generator/DetailGeneratorWorkspace.tsx @@ -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() 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() {

详情页生成

内测
{dirty && 有未保存修改}
{(error || notice) &&
{error || notice}{error && }
} {!config?.configured &&

模型服务尚未配置。可先准备项目资料。

} + {config?.configured && (!analysisAvailable || !imageAvailable) &&

项目模型当前不可用。可修改模型选择,已有内容仍可编辑和导出。

}
{activeJob &&
} {unknownJob &&

模型结果待确认

{unknownJob.error || '请求已经发出,结果尚未确认。请勿重复生成。'}

{unknownJob.requestId &&

请求编号:{unknownJob.requestId}

}
} {latestFailure && !activeJob && !unknownJob &&

{jobLabel[latestFailure.kind]}未完成:{latestFailure.error || '请检查项目资料后重试'}

} - {!draft.analysis && !draft.approvedAt ? <>{matches &&

匹配素材

{!matches.length &&

没有找到当前可用的素材,可直接上传产品图。

}{matches.map((match) =>

{match.variant || match.skuCode || match.productModel}

{match.assets.map((asset) => )}
)}
}
: !draft.approvedAt && draft.analysis ? <> changeDocument({ ...draft, analysis })} />
: <> !!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 &&
}} + {draft.analysis &&
} + {!draft.analysis && !draft.approvedAt ? <>{matches &&

匹配素材

{!matches.length &&

没有找到当前可用的素材,可直接上传产品图。

}{matches.map((match) =>

{match.variant || match.skuCode || match.productModel}

{match.assets.map((asset) => )}
)}
}
: !draft.approvedAt && draft.analysis ? <> changeDocument({ ...draft, analysis })} />
: <> !!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 &&
}} {!!exportJobs.length && } - {dirty && !unavailable &&
} + {dirty && !unavailable &&
}
diff --git a/components/detail-generator/DetailProductForm.tsx b/components/detail-generator/DetailProductForm.tsx index 04366cd..d076827 100644 --- a/components/detail-generator/DetailProductForm.tsx +++ b/components/detail-generator/DetailProductForm.tsx @@ -79,10 +79,25 @@ export default function DetailProductForm({ document, config, assets, busy, onCh
{([{ key: 'primaryColor', label: '品牌色' }, { key: 'backgroundColor', label: '背景色' }, { key: 'textColor', label: '文字色' }] as const).map((color) => )}
-
- 高级选项 -
{([{ key: 'analysis', label: '资料分析' }, { key: 'copy', label: '卖点文案' }, { key: 'image', label: '图片生成' }] as const).map((stage) => )}
-
+
) } + +export function DetailModelOptions({ document, config, busy, onChange }: { + document: DetailDocument + config: DetailConfig | null + busy: boolean + onChange: (document: DetailDocument) => void +}) { + return
+ 模型选择 +
{([{ 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 + })}
+
+} diff --git a/components/detail-generator/client.ts b/components/detail-generator/client.ts index 04c6021..e5c9b36 100644 --- a/components/detail-generator/client.ts +++ b/components/detail-generator/client.ts @@ -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(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): 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 { diff --git a/docs/ai-model-management.md b/docs/ai-model-management.md new file mode 100644 index 0000000..d175782 --- /dev/null +++ b/docs/ai-model-management.md @@ -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`。目录同步为只读查询,脚本不提交生成任务。 diff --git a/docs/detail-generator.md b/docs/detail-generator.md index 5c88191..7df28e4 100644 --- a/docs/detail-generator.md +++ b/docs/detail-generator.md @@ -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、项目文档、浏览器或任务记录。 ## 任务与故障处理 diff --git a/docs/生产数据库安全增量迁移SOP-2026-08-12.md b/docs/生产数据库安全增量迁移SOP-2026-08-12.md index 1e37558..30d0b2e 100644 --- a/docs/生产数据库安全增量迁移SOP-2026-08-12.md +++ b/docs/生产数据库安全增量迁移SOP-2026-08-12.md @@ -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。 diff --git a/lib/__tests__/ai-model-capabilities.test.ts b/lib/__tests__/ai-model-capabilities.test.ts new file mode 100644 index 0000000..0e3b095 --- /dev/null +++ b/lib/__tests__/ai-model-capabilities.test.ts @@ -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, 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: [] }) +}) diff --git a/lib/__tests__/ai-model-security.test.ts b/lib/__tests__/ai-model-security.test.ts new file mode 100644 index 0000000..4d1f37b --- /dev/null +++ b/lib/__tests__/ai-model-security.test.ts @@ -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') +}) diff --git a/lib/__tests__/ai-model-service.test.ts b/lib/__tests__/ai-model-service.test.ts new file mode 100644 index 0000000..975c966 --- /dev/null +++ b/lib/__tests__/ai-model-service.test.ts @@ -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) +}) diff --git a/lib/__tests__/detail-generator-backend.test.ts b/lib/__tests__/detail-generator-backend.test.ts index 56e6468..9bd81cf 100644 --- a/lib/__tests__/detail-generator-backend.test.ts +++ b/lib/__tests__/detail-generator-backend.test.ts @@ -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({ start(controller) { controller.enqueue(new Uint8Array(16)); controller.enqueue(new Uint8Array(16)); controller.close() } }) await expect(readDetailResponseBytes(new Response(stream), 20)).rejects.toThrow('响应过大') diff --git a/lib/__tests__/detail-generator-jobs.test.ts b/lib/__tests__/detail-generator-jobs.test.ts index 892c978..cee3878 100644 --- a/lib/__tests__/detail-generator-jobs.test.ts +++ b/lib/__tests__/detail-generator-jobs.test.ts @@ -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) }) diff --git a/lib/__tests__/detail-generator-service.test.ts b/lib/__tests__/detail-generator-service.test.ts index 1935551..f63ba9c 100644 --- a/lib/__tests__/detail-generator-service.test.ts +++ b/lib/__tests__/detail-generator-service.test.ts @@ -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 }) diff --git a/lib/ai-model-access.ts b/lib/ai-model-access.ts new file mode 100644 index 0000000..1ef1a36 --- /dev/null +++ b/lib/ai-model-access.ts @@ -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 { + 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): Promise { + 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' } }) + } +} diff --git a/lib/ai-model-capabilities.ts b/lib/ai-model-capabilities.ts new file mode 100644 index 0000000..5a7b816 --- /dev/null +++ b/lib/ai-model-capabilities.ts @@ -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 | null { return value && typeof value === 'object' && !Array.isArray(value) ? value as Record : 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 { + 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() + 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 => 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' ? '当前详情页不支持该模型类型' : '上游能力尚不匹配当前详情页接口' } +} diff --git a/lib/ai-model-crypto.ts b/lib/ai-model-crypto.ts new file mode 100644 index 0000000..0e4fba0 --- /dev/null +++ b/lib/ai-model-crypto.ts @@ -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') } +} diff --git a/lib/ai-model-http.ts b/lib/ai-model-http.ts new file mode 100644 index 0000000..53a0428 --- /dev/null +++ b/lib/ai-model-http.ts @@ -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> +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> { + const host = new URL(normalizeAiModelBaseUrl(baseUrl)).hostname.replace(/^\[|\]$/g, '') + let addresses: Array<{ address: string; family: number }> + let timeout: ReturnType | undefined + try { + addresses = await Promise.race([resolver(host), new Promise((_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): Promise { + 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): Promise { + 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, catalog: boolean, body?: string | FormData, beforeRequest?: () => Promise): Promise { + 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((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() + }) +} diff --git a/lib/ai-model-repository.ts b/lib/ai-model-repository.ts new file mode 100644 index 0000000..c2cf8e6 --- /dev/null +++ b/lib/ai-model-repository.ts @@ -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 { 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 { + return db.$queryRawUnsafe(`SELECT ${providerColumns} FROM ai_model_providers ORDER BY created_at, id`) +} +export async function getAiModelProviderRow(id: string, db: AiModelDatabase = prisma): Promise { + const rows = await db.$queryRawUnsafe(`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 { + return db.$queryRawUnsafe(`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 { + const rows = await db.$queryRawUnsafe>(`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(action: (db: Prisma.TransactionClient) => Promise): Promise { + 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 { + 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 { + 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 { + 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): Promise { + // 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 } } diff --git a/lib/ai-model-service.ts b/lib/ai-model-service.ts new file mode 100644 index 0000000..d170652 --- /dev/null +++ b/lib/ai-model-service.ts @@ -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(schema: z.ZodType, 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 { + 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 { + 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 { + 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 { + const output: Record = { 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)[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 { + 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() + 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 { + 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() + for (const entry of (value as { data: Array> }).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 { + 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 { + 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 { + 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): Promise { + 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 +} diff --git a/lib/ai-model-types.ts b/lib/ai-model-types.ts new file mode 100644 index 0000000..4e099e0 --- /dev/null +++ b/lib/ai-model-types.ts @@ -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 +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 + defaults: Record + modelOptions: Record + legacyModels: Record +} +export interface AiModelBinding { providerId: string; modelKey: string; modelId: string; providerName: string; baseUrl: string; revision: number } +export type AiModelBindings = Record diff --git a/lib/detail-generator/jobs.ts b/lib/detail-generator/jobs.ts index e9d7e9a..6ef425e 100644 --- a/lib/detail-generator/jobs.ts +++ b/lib/detail-generator/jobs.ts @@ -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 { const ids = [...document.product.assetIds, ...document.product.referenceAssetIds].slice(0, 8) @@ -72,6 +73,11 @@ export async function processDetailJob(current: StoredDetailJob): Promise 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 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 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') diff --git a/lib/detail-generator/provider.ts b/lib/detail-generator/provider.ts index fc82e34..a342cb6 100644 --- a/lib/detail-generator/provider.ts +++ b/lib/detail-generator/provider.ts @@ -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)[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 { + await captureAiModelBindings(models) } -export function isDetailProviderConfigured(): boolean { return Boolean(process.env.DETAIL_GENERATOR_API_KEY?.trim()) } +export async function isDetailProviderConfigured(): Promise { 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 { - 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 { + 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 { @@ -99,19 +79,17 @@ async function imageContent(images: DetailProviderImage[]) { }))) } -export async function analyzeDetailFacts(document: DetailDocument, images: DetailProviderImage[], extractedText: string, idempotencyKey: string): Promise { - assertDetailModels(document.models) +export async function analyzeDetailFacts(document: DetailDocument, images: DetailProviderImage[], extractedText: string, idempotencyKey: string, ownerId: string, expected?: AiModelBinding): Promise { 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 { - assertDetailModels(document.models) +export async function composeDetailOutline(document: DetailDocument, facts: string, idempotencyKey: string, ownerId: string, expected?: AiModelBinding): Promise { 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 { - assertDetailModels(document.models) +export async function generateDetailImage(document: DetailDocument, prompt: string, images: DetailProviderImage[], idempotencyKey: string, ownerId: string, expected?: AiModelBinding): Promise { 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') diff --git a/lib/detail-generator/service.ts b/lib/detail-generator/service.ts index 416cba6..88a3e48 100644 --- a/lib/detail-generator/service.ts +++ b/lib/detail-generator/service.ts @@ -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 { - 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) { 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 = {} 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('请补充产品名称和真实产品资料') diff --git a/lib/detail-generator/types.ts b/lib/detail-generator/types.ts index ad0225e..8b2f0b4 100644 --- a/lib/detail-generator/types.ts +++ b/lib/detail-generator/types.ts @@ -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 + legacyModels?: Record } diff --git a/lib/detail-generator/validation.ts b/lib/detail-generator/validation.ts index c022605..640a1ae 100644 --- a/lib/detail-generator/validation.ts +++ b/lib/detail-generator/validation.ts @@ -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: '屏幕编号重复' }) diff --git a/prisma/migrations/20261012_ai_model_registry/migration.sql b/prisma/migrations/20261012_ai_model_registry/migration.sql new file mode 100644 index 0000000..fc4b68d --- /dev/null +++ b/prisma/migrations/20261012_ai_model_registry/migration.sql @@ -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); diff --git a/prisma/migrations/20261013_ai_model_capabilities/migration.sql b/prisma/migrations/20261013_ai_model_capabilities/migration.sql new file mode 100644 index 0000000..100bbfe --- /dev/null +++ b/prisma/migrations/20261013_ai_model_capabilities/migration.sql @@ -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; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 2021921..9b36fb4 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -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") diff --git a/scripts/ai-model-migration.test.mjs b/scripts/ai-model-migration.test.mjs new file mode 100644 index 0000000..a23eaae --- /dev/null +++ b/scripts/ai-model-migration.test.mjs @@ -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/) +}) diff --git a/scripts/fixtures/ai-model-registry-evidence.mjs b/scripts/fixtures/ai-model-registry-evidence.mjs new file mode 100644 index 0000000..3b28e1d --- /dev/null +++ b/scripts/fixtures/ai-model-registry-evidence.mjs @@ -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'), + ], + } +} diff --git a/scripts/git-incremental-deploy.ps1 b/scripts/git-incremental-deploy.ps1 index 3ab40b9..2e3bd80 100644 --- a/scripts/git-incremental-deploy.ps1 +++ b/scripts/git-incremental-deploy.ps1 @@ -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) diff --git a/scripts/git-incremental-deploy.test.mjs b/scripts/git-incremental-deploy.test.mjs index 11c7c3a..75639f4 100644 --- a/scripts/git-incremental-deploy.test.mjs +++ b/scripts/git-incremental-deploy.test.mjs @@ -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;/) }) diff --git a/scripts/safe-production-migrate.mjs b/scripts/safe-production-migrate.mjs index 987266f..37c9b18 100644 --- a/scripts/safe-production-migrate.mjs +++ b/scripts/safe-production-migrate.mjs @@ -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) { diff --git a/scripts/safe-production-migrate.test.mjs b/scripts/safe-production-migrate.test.mjs index 9a18ed8..80deb50 100644 --- a/scripts/safe-production-migrate.test.mjs +++ b/scripts/safe-production-migrate.test.mjs @@ -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'") diff --git a/scripts/verify-ai-model-admin.ts b/scripts/verify-ai-model-admin.ts new file mode 100644 index 0000000..74abd0a --- /dev/null +++ b/scripts/verify-ai-model-admin.ts @@ -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 = { generationCalls: 0 } +async function session(index: number) { + const jar = new Map() + 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()) diff --git a/scripts/verify-ai-model-capabilities.ts b/scripts/verify-ai-model-capabilities.ts new file mode 100644 index 0000000..bb1d649 --- /dev/null +++ b/scripts/verify-ai-model-capabilities.ts @@ -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 = { generationCalls: 0 } + +async function signIn(index: number) { + const jar = new Map() + 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()) diff --git a/scripts/verify-detail-generator.ts b/scripts/verify-detail-generator.ts index d26ed75..bab3de5 100644 --- a/scripts/verify-detail-generator.ts +++ b/scripts/verify-detail-generator.ts @@ -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>('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; 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; 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 })