Keyboard shortcuts

Press ← or → to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

支付订单与状态机

“支付业务的核心是 ’ 绝不能丢钱, 也绝不能多扣钱’. 网络可能是不可靠的, 但我们的系统必须是可靠的.”

面试策略: 在这部分重点体现你对分布式系统异常情况的理解. 突出幂等性, 重试, 轮询, 超时取消, 以及状态机控制这五个关键武器.

一, 客户端支付全链路流程

一次完整的第三方支付 (如微信 / 支付宝) 交互绝不是客户端拿着金额去请求 SDK 这么简单, 它涉及三方交互:

  1. 客户端发起订单: 客户端请求自己服务器, 传递商品信息.
  2. 服务端生成预支付单: 业务服务器调用微信 / 支付宝生成预支付交易单, 将包含签名等核心信息的 PayInfo 返给客户端.
  3. 客户端拉起收银台: 客户端使用 SDK, 传入 PayInfo 唤起支付 App 完成支付.
  4. 客户端获取同步结果: 支付 SDK 回调给客户端支付结果 (成功/取消/失败).此结果仅供 UI 展示参考, 不能作为最终发货依据.
  5. 服务端接收异步通知: 微信 / 支付宝服务器将真实的支付成功通知发给你的业务服务器.
  6. 客户端轮询 / 长连同步真实结果: 客户端主动拉取或接收服务器推送, 确认支付最终状态, 展示成功页.

二, 订单状态机设计

复杂业务中, 订单绝不能只有 “成功” 和 “失败”.必须用严谨的状态机约束状态流转, 防止非法倒流 (如 “已取消” 的订单被发货).

           [待支付]
          /    |   \
 (超时未付)   (支付)  (用户主动取消)
    /          |        \
[已取消]    [支付中]     [已取消]
               |
      (服务端接收回调成功)
               |
           [已支付/待发货]
               |
           [已发货] → [已完成]
  • 核心原则: 状态只能单向推进, 或根据特定规则流转. 客户端 UI 根据状态机的当前状态来渲染按钮 (如: 待支付展示 “去支付”, 已取消展示 “重新购买”).

三, 网络异常与重试机制 (幂等性)

移动端面临弱网, 断网, 重切等各种网络异常. 当发出 “确认购买” 请求, 但遇到了网络超时, 此时钱扣了吗?

  • 幂等性 (Idempotency): 无论接口被调用多少次, 产生的业务结果应该和调用一次相同.
  • 防重点击机制:
    • 前端 UI 防止连点 (按钮变灰 / Debounce 拦截).
    • 核心防线在服务端: 客户端生成全局唯一的单号或携带防重 Token, 服务端依赖数据库唯一索引或 Redis 分布式锁拦截重复请求. 客户端重试时必须带上同样的 ID.

四, 支付结果确认与轮询机制

如第一节所述, 客户端 SDK 返回成功, 不代表真的成功了 (可能是网络劫持伪造的响应).

  • 确认机制: 支付完成后, 客户端展示 “支付确认中” 的加载框.
  • 轮询 (Polling):
    • 客户端定时向业务服务器发起请求查询订单真实状态 (比如: 延时 1s, 2s, 4s 递增查询, 最多查询 5 次).
  • 兜底策略: 如果轮询一直未确认, 不能告诉用户 “支付失败”, 应该提示 “结果确认中, 请稍后查看订单列表”.服务端会在后续收到异步通知时更正订单状态.

五, 超时取消与库存回退

  • 当订单处于 “待支付” 状态, 业务逻辑往往已经预占了商品的库存.
  • 如果用户迟迟不付, 必须有超时取消机制 (例如 15 分钟未支付自动关闭), 释放占用的库存.
  • 客户端倒计时: 倒计时的计算一定要以服务端返回的时间戳为基准, 切勿使用客户端本地的 System.currentTimeMillis(), 因为用户可以随便修改手机时间.

六, 安全边界与反作弊

  • 金额不可信: 客户端绝不能自己提交 amount=100 给支付 SDK. 金额必须由业务服务端通过商品 ID 和促销规则计算生成, 客户端只拿组装好的签名串.
  • 拦截抓包与篡改: 防止用户抓包拦截服务端的预支付单, 修改成别人的订单号或极小金额. 这里结合 Android 安全与逆向中的签名, 传输安全和风控边界分析.

七, 对账, 补单, 订阅与风控

支付题的高级追问通常不是 “怎么调 SDK”, 而是异常路径如何兜底.

三类 ID 不要混

名称谁生成用途
业务订单号自家服务端业务系统里的订单主键
支付流水号自家支付服务 / 网关一次支付尝试, 可多次重试
三方交易号微信/支付宝/Google Play和外部支付平台对账

客户端展示可以用业务订单号, 但对账和退款必须关联三方交易号与支付流水.

掉单, 补单与对账

  • 掉单: 用户已扣款, 业务服务端没有及时收到或处理回调.
  • 补单: 客户端进入订单页或服务端定时任务主动向支付平台查询真实交易状态, 补齐本地订单.
  • 对账: 每天按支付平台账单和本地流水比对, 发现长时间不一致的订单, 进入人工 / 自动处理流程.

Google Play Billing / 订阅

如果面试涉及海外或会员订阅, 要补充:

  • 客户端拿到 purchase token 后必须发给服务端校验, 不能只信本地回调.
  • 消耗型商品要 consume, 订阅要处理续费, 取消, 宽限期, 暂停和退款.
  • 服务端保存 token, 商品 ID, 订单号和用户关系, 并处理重复通知.

支付风控

支付风控不是客户端单点判断, 而是服务端综合评估设备, 账号, IP, 行为, 金额, 频率, 历史风险等因素判断. 客户端可提供设备风险信号, 但不能在本地决定 “是否可信”.

异常状态补全

真实状态机还应覆盖: 支付中超时, 已支付待确认, 已退款, 部分退款, 已关闭, 重复回调, 风控拦截, 人工审核中. 面试时可以说 “主链路简单, 复杂度都在异常状态和补偿任务”.

迁移推演示例: 以 PAYING 为源态: 收到验签通过的成功回调则推进 PENDING_CONFIRMATION -> PAID(触发条件: 渠道成功事实幂等消费); 超过支付时限且轮询 / 对账无果则转 CLOSED(触发条件: 服务端权威超时, 释放预占库存); 收到退款 / 部分退款通知则进入 REFUNDING -> REFUNDED / PARTIALLY_REFUNDED(触发条件: 渠道退款回调, 且累计退款不超过已支付金额). “人工审核中” 会挂起流转, 复核后按上述规则收敛.


八, 服务端是支付信任边界

客户端传入的金额, 商品, 优惠, 支付结果和本地时间都不可信. 服务端应根据受信商品/订单数据重新计算金额, 生成不可混淆的业务订单号和支付请求, 验证支付渠道签名/通知, 并以幂等事务推进状态机. 客户端 SDK 回调只用于改善 UI, 不能直接把订单标记为成功.

关键控制:

  • 创建, 支付, 回调, 查询, 退款分别使用明确 idempotency key 和状态迁移条件.
  • 验证回调签名, 商户 / 应用标识, 金额, 币种, 订单号和通知唯一 ID.
  • 重复 / 乱序通知安全幂等; 非法倒流拒绝并记录审计.
  • 对账任务以渠道账单 / 服务端流水补偿, 客户端不承担最终对账.
  • 本地倒计时只做展示, 超时和库存释放由服务端权威时间决定.

可执行的状态迁移规则

本段以枚举 + 条件更新 (转移表) 表达状态机, 客户端也可用状态模式 (状态类 + reduce) 表达, 实现视角见设计模式与 Android 源码应用. 以下是状态机伪代码, 用于说明服务端条件更新, 不代表某个支付渠道的接口:

onVerifiedChannelFact(fact):  # 回调,主动查询,对账先验证为渠道事实
  verify signature when supplied; verify merchant/app id, transaction id, amount, currency
  payment = findByChannelTransactionId(fact.transactionId)
  if payment missing: record UNKNOWN_TRANSACTION; return without fulfillment
  transaction:
    insert processed_channel_fact(fact.source, fact.uniqueId) on conflict do nothing
    if fact already processed: return current order
    update order
      set status = PAID, paid_at = server_now
      where id = payment.order_id and status in (PAYING, PENDING_CONFIRMATION)
    if rowCount == 1: enqueue fulfillment outbox event
    else: record illegal/late transition for reconciliation

服务端验证的渠道事实才是支付最终真相: 验签回调, 主动查询和对账差异都走同一幂等状态机与 outbox. 验签必须使用渠道提供材料, 并校验商户/应用标识, 订单号, 金额, 币种和唯一事实 ID;“验签通过” 不等于可更新任意订单. 回调早于本地流水提交时隔离并重试/查询; 未知交易号只审计和人工/受控补偿, 绝不直接发货. 退款通知可早于本地退款任务或乱序到达, 应按原交易和累计退款约束进入 REFUNDING/REFUNDED, 不能倒流为支付成功.

CREATED -> PAYING -> PENDING_CONFIRMATION -> PAID -> FULFILLING -> FULFILLED
   |          |              |                |                 |
   +-> CLOSED +-> CLOSED     +-> RECONCILING  +-> REFUNDING -> REFUNDED/PARTIALLY_REFUNDED
                         \-> RISK_REVIEW -----> CLOSED or PAYING

CLOSED 后收到成功通知不是简单丢弃: 冻结自动发货, 进入 RECONCILING, 查询渠道真相并按退款 / 人工处理规则收敛. 退款也应引用原支付流水, 并以累计退款金额不超过已支付金额作为服务端不变量.

RISK_REVIEW(风控拦截态) 是等待人工 / 风控复核的中间态: 订单在 PAYING 或 PENDING_CONFIRMATION 命中风控规则 (设备 / 账号 / 频次 / 金额异常) 时被挂起, 不发货, 不改账. 收敛只有两条路径: 复核放行则回到 PAYING 继续原支付流程, 复核确认风险则进入拒绝终态 (如 CLOSED / REJECTED), 按退款 / 人工补偿处理, 不能从拦截态直接推进到已支付.

失败排查: 用户称 “扣款未到账”

  1. 症状: 客户端支付页未确认, 用户提供渠道交易号.
  2. 证据: 按业务订单号, 支付流水号, 三方交易号查询创建记录, 回调审计, 验签结果和渠道查询结果.
  3. 定位: 区分未收到通知, 验签 / 金额校验拒绝, 事务回滚, 发货消费者失败和客户端仅未同步五类情况.
  4. 修复: 由补单任务或人工审核按受控状态迁移补偿; 不能直接改数据库状态绕过审计和库存 / 权益逻辑.
  5. 验证: 确认订单, 支付流水, 权益 / 库存和渠道账单一致, 并让客户端从服务端重新拉取结果.

资损 / 掉单事故叙事模板

面试可把一次 “用户已扣款但订单未完成” 的事故讲成五段: 症状: 用户已扣款但订单未完成, 出现客诉或对账报警; 证据: 渠道账单与本地流水比对出差异, 还原回调到达时序与幂等键消费记录; 定位: 区分回调丢失未进补单流程, 还是幂等键设计错误 (重试未带同一 ID) 导致重复回调被误吞; 修复: 补单任务补齐状态, 修正幂等键与回调消费, 对账任务按渠道事实补偿; 验证: 线上对账持续跑通, 历史差异订单收敛一致且无新增资损.

学完能做什么与练习

你应能让三种渠道事实来源收敛到同一状态机. 练习: 依次投递 “回调先到, 本地流水后提交”“ 未知交易号 ““重复成功回调”“ 退款先于退款任务 “ 四个事实; 预期证据是只有已匹配且验证通过的订单生成一次 outbox, 未知项留审计, 累计退款不超过支付额.

高频面试题

Q1: 支付完 SDK 告诉客户端成功了, 此时可以立刻更新本地状态为 “已支付” 并给用户发货吗? 绝对不可以. 客户端 SDK 结果只用于改善 UI 和触发查询. 发货凭证是服务端验证后的渠道事实: 验签异步回调, 服务端主动查询或渠道对账均可成为事实来源, 且必须经同一幂等状态机和 outbox 推进.

Q2: 弱网下用户点击 “立即支付” 由于没有响应, 狂点了三下, 怎么保证不产生三个订单?

  1. 客户端防重: 按钮点击后 disable, 通过 RxBinding 或协程防抖.
  2. 状态机控制: 本地记录正在请求中, 不响应二次点击.
  3. 服务端唯一防重: 客户端生成或服务端提前下发的 UUID 作为本次交易的凭据 (幂等 Key), 服务端收到多次相同 Key 的请求时, 只处理一次, 其余的直接返回旧订单信息.

Q3: 如果支付完成后回到 App, 网络断了, 无法轮询服务端拿到结果, 应该怎么处理? 展示 “订单处理中” 或 “网络异常, 稍后请在订单列表查看”.绝不能显示 “支付失败”(万一钱扣了会引发严重客诉), 也绝不能显示 “支付成功”(万一真没成功则产生资损).等待网络恢复后, 用户进入订单列表时再次向服务器同步最新状态.

Q4: 什么是掉单? 怎么补? 掉单是用户侧或三方平台显示已支付, 但业务服务端没有及时把订单推进到已支付. 补单通常由客户端重进订单页触发查询, 服务端定时任务扫 “支付中 / 待确认” 订单并调用三方查询接口, 以及每日对账发现差异后修正.

Q5: Google Play Billing 的 purchase token 能不能只在客户端校验? 不能. 客户端环境不可信, purchase token 必须发给服务端调用 Google Play Developer API 校验, 并在服务端维护商品, 用户, 订单和 token 状态. 订阅还要处理续费, 取消, 退款, 宽限期和重复通知.

易错点 / 追问

  • 倒计时依赖本地时间: 利用修改手机系统时间可以无限延长支付时间或卡出倒计时负数 bug.
  • 本地订单状态与服务端不一致: 客户端由于进程被杀等原因错过状态流转, 重入时一定要从服务器重新拉取当前最新状态机节点.
  • 支付 SDK 冲突: 集成多方支付 SDK 导致依赖库冲突 (通常通过 exclude 或使用精简版 SDK 解决).