项目支付系统设计与接入
如果你已经看完前面几篇,会发现 oak-pay-business 并不是“拿来就能直接付款”的一个页面组件包,而是一套完整的支付域基础设施。真正做项目时,最重要的问题也不是“怎么拉起微信支付”,而是:
- 支付系统应该怎么分层;
- 哪些能力应该沉到公共支付域里;
- 哪些规则应该留在项目自己的 trigger / aspect 里;
- 一个新项目最小应该接哪些东西,才不至于只把支付页面做出来,却把状态机、回调和补偿链路丢了。
这一篇就用 taicang 作为已经跑通的参考项目,把“项目支付系统应该怎么设计”完整梳理一遍。
先说结论
一个 Oak 项目的支付系统,最稳的设计不是“自己写一套 payment service”,而是分成下面四层:
1. 支付域公共底座
这层由 oak-pay-business 提供,负责:
- 支付、退款、充值、提现、物流、系统资金、结算这些通用实体;
- checker、trigger、watcher、timer 组成的资金状态机;
- 支付渠道抽象
payClazz; - 支付回调处理;
- 前端
payfeature; - 后台配置组件、支付详情组件、账户与流水组件。
2. 项目支付接入层
这层在项目里负责:
- 把
oak-pay-business的checkers / triggers / watchers / timers / aspects / features合并进来; - 让
make:dep生成的 Context 组合支付域 module,项目自己的RuntimeContext继承生成的 Context; - 暴露项目自己的支付回调 endpoint;
- 按需要注册新的支付渠道、前端拉起流程和后台配置页。
3. 业务支付编排层
这层仍然在项目里,但职责不是“解支付协议”,而是:
- 决定当前订单该拆成几笔
pay; - 决定能不能余额支付、能不能押金抵扣、能不能部分支付;
- 决定支付成功之后业务对象怎么变化。
4. 渠道协议层
这层才是各支付渠道自己的 SDK 交互,例如:
- 微信预下单;
- 微信回调解密;
- 微信查单、关单、退款;
- 其它支付机构的下单和状态查询。
这一层应该放进 payClazz,而不应该散在页面、controller 或项目自己的 service 里。
taicang 当前是怎么做的
taicang 基本就是这套分层的标准样板。
1. 依赖层直接接入 oak-pay-business
src/configuration/dependency.ts 直接声明了:
oak-general-businessoak-pay-business
这一步的意义不是“安装一个库”,而是把支付域对象和运行时能力纳入 Oak 依赖体系。
2. 初始化阶段合并支付域能力
taicang 在初始化时并没有自己重写支付流程,而是把公共支付域能力接入当前 Oak 运行时。需要区分当前模板和历史文件:
- 新项目先在
src/configuration/dependency.ts声明oak-pay-business,再执行project:init、make:domain和make:dep - 当前模板的前端运行时主入口是
src/initialize.ts -> src/initialize.server.ts - 应用启动后继续执行
src/initializeFeatures.ts,web 端可按需追加src/initializeFeatures.web.ts - 老项目里如果还保留
src/initialize.frontend.ts,通常只是历史 DebugConnector 场景,不应再当作纯前台模式入口复制
这里合并的真实内容包括:
- 前端运行时:
checkers / common / render / features - 后端运行时:
aspects / checkers / triggers / watchers / timers / data / ports / routines
同时,taicang 的前后端 RuntimeContext 都继承各自的 generatedBackend / generatedFrontend。生成文件从 oak-pay-business 原 RuntimeContext 入口取得具名 module,并按 oak-general-business -> oak-pay-business -> 项目 的依赖顺序构造最终 Context:
src/context/BackendRuntimeContext.tssrc/context/FrontendRuntimeContext.ts
这一步非常关键。项目不应再手工直继承支付库的 RuntimeContext,也不需要直接依赖 pay 已经传递依赖的 general Context;具体 layer/module 写法和冲突规则见上下文。很多项目失败就失败在这里只是“引了组件”,却没有把支付域运行时真的接进来。
3. 实体层复用公共支付模型
taicang 没有自己另起一套 paymentOrder、paymentRecord、wallet 模型,而是直接扩展公共实体:
src/entities/Order.ts继承@oak-pay-business/entities/Ordersrc/entities/System.ts继承@oak-pay-business/entities/Systemsrc/entities/Ship.ts继承@oak-pay-business/entities/Shipsrc/entities/Supplier.ts使用Account、WithdrawAccount
也就是说,订单支付状态机、系统支付配置、账户与提现账户这些核心模型,在 taicang 里本质都沿用了公共支付域。
4. 项目只在业务差异点上扩展
taicang 真正自己补的,是那些明显不属于通用支付域的规则:
- 号牌押金能否抵扣;
- 号牌押金在支付成功后如何返还或消费;
- 拍卖业务下订单完成后如何生成后续结算计划;
- 小程序确认收货时如何结合号牌、物流和支付元数据继续推进业务。
这些逻辑主要落在:
src/utils/spPlate.tssrc/aspects/spPlate.tssrc/triggers/pay.tssrc/triggers/order.ts
这就是正确的边界。通用支付域负责支付本身,项目触发器负责“支付成功后这门生意该怎么走”。
oak-pay-business 真正负责什么
如果要正确设计项目支付系统,先要知道 oak-pay-business 已经帮你做了什么。
1. 它已经提供了完整支付主模型
核心对象至少包括:
PayRefundDepositAccountWithdrawWithdrawTransferSystem.payConfigOfflineAccountWpAccountWpProductSettlementSettlePlan
这些对象不是孤立存在的,而是已经通过 checker、trigger 和 watcher 形成了一条可运行的支付状态机。
2. 它已经提供了支付状态推进机制
项目真正发起支付时,正确路径不是“页面调用 SDK 成功后自己改状态”,而是:
- 创建
pay - 执行
startPaying - trigger 在
before阶段调用渠道prepay - 回调或 watcher 再把
pay推进到paid / closed / refunding / refunded - trigger 再把
order、deposit、account、sysAccount往前推进
这套状态推进主要由这些文件负责:
src/checkers/order.tssrc/checkers/pay.tssrc/triggers/pay.tssrc/watchers/pay.tssrc/utils/pay.ts
3. 它已经提供了渠道抽象
真正和支付机构打交道的,不应该是页面,也不应该是项目 controller,而应该是 payClazz。
oak-pay-business 当前默认已经内建:
accountofflineAccountwpProductapProductepProductspProductcpProductwfProductppProduct
并通过下面这些入口暴露扩展点:
registerPayClazz(...)registerFrontendPayRoutine(...)registerPayChannelComponent(...)
其中 ap/ep/sp/cp/wf/pp 的前端选择与跳转当前只在 Web 环境启用。新项目只有在接入这些内建合同之外的支付机构时,才需要扩展渠道层。
4. 它已经提供了后台和前台现成组件
最常直接复用的组件包括:
payConfig/systemorder/paypay/detailpay/listrefund/listaccount/detaildeposit/newwithdraw/createsysAccount/survey
因此一个新项目的正确思路,不是自己重搭管理后台,而是优先复用这些组件,再在项目层补壳。
一个项目的支付系统应该怎么分对象
这部分最容易设计错。下面是推荐的对象分法。
1. 订单对象只负责业务订单
订单对象应该表达的是:
- 买了什么;
- 应付多少钱;
- 已付多少钱;
- 已退多少钱;
- 当前处于待支付、支付中、已支付还是退款中。
订单不应该自己塞一堆渠道协议字段,也不应该自己承担回调验签逻辑。
正确做法就是像 taicang/src/entities/Order.ts 一样,继承支付域 Order,然后只补业务字段。
2. 支付对象只负责一次资金动作
Pay 是一笔支付分录,不一定等于一个订单。
一个订单可能拆成多笔 pay:
- 一笔账户余额支付;
- 一笔外部渠道支付;
- 甚至多笔不同外部渠道支付。
因此项目不要把“订单”和“支付单”混成一个对象。真正的组合支付能力,就是靠订单下挂多笔 pay$order 实现的。
3. 系统对象统一承载支付策略
充值和提现手续费、系统可用渠道、系统资金账户这些内容,应该挂在 System,而不是挂在订单、用户或某个页面配置表里。
这一点 oak-pay-business/entities/System.ts 已经定好了,项目继续扩展这个对象即可。
4. 渠道对象统一承载支付机构配置
推荐分成两层:
- 支付账号,例如
WpAccount、OfflineAccount - 支付产品,例如
WpProduct
这样好处非常大:
- 一个系统可以有多个支付产品;
- 多个应用可以挂到不同支付产品;
- 账号配置和产品配置分离;
- 前端可以根据当前应用自动算出可用渠道。
一个项目的支付系统应该怎么分流程
1. 下单和选择支付方式
页面只负责收集支付方案,不直接触发第三方支付。
推荐做法是:
- 订单页里挂一个支付方案组件;
- 这个组件只回传
pay$order创建数据; - 父组件再调用
order.startPaying。
taicang 的前台订单详情页就是这样做的:
- 自定义组件
src/components/pay/index.ts负责拼pay$order - 页面
src/pages/frontend/order/detail/index.ts负责执行startPaying
2. 真正拉起支付
真正外部支付不应在订单页直接完成,而应该切到 pay/detail 或复用它的内部能力。
因为支付详情页已经内建:
- 渠道可支付性判断;
- 小程序
wx.requestPayment(...); - 微信网页
chooseWXPay; - 线下支付展示;
- 支付失败后的关闭或回退策略。
taicang 就是在 createOrderPay() 后跳到 /pay/detail 来完成外部支付。
3. 回调和补偿
异步通知应该只做一件事:把请求交给支付域公共处理逻辑。
taicang 的做法非常标准:
src/endpoints/wechatPay.ts里暴露项目 endpoint- 内部直接调用
@oak-pay-business/utils/pay的payNotify / refundNotify
这一步不要自己重写支付状态推进,否则后面的 watcher 和 trigger 会越来越难协调。
4. 支付成功后的业务动作
支付成功本身只是资金域事件,不应该直接写死在公共包里。
项目应该在自己的 triggers/pay.ts 或 triggers/order.ts 里处理:
- 这笔钱对应哪个业务对象;
- 押金怎么消费或返还;
- 订单之后如何结算;
- 是否要创建物流或确认收货流程。
taicang 正是把这部分放在项目 trigger 里,而不是改 oak-pay-business 本体。
新项目最小落地清单
这是最值得直接照抄的部分。一个新项目要接 oak-pay-business,最少应该做下面这些事。
1. 依赖与实体
- 在
dependency.ts里加入oak-pay-business - 让项目的
System继承支付域System - 让项目的
Order继承支付域Order - 如果有账户或提现需求,接入
Account、WithdrawAccount
2. 初始化与上下文
- 在
dependency.ts里声明oak-pay-business,执行project:init、make:domain和make:dep - 创建并注入
payfeature,当前模板由生成的initialize.server.ts处理 - 调用
initializeOpb1Features(...) - 让前后端 RuntimeContext 继承
make:dep生成的 Context;由 pay 的 module 递归带入 general Context
3. 系统配置后台
- 直接挂
payConfig/system - 让运营可以配置:
System.payConfigOfflineAccountWpAccountWpProduct
- 如果需要,再挂
sysAccount/survey
4. 订单支付前台
- 准备一个订单支付方案组件
- 由它生成
pay$order - 父组件执行
order.startPaying - 外部支付统一进入
pay/detail
5. 支付回调
- 项目里暴露自己的 endpoint 路由
- 内部复用
oak-pay-business/utils/pay - 至少接上:
payNotifyrefundNotify
6. 业务 trigger
- 把支付成功后的业务差异逻辑写在项目自己的 trigger 里
- 不要直接修改公共支付域的主状态机
什么时候应该扩展 oak-pay-business
并不是每个项目都要去注册新渠道。
不需要扩展的场景
如果项目只需要:
- 账户余额支付;
- 线下收款码或银行转账;
- 微信支付;
- 标准充值、退款、提现;
那么大多数情况下:
- 公共实体够用;
- 公共
payClazz够用; - 公共前端支付 routine 也够用。
这时项目只需要做接入和业务 trigger,不需要扩展渠道层。
需要扩展的场景
如果项目要接:
- 新支付机构;
- 新的支付产品实体;
- 新的后台支付配置页;
- 新的前端拉起支付流程;
- 新的系统资金账户展示;
才需要用这些扩展点:
registerPayClazz(...)registerFrontendPayRoutine(...)registerPayChannelComponent(...)registerSysAccountCardTopComponent(...)registerSysAccountDetailComponent(...)
推荐的接入顺序
实践里,最稳的顺序通常是:
- 先接实体和初始化
- 再接系统支付配置后台
- 再接订单支付前台
- 再接支付回调
- 最后补业务 trigger 和补偿规则
不要一上来先写页面。只把页面做出来,是最容易形成“能点支付但状态不对、回调不进、资金不平”的假接入。
taicang 最值得复用的经验
最后把最值得照抄的经验直接列出来。
1. 公共支付域和业务规则边界划得很清楚
- 公共支付域负责支付本身;
- 项目 trigger 负责拍卖押金和订单结算。
2. 页面不直接持有支付协议
- 页面只创建
pay$order - 真正支付在
pay/detail里拉起 - 回调在 endpoint 里统一处理
3. 项目扩展点只落在必要位置
- 需要自定义前台支付交互时,包一层本地组件
- 需要业务差异时,写本地 aspect / trigger
- 不去改公共支付域主链路
4. 小程序特殊规则不污染主状态机
像“确认收货后到账”这种微信小程序特性,在支付域里通过 needReceiving、ship 和前端确认收货流程承接,而不是把订单和支付状态机写乱。
最后再强调一次
一个 Oak 项目的支付系统,正确目标不是“把支付接口调通”,而是把下面这五件事一起接完整:
- 资金对象模型
- 状态推进机制
- 支付渠道抽象
- 回调与补偿
- 项目自己的业务后处理
taicang 已经证明,这套方式是能跑通复杂业务的。新项目最不应该做的,就是绕开 oak-pay-business 重新做一套平行支付系统。那样前期看似快,后期几乎一定会在退款、补偿、回调、对账和系统资金上吃大亏。