add:新增同一个工单同一人可以多次拼单

This commit is contained in:
2026-10-23 15:14:07 +08:00
parent bfd2325ac5
commit 1d5615f1ae
18 changed files with 2996 additions and 128 deletions
@@ -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)精确多选筛选';
@@ -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 <> '';
@@ -66,8 +66,25 @@ test('大厅候选集(buildHallCandidateIdsSql)不再聚合整表拼单', as
test('全仓库只允许保留一处「整表拼单聚合」,且必须是无打手范围的全表统计分支', async () => { test('全仓库只允许保留一处「整表拼单聚合」,且必须是无打手范围的全表统计分支', async () => {
const raw = await readRepoSource() const raw = await readRepoSource()
const source = stripComments(raw) 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 = [ 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( assert.equal(
@@ -78,15 +95,28 @@ test('全仓库只允许保留一处「整表拼单聚合」,且必须是无
) )
const onlyRemaining = globalAggregations[0]! 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( assert.ok(
around.includes('operational_share_summary'), around.includes('operational_share_summary'),
'保留的那处整表聚合不在 operational_share_summary 分支里,需要确认是否有意为之', '保留的那处整表聚合不在 operational_share_summary 分支里,需要确认是否有意为之',
) )
// 注释在 stripComments 里被去掉了,所以断言理由时用原文 // 注释在 stripComments 里被去掉了,所以断言理由时用原文。
const rawIndex = raw.indexOf('GROUP BY wos.work_order_id') // 注意:raw 里前面还有 share_status_rows 的工单内聚合,必须找 operational 那一处。
const rawAround = raw.slice(Math.max(0, rawIndex - 1200), rawIndex + 200) 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( assert.ok(
rawAround.includes('无打手范围时一次聚合全部拼单'), rawAround.includes('无打手范围时一次聚合全部拼单'),
'保留理由(无打手范围时一次聚合优于逐行关联)必须写在注释里,避免被误删', '保留理由(无打手范围时一次聚合优于逐行关联)必须写在注释里,避免被误删',
@@ -105,18 +135,67 @@ test('我的订单的份额关联按工单取一条,避免多份额把列表
test('我的订单概览按工单计数,金额仍按份额求和', async () => { test('我的订单概览按工单计数,金额仍按份额求和', async () => {
const source = stripComments(await readRepoSource()) const source = stripComments(await readRepoSource())
const start = source.indexOf('worker_base AS (') const start = source.indexOf('order_flags AS (')
assert.ok(start >= 0, '缺少我的订单概览的 worker_base CTE') assert.ok(start >= 0, '缺少我的订单概览的 order_flags CTE')
const block = source.slice(start, start + 2200) const block = source.slice(start, start + 4200)
assert.match(block, /LEFT JOIN LATERAL \(/, '概览的份额关联没有按工单取一条') assert.match(block, /share_status_rows AS \(/, '概览没有按份额状态拆分金额')
assert.match(block, /share_reward_total/, '概览没有汇总多份份额的奖励金额') 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( assert.ok(
!block.includes('LEFT JOIN work_order_shares wos'), !block.includes('LEFT JOIN work_order_shares wos'),
'概览仍在直接 JOIN 份额表:同一工单 3 份会被统计成 3 单', '概览仍在直接 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 () => { test('超时统计按工单去重,不按份额/事件行数累加', async () => {
const source = stripComments(await readRepoSource()) const source = stripComments(await readRepoSource())
@@ -223,25 +223,29 @@ export function workerOrderBaseStatusExpression(workerShareAlias = 'worker_share
* 传入 workerId 时,代练中但存在待审核取消申请或未处理反馈的订单归入问题单, * 传入 workerId 时,代练中但存在待审核取消申请或未处理反馈的订单归入问题单,
* 客服驳回/解决后自动回到代练中(计算型归类,无状态迁移)。 * 客服驳回/解决后自动回到代练中(计算型归类,无状态迁移)。
*/ */
export function workerOrderStatusExpression(workerId = 0, workerShareAlias = 'worker_share') { function workerPendingHandlingExistsExpression(workerId: number): string {
const baseExpression = workerOrderBaseStatusExpression(workerShareAlias) return `(
const normalizedWorkerId = Math.max(0, Math.floor(Number(workerId) || 0))
if (normalizedWorkerId <= 0) return baseExpression
const pendingHandlingExists = `(
EXISTS ( EXISTS (
SELECT 1 SELECT 1
FROM worker_order_cancel_requests wcr FROM worker_order_cancel_requests wcr
WHERE wcr.work_order_id = wo.id WHERE wcr.work_order_id = wo.id
AND wcr.worker_id = ${normalizedWorkerId} AND wcr.worker_id = ${workerId}
AND wcr.status = 'pending' AND wcr.status = 'pending'
) OR EXISTS ( ) OR EXISTS (
SELECT 1 SELECT 1
FROM worker_order_feedbacks wf FROM worker_order_feedbacks wf
WHERE wf.work_order_id = wo.id WHERE wf.work_order_id = wo.id
AND wf.worker_id = ${normalizedWorkerId} AND wf.worker_id = ${workerId}
AND wf.status = 'pending' 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 = `( const isTimeoutForMe = `(
(wo.assigned_worker_id IS DISTINCT FROM ${normalizedWorkerId}) (wo.assigned_worker_id IS DISTINCT FROM ${normalizedWorkerId})
AND COALESCE(${workerShareAlias}.status, '') NOT IN ('joined', 'submitted', 'accepted') 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_%' WHERE timeout_ev.event_type LIKE 'timeout_%'
AND timeout_ev.payload_json->>'workerId' = $1::text AND timeout_ev.payload_json->>'workerId' = $1::text
), ),
worker_base AS ( order_flags AS (
SELECT SELECT
wo.id, wo.id,
wo.status AS wo_status, wo.status AS wo_status,
wos.status AS wos_status, wo.reward_amount,
wos.status IS NOT NULL AS is_worker_share, wo.acceptance_json,
wo.assigned_worker_id = $1 AS is_assigned_to_me, wo.assigned_worker_id = $1 AS is_assigned_to_me,
EXISTS ( EXISTS (
SELECT 1 FROM work_order_events we 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.event_type LIKE 'timeout_%'
AND we.payload_json->>'workerId' = $1::text AND we.payload_json->>'workerId' = $1::text
) AS has_timeout_for_me, ) AS has_timeout_for_me,
CASE (
WHEN wos.status IS NOT NULL THEN wos.share_reward_total EXISTS (
WHEN wo.assigned_worker_id = $1 THEN wo.reward_amount SELECT 1 FROM worker_order_cancel_requests wcr
ELSE 0 WHERE wcr.work_order_id = wo.id
END AS worker_settlement_amount, AND wcr.worker_id = $1
wo.acceptance_json 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 FROM work_orders wo
INNER JOIN visible_orders vo ON vo.work_order_id = wo.id 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 SELECT
wos.work_order_id,
CASE 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 wos.status = 'joined' AND f.has_pending_handling THEN 'problem'
WHEN is_worker_share AND wo_status NOT IN ('accepted', 'cancelled', 'problem') WHEN wos.status = 'joined' THEN 'in_progress'
THEN CASE wos_status WHEN wos.status = 'submitted' THEN 'pending_acceptance'
WHEN 'joined' THEN 'in_progress' WHEN wos.status = 'accepted' THEN 'accepted'
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
END AS status, 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, acceptance_json,
is_worker_share, TRUE AS is_worker_share
worker_settlement_amount FROM share_status_rows
FROM worker_base
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 SELECT
status, status,
@@ -742,7 +750,7 @@ export async function getWorkerOrderOverview(workerId: number | string): Promise
CASE CASE
WHEN status IN ('in_progress', 'pending_acceptance', 'problem', 'accepted') WHEN status IN ('in_progress', 'pending_acceptance', 'problem', 'accepted')
OR (status = 'open' AND is_worker_share) OR (status = 'open' AND is_worker_share)
THEN worker_settlement_amount THEN amount
ELSE 0 ELSE 0
END END
), ),
@@ -755,7 +763,7 @@ export async function getWorkerOrderOverview(workerId: number | string): Promise
AND NULLIF(acceptance_json->>'evidenceDueAt', '')::timestamptz > NOW() AND NULLIF(acceptance_json->>'evidenceDueAt', '')::timestamptz > NOW()
)::int AS vip_evidence_pending_count, )::int AS vip_evidence_pending_count,
COALESCE( COALESCE(
SUM(worker_settlement_amount) FILTER ( SUM(amount) FILTER (
WHERE status = 'accepted' WHERE status = 'accepted'
AND acceptance_json->>'reviewMode' = 'vip_auto' AND acceptance_json->>'reviewMode' = 'vip_auto'
AND acceptance_json->>'evidenceStatus' = 'pending' AND acceptance_json->>'evidenceStatus' = 'pending'
@@ -763,8 +771,41 @@ export async function getWorkerOrderOverview(workerId: number | string): Promise
), ),
0 0
)::int AS vip_evidence_pending_settlement_amount )::int AS vip_evidence_pending_settlement_amount
FROM worker_orders FROM order_status_rows
GROUP BY status 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], [normalizedWorkerId],
) )
@@ -1277,9 +1318,35 @@ export function buildWorkOrderWhere({
params.push(normalizedStatuses) params.push(normalizedStatuses)
const statusesPlaceholder = `$${params.length}` const statusesPlaceholder = `$${params.length}`
if (workerSharingId) { if (workerSharingId) {
filters.push( const representativeStatus = `${workerOrderStatusExpression(workerSharingId)} = ANY(${statusesPlaceholder}::text[])`
`${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 { } else {
filters.push( filters.push(
`${useOperationalStatus ? adminOperationalStatusExpression('wo', 'operational_share_summary') : 'wo.status'} = ANY(${statusesPlaceholder}::text[])`, `${useOperationalStatus ? adminOperationalStatusExpression('wo', 'operational_share_summary') : 'wo.status'} = ANY(${statusesPlaceholder}::text[])`,
@@ -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} 仍在把「指派单数 + 拼单份额行数」直接相加`)
}
})
@@ -3,9 +3,13 @@ import type { Response } from 'express'
import { WORK_ORDER_STATUS } from '../../domain/work-order-status.js' import { WORK_ORDER_STATUS } from '../../domain/work-order-status.js'
import { import {
getLatestMaterialEventAtByOrderIds,
getPendingCancelRequestOrderIds,
getPendingFeedbackOrderIds,
getWorkerFinanceRequestById, getWorkerFinanceRequestById,
getWorkerUserById, getWorkerUserById,
getWorkOrderById, getWorkOrderById,
listWorkerTimeoutEventsByOrderIds,
listWorkerWorkOrderNotes, listWorkerWorkOrderNotes,
listWorkerWorkOrderViews, listWorkerWorkOrderViews,
listWorkOrderShares, listWorkOrderShares,
@@ -23,10 +27,13 @@ import { mapAdminOrderListItem } from '../admin/admin-order-read-helpers.js'
import { mapAdminTaskListItem } from '../admin/admin-task-read-helpers.js' import { mapAdminTaskListItem } from '../admin/admin-task-read-helpers.js'
import { import {
isWorkOrderSharingFilled, isWorkOrderSharingFilled,
isWorkerOrderVipEvidencePending,
mapFinanceRequest, mapFinanceRequest,
mapWorkOrderAdmin, mapWorkOrderAdmin,
mapWorkOrderForWorker, mapWorkOrderForWorker,
mapWorkerUser, mapWorkerUser,
pickWorkerRepresentativeShare,
resolveWorkerOrderStatusTabs,
resolveWorkerPermissions, resolveWorkerPermissions,
} from '../worker-platform/mappers.js' } from '../worker-platform/mappers.js'
import { invalidateWorkerMyOrderCaches } from '../worker-platform/worker-my-order-cache.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( for (const workerId of new Set(
(input.workerIds || []).map((item) => Number(item)).filter(Boolean), (input.workerIds || []).map((item) => Number(item)).filter(Boolean),
)) { )) {
const worker = await getWorkerUserById(workerId) const worker = await getWorkerUserById(workerId)
if (!worker) continue if (!worker) continue
const [notes, views] = await Promise.all([
listWorkerWorkOrderNotes(workerId, [workOrderId]), // 同一打手在同一工单可能有多份份额(多次拼单),必须按「全部份额」还原这一行:
listWorkerWorkOrderViews(workerId, [workOrderId]), // 旧实现用 shares.find(...) 只取最早一条,多份额时会把可见性、个人状态、金额都算错,
]) // 还会让实时补丁把行从错误的页签删掉(打手端偶发「页签里没有数据」)。
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)
const isAssignedWorker = Number(workOrder.assigned_worker_id || 0) === workerId 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<number, { createdAt: string }>()),
])
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({ publishRealtimeEvent({
type: 'work_order.changed', type: 'work_order.changed',
entityId: workOrderId, entityId: workOrderId,
scopes: ['worker_my_orders'], scopes: ['worker_my_orders'],
operation: workerOrderVisible ? 'upsert' : 'remove', operation: workerOrderVisible ? 'upsert' : 'remove',
data: mapWorkOrderForWorker( data: {
workOrder, ...mapWorkOrderForWorker(
resolveWorkerPermissions(worker), workOrder,
share, resolveWorkerPermissions(worker),
note, representativeShare,
viewedAt, 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], workerIds: [workerId],
}) })
} }
@@ -83,6 +83,7 @@ export {
} from './worker-finance-mappers.js' } from './worker-finance-mappers.js'
export { export {
isWorkerOrderVipEvidencePending,
mapCollectLookupOrder, mapCollectLookupOrder,
mapWorkCategory, mapWorkCategory,
mapWorkOrderAdmin, mapWorkOrderAdmin,
@@ -91,6 +92,8 @@ export {
mapWorkOrderPublic, mapWorkOrderPublic,
mapWorkOrderShare, mapWorkOrderShare,
mapWorkOrderSharingWorkers, mapWorkOrderSharingWorkers,
pickWorkerRepresentativeShare,
resolveWorkerOrderStatusTabs,
resolveWorkOrderOperationalStatus, resolveWorkOrderOperationalStatus,
resolveWorkerShareProductName, resolveWorkerShareProductName,
resolveWorkerShareStatus, resolveWorkerShareStatus,
@@ -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<string, unknown>): 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'],
)
})
@@ -597,6 +597,147 @@ export function resolveWorkerShareStatus(workOrderStatus: string, shareStatus: u
return workOrderStatus 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<string, number> = {
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<T extends WorkerShareLike>(
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<WorkerOrderStatusTab>()
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<WorkOrderRow, 'status' | 'acceptance_json'>,
nowMs = Date.now(),
): boolean {
if (String(workOrder.status || '') !== WORK_ORDER_STATUS.ACCEPTED) return false
const acceptance = mapAcceptanceForResponse(workOrder.acceptance_json) as Record<string, unknown>
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) { export function mapWorkOrderShare(share: WorkOrderShareRow) {
return { return {
shareId: Number(share.id), shareId: Number(share.id),
@@ -245,25 +245,31 @@ export async function getWorkerMyOrderOverview(session: WorkerSession) {
buildMyOrderCacheKey(worker.id, 'overview', 0), buildMyOrderCacheKey(worker.id, 'overview', 0),
() => getWorkerOrderOverview(worker.id), () => getWorkerOrderOverview(worker.id),
) )
const items = rows.map((row) => ({ const statusRows = rows.filter((row) => row.status !== 'all')
const items = statusRows.map((row) => ({
status: row.status, status: row.status,
orderCount: row.orderCount, orderCount: row.orderCount,
settlementAmount: row.settlementAmount, settlementAmount: row.settlementAmount,
})) }))
const vipEvidencePending = rows.reduce( const vipEvidencePending = statusRows.reduce(
(summary, row) => ({ (summary, row) => ({
orderCount: summary.orderCount + row.vipEvidencePendingCount, orderCount: summary.orderCount + row.vipEvidencePendingCount,
settlementAmount: summary.settlementAmount + row.vipEvidencePendingSettlementAmount, settlementAmount: summary.settlementAmount + row.vipEvidencePendingSettlementAmount,
}), }),
{ orderCount: 0, settlementAmount: 0 }, { orderCount: 0, settlementAmount: 0 },
) )
const all = items.reduce( // 混合状态的多份额工单会同时出现在多个页签,不能再用各页签相加当「全部订单」,
(summary, item) => ({ // 否则同一工单会被重复计数;优先使用 SQL 里按 distinct 工单算好的 all 行。
orderCount: summary.orderCount + item.orderCount, const allRow = rows.find((row) => row.status === 'all')
settlementAmount: summary.settlementAmount + item.settlementAmount, const all = allRow
}), ? { orderCount: allRow.orderCount, settlementAmount: allRow.settlementAmount }
{ orderCount: 0, settlementAmount: 0 }, : items.reduce(
) (summary, item) => ({
orderCount: summary.orderCount + item.orderCount,
settlementAmount: summary.settlementAmount + item.settlementAmount,
}),
{ orderCount: 0, settlementAmount: 0 },
)
return { return {
items: [ items: [
...items, ...items,
@@ -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<string, { label: string; color: string }> = {
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<WorkOrderShare> = [
{
title: '份额',
width: 92,
render: (_, share) => (
<div className="cell-stack">
<Typography.Text strong>
第 {shares.findIndex((item) => item.shareId === share.shareId) + 1} 份
</Typography.Text>
<Typography.Text type="secondary" style={{ fontSize: 12 }}>
#{share.shareId}
</Typography.Text>
</div>
),
},
{
title: '数量',
width: 80,
render: (_, share) => <Typography.Text strong>{share.quantity} 份</Typography.Text>,
},
{
title: '奖励',
width: 110,
render: (_, share) => (
<Typography.Text strong style={{ color: '#cf1322' }}>
{formatMoney(share.shareReward)}
</Typography.Text>
),
},
{
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 <Tag color={meta.color}>{meta.label}</Tag>
},
},
{
title: '截止时间',
width: 170,
render: (_, share) => {
if (!share.deadlineAt) {
return (
<Typography.Text type="secondary" style={{ fontSize: 12 }}>
不限时
</Typography.Text>
)
}
return (
<div className="cell-stack">
<Typography.Text style={{ fontSize: 12 }}>
{formatDateTime(share.deadlineAt)}
</Typography.Text>
{share.status === 'joined' && !isTimedOut ? (
<DeadlineCountdown deadlineAt={share.deadlineAt} />
) : null}
</div>
)
},
},
{
title: '验收信息',
width: 220,
render: (_, share) => <ShareAcceptanceCell share={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 (
<Typography.Text type="secondary" style={{ fontSize: 12 }}>
无
</Typography.Text>
)
}
return (
<Space size={6} wrap>
{canAccept ? (
<Button
type="primary"
size="small"
onClick={() => onOpenAcceptance(order, share.shareId)}
>
验收
</Button>
) : null}
{canEdit && share.status === 'submitted' ? (
<Button size="small" onClick={() => onOpenAcceptance(order, share.shareId)}>
补充图片
</Button>
) : null}
{canRemind ? (
<Button size="small" onClick={() => onRemindAcceptance(order)}>
催验收
</Button>
) : null}
{canCancel ? (
<Button danger size="small" onClick={() => onOpenCancelRequest(order, share.shareId)}>
申请取消
</Button>
) : null}
</Space>
)
},
},
]
return (
<Table<WorkOrderShare>
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 (
<Typography.Text type="secondary" style={{ fontSize: 12 }}>
未提交
</Typography.Text>
)
}
return (
<div className="cell-stack">
{acceptanceFiles.length > 0 ? (
<ImagePreviewList files={acceptanceFiles} size={32} maxVisible={2} />
) : null}
{share.submittedAt ? (
<Typography.Text type="secondary" style={{ fontSize: 12 }}>
提交:{formatDateTime(share.submittedAt)}
</Typography.Text>
) : null}
{draftFiles.length > 0 && !share.submittedAt ? (
<Tag color="processing">已暂存 {draftFiles.length} 张</Tag>
) : null}
{String(share.acceptance?.note || '').trim() ? (
<Typography.Text
type="secondary"
ellipsis={{ tooltip: share.acceptance.note }}
style={{ fontSize: 12 }}
>
{share.acceptance.note}
</Typography.Text>
) : null}
</div>
)
}
@@ -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 { useNavigate } from 'react-router'
import { App, Badge, Button, Form, Modal, Radio, Space, Tag, Typography } from 'antd' import { App, Badge, Button, Form, Modal, Radio, Space, Tag, Typography } from 'antd'
import { useState } from 'react' import { useState } from 'react'
@@ -193,6 +193,8 @@ export default function WorkerOrdersPage() {
queryKey: ['worker-my-orders', status, keyword, page, pageSize], queryKey: ['worker-my-orders', status, keyword, page, pageSize],
queryFn: () => fetchWorkerMyOrders(buildWorkerOrderQuery(status, keyword, page, pageSize)), queryFn: () => fetchWorkerMyOrders(buildWorkerOrderQuery(status, keyword, page, pageSize)),
staleTime: 2_000, staleTime: 2_000,
// 切页签时保留上一页数据,避免请求返回前先闪一下空列表。
placeholderData: keepPreviousData,
refetchOnWindowFocus: false, refetchOnWindowFocus: false,
retry: false, retry: false,
}) })
+8
View File
@@ -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}`
}
@@ -89,7 +89,20 @@ export function resolveWorkerMyOrderRealtimeVerdict(input: {
}): { operation: 'upsert' | 'remove'; shouldInsert: boolean } { }): { operation: 'upsert' | 'remove'; shouldInsert: boolean } {
const item = isRecord(input.item) ? input.item : {} const item = isRecord(input.item) ? input.item : {}
const statusFilter = String(input.statusFilter || '') 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 statusProvable = input.explicitOrderNo || statusMatches
const shouldRemove = String(input.operation || '') === 'remove' || !statusProvable const shouldRemove = String(input.operation || '') === 'remove' || !statusProvable
@@ -188,3 +188,95 @@ test('非第 1 页不得插入,只做就地更新', () => {
assert.equal(verdict.operation, 'upsert') assert.equal(verdict.operation, 'upsert')
assert.equal(verdict.shouldInsert, false) 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')
})
@@ -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` 路由守卫。均未触及本次改动文件,建议后续统一把源码守卫改成忽略行尾。
@@ -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<br/>生命周期状态"]
WS["work_order_shares<br/>拼单份额"]
EV["work_order_events<br/>只追加时间线"]
end
subgraph 资金
W["worker_wallets<br/>available / frozen_deposit<br/>pending_unfreeze / frozen_withdraw"]
L["worker_wallet_ledgers<br/>只追加流水"]
U["worker_deposit_unfreezes<br/>延迟解冻队列"]
end
WO -->|"整单接取"| D1["deposit_freeze"]
WS -->|"按份额接取"| D1
WO -->|"验收"| D2["reward_settlement / deposit_release"]
WS -->|"份额验收"| D2
D2 -->|"unfreezeDays > 0"| U
U -->|"到期任务"| D3["deposit_unfreeze"]
WO -->|"超时/问题单/退单"| D4["deposit_release 或 deposit_deduction"]
D1 --> L
D2 --> L
D3 --> L
D4 --> L
L --> W
```
**唯一的余额写入方式**(除提现外)都是「`SELECT … FOR UPDATE` 读钱包 → 计算 next → `UPDATE` → `INSERT ledger`」,没有任何一处只更新余额而不写流水。
---
## 三、抢单
### 3.1 入口与校验顺序
`POST /api/v1/worker/hall/orders/:workOrderId/grab` → `grabWorkerHallOrder`
```mermaid
sequenceDiagram
participant W as 打手
participant S as hall-action-service
participant DB as PostgreSQL
W->>S: grab(workOrderId)
S->>S: 会话有效 → 读打手 + 等级权限 + 大厅配置
S->>S: visibleAfterIso = now - visibleDelaySeconds(等级可见延迟)
S->>DB: 读工单
alt workOrder.sharing_enabled
S->>DB: joinWorkOrderShare(takeRemaining=true)
else 整单抢单
S->>DB: countWorkerActiveOrders
S->>S: 校验 maxActiveOrders / 可用余额 ≥ 冻结额
S->>DB: grabWorkOrder(...)(单事务)
S->>DB: work_order_events grabbed + 实时事件
end
```
整单抢单在**事务内**再次做全部校验(防并发),并条件更新:
```sql
UPDATE work_orders
SET status='in_progress', assigned_worker_id=$1, last_assigned_worker_id=$1,
assigned_at=$2, deadline_at=$3,
gift_phase = CASE WHEN acceptance_mode='friend_gift' THEN 'material_required' ELSE '' END,
booster_material_json='{}', friend_added_at=NULL, gift_available_at=NULL,
<CLEAR_DELIVERY_RESIDUE_SQL>, updated_at=$2
WHERE id=$4 AND status='open' AND assigned_worker_id IS NULL
```
`CLEAR_DELIVERY_RESIDUE_SQL` 清掉上一手的 `submitted_at / accepted_at / acceptance_json / draft_acceptance_json / problem_note / worker_note`,但**不动买家资料**(`material.collect`)和寄卖留痕(`consignmentHistory`)。
### 3.2 冻结押金怎么算
```text
整单:freezeAmount = max(0, work_order.required_deposit_amount - depositFreeAmount)
拼单:nextRequired = round(required_deposit_amount × nextQuantity / totalQuantity)
requiredShareDeposit = max(0, nextRequired - depositFreeAmount)
share_deposit = max(已冻结的 share_deposit, requiredShareDeposit) // 不回退
本次冻结 = share_deposit - 已冻结
```
`depositFreeAmount = 等级 permission_json.depositFreeAmount + worker_users.personal_deposit_free_amount`(两者叠加,个人额度是等级之上的追加额度)。
> 注意:`deposit_threshold_amount`(押金门槛)目前只读写/展示,不参与冻结计算;自动生成的工单把 `required_deposit_amount` 与 `deposit_threshold_amount` 都写成 0(见风险 W1)。
### 3.3 大厅可见性
| 规则 | 实现 |
| --- | --- |
| 等级可见延迟 | `permission_json.visibleDelaySeconds`;未配置时按等级键默认:vip1 20s / vip2 15s / vip3 10s / vip4 5s / vip5 0s |
| 大厅总量 | `worker_hall.maxVisibleOrders`(默认 50,上限 500) |
| 分类上限 | `worker_hall.categoryMaxVisibleOrders`(分类 ID → 上限) |
| 拼满隐藏 | `excludeFilledSharing` |
可见延迟**不是拦截**,而是「只把发布时间早于 `now - delay` 的单放进候选集」,抢单时在事务内再校验一次容量,防止延迟期内被绕过。
---
## 四、拼单
### 4.1 数据模型
`work_order_shares`:`UNIQUE(work_order_id, worker_id)`,一份行代表一个打手在该工单的全部份额。
| 列 | 含义 |
| --- | --- |
| `quantity` | 该打手累计份数(同一工单可追加) |
| `unit_reward` / `share_reward` | 单份报酬 / 累计报酬 |
| `share_deposit` | 该打手是否该单的累计冻结额(不是本次增量) |
| `status` | `joined → submitted → accepted / cancelled` |
| `acceptance_json` / `draft_acceptance_json` | 验收资料 / 草稿 |
### 4.2 参与规则
- 普通参与:指定 `quantity`(默认 1),不得超过剩余份数 `sharing_total_quantity - Σ(非 cancelled 份额)`。
- 「立即抢单」:`takeRemaining=true`,在事务锁内**承接全部剩余份数**;如果已有自己的 `joined` 份额,则在此基础上追加,否则报「数量已满」。
- 整单接取者不能再拼单(`work_order_owner_conflict`)。
- 重复参与被唯一键挡住;同一打手多次参与只能走「追加份数」路径。
- 活跃单上限只在**新建份额**时校验(追加不重复计入)。
### 4.3 母单状态收敛
```text
份额 joined → 母单保持 open(可继续参与)
份额 submitted → 仍 open;若「份数已全部分配」且「没有 joined 份额」→ 母单转 pending_acceptance
单份额验收 → 该份额 accepted;若这是最后一个未结算份额 → 母单 accepted 并补跑寄卖钩子
全部取消 → 母单回 open
```
`submitWorkOrderShareAcceptance` 里那句 `WHERE id=$2 AND status='open'` 是关键:只有母单还在 `open` 时才允许被自动推进,避免与后台手动验收并发覆盖。
### 4.4 两种验收姿势
| 方式 | 入口 | 行为 |
| --- | --- | --- |
| 单独验收某份额 | `POST /admin/worker-platform/orders/:id/shares/:shareId/accept` | 只结算该份额:份额报酬 + 份额押金;已结算份额不会在统一验收时重复结算 |
| 统一验收母单 | `POST /admin/worker-platform/orders/:id/accept` | 先检查所有份额都已 `submitted`(有任一 `joined` 直接 409 `sharing_pending_submissions`),再逐个结算未结算份额,最后结算整单打手(如有) |
> 设计上的一个细节:统一验收时 `settledShareCount` 用「`status !== 'accepted'`」统计,已经单独验收过的份额会被跳过——这是避免重复发钱的唯一保护,没有幂等键。
### 4.5 已提交份额不能绕过
问题单的 `reopen / cancel_*` 与超时处置都会先检查 `getActiveWorkOrderShareCountWithClient`;有活跃份额就拒绝整单处置(`work_order_has_active_shares`)。这是为了防止「母单被整单带走,份额验收入口全部锁死」。
---
## 五、好友赠送(friend_gift)
### 5.1 三态叠加
```mermaid
stateDiagram-v2
[*] --> material_required: 建单时 acceptance_mode=friend_gift
material_required --> material_submitted: 打手提交本人资料(昵称/ID/区服/系统 + 主页截图)
material_submitted --> friend_countdown: 后台确认已加好友(记 friend_added_at,算 gift_available_at)
friend_countdown --> gift_ready: 到点(不落库,实时计算)
gift_ready --> pending_acceptance: 打手提交赠送凭证
pending_acceptance --> accepted: 后台人工验收(必审)
material_submitted --> material_required: 后台重置资料
friend_countdown --> material_required: 后台重置资料
```
- `work_orders.status` 在整条链路上保持 `in_progress`(或 `problem`),**不因赠送阶段改变**;前端展示用 `resolveWorkOrderGiftPhase()`。
- 冷却时长 = `gift_cooldown_hours` 小时 + `gift_cooldown_minutes` 分钟;两者都为 0 时按默认 **72 小时** 兜底(`DEFAULT_GIFT_COOLDOWN_HOURS`),上限 30 天。
- 确认加好友时,`deadline_at` 被顺延为「冷却结束 + `timeout_minutes`」,即**冷却期不计入打手超时**。
- 提交验收时如果 `gift_phase !== friend_countdown` 或未到点,直接 409 `work_order_gift_cooldown_active`。
### 5.2 押金分水岭
```text
friend_added_at IS NULL → 取消/退回/超时:按策略正常退还
friend_added_at 有值 → 取消/退回/超时:释放类一律转为扣除(deposit_deduction)
寄卖卖家的发布押金同口径没收
```
实现集中在 `isWorkOrderGiftDepositForfeitable()`,被超时处置、问题单处置、退单审核三处共用。语义是:**好友已经加了,赠送动作由打手/卖家完成,此时脱离订单属于套利**。
### 5.3 内部现货变体(寄卖)
寄卖库存绑定工单走 `internal_stock` 时没有接单打手,流程被压缩:
```text
assigned_worker_id = null,material.consignment.fulfillmentType = internal_stock
→ parkWorkOrderWaitingFriendAdd:status=in_progress,gift_phase=material_required(语义变成「待加好友」)
→ 客服线下通知买家加好友 → 手动确认 → 进入冷却
→ 冷却结束:后台可直接验收(无需打手交图)
```
`hasInternalConsignmentFulfillment()` 是识别标志:`assigned_worker_id=0` + `material.consignment.inventoryId` + 非 `worker_deposit` + 无 `consignment.workerId`。
---
## 六、验收
### 6.1 打手提交(整单)
| 规则 | 细节 |
| --- | --- |
| 归属 | 必须是 `assigned_worker_id` 本人(寄卖卖家走 `asConsignmentSupplier` 例外) |
| 状态 | `in_progress` / `problem` |
| 超时前置 | 已过期则先执行超时处置再抛 409(接口惰性判定) |
| 图片 | 默认必须至少 1 张(`work_order_acceptance_image_required`);VIP/内部打手可不交图 |
| 赠送单 | 必须 `friend_countdown` 且已到点;**一律人工验收**,VIP/内部免审不适用 |
| 草稿 | 可先存 `draft_acceptance_json`,提交时优先取「本次上传 + 草稿里已有的图」并去重 |
免审条件:`worker_type === 'internal'` **或** 等级 `permission_json.autoAcceptWithoutEvidence === true`(且不是拼单份额参与,`canWorkerAutoAcceptWholeOrder` 明确排除拼单)。
免审且无图时,验收资料写入:
```json
{ "reviewMode": "vip_auto", "evidenceStatus": "pending", "evidenceDueAt": "<now+24h>" }
```
补证据走 `supplementWorkerAcceptedOrderEvidence`(验收后仍可补)。
### 6.2 催促
- 提交满 **10 分钟** 才能催;
- 每 **30 分钟** 一次,最多 **3 次**;
- 只支持整单(拼单份额不能催);
- 催的是 `admin_notifications` 的同一个待办(`reminder_count` 累加),不会刷屏。
### 6.3 后台验收
- 只接受 `pending_acceptance`(`capabilities.canAccept`)。
- 历史拼单母单如果还是 `open`,验收前自动推成 `pending_acceptance`。
- 内部现货好友赠送在 `gift_ready` 且 `in_progress` 时,后台可直接验收(自动补 `submitted_at`)。
- 有未提交份额 → 409 `work_order_sharing_pending_submissions`。
---
## 七、结算(核心)
### 7.1 一个事务里做完 8 件事
`acceptWorkOrderAndSettle`(整单)与 `acceptWorkOrderShareAndSettle`(份额)结构一致:
```mermaid
flowchart TD
A["锁工单(pending_acceptance)+ 锁全部非取消份额"] --> B{"还有 joined 份额?"}
B -->|是| X["409 sharing_pending_submissions"]
B -->|否| C["工单置 accepted / 份额置 accepted"]
C --> D["释放寄卖发布押金(仅整单,按 material.consignment.sourceSubmissionId)"]
D --> E["逐份额结算:售后欠款抵扣 → 报酬 → 押金"]
E --> F["整单打手结算:押金 → 报酬 → 欠款抵扣"]
F --> G["寄卖供货货款(internal_stock 时补给卖家)"]
G --> H["等级提升判定"]
H --> I["写 work_order_events accepted"]
```
### 7.2 资金等式(单次结算)
```text
releaseAmount = min(该工单未结押金, 钱包 frozen_deposit_amount)
debtOffset = min(售后未结欠款, 本次报酬) // FIFO,按欠款创建时间
shouldDelay = rewardUnfreezeEnabled && unfreezeDays > 0
pendingDelta = (shouldDelay ? 报酬 : 0) + (unfreezeDays > 0 ? 押金 : 0)
available' = available + (shouldDelay ? 0 : 报酬) + (unfreezeDays > 0 ? 0 : 押金) − debtOffset
frozen' = max(0, frozen − releaseAmount)
total_settled += 报酬
```
默认财务配置(`worker_finance` 配置键):
| 项 | 默认 | 范围 |
| --- | --- | --- |
| `depositUnfreezeDays` | 3 天 | 0–30 |
| `rewardUnfreezeEnabled` | 开启 | 布尔 |
| `consignmentPublishDepositFen` | 2000 分(¥20) | 0–1,000,000 |
| `consignmentWhitelistEnabled` | 开启 | 布尔 |
### 7.3 流水类型对照
| 场景 | ledger_type | 金额符号 |
| --- | --- | --- |
| 接单冻结 | `deposit_freeze` | − |
| 验收即释放押金 | `deposit_release` | + |
| 验收延迟解冻(入队) | `deposit_pending_unfreeze` | +(余额不加,进 pending_unfreeze) |
| 到期解冻 | `deposit_unfreeze` | + |
| 验收即结算报酬 | `reward_settlement` | + |
| 验收延迟解冻报酬 | `reward_pending_unfreeze` | + |
| 拼单报酬 | `sharing_reward` | + |
| 售后抵扣 | `after_sales_debt_offset` | − |
| 售后从可用余额扣 | `after_sales_balance_deduction` | − |
| 超时/退单/问题单扣押金 | `deposit_deduction` | − |
| 提现冻结 / 支付 / 解冻 | `withdraw_freeze` / `withdraw_paid` / `withdraw_release` | − / − / + |
| 后台手动加款 | `manual_credit` | + |
### 7.4 延迟解冻
- 入队:`worker_deposit_unfreezes(worker_id, work_order_id, amount, fund_type='deposit'|'reward', unfreeze_at, status='pending')`。
- 出队:定时任务 `deposit-unfreeze`(60s,scanLimit 100)→ `settleDueDepositUnfreezes`。
- **到期判定按「当前配置天数」回溯**:管理员把 `depositUnfreezeDays` 调小后,存量队列会提前视为到期(`retroactiveDays`),不会卡在旧到期时间。
- 提现前后台会先跑一次 `settleDueDepositUnfreezes({workerId})`,避免「到期了但还没解冻、余额显示不足」。
### 7.5 售后抵扣顺序
```text
验收报酬 → 先抵扣 open 欠款(FIFO)→ 抵扣部分写 after_sales_debt_offset
→ 欠款结清时 cases.debt_amount 归零、status 从 recovery_pending 变 closed
→ 报酬不足抵扣的部分继续留在 worker_after_sales_debts.outstanding
```
### 7.6 等级提升 / 降级
| 方向 | 触发 | 规则 |
| --- | --- | --- |
| 升 | 每次验收后 `maybeUpgradeWorkerLevelWithClient` | `有效验收数 = 累计已验收单数 − level_penalty_accepted_count`;取 `upgradeThreshold ≤ 有效数` 的最高等级;`is_level_locked` 时完全不动;只升不降 |
| 降 | 退单申请审核通过 | 非 VIP1:每 **10 次**通过降一级,并把 `level_penalty_accepted_count` 抬到「实际验收数 − 新等级门槛」,提高重新升级成本 |
| 封 | 退单申请审核通过 | VIP1 累计 **5 次**通过 → `status=disabled`、`session_version+1`、吊销全部会话 |
`worker_level_logs.reason` 取值:`auto` / `cancel_request_penalty` / `cancel_request_ban`。
### 7.7 寄卖钩子
| 钩子 | 时机 | 作用 |
| --- | --- | --- |
| `releaseConsignmentPublishDepositBySubmissionWithClient` | 整单验收 | 释放卖家发布押金 |
| `settleConsignmentSupplierPayoutByWorkOrderWithClient` | 验收后 | 内部现货履约时订单没有接单打手,货款补给供货卖家;接单人就是卖家本人时不重复付款 |
| `releaseConsignmentInventoryBindingForWorkOrderWithClient` | 超时/退单/问题单/取消 | 把占用的现货放回在库池,避免永久 `consumed` |
---
## 八、三条处置路径的押金矩阵
| 处置 | 入口 | 目标状态 | `reopen` | `cancel_release` | `cancel_deduct` | `return_to_worker` |
| --- | --- | --- | --- | --- | --- | --- |
| 超时 | 定时任务 / 接口惰性 | open / cancelled | 退押金 | 退押金 | 扣押金 | — |
| 问题单 | 后台 `problem/resolve` | open / cancelled / 恢复 | 退押金 | 退押金 | 扣押金 | **不动押金**,恢复到 `marked_problem` 的 `from_status` |
| 退单申请 | 后台审核通过 | open(重新上架) | — | 退押金 | — | — |
补充规则:
- 三种路径都先检查「是否还有活跃份额」;有则拒绝整单处置。
- **已确认加好友的赠送单**:`cancel_release` 会被改写成 `cancel_deduct`,事件类型也按实际资金动作记(`timeout_cancel_deduct` / `problem_cancel_deduct`)。
- 超时与问题单会释放寄卖现货绑定;`return_to_worker` 不会(单还在进行)。
- 退单申请:待审核期间订单与押金保持原状;审核通过**在同一事务**里撤单、退押金、执行等级处罚。
- 打手每天最多提 **5 次**退单申请(按 Asia/Shanghai 自然日,`pending + approved` 计数),同一工单+份额不能有两条 pending。
- 拼单份额超时只在母单 `open` 时生效(份额 `joined` 且 `created_at + timeout_minutes` 已过),否则跳过。
---
## 九、售后与欠款
### 9.1 生命周期
```mermaid
stateDiagram-v2
[*] --> awaiting_worker_response: 后台建单(必须已验收)
awaiting_worker_response --> under_review: 打手提交说明 + 证据
awaiting_worker_response --> under_review: 后台直接处置
under_review --> closed: not_upheld(不成立)
under_review --> closed: remedied(已补救)
under_review --> recovery_pending: upheld 且追缴 > 报酬/可扣押金 → 产生欠款
under_review --> closed: upheld 且全额扣到
recovery_pending --> closed: 欠款结清(验收抵扣 / 充值抵扣 / 后台代还)
```
### 9.2 建单约束
| 约束 | 说明 |
| --- | --- |
| 工单必须 `accepted` | `work_order_not_accepted` |
| 指定份额时必须已验收 | `work_order_share_not_accepted` |
| 必须能定位责任打手 | `worker_missing`;内部现货寄卖单没有接单打手时,自动反查供货卖家(`getConsignmentSupplierByWorkOrderWithClient`)作为责任人 |
| 同一「工单 + 打手 + 份额」同时只能有 1 条进行中 case | 由部分唯一索引 `idx_worker_after_sales_cases_active_target` 保证:`UNIQUE(work_order_id, worker_id, COALESCE(work_order_share_id,0)) WHERE status IN ('awaiting_worker_response','under_review','recovery_pending')`,插入用 `ON CONFLICT DO NOTHING`,冲突即 `active_case_exists` |
| 投诉说明必填、≤ 2000 字 | |
| 问题类型默认 `fake_evidence` | |
### 9.3 追缴顺序
```text
1. 该工单尚未解冻的押金(worker_deposit_unfreezes pending, fund_type='deposit')
→ 走 deductPendingDepositUnfreeze,直接从队列扣,不进可用余额
2. 可用余额(available_amount)
3. 仍不足 → 写入 worker_after_sales_debts(status='open', outstanding_amount)
→ case.status = recovery_pending
```
后续抵扣:
- 该打手下一次验收报酬 → 自动 FIFO 抵扣(`offsetWorkerAfterSalesDebtsWithClient`);
- 充值入账 → `repayWorkerAfterSalesDebtsFromAvailableWithClient`;
- 后台手动代还 → `repayAfterSalesCaseDebt`。
每一笔抵扣都会写 `worker_after_sales_case_events`(`debt_offset` / `debt_settled`)+ 对应钱包流水,两边都有痕迹。
---
## 十、发现的隐患(按资金风险排序)
| # | 隐患 | 证据 | 后果 |
| --- | --- | --- | --- |
| W1 | 自动建单硬编码 `required_deposit_amount=0`、`deposit_threshold_amount=0` | `sync-worker-orders-service.ts` | 自动单永远无押金约束,模板里的押金配置是死配置 |
| W2 | 抢单先锁钱包、指派/拼单先锁工单 | `work-order-grab-repo.ts` vs 同文件 `assignWorkOrderToWorker` / `work-order-share-repo.ts` | 并发抢单+指派可 40P01 死锁;目前只靠客户端重试 |
| W3 | 统一验收的「已结算份额」判断只看 `status !== 'accepted'`,没有幂等键 | `acceptWorkOrderAndSettle` | 极端并发下(两次验收同时进入)可能重复发报酬;`FOR UPDATE` 锁工单能挡住大部分,但没有业务幂等 |
| W4 | 发单金额 > 用户付款只在前端提示 | `publishAdminWorkOrder` 只校验 `reward_amount > 0` | 直接调 API 可发亏损单;`source_order_amount_fen` 只是记录,不参与校验 |
| W5 | 发布工单时电子凭证核销失败只写事件,不回滚发布 | `publishAdminWorkOrder` 的 `voucherConsume.ok=false` 分支 | 券没核销但工单已在大厅;指派路径却会拦截,同一规则两套行为 |
| W6 | 钱包余额没有「由流水重算」校验,也没有月度关账 | `worker_wallets` 可变字段 | 任何一次漏写/重复写流水都不会被系统发现,只能人工对账 |
| W7 | `work_orders.task_id` 是历史遗留列(迁移 091 已注释废弃) | 仍参与部分 mapper | 新人容易误以为「工单必须挂履约任务」 |
---
## 十一、两笔完整流水示例
> 先记一条硬约束:**好友赠送与拼单互斥**。建单时 `sharingEnabled = isFriendGiftAcceptanceMode(rule.acceptance_mode) ? false : ...`,所以不存在「好友赠送 + 拼单份额」的单子。
### 11.1 拼单单
设定:`sharing_total_quantity=3`、`unit_reward=3000 分`、`required_deposit_amount=6000 分`;打手 A 等级免押 1000 分、活跃上限 3;`depositUnfreezeDays=3`、`rewardUnfreezeEnabled=true`。
| 步骤 | 动作 | 钱包变化 | 关键字段/流水 |
| --- | --- | --- | --- |
| 1 | A 参与 2 份 | nextRequired = 6000×2/3 = 4000;requiredShare = 4000−1000 = 3000;冻结 3000 | `work_order_shares(quantity=2, share_deposit=3000)`,`deposit_freeze −3000` |
| 2 | A 追加 1 份 | nextRequired = 6000;requiredShare = 5000;本次冻结 2000 | `quantity=3, share_deposit=5000`,`deposit_freeze −2000` |
| 3 | 提交验收 | 份数已满且无 `joined` 份额 → 母单 `open → pending_acceptance` | `share.status=submitted` |
| 4 | 验收该份额 | 报酬 9000 延迟解冻;押金 5000 延迟解冻 | `pending_unfreeze +14000`,`reward_pending_unfreeze +9000`,`deposit_pending_unfreeze +5000` |
| 5 | 3 天后任务 | 到期释放 | `deposit_unfreeze +14000`,`pending_unfreeze −14000` |
### 11.2 好友赠送单
设定:`acceptance_mode=friend_gift`、`required_deposit_amount=5000`、免押 0、冷却 72h、`timeout_minutes=120`。
| 步骤 | 动作 | 结果 |
| --- | --- | --- |
| 1 | 打手抢单 | 冻结 5000(`deposit_freeze`);`gift_phase=material_required`;`deadline_at=now+2h` |
| 2 | 提交本人资料 | `gift_phase=material_submitted`;`friend_added_at=NULL` |
| 3 | 客服确认加好友 | `friend_added_at=now`;`gift_available_at=now+72h`;`deadline_at=now+72h+2h`(冷却不计超时) |
| 4 | 冷却中尝试提交 | 409 `work_order_gift_cooldown_active` |
| 5 | 到点后提交赠送凭证 | `status=pending_acceptance`;**不触发免审**(赠送单强制人工) |
| 6 | 客服验收 | 押金 5000 → 延迟解冻;报酬按 `reward_amount` 入账(同样按 `rewardUnfreezeEnabled` 决定是否延迟) |
| 7′ | 若第 3 步之后打手退单 | 押金从「应释放」改为 `deposit_deduction −5000`;寄卖发布押金同口径没收 |
---
## 十二、待确认问题(等你回答后我再补口径)
1. **押金策略的业务意图**:`required_deposit_amount` 现在是「按单金额」还是「按 SKU 单价 × 份数」?自动单为什么固定为 0——是刻意豁免,还是历史遗留?
2. **延迟解冻的初衷**:`depositUnfreezeDays=3` + `rewardUnfreezeEnabled=true` 是为了防售后,还是为了压打手现金流?这决定售后追缴是否应该优先扣「待解冻队列」而不是可用余额。
3. **VIP1 五次退单封号**这条规则现在还在执行吗?线上是否有误封申诉流程(代码里只有 `review_note`)?
4. **好友赠送冷却 72h** 是否要按品类差异化?现在只有模板上的 `gift_cooldown_hours/minutes` 两个字段。
5. **拼单是否允许「一人多份 + 全部单人完成」**?当前逻辑允许,且验收后一次性结算全部份额——如果业务上要求分散风险,这条需要改。
@@ -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["外部平台<br/>91卡券 / 快手 / 拼多多 / 抖音码速达"] --> C
U2["买家<br/>领取页 / H5 / 兑换页"] --> C
U3["打手<br/>worker.khhao.com"] --> C
U4["后台运营 / 客服 / 财务"] --> C
C["Caddy :80/443<br/>反代 + 静态缓存 + 压缩"] --> F["frontend<br/>React 19 + Vite(一套产物三个入口)"]
C --> B["backend<br/>Express 5 + TS<br/>routes → services → repositories → pg"]
B --> P[("PostgreSQL 16")]
B --> O["对象存储<br/>生产 OSS / 本地 MinIO"]
B --> X["外部 API<br/>affiliate-dash / 快手 OpenAPI / 抖音码速达 / PDD"]
B --> M["进程内存<br/>限流桶 / 调度器 / 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["来源进单<br/>91 / PDD / 抖音"] --> B["upsertOrderFromSource<br/>advisory lock 去重 + 状态合并"]
B --> C["order_items 落库<br/>resolveOrderItemForFulfillment 判定载体"]
C --> D{"isConfigured?"}
D -->|否| E["待补全<br/>91 返回 orderStatus=10"]
C -->|是| F["syncDeliveryTasksForOrder<br/>按 order_item × quantity 建任务单元"]
F --> G{"executor_key"}
G -->|affiliate_dash| H["自动发货<br/>建 affiliate-dash 单 + 领取链接"]
G -->|manual_dispatch| I["人工履约<br/>manual_review 待办"]
C --> J["syncWorkerOrdersForSourceOrder<br/>product-match 匹配接单模板"]
J -->|命中且 auto_create| K["work_orders<br/>待补素材 / 待发布"]
K --> L["打手大厅抢单 / 后台指派"]
L --> M["代练履约 → 提交验收"]
M --> N["验收 → 结算报酬 / 释放押金 / 等级进度"]
H --> O["买家领取页 claimUrl<br/>填 UID → 绑定 → 提交发货"]
O --> P["affiliate-dash delivered 回调"]
P --> Q["任务 completed / redeemed<br/>快手电子凭证核销闭环"]
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<br/>provider=91kaquan, platform=kuaishou<br/>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 加密卡密)<br/>或 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 映射<br/>常量表 + 后台 skuMapping 覆盖"]
B -->|抖音| D["内部 SKU → affiliate-dash sku 映射"]
C --> E["resolveFulfillmentRoute"]
D --> E
E --> F{"路由顺序"}
F --> G["1. 履约路由总开关<br/>关闭 = 停止自动履约"]
F --> H["2. 指定匹配(显式映射)"]
F --> I["3. 商品名规则 exact / contains"]
F --> J["4. 内部 SKU 规则"]
F --> K["5. 全局执行器优先级"]
F --> L["6. unmatchedExecutorKey(生产为空)"]
G --> M["snapshot.fulfillmentRoute<br/>selectedExecutorKey / reason / candidates / skipped"]
H --> M
I --> M
J --> M
K --> M
L --> M
M --> N{"selectedExecutorKey"}
N -->|affiliate_dash| O["isConfigured=true<br/>建自动履约任务"]
N -->|manual_dispatch| P["isConfigured=true<br/>建人工任务"]
N -->|空| Q["isConfigured=false<br/>待补全 / 交给发单层判定"]
```
判定结果全部写进 `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<br/>游戏/系统/区服/系列/钻级/璀璨/要价/凭证"] --> B{"客服审核"}
B -->|驳回| C["rejected(可改后重提)"]
B -->|通过| D["入库 consignment_inventories<br/>inventory_no = INV…<br/>status=in_stock"]
D --> E{"订单匹配"}
E -->|规格匹配| F["占用 consumed<br/>bound_order_id / bound_work_order_id"]
E -->|不匹配| D
F --> G{"履约方式"}
G -->|internal_stock| H["内部现货交付<br/>客服操作"]
G -->|worker_deposit| I["指派打手代练<br/>冻结打手押金"]
H --> J["验收 → 释放发布押金 + 结货款"]
I --> J
F -.->|撤销| K["revoke:打手押金 + 卖家押金<br/>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` 是所有链路的锚点)与**打手钱包**(工单与寄卖都用它)。
---
## 九、需要你确认的问题
见对话中的提问(不在文档内固化,确认后我再补「口径确认」章节)。