一句话
合成流水线是一条**”用户提交 → 后台抢占 → 调合成微服务渲染 → 服务自行入库 → 状态回写”的异步管道。pf-service 内嵌的 Job 只负责调度**,实际的 Puppeteer 离屏渲染与 MinIO 入库由独立的 pf-compose-service 完成——这是近期一次关键解耦(5414dbe / 2809bf4)。
入口与时序
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21
| t=0 用户在 pf-app 点"提交合成" ▼ t=0+ε POST /api/app/order-templates/submission │ 入库 order_template_submissions(status=pending) │ + N 条 order_template_submission_items(pending) │ 订单 uploadStatus=submitted, composeStatus=pending │ t=Δ(≤10s) orderTemplateCompositionJob 轮询 │ FOR UPDATE SKIP LOCKED 抢占 ≤3 条明细 → processing ▼ t=Δ+T 并发 3 处理明细: ├─ buildPreviewSceneJson(快照 + uploads + texts + 订单变量) ├─ POST http://pf-compose-service/api/compose │ → Worker 渲染 Leafer → 导出图 → 上传 MinIO │ ← { fileKey, publicUrl, renderTimeMs, uploadTimeMs, totalTimeMs } └─ 写回 item.composedImageUrls, status=completed │ t=Δ+T+ε 聚合: 全 completed → submission/order completed 有永久失败 → failed;否则 processing ▼ t=... pf-manage 审核 / pf-download 下载
|
状态机
明细、提交主表、订单 composeStatus 共用 taskStatus 四态:
1 2 3 4 5
| pending ──► processing ──► completed │ └────► failed (item.failReason 写原因; attemptCount<3 可重试回 processing)
processing 超 10 分钟无进展 → 下一轮被判为"僵尸"重新抢占
|
任何一条明细失败不影响其它明细——父状态由所有明细聚合得出(syncParentSubmissionStatus)。
核心源码
Job 调度细节
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16
| export function startOrderTemplateCompositionJob(intervalMs = 10_000) { timer = setInterval(async () => { if (isRunning || isStopped) return; isRunning = true; try { await runOnce(); } finally { isRunning = false; } }, intervalMs); }
async function runOnce() { }
|
- DB 级抢占:
FOR UPDATE SKIP LOCKED 天生支持多进程/多实例并行,不再依赖内存锁(旧文档里的”扩展点”已落地)
- 并发:明细内实例也走
asyncPool(3);AGENTS.md 的并发上限 3 在 pf-service 侧(MAX_COMPOSITION_CONCURRENCY)和合成服务侧(3-Worker 池)双重生效
- 轮询间隔:
config.server.jobs.composition.intervalMs(默认 10s);enabled = false 可完全停掉
合成主流程(jobProcessor → 合成服务)
1 2 3 4 5 6 7 8 9 10 11 12
| processSingleItem(item) ├─ 读 item.sceneJson 快照(优先) ├─ 对每个实例 buildPreviewSceneJson(scene, uploads, texts, orderVars): │ ├─ 按 elementKey 替换 Image.url / Text.text │ ├─ 注入订单变量(buyerName / goodsName / remark / orderDate...) │ └─ faceSafe 节点:前端已算好裁剪,后端合成时按快照消费 ├─ processComposition({ orderNo, userSceneJson, compositionKey }): │ └─ requestComposition → POST {compose}/api/compose │ body: { sceneJson, storage:{purpose:"composition", fileName}, options:{format:"png", pixelRatio:1} } │ ← { fileKey, publicUrl, renderTimeMs, uploadTimeMs, totalTimeMs } ├─ 增量写回 item.composedImageUrls └─ 失败 → status=failed, failReason, attemptCount++, nextRetryAt=now+2^n s
|
buildPreviewSceneJson 是”sceneJson + 用户填值合并”的单一实现,编辑器预览、用户端预览、后端合成共用同一套语义,保证三端视觉一致。渲染与截图/上传的实现细节全部在 合成微服务。
为什么把合成拆出去
重试与容错
- 自动退避重试:失败明细在
nextRetryAt(now + 2^attempt s)到点后被下一轮重新抢占,最多 3 次,之后永久失败
- 无本地兜底:合成服务不可达/超时 →
requestComposition 抛错 → 明细标 failed 走退避;不会退回本地 Puppeteer(主服务已无该能力)
- 僵尸恢复:
processing 超 10 分钟自动回收
- 手动重试:
pf-manage 的重合成接口把 composeStatus 回退 pending
常见 failReason:素材 URL 不可访问(MinIO 权限/网络)、字体加载超时、合成服务超时/不可达、渲染超时(模板复杂或大图未压缩)。
性能与扩展
监控建议
pending 明细队列长度(堆积)
- 合成服务
/health 的 workers(idle/active/waiting)
- 单条合成
renderTimeMs / uploadTimeMs P95
failReason 分组统计(不打开数据库就知道是素材死链还是服务挂了)
下一步