前端解决离线收银、数据同步异常的核心思路是离线优先(Offline First)设计 + 本地可靠存储 + 容错同步策略 + 业务兜底机制,既要保证离线场景下收银核心流程不中断,也要解决网络恢复后数据同步的一致性、幂等性问题。以下是分模块的落地方案:
一、核心设计原则
- 数据一致性优先:离线操作的核心数据(订单、金额、库存)需保证本地与服务端最终一致,避免账实不符;
- 幂等性设计:所有同步接口必须幂等(重复提交不产生副作用),防止重复入账/扣库存;
- 故障可追溯:离线操作、同步异常需全链路日志记录,支持事后对账;
- 最小可用降级:离线时仅保留核心收银功能,非核心功能(如营销活动、会员积分)可降级关闭。
二、基础层:离线存储与静态资源保障
离线收银的前提是页面能打开、数据能存储、核心逻辑能执行,首先解决基础层问题:
1. 本地存储方案选型(核心)
收银数据量大、需事务保障,优先选 IndexedDB(推荐封装库:localForage/Dexie.js),搭配轻量存储做补充:
| 存储方案 | 适用场景 | 优势 | 注意点 |
|---|---|---|---|
| IndexedDB | 离线订单、商品库、收银记录 | 大容量(无上限)、支持事务/索引、异步 | 需封装简化使用,注意浏览器兼容性 |
| LocalStorage | 设备标识、离线状态、同步配置 | 同步、易用 | 容量5MB,不支持事务,仅存轻量数据 |
| Service Worker | 缓存静态资源(页面、JS/CSS、商品图片) | 离线时页面仍能打开,拦截网络请求 | 需配置缓存策略,避免静态资源过期 |
示例:Dexie.js 封装离线订单存储(事务保障)
javascript
// 初始化IndexedDB数据库(收银库)
import Dexie from 'dexie';
const db = new Dexie('CashierDB');
// 定义表结构:订单表(主键orderId,索引status/syncStatus)
db.version(1).stores({
orders: '++id, orderId, status, syncStatus, amount, payType, createTime, deviceId',
syncLogs: '++id, orderId, syncTime, result, errorMsg' // 同步日志表
});
// 离线保存订单(事务:保证订单+金额+库存原子性)
async function saveOfflineOrder(orderData) {
try {
return await db.transaction('rw', db.orders, async () => {
// 1. 生成离线唯一订单号(避免重复):设备ID + 时间戳 + 随机数
const orderId = `OFFLINE_${deviceId}_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`;
// 2. 存储订单(标记为未同步)
const orderId = await db.orders.put({
...orderData,
orderId,
syncStatus: 'pending', // 待同步
createTime: new Date().getTime(),
deviceId: getDeviceId() // 设备唯一标识(如UUID,首次打开生成)
});
// 3. 本地扣减库存(若有)
await updateLocalStock(orderData.goodsList);
return orderId;
});
} catch (e) {
console.error('离线保存订单失败:', e);
throw new Error('本地存储异常,无法完成收银');
}
}
2. 静态资源离线访问(PWA + Service Worker)
通过 Service Worker 缓存收银页面的核心静态资源(HTML/JS/CSS/商品基础库),保证断网后页面能正常打开:
javascript
// service-worker.js 核心逻辑
const CACHE_NAME = 'cashier-v1';
const CORE_ASSETS = [
'/cashier/index.html',
'/cashier/core.js',
'/cashier/style.css',
'/cashier/goods-base.json' // 基础商品库(离线兜底)
];
// 安装阶段:缓存核心资源
self.addEventListener('install', (event) => {
event.waitUntil(
caches.open(CACHE_NAME)
.then(cache => cache.addAll(CORE_ASSETS))
.then(() => self.skipWaiting()) // 立即激活SW
);
});
// 激活阶段:清理旧缓存
self.addEventListener('activate', (event) => {
event.waitUntil(
caches.keys().then(cacheNames => {
return Promise.all(
cacheNames.filter(name => name !== CACHE_NAME).map(name => caches.delete(name))
);
}).then(() => self.clients.claim()) // 控制所有打开的页面
);
});
// 拦截网络请求:离线时优先用缓存
self.addEventListener('fetch', (event) => {
// 仅拦截收银相关请求
if (event.request.url.includes('/cashier/')) {
event.respondWith(
caches.match(event.request)
.then(cachedResponse => {
// 有缓存用缓存,无缓存则尝试网络(离线时返回兜底页面)
return cachedResponse || fetch(event.request).catch(() => {
if (event.request.mode === 'navigate') {
return caches.match('/cashier/index.html'); // 兜底页面
}
});
})
);
}
});
三、离线收银核心流程实现
离线收银的核心是"离线能收、数据不丢、联网能同步",流程拆解如下:
1. 离线状态检测
通过 navigator.onLine 结合「主动轮询接口」判断网络状态,避免仅依赖浏览器原生状态(存在误判):
javascript
// 网络状态管理
class NetworkStatus {
constructor() {
this.isOnline = navigator.onLine;
this.listeners = [];
// 监听原生网络状态
window.addEventListener('online', () => this.updateStatus(true));
window.addEventListener('offline', () => this.updateStatus(false));
// 主动轮询(每5s):验证网络是否真的可用
setInterval(() => this.checkNetwork(), 5000);
}
// 主动检测:请求轻量接口(如/health)
async checkNetwork() {
try {
const res = await fetch('/api/health', { timeout: 3000 });
this.updateStatus(res.ok);
} catch (e) {
this.updateStatus(false);
}
}
updateStatus(status) {
if (this.isOnline !== status) {
this.isOnline = status;
// 通知所有监听者(如收银页面更新状态UI)
this.listeners.forEach(fn => fn(status));
}
}
onStatusChange(fn) {
this.listeners.push(fn);
}
}
// 实例化使用
const network = new NetworkStatus();
network.onStatusChange((isOnline) => {
document.querySelector('.network-status').textContent = isOnline ? '在线' : '离线';
});
2. 离线收银核心步骤
graph TD
A[用户发起收银] --> B{检测网络状态};
B -->|在线| C[正常调用服务端接口创建订单+支付];
B -->|离线| D[本地生成唯一订单号];
D --> E[IndexedDB事务存储订单(金额/商品/支付方式)];
E --> F[本地扣减库存(商品库本地副本)];
F --> G[打印小票(调用本地打印机/缓存打印任务)];
G --> H[标记订单为「离线待同步」];
H --> I[网络恢复后自动触发同步];
关键细节:
- 离线订单号生成 :必须全局唯一(避免同步时重复),规则:
设备ID(UUID) + 时间戳(ms) + 随机数(6位); - 支付方式适配:离线时仅支持「现金/刷卡」(无需联网验证),「微信/支付宝」需标记为「待补单」,网络恢复后自动发起支付验证;
- 小票打印:若打印机离线,将打印任务存入IndexedDB,恢复后自动重打(需记录打印状态:未打印/已打印/打印失败)。
3. 防止本地数据篡改
收银数据敏感,需避免本地篡改:
- 核心字段(金额、订单号)本地存储时做签名校验:服务端下发公钥,本地对订单数据加密签名,同步时服务端验签;
- 关键操作(如改单、退款)需本地记录操作人+操作时间,同步后服务端审计。
四、数据同步异常全链路解决方案
同步异常是离线收银的核心痛点,需覆盖「网络中断、服务端错误、数据冲突、重复提交」等场景,按"事前预防-事中处理-事后兜底"设计:
1. 同步策略:增量+优先级
- 增量同步:仅同步「待同步/同步失败」的订单,避免全量同步耗带宽;
- 优先级排序:先同步「已支付完成」的订单,再同步「待支付」的订单,避免核心订单漏同步;
- 分批同步:单次同步≤10条订单,避免单次请求过大导致超时。
2. 异常场景与解决方案
| 异常类型 | 表现形式 | 前端处理方案 |
|---|---|---|
| 网络中断(同步中断) | 请求发送中网络断开 | 记录同步进度,网络恢复后从断点继续同步(IndexedDB标记订单同步状态:pending/processing/failed/success) |
| 服务端错误(5xx/4xx) | 接口返回500/400,同步失败 | 指数退避重试(重试间隔:10s→30s→1min→5min,最多重试10次),超过次数标记为「人工处理」 |
| 数据冲突(主键重复/版本不一致) | 同步时提示订单已存在/库存不足 | 乐观锁解决:本地订单携带「版本号」,服务端对比版本;冲突时弹窗提示,支持「覆盖/合并/人工核对」 |
| 数据格式错误 | 订单字段缺失/格式不符,服务端拒收 | 本地校验(同步前检查必填字段)+ 错误日志记录(含完整订单数据),支持手动编辑错误字段后重同步 |
| 服务端限流/超时 | 接口超时/返回429 | 暂停同步,触发限流等待,同时降级为「低频同步」(从30s轮询改为5min轮询) |
示例:指数退避重试代码
javascript
// 同步单个订单
async function syncOrder(orderId) {
const order = await db.orders.get({ orderId });
if (!order || order.syncStatus === 'success') return;
// 更新同步状态为「处理中」(避免重复同步)
await db.orders.update(orderId, { syncStatus: 'processing' });
const retryConfig = {
maxRetries: 10, // 最大重试次数
baseDelay: 10000, // 初始延迟10s
retries: order.syncRetryCount || 0 // 已重试次数
};
try {
const res = await fetch('/api/cashier/sync', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
order,
sign: signOrder(order) // 订单签名,服务端验签
})
});
if (res.ok) {
// 同步成功:更新状态+记录日志
await db.orders.update(orderId, { syncStatus: 'success' });
await db.syncLogs.add({
orderId,
syncTime: Date.now(),
result: 'success'
});
} else {
throw new Error(`同步失败:${res.status}`);
}
} catch (e) {
retryConfig.retries += 1;
// 记录错误日志
await db.syncLogs.add({
orderId,
syncTime: Date.now(),
result: 'failed',
errorMsg: e.message
});
if (retryConfig.retries <= retryConfig.maxRetries) {
// 指数退避计算延迟:baseDelay * (2^retries)
const delay = retryConfig.baseDelay * Math.pow(2, retryConfig.retries);
setTimeout(() => syncOrder(orderId), delay);
} else {
// 超过最大重试次数,标记为「人工处理」
await db.orders.update(orderId, {
syncStatus: 'manual',
syncRetryCount: retryConfig.retries
});
// 弹窗提示收银员
alert(`订单${orderId}同步失败,请联系管理员手动处理`);
}
}
}
// 网络恢复后批量同步待同步订单
network.onStatusChange(async (isOnline) => {
if (isOnline) {
// 查询所有待同步/同步失败的订单
const pendingOrders = await db.orders.where('syncStatus').anyOf('pending', 'failed').toArray();
// 按创建时间升序同步(先同步早的订单)
pendingOrders.sort((a, b) => a.createTime - b.createTime)
.forEach(order => syncOrder(order.orderId));
}
});
3. 幂等性保障(核心)
同步接口必须幂等,前端需配合:
- 以「离线订单号」作为幂等键,服务端根据该ID判断是否已处理;
- 同步请求携带
requestId(唯一),服务端记录已处理的requestId,重复请求直接返回成功。
4. 数据冲突解决
离线操作可能导致本地与服务端数据不一致(如库存扣减冲突):
- 乐观锁方案:本地商品库存储「版本号」,离线扣减库存时记录版本号;同步时服务端对比版本号,若不一致则返回冲突,前端弹窗展示「本地库存/服务端库存」,支持收银员手动确认扣减;
- 兜底规则:若冲突无法自动解决,优先以服务端数据为准,本地记录差异,生成对账差异单,供财务事后核对。
五、容错兜底与业务保障
1. 降级策略
- 离线时关闭非核心功能:会员积分抵扣、优惠券使用、营销活动展示等;
- 服务端整体不可用时,收银页面仅保留「现金收银+本地小票打印」功能,其他支付方式禁用。
2. 手动兜底机制
- 同步失败的订单,提供「手动触发同步」按钮,支持收银员重试;
- 极端情况下(如本地数据库损坏),支持「导出离线订单数据」(JSON/Excel),人工导入后台系统;
- 本地存储定期备份:每日凌晨自动将订单数据备份到本地文件,支持手动备份。
3. 对账机制
- 本地记录「收银台账」:每日汇总离线订单金额、支付方式,生成本地对账表;
- 网络恢复后,自动拉取服务端对账数据,对比本地与服务端的订单数、金额,生成差异报表;
- 财务定期核对差异报表,修正账实不符问题。
六、监控与可追溯
- 本地日志:所有离线操作、同步失败、支付异常均记录到IndexedDB(含操作人、时间、设备、错误栈),网络恢复后自动上传到服务端日志系统;
- 状态可视化:收银页面展示「离线订单数、同步失败数、待打印小票数」,异常状态标红提醒;
- 告警机制:同步失败次数超过阈值(如5次),自动触发门店管理员的微信/短信告警。
七、实战简化示例(核心流程)
javascript
// 1. 初始化网络状态
const network = new NetworkStatus();
// 2. 初始化本地数据库
const db = initCashierDB();
// 3. 收银提交函数
async function submitCashier(orderForm) {
// 校验核心字段
if (!orderForm.amount || !orderForm.goodsList.length) {
alert('请填写完整订单信息');
return;
}
if (network.isOnline) {
// 在线收银:调用服务端接口
try {
const res = await fetch('/api/cashier/create', {
method: 'POST',
body: JSON.stringify(orderForm)
});
const data = await res.json();
if (data.success) {
printReceipt(data.order); // 打印小票
updateStock(data.stock); // 更新本地库存
} else {
throw new Error(data.msg);
}
} catch (e) {
// 在线收银失败,降级为离线收银
alert(`在线收银失败,切换离线模式:${e.message}`);
await saveOfflineOrder(orderForm);
printReceipt(orderForm); // 本地打印小票
}
} else {
// 离线收银:本地存储
await saveOfflineOrder(orderForm);
printReceipt(orderForm);
alert('当前离线,订单已保存,网络恢复后自动同步');
}
}
// 4. 网络恢复后自动同步
network.onStatusChange(async (isOnline) => {
if (isOnline) {
const pendingOrders = await db.orders.where('syncStatus').anyOf('pending', 'failed').toArray();
pendingOrders.forEach(order => syncOrder(order.orderId));
}
});
总结
前端解决离线收银、数据同步异常的核心是:
- 离线层:用IndexedDB保障核心数据的本地存储与事务性,Service Worker保障页面可用;
- 同步层:幂等设计+指数退避重试+优先级同步,解决各类同步异常;
- 业务层:离线仅保留核心功能,异常订单支持手动兜底,全链路日志保障可追溯;
- 对账层:本地与服务端定期对账,保证金额、库存最终一致。
最终目标是:离线能收、数据不丢、同步不重、异常可解,即使极端场景下也能通过人工兜底避免账实不符。