200 lines
14 KiB
TypeScript

import { randomUUID } from 'crypto'
import sharp from 'sharp'
import { assertDetailGeneratorUser, DetailGeneratorError } from './access'
import { analyzeDetailFacts, composeDetailOutline, DetailProviderError, generateDetailImage, type DetailProviderImage } from './provider'
import { assertDetailDocumentAssets, checkpointDetailJob, claimDetailJob, deferDetailJob, findDetailAsset, findDetailProject, finishDetailJob, renewDetailJobLease, saveDetailJobAsset, saveDetailJobDocument, type StoredDetailJob } from './repository'
import { deleteDetailFile, readDetailFile, saveDetailFile } from './storage'
import { renderDetailExport } from './render'
import { type DetailDocument, type DetailExportFormat } from './types'
import { detailModelBindingsSchema } from './validation'
import { canPollDetailUpstreamTask, readDetailUpstreamTask } from './upstream-task'
import { detailTextDispatchKey, readDetailTextRecovery } from './text-recovery'
async function inputImages(document: DetailDocument, ownerId: string, projectId: string): Promise<DetailProviderImage[]> {
const ids = [...document.product.assetIds, ...document.product.referenceAssetIds].slice(0, 8)
const result: DetailProviderImage[] = []
for (const id of ids) {
const asset = await findDetailAsset(id, ownerId)
if (asset.projectId !== projectId || !['product', 'reference'].includes(asset.kind)) throw new DetailGeneratorError('模型输入文件不可访问', 403)
result.push({ buffer: await readDetailFile(asset.storageKey), mimeType: asset.mimeType })
}
return result
}
async function productText(document: DetailDocument, ownerId: string, projectId: string): Promise<string> {
const text = [document.product.facts]
for (const id of document.product.documentAssetIds) {
const asset = await findDetailAsset(id, ownerId)
if (asset.projectId !== projectId || asset.kind !== 'document') throw new DetailGeneratorError('产品资料不可访问', 403)
text.push(asset.extractedText || '')
}
const supplied = text.join('\n')
if (supplied.length > 60000) throw new DetailGeneratorError('产品资料合计超过 6 万字,请精简后再分析')
return supplied
}
export async function processDetailJob(current: StoredDetailJob): Promise<void> {
const token = current.leaseToken
if (!token) throw new Error('Job lease missing')
let checkpoint: Record<string, unknown> = { ...current.checkpoint }
let leaseLost = false
let heartbeatBusy = false
const heartbeat = setInterval(async () => {
if (heartbeatBusy) return
heartbeatBusy = true
try { if (!(await renewDetailJobLease(current.id, token))) leaseLost = true } catch { leaseLost = true } finally { heartbeatBusy = false }
}, 20000)
heartbeat.unref?.()
const persistCheckpoint = async (progress: number) => {
if (leaseLost) throw new DetailGeneratorError('任务执行租约已失效', 409, 'lease_lost')
await checkpointDetailJob(current.id, token, checkpoint, progress)
}
const dispatch = async <T>(stage: string, progress: number, call: (key: string) => Promise<T>): Promise<T> => {
if (leaseLost) throw new DetailGeneratorError('任务执行租约已失效', 409, 'lease_lost')
await assertDetailGeneratorUser(current.ownerId)
const key = detailTextDispatchKey(current.id, stage, checkpoint)
checkpoint = { ...checkpoint, stage, dispatchPending: true, dispatchKey: key }
await persistCheckpoint(progress)
try { await assertDetailGeneratorUser(current.ownerId) } catch (error) {
// Revocation stops polling but cannot turn an already accepted generation into a safe retry.
if (!readDetailUpstreamTask(checkpoint) && !readDetailTextRecovery(checkpoint)) {
checkpoint = { ...checkpoint, dispatchPending: false }
await persistCheckpoint(progress)
}
throw error
}
try { return await call(key) } catch (error) {
if (error instanceof DetailProviderError && !error.outcomeUnknown || error instanceof DetailGeneratorError && !readDetailUpstreamTask(checkpoint) && !readDetailTextRecovery(checkpoint)) {
checkpoint = { ...checkpoint, dispatchPending: false }
// Text failures are closed atomically with their terminal status below.
// Persisting a cleared marker first would let an expired lease requeue a paid call.
if (!['product-analysis', 'outline'].includes(stage)) await persistCheckpoint(progress)
}
throw error
}
}
try {
await assertDetailGeneratorUser(current.ownerId)
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)
const upstreamTask = readDetailUpstreamTask(checkpoint)
const textRecovery = current.kind === 'analyze' ? readDetailTextRecovery(checkpoint) : null
if (checkpoint.dispatchPending === true && !textRecovery && (!upstreamTask || !['generate', 'regenerate'].includes(current.kind) || !canPollDetailUpstreamTask(upstreamTask))) throw new DetailProviderError('模型请求结果未知,任务已暂停;请核查服务商记录', true, current.requestId)
if (checkpoint.stage === 'complete') {
await finishDetailJob(current.id, token, 'succeeded', checkpoint)
return
}
if (current.kind === 'analyze') {
const images = textRecovery ? [] : await inputImages(document, current.ownerId, current.projectId)
const suppliedText = await productText(document, current.ownerId, current.projectId)
let facts = typeof checkpoint.facts === 'string' ? checkpoint.facts : null
if (textRecovery && (textRecovery.stage === 'product-analysis' ? !!facts : !facts)) throw new DetailProviderError('原请求阶段与事实检查点不一致,请核查;勿重复提交', true, textRecovery.requestId)
if (!facts) {
facts = await dispatch('product-analysis', 10, key => analyzeDetailFacts(document, images, suppliedText, key, current.ownerId, document.modelBindings!.analysis, textRecovery ? { resumeRequestId: textRecovery.requestId } : undefined))
await assertDetailGeneratorUser(current.ownerId)
checkpoint = { ...checkpoint, facts, stage: 'selling-points', dispatchPending: false,
...(textRecovery ? { textRecovery: undefined, recoveredText: { stage: 'product-analysis', requestId: textRecovery.requestId, dispatchKey: `${current.id}-product-analysis`, recoveredAt: new Date().toISOString() } } : {}) }
if (textRecovery) {
// Querying the original analysis never silently starts a new, billable copy call.
await finishDetailJob(current.id, token, 'unknown', checkpoint, '资料分析已取回,确认后可继续卖点文案;不会重复分析', textRecovery.requestId)
return
}
await persistCheckpoint(40)
}
// Keep the validated facts checkpoint separate from the untrusted source text.
const analysis = await dispatch('outline', 45, key => composeDetailOutline(document, facts, key, current.ownerId, document.modelBindings!.copy, suppliedText, textRecovery ? { resumeRequestId: textRecovery.requestId } : undefined))
await assertDetailGeneratorUser(current.ownerId)
document = { ...document, analysis, approvedAt: null }
checkpoint = { ...checkpoint, stage: 'complete', dispatchPending: false, textRecovery: undefined,
...(textRecovery ? { recoveredText: { stage: 'outline', requestId: textRecovery.requestId, dispatchKey: `${current.id}-outline`, recoveredAt: new Date().toISOString() } } : {}) }
await saveDetailJobDocument(current.id, token, document, checkpoint, 100)
} else if (current.kind === 'generate' || current.kind === 'regenerate') {
if (!document.approvedAt || !document.analysis) throw new DetailGeneratorError('卖点和大纲尚未确认')
const images = await inputImages(document, current.ownerId, current.projectId)
const target = current.kind === 'regenerate' ? document.screens.filter(screen => screen.id === current.payload.screenId) : document.screens
if (!target.length) throw new DetailGeneratorError('没有待生成屏幕')
const completed = new Set(Array.isArray(checkpoint.completedScreenIds) ? checkpoint.completedScreenIds as string[] : [])
if (checkpoint.dispatchPending === true && !target.some(screen => `screen-${screen.id}` === upstreamTask?.stage && !completed.has(screen.id) && !(current.kind === 'generate' && screen.image))) throw new DetailProviderError('待查询的图片任务与当前屏幕不一致,已暂停', true)
for (let index = 0; index < target.length; index++) {
const screen = target[index]
if (completed.has(screen.id) || (current.kind === 'generate' && screen.image)) continue
const progress = Math.round(index / target.length * 90)
const stage = `screen-${screen.id}`
const resume = readDetailUpstreamTask(checkpoint)
const buffer = await dispatch(stage, progress, key => generateDetailImage(document, screen.imagePrompt, images, key, current.ownerId, document.modelBindings!.image, {
resumeTaskId: resume?.stage === stage ? resume.id : undefined,
onTaskSubmitted: async id => {
const saved = { id, stage, submittedAt: Date.now(), polls: 0 }
if (!readDetailUpstreamTask({ stage, upstreamTask: saved })) throw new DetailProviderError('上游任务编号无效,已暂停核查', true)
checkpoint = { ...checkpoint, upstreamTask: saved }
await persistCheckpoint(progress)
},
}))
const metadata = await sharp(buffer).metadata()
const assetId = randomUUID()
const storageKey = await saveDetailFile(current.projectId, buffer, 'png')
const area = screen.imageArea || { x: 0, y: 380, width: 750, height: Math.min(750, screen.height - 380) }
const replaced = { ...screen, image: { assetId, ...area } }
document = { ...document, screens: document.screens.map(value => value.id === screen.id ? replaced : value) }
completed.add(screen.id)
const receivedCheckpoint = { ...checkpoint, completedScreenIds: Array.from(completed), stage: 'rendering', dispatchPending: false, upstreamTask: undefined, pollNotBefore: undefined }
try {
await saveDetailJobAsset(current.id, token, { id: assetId, projectId: current.projectId, ownerId: current.ownerId, kind: 'generated', name: `${index + 1}-${screen.title}.png`, mimeType: 'image/png', storageKey, byteSize: buffer.length, width: metadata.width, height: metadata.height }, document, receivedCheckpoint, Math.round((index + 1) / target.length * 95))
checkpoint = receivedCheckpoint
} catch (error) { await deleteDetailFile(storageKey).catch(() => undefined); throw error }
}
} else if (current.kind === 'export') {
const assets: Record<string, DetailProviderImage> = {}
const required = new Set(document.screens.flatMap(screen => [screen.image?.assetId, screen.logo?.assetId].filter((id): id is string => Boolean(id))))
for (const id of Array.from(required)) {
const asset = await findDetailAsset(id, current.ownerId)
if (asset.projectId !== current.projectId) throw new DetailGeneratorError('导出图片不可访问', 403)
assets[id] = { buffer: await readDetailFile(asset.storageKey), mimeType: asset.mimeType }
}
checkpoint = { ...checkpoint, stage: 'export' }; await persistCheckpoint(10)
await assertDetailGeneratorUser(current.ownerId)
const output = await renderDetailExport(document, assets, current.payload.format as DetailExportFormat)
const extension = output.filename.split('.').pop() || 'bin'
const storageKey = await saveDetailFile(current.projectId, output.buffer, extension)
const id = randomUUID()
const received = { ...checkpoint, stage: 'complete', exportAssetId: id, dispatchPending: false }
try {
await saveDetailJobAsset(current.id, token, { id, projectId: current.projectId, ownerId: current.ownerId, kind: 'export', name: output.filename, mimeType: output.mimeType, storageKey, byteSize: output.buffer.length }, null, received, 100)
checkpoint = received
} catch (error) { await deleteDetailFile(storageKey).catch(() => undefined); throw error }
}
checkpoint = { ...checkpoint, stage: 'complete', dispatchPending: false }
await finishDetailJob(current.id, token, 'succeeded', checkpoint)
} catch (error) {
const upstreamTask = readDetailUpstreamTask(checkpoint)
if (error instanceof DetailProviderError && error.taskPending && upstreamTask && canPollDetailUpstreamTask(upstreamTask) && !leaseLost) {
checkpoint = { ...checkpoint, upstreamTask: { ...upstreamTask, polls: upstreamTask.polls + 1 }, pollNotBefore: Date.now() + 30_000 }
await deferDetailJob(current.id, token, checkpoint)
return
}
const recoveredAnalysisPending = checkpoint.stage === 'selling-points' && (checkpoint.recoveredText as { stage?: unknown } | undefined)?.stage === 'product-analysis'
const unknown = recoveredAnalysisPending || (error instanceof DetailProviderError ? error.outcomeUnknown : checkpoint.dispatchPending === true)
const safeMessage = error instanceof DetailProviderError || error instanceof DetailGeneratorError ? error.message : unknown ? '模型结果尚未确认,请核查请求记录,勿重复提交' : '任务执行失败,请检查输入资料或服务端配置'
const requestId = (error instanceof DetailProviderError ? error.requestId : null) || readDetailTextRecovery(checkpoint)?.requestId || current.requestId
await finishDetailJob(current.id, token, unknown ? 'unknown' : 'failed', checkpoint, safeMessage, requestId)
} finally { clearInterval(heartbeat) }
}
export async function runDetailGeneratorWorkerOnce(): Promise<boolean> {
if (process.env.DETAIL_GENERATOR_ENABLED !== 'true') return false
const claimed = await claimDetailJob()
if (!claimed) return false
await processDetailJob(claimed)
return true
}