物流
很多人第一次接 oak-pay-business 时,会把 Ship 当成“订单附带的物流表”。但如果你沿着源码往下读,会发现它的作用远不止展示快递单号:
- 它会决定快递单由哪个物流系统接单
- 它会和微信小程序发货信息录入打通
- 它会影响虚拟充值什么时候真正到账
- 它还会通过 timer 和 aspect 与第三方物流状态持续同步
所以在 oak-pay-business 里,物流不是支付域的边角料,而是一个和支付、充值直接耦合的正式模块。
主要对象
这一章最关键的对象有五个:
ShipShipServiceShipServiceSystemShipCompanyWechatMpShip
其中 Ship 本身在 src/entities/Ship.ts 里定义了完整状态机。
Ship 状态
当前状态包括:
unshippedshippingcancelledreceivedrejectedunknownreceiving
动作包括两类。
主动作
shipreceivecancelrejectunknowstartReceivingsucceedReceiving
扩展动作
syncStatesyncPathssyncAllprint
ShipService / ShipServiceSystem 决定“系统支持哪些物流服务”;WechatMpShip 则是微信小程序发货配置对象,主要服务于微信发货信息录入和确认收货流程。
组件
物流这一章最关键的前端组件有三组。
1. src/components/ship/system/web.pc.tsx
这是系统物流设置页的真实入口。它固定包含两块内容:
- 已注册物流系统设置组件的页签区
shipServiceSystem/list的物流服务配置页签
如果当前项目还没有注册任何物流系统组件,它会直接在页面里提示应该如何调用:
import { registerShipSettingComponent } from '@oak-pay-business/registry.frontend';
import WechatMpShipSetting from '@oak-pay-business/components/ship/wechatMpShip';
registerShipSettingComponent('wechatMpShip', WechatMpShipSetting);
2. src/components/ship/wechatMpShip/index.ts
这是 WechatMpShip 的系统配置组件。它会在当前 systemId 下拉取:
- 所有
type === 'wechatMp'的application
然后用于配置当前系统对应的小程序发货设置。
3. 其它 ship/* 组件
oak-pay-business/src/components/ship 目录下还有物流详情、列表等实体组件;不过真正决定系统如何扩展的,还是上面两个“系统配置入口”。
ship/system 常用参数
系统物流配置页本身项目层最常传的还是两个参数:
oakIdoakPath
taicang/src/pages/console/systemProvider/system/shipConfig/web.pc.tsx 的真实写法就是:
<SystemShipConfig
oakId={systemId}
oakPath={`${oakFullpath}.system`}
/>
它在页面顶部先执行了:
registerShipSettingComponent('wechatMpShip', WechatMpShipSetting);
所以新物流系统要不要出现在后台配置页,核心不是改页面,而是先注册。
wechatMpShip 组件适配要求
oak-pay-business/src/components/ship/wechatMpShip/index.ts 的关键参数其实只有:
systemId
但它内部会额外拉:
- 当前
systemId下所有type === 'wechatMp'的application
所以如果项目层系统里根本没有小程序应用,这个配置页就不会有可选 application。换句话说,WechatMpShip 能不能配,不只取决于物流模块本身,还取决于前面 Application 这一章有没有把小程序应用配好。
shipServiceSystem/list 的真实作用
这一块在系统物流页里很容易被忽略,但它其实决定了“当前系统到底开放了哪些物流服务”。当前组件最关键的参数只有:
systemId
它的真实行为包括:
- 列出当前
systemId下已经启用的shipServiceSystem - 根据
systemId反查系统名称,作为页面展示上下文 - 新增物流服务时,不是查全部
shipService,而是用#sqp: 'not in'排除已经绑定到当前系统的服务 - 创建时会一次性批量写入多条
shipServiceSystem.create
所以这不是一个普通“服务字典列表”,而是“系统与物流服务的挂接关系管理器”。
从项目整合角度看,ship/system 之所以能成立,就是因为它把:
- 物流系统实现配置
- 物流服务与系统的绑定关系
两块同时放到了同一个后台页里。
真实项目里的物流类注册参考
taicang/src/routines/weChatMpShip.ts 虽然当前保留成注释示例,但它恰好把 registerShipClazzEntity(...) 需要补的两类回调写得很完整:
- 如何从订单/应用里取收件人
openId和小程序appWxId - 如何为物流单准备打印、货品、重量、图片等扩展信息
如果项目要接新的物流系统,最好直接参考这种结构,而不是只写一个“下单函数”。
aspect
物流模块当前导出了三个真正会被项目层调用的 aspect。
getMpShipState
定义在 src/aspects/ship.ts。它只在当前应用类型是 wechatMp 时工作,用来读取小程序订单的真实发货状态。
getExpressPrintInfo
同样定义在 src/aspects/ship.ts。它会先校验:
ship.type === 'express'ship.entity/ship.entityId存在ship.extraShipId存在
然后再通过对应的 shipClazz 获取打印面单所需信息。
shipConfirmSuccess
这是物流和充值真正连起来的那个 aspect。当前应用如果是 wechatMp,它会去查询微信侧真实物流状态;如果微信侧已经确认收货,而 Oak 中的 ship 仍停在 receiving,它就会执行:
ship.succeedReceiving
后续再由 trigger 推进充值到账。
后台规则
Ship checker
src/checkers/ship.ts 会约束几类关键动作:
syncPaths/syncState/syncAll只允许在unshipped或shipping状态执行- 非 root 用户手工
ship/receive时,只能操作没有extraShipId的单 print只能对已有外部单号、且尚未发货的快递单执行startReceiving/succeedReceiving只允许虚拟物流,或“支付产品要求确认收货”的订单物流执行
Ship trigger
src/triggers/ship.ts 里有一整条完整的物流自动化链路。
1. 创建时自动选择物流系统
如果创建 ship 时带了 shipOrder$ship,trigger 会先调用 getShipEntity(...),按系统配置里各物流系统的 sort 和 available(...) 结果挑出一个可用物流系统,并把:
entityentityId
自动回填到 ship 上。
2. 创建后自动发货或自动下单
virtual/pickup类型在创建后会自动shipexpress类型在创建后会自动调用外部物流系统下单
这两段都是 when: 'commit' 且 strict: 'makeSure' 的触发器。
3. 发货后自动录入微信小程序发货信息
当 ship 进入 shipping 时,如果它属于:
- 充值单对应的发货
- 或带
wpProduct.needReceiving的订单发货
trigger 会调用微信小程序发货信息录入逻辑。
4. 取消快递单时调用外部取消接口
如果 ship.type === 'express' 且已有 extraShipId,cancel 之后会继续调用物流渠道的取消下单接口。
5. 进入签收确认阶段时发送小程序提醒
startReceiving 之后,如果能拿到收货人的 openId,会调用小程序确认收货提醒。
6. 虚拟收货确认后推进充值到账
当 ship.succeedReceiving 发生在虚拟充值链路上时,trigger 会继续执行:
deposit.succeed
这也是为什么物流这章必须和充值一起理解。
timer 与后台补偿
物流的异步同步主要由 src/timers/ship.ts 负责,而不是靠页面手动刷新。
当前有两类定时任务:
同步快递ship状态同步微信小程序虚拟、自提ship状态
也就是说:
- 快递单状态变化,会定时去第三方物流系统刷新
- 小程序虚拟/自提场景,会定时去刷新微信侧确认收货状态
相反,src/watchers/ship.ts 目前没有启用中的 watcher,实际同步逻辑主要都在 timer。
注入点
物流这一章有两类注入点。
前端设置页注入
通过 registerShipSettingComponent(...) 注入新的系统设置组件。
后端物流类注入
通过 src/utils/shipClazz/index.ts 里的:
registerShipClazzEntity(...)
注册新的物流系统实现。这个注册函数会检查被注册实体是否满足这些字段约束:
sortsystemIddisabled
然后 getShipEntity(...) 会在创建物流单时,从当前系统所有可用物流实体里按 sort 选出最优的那个。
项目中如何接入
1. 先把系统物流配置页接起来
如果项目需要管理后台配置物流,先注册物流系统设置组件,然后在系统详情页里挂 components/ship/system。
2. 后端注册物流类
只有把物流类实现注册进 registerShipClazzEntity(...),自动下单、取消、打印面单、收件人信息获取这些行为才会真正工作。
3. 小程序项目再接 WechatMpShip
如果项目是微信小程序或者涉及微信小程序发货信息录入,就继续补 WechatMpShip 配置组件和对应物流类实现。
真实项目里的整合顺序
把 ship 接进现有项目时,最稳妥的顺序通常是:
- 先注册
registerShipSettingComponent(...) - 再注册
registerShipClazzEntity(...) - 确保系统页已经能配置
ShipServiceSystem - 最后再让订单/充值创建
ship
否则经常会出现“页面上能选物流服务,但后端没有任何可用物流类接单”的空跑情况。
使用示例
1. 注册系统物流设置组件
import { registerShipSettingComponent } from '@oak-pay-business/registry.frontend';
import WechatMpShipSetting from '@oak-pay-business/components/ship/wechatMpShip';
registerShipSettingComponent('wechatMpShip', WechatMpShipSetting);
2. 后端注册物流类
import { registerShipClazzEntity } from '@oak-pay-business/utils/shipClazz';
registerShipClazzEntity(
'myExpressAccount',
async (entityId, context) => new MyExpressClazz(entityId, context),
storageSchema,
);
3. 物流创建时让系统自动挑渠道
await context.operate('ship', {
id: await generateNewIdAsync(),
action: 'create',
data: {
type: 'express',
shipServiceId,
shipOrder$ship: [
{
id: await generateNewIdAsync(),
action: 'create',
data: {
orderId,
},
},
],
},
}, {});
只要系统已经注册了可用物流类,并且当前物流服务可用,entity / entityId 就会在创建前被 trigger 自动补上。
使用建议
物流这块最推荐的思路是:
- 把“哪个物流系统来接单”交给
getShipEntity(...) - 把“怎么发货、取消、查状态、打面单”交给
shipClazz - 把“小程序收货确认后的业务推进”继续交还给
shipConfirmSuccess和shiptrigger
不要把这些逻辑零散写在订单页面、充值页面或者单独的 cron 脚本里。否则最后最难排查的,往往不是物流本身,而是“为什么收货确认后余额还没到账”这种跨模块问题。