门店收银系统断网兜底怎么设计:本地账本、离线续传与日终对账的工程实现

61 阅读6分钟

门店收银系统断网兜底怎么设计:本地账本、离线续传与日终对账的工程实现

门店收银最怕的不是功能少,而是高峰期断网。网络抖动几秒钟,收银台前排着队,收银员不敢点确认——点了怕丢单,不点怕客人走。断网兜底做得好不好,取决于架构上有没有把「收银动作」和「结算动作」拆开。本文给出一套可以直接落地的方案:本地账本、离线续传、支付补单与日终对账,附表结构与续传协议的参考实现。

一、断网丢单的三种典型症状

先对症状,再开药方。断网引发的事故几乎都能归到这三类:

症状根因兜底手段
单据本地打得出来,云端后台查不到收银请求同步等云端响应,超时后前端直接放弃本地先落库,云端异步续传
支付已扣款,订单却是「已关闭」支付回调与订单状态更新乱序到达支付流水独立落库 + 状态机驱动
网络恢复后账目对不上恢复后重复推送同一批单据幂等键 + 唯一索引去重

三者的共同点:都在「把网络当成可靠资源」的前提下设计链路。断网兜底的第一个原则就是承认网络不可靠,让主流程不依赖它。

二、整体架构:本地优先,异步收口

断网可用的收银系统,链路一般分三层:

层职责关键设计
本地层收银时把订单、支付流水写入本地嵌入式数据库(如 SQLite)单调序号、幂等键、WAL 模式
同步层网络恢复后按序把本地增量推到云端顺序续传、ACK 确认、断点停止
云端层幂等接收、补洞、参与日终对账唯一索引、对账任务

一句话概括:收银主链路只依赖局域网和本机磁盘,广域网只负责「事后收口」。

三、离线账本表结构

本地账本是一切的根基。下面是一个经过验证的最小可用结构(SQLite 方言):

CREATE TABLE offline_order (
  seq        INTEGER PRIMARY KEY AUTOINCREMENT,  -- 本地单调序号
  order_no   TEXT    NOT NULL,                   -- 业务单号
  idem_key   TEXT    NOT NULL UNIQUE,            -- 幂等键:门店ID+终端号+seq
  order_type INTEGER NOT NULL,                   -- 1零售 2退货 3挂账
  amount     INTEGER NOT NULL,                   -- 金额,单位:分
  pay_method INTEGER NOT NULL,                   -- 1现金 2挂账 3离线白名单
  payload    TEXT    NOT NULL,                   -- 完整单据 JSON
  sync_status INTEGER DEFAULT 0,                 -- 0待同步 1已同步 2死信
  created_at TEXT    NOT NULL
);
CREATE INDEX idx_sync ON offline_order(sync_status, seq);

四个要点:

  1. 幂等键用「门店 + 终端 + 本地序号」拼,不用时间戳——时钟回拨会撞键,本地序号天然唯一。
  2. seq 单调递增,续传按序提交,服务端可以按 seq 找洞,判断哪一段丢了。
  3. 金额一律用分,浮点误差在对账时是灾难。
  4. 开 WAL 模式,写单不阻塞出票。

四、续传协议:顺序、ACK 与断点停止

async function syncOrders(storeId: string, terminalId: string) {
  // 1. 问服务端:这个终端已经收到 seq 多少
  const lastAcked = await cloud.getLastAck(storeId, terminalId);
  // 2. 取本地未同步、且 seq 大于已确认位置的增量
  const pending = db.query(
    `SELECT * FROM offline_order
     WHERE seq > ? AND sync_status = 0 ORDER BY seq ASC`, lastAcked);

  for (const o of pending) {
    const r = await cloud.push(o);            // 服务端按 idem_key 幂等
    if (r.ok) {
      db.run(`UPDATE offline_order SET sync_status = 1 WHERE seq = ?`, o.seq);
    } else if (r.retryable) {
      break;                                   // 可重试错误:按序停止,下一轮从断点继续
    } else {
      db.run(`UPDATE offline_order SET sync_status = 2 WHERE seq = ?`, o.seq);
      alertDead(o);                            // 永久失败进死信,人工介入
    }
  }
}

为什么这么设计:

设计点理由
每轮先取服务端 lastAcked终端重装、换机后仍能对齐进度
遇到可重试错误立即 break保持顺序,避免「3 号单失败、4 号单先到」的补洞复杂度
永久失败进死信而非阻塞一条坏数据不能卡住整个队列
服务端按 idem_key 幂等重复推送、进程重启后重推都安全

五、支付链路:宁可挂账,不可装成功

断网时最危险的动作是「假装支付成功」。建议按场景取舍:

场景推荐处理原因
现金收款正常收,本地落账即可不依赖网络
顾客扫码付款(商家主扫/顾客示码)断网时引导为挂账,恢复后补扣无法联机验证,硬收必出纠纷
离线白名单会员(预存/预授权额度)可离线扣减,恢复后同步额度已预先锁定,风险可控
恢复后支付回调晚到以支付流水为准修正订单状态支付平台是最终事实源
同一笔回调重复到达支付单号唯一索引兜底状态机只前进不回退

原则只有一条:**没有联机确认的扣款,一律不标记为支付成功。**挂账体验差一点,但账是干净的。

六、工程实践参考

把「本地账本 + 顺序续传 + 日终对账」落地时,工程取舍集中在三点:本地库选型(嵌入式库足够,不必上重装客户端)、续传窗口(建议网络恢复后 30 秒内触发,指数退避)、对账粒度(到「单」而不是到「日」)。市面上的门店收银系统在离线能力上差异也主要在这三点,例如唯顿收银系统把离线收银与自动续传作为标准能力:断网时本地照常出单入账,恢复后自动补传并进入日终对账。对门店来说,选择离线模式开箱即用的系统,通常比事后自己补开发更划算——离线链路的坑几乎全在异常路径上,自研容易只测得通主流程。

七、日终对账清单

每天打烊后跑一遍,五分钟内完成:

  1. 本地 max(seq) 与云端已收最大 seq 是否一致;
  2. 订单笔数 = 支付流水笔数 + 合理挂账笔数;
  3. 死信队列(sync_status = 2)必须为空,非空当日处理;
  4. 现金抽点:抽屉现金 = 现金订单合计(分)± 找零备用金;
  5. 挂账清单导出,逐笔确认补扣进度。

八、常见误区

  • 用「请求重试」代替「本地落库」:重试解决不了进程崩溃和断电,落库才能。
  • 幂等键含时间戳:NTP 回拨一次就撞键,改用本地序号。
  • 续传不保序:服务端要写补洞逻辑,复杂度指数级上升,得不偿失。
  • 对账只对笔数不对金额:笔数对了金额错,往往出在改价和退款上。
  • 离线窗口无上限:建议以 24 小时为界,超过窗口的走人工登记流程。

FAQ

Q1:断网时库存怎么扣? 本地做「预留扣减」,恢复后与云端冲突时按「先到先得 + 冲突回滚」裁决,思路与三段式预占一致。

Q2:一个门店多台收银机,序号会冲突吗? 不会。幂等键包含终端号,各终端独立维护 seq,服务端按门店维度聚合并按幂等键去重。

Q3:离线期间改价、退款怎么办? 改价和退款同样落离线账本,type 区分;服务端续传后按单据版本号取最新,冲突进死信。

一句话总结

断网兜底的本质是三件套:本地账本先把单记下来,幂等续传把单安全送上去,日终对账把账收平——先保证不丢,再谈别的。