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

本文按交易闭环拆解:
- 创建业务订单,明确本地流水和服务端预单的关系。
- 拉起支付时只处理用户动作,不直接发货。
- 用回调、主动查单和幂等记录合并最终状态。
- 给失败订单准备补偿入口,避免客服只能让用户重试。
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 不应该再回到 PAYING,CLOSED 不能被客户端按钮重新激活。把跳转规则写进代码,比在页面里散落 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. 小结:支付结果要靠合并而不是相信一次返回
支付一致性的工程方法可以压缩成一句话:客户端负责发起和展示,服务端负责确认和幂等,台账负责追踪和补偿。只要订单状态机、回调合并、主动查单、补偿记录这四块建好,用户支付过程中断网、重复返回、重复回调都不会把系统拖进不可解释状态。
更多推荐



所有评论(0)