200 lines
14 KiB
TypeScript
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
|
|
}
|