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 和促销规则计算生成,客户端只拿组装好的签名串。
  • 拦截抓包与篡改: 防止用户抓包拦截服务端的预支付单,修改成别人的订单号或极小金额。这里结合第 20 篇提到的签名、HTTPS 双向校验和风控逻辑发挥。

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

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

三类 ID 不要混

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

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

掉单、补单与对账

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

Google Play Billing / 订阅

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

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

支付风控

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

异常状态补全

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


高频面试题

Q1: 支付完 SDK 告诉客户端成功了,此时可以立刻更新本地状态为“已支付”并给用户发货吗? 绝对不可以。客户端环境不可控,SDK 返回的结果可能被篡改(如破解包或劫持回调)。发货的唯一凭证是业务服务器收到微信/支付宝的官方异步回调校验成功。客户端结果只用于触发向服务端查询的动作。

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 解决)。