261 lines
19 KiB
TypeScript

import { randomUUID } from 'crypto'
import { spawn } from 'child_process'
import path from 'path'
import AdmZip from 'adm-zip'
import { inflateRawSync } from 'zlib'
import { isDeepStrictEqual } from 'util'
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 {
assertDetailDocumentAssets, attachDetailAsset, createDetailProject, detailPublicAsset, detailPublicJob,
enqueueDetailJob, findDetailProject, listDetailAssets, listDetailJobs, listDetailProjects, updateDetailProject,
} from './repository'
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'
export async function getDetailConfig(): Promise<DetailConfig> {
const [knowledge, brandProfiles] = await Promise.all([
prisma.brandKnowledge.findMany({ distinct: ['brand'], select: { brand: true }, where: { brand: { not: '' } }, take: 100 }),
listDetailBrandProfiles(),
])
return { access: true, configured: isDetailProviderConfigured(), models: getDetailModels(), defaults: DETAIL_MODEL_DEFAULTS,
brands: Array.from(new Set([...knowledge.map(value => value.brand), ...brandProfiles.map(value => value.profile.name)])).sort(), brandProfiles }
}
export async function getDetailHome(ownerId: string) { return { config: await getDetailConfig(), projects: await listDetailProjects(ownerId) } }
export async function getDetailProject(ownerId: string, id: string) {
const project = await findDetailProject(id, ownerId)
const [assets, jobs] = await Promise.all([listDetailAssets(id, ownerId), listDetailJobs(id, ownerId)])
return { project, assets: assets.map(detailPublicAsset), jobs: jobs.map(detailPublicJob) }
}
export async function newDetailProject(ownerId: string, input: unknown) {
const document = parseDetailDocument(input)
assertDetailModels(document.models)
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)
}
export async function editDetailProject(ownerId: string, id: string, body: Record<string, unknown>) {
const current = await findDetailProject(id, ownerId)
const document = parseDetailDocument(body.document)
assertDetailModels(document.models)
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) {
document.analysis = null; document.approvedAt = null; document.screens = []
} else if (!isDeepStrictEqual(document.analysis, current.document.analysis)) {
if (!current.document.analysis) throw new DetailGeneratorError('请先分析产品资料后再保存大纲')
if (current.document.approvedAt) throw new DetailGeneratorError('已确认的大纲须先返回资料步骤,再重新分析确认')
document.approvedAt = null; document.screens = []
}
await assertDetailDocumentAssets(document, id, ownerId)
return updateDetailProject(id, ownerId, parseRevision(body.revision), document)
}
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,
...(document.brand.logoAssetId ? { logo: { assetId: document.brand.logoAssetId, x: 600, y: 32, width: 100, height: 56 } } : {}),
texts: [
{ id: `${outline.id}-title`, text: outline.title, x: 48, y: 58, width: 654, fontSize: 48, lineHeight: 1.25, color: document.brand.textColor, fontFamily: DETAIL_FONT_FAMILY, align: 'left' },
{ id: `${outline.id}-body`, text: outline.body, x: 48, y: 182, width: 654, fontSize: 26, lineHeight: 1.5, color: document.brand.textColor, fontFamily: DETAIL_FONT_FAMILY, align: 'left' },
],
})
}
export async function approveDetailAnalysis(ownerId: string, id: string, body: Record<string, unknown>) {
const project = await findDetailProject(id, ownerId)
const parsed = detailAnalysisSchema.safeParse(body.analysis)
if (!project.document.analysis || !parsed.success) throw new DetailGeneratorError('请先分析产品资料并提供有效大纲')
const document = { ...project.document, analysis: parsed.data, approvedAt: new Date().toISOString() }
const existing = new Map(document.screens.map(screen => [screen.id, screen]))
try {
document.screens = parsed.data.screens.map(outline => {
const current = existing.get(outline.id)
if (!current) return createDetailScreen(outline, document)
return reflowDetailScreen({ ...current, ...outline })
})
} catch { throw new DetailGeneratorError('文案过长,请精简后确认;每屏高度最多 2400px') }
await assertDetailDocumentAssets(document, id, ownerId)
return updateDetailProject(id, ownerId, parseRevision(body.revision), document)
}
export async function editDetailScreen(ownerId: string, id: string, screenId: string, body: Record<string, unknown>) {
const current = await findDetailProject(id, ownerId)
let screen = parseDetailScreen(body.screen)
if (screen.id !== screenId || !current.document.screens.some(value => value.id === screenId)) throw new DetailGeneratorError('屏幕不存在', 404)
try { screen = reflowDetailScreen(screen) } catch { throw new DetailGeneratorError('文案过长,请精简后保存;每屏高度最多 2400px') }
const document = { ...current.document, screens: current.document.screens.map(value => value.id === screenId ? screen : value) }
await assertDetailDocumentAssets(document, id, ownerId)
return updateDetailProject(id, ownerId, parseRevision(body.revision), document)
}
export async function createDetailAction(ownerId: string, id: string, kind: 'analyze' | 'generate' | 'regenerate' | 'export', body: Record<string, unknown>, screenId?: string) {
const project = await findDetailProject(id, ownerId)
const document = project.document
const payload: Record<string, unknown> = {}
await assertDetailDocumentAssets(document, id, ownerId)
if (kind !== 'export') {
assertDetailModels(document.models)
if (!isDetailProviderConfigured()) throw new DetailGeneratorError('请先在服务端配置模型密钥', 503, 'provider_not_configured')
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('请补充产品名称和真实产品资料')
if (document.product.identifierType === 'spu' && !document.product.variant.trim()) throw new DetailGeneratorError('SPU 下有不同款式时,请填写或选择本次生成的具体款式')
}
if (kind === 'generate' || kind === 'regenerate') {
if (!document.approvedAt || !document.analysis || !document.screens.length) throw new DetailGeneratorError('请先确认卖点和大纲')
if (kind === 'regenerate') {
if (!screenId || !document.screens.some(screen => screen.id === screenId)) throw new DetailGeneratorError('屏幕不存在', 404)
payload.screenId = screenId
}
}
if (kind === 'export') {
if (!['png', 'jpg', 'long-png', 'psd'].includes(String(body.format))) throw new DetailGeneratorError('导出格式无效')
if (!document.screens.length) throw new DetailGeneratorError('请先生成详情页')
if (document.screens.some(screen => !screen.image)) throw new DetailGeneratorError('请先完成所有屏幕的图片生成')
payload.format = body.format
}
const result = await enqueueDetailJob({ projectId: id, ownerId, kind, idempotencyKey: parseIdempotencyKey(body.idempotencyKey), revision: project.revision, payload })
return detailPublicJob(result)
}
function xmlText(value: string): string {
if (/<!DOCTYPE|<!ENTITY/i.test(value)) throw new DetailGeneratorError('不支持含外部实体的文档')
return value.replace(/<\/w:p>/g, '\n').replace(/<[^>]*>/g, '').replace(/&lt;/g, '<').replace(/&gt;/g, '>').replace(/&quot;/g, '"').replace(/&apos;/g, "'").replace(/&amp;/g, '&').replace(/&#(\d+);/g, (_, number) => String.fromCodePoint(Number(number))).trim()
}
const DOCX_XML_MAX_BYTES = 512 * 1024
const crcTable = Array.from({ length: 256 }, (_, index) => {
let value = index
for (let bit = 0; bit < 8; bit++) value = value & 1 ? 0xedb88320 ^ (value >>> 1) : value >>> 1
return value >>> 0
})
function boundedDocxEntry(entry: AdmZip.IZipEntry): Buffer {
const { size, compressedSize, method, flags, crc } = entry.header
if (entry.isDirectory || !Number.isSafeInteger(size) || size <= 0 || size > DOCX_XML_MAX_BYTES || !Number.isSafeInteger(compressedSize) || compressedSize <= 0 || compressedSize > 8 * 1024 * 1024 || (flags & 0x41) !== 0 || ![0, 8].includes(method)) throw new Error('Invalid DOCX entry')
const compressed = entry.getCompressedData()
if (compressed.length !== compressedSize) throw new Error('Truncated DOCX entry')
const output = method === 0 ? compressed : inflateRawSync(compressed, { maxOutputLength: DOCX_XML_MAX_BYTES })
if (output.length !== size || output.length > DOCX_XML_MAX_BYTES) throw new Error('DOCX size mismatch')
let checksum = 0xffffffff
for (let index = 0; index < output.length; index++) checksum = crcTable[(checksum ^ output[index]) & 0xff] ^ (checksum >>> 8)
if (((checksum ^ 0xffffffff) >>> 0) !== (crc >>> 0)) throw new Error('DOCX CRC mismatch')
return output
}
async function extractPdf(buffer: Buffer): Promise<string> {
if (buffer.subarray(0, 5).toString() !== '%PDF-') throw new DetailGeneratorError('PDF 文件格式无效')
return new Promise((resolve, reject) => {
const child = spawn('pdftotext', ['-enc', 'UTF-8', '-layout', '-', '-'], { stdio: ['pipe', 'pipe', 'pipe'], windowsHide: true })
const chunks: Buffer[] = []; let size = 0; let settled = false
const finish = (error?: Error, value?: string) => { if (settled) return; settled = true; clearTimeout(timer); error ? reject(error) : resolve(value || '') }
const timer = setTimeout(() => { child.kill(); finish(new DetailGeneratorError('PDF 解析超时,请精简产品资料')) }, 20000)
child.stdout.on('data', (chunk: Buffer) => { size += chunk.length; if (size > 256 * 1024) { child.kill(); finish(new DetailGeneratorError('PDF 文字过多,请精简产品资料')) } else chunks.push(chunk) })
child.stderr.on('data', () => undefined)
child.on('error', () => finish(new DetailGeneratorError('服务端尚未安装 PDF 解析工具 pdftotext', 503)))
child.on('close', code => code === 0 ? finish(undefined, Buffer.concat(chunks).toString('utf8')) : finish(new DetailGeneratorError('PDF 无法读取,请检查文件')))
child.stdin.on('error', () => undefined); child.stdin.end(buffer)
})
}
async function normalizeUpload(buffer: Buffer, name: string, kind: DetailAssetKind) {
if (!buffer.length) throw new DetailGeneratorError('文件为空')
if (kind !== 'document') {
if (buffer.length > 16 * 1024 * 1024) throw new DetailGeneratorError('图片超过 16MB 限制', 413)
let normalized: Buffer
try {
const metadata = await sharp(buffer, { limitInputPixels: 40_000_000 }).metadata()
if (!['jpeg', 'png', 'webp'].includes(metadata.format || '') || (metadata.pages || 1) > 1) throw new Error('format')
normalized = await sharp(buffer, { limitInputPixels: 40_000_000 }).rotate().resize(4096, 4096, { fit: 'inside', withoutEnlargement: true }).png().toBuffer()
} catch { throw new DetailGeneratorError('仅支持有效的 JPG、PNG 或 WebP 单帧图片') }
if (normalized.length > 20 * 1024 * 1024) throw new DetailGeneratorError('图片过大,请先缩小尺寸', 413)
const metadata = await sharp(normalized).metadata()
return { buffer: normalized, extension: 'png', mimeType: 'image/png', width: metadata.width!, height: metadata.height!, extractedText: null }
}
if (buffer.length > 8 * 1024 * 1024) throw new DetailGeneratorError('产品文档超过 8MB 限制', 413)
const extension = path.extname(name).toLowerCase().slice(1)
let extractedText: string; let mimeType: string
if (extension === 'pdf') { extractedText = await extractPdf(buffer); mimeType = 'application/pdf' }
else if (extension === 'docx') {
try {
const zip = new AdmZip(buffer)
const entry = zip.getEntry('word/document.xml')
if (!entry) throw new Error('missing')
extractedText = xmlText(boundedDocxEntry(entry).toString('utf8'))
mimeType = 'application/vnd.openxmlformats-officedocument.wordprocessingml.document'
} catch (error) { if (error instanceof DetailGeneratorError) throw error; throw new DetailGeneratorError('DOCX 文档无法读取或文字过多') }
} else if (extension === 'txt' || extension === 'md') {
try { extractedText = new TextDecoder('utf-8', { fatal: true }).decode(buffer); if (extractedText.includes('\u0000')) throw new Error('binary') } catch { throw new DetailGeneratorError('文字资料须为 UTF-8 文本') }
mimeType = 'text/plain; charset=utf-8'
} else throw new DetailGeneratorError('产品文档支持 PDF、DOCX、TXT 或 Markdown')
extractedText = extractedText.replace(/[\x00-\x08\x0b\x0c\x0e-\x1f]/g, '').trim()
if (!extractedText) throw new DetailGeneratorError('文档没有可识别文字,请补充文字资料;扫描件请作为参考图片上传')
if (extractedText.length > 60000) throw new DetailGeneratorError('文档文字过多,请精简至 6 万字以内')
return { buffer, extension, mimeType, width: null, height: null, extractedText }
}
async function attachUpload(ownerId: string, id: string, revision: number, name: string, kind: DetailAssetKind, raw: Buffer, variant?: { name: string; productModel: string }) {
const current = await findDetailProject(id, ownerId)
if (current.revision !== revision) throw new DetailGeneratorError('项目已更新,请刷新后重试', 409, 'revision_conflict')
const existing = await listDetailAssets(id, ownerId)
if (existing.filter(value => value.kind === kind).length >= (kind === 'logo' ? 5 : 30)) throw new DetailGeneratorError('项目文件数量已达到上限', 413)
const prepared = await normalizeUpload(raw, name, kind)
const assetId = randomUUID()
const document: DetailDocument = JSON.parse(JSON.stringify(current.document))
if (kind === 'logo') document.brand.logoAssetId = assetId
else if (kind !== 'generated') {
const field = kind === 'product' ? 'assetIds' : kind === 'reference' ? 'referenceAssetIds' : 'documentAssetIds'
if (document.product[field].length >= 8) throw new DetailGeneratorError('同类输入文件最多 8 个')
document.product[field].push(assetId)
}
if (variant) {
if (document.product.variant && variant.name && document.product.variant !== variant.name && current.document.product.assetIds.length) throw new DetailGeneratorError('同一项目不能混用不同产品款式')
document.product.variant = variant.name || document.product.variant
}
if (kind !== 'generated') { document.analysis = null; document.approvedAt = null; document.screens = [] }
const storageKey = await saveDetailFile(id, prepared.buffer, prepared.extension)
try {
const result = await attachDetailAsset({ id: assetId, projectId: id, ownerId, kind, name: path.basename(name).slice(0, 180), mimeType: prepared.mimeType, storageKey, byteSize: prepared.buffer.length,
width: prepared.width, height: prepared.height, extractedText: prepared.extractedText }, revision, document)
return { asset: detailPublicAsset(result.asset), project: result.project }
} catch (error) { await deleteDetailFile(storageKey).catch(() => undefined); throw error }
}
export async function uploadDetailAsset(ownerId: string, id: string, request: Request) {
await findDetailProject(id, ownerId)
const maxBytes = 17 * 1024 * 1024
const bytes = await readDetailResponseBytes(new Response(request.body, { headers: { 'content-length': request.headers.get('content-length') || '' } }), maxBytes).catch(() => { throw new DetailGeneratorError('上传文件过大或请求不完整', 413) })
let form: FormData
try { form = await new Response(new Uint8Array(bytes), { headers: { 'content-type': request.headers.get('content-type') || '' } }).formData() } catch { throw new DetailGeneratorError('上传格式无效') }
const file = form.get('file')
const kind = String(form.get('kind') || '')
if (!file || typeof file === 'string' || !['product', 'reference', 'document', 'logo'].includes(kind)) throw new DetailGeneratorError('请选择文件和用途')
const revision = parseRevision(Number(form.get('revision')))
const purpose = form.get('purpose')
if (purpose && purpose !== 'screen-replacement') throw new DetailGeneratorError('文件用途无效')
if (purpose === 'screen-replacement' && kind !== 'product') throw new DetailGeneratorError('替换图片须选择图片用途')
return attachUpload(ownerId, id, revision, file.name, purpose === 'screen-replacement' ? 'generated' : kind as DetailAssetKind, Buffer.from(await file.arrayBuffer()))
}
export async function importDetailMaterial(ownerId: string, id: string, body: Record<string, unknown>) {
const current = await findDetailProject(id, ownerId)
const source = await readDetailMaterialImage(ownerId, String(body.materialId || ''), String(body.source || ''), String(body.sourceId || ''))
const product = current.document.product
const identifier = product.identifier.trim().toUpperCase()
if (identifier && (product.identifierType === 'spu' && source.productModel.toUpperCase() !== identifier || product.identifierType === 'sku' && source.variant.toUpperCase() !== identifier || product.identifierType === 'barcode' && !source.barcodes.includes(identifier))) {
throw new DetailGeneratorError('选中图片与项目的产品编号不一致,请重新匹配', 409, 'product_identity_conflict')
}
return attachUpload(ownerId, id, parseRevision(body.revision), source.name, 'product', source.buffer, { name: source.variant, productModel: source.productModel })
}
export const __detailUploadTesting = { normalizeUpload, xmlText, boundedDocxEntry }