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 时,会把 Ship 当成“订单附带的物流表”。但如果你沿着源码往下读,会发现它的作用远不止展示快递单号:

  • 它会决定快递单由哪个物流系统接单
  • 它会和微信小程序发货信息录入打通
  • 它会影响虚拟充值什么时候真正到账
  • 它还会通过 timer 和 aspect 与第三方物流状态持续同步

所以在 oak-pay-business 里,物流不是支付域的边角料,而是一个和支付、充值直接耦合的正式模块。

主要对象

这一章最关键的对象有五个:

  • Ship
  • ShipService
  • ShipServiceSystem
  • ShipCompany
  • WechatMpShip

其中 Ship 本身在 src/entities/Ship.ts 里定义了完整状态机。

Ship 状态

当前状态包括:

  • unshipped
  • shipping
  • cancelled
  • received
  • rejected
  • unknown
  • receiving

动作包括两类。

主动作

  • ship
  • receive
  • cancel
  • reject
  • unknow
  • startReceiving
  • succeedReceiving

扩展动作

  • syncState
  • syncPaths
  • syncAll
  • print

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 常用参数

系统物流配置页本身项目层最常传的还是两个参数:

  • oakId
  • oakPath

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 只允许在 unshippedshipping 状态执行
  • 非 root 用户手工 ship / receive 时,只能操作没有 extraShipId 的单
  • print 只能对已有外部单号、且尚未发货的快递单执行
  • startReceiving / succeedReceiving 只允许虚拟物流,或“支付产品要求确认收货”的订单物流执行

Ship trigger

src/triggers/ship.ts 里有一整条完整的物流自动化链路。

1. 创建时自动选择物流系统

如果创建 ship 时带了 shipOrder$ship,trigger 会先调用 getShipEntity(...),按系统配置里各物流系统的 sortavailable(...) 结果挑出一个可用物流系统,并把:

  • entity
  • entityId

自动回填到 ship 上。

2. 创建后自动发货或自动下单

  • virtual / pickup 类型在创建后会自动 ship
  • express 类型在创建后会自动调用外部物流系统下单

这两段都是 when: 'commit'strict: 'makeSure' 的触发器。

3. 发货后自动录入微信小程序发货信息

ship 进入 shipping 时,如果它属于:

  • 充值单对应的发货
  • 或带 wpProduct.needReceiving 的订单发货

trigger 会调用微信小程序发货信息录入逻辑。

4. 取消快递单时调用外部取消接口

如果 ship.type === 'express' 且已有 extraShipIdcancel 之后会继续调用物流渠道的取消下单接口。

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(...)

注册新的物流系统实现。这个注册函数会检查被注册实体是否满足这些字段约束:

  • sort
  • systemId
  • disabled

然后 getShipEntity(...) 会在创建物流单时,从当前系统所有可用物流实体里按 sort 选出最优的那个。

项目中如何接入

1. 先把系统物流配置页接起来

如果项目需要管理后台配置物流,先注册物流系统设置组件,然后在系统详情页里挂 components/ship/system

2. 后端注册物流类

只有把物流类实现注册进 registerShipClazzEntity(...),自动下单、取消、打印面单、收件人信息获取这些行为才会真正工作。

3. 小程序项目再接 WechatMpShip

如果项目是微信小程序或者涉及微信小程序发货信息录入,就继续补 WechatMpShip 配置组件和对应物流类实现。

真实项目里的整合顺序

ship 接进现有项目时,最稳妥的顺序通常是:

  1. 先注册 registerShipSettingComponent(...)
  2. 再注册 registerShipClazzEntity(...)
  3. 确保系统页已经能配置 ShipServiceSystem
  4. 最后再让订单/充值创建 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 自动补上。

使用建议

物流这块最推荐的思路是:

  1. 把“哪个物流系统来接单”交给 getShipEntity(...)
  2. 把“怎么发货、取消、查状态、打面单”交给 shipClazz
  3. 把“小程序收货确认后的业务推进”继续交还给 shipConfirmSuccessship trigger

不要把这些逻辑零散写在订单页面、充值页面或者单独的 cron 脚本里。否则最后最难排查的,往往不是物流本身,而是“为什么收货确认后余额还没到账”这种跨模块问题。