欢迎光临
我们一直在努力

离线支付实现方案

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

目标:在弱网/断网情况下仍能完成“收款-确认-交付”,并在网络恢复后与服务端最终一致完成入账、对账、风控与结算。
适用:商超收银、展会摆摊、园区食堂、移动收款、车场/景区等。


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)
  • 展示给消费者确认(二维码 + 金额 + 商户名 + 时间 + 校验码)
  • 消费者确认后 → 本地将订单置为 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(客户端确认) 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. 测试清单(上线前必测)

  • 断网创建订单 → 强杀 App → 重启后订单仍在(WAL)
  • 断电/关机 → 重启 → 订单不丢
  • 时间被用户手动改到未来/过去 → 服务端识别并风控
  • token 重复使用 → 服务端拒绝
  • 同 pay_order_id 重复补传 → 幂等返回同结果
  • 大批量离线订单(1000 笔)恢复网络 → 补传吞吐、退避、失败重试
  • 服务端故障(500/超时)→ 客户端重试不炸
  • 风控命中 → 客户端展示“待审核”
  • 对账:客户端确认数 vs 服务端入账数一致,差错可追踪

  • 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(仅用于人工核对,不替代签名)

    赞(0)
    未经允许不得转载:171主机测评 » 离线支付实现方案
    分享到: 更多 (0)

    评论 抢沙发

    • 昵称 (必填)
    • 邮箱 (必填)
    • 网址