
第43篇订单支付回调丢失用状态机和轮询补偿闭环摘要支付完成后页面没更新常见原因不是支付一定失败而是回调丢失、应用切后台、页面销毁或服务端结果延迟。更稳的做法是订单进入本地状态机发起支付后记录paying回调和主动查询都进入结果收口超时后展示待确认状态而不是让页面停在 loading。一次会员订单问题里用户已经付款回到应用仍看到“待支付”。客服后台能查到成功客户端没有收到回调。原代码把支付结果完全绑定在回调函数里回调没回来就不刷新。支付这种链路不能只相信一次回调必须让订单状态机和主动查询一起兜底。这篇文章解决四个实际问题订单本地状态机记录 paying、success、failed、unknown。支付回调先入箱不直接改页面。页面恢复时主动查询订单结果补偿丢失回调。超时后显示待确认不误报失败。先区分支付失败和结果未知支付回调没回来时客户端并不知道用户是否付款成功。直接提示失败会制造更大问题正确状态应该是结果待确认。位置现象风险处理方向回调丢失页面一直待支付只依赖回调恢复时主动查询页面销毁回调找不到页面实例结果丢失回调入箱服务端延迟查询先返回未支付误判失败轮询几次重复回调成功后又处理一次状态回退状态机单向推进这一步的价值是先把问题放回运行链路。只要能确认问题停在哪个位置后面就不用靠猜测改页面。支付回调不应该直接改页面回调只是支付结果的一种来源主动查询也是结果来源。两者都应该进入同一个状态机由状态机决定订单最终状态。模块应该负责不应该负责OrderPage订单展示和操作按钮支付 SDK 回调细节PaymentService发起支付和记录 paying页面 loadingPaymentResultInbox回调结果暂存状态仲裁OrderStateMachine状态推进规则支付渠道 UIOrderRemote服务端最终结果查询组件展示边界定清后代码就不会在页面、服务和回调之间来回复制同一段判断。后续新增场景也能先判断应该落在哪一层。订单状态要能表达未知exportenumOrderPayState{Createdcreated,Payingpaying,Paidpaid,Failedfailed,Unknownunknown}exportinterfaceOrderPaySnapshot{orderId:stringstate:OrderPayState lastChangedAt:numberchannelTradeNo:stringmessage:string}exportfunctioncanMoveTo(current:OrderPayState,next:OrderPayState):boolean{if(currentOrderPayState.Paid){returnfalse}returntrue}Unknown很重要。它表示客户端暂时无法确认而不是支付失败。已支付状态不能被后续失败或旧回调覆盖。这类模型最好放在models或common中页面、Service 和仓储都使用同一份类型避免各层用字符串互相猜。发起支付前先落本地状态exportclassPaymentService{constructor(privatereadonlyorderStore:OrderStore,privatereadonlypayChannel:PayChannel){}asyncstartPay(orderId:string):Promisevoid{awaitthis.orderStore.updatePayState(orderId,OrderPayState.Paying,已拉起支付)constrequestawaitthis.orderStore.buildPayRequest(orderId)awaitthis.payChannel.pay(request)}}先写Paying再拉起支付。即使应用切后台或页面销毁重进后也知道这个订单需要继续确认结果。Service 的目标不是把所有逻辑都塞进去而是把跨页面、跨生命周期或需要持久化的判断集中起来。页面只表达用户动作。回调结果先进入 InboxexportinterfacePaymentCallbackResult{orderId:stringsuccess:booleanchannelTradeNo:stringreceivedAt:number}exportclassPaymentResultInbox{constructor(privatereadonlystore:PaymentCallbackStore){}asyncaccept(result:PaymentCallbackResult):Promisevoid{if(awaitthis.store.exists(result.orderId,result.channelTradeNo)){return}awaitthis.store.insert(result)}}回调不直接操作页面因为回调到达时页面可能已经不在。入箱后由消费器统一推进订单状态重复回调也能去重。页面层要控制展示和交互节奏但不应该拥有底层事实。这样页面重建、横竖屏变化或返回前台时都能重新从 Service 拿到可信结果。页面恢复时主动查询补偿exportclassOrderResultCompensator{constructor(privatereadonlyorderStore:OrderStore,privatereadonlyremote:OrderRemote){}asyncconfirmIfNeeded(orderId:string):PromiseOrderPaySnapshot{constlocalawaitthis.orderStore.find(orderId)if(local.state!OrderPayState.Payinglocal.state!OrderPayState.Unknown){returnlocal}constremoteStateawaitthis.remote.queryPayState(orderId)awaitthis.orderStore.updatePayState(orderId,remoteState.state,remoteState.message)returnthis.orderStore.find(orderId)}}补偿查询让“回调没回来”不再等于“结果永远不更新”。页面显示前主动确认一次必要时再进入短轮询。实际项目里很多问题不是第一次进入页面暴露而是在冷启动、返回前台、页面重建或回调晚到时暴露。把这段生命周期补上修复才算闭环。超时后不要误报失败情况页面表现工程处理回调和查询都没结果显示待确认给刷新入口查询返回处理中短轮询几次超时后 Unknown成功后收到失败回调忽略回退保持 Paid重复点击支付检查 paying 状态禁止重复创建订单兜底路径不是可有可无。用户遇到异常时页面至少要给出当前状态、可执行动作和可排查线索。排查时找只依赖回调的路径rg-npay\(|Payment|callback|onPay|orderId|Payingentry features common rg-nOrderPayState|Unknown|PaymentResultInbox|confirmIfNeededentry features common rg-nonShown|aboutToAppear|queryPayState|pollentry features common第一组命令找旧路径第二组命令找新边界第三组命令看生命周期或回调位置。命中后不要只看字段名要判断它是否仍在直接改页面状态。轮询补偿要有上限和节奏主动查询不是无限轮询。支付链路要兼顾用户体验、服务端压力和状态可信度。更稳的策略是短时间内快速确认几次仍然拿不到最终结果时进入Unknown让用户看到“结果确认中”并提供手动刷新。阶段建议动作页面文案支付刚返回立即查询一次正在确认支付结果前几秒间隔查询两到三次请稍候正在同步订单仍无结果进入 Unknown支付结果确认中用户手动刷新再查服务端最终状态已更新或继续待确认这样做比直接提示失败更稳。支付场景里错误提示的成本很高用户可能已经扣款客户端却说失败。用状态机表达未知再用轮询和手动刷新把结果补回来才能减少客服和用户误解。验证清单验证项预期结果支付后杀应用重进能查询并更新结果回调重复到达状态只推进一次服务端延迟先待确认再更新成功成功后旧失败回调不覆盖 Paid用户连点支付不会创建多笔支付这些验证最好在真机或模拟器里按顺序走一遍。只看源码很难发现冷启动、重启和回调晚到这类问题。常见问题和处理方式现象常见原因处理方式已付仍待支付回调丢失无补偿恢复时查询订单成功变失败状态允许回退Paid 终态不回退页面销毁丢结果回调直接改页面回调入箱一直转圈没有 Unknown 状态超时展示待确认如果现象没有出现在表里仍然建议按“输入来源 - 中间状态 - 持久化或页面展示 - 失败兜底”的顺序排查。小结支付结果要有回调之外的第二条路支付链路不能只相信回调。先写本地 paying回调入箱页面恢复主动查询状态机只允许合理推进超时展示待确认。这样用户付款后不会因为一次回调丢失就卡在错误状态。own 状态 | 超时展示待确认 |如果现象没有出现在表里仍然建议按“输入来源 - 中间状态 - 持久化或页面展示 - 失败兜底”的顺序排查。小结支付结果要有回调之外的第二条路支付链路不能只相信回调。先写本地 paying回调入箱页面恢复主动查询状态机只允许合理推进超时展示待确认。这样用户付款后不会因为一次回调丢失就卡在错误状态。