离线支付实现方案(可落地版)
目标:在弱网/断网 情况下仍能完成"收款-确认-交付",并在网络恢复后与服务端最终一致 完成入账、对账、风控与结算。
适用:商超收银、展会摆摊、园区食堂、移动收款、车场/景区等。
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(含签名、有效期、金额范围/限额、门店设备绑定)
- 离线时收银端从 token 池取一个,生成离线订单并展示给用户(二维码/数字码)
- 网络恢复后:客户端上传
token + 订单详情 + 本地签名 - 服务端校验 token:未使用、未过期、绑定商户/设备一致、金额满足规则 → 入账
优点 :防伪强、可控限额;缺点:需要在线预取、token 管理复杂。
模式 B:本地生成订单 + 服务端验签(基于设备密钥)
适合:自有钱包/账户体系,或对防伪要求中等。
- 在线注册时服务端给设备下发
device_key(或证书) - 离线时:设备按规则生成
pay_order_id,对关键字段签名,展示二维码/数字码 - 网络恢复后:上传订单 + 签名
- 服务端用
device_key验签、风控后入账
优点 :不需要 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通用)
- 设备登录/激活 → 获取
device_id、权限、风控策略(离线限额等) - 同步本地时间(非常重要:防重放、防过期判断)
- 拉取配置:
max_offline_amount、max_offline_count_per_day、token_batch_size等 - (模式A)预拉取 token 批次(比如 200 个)
5.2 离线收款(断网)
- 收银端输入金额/选择商品 → 本地生成
pay_order_id - 写入本地账本(SQLite,事务提交)
- 生成离线凭证:
- 模式A:取
token+ 订单摘要 - 模式B:对摘要签名(HMAC/ECDSA)
- 模式A:取
- 展示给消费者确认(二维码 + 金额 + 商户名 + 时间 + 校验码)
- 消费者确认后 → 本地将订单置为
OFFLINE_CONFIRMED
经验:一定要先落盘再展示,否则断电会丢单。
5.3 网络恢复补传
- 客户端检测网络恢复 → 拉起补传队列
- 按队列上传:
pay_order_id + 明细 + token/签名 + 本地时间戳 + 设备信息 - 服务端幂等入账:
- 若
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(客户端确认) vsBOOKED_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 建表示例(简化)
sql
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)
java
@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 幂等落库(伪代码)
java
@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. 测试清单(上线前必测)
- 断网创建订单 → 强杀 App → 重启后订单仍在(WAL)
- 断电/关机 → 重启 → 订单不丢
- 时间被用户手动改到未来/过去 → 服务端识别并风控
- token 重复使用 → 服务端拒绝
- 同 pay_order_id 重复补传 → 幂等返回同结果
- 大批量离线订单(1000 笔)恢复网络 → 补传吞吐、退避、失败重试
- 服务端故障(500/超时)→ 客户端重试不炸
- 风控命中 → 客户端展示"待审核"
- 对账:客户端确认数 vs 服务端入账数一致,差错可追踪
12. 推荐落地组合(最稳)
- 模式A(token 预发) + 严格限额 + 服务端风控 + 日对账
- 客户端 SQLite 本地账本 + 队列补传 + 幂等
- 服务端:唯一键幂等 + 状态机 + 审核后台(简单列表也行)
13. 常见坑(踩过的都懂)
- 设备时间不可信:一定要允许窗口 + 风控,而不是完全依赖本地时间
- 先展示后落盘:断电就丢单,后果很惨
- 没有限额:离线就是开挂,风险直冲天花板
- 没有幂等:网络抖动会让你重复入账,直接资损
- 只做技术不做运营流程:审核、差错、退款、对账没人接,照样翻车
14. 附:离线凭证内容建议(示例)
二维码内容(JSON + Base64,外层再加签名):
json
{
"v": 1,
"payOrderId": "PO202601140001",
"mchNo": "M10001",
"storeId": "S01",
"deviceId": "D09",
"amount": 1999,
"ts": 1736800000,
"nonce": "8f3a2c..."
}
展示给用户的短码(可选):
校验码 = CRC32(payOrderId + amount + ts) % 1000000(仅用于人工核对,不替代签名)