gongxiangp2/scripts/verify-detail-generator.ts

264 lines
20 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/** Disposable local PostgreSQL acceptance. Never connects to the production database. */
import assert from 'node:assert/strict'
import { randomUUID } from 'node:crypto'
import { mkdir, writeFile } from 'node:fs/promises'
import path from 'node:path'
import bcrypt from 'bcryptjs'
import sharp from 'sharp'
import { readPsd } from 'ag-psd'
import { prisma } from '../lib/prisma'
import { assertDetailGeneratorUser } from '../lib/detail-generator/access'
import { approveDetailAnalysis, createDetailAction, editDetailProject, editDetailScreen, getDetailProject, newDetailProject, uploadDetailAsset } from '../lib/detail-generator/service'
import { claimDetailJob, enqueueDetailJob, findDetailAsset, findDetailJob, findDetailProject, finishDetailJob } from '../lib/detail-generator/repository'
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 || '')
assert.equal(db.hostname, '127.0.0.1', 'Only disposable local PostgreSQL is permitted')
assert.equal(db.port, '15437')
assert.equal(db.pathname, '/detail_test')
process.env.DETAIL_GENERATOR_ENABLED = 'true'
process.env.DETAIL_GENERATOR_API_KEY = 'synthetic-test-key-no-provider-spend'
process.env.DETAIL_GENERATOR_BASE_URL = 'https://detail-provider.invalid/v1'
process.env.DETAIL_GENERATOR_STORAGE_DIR = path.resolve('output/detail-generator/private')
const password = 'LocalDetailPreviewOnly-20261012'
const hashed = await bcrypt.hash(password, 4)
const owners = await Promise.all([0, 1, 2, 3].map(async index => prisma.user.upsert({
where: { email: `detail-test-${index}@example.invalid` },
update: { isSuperAdmin: index !== 3, status: 'active', isActive: true },
create: { email: `detail-test-${index}@example.invalid`, username: `详情页本地验收${index}`, password: hashed,
isSuperAdmin: index !== 3, role: 'ADMIN', status: 'active', isActive: true },
})))
for (const owner of owners) await prisma.$executeRaw`DELETE FROM detail_generator_projects WHERE owner_id=${owner.id}`
await prisma.$executeRaw`DELETE FROM detail_generator_brand_profiles WHERE name='本地验收品牌'`
await assertDetailGeneratorUser(owners[0].id)
await assert.rejects(assertDetailGeneratorUser(owners[3].id), /超级管理员/)
if (!(await prisma.$queryRawUnsafe<Array<{ id: string }>>('SELECT id FROM ai_model_providers WHERE legacy_binding=TRUE')).length) await importAiModelEnvironment(owners[0].id)
// The guarded synthetic provider has explicit test evidence; it must not inherit real-provider capabilities by model name.
for (const stage of ['copy', 'image'] as const) {
const stages = stage === 'copy' ? ['analysis', 'copy'] : ['image']
const metadata = parseAiModelCapabilityMetadata({ input_modalities: ['text', 'image'], output_modalities: [stage === 'image' ? 'image' : 'text'], endpoints: [stage === 'image' ? '/images/edits' : '/chat/completions'] })
await prisma.$executeRawUnsafe(`UPDATE ai_model_catalog SET capability_metadata=$1::jsonb,stages=$2::text[],enabled=TRUE,compatibility='confirmed' WHERE model_id=$3 AND provider_id IN (SELECT id FROM ai_model_providers WHERE base_url='https://detail-provider.invalid/v1')`, JSON.stringify(metadata), stages, DETAIL_MODEL_DEFAULTS[stage])
}
await prisma.$executeRawUnsafe(`UPDATE ai_model_configuration SET default_analysis=(SELECT c.id FROM ai_model_catalog c JOIN ai_model_providers p ON p.id=c.provider_id WHERE p.legacy_binding=TRUE AND c.model_id=$1),default_copy=(SELECT c.id FROM ai_model_catalog c JOIN ai_model_providers p ON p.id=c.provider_id WHERE p.legacy_binding=TRUE AND c.model_id=$1),default_image=(SELECT c.id FROM ai_model_catalog c JOIN ai_model_providers p ON p.id=c.provider_id WHERE p.legacy_binding=TRUE AND c.model_id=$2) WHERE id='detail-generator'`, DETAIL_MODEL_DEFAULTS.analysis, DETAIL_MODEL_DEFAULTS.image)
const document = (): DetailDocument => ({
version: 1, name: '本地验收 · 产品详情页',
product: { identifierType: 'sku', identifier: 'TEST-SKU-001', variant: '验收款式', name: '本地验收产品',
facts: '仅用于测试:木色外观、圆角设计。没有提供尺寸、材质、承重和认证。', assetIds: [], referenceAssetIds: [], documentAssetIds: [] },
brand: { name: '本地验收品牌', tone: '自然、简洁,真实产品摄影', primaryColor: '#415947', backgroundColor: '#ffffff', textColor: '#101828', fontFamily: DETAIL_FONT_FAMILY, status: 'draft' },
models: { ...DETAIL_MODEL_DEFAULTS }, analysis: null, approvedAt: null, screens: [],
})
const image = await sharp(Buffer.from('<svg width="750" height="750" xmlns="http://www.w3.org/2000/svg"><rect width="750" height="750" fill="#f4f0e8"/><rect x="210" y="190" width="330" height="330" rx="32" fill="#aa855a"/><path d="M240 520v100M510 520v100" stroke="#785a3b" stroke-width="30"/></svg>')).png().toBuffer()
async function inputProject(ownerId: string) {
let project = await newDetailProject(ownerId, document())
for (const kind of ['product', 'reference', 'document'] as const) {
const form = new FormData()
form.set('kind', kind); form.set('revision', String(project.revision))
const bytes = kind === 'document' ? Buffer.from('测试产品资料:木色外观、圆角设计。禁止编造尺寸、认证或材质。') : image
form.set('file', new Blob([new Uint8Array(bytes)], { type: kind === 'document' ? 'text/plain' : 'image/png' }), kind === 'document' ? '产品资料.txt' : `${kind}.png`)
const response = await uploadDetailAsset(ownerId, project.id, new Request('http://localhost/upload', { method: 'POST', body: form }))
project = response.project
}
return project
}
const project = await inputProject(owners[0].id)
await assert.rejects(findDetailProject(project.id, owners[1].id), /项目不存在/)
let providerCalls = 0
let imageCalls = 0
let returnUnknown = false
let revokeDuringAnalysis: string | null = null
async function fakeProvider(input: string, init: { headers: Record<string, string>; body: Buffer }) {
assert.ok(String(input).startsWith('https://detail-provider.invalid/'), 'No live provider calls')
assert.ok(new Headers(init?.headers).get('Idempotency-Key'))
providerCalls++
if (returnUnknown) return Response.json({ error: { code: 'request_outcome_unknown' } }, { status: 503, headers: { 'x-request-id': 'test-unknown-request' } })
if (String(input).endsWith('/images/edits')) {
imageCalls++
assert.ok(init.body.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))
const isAnalysis = Array.isArray(body.messages[0].content)
if (isAnalysis) {
assert.ok(JSON.stringify(body).includes('测试产品资料'), 'Documents included in analysis')
if (revokeDuringAnalysis) await prisma.user.update({ where: { id: revokeDuringAnalysis }, data: { isSuperAdmin: false } })
}
return Response.json({ choices: [{ message: { content: JSON.stringify(isAnalysis ? { facts: ['木色外观', '圆角设计'], warnings: ['未提供材质和尺寸'] } : {
sellingPoints: ['木色外观', '圆角设计'], warnings: ['未提供材质和尺寸'], screens: [
{ title: '木色,自然融入空间', body: '本地验收示例。展示真实外观与圆角细节。', imagePrompt: '简洁自然背景,保持产品款式' },
{ title: '圆角细节', body: '本地验收示例。呈现可见细节。', imagePrompt: '近距离展示实际圆角细节' },
],
}) } }] })
}
// Test harness replaces the Node HTTPS socket and DNS only after the disposable DB guard.
// Production transport remains unchanged, including its public-address and dispatch checks.
const realLookup = dns.lookup
const realRequest = https.request
dns.lookup = (async hostname => { assert.equal(hostname, 'detail-provider.invalid'); return [{ address: '93.184.216.34', family: 4 }] }) as typeof dns.lookup
https.request = ((target: URL, options: any, onResponse: (response: any) => void) => {
assert.equal(target.hostname, 'detail-provider.invalid', 'No live provider sockets')
const chunks: Buffer[] = []
return new Writable({
write(chunk, _encoding, callback) { chunks.push(Buffer.from(chunk)); callback() },
final(callback) {
fakeProvider(target.href, { headers: options.headers, body: Buffer.concat(chunks) }).then(async response => {
const incoming = new EventEmitter() as EventEmitter & { statusCode: number; headers: Record<string, string>; destroy: () => void }
incoming.statusCode = response.status; incoming.headers = Object.fromEntries(response.headers.entries()); incoming.destroy = () => undefined
onResponse(incoming)
incoming.emit('data', Buffer.from(await response.arrayBuffer())); incoming.emit('end'); callback()
}).catch(callback)
},
})
}) as typeof https.request
syncBuiltinESMExports()
try {
const key = randomUUID()
const job = await createDetailAction(owners[0].id, project.id, 'analyze', { idempotencyKey: key })
assert.equal((await createDetailAction(owners[0].id, project.id, 'analyze', { idempotencyKey: key })).id, job.id)
await assert.rejects(createDetailAction(owners[0].id, project.id, 'analyze', { idempotencyKey: randomUUID() }), /已有任务/)
await runDetailGeneratorWorkerOnce()
assert.equal((await findDetailJob(job.id, owners[0].id)).status, 'succeeded')
let current = (await getDetailProject(owners[0].id, project.id)).project
const humanDraft = structuredClone(current.document)
assert.ok(humanDraft.analysis)
humanDraft.analysis.screens[0].title = '人工核对:木色自然融入空间'
humanDraft.analysis.sellingPoints[0] = '人工核对后的木色外观'
const beforeDraftSave = providerCalls
current = await editDetailProject(owners[0].id, current.id, { document: humanDraft, revision: current.revision })
current = (await getDetailProject(owners[0].id, current.id)).project
assert.deepEqual(current.document.analysis, humanDraft.analysis)
assert.equal(current.document.approvedAt, null)
assert.equal(providerCalls, beforeDraftSave, 'Saving an unapproved human draft must not call a model')
current = await approveDetailAnalysis(owners[0].id, project.id, { analysis: current.document.analysis, revision: current.revision })
const generation = await createDetailAction(owners[0].id, project.id, 'generate', { idempotencyKey: randomUUID() })
await runDetailGeneratorWorkerOnce()
assert.equal((await findDetailJob(generation.id, owners[0].id)).status, 'succeeded')
assert.equal(imageCalls, 2)
current = (await getDetailProject(owners[0].id, project.id)).project
const previousCalls = providerCalls
const screen = current.document.screens[0]
const edited = { ...screen, body: '修改后的中文文案\n本地验收,不调用生图。', texts: screen.texts.map(layer => layer.id.endsWith('-body') ? { ...layer, text: '修改后的中文文案\n本地验收,不调用生图。' } : layer) }
current = await editDetailScreen(owners[0].id, current.id, edited.id, { screen: edited, revision: current.revision })
assert.equal(providerCalls, previousCalls)
const style = await saveDetailBrandProfile(current.document.brand, undefined)
await saveDetailBrandProfile({ ...style.profile, tone: '修改后的品牌草案' }, style.revision)
await assert.rejects(saveDetailBrandProfile(style.profile, style.revision), /已更新/)
assert.equal(current.document.brand.tone, '自然、简洁,真实产品摄影')
await mkdir('output/detail-generator', { recursive: true })
for (const format of ['png', 'jpg', 'long-png', 'psd'] as const) {
const exportJob = await createDetailAction(owners[0].id, current.id, 'export', { format, idempotencyKey: randomUUID() })
await runDetailGeneratorWorkerOnce()
const result = await findDetailJob(exportJob.id, owners[0].id)
assert.equal(result.status, 'succeeded', result.error || undefined)
const asset = await findDetailAsset(String(result.checkpoint.exportAssetId), owners[0].id)
await assert.rejects(findDetailAsset(asset.id, owners[1].id), /文件不存在/)
const bytes = await readDetailFile(asset.storageKey)
await writeFile(path.join('output/detail-generator', asset.name), bytes)
if (format === 'psd') {
const psd = readPsd(bytes, { skipLayerImageData: true, skipCompositeImageData: true, skipThumbnail: true })
assert.equal(psd.children?.length, 2)
assert.ok(JSON.stringify(psd).includes('修改后的中文文案'))
}
}
const untouchedImage = current.document.screens[1].image?.assetId
const regenerate = await createDetailAction(owners[0].id, current.id, 'regenerate', { idempotencyKey: randomUUID() }, screen.id)
await runDetailGeneratorWorkerOnce()
assert.equal((await findDetailJob(regenerate.id, owners[0].id)).status, 'succeeded')
current = (await getDetailProject(owners[0].id, current.id)).project
assert.equal(current.document.screens[1].image?.assetId, untouchedImage)
assert.equal(imageCalls, 3)
// Separate projects exercise draft reset without altering the generated browser preview.
const blankProject = await newDetailProject(owners[2].id, document())
const forged = { ...blankProject.document, analysis: structuredClone(current.document.analysis), approvedAt: new Date().toISOString() }
const beforeForged = providerCalls
await assert.rejects(editDetailProject(owners[2].id, blankProject.id, { document: forged, revision: blankProject.revision }), /请先分析产品资料/)
assert.equal(providerCalls, beforeForged)
assert.equal((await getDetailProject(owners[2].id, blankProject.id)).project.document.analysis, null)
let workflow = await inputProject(owners[2].id)
const workflowAnalysis = await createDetailAction(owners[2].id, workflow.id, 'analyze', { idempotencyKey: randomUUID() })
await runDetailGeneratorWorkerOnce()
assert.equal((await findDetailJob(workflowAnalysis.id, owners[2].id)).status, 'succeeded')
workflow = (await getDetailProject(owners[2].id, workflow.id)).project
workflow = await approveDetailAnalysis(owners[2].id, workflow.id, { analysis: workflow.document.analysis, revision: workflow.revision })
assert.ok(workflow.document.approvedAt)
assert.ok(workflow.document.screens.length)
const resetDraft = structuredClone(workflow.document)
resetDraft.analysis = null
resetDraft.product.facts = '人工修正后的测试产品资料:木色外观、圆角设计;其余参数未提供。'
workflow = await editDetailProject(owners[2].id, workflow.id, { document: resetDraft, revision: workflow.revision })
assert.equal(workflow.document.analysis, null)
assert.equal(workflow.document.approvedAt, null)
assert.deepEqual(workflow.document.screens, [])
const reanalysis = await createDetailAction(owners[2].id, workflow.id, 'analyze', { idempotencyKey: randomUUID() })
await runDetailGeneratorWorkerOnce()
assert.equal((await findDetailJob(reanalysis.id, owners[2].id)).status, 'succeeded')
workflow = (await getDetailProject(owners[2].id, workflow.id)).project
assert.ok(workflow.document.analysis)
assert.equal(workflow.document.approvedAt, null)
workflow = await approveDetailAnalysis(owners[2].id, workflow.id, { analysis: workflow.document.analysis, revision: workflow.revision })
const changedSource = structuredClone(workflow.document)
changedSource.brand.tone = '修改后的测试品牌风格草案'
const beforeSourceChange = providerCalls
workflow = await editDetailProject(owners[2].id, workflow.id, { document: changedSource, revision: workflow.revision })
assert.equal(workflow.document.analysis, null)
assert.equal(workflow.document.approvedAt, null)
assert.deepEqual(workflow.document.screens, [])
assert.equal(providerCalls, beforeSourceChange)
const outlinePreviewJob = await createDetailAction(owners[2].id, workflow.id, 'analyze', { idempotencyKey: randomUUID() })
await runDetailGeneratorWorkerOnce()
assert.equal((await findDetailJob(outlinePreviewJob.id, owners[2].id)).status, 'succeeded')
workflow = (await getDetailProject(owners[2].id, workflow.id)).project
assert.ok(workflow.document.analysis)
assert.equal(workflow.document.approvedAt, null)
assert.deepEqual(workflow.document.screens, [])
const unknownProject = await inputProject(owners[1].id)
returnUnknown = true
const unknownJob = await createDetailAction(owners[1].id, unknownProject.id, 'analyze', { idempotencyKey: randomUUID() })
const beforeUnknown = providerCalls
await runDetailGeneratorWorkerOnce()
assert.equal((await findDetailJob(unknownJob.id, owners[1].id)).status, 'unknown')
await runDetailGeneratorWorkerOnce()
assert.equal(providerCalls, beforeUnknown + 1)
await assert.rejects(createDetailAction(owners[1].id, unknownProject.id, 'analyze', { idempotencyKey: randomUUID() }), /结果未知/)
returnUnknown = false
const revokedProject = await inputProject(owners[2].id)
revokeDuringAnalysis = owners[2].id
const revokedJob = await createDetailAction(owners[2].id, revokedProject.id, 'analyze', { idempotencyKey: randomUUID() })
const beforeRevoked = providerCalls
await runDetailGeneratorWorkerOnce()
assert.equal((await findDetailJob(revokedJob.id, owners[2].id)).status, 'failed')
assert.equal(providerCalls, beforeRevoked + 1, 'No copy/image call after revocation')
await assert.rejects(assertDetailGeneratorUser(owners[2].id), /超级管理员/)
await prisma.user.update({ where: { id: owners[2].id }, data: { isSuperAdmin: true } })
revokeDuringAnalysis = null
// Concurrent claims prove the limit under real PostgreSQL locks.
const queueProjects = await Promise.all(owners.slice(0, 3).map(owner => newDetailProject(owner.id, document())))
const queue = await Promise.all(queueProjects.map((item, i) => enqueueDetailJob({ projectId: item.id, ownerId: owners[i].id, kind: 'export', revision: item.revision, idempotencyKey: randomUUID(), payload: { format: 'png' } })))
const claimed = (await Promise.all([claimDetailJob(), claimDetailJob(), claimDetailJob()])).filter(item => item !== null)
assert.equal(claimed.length, 2)
assert.equal(new Set(claimed.map(item => item.ownerId)).size, 2)
for (const item of claimed) await finishDetailJob(item.id, item.leaseToken!, 'failed', {}, 'Synthetic capacity test')
const last = await claimDetailJob()
assert.ok(last)
await prisma.$executeRaw`UPDATE detail_generator_jobs SET checkpoint='{"dispatchPending":true}'::jsonb,lease_expires_at=NOW()-INTERVAL '1 second' WHERE id=${last.id}`
assert.equal(await claimDetailJob(), null)
assert.equal((await findDetailJob(last.id, last.ownerId)).status, 'unknown')
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 { dns.lookup = realLookup; https.request = realRequest; syncBuiltinESMExports(); await prisma.$disconnect() }
}
main().catch(error => { console.error(error); process.exitCode = 1 })