自助打印最严重的技术事故不是接口报错,而是用户只付一次钱却打印两次,或已经付款却无法解释任务去了哪里。解决这个问题需要把幂等控制贯穿订单、支付、调度、代理和本地打印回执。

状态说明

本章是生产目标设计。当前原型已有订单幂等、单任务约束、尝试号、处理/打印租约和终态 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

旁路状态包括:cancelledrefundingrefundedattention_required

支付状态

created → processing → succeeded
       └→ closed
       └→ failed
succeeded → refunding → refunded

打印任务状态

queued
  → leased
  → downloading
  → validated
  → submitted
  → completed

异常可以进入 failedattention_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、尝试号、令牌和有效期。旧进程即使晚到,也不能覆盖新尝试。

打印命令执行前,代理做最后一次栅栏检查:

  1. 任务仍为当前尝试。
  2. 租约未过期。
  3. 订单仍为已支付且未取消。
  4. 目标打印机与本地队列映射没有变化。
  5. 本地不存在同一尝试已提交回执。

五、本地回执协议

物理打印前后存在无法靠网络事务覆盖的窗口,因此代理需要一个小型本地持久数据库。

提交前

写入 prepared 回执,包含任务 ID、尝试号、文件 SHA-256、打印机 ID、Windows 队列、参数摘要和时间,并确保记录落盘。

调用打印工具后

尽可能记录工具退出码、Windows Spooler 作业号和 submitted_at。然后把回执更新为 submitted

服务端确认后

记录中心平台已确认的版本。之后即使代理重启并再次看到同一任务,也只会上报回执,不会再次执行打印命令。

不确定窗口

如果进程在调用打印工具之后、写入 submitted 之前崩溃,本地只剩 prepared。重启后无法证明打印命令是否执行,必须上报 attention_required,禁止自动重打。

六、可靠队列的正确用法

可靠队列通常提供至少一次投递。相同消息可能因为消费者超时、网络中断或可见性窗口重新出现。正确做法是:

  1. 消息只携带任务 ID 和唤醒信息。
  2. 消费者收到后重新读取 PostgreSQL。
  3. 通过条件更新争抢租约。
  4. 没抢到租约就安全确认消息。
  5. 可重试错误按退避规则重试。
  6. 超过上限进入死信队列并告警。

队列不是订单事实源,也不能因为消息再次出现就再次打印。

七、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 请求证明物理世界严格“恰好一次”。可实现的正确目标是:

  • 数字状态强幂等
  • 同一任务只有一个有效执行租约
  • 物理提交前有栅栏
  • 提交后有本地持久回执
  • 所有不确定结果可见、可审计、可人工处理
  • 未确认前绝不自动重打

上一篇:多门店目标架构下一篇:文件安全