HarmonyOS 支付结果一致性实战:创建订单、查单、幂等与补偿

支付链路最容易被低估。开发阶段点击支付、返回成功、页面解锁,看起来已经完成;上线后才会遇到用户扣款但订单失败、回调重复导致重复发货、客户端返回成功但服务端没有确认、网络切换后页面不知道该显示什么。支付结果一致性要解决的不是“能不能拉起支付”,而是任何异常路径下,订单最终都能回到一个可解释、可追踪、可补偿的状态。

请添加图片描述

本文按交易闭环拆解:

  1. 创建业务订单,明确本地流水和服务端预单的关系。
  2. 拉起支付时只处理用户动作,不直接发货。
  3. 用回调、主动查单和幂等记录合并最终状态。
  4. 给失败订单准备补偿入口,避免客服只能让用户重试。

1. 支付一致性先看订单状态机

支付状态不是成功和失败两个值。真实项目里至少要表达已创建、支付中、待确认、成功、失败、已关闭、已退款。支付返回只是状态来源之一,服务端回调、主动查单、后台对账都可能更新同一笔订单。

请添加图片描述

状态 含义 用户侧展示
CREATED 业务订单已创建,还未拉起支付 “待支付”
PAYING 已进入收银台或等待平台返回 “支付处理中”
VERIFYING 客户端拿到结果,等待服务端确认 “正在确认订单”
SUCCESS 服务端确认支付成功并完成业务处理 “支付成功”
FAILED 支付失败或用户取消 “支付未完成”
CLOSED 超时关闭或业务取消 “订单已关闭”

这里最关键的是 VERIFYING。很多掉单问题发生在“客户端以为成功”和“服务端还没确认”之间。如果页面没有这个中间态,就会逼着开发者在客户端直接发货。

2. 官方资料边界和模块落点

Payment Kit 和 IAP Kit 的支付能力都会强调订单、回调、查询和退款等闭环能力。工程实现不能只看客户端 API,还要把服务端查询和退款流程放进设计里。

资料入口 工程落点
IAP Kit 应用内支付服务 应用内购买场景需要关注异常订单查询和补发
IAP 发起购买参考 客户端发起购买后仍要走服务端确认
Payment Kit 申请退款 成功交易需要有退款申请和结果回调处理
退款记录查询 后台对账和客服定位要能根据订单号查询退款记录

建议目录:

entry/src/main/ets/
  common/payment/TradeOrder.ets
  common/payment/PaymentLauncher.ets
  common/payment/PaymentReconciler.ets
  common/payment/IdempotencyGuard.ets
  common/payment/TradeLedger.ets
  pages/order/CheckoutPage.ets

页面只负责展示和触发,交易模块负责状态机,对账模块负责合并结果。这样用户从收银台返回时,即使网络异常,也可以靠订单号恢复。

3. TradeOrder 定义状态机

订单状态机要限制非法跳转。例如 SUCCESS 不应该再回到 PAYINGCLOSED 不能被客户端按钮重新激活。把跳转规则写进代码,比在页面里散落 if 更可靠。

export type TradeState = 'CREATED' | 'PAYING' | 'VERIFYING' | 'SUCCESS' | 'FAILED' | 'CLOSED'

export interface TradeOrder {
  tradeNo: string
  productId: string
  amountFen: number
  state: TradeState
  createdAt: number
  updatedAt: number
}

const TRANSITIONS: Record<TradeState, TradeState[]> = {
  CREATED: ['PAYING', 'CLOSED'],
  PAYING: ['VERIFYING', 'FAILED', 'CLOSED'],
  VERIFYING: ['SUCCESS', 'FAILED', 'CLOSED'],
  SUCCESS: [],
  FAILED: ['PAYING', 'CLOSED'],
  CLOSED: []
}

export class TradeOrderMachine {
  move(order: TradeOrder, next: TradeState): TradeOrder {
    if (!TRANSITIONS[order.state].includes(next)) {
      throw new Error(`非法订单状态流转:${order.state} -> ${next}`)
    }
    return { ...order, state: next, updatedAt: Date.now() }
  }
}

这段代码的价值是防止“成功订单被失败回调覆盖”。支付平台或服务端可能重复推送结果,前端也可能重复刷新订单。如果没有状态机保护,晚到的失败消息可能把已成功订单改坏。

4. PaymentLauncher 只负责拉起支付和返回

拉起支付的模块不应该知道发货逻辑。它只需要把业务订单和平台支付请求关联起来,并把用户取消、平台返回、异常抛错变成统一结果。

export interface PaymentLaunchResult {
  tradeNo: string
  returned: boolean
  userCanceled: boolean
  rawCode?: string
  rawMessage?: string
}

export class PaymentLauncher {
  async launch(order: TradeOrder): Promise<PaymentLaunchResult> {
    try {
      // 实际项目中这里调用 Payment Kit 或 IAP Kit 相关支付能力。
      return {
        tradeNo: order.tradeNo,
        returned: true,
        userCanceled: false,
        rawCode: 'OK'
      }
    } catch (err) {
      return {
        tradeNo: order.tradeNo,
        returned: false,
        userCanceled: false,
        rawCode: 'LAUNCH_ERROR',
        rawMessage: `${err}`
      }
    }
  }
}

支付返回成功也不要在这里发货。正确的下一步是把订单推到 VERIFYING,然后请求服务端查单或等待回调结果。用户看到的是“正在确认订单”,而不是马上跳成“已完成”。

5. PaymentReconciler 合并回调和查单结果

一致性核心在合并结果。服务端回调、主动查单、客户端返回都可能带来不同信号。合并策略要有优先级:已成功优先于处理中,已关闭不能被弱信号重开,退款状态要走售后链路。

export type RemotePayState = 'paid' | 'pending' | 'failed' | 'closed'

export interface PayStateSignal {
  tradeNo: string
  source: 'client_return' | 'server_callback' | 'active_query'
  remoteState: RemotePayState
  receivedAt: number
}

export class PaymentReconciler {
  constructor(private readonly machine: TradeOrderMachine) {}

  merge(order: TradeOrder, signal: PayStateSignal): TradeOrder {
    if (order.state === 'SUCCESS' || order.state === 'CLOSED') {
      return order
    }
    if (signal.remoteState === 'paid') {
      return this.machine.move(order, 'SUCCESS')
    }
    if (signal.remoteState === 'pending') {
      return order.state === 'PAYING' ? this.machine.move(order, 'VERIFYING') : order
    }
    if (signal.remoteState === 'failed') {
      return this.machine.move(order, 'FAILED')
    }
    return this.machine.move(order, 'CLOSED')
  }
}

这里没有把 client_return 当成最终可信结果。客户端返回只能证明用户完成了一次支付动作或退出了收银台,最终交易状态仍以服务端确认和平台查询为准。

6. IdempotencyGuard 处理重复回调

重复回调并不罕见。网络重试、平台补推、服务端队列重放,都可能让同一个订单结果处理多次。幂等保护需要基于业务单号和事件类型,而不是基于页面是否还在。

请添加图片描述

export interface IdempotentEvent {
  eventId: string
  tradeNo: string
  eventType: 'PAY_SUCCESS' | 'PAY_FAILED' | 'REFUND_SUCCESS'
  createdAt: number
}

export class IdempotencyGuard {
  private readonly handled = new Set<string>()

  canHandle(event: IdempotentEvent): boolean {
    const key = `${event.tradeNo}:${event.eventType}:${event.eventId}`
    if (this.handled.has(key)) {
      return false
    }
    this.handled.add(key)
    return true
  }
}

示例里用内存集合表达规则,真实项目应放在服务端数据库或持久化队列里。客户端可以保留最近处理记录用于 UI 防抖,但不能作为最终幂等依据。

7. TradeLedger 记录补偿动作

支付系统必须有补偿记录。用户反馈“钱扣了但订单没完成”时,工程同学不能只看客户端日志,还要能找到查单、补发、关闭、退款等动作的时间线。

export interface LedgerRecord {
  tradeNo: string
  action: 'CREATE' | 'LAUNCH' | 'QUERY' | 'DELIVER' | 'CLOSE' | 'REFUND'
  operator: 'client' | 'server' | 'admin'
  detail: string
  createdAt: number
}

export class TradeLedger {
  private readonly records: LedgerRecord[] = []

  append(record: Omit<LedgerRecord, 'createdAt'>): void {
    this.records.push({ ...record, createdAt: Date.now() })
  }

  listByTradeNo(tradeNo: string): LedgerRecord[] {
    return this.records
      .filter(record => record.tradeNo === tradeNo)
      .sort((a, b) => a.createdAt - b.createdAt)
  }
}

台账的意义是让每一次状态变化都有证据。客服、测试、开发看到同一条订单时,不需要互相猜测“当时到底发生了什么”。

8. 结算页的用户体验不能阻塞排查

用户从支付页回来时,页面应该基于订单状态展示不同动作。支付中可以提示等待,确认中可以提供刷新,失败可以重新支付,成功可以进入权益或订单详情。

interface CheckoutViewState {
  title: string
  description: string
  primaryAction: string
  canRetry: boolean
}

export function buildCheckoutView(order: TradeOrder): CheckoutViewState {
  const views: Record<TradeState, CheckoutViewState> = {
    CREATED: { title: '订单待支付', description: '请确认商品和金额后继续支付。', primaryAction: '去支付', canRetry: true },
    PAYING: { title: '支付处理中', description: '请在收银台完成支付,不要重复提交。', primaryAction: '等待返回', canRetry: false },
    VERIFYING: { title: '正在确认订单', description: '系统正在核对支付结果,稍后会更新状态。', primaryAction: '刷新订单', canRetry: false },
    SUCCESS: { title: '支付成功', description: '订单已完成,相关权益会自动生效。', primaryAction: '查看订单', canRetry: false },
    FAILED: { title: '支付未完成', description: '未收到有效支付结果,你可以重新发起支付。', primaryAction: '重新支付', canRetry: true },
    CLOSED: { title: '订单已关闭', description: '当前订单不可继续支付,请重新创建订单。', primaryAction: '重新下单', canRetry: true }
  }
  return views[order.state]
}

这种页面状态看似简单,但能显著减少误操作。尤其是 VERIFYING,它能避免用户在支付已经成功但回调稍慢时重复下单。

9. 支付链路验收动作

场景 操作 预期结果
正常支付 创建订单并完成付款 订单进入 SUCCESS,台账包含创建、拉起、确认记录
用户取消 收银台返回取消 订单进入 FAILED 或保持可重试,不发货
返回后断网 支付完成后立即断网 页面展示确认中,恢复网络后主动查单
重复回调 服务端推送两次成功 只发货一次,台账记录一次有效处理
查单失败 主动查单超时 保持确认中或进入可解释失败态,不直接关闭成功订单

可以加一个状态保护用例,确保成功订单不会被弱信号覆盖。

export function assertPaidOrderStable(order: TradeOrder, next: TradeState): void {
  if (order.state === 'SUCCESS' && next !== 'SUCCESS') {
    throw new Error('已成功订单不能被客户端弱信号降级')
  }
  if (order.amountFen <= 0) {
    throw new Error('支付订单金额必须大于 0')
  }
}

这类断言适合放在交易模块单元用例里。它能提前拦住最危险的状态倒退问题。

10. 支付对账异常排查表

现象 优先查看 处理建议
扣款成功但订单失败 服务端查单结果、台账、平台订单号 不让用户重复付款,先走查单和补发
重复发货 幂等键、回调事件 ID、发货记录 发货动作必须由服务端幂等控制
页面一直确认中 查单接口、网络状态、订单超时策略 展示刷新和客服入口,后台继续补偿
支付失败还能使用权益 权益发放入口、订单成功判断 只允许 SUCCESS 后发放
退款后订单仍显示完成 退款回调、售后状态、订单详情页 订单详情增加退款进度区块

11. 小结:支付结果要靠合并而不是相信一次返回

支付一致性的工程方法可以压缩成一句话:客户端负责发起和展示,服务端负责确认和幂等,台账负责追踪和补偿。只要订单状态机、回调合并、主动查单、补偿记录这四块建好,用户支付过程中断网、重复返回、重复回调都不会把系统拖进不可解释状态。

Logo

讨论HarmonyOS开发技术,专注于API与组件、DevEco Studio、测试、元服务和应用上架分发等。

更多推荐