一句话

合成流水线是一条**”用户提交 → 后台轮询 → Puppeteer 渲染 → 上传成品 → 状态回写”**的异步管道,由 pf-service 内嵌的 Job 驱动;它把”模板 sceneJson + 用户填值”变成可下载、可打印的高清图片。

入口与时序

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
t=0          用户在 pf-app 点"提交合成"


t=0+ε POST /pf-app/order-template-submissions
│ 入库 order_template_submissions(status=pending)
│ + N 条 order_template_submission_items(pending)

t=Δ(≤10s) orderTemplateCompositionJob 轮询命中
│ submission.status = processing


t=Δ+T 遍历 items:
├─ 准备 sceneJson + uploads + texts
├─ Puppeteer 渲染
├─ 截图 → 上传 MinIO
└─ item.status = completed

t=Δ+T+ε 汇总:
全部 completed → submission.status = completed
任一 failed → submission.status = failed


t=... 管理员在 pf-manage 看到结果可审核

状态机

提交主表 + 明细表共用四态:

1
2
3
4
5
pending ──► processing ──► completed

└───────────► failed (item.failReason 写入原因)

pending ──► processing 之后异常退出 → 下一轮被 Job 检测为"僵尸"重新置回 pending

任何一条明细失败不影响其它明细继续合成——提交主表只在所有明细处理完后汇总状态。

核心源码

文件 作用
apps/pf-service/src/jobs/orderTemplateCompositionJob.ts 轮询调度、并发控制、状态汇总
apps/pf-service/src/utils/jobProcessor.ts 合成主流程:合并 sceneJson、调 Puppeteer、上传
apps/pf-service/src/utils/puppeteerEventBus.ts Puppeteer 实例间事件总线,便于浏览器侧回传日志/错误
apps/pf-service/src/utils/exportImg.ts 截图/导出(PNG/PDF)封装

Job 调度细节

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
// 简化伪代码
let timer = null;
let isRunning = false;

export function startOrderTemplateCompositionJob(intervalMs = 10_000) {
timer = setInterval(async () => {
if (isRunning) return; // 单实例锁,防重入
isRunning = true;
try {
await runOnce();
} finally {
isRunning = false;
}
}, intervalMs);
}

async function runOnce() {
// 1. 取一条 pending 的 submission
// 2. 置 processing
// 3. 遍历明细 → processItem
// 4. 汇总状态
}
  • 单进程内串行isRunning 锁确保同一进程不并发
  • 多进程时:依赖数据库 row-level lock / Redis 抢占(建议生产期加 SELECT ... FOR UPDATE SKIP LOCKED,当前实现保留扩展点)
  • 轮询间隔config.server.jobs.composition.intervalMs,默认 10s;可在配置中设 enabled = false 完全停掉

合成主流程(jobProcessor)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
processSubmission(submission)
├─ load items (Drizzle)
├─ for each item:
│ ├─ load template.sceneJson 快照 (优先用 item.sceneJson)
│ ├─ buildPreviewSceneJson(sceneJson, uploads, texts)
│ │ ├─ 按 elementKey 替换 Image.url
│ │ ├─ 按 elementKey 替换 Text.text
│ │ └─ 对 faceSafe Image 节点:
│ │ ├─ 调 pf-face-service 取人脸框
│ │ └─ 重算 cropBox 使脸居中且不被裁掉
│ ├─ processComposition(scene):
│ │ ├─ 启动/复用 Puppeteer 实例
│ │ ├─ 加载内嵌的 Leafer 渲染页面
│ │ ├─ window.__renderScene(scene)
│ │ ├─ 等待字体/图片加载完成
│ │ ├─ page.screenshot 或 PDF
│ │ └─ 上传 MinIO → 得到 url
│ ├─ 更新 item:
│ │ status=completed
│ │ composedImageUrls=[url1, url2, ...]
│ └─ 失败:
│ status=failed, failReason=err.message
└─ 汇总 submission 状态

buildPreviewSceneJson 是关键——它是同一份”sceneJson + 用户填值合并”逻辑,编辑器预览、用户端预览、后端合成共用它,保证三端视觉一致。

Puppeteer 实例管理

  • 启动时复用一个/几个长连接 Browser,按需开 Page
  • 每张图渲染走独立 Page,避免上下文污染
  • 内嵌静态 HTML(Leafer 已打包进去),不依赖外网 CDN
  • 字体通过 page.evaluate 注入 FontFace,确保与编辑器同源
  • 通过 puppeteerEventBus.ts 把浏览器端 console / error 桥接回 Node 日志

错误分类

jobProcessor 给每个错误附 CompositionErrorContext,便于排障:

1
2
3
4
5
6
7
8
9
10
11
{
scope: "process-item" | "process-submission-item" | "run-once",
orderNo?: string,
submissionId?: number,
itemId?: number,
instanceKey?: string,
compositionKey?: string,
uploadCount?: number,
textCount?: number,
submissionsCount?: number,
}

常见 failReason

  • 素材 URL 不可访问 — MinIO 权限/网络
  • 字体加载超时 — font URL 不通或 woff2 损坏
  • face-service 异常 — 模型未加载或被打挂
  • Puppeteer 渲染超时 — 模板复杂或大图未压缩

重试策略

  • 单 item 重试:管理员在 pf-manage 把 item.status 改回 pending → 下一轮 Job 自动处理
  • 整 submission 重试:把所有失败 item 一起置回 pending
  • 自动重试:当前实现未默认开启自动重试,避免”错误内容反复跑”占资源——建议结合监控按 failReason 分类做有限次重试

性能与扩展

维度 默认 升级路径
调度并发 1(单进程串行) 多进程 + DB 抢占锁 / Redis 队列
渲染并发 Browser 数 × Page 数 拓展 Browser 池 / 拓 GPU 节点
渲染时间 单张约 0.5–3 s(视模板复杂度) 提前抽帧 / 复用素材缓存
输出格式 PNG / PDF 增加 TIFF / 高 DPI 模式

监控建议

最少接两个指标:

  • pending 队列长度 — 反映堆积
  • 单条 item 处理耗时 P95 — 反映健康

加上 failReason 分组统计,能让你在不打开数据库的情况下知道”今天是 face-service 挂了还是模板里有死链”。

下一步