自助打印最严重的技术事故不是接口报错,而是用户只付一次钱却打印两次,或已经付款却无法解释任务去了哪里。解决这个问题需要把幂等控制贯穿订单、支付、调度、代理和本地打印回执。
状态说明
本章是生产目标设计。当前原型已有订单幂等、单任务约束、尝试号、处理/打印租约和终态 JSON 回执,但独立
lease_token、所有状态更新的栅栏校验、提交前回执和本地回执数据库仍需实现。
一、核心数据实体
| 实体 | 关键字段 | 关键约束 |
|---|---|---|
tenant |
名称、状态 | 所有业务数据必须携带租户边界 |
store |
租户、地址、时区、营业状态 | 门店归属稳定 |
printer |
门店、别名、能力、状态 | 二维码别名全局唯一 |
agent |
门店、证书指纹、版本、最后心跳 | 每个代理凭证独立可吊销 |
user |
租户、微信用户标识、状态 | 用户标识加密或哈希索引 |
file_asset |
对象键、SHA-256、大小、类型、过期时间 | 对象私有,到期清理 |
quote |
价格版本、金额、币种、过期时间 | 支付必须引用有效报价 |
order |
用户、打印机、客户端请求 ID、状态 | 用户 + 客户端请求 ID 唯一 |
payment |
商户单号、平台交易号、金额、状态 | 两类交易号分别唯一 |
print_job |
订单、打印机、尝试号、租约、状态 | 订单 ID 唯一 |
job_receipt |
任务、尝试号、Spooler ID、结果 | 任务 + 尝试号唯一 |
audit_event |
操作者、动作、资源、前后值、trace ID | 追加写,不覆盖历史 |
二、建议状态机
订单状态
draft
→ quoted
→ pending_payment
→ paid
→ printing
→ completed
旁路状态包括:cancelled、refunding、refunded 和 attention_required。
支付状态
created → processing → succeeded
└→ closed
└→ failed
succeeded → refunding → refunded
打印任务状态
queued
→ leased
→ downloading
→ validated
→ submitted
→ completed
异常可以进入 failed 或 attention_required。只有在明确尚未提交实体打印时,失败任务才允许自动重试。
三、幂等键
| 操作 | 幂等键 | 重复请求结果 |
|---|---|---|
| 创建订单 | user_id + client_request_id |
返回原订单 |
| 支付回调 | 平台交易号 + 事件类型 | 只首次推进状态 |
| 创建打印任务 | order_id 唯一 |
不再创建第二条 |
| 代理领取 | job_id + attempt + lease_token |
过期令牌被拒绝 |
| 完成回执 | job_id + attempt + receipt_id |
返回已确认结果 |
| 退款 | 订单 ID + 退款业务版本 | 查询已有退款单 |
幂等不能只依赖应用代码里的“先查询再插入”,因为并发请求可能同时通过查询。必须使用数据库唯一索引,再把冲突转换为返回已有资源。
四、租约与栅栏
代理领取任务时,中心平台生成:
- 单调增加的
attempt - 不可猜测的
lease_token lease_expires_at- 当前代理和打印机身份
代理每次更新状态都提交这些字段。数据库更新条件同时检查任务 ID、尝试号、令牌和有效期。旧进程即使晚到,也不能覆盖新尝试。
打印命令执行前,代理做最后一次栅栏检查:
- 任务仍为当前尝试。
- 租约未过期。
- 订单仍为已支付且未取消。
- 目标打印机与本地队列映射没有变化。
- 本地不存在同一尝试已提交回执。
五、本地回执协议
物理打印前后存在无法靠网络事务覆盖的窗口,因此代理需要一个小型本地持久数据库。
提交前
写入 prepared 回执,包含任务 ID、尝试号、文件 SHA-256、打印机 ID、Windows 队列、参数摘要和时间,并确保记录落盘。
调用打印工具后
尽可能记录工具退出码、Windows Spooler 作业号和 submitted_at。然后把回执更新为 submitted。
服务端确认后
记录中心平台已确认的版本。之后即使代理重启并再次看到同一任务,也只会上报回执,不会再次执行打印命令。
不确定窗口
如果进程在调用打印工具之后、写入 submitted 之前崩溃,本地只剩 prepared。重启后无法证明打印命令是否执行,必须上报 attention_required,禁止自动重打。
六、可靠队列的正确用法
可靠队列通常提供至少一次投递。相同消息可能因为消费者超时、网络中断或可见性窗口重新出现。正确做法是:
- 消息只携带任务 ID 和唤醒信息。
- 消费者收到后重新读取 PostgreSQL。
- 通过条件更新争抢租约。
- 没抢到租约就安全确认消息。
- 可重试错误按退避规则重试。
- 超过上限进入死信队列并告警。
队列不是订单事实源,也不能因为消息再次出现就再次打印。
七、API 基线
小程序侧
POST /v1/auth/wechat
GET /v1/scenes/{code}
POST /v1/uploads
POST /v1/files/{id}/finalize
POST /v1/quotes
POST /v1/orders
POST /v1/orders/{id}/pay
GET /v1/orders/{id}
POST /v1/orders/{id}/cancel
门店代理侧
POST /v1/agent/session
POST /v1/agent/heartbeat
POST /v1/agent/jobs/lease
GET /v1/agent/jobs/{id}/file
POST /v1/agent/jobs/{id}/submitted
POST /v1/agent/jobs/{id}/complete
POST /v1/agent/jobs/{id}/attention
管理侧
管理 API 应与用户 API 使用不同身份和权限。定价发布、设备吊销、退款、手工结单和文件访问都必须记录操作者、原因、前后值、IP、时间和 trace ID。
八、工程上的真实目标
网络系统无法仅凭一次 HTTP 请求证明物理世界严格“恰好一次”。可实现的正确目标是:
- 数字状态强幂等
- 同一任务只有一个有效执行租约
- 物理提交前有栅栏
- 提交后有本地持久回执
- 所有不确定结果可见、可审计、可人工处理
- 未确认前绝不自动重打