订单、支付与退款
oak-pay-business 里最核心的一条主线,就是 Order -> Pay -> Refund。但 Oak 里的这条链并不是“用户点一下支付按钮,然后状态改掉”这么简单,它背后至少同时有四层逻辑在一起工作:
- 实体状态机
- checker 的入场校验
- trigger 的状态推进和副作用
- watcher / endpoint 的异步补偿
如果你把这四层拆开看,就会明白为什么很多支付项目自己写起来会越来越乱,而 oak-pay-business 却能保持相对稳定。
主要对象
Order
src/entities/Order.ts 里的状态包括:
unpaidtimeoutcancelledpayingpartiallyPaidpaidrefundingpartiallyRefundedrefunded
动作包括:
startPayingpayAllpayPartiallypayNonetimeoutcancelstartRefundingrefundAllrefundPartiallyrefundNone
Pay
src/entities/Pay.ts 里的状态包括:
unpaidpayingpaidclosedrefundingpartiallyRefundedrefunded
动作包括:
startPayingsucceedPayingclosestartRefundingrefundAllrefundPartiallystopRefunding
另外还额外定义了两个动作:
closeRefundcontinuePaying
近期 stopRefunding 后订单状态回滚已修正。项目层不要绕过 refund.fail / pay.stopRefunding 自己手动改订单状态,否则会跳过公共 trigger 中对支付、退款和订单状态的一致性处理。
Refund
src/entities/Refund.ts 里的状态相对简单:
refundingsuccessfulfailed
动作则是:
succeedfail
组件
这条主线里最值得先读的前端组件有两个。
1. src/components/order/pay/index.ts
这个组件负责把“订单要怎么支付”拆成真正的 pay$order 创建数据。它不是单纯选一个渠道,而是支持同时拼出两段支付:
- 一段
account余额支付 - 一段外部渠道支付,例如
offlineAccount或wpProduct
也就是说,项目层如果要做“余额 + 微信支付”这种组合支付,不需要自己重新设计数据结构,这个组件已经按 pay$order 的 Oak 操作结构拼好了。
2. src/components/pay/detail/index.ts
这个组件负责展示单个支付单,并决定“下一步如何真正发起支付”。它内部已经做了几件很重要的事:
- 会合并所有已注册渠道的额外投影
- 会判断当前渠道能不能发起支付
- 内置了
wpProduct的前端支付流程 - 小程序充值待确认收货时,会调用
shipConfirmSuccess
项目层如果自己再单写一套“支付详情页”,很容易把这些渠道分支和确认收货逻辑漏掉。
order/pay 常用参数
oak-pay-business/src/components/order/pay/index.ts 项目里最常用的参数有:
accountId:允许使用哪个余额账户抵扣accountAvailMax:本次最多可用多少余额onSetPays:组件生成pay$order后回传给父组件accountTips:余额抵扣提示autoStartPay:外部渠道支付单是否自动开始
taicang/src/pages/console/order/detail/web.pc.tsx 的真实接法是:
<OrderPay
accountAvailMax={available + freeable}
oakId={order.id}
accountId={accountId}
oakPath="$$console-order-detail-order:pay"
onSetPays={setPays}
accountTips={t('tips.account', {
count: ToYuan(locked),
price: ToYuan(available + freeable),
})}
/>
pay/detail 常用参数
oak-pay-business/src/components/pay/detail/index.ts 最常用的参数则是:
oakId、oakPathonCloseonPaidonPayFailuremode: 'frontend' | 'backend'disableAutoPaycloseWhenFailuredisableClose
taicang 在后台支付详情弹窗里传的是:
<PayDetail
oakPath="$$console-order-detail-pay:detail"
oakId={unCompletedPayId}
onClose={() => {
unsetPays();
setShowPay(false);
}}
disableClose={true}
mode="backend"
/>
这里还有两个很容易忽略、但在项目里很有用的参数语义:
mode="backend":允许后台场景展示和手工处理支付,不完全按前台用户交互来限制disableClose={true}:适合订单详情里的支付弹窗,避免用户把“待支付中的支付单”随手关掉后丢失上下文
pay/channelPicker2 的职责
如果项目不是直接复用 order/pay,而是自己写支付表单,那么最推荐先复用的通常不是整个支付详情页,而是 pay/channelPicker2。
这个组件暴露的参数非常明确:
payChannelspayChannelonPick
它本身只做一件事:
- 把
features.pay.getPayChannels(...)返回的渠道数组转成可选项,并把选中的PayChannel回传给父组件
它不会自己创建 pay,也不会自己发起支付,所以更适合做:
- 订单支付页里的渠道选择器
- 充值页里的渠道选择器
- 后台人工补单页里的支付方式选择器
也正因为它只认 PayChannel 结构,项目层新增支付产品后,只要渠道数据还遵守公共包约定,这个组件通常不用改。
refund/list 适合放在哪里
退款列表虽然不是支付最显眼的前台组件,但在后台排查支付链路时很有价值。它当前的真实行为包括:
- 默认按当前应用所属
systemId过滤退款 - 自动把
creator.name / nickname / mobile整理成展示字段 - 自动把
pay.entity转成渠道名称 - 通过
withdrawId标记这条退款是不是“提现拆单产生的退款”
所以它很适合放在:
- 财务后台的退款记录页
- 订单详情里的退款历史页
- 提现详情的辅助排查页
如果项目层改动了 pay.application、creator.mobile$user 这些关系,这个列表的默认展示就会受影响,文档里最好提前提醒使用方。
order/list / pay/list
这两个列表组件也很值得单独写出来,因为它们通常就是后台运营和财务页最直接的入口。
order/list 的真实行为包括:
- 默认按当前应用所属
systemId过滤订单 - 自动把金额字段转成人类可读的元单位字符串
- 自动整理
creatorName、creatorMobile
它适合做:
- 后台订单列表
- 财务订单查询页
- 某个系统的支付订单概览
pay/list 则更偏支付单后台,当前真实行为包括:
- 默认按
application.systemId过滤支付单 - 默认按
$$createAt$$ desc排序 - 自动刷新当前系统下的
offlineAccount - 自动把
creator信息整理成展示字段 - 暴露
close、succeedPaying动作
所以它很适合:
- 支付单管理页
- 财务对账页
- 线下支付人工确认页
相比订单列表,pay/list 更适合查“这一笔支付本身发生了什么”;而 order/list 更适合查“订单整体支付到哪一步了”。
前端入口
这一章的前端入口分成三块。
features.pay.getPayChannels(...)
订单支付页和支付详情页都会依赖它来拿渠道。当前返回结果包括:
offlineAccount- 当前平台允许的
wpProduct - Web 环境下的
apProduct、epProduct、spProduct、cpProduct、wfProduct、ppProduct - 如果传了
accountId且场景是支付,还会追加account
registerFrontendPayRoutine(...)
支付详情页的真正唤起动作不是硬编码的,它通过 registerFrontendPayRoutine(...) 扩展。
当前内建实现包括:
wpProduct:小程序调用wx.requestPayment(...),微信 H5 走wechatSdk.loadWxAPi('chooseWXPay', ...);apProduct、epProduct、spProduct、cpProduct、wfProduct、ppProduct:仅 Web 使用公共 redirect routine,从pay.meta解析跳转地址。
如果项目又加了新的支付产品,就要继续注册自己的前端支付 routine。
支付回调 endpoint
src/endpoints/wechatPay.ts 当前直接提供了两个回调 endpoint:
payNotifyrefundNotify
它们内部继续复用了 src/utils/pay.ts 中的回调处理逻辑,项目层一般不需要再自己解微信支付通知。
后台规则
Order checker
src/checkers/order.ts 做了两类很关键的检查:
create时补默认值,例如creatorId、paid、refunded、settled、settlePlannedstartPaying时检查支付单是否齐全,以及在不允许部分支付时,支付总额必须等于订单金额
如果订单还带了结算目标,checker 还会检查这些分账目标的金额总和是否等于订单金额。
Pay checker
src/checkers/pay.ts 会约束:
- 支付金额必须不小于 0
- 充值类支付必须带
depositId - 充值类支付不能使用
account - 订单上所有
paying / paid的支付总额不能超过订单金额 - 手工
succeedPaying时必须带successAt - 非 root 用户只能手工成功
offlineAccount类型的支付
Pay trigger
src/triggers/pay.ts 是整条链里最重要的触发器文件之一,至少做了这些事情:
changeOrderStateByPay(...)会汇总pay$order推进订单状态autoStart的支付在创建后会自动执行startPayingstartPaying前会调用对应payClazz.prepay(...)close前会调用渠道关闭逻辑,必要时也会让充值失败succeedPaying后会记录sysAccountOper- 如果这是充值类支付,还会进一步推进
deposit - 对
wpProduct.needReceiving的小程序充值,会先把deposit推进到ship
另外,tryCompleteAccountPay(...) 还会自动补齐或关闭 account 类型支付,这就是为什么余额支付通常不需要你自己再写一套专门的支付回调。
Refund checker 与 trigger
src/checkers/refund.ts 会约束:
- 同一个
pay不能同时存在多个refunding - 某些成功回调场景必须有
externalId - 非
account类型退款失败时必须有reason
src/triggers/refund.ts 则负责:
- 创建退款前做合法性检查和准备
- 创建退款后以
strict: 'makeSure'触发真实外部退款 - 退款成功或失败后更新
pay - 充值退款时更新
accountOper - 需要时记录
sysAccountOper
这里的 makeSure 很重要,它意味着退款不是“尽量触发一下”,而是按 Oak 的补偿机制保证最终完成。
watcher 与异步补偿
支付这条链里至少有三组 watcher 在兜底。
watchers/order.ts
把到期未支付的订单自动改成 timeout。
watchers/pay.ts
做两件事:
- 轮询
paying且已有externalId的支付,和第三方渠道同步真实支付状态 - 到达
timeoutAt后自动关闭支付 - 到达
forbidRefundAt后自动执行closeRefund
watchers/refund.ts
轮询 refunding 且已有 externalId 的退款,调用 payClazz.getRefundState(...) 与真实渠道同步。
项目中如何接入
项目里真正接这条链,一般按下面方式分层。
1. 订单页负责收集支付方案
推荐直接复用 components/order/pay,让它生成 pay$order 的创建数据。
2. 服务端或父组件执行 order.startPaying
这个动作不是简单改状态,而是会触发前面的 checker 和 trigger,把支付单挂到订单上并推进状态机。
3. 支付详情页负责真正拉起支付
推荐直接用 components/pay/detail。它已经知道什么时候该:
- 显示线下收款信息
- 拉起小程序支付
- 拉起微信网页支付
- 允许手工成功
- 在充值收货确认后继续推进
4. 第三方支付平台回调走 endpoint
不要把异步回调自己写成零散 controller,直接接 payNotify / refundNotify。
真实项目里的页面组合
从 taicang 的订单详情页可以直接看出推荐的页面分层:
- 订单详情页里弹出
order/pay - 父组件拿到
pays后执行order.startPaying - 如果订单已经在支付中,再切到
pay/detail
这比“订单页自己调 SDK、自己改状态、自己处理回调”稳定得多。
开发注意事项
支付详情页还有一个新手容易忽略的点:
pay/detail会根据已注册的前端支付 routine 自动合并额外 projection
所以项目层如果新增了支付渠道,却只写了后端 registerPayClazz(...),没有写:
registerFrontendPayRoutine(...)
那么页面层通常连拉起支付所需的数据都拿不全。
真实项目里的回调复用
haina-busi 的 wechatPay.ts、cmbPay.ts、aliPay.ts 都没有重写整套支付回调逻辑,而是统一复用了:
@oak-pay-business/utils/pay的payNotify@oak-pay-business/utils/pay的refundNotify
这点很值得模仿。项目层真正需要做的通常只是暴露不同路由和参数,不要重写回调状态推进本身。
使用示例
1. 用订单支付组件生成 pay$order
<OrderPay
oakPath="order"
accountId={accountId}
accountAvailMax={account.avail}
autoStartPay
onSetPays={(pays) => this.setState({ pays })}
/>
这个组件最终回给父组件的 pays,就是可以直接塞进 order.startPaying 的 pay$order 创建数据。
2. 执行订单支付
await this.features.cache.exec('operate', {
entity: 'order',
operation: {
id: await generateNewIdAsync(),
action: 'startPaying',
data: {
pay$order: pays.map((pay) => ({
id: await generateNewIdAsync(),
action: 'create',
data: pay,
})),
},
filter: {
id: orderId,
},
},
});
3. 微信支付异步回调如何进入应用
import * as wechatPay from '@oak-pay-business/endpoints/wechatPay';
export default {
wechatPay: [wechatPay.payNotify, wechatPay.refundNotify],
};
这段代码写在项目的 src/endpoints/index.ts。当前 oak-pay-business/src/endpoints/index.ts 默认导出空对象,所以依赖自动装载不会主动暴露任何支付回调;项目必须显式选择实际启用的渠道。公共 wechatPay endpoint 已负责解析并调用支付域状态推进逻辑,项目不需要再重写通知解密和状态机。
调用公共路由时参数仍要带:
payNotify的payIdrefundNotify的refundId
使用建议
如果你准备在项目里复用这条链,最重要的建议只有两条:
- 订单状态不要自己手动推,尽量通过
order.startPaying、pay.succeedPaying、refund.succeed这些动作进入状态机。 - 前端支付流程不要只写页面逻辑,要同时把
registerFrontendPayRoutine(...)、支付回调 endpoint、watcher 补偿一起接上。
否则最常见的问题就是:页面能跳支付,但订单状态、退款状态、过期关闭和异步回调并没有跟着一起工作。