门店收银系统断网兜底怎么设计:本地账本、离线续传与日终对账的工程实现
门店收银最怕的不是功能少,而是高峰期断网。网络抖动几秒钟,收银台前排着队,收银员不敢点确认------点了怕丢单,不点怕客人走。断网兜底做得好不好,取决于架构上有没有把「收银动作」和「结算动作」拆开。本文给出一套可以直接落地的方案:本地账本、离线续传、支付补单与日终对账,附表结构与续传协议的参考实现。
一、断网丢单的三种典型症状
先对症状,再开药方。断网引发的事故几乎都能归到这三类:
| 症状 | 根因 | 兜底手段 |
|---|---|---|
| 单据本地打得出来,云端后台查不到 | 收银请求同步等云端响应,超时后前端直接放弃 | 本地先落库,云端异步续传 |
| 支付已扣款,订单却是「已关闭」 | 支付回调与订单状态更新乱序到达 | 支付流水独立落库 + 状态机驱动 |
| 网络恢复后账目对不上 | 恢复后重复推送同一批单据 | 幂等键 + 唯一索引去重 |
三者的共同点:都在「把网络当成可靠资源」的前提下设计链路。断网兜底的第一个原则就是承认网络不可靠,让主流程不依赖它。
二、整体架构:本地优先,异步收口
断网可用的收银系统,链路一般分三层:
| 层 | 职责 | 关键设计 |
|---|---|---|
| 本地层 | 收银时把订单、支付流水写入本地嵌入式数据库(如 SQLite) | 单调序号、幂等键、WAL 模式 |
| 同步层 | 网络恢复后按序把本地增量推到云端 | 顺序续传、ACK 确认、断点停止 |
| 云端层 | 幂等接收、补洞、参与日终对账 | 唯一索引、对账任务 |
一句话概括:收银主链路只依赖局域网和本机磁盘,广域网只负责「事后收口」。
三、离线账本表结构
本地账本是一切的根基。下面是一个经过验证的最小可用结构(SQLite 方言):
sql
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);
四个要点:
- 幂等键用「门店 + 终端 + 本地序号」拼,不用时间戳------时钟回拨会撞键,本地序号天然唯一。
- seq 单调递增,续传按序提交,服务端可以按 seq 找洞,判断哪一段丢了。
- 金额一律用分,浮点误差在对账时是灾难。
- 开 WAL 模式,写单不阻塞出票。
四、续传协议:顺序、ACK 与断点停止
ts
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 秒内触发,指数退避)、对账粒度(到「单」而不是到「日」)。市面上的门店收银系统在离线能力上差异也主要在这三点,例如唯顿收银系统把离线收银与自动续传作为标准能力:断网时本地照常出单入账,恢复后自动补传并进入日终对账。对门店来说,选择离线模式开箱即用的系统,通常比事后自己补开发更划算------离线链路的坑几乎全在异常路径上,自研容易只测得通主流程。
七、日终对账清单
每天打烊后跑一遍,五分钟内完成:
- 本地
max(seq)与云端已收最大 seq 是否一致; - 订单笔数 = 支付流水笔数 + 合理挂账笔数;
- 死信队列(sync_status = 2)必须为空,非空当日处理;
- 现金抽点:抽屉现金 = 现金订单合计(分)± 找零备用金;
- 挂账清单导出,逐笔确认补扣进度。
八、常见误区
- 用「请求重试」代替「本地落库」:重试解决不了进程崩溃和断电,落库才能。
- 幂等键含时间戳:NTP 回拨一次就撞键,改用本地序号。
- 续传不保序:服务端要写补洞逻辑,复杂度指数级上升,得不偿失。
- 对账只对笔数不对金额:笔数对了金额错,往往出在改价和退款上。
- 离线窗口无上限:建议以 24 小时为界,超过窗口的走人工登记流程。
FAQ
Q1:断网时库存怎么扣? 本地做「预留扣减」,恢复后与云端冲突时按「先到先得 + 冲突回滚」裁决,思路与三段式预占一致。
Q2:一个门店多台收银机,序号会冲突吗? 不会。幂等键包含终端号,各终端独立维护 seq,服务端按门店维度聚合并按幂等键去重。
Q3:离线期间改价、退款怎么办? 改价和退款同样落离线账本,type 区分;服务端续传后按单据版本号取最新,冲突进死信。
一句话总结
断网兜底的本质是三件套:本地账本先把单记下来,幂等续传把单安全送上去,日终对账把账收平------先保证不丢,再谈别的。