PrintEase 技术复盘:文件转换、订单状态与打印任务恢复
多端打印系统
上传、转换、下单、打印。
重点记录异步任务、状态一致性,以及打印客户端的异常恢复。
- 01上传 / 转换分片 · SHA-256 · PDF
- 02下单 / 支付逐文件参数 · 服务端计价
- 03接单 / 认领事务锁 · 节点 UUID
- 04打印 / 回报本地持久化 · 完成补报
retry完成回报可以重试;提交结果不确定的打印任务暂停,人工确认。PrintEase 是一个多端打印项目:用户在微信小程序上传文件、设置参数并下单,后端处理转换、计价和支付,商户侧 Windows 客户端领取任务并提交打印。
这篇主要记录几个实现细节,不展开讲页面 CRUD:文件怎么转成 PDF、订单和打印任务怎么拆、客户端异常退出后如何恢复。内容依据当前代码,不代表已经完成生产环境和真实设备验收。
1. 架构与技术栈
| 模块 | 职责 | 技术栈 |
|---|---|---|
| 微信小程序 | 上传、打印参数、下单、订单查询 | uni-app、Vue 3、TypeScript、Pinia |
| 业务后端 | 文件记录、价格、订单、支付、任务状态 | NestJS、TypeORM、MySQL |
| 管理后台 | 商户、订单、调度、业务配置 | Vue、Element Plus |
| 转换服务 | Office / 图片转 PDF、文件分析 | Python、FastAPI、WPS COM、MySQL 队列 |
| 打印客户端 | 领取任务、本地持久化、提交打印、回报结果 | Python、Tkinter、SQLite、SumatraPDF |
处理顺序如下:
小程序上传文件
→ 后端保存文件记录、触发转换
→ 转换完成,获取 PDF 和页数
→ 用户按文件设置参数、提交订单
→ 后端计价、创建支付交易、确认付款
→ 商户接单,生成打印任务
→ Windows 节点认领任务
→ 提交 SumatraPDF,回报处理结果统一使用 PDF 作为打印输入,避免打印客户端再分别处理 Word、Excel 和图片。Windows 端通过现成工具和驱动打印,不自己实现一套打印引擎。
代价是环境依赖比较明确:Office 转换依赖 WPS,打印依赖 Windows 和设备驱动。拆成独立模块只是隔离了依赖,并没有解决字体缺失、版式变化和驱动兼容问题。
2. 文件上传:分片传输与 hash 去重
当前上传流程:
- 初始化上传。
- 小程序读取文件,切片并编码为 Base64,串行上传。
- 后端按片号合并,计算 SHA-256。
- 根据 hash 查找已有文件,决定是否复用物理存储和转换结果。
- 为当前用户创建独立的文件记录。
默认分片大小为 2 MiB,文件超过 50 MiB 时改为 5 MiB。
这里需要区分两件事:
- 存储去重:内容相同可以共享物理文件,已成功的转换结果也可以复用。
- 业务归属:用户仍然各自持有文件记录,不能因为 hash 相同就共用一条用户记录。
目前客户端和后端都仍有整文件读取,所以这是分片传输,不是流式上传。去重发生在上传完成后,也不属于上传前“秒传”。客户端没有持久化上传会话,重启后继续上传还没实现。
下一步优先处理内存占用和断点恢复,而不是继续细化上传进度动画。
3. 异步转换:任务持久化与产物校验
上传完成后,后端异步触发转换。小程序每秒查询文件状态,只有服务端返回 completed 才允许继续,返回 failed 则显示错误。
进度条使用模拟增长和已完成文件比例,不是转换引擎的实时进度。UI 进度不参与业务判断。
PDF 与 Office 分开处理
PDF 输入直接读取页数,不再执行 Office 转换。其他格式可以交给远程转换服务,由 Windows Worker 调用 WPS COM;图片则生成 PDF。
远程链路的重点是恢复和校验:
| 环节 | 当前实现 |
|---|---|
| 业务端提交 | 保存远程任务信息,可定期扫描恢复 |
| 转换端排队 | MySQL 持久化队列 |
| Worker 领取 | 通过租约、本次尝试 ID 和租约版本约束处理 |
| 后端接收产物 | 核对大小、SHA-256、PDF 页数 |
| 后续确认 | 持久化待确认信息,继续处理 |
转换耗时可能超过一次请求,也可能遇到进程重启。只把任务 ID 留在内存里,恢复后就无法继续追踪,所以业务记录和队列都要落库。
当前服务仍采用单机本地存储,不能当成跨主机高可用方案。WPS 对复杂表格、字体和分页的处理效果,也需要用真实文档回归验证。
覆盖率不参与当前计价
代码有 PDF 栅格化后的覆盖率估算,也有按 profile 应用的兼容规则。但它不是打印机实际耗墨量,当前计价函数也没有使用该字段。
已有文档和当前实现存在部分差异,排查时应以实际调用链为准。
4. 订单建模:打印参数放在文件上
一个订单可以包含多份文件,各自的颜色、单双面、份数和页码范围可能不同。因此打印参数保存在订单文件关联记录上,订单只负责聚合。
后端校验文件归属,读取价格配置重新计价,再通过事务保存订单和订单文件。
当前计价规则可以概括为:
计费单位 = 单面 ? 页数 : ceil(页数 / 2)
文件金额 = 计费单位 × 份数 × 对应单价
订单金额 = 各文件金额之和例如 5 页双面、2 份,计费单位是 3 × 2。这是本项目的业务口径,不是通用打印收费标准。纸型、质量和覆盖率目前不参与这个计价函数。
配送楼栋、时段同时保存 ID 和名称快照,避免配置改名后影响历史订单展示。
还需要补的是计价输入校验。 当前页数仍包含客户端提交值,页码范围的去重、越界和真实总页数核对还不完整。后端重算金额只解决了计算位置的问题,不能代替输入验证。
5. 支付:交易复用与服务端确认
当前接入的是 mpay + WebView,不是小程序原生微信支付调用。
重复进入支付流程时,后端会检查是否存在可复用的待支付订单和未过期渠道交易,渠道使用稳定的业务订单 ID 作为商户订单号,避免每次点击都新建交易。
支付状态由服务端确认:
- 回调检查签名和金额,通过后推进订单状态。
- “我已支付”按钮只触发查询,不直接修改订单。
- 主动查询异步执行,刷新接口可能暂时返回旧状态。
当前回调先响应成功,再做进程内异步处理,缺少可靠的通知持久化与恢复机制。这部分需要补落库处理,不能只依赖进程内任务。
6. 打印调度:商户接单与节点认领分开
商户接单和客户端领取任务是两层操作:
- 商户接单:事务内锁定订单、检查接单资格,为订单文件创建打印任务。
- 节点认领:具体 Windows 节点使用稳定 UUID 领取任务,其他节点不能直接接管已认领任务。
这样可以区分商户归属和设备执行状态。全部打印任务完成后,订单推进到 PRINTED,后续配送和交付仍是其他状态。
这里没有做不确定任务的自动故障转移,原因是打印有不可撤销的物理副作用。
7. 异常恢复:补报结果,不盲目重打
考虑这个情况:SumatraPDF 已经执行,客户端准备通知后端时网络断开。
后端没有收到完成结果,但打印可能已经发生。如果直接重新派发,就可能重复打印。反过来,客户端也可能在真正提交前崩溃,此时同样无法仅凭网络结果确认有没有打印。
当前用 SQLite 持久化本地状态,区分“提交不确定”和“等待回报”:
| 阶段 | 本地状态 / 操作 | 恢复方式 |
|---|---|---|
| 调用打印工具之前 | 先保存 uncertain,再继续提交 | 若重启后仍不确定,暂停核实 |
| 打印工具返回成功 | 先保存 report_pending,再回报后端 | 若回报失败,仅补报结果 |
| 回报接口失败 | 保留待回报状态 | 后续轮询补报,不再次打印 |
| 重启后发现提交不确定 | 暂停任务 | 人工核实,不自动重打 |
这是一种保守策略,优先避免重复打印,不是物理打印的 exactly-once 保证。有些实际没打印的任务也可能暂停,需要操作人员确认。
实现这类恢复逻辑时,需要把可重试的网络通知和不可直接重试的设备操作分开。
打印成功的判定范围
客户端把纸型、页码、颜色和单双面参数传给 SumatraPDF,多份文件逐份调用。A3 场景还保留了人工双面流程:打印一面、提示翻纸、再打印另一面。
目前成功主要根据 SumatraPDF 的进程退出码判断。它表示程序调用成功,不代表纸张已经正确输出。卡纸、缺纸、翻纸方向和驱动参数支持都需要设备侧验收。
因此不能把这几个状态合并:
任务已认领 ≠ 已提交打印 ≠ 纸张输出完成 ≠ 订单交付完成8. 当前限制与后续事项
| 问题 | 后续处理 |
|---|---|
| 分片上传仍整文件读取 | 降低内存占用,持久化上传会话 |
| 页数和页码范围校验不足 | 结合转换产物校验计价输入 |
| 支付通知缺少可靠恢复 | 通知先持久化,再幂等处理 |
| 节点注册和心跳存在兼容占位 | 补全在线状态与监控 |
| 转换结果受环境影响 | 建立真实文档回归样本 |
| 进程退出码不能确认出纸 | 明确设备反馈和人工确认流程 |
优先级上,先补计价校验、支付通知持久化和真实设备验收,再扩展功能。
小结
PrintEase 的主要难点不是页面数量,而是多个进程之间的状态一致性。转换任务、支付结果和打印提交都可能跨越一次请求,必须考虑超时、重启和重复执行。
目前的处理思路是:任务信息落库,结果接收前校验,打印提交与完成回报分开。对结果不确定的设备操作暂停处理,不靠自动重试猜测执行结果。
