From 1d5615f1ae1a5b7cb543e7e7f1f35d9fdf23857c Mon Sep 17 00:00:00 2001 From: zhaoxing <2558691827@qq.com> Date: Fri, 23 Oct 2026 15:14:07 +0800 Subject: [PATCH] =?UTF-8?q?add:=E6=96=B0=E5=A2=9E=E5=90=8C=E4=B8=80?= =?UTF-8?q?=E4=B8=AA=E5=B7=A5=E5=8D=95=E5=90=8C=E4=B8=80=E4=BA=BA=E5=8F=AF?= =?UTF-8?q?=E4=BB=A5=E5=A4=9A=E6=AC=A1=E6=8B=BC=E5=8D=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../103_work_order_shop_filter_index.sql | 9 + ...4_work_order_share_multi_participation.sql | 64 ++ .../share-summary-scope.test.ts | 99 +- .../worker-platform/work-order-query-repo.ts | 249 +++-- .../worker-active-order-count.test.ts | 49 + .../realtime/realtime-event-service.ts | 127 ++- .../src/services/worker-platform/mappers.ts | 3 + .../work-order-display-mappers.test.ts | 220 +++++ .../work-order-display-mappers.ts | 141 +++ .../worker-my-order-service.ts | 24 +- .../pages/worker/WorkerOrderShareTable.tsx | 213 +++++ .../src/pages/worker/WorkerOrdersPage.tsx | 4 +- apps/frontend/src/utils/request-id.ts | 8 + .../src/utils/worker-realtime-work-orders.ts | 15 +- .../tests/worker-realtime-work-orders.test.ts | 92 ++ docs/工单拼单多次参与改造方案-2026-10-23.md | 466 ++++++++++ ...单深挖-抢单拼单好友赠送结算-2026-10-23.md | 467 ++++++++++ docs/项目全流程与数据库设计梳理-2026-10-23.md | 874 ++++++++++++++++++ 18 files changed, 2996 insertions(+), 128 deletions(-) create mode 100644 apps/backend/src/db/migrations/103_work_order_shop_filter_index.sql create mode 100644 apps/backend/src/db/migrations/104_work_order_share_multi_participation.sql create mode 100644 apps/backend/src/repositories/worker-platform/worker-active-order-count.test.ts create mode 100644 apps/backend/src/services/worker-platform/work-order-display-mappers.test.ts create mode 100644 apps/frontend/src/pages/worker/WorkerOrderShareTable.tsx create mode 100644 apps/frontend/src/utils/request-id.ts create mode 100644 docs/工单拼单多次参与改造方案-2026-10-23.md create mode 100644 docs/打手工单深挖-抢单拼单好友赠送结算-2026-10-23.md create mode 100644 docs/项目全流程与数据库设计梳理-2026-10-23.md diff --git a/apps/backend/src/db/migrations/103_work_order_shop_filter_index.sql b/apps/backend/src/db/migrations/103_work_order_shop_filter_index.sql new file mode 100644 index 00000000..1238cbbd --- /dev/null +++ b/apps/backend/src/db/migrations/103_work_order_shop_filter_index.sql @@ -0,0 +1,9 @@ +-- 后台「接单工单 → 店铺」筛选:按订单归属店铺精确匹配(可多选)。 +-- 过滤器写法是 BTRIM(o.shop_name) = ANY($n::text[]),普通 btree 无法命中表达式, +-- 所以补一个与查询同口径的表达式索引;部分索引省掉空店铺的无效条目。 +CREATE INDEX IF NOT EXISTS idx_orders_shop_name_trimmed + ON orders (BTRIM(shop_name)) + WHERE BTRIM(shop_name) <> ''; + +COMMENT ON INDEX idx_orders_shop_name_trimmed IS + '支持后台接单工单按订单店铺(orders.shop_name)精确多选筛选'; diff --git a/apps/backend/src/db/migrations/104_work_order_share_multi_participation.sql b/apps/backend/src/db/migrations/104_work_order_share_multi_participation.sql new file mode 100644 index 00000000..6101ac12 --- /dev/null +++ b/apps/backend/src/db/migrations/104_work_order_share_multi_participation.sql @@ -0,0 +1,64 @@ +-- 104_work_order_share_multi_participation.sql +-- 目标:同一打手可对同一拼单工单多次参与,每次参与一条 work_order_shares; +-- 每份在参与时固化截止时间与超时策略;超时/验收/撤单按份额独立。 +-- +-- 说明: +-- 1. 只加列 + 回填 + 删「一人一条」唯一键 + 建索引,不拆分历史行、不改历史资金。 +-- 2. 历史合并份额(quantity > 1)保持原样,结算公式 quantity × unit_reward 完全兼容。 +-- 3. request_id 为客户端幂等键,唯一索引只约束非空值。 + +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) +DO $$ +BEGIN + IF EXISTS ( + SELECT 1 FROM pg_constraint + WHERE conname = 'work_order_shares_work_order_id_worker_id_key' + ) THEN + ALTER TABLE work_order_shares + DROP CONSTRAINT work_order_shares_work_order_id_worker_id_key; + END IF; +END $$; + +-- 到期扫描:只覆盖 joined 份额,走部分索引 +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); + +-- 母单份额集合推导(recompute 母单状态、剩余份数) +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 <> ''; diff --git a/apps/backend/src/repositories/worker-platform/share-summary-scope.test.ts b/apps/backend/src/repositories/worker-platform/share-summary-scope.test.ts index add275bf..c4c40ad6 100644 --- a/apps/backend/src/repositories/worker-platform/share-summary-scope.test.ts +++ b/apps/backend/src/repositories/worker-platform/share-summary-scope.test.ts @@ -66,8 +66,25 @@ test('大厅候选集(buildHallCandidateIdsSql)不再聚合整表拼单', as test('全仓库只允许保留一处「整表拼单聚合」,且必须是无打手范围的全表统计分支', async () => { const raw = await readRepoSource() const source = stripComments(raw) + + // 我的订单概览里按「工单 + 份额状态」聚合金额,必须限定到当前打手; + // 它不是整表聚合,先单独摘出来校验,避免被下面的全表聚合守卫误判。 + const shareStatusStart = source.indexOf('share_status_rows AS (') + const shareStatusEnd = source.indexOf('order_status_rows AS (') + const workerShareAggregation = + shareStatusStart >= 0 && shareStatusEnd > shareStatusStart + ? source.slice(shareStatusStart, shareStatusEnd) + : '' + assert.ok(workerShareAggregation, '缺少我的订单概览的份额状态聚合') + + const sourceWithoutWorkerShareAggregation = + shareStatusStart >= 0 && shareStatusEnd > shareStatusStart + ? source.slice(0, shareStatusStart) + source.slice(shareStatusEnd) + : source const globalAggregations = [ - ...source.matchAll(/GROUP BY\s+wos\.work_order_id|GROUP BY\s+work_order_id/g), + ...sourceWithoutWorkerShareAggregation.matchAll( + /GROUP BY\s+wos\.work_order_id|GROUP BY\s+work_order_id/g, + ), ] assert.equal( @@ -78,15 +95,28 @@ test('全仓库只允许保留一处「整表拼单聚合」,且必须是无 ) const onlyRemaining = globalAggregations[0]! - const around = source.slice(Math.max(0, onlyRemaining.index - 900), onlyRemaining.index + 200) + const around = sourceWithoutWorkerShareAggregation.slice( + Math.max(0, (onlyRemaining.index ?? 0) - 900), + (onlyRemaining.index ?? 0) + 200, + ) assert.ok( around.includes('operational_share_summary'), '保留的那处整表聚合不在 operational_share_summary 分支里,需要确认是否有意为之', ) - // 注释在 stripComments 里被去掉了,所以断言理由时用原文 - const rawIndex = raw.indexOf('GROUP BY wos.work_order_id') - const rawAround = raw.slice(Math.max(0, rawIndex - 1200), rawIndex + 200) + // 注释在 stripComments 里被去掉了,所以断言理由时用原文。 + // 注意:raw 里前面还有 share_status_rows 的工单内聚合,必须找 operational 那一处。 + const rawMatches = [...raw.matchAll(/GROUP BY\s+wos\.work_order_id/g)] + const operationalRaw = rawMatches.find((match) => + raw + .slice(Math.max(0, (match.index ?? 0) - 1200), (match.index ?? 0) + 200) + .includes('operational_share_summary'), + ) + assert.ok(operationalRaw, '找不到 operational_share_summary 的整表聚合') + const rawAround = raw.slice( + Math.max(0, (operationalRaw.index ?? 0) - 1200), + (operationalRaw.index ?? 0) + 200, + ) assert.ok( rawAround.includes('无打手范围时一次聚合全部拼单'), '保留理由(无打手范围时一次聚合优于逐行关联)必须写在注释里,避免被误删', @@ -105,18 +135,67 @@ test('我的订单的份额关联按工单取一条,避免多份额把列表 test('我的订单概览按工单计数,金额仍按份额求和', async () => { const source = stripComments(await readRepoSource()) - const start = source.indexOf('worker_base AS (') - assert.ok(start >= 0, '缺少我的订单概览的 worker_base CTE') + const start = source.indexOf('order_flags AS (') + assert.ok(start >= 0, '缺少我的订单概览的 order_flags CTE') - const block = source.slice(start, start + 2200) - assert.match(block, /LEFT JOIN LATERAL \(/, '概览的份额关联没有按工单取一条') - assert.match(block, /share_reward_total/, '概览没有汇总多份份额的奖励金额') + const block = source.slice(start, start + 4200) + assert.match(block, /share_status_rows AS \(/, '概览没有按份额状态拆分金额') + assert.match(block, /order_status_rows AS \(/, '概览没有聚合成工单状态行') + assert.match(block, /SUM\(wos\.share_reward\)/, '概览没有汇总份额奖励') + assert.match( + block, + /GROUP BY wos\.work_order_id, wos\.status/, + '份额金额没有按「工单 + 份额状态」聚合', + ) assert.ok( !block.includes('LEFT JOIN work_order_shares wos'), '概览仍在直接 JOIN 份额表:同一工单 3 份会被统计成 3 单', ) }) +test('多份额混合状态:任一状态命中即可出现在对应页签', async () => { + const source = stripComments(await readRepoSource()) + + assert.ok(source.includes("ws_filter.status = 'submitted'"), '待验收筛选没有检查任一份额') + assert.ok(source.includes("'pending_acceptance' = ANY"), '待验收状态没有参与任一份额匹配') +}) + +/** + * 概览与列表必须是同一把尺子:列表里「待审核取消申请/未处理反馈」只把进行中的份额 + * 归入问题单,已提交待验收的份额照旧留在待验收。 + * 概览旧写法把这类工单的份额整体排除、再由第二分支记成问题单,于是出现 + * 「问题单卡片有数字、点进去列表是空的」「待验收列表有单、卡片不计数」。 + */ +test('概览与列表同口径:待处理申请只把进行中份额归问题单', async () => { + const source = stripComments(await readRepoSource()) + + const shareStart = source.indexOf('share_status_rows AS (') + assert.ok(shareStart >= 0, '缺少 share_status_rows CTE') + const shareBlock = source.slice(shareStart, shareStart + 1600) + assert.match( + shareBlock, + /WHEN wos\.status = 'joined' AND f\.has_pending_handling THEN 'problem'/, + '待处理申请只应把进行中的份额归问题单', + ) + assert.ok( + !shareBlock.includes('AND NOT f.has_pending_handling'), + '概览不能整体排除有待处理申请的份额,否则已提交待验收会从页签里消失', + ) + + const orderStart = source.indexOf('order_status_rows AS (', shareStart) + assert.ok(orderStart > shareStart, '缺少 order_status_rows CTE') + const orderBlock = source.slice(orderStart, orderStart + 1600) + assert.ok( + !/OR f\.has_pending_handling\b/.test(orderBlock), + '第二分支不能无条件把有待处理申请的工单塞进问题单(会产生重复/错页签计数)', + ) + assert.match( + orderBlock, + /WHEN f\.has_pending_handling AND f\.wo_status = 'in_progress'/, + '无份额的指派单也要按「进行中才归问题单」判定', + ) +}) + test('超时统计按工单去重,不按份额/事件行数累加', async () => { const source = stripComments(await readRepoSource()) diff --git a/apps/backend/src/repositories/worker-platform/work-order-query-repo.ts b/apps/backend/src/repositories/worker-platform/work-order-query-repo.ts index 6b675528..492d3d66 100644 --- a/apps/backend/src/repositories/worker-platform/work-order-query-repo.ts +++ b/apps/backend/src/repositories/worker-platform/work-order-query-repo.ts @@ -223,25 +223,29 @@ export function workerOrderBaseStatusExpression(workerShareAlias = 'worker_share * 传入 workerId 时,代练中但存在待审核取消申请或未处理反馈的订单归入问题单, * 客服驳回/解决后自动回到代练中(计算型归类,无状态迁移)。 */ -export function workerOrderStatusExpression(workerId = 0, workerShareAlias = 'worker_share') { - const baseExpression = workerOrderBaseStatusExpression(workerShareAlias) - const normalizedWorkerId = Math.max(0, Math.floor(Number(workerId) || 0)) - if (normalizedWorkerId <= 0) return baseExpression - const pendingHandlingExists = `( +function workerPendingHandlingExistsExpression(workerId: number): string { + return `( EXISTS ( SELECT 1 FROM worker_order_cancel_requests wcr WHERE wcr.work_order_id = wo.id - AND wcr.worker_id = ${normalizedWorkerId} + AND wcr.worker_id = ${workerId} AND wcr.status = 'pending' ) OR EXISTS ( SELECT 1 FROM worker_order_feedbacks wf WHERE wf.work_order_id = wo.id - AND wf.worker_id = ${normalizedWorkerId} + AND wf.worker_id = ${workerId} AND wf.status = 'pending' ) )` +} + +export function workerOrderStatusExpression(workerId = 0, workerShareAlias = 'worker_share') { + const baseExpression = workerOrderBaseStatusExpression(workerShareAlias) + const normalizedWorkerId = Math.max(0, Math.floor(Number(workerId) || 0)) + if (normalizedWorkerId <= 0) return baseExpression + const pendingHandlingExists = workerPendingHandlingExistsExpression(normalizedWorkerId) const isTimeoutForMe = `( (wo.assigned_worker_id IS DISTINCT FROM ${normalizedWorkerId}) AND COALESCE(${workerShareAlias}.status, '') NOT IN ('joined', 'submitted', 'accepted') @@ -638,12 +642,12 @@ export async function getWorkerOrderOverview(workerId: number | string): Promise WHERE timeout_ev.event_type LIKE 'timeout_%' AND timeout_ev.payload_json->>'workerId' = $1::text ), - worker_base AS ( + order_flags AS ( SELECT wo.id, wo.status AS wo_status, - wos.status AS wos_status, - wos.status IS NOT NULL AS is_worker_share, + wo.reward_amount, + wo.acceptance_json, wo.assigned_worker_id = $1 AS is_assigned_to_me, EXISTS ( SELECT 1 FROM work_order_events we @@ -651,88 +655,92 @@ export async function getWorkerOrderOverview(workerId: number | string): Promise AND we.event_type LIKE 'timeout_%' AND we.payload_json->>'workerId' = $1::text ) AS has_timeout_for_me, - CASE - WHEN wos.status IS NOT NULL THEN wos.share_reward_total - WHEN wo.assigned_worker_id = $1 THEN wo.reward_amount - ELSE 0 - END AS worker_settlement_amount, - wo.acceptance_json + ( + EXISTS ( + SELECT 1 FROM worker_order_cancel_requests wcr + WHERE wcr.work_order_id = wo.id + AND wcr.worker_id = $1 + AND wcr.status = 'pending' + ) OR EXISTS ( + SELECT 1 FROM worker_order_feedbacks wf + WHERE wf.work_order_id = wo.id + AND wf.worker_id = $1 + AND wf.status = 'pending' + ) + ) AS has_pending_handling, + COALESCE(( + SELECT SUM(wos.share_reward) + FROM work_order_shares wos + WHERE wos.work_order_id = wo.id + AND wos.worker_id = $1 + AND wos.status != 'cancelled' + ), 0)::int AS share_reward_total, + EXISTS ( + SELECT 1 FROM work_order_shares wos + WHERE wos.work_order_id = wo.id + AND wos.worker_id = $1 + AND wos.status != 'cancelled' + ) AS is_worker_share FROM work_orders wo INNER JOIN visible_orders vo ON vo.work_order_id = wo.id - LEFT JOIN LATERAL ( - SELECT - ( - SELECT w2.status - FROM work_order_shares w2 - WHERE w2.work_order_id = wo.id - AND w2.worker_id = $1 - AND w2.status != 'cancelled' - ORDER BY - CASE w2.status - WHEN 'joined' THEN 0 - WHEN 'submitted' THEN 1 - WHEN 'accepted' THEN 2 - ELSE 3 - END, - w2.created_at DESC, - w2.id DESC - LIMIT 1 - ) AS status, - COALESCE(SUM(wos.share_reward), 0)::int AS share_reward_total - FROM work_order_shares wos - WHERE wos.work_order_id = wo.id - AND wos.worker_id = $1 - AND wos.status != 'cancelled' - ) wos ON true ), - worker_orders AS ( + share_status_rows AS ( SELECT + wos.work_order_id, CASE - WHEN NOT is_assigned_to_me AND (NOT is_worker_share OR wos_status = 'cancelled' OR wos_status IS NULL) AND has_timeout_for_me - THEN 'open' - WHEN ( - CASE - WHEN is_worker_share AND wo_status NOT IN ('accepted', 'cancelled', 'problem') - THEN CASE wos_status - WHEN 'joined' THEN 'in_progress' - WHEN 'submitted' THEN 'pending_acceptance' - WHEN 'accepted' THEN 'accepted' - WHEN 'cancelled' THEN 'cancelled' - ELSE wo_status - END - ELSE wo_status - END - ) = 'in_progress' AND ( - EXISTS ( - SELECT 1 - FROM worker_order_cancel_requests wcr - WHERE wcr.work_order_id = worker_base.id - AND wcr.worker_id = $1 - AND wcr.status = 'pending' - ) OR EXISTS ( - SELECT 1 - FROM worker_order_feedbacks wf - WHERE wf.work_order_id = worker_base.id - AND wf.worker_id = $1 - AND wf.status = 'pending' - ) - ) THEN 'problem' - ELSE CASE - WHEN is_worker_share AND wo_status NOT IN ('accepted', 'cancelled', 'problem') - THEN CASE wos_status - WHEN 'joined' THEN 'in_progress' - WHEN 'submitted' THEN 'pending_acceptance' - WHEN 'accepted' THEN 'accepted' - WHEN 'cancelled' THEN 'cancelled' - ELSE wo_status - END - ELSE wo_status - END + -- 待审核取消申请/未处理反馈只把「进行中」的份额归入问题单; + -- 已提交待验收的份额必须留在待验收(列表筛选就是这么判的, + -- 这里若整体排除会出现「问题单有数字、点进去空列表」)。 + WHEN wos.status = 'joined' AND f.has_pending_handling THEN 'problem' + WHEN wos.status = 'joined' THEN 'in_progress' + WHEN wos.status = 'submitted' THEN 'pending_acceptance' + WHEN wos.status = 'accepted' THEN 'accepted' END AS status, + SUM(wos.share_reward)::int AS amount, + (ARRAY_AGG(wos.acceptance_json ORDER BY wos.id DESC))[1] AS acceptance_json + FROM work_order_shares wos + INNER JOIN order_flags f ON f.id = wos.work_order_id + WHERE wos.worker_id = $1 + AND wos.status IN ('joined', 'submitted', 'accepted') + AND f.wo_status NOT IN ('accepted', 'cancelled', 'problem') + GROUP BY wos.work_order_id, wos.status, f.has_pending_handling + ), + order_status_rows AS ( + SELECT + work_order_id, + status, + amount, acceptance_json, - is_worker_share, - worker_settlement_amount - FROM worker_base + TRUE AS is_worker_share + FROM share_status_rows + + UNION ALL + + SELECT + f.id AS work_order_id, + CASE + WHEN f.has_timeout_for_me + AND NOT f.is_assigned_to_me + AND NOT f.is_worker_share + THEN 'open' + WHEN f.wo_status IN ('accepted', 'cancelled', 'problem') + THEN f.wo_status + WHEN f.has_pending_handling AND f.wo_status = 'in_progress' + THEN 'problem' + ELSE f.wo_status + END AS status, + CASE + WHEN f.is_worker_share THEN f.share_reward_total + WHEN f.is_assigned_to_me THEN f.reward_amount + ELSE 0 + END AS amount, + f.acceptance_json, + f.is_worker_share + FROM order_flags f + WHERE + f.wo_status IN ('accepted', 'cancelled', 'problem') + OR (f.has_timeout_for_me AND NOT f.is_assigned_to_me AND NOT f.is_worker_share) + OR NOT f.is_worker_share ) SELECT status, @@ -742,7 +750,7 @@ export async function getWorkerOrderOverview(workerId: number | string): Promise CASE WHEN status IN ('in_progress', 'pending_acceptance', 'problem', 'accepted') OR (status = 'open' AND is_worker_share) - THEN worker_settlement_amount + THEN amount ELSE 0 END ), @@ -755,7 +763,7 @@ export async function getWorkerOrderOverview(workerId: number | string): Promise AND NULLIF(acceptance_json->>'evidenceDueAt', '')::timestamptz > NOW() )::int AS vip_evidence_pending_count, COALESCE( - SUM(worker_settlement_amount) FILTER ( + SUM(amount) FILTER ( WHERE status = 'accepted' AND acceptance_json->>'reviewMode' = 'vip_auto' AND acceptance_json->>'evidenceStatus' = 'pending' @@ -763,8 +771,41 @@ export async function getWorkerOrderOverview(workerId: number | string): Promise ), 0 )::int AS vip_evidence_pending_settlement_amount - FROM worker_orders + FROM order_status_rows GROUP BY status + + UNION ALL + + SELECT + 'all' AS status, + COUNT(DISTINCT work_order_id)::int AS order_count, + COALESCE( + SUM( + CASE + WHEN status IN ('in_progress', 'pending_acceptance', 'problem', 'accepted') + OR (status = 'open' AND is_worker_share) + THEN amount + ELSE 0 + END + ), + 0 + )::int AS settlement_amount, + COUNT(*) FILTER ( + WHERE status = 'accepted' + AND acceptance_json->>'reviewMode' = 'vip_auto' + AND acceptance_json->>'evidenceStatus' = 'pending' + AND NULLIF(acceptance_json->>'evidenceDueAt', '')::timestamptz > NOW() + )::int AS vip_evidence_pending_count, + COALESCE( + SUM(amount) FILTER ( + WHERE status = 'accepted' + AND acceptance_json->>'reviewMode' = 'vip_auto' + AND acceptance_json->>'evidenceStatus' = 'pending' + AND NULLIF(acceptance_json->>'evidenceDueAt', '')::timestamptz > NOW() + ), + 0 + )::int AS vip_evidence_pending_settlement_amount + FROM order_status_rows `, [normalizedWorkerId], ) @@ -1277,9 +1318,35 @@ export function buildWorkOrderWhere({ params.push(normalizedStatuses) const statusesPlaceholder = `$${params.length}` if (workerSharingId) { - filters.push( - `${workerOrderStatusExpression(workerSharingId)} = ANY(${statusesPlaceholder}::text[])`, - ) + const representativeStatus = `${workerOrderStatusExpression(workerSharingId)} = ANY(${statusesPlaceholder}::text[])` + // 一个工单可能同时有「进行中 + 待验收」等多份不同状态的份额。 + // 只看代表份额会让其中一种状态在页签里完全消失,所以只要任一份额命中就展示。 + const anyShareStatus = `( + wo.status NOT IN ('accepted', 'cancelled', 'problem') + AND EXISTS ( + SELECT 1 + FROM work_order_shares ws_filter + WHERE ws_filter.work_order_id = wo.id + AND ws_filter.worker_id = ${workerSharingId} + AND ws_filter.status IN ('joined', 'submitted', 'accepted') + AND ( + ( + ws_filter.status = 'joined' + AND 'in_progress' = ANY(${statusesPlaceholder}::text[]) + AND NOT ${workerPendingHandlingExistsExpression(workerSharingId)} + ) + OR ( + ws_filter.status = 'submitted' + AND 'pending_acceptance' = ANY(${statusesPlaceholder}::text[]) + ) + OR ( + ws_filter.status = 'accepted' + AND 'accepted' = ANY(${statusesPlaceholder}::text[]) + ) + ) + ) + )` + filters.push(`(${representativeStatus} OR ${anyShareStatus})`) } else { filters.push( `${useOperationalStatus ? adminOperationalStatusExpression('wo', 'operational_share_summary') : 'wo.status'} = ANY(${statusesPlaceholder}::text[])`, diff --git a/apps/backend/src/repositories/worker-platform/worker-active-order-count.test.ts b/apps/backend/src/repositories/worker-platform/worker-active-order-count.test.ts new file mode 100644 index 00000000..035addf8 --- /dev/null +++ b/apps/backend/src/repositories/worker-platform/worker-active-order-count.test.ts @@ -0,0 +1,49 @@ +import assert from 'node:assert/strict' +import { readFile } from 'node:fs/promises' +import test from 'node:test' + +/** + * 回归:同一打手在同一拼单工单里有多份份额时,活跃接单数只应计 1 单。 + * + * 之前 countWorkerActiveOrders 仍是「指派单数 + 拼单份额行数」直接相加, + * 打手资料页、抢普通单和后台指派会把同工单的多份拼单算成多单,提前撞接单上限。 + * 事务内版本已经去重,这里把「事务内外共用同一段计数 SQL」钉死,防止再次分裂。 + */ +async function readRepoSource() { + return readFile(new URL('./work-order-query-repo.ts', import.meta.url), 'utf8') +} + +/** 去掉 SQL 行注释与 TS 行注释,避免注释里的旧写法被误判成代码。 */ +function stripComments(source: string) { + return source + .split('\n') + .map((line) => line.replace(/--.*$/, '').replace(/\/\/.*$/, '')) + .join('\n') +} + +test('活跃接单数按工单去重,同一工单多份拼单只占 1 个名额', async () => { + const source = stripComments(await readRepoSource()) + const start = source.indexOf('const COUNT_WORKER_ACTIVE_ORDERS_SQL') + assert.ok(start >= 0, '缺少共享的活跃单计数 SQL 常量') + + const sql = source.slice(start, start + 800) + assert.match(sql, /UNION/, '活跃单计数必须用 UNION 按工单去重') + assert.match(sql, /work_order_shares/, '活跃单计数必须包含拼单份额') + assert.match(sql, /swo\.status = 'open'/, '只统计母单仍在大厅的拼单份额') +}) + +test('事务内外共用同一段活跃单计数 SQL,避免口径分裂', async () => { + const source = stripComments(await readRepoSource()) + + for (const fn of ['countWorkerActiveOrders', 'countWorkerActiveOrdersWithClient']) { + const start = source.indexOf(`export async function ${fn}`) + assert.ok(start >= 0, `缺少函数 ${fn}`) + + const block = source.slice(start, start + 700) + assert.ok( + block.includes('COUNT_WORKER_ACTIVE_ORDERS_SQL'), + `${fn} 没有复用共享的活跃单计数 SQL`, + ) + assert.ok(!block.includes(') + ('), `${fn} 仍在把「指派单数 + 拼单份额行数」直接相加`) + } +}) diff --git a/apps/backend/src/services/realtime/realtime-event-service.ts b/apps/backend/src/services/realtime/realtime-event-service.ts index a149dd8d..487de6cc 100644 --- a/apps/backend/src/services/realtime/realtime-event-service.ts +++ b/apps/backend/src/services/realtime/realtime-event-service.ts @@ -3,9 +3,13 @@ import type { Response } from 'express' import { WORK_ORDER_STATUS } from '../../domain/work-order-status.js' import { + getLatestMaterialEventAtByOrderIds, + getPendingCancelRequestOrderIds, + getPendingFeedbackOrderIds, getWorkerFinanceRequestById, getWorkerUserById, getWorkOrderById, + listWorkerTimeoutEventsByOrderIds, listWorkerWorkOrderNotes, listWorkerWorkOrderViews, listWorkOrderShares, @@ -23,10 +27,13 @@ import { mapAdminOrderListItem } from '../admin/admin-order-read-helpers.js' import { mapAdminTaskListItem } from '../admin/admin-task-read-helpers.js' import { isWorkOrderSharingFilled, + isWorkerOrderVipEvidencePending, mapFinanceRequest, mapWorkOrderAdmin, mapWorkOrderForWorker, mapWorkerUser, + pickWorkerRepresentativeShare, + resolveWorkerOrderStatusTabs, resolveWorkerPermissions, } from '../worker-platform/mappers.js' import { invalidateWorkerMyOrderCaches } from '../worker-platform/worker-my-order-cache.js' @@ -336,33 +343,121 @@ async function publishWorkOrderSnapshots(input: { }) } + // 资料更新时间只与工单有关,提到循环外只查一次(参与人数多时省掉 N 次查询)。 + const materialEventAt = + (await getLatestMaterialEventAtByOrderIds([workOrderId])).get(workOrderId) || '' + for (const workerId of new Set( (input.workerIds || []).map((item) => Number(item)).filter(Boolean), )) { const worker = await getWorkerUserById(workerId) if (!worker) continue - const [notes, views] = await Promise.all([ - listWorkerWorkOrderNotes(workerId, [workOrderId]), - listWorkerWorkOrderViews(workerId, [workOrderId]), - ]) - const note = notes.find((item) => Number(item.work_order_id) === workOrderId)?.note || '' - const viewedAt = - views.find((item) => Number(item.work_order_id) === workOrderId)?.viewed_at || null - const share = shares.find((item) => Number(item.worker_id) === workerId) + + // 同一打手在同一工单可能有多份份额(多次拼单),必须按「全部份额」还原这一行: + // 旧实现用 shares.find(...) 只取最早一条,多份额时会把可见性、个人状态、金额都算错, + // 还会让实时补丁把行从错误的页签删掉(打手端偶发「页签里没有数据」)。 const isAssignedWorker = Number(workOrder.assigned_worker_id || 0) === workerId - const workerOrderVisible = isAssignedWorker || Boolean(share && share.status !== 'cancelled') + const workerShares = shares.filter((item) => Number(item.worker_id) === workerId) + const activeWorkerShares = workerShares.filter((item) => item.status !== 'cancelled') + const representativeShare = pickWorkerRepresentativeShare( + activeWorkerShares.length > 0 ? activeWorkerShares : workerShares, + ) + const cancelledShare = workerShares + .filter((item) => item.status === 'cancelled') + .sort((left, right) => Number(right.id) - Number(left.id))[0] + const isOwnerCurrently = isAssignedWorker || activeWorkerShares.length > 0 + const workerOrderVisible = isOwnerCurrently + + // 只有「已不持有该单」时才需要判断超时归属,避免给正常在办的单多打一次查询。 + const needsTimeoutCheck = !isOwnerCurrently + const [notes, views, pendingCancelOrderIds, pendingFeedbackOrderIds, timeoutEventsByOrderId] = + await Promise.all([ + listWorkerWorkOrderNotes(workerId, [workOrderId]), + listWorkerWorkOrderViews(workerId, [workOrderId]), + getPendingCancelRequestOrderIds(workerId, [workOrderId]), + getPendingFeedbackOrderIds(workerId, [workOrderId]), + needsTimeoutCheck + ? listWorkerTimeoutEventsByOrderIds(workerId, [workOrderId]) + : Promise.resolve(new Map()), + ]) + const note = notes.find((item) => Number(item.work_order_id) === workOrderId)?.note || '' + const view = views.find((item) => Number(item.work_order_id) === workOrderId) + const viewedAt = view?.viewed_at || null + const timeoutEvent = timeoutEventsByOrderId.get(workOrderId) + const isTimedOutForMe = + !isOwnerCurrently && Boolean(timeoutEvent || (cancelledShare && workOrder.sharing_enabled)) + + const timeoutNotice = isTimedOutForMe + ? { + isTimedOut: true, + timedOutAt: + timeoutEvent?.createdAt || + (workOrder.deadline_at ? String(workOrder.deadline_at) : null) || + workOrder.updated_at, + warningMessage: + (workOrder.assigned_worker_id != null && + Number(workOrder.assigned_worker_id) !== workerId) || + ['in_progress', 'submitted', 'pending_acceptance', 'accepted'].includes( + workOrder.status, + ) + ? '该订单已超时返回大厅并被其他打手接手,请勿继续在游戏内赠送!若私自继续赠送,造成的财产损失平台概不负责。' + : '该订单已超时退回抢单大厅,您已失去接单资格,请勿在游戏内赠送!若私自继续赠送,造成的财产损失平台概不负责。', + isTakenByOther: + (workOrder.assigned_worker_id != null && + Number(workOrder.assigned_worker_id) !== workerId) || + ['in_progress', 'submitted', 'pending_acceptance', 'accepted'].includes( + workOrder.status, + ), + } + : undefined + + const hasPendingHandling = + pendingCancelOrderIds.has(workOrderId) || pendingFeedbackOrderIds.has(workOrderId) + const materialSeenAt = view?.material_seen_at || '' + const vipEvidencePending = isWorkerOrderVipEvidencePending(workOrder) + publishRealtimeEvent({ type: 'work_order.changed', entityId: workOrderId, scopes: ['worker_my_orders'], operation: workerOrderVisible ? 'upsert' : 'remove', - data: mapWorkOrderForWorker( - workOrder, - resolveWorkerPermissions(worker), - share, - note, - viewedAt, - ), + data: { + ...mapWorkOrderForWorker( + workOrder, + resolveWorkerPermissions(worker), + representativeShare, + note, + viewedAt, + { + isTimedOutForMe, + ...(timeoutNotice ? { timeoutNotice } : {}), + // 与列表一致:只带未取消份额,按 id DESC(参与先后倒序) + myShares: [...activeWorkerShares].sort( + (left, right) => Number(right.id) - Number(left.id), + ), + }, + ), + /** + * 这张单在该打手「我的订单」里属于哪些状态页签。 + * 多份额工单可同时属于代练中/待验收,单个 status 字段表达不了,前端据此判归属。 + */ + workerStatuses: resolveWorkerOrderStatusTabs({ + workOrderStatus: workOrder.status, + isAssignedWorker, + shares: workerShares, + hasPendingHandling, + hasTimeoutForMe: Boolean(timeoutEvent), + vipEvidencePending, + }), + problemMarked: !isTimedOutForMe && workOrder.status === WORK_ORDER_STATUS.PROBLEM, + pendingCancelRequest: !isTimedOutForMe && pendingCancelOrderIds.has(workOrderId), + pendingFeedback: !isTimedOutForMe && pendingFeedbackOrderIds.has(workOrderId), + materialUpdated: + !isTimedOutForMe && + Boolean(materialEventAt) && + (!materialSeenAt || + new Date(materialEventAt).getTime() > new Date(materialSeenAt).getTime()), + }, workerIds: [workerId], }) } diff --git a/apps/backend/src/services/worker-platform/mappers.ts b/apps/backend/src/services/worker-platform/mappers.ts index 6b9162d3..cac7d695 100644 --- a/apps/backend/src/services/worker-platform/mappers.ts +++ b/apps/backend/src/services/worker-platform/mappers.ts @@ -83,6 +83,7 @@ export { } from './worker-finance-mappers.js' export { + isWorkerOrderVipEvidencePending, mapCollectLookupOrder, mapWorkCategory, mapWorkOrderAdmin, @@ -91,6 +92,8 @@ export { mapWorkOrderPublic, mapWorkOrderShare, mapWorkOrderSharingWorkers, + pickWorkerRepresentativeShare, + resolveWorkerOrderStatusTabs, resolveWorkOrderOperationalStatus, resolveWorkerShareProductName, resolveWorkerShareStatus, diff --git a/apps/backend/src/services/worker-platform/work-order-display-mappers.test.ts b/apps/backend/src/services/worker-platform/work-order-display-mappers.test.ts new file mode 100644 index 00000000..b417b155 --- /dev/null +++ b/apps/backend/src/services/worker-platform/work-order-display-mappers.test.ts @@ -0,0 +1,220 @@ +import assert from 'node:assert/strict' +import test from 'node:test' + +import type { WorkOrderShareRow } from '../../repositories/worker-platform/types.js' +import { + isWorkerOrderVipEvidencePending, + pickWorkerRepresentativeShare, + resolveWorkerOrderStatusTabs, + summarizeWorkerShares, +} from './work-order-display-mappers.js' + +function share(overrides: Record): WorkOrderShareRow { + return { + id: 1, + work_order_id: 1, + worker_id: 1, + quantity: 1, + unit_reward: 10, + share_reward: 10, + share_deposit: 0, + status: 'joined', + deadline_at: null, + timeout_minutes: 0, + created_at: '2026-10-23T10:00:00.000Z', + ...overrides, + } as unknown as WorkOrderShareRow +} + +test('同一工单多份份额:数量/奖励/押金按份额求和,已取消份额不计入金额', () => { + const summary = summarizeWorkerShares([ + share({ + id: 1, + quantity: 2, + share_reward: 20, + share_deposit: 5, + status: 'joined', + created_at: '2026-10-23T10:00:00.000Z', + deadline_at: '2026-10-23T12:00:00.000Z', + }), + share({ + id: 2, + quantity: 3, + share_reward: 30, + share_deposit: 5, + status: 'joined', + created_at: '2026-10-23T10:10:00.000Z', + deadline_at: '2026-10-23T11:00:00.000Z', + }), + share({ + id: 3, + quantity: 9, + share_reward: 90, + share_deposit: 99, + status: 'cancelled', + created_at: '2026-10-23T09:00:00.000Z', + }), + ]) + + assert.equal(summary.quantity, 5) + assert.equal(summary.rewardAmount, 50) + assert.equal(summary.depositAmount, 10) + // 有有效份额时,接单时间取第一份有效份额,不取更早的已取消记录。 + assert.equal(summary.firstJoinedAt, '2026-10-23T10:00:00.000Z') + // 父行倒计时取未完成份额中最早的截止时间。 + assert.equal(summary.earliestActiveDeadlineAt, '2026-10-23T11:00:00.000Z') +}) + +test('历史份额没有 deadline_at 时,用份额自己的 timeout_minutes 兜底', () => { + const summary = summarizeWorkerShares([ + share({ + id: 1, + quantity: 2, + share_reward: 20, + status: 'joined', + created_at: '2026-10-23T10:00:00.000Z', + deadline_at: null, + timeout_minutes: 30, + }), + ]) + + assert.equal(summary.earliestActiveDeadlineAt, '2026-10-23T10:30:00.000Z') +}) + +test('只剩已取消份额时,金额归零但保留最早参与时间', () => { + const summary = summarizeWorkerShares([ + share({ + id: 1, + quantity: 2, + share_reward: 20, + status: 'cancelled', + created_at: '2026-10-23T09:00:00.000Z', + }), + share({ + id: 2, + quantity: 3, + share_reward: 30, + status: 'cancelled', + created_at: '2026-10-23T09:30:00.000Z', + }), + ]) + + assert.equal(summary.quantity, 0) + assert.equal(summary.rewardAmount, 0) + assert.equal(summary.depositAmount, 0) + assert.equal(summary.firstJoinedAt, '2026-10-23T09:00:00.000Z') + assert.equal(summary.earliestActiveDeadlineAt, null) +}) + +test('代表份额与列表 LATERAL 同序:joined > submitted > accepted > cancelled,再按创建时间/id 倒序', () => { + const shares = [ + share({ id: 1, status: 'cancelled', created_at: '2026-10-23T09:00:00.000Z' }), + share({ id: 2, status: 'submitted', created_at: '2026-10-23T09:30:00.000Z' }), + share({ id: 3, status: 'joined', created_at: '2026-10-23T10:00:00.000Z' }), + ] + assert.equal(pickWorkerRepresentativeShare(shares)?.id, 3) + + // 同状态取更晚创建的一份(旧实现取的是「最早一条」,多份额时必然挑错) + const sameStatus = [ + share({ id: 10, status: 'submitted', created_at: '2026-10-23T09:00:00.000Z' }), + share({ id: 11, status: 'submitted', created_at: '2026-10-23T10:00:00.000Z' }), + share({ id: 12, status: 'submitted', created_at: '2026-10-23T10:00:00.000Z' }), + ] + assert.equal(pickWorkerRepresentativeShare(sameStatus)?.id, 12) +}) + +test('多份额工单(一份进行中 + 一份待验收)必须同时属于两个页签', () => { + const tabs = resolveWorkerOrderStatusTabs({ + workOrderStatus: 'in_progress', + isAssignedWorker: false, + shares: [share({ id: 1, status: 'joined' }), share({ id: 2, status: 'submitted' })], + }) + assert.deepEqual(tabs.sort(), ['in_progress', 'pending_acceptance']) +}) + +test('有待审核取消申请/未处理反馈时,进行中份额归问题单,但待验收份额仍留在待验收', () => { + const tabs = resolveWorkerOrderStatusTabs({ + workOrderStatus: 'in_progress', + isAssignedWorker: false, + shares: [share({ id: 1, status: 'joined' }), share({ id: 2, status: 'submitted' })], + hasPendingHandling: true, + }) + assert.deepEqual(tabs.sort(), ['pending_acceptance', 'problem']) +}) + +test('母单进入终态后只按母单状态归类(与 SQL 关闭 anyShare 分支一致)', () => { + const tabs = resolveWorkerOrderStatusTabs({ + workOrderStatus: 'problem', + isAssignedWorker: true, + shares: [share({ id: 1, status: 'submitted' })], + }) + assert.deepEqual(tabs, ['problem']) +}) + +test('份额全部取消时只归已取消,不得混进待验收', () => { + const tabs = resolveWorkerOrderStatusTabs({ + workOrderStatus: 'pending_acceptance', + isAssignedWorker: true, + shares: [share({ id: 1, status: 'cancelled' })], + }) + assert.deepEqual(tabs, ['cancelled']) +}) + +test('没有份额的指派单按母单状态归类;超时退回后归已超时', () => { + assert.deepEqual( + resolveWorkerOrderStatusTabs({ + workOrderStatus: 'pending_acceptance', + isAssignedWorker: true, + shares: [], + }), + ['pending_acceptance'], + ) + assert.deepEqual( + resolveWorkerOrderStatusTabs({ + workOrderStatus: 'pending_acceptance', + isAssignedWorker: false, + shares: [share({ id: 1, status: 'cancelled' })], + hasTimeoutForMe: true, + }), + ['open'], + ) +}) + +test('VIP 免审核待补图:只有已验收 + vip_auto + 待补图 + 未到期才归入该页签', () => { + const now = Date.parse('2026-10-23T10:00:00.000Z') + const base = { + status: 'accepted', + acceptance_json: { + reviewMode: 'vip_auto', + evidenceStatus: 'pending', + evidenceDueAt: '2026-10-24T10:00:00.000Z', + }, + } + assert.equal(isWorkerOrderVipEvidencePending(base, now), true) + assert.equal( + isWorkerOrderVipEvidencePending( + { ...base, acceptance_json: { ...base.acceptance_json, evidenceStatus: 'submitted' } }, + now, + ), + false, + ) + assert.equal( + isWorkerOrderVipEvidencePending( + { + ...base, + acceptance_json: { ...base.acceptance_json, evidenceDueAt: '2026-10-22T10:00:00.000Z' }, + }, + now, + ), + false, + ) + assert.deepEqual( + resolveWorkerOrderStatusTabs({ + workOrderStatus: 'accepted', + isAssignedWorker: true, + shares: [share({ id: 1, status: 'accepted' })], + vipEvidencePending: true, + }).sort(), + ['accepted', 'vip_evidence_pending'], + ) +}) diff --git a/apps/backend/src/services/worker-platform/work-order-display-mappers.ts b/apps/backend/src/services/worker-platform/work-order-display-mappers.ts index 5e5d24c0..8deee423 100644 --- a/apps/backend/src/services/worker-platform/work-order-display-mappers.ts +++ b/apps/backend/src/services/worker-platform/work-order-display-mappers.ts @@ -597,6 +597,147 @@ export function resolveWorkerShareStatus(workOrderStatus: string, shareStatus: u return workOrderStatus } +/** 打手端「我的订单」状态页签键,与 buildWorkOrderWhere 的打手状态筛选同口径。 */ +export type WorkerOrderStatusTab = + | 'in_progress' + | 'pending_acceptance' + | 'problem' + | 'open' + | 'accepted' + | 'cancelled' + | 'vip_evidence_pending' + +/** 母单进入终态后,列表只按母单状态归类(任一命中分支会被 wo.status NOT IN (...) 关掉)。 */ +const TERMINAL_WORK_ORDER_STATUSES = new Set(['accepted', 'cancelled', 'problem']) + +/** 代表份额优先级,与 SQL LATERAL 的 CASE 顺序一致:joined > submitted > accepted > cancelled。 */ +const WORKER_SHARE_STATUS_PRIORITY: Record = { + joined: 0, + submitted: 1, + accepted: 2, + cancelled: 3, +} + +type WorkerShareLike = { + status?: unknown + created_at?: unknown + id?: unknown +} + +/** + * 同一打手在一个工单里的「代表份额」。 + * + * 实时广播过去用 `shares.find(worker_id === ...)`(= 最早一条),同一打手多次拼单时会挑到 + * 已取消/进行中的那份,进而把快照算成「不在可见范围」或错误的个人状态,导致打手端某个 + * 状态页签的行被实时补丁删掉(表现为「待验收有时候没有数据」)。 + * 这里与 `work-order-query-repo` 的 LATERAL 排序完全一致: + * status 优先级 ASC, created_at DESC, id DESC + */ +export function pickWorkerRepresentativeShare( + shares: readonly T[], +): T | null { + let representative: T | null = null + for (const share of shares) { + if (!representative) { + representative = share + continue + } + const priority = WORKER_SHARE_STATUS_PRIORITY[String(share.status || '')] ?? 4 + const representativePriority = + WORKER_SHARE_STATUS_PRIORITY[String(representative.status || '')] ?? 4 + if (priority !== representativePriority) { + if (priority < representativePriority) representative = share + continue + } + const createdMs = Date.parse(String(share.created_at || '')) + const representativeCreatedMs = Date.parse(String(representative.created_at || '')) + if (Number.isFinite(createdMs) && Number.isFinite(representativeCreatedMs)) { + if (createdMs !== representativeCreatedMs) { + if (createdMs > representativeCreatedMs) representative = share + continue + } + } + if (Number(share.id || 0) > Number(representative.id || 0)) representative = share + } + return representative +} + +/** + * 一个工单在该打手「我的订单」里会出现在哪些状态页签。 + * + * 为什么需要它:多份额工单可以同时属于「代练中」和「待验收」(一份 joined + 一份 submitted), + * 但实时快照只能带一个 `status` 字段。前端旧逻辑只要「快照状态 ≠ 当前页签」就把行从缓存删掉, + * 于是同一张单在其中一个页签凭空消失。广播里带上完整页签集合后,前端按集合判归属即可。 + * + * 口径逐条对齐 `buildWorkOrderWhere`(打手状态筛选): + * - 母单 accepted/cancelled/problem:只看代表状态(母单状态); + * - 其余:代表状态 + 「任一有效份额命中」(joined 需无待处理申请,submitted/accepted 直接命中); + * - 有该打手的超时事件且他已不持有订单:代表状态记 open。 + */ +export function resolveWorkerOrderStatusTabs(input: { + workOrderStatus: string + isAssignedWorker: boolean + /** 该打手在该工单的全部份额(含已取消),用于还原代表份额。 */ + shares?: readonly WorkerShareLike[] + /** 存在待审核取消申请或未处理反馈(进行中的份额会归入问题单)。 */ + hasPendingHandling?: boolean + /** 该打手在这张单上有超时事件。 */ + hasTimeoutForMe?: boolean + /** 命中「VIP 免审核待补图」筛选(列表里它是独立筛选,不参与 status 参数)。 */ + vipEvidencePending?: boolean +}): WorkerOrderStatusTab[] { + const tabs = new Set() + const workOrderStatus = String(input.workOrderStatus || '') + if (input.vipEvidencePending) tabs.add('vip_evidence_pending') + + if (TERMINAL_WORK_ORDER_STATUSES.has(workOrderStatus)) { + tabs.add(workOrderStatus as WorkerOrderStatusTab) + return [...tabs] + } + + const shares = input.shares || [] + const activeShares = shares.filter( + (share) => + share.status === 'joined' || share.status === 'submitted' || share.status === 'accepted', + ) + const representative = pickWorkerRepresentativeShare(shares) + let representativeStatus = representative + ? resolveWorkerShareStatus(workOrderStatus, representative.status) + : workOrderStatus + if (input.hasTimeoutForMe && !input.isAssignedWorker && activeShares.length === 0) { + representativeStatus = WORK_ORDER_STATUS.OPEN + } else if (representativeStatus === WORK_ORDER_STATUS.IN_PROGRESS && input.hasPendingHandling) { + representativeStatus = WORK_ORDER_STATUS.PROBLEM + } + if (representativeStatus) tabs.add(representativeStatus as WorkerOrderStatusTab) + + for (const share of activeShares) { + if (share.status === 'joined') { + // 挂待处理申请/反馈的进行中份额归问题单,不再计入代练中(与 SQL 的 anyShare 分支一致)。 + if (!input.hasPendingHandling) tabs.add('in_progress') + } else if (share.status === 'submitted') { + tabs.add('pending_acceptance') + } else if (share.status === 'accepted') { + tabs.add('accepted') + } + } + + return [...tabs] +} + +/** 「VIP 免审核,待补图」判定,口径同 `buildWorkOrderWhere` 的 vipEvidencePendingWhere。 */ +export function isWorkerOrderVipEvidencePending( + workOrder: Pick, + nowMs = Date.now(), +): boolean { + if (String(workOrder.status || '') !== WORK_ORDER_STATUS.ACCEPTED) return false + const acceptance = mapAcceptanceForResponse(workOrder.acceptance_json) as Record + if (String(acceptance.reviewMode || '') !== 'vip_auto') return false + if (String(acceptance.evidenceStatus || '') !== 'pending') return false + const dueAtMs = Date.parse(String(acceptance.evidenceDueAt || '')) + return Number.isFinite(dueAtMs) && dueAtMs > nowMs +} + export function mapWorkOrderShare(share: WorkOrderShareRow) { return { shareId: Number(share.id), diff --git a/apps/backend/src/services/worker-platform/worker-my-order-service.ts b/apps/backend/src/services/worker-platform/worker-my-order-service.ts index fe9cd24a..2619012d 100644 --- a/apps/backend/src/services/worker-platform/worker-my-order-service.ts +++ b/apps/backend/src/services/worker-platform/worker-my-order-service.ts @@ -245,25 +245,31 @@ export async function getWorkerMyOrderOverview(session: WorkerSession) { buildMyOrderCacheKey(worker.id, 'overview', 0), () => getWorkerOrderOverview(worker.id), ) - const items = rows.map((row) => ({ + const statusRows = rows.filter((row) => row.status !== 'all') + const items = statusRows.map((row) => ({ status: row.status, orderCount: row.orderCount, settlementAmount: row.settlementAmount, })) - const vipEvidencePending = rows.reduce( + const vipEvidencePending = statusRows.reduce( (summary, row) => ({ orderCount: summary.orderCount + row.vipEvidencePendingCount, settlementAmount: summary.settlementAmount + row.vipEvidencePendingSettlementAmount, }), { orderCount: 0, settlementAmount: 0 }, ) - const all = items.reduce( - (summary, item) => ({ - orderCount: summary.orderCount + item.orderCount, - settlementAmount: summary.settlementAmount + item.settlementAmount, - }), - { orderCount: 0, settlementAmount: 0 }, - ) + // 混合状态的多份额工单会同时出现在多个页签,不能再用各页签相加当「全部订单」, + // 否则同一工单会被重复计数;优先使用 SQL 里按 distinct 工单算好的 all 行。 + const allRow = rows.find((row) => row.status === 'all') + const all = allRow + ? { orderCount: allRow.orderCount, settlementAmount: allRow.settlementAmount } + : items.reduce( + (summary, item) => ({ + orderCount: summary.orderCount + item.orderCount, + settlementAmount: summary.settlementAmount + item.settlementAmount, + }), + { orderCount: 0, settlementAmount: 0 }, + ) return { items: [ ...items, diff --git a/apps/frontend/src/pages/worker/WorkerOrderShareTable.tsx b/apps/frontend/src/pages/worker/WorkerOrderShareTable.tsx new file mode 100644 index 00000000..ee16d0a6 --- /dev/null +++ b/apps/frontend/src/pages/worker/WorkerOrderShareTable.tsx @@ -0,0 +1,213 @@ +import { Button, Space, Table, Tag, Typography } from 'antd' +import type { TableColumnsType } from 'antd' + +import { DeadlineCountdown } from '@/components/DeadlineCountdown' +import ImagePreviewList from '@/components/files/ImagePreviewList' +import type { WorkOrder, WorkOrderShare } from '@/types/worker-platform' +import { formatDateTime } from '@/utils/date-time' + +import { formatMoney, getOrderShares, getUniqueAcceptanceFiles } from './worker-order-utils' + +const SHARE_STATUS_META: Record = { + joined: { label: '进行中', color: 'processing' }, + submitted: { label: '待验收', color: 'orange' }, + accepted: { label: '已验收', color: 'green' }, + cancelled: { label: '已取消', color: 'default' }, +} + +/** + * 一个工单一行,展开后展示该打手在这个工单里的每一份份额。 + * 数量 / 奖励 / 押金直接取份额字段,与后台「拼单明细」同口径。 + */ +export function WorkerOrderShareTable({ + order, + mobile = false, + onOpenAcceptance, + onOpenCancelRequest, + onRemindAcceptance, +}: { + order: WorkOrder + mobile?: boolean + onOpenAcceptance: (order: WorkOrder, shareId: number) => void + onOpenCancelRequest: (order: WorkOrder, shareId: number) => void + onRemindAcceptance: (order: WorkOrder) => void +}) { + const shares = getOrderShares(order) + .slice() + .sort((left, right) => { + const byCreatedAt = String(left.createdAt || '').localeCompare(String(right.createdAt || '')) + return byCreatedAt !== 0 ? byCreatedAt : left.shareId - right.shareId + }) + const isTimedOut = order.status === 'open' || Boolean(order.timeoutNotice?.isTimedOut) + + if (shares.length === 0) return null + + const columns: TableColumnsType = [ + { + title: '份额', + width: 92, + render: (_, share) => ( +
+ + 第 {shares.findIndex((item) => item.shareId === share.shareId) + 1} 份 + + + #{share.shareId} + +
+ ), + }, + { + title: '数量', + width: 80, + render: (_, share) => {share.quantity} 份, + }, + { + title: '奖励', + width: 110, + render: (_, share) => ( + + {formatMoney(share.shareReward)} + + ), + }, + { + title: '押金', + width: 100, + render: (_, share) => formatMoney(share.shareDeposit), + }, + { + title: '状态', + width: 96, + render: (_, share) => { + const meta = SHARE_STATUS_META[String(share.status)] || { + label: String(share.status || '-'), + color: 'default', + } + return {meta.label} + }, + }, + { + title: '截止时间', + width: 170, + render: (_, share) => { + if (!share.deadlineAt) { + return ( + + 不限时 + + ) + } + return ( +
+ + {formatDateTime(share.deadlineAt)} + + {share.status === 'joined' && !isTimedOut ? ( + + ) : null} +
+ ) + }, + }, + { + title: '验收信息', + width: 220, + render: (_, share) => , + }, + { + title: '操作', + width: 190, + render: (_, share) => { + const canAccept = !isTimedOut && share.status === 'joined' + const canEdit = !isTimedOut && ['joined', 'submitted'].includes(String(share.status)) + const canRemind = !isTimedOut && share.status === 'submitted' + const canCancel = !isTimedOut && ['joined', 'submitted'].includes(String(share.status)) + if (!canAccept && !canEdit && !canRemind && !canCancel) { + return ( + + 无 + + ) + } + return ( + + {canAccept ? ( + + ) : null} + {canEdit && share.status === 'submitted' ? ( + + ) : null} + {canRemind ? ( + + ) : null} + {canCancel ? ( + + ) : null} + + ) + }, + }, + ] + + return ( + + rowKey="shareId" + size="small" + bordered={false} + pagination={false} + dataSource={shares} + columns={columns} + className="worker-order-share-table" + scroll={{ x: mobile ? 820 : 1080 }} + /> + ) +} + +function ShareAcceptanceCell({ share }: { share: WorkOrderShare }) { + const acceptanceFiles = getUniqueAcceptanceFiles(share.acceptance) + const draftFiles = getUniqueAcceptanceFiles(share.draftAcceptance) + if (acceptanceFiles.length === 0 && draftFiles.length === 0 && !share.submittedAt) { + return ( + + 未提交 + + ) + } + return ( +
+ {acceptanceFiles.length > 0 ? ( + + ) : null} + {share.submittedAt ? ( + + 提交:{formatDateTime(share.submittedAt)} + + ) : null} + {draftFiles.length > 0 && !share.submittedAt ? ( + 已暂存 {draftFiles.length} 张 + ) : null} + {String(share.acceptance?.note || '').trim() ? ( + + {share.acceptance.note} + + ) : null} +
+ ) +} diff --git a/apps/frontend/src/pages/worker/WorkerOrdersPage.tsx b/apps/frontend/src/pages/worker/WorkerOrdersPage.tsx index 50fd61c0..f96f5eae 100644 --- a/apps/frontend/src/pages/worker/WorkerOrdersPage.tsx +++ b/apps/frontend/src/pages/worker/WorkerOrdersPage.tsx @@ -1,4 +1,4 @@ -import { useQuery, useQueryClient } from '@tanstack/react-query' +import { keepPreviousData, useQuery, useQueryClient } from '@tanstack/react-query' import { useNavigate } from 'react-router' import { App, Badge, Button, Form, Modal, Radio, Space, Tag, Typography } from 'antd' import { useState } from 'react' @@ -193,6 +193,8 @@ export default function WorkerOrdersPage() { queryKey: ['worker-my-orders', status, keyword, page, pageSize], queryFn: () => fetchWorkerMyOrders(buildWorkerOrderQuery(status, keyword, page, pageSize)), staleTime: 2_000, + // 切页签时保留上一页数据,避免请求返回前先闪一下空列表。 + placeholderData: keepPreviousData, refetchOnWindowFocus: false, retry: false, }) diff --git a/apps/frontend/src/utils/request-id.ts b/apps/frontend/src/utils/request-id.ts new file mode 100644 index 00000000..846ed500 --- /dev/null +++ b/apps/frontend/src/utils/request-id.ts @@ -0,0 +1,8 @@ +/** 客户端幂等键:一次用户点击一个,用于拼单参与/抢单的服务端去重。 */ +export function createRequestId(prefix = 'req'): string { + const random = + typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function' + ? crypto.randomUUID() + : `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 10)}` + return `${prefix}-${random}` +} diff --git a/apps/frontend/src/utils/worker-realtime-work-orders.ts b/apps/frontend/src/utils/worker-realtime-work-orders.ts index 2fa19f5f..911335f4 100644 --- a/apps/frontend/src/utils/worker-realtime-work-orders.ts +++ b/apps/frontend/src/utils/worker-realtime-work-orders.ts @@ -89,7 +89,20 @@ export function resolveWorkerMyOrderRealtimeVerdict(input: { }): { operation: 'upsert' | 'remove'; shouldInsert: boolean } { const item = isRecord(input.item) ? input.item : {} const statusFilter = String(input.statusFilter || '') - const statusMatches = !statusFilter || statusFilter === String(item.status || '') + /** + * 服务端快照里的 `workerStatuses` 才是「这张单属于哪些页签」的准确答案。 + * + * 同一打手在同一工单可能有多份份额(多次拼单),一张单可以同时出现在「代练中」和「待验收」; + * 而快照的 `status` 只能是一个值。旧逻辑拿单个 status 跟页签比对,只要不相等就删行, + * 于是多份额工单总会在其中一个页签里被删掉(表现为「待验收有时候没有数据」)。 + * 老事件/其它来源没有该字段时,退回原来的单状态比对。 + */ + const tabs = Array.isArray(item.workerStatuses) + ? item.workerStatuses.map((tab) => String(tab || '')).filter(Boolean) + : [] + const statusMatches = + !statusFilter || + (tabs.length > 0 ? tabs.includes(statusFilter) : statusFilter === String(item.status || '')) const statusProvable = input.explicitOrderNo || statusMatches const shouldRemove = String(input.operation || '') === 'remove' || !statusProvable diff --git a/apps/frontend/tests/worker-realtime-work-orders.test.ts b/apps/frontend/tests/worker-realtime-work-orders.test.ts index 192984b6..4b832e36 100644 --- a/apps/frontend/tests/worker-realtime-work-orders.test.ts +++ b/apps/frontend/tests/worker-realtime-work-orders.test.ts @@ -188,3 +188,95 @@ test('非第 1 页不得插入,只做就地更新', () => { assert.equal(verdict.operation, 'upsert') assert.equal(verdict.shouldInsert, false) }) + +/** + * 同一打手在同一个工单多次拼单:一张单可以同时属于「代练中」和「待验收」, + * 但快照的 status 只能是一个值。前端若按单个 status 删行,另一个页签就会莫名其妙变空 + * (线上「待验收有时候没有数据」)。服务端现在带 workerStatuses,按集合判归属。 + */ +test('多份额工单:快照 status 是进行中,但属于待验收时不得从待验收页签删行', () => { + const snapshot = buildRow({ + status: 'in_progress', + workerStatuses: ['in_progress', 'pending_acceptance'], + }) + const verdict = resolveWorkerMyOrderRealtimeVerdict({ + item: snapshot, + statusFilter: 'pending_acceptance', + keyword: '', + operation: 'upsert', + page: 1, + explicitOrderNo: false, + }) + assert.equal(verdict.operation, 'upsert', '待验收页签必须保留这张多份额工单') + assert.equal(verdict.shouldInsert, true) + + const updated = patchWorkerWorkOrderListEnvelope( + buildEnvelope([buildRow({ status: 'pending_acceptance' })]), + { operation: verdict.operation, data: snapshot }, + verdict.shouldInsert, + ) + assert.equal(itemsOf(updated).length, 1) + assert.equal((itemsOf(updated)[0] as { status?: string }).status, 'in_progress') +}) + +test('多份额工单:workerStatuses 同时也是代练中页签的归属依据', () => { + const verdict = resolveWorkerMyOrderRealtimeVerdict({ + item: buildRow({ + status: 'in_progress', + workerStatuses: ['in_progress', 'pending_acceptance'], + }), + statusFilter: 'in_progress', + keyword: '', + operation: 'upsert', + page: 1, + explicitOrderNo: false, + }) + assert.equal(verdict.operation, 'upsert') +}) + +test('不在页签集合里时才允许删行(例如已验收后离开待验收页签)', () => { + const verdict = resolveWorkerMyOrderRealtimeVerdict({ + item: buildRow({ status: 'accepted', workerStatuses: ['accepted'] }), + statusFilter: 'pending_acceptance', + keyword: '', + operation: 'upsert', + page: 1, + explicitOrderNo: false, + }) + assert.equal(verdict.operation, 'remove') + assert.equal(verdict.shouldInsert, false) +}) + +test('VIP 待补图页签也靠 workerStatuses 判定,不会被当成状态不符删掉', () => { + const verdict = resolveWorkerMyOrderRealtimeVerdict({ + item: buildRow({ status: 'accepted', workerStatuses: ['accepted', 'vip_evidence_pending'] }), + statusFilter: 'vip_evidence_pending', + keyword: '', + operation: 'upsert', + page: 1, + explicitOrderNo: false, + }) + assert.equal(verdict.operation, 'upsert') +}) + +test('没有 workerStatuses 的旧快照仍按单个 status 比对(保持既有行为)', () => { + const mismatch = resolveWorkerMyOrderRealtimeVerdict({ + item: buildRow({ status: 'accepted' }), + statusFilter: 'pending_acceptance', + keyword: '', + operation: 'upsert', + page: 1, + explicitOrderNo: false, + }) + assert.equal(mismatch.operation, 'remove') + + const match = resolveWorkerMyOrderRealtimeVerdict({ + item: buildRow({ status: 'pending_acceptance' }), + statusFilter: 'pending_acceptance', + keyword: '', + operation: 'upsert', + page: 1, + explicitOrderNo: false, + }) + assert.equal(match.operation, 'upsert') +}) diff --git a/docs/工单拼单多次参与改造方案-2026-10-23.md b/docs/工单拼单多次参与改造方案-2026-10-23.md new file mode 100644 index 00000000..cce8a61f --- /dev/null +++ b/docs/工单拼单多次参与改造方案-2026-10-23.md @@ -0,0 +1,466 @@ +# 拼单多次参与改造方案(同一打手可多次参与、每份独立计时) + +> 状态:**方案(待确认,未执行)** +> 日期: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={… || Boolean(order.myShare)}`,第二次根本点不了 | + +补充事实:现有 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` 是该份额实际冻结的增量金额,释放/扣除都只针对它;同一打手在同一工单的多条份额互不覆盖。 + +--- + +## 四、数据库方案(迁移 `104_work_order_share_multi_participation.sql`) + +### 4.1 DDL 草案 + +```sql +-- 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 迁移前的数据体检(只读) + +```sql +-- 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 计算式(定稿) + +```text +// 本工单该打手已参与份数(含本次,排除 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 参与时固化 + +```text +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` 改为: + +```sql +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 新函数 + +```text +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/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 已确认:按不同工单) + +```sql +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={… || Boolean(order.myShare)}`;按钮文案改「再拼 1 份 / 抢剩余 N 份」;显示「已参与 2 次 · 共 3 份」 | +| `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. 迁移 104(加列 → 回填 → 删唯一键 → 建索引)。 +2. 后端发布(新逻辑兼容旧前端:`shareId` 可选、`myShare` 保留)。 +3. 前端发布(切到 `myShares`)。 +4. 观察 3 天:份额表增长、超时任务耗时、验收重复结算告警。 + +**回滚** + +- 代码回滚安全(旧代码读不到新列,但 `myShares` 字段多余无影响)。 +- **数据回滚不安全**:重新加回唯一键前必须合并重复份额。提供脚本草案: + +```sql +-- 回滚前合并:同一工单同一打手的多条份额合并成一条(只适用于未验收的 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 条历史脏状态仅记录未动资金 | +| 迁移 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` 路由守卫。均未触及本次改动文件,建议后续统一把源码守卫改成忽略行尾。 + diff --git a/docs/打手工单深挖-抢单拼单好友赠送结算-2026-10-23.md b/docs/打手工单深挖-抢单拼单好友赠送结算-2026-10-23.md new file mode 100644 index 00000000..bc23a932 --- /dev/null +++ b/docs/打手工单深挖-抢单拼单好友赠送结算-2026-10-23.md @@ -0,0 +1,467 @@ +# 打手工单深挖:抢单 / 拼单 / 好友赠送 / 结算(2026-10-23) + +> 状态:**梳理稿(待评审,未登记索引)** +> 落笔日期:2026-10-23 +> 代码基线:工作区当前 HEAD(迁移 001–103) +> 范围:`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
生命周期状态"] + WS["work_order_shares
拼单份额"] + EV["work_order_events
只追加时间线"] + end + subgraph 资金 + W["worker_wallets
available / frozen_deposit
pending_unfreeze / frozen_withdraw"] + L["worker_wallet_ledgers
只追加流水"] + U["worker_deposit_unfreezes
延迟解冻队列"] + 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, + , 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": "" } +``` + +补证据走 `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. **拼单是否允许「一人多份 + 全部单人完成」**?当前逻辑允许,且验收后一次性结算全部份额——如果业务上要求分散风险,这条需要改。 diff --git a/docs/项目全流程与数据库设计梳理-2026-10-23.md b/docs/项目全流程与数据库设计梳理-2026-10-23.md new file mode 100644 index 00000000..8dfb3385 --- /dev/null +++ b/docs/项目全流程与数据库设计梳理-2026-10-23.md @@ -0,0 +1,874 @@ +# order_site 全流程与数据库设计梳理(2026-10-23) + +> 状态:**梳理稿(待评审,尚未登记到 `docs/README.md`)** +> 落笔日期:2026-10-23 +> 代码基线:工作区当前 HEAD;数据库迁移 `001`–`103`(共 103 个文件,本地库 `schema_migrations` 记录 103 条,已全量应用) +> 核对方式:源码走查 + 本地 PostgreSQL 实读表结构 / 约束 / 索引 + 本地库状态与数据量统计 +> 说明:本地库与生产同量级(`orders` 约 37.5 万行),本文引用的数据量均为**本地库实测**,不与生产承诺一致,仅用于判断结构合理性。 + +--- + +## 一、先看结论(10 条) + +1. 系统早已不是「快手自动发货工具」,而是**三来源订单履约 + 打手众包接单 + 寄卖仓库 + 电子凭证核销 + 客服备用金台账**的一体化平台。 +2. 订单来源只有三个:`kuaishou-91`(91 回调 + 快手 send-code 两条通道,**同一个来源**)、`pdd`、`douyin-mashida`。代码层的来源键定义在 `apps/backend/src/services/product-match/types.ts`。 +3. **核心不变式:一个订单 = 骨架(`orders` + `order_items`)+ 买家凭证(快手电子凭证 / 抖音券)+ 一个执行载体**。载体只有三种:`affiliate_dash` 自动发货、`work_orders` 工单、`manual` 人工(当前无显式表,靠「无任务且无工单」判定)。 +4. 履约任务按「订单项 × 数量」拆单元:`fulfillment_tasks` 的 `UNIQUE(order_item_id, unit_index)` 是整套领取链接体系的基座。 +5. 领取链接 = `claimLinkBase + '/' + claim_tokens.token`,一任务一 token(`claim_tokens.task_id` 唯一);`CLAIM_TOKEN_TTL_HOURS=0` 表示永久有效。 +6. 打手侧是一条**独立业务线**:工单状态机 `pending_material → unassigned → open → in_progress → pending_acceptance → accepted`,另有 `problem`、`cancelled`;资金走「钱包 + 流水」模型,不是会计总账。 +7. 寄卖是第三个业务面:提报 → 审核入库(`INV…`)→ 规格匹配(系统/区服/系列/钻级/璀璨)→ 履约(内部现货 or 指派打手)→ 结算与撤销押金策略。 +8. 备用金台账(迁移 100)已落地:`fund_accounts`(主管池 / 客服账户)+ `fund_entries`(只追加流水,余额只能由流水变动)+ `manual_expenses` + `fund_reconciliations`。 +9. 数据库现状:**60 张业务表 + `schema_migrations`**,113 个外键、21 个索引的 `work_orders` 是全库索引最多的表;配置不入表结构,统一存 `app_config_entries`(本地 18 个配置键)。 +10. 主要架构性风险仍是 2026-10-17 报告列的那几处:资金门禁只在前端、Webhook 先落库后处理、`/collect` `/redeem` 无第二因子、抢单/指派锁序相反、单实例依赖进程内存。 + +--- + +## 二、系统地图 + +### 2.1 运行拓扑 + +```mermaid +flowchart LR + U1["外部平台
91卡券 / 快手 / 拼多多 / 抖音码速达"] --> C + U2["买家
领取页 / H5 / 兑换页"] --> C + U3["打手
worker.khhao.com"] --> C + U4["后台运营 / 客服 / 财务"] --> C + C["Caddy :80/443
反代 + 静态缓存 + 压缩"] --> F["frontend
React 19 + Vite(一套产物三个入口)"] + C --> B["backend
Express 5 + TS
routes → services → repositories → pg"] + B --> P[("PostgreSQL 16")] + B --> O["对象存储
生产 OSS / 本地 MinIO"] + B --> X["外部 API
affiliate-dash / 快手 OpenAPI / 抖音码速达 / PDD"] + B --> M["进程内存
限流桶 / 调度器 / SSE / 列表缓存"] +``` + +单实例假设写在代码里:限流、定时任务、SSE 订阅、列表缓存全部在进程内存(`middleware/rate-limit.ts`、`services/realtime/`),扩容前必须先外置。 + +### 2.2 后端入口与路由挂载 + +入口 `apps/backend/src/app.ts`: + +| 挂载点 | 说明 | +| --- | --- | +| `/health`、`/health/live`、`/health/ready` | 健康检查;未 ready 时其余请求统一 503 | +| `/api/v1/open/douyin-mashida` | **必须在全局 body parser 之前**挂载,签名基于原始 JSON 字符串 | +| `/api/v1/open/affiliate-dash` | 发货平台异步通知(验签 + 时间戳容差) | +| `/api/v1/open/91` | 91 卡券异步下单 / 查询(验签) | +| `/api/v1/open/kuaishou-industry` | 快手行业电子凭证 send-code / destroy / query / consume | +| `/api/v1/redeem` | 抖音券兑换页接口 | +| `/api/v1/claim` | 买家领取页(token 与 H5 两种入口) | +| `/api/v1/collect` | 待补全订单资料收集 | +| `/api/v1/pdd/extension` | 拼多多浏览器扩展上报 Cookie / 公钥 | +| `/api/v1/files` | 文件对象读取 | +| `/api/v1/worker` | 打手端(公开鉴权路由 + 会话路由) | +| `/api/dcauth` | 设备认证(dcauth 协议) | +| `/api/v1/admin` | 后台 API(auth 路由放行,其余全部 `requireAdminSession`) | +| `/s` | 短链跳转 | + +### 2.3 分层与模块职责 + +| 层 | 目录 | 职责 | +| --- | --- | --- | +| 路由 | `src/routes/**` | 参数解析、限流、鉴权、调用 service | +| 领域 | `src/domain/**` | 状态机与纯策略(任务、工单、寄卖、押金、验收模式) | +| 服务 | `src/services/**` | 订单、履约、工单平台、平台对接、财务、寄卖、调度 | +| 仓储 | `src/repositories/**` | 全部 SQL(含 `worker-platform/*` 大仓储) | +| 迁移 | `src/db/migrations` | 001 基线 + 102 个增量 | +| 前端 | `apps/frontend/src/pages/**` | `admin` 后台 / `worker` 打手端 / `claim` 领取页 / `redeem` 兑换页 / `collect` 补资料 | + +--- + +## 三、端到端主流程 + +### 3.1 总览(一条单的完整生命周期) + +```mermaid +flowchart TD + A["来源进单
91 / PDD / 抖音"] --> B["upsertOrderFromSource
advisory lock 去重 + 状态合并"] + B --> C["order_items 落库
resolveOrderItemForFulfillment 判定载体"] + C --> D{"isConfigured?"} + D -->|否| E["待补全
91 返回 orderStatus=10"] + C -->|是| F["syncDeliveryTasksForOrder
按 order_item × quantity 建任务单元"] + F --> G{"executor_key"} + G -->|affiliate_dash| H["自动发货
建 affiliate-dash 单 + 领取链接"] + G -->|manual_dispatch| I["人工履约
manual_review 待办"] + C --> J["syncWorkerOrdersForSourceOrder
product-match 匹配接单模板"] + J -->|命中且 auto_create| K["work_orders
待补素材 / 待发布"] + K --> L["打手大厅抢单 / 后台指派"] + L --> M["代练履约 → 提交验收"] + M --> N["验收 → 结算报酬 / 释放押金 / 等级进度"] + H --> O["买家领取页 claimUrl
填 UID → 绑定 → 提交发货"] + O --> P["affiliate-dash delivered 回调"] + P --> Q["任务 completed / redeemed
快手电子凭证核销闭环"] + N --> R["钱包流水 + 售后 / 欠款"] + Q --> S["后台报表 / 备用金台账 / 审计"] + R --> S +``` + +### 3.2 三个来源的进单通道 + +| sourceKey(业务口径) | 实际 provider / platform | 入口 | 数据形态 | 备注 | +| --- | --- | --- | --- | --- | +| `kuaishou-91` | `91kaquan` / `kuaishou` | `POST /api/v1/open/91/orders/create` | 一单一条,`productNo` 解析出商品名与店铺 ID | 91 是订单骨架权威源 | +| `kuaishou-91` | `91kaquan` / `kuaishou` | `POST /api/v1/open/kuaishou-industry/send-code` | 快手电子凭证补全/兜底 | 与 91 共享店铺键空间,**不是新来源** | +| `pdd` | `pdd` / `pdd` | 定时任务 `pdd_order_sync`(20s)拉取 | 两段式 `pdd_raw_orders → orders` | 浏览器扩展上报 Cookie | +| `douyin-mashida` | `douyin_mashida` / `doudian` | `POST /api/v1/open/douyin-mashida/message` | 消息推送,先落 `webhook_events` 去重 | 抖店订单 + DYC 券 | + +本地库实测分布:91 368,355 单;PDD 5,563 单;抖音 1,541 单。 + +#### 3.2.1 91 卡券(下单 + 查询) + +```mermaid +sequenceDiagram + participant K as 91卡券 + participant API as backend /open/91 + participant S as order-service + participant DB as PostgreSQL + K->>API: POST /orders/create(验签) + API->>API: buildOpen91SourceEvent
provider=91kaquan, platform=kuaishou
totalAmount=maxAmount(分) + API->>S: upsertOrderFromSource(event) + S->>DB: pg_advisory_xact_lock(hashtextextended('provider|platform|orderNo')) + S->>DB: upsert orders + replace order_items + S->>S: 载体判定 → 建任务 / 建工单 + API-->>K: orderStatus=10(未生成 claimUrl)或成功 + K->>API: POST /orders/query + API-->>K: orderStatus=20 + cards(claimUrl 加密卡密)
或 10(处理中)/ 30(失败) +``` + +要点: + +- 订单幂等键是 `UNIQUE(provider, platform, shop_id, platform_order_id)`,事务内先取 advisory lock,避免并发重复建单。 +- 状态合并用优先级表:`created(0) < paid(1) < closed(2) < refunded(3)`,`pay_status` 同理;`pending_config` 这类历史状态优先级最低,会被新事件覆盖。 +- 91 查询的三种状态:`10` 处理中/未就绪,`20` 已发货(`cards` 为加密后的 claimUrl),`30` 失败(`pending_config`、`manual_failed` 或任务失败)。 +- 历史遗留:库中仍有 25 条 `order_status='pending_config'` 的 91 老单(最近一条 2026-08-11),当前代码不再写入该状态,只作为查询口径识别。 + +#### 3.2.2 快手行业电子凭证(send-code 四条接口 + 两条出站回调) + +- 券号与 91 订单解耦:`voucher_code = 'KSV' + Crockford Base32(10 字节随机)`,`UNIQUE(voucher_code)`,冲突重试 5 次。 +- **幂等键是 `UNIQUE(oid, unit_index)`**:重复 `send-code` 返回同一批 `eticket.id`;`unit_index = 1..num`。 +- 未找到 91 任务时券先落库(`task_id` 为空),后续由 `voucher-binding-service` 绑定,支持「快手先发码、91 后建单」。 +- 出站回调用 `send_callback_status/attempt_count/last_error/response_json/sent_at` 记录,失败可重推。 +- 核销流水号幂等:`consume_serial_num = CONSUME-{voucher_code}`;状态只有 `UNUSED / CONSUMED / DESTROYED`。 +- 本地库实测:`CONSUMED` 376,179、`DESTROYED` 26,815、`UNUSED` 106 —— 说明核销链路是真实主路径。 + +#### 3.2.3 拼多多(两段式) + +```text +pdd_order_sync 定时任务(默认 20s / pageSize 10) + → pdd-client 拉订单(Cookie + anti-content) + → upsert pdd_raw_orders(UNIQUE(order_sn)) + → processPendingPddRawOrders 逐条消费 + → pdd-order-mapper 归一化 + → upsertOrderFromSource + → sync_status: pending → synced / ignored / failed + → 后台可对 failed 的原始单做 replay +``` + +设计意图:原始报文与业务订单分离,失败可重放,不会因为映射问题丢单。断路器与 Cookie 过期识别在 `pdd-sync-circuit-breaker.ts` / `isPddCookieExpiredError`。 + +#### 3.2.4 抖音码速达(推送 → 建单 → 发券 → 领取) + +```mermaid +sequenceDiagram + participant D as 抖音码速达 + participant N as notify-service + participant O as order-service + participant C as certificate-service + D->>N: POST /message?topic&pub_time&sign(原始 body) + N->>N: 验签(data 原始字符串) + N->>N: eventId = f(topic, pub_time, data) + N->>N: insertWebhookEventOnce(先落库去重) + alt topic = trade_TradePaid + N->>D: 拉订单详情(网关) + N->>O: upsertOrderFromSource + N->>C: 生成 / 同步 DYC 券(redeem_certificates) + N->>D: 推送给买家的领取链接 + else trade_TradeCanceled / refund_RefundSuccess + N->>C: 券失效 + 订单状态收敛 + end +``` + +- 抖音 `sku_order_list[].code` 是**内部 SKU**,`product_name` 是引流标题;`sku_code` 恒存内部 SKU,标题只进 `snapshot.productTitle`,永不参与匹配。 +- 券表 `redeem_certificates` 自带状态机 `issued / synced / verified / invalid` 与同步状态 `pending / success / failed`,核销走 `coupons.verifyV3`。 +- **已知缺陷**:`webhook_events` 先插入后处理,处理失败时平台重推会被判 `duplicate` 丢弃(详见风险清单)。 + +### 3.3 载体判定(本系统最关键的一步) + +```mermaid +flowchart TD + A["order_item"] --> B{"来源"} + B -->|91 / 快手| C["productNo → affiliate-dash sku 映射
常量表 + 后台 skuMapping 覆盖"] + B -->|抖音| D["内部 SKU → affiliate-dash sku 映射"] + C --> E["resolveFulfillmentRoute"] + D --> E + E --> F{"路由顺序"} + F --> G["1. 履约路由总开关
关闭 = 停止自动履约"] + F --> H["2. 指定匹配(显式映射)"] + F --> I["3. 商品名规则 exact / contains"] + F --> J["4. 内部 SKU 规则"] + F --> K["5. 全局执行器优先级"] + F --> L["6. unmatchedExecutorKey(生产为空)"] + G --> M["snapshot.fulfillmentRoute
selectedExecutorKey / reason / candidates / skipped"] + H --> M + I --> M + J --> M + K --> M + L --> M + M --> N{"selectedExecutorKey"} + N -->|affiliate_dash| O["isConfigured=true
建自动履约任务"] + N -->|manual_dispatch| P["isConfigured=true
建人工任务"] + N -->|空| Q["isConfigured=false
待补全 / 交给发单层判定"] +``` + +判定结果全部写进 `order_items.item_snapshot_json`: + +| 快照键 | 内容 | +| --- | --- | +| `fulfillmentRoute` | `selectedExecutorKey`、`reason`、`candidates`、`skipped`、`matchedRule` | +| `classification`(抖音) | `channel: affiliate_dash / unmatched`、`canonicalSku` | +| `affiliateDash` | 命中的 `sku`、`productNo`、`skuName`、`matchMode` | +| `isConfigured` | 是否已配置可执行载体 | +| `productTitle`(抖音) | 引流长标题(只读展示) | +| `kuaishouSendCode` | 快手 send-code 补全的卖家/SKU 上下文 | + +**载体互斥守护**:`sync-worker-orders-service.isAffiliateDashCarrierSnapshot()` 会跳过 `classification.channel === 'affiliate_dash'` 的订单项,避免自动发货与工单双通道同时消费一个订单项。 + +### 3.4 履约任务与领取链接 + +1. `syncDeliveryTasksForOrder` 对每个订单项按 `quantity` 补建 `unit_index = 1..n`,重复执行只补缺口。 +2. 执行器来自 `snapshot.fulfillmentRoute`: + - `affiliate_dash` → 绑定 `fulfillment_profiles.profile_key='affiliate_dash'`,`requires_claim=true`,初始状态 `paid`; + - `manual_dispatch` → `profile_key='manual_review'`,初始状态 `manual_review`; + - 未支付 → `pending_payment`。 +3. 已支付订单立即 `preparePaidFulfillmentTask`(执行器注册表:`affiliate_dash` / `manual` / `legacy-retired` 只读桩)。 +4. 领取 token:`createTaskClaimToken` 写 `claim_tokens`(`task_id` 唯一、`token` 唯一、`max_use_count=1`),`claimUrl = claimBaseUrl + '/' + token`。 +5. 抖音走独立基址 `claimLinkBase`(后台配置),**刻意不回退** `orders.claimBaseUrl`。 +6. `orders.claim_status`(迁移 099)= `active / paused / closed`,用于暂停或关闭整单兑换,是后台处置链接失效问题的新开关。 + +### 3.5 affiliate-dash 自动履约 + +```text +建任务(executor=affiliate_dash,requires_claim=true) + → preparePaidFulfillmentTask:调 affiliate-dash 建单,拿到 orderNo / bindUrl / qrUrl + → 生成 claim token 与 claimUrl(91 查询即可返回 20) + → 建单成功时即核销快手电子凭证(consumeStatus=success;失败只告警) + → 买家在领取页:查 delivery → 填 UID → bind → 轮询 bind-result → submit + → affiliate-dash 回调 order.shipping.updated(delivered) + → 任务 completed / redeemed;回调只做状态收敛与兜底补核销 +``` + +任务上下文固定形状 `context_json.affiliateDash`(`AffiliateDashFlow`),包含 `sku / orderNo / clientOrderNo / orderStatus / canShip / bindUrl / qrUrl / bound / submitStatus / consumeStatus / lastSyncedAt`。 + +### 3.6 买家侧三个页面 + +| 入口 | 路由 | 用途 | +| --- | --- | --- | +| 主站领取页 | `/#/claim/:token` | 快手渠道主路径,按 `executorKey` 渲染不同步骤 | +| H5 领取页 | `/h5/:orderNo`(BrowserRouter 分支) | 抖音等按订单号承接,`/api/v1/claim/h5/...` | +| 兑换页 | `/redeem` | 抖音 DYC 券兑换/核销 | +| 补资料页 | `/collect` | 待补全订单资料收集(**仅凭订单号,无第二因子**) | + +领取页快照与轮询逻辑在 `pages/claim/claim-snapshot.ts`、`claim-poll.ts`;`affiliate-dash` 的具体步骤在 `ClaimAffiliateDashSteps.tsx`(查 delivery → UID → bind → 轮询 → submit)。 + +### 3.7 打手众包:从匹配到结算 + +#### 3.7.1 接单模板匹配(product-match 引擎) + +匹配引擎是**纯函数**(`services/product-match/engine.ts`),来源差异只在适配器: + +| 概念 | 说明 | +| --- | --- | +| 条目 `entry` | 一条接单模板(`work_product_rules`)或其映射(`work_product_rule_mappings`)编译而来 | +| 作用域 | `sourceKeys`、`shopKeys`、`qtyMin/qtyMax`:配了就必须命中 | +| 条件组 | `all`(全部命中)、`any`(至少一条)、`bonus`(命中加分、不中不淘汰) | +| 匹配模式 | `exact`、`contains`、`series`(剥离「N个」后比系列,兼容秘钥/密钥)、`series-exact` | +| 权重 | `skuId/relSkuId 900 > skuText/skuNick 800 > series 600 > skuCode 500 > itemId 300 > itemTitle 200`(contains 降档,文本另有「具体度」加成上限 80) | +| 结果 | 唯一最高分 → `matched`;并列最高 → `ambiguous`(**不猜**);无候选 → `unmatched` | + +每次匹配都会写 `work_product_match_logs`(含 `candidates_json`、`raw_payload_json`),后台有「匹配测试 / 匹配日志 / 日志清理」三个入口。 + +#### 3.7.2 建单规则 + +```text +for each order_item: + if snapshot.classification.channel == 'affiliate_dash' → skip(载体已被自动发货占用) + if 该订单项已有工单 → skip(already_exists,不纠正旧单) + match = 匹配引擎 + 无命中/歧义 → skip + 日志 + 命中但 auto_create=false 且 autoOnly → skip + platform_order_id 已挂在**其他**订单下 → skip(防撞号) + 建单: + status = 素材必填项齐全 ? unassigned : pending_material + reward = 单价 × SKU 数量(unit_price_fen)或固定 reward_amount + sharing(拼单)按模板 sharing_total_quantity / unit_reward 计算 + acceptance_mode = normal / friend_gift(好友赠送) + timeout_minutes + timeout_policy(reopen / cancel_release / cancel_deduct) + material = 来源摘要 +(抖音)已有券快照 +pending_material → 生成后台「待补素材」通知 +``` + +#### 3.7.3 工单生命周期 + +```mermaid +stateDiagram-v2 + [*] --> pending_material + pending_material --> unassigned: 素材补全 + pending_material --> cancelled: 取消 + unassigned --> open: 发布到大厅 + unassigned --> in_progress: 后台指派 + open --> in_progress: 打手抢单 / 拼单参与 + in_progress --> pending_acceptance: 打手提交验收 + pending_acceptance --> accepted: 验收通过(结算) + pending_acceptance --> problem: 发起问题单 + problem --> open: 退回大厅 + problem --> cancelled: 取消 + in_progress --> open: 超时按 reopen 策略 + in_progress --> cancelled: 超时按 cancel 策略 + open --> cancelled: 取消 + accepted --> [*] + cancelled --> [*] +``` + +- **抢单**:单事务内 `ensureWallet → 校验活跃单上限 → 校验可用余额 ≥ 押金 → 条件更新 `WHERE status='open' AND assigned_worker_id IS NULL` → 冻结押金(`deposit_freeze` 流水)→ 写 `work_order_events`。 +- **指派**:先 `SELECT … FOR UPDATE` 锁工单,再锁钱包(与抢单顺序相反,见风险 R4)。 +- **拼单**:`work_order_shares` 一份一行,`UNIQUE(work_order_id, worker_id)`;份额状态 `joined → submitted → accepted / cancelled`;母单可长期 `open`,最后一个份额验收后母单转 `accepted`。 +- **好友赠送**:`acceptance_mode='friend_gift'`,`gift_phase`:`material_required → material_submitted → friend_countdown → gift_ready`,冷却结束才能提交赠送凭证;确认加好友后不再退押金(防套利)。 +- **超时**:定时任务 `work-order-timeout`(默认 60s,scanLimit 50)扫描 `deadline_at`,按 `timeout_policy`(`reopen` 退回大厅 / `cancel_release` 取消并退押金 / `cancel_deduct` 取消并扣押金)处置;已确认加好友的赠送单即使配 `cancel_release` 也会转为扣款。超时只写 `timeout_*` 事件并释放寄卖现货,**不直接改等级计数**(等级处罚来自退单审核通过次数,见 3.7.6)。 +- **验收与结算**(`acceptWorkOrderAndSettle`):锁工单与份额 → 报酬入账(可延迟解冻)→ 释放/延迟解冻押金(`worker_deposit_unfreezes` 队列)→ 抵扣售后欠款 → 升级等级 → 写 `worker_wallet_ledgers`。 +- **售后**:`worker_after_sales_cases`(问题类型、投诉、验收快照、处置动作、退款)+ `worker_after_sales_debts`(欠款余额)+ `worker_after_sales_case_events`(时间线)。 + +#### 3.7.4 打手资金模型 + +| 表 | 作用 | +| --- | --- | +| `worker_wallets` | 可用余额、冻结押金、累计入账、累计结算、待解冻、提现冻结 | +| `worker_wallet_ledgers` | 只追加流水,13 种类型(见下) | +| `worker_finance_requests` | 充值 / 提现申请与审核 | +| `worker_withdrawal_accounts` | 提现账户(微信 / 支付宝二维码) | +| `worker_deposit_unfreezes` | 延迟解冻队列(押金、报酬两类) | + +实测流水类型分布(本地库):`reward_settlement 108,623`、`sharing_reward 34,295`、`deposit_freeze 8,283`、`deposit_release 6,705`、`withdraw_paid 6,381`、`withdraw_freeze 5,833`、`deposit_deduction 920`、`deposit_pending_unfreeze / deposit_unfreeze 各 621`、`manual_credit 398`、`withdraw_release 121`、`after_sales_balance_deduction 20`、`after_sales_debt_offset 9`。 + +#### 3.7.5 等级、权限与风控 + +- `worker_levels.permission_json` 承载:免押额度、最大同时接单量、大厅可见延迟、自动验收(VIP)。 +- `worker_level_logs` 记录升降级;`worker_users.is_level_locked` 可锁定等级。 +- `worker_sessions` 会话落库 + `session_version`,设备数与设备类型可限制。 +- `worker_risk_events` 记录刷单/异常行为与处置(ban / unban / release),`middleware/worker-risk-control.ts` 做抢单前置限流与拦截。 +- 提现黑名单:`worker_users.is_withdrawal_blacklisted` + 原因 + 时间。 + +> 抢单/拼单/好友赠送/验收/结算的逐行细节(含押金矩阵、资金等式、退单处罚规则)见 [打手工单深挖-抢单拼单好友赠送结算-2026-10-23.md](打手工单深挖-抢单拼单好友赠送结算-2026-10-23.md)。 + +### 3.8 寄卖仓库 + +```mermaid +flowchart TD + A["卖家提报 consignment_submissions
游戏/系统/区服/系列/钻级/璀璨/要价/凭证"] --> B{"客服审核"} + B -->|驳回| C["rejected(可改后重提)"] + B -->|通过| D["入库 consignment_inventories
inventory_no = INV…
status=in_stock"] + D --> E{"订单匹配"} + E -->|规格匹配| F["占用 consumed
bound_order_id / bound_work_order_id"] + E -->|不匹配| D + F --> G{"履约方式"} + G -->|internal_stock| H["内部现货交付
客服操作"] + G -->|worker_deposit| I["指派打手代练
冻结打手押金"] + H --> J["验收 → 释放发布押金 + 结货款"] + I --> J + F -.->|撤销| K["revoke:打手押金 + 卖家押金
auto / refund / forfeit / keep_frozen"] +``` + +匹配口径(`consignment-matching-service.ts`): + +- **系统 / 区服 / 系列必须一致**(区服「互通」、系统「通用」视为通配); +- **钻级与璀璨低配不充高配**:号主 2 钻不能配 1 钻库存; +- 三态比对:`match` / `mismatch` / `unknown`,`unknown`(任一侧没值)**不算精确匹配**,只能进部分匹配候选; +- 人工指定时用 `classifyConsignmentMatch` 返回 `conflicts`,把「差在哪」显式告诉客服; +- 建议价从接单模板反推(`work_product_rules` 商品名 → 规格解析 → 最接近档位兜底,命中多条取最高价)。 + +卖家视角的单一阶段由 `deriveConsignmentItemStage` 从「提报 + 库存 + 工单」三套状态推导:`pending_review → in_stock → matched_waiting_friend → gift_cooldown → gift_ready → gift_submitted → fulfilling → pending_acceptance → completed`,终态 `rejected / archived / cancelled`。 + +押金策略(`domain/consignment-deposit-policy.ts`): + +| 场景 | `auto` 行为 | +| --- | --- | +| 打手履约押金撤销 | 全额退还;要扣必须显式 `forfeit` | +| 卖家发布押金撤销 | 作废(下架)→ 退还;回库(继续挂卖)→ 保持冻结 | +| 显式覆盖 | 必须填原因(`isDepositHandlingOverridden` 校验) | + +### 3.9 客服备用金台账(迁移 100) + +```text +公司 → 主管池(fund_accounts.account_type='supervisor') +主管 → 客服账户('agent',responsible_supervisor_id 指向主管) +客服 → 支出明细(manual_expenses:平台充值 / 采购 / 赔付 / 运费 / 其他) +``` + +四条硬约束(写在建表注释里): + +1. 余额只能由 `fund_entries` 变动,禁止手工改; +2. 发放 / 缴回必须同时写主管与客服两条方向相反的流水,共用 `transfer_no`; +3. 支出必须同时写客服负数流水与 `manual_expenses` 明细(`UNIQUE(fund_entry_id)`); +4. 已记账流水不修改不删除,差异走 `adjustment`。 + +对账:`fund_reconciliations`(`pending / confirmed / discrepancy`),DB 级校验 `difference_fen = actual_balance_fen - system_balance_fen`。 + +权限点:`fund.view / issue / expense / reconcile / adjust / export`,按角色(admin / finance / operator / support)分别在迁移里授权。 + +### 3.10 后台治理 + +| 能力 | 实现 | +| --- | --- | +| 登录 | 用户名密码 + 短信二次(可选) + 设备认证(`admin_user_devices` / `admin_device_challenges`) | +| 会话 | `admin_sessions` 落库 + `session_version`,可远程踢设备 | +| RBAC | 4 个内置角色(admin / finance / operator / support)+ 29 个权限点 + 72 条角色授权 | +| 审计 | `admin_audit_logs`(actor / action / target / payload),实测 55 万行 | +| 通知 | `admin_notifications`(类型 / 优先级 / 去重键 / 提醒次数 / 可见性 / 声音) | +| 实时 | `realtime_events` + SSE,后台列表与工单详情增量刷新 | +| 定时任务 | 后台可视化管理(启用、间隔、立即执行、运行状态) | + +### 3.11 定时任务 + +| id | 类型 | 默认间隔 | 作用 | +| --- | --- | --- | --- | +| `work-order-timeout` | `work_order_timeout` | 60s(scanLimit 50) | 工单超时处置 + 打手超时计数 | +| `deposit-unfreeze` | `deposit_unfreeze` | 60s(scanLimit 100) | 押金/报酬延迟解冻出队 | +| `pdd_order_sync` | `pdd_order_sync` | 20s(pageSize 10) | 拼多多订单拉取 | +| `data-retention` | `data_retention` | 24h | 清理原始报文、匹配日志、任务事件、webhook、审计;回填 `source_order_amount_fen` | + +调度器在进程内(`services/scheduler/scheduler-service.ts`),同一任务运行中会跳过本轮;配置存 `app_config_entries.scheduled_jobs`。 + +--- + +## 四、状态机一页汇总 + +### 4.1 订单 `orders` + +| 字段 | 取值 | 备注 | +| --- | --- | --- | +| `order_status` | `created / paid / closed / refunded`(另有历史值 `pending_config`、`manual_failed`) | 合并时按优先级只升不降 | +| `pay_status` | `unpaid / failed / paid / refunded` | 与 `paid_at` 联动 | +| `claim_status` | `active / paused / closed` | 兑换有效性开关(迁移 099) | + +> 注意:`order_status` **不能代表履约完成**。本地库中 375,432 条 `paid` 订单里,绝大多数并没有「订单级完成」状态,履约进度只能看 `fulfillment_tasks.task_status` 或 `work_orders.status`。 + +### 4.2 履约任务 `fulfillment_tasks` + +`pending_payment → paid → link_generated → claimed → pending_binding_prepare → waiting_binding → role_confirmed → redeeming → dispatched_pending_return → completed / redeemed`,旁支 `retry_pending`、`manual_review`、`failed`、`expired`、`closed`。 + +状态转移约束定义在 `domain/task-status.ts`(`TASK_TRANSITIONS`),终态 `completed / redeemed / expired / closed`。 + +### 4.3 接单工单 `work_orders` / 份额 `work_order_shares` + +- 工单:`pending_material / unassigned / open / in_progress / pending_acceptance / problem / accepted / cancelled`(终态 accepted、cancelled)。 +- 份额:`joined / submitted / accepted / cancelled`。 +- 好友赠送额外有 `gift_phase`:`material_required / material_submitted / friend_countdown / gift_ready`。 + +### 4.4 寄卖 + +| 对象 | 取值 | +| --- | --- | +| `consignment_submissions.status` | `pending_review / approved / rejected` | +| `consignment_inventories.status` | `in_stock / consumed / archived` | +| `consignment_inventories.allocation_status` | `待安排 / 已安排`(中文枚举,历史遗留) | +| `source_type` | `manual / worker_portal / recycle` | +| 卖家可感知阶段 | `pending_review / in_stock / matched_waiting_friend / gift_cooldown / gift_ready / gift_submitted / fulfilling / pending_acceptance / completed / rejected / archived / cancelled` | + +### 4.5 凭证与券 + +| 对象 | 取值 | +| --- | --- | +| `kuaishou_industry_vouchers.status` | `UNUSED / CONSUMED / DESTROYED` | +| `kuaishou_industry_vouchers.send_callback_status` | `success / failed / pending`(回调快手发码结果) | +| `redeem_certificates.status` | `issued / synced / verified / invalid` | +| `redeem_certificates.sync_status` | `pending / success / failed` | +| `kuaishou_cloud_task_states.ticket_status` | 快手云端取号/绑号状态(配合 `dispatch_status`、`consume_status`) | + +### 4.6 资金与售后 + +| 对象 | 取值 | +| --- | --- | +| `worker_finance_requests.status` | `pending / approved / rejected / cancelled` | +| `worker_finance_requests.request_type` | `recharge / withdraw` | +| `worker_deposit_unfreezes.status` | 待解冻 / 已释放(`released_at` 落地) | +| `worker_after_sales_cases.status` | 待响应 / 已响应 / 已处置 / 已退款(配合 `resolution_action`) | +| `worker_after_sales_debts.status` | 未结清 / 已结清 | +| `fund_entries.entry_type` | `opening / funding / issue / expense / return / adjustment` | +| `fund_reconciliations.status` | `pending / confirmed / discrepancy` | + +--- + +## 五、数据库设计 + +### 5.1 总览(本地库实读) + +| 指标 | 数值 | +| --- | --- | +| 业务表 | **59** 张(+ `schema_migrations`) | +| 迁移文件 | 103 个(001 基线 + 002–103 增量),全部已应用 | +| 外键 | 91 | +| 唯一约束 | 29 | +| CHECK 约束 | 28 | +| 索引 | 268(含主键与唯一索引) | +| 主键 | 绝大多数是 `BIGSERIAL`;钱包、工单备注、工单已读等关系表用复合主键 | +| 时间类型 | 统一 `TIMESTAMPTZ`(不存本地时间) | +| 金额类型 | 打手侧 `INTEGER`(分);`orders.total_amount` 也是 `INTEGER`;资金台账、PDD 与工单来源金额用 `BIGINT`(分);**没有 `NUMERIC`,也没有浮点金额** | +| JSON | 变化快的扩展信息一律 `JSONB`(快照、上下文、凭证原文),不建宽表 | + +设计风格非常一致:**关系表 + `*_json` 扩展 + 只追加流水**。代价是 JSONB 里的字段无法被 DB 约束保护(如 `context_json.affiliateDash` 全靠 TS 类型)。 + +### 5.2 分域表清单 + +#### A. 订单与履约主链路(12) + +| 表 | 行数(估) | 说明 | +| --- | --- | --- | +| `orders` | 375,459 | 订单骨架;`UNIQUE(provider, platform, shop_id, platform_order_id)` | +| `order_items` | 375,263 | 订单项;`item_snapshot_json` 是载体判定的全部证据 | +| `fulfillment_tasks` | 107,111 | 履约单元;`UNIQUE(order_item_id, unit_index)`、`UNIQUE(task_no)` | +| `claim_tokens` | 107,083 | 领取 token;`UNIQUE(task_id)`、`UNIQUE(token)` | +| `task_events` | 1,310,742 | 任务事件流水(最大表) | +| `task_runtime_contexts` | 45,976 | 运行时上下文(登录态、角色、截图、产物) | +| `fulfillment_profiles` | 4 | 履约档案(affiliate_dash / manual_review / legacy 桩) | +| `fulfillment_profile_requirements` | 少量 | 档案的凭证要求 | +| `pdd_raw_orders` | 5,068 | PDD 原始单,`UNIQUE(order_sn)`,`sync_status` 四态 | +| `webhook_events` | 110,233 | 外部回调去重表(`provider + event_id`) | +| `short_links` | 95 | 短链(目标 URL 哈希 + 访问计数) | +| `file_assets` | 289,260 | 对象存储资产(缩略图/中图/URL/场景/上传者) | + +#### B. 凭证(3) + +| 表 | 行数(估) | 说明 | +| --- | --- | --- | +| `kuaishou_industry_vouchers` | 403,299 | 快手电子凭证;`UNIQUE(voucher_code)`、`UNIQUE(oid, unit_index)` | +| `kuaishou_cloud_task_states` | 46,894 | 快手云端取号/绑号/核销状态(一任务一行) | +| `redeem_certificates` | 1,541 | 抖音 DYC 券,含平台券号、平台状态、同步错误 | + +#### C. 接单平台(25) + +| 表 | 行数(估) | 说明 | +| --- | --- | --- | +| `worker_users` | 1,905 | 打手账号(30 列:等级、邀请、改名卡、冻结、黑名单、寄卖权限、等级锁) | +| `worker_levels` / `worker_level_logs` | 6 / 133 | 等级定义(`permission_json`)与升降级流水 | +| `worker_sessions` | 32,727 | 会话(设备、IP、UA、过期、吊销) | +| `worker_sms_codes` | 5,206 | 短信验证码(哈希、失败次数) | +| `worker_wallets` | 1,905 | 钱包(可用、冻结押金、待解冻、提现冻结、累计) | +| `worker_wallet_ledgers` | 172,830 | 钱包流水(13 种 `ledger_type`) | +| `worker_finance_requests` | 6,769 | 充值/提现申请(含冻结金额与审核人) | +| `worker_withdrawal_accounts` | 776 | 提现账户(微信/支付宝二维码 JSON) | +| `worker_deposit_unfreezes` | 621 | 延迟解冻队列(`fund_type` 区分押金/报酬) | +| `work_categories` | 30 | 工单分类(支持父级与图标) | +| `work_orders` | 156,043 | 工单主表(44 列,全库最宽) | +| `work_order_events` | 1,258,811 | 工单事件流水 | +| `work_order_shares` | 41,383 | 拼单份额 | +| `worker_work_order_notes` | 13,604 | 打手备注(一工单一打手一行) | +| `worker_work_order_views` | 147,356 | 已读/素材已读 | +| `worker_order_cancel_requests` | 953 | 打手取消申请与审核 | +| `worker_order_feedbacks` | 501 | 工单反馈/申诉 | +| `worker_after_sales_cases` | 39 | 售后问题单 | +| `worker_after_sales_debts` | 4 | 售后欠款 | +| `worker_after_sales_case_events` | 93 | 售后时间线 | +| `worker_risk_events` | 209 | 风控事件与处置 | +| `work_product_rules` | 710 | 接单模板(29 列:匹配、奖励、押金、拼单、时限、验收模式) | +| `work_product_rule_mappings` | 707 | 模板映射(卖家/SKU/标题,`sku_match_mode`) | +| `work_product_match_logs` | 10,000 | 匹配诊断日志(保留量受清理任务控制) | + +#### D. 寄卖(2) + +| 表 | 行数(估) | 说明 | +| --- | --- | --- | +| `consignment_submissions` | 877 | 提报单(`submission_no` 唯一、要价、押金、审核结果指向库存) | +| `consignment_inventories` | 784 | 库存(`INV…` 唯一、38 列:规格、凭证、绑定订单/工单、卖家、指派打手) | + +#### E. 后台与权限(11) + +`admin_users`(40)、`admin_sessions`(3,077)、`admin_login_logs`(4,936)、`admin_roles`(4)、`admin_permissions`(29)、`admin_role_permissions`(72)、`admin_audit_logs`(556,360)、`admin_notifications`(166,883)、`admin_sms_codes`、`admin_user_devices`、`admin_device_challenges`。 + +#### F. 配置与实时(2) + +`app_config_entries`(18 行,全部平台/业务配置)、`realtime_events`(600,滚动保留)。 + +#### G. 资金台账(4) + +`fund_accounts`、`fund_entries`、`manual_expenses`、`fund_reconciliations`(本地均为空表,属于新落地模块)。 + +### 5.3 核心表字段速查 + +#### `orders` + +| 列 | 类型 | 说明 | +| --- | --- | --- | +| `id` | bigserial PK | | +| `provider` / `platform` | text NOT NULL | 例如 `91kaquan` / `kuaishou` | +| `shop_id` / `shop_name` | text | 店铺键空间(参与唯一键) | +| `platform_order_id` | text NOT NULL | 平台单号 | +| `order_status` / `pay_status` | text | 见 4.1 | +| `buyer_id` / `buyer_name` / `receiver_contact` | text | 买家信息 | +| `total_amount` | integer | **用户付款金额(分)**,三家口径已统一为「实付」;91 = `maxAmount` | +| `currency` | text | 默认 CNY | +| `raw_payload_json` | jsonb | 原始报文(保留期由 data-retention 控制) | +| `paid_at` | timestamptz | 支付/成团时间,建单时用作 `created_at` 优先值 | +| `claim_status` / `claim_status_reason` / `claim_status_updated_at` | text / text / timestamptz | 兑换有效性开关 | + +索引(12 个)覆盖:唯一键、`(provider, platform, order_status, created_at)`、平台单号、店铺筛选、claim 状态等(迁移 070/074/086/099)。 + +#### `order_items` + +`id`、`order_id`(FK CASCADE)、`sku_code`、`sku_name`、`quantity`、`spec_json`、`item_snapshot_json`。 + +没有金额字段,也没有 `canonical_product_id`:**多商品订单无法直接分摊收入**(缺口 C5)。 + +#### `fulfillment_tasks` + +| 列 | 说明 | +| --- | --- | +| `order_id` / `order_item_id` / `unit_index` | 归属与单元序号;`UNIQUE(order_item_id, unit_index)` | +| `task_no` | `DT…` 唯一编号 | +| `provider / platform / shop_id / shop_name / platform_order_id` | 冗余来源字段(便于按来源查询,避免 join) | +| `profile_id` / `executor_key` | 履约档案与执行器 | +| `task_status` / `delivery_status` / `result_code` / `result_message` | 主状态机 + 交付状态 | +| `claim_token` / `claim_expires_at` | 历史冗余列;实际 token 在 `claim_tokens`(查询时 `LEFT JOIN` 出 `primary_claim_token`) | +| `automation_mode` / `requires_claim` / `user_action_status` | 自动/人工、是否需要用户领取 | +| `attempt_count` / `last_error` | 重试 | +| `context_json` | 执行器上下文(`affiliateDash`、`kuaishouIndustryVoucher` 等) | +| `claimed_at` / `role_confirmed_at` / `redeemed_at` | 关键时间点 | + +#### `work_orders`(44 列,按用途分组) + +| 组 | 列 | +| --- | --- | +| 标识 | `id`、`work_order_no`(唯一)、`order_id`、`order_item_id`、`task_id`(历史遗留,已注释废弃)、`platform_order_id`、`product_rule_id`、`category_id` | +| 商品 | `product_name` | +| 状态 | `status`、`acceptance_mode`、`gift_phase`、`admin_note`、`problem_note` | +| 金额 | `reward_amount`、`required_deposit_amount`、`deposit_threshold_amount`、`source_order_amount_fen`、`sharing_unit_reward` | +| 归属 | `assigned_worker_id`、`last_assigned_worker_id` | +| 拼单 | `sharing_enabled`、`sharing_total_quantity`、`sharing_unit_reward` | +| 时限 | `deadline_at`、`timeout_minutes`、`timeout_policy` | +| 好友赠送 | `friend_added_at`、`gift_available_at`、`gift_cooldown_hours`、`gift_cooldown_minutes` | +| 内容 | `material_json`、`requirement_json`、`acceptance_json`、`draft_acceptance_json`、`booster_material_json`、`worker_note` | +| 大厅 | `hall_queued_at`、`pinned_at`、`published_at` | +| 时间 | `assigned_at`、`submitted_at`、`accepted_at`、`created_at`、`updated_at` | + +21 个索引,覆盖:状态+时间、分类+状态、店铺筛选、大厅队列、统计口径、关键词查询等(迁移 031/047/051/073/083/085/087/088/090/103)。 + +#### `worker_wallets` / `worker_wallet_ledgers` + +`worker_wallets`:`worker_id` PK/FK、`available_amount`、`frozen_deposit_amount`、`pending_unfreeze_amount`、`frozen_withdraw_amount`、`total_credited_amount`、`total_settled_amount`。 + +`worker_wallet_ledgers`:`worker_id`、`ledger_type`、`amount`(有符号)、`balance_after`、`frozen_after`、`related_work_order_id`、`audit_status`、`note`、`payload_json`。 + +> 余额是**可变字段**,没有「由流水重算」的校验;这是财务化的第一道缺口(见第七节)。 + +#### `consignment_inventories`(38 列) + +`inventory_no`、`game_name`、`category_id`、`item_name`、`system`、`server_zone`、`series`、`diamond_level`、`brilliance_count`、`cost_price_fen`、`seller_*`(联系人/游戏 ID/收款/手机)、`credentials_json`(**游戏账号密码**)、`screenshots_json`、`status`、`allocation_status`、`bound_order_id`、`bound_work_order_id`、`bound_platform_order_id`、`shop_name`、`source_type`、`source_submission_id`、`worker_id`、`assigned_worker_id`、`deposit_amount_fen`、`inbound_at`、`consumed_at`。 + +#### `fund_*` + +- `fund_accounts`:`account_no` 唯一、`account_type`(supervisor/agent)、`owner_admin_user_id`、`responsible_supervisor_id`(自引用)、`balance_fen >= 0`、`status`。 +- `fund_entries`:`entry_no` 唯一、`account_id`、`entry_type`(6 种)、`amount_fen <> 0`(有符号)、`balance_after_fen`、`transfer_no`、`counterparty_account_id`、`reconciliation_id`、`evidence_json`。 +- `manual_expenses`:`expense_no` 唯一、`account_id`、`amount_fen > 0`、`category`(5 种)、`payee_type`(4 种)、`order_id`/`order_item_id`、`fund_entry_id` 唯一(一支出对一流水)。 +- `fund_reconciliations`:`period_start/end`、`system_balance_fen`、`actual_balance_fen`、`difference_fen`(CHECK 等于差值)、`status`、`confirmed_by`。 + +### 5.4 关系图(核心链路) + +```mermaid +erDiagram + orders ||--o{ order_items : "1:N" + orders ||--o{ fulfillment_tasks : "1:N" + order_items ||--o{ fulfillment_tasks : "1:N (unit_index)" + fulfillment_tasks ||--|| claim_tokens : "1:1 (task_id unique)" + fulfillment_tasks ||--o{ task_events : "1:N" + fulfillment_tasks ||--o| task_runtime_contexts : "1:0..1" + fulfillment_tasks ||--o| kuaishou_cloud_task_states : "1:0..1" + orders ||--o{ kuaishou_industry_vouchers : "1:N" + fulfillment_tasks |o--o{ kuaishou_industry_vouchers : "task_id 可空" + orders ||--o{ redeem_certificates : "local_order_id" + order_items ||--o| work_orders : "1:0..1" + work_orders ||--o{ work_order_shares : "1:N" + work_orders ||--o{ work_order_events : "1:N" + worker_users ||--|| worker_wallets : "1:1" + worker_users ||--o{ worker_wallet_ledgers : "1:N" + worker_users ||--o{ worker_sessions : "1:N" + worker_users ||--o{ worker_finance_requests : "1:N" + work_orders ||--o{ worker_deposit_unfreezes : "1:N" + work_orders ||--o{ worker_after_sales_cases : "1:N" + worker_after_sales_cases ||--o{ worker_after_sales_debts : "1:N" + consignment_submissions ||--o| consignment_inventories : "审核产出" + consignment_inventories |o--o{ work_orders : "bound_work_order_id" + admin_users ||--o{ fund_accounts : "owner" + fund_accounts ||--o{ fund_entries : "1:N" + fund_entries ||--|| manual_expenses : "1:1 (fund_entry_id)" + fund_accounts ||--o{ fund_reconciliations : "1:N" + pdd_raw_orders |o--|| orders : "synced_order_id" +``` + +### 5.5 约束与索引策略(值得记住的几条) + +1. **唯一键即幂等键**:`orders(provider, platform, shop_id, platform_order_id)`、`fulfillment_tasks(order_item_id, unit_index)`、`kuaishou_industry_vouchers(oid, unit_index)`、`pdd_raw_orders(order_sn)`、`work_order_shares(work_order_id, worker_id)`、`fund_entries(entry_no)`、`manual_expenses(fund_entry_id)`。 +2. **DDL 层不枚举业务状态**:`orders` / `fulfillment_tasks` / `work_orders` 的状态列**没有 CHECK**,状态机只存在于 TS(`domain/*.ts`)。好处是迁移成本低,坏处是脏状态只能靠代码兜底(`pending_config` 就是遗留证据)。寄卖与资金表则加了 CHECK,口径不完全统一。 +3. **索引高度针对后台查询**:迁移 070/071/073/074/082/084–090 全是后台列表/统计/模糊搜索的覆盖索引,其中 073 用 `pg_trgm` GIN 支撑工单关键词(游戏 ID)搜索。 +4. **JSONB 不做 GIN**:除搜索字段外,`snapshot/context` 的查询都走「先取行再在应用层判断」,这也是解析逻辑必须集中在少数几个 service 的原因。 +5. **软删/归档**:业务表基本不物理删除(`ON DELETE CASCADE` 只用于任务→事件、订单→订单项等从属关系),保留靠 `status` 与归档字段。 + +### 5.6 配置存储(`app_config_entries`) + +配置不在文件里、也不在环境变量里(环境变量只管连接串与密钥),全部走一张 key-value 表,后台保存后即时生效(部分需要重启或重载调度器): + +| key | 内容 | +| --- | --- | +| `affiliate_dash` | 自动发货平台配置(baseUrl、商户号、密钥、skuMapping) | +| `kuaishou_industry_source` | 快手电子凭证接入(appId、secret、token、店铺) | +| `douyin_mashida` | 抖音码速达(uid/secret/baseUrl/claimLinkBase/handledTopics) | +| `pdd_source` | 拼多多(店铺、Cookie、同步开关) | +| `fulfillment_routing` | 履约路由(总开关、商品名/SKU 规则、全局优先级、unmatchedExecutorKey) | +| `scheduled_jobs` | 定时任务配置与间隔 | +| `notification` | 通用通知(Bark / WPush) | +| `worker_finance` | 打手财务策略(押金、解冻天数、提现规则) | +| `worker_product_match` | 匹配引擎开关(`legacyFallbackEnabled`) | +| `worker_hall` | 大厅可见性、容量、分类上限 | +| `worker_announcement` | 打手端公告 | +| `worker_platform_notifications` | 打手端通知模板 | +| `worker_risk_control` | 风控阈值与处罚 | +| `worker_mock_shops` | 开发/联调假店铺 | +| 历史键 | `cloudtentacles_*`、`kuaishou_feifei`(已下线通道,仅保留数据) | + +### 5.7 迁移演进(001–103) + +| 区段 | 主题 | +| --- | --- | +| 001 | 基线:后台、配置、履约档案、订单/订单项/任务/领取 token、任务事件、快手券、短链 | +| 002–003 | 登录日志、电子凭证类型 | +| 004–012 | **接单平台落地**:等级、打手、钱包、流水、分类、工单、工单事件、文件、商品规则、财务申请、拼单、超时、解冻、短信 | +| 013–028 | 回调去重、权限修补、永久 claim token、工单备注/置顶、提现账户、通知 | +| 029–040 | 会话、实时事件、搜索索引、匹配上下文/目录/SKU 模式、匹配日志、后台会话 | +| 041–058 | 排行榜、自动激活、取消申请、反馈、来源规则、冻结原因、性能护栏、大厅队列、售后、改名卡、赠送验收、解冻配置、素材已读 | +| 059–076 | **RBAC**、提现冻结、管理备注、取消措辞、赠送冷却、个人免押、数据保留、筛选索引、财务角色、搜索重建、分类父子、提现黑名单 | +| 077–082 | **寄卖**(库存、打手押金、解除收集链接、分配状态、白名单) | +| 081 | **PDD 原始单**两段式 | +| 083–093 | 工单来源金额、多组覆盖索引、风控事件、遗留列注释、autovacuum | +| 094–098 | **抖音券** `redeem_certificates`、平台券号、任务 claim 提交(后废弃)、等级锁 | +| 099–103 | 订单兑换有效性、**备用金台账**、后台显示名、短信/设备认证、店铺筛选索引 | + +约定(README):不改历史迁移;结构变更一律新增;空库按序执行;后端启动自动 `up`。 + +### 5.8 实测状态分布(本地库,用于验证对业务的理解) + +| 维度 | 分布 | +| --- | --- | +| 订单 | `paid/paid/active` 375,432;`pending_config` 25;`manual_failed` 1;`closed` 1 | +| 履约任务 | `redeemed+delivered` 49,527;`completed+delivered` 45,658;`closed+cancelled` 5,158;`closed+closed` 3,238;`waiting_binding+pending` 956 | +| 工单 | `accepted` 131,786;`pending_material` 21,509;`cancelled` 1,075;`unassigned` 760;`in_progress` 612;`pending_acceptance` 174;`open` 101;`problem` 26 | +| 快手券 | `CONSUMED` 376,179;`DESTROYED` 26,815;`UNUSED` 106 | +| 抖音券 | `verified` 1,337;`invalid` 123;`synced` 81 | +| 财务申请 | 提现 approve 6,381;充值 reject 151;提现 reject 129;充值 approve 91 | + +两个值得注意的信号: + +1. `pending_material` 占工单 13.8%(21,509 条),说明**接单素材缺口是常态**,补素材是运营主要工作量之一。 +2. `unassigned` 只有 760,而 `open` 只有 101 —— 大量工单没有经过大厅,直接被后台指派或同步生成后长期停在待补素材。 + +--- + +## 六、外部集成与安全边界 + +| 链路 | 验签 | 幂等/去重 | 备注 | +| --- | --- | --- | --- | +| 91 卡券 | 签名 + 时间戳容差 | `orders` 唯一键 + advisory lock | 卡密用 `buildOpen91Cards` 加密返回 | +| 快手电子凭证 | 签名(`crypto.ts`) | `UNIQUE(oid, unit_index)` | 4 个入站接口 + 2 个出站回调 | +| affiliate-dash 通知 | 验签 + 时间戳 | `webhook_events` | 失败重推会被判 duplicate(风险 R2) | +| 抖音码速达 | 原始 body 验签 | `webhook_events`(先落库) | 同上 | +| PDD | Cookie / anti-content | `pdd_raw_orders(order_sn)` + 断路器 | 无回调,纯拉取 | + +其他安全面: + +- 后台/打手口令都是 scrypt + 盐,会话落库 + `session_version`。 +- 日志统一脱敏(敏感 key、URL 参数、JSON 字段),有单测。 +- 生产强制:session secret 长度、显式 CORS 白名单、存储凭据完整。 +- 明显缺口:`/collect/*`、`/redeem/*` 只凭订单号;`/files/object` 无鉴权;Caddy 无 CSP/HSTS/X-Frame-Options;后台 token 存 localStorage。 + +--- + +## 七、风险与设计缺口(按优先级) + +### P0 · 资金与一致性 + +| # | 问题 | 位置 | 影响 | +| --- | --- | --- | --- | +| R1 | 发单金额高于用户付款只有前端提醒,后端无统一门禁 | `frontend/pages/admin/panels/work-order-payment.ts`;`admin-work-order-management-service.ts` | 直接调 API 可发亏损单 | +| R2 | 寄卖审核可由请求体改 `workerId` 并按改后归属结算货款 | `consignment-admin-service.ts`、`consignment-supplier-repo.ts` | 钱结给错误的人 | +| R3 | 寄卖审核详情全量返回游戏账号密码,只需 `consignment.view` | `consignment-admin-service.ts` | 权限粒度过粗 | +| R4 | 抢单先锁钱包、指派先锁工单(锁序相反) | `work-order-grab-repo.ts` | 并发下 40P01 死锁 | +| R5 | 自动工单硬编码零押金,模板押金字段不生效 | `sync-worker-orders-service.ts` | 自动发单无押金约束 | +| R6 | 发布工单时凭证核销失败不回滚,指派却拦截 | `admin-work-order-management-service.ts` / `...assignment-service.ts` | 同一规则两套行为 | + +### P1 · 可靠性 + +| # | 问题 | +| --- | --- | +| R7 | Webhook 先落库后处理,失败后平台重推被永久跳过 | +| R8 | 抖音回调同步串行调上游,单次超时 20s,与 R7 叠加不可恢复 | +| R9 | `/collect/*`、`/redeem/*` 无第二因子 | +| R10 | `/collect/upload` 可匿名上传任意文件到 OSS | +| R11 | `/files/object` 无鉴权;claim token 默认永久 | +| R12 | 无 CSP/HSTS;后台 token 在 localStorage | +| R13 | 先到先匹配,后到的快手补全数据不纠正既有工单 | +| R14 | 单实例耦合(限流/调度/SSE/缓存都在进程内) | + +### P2 · 可维护性 + +巨型文件(`WorkOrdersPanel.tsx` 3,686 行、`AdminTaskDetailPage.tsx` 1,925 行、`work-order-query-repo.ts` 1,409 行、`consignment-repo.ts` 1,410 行、`work-order-settlement-repo.ts` 1,181 行);前端无组件测试/E2E;无 CI;迁移启动自动执行且无 down。 + +### 数据模型层面的四个缺口 + +| # | 缺口 | 后果 | +| --- | --- | --- | +| D1 | 没有「人工块」载体:无自动任务且无工单的已支付订单项只靠「无任务且无工单」推断 | 人工履约不可监管、不可计量 | +| D2 | 无商品主数据 `product_catalog` / `channel_product_mappings` | 同一商品名对应 27–35 个 `sku_code`,销量统计不可靠 | +| D3 | 无成本版本与快照 `cost_items/cost_versions/cost_snapshots` | 改价不可追溯,历史毛利不可重算;自动发货 `cost_amount` 只映射不落库 | +| D4 | `order_items` 无金额字段 | 多商品订单无法分摊收入 | + +> 这几条与 `docs/成本中心与三块履约模型.md`、`docs/drafts/*` 的草案一致,草案尚未进迁移。 + +--- + +## 八、我看到的「三个业务面」边界图 + +```mermaid +flowchart LR + subgraph 订单面 + O["orders / order_items"] + T["fulfillment_tasks / claim_tokens"] + V["vouchers / redeem_certificates"] + end + subgraph 工单面 + W["work_orders / work_order_shares"] + WW["worker_users / wallets / ledgers"] + P["work_product_rules / mappings"] + end + subgraph 寄卖面 + CS["consignment_submissions"] + CI["consignment_inventories"] + end + subgraph 财务面 + FA["fund_accounts / fund_entries"] + ME["manual_expenses"] + end + O --> T + O --> V + O --> W + P --> W + W --> WW + CI --> O + CS --> CI + CI --> W + ME --> FA + W -. 影子总账未落地 .-> FA +``` + +三个面共享的只有两件事:**订单项**(`order_items.id` 是所有链路的锚点)与**打手钱包**(工单与寄卖都用它)。 + +--- + +## 九、需要你确认的问题 + +见对话中的提问(不在文档内固化,确认后我再补「口径确认」章节)。 +