Files
order_site/docs/打手工单深挖-抢单拼单好友赠送结算-2026-10-23.md
yml2213 31a8b6fc65 Merge branch 'dev1023' into dev
解决 9 个冲突文件,并修复合并后遗留问题:

- 大厅候选集:采用 FIFO 窗口 + 全部置顶单 UNION(列表与抢单/拼单复核同源)
- 拼单份额:采用多次参与模型(每次参与 INSERT 新份额、requestId 幂等),
  取消后可重新参与;移除 HEAD 已废弃的单行 rejoin 逻辑与对应测试
- 工单迁移与成本中心 103–108 编号撞车:合并重写为
  109_work_order_share_participation / 110_work_order_hall_and_shop_indexes;
  临时库全量执行与重跑幂等验证通过(10 列、唯一键已删、7 个索引)
- 实时补丁:保留个性化字段、置顶重排、分页总数同步;补回 orderTimelineVisible 类型
- 打手大厅:保留 memo 组件化,移植「再拼一次」多份额按钮逻辑
- 全项目表格统一 PercentTable:WorkerOrderShareTable 改造

验证:backend typecheck/lint/check:sql/test(833 pass)、
frontend typecheck/lint/test(172 pass)/build 全绿。
2026-10-26 12:10:53 +08:00

468 lines
26 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 打手工单深挖:抢单 / 拼单 / 好友赠送 / 结算(2026-10-23)
> 状态:**梳理稿(待评审,未登记索引)**
> 落笔日期:2026-10-23
> 代码基线:工作区当前 HEAD(迁移 001–110)
> 范围:`work_orders` 从「可抢」到「已验收 + 资金结算」的完整闭环,含拼单份额、好友赠送、超时/问题单/退单申请、售后与欠款
> 不在范围:寄卖提报与匹配算法、订单进单、领取页(见 `项目全流程与数据库设计梳理-2026-10-23.md`)
---
## 一、一页结论
1. **一条工单只有两个资金入口**:接单时冻结押金(`deposit_freeze`),验收时结算报酬 + 释放押金(`reward_settlement` / `deposit_release`,或延迟解冻)。
2. **押金不是「按单」而是「按接单关系的未结押金」**:`getOutstandingDepositAmountWithClient(workerId, workOrderId)` 是唯一口径,同一工单多次接取/退回也只处理当前仍冻结的部分。
3. **拼单的免押额度按「该打手在本工单的累计份额」抵扣一次**,且**已冻结金额不会因为追加份数变少而自动回退**(`Math.max(当前冻结, 新要求)`)。
4. **好友赠送是「三态叠加」**:`work_orders.status`(生命周期)+ `gift_phase`(赠送阶段)+ `gift_available_at`(冷却到点)。`gift_ready` **不落库**,由到点实时计算。
5. **确认加好友是一道资金分水岭**:`friend_added_at` 一旦写入,任何「释放押金」的处置都会被强制改成「扣除押金」,寄卖卖家的发布押金同口径一并没收。
6. **超时、问题单、退单申请三条处置路径共用一套押金策略词表**:`reopen / cancel_release / cancel_deduct`(问题单另有 `return_to_worker`,不碰押金)。
7. **验收有两条免审通道**:内部打手(`worker_type='internal'`)与 VIP(`autoAcceptWithoutEvidence`,可无图提交,但留 24 小时补证据窗口);**好友赠送单一律人工验收**。
8. **退单是唯一会掉等级/封号的常规路径**:VIP1 累计 5 次通过 → 直接禁用账号并吊销会话;其他等级每 10 次通过 → 降一级并把 `level_penalty_accepted_count` 抬高,变相提高升级门槛。
9. **资金全部落在 `worker_wallet_ledgers`**(只追加),但**余额没有「由流水重算」的校验**,也没有会计期间;它仍然是业务钱包,不是总账。
10. **锁序不统一**:抢单先锁钱包再更新工单,指派先锁工单再锁钱包,拼单先锁工单再锁钱包——`grabWorkOrder` 是唯一的「钱包优先」路径。
---
## 二、单据与资金模型
```mermaid
flowchart LR
subgraph 单据
WO["work_orders<br/>生命周期状态"]
WS["work_order_shares<br/>拼单份额"]
EV["work_order_events<br/>只追加时间线"]
end
subgraph 资金
W["worker_wallets<br/>available / frozen_deposit<br/>pending_unfreeze / frozen_withdraw"]
L["worker_wallet_ledgers<br/>只追加流水"]
U["worker_deposit_unfreezes<br/>延迟解冻队列"]
end
WO -->|"整单接取"| D1["deposit_freeze"]
WS -->|"按份额接取"| D1
WO -->|"验收"| D2["reward_settlement / deposit_release"]
WS -->|"份额验收"| D2
D2 -->|"unfreezeDays > 0"| U
U -->|"到期任务"| D3["deposit_unfreeze"]
WO -->|"超时/问题单/退单"| D4["deposit_release 或 deposit_deduction"]
D1 --> L
D2 --> L
D3 --> L
D4 --> L
L --> W
```
**唯一的余额写入方式**(除提现外)都是「`SELECT … FOR UPDATE` 读钱包 → 计算 next → `UPDATE` → `INSERT ledger`」,没有任何一处只更新余额而不写流水。
---
## 三、抢单
### 3.1 入口与校验顺序
`POST /api/v1/worker/hall/orders/:workOrderId/grab` → `grabWorkerHallOrder`
```mermaid
sequenceDiagram
participant W as 打手
participant S as hall-action-service
participant DB as PostgreSQL
W->>S: grab(workOrderId)
S->>S: 会话有效 → 读打手 + 等级权限 + 大厅配置
S->>S: visibleAfterIso = now - visibleDelaySeconds(等级可见延迟)
S->>DB: 读工单
alt workOrder.sharing_enabled
S->>DB: joinWorkOrderShare(takeRemaining=true)
else 整单抢单
S->>DB: countWorkerActiveOrders
S->>S: 校验 maxActiveOrders / 可用余额 ≥ 冻结额
S->>DB: grabWorkOrder(...)(单事务)
S->>DB: work_order_events grabbed + 实时事件
end
```
整单抢单在**事务内**再次做全部校验(防并发),并条件更新:
```sql
UPDATE work_orders
SET status='in_progress', assigned_worker_id=$1, last_assigned_worker_id=$1,
assigned_at=$2, deadline_at=$3,
gift_phase = CASE WHEN acceptance_mode='friend_gift' THEN 'material_required' ELSE '' END,
booster_material_json='{}', friend_added_at=NULL, gift_available_at=NULL,
<CLEAR_DELIVERY_RESIDUE_SQL>, updated_at=$2
WHERE id=$4 AND status='open' AND assigned_worker_id IS NULL
```
`CLEAR_DELIVERY_RESIDUE_SQL` 清掉上一手的 `submitted_at / accepted_at / acceptance_json / draft_acceptance_json / problem_note / worker_note`,但**不动买家资料**(`material.collect`)和寄卖留痕(`consignmentHistory`)。
### 3.2 冻结押金怎么算
```text
整单:freezeAmount = max(0, work_order.required_deposit_amount - depositFreeAmount)
拼单:nextRequired = round(required_deposit_amount × nextQuantity / totalQuantity)
requiredShareDeposit = max(0, nextRequired - depositFreeAmount)
share_deposit = max(已冻结的 share_deposit, requiredShareDeposit) // 不回退
本次冻结 = share_deposit - 已冻结
```
`depositFreeAmount = 等级 permission_json.depositFreeAmount + worker_users.personal_deposit_free_amount`(两者叠加,个人额度是等级之上的追加额度)。
> 注意:`deposit_threshold_amount`(押金门槛)目前只读写/展示,不参与冻结计算;自动生成的工单把 `required_deposit_amount` 与 `deposit_threshold_amount` 都写成 0(见风险 W1)。
### 3.3 大厅可见性
| 规则 | 实现 |
| --- | --- |
| 等级可见延迟 | `permission_json.visibleDelaySeconds`;未配置时按等级键默认:vip1 20s / vip2 15s / vip3 10s / vip4 5s / vip5 0s |
| 大厅总量 | `worker_hall.maxVisibleOrders`(默认 50,上限 500) |
| 分类上限 | `worker_hall.categoryMaxVisibleOrders`(分类 ID → 上限) |
| 拼满隐藏 | `excludeFilledSharing` |
可见延迟**不是拦截**,而是「只把发布时间早于 `now - delay` 的单放进候选集」,抢单时在事务内再校验一次容量,防止延迟期内被绕过。
---
## 四、拼单
### 4.1 数据模型
`work_order_shares`:`UNIQUE(work_order_id, worker_id)`,一份行代表一个打手在该工单的全部份额。
| 列 | 含义 |
| --- | --- |
| `quantity` | 该打手累计份数(同一工单可追加) |
| `unit_reward` / `share_reward` | 单份报酬 / 累计报酬 |
| `share_deposit` | 该打手是否该单的累计冻结额(不是本次增量) |
| `status` | `joined → submitted → accepted / cancelled` |
| `acceptance_json` / `draft_acceptance_json` | 验收资料 / 草稿 |
### 4.2 参与规则
- 普通参与:指定 `quantity`(默认 1),不得超过剩余份数 `sharing_total_quantity - Σ(非 cancelled 份额)`。
- 「立即抢单」:`takeRemaining=true`,在事务锁内**承接全部剩余份数**;如果已有自己的 `joined` 份额,则在此基础上追加,否则报「数量已满」。
- 整单接取者不能再拼单(`work_order_owner_conflict`)。
- 重复参与被唯一键挡住;同一打手多次参与只能走「追加份数」路径。
- 活跃单上限只在**新建份额**时校验(追加不重复计入)。
### 4.3 母单状态收敛
```text
份额 joined → 母单保持 open(可继续参与)
份额 submitted → 仍 open;若「份数已全部分配」且「没有 joined 份额」→ 母单转 pending_acceptance
单份额验收 → 该份额 accepted;若这是最后一个未结算份额 → 母单 accepted 并补跑寄卖钩子
全部取消 → 母单回 open
```
`submitWorkOrderShareAcceptance` 里那句 `WHERE id=$2 AND status='open'` 是关键:只有母单还在 `open` 时才允许被自动推进,避免与后台手动验收并发覆盖。
### 4.4 两种验收姿势
| 方式 | 入口 | 行为 |
| --- | --- | --- |
| 单独验收某份额 | `POST /admin/worker-platform/orders/:id/shares/:shareId/accept` | 只结算该份额:份额报酬 + 份额押金;已结算份额不会在统一验收时重复结算 |
| 统一验收母单 | `POST /admin/worker-platform/orders/:id/accept` | 先检查所有份额都已 `submitted`(有任一 `joined` 直接 409 `sharing_pending_submissions`),再逐个结算未结算份额,最后结算整单打手(如有) |
> 设计上的一个细节:统一验收时 `settledShareCount` 用「`status !== 'accepted'`」统计,已经单独验收过的份额会被跳过——这是避免重复发钱的唯一保护,没有幂等键。
### 4.5 已提交份额不能绕过
问题单的 `reopen / cancel_*` 与超时处置都会先检查 `getActiveWorkOrderShareCountWithClient`;有活跃份额就拒绝整单处置(`work_order_has_active_shares`)。这是为了防止「母单被整单带走,份额验收入口全部锁死」。
---
## 五、好友赠送(friend_gift)
### 5.1 三态叠加
```mermaid
stateDiagram-v2
[*] --> material_required: 建单时 acceptance_mode=friend_gift
material_required --> material_submitted: 打手提交本人资料(昵称/ID/区服/系统 + 主页截图)
material_submitted --> friend_countdown: 后台确认已加好友(记 friend_added_at,算 gift_available_at)
friend_countdown --> gift_ready: 到点(不落库,实时计算)
gift_ready --> pending_acceptance: 打手提交赠送凭证
pending_acceptance --> accepted: 后台人工验收(必审)
material_submitted --> material_required: 后台重置资料
friend_countdown --> material_required: 后台重置资料
```
- `work_orders.status` 在整条链路上保持 `in_progress`(或 `problem`),**不因赠送阶段改变**;前端展示用 `resolveWorkOrderGiftPhase()`。
- 冷却时长 = `gift_cooldown_hours` 小时 + `gift_cooldown_minutes` 分钟;两者都为 0 时按默认 **72 小时** 兜底(`DEFAULT_GIFT_COOLDOWN_HOURS`),上限 30 天。
- 确认加好友时,`deadline_at` 被顺延为「冷却结束 + `timeout_minutes`」,即**冷却期不计入打手超时**。
- 提交验收时如果 `gift_phase !== friend_countdown` 或未到点,直接 409 `work_order_gift_cooldown_active`。
### 5.2 押金分水岭
```text
friend_added_at IS NULL → 取消/退回/超时:按策略正常退还
friend_added_at 有值 → 取消/退回/超时:释放类一律转为扣除(deposit_deduction)
寄卖卖家的发布押金同口径没收
```
实现集中在 `isWorkOrderGiftDepositForfeitable()`,被超时处置、问题单处置、退单审核三处共用。语义是:**好友已经加了,赠送动作由打手/卖家完成,此时脱离订单属于套利**。
### 5.3 内部现货变体(寄卖)
寄卖库存绑定工单走 `internal_stock` 时没有接单打手,流程被压缩:
```text
assigned_worker_id = null,material.consignment.fulfillmentType = internal_stock
→ parkWorkOrderWaitingFriendAdd:status=in_progress,gift_phase=material_required(语义变成「待加好友」)
→ 客服线下通知买家加好友 → 手动确认 → 进入冷却
→ 冷却结束:后台可直接验收(无需打手交图)
```
`hasInternalConsignmentFulfillment()` 是识别标志:`assigned_worker_id=0` + `material.consignment.inventoryId` + 非 `worker_deposit` + 无 `consignment.workerId`。
---
## 六、验收
### 6.1 打手提交(整单)
| 规则 | 细节 |
| --- | --- |
| 归属 | 必须是 `assigned_worker_id` 本人(寄卖卖家走 `asConsignmentSupplier` 例外) |
| 状态 | `in_progress` / `problem` |
| 超时前置 | 已过期则先执行超时处置再抛 409(接口惰性判定) |
| 图片 | 默认必须至少 1 张(`work_order_acceptance_image_required`);VIP/内部打手可不交图 |
| 赠送单 | 必须 `friend_countdown` 且已到点;**一律人工验收**,VIP/内部免审不适用 |
| 草稿 | 可先存 `draft_acceptance_json`,提交时优先取「本次上传 + 草稿里已有的图」并去重 |
免审条件:`worker_type === 'internal'` **或** 等级 `permission_json.autoAcceptWithoutEvidence === true`(且不是拼单份额参与,`canWorkerAutoAcceptWholeOrder` 明确排除拼单)。
免审且无图时,验收资料写入:
```json
{ "reviewMode": "vip_auto", "evidenceStatus": "pending", "evidenceDueAt": "<now+24h>" }
```
补证据走 `supplementWorkerAcceptedOrderEvidence`(验收后仍可补)。
### 6.2 催促
- 提交满 **10 分钟** 才能催;
- 每 **30 分钟** 一次,最多 **3 次**;
- 只支持整单(拼单份额不能催);
- 催的是 `admin_notifications` 的同一个待办(`reminder_count` 累加),不会刷屏。
### 6.3 后台验收
- 只接受 `pending_acceptance`(`capabilities.canAccept`)。
- 历史拼单母单如果还是 `open`,验收前自动推成 `pending_acceptance`。
- 内部现货好友赠送在 `gift_ready` 且 `in_progress` 时,后台可直接验收(自动补 `submitted_at`)。
- 有未提交份额 → 409 `work_order_sharing_pending_submissions`。
---
## 七、结算(核心)
### 7.1 一个事务里做完 8 件事
`acceptWorkOrderAndSettle`(整单)与 `acceptWorkOrderShareAndSettle`(份额)结构一致:
```mermaid
flowchart TD
A["锁工单(pending_acceptance)+ 锁全部非取消份额"] --> B{"还有 joined 份额?"}
B -->|是| X["409 sharing_pending_submissions"]
B -->|否| C["工单置 accepted / 份额置 accepted"]
C --> D["释放寄卖发布押金(仅整单,按 material.consignment.sourceSubmissionId)"]
D --> E["逐份额结算:售后欠款抵扣 → 报酬 → 押金"]
E --> F["整单打手结算:押金 → 报酬 → 欠款抵扣"]
F --> G["寄卖供货货款(internal_stock 时补给卖家)"]
G --> H["等级提升判定"]
H --> I["写 work_order_events accepted"]
```
### 7.2 资金等式(单次结算)
```text
releaseAmount = min(该工单未结押金, 钱包 frozen_deposit_amount)
debtOffset = min(售后未结欠款, 本次报酬) // FIFO,按欠款创建时间
shouldDelay = rewardUnfreezeEnabled && unfreezeDays > 0
pendingDelta = (shouldDelay ? 报酬 : 0) + (unfreezeDays > 0 ? 押金 : 0)
available' = available + (shouldDelay ? 0 : 报酬) + (unfreezeDays > 0 ? 0 : 押金) − debtOffset
frozen' = max(0, frozen − releaseAmount)
total_settled += 报酬
```
默认财务配置(`worker_finance` 配置键):
| 项 | 默认 | 范围 |
| --- | --- | --- |
| `depositUnfreezeDays` | 3 天 | 0–30 |
| `rewardUnfreezeEnabled` | 开启 | 布尔 |
| `consignmentPublishDepositFen` | 2000 分(¥20) | 0–1,000,000 |
| `consignmentWhitelistEnabled` | 开启 | 布尔 |
### 7.3 流水类型对照
| 场景 | ledger_type | 金额符号 |
| --- | --- | --- |
| 接单冻结 | `deposit_freeze` | − |
| 验收即释放押金 | `deposit_release` | + |
| 验收延迟解冻(入队) | `deposit_pending_unfreeze` | +(余额不加,进 pending_unfreeze) |
| 到期解冻 | `deposit_unfreeze` | + |
| 验收即结算报酬 | `reward_settlement` | + |
| 验收延迟解冻报酬 | `reward_pending_unfreeze` | + |
| 拼单报酬 | `sharing_reward` | + |
| 售后抵扣 | `after_sales_debt_offset` | − |
| 售后从可用余额扣 | `after_sales_balance_deduction` | − |
| 超时/退单/问题单扣押金 | `deposit_deduction` | − |
| 提现冻结 / 支付 / 解冻 | `withdraw_freeze` / `withdraw_paid` / `withdraw_release` | − / − / + |
| 后台手动加款 | `manual_credit` | + |
### 7.4 延迟解冻
- 入队:`worker_deposit_unfreezes(worker_id, work_order_id, amount, fund_type='deposit'|'reward', unfreeze_at, status='pending')`。
- 出队:定时任务 `deposit-unfreeze`(60s,scanLimit 100)→ `settleDueDepositUnfreezes`。
- **到期判定按「当前配置天数」回溯**:管理员把 `depositUnfreezeDays` 调小后,存量队列会提前视为到期(`retroactiveDays`),不会卡在旧到期时间。
- 提现前后台会先跑一次 `settleDueDepositUnfreezes({workerId})`,避免「到期了但还没解冻、余额显示不足」。
### 7.5 售后抵扣顺序
```text
验收报酬 → 先抵扣 open 欠款(FIFO)→ 抵扣部分写 after_sales_debt_offset
→ 欠款结清时 cases.debt_amount 归零、status 从 recovery_pending 变 closed
→ 报酬不足抵扣的部分继续留在 worker_after_sales_debts.outstanding
```
### 7.6 等级提升 / 降级
| 方向 | 触发 | 规则 |
| --- | --- | --- |
| 升 | 每次验收后 `maybeUpgradeWorkerLevelWithClient` | `有效验收数 = 累计已验收单数 − level_penalty_accepted_count`;取 `upgradeThreshold ≤ 有效数` 的最高等级;`is_level_locked` 时完全不动;只升不降 |
| 降 | 退单申请审核通过 | 非 VIP1:每 **10 次**通过降一级,并把 `level_penalty_accepted_count` 抬到「实际验收数 − 新等级门槛」,提高重新升级成本 |
| 封 | 退单申请审核通过 | VIP1 累计 **5 次**通过 → `status=disabled`、`session_version+1`、吊销全部会话 |
`worker_level_logs.reason` 取值:`auto` / `cancel_request_penalty` / `cancel_request_ban`。
### 7.7 寄卖钩子
| 钩子 | 时机 | 作用 |
| --- | --- | --- |
| `releaseConsignmentPublishDepositBySubmissionWithClient` | 整单验收 | 释放卖家发布押金 |
| `settleConsignmentSupplierPayoutByWorkOrderWithClient` | 验收后 | 内部现货履约时订单没有接单打手,货款补给供货卖家;接单人就是卖家本人时不重复付款 |
| `releaseConsignmentInventoryBindingForWorkOrderWithClient` | 超时/退单/问题单/取消 | 把占用的现货放回在库池,避免永久 `consumed` |
---
## 八、三条处置路径的押金矩阵
| 处置 | 入口 | 目标状态 | `reopen` | `cancel_release` | `cancel_deduct` | `return_to_worker` |
| --- | --- | --- | --- | --- | --- | --- |
| 超时 | 定时任务 / 接口惰性 | open / cancelled | 退押金 | 退押金 | 扣押金 | — |
| 问题单 | 后台 `problem/resolve` | open / cancelled / 恢复 | 退押金 | 退押金 | 扣押金 | **不动押金**,恢复到 `marked_problem` 的 `from_status` |
| 退单申请 | 后台审核通过 | open(重新上架) | — | 退押金 | — | — |
补充规则:
- 三种路径都先检查「是否还有活跃份额」;有则拒绝整单处置。
- **已确认加好友的赠送单**:`cancel_release` 会被改写成 `cancel_deduct`,事件类型也按实际资金动作记(`timeout_cancel_deduct` / `problem_cancel_deduct`)。
- 超时与问题单会释放寄卖现货绑定;`return_to_worker` 不会(单还在进行)。
- 退单申请:待审核期间订单与押金保持原状;审核通过**在同一事务**里撤单、退押金、执行等级处罚。
- 打手每天最多提 **5 次**退单申请(按 Asia/Shanghai 自然日,`pending + approved` 计数),同一工单+份额不能有两条 pending。
- 拼单份额超时只在母单 `open` 时生效(份额 `joined` 且 `created_at + timeout_minutes` 已过),否则跳过。
---
## 九、售后与欠款
### 9.1 生命周期
```mermaid
stateDiagram-v2
[*] --> awaiting_worker_response: 后台建单(必须已验收)
awaiting_worker_response --> under_review: 打手提交说明 + 证据
awaiting_worker_response --> under_review: 后台直接处置
under_review --> closed: not_upheld(不成立)
under_review --> closed: remedied(已补救)
under_review --> recovery_pending: upheld 且追缴 > 报酬/可扣押金 → 产生欠款
under_review --> closed: upheld 且全额扣到
recovery_pending --> closed: 欠款结清(验收抵扣 / 充值抵扣 / 后台代还)
```
### 9.2 建单约束
| 约束 | 说明 |
| --- | --- |
| 工单必须 `accepted` | `work_order_not_accepted` |
| 指定份额时必须已验收 | `work_order_share_not_accepted` |
| 必须能定位责任打手 | `worker_missing`;内部现货寄卖单没有接单打手时,自动反查供货卖家(`getConsignmentSupplierByWorkOrderWithClient`)作为责任人 |
| 同一「工单 + 打手 + 份额」同时只能有 1 条进行中 case | 由部分唯一索引 `idx_worker_after_sales_cases_active_target` 保证:`UNIQUE(work_order_id, worker_id, COALESCE(work_order_share_id,0)) WHERE status IN ('awaiting_worker_response','under_review','recovery_pending')`,插入用 `ON CONFLICT DO NOTHING`,冲突即 `active_case_exists` |
| 投诉说明必填、≤ 2000 字 | |
| 问题类型默认 `fake_evidence` | |
### 9.3 追缴顺序
```text
1. 该工单尚未解冻的押金(worker_deposit_unfreezes pending, fund_type='deposit')
→ 走 deductPendingDepositUnfreeze,直接从队列扣,不进可用余额
2. 可用余额(available_amount)
3. 仍不足 → 写入 worker_after_sales_debts(status='open', outstanding_amount)
→ case.status = recovery_pending
```
后续抵扣:
- 该打手下一次验收报酬 → 自动 FIFO 抵扣(`offsetWorkerAfterSalesDebtsWithClient`);
- 充值入账 → `repayWorkerAfterSalesDebtsFromAvailableWithClient`;
- 后台手动代还 → `repayAfterSalesCaseDebt`。
每一笔抵扣都会写 `worker_after_sales_case_events`(`debt_offset` / `debt_settled`)+ 对应钱包流水,两边都有痕迹。
---
## 十、发现的隐患(按资金风险排序)
| # | 隐患 | 证据 | 后果 |
| --- | --- | --- | --- |
| W1 | 自动建单硬编码 `required_deposit_amount=0`、`deposit_threshold_amount=0` | `sync-worker-orders-service.ts` | 自动单永远无押金约束,模板里的押金配置是死配置 |
| W2 | 抢单先锁钱包、指派/拼单先锁工单 | `work-order-grab-repo.ts` vs 同文件 `assignWorkOrderToWorker` / `work-order-share-repo.ts` | 并发抢单+指派可 40P01 死锁;目前只靠客户端重试 |
| W3 | 统一验收的「已结算份额」判断只看 `status !== 'accepted'`,没有幂等键 | `acceptWorkOrderAndSettle` | 极端并发下(两次验收同时进入)可能重复发报酬;`FOR UPDATE` 锁工单能挡住大部分,但没有业务幂等 |
| W4 | 发单金额 > 用户付款只在前端提示 | `publishAdminWorkOrder` 只校验 `reward_amount > 0` | 直接调 API 可发亏损单;`source_order_amount_fen` 只是记录,不参与校验 |
| W5 | 发布工单时电子凭证核销失败只写事件,不回滚发布 | `publishAdminWorkOrder` 的 `voucherConsume.ok=false` 分支 | 券没核销但工单已在大厅;指派路径却会拦截,同一规则两套行为 |
| W6 | 钱包余额没有「由流水重算」校验,也没有月度关账 | `worker_wallets` 可变字段 | 任何一次漏写/重复写流水都不会被系统发现,只能人工对账 |
| W7 | `work_orders.task_id` 是历史遗留列(迁移 091 已注释废弃) | 仍参与部分 mapper | 新人容易误以为「工单必须挂履约任务」 |
---
## 十一、两笔完整流水示例
> 先记一条硬约束:**好友赠送与拼单互斥**。建单时 `sharingEnabled = isFriendGiftAcceptanceMode(rule.acceptance_mode) ? false : ...`,所以不存在「好友赠送 + 拼单份额」的单子。
### 11.1 拼单单
设定:`sharing_total_quantity=3`、`unit_reward=3000 分`、`required_deposit_amount=6000 分`;打手 A 等级免押 1000 分、活跃上限 3;`depositUnfreezeDays=3`、`rewardUnfreezeEnabled=true`。
| 步骤 | 动作 | 钱包变化 | 关键字段/流水 |
| --- | --- | --- | --- |
| 1 | A 参与 2 份 | nextRequired = 6000×2/3 = 4000;requiredShare = 4000−1000 = 3000;冻结 3000 | `work_order_shares(quantity=2, share_deposit=3000)`,`deposit_freeze −3000` |
| 2 | A 追加 1 份 | nextRequired = 6000;requiredShare = 5000;本次冻结 2000 | `quantity=3, share_deposit=5000`,`deposit_freeze −2000` |
| 3 | 提交验收 | 份数已满且无 `joined` 份额 → 母单 `open → pending_acceptance` | `share.status=submitted` |
| 4 | 验收该份额 | 报酬 9000 延迟解冻;押金 5000 延迟解冻 | `pending_unfreeze +14000`,`reward_pending_unfreeze +9000`,`deposit_pending_unfreeze +5000` |
| 5 | 3 天后任务 | 到期释放 | `deposit_unfreeze +14000`,`pending_unfreeze −14000` |
### 11.2 好友赠送单
设定:`acceptance_mode=friend_gift`、`required_deposit_amount=5000`、免押 0、冷却 72h、`timeout_minutes=120`。
| 步骤 | 动作 | 结果 |
| --- | --- | --- |
| 1 | 打手抢单 | 冻结 5000(`deposit_freeze`);`gift_phase=material_required`;`deadline_at=now+2h` |
| 2 | 提交本人资料 | `gift_phase=material_submitted`;`friend_added_at=NULL` |
| 3 | 客服确认加好友 | `friend_added_at=now`;`gift_available_at=now+72h`;`deadline_at=now+72h+2h`(冷却不计超时) |
| 4 | 冷却中尝试提交 | 409 `work_order_gift_cooldown_active` |
| 5 | 到点后提交赠送凭证 | `status=pending_acceptance`;**不触发免审**(赠送单强制人工) |
| 6 | 客服验收 | 押金 5000 → 延迟解冻;报酬按 `reward_amount` 入账(同样按 `rewardUnfreezeEnabled` 决定是否延迟) |
| 7′ | 若第 3 步之后打手退单 | 押金从「应释放」改为 `deposit_deduction −5000`;寄卖发布押金同口径没收 |
---
## 十二、待确认问题(等你回答后我再补口径)
1. **押金策略的业务意图**:`required_deposit_amount` 现在是「按单金额」还是「按 SKU 单价 × 份数」?自动单为什么固定为 0——是刻意豁免,还是历史遗留?
2. **延迟解冻的初衷**:`depositUnfreezeDays=3` + `rewardUnfreezeEnabled=true` 是为了防售后,还是为了压打手现金流?这决定售后追缴是否应该优先扣「待解冻队列」而不是可用余额。
3. **VIP1 五次退单封号**这条规则现在还在执行吗?线上是否有误封申诉流程(代码里只有 `review_note`)?
4. **好友赠送冷却 72h** 是否要按品类差异化?现在只有模板上的 `gift_cooldown_hours/minutes` 两个字段。
5. **拼单是否允许「一人多份 + 全部单人完成」**?当前逻辑允许,且验收后一次性结算全部份额——如果业务上要求分散风险,这条需要改。