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 没把它做成一个孤立的钱包模块,而是把充值、到账、系统资金、账户流水放进了一套统一模型里。

如果你从源码角度看,这条线的核心其实是:

  • Account
  • Deposit
  • AccountOper
  • SysAccountOper
  • SysAccountMove

它和订单支付会复用同一套渠道能力,但状态推进和记账方式并不完全一样。

主要对象

Account

账户本身记录的是用户或业务对象的余额。最常见的两个数是:

  • total
  • avail

Deposit

src/entities/Deposit.ts 定义了充值单状态:

  • depositing
  • successful
  • failed
  • shipped

动作包括:

  • succeed
  • fail
  • ship

这里的 shipped 很容易让人意外。原因是 oak-pay-business 允许把某些充值做成“先支付,后确认收货再到账”的模式,尤其是小程序虚拟发货场景。

AccountOper

这是账户流水。充值到账、退款返还、提现冻结或回退,最后都会落到 accountOper 上。

SysAccountOper / SysAccountMove

这两类对象对应的是系统资金层面的流水和划拨。也就是说,用户账户余额变化之外,平台自身的系统账户也有一套独立流水。

组件

这条主线最关键的现成组件有三组。

1. src/components/deposit/new/index.ts

这是“创建充值”的真实前端组件。它会:

  • 通过 features.pay.getPayChannels('deposit') 列出充值可用渠道
  • 通过 features.pay.calcDepositLoss(...) 计算充值手续费
  • 在小程序环境里默认优先选 wpProduct

如果项目里自己做充值输入框和渠道选择,至少也要把这三件事一起补上。

deposit/new 常用参数与组件边界

这不是一个“自己持有全部状态并直接提交充值”的黑盒组件,而是一个受控表单。当前源码里暴露的关键参数有:

  • accountId
  • depositMinCent
  • depositMaxCent
  • price
  • channel
  • onSetPrice
  • onSetChannel
  • loss
  • focus

其中真正会直接参与渲染和联动的,是:

  • depositMinCent / depositMaxCent:控制金额上下限,并在 ready() 时用最小充值额初始化输入框
  • price / channel / loss:父组件维护的受控状态
  • onSetPrice / onSetChannel:组件内部只负责把变更往外抛

focus 目前主要用于小程序输入框聚焦,web 端并不会额外依赖它。accountId 虽然保留在属性里,但当前组件本体并不直接用它创建充值单,所以真正的提交动作仍然应该放在父组件,例如 account/detail 或项目自己的充值页里。

从实现上看,它还有两个默认行为很适合直接写进接入文档:

  • 小程序环境且未指定渠道时,会自动优先选 wpProduct
  • priceloss 变化后,会重新计算小程序里的 cursorSpacing,保证渠道和手续费区域不会挡住输入框

也就是说,deposit/new 更适合做“充值参数录入器”,而不是一个独立完成整条充值链路的页面。

2. src/components/account/detail/index.ts

这是账户详情页的核心组件。它不是只读组件,内部还封装了完整的充值创建流程:

  • 调用 account.deposit
  • 嵌套创建 deposit
  • 再嵌套创建 pay
  • 轮询 pay 直到进入 paying
  • 最后跳转到未完成支付详情

也就是说,项目层如果直接复用它,充值链路已经是完整的。

3. 系统资金相关组件

系统侧的资金展示和流水组件主要分布在:

  • src/components/sysAccount/survey
  • src/components/sysAccountMove/create
  • src/components/sysAccountOper/list
  • src/components/accountOper/*

这些组件通常会和上一章的系统支付配置页一起出现在管理后台里。

account/detail 常用参数

oak-pay-business/src/components/account/detail/index.ts 不是一个纯展示组件,项目里最常传的参数有:

  • depositMinCent
  • depositMaxCent
  • autoStartPay
  • onGoToUnfinishedPay
  • onWithdraw
  • preWithdraw
  • onGoToHistory

taicang/src/pages/frontend/account/detail/web.pc.tsx 的真实包法是:

<AccountDetail
  autoStartPay={true}
  depositMinCent={depositMinCent}
  oakId={accountId}
  oakPath={`${oakFullpath}.account`}
  onGoToUnfinishedPay={(payId) => navigateTo({
    url: '/pay/detail',
    oakId: payId,
  })}
  onWithdraw={() => gotoWithdraw()}
  preWithdraw={() => preWithdraw()}
  onGoToHistory={gotoHistory}
/>

从组件内部逻辑看,它默认还会关心:

  • 是否存在 depositing 状态的充值单
  • 是否存在 shipped 状态、待确认收货的充值单
  • 最近几条 accountOper$account

所以它适合做“账户主页”而不是只做一个充值按钮。项目层如果只是想要极简充值入口,才更适合单独包 deposit/new

组件适合放在哪里

这组组件最适合的几个落点其实很明确:

  • 用户钱包页、保证金账户页:用 account/detail
  • 账户流水页:用 accountOper/list
  • 系统资金总览页:用 sysAccount/survey

taicang 前台账户页和后台账户页都是围绕这套公共组件搭出来的。

accountOper/list 的真实筛选能力

这个列表组件并不只是简单把流水按时间倒序列出来。它当前的关键参数是:

  • accountId

但内部还已经内置了两组常见筛选:

  • 按月份筛选 $$createAt$$
  • 按收支方向筛选 availPlus

具体来说:

  • type: 'in' 只看收入流水
  • type: 'out' 只看支出流水
  • type: 'both' 会过滤掉 availPlus === 0 的无效流水

所以项目层如果做账户历史页,通常不需要自己再额外写一个月份/收支筛选器,直接复用这个组件即可。

系统资金列表组件

除了总览卡片,系统资金后台里还有两组很值得直接复用的列表组件:

  • sysAccountOper/list
  • sysAccount/transferList

sysAccountOper/list 当前通过下面两个参数工作:

  • entity
  • entityId

它会按这两个字段过滤系统流水,并内置:

  • 月份筛选
  • 类型筛选,当前枚举包括 withdrawTransferpayrefundcompensatemoveInmoveOut

sysAccount/transferList 则更偏运营处理页。它默认只看:

  • 当前应用所属系统下的提现转账
  • iState === 'transferring'

并且会把 withdrawAccount.channel 自动转成可读渠道名。所以它很适合做“待打款提现处理列表”,而不是通用历史查询页。

开发注意事项

account/detail 的一个重要适配前提是:项目实体关系不要把公共充值链路打断。至少要保证这些关系还能正常工作:

  • deposit$account
  • pay$deposit
  • accountOper$account

因为组件内部会直接沿着这些关系判断“是否有未完成充值”“是否需要去支付详情页”“最近流水是什么”。

前端入口

这条线主要依赖的前端入口还是 features.pay

getPayChannels('deposit')

会返回所有允许充值的渠道。当前内建来源是:

  • offlineAccount.allowDeposit === true
  • 当前环境可用的 wpProduct

calcDepositLoss(price, channel)

会按 System.payConfig.depositLoss 直接计算充值损耗。返回值是一个三元组:

  • 手续费金额
  • 说明文案 key
  • 文案参数

这也是 deposit/newaccount/detail 里实际在用的方法。

后台规则

account.detail 发起充值时的真实操作

src/components/account/detail/index.ts 里最值得注意的代码,是它并不是直接创建一个 pay,而是走:

  • account.deposit
  • 嵌套 deposit$account.create
  • 再嵌套 pay$deposit.create

也就是说,在 Oak 模型里,“账户充值”不是一个页面自定义动作,而是账户对象上的正式业务动作。

Pay trigger 与 Deposit trigger 的配合

充值能不能真正到账,主要靠两层 trigger 协作:

  • triggers/pay.ts
  • triggers/deposit.ts

当前源码里的真实行为是:

  • pay.succeedPaying 后会根据 depositId 去推进充值单
  • 如果支付产品要求确认收货,充值单会先进入 ship
  • deposit.succeed 时才真正补记账户和系统账户的流水
  • deposit 状态变化还会推送数据事件

这就是为什么有些充值会“支付成功但余额还没到账”,因为它还卡在确认收货这一层。

小程序确认收货

如果是带确认收货的小程序充值,前端组件会调用:

  • shipConfirmSuccess

这个 aspect 定义在 src/aspects/ship.ts,如果微信侧物流状态已经确认收货,就会把 shipreceiving 推到 succeedReceiving,再由后续 trigger 推动 deposit.succeed

注入点

账户充值这一章本身没有额外的独立 registry,但它会直接复用支付域已有的几个扩展点:

  • registerPayClazz(...)
  • registerFrontendPayRoutine(...)
  • registerSysAccountCardTopComponent(...)
  • registerSysAccountDetailComponent(...)

也就是说,项目一旦加了新的充值渠道,不只订单支付会受影响,账户充值入口和系统资金展示也要一起补。

项目中如何接入

1. 先让应用能拿到充值渠道

features.pay.getPayChannels('deposit') 依赖当前应用和系统的投影,所以初始化阶段一定要把:

  • system.offlineAccount$system
  • wpProduct$application
  • system.payConfig

带进来。

2. 充值入口优先复用现成组件

对新项目来说,最简单且最稳的做法一般是:

  • 账户页直接挂 components/account/detail
  • 需要单独充值表单时,再挂 components/deposit/new

这样充值手续费、充值渠道和支付详情跳转都能统一起来。

3. 如果有系统资金管理台,再补系统侧组件

当项目需要运营后台查看平台收支时,再接:

  • sysAccount/survey
  • sysAccountMove/create
  • sysAccountOper/list

真实项目里的页面拆法

taicang 的做法很值得直接照搬:

  • /frontend/account/detail 负责账户余额、充值入口、提现入口
  • /frontend/pay/detail 负责未完成支付
  • /frontend/account/history 负责账户流水

这样充值、支付、流水三条链路各自有独立页面,但底层仍然是同一套公共组件。

使用示例

1. 单独使用充值创建组件

<DepositNew
  price={price}
  channel={channel}
  loss={loss}
  depositMinCent={100}
  depositMaxCent={200000}
  onSetPrice={(nextPrice) => this.setState({ price: nextPrice })}
  onSetChannel={(nextChannel) => this.setState({ channel: nextChannel })}
/>

当价格或渠道变化时,可以直接用 feature 重新计算手续费:

const loss = price && channel
  ? this.features.pay.calcDepositLoss(price, channel)
  : [0, '', undefined];

2. 通过账户动作创建充值和支付

这就是 components/account/detail 当前真实采用的 Oak 操作结构:

await this.execute(undefined, undefined, undefined, [
  {
    entity: 'account',
    operation: {
      id: await generateNewIdAsync(),
      action: 'deposit',
      data: {
        deposit$account: [
          {
            id: await generateNewIdAsync(),
            action: 'create',
            data: {
              id: await generateNewIdAsync(),
              price: depPrice,
              loss: depositLoss[0] || 0,
              creatorId: this.features.token.getUserId()!,
              pay$deposit: [
                {
                  id: await generateNewIdAsync(),
                  action: 'create',
                  data: {
                    id: payId,
                    autoStart: true,
                    price: depPrice,
                    entity: depositChannel.entity,
                    entityId: depositChannel.entityId,
                  },
                },
              ],
            },
          },
        ],
      },
      filter: {
        id: accountId,
      },
    },
  },
]);

这段结构的意义在于:充值、支付、账户动作三者天然保持同一条 Oak 数据链。

使用建议

如果项目里既有订单支付,又有账户充值,最容易犯的错是把两条链拆成两套完全不同的前端和后台逻辑。实际上更推荐:

  1. 渠道层统一复用 features.payregisterFrontendPayRoutine(...)
  2. 记账层统一复用 depositaccountOpersysAccountOper
  3. 需要确认收货的充值,直接复用 ship 主线,不要额外发明一个“待到账状态”

这样后面要查“钱为什么没到”“系统资金为什么少了一笔”“用户余额为什么和支付成功不一致”时,排查路径会清楚很多。