线下门店和纯线上业务最大的不同,是网络不可靠这件事每天都在发生:商场地下一层信号差、运营商线路临时故障、高峰期路由器拥塞。如果收银系统一断网就瘫痪,顾客排着长队结不了账,损失的是实打实的成交。一套合格的门店系统应当做到"断网能继续营业、恢复后自动对账、数据最终不错不乱"。这篇文章拆解离线优先(Offline-First)的收银设计:本地持久化队列、断网续传、客户端生成幂等单号、恢复后的冲突检测与对账,并复盘五个踩坑。
很多系统把"没网"当成异常直接报错,离线优先的思路恰好相反:默认网络会断,核心交易在本地就能闭环,网络只是用来同步的。门店收银要保证三件事:
收银端(Pad/POS)内置一个本地数据库(如 SQLite/IndexedDB),开单时不等待服务端,先写本地、立即返回成功,同时给每笔交易打上同步状态标记。
// 交易状态机:LOCAL_ONLY(仅本地) -> SYNCING -> SYNCED / CONFLICT
async function createOrderLocal(cart, payment) {
const order = {
orderNo: genLocalOrderNo(), // 客户端生成全局唯一单号(见第三节)
items: cart,
payAmount: payment.amount,
payChannel: payment.channel,
status: 'LOCAL_ONLY',
createdAt: Date.now(),
deviceId: DEVICE_ID,
version: 1,
};
await localDb.orders.put(order); // 先落本地,事务保证持久化
printReceipt(order); // 立即出票,不阻塞于网络
syncQueue.enqueue(order.orderNo); // 入同步队列,在线就立刻尝试上传
return order;
}关键是写入本地要用真正的事务落盘,而不是只写内存,否则 POS 突然断电,刚收的钱就没记录了。
离线场景下单号不能由服务端发(断网时拿不到),必须客户端生成全局唯一 ID,并且让服务端以这个单号做幂等键,这样断网续传、手动重试、超时重发都不会产生重复订单。
import time, uuid
def gen_local_order_no(store_id, pos_id):
# 门店号+终端号+毫秒时间+随机段,全局唯一且自带业务信息
ts = time.strftime('%Y%m%d%H%M%S')
rand = uuid.uuid4().hex[:6].upper()
return f"{store_id}{pos_id}{ts}{rand}"
def server_ingest(order):
# 单号唯一索引兜底:重复上传直接返回已存在,不再插第二笔
existed = db.query("SELECT status FROM orders WHERE order_no=%s", order['order_no'])
if existed:
return {'code': 0, 'orderNo': order['order_no'], 'duplicated': True}
db.insert('orders', order)
return {'code': 0, 'orderNo': order['order_no'], 'duplicated': False}注意:支付状态要和下单分开对账。离线时"现金"可以直接确认;"扫码支付"在断网时无法真正向支付通道确认,应当只记录顾客付款意图,联网后以支付平台的实际流水为准,避免出现"小票打了但钱没到账"。
本地维护一个同步队列,按先进先出上传,网络恢复自动续传,失败指数退避,并实时反馈同步进度给店员。
class SyncEngine {
async flush() {
if (!navigator.onLine || this.running) return;
this.running = true;
const pending = await localDb.orders.where('status').equals('LOCAL_ONLY').toArray();
for (const order of pending) {
try {
await localDb.orders.update(order.orderNo, { status: 'SYNCING' });
const res = await api.post('/orders/sync', order);
await localDb.orders.update(order.orderNo, {
status: 'SYNCED', serverAckAt: Date.now(),
});
} catch (e) {
await localDb.orders.update(order.orderNo, { status: 'LOCAL_ONLY' });
await this.backoff(); // 失败退避,等下一轮,不阻塞后续可成功的单
}
}
this.running = false;
}
start() {
window.addEventListener('online', () => this.flush());
setInterval(() => this.flush(), 15000); // 兜底轮询
}
}同步要做到"单条失败不阻塞整批":某一单因为数据问题传不上去,先跳过它继续传后面的,最后把问题单单独拎出来处理,避免一单卡死全天营业数据。
离线期间多个终端可能各自开单,恢复后需要一套对账机制兜底,而不是假设一定不冲突:
-- 恢复后拉取服务端本班次订单,与本地做全量比对
SELECT o.order_no, o.pay_amount, l.pay_amount AS local_amount
FROM orders o
LEFT JOIN local_upload_log l ON o.order_no = l.order_no
WHERE o.shift_id = ? AND o.created_at >= ?
HAVING o.pay_amount <> IFNULL(l.pay_amount, -1);对账不追求"零人工",而是把可能出错的极少数单精确定位出来,让店员只处理异常,而不是逐单核对。
离线能营业不等于什么都能离线,必须给店员清晰的边界提示,避免误用:界面上要常驻"当前离线,数据保存在本机,联网后自动同步"的状态条,让店员知道现在是离线模式;离线期间不可用的能力(如核销需要服务端校验的线上券、查询会员实时余额)要明确置灰并说明,而不是让店员操作到一半才失败。本地落库的交易数据涉及金额和顾客信息,应当加密存储并绑定设备,POS 退出登录或交班后清理敏感缓存,防止设备丢失导致数据泄露。把"哪些能离线、哪些不能、离线数据存哪安不安全"在产品层定义清楚,离线优先才不会在真实门店里制造新的混乱。
门店离线收银的本质,是把"网络"从交易的必经之路降级为同步工具:本地事务保证断网能营业且不丢单,客户端幂等单号保证续传不重复,同步队列保证恢复后自动收敛,对账机制把极少数冲突精确定位。这套离线优先思想同样适用于上门服务、仓储盘点、移动巡检等所有弱网现场作业场景。下一步建议在门店做一次真实的"拔网线演练":营业高峰主动断网 20 分钟再恢复,观察是否能正常出票、续传是否零重复、对账差异是否都能被自动找出,用演练结果打磨异常处理流程。
原创声明:本文系作者授权腾讯云开发者社区发表,未经许可,不得转载。
如有侵权,请联系 cloudcommunity@tencent.com 删除。