30 KiB
拼单多次参与改造方案(同一打手可多次参与、每份独立计时)
状态:方案(待确认,未执行) 日期:2026-10-23 代码基线:工作区当前 HEAD(迁移 001–103) 目标:同一打手对同一拼单工单可重复参与,每次参与新增一条
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) 只有一条(唯一键保证),所以删键不会产生历史冲突。
三、目标语义(改造后的不变式)
- 一次参与 = 一条
work_order_shares记录,quantity表示「本次参与份数」(默认 1,「立即抢单」= 承接全部剩余)。 - 份额截止时间在参与瞬间固化:
deadline_at = 参与时间 + 母单当时的 timeout_minutes;母单之后改配置不影响已参与份额(timeout_minutes = 0时deadline_at = NULL,不限时)。 - 超时只处置该份额:份额
joined → cancelled(按快照timeout_policy决定押金释放/扣除),同工单其他份额状态不变。 - 验收只针对该份额:
joined → submitted(打手提交)、submitted → accepted(后台验收 + 结算);同工单其他份额不受影响。 - 撤单只针对该份额:
joined/submitted → cancelled,只退/扣该份额的share_deposit。 - 母单状态只由份额集合推导(见第七节),不再由单个份额操作直接改写。
- 押金按份额落账:
share_deposit是该份额实际冻结的增量金额,释放/扣除都只针对它;同一打手在同一工单的多条份额互不覆盖。
四、数据库方案(迁移 104_work_order_share_multi_participation.sql)
4.1 DDL 草案
-- 104_work_order_share_multi_participation.sql
-- 目标:同一打手可对同一拼单工单多次参与,每份独立计时/验收/撤单。
ALTER TABLE work_order_shares
ADD COLUMN IF NOT EXISTS deadline_at TIMESTAMPTZ,
ADD COLUMN IF NOT EXISTS timeout_minutes INTEGER NOT NULL DEFAULT 0,
ADD COLUMN IF NOT EXISTS timeout_policy TEXT NOT NULL DEFAULT '',
ADD COLUMN IF NOT EXISTS cancelled_reason TEXT NOT NULL DEFAULT '',
ADD COLUMN IF NOT EXISTS request_id TEXT NOT NULL DEFAULT '';
COMMENT ON COLUMN work_order_shares.deadline_at IS '本份额截止时间:参与时 = created_at + 母单当时 timeout_minutes;0 分钟为 NULL';
COMMENT ON COLUMN work_order_shares.timeout_minutes IS '参与时固化的时限(分钟),母单后续改配置不影响';
COMMENT ON COLUMN work_order_shares.timeout_policy IS '参与时固化的超时策略 reopen/cancel_release/cancel_deduct';
COMMENT ON COLUMN work_order_shares.cancelled_reason IS '取消来源:timeout/worker_request/admin/problem/settlement';
COMMENT ON COLUMN work_order_shares.request_id IS '客户端幂等键(一次点击一个),重复请求返回原份额';
-- 回填历史份额(按当时母单配置一次性固化)
UPDATE work_order_shares s
SET timeout_minutes = COALESCE(w.timeout_minutes, 0),
timeout_policy = COALESCE(NULLIF(w.timeout_policy, ''), 'reopen'),
deadline_at = CASE
WHEN COALESCE(w.timeout_minutes, 0) > 0
THEN s.created_at + (w.timeout_minutes || ' minutes')::interval
ELSE NULL
END
FROM work_orders w
WHERE w.id = s.work_order_id
AND s.timeout_minutes = 0;
-- 删除「一人一条」约束(必须先删,否则第二次参与报 23505)
ALTER TABLE work_order_shares
DROP CONSTRAINT IF EXISTS work_order_shares_work_order_id_worker_id_key;
-- 新增索引
CREATE INDEX IF NOT EXISTS idx_work_order_shares_due
ON work_order_shares (deadline_at)
WHERE status = 'joined';
CREATE INDEX IF NOT EXISTS idx_work_order_shares_worker_order
ON work_order_shares (worker_id, work_order_id, created_at DESC);
CREATE INDEX IF NOT EXISTS idx_work_order_shares_order_status
ON work_order_shares (work_order_id, status, created_at ASC);
-- 幂等键:同一打手同一个 request_id 只能产生一条份额
CREATE UNIQUE INDEX IF NOT EXISTS uq_work_order_shares_worker_request
ON work_order_shares (worker_id, request_id)
WHERE request_id <> '';
项目约定:迁移必须幂等、不改历史文件;
ALTER TABLE … DROP CONSTRAINT IF EXISTS也符合现有风格(059/099 都用 DO 块判存在,这里用IF EXISTS更短,建议套 DO 块与团队风格一致)。
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 下完全兼容,不需要拆历史行。拆行会重写历史资金归属,风险大于收益。
五、押金与免押额度(需要你选择)
现状:resolveWorkOrderShareDepositAmount 按「该打手在本工单累计份额」计算应冻结总额,并与已冻结额取 max(不回退)。拆成多行后,必须明确免押额度怎么分摊,否则可被拆分套利。
| 方案 | 规则 | 优点 | 缺点 |
|---|---|---|---|
| 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),只改两处:
- 策略从
share.timeout_policy取,不再从母单取; - 结尾把「无条件
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/acceptPOST /admin/worker-platform/orders/:id/shares/:shareId/cancelGET /admin/worker-platform/orders/:id/sharing已返回shares[](新增deadlineAt、timeoutMinutes、cancelledReason)
需要调整的只是展示层:sharingWorkers 目前把同一打手的多份额合并成一行(mapWorkOrderSharingWorkers 累加 quantity),建议保留汇总但新增 sharingShares 明细分页,否则后台无法逐份撤单。
九、后端改动清单(文件级)
| 文件 | 改动 |
|---|---|
db/migrations/104_*.sql |
新增(DDL + 回填 + 删唯一键 + 索引) |
repositories/worker-platform/types.ts |
WorkOrderShareRow 加 4 个字段 |
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) |
| 退单申请与超时并发 | 退单申请锁工单;超时处置也锁工单;后到者按当前状态失败返回 |
十二、测试计划
单元/集成(必须新增)
- 同一打手连续参与 3 次 → 3 条记录,
quantity各自独立,deadline_at依次递增。 - 参与时母单
timeout_minutes=60,随后母单改为 120 → 已有份额deadline_at不变。 - 份额 A 超时 → 只有 A 变
cancelled,B 仍joined;母单仍open。 - 份额 A、B 都提交,A 超时(不可能,已提交)→ 跳过;验证只扫
joined。 - 最后一条
joined份额超时 → 母单按recompute变为pending_acceptance(其余已提交)或open(其余仍 joined)。 - 全部份额取消 → 母单回
open,剩余份数 = 全部。 - 多份额分别验收 → 每笔结算金额 = 该份额
share_reward+share_deposit,互不重复。 - 押金累计口径:两次参与后
Σ share_deposit = max(0, 累计需求 − 免押);取消其中一份只释放它自己。 - 退单申请指定 shareId → 只撤该份额;不传 → 撤该打手在该工单的全部有效份额。
- 并发参与同一工单(两个打手)→ 总份数不超
sharing_total_quantity。 - 同一
requestId重复提交 → 只产生一条份额,第二次返回原份额、不重复冻结押金。 - 整单退单(不传 shareId)→ 该打手在该工单的全部有效份额一并撤销,其他打手份额不动。
- 拼单验收通知:每份提交各产生一条后台待办(按 shareId 去重);统一验收完成后再产生一条整单完成通知。
- 历史单回归:改造前创建的合并份额仍能正常验收、超时、退单。
SQL 护栏:npm run check:sql(迁移不可变、热点索引存在)。
十三、上线与回滚
上线顺序
- 迁移 104(加列 → 回填 → 删唯一键 → 建索引)。
- 后端发布(新逻辑兼容旧前端:
shareId可选、myShare保留)。 - 前端发布(切到
myShares)。 - 观察 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;统一验收也要通知 |
十六、执行顺序(范围已确认:一次性做完)
- 先跑第四节的数据体检 SQL,确认无重复份额、无脏数据。
- 写迁移
104(加列 + 回填 + 删唯一键 + 索引 +request_id幂等索引),本地db:migrate验证,再跑check:sql。 - 仓库层:份额创建(每次 INSERT + 幂等 + 押金累计 + deadline 快照)→ 超时(按份额 deadline)→ 验收(按 shareId)→ 撤单(整单 / 单份额两种)→ 新增
recomputeWorkOrderSharingStatusWithClient并替换 7 处内联状态判断。 - 服务层与路由:
shareId可选兼容、requestId透传、活跃单按不同工单计数。 - 通知:拼单验收待办按
shareId去重;统一验收完成后再发一条整单完成通知。 - 前端:大厅按钮(可重复参与 + 每次生成 requestId)→ 我的订单(一行一工单 + 展开份额 + 每份独立操作)→ 后台份额表(参与时间/截止时间/逐份验收与撤单)。
- 验证:
npm run check(后端 + 前端)+ Docker 内跑测试,最后手工走一遍:参与 2 次 → 一份超时 → 一份单独验收 → 剩余份数统一验收 → 核对母单状态与钱包流水。
待你启动 Docker Desktop 后开始第 2 步;代码改动本身不依赖容器,但迁移验证与 PostgreSQL 集成测试必须用容器。
十七、执行结果(2026-10-23 一次性完成)
| 项 | 状态 | 证据 |
|---|---|---|
| 数据体检 | ✅ | 0 重复 (work_order_id, worker_id)、0 孤立份额;103 条历史脏状态仅记录未动资金 |
| 迁移 104 | ✅ | db:migrate 成功;psql 确认 5 列、唯一键已删、4 个索引;宿主机 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路由守卫。均未触及本次改动文件,建议后续统一把源码守卫改成忽略行尾。