提现
提现是 oak-pay-business 里最容易看起来“像一个简单表单”,但实际上规则最复杂的能力之一。因为在 Oak 的支付域模型里,提现不是单一渠道直接打款,而是可能同时拆成两部分:
- 能原路退回的部分,走
Refund - 不能原路退回的部分,走
WithdrawTransfer
这也是为什么提现这一章一定要连着退款一起理解。
主要对象
Withdraw
src/entities/Withdraw.ts 里的状态包括:
withdrawingsuccessfulpartiallySuccessfulfailedapplying
动作包括:
succeedfailsucceedPartially
WithdrawTransfer
src/entities/WithdrawTransfer.ts 里的状态包括:
transferringsuccessfulfailed
动作包括:
succeedfail
WithdrawAccount / WithdrawChannel
这两个对象分别代表:
- 用户或业务对象可用的提现账户
- 提现账户关联的打款渠道
当原路退款额度不够时,提现就会继续依赖这里配置的提现账户和渠道。
组件
提现这条线最值得先看的两个组件是:
src/components/withdraw/create/index.tssrc/components/withdraw/detail/index.ts
其中创建页最关键,因为它真实体现了 Oak 支付域里的提现流程不是“点提交直接 create withdraw”,而是:
- 先调用 aspect 预计算可行的提现拆单方案
- 再拿这个方案去执行
withdraw.create
详情页则会直接展示:
refund$withdrawwithdrawTransfer$withdraw
这两个关系正好对应提现拆单后的两段执行路径。
withdraw/create 常用参数
oak-pay-business/src/components/withdraw/create/index.ts 项目里最常用的参数有:
accountIdwithdrawAccountFilteronNewWithdrawAccountonCreateWithdrawonGoToHistoryonGoToWaManage
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/detailwithdraw/listwithdrawAccount/list
taicang 前后台都已经把这几个组件分开挂页了,这样一旦出现“部分成功”“部分原路退回”,业务方可以直接在详情页看到拆单结果。
withdraw/detail 的职责边界
withdraw/detail 在 web 端的渲染其实很薄,它本身主要就是把一条 withdraw 交给 withdraw/display 展示。
这意味着:
- 创建逻辑不应该放进详情页
- 拆单解释、退款明细、转账明细适合统一在详情展示层处理
- 页面层更适合负责“从列表跳转到哪条提现详情”
这种拆法和 taicang 当前的页面组织是一致的。
withdraw/display 的真实职责
withdraw/detail 之所以能把“部分成功”“部分失败”讲清楚,关键不是页面壳本身,而是 withdraw/display 已经把提现拆单明细整理好了。它当前最关键的参数只有两个:
withdrawcreate
其中 withdraw 里它会直接读取:
refund$withdrawwithdrawTransfer$withdraw
然后把两边数据拍平成一组统一的展示项。也就是说,这个组件不是分别画两张表,而是把“原路退款”和“转账打款”都收敛成同一种明细卡片结构。
渠道名称的解析也已经内置了:
- 优先从
withdrawTransfer.withdrawAccount.channel取渠道 - 如果详情数据里没带全,再回退到缓存里按
withdrawAccountId查一次 offlineAccount会额外展开成具体线下账户类型文案
create 参数的作用主要是控制展示阶段:
create: true时按“刚提交申请”显示步骤,不展示执行态和更新时间- 详情页常规查看时,则会显示
refund/withdrawTransfer的执行状态、更新时间和失败原因
所以项目里如果想自己重画提现详情,至少也要把这套“退款 + 转账”的汇总逻辑一起带走。
withdraw/list 常用参数
oak-pay-business/src/components/withdraw/list/index.ts 最常用的参数非常明确:
accountIdgotoDetail
它内部会:
- 强制按
accountId过滤 - 默认按
$$createAt$$ desc排序 - 直接把每条提现的拆单数量聚合出来
所以它很适合直接作为“某个账户的提现历史页”。
withdrawAccount/list 常用参数
oak-pay-business/src/components/withdrawAccount/list/index.ts 则更适合两种模式复用:
- 管理模式:维护某个对象的提现账户
- 选择器模式:给
withdraw/create挑一个打款账户
常用参数包括:
entityentityIdonPickonCancel
从源码看它会默认:
- 只展示
enabled: true的账户 - 优先选中默认账户
- 在
onPick存在时自动切成 picker 模式
这也是为什么 withdraw/create 只要传 withdrawAccountFilter,后续选择器链路就能自然接起来。
withdrawAccount/list 的两种模式
结合 index.ts 和 web.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 - 自动整理
creatorName、creatorMobile - 自动整理
operatorName、operatorMobile - 自动把
withdrawAccount.channel转成渠道名称 - 暴露
succeed、fail两类动作
它还有一个很实际的实现细节:
- 在准备更新某条转账记录时,会先刷新对应系统账户余额,并写到本地状态
sysAccountAmount
所以它更适合:
- 运营后台待打款列表
- 提现转账处理页
- 财务审核后的执行页
而 withdraw/list 更适合作为用户或业务对象视角的“提现历史”。
aspect
提现最核心的后端入口就是 src/aspects/withdraw.ts#getWithdrawCreateData(...)。
它的参数很简单:
accountIdpricewithdrawAccountId?
但内部做的事情非常多:
- 检查
System.payConfig.withdrawLoss是否已配置 - 锁定当前账户,读取
avail和refundable - 如果申请提现金额超过可原路退款额度,且又没选
withdrawAccountId,直接报错 - 计算本次提现应拆成哪些
refund$withdraw - 如果仍有剩余金额,再补一笔
withdrawTransfer$withdraw - 最后算出整单总手续费
loss
也就是说,提现创建页拿到的不是一个“展示用报价”,而是最终可直接落库的 withdraw.create 数据。
后台规则
提现手续费计算
提现手续费由 System.payConfig.withdrawLoss 决定,源码里有两套模式。
1. conservative: true
保守模式下,不按系统固定比例算,而是根据真实渠道税费估损:
- 原路退款部分会调用
payClazz.calcRefundTax(...)和payClazz.calcPayTax(...) - 转账部分会调用
payClazz.calcTransferTax(...)
2. conservative: false
非保守模式下,会先按系统配置预计算总手续费,再按提现拆单比例分摊到每一笔 refund 或 withdrawTransfer 上。
这时会用到:
ratiolowesthighesttrim
其中 trim 支持:
jiaoyuan
Withdraw trigger
src/triggers/withdraw.ts 负责的事情主要有三类。
1. 创建提现时先扣账户并记流水
提现创建时会先扣减账户可用余额,并创建关联的 accountOper。
2. 汇总执行结果更新提现状态
updateWithdrawState(...) 会把:
refund$withdrawwithdrawTransfer$withdraw
两边实际完成的金额和手续费汇总成:
dealPricedealLoss
再决定提现最终是:
failsucceedsucceedPartially
3. 失败或部分成功时返还差额
如果整单失败,或者只成功了一部分,剩余差额会通过新的 accountOper 返还到账户。
WithdrawTransfer checker 与 trigger
src/checkers/withdrawTransfer.ts 约束很明确:
succeed时必须有externalIdfail时必须有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$withdraw 和 withdrawTransfer$withdraw,不要只显示一个总状态。否则一旦出现部分成功,就很难让业务方理解到底发生了什么。
真实项目里的页面拆法
从 taicang 的页面结构看,提现最稳的拆法是:
- 账户详情页只负责跳到提现创建页
- 提现创建页只负责生成并提交
withdrawData - 提现详情页展示
refund$withdraw和withdrawTransfer$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 模型里本来就是“退款 + 转账”的组合单。
使用建议
提现这块最重要的经验是:不要把它写成一个只看余额的简单转账表单。更好的做法是:
- 始终先调
getWithdrawCreateData(...) - 始终把
refund$withdraw和withdrawTransfer$withdraw当成两条真正独立的执行路径 - 系统配置里先把
withdrawLoss配完整,再开放提现入口
否则一旦碰到“部分金额原路退,部分金额人工打款”的场景,前台和后台很快就会对不上。