离线支付实现方案

离线支付实现方案(可落地版)

目标:在弱网/断网 情况下仍能完成"收款-确认-交付",并在网络恢复后与服务端最终一致 完成入账、对账、风控与结算。

适用:商超收银、展会摆摊、园区食堂、移动收款、车场/景区等。


1. 术语与边界

  • 离线支付:客户端(收银端/POS/商户端App)在无法访问支付网关或业务服务时,仍可完成"收款凭证生成/展示、消费者确认、交易记录落盘",并在网络恢复后补传交易完成最终入账。
  • 核心矛盾 :离线时无法实时验签/扣款/余额校验 → 只能做到"先确认、后清算 ",必须配合限额、风控、可追溯凭证、补传幂等
  • 必须明确 :离线支付不等于"离线扣款"。真正扣款/记账通常发生在网络恢复后的补传阶段。

2. 总体架构

2.1 角色

  • 收银端(POS/APP):离线生成交易、保存交易、展示/扫描凭证、补传交易、展示状态。
  • 服务端(业务服务):接收补传、幂等入账、状态机推进、风控校验、对账、清分结算。
  • 支付渠道(可选):微信/支付宝/银行卡/账户余额等。离线期通常无法直接调用。
  • 对账/清结算:T+0/T+1 批处理、差错处理、退款/冲正。

2.2 关键设计点

  1. 离线凭证(Offline Token / Offline Proof)

    • 可验证:消费者/商户能确认"这是这个商户开的单、金额是多少、时间/门店/设备是谁"。
    • 可追溯:凭证能映射到 pay_order_id 或包含必要字段签名。
    • 可防篡改:签名/校验码,至少要防止"改金额、改商户"。
  2. 本地账本(Local Ledger)

    • 收银端必须有本地事务库(SQLite/Realm/LevelDB)+ WAL,确保断电不丢单。
    • 所有离线交易先进入本地账本:INIT -> OFFLINE_CONFIRMED -> UPLOADING -> SUCCESS/FAIL/NEED_REVIEW
  3. 补传队列(Upload Queue)

    • 网络恢复后按序补传(可并发,但同一设备建议有序)。
    • 补传必须幂等 :以 pay_order_id/offline_trade_no 做唯一键。
  4. 最终一致

    • 离线成交仅代表"线下交付确认",最终入账以服务端状态为准。
    • 客户端需要展示:已确认(待入账)入账成功入账失败(需处理)

3. 离线支付模式(推荐两种)

模式 A:预下发离线凭证(Token 预发)

适合:固定商户、风控可控、要更强防伪。

  1. 在线时服务端下发 一批离线 token(含签名、有效期、金额范围/限额、门店设备绑定)
  2. 离线时收银端从 token 池取一个,生成离线订单并展示给用户(二维码/数字码)
  3. 网络恢复后:客户端上传 token + 订单详情 + 本地签名
  4. 服务端校验 token:未使用、未过期、绑定商户/设备一致、金额满足规则 → 入账

优点 :防伪强、可控限额;缺点:需要在线预取、token 管理复杂。

模式 B:本地生成订单 + 服务端验签(基于设备密钥)

适合:自有钱包/账户体系,或对防伪要求中等。

  1. 在线注册时服务端给设备下发 device_key(或证书)
  2. 离线时:设备按规则生成 pay_order_id,对关键字段签名,展示二维码/数字码
  3. 网络恢复后:上传订单 + 签名
  4. 服务端用 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通用)

  1. 设备登录/激活 → 获取 device_id、权限、风控策略(离线限额等)
  2. 同步本地时间(非常重要:防重放、防过期判断)
  3. 拉取配置:max_offline_amountmax_offline_count_per_daytoken_batch_size
  4. (模式A)预拉取 token 批次(比如 200 个)

5.2 离线收款(断网)

  1. 收银端输入金额/选择商品 → 本地生成 pay_order_id
  2. 写入本地账本(SQLite,事务提交)
  3. 生成离线凭证:
    • 模式A:取 token + 订单摘要
    • 模式B:对摘要签名(HMAC/ECDSA)
  4. 展示给消费者确认(二维码 + 金额 + 商户名 + 时间 + 校验码)
  5. 消费者确认后 → 本地将订单置为 OFFLINE_CONFIRMED

经验:一定要先落盘再展示,否则断电会丢单。

5.3 网络恢复补传

  1. 客户端检测网络恢复 → 拉起补传队列
  2. 按队列上传:pay_order_id + 明细 + token/签名 + 本地时间戳 + 设备信息
  3. 服务端幂等入账:
    • pay_order_id 已存在且已成功 → 直接返回成功(幂等)
    • 若存在但状态为失败/审核 → 返回对应状态
    • 若不存在 → 校验 → 创建订单 → 推进状态
  4. 客户端更新本地订单状态并展示

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 建表示例(简化)

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. 测试清单(上线前必测)

  1. 断网创建订单 → 强杀 App → 重启后订单仍在(WAL)
  2. 断电/关机 → 重启 → 订单不丢
  3. 时间被用户手动改到未来/过去 → 服务端识别并风控
  4. token 重复使用 → 服务端拒绝
  5. 同 pay_order_id 重复补传 → 幂等返回同结果
  6. 大批量离线订单(1000 笔)恢复网络 → 补传吞吐、退避、失败重试
  7. 服务端故障(500/超时)→ 客户端重试不炸
  8. 风控命中 → 客户端展示"待审核"
  9. 对账:客户端确认数 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(仅用于人工核对,不替代签名)