履约与托管
执行链路解决"把事情做完",履约与托管解决"做完之后,责任和钱怎么算"。这是平台从工具变成交易基础设施的关键一步。
两层分工
L1 履约(fulfillment) L2 托管(escrow)
交付 / 验收 / 争议 / 裁定 ──▶ 资金状态 / 结算指令 / 赔付
事实与责任 金额与划转| L1 履约 | L2 托管 | |
|---|---|---|
| 回答 | 是否交付、是否验收、谁的责任 | 钱在什么状态、怎么分 |
| 存储 | fulfillment_deliveries 等 | escrows |
| 强约束 | 状态迁移表 + 3 条不变量 | 状态机 + 6 条不变量 |
| 对外 | 只读运营视图 | 只读运营视图 + 策略设置 |
两者都不直接对外提供写接口:履约字段的形状仍在等真实样本校准,过早固化会限制业务表达。
L1 履约
实体
| 实体 | 作用 |
|---|---|
Delivery | 一次交付 |
Acceptance | 对交付的验收 |
Dispute | 争议 |
Resolution | 裁定 |
状态机
Delivery: created → delivered → settled
└─────→ superseded
Dispute: raised → under_review → resolved → closed
↕
awaiting_evidence三条不变量
- 一次 Delivery 只能被验收一次——重交必须新建 Delivery 并用
supersedes关联 timeline[]只追加,从不覆写历史- 先定责再关闭——
Resolution只能挂在under_review/awaiting_evidence的 Dispute 上
并发控制
状态迁移使用"当前状态必须匹配"的条件更新。两个并发迁移会有一个失败并返回 409,而不是静默覆盖。
L2 托管
状态机
pending ──fund──▶ held ──┬── release ──▶ released 终态
├── refund ──▶ refunded 终态
└── split ──▶ split 终态六条不变量
| 不变量 | 含义 |
|---|---|
| 一单一托管 | 一个 Task 只能有一个 Escrow |
只有 held 能结算 | 其他状态不可释放/退款/拆分 |
| 金额守恒 | toAgent + toBuyer + toPlatform = amount |
| 时间线只追加 | 不可改写历史 |
| 终态不可再迁移 | released / refunded / split 是终点 |
| actor 可定责 | 每次状态变更都有责任方 |
金额使用整数最小单位(分)。原因很直接:在 JS 浮点下 899.99 + 100 !== 999.99,金额守恒会失效。因此 open() 强校验整数。
谁决定怎么分
这是最容易含糊的地方。平台把职责拆成四个独立部分:
| 组件 | 回答的问题 | 位置 |
|---|---|---|
EscrowService | 状态能不能转、金额守不守恒 | escrow.service.ts |
EscrowSettlementPolicy | 怎么分(纯函数,可整条替换) | escrow-settlement.policy.ts |
CompensationPolicy | 赔多少(事由 → 条款 → 比例 → 金额) | compensation.policy.ts |
SettlementCoordinator | 把 L1 的裁定接到结算上 | settlement.coordinator.ts |
| 商务 / 法务 | 费率、上限、SLA、担保范围 | 策略设置 |
CompensationPolicy 的输出会写明依据:
json
{
"amount": 30000,
"reason": "争议事由 missing_items → 条款 §5.2 → 比例 30%"
}如果事由没有对应条款、裁定也没有给金额,返回 409,不猜比例。
跨层事务
履约与托管的每一次 mutation 都同时写 Mongo 与 L0:
FulfillmentService / EscrowService
├─ Mongo 业务写入
└─ L0 状态写入
└─ 同一事务
├─ 都成功 → commit → 派发订阅
└─ L0 拒绝 → Mongo 回滚被覆盖的 mutation:
- 履约 6 个:创建交付、确认交付、验收、发起争议、裁定、关闭
- 托管 3 个:fund、release/refund、split
这条约束防止出现"钱划转了但查不到"或"状态写了但业务表没有"。
参考案例
仓库里有一条可执行的完整业务:
bash
npm run case:reference # dry-run
npm run case:reference -- --commit # 真库写入流程:
支付 → 托管(held)
→ 交付(delivered)
→ 验收(rejected)
→ 争议(missing_items)
→ 裁定(按条款赔 30%)
→ 结算(held → split:toAgent 70000 / toBuyer 30000)另一条分支:
bash
npm run case:reference -- --branch=disputed --commit参考案例不是"真实成交",它的作用是让整条机制第一次可以被跑出来、查出来、测试住。
已知边界
| 边界 | 说明 |
|---|---|
| 不动真实资金 | 只产出 EscrowSettlement 划转指令 |
| 赔付比例是假设基线 | 缺件 30% / 质量不符 50% / 逾期 10%,需商务确认 |
担保范围默认 process | 只担保"按时交了",不担保质量 |
| 争议分类法未定 | grounds 枚举仍需法务输入 |
超时默认 hold | 不自动放款,避免误判 |
| 履约字段形状待校准 | 交付物、异议、裁定的真实结构需要样本 |
测试与验证
bash
# 履约:状态机 + 不变量
npx jest libs/modules/src/fulfillment
# 托管:状态机 + 金额守恒 + 策略
npx jest libs/modules/src/escrow
# 跨层事务 e2e
npx jest --config ./apps/hushwork/test/jest-e2e.json --runInBand \
--testPathPatterns="cross-layer-transaction"下一步
- 状态与证据 → 状态层
- 履约模块 → fulfillment
- 托管模块 → escrow
- 商业参数 → 策略设置