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 里最容易看起来“像一个简单表单”,但实际上规则最复杂的能力之一。因为在 Oak 的支付域模型里,提现不是单一渠道直接打款,而是可能同时拆成两部分:

  • 能原路退回的部分,走 Refund
  • 不能原路退回的部分,走 WithdrawTransfer

这也是为什么提现这一章一定要连着退款一起理解。

主要对象

Withdraw

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

  • withdrawing
  • successful
  • partiallySuccessful
  • failed
  • applying

动作包括:

  • succeed
  • fail
  • succeedPartially

WithdrawTransfer

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

  • transferring
  • successful
  • failed

动作包括:

  • succeed
  • fail

WithdrawAccount / WithdrawChannel

这两个对象分别代表:

  • 用户或业务对象可用的提现账户
  • 提现账户关联的打款渠道

当原路退款额度不够时,提现就会继续依赖这里配置的提现账户和渠道。

组件

提现这条线最值得先看的两个组件是:

  • src/components/withdraw/create/index.ts
  • src/components/withdraw/detail/index.ts

其中创建页最关键,因为它真实体现了 Oak 支付域里的提现流程不是“点提交直接 create withdraw”,而是:

  1. 先调用 aspect 预计算可行的提现拆单方案
  2. 再拿这个方案去执行 withdraw.create

详情页则会直接展示:

  • refund$withdraw
  • withdrawTransfer$withdraw

这两个关系正好对应提现拆单后的两段执行路径。

withdraw/create 常用参数

oak-pay-business/src/components/withdraw/create/index.ts 项目里最常用的参数有:

  • accountId
  • withdrawAccountFilter
  • onNewWithdrawAccount
  • onCreateWithdraw
  • onGoToHistory
  • onGoToWaManage

taicang/src/pages/frontend/account/withdraw/web.pc.tsx 的真实写法就是:

<WithdrawCreate
  accountId={account.id}
  withdrawAccountFilter={{
    entity: 'user',
    entityId: userId,
  }}
  onCreateWithdraw={gotoWithdrawDetail}
  onGoToHistory={gotoHistory}
  onGoToWaManage={gotoWaManageMp}
/>

withdraw/detail 与相关列表组件

提现页一般不是只接一个创建表单,还会和这些组件一起出现:

  • withdraw/detail
  • withdraw/list
  • withdrawAccount/list

taicang 前后台都已经把这几个组件分开挂页了,这样一旦出现“部分成功”“部分原路退回”,业务方可以直接在详情页看到拆单结果。

withdraw/detail 的职责边界

withdraw/detail 在 web 端的渲染其实很薄,它本身主要就是把一条 withdraw 交给 withdraw/display 展示。

这意味着:

  • 创建逻辑不应该放进详情页
  • 拆单解释、退款明细、转账明细适合统一在详情展示层处理
  • 页面层更适合负责“从列表跳转到哪条提现详情”

这种拆法和 taicang 当前的页面组织是一致的。

withdraw/display 的真实职责

withdraw/detail 之所以能把“部分成功”“部分失败”讲清楚,关键不是页面壳本身,而是 withdraw/display 已经把提现拆单明细整理好了。它当前最关键的参数只有两个:

  • withdraw
  • create

其中 withdraw 里它会直接读取:

  • refund$withdraw
  • withdrawTransfer$withdraw

然后把两边数据拍平成一组统一的展示项。也就是说,这个组件不是分别画两张表,而是把“原路退款”和“转账打款”都收敛成同一种明细卡片结构。

渠道名称的解析也已经内置了:

  • 优先从 withdrawTransfer.withdrawAccount.channel 取渠道
  • 如果详情数据里没带全,再回退到缓存里按 withdrawAccountId 查一次
  • offlineAccount 会额外展开成具体线下账户类型文案

create 参数的作用主要是控制展示阶段:

  • create: true 时按“刚提交申请”显示步骤,不展示执行态和更新时间
  • 详情页常规查看时,则会显示 refund / withdrawTransfer 的执行状态、更新时间和失败原因

所以项目里如果想自己重画提现详情,至少也要把这套“退款 + 转账”的汇总逻辑一起带走。

withdraw/list 常用参数

oak-pay-business/src/components/withdraw/list/index.ts 最常用的参数非常明确:

  • accountId
  • gotoDetail

它内部会:

  • 强制按 accountId 过滤
  • 默认按 $$createAt$$ desc 排序
  • 直接把每条提现的拆单数量聚合出来

所以它很适合直接作为“某个账户的提现历史页”。

withdrawAccount/list 常用参数

oak-pay-business/src/components/withdrawAccount/list/index.ts 则更适合两种模式复用:

  • 管理模式:维护某个对象的提现账户
  • 选择器模式:给 withdraw/create 挑一个打款账户

常用参数包括:

  • entity
  • entityId
  • onPick
  • onCancel

从源码看它会默认:

  • 只展示 enabled: true 的账户
  • 优先选中默认账户
  • onPick 存在时自动切成 picker 模式

这也是为什么 withdraw/create 只要传 withdrawAccountFilter,后续选择器链路就能自然接起来。

withdrawAccount/list 的两种模式

结合 index.tsweb.pc.tsx,这个组件其实明确支持两种模式。

1. 管理模式

不传 onPick 时,它就是普通管理列表,适合放在“我的提现账户”或后台账户维护页里。当前 web 端行为包括:

  • 可以直接打开 WdaUpsert 模态框创建或编辑
  • 可以切换 isDefault
  • 可以删除账户;如果当前行没有 remove 权限,则会退化成执行 disable

2. 选择器模式

传了 onPick 以后,它会自动进入 picker 模式:

  • asPicker 会变成 true
  • 底部出现确认选择和取消按钮
  • 默认会优先选中 isDefault 的提现账户

所以项目层如果是在提现创建页里选打款账户,直接传 onPick / onCancel 就够了;如果是在个人中心维护账户,就不要传 onPick,让它走管理模式。

withdrawTransfer/list 适合放在哪里

如果提现后台需要真正处理“打款执行”这一步,通常不会直接看 withdraw/list,而会继续接 withdrawTransfer/list。这个组件当前的真实行为包括:

  • 默认按当前应用所属系统过滤 withdraw.account.ofSystemId
  • 自动整理 creatorNamecreatorMobile
  • 自动整理 operatorNameoperatorMobile
  • 自动把 withdrawAccount.channel 转成渠道名称
  • 暴露 succeedfail 两类动作

它还有一个很实际的实现细节:

  • 在准备更新某条转账记录时,会先刷新对应系统账户余额,并写到本地状态 sysAccountAmount

所以它更适合:

  • 运营后台待打款列表
  • 提现转账处理页
  • 财务审核后的执行页

withdraw/list 更适合作为用户或业务对象视角的“提现历史”。

aspect

提现最核心的后端入口就是 src/aspects/withdraw.ts#getWithdrawCreateData(...)

它的参数很简单:

  • accountId
  • price
  • withdrawAccountId?

但内部做的事情非常多:

  • 检查 System.payConfig.withdrawLoss 是否已配置
  • 锁定当前账户,读取 availrefundable
  • 如果申请提现金额超过可原路退款额度,且又没选 withdrawAccountId,直接报错
  • 计算本次提现应拆成哪些 refund$withdraw
  • 如果仍有剩余金额,再补一笔 withdrawTransfer$withdraw
  • 最后算出整单总手续费 loss

也就是说,提现创建页拿到的不是一个“展示用报价”,而是最终可直接落库的 withdraw.create 数据。

后台规则

提现手续费计算

提现手续费由 System.payConfig.withdrawLoss 决定,源码里有两套模式。

1. conservative: true

保守模式下,不按系统固定比例算,而是根据真实渠道税费估损:

  • 原路退款部分会调用 payClazz.calcRefundTax(...)payClazz.calcPayTax(...)
  • 转账部分会调用 payClazz.calcTransferTax(...)

2. conservative: false

非保守模式下,会先按系统配置预计算总手续费,再按提现拆单比例分摊到每一笔 refundwithdrawTransfer 上。

这时会用到:

  • ratio
  • lowest
  • highest
  • trim

其中 trim 支持:

  • jiao
  • yuan

Withdraw trigger

src/triggers/withdraw.ts 负责的事情主要有三类。

1. 创建提现时先扣账户并记流水

提现创建时会先扣减账户可用余额,并创建关联的 accountOper

2. 汇总执行结果更新提现状态

updateWithdrawState(...) 会把:

  • refund$withdraw
  • withdrawTransfer$withdraw

两边实际完成的金额和手续费汇总成:

  • dealPrice
  • dealLoss

再决定提现最终是:

  • fail
  • succeed
  • succeedPartially

3. 失败或部分成功时返还差额

如果整单失败,或者只成功了一部分,剩余差额会通过新的 accountOper 返还到账户。

WithdrawTransfer checker 与 trigger

src/checkers/withdrawTransfer.ts 约束很明确:

  • succeed 时必须有 externalId
  • fail 时必须有 reason

src/triggers/withdrawTransfer.ts 则会在成功或失败后继续更新 withdraw,并在成功时记:

  • sysAccountOper
  • 系统账户侧 accountOper

WithdrawAccount checker

src/checkers/withdrawAccount.ts 会保证:同一 entity / entityId 下默认提现账户只能有一个。

注入点

提现这条线自己没有单独的 registry,但它直接依赖支付渠道扩展能力:

  • 原路退款依赖 getPayClazz(...)
  • 转账打款依赖 WithdrawChannel 关联的支付类
  • 所以项目层新增支付渠道后,提现能力也会自动用到这套扩展

项目中如何接入

提现最推荐的接法就是严格复用 withdraw/create 组件里的顺序。

1. 先调 aspect 拿最终创建数据

这是必须的,不能省略。因为只有后端才能正确判断:

  • 哪些金额能原路退款
  • 哪些金额必须打到提现账户
  • 本次手续费到底该怎么算

2. 再执行 withdraw.create

拿到 withdrawData 后,再直接走 Oak operate

3. 详情页统一看拆单结果

提现详情页应该直接展示 refund$withdrawwithdrawTransfer$withdraw,不要只显示一个总状态。否则一旦出现部分成功,就很难让业务方理解到底发生了什么。

真实项目里的页面拆法

taicang 的页面结构看,提现最稳的拆法是:

  • 账户详情页只负责跳到提现创建页
  • 提现创建页只负责生成并提交 withdrawData
  • 提现详情页展示 refund$withdrawwithdrawTransfer$withdraw
  • 提现账户管理页单独维护 WithdrawAccount

这样每个页面的职责都很单一,出问题也更好排查。

开发注意事项

提现这块有两个实现细节很值得提前写进文档:

  • withdraw/list 是按账户维度查数据的,不是全局提现列表组件
  • withdrawAccount/list 的默认过滤是“只看启用中的账户”,如果你项目里有停用后仍需展示的管理需求,就要单独做后台页,而不要直接拿 picker 模式复用

使用示例

1. 创建页先向后端要提现方案

const { result: withdrawData } = await this.features.cache.exec('getWithdrawCreateData', {
  accountId,
  price,
  withdrawAccountId,
});

如果金额超过了当前可原路退款额度,但又没有选择 withdrawAccountId,这里就会直接报错,而不是等到真正创建后才失败。

2. 再执行提现创建

await this.features.cache.exec('operate', {
  entity: 'withdraw',
  operation: {
    id: await generateNewIdAsync(),
    action: 'create',
    data: withdrawData,
  },
});

3. 提现数据的真实结构

getWithdrawCreateData(...) 返回的数据结构里,最重要的就是这两段:

{
  id: 'withdraw-id',
  accountId,
  price,
  loss,
  refund$withdraw: [
    {
      id: '...',
      action: 'create',
      data: {
        payId: '...',
        price: 5000,
        loss: 120,
      },
    },
  ],
  withdrawTransfer$withdraw: [
    {
      id: '...',
      action: 'create',
      data: {
        price: 3000,
        loss: 80,
        withdrawAccountId,
      },
    },
  ],
}

也就是说,一次提现在 Oak 模型里本来就是“退款 + 转账”的组合单。

使用建议

提现这块最重要的经验是:不要把它写成一个只看余额的简单转账表单。更好的做法是:

  1. 始终先调 getWithdrawCreateData(...)
  2. 始终把 refund$withdrawwithdrawTransfer$withdraw 当成两条真正独立的执行路径
  3. 系统配置里先把 withdrawLoss 配完整,再开放提现入口

否则一旦碰到“部分金额原路退,部分金额人工打款”的场景,前台和后台很快就会对不上。