Files
order_site/docs/工单拼单多次参与改造方案-2026-10-23.md
T
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

29 KiB
Raw Blame History

拼单多次参与改造方案(同一打手可多次参与、每份独立计时)

状态:方案(待确认,未执行) 日期:2026-10-23 代码基线:工作区当前 HEAD(迁移 001–110) 目标:同一打手对同一拼单工单可重复参与,每次参与新增一条 work_order_shares;每份固定自己的截止时间;超时/验收/撤单按份额独立。


一、结论速览

已确认的七项口径(2026-10-23)

决策 确认结果 落地要点
D1 免押额度 跟着母单走(工单维度累计,等价方案 B) 免押额度在该工单只抵扣一次:累计需求 − 免押额度 − 本工单已冻结,只有差额进入本份 share_deposit
D2 活跃单计数 按不同工单数 同一工单拼 3 份只占 1 个名额;改为 COUNT(DISTINCT work_order_id)
D3 撤单粒度 整单 + 单份额都要支持 不传 shareId = 撤销该打手在该工单的全部有效份额;传 shareId = 只撤这一份
D4 验收方式 全部验收 + 单独验收都要有 统一验收 = 逐份额结算的批处理;单独验收按 shareId
D5 我的订单列表 一行一工单 + 展开份额 列表不拆行,工单行内嵌 myShares[],每份独立展示与操作
D6 防误触 客户端 requestId 幂等 份额表加 request_id + 唯一索引;重复请求返回原份额
D7 验收通知 拼单按 shareId 去重;统一验收也要通知 每份提交各一条待办;统一验收完成再发一条整单完成通知
项 结论
能不能做 能,改造集中在工单模块,不触及订单/履约/凭证链路
最大阻塞 work_order_shares 上的 UNIQUE(work_order_id, worker_id)(迁移 008),以及 7 处 getWorkOrderShare(orderId, workerId) 假设「一人一条」
需要新增的列 deadline_at(本份截止)、timeout_minutes / timeout_policy(参与时快照)、cancelled_reason
需要新增的索引 份额到期扫描索引、打手份额列表索引;删除唯一键
影响接口 打手端 5 个(参与、提交验收、草稿、补证据、退单申请)+ 返回结构 myShare → myShares
影响前端 大厅「已拼」按钮逻辑、我的订单列表/详情、验收弹窗、后台工单详情份额表
数据迁移 只加列 + 回填 + 删唯一键,不改历史行;历史合并份额保持原样
工作量估算 后端 2 人日、前端 2 人日、测试 1 人日(不含评审与联调)
需要你拍板 押金/免押分摊方式、活跃单计数口径、退单申请粒度等 7 项(见第十三节)

二、现状:四个硬阻塞

# 阻塞 位置 影响
B1 UNIQUE(work_order_id, worker_id) 迁移 008_work_order_sharing.sql:32,约束名 work_order_shares_work_order_id_worker_id_key 第二次参与直接违反唯一键
B2 「按工单 + 打手」只取一条份额 getWorkOrderShare(workOrderId, workerId),被 7 处调用:验收提交、补证据、草稿、流转记录、备注、已读、我的订单 多份额后行为不确定(当前 LIMIT 1 取任意一条)
B3 超时时间由「母单当前配置」实时算 listOverdueWorkOrderShares:wos.created_at + wo.timeout_minutes;submitWorkerSharingAcceptance 同口径 母单改时限会追溯影响已参与份额;且不是「参与时固化」
B4 前端单值模型 myShare 在 worker-order-utils.tsx、WorkerHallPage、WorkerOrderMobileList、WorkerOrderTableColumns、types/worker-platform.ts 共 30+ 处 大厅按钮 `disabled={…

补充事实:现有 41,383 条份额里,同一 (work_order_id, worker_id) 只有一条(唯一键保证),所以删键不会产生历史冲突。


三、目标语义(改造后的不变式)

  1. 一次参与 = 一条 work_order_shares 记录,quantity 表示「本次参与份数」(默认 1,「立即抢单」= 承接全部剩余)。
  2. 份额截止时间在参与瞬间固化:deadline_at = 参与时间 + 母单当时的 timeout_minutes;母单之后改配置不影响已参与份额(timeout_minutes = 0 时 deadline_at = NULL,不限时)。
  3. 超时只处置该份额:份额 joined → cancelled(按快照 timeout_policy 决定押金释放/扣除),同工单其他份额状态不变。
  4. 验收只针对该份额:joined → submitted(打手提交)、submitted → accepted(后台验收 + 结算);同工单其他份额不受影响。
  5. 撤单只针对该份额:joined/submitted → cancelled,只退/扣该份额的 share_deposit。
  6. 母单状态只由份额集合推导(见第七节),不再由单个份额操作直接改写。
  7. 押金按份额落账:share_deposit 是该份额实际冻结的增量金额,释放/扣除都只针对它;同一打手在同一工单的多条份额互不覆盖。

四、数据库方案(迁移 109_work_order_share_participation.sql)

4.1 DDL(最终实现)

原「多次参与 / 份额级问题单 / 撤销原因 / 撤销份数」四个增量迁移已合并重写为一个文件: 109_work_order_share_participation.sql。

迁移内容:

  1. 新增列:deadline_at / timeout_minutes / timeout_policy / cancelled_reason / request_id / problem_note / problem_marked_at / problem_marked_by / cancel_note / cancel_quantity;
  2. 回填历史份额:按参与时母单配置一次性固化截止时间与超时策略(只碰 timeout_minutes = 0 的历史行);
  3. 删除 (work_order_id, worker_id) 唯一约束:取消的历史份额保留追溯,重新参与直接新增一条;
  4. 索引:到期扫描(joined + deadline_at)、打手份额列表、母单状态推导、request_id 幂等唯一索引、份额级问题单探测。

项目约定:迁移必须幂等、不改历史文件;因此所有列/索引都用 IF NOT EXISTS,删约束用 DO 块判存在。

大厅置顶索引与后台店铺筛选索引合并到 110_work_order_hall_and_shop_indexes.sql。

4.2 迁移前的数据体检(只读)

-- 1) 同一打手是否存在重复份额(预期 0)
SELECT work_order_id, worker_id, COUNT(*)
FROM work_order_shares GROUP BY 1,2 HAVING COUNT(*) > 1;

-- 2) 有无份额缺少母单(外键保证应为 0)
SELECT COUNT(*) FROM work_order_shares s
LEFT JOIN work_orders w ON w.id = s.work_order_id WHERE w.id IS NULL;

-- 3) 已加入但母单已终态的脏数据
SELECT s.status, w.status, COUNT(*) FROM work_order_shares s
JOIN work_orders w ON w.id = s.work_order_id
GROUP BY 1,2 ORDER BY 3 DESC;

4.3 为什么不动 quantity 语义

quantity 仍然是「本次参与份数」。历史行的一条记录可能代表多份(旧逻辑合并),新行默认 1 份,二者在结算公式 share_reward = quantity × unit_reward 下完全兼容,不需要拆历史行。拆行会重写历史资金归属,风险大于收益。


五、押金与免押额度(需要你选择)

现状:resolveWorkOrderShareDepositPlan(原 resolveWorkOrderShareDepositAmount 的重写)按「该打手在本工单累计份额」计算应冻结总额,并按已冻结合计只补差额(不回退)。拆成多行后,必须明确免押额度怎么分摊,否则可被拆分套利。

方案 规则 优点 缺点
A 每份独立全额免押 每份 max(0, round(required × q/total) − free) 实现最简单 可套利:免押 5000、每份需求 1000 时可无限拆成 0 押金份额
B 工单累计口径(已确认) 先算本工单累计需求 round(required × 累计份数/total) − 免押额度,再减去该打手在该工单已冻结合计,差额即本份 share_deposit(不为负) 经济语义与现在完全一致;不会因拆分多冻结或多免押 需要一次 SUM 查询;「已冻结不回退」的既有特性保留
C 按份数比例分摊免押 freePerUnit = floor(free / total),每份 max(0, round(required×q/total) − freePerUnit×q),尾差归最后一份 每份可独立解释 需要记录「免押已分摊额度」,多一张表或加列;尾差处理易错

已确认采用 B(免押额度跟着母单走):当前线上语义就是「累计抵扣、只补差额」,B 不改变任何已验证的金额结果;A 会出现新的套利路径;C 引入尾差与额外状态。

5.1 计算式(定稿)

// 本工单该打手已参与份数(含本次,排除 cancelled)
cumQty        = Σ quantity(该 worker 在该 order 的非 cancelled 份额) + 本次 qty
cumRequired   = round(required_deposit_amount × cumQty / sharing_total_quantity)
cumFree       = 等级 depositFreeAmount + 个人 personal_deposit_free_amount   // 母单维度只用一次
cumDeposit    = max(0, cumRequired − cumFree)
frozenSoFar   = Σ share_deposit(该 worker 在该 order 的非 cancelled 份额)
本份冻结       = max(0, cumDeposit − frozenSoFar)
本份 share_deposit = 本份冻结
  • 免押额度在同一母单内累计只抵扣一次,不会因为拆成多份而重复使用。
  • frozenSoFar 用 FOR UPDATE 读钱包同一事务内求和,天然串行。
  • 释放/扣除只针对该条 share_deposit,不影响同工单其他份额。

注意:无论选哪个,share_deposit 必须记录该份额实际冻结的增量,释放/扣除只针对它,避免一次取消把同工单其他份额的押金也解冻。


六、计时与超时独立化

6.1 参与时固化

timeoutMinutes = work_orders.timeout_minutes(参与瞬间读取)
deadline_at    = timeoutMinutes > 0 ? now + timeoutMinutes 分钟 : NULL
timeout_policy = work_orders.timeout_policy(参与瞬间快照)

落在 INSERT work_order_shares 里,与冻结押金同一事务。

6.2 超时扫描改为按份额

listOverdueWorkOrderShares 改为:

SELECT wos.id, wos.work_order_id, wos.worker_id, wos.quantity, wos.share_deposit,
       wos.timeout_minutes, wos.timeout_policy, wos.created_at
FROM work_order_shares wos
JOIN work_orders wo ON wo.id = wos.work_order_id
WHERE wos.status = 'joined'
  AND wos.deadline_at IS NOT NULL
  AND wos.deadline_at < NOW()
  AND wo.status NOT IN ('accepted', 'cancelled')
ORDER BY wos.deadline_at ASC
LIMIT $1

变化点:不再读母单 timeout_minutes(改读份额快照)、不再要求母单必须 open(避免母单被误置 pending_acceptance 后份额卡死)。

6.3 处置只影响该份额

settleOverdueWorkOrderShare 保留现有押金分支(release / deduct),只改两处:

  1. 策略从 share.timeout_policy 取,不再从母单取;
  2. 结尾把「无条件 UPDATE work_orders SET status='open'」替换为 recomputeWorkOrderSharingStatusWithClient(见第七节)。

七、母单状态收敛(新增统一函数)

现状问题:settleOverdueWorkOrderShare 结尾会把母单强行置 open 并重置 hall_queued_at,多份额下会把「其他份额都已提交、本该待验收」的母单踢回大厅。

7.1 新函数

recomputeWorkOrderSharingStatusWithClient(client, workOrderId, now) -> WorkOrderRow

allocated = Σ quantity(status != 'cancelled')
joined    = COUNT(status = 'joined')
submitted = COUNT(status = 'submitted')
accepted  = COUNT(status = 'accepted')
total     = sharing_total_quantity

if allocated >= total && joined == 0 && submitted > 0   → pending_acceptance
else if allocated < total                               → open(若当前是 pending_acceptance 则回退 open)
else if allocated >= total && joined > 0                → open(还有份额未提交)
else if allocated == 0                                  → open(全部取消,重新可参与)
母单为 accepted / cancelled 时不动

7.2 所有调用点

触发 调用
份额参与 open(保持)
份额提交验收 recompute(替换现有的内联判断)
份额验收 recompute(最后一个份额验收后母单 accepted)
份额超时 recompute
后台撤单份额 recompute
打手退单申请通过 recompute
统一验收 recompute(等价于全部 accepted → 母单 accepted)

八、接口变更

8.1 打手端

方法 路径 变化
POST /api/v1/worker/hall/orders/:workOrderId/join-sharing body quantity 语义改为「本次份数」;新增 requestId(幂等);不再拒绝已参与过的打手
POST /api/v1/worker/hall/orders/:workOrderId/grab 拼单工单:takeRemaining=true 创建新份额(承接全部剩余);同样支持 requestId
POST /api/v1/worker/orders/:workOrderId/submit-acceptance body 新增可选 shareId;不传且该工单存在多条有效份额 → 400 work_order_share_id_required
POST /api/v1/worker/orders/:workOrderId/acceptance-draft 同上
POST /api/v1/worker/orders/:workOrderId/acceptance-evidence 同上
POST /api/v1/worker/orders/:workOrderId/cancel-requests body 可选 shareId:传 = 只申请撤该份额;不传 = 申请撤销我在该工单的全部有效份额
GET /api/v1/worker/orders/:workOrderId 返回新增 myShares[];myShare 保留为「最新一条未完成,否则最新一条」
GET /api/v1/worker/orders 列表项新增 myShares[](一行一工单,见 D5)

兼容策略:所有新增 shareId 都是可选的;只有「该工单该打手有多条有效份额」时才强制。老前端不会立刻坏。

8.2 后台端

已有按份额的接口,无需新增:

  • POST /admin/worker-platform/orders/:id/shares/:shareId/accept
  • POST /admin/worker-platform/orders/:id/shares/:shareId/cancel
  • GET /admin/worker-platform/orders/:id/sharing 已返回 shares[](新增 deadlineAt、timeoutMinutes、cancelledReason)

需要调整的只是展示层:sharingWorkers 目前把同一打手的多份额合并成一行(mapWorkOrderSharingWorkers 累加 quantity),建议保留汇总但新增 sharingShares 明细分页,否则后台无法逐份撤单。


九、后端改动清单(文件级)

文件 改动
db/migrations/109_work_order_share_participation.sql 新增(10 列 + 回填 + 删唯一键 + 5 个索引,合并多次参与/份额问题/撤销原因/撤销份数)
db/migrations/110_work_order_hall_and_shop_indexes.sql 新增(大厅置顶候选集索引 + 后台店铺筛选表达式索引)
repositories/worker-platform/types.ts WorkOrderShareRow 加 10 个字段
repositories/worker-platform/work-order-share-repo.ts 核心:joinWorkOrderShare 去掉「合并到 existingShare」分支,改为每次都 INSERT;押金改累计口径;写入 deadline 快照;新增 getWorkOrderShareById、listWorkOrderSharesByWorkerInOrder;getWorkOrderShare 保留但改名语义为「主份额」
repositories/worker-platform/work-order-share-repo.ts submitWorkOrderShareAcceptance / updateWorkOrderShareDraftAcceptance / updateWorkOrderShareAcceptanceRecord 改为接收 shareId(workerId 仅做归属校验)
repositories/worker-platform/work-order-query-repo.ts listOverdueWorkOrderShares 改按 deadline_at;countWorkerActiveOrdersWithClient 改为按不同工单计数(见 9.1)
repositories/worker-platform/work-order-settlement-repo.ts settleOverdueWorkOrderShare 用份额快照 + 调用 recompute;acceptWorkOrderShareAndSettle 结尾调用 recompute;cancelWorkOrderShare 调用 recompute
repositories/worker-platform/worker-cancel-request-repo.ts 创建退单申请必须定位到具体 shareId;审核通过后 recompute
services/worker-platform/worker-hall-action-service.ts 拼单参与不再阻塞已参与;deadline 由 repo 计算
services/worker-platform/worker-acceptance-service.ts 3 处 getWorkOrderShare 改为「按 shareId 或唯一份额解析」;超时判定用 share.deadline_at
services/worker-platform/worker-my-order-service.ts 3 处授权判断改为「存在任一有效份额」;响应补 myShares
services/worker-platform/work-order-display-mappers.ts mapWorkOrderForWorker 接收 myShares[];myShare 取主份额;timeoutAt 用 share.deadline_at;mapWorkOrderShare 输出新字段;mapWorkOrderSharingWorkers 增加明细
services/worker-platform/admin-work-order-sharing-service.ts 返回 sharingShares 明细
routes/worker/hall.ts、routes/worker/orders.ts 透传 shareId
routes/admin/worker-platform/orders.ts 无新增(沿用 shareId 路由)
services/worker-platform/index.ts 导出新增函数

新增内部函数:recomputeWorkOrderSharingStatusWithClient(建议放 work-order-share-repo.ts,因为它只读份额表)。

9.1 活跃单计数(D2 已确认:按不同工单)

SELECT COUNT(*)::int AS total
FROM (
  SELECT wo.id
  FROM work_orders wo
  WHERE wo.assigned_worker_id = $1 AND wo.status = 'in_progress'
  UNION
  SELECT wos.work_order_id
  FROM work_order_shares wos
  JOIN work_orders swo ON swo.id = wos.work_order_id
  WHERE wos.worker_id = $1
    AND wos.status = 'joined'
    AND swo.status = 'open'
) AS active;

UNION 自带去重;整单接取与拼单在同一工单上互斥,正常情况下两侧不会重叠。对只有一条份额的打手,结果与旧口径完全一致,无回归。


十、前端改动清单

文件 改动
types/worker-platform.ts myShares?: WorkOrderShare[];WorkOrderShare 加 deadlineAt/timeoutMinutes/cancelledReason
pages/worker/WorkerHallPage.tsx 去掉 `disabled={…
pages/worker/worker-order-utils.tsx 从 order.myShare 改为「当前选中份额」上下文;canSubmitAcceptance、canRequestCancel 等按份额判断
pages/worker/WorkerOrdersPage.tsx + WorkerOrderMobileList.tsx + WorkerOrderTableColumns.tsx 一行一工单,展开显示我的份额列表(每份:份数、状态、倒计时、押金、报酬、操作)
pages/worker/WorkerOrderDetailDrawer.tsx 份额 Tab / 列表;每份独立「提交验收 / 申请退单 / 补证据」
pages/worker/WorkerOrderModals.tsx 验收、退单弹窗带 shareId
pages/admin/panels/WorkOrdersPanel.tsx 份额表加「参与时间、截止时间、超时策略」列;统一验收按钮在存在 joined 份额时禁用(接口已拦)
pages/admin/panels/AfterSalesPanel.tsx 份额下拉在「同一打手多份额」时显示参与时间区分(已有 shareId,仅展示优化)
pages/admin/panels/CancelRequestsPanel.tsx 显示份额份数与参与时间

十一、并发与幂等

场景 处理
两个请求同时参与同一工单 SELECT … FROM work_orders FOR UPDATE 已存在,份额求和在同一事务内,天然串行,不会超卖
同一打手快速点两次 客户端每次点击生成一个 requestId;服务端按 (worker_id, request_id) 先查后插,唯一索引兜底;命中则返回原份额并标记 duplicated: true,不重复冻结
超时任务与打手提交验收并发 两者都锁份额行(FOR UPDATE);后到者读到 status != 'joined' 直接返回,不会重复处置
统一验收与单份额验收并发 都 FOR UPDATE 锁工单;仍建议补「已结算份额」的显式标记(见风险 R3)
退单申请与超时并发 退单申请锁工单;超时处置也锁工单;后到者按当前状态失败返回

十二、测试计划

单元/集成(必须新增)

  1. 同一打手连续参与 3 次 → 3 条记录,quantity 各自独立,deadline_at 依次递增。
  2. 参与时母单 timeout_minutes=60,随后母单改为 120 → 已有份额 deadline_at 不变。
  3. 份额 A 超时 → 只有 A 变 cancelled,B 仍 joined;母单仍 open。
  4. 份额 A、B 都提交,A 超时(不可能,已提交)→ 跳过;验证只扫 joined。
  5. 最后一条 joined 份额超时 → 母单按 recompute 变为 pending_acceptance(其余已提交)或 open(其余仍 joined)。
  6. 全部份额取消 → 母单回 open,剩余份数 = 全部。
  7. 多份额分别验收 → 每笔结算金额 = 该份额 share_reward + share_deposit,互不重复。
  8. 押金累计口径:两次参与后 Σ share_deposit = max(0, 累计需求 − 免押);取消其中一份只释放它自己。
  9. 退单申请指定 shareId → 只撤该份额;不传 → 撤该打手在该工单的全部有效份额。
  10. 并发参与同一工单(两个打手)→ 总份数不超 sharing_total_quantity。
  11. 同一 requestId 重复提交 → 只产生一条份额,第二次返回原份额、不重复冻结押金。
  12. 整单退单(不传 shareId)→ 该打手在该工单的全部有效份额一并撤销,其他打手份额不动。
  13. 拼单验收通知:每份提交各产生一条后台待办(按 shareId 去重);统一验收完成后再产生一条整单完成通知。
  14. 历史单回归:改造前创建的合并份额仍能正常验收、超时、退单。

SQL 护栏:npm run check:sql(迁移不可变、热点索引存在)。


十三、上线与回滚

上线顺序

  1. 迁移 109(加列 → 回填 → 删唯一键 → 建索引)+ 110(大厅置顶 / 店铺筛选索引)。
  2. 后端发布(新逻辑兼容旧前端:shareId 可选、myShare 保留)。
  3. 前端发布(切到 myShares)。
  4. 观察 3 天:份额表增长、超时任务耗时、验收重复结算告警。

回滚

  • 代码回滚安全(旧代码读不到新列,但 myShares 字段多余无影响)。
  • 数据回滚不安全:重新加回唯一键前必须合并重复份额。提供脚本草案:
-- 回滚前合并:同一工单同一打手的多条份额合并成一条(只适用于未验收的 joined)
-- 已验收/已结算的份额不要合并,否则资金归属会被改写

结论:迁移发布后建议不轻易回滚,改用开关控制前端是否展示多份额入口(如需灰度,可加 worker_sharing_multi_participation 配置键)。

灰度建议:先在 worker_hall 配置里加布尔开关 allowRepeatParticipation,默认关闭;开关打开才允许第二次参与,其余逻辑改造后向后兼容。


十四、风险清单

# 风险 影响 缓解
R1 免押额度被拆分套利 资金 选方案 B(累计口径)
R2 活跃单计数把每份算 1 个名额 打手体验 / 可接单量 决策 D2;建议按「不同工单数」计
R3 统一验收与逐份验收并发重复结算(原有隐患被放大) 资金 验收时加 settled_at 或 settlement_ledger_id 幂等标记
R4 前端遗漏 myShare 判断导致按钮永久禁用 功能不可用 全量替换 + 组件测试
R5 超时任务扫描量上升(份额数 > 工单数) 性能 idx_work_order_shares_due 部分索引 + 每轮 limit
R6 后台通知按工单去重,第二份提交不提醒 运营漏验收 分享单的 dedupeKey 加 shareId(决策 D7)
R7 历史合并份额与新单份额混存 口径解释成本 文档标注;后台展示「本次参与份数」
R8 worker_work_order_notes / views 仍按工单+打手唯一 多份额共用一条备注 视为「对工单的备注」,不拆分;文档说明
R9 母单「已份数分配完」但份额被取消后重新开放 大厅可见性 recompute 里重置 hall_queued_at,避免已看过的人看不到

十五、决策记录(已全部确认)

# 决策 选项 我的建议
D1 免押额度怎么分摊 A 每份全额 / B 工单累计 / C 按份数比例 ✅ 已确认:跟着母单走(B)
D2 「最大同时接单量」怎么算 A 每份算 1 单 / B 按不同工单计 ✅ 已确认:B
D3 撤单/退单申请粒度 A 只支持整单 / B 只支持份额 ✅ 已确认:整单 + 单份额都支持
D4 保留「统一验收」吗 A 保留 / B 取消 ✅ 已确认:两种都要(统一验收 = 逐份结算批处理)
D5 我的订单列表形态 A 一行一工单 + 展开份额 / B 一行一份额 ✅ 已确认:A
D6 是否需要防误触 A 不做 / C 客户端 requestId 幂等 ✅ 已确认:C
D7 验收通知去重 A 按工单 / B 拼单按 shareId ✅ 已确认:B;统一验收也要通知

十六、执行顺序(范围已确认:一次性做完)

  1. 先跑第四节的数据体检 SQL,确认无重复份额、无脏数据。
  2. 写迁移 104(加列 + 回填 + 删唯一键 + 索引 + request_id 幂等索引),本地 db:migrate 验证,再跑 check:sql。
  3. 仓库层:份额创建(每次 INSERT + 幂等 + 押金累计 + deadline 快照)→ 超时(按份额 deadline)→ 验收(按 shareId)→ 撤单(整单 / 单份额两种)→ 新增 recomputeWorkOrderSharingStatusWithClient 并替换 7 处内联状态判断。
  4. 服务层与路由:shareId 可选兼容、requestId 透传、活跃单按不同工单计数。
  5. 通知:拼单验收待办按 shareId 去重;统一验收完成后再发一条整单完成通知。
  6. 前端:大厅按钮(可重复参与 + 每次生成 requestId)→ 我的订单(一行一工单 + 展开份额 + 每份独立操作)→ 后台份额表(参与时间/截止时间/逐份验收与撤单)。
  7. 验证:npm run check(后端 + 前端)+ Docker 内跑测试,最后手工走一遍:参与 2 次 → 一份超时 → 一份单独验收 → 剩余份数统一验收 → 核对母单状态与钱包流水。

待你启动 Docker Desktop 后开始第 2 步;代码改动本身不依赖容器,但迁移验证与 PostgreSQL 集成测试必须用容器。


十七、执行结果(2026-10-23 一次性完成)

项 状态 证据
数据体检 ✅ 0 重复 (work_order_id, worker_id)、0 孤立份额;103 条历史脏状态仅记录未动资金
迁移 109 + 110 ✅ 四个增量脚本合并重写;临时库全量执行成功、重跑幂等;psql 确认 10 列、唯一键已删、5 个份额索引 + 2 个性能索引;宿主机 npm run check:sql 通过
仓库层 ✅ 每次参与 INSERT 新份额;(worker_id, request_id) 先查后插幂等;押金按累计口径只补差额;deadline_at/timeout_minutes/timeout_policy 参与时快照;recomputeWorkOrderSharingStatusWithClient 成为母单状态唯一出口
超时 / 撤单 ✅ 超时只处置单份并写 cancelled_reason='timeout';单份撤单 admin、打手退单 worker_request;整单 + 单份额两种粒度都支持;均不再各自 UPDATE 母单状态
服务层 / 路由 ✅ 大厅与我的订单透传 requestId;多份额验收未传 shareId 时返回 400 work_order_share_id_required;活跃单按不同工单去重
通知 ✅ 拼单验收待办 dedupeKey 带 shareId;新增 order_accepted 整单完成通知(含统一验收)
前端 ✅ 大厅可重复参与(每次生成 requestId,显示「已参与 N 次 · 共 M 份」);我的订单一行一工单 + 展开份额 + 逐份验收/退单;后台份额表新增「状态+撤单原因 / 参与时间 / 截止时间 / 超时策略」并保留逐份验收与单人撤单
测试 ✅ 后端 750 项:735 通过、10 跳过、5 项为既有 Windows/CRLF 环境失败(与本次改动文件无关);前端 143 项全通过
冒烟走查 ✅ 真实库脚本化验证:同一打手 2 次参与生成 2 条份额(押金 2000 + 3000,累计口径)→ requestId 重放返回原份额且不重复扣款 → 母单改超时配置不影响已固化 deadline → 单份超时只取消该份、另一份保持 joined、母单仍 open → 补满并逐份提交后母单才转 pending_acceptance;脚本数据已全部清理(0 残留)

已知遗留

  • 后台 mapWorkOrderSharingWorkers 仍按打手汇总一行(用于列表角标),明细操作走份额表,属设计保留。
  • 5 项后端既有测试失败:backup-db.sh 在容器内缺 /deploy 文件;3 项为 CRLF 下源码字符串守卫断言;1 项 http-cache 路由守卫。均未触及本次改动文件,建议后续统一把源码守卫改成忽略行尾。