离线支付实现方案(可落地版)
目标:在弱网/断网情况下仍能完成“收款-确认-交付”,并在网络恢复后与服务端最终一致完成入账、对账、风控与结算。
适用:商超收银、展会摆摊、园区食堂、移动收款、车场/景区等。
1. 术语与边界
- 离线支付:客户端(收银端/POS/商户端App)在无法访问支付网关或业务服务时,仍可完成“收款凭证生成/展示、消费者确认、交易记录落盘”,并在网络恢复后补传交易完成最终入账。
- 核心矛盾:离线时无法实时验签/扣款/余额校验 → 只能做到“先确认、后清算”,必须配合限额、风控、可追溯凭证、补传幂等。
- 必须明确:离线支付不等于“离线扣款”。真正扣款/记账通常发生在网络恢复后的补传阶段。
2. 总体架构
2.1 角色
- 收银端(POS/APP):离线生成交易、保存交易、展示/扫描凭证、补传交易、展示状态。
- 服务端(业务服务):接收补传、幂等入账、状态机推进、风控校验、对账、清分结算。
- 支付渠道(可选):微信/支付宝/银行卡/账户余额等。离线期通常无法直接调用。
- 对账/清结算:T+0/T+1 批处理、差错处理、退款/冲正。
2.2 关键设计点
离线凭证(Offline Token / Offline Proof)
- 可验证:消费者/商户能确认“这是这个商户开的单、金额是多少、时间/门店/设备是谁”。
- 可追溯:凭证能映射到 pay_order_id 或包含必要字段签名。
- 可防篡改:签名/校验码,至少要防止“改金额、改商户”。
本地账本(Local Ledger)
- 收银端必须有本地事务库(SQLite/Realm/LevelDB)+ WAL,确保断电不丢单。
- 所有离线交易先进入本地账本:INIT -> OFFLINE_CONFIRMED -> UPLOADING -> SUCCESS/FAIL/NEED_REVIEW
补传队列(Upload Queue)
- 网络恢复后按序补传(可并发,但同一设备建议有序)。
- 补传必须幂等:以 pay_order_id/offline_trade_no 做唯一键。
最终一致
- 离线成交仅代表“线下交付确认”,最终入账以服务端状态为准。
- 客户端需要展示:已确认(待入账)、入账成功、入账失败(需处理)。
3. 离线支付模式(推荐两种)
模式 A:预下发离线凭证(Token 预发)
适合:固定商户、风控可控、要更强防伪。
优点:防伪强、可控限额;缺点:需要在线预取、token 管理复杂。
模式 B:本地生成订单 + 服务端验签(基于设备密钥)
适合:自有钱包/账户体系,或对防伪要求中等。
优点:不需要 token 池;缺点:密钥管理要做得好(防泄露、轮换、吊销)。
4. 数据模型(服务端)
4.1 订单表 pay_order
| id | 自增主键 |
| pay_order_id | 业务支付单号(全局唯一) |
| mch_no / store_id / device_id | 商户/门店/设备 |
| amount | 金额(分) |
| currency | 币种 |
| channel | 支付渠道(offline) |
| status | 状态机(见下) |
| offline_token_id | 模式A使用 |
| offline_sign | 模式B使用 |
| offline_confirm_time | 离线确认时间 |
| upload_time | 补传时间 |
| risk_level | 风控等级 |
| fail_code / fail_msg | 失败原因 |
| version | 乐观锁 |
| created_at / updated_at | 时间 |
4.2 离线 token 表 offline_token(模式A)
| token_id | 唯一 |
| token_body | 明文/密文内容 |
| token_sign | 服务端签名 |
| mch_no/store_id/device_id | 绑定信息 |
| amount_min/max | 金额范围 |
| expire_at | 过期时间 |
| used | 是否已使用 |
| used_pay_order_id | 使用到哪笔单 |
4.3 状态机(建议)
- INIT:本地创建(服务端可能未知)
- OFFLINE_CONFIRMED:离线确认完成(待补传)
- UPLOADED:已补传到服务端
- BOOKED_SUCCESS:入账成功
- BOOKED_FAIL:入账失败(可重试/需人工)
- NEED_REVIEW:风控命中,需要审核
- REFUNDED / REVERSED:退款/冲正完成
5. 关键流程
5.1 在线预热(模式A/B通用)
5.2 离线收款(断网)
- 模式A:取 token + 订单摘要
- 模式B:对摘要签名(HMAC/ECDSA)
经验:一定要先落盘再展示,否则断电会丢单。
5.3 网络恢复补传
- 若 pay_order_id 已存在且已成功 → 直接返回成功(幂等)
- 若存在但状态为失败/审核 → 返回对应状态
- 若不存在 → 校验 → 创建订单 → 推进状态
6. 安全与风控(离线成败关键)
6.1 离线限额策略(强烈建议)
- 单笔限额:如 ≤ 200 元
- 单设备日限额:如 ≤ 2000 元
- 单设备日笔数:如 ≤ 50 笔
- 单门店离线总额上限
- 超限直接禁止离线收款(提示“需要联网”)
6.2 防篡改与防重放
订单摘要字段(至少):
- pay_order_id, mch_no, store_id, device_id, amount, offline_confirm_time, nonce
模式B签名建议:
- HMAC-SHA256(简单、快)或 ECDSA(更强)
- sign = HMAC(device_secret, canonicalString(summary))
- canonicalString 规则必须稳定:字段顺序固定、分隔符固定、金额用分为单位、时间用 UTC 秒
防重放:
- nonce(随机数)+ 服务端记录最近窗口
- offline_confirm_time 必须在允许窗口内(比如 48 小时)
6.3 密钥管理(模式B)
- device_secret 只能在安全存储(Android Keystore / iOS Keychain)
- 支持密钥轮换:key_version
- 设备丢失可吊销:服务端黑名单 device_id
6.4 风控命中处理
- 金额异常(超过该门店均值很多)
- 同设备短时间内高频离线确认
- 时间漂移过大(设备时间不可信)
- token 异常(重复使用/过期/门店不匹配)
- 命中后:状态 NEED_REVIEW,并给客户端提示“已记录,待确认入账”
7. 对账与差错处理
7.1 日对账(服务端)
- 对账口径:OFFLINE_CONFIRMED(客户端确认) vs BOOKED_SUCCESS(服务端入账)
- 每日输出:
- 待补传列表(超过 X 小时仍未补传)
- 入账失败列表(失败原因分类:验签失败/超限/黑名单/重复/数据缺失)
- 风控待审核列表
7.2 差错处理策略
- 补传失败可重试:网络/超时
- 验签失败不可自动重试:大概率被篡改或密钥不一致
- 超限:进入审核或拒绝(看业务策略)
- 退款/冲正:离线收款后发现问题,提供“撤销/退款”能力(走服务端流程)
8. 客户端本地账本设计(SQLite 示例)
8.1 本地表 local_pay_order
| pay_order_id | 主键 |
| amount | 金额 |
| status | INIT / OFFLINE_CONFIRMED / UPLOADING / SUCCESS / FAIL / NEED_REVIEW |
| token_or_sign | token/签名 |
| create_time / confirm_time | 时间 |
| retry_count | 重试次数 |
| last_error | 最后错误 |
8.2 补传队列策略
- 采用指数退避:1s、2s、4s、8s… 上限 5min
- 同一笔订单最大重试次数(如 50)
- 失败后分类型处理(可重试 vs 需人工)
9. 服务端幂等与一致性(Java/Spring Boot 可落地)
9.1 幂等关键:唯一键 + 状态机
- 数据库唯一键:pay_order_id UNIQUE
- 入口幂等:先查再插或直接插入捕获唯一键冲突
MySQL 建表示例(简化)
CREATE TABLE pay_order (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
pay_order_id VARCHAR(64) NOT NULL,
mch_no VARCHAR(64) NOT NULL,
store_id VARCHAR(64) NOT NULL,
device_id VARCHAR(64) NOT NULL,
amount BIGINT NOT NULL,
status VARCHAR(32) NOT NULL,
offline_token_id VARCHAR(64),
offline_sign VARCHAR(256),
offline_confirm_time DATETIME,
upload_time DATETIME,
risk_level INT DEFAULT 0,
fail_code VARCHAR(32),
fail_msg VARCHAR(256),
version INT NOT NULL DEFAULT 0,
created_at DATETIME NOT NULL,
updated_at DATETIME NOT NULL,
UNIQUE KEY uk_pay_order_id (pay_order_id)
);
9.2 补传接口(Controller)
@PostMapping("/offline/pay/upload")
public UploadResp upload(@RequestBody OfflineUploadReq req) {
// 1) 基础校验(字段完整、金额范围、时间窗口)
// 2) 幂等:pay_order_id 唯一
// 3) 验签/验token
// 4) 风控:限额、频率、黑名单
// 5) 落库 + 状态推进
// 6) 返回最终状态给客户端
return offlinePayService.handleUpload(req);
}
9.3 Service 幂等落库(伪代码)
@Transactional
public UploadResp handleUpload(OfflineUploadReq req) {
PayOrder existed = payOrderMapper.selectByPayOrderId(req.getPayOrderId());
if (existed != null) {
return UploadResp.from(existed.getStatus(), existed.getFailCode(), existed.getFailMsg());
}
// 验签 / 验 token
verify(req);
// 风控
RiskResult risk = riskService.check(req);
PayOrder order = PayOrder.from(req);
if (risk.needReview()) {
order.setStatus("NEED_REVIEW");
order.setRiskLevel(risk.level());
} else {
order.setStatus("BOOKED_SUCCESS");
}
try {
payOrderMapper.insert(order);
} catch (DuplicateKeyException dup) {
PayOrder again = payOrderMapper.selectByPayOrderId(req.getPayOrderId());
return UploadResp.from(again.getStatus(), again.getFailCode(), again.getFailMsg());
}
return UploadResp.from(order.getStatus(), order.getFailCode(), order.getFailMsg());
}
10. 体验设计(别忽略,不然会被投诉)
- 离线时明确提示:“已记录,待网络恢复后入账”(避免用户以为已经扣款)
- 提供“交易凭证编号/二维码截图”方便售后
- 让收银员一眼能看出:哪些单“已入账”、哪些“待补传”
- 网络恢复后自动补传 + 结果通知(本地通知/角标)
11. 测试清单(上线前必测)
12. 推荐落地组合(最稳)
- 模式A(token 预发) + 严格限额 + 服务端风控 + 日对账
- 客户端 SQLite 本地账本 + 队列补传 + 幂等
- 服务端:唯一键幂等 + 状态机 + 审核后台(简单列表也行)
13. 常见坑(踩过的都懂)
- 设备时间不可信:一定要允许窗口 + 风控,而不是完全依赖本地时间
- 先展示后落盘:断电就丢单,后果很惨
- 没有限额:离线就是开挂,风险直冲天花板
- 没有幂等:网络抖动会让你重复入账,直接资损
- 只做技术不做运营流程:审核、差错、退款、对账没人接,照样翻车
14. 附:离线凭证内容建议(示例)
二维码内容(JSON + Base64,外层再加签名):
{
"v": 1,
"payOrderId": "PO202601140001",
"mchNo": "M10001",
"storeId": "S01",
"deviceId": "D09",
"amount": 1999,
"ts": 1736800000,
"nonce": "8f3a2c…"
}
展示给用户的短码(可选):
- 校验码 = CRC32(payOrderId + amount + ts) % 1000000(仅用于人工核对,不替代签名)



