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

订单、支付与退款

oak-pay-business 里最核心的一条主线,就是 Order -> Pay -> Refund。但 Oak 里的这条链并不是“用户点一下支付按钮,然后状态改掉”这么简单,它背后至少同时有四层逻辑在一起工作:

  • 实体状态机
  • checker 的入场校验
  • trigger 的状态推进和副作用
  • watcher / endpoint 的异步补偿

如果你把这四层拆开看,就会明白为什么很多支付项目自己写起来会越来越乱,而 oak-pay-business 却能保持相对稳定。

主要对象

Order

src/entities/Order.ts 里的状态包括:

  • unpaid
  • timeout
  • cancelled
  • paying
  • partiallyPaid
  • paid
  • refunding
  • partiallyRefunded
  • refunded

动作包括:

  • startPaying
  • payAll
  • payPartially
  • payNone
  • timeout
  • cancel
  • startRefunding
  • refundAll
  • refundPartially
  • refundNone

Pay

src/entities/Pay.ts 里的状态包括:

  • unpaid
  • paying
  • paid
  • closed
  • refunding
  • partiallyRefunded
  • refunded

动作包括:

  • startPaying
  • succeedPaying
  • close
  • startRefunding
  • refundAll
  • refundPartially
  • stopRefunding

另外还额外定义了两个动作:

  • closeRefund
  • continuePaying

近期 stopRefunding 后订单状态回滚已修正。项目层不要绕过 refund.fail / pay.stopRefunding 自己手动改订单状态,否则会跳过公共 trigger 中对支付、退款和订单状态的一致性处理。

Refund

src/entities/Refund.ts 里的状态相对简单:

  • refunding
  • successful
  • failed

动作则是:

  • succeed
  • fail

组件

这条主线里最值得先读的前端组件有两个。

1. src/components/order/pay/index.ts

这个组件负责把“订单要怎么支付”拆成真正的 pay$order 创建数据。它不是单纯选一个渠道,而是支持同时拼出两段支付:

  • 一段 account 余额支付
  • 一段外部渠道支付,例如 offlineAccountwpProduct

也就是说,项目层如果要做“余额 + 微信支付”这种组合支付,不需要自己重新设计数据结构,这个组件已经按 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 最常用的参数则是:

  • oakIdoakPath
  • onClose
  • onPaid
  • onPayFailure
  • mode: 'frontend' | 'backend'
  • disableAutoPay
  • closeWhenFailure
  • disableClose

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

这个组件暴露的参数非常明确:

  • payChannels
  • payChannel
  • onPick

它本身只做一件事:

  • features.pay.getPayChannels(...) 返回的渠道数组转成可选项,并把选中的 PayChannel 回传给父组件

它不会自己创建 pay,也不会自己发起支付,所以更适合做:

  • 订单支付页里的渠道选择器
  • 充值页里的渠道选择器
  • 后台人工补单页里的支付方式选择器

也正因为它只认 PayChannel 结构,项目层新增支付产品后,只要渠道数据还遵守公共包约定,这个组件通常不用改。

refund/list 适合放在哪里

退款列表虽然不是支付最显眼的前台组件,但在后台排查支付链路时很有价值。它当前的真实行为包括:

  • 默认按当前应用所属 systemId 过滤退款
  • 自动把 creator.name / nickname / mobile 整理成展示字段
  • 自动把 pay.entity 转成渠道名称
  • 通过 withdrawId 标记这条退款是不是“提现拆单产生的退款”

所以它很适合放在:

  • 财务后台的退款记录页
  • 订单详情里的退款历史页
  • 提现详情的辅助排查页

如果项目层改动了 pay.applicationcreator.mobile$user 这些关系,这个列表的默认展示就会受影响,文档里最好提前提醒使用方。

order/list / pay/list

这两个列表组件也很值得单独写出来,因为它们通常就是后台运营和财务页最直接的入口。

order/list 的真实行为包括:

  • 默认按当前应用所属 systemId 过滤订单
  • 自动把金额字段转成人类可读的元单位字符串
  • 自动整理 creatorNamecreatorMobile

它适合做:

  • 后台订单列表
  • 财务订单查询页
  • 某个系统的支付订单概览

pay/list 则更偏支付单后台,当前真实行为包括:

  • 默认按 application.systemId 过滤支付单
  • 默认按 $$createAt$$ desc 排序
  • 自动刷新当前系统下的 offlineAccount
  • 自动把 creator 信息整理成展示字段
  • 暴露 closesucceedPaying 动作

所以它很适合:

  • 支付单管理页
  • 财务对账页
  • 线下支付人工确认页

相比订单列表,pay/list 更适合查“这一笔支付本身发生了什么”;而 order/list 更适合查“订单整体支付到哪一步了”。

前端入口

这一章的前端入口分成三块。

features.pay.getPayChannels(...)

订单支付页和支付详情页都会依赖它来拿渠道。当前返回结果包括:

  • offlineAccount
  • 当前平台允许的 wpProduct
  • Web 环境下的 apProductepProductspProductcpProductwfProductppProduct
  • 如果传了 accountId 且场景是支付,还会追加 account

registerFrontendPayRoutine(...)

支付详情页的真正唤起动作不是硬编码的,它通过 registerFrontendPayRoutine(...) 扩展。

当前内建实现包括:

  • wpProduct:小程序调用 wx.requestPayment(...),微信 H5 走 wechatSdk.loadWxAPi('chooseWXPay', ...)
  • apProductepProductspProductcpProductwfProductppProduct:仅 Web 使用公共 redirect routine,从 pay.meta 解析跳转地址。

如果项目又加了新的支付产品,就要继续注册自己的前端支付 routine。

支付回调 endpoint

src/endpoints/wechatPay.ts 当前直接提供了两个回调 endpoint:

  • payNotify
  • refundNotify

它们内部继续复用了 src/utils/pay.ts 中的回调处理逻辑,项目层一般不需要再自己解微信支付通知。

后台规则

Order checker

src/checkers/order.ts 做了两类很关键的检查:

  • create 时补默认值,例如 creatorIdpaidrefundedsettledsettlePlanned
  • startPaying 时检查支付单是否齐全,以及在不允许部分支付时,支付总额必须等于订单金额

如果订单还带了结算目标,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 的支付在创建后会自动执行 startPaying
  • startPaying 前会调用对应 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-busiwechatPay.tscmbPay.tsaliPay.ts 都没有重写整套支付回调逻辑,而是统一复用了:

  • @oak-pay-business/utils/paypayNotify
  • @oak-pay-business/utils/payrefundNotify

这点很值得模仿。项目层真正需要做的通常只是暴露不同路由和参数,不要重写回调状态推进本身。

使用示例

1. 用订单支付组件生成 pay$order

<OrderPay
  oakPath="order"
  accountId={accountId}
  accountAvailMax={account.avail}
  autoStartPay
  onSetPays={(pays) => this.setState({ pays })}
/>

这个组件最终回给父组件的 pays,就是可以直接塞进 order.startPayingpay$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 已负责解析并调用支付域状态推进逻辑,项目不需要再重写通知解密和状态机。

调用公共路由时参数仍要带:

  • payNotifypayId
  • refundNotifyrefundId

使用建议

如果你准备在项目里复用这条链,最重要的建议只有两条:

  1. 订单状态不要自己手动推,尽量通过 order.startPayingpay.succeedPayingrefund.succeed 这些动作进入状态机。
  2. 前端支付流程不要只写页面逻辑,要同时把 registerFrontendPayRoutine(...)、支付回调 endpoint、watcher 补偿一起接上。

否则最常见的问题就是:页面能跳支付,但订单状态、退款状态、过期关闭和异步回调并没有跟着一起工作。