261 lines
19 KiB
TypeScript
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(/</g, '<').replace(/>/g, '>').replace(/"/g, '"').replace(/'/g, "'").replace(/&/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 }
|