diff --git a/.env.mac-docker.example b/.env.mac-docker.example index 56de090c..a21954ec 100644 --- a/.env.mac-docker.example +++ b/.env.mac-docker.example @@ -42,6 +42,17 @@ STORAGE_ACCESS_KEY_ID=minioadmin STORAGE_SECRET_ACCESS_KEY=minioadmin STORAGE_REGION=us-east-1 STORAGE_MAX_UPLOAD_SIZE_MB=10 + +# 验收视频(打手端验收上传,选填):只在这里配置,后台不提供配置界面。 +# 超过时长上限直接拒绝(不自动裁剪);体积上限同时是上传接口硬上限。 +WORKER_ACCEPTANCE_VIDEO_ENABLED=true +WORKER_ACCEPTANCE_VIDEO_MAX_DURATION_SECONDS=30 +WORKER_ACCEPTANCE_VIDEO_MAX_SIZE_MB=12 +# 压缩参数:目标高度 360/480/540/720/1080、视频码率、音频码率、帧率 +WORKER_ACCEPTANCE_VIDEO_TARGET_HEIGHT=720 +WORKER_ACCEPTANCE_VIDEO_BITRATE_KBPS=1200 +WORKER_ACCEPTANCE_VIDEO_AUDIO_BITRATE_KBPS=64 +WORKER_ACCEPTANCE_VIDEO_FPS=30 STORAGE_OSS_ENDPOINT= STORAGE_OSS_BUCKET= STORAGE_OSS_ACCESS_KEY_ID= diff --git a/.env.server.example b/.env.server.example index 82814883..3ee4594d 100644 --- a/.env.server.example +++ b/.env.server.example @@ -91,6 +91,17 @@ STORAGE_ACCESS_KEY_ID=change-me-storage-access-key STORAGE_SECRET_ACCESS_KEY=change-me-storage-secret-key STORAGE_REGION=us-east-1 STORAGE_MAX_UPLOAD_SIZE_MB=10 + +# 验收视频(打手端验收上传,选填):只在这里配置,后台不提供配置界面。 +# 超过时长上限直接拒绝(不自动裁剪);体积上限同时是上传接口硬上限。 +WORKER_ACCEPTANCE_VIDEO_ENABLED=true +WORKER_ACCEPTANCE_VIDEO_MAX_DURATION_SECONDS=30 +WORKER_ACCEPTANCE_VIDEO_MAX_SIZE_MB=12 +# 压缩参数:目标高度 360/480/540/720/1080、视频码率、音频码率、帧率 +WORKER_ACCEPTANCE_VIDEO_TARGET_HEIGHT=720 +WORKER_ACCEPTANCE_VIDEO_BITRATE_KBPS=1200 +WORKER_ACCEPTANCE_VIDEO_AUDIO_BITRATE_KBPS=64 +WORKER_ACCEPTANCE_VIDEO_FPS=30 # 阿里云 OSS S3 兼容端点:ECS 与桶同地域时用内网地址(免流量费、低延迟) STORAGE_OSS_ENDPOINT=https://oss-cn-hangzhou-internal.aliyuncs.com STORAGE_OSS_BUCKET=change-me-oss-bucket diff --git a/apps/backend/src/config/defaults.ts b/apps/backend/src/config/defaults.ts index 0ad45d6e..36c4feea 100644 --- a/apps/backend/src/config/defaults.ts +++ b/apps/backend/src/config/defaults.ts @@ -129,6 +129,15 @@ export function createDefaultRuntimeConfig(projectRoot: string): RuntimeConfig { // 打手:7 天空闲过期,登录后最多 30 天必须重新登录。 sessionIdleTtlHours: 168, sessionAbsoluteTtlHours: 720, + acceptanceVideo: { + enabled: true, + maxDurationSeconds: 30, + maxSizeMb: 12, + targetHeight: 720, + bitrateKbps: 1200, + audioBitrateKbps: 64, + fps: 30, + }, }, } } diff --git a/apps/backend/src/config/env-overrides.ts b/apps/backend/src/config/env-overrides.ts index cadf30c8..46fcf676 100644 --- a/apps/backend/src/config/env-overrides.ts +++ b/apps/backend/src/config/env-overrides.ts @@ -109,6 +109,26 @@ export const ENV_OVERRIDES: readonly EnvOverride[] = [ 'skuMapping', ]), corsOriginsEnv('CORS_ALLOWED_ORIGINS', ['cors', 'allowedOrigins']), + // 验收视频(选填):只走 .env,后台没有配置界面。 + booleanEnv('WORKER_ACCEPTANCE_VIDEO_ENABLED', ['worker', 'acceptanceVideo', 'enabled']), + integerEnv('WORKER_ACCEPTANCE_VIDEO_MAX_DURATION_SECONDS', [ + 'worker', + 'acceptanceVideo', + 'maxDurationSeconds', + ]), + integerEnv('WORKER_ACCEPTANCE_VIDEO_MAX_SIZE_MB', ['worker', 'acceptanceVideo', 'maxSizeMb']), + integerEnv('WORKER_ACCEPTANCE_VIDEO_TARGET_HEIGHT', [ + 'worker', + 'acceptanceVideo', + 'targetHeight', + ]), + integerEnv('WORKER_ACCEPTANCE_VIDEO_BITRATE_KBPS', ['worker', 'acceptanceVideo', 'bitrateKbps']), + integerEnv('WORKER_ACCEPTANCE_VIDEO_AUDIO_BITRATE_KBPS', [ + 'worker', + 'acceptanceVideo', + 'audioBitrateKbps', + ]), + integerEnv('WORKER_ACCEPTANCE_VIDEO_FPS', ['worker', 'acceptanceVideo', 'fps']), ] export function applyEnvOverrides( diff --git a/apps/backend/src/db/migrations/109_work_order_share_participation.sql b/apps/backend/src/db/migrations/109_work_order_share_participation.sql new file mode 100644 index 00000000..37fb4b41 --- /dev/null +++ b/apps/backend/src/db/migrations/109_work_order_share_participation.sql @@ -0,0 +1,101 @@ +-- 109_work_order_share_participation.sql +-- 工单拼单份额模型:多次参与 + 份额级问题标记 + 撤单原因/份数。 +-- (由原 110/111/113/114 四个增量迁移合并重写,纯新增列/索引,不改历史结构。) +-- +-- 目标: +-- 1. 同一打手可对同一拼单工单多次参与,每次参与一条 work_order_shares; +-- 每份在参与时固化截止时间与超时策略,超时 / 验收 / 撤单按份额独立。 +-- 2. 后台可对单个份额标记问题单:只影响该份额持有人,母单状态与其他参与者不变。 +-- 3. 撤单 / 撤份额的原因与份数落库,列表可直接展示「撤 N 份 · 原因」。 +-- +-- 兼容性: +-- - 只加列 + 回填 + 删「一人一条」唯一键 + 建索引,不拆分历史行、不改历史资金; +-- - 历史合并份额(quantity > 1)保持原样,结算公式 quantity × unit_reward 完全兼容; +-- - DDL 全部幂等(IF NOT EXISTS / 受控 DROP),可安全重跑。 + +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 '', + -- 取消来源枚举:timeout / worker_request / admin / problem / settlement + ADD COLUMN IF NOT EXISTS cancelled_reason TEXT NOT NULL DEFAULT '', + -- 客户端幂等键(一次点击一个);唯一索引只约束非空值 + ADD COLUMN IF NOT EXISTS request_id TEXT NOT NULL DEFAULT '', + -- 份额级问题标记(与份额状态正交;problem_marked_at 为 NULL = 正常) + ADD COLUMN IF NOT EXISTS problem_note TEXT NOT NULL DEFAULT '', + ADD COLUMN IF NOT EXISTS problem_marked_at TIMESTAMPTZ, + ADD COLUMN IF NOT EXISTS problem_marked_by TEXT NOT NULL DEFAULT '', + -- 后台撤单 / 撤份额:原因只做展示;份数 0 = 从未撤过 + ADD COLUMN IF NOT EXISTS cancel_note TEXT NOT NULL DEFAULT '', + ADD COLUMN IF NOT EXISTS cancel_quantity INTEGER NOT NULL DEFAULT 0; + +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 + '客户端幂等键(一次点击一个),重复请求返回原份额'; +COMMENT ON COLUMN work_order_shares.problem_note IS + '份额级问题备注(后台标记问题时填写,选填)'; +COMMENT ON COLUMN work_order_shares.problem_marked_at IS + '份额级问题标记时间;NULL = 正常。仅影响该份额持有人展示'; +COMMENT ON COLUMN work_order_shares.problem_marked_by IS + '标记操作人(后台账号显示名)'; +COMMENT ON COLUMN work_order_shares.cancel_note IS + '后台撤单/撤份额时填写的原因(自由文本,选填),仅用于展示'; +COMMENT ON COLUMN work_order_shares.cancel_quantity IS + '被撤掉的份数(撤份额累计 / 单人撤单为整份份数;0 = 未撤过),仅用于展示'; + +-- 回填历史份额:按当时母单配置一次性固化(只碰尚未固化的历史行)。 +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 <> ''; + +-- 打手「问题单」页签筛选与概览统计:按 (worker_id, work_order_id) 探测被标记的份额。 +CREATE INDEX IF NOT EXISTS idx_work_order_shares_problem_worker + ON work_order_shares (worker_id, work_order_id) + WHERE problem_marked_at IS NOT NULL; diff --git a/apps/backend/src/db/migrations/110_work_order_hall_and_shop_indexes.sql b/apps/backend/src/db/migrations/110_work_order_hall_and_shop_indexes.sql new file mode 100644 index 00000000..6a617ffa --- /dev/null +++ b/apps/backend/src/db/migrations/110_work_order_hall_and_shop_indexes.sql @@ -0,0 +1,25 @@ +-- 110_work_order_hall_and_shop_indexes.sql +-- 工单相关热查询的部分索引(由原 103/106 两个索引迁移合并重写)。 +-- +-- 两处查询都已经有明确的 SQL 口径,这里只补与口径完全一致的索引: +-- 1. 后台接单工单「按店铺筛选」:BTRIM(o.shop_name) = ANY($n::text[]), +-- 普通 btree 无法命中表达式,必须建同表达式索引; +-- 2. 抢单大厅候选集:FIFO 窗口之外还要无条件并入全部置顶单, +-- 置顶单通常只有个位数,用「open 且 pinned_at 非空」的部分索引即可。 +-- 全部 IF NOT EXISTS,可安全重跑。 + +-- 后台「接单工单 → 店铺」筛选(可多选):表达式索引 + 部分索引省掉空店铺条目。 +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)精确多选筛选'; + +-- 抢单大厅置顶候选集:只索引处于 open 且已置顶的工单 id,避免扫全表。 +CREATE INDEX IF NOT EXISTS idx_work_orders_hall_pinned + ON work_orders (id) + WHERE status = 'open' AND pinned_at IS NOT NULL; + +COMMENT ON INDEX idx_work_orders_hall_pinned IS + '抢单大厅置顶候选集:open 且 pinned_at 非空的工单 id'; diff --git a/apps/backend/src/domain/work-order-acceptance-evidence.test.ts b/apps/backend/src/domain/work-order-acceptance-evidence.test.ts new file mode 100644 index 00000000..7645a878 --- /dev/null +++ b/apps/backend/src/domain/work-order-acceptance-evidence.test.ts @@ -0,0 +1,31 @@ +import assert from 'node:assert/strict' +import test from 'node:test' +import { hasAcceptanceEvidence } from './work-order-acceptance-evidence.js' + +const VIDEO = { url: 'https://example.com/worker-acceptance-video/a.mp4', durationSeconds: 12 } + +test('验收证据:只有图片算有证据', () => { + assert.equal(hasAcceptanceEvidence({ imageUrls: ['https://example.com/a.jpg'] }), true) +}) + +test('验收证据:只有视频也算有证据(视频视同图片)', () => { + assert.equal(hasAcceptanceEvidence({ imageUrls: [], video: VIDEO }), true) +}) + +test('验收证据:图片与视频都有算有证据', () => { + assert.equal( + hasAcceptanceEvidence({ imageUrls: ['https://example.com/a.jpg'], video: VIDEO }), + true, + ) +}) + +test('验收证据:图片视频都没有则没有证据', () => { + assert.equal(hasAcceptanceEvidence({ imageUrls: [], video: null }), false) + assert.equal(hasAcceptanceEvidence({}), false) + assert.equal(hasAcceptanceEvidence({ imageUrls: undefined, video: undefined }), false) +}) + +test('验收证据:兼容非数组的 imageUrls(脏数据按无图片处理)', () => { + assert.equal(hasAcceptanceEvidence({ imageUrls: null, video: VIDEO }), true) + assert.equal(hasAcceptanceEvidence({ imageUrls: null, video: null }), false) +}) diff --git a/apps/backend/src/domain/work-order-acceptance-evidence.ts b/apps/backend/src/domain/work-order-acceptance-evidence.ts new file mode 100644 index 00000000..d8d2bbb7 --- /dev/null +++ b/apps/backend/src/domain/work-order-acceptance-evidence.ts @@ -0,0 +1,15 @@ +/** + * 验收证据口径:验收图片与验收视频至少上传一个。 + * + * 口径(已确认):有视频时视同有图片,其它逻辑完全按「已上传图片」走—— + * 打手端提交/保存验收的必填校验、VIP 免审核的 `evidenceStatus` / `evidenceDueAt` + * 判定都复用这个函数,避免整单与拼单份额两处口径漂移。 + */ +export function hasAcceptanceEvidence(input: { + imageUrls?: readonly unknown[] | null + video?: unknown +}): boolean { + const imageUrls = input.imageUrls + const imageCount = Array.isArray(imageUrls) ? imageUrls.length : 0 + return imageCount > 0 || Boolean(input.video) +} diff --git a/apps/backend/src/domain/work-order-share-quantity.test.ts b/apps/backend/src/domain/work-order-share-quantity.test.ts new file mode 100644 index 00000000..2514db42 --- /dev/null +++ b/apps/backend/src/domain/work-order-share-quantity.test.ts @@ -0,0 +1,51 @@ +import assert from 'node:assert/strict' +import test from 'node:test' + +import { resolveShareReduceTargetQuantity } from './work-order-share-quantity.js' + +test('按份数撤份额:正常扣减', () => { + assert.deepEqual(resolveShareReduceTargetQuantity({ currentQuantity: 9, reduceQuantity: 4 }), { + nextQuantity: 5, + failureReason: null, + }) + assert.deepEqual(resolveShareReduceTargetQuantity({ currentQuantity: 2, reduceQuantity: 1 }), { + nextQuantity: 1, + failureReason: null, + }) +}) + +test('按份数撤份额:不能撤光、不能撤 0、不能超量', () => { + assert.equal( + resolveShareReduceTargetQuantity({ currentQuantity: 1, reduceQuantity: 1 }).failureReason, + 'invalid_quantity', + ) + assert.equal( + resolveShareReduceTargetQuantity({ currentQuantity: 9, reduceQuantity: 0 }).failureReason, + 'invalid_quantity', + ) + assert.equal( + resolveShareReduceTargetQuantity({ currentQuantity: 9, reduceQuantity: -2 }).failureReason, + 'invalid_quantity', + ) + assert.equal( + resolveShareReduceTargetQuantity({ currentQuantity: 9, reduceQuantity: 9 }).failureReason, + 'invalid_quantity', + ) + assert.equal( + resolveShareReduceTargetQuantity({ currentQuantity: 9, reduceQuantity: 10 }).failureReason, + 'invalid_quantity', + ) +}) + +test('按份数撤份额:非法输入(非数字/小数/空)一律拒绝', () => { + assert.equal( + resolveShareReduceTargetQuantity({ currentQuantity: Number.NaN, reduceQuantity: 4 }) + .failureReason, + 'invalid_quantity', + ) + assert.equal( + resolveShareReduceTargetQuantity({ currentQuantity: 9, reduceQuantity: 4.7 }).nextQuantity, + 5, + '小数向下取整后按 4 份处理', + ) +}) diff --git a/apps/backend/src/domain/work-order-share-quantity.ts b/apps/backend/src/domain/work-order-share-quantity.ts new file mode 100644 index 00000000..34433023 --- /dev/null +++ b/apps/backend/src/domain/work-order-share-quantity.ts @@ -0,0 +1,21 @@ +/** + * 后台按份数撤销拼单份额:校验并算出保留下来的份数。 + * + * 口径(已确认): + * - 只能部分撤销,撤到 0 份走「单人撤单」,所以 `1 ≤ 撤掉份数 ≤ 当前份数 − 1`; + * - 押金不随份数减少而退还(保持原冻结额),这里只算数量。 + */ +export function resolveShareReduceTargetQuantity(input: { + currentQuantity: number + reduceQuantity: number +}): { nextQuantity: number; failureReason: 'invalid_quantity' | null } { + const current = Math.floor(Number(input.currentQuantity) || 0) + const reduce = Math.floor(Number(input.reduceQuantity) || 0) + if (!Number.isFinite(current) || !Number.isFinite(reduce)) { + return { nextQuantity: 0, failureReason: 'invalid_quantity' } + } + if (current < 2 || reduce < 1 || reduce > current - 1) { + return { nextQuantity: 0, failureReason: 'invalid_quantity' } + } + return { nextQuantity: current - reduce, failureReason: null } +} diff --git a/apps/backend/src/repositories/realtime-event-repo.ts b/apps/backend/src/repositories/realtime-event-repo.ts index 31b40d11..bcc2c6f8 100644 --- a/apps/backend/src/repositories/realtime-event-repo.ts +++ b/apps/backend/src/repositories/realtime-event-repo.ts @@ -62,10 +62,13 @@ export async function createRealtimeEvent(input: { export async function listRealtimeEventsAfter(eventId: number, limit = 500) { const result = await query( ` - SELECT * FROM realtime_events - WHERE id > $1 + SELECT * FROM ( + SELECT * FROM realtime_events + WHERE id > $1 + ORDER BY id DESC + LIMIT $2 + ) replay_window ORDER BY id ASC - LIMIT $2 `, [Math.max(0, Number(eventId) || 0), Math.min(500, Math.max(1, Number(limit) || 500))], ) diff --git a/apps/backend/src/repositories/worker-platform-repo.test.ts b/apps/backend/src/repositories/worker-platform-repo.test.ts index b6f2fe0b..add71870 100644 --- a/apps/backend/src/repositories/worker-platform-repo.test.ts +++ b/apps/backend/src/repositories/worker-platform-repo.test.ts @@ -109,7 +109,7 @@ test('未指定打手的运营状态统计使用一次全表拼单汇总', () => assert.doesNotMatch(query.joins, /worker_visible/) }) -test('大厅容量先确定全局候选集合,再应用页面筛选条件', () => { +test('大厅容量先确定全局候选集合(FIFO 窗口 + 全部置顶单),再应用页面筛选条件', () => { const query = buildWorkOrderWhere({ statuses: ['open'], keyword: '测试商品', @@ -120,10 +120,10 @@ test('大厅容量先确定全局候选集合,再应用页面筛选条件', () }) assert.match(query.whereClause, /candidate\.status = 'open'/) - assert.match( - query.whereClause, - /ORDER BY candidate\.pinned_at DESC NULLS LAST, candidate\.hall_queued_at ASC NULLS LAST/, - ) + // 置顶单不参与 FIFO 截断:UNION 无条件并入候选集,窗口外也能抢/拼。 + assert.match(query.whereClause, /UNION/) + assert.match(query.whereClause, /pinned\.status = 'open'/) + assert.match(query.whereClause, /pinned\.pinned_at IS NOT NULL/) assert.match(query.whereClause, /LIMIT \$4/) const candidateSql = query.whereClause.slice(query.whereClause.indexOf('candidate.status')) assert.doesNotMatch(candidateSql, /candidate\.category_id|candidate.*测试商品/) diff --git a/apps/backend/src/repositories/worker-platform/share-summary-scope.test.ts b/apps/backend/src/repositories/worker-platform/share-summary-scope.test.ts index f4ec8554..6dccfc56 100644 --- a/apps/backend/src/repositories/worker-platform/share-summary-scope.test.ts +++ b/apps/backend/src/repositories/worker-platform/share-summary-scope.test.ts @@ -63,11 +63,46 @@ test('大厅候选集(buildHallCandidateIdsSql)不再聚合整表拼单', as ) }) +/** + * 生产现象:置顶单 WK26102208267560(FIFO 队列第 92 名,窗口只有 50)在列表里看得到, + * 点「拼单」却报「该订单当前不可拼单」。根因是候选集只截 FIFO 队首 N 个, + * 漏掉了置顶单,而抢单/拼单在事务内用同一段 SQL 复核容量。 + * 置顶是运营显式优先级,必须无条件并入候选集,否则置顶功能形同虚设。 + */ +test('大厅候选集必须无条件并进置顶单(置顶单排在 FIFO 窗口外也要能抢/拼)', async () => { + const source = stripComments(await readRepoSource()) + const start = source.indexOf('function buildHallCandidateIdsSql') + const hallSql = source.slice(start, start + 4200) + + assert.ok(hallSql.includes('UNION'), '候选集没有把置顶单并进 FIFO 窗口') + assert.ok( + hallSql.includes('pinned.pinned_at IS NOT NULL'), + '候选集缺少「status=open 且 pinned_at 非空」的置顶分支', + ) +}) + test('全仓库只允许保留一处「整表拼单聚合」,且必须是无打手范围的全表统计分支', async () => { const raw = await readRepoSource() const source = stripComments(raw) + + // 我的订单概览里按「工单 + 份额状态」聚合金额,必须限定到当前打手; + // 它不是整表聚合,先单独摘出来校验,避免被下面的全表聚合守卫误判。 + const shareStatusStart = source.indexOf('share_status_rows AS (') + const shareStatusEnd = source.indexOf('order_status_rows AS (') + const workerShareAggregation = + shareStatusStart >= 0 && shareStatusEnd > shareStatusStart + ? source.slice(shareStatusStart, shareStatusEnd) + : '' + assert.ok(workerShareAggregation, '缺少我的订单概览的份额状态聚合') + + const sourceWithoutWorkerShareAggregation = + shareStatusStart >= 0 && shareStatusEnd > shareStatusStart + ? source.slice(0, shareStatusStart) + source.slice(shareStatusEnd) + : source const globalAggregations = [ - ...source.matchAll(/GROUP BY\s+wos\.work_order_id|GROUP BY\s+work_order_id/g), + ...sourceWithoutWorkerShareAggregation.matchAll( + /GROUP BY\s+wos\.work_order_id|GROUP BY\s+work_order_id/g, + ), ] assert.equal( @@ -78,17 +113,149 @@ test('全仓库只允许保留一处「整表拼单聚合」,且必须是无 ) const onlyRemaining = globalAggregations[0]! - const around = source.slice(Math.max(0, onlyRemaining.index - 900), onlyRemaining.index + 200) + const around = sourceWithoutWorkerShareAggregation.slice( + Math.max(0, (onlyRemaining.index ?? 0) - 900), + (onlyRemaining.index ?? 0) + 200, + ) assert.ok( around.includes('operational_share_summary'), '保留的那处整表聚合不在 operational_share_summary 分支里,需要确认是否有意为之', ) - // 注释在 stripComments 里被去掉了,所以断言理由时用原文 - const rawIndex = raw.indexOf('GROUP BY wos.work_order_id') - const rawAround = raw.slice(Math.max(0, rawIndex - 1200), rawIndex + 200) + // 注释在 stripComments 里被去掉了,所以断言理由时用原文。 + // 注意:raw 里前面还有 share_status_rows 的工单内聚合,必须找 operational 那一处。 + const rawMatches = [...raw.matchAll(/GROUP BY\s+wos\.work_order_id/g)] + const operationalRaw = rawMatches.find((match) => + raw + .slice(Math.max(0, (match.index ?? 0) - 1200), (match.index ?? 0) + 200) + .includes('operational_share_summary'), + ) + assert.ok(operationalRaw, '找不到 operational_share_summary 的整表聚合') + const rawAround = raw.slice( + Math.max(0, (operationalRaw.index ?? 0) - 1200), + (operationalRaw.index ?? 0) + 200, + ) assert.ok( rawAround.includes('无打手范围时一次聚合全部拼单'), '保留理由(无打手范围时一次聚合优于逐行关联)必须写在注释里,避免被误删', ) }) + +test('我的订单的份额关联按工单取一条,避免多份额把列表行放大', async () => { + const source = stripComments(await readRepoSource()) + + assert.ok(source.includes('LEFT JOIN LATERAL ('), '缺少按工单取代表份额的 LATERAL 关联') + assert.ok( + !source.includes('LEFT JOIN work_order_shares worker_share'), + '仍在直接 JOIN 份额表:同一工单多份拼单会让「我的订单」出现多行', + ) +}) + +test('我的订单概览按工单计数,金额仍按份额求和', async () => { + const source = stripComments(await readRepoSource()) + const start = source.indexOf('order_flags AS (') + assert.ok(start >= 0, '缺少我的订单概览的 order_flags CTE') + + const block = source.slice(start, start + 4200) + assert.match(block, /share_status_rows AS \(/, '概览没有按份额状态拆分金额') + assert.match(block, /order_status_rows AS \(/, '概览没有聚合成工单状态行') + assert.match(block, /SUM\(wos\.share_reward\)/, '概览没有汇总份额奖励') + assert.match( + block, + /GROUP BY wos\.work_order_id, wos\.status/, + '份额金额没有按「工单 + 份额状态」聚合', + ) + assert.ok( + !block.includes('LEFT JOIN work_order_shares wos'), + '概览仍在直接 JOIN 份额表:同一工单 3 份会被统计成 3 单', + ) +}) + +test('多份额混合状态:任一状态命中即可出现在对应页签', async () => { + const source = stripComments(await readRepoSource()) + + assert.ok(source.includes("ws_filter.status = 'submitted'"), '待验收筛选没有检查任一份额') + assert.ok(source.includes("'pending_acceptance' = ANY"), '待验收状态没有参与任一份额匹配') +}) + +/** + * 概览与列表必须是同一把尺子:列表里「待审核取消申请/未处理反馈」只把进行中的份额 + * 归入问题单,已提交待验收的份额照旧留在待验收。 + * 概览旧写法把这类工单的份额整体排除、再由第二分支记成问题单,于是出现 + * 「问题单卡片有数字、点进去列表是空的」「待验收列表有单、卡片不计数」。 + */ +test('概览与列表同口径:待处理申请只把进行中份额归问题单', async () => { + const source = stripComments(await readRepoSource()) + + const shareStart = source.indexOf('share_status_rows AS (') + assert.ok(shareStart >= 0, '缺少 share_status_rows CTE') + const shareBlock = source.slice(shareStart, shareStart + 1600) + assert.match( + shareBlock, + /WHEN wos\.status = 'joined' AND f\.has_pending_handling THEN 'problem'/, + '待处理申请只应把进行中的份额归问题单', + ) + assert.ok( + !shareBlock.includes('AND NOT f.has_pending_handling'), + '概览不能整体排除有待处理申请的份额,否则已提交待验收会从页签里消失', + ) + + const orderStart = source.indexOf('order_status_rows AS (', shareStart) + assert.ok(orderStart > shareStart, '缺少 order_status_rows CTE') + const orderBlock = source.slice(orderStart, orderStart + 1600) + assert.ok( + !/OR f\.has_pending_handling\b/.test(orderBlock), + '第二分支不能无条件把有待处理申请的工单塞进问题单(会产生重复/错页签计数)', + ) + assert.match( + orderBlock, + /WHEN f\.has_pending_handling AND f\.wo_status = 'in_progress'/, + '无份额的指派单也要按「进行中才归问题单」判定', + ) +}) + +test('超时统计按工单去重,不按份额/事件行数累加', async () => { + const source = stripComments(await readRepoSource()) + + assert.ok( + source.includes('COUNT(DISTINCT work_order_id)::int AS total'), + '超时单数没有按工单去重', + ) +}) + +test('份额级问题单:概览优先归 problem,且筛选只认本人被标记的份额', async () => { + const source = stripComments(await readRepoSource()) + + const shareStart = source.indexOf('share_status_rows AS (') + const orderStart = source.indexOf('order_status_rows AS (') + assert.ok(shareStart >= 0 && orderStart > shareStart, '缺少 share_status_rows CTE') + const shareBlock = source.slice(shareStart, orderStart) + + assert.match( + shareBlock, + /CASE\s+WHEN wos\.problem_marked_at IS NOT NULL THEN 'problem'/, + '份额级问题单必须是概览 CASE 的第一分支(优先于份额状态)', + ) + assert.match( + shareBlock, + /GROUP BY wos\.work_order_id, wos\.status, f\.has_pending_handling,\s*\(wos\.problem_marked_at IS NOT NULL\)/, + '概览聚合必须带上份额问题标记,否则同工单会被合并回原状态', + ) + assert.ok( + shareBlock.includes('WHERE wos.worker_id = $1'), + '概览份额统计必须限定当前打手,不能把其他打手的问题份额算进来', + ) + + const existsStart = source.indexOf('export function workerShareProblemExistsExpression(') + assert.ok(existsStart >= 0, '缺少按打手判定份额问题的表达式') + const existsBlock = source.slice(existsStart, existsStart + 420) + assert.match(existsBlock, /wsp\.worker_id = \$\{workerId\}/, '问题份额必须按打手隔离') + assert.match(existsBlock, /wsp\.status <> 'cancelled'/, '已取消份额不参与问题单筛选') + assert.match(existsBlock, /wsp\.problem_marked_at IS NOT NULL/, '缺少问题标记条件') + + assert.match( + source, + /wo\.status = 'problem'\s*OR \$\{workerShareProblemExistsExpression\(workerSharingId\)\}/, + '问题单页签必须同时命中母单问题与份额问题', + ) +}) diff --git a/apps/backend/src/repositories/worker-platform/types.ts b/apps/backend/src/repositories/worker-platform/types.ts index 4b81675c..0cfef91c 100644 --- a/apps/backend/src/repositories/worker-platform/types.ts +++ b/apps/backend/src/repositories/worker-platform/types.ts @@ -358,6 +358,23 @@ export type WorkOrderShareRow = { accepted_at: string | null created_at: string updated_at: string + /** 本份额截止时间;参与时固化,null = 不限时 */ + deadline_at?: string | null + /** 参与时固化的时限分钟数与策略 */ + timeout_minutes?: number + timeout_policy?: string + /** 取消来源:timeout/worker_request/admin/problem/settlement */ + cancelled_reason?: string + /** 后台撤单/撤份额时填写的原因(自由文本,选填),仅用于列表展示 */ + cancel_note?: string + /** 被撤掉的份数(撤份额累计 / 单人撤单为整份份数;0 = 未撤过) */ + cancel_quantity?: number + /** 份额级问题单:备注 / 标记时间(NULL = 正常)/ 操作人;不影响母单状态 */ + problem_note?: string + problem_marked_at?: string | null + problem_marked_by?: string + /** 客户端幂等键 */ + request_id?: string worker_username?: string worker_display_name?: string } diff --git a/apps/backend/src/repositories/worker-platform/work-order-issue-repo.ts b/apps/backend/src/repositories/worker-platform/work-order-issue-repo.ts index 0d958721..27919778 100644 --- a/apps/backend/src/repositories/worker-platform/work-order-issue-repo.ts +++ b/apps/backend/src/repositories/worker-platform/work-order-issue-repo.ts @@ -3,12 +3,16 @@ import type { PoolClient } from 'pg' import { isWorkOrderGiftDepositForfeitable } from '../../domain/work-order-acceptance-mode.js' import { forfeitConsignmentPublishDepositByWorkOrderWithClient } from './consignment-deposit-repo.js' import { releaseConsignmentInventoryBindingForWorkOrderWithClient } from './consignment-repo.js' -import { ensureWorkerWalletWithClient, getWorkerWalletWithClient } from './shared.js' +import { + ensureWorkerWalletWithClient, + getWorkerWalletWithClient, + WORK_ORDER_SHARE_SELECT, +} from './shared.js' import { createWorkOrderEventWithClient } from './work-order-event-repo.js' import { getWorkOrderByIdWithClient } from './work-order-query-repo.js' import { getOutstandingDepositAmountWithClient } from './work-order-deposit-repo.js' import { CLEAR_DELIVERY_RESIDUE_SQL, RESET_GIFT_FLOW_SQL } from './work-order-gift-repo.js' -import type { ProblemWorkOrderResolutionAction, WorkOrderRow } from './types.js' +import type { ProblemWorkOrderResolutionAction, WorkOrderRow, WorkOrderShareRow } from './types.js' /** * 统计工单的活跃拼单份额(除已取消外都算活跃)。 @@ -278,6 +282,294 @@ export async function resolveProblemWorkOrder(input: { }) } +/** + * 后台按份额标记问题单。 + * + * 只写份额自身的 `problem_*` 三个字段: + * - 母单 `status` / `problem_note` 一律不动(同工单其他打手完全无感); + * - 份额状态(joined/submitted/accepted)也不动,问题标记是正交属性; + * - 只有该份额持有人会在「我的订单」里看到问题单。 + * + * 母单已 accepted / cancelled / problem 时不允许按份额标记: + * 前两者已结算/终止,后者本身就是整单问题单(走整单处置)。 + */ +export async function markWorkOrderShareProblem(input: { + workOrderId: number + shareId: number + note: string + now: string + actorName?: string +}): Promise<{ + order: WorkOrderRow | null + share: WorkOrderShareRow | null + failureReason: 'work_order_not_markable' | 'share_not_found' | 'share_status_not_markable' | null +}> { + return withTransaction(async (client) => { + const orderResult = await client.query( + `SELECT * FROM work_orders WHERE id = $1 FOR UPDATE`, + [input.workOrderId], + ) + const workOrder = orderResult.rows[0] || null + if ( + !workOrder || + ['accepted', 'cancelled', 'problem'].includes(String(workOrder.status || '')) + ) { + return { + order: workOrder, + share: null, + failureReason: 'work_order_not_markable' as const, + } + } + + const shareResult = await client.query( + `SELECT * FROM work_order_shares WHERE id = $1 AND work_order_id = $2 FOR UPDATE`, + [input.shareId, input.workOrderId], + ) + const share = shareResult.rows[0] || null + if (!share) { + return { order: workOrder, share: null, failureReason: 'share_not_found' as const } + } + if (share.status === 'cancelled') { + return { + order: workOrder, + share, + failureReason: 'share_status_not_markable' as const, + } + } + + await client.query( + `UPDATE work_order_shares + SET problem_note = $1, + problem_marked_at = $2, + problem_marked_by = $3, + updated_at = $2 + WHERE id = $4`, + [String(input.note || '').trim(), input.now, input.actorName || '', input.shareId], + ) + await createWorkOrderEventWithClient(client, { + workOrderId: input.workOrderId, + actorType: 'admin', + actorId: input.actorName || '', + eventType: 'sharing_share_marked_problem', + // 母单状态不迁移,事件只记录份额级标记 + fromStatus: workOrder.status, + toStatus: workOrder.status, + payloadJson: JSON.stringify({ + shareId: input.shareId, + workerId: Number(share.worker_id || 0), + note: String(input.note || '').trim(), + }), + now: input.now, + }) + + const updated = await client.query( + `${WORK_ORDER_SHARE_SELECT} WHERE wos.id = $1 LIMIT 1`, + [input.shareId], + ) + return { + order: await getWorkOrderByIdWithClient(client, input.workOrderId), + share: updated.rows[0] || null, + failureReason: null, + } + }) +} + +/** + * 处置份额级问题单:与母单处置同名的四种方式,但只作用于这一份份额。 + * + * - return_to_worker:清标记;已提交的份额回到进行中并清空验收提交(继续代练)。 + * - reopen / cancel_release:取消该份额并退还押金。 + * - cancel_deduct:取消该份额并扣除押金。 + * + * 母单 `status` / `problem_note` 一律不写,同工单其他打手不受影响。 + */ +export async function disposeWorkOrderShareProblem(input: { + workOrderId: number + shareId: number + action: ProblemWorkOrderResolutionAction + note: string + now: string + actorName?: string +}): Promise<{ + order: WorkOrderRow | null + share: WorkOrderShareRow | null + depositAmount: number + failureReason: + | 'work_order_not_disposable' + | 'share_not_found' + | 'share_not_marked' + | 'share_status_not_disposable' + | null +}> { + return withTransaction(async (client) => { + const orderResult = await client.query( + `SELECT * FROM work_orders WHERE id = $1 FOR UPDATE`, + [input.workOrderId], + ) + const workOrder = orderResult.rows[0] || null + if (!workOrder || ['accepted', 'cancelled'].includes(String(workOrder.status || ''))) { + return { + order: workOrder, + share: null, + depositAmount: 0, + failureReason: 'work_order_not_disposable' as const, + } + } + + const shareResult = await client.query( + `SELECT * FROM work_order_shares WHERE id = $1 AND work_order_id = $2 FOR UPDATE`, + [input.shareId, input.workOrderId], + ) + const share = shareResult.rows[0] || null + if (!share) { + return { + order: workOrder, + share: null, + depositAmount: 0, + failureReason: 'share_not_found' as const, + } + } + if (!share.problem_marked_at) { + return { + order: workOrder, + share, + depositAmount: 0, + failureReason: 'share_not_marked' as const, + } + } + // 已验收份额的报酬/押金已结算,只能走售后,不能在这里取消或退回。 + if (!['joined', 'submitted'].includes(String(share.status || ''))) { + return { + order: workOrder, + share, + depositAmount: 0, + failureReason: 'share_status_not_disposable' as const, + } + } + + const workerId = Number(share.worker_id || 0) + let depositAmount = 0 + + if (input.action === 'return_to_worker') { + await client.query( + `UPDATE work_order_shares + SET status = CASE WHEN status = 'submitted' THEN 'joined' ELSE status END, + acceptance_json = CASE + WHEN status = 'submitted' THEN '{}'::jsonb + ELSE acceptance_json + END, + draft_acceptance_json = CASE + WHEN status = 'submitted' THEN '{}'::jsonb + ELSE draft_acceptance_json + END, + submitted_at = CASE WHEN status = 'submitted' THEN NULL ELSE submitted_at END, + deadline_at = CASE + WHEN status = 'submitted' THEN + CASE + WHEN timeout_minutes > 0 + THEN $2::timestamptz + (timeout_minutes * INTERVAL '1 minute') + ELSE NULL + END + ELSE deadline_at + END, + problem_note = '', + problem_marked_at = NULL, + problem_marked_by = '', + updated_at = $2 + WHERE id = $1`, + [input.shareId, input.now], + ) + } else { + const shouldDeduct = input.action === 'cancel_deduct' + if (workerId > 0) { + await ensureWorkerWalletWithClient(client, workerId, input.now) + const wallet = await getWorkerWalletWithClient(client, workerId) + depositAmount = Math.min( + Number(share.share_deposit || 0), + Number(wallet?.frozen_deposit_amount || 0), + ) + if (depositAmount > 0) { + const nextAvailable = + Number(wallet?.available_amount || 0) + (shouldDeduct ? 0 : depositAmount) + const nextFrozen = Math.max(0, Number(wallet?.frozen_deposit_amount || 0) - depositAmount) + await client.query( + `UPDATE worker_wallets + SET available_amount = $1, frozen_deposit_amount = $2, updated_at = $3 + WHERE worker_id = $4`, + [nextAvailable, nextFrozen, input.now, workerId], + ) + await client.query( + `INSERT INTO worker_wallet_ledgers ( + worker_id, ledger_type, amount, balance_after, frozen_after, + related_work_order_id, note, payload_json, created_at + ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8::jsonb, $9)`, + [ + workerId, + shouldDeduct ? 'deposit_deduction' : 'deposit_release', + shouldDeduct ? -depositAmount : depositAmount, + nextAvailable, + nextFrozen, + input.workOrderId, + shouldDeduct ? '份额问题单处置扣除押金' : '份额问题单处置退还押金', + JSON.stringify({ + action: input.action, + shareId: input.shareId, + note: input.note, + }), + input.now, + ], + ) + } + } + + await client.query( + `UPDATE work_order_shares + SET status = 'cancelled', + cancelled_reason = 'problem', + acceptance_json = '{}'::jsonb, + draft_acceptance_json = '{}'::jsonb, + submitted_at = NULL, + accepted_at = NULL, + problem_note = '', + problem_marked_at = NULL, + problem_marked_by = '', + updated_at = $1 + WHERE id = $2`, + [input.now, input.shareId], + ) + } + + // 母单状态保持不变:与「后台单人撤单」同口径,只处置这一份。 + await createWorkOrderEventWithClient(client, { + workOrderId: input.workOrderId, + actorType: 'admin', + actorId: input.actorName || '', + eventType: 'sharing_share_problem_disposed', + fromStatus: workOrder.status, + toStatus: workOrder.status, + payloadJson: JSON.stringify({ + action: input.action, + shareId: input.shareId, + workerId, + note: input.note, + depositAmount, + }), + now: input.now, + }) + + const updated = await client.query( + `${WORK_ORDER_SHARE_SELECT} WHERE wos.id = $1 LIMIT 1`, + [input.shareId], + ) + return { + order: await getWorkOrderByIdWithClient(client, input.workOrderId), + share: updated.rows[0] || null, + depositAmount, + failureReason: null, + } + }) +} + export async function settleOverdueWorkOrder(input: { workOrderId: number policy: 'reopen' | 'cancel_release' | 'cancel_deduct' diff --git a/apps/backend/src/repositories/worker-platform/work-order-query-repo.ts b/apps/backend/src/repositories/worker-platform/work-order-query-repo.ts index 49ef1738..0f5e0e21 100644 --- a/apps/backend/src/repositories/worker-platform/work-order-query-repo.ts +++ b/apps/backend/src/repositories/worker-platform/work-order-query-repo.ts @@ -223,25 +223,47 @@ export function workerOrderBaseStatusExpression(workerShareAlias = 'worker_share * 传入 workerId 时,代练中但存在待审核取消申请或未处理反馈的订单归入问题单, * 客服驳回/解决后自动回到代练中(计算型归类,无状态迁移)。 */ -export function workerOrderStatusExpression(workerId = 0, workerShareAlias = 'worker_share') { - const baseExpression = workerOrderBaseStatusExpression(workerShareAlias) - const normalizedWorkerId = Math.max(0, Math.floor(Number(workerId) || 0)) - if (normalizedWorkerId <= 0) return baseExpression - const pendingHandlingExists = `( +function workerPendingHandlingExistsExpression(workerId: number): string { + return `( EXISTS ( SELECT 1 FROM worker_order_cancel_requests wcr WHERE wcr.work_order_id = wo.id - AND wcr.worker_id = ${normalizedWorkerId} + AND wcr.worker_id = ${workerId} AND wcr.status = 'pending' ) OR EXISTS ( SELECT 1 FROM worker_order_feedbacks wf WHERE wf.work_order_id = wo.id - AND wf.worker_id = ${normalizedWorkerId} + AND wf.worker_id = ${workerId} AND wf.status = 'pending' ) )` +} + +/** + * 该打手在本工单是否有被后台标记问题单的份额(份额级问题单)。 + * + * 只影响该份额持有人:母单 `status` 不变,同工单其他打手也看不到。 + * 多条份额时只要有一份被标记,这张单在「我的订单」里就显示为问题单。 + */ +export function workerShareProblemExistsExpression(workerId: number): string { + return `EXISTS ( + SELECT 1 + FROM work_order_shares wsp + WHERE wsp.work_order_id = wo.id + AND wsp.worker_id = ${workerId} + AND wsp.status <> 'cancelled' + AND wsp.problem_marked_at IS NOT NULL + )` +} + +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 shareProblemExists = workerShareProblemExistsExpression(normalizedWorkerId) const isTimeoutForMe = `( (wo.assigned_worker_id IS DISTINCT FROM ${normalizedWorkerId}) AND COALESCE(${workerShareAlias}.status, '') NOT IN ('joined', 'submitted', 'accepted') @@ -254,6 +276,8 @@ export function workerOrderStatusExpression(workerId = 0, workerShareAlias = 'wo )` return `CASE WHEN ${isTimeoutForMe} THEN 'open' + WHEN (${baseExpression}) IN ('accepted', 'cancelled', 'problem') THEN ${baseExpression} + WHEN ${shareProblemExists} THEN 'problem' WHEN (${baseExpression}) = 'in_progress' AND ${pendingHandlingExists} THEN 'problem' ELSE ${baseExpression} END` @@ -638,12 +662,12 @@ export async function getWorkerOrderOverview(workerId: number | string): Promise WHERE timeout_ev.event_type LIKE 'timeout_%' AND timeout_ev.payload_json->>'workerId' = $1::text ), - worker_base AS ( + order_flags AS ( SELECT wo.id, wo.status AS wo_status, - wos.status AS wos_status, - wos.id IS NOT NULL AS is_worker_share, + wo.reward_amount, + wo.acceptance_json, wo.assigned_worker_id = $1 AS is_assigned_to_me, EXISTS ( SELECT 1 FROM work_order_events we @@ -651,67 +675,96 @@ export async function getWorkerOrderOverview(workerId: number | string): Promise AND we.event_type LIKE 'timeout_%' AND we.payload_json->>'workerId' = $1::text ) AS has_timeout_for_me, - CASE - WHEN wos.id IS NOT NULL THEN wos.share_reward - WHEN wo.assigned_worker_id = $1 THEN wo.reward_amount - ELSE 0 - END AS worker_settlement_amount, - wo.acceptance_json + ( + EXISTS ( + SELECT 1 FROM worker_order_cancel_requests wcr + WHERE wcr.work_order_id = wo.id + AND wcr.worker_id = $1 + AND wcr.status = 'pending' + ) OR EXISTS ( + SELECT 1 FROM worker_order_feedbacks wf + WHERE wf.work_order_id = wo.id + AND wf.worker_id = $1 + AND wf.status = 'pending' + ) + ) AS has_pending_handling, + COALESCE(( + SELECT SUM(wos.share_reward) + FROM work_order_shares wos + WHERE wos.work_order_id = wo.id + AND wos.worker_id = $1 + AND wos.status != 'cancelled' + ), 0)::int AS share_reward_total, + EXISTS ( + SELECT 1 FROM work_order_shares wos + WHERE wos.work_order_id = wo.id + AND wos.worker_id = $1 + AND wos.status != 'cancelled' + ) AS is_worker_share FROM work_orders wo INNER JOIN visible_orders vo ON vo.work_order_id = wo.id - LEFT JOIN work_order_shares wos - ON wos.work_order_id = wo.id - AND wos.worker_id = $1 - AND wos.status != 'cancelled' ), - worker_orders AS ( + share_status_rows AS ( SELECT + wos.work_order_id, CASE - WHEN NOT is_assigned_to_me AND (NOT is_worker_share OR wos_status = 'cancelled' OR wos_status IS NULL) AND has_timeout_for_me - THEN 'open' - WHEN ( - CASE - WHEN is_worker_share AND wo_status NOT IN ('accepted', 'cancelled', 'problem') - THEN CASE wos_status - WHEN 'joined' THEN 'in_progress' - WHEN 'submitted' THEN 'pending_acceptance' - WHEN 'accepted' THEN 'accepted' - WHEN 'cancelled' THEN 'cancelled' - ELSE wo_status - END - ELSE wo_status - END - ) = 'in_progress' AND ( - EXISTS ( - SELECT 1 - FROM worker_order_cancel_requests wcr - WHERE wcr.work_order_id = worker_base.id - AND wcr.worker_id = $1 - AND wcr.status = 'pending' - ) OR EXISTS ( - SELECT 1 - FROM worker_order_feedbacks wf - WHERE wf.work_order_id = worker_base.id - AND wf.worker_id = $1 - AND wf.status = 'pending' - ) - ) THEN 'problem' - ELSE CASE - WHEN is_worker_share AND wo_status NOT IN ('accepted', 'cancelled', 'problem') - THEN CASE wos_status - WHEN 'joined' THEN 'in_progress' - WHEN 'submitted' THEN 'pending_acceptance' - WHEN 'accepted' THEN 'accepted' - WHEN 'cancelled' THEN 'cancelled' - ELSE wo_status - END - ELSE wo_status - END + -- 份额级问题单:只有该份额持有人会在概览里多出一条 problem 计数; + -- 同工单其他打手、母单状态都不受影响。被标记的份额不再计入原状态。 + WHEN wos.problem_marked_at IS NOT NULL THEN 'problem' + -- 待审核取消申请/未处理反馈只把「进行中」的份额归入问题单; + -- 已提交待验收的份额必须留在待验收(列表筛选就是这么判的, + -- 这里若整体排除会出现「问题单有数字、点进去空列表」)。 + WHEN wos.status = 'joined' AND f.has_pending_handling THEN 'problem' + WHEN wos.status = 'joined' THEN 'in_progress' + WHEN wos.status = 'submitted' THEN 'pending_acceptance' + WHEN wos.status = 'accepted' THEN 'accepted' END AS status, + SUM(wos.share_reward)::int AS amount, + (ARRAY_AGG(wos.acceptance_json ORDER BY wos.id DESC))[1] AS acceptance_json + FROM work_order_shares wos + INNER JOIN order_flags f ON f.id = wos.work_order_id + WHERE wos.worker_id = $1 + AND wos.status IN ('joined', 'submitted', 'accepted') + AND f.wo_status NOT IN ('accepted', 'cancelled', 'problem') + GROUP BY wos.work_order_id, wos.status, f.has_pending_handling, + (wos.problem_marked_at IS NOT NULL) + ), + order_status_rows AS ( + SELECT + work_order_id, + status, + amount, acceptance_json, - is_worker_share, - worker_settlement_amount - FROM worker_base + TRUE AS is_worker_share + FROM share_status_rows + + UNION ALL + + SELECT + f.id AS work_order_id, + CASE + WHEN f.has_timeout_for_me + AND NOT f.is_assigned_to_me + AND NOT f.is_worker_share + THEN 'open' + WHEN f.wo_status IN ('accepted', 'cancelled', 'problem') + THEN f.wo_status + WHEN f.has_pending_handling AND f.wo_status = 'in_progress' + THEN 'problem' + ELSE f.wo_status + END AS status, + CASE + WHEN f.is_worker_share THEN f.share_reward_total + WHEN f.is_assigned_to_me THEN f.reward_amount + ELSE 0 + END AS amount, + f.acceptance_json, + f.is_worker_share + FROM order_flags f + WHERE + f.wo_status IN ('accepted', 'cancelled', 'problem') + OR (f.has_timeout_for_me AND NOT f.is_assigned_to_me AND NOT f.is_worker_share) + OR NOT f.is_worker_share ) SELECT status, @@ -721,7 +774,7 @@ export async function getWorkerOrderOverview(workerId: number | string): Promise CASE WHEN status IN ('in_progress', 'pending_acceptance', 'problem', 'accepted') OR (status = 'open' AND is_worker_share) - THEN worker_settlement_amount + THEN amount ELSE 0 END ), @@ -734,7 +787,7 @@ export async function getWorkerOrderOverview(workerId: number | string): Promise AND NULLIF(acceptance_json->>'evidenceDueAt', '')::timestamptz > NOW() )::int AS vip_evidence_pending_count, COALESCE( - SUM(worker_settlement_amount) FILTER ( + SUM(amount) FILTER ( WHERE status = 'accepted' AND acceptance_json->>'reviewMode' = 'vip_auto' AND acceptance_json->>'evidenceStatus' = 'pending' @@ -742,8 +795,41 @@ export async function getWorkerOrderOverview(workerId: number | string): Promise ), 0 )::int AS vip_evidence_pending_settlement_amount - FROM worker_orders + FROM order_status_rows GROUP BY status + + UNION ALL + + SELECT + 'all' AS status, + COUNT(DISTINCT work_order_id)::int AS order_count, + COALESCE( + SUM( + CASE + WHEN status IN ('in_progress', 'pending_acceptance', 'problem', 'accepted') + OR (status = 'open' AND is_worker_share) + THEN amount + ELSE 0 + END + ), + 0 + )::int AS settlement_amount, + COUNT(*) FILTER ( + WHERE status = 'accepted' + AND acceptance_json->>'reviewMode' = 'vip_auto' + AND acceptance_json->>'evidenceStatus' = 'pending' + AND NULLIF(acceptance_json->>'evidenceDueAt', '')::timestamptz > NOW() + )::int AS vip_evidence_pending_count, + COALESCE( + SUM(amount) FILTER ( + WHERE status = 'accepted' + AND acceptance_json->>'reviewMode' = 'vip_auto' + AND acceptance_json->>'evidenceStatus' = 'pending' + AND NULLIF(acceptance_json->>'evidenceDueAt', '')::timestamptz > NOW() + ), + 0 + )::int AS vip_evidence_pending_settlement_amount + FROM order_status_rows `, [normalizedWorkerId], ) @@ -793,7 +879,12 @@ export interface OverdueWorkOrderShareItem { shareCreatedAt: string } -/** 查询已超过接单时限、尚未提交材料的拼单份额(status=joined)。 */ +/** + * 查询已到期、尚未提交材料的拼单份额(status=joined)。 + * + * 到期判定只看份额自身的 deadline_at(参与时固化),不再用「母单当前配置 + created_at」实时算: + * 母单改时限不会追溯影响已参与份额。 + */ export async function listOverdueWorkOrderShares({ limit = 50, workerId = 0, @@ -821,18 +912,17 @@ export async function listOverdueWorkOrderShares({ wos.worker_id, wos.quantity, wos.share_deposit, - wo.timeout_minutes, - wo.timeout_policy, + wos.timeout_minutes, + wos.timeout_policy, wos.created_at AS share_created_at FROM work_order_shares wos INNER JOIN work_orders wo ON wo.id = wos.work_order_id WHERE wos.status = 'joined' - AND wo.sharing_enabled = TRUE - AND wo.timeout_minutes > 0 - AND wos.created_at + (wo.timeout_minutes || ' minutes')::interval < NOW() - AND wo.status = 'open' + AND wos.deadline_at IS NOT NULL + AND wos.deadline_at < NOW() + AND wo.status NOT IN ('accepted', 'cancelled') ${workerClause} - ORDER BY wos.created_at ASC + ORDER BY wos.deadline_at ASC LIMIT $${params.length}`, params, ) @@ -848,25 +938,31 @@ export async function listOverdueWorkOrderShares({ })) } +/** + * 打手当前活跃单数 SQL(按**不同工单**计)。 + * + * 同一工单拼多份只占 1 个名额:拼单份额按 work_order_id 去重,避免拆份占满接单上限。 + * 事务内外的计数必须共用这一段 SQL,否则会出现「资料页/抢普通单按份额算、 + * 拼单入口按工单算」的口径分裂。 + */ +const COUNT_WORKER_ACTIVE_ORDERS_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 + INNER 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_orders +` + export async function countWorkerActiveOrders(workerId: number | string): Promise { - const result = await query<{ total: number }>( - ` - SELECT ( - SELECT COUNT(*)::int - FROM work_orders - WHERE assigned_worker_id = $1 - AND status = 'in_progress' - ) + ( - SELECT COUNT(*)::int - FROM work_order_shares wos - INNER JOIN work_orders wo ON wo.id = wos.work_order_id - WHERE wos.worker_id = $1 - AND wos.status = 'joined' - AND wo.status = 'open' - ) AS total - `, - [Number(workerId)], - ) + const result = await query<{ total: number }>(COUNT_WORKER_ACTIVE_ORDERS_SQL, [Number(workerId)]) return Number(result.rows[0]?.total || 0) } @@ -874,20 +970,7 @@ export async function countWorkerActiveOrdersWithClient( client: PoolClient, workerId: number, ): Promise { - const result = await client.query<{ total: number }>( - ` - SELECT ( - SELECT COUNT(*)::int FROM work_orders - WHERE assigned_worker_id = $1 AND status = 'in_progress' - ) + ( - SELECT COUNT(*)::int - FROM work_order_shares wos - INNER JOIN work_orders wo ON wo.id = wos.work_order_id - WHERE wos.worker_id = $1 AND wos.status = 'joined' AND wo.status = 'open' - ) AS total - `, - [workerId], - ) + const result = await client.query<{ total: number }>(COUNT_WORKER_ACTIVE_ORDERS_SQL, [workerId]) return Number(result.rows[0]?.total || 0) } @@ -907,7 +990,8 @@ export async function countTimeoutEventsByWorkerIds( const counts = new Map() const result = await query<{ worker_id: string; total: number }>( ` - SELECT payload_json->>'workerId' AS worker_id, COUNT(*)::int AS total + SELECT payload_json->>'workerId' AS worker_id, + COUNT(DISTINCT work_order_id)::int AS total FROM work_order_events WHERE event_type LIKE 'timeout_%' AND payload_json->>'workerId' IS NOT NULL @@ -1165,9 +1249,23 @@ export function buildWorkOrderWhere({ WHERE timeout_ev.event_type LIKE 'timeout_%' AND timeout_ev.payload_json->>'workerId' = ${workerSharePlaceholder}::text ) worker_visible ON worker_visible.work_order_id = wo.id - LEFT JOIN work_order_shares worker_share - ON worker_share.work_order_id = wo.id - AND worker_share.worker_id = ${workerSharePlaceholder}::bigint`) + LEFT JOIN LATERAL ( + SELECT wos.status, wos.created_at + FROM work_order_shares wos + WHERE wos.work_order_id = wo.id + AND wos.worker_id = ${workerSharePlaceholder}::bigint + ORDER BY + CASE wos.status + WHEN 'joined' THEN 0 + WHEN 'submitted' THEN 1 + WHEN 'accepted' THEN 2 + WHEN 'cancelled' THEN 3 + ELSE 4 + END, + wos.created_at DESC, + wos.id DESC + LIMIT 1 + ) worker_share ON true`) } if (useOperationalStatus) { @@ -1214,8 +1312,8 @@ export function buildWorkOrderWhere({ } } else if (normalizedStatuses.length > 0) { if (workerSharingId && isProblemOnlyStatusFilter(normalizedStatuses)) { - // 「仅问题单」短路:问题单 = wo.status 直接是 problem,或 进行中 且挂着 - // 待审核取消申请/未处理反馈。两条 pending 表极小(生产实测常为 0 行), + // 「仅问题单」短路:问题单 = 母单 problem、自己某份份额被后台标记问题单, + // 或 进行中 且挂着待审核取消申请/未处理反馈。两条 pending 表极小(生产实测常为 0 行), // 从它们出发反查,避免为了一个通常为空的结果枚举该打手全部可见单再逐行求值 // 完整 CASE(重打手 8,586 单 × 逐行探测 ≈ 233MB 缓冲读)。 // 注意左臂必须写成 sargable 的 wo.status = 'problem':完整表达式里 @@ -1225,6 +1323,7 @@ export function buildWorkOrderWhere({ const baseExpression = workerOrderBaseStatusExpression('worker_share') filters.push(`( wo.status = 'problem' + OR ${workerShareProblemExistsExpression(workerSharingId)} OR ( ${baseExpression} = 'in_progress' AND wo.id IN ( @@ -1244,9 +1343,37 @@ export function buildWorkOrderWhere({ params.push(normalizedStatuses) const statusesPlaceholder = `$${params.length}` if (workerSharingId) { - filters.push( - `${workerOrderStatusExpression(workerSharingId)} = ANY(${statusesPlaceholder}::text[])`, - ) + const representativeStatus = `${workerOrderStatusExpression(workerSharingId)} = ANY(${statusesPlaceholder}::text[])` + // 一个工单可能同时有「进行中 + 待验收」等多份不同状态的份额。 + // 只看代表份额会让其中一种状态在页签里完全消失,所以只要任一份额命中就展示。 + // 被后台标记问题单的份额只归问题单页签,不再按原状态出现(问题标记是正交属性)。 + const anyShareStatus = `( + wo.status NOT IN ('accepted', 'cancelled', 'problem') + AND EXISTS ( + SELECT 1 + FROM work_order_shares ws_filter + WHERE ws_filter.work_order_id = wo.id + AND ws_filter.worker_id = ${workerSharingId} + AND ws_filter.status IN ('joined', 'submitted', 'accepted') + AND ws_filter.problem_marked_at IS NULL + AND ( + ( + ws_filter.status = 'joined' + AND 'in_progress' = ANY(${statusesPlaceholder}::text[]) + AND NOT ${workerPendingHandlingExistsExpression(workerSharingId)} + ) + OR ( + ws_filter.status = 'submitted' + AND 'pending_acceptance' = ANY(${statusesPlaceholder}::text[]) + ) + OR ( + ws_filter.status = 'accepted' + AND 'accepted' = ANY(${statusesPlaceholder}::text[]) + ) + ) + ) + )` + filters.push(`(${representativeStatus} OR ${anyShareStatus})`) } else { filters.push( `${useOperationalStatus ? adminOperationalStatusExpression('wo', 'operational_share_summary') : 'wo.status'} = ANY(${statusesPlaceholder}::text[])`, @@ -1312,7 +1439,7 @@ export function buildWorkOrderWhere({ if (normalizedShopNames.length > 0) { // 店铺口径是「订单归属店铺」:工单只存 order_id,必须回查 orders.shop_name。 // 下拉选项本身就是真实值,这里做精确匹配,并用 BTRIM 兼容历史脏数据的前后空格 - // (表达式索引见 103_work_order_shop_filter_index.sql)。 + // (表达式索引见 110_work_order_hall_and_shop_indexes.sql)。 params.push(normalizedShopNames) filters.push(`wo.order_id IN ( SELECT shop_order.id @@ -1464,59 +1591,92 @@ export function buildWorkOrderWhere({ } } -/** 大厅容量先选出全局队首(置顶单优先占位),分类、关键词和 VIP 延迟只能在该集合内继续筛选。 */ +/** + * 大厅候选集:FIFO 队首 N 个 + 全部置顶单。 + * + * 旧实现只按 `hall_queued_at ASC, id ASC` 取全局队首 N 个,置顶单若入厅时间偏早 + * (排在队列 N 名之后)就不在候选集里:列表端按 `pinned_at DESC` 排序、置顶广播 + * 又会让它出现在打手眼前,但抢单/拼单在事务内复核容量时会判「不在候选集」, + * 前端表现为「置顶订单当前不可抢 / 不可拼单」(生产已复现:5 个置顶单里 4 个 + * 排在 FIFO 窗口 50 名之外)。置顶是运营显式给的最高优先级,必须无条件进入 + * 候选集;置顶名额不占用 FIFO 容量,保持原队列语义不变。 + */ function buildHallCandidateIdsSql(limitPlaceholder: string, categoryLimitsPlaceholder: string) { return ` - SELECT ranked.id + SELECT visible.id FROM ( - SELECT - candidate.id, - candidate.category_id, - candidate.hall_queued_at, - candidate.pinned_at, - COALESCE(candidate_wc.parent_id, candidate.category_id) AS primary_category_id, - ROW_NUMBER() OVER ( - PARTITION BY COALESCE(candidate_wc.parent_id, candidate.category_id) - ORDER BY candidate.pinned_at DESC NULLS LAST, candidate.hall_queued_at ASC NULLS LAST, candidate.id ASC - ) AS primary_category_rank, - ROW_NUMBER() OVER ( - PARTITION BY candidate.category_id - ORDER BY candidate.pinned_at DESC NULLS LAST, candidate.hall_queued_at ASC NULLS LAST, candidate.id ASC - ) AS sub_category_rank - FROM work_orders candidate - LEFT JOIN work_categories candidate_wc ON candidate_wc.id = candidate.category_id - WHERE candidate.status = 'open' - AND ( - candidate.sharing_enabled IS NOT TRUE - -- 只按候选工单取拼单份额。历史写法是先把整张 work_order_shares 聚合一遍 - -- (GROUP BY work_order_id)再 join:生产实测那次聚合 22,987 行 / 35ms / 5,025 buffer, - -- 而候选只有 38 笔,聚合结果 99% 被丢掉;而且它是这条 COUNT 的主成本 - -- (EXPLAIN: Aggregate 82.8ms → Index Only Scan + GroupAggregate 35ms)。 - -- 关联子查询走 idx_work_order_shares_work_order,每笔候选一次索引探测即可。 - OR COALESCE(( - SELECT SUM(wos.quantity) - FROM work_order_shares wos - WHERE wos.work_order_id = candidate.id - AND wos.status != 'cancelled' - ), 0) < candidate.sharing_total_quantity + ( + SELECT ranked.id + FROM ( + SELECT + candidate.id, + candidate.category_id, + candidate.hall_queued_at, + COALESCE(candidate_wc.parent_id, candidate.category_id) AS primary_category_id, + ROW_NUMBER() OVER ( + PARTITION BY COALESCE(candidate_wc.parent_id, candidate.category_id) + ORDER BY candidate.hall_queued_at ASC NULLS LAST, candidate.id ASC + ) AS primary_category_rank, + ROW_NUMBER() OVER ( + PARTITION BY candidate.category_id + ORDER BY candidate.hall_queued_at ASC NULLS LAST, candidate.id ASC + ) AS sub_category_rank + FROM work_orders candidate + LEFT JOIN work_categories candidate_wc ON candidate_wc.id = candidate.category_id + WHERE candidate.status = 'open' + AND ( + candidate.sharing_enabled IS NOT TRUE + -- 只按候选工单取拼单份额。历史写法是先把整张 work_order_shares 聚合一遍 + -- (GROUP BY work_order_id)再 join:生产实测那次聚合 22,987 行 / 35ms / 5,025 buffer, + -- 而候选只有 38 笔,聚合结果 99% 被丢掉;而且它是这条 COUNT 的主成本 + -- (EXPLAIN: Aggregate 82.8ms → Index Only Scan + GroupAggregate 35ms)。 + -- 关联子查询走 idx_work_order_shares_work_order,每笔候选一次索引探测即可。 + OR COALESCE(( + SELECT SUM(wos.quantity) + FROM work_order_shares wos + WHERE wos.work_order_id = candidate.id + AND wos.status != 'cancelled' + ), 0) < candidate.sharing_total_quantity + ) + ) ranked + WHERE ranked.primary_category_rank <= COALESCE( + NULLIF(${categoryLimitsPlaceholder}::jsonb ->> ranked.primary_category_id::text, '')::int, + ${limitPlaceholder} ) - ) ranked - WHERE ranked.primary_category_rank <= COALESCE( - NULLIF(${categoryLimitsPlaceholder}::jsonb ->> ranked.primary_category_id::text, '')::int, - ${limitPlaceholder} - ) - AND ( - NULLIF(${categoryLimitsPlaceholder}::jsonb ->> ranked.category_id::text, '') IS NULL - OR ranked.category_id = ranked.primary_category_id - OR ranked.sub_category_rank <= NULLIF(${categoryLimitsPlaceholder}::jsonb ->> ranked.category_id::text, '')::int - ) - -- 置顶单必须占住最终候选集的席位,不能被后面的新单挤出 limit。 - ORDER BY ranked.pinned_at DESC NULLS LAST, ranked.hall_queued_at ASC NULLS LAST, ranked.id ASC - LIMIT ${limitPlaceholder} + AND ( + NULLIF(${categoryLimitsPlaceholder}::jsonb ->> ranked.category_id::text, '') IS NULL + OR ranked.category_id = ranked.primary_category_id + OR ranked.sub_category_rank <= NULLIF(${categoryLimitsPlaceholder}::jsonb ->> ranked.category_id::text, '')::int + ) + ORDER BY ranked.hall_queued_at ASC NULLS LAST, ranked.id ASC + LIMIT ${limitPlaceholder} + ) + UNION + ( + -- 置顶单无条件入选:不参与 FIFO 截断,也不受分类容量限制。 + -- 部分索引 idx_work_orders_hall_pinned 支撑这段(见 110 迁移)。 + SELECT pinned.id + FROM work_orders pinned + WHERE pinned.status = 'open' + AND pinned.pinned_at IS NOT NULL + AND ( + pinned.sharing_enabled IS NOT TRUE + OR COALESCE(( + SELECT SUM(wos.quantity) + FROM work_order_shares wos + WHERE wos.work_order_id = pinned.id + AND wos.status != 'cancelled' + ), 0) < pinned.sharing_total_quantity + ) + ) + ) visible ` } -/** 抢单操作在事务中复核容量与当前打手的可见延迟,避免通过订单 ID 绕过大厅队列。 */ +/** + * 抢单操作在事务中复核容量与当前打手的可见延迟,避免通过订单 ID 绕过大厅队列。 + * 候选集与列表接口同源(FIFO 窗口 + 全部置顶单),所以置顶单即使排在窗口外也能正常抢单/拼单。 + */ export async function isWorkOrderWithinHallCapacityWithClient( client: PoolClient, input: { diff --git a/apps/backend/src/repositories/worker-platform/work-order-settlement-repo.ts b/apps/backend/src/repositories/worker-platform/work-order-settlement-repo.ts index 6642e055..56cb8390 100644 --- a/apps/backend/src/repositories/worker-platform/work-order-settlement-repo.ts +++ b/apps/backend/src/repositories/worker-platform/work-order-settlement-repo.ts @@ -10,6 +10,7 @@ import { import { CLEAR_DELIVERY_RESIDUE_SQL, RESET_GIFT_FLOW_SQL } from './work-order-gift-repo.js' import { createWorkOrderEventWithClient } from './work-order-event-repo.js' import { getWorkOrderByIdWithClient } from './work-order-query-repo.js' +import { recomputeWorkOrderSharingStatusWithClient } from './work-order-share-repo.js' import { enqueueDepositUnfreezeWithClient, getOutstandingDepositAmountWithClient, @@ -708,7 +709,13 @@ export async function acceptWorkOrderShareAndSettle(input: { }) } -/** 后台撤销单个拼单份额,释放该打手押金并把份数重新放回大厅。 */ +/** + * 后台撤销单个拼单份额:退还该打手押金、释放这一份的占用。 + * + * **不改母工单状态**:后台撤单常用于剔除错误参与者/纠错,撤掉一份不应把 + * 待验收(或进行中)的母单踢回大厅;需要改母单状态时走「拼单撤单」的 + * 退回大厅/取消整单显式操作。 + */ export async function cancelWorkOrderShare(input: { workOrderId: number shareId: number @@ -809,30 +816,27 @@ export async function cancelWorkOrderShare(input: { await client.query( `UPDATE work_order_shares SET status = 'cancelled', + cancelled_reason = 'admin', + cancel_note = $2, + cancel_quantity = COALESCE(quantity, 0), acceptance_json = '{}'::jsonb, draft_acceptance_json = '{}'::jsonb, submitted_at = NULL, accepted_at = NULL, updated_at = $1 - WHERE id = $2`, - [input.now, input.shareId], - ) - await client.query( - `UPDATE work_orders - SET status = $1, - submitted_at = NULL, - published_at = COALESCE(published_at, $2), - updated_at = $2 WHERE id = $3`, - [nextOrderStatus, input.now, input.workOrderId], + [input.now, String(input.reason || '').trim(), input.shareId], ) + // 母单状态保持不变:这里只取消这一份份额,不再按剩余份额重推母单状态。 + // (旧实现会 recompute:待验收的母单撤掉一份后 allocated < total → 变回 open, + // 等于整单被踢回抢单大厅,与「后台撤单只影响该份额」的预期不符。) await createWorkOrderEventWithClient(client, { workOrderId: input.workOrderId, actorType: 'admin', actorId: input.actorName || '', eventType: 'sharing_share_cancelled_by_admin', fromStatus: workOrder.status, - toStatus: nextOrderStatus, + toStatus: workOrder.status, payloadJson: JSON.stringify({ shareId: input.shareId, workerId, @@ -878,7 +882,11 @@ export async function settleOverdueWorkOrderShare(input: { [input.workOrderId], ) const workOrder = orderResult.rows[0] || null - if (!workOrder || workOrder.sharing_enabled !== true || workOrder.status !== 'open') { + if ( + !workOrder || + workOrder.sharing_enabled !== true || + !['open', 'pending_acceptance', 'in_progress'].includes(String(workOrder.status || '')) + ) { return { order: workOrder, share: null, @@ -901,7 +909,8 @@ export async function settleOverdueWorkOrderShare(input: { } } - const timeoutMinutes = Number(workOrder.timeout_minutes || 0) + // 到期判定只看份额自身快照:参与时固化的 deadline_at / timeout_minutes + const timeoutMinutes = Number(share.timeout_minutes ?? workOrder.timeout_minutes ?? 0) if (timeoutMinutes <= 0) { return { order: workOrder, @@ -910,8 +919,11 @@ export async function settleOverdueWorkOrderShare(input: { failureReason: 'share_not_overdue', } } - const deadlineMs = new Date(share.created_at).getTime() + timeoutMinutes * 60 * 1000 - if (deadlineMs > new Date(input.now).getTime()) { + const snapshotDeadline = String(share.deadline_at || '').trim() + const deadlineMs = snapshotDeadline + ? new Date(snapshotDeadline).getTime() + : new Date(share.created_at).getTime() + timeoutMinutes * 60 * 1000 + if (Number.isFinite(deadlineMs) && deadlineMs > new Date(input.now).getTime()) { return { order: workOrder, share, @@ -996,6 +1008,7 @@ export async function settleOverdueWorkOrderShare(input: { await client.query( `UPDATE work_order_shares SET status = 'cancelled', + cancelled_reason = 'timeout', acceptance_json = '{}'::jsonb, draft_acceptance_json = '{}'::jsonb, submitted_at = NULL, @@ -1005,15 +1018,8 @@ export async function settleOverdueWorkOrderShare(input: { [input.now, input.shareId], ) - await client.query( - `UPDATE work_orders - SET status = 'open', - published_at = COALESCE(published_at, $1), - hall_queued_at = $1, - updated_at = $1 - WHERE id = $2`, - [input.now, input.workOrderId], - ) + // 母单状态只由份额集合推导:这一份超时不应把其他已提交份额的母单踢回大厅 + await recomputeWorkOrderSharingStatusWithClient(client, input.workOrderId, input.now) await createWorkOrderEventWithClient(client, { workOrderId: input.workOrderId, @@ -1160,6 +1166,7 @@ export async function cancelWorkOrderSharing(input: { await client.query( `UPDATE work_order_shares SET status = 'cancelled', + cancelled_reason = 'admin', acceptance_json = '{}'::jsonb, draft_acceptance_json = '{}'::jsonb, submitted_at = NULL, diff --git a/apps/backend/src/repositories/worker-platform/work-order-share-repo.ts b/apps/backend/src/repositories/worker-platform/work-order-share-repo.ts index 6e56b837..69f84af3 100644 --- a/apps/backend/src/repositories/worker-platform/work-order-share-repo.ts +++ b/apps/backend/src/repositories/worker-platform/work-order-share-repo.ts @@ -1,4 +1,7 @@ +import type { PoolClient } from 'pg' + import { query, withTransaction } from '../../db/client.js' +import { resolveShareReduceTargetQuantity } from '../../domain/work-order-share-quantity.js' import { ensureWorkerWalletWithClient, getWorkerWalletWithClient, @@ -9,18 +12,11 @@ import { createWorkOrderEventWithClient } from './work-order-event-repo.js' import { countWorkerActiveOrdersWithClient, getWorkOrderById, + getWorkOrderByIdWithClient, isWorkOrderWithinHallCapacityWithClient, } from './work-order-repo.js' import type { WorkOrderRow, WorkOrderShareRow } from './types.js' -async function getWorkOrderShareById(shareId: number): Promise { - const result = await query( - `${WORK_ORDER_SHARE_SELECT} WHERE wos.id = $1 LIMIT 1`, - [shareId], - ) - return result.rows[0] || null -} - export async function listWorkOrderShares( workOrderId: number | string, ): Promise { @@ -90,18 +86,56 @@ export async function countWorkOrderPendingSharingSubmissions( return Number(result.rows[0]?.total || 0) } +/** + * 该打手在该工单的「主份额」:优先未完成(joined/submitted)的最新一条, + * 否则取最新一条(含已验收/已取消)。仅用于展示与权限判断,**不要**用它做验收/撤单定位。 + */ export async function getWorkOrderShare( workOrderId: number | string, workerId: number | string, ): Promise { const result = await query( `${WORK_ORDER_SHARE_SELECT} - WHERE wos.work_order_id = $1 AND wos.worker_id = $2 AND wos.status != 'cancelled' LIMIT 1`, + WHERE wos.work_order_id = $1 AND wos.worker_id = $2 AND wos.status != 'cancelled' + ORDER BY CASE WHEN wos.status IN ('joined', 'submitted') THEN 0 ELSE 1 END, wos.id DESC + LIMIT 1`, [Number(workOrderId), Number(workerId)], ) return result.rows[0] || null } +/** 该打手在该工单的全部有效份额(未取消),按参与顺序返回。 */ +export async function listWorkOrderSharesByWorkerInOrder( + workOrderId: number | string, + workerId: number | string, +): Promise { + const result = await query( + `${WORK_ORDER_SHARE_SELECT} + WHERE wos.work_order_id = $1 AND wos.worker_id = $2 AND wos.status != 'cancelled' + ORDER BY wos.id ASC`, + [Number(workOrderId), Number(workerId)], + ) + return result.rows +} + +/** 按份额 id 读取(可带工单约束,防止跨单操作)。 */ +export async function getWorkOrderShareById( + shareId: number | string, + workOrderId?: number | string, +): Promise { + const params: unknown[] = [Number(shareId)] + let workOrderClause = '' + if (workOrderId !== undefined && Number(workOrderId) > 0) { + params.push(Number(workOrderId)) + workOrderClause = ` AND wos.work_order_id = $${params.length}` + } + const result = await query( + `${WORK_ORDER_SHARE_SELECT} WHERE wos.id = $1${workOrderClause} LIMIT 1`, + params, + ) + return result.rows[0] || null +} + /** 普通拼单使用指定份数,立即抢单则在事务中承接全部剩余份数。 */ export function resolveWorkOrderShareJoinQuantity(input: { quantity: number @@ -112,45 +146,41 @@ export function resolveWorkOrderShareJoinQuantity(input: { } /** - * 同一打手再次参与同一拼单工单时的处理方式。 - * 份额表有 UNIQUE(work_order_id, worker_id),取消的份额会保留用于追溯, - * 所以重新拼单必须复用该行并重置为一条全新份额,不能直接插入。 + * 计算「本次参与」应新增冻结的押金。 + * + * 口径(已确认):免押额度跟着母单走 —— 在该工单内只抵扣一次。 + * cumRequired = round(required × 累计份数 / 总份数) + * cumDeposit = max(0, cumRequired − 免押额度) + * 本次冻结 = max(0, cumDeposit − 该打手在本工单已冻结合计) + * + * alreadyFrozenDeposit 只用于避免重复冻结,不会因为份数变少而回退(既有语义)。 */ -export function resolveWorkOrderShareJoinMode(input: { - existingShareStatus?: string | null - takeRemaining?: boolean -}): 'create' | 'append' | 'rejoin' | 'reject' { - const status = String(input.existingShareStatus || '') - if (!status) return 'create' - // 取消即结束该次参与,允许打手把同一行重新启用为份额。 - if (status === 'cancelled') return 'rejoin' - // 立即抢单承接剩余份数时才允许在未提交的份额上追加。 - if (status === 'joined' && input.takeRemaining === true) return 'append' - return 'reject' -} - -/** - * 计算打手当前累计拼单份额实际需要冻结的押金。 - * 等级与个人叠加后的免押额度针对该打手在本工单的累计份额生效;已经冻结的金额不自动回退。 - */ -export function resolveWorkOrderShareDepositAmount(input: { +export function resolveWorkOrderShareDepositPlan(input: { requiredDepositAmount: number totalQuantity: number + /** 本次参与前,该打手在该工单已参与的有效份数(不含本次) */ + quantityBefore: number + /** 本次参与份数 */ nextQuantity: number depositFreeAmount?: number - currentFrozenDeposit?: number -}) { + /** 该打手在该工单已冻结的押金合计 */ + frozenDepositSoFar?: number +}): { cumulativeRequired: number; cumulativeDeposit: number; increment: number } { const totalQuantity = Math.max(1, Number(input.totalQuantity || 0)) - const nextRequiredDeposit = Math.round( - (Math.max(0, Number(input.requiredDepositAmount || 0)) * - Math.max(0, Number(input.nextQuantity || 0))) / - totalQuantity, + const cumulativeQuantity = + Math.max(0, Number(input.quantityBefore || 0)) + Math.max(0, Number(input.nextQuantity || 0)) + const cumulativeRequired = Math.round( + (Math.max(0, Number(input.requiredDepositAmount || 0)) * cumulativeQuantity) / totalQuantity, ) - const requiredShareDeposit = Math.max( + const cumulativeDeposit = Math.max( 0, - nextRequiredDeposit - Math.max(0, Number(input.depositFreeAmount || 0)), + cumulativeRequired - Math.max(0, Number(input.depositFreeAmount || 0)), ) - return Math.max(Math.max(0, Number(input.currentFrozenDeposit || 0)), requiredShareDeposit) + const increment = Math.max( + 0, + cumulativeDeposit - Math.max(0, Number(input.frozenDepositSoFar || 0)), + ) + return { cumulativeRequired, cumulativeDeposit, increment } } export async function joinWorkOrderShare(input: { @@ -159,22 +189,24 @@ export async function joinWorkOrderShare(input: { quantity: number /** 立即抢单时在事务锁内承接全部剩余份数。 */ takeRemaining?: boolean - /** 当前打手的有效免押额度(分),按其本次累计拼单份额抵扣。 */ + /** 当前打手的有效免押额度(分),按其在本工单的累计份额抵扣一次。 */ depositFreeAmount?: number maxActiveOrders: number hallCandidateLimit?: number hallCategoryLimits?: Record visibleAfterIso?: string + /** 客户端幂等键:同一个打手同一个 requestId 只产生一条份额。 */ + requestId?: string now: string }): Promise<{ share: WorkOrderShareRow | null + /** true = 命中幂等键,返回既有份额,未重复冻结 */ + duplicated: boolean failureReason: | 'work_order_not_open' | 'sharing_disabled' | 'work_order_owner_conflict' | 'sharing_quantity_full' - | 'worker_share_exists' - | 'worker_share_locked' | 'worker_active_order_limit' | 'worker_deposit_insufficient' | null @@ -193,7 +225,7 @@ export async function joinWorkOrderShare(input: { ) const workOrder = orderResult.rows[0] || null if (!workOrder || workOrder.status !== 'open') { - return { share: null, failureReason: 'work_order_not_open' } + return { share: null, duplicated: false, failureReason: 'work_order_not_open' } } if ( input.hallCandidateLimit && @@ -204,13 +236,26 @@ export async function joinWorkOrderShare(input: { visibleAfterIso: input.visibleAfterIso, })) ) { - return { share: null, failureReason: 'work_order_not_open' } + return { share: null, duplicated: false, failureReason: 'work_order_not_open' } } if (workOrder.sharing_enabled !== true) { - return { share: null, failureReason: 'sharing_disabled' } + return { share: null, duplicated: false, failureReason: 'sharing_disabled' } } if (Number(workOrder.assigned_worker_id || 0) === input.workerId) { - return { share: null, failureReason: 'work_order_owner_conflict' } + return { share: null, duplicated: false, failureReason: 'work_order_owner_conflict' } + } + + // 幂等:同一打手同一 requestId 直接返回原份额(不重复冻结、不重复算份数) + const requestId = String(input.requestId || '').trim() + if (requestId) { + const idempotentResult = await client.query( + `${WORK_ORDER_SHARE_SELECT} WHERE wos.worker_id = $1 AND wos.request_id = $2 LIMIT 1`, + [input.workerId, requestId], + ) + const existing = idempotentResult.rows[0] || null + if (existing) { + return { share: existing, duplicated: true, failureReason: null } + } } const totalQuantity = toPositiveInteger(workOrder.sharing_total_quantity, 1) @@ -230,122 +275,82 @@ export async function joinWorkOrderShare(input: { takeRemaining: input.takeRemaining === true, }) if (requestedQuantity <= 0 || requestedQuantity > remaining) { - return { share: null, failureReason: 'sharing_quantity_full' } + return { share: null, duplicated: false, failureReason: 'sharing_quantity_full' } } - // 取消的份额也一并查出:UNIQUE(work_order_id, worker_id) 下只能复用该行重新加入。 - const existingShareResult = await client.query( - `SELECT * - FROM work_order_shares - WHERE work_order_id = $1 AND worker_id = $2 - FOR UPDATE`, + // 该打手在本工单的累计份数与已冻结押金(免押额度按母单只抵扣一次) + const workerAggResult = await client.query<{ quantity_before: number; frozen_before: number }>( + ` + SELECT + COALESCE(SUM(quantity), 0)::int AS quantity_before, + COALESCE(SUM(share_deposit), 0)::int AS frozen_before + FROM work_order_shares + WHERE work_order_id = $1 AND worker_id = $2 AND status != 'cancelled' + `, [input.workOrderId, input.workerId], ) - const existingShare = existingShareResult.rows[0] || null - const joinMode = resolveWorkOrderShareJoinMode({ - existingShareStatus: existingShare?.status ?? null, - takeRemaining: input.takeRemaining === true, - }) - if (joinMode === 'reject') { - return { - share: null, - failureReason: - existingShare?.status === 'joined' ? 'worker_share_exists' : 'worker_share_locked', - } - } - const revivingCancelledShare = joinMode === 'rejoin' - if (joinMode === 'create' || revivingCancelledShare) { + const quantityBefore = Number(workerAggResult.rows[0]?.quantity_before || 0) + const frozenBefore = Number(workerAggResult.rows[0]?.frozen_before || 0) + + // 活跃单按「不同工单」计:已经在该工单有份额时不重复占名额 + if (quantityBefore <= 0) { const activeOrderCount = await countWorkerActiveOrdersWithClient(client, input.workerId) if (activeOrderCount >= input.maxActiveOrders) { - return { share: null, failureReason: 'worker_active_order_limit' } + return { share: null, duplicated: false, failureReason: 'worker_active_order_limit' } } } const unitReward = toPositiveInteger(workOrder.sharing_unit_reward, 0) - // 取消的份额已释放份数与押金,重新加入时按一条全新份额计算。 - const currentQuantity = revivingCancelledShare ? 0 : Number(existingShare?.quantity || 0) - const currentFrozenDeposit = revivingCancelledShare - ? 0 - : Number(existingShare?.share_deposit || 0) - const nextQuantity = currentQuantity + requestedQuantity - const shareReward = nextQuantity * unitReward - const nextShareDeposit = resolveWorkOrderShareDepositAmount({ + const shareReward = requestedQuantity * unitReward + const depositPlan = resolveWorkOrderShareDepositPlan({ requiredDepositAmount: Number(workOrder.required_deposit_amount || 0), totalQuantity, - nextQuantity, + quantityBefore, + nextQuantity: requestedQuantity, ...(input.depositFreeAmount === undefined ? {} : { depositFreeAmount: input.depositFreeAmount }), - currentFrozenDeposit, + frozenDepositSoFar: frozenBefore, }) - const depositToFreeze = Math.max(0, nextShareDeposit - currentFrozenDeposit) + const depositToFreeze = depositPlan.increment const wallet = await getWorkerWalletWithClient(client, input.workerId) const available = Number(wallet?.available_amount || 0) if (available < depositToFreeze) { - return { share: null, failureReason: 'worker_deposit_insufficient' } + return { share: null, duplicated: false, failureReason: 'worker_deposit_insufficient' } } - let shareId = 0 - if (!existingShare) { - const inserted = await client.query<{ id: number }>( - ` + // 参与瞬间固化截止时间与超时策略:母单后续改配置不影响这一份 + const timeoutMinutes = toPositiveInteger(workOrder.timeout_minutes, 0) + const timeoutPolicy = String(workOrder.timeout_policy || '').trim() || 'reopen' + const deadlineAt = + timeoutMinutes > 0 + ? new Date(new Date(input.now).getTime() + timeoutMinutes * 60 * 1000).toISOString() + : null + + const shareResult = await client.query<{ id: number }>( + ` INSERT INTO work_order_shares ( work_order_id, worker_id, quantity, unit_reward, share_reward, - share_deposit, status, acceptance_json, created_at, updated_at - ) VALUES ($1, $2, $3, $4, $5, $6, 'joined', '{}'::jsonb, $7, $7) + share_deposit, status, acceptance_json, deadline_at, timeout_minutes, + timeout_policy, request_id, created_at, updated_at + ) VALUES ($1, $2, $3, $4, $5, $6, 'joined', '{}'::jsonb, $7, $8, $9, $10, $11, $11) RETURNING id `, - [ - input.workOrderId, - input.workerId, - requestedQuantity, - unitReward, - shareReward, - nextShareDeposit, - input.now, - ], - ) - shareId = Number(inserted.rows[0]?.id || 0) - } else if (revivingCancelledShare) { - const revived = await client.query<{ id: number }>( - ` - UPDATE work_order_shares - SET - status = 'joined', - quantity = $1, - unit_reward = $2, - share_reward = $3, - share_deposit = $4, - acceptance_json = '{}'::jsonb, - draft_acceptance_json = '{}'::jsonb, - submitted_at = NULL, - accepted_at = NULL, - created_at = $5, - updated_at = $5 - WHERE id = $6 - RETURNING id - `, - [nextQuantity, unitReward, shareReward, nextShareDeposit, input.now, existingShare.id], - ) - shareId = Number(revived.rows[0]?.id || 0) - } else { - const appended = await client.query<{ id: number }>( - ` - UPDATE work_order_shares - SET - quantity = $1, - unit_reward = $2, - share_reward = $3, - share_deposit = $4, - updated_at = $5 - WHERE id = $6 - RETURNING id - `, - [nextQuantity, unitReward, shareReward, nextShareDeposit, input.now, existingShare.id], - ) - shareId = Number(appended.rows[0]?.id || 0) - } + [ + input.workOrderId, + input.workerId, + requestedQuantity, + unitReward, + shareReward, + depositToFreeze, + deadlineAt, + timeoutMinutes, + timeoutPolicy, + requestId, + input.now, + ], + ) if (depositToFreeze > 0) { const nextAvailable = available - depositToFreeze @@ -372,7 +377,12 @@ export async function joinWorkOrderShare(input: { nextFrozen, input.workOrderId, `拼单冻结押金 ${requestedQuantity} 份`, - JSON.stringify({ workOrderId: input.workOrderId, quantity: requestedQuantity }), + JSON.stringify({ + workOrderId: input.workOrderId, + quantity: requestedQuantity, + shareId: Number(shareResult.rows[0]?.id || 0), + cumulativeDeposit: depositPlan.cumulativeDeposit, + }), input.now, ], ) @@ -387,24 +397,146 @@ export async function joinWorkOrderShare(input: { toStatus: 'open', payloadJson: JSON.stringify({ workerId: input.workerId, + shareId: Number(shareResult.rows[0]?.id || 0), quantity: requestedQuantity, takeRemaining: input.takeRemaining === true, - rejoined: revivingCancelledShare, + deadlineAt, + timeoutMinutes, + timeoutPolicy, + requestId: requestId || null, }), now: input.now, }) const share = await client.query( `${WORK_ORDER_SHARE_SELECT} WHERE wos.id = $1 LIMIT 1`, - [shareId], + [Number(shareResult.rows[0]?.id || 0)], ) - return { share: share.rows[0] || null, failureReason: null } + return { share: share.rows[0] || null, duplicated: false, failureReason: null } + }) +} + +/** + * 后台按份数撤销某个打手的部分份额。 + * + * 只改这一行的数量与份额报酬: + * - 大厅余量 = 总份数 − Σ 有效份额数量,所以减少的份数会自动回到大厅; + * - 母单状态不动(与「单人撤单」同口径); + * - 押金按要求保持原冻结额,不做退还; + * - 验收资料(验收图/视频/备注)留在原份额上,不清空。 + */ +export async function reduceWorkOrderShareQuantity(input: { + workOrderId: number + shareId: number + reduceQuantity: number + note?: string + actorName?: string + now: string +}): Promise<{ + order: WorkOrderRow | null + share: WorkOrderShareRow | null + failureReason: + | 'work_order_not_reducible' + | 'share_not_found' + | 'share_status_not_reducible' + | 'share_quantity_invalid' + | null +}> { + return withTransaction(async (client) => { + const orderResult = await client.query( + `SELECT * FROM work_orders WHERE id = $1 FOR UPDATE`, + [input.workOrderId], + ) + const workOrder = orderResult.rows[0] || null + if ( + !workOrder || + !['open', 'in_progress', 'pending_acceptance'].includes(String(workOrder.status || '')) + ) { + return { order: workOrder, share: null, failureReason: 'work_order_not_reducible' as const } + } + + const shareResult = await client.query( + `SELECT * FROM work_order_shares WHERE id = $1 AND work_order_id = $2 FOR UPDATE`, + [input.shareId, input.workOrderId], + ) + const share = shareResult.rows[0] || null + if (!share) { + return { order: workOrder, share: null, failureReason: 'share_not_found' as const } + } + if (!['joined', 'submitted'].includes(String(share.status || ''))) { + return { order: workOrder, share, failureReason: 'share_status_not_reducible' as const } + } + + const target = resolveShareReduceTargetQuantity({ + currentQuantity: Number(share.quantity || 0), + reduceQuantity: input.reduceQuantity, + }) + if (target.failureReason || target.nextQuantity <= 0) { + return { order: workOrder, share, failureReason: 'share_quantity_invalid' as const } + } + + const unitReward = toPositiveInteger(share.unit_reward, 0) + const nextRewardAmount = target.nextQuantity * unitReward + // 原因只做展示:本次没填就保留上一次填的,避免后续撤份额把已有说明覆盖成空 + const nextCancelNote = String(input.note || '').trim() || String(share.cancel_note || '') + // 撤销份数累计:同一份额多次撤份额时,列表显示总共撤了多少份 + const nextCancelQuantity = + Math.max(0, Number(share.cancel_quantity || 0)) + input.reduceQuantity + await client.query( + `UPDATE work_order_shares + SET quantity = $1, share_reward = $2, cancel_note = $3, + cancel_quantity = $4, updated_at = $5 + WHERE id = $6`, + [ + target.nextQuantity, + nextRewardAmount, + nextCancelNote, + nextCancelQuantity, + input.now, + input.shareId, + ], + ) + + await createWorkOrderEventWithClient(client, { + workOrderId: input.workOrderId, + actorType: 'admin', + actorId: input.actorName || '', + eventType: 'sharing_share_quantity_reduced', + fromStatus: workOrder.status, + toStatus: workOrder.status, + payloadJson: JSON.stringify({ + shareId: input.shareId, + workerId: Number(share.worker_id || 0), + fromQuantity: Number(share.quantity || 0), + toQuantity: target.nextQuantity, + reduceQuantity: input.reduceQuantity, + rewardBefore: Number(share.share_reward || 0), + rewardAfter: nextRewardAmount, + // 押金口径:本次保持原冻结额,不退不补 + shareDeposit: Number(share.share_deposit || 0), + note: String(input.note || '').trim(), + }), + now: input.now, + }) + + const updatedShare = await client.query( + `${WORK_ORDER_SHARE_SELECT} WHERE wos.id = $1 LIMIT 1`, + [input.shareId], + ) + // 必须回读聚合口径的母单(active_share_quantity 等),否则前端拿到的拼单进度会归零 + const updatedOrder = await getWorkOrderByIdWithClient(client, input.workOrderId) + return { + order: updatedOrder || workOrder, + share: updatedShare.rows[0] || null, + failureReason: null, + } }) } export async function submitWorkOrderShareAcceptance(input: { workOrderId: number workerId: number + shareId: number acceptanceJson: string now: string }): Promise<{ @@ -412,11 +544,8 @@ export async function submitWorkOrderShareAcceptance(input: { failureReason: 'share_not_found' | 'share_status_invalid' | 'work_order_status_invalid' | null }> { return withTransaction(async (client) => { - const orderResult = await client.query<{ - status: string - sharing_total_quantity: number - }>( - `SELECT status, sharing_total_quantity + const orderResult = await client.query<{ status: string }>( + `SELECT status FROM work_orders WHERE id = $1 FOR UPDATE`, @@ -434,10 +563,10 @@ export async function submitWorkOrderShareAcceptance(input: { ` SELECT id FROM work_order_shares - WHERE work_order_id = $1 AND worker_id = $2 + WHERE id = $3 AND work_order_id = $1 AND worker_id = $2 FOR UPDATE `, - [input.workOrderId, input.workerId], + [input.workOrderId, input.workerId, input.shareId], ) const shareId = lockResult.rows[0]?.id || 0 if (!shareId) { @@ -463,34 +592,7 @@ export async function submitWorkOrderShareAcceptance(input: { [input.acceptanceJson, input.now, Number(share.id)], ) - const progressResult = await client.query<{ - joined_quantity: number - pending_submission_count: number - }>( - `SELECT - COALESCE(SUM(quantity), 0)::int AS joined_quantity, - COUNT(*) FILTER (WHERE status = 'joined')::int AS pending_submission_count - FROM work_order_shares - WHERE work_order_id = $1 - AND status != 'cancelled'`, - [input.workOrderId], - ) - const progress = progressResult.rows[0] - const allQuantityAllocated = - Number(progress?.joined_quantity || 0) >= - toPositiveInteger(workOrder.sharing_total_quantity, 1) - if (allQuantityAllocated && Number(progress?.pending_submission_count || 0) === 0) { - await client.query( - `UPDATE work_orders - SET - status = 'pending_acceptance', - submitted_at = COALESCE(submitted_at, $1), - updated_at = $1 - WHERE id = $2 - AND status = 'open'`, - [input.now, input.workOrderId], - ) - } + await recomputeWorkOrderSharingStatusWithClient(client, input.workOrderId, input.now) await createWorkOrderEventWithClient(client, { workOrderId: input.workOrderId, @@ -499,7 +601,11 @@ export async function submitWorkOrderShareAcceptance(input: { eventType: 'sharing_acceptance_submitted', fromStatus: 'joined', toStatus: 'submitted', - payloadJson: JSON.stringify({ workerId: input.workerId, quantity: share.quantity }), + payloadJson: JSON.stringify({ + workerId: input.workerId, + shareId: Number(share.id), + quantity: share.quantity, + }), now: input.now, }) const updated = await client.query( @@ -510,6 +616,86 @@ export async function submitWorkOrderShareAcceptance(input: { }) } +/** + * 按份额集合推导母单拼单状态(唯一口径,禁止各调用点各自 UPDATE work_orders.status)。 + * + * allocated >= total && joined == 0 && submitted > 0 → pending_acceptance + * allocated < total 或 joined > 0 → open + * allocated == 0(全部取消) → open(重新可参与) + * 母单 accepted / cancelled → 不动 + * + * 返回更新后的母单行(供调用方回给前端)。 + */ +export async function recomputeWorkOrderSharingStatusWithClient( + client: PoolClient, + workOrderId: number, + now: string, +): Promise { + const orderResult = await client.query( + `SELECT * FROM work_orders WHERE id = $1 FOR UPDATE`, + [workOrderId], + ) + const workOrder = orderResult.rows[0] || null + if (!workOrder) return null + if (['accepted', 'cancelled'].includes(String(workOrder.status || ''))) { + return workOrder + } + + const aggResult = await client.query<{ + allocated: number + joined_count: number + submitted_count: number + accepted_count: number + }>( + ` + SELECT + COALESCE(SUM(quantity), 0)::int AS allocated, + COUNT(*) FILTER (WHERE status = 'joined')::int AS joined_count, + COUNT(*) FILTER (WHERE status = 'submitted')::int AS submitted_count, + COUNT(*) FILTER (WHERE status = 'accepted')::int AS accepted_count + FROM work_order_shares + WHERE work_order_id = $1 AND status != 'cancelled' + `, + [workOrderId], + ) + const agg = aggResult.rows[0] + const allocated = Number(agg?.allocated || 0) + const joinedCount = Number(agg?.joined_count || 0) + const submittedCount = Number(agg?.submitted_count || 0) + const acceptedCount = Number(agg?.accepted_count || 0) + const total = toPositiveInteger(workOrder.sharing_total_quantity, 1) + + const nextStatus = + allocated >= total && joinedCount === 0 && submittedCount > 0 ? 'pending_acceptance' : 'open' + + // 已全是 accepted 且母单已验收时不再回退(上面的终态分支已挡住) + if (allocated === 0 && acceptedCount === 0 && workOrder.status === 'open') { + return workOrder + } + if (String(workOrder.status || '') === nextStatus) { + return workOrder + } + + const updated = await client.query( + ` + UPDATE work_orders + SET + status = $1, + published_at = CASE WHEN $1 = 'open' THEN $2 ELSE published_at END, + hall_queued_at = CASE WHEN $1 = 'open' THEN $2 ELSE hall_queued_at END, + submitted_at = CASE + WHEN $1 = 'pending_acceptance' THEN COALESCE(submitted_at, $2) + ELSE submitted_at + END, + updated_at = $2 + WHERE id = $3 + RETURNING * + `, + [nextStatus, now, workOrderId], + ) + return updated.rows[0] || null +} + /** 保存整单验收草稿(暂存图片/说明),不改变工单状态 */ export async function updateWorkOrderDraftAcceptance(input: { workOrderId: number @@ -548,13 +734,14 @@ export async function updateWorkOrderAcceptanceRecord(input: { export async function updateWorkOrderShareDraftAcceptance(input: { workOrderId: number workerId: number + shareId: number draftJson: string now: string }): Promise { const result = await query( `${WORK_ORDER_SHARE_SELECT} - WHERE wos.work_order_id = $1 AND wos.worker_id = $2`, - [Number(input.workOrderId), Number(input.workerId)], + WHERE wos.id = $3 AND wos.work_order_id = $1 AND wos.worker_id = $2`, + [Number(input.workOrderId), Number(input.workerId), Number(input.shareId)], ) const share = result.rows[0] || null if (!share) return null @@ -573,13 +760,14 @@ export async function updateWorkOrderShareDraftAcceptance(input: { export async function updateWorkOrderShareAcceptanceRecord(input: { workOrderId: number workerId: number + shareId: number acceptanceJson: string now: string }): Promise { const result = await query( `${WORK_ORDER_SHARE_SELECT} - WHERE wos.work_order_id = $1 AND wos.worker_id = $2`, - [Number(input.workOrderId), Number(input.workerId)], + WHERE wos.id = $3 AND wos.work_order_id = $1 AND wos.worker_id = $2`, + [Number(input.workOrderId), Number(input.workerId), Number(input.shareId)], ) const share = result.rows[0] || null if (!share) return null diff --git a/apps/backend/src/repositories/worker-platform/worker-active-order-count.test.ts b/apps/backend/src/repositories/worker-platform/worker-active-order-count.test.ts new file mode 100644 index 00000000..035addf8 --- /dev/null +++ b/apps/backend/src/repositories/worker-platform/worker-active-order-count.test.ts @@ -0,0 +1,49 @@ +import assert from 'node:assert/strict' +import { readFile } from 'node:fs/promises' +import test from 'node:test' + +/** + * 回归:同一打手在同一拼单工单里有多份份额时,活跃接单数只应计 1 单。 + * + * 之前 countWorkerActiveOrders 仍是「指派单数 + 拼单份额行数」直接相加, + * 打手资料页、抢普通单和后台指派会把同工单的多份拼单算成多单,提前撞接单上限。 + * 事务内版本已经去重,这里把「事务内外共用同一段计数 SQL」钉死,防止再次分裂。 + */ +async function readRepoSource() { + return readFile(new URL('./work-order-query-repo.ts', import.meta.url), 'utf8') +} + +/** 去掉 SQL 行注释与 TS 行注释,避免注释里的旧写法被误判成代码。 */ +function stripComments(source: string) { + return source + .split('\n') + .map((line) => line.replace(/--.*$/, '').replace(/\/\/.*$/, '')) + .join('\n') +} + +test('活跃接单数按工单去重,同一工单多份拼单只占 1 个名额', async () => { + const source = stripComments(await readRepoSource()) + const start = source.indexOf('const COUNT_WORKER_ACTIVE_ORDERS_SQL') + assert.ok(start >= 0, '缺少共享的活跃单计数 SQL 常量') + + const sql = source.slice(start, start + 800) + assert.match(sql, /UNION/, '活跃单计数必须用 UNION 按工单去重') + assert.match(sql, /work_order_shares/, '活跃单计数必须包含拼单份额') + assert.match(sql, /swo\.status = 'open'/, '只统计母单仍在大厅的拼单份额') +}) + +test('事务内外共用同一段活跃单计数 SQL,避免口径分裂', async () => { + const source = stripComments(await readRepoSource()) + + for (const fn of ['countWorkerActiveOrders', 'countWorkerActiveOrdersWithClient']) { + const start = source.indexOf(`export async function ${fn}`) + assert.ok(start >= 0, `缺少函数 ${fn}`) + + const block = source.slice(start, start + 700) + assert.ok( + block.includes('COUNT_WORKER_ACTIVE_ORDERS_SQL'), + `${fn} 没有复用共享的活跃单计数 SQL`, + ) + assert.ok(!block.includes(') + ('), `${fn} 仍在把「指派单数 + 拼单份额行数」直接相加`) + } +}) diff --git a/apps/backend/src/repositories/worker-platform/worker-cancel-request-repo.ts b/apps/backend/src/repositories/worker-platform/worker-cancel-request-repo.ts index f4b3c5bd..e69f7de8 100644 --- a/apps/backend/src/repositories/worker-platform/worker-cancel-request-repo.ts +++ b/apps/backend/src/repositories/worker-platform/worker-cancel-request-repo.ts @@ -11,6 +11,7 @@ import { } from './shared.js' import { CLEAR_DELIVERY_RESIDUE_SQL, RESET_GIFT_FLOW_SQL } from './work-order-gift-repo.js' import { createWorkOrderEventWithClient } from './work-order-event-repo.js' +import { recomputeWorkOrderSharingStatusWithClient } from './work-order-share-repo.js' import { getOutstandingDepositAmountWithClient, getWorkOrderByIdWithClient, @@ -229,6 +230,8 @@ export async function getPendingCancelRequestOrderIds( export async function createWorkerCancelRequest(input: { workOrderId: number workerId: number + /** 指定份额:只申请撤销这一份;不传(拼单)= 申请撤销我在该工单的全部有效份额 */ + shareId?: number | null reason: string now: string }): Promise<{ @@ -254,17 +257,35 @@ export async function createWorkerCancelRequest(input: { let shareId: number | null = null if (workOrder.sharing_enabled === true) { - const shareResult = await client.query( - `SELECT * FROM work_order_shares - WHERE work_order_id = $1 AND worker_id = $2 - FOR UPDATE`, - [input.workOrderId, input.workerId], - ) - const share = shareResult.rows[0] || null - if (!share || !resolveWorkOrderShareCancellationStatus(workOrder.status, share.status)) { - return { request: null, dailyUsed: 0, failureReason: 'share_not_cancellable' } + const requestedShareId = Number(input.shareId || 0) + if (requestedShareId > 0) { + const shareResult = await client.query( + `SELECT * FROM work_order_shares + WHERE id = $3 AND work_order_id = $1 AND worker_id = $2 + FOR UPDATE`, + [input.workOrderId, input.workerId, requestedShareId], + ) + const share = shareResult.rows[0] || null + if (!share || !resolveWorkOrderShareCancellationStatus(workOrder.status, share.status)) { + return { request: null, dailyUsed: 0, failureReason: 'share_not_cancellable' } + } + shareId = Number(share.id) + } else { + // 整单退单(拼单):至少要有 1 条可撤份额;审核通过时撤销我在该工单的全部有效份额 + const sharesResult = await client.query( + `SELECT * FROM work_order_shares + WHERE work_order_id = $1 AND worker_id = $2 + FOR UPDATE`, + [input.workOrderId, input.workerId], + ) + const cancellable = sharesResult.rows.filter((share) => + resolveWorkOrderShareCancellationStatus(workOrder.status, share.status), + ) + if (cancellable.length === 0) { + return { request: null, dailyUsed: 0, failureReason: 'share_not_cancellable' } + } + shareId = null } - shareId = Number(share.id) } else if ( workOrder.status !== 'in_progress' || Number(workOrder.assigned_worker_id || 0) !== input.workerId @@ -534,6 +555,7 @@ export async function reviewWorkerCancelRequest(input: { await client.query( `UPDATE work_order_shares SET status = 'cancelled', + cancelled_reason = 'worker_request', acceptance_json = '{}'::jsonb, draft_acceptance_json = '{}'::jsonb, submitted_at = NULL, @@ -542,17 +564,84 @@ export async function reviewWorkerCancelRequest(input: { WHERE id = $2`, [input.now, share.id], ) - await client.query( - `UPDATE work_orders - SET status = 'open', - ${CLEAR_DELIVERY_RESIDUE_SQL}, - ${RESET_GIFT_FLOW_SQL}, - published_at = COALESCE(published_at, $1), - hall_queued_at = COALESCE(hall_queued_at, $1), - updated_at = $1 - WHERE id = $2`, - [input.now, workOrder.id], + await recomputeWorkOrderSharingStatusWithClient(client, workOrder.id, input.now) + } else if (workOrder.sharing_enabled === true) { + // 整单退单(拼单):撤销我在该工单的全部可撤份额,逐份退还(或没收)押金 + const sharesResult = await client.query( + `SELECT * FROM work_order_shares + WHERE work_order_id = $1 AND worker_id = $2 AND status IN ('joined', 'submitted') + FOR UPDATE`, + [workOrder.id, request.worker_id], ) + const cancellableShares = sharesResult.rows.filter((share) => + resolveWorkOrderShareCancellationStatus(workOrder.status, share.status), + ) + if (cancellableShares.length === 0) { + return { + request: null, + order: null, + releasedDepositAmount: 0, + levelChanged: false, + accountDisabled: false, + failureReason: 'target_state_changed', + } + } + await ensureWorkerWalletWithClient(client, Number(request.worker_id), input.now) + for (const share of cancellableShares) { + const wallet = await getWorkerWalletWithClient(client, Number(request.worker_id)) + const releaseAmount = Math.min( + Number(share.share_deposit || 0), + Number(wallet?.frozen_deposit_amount || 0), + ) + if (releaseAmount > 0) { + const nextAvailable = + Number(wallet?.available_amount || 0) + (forfeitGiftDeposit ? 0 : releaseAmount) + const nextFrozen = Math.max(0, Number(wallet?.frozen_deposit_amount || 0) - releaseAmount) + await client.query( + `UPDATE worker_wallets + SET available_amount = $1, frozen_deposit_amount = $2, updated_at = $3 + WHERE worker_id = $4`, + [nextAvailable, nextFrozen, input.now, request.worker_id], + ) + await client.query( + `INSERT INTO worker_wallet_ledgers ( + worker_id, ledger_type, amount, balance_after, frozen_after, + related_work_order_id, note, payload_json, created_at + ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8::jsonb, $9)`, + [ + request.worker_id, + forfeitGiftDeposit ? 'deposit_deduction' : 'deposit_release', + forfeitGiftDeposit ? -releaseAmount : releaseAmount, + nextAvailable, + nextFrozen, + request.work_order_id, + forfeitGiftDeposit + ? '打手整单退单审核通过扣除拼单押金(已确认加好友)' + : '打手整单退单审核通过退还拼单押金', + JSON.stringify({ + requestId: request.id, + shareId: Number(share.id), + reason: request.reason, + }), + input.now, + ], + ) + releasedDepositAmount += releaseAmount + } + await client.query( + `UPDATE work_order_shares + SET status = 'cancelled', + cancelled_reason = 'worker_request', + acceptance_json = '{}'::jsonb, + draft_acceptance_json = '{}'::jsonb, + submitted_at = NULL, + accepted_at = NULL, + updated_at = $1 + WHERE id = $2`, + [input.now, share.id], + ) + } + await recomputeWorkOrderSharingStatusWithClient(client, workOrder.id, input.now) } else { if ( workOrder.status !== 'in_progress' || diff --git a/apps/backend/src/repositories/worker-platform/worker-repo.ts b/apps/backend/src/repositories/worker-platform/worker-repo.ts index b8735e4e..45bdca3f 100644 --- a/apps/backend/src/repositories/worker-platform/worker-repo.ts +++ b/apps/backend/src/repositories/worker-platform/worker-repo.ts @@ -317,7 +317,7 @@ export async function getWorkerUserStatistics( const timeoutResult = await query<{ total: number }>( ` - SELECT COUNT(*)::int AS total + SELECT COUNT(DISTINCT (e.payload_json->>'workerId', e.work_order_id))::int AS total FROM work_order_events e INNER JOIN worker_users wu ON e.payload_json->>'workerId' = wu.id::text ${whereClause ? `${whereClause} AND` : 'WHERE'} e.event_type LIKE 'timeout_%' diff --git a/apps/backend/src/routes/admin/worker-platform/acceptance.ts b/apps/backend/src/routes/admin/worker-platform/acceptance.ts index 3a91e57a..7f6c8cc7 100644 --- a/apps/backend/src/routes/admin/worker-platform/acceptance.ts +++ b/apps/backend/src/routes/admin/worker-platform/acceptance.ts @@ -7,7 +7,10 @@ import { cancelAdminWorkOrderSharing, deductAdminWorkOrderPendingDeposit, markAdminWorkOrderProblem, + markAdminWorkOrderShareProblem, + reduceAdminWorkOrderShareQuantity, resolveAdminProblemWorkOrder, + disposeAdminWorkOrderShareProblem, } from '../../../services/worker-platform/index.js' import { createJsonHandler, requireAdminPermission, requireAnyAdminPermission } from '../session.js' @@ -148,7 +151,7 @@ router.post( req.adminSession?.displayName || req.adminSession?.username || '', ), { - successMessage: '拼单参与者已撤单,份数已退回大厅', + successMessage: '拼单参与者已撤单,押金已退还,母单状态保持不变', errorMessage: '撤销拼单参与者失败', scope: '[admin/worker-platform/orders/:workOrderId/shares/:shareId/cancel]', audit: (req, data) => ({ @@ -161,6 +164,84 @@ router.post( ), ) +// 按份数撤销部分份额:减少的份数自动回到大厅,押金保持原冻结额。 +router.post( + '/worker-platform/orders/:workOrderId/shares/:shareId/reduce-quantity', + requireAdminPermission('worker_order.cancel_share'), + createJsonHandler( + (req) => + reduceAdminWorkOrderShareQuantity( + String(req.params.workOrderId || ''), + String(req.params.shareId || ''), + req.body || {}, + req.adminSession?.displayName || req.adminSession?.username || '', + ), + { + successMessage: '已撤销指定份数,剩余份数继续履约', + errorMessage: '撤销拼单份额失败', + scope: '[admin/worker-platform/orders/:workOrderId/shares/:shareId/reduce-quantity]', + audit: (req, data) => ({ + action: 'work_order_share_quantity_reduced', + targetType: 'work_order_share', + targetId: String(req.params.shareId || ''), + data: data && typeof data === 'object' ? (data as Record) : {}, + }), + }, + ), +) + +// 份额级问题单:只影响该份额持有人,母单状态不变。 +router.post( + '/worker-platform/orders/:workOrderId/shares/:shareId/problem', + requireAdminPermission('worker_order.manage_problem'), + createJsonHandler( + (req) => + markAdminWorkOrderShareProblem( + String(req.params.workOrderId || ''), + String(req.params.shareId || ''), + req.body || {}, + req.adminSession?.displayName || req.adminSession?.username || '', + ), + { + successMessage: '该份额已标记为问题单,仅影响该打手', + errorMessage: '标记份额问题单失败', + scope: '[admin/worker-platform/orders/:workOrderId/shares/:shareId/problem]', + audit: (req, data) => ({ + action: 'work_order_share_marked_problem', + targetType: 'work_order_share', + targetId: String(req.params.shareId || ''), + data: data && typeof data === 'object' ? (data as Record) : {}, + }), + }, + ), +) + +// 份额级问题单处置:处置方式与母单一致,但只作用于这一份份额,母单状态不变。 +router.post( + '/worker-platform/orders/:workOrderId/shares/:shareId/problem/dispose', + requireAdminPermission('worker_order.manage_problem'), + createJsonHandler( + (req) => + disposeAdminWorkOrderShareProblem( + String(req.params.workOrderId || ''), + String(req.params.shareId || ''), + req.body || {}, + req.adminSession?.displayName || req.adminSession?.username || '', + ), + { + successMessage: '该份额问题单已处置', + errorMessage: '处置份额问题单失败', + scope: '[admin/worker-platform/orders/:workOrderId/shares/:shareId/problem/dispose]', + audit: (req, data) => ({ + action: 'work_order_share_problem_disposed', + targetType: 'work_order_share', + targetId: String(req.params.shareId || ''), + data: data && typeof data === 'object' ? (data as Record) : {}, + }), + }, + ), +) + router.post( '/worker-platform/orders/:workOrderId/deduct-pending-deposit', requireAdminPermission('worker_order.deduct_deposit'), diff --git a/apps/backend/src/routes/file-upload.ts b/apps/backend/src/routes/file-upload.ts index 726bc354..54c98b74 100644 --- a/apps/backend/src/routes/file-upload.ts +++ b/apps/backend/src/routes/file-upload.ts @@ -1,13 +1,17 @@ import type { NextFunction, Request, Response } from 'express' import multer from 'multer' -import { getMaxUploadSizeBytes } from '../services/file-storage/file-storage-service.js' +import { + getMaxUploadSizeBytes, + getVideoUploadSizeLimitBytes, +} from '../services/file-storage/file-storage-service.js' import { createHttpError, sendRouteError } from '../utils/http.js' const upload = multer({ storage: multer.memoryStorage(), limits: { - fileSize: getMaxUploadSizeBytes(), + // 先按较大的验收视频上限收流,具体类型再在 service 内按图片/视频分别校验。 + fileSize: Math.max(getMaxUploadSizeBytes(), getVideoUploadSizeLimitBytes()), files: 1, }, }) diff --git a/apps/backend/src/routes/worker/hall.ts b/apps/backend/src/routes/worker/hall.ts index 7824822d..e7a0327d 100644 --- a/apps/backend/src/routes/worker/hall.ts +++ b/apps/backend/src/routes/worker/hall.ts @@ -50,7 +50,11 @@ router.post( requireActiveWorker, createRouteHandler( (req) => - grabWorkerHallOrder(String(req.params.workOrderId || ''), getRequiredWorkerSession(req)), + grabWorkerHallOrder( + String(req.params.workOrderId || ''), + getRequiredWorkerSession(req), + req.body || {}, + ), { successMessage: '抢单成功', errorMessage: '抢单失败', diff --git a/apps/backend/src/routes/worker/orders.ts b/apps/backend/src/routes/worker/orders.ts index 910114c0..d9928bef 100644 --- a/apps/backend/src/routes/worker/orders.ts +++ b/apps/backend/src/routes/worker/orders.ts @@ -2,6 +2,7 @@ import { Router } from 'express' import { getWorkerAfterSalesCaseSummary, + getWorkerAcceptanceConfig, getWorkerAnnouncement, getWorkerCancelRequestSummary, getWorkerMyOrderEvents, @@ -54,6 +55,17 @@ router.get( }), ) +// 验收弹窗的压缩参数(时长上限/目标高度/码率等),后台可改,打手端每次打开弹窗拉取。 +router.get( + '/acceptance/config', + requireActiveWorker, + createRouteHandler(() => getWorkerAcceptanceConfig(), { + successMessage: 'ok', + errorMessage: '读取验收配置失败', + scope: '[worker/acceptance/config]', + }), +) + router.get( '/announcement', requireActiveWorker, diff --git a/apps/backend/src/routes/worker/profile.ts b/apps/backend/src/routes/worker/profile.ts index 8b378459..4edc94d4 100644 --- a/apps/backend/src/routes/worker/profile.ts +++ b/apps/backend/src/routes/worker/profile.ts @@ -134,6 +134,8 @@ router.post( scene: req.body?.scene || 'worker-acceptance', uploaderType: 'worker', uploaderId: String(session.workerId), + // 验收视频:容器解析不到时长时用前端上报值兜底(见 resolveVideoDuration)。 + durationSeconds: req.body?.durationSeconds, }) }, { diff --git a/apps/backend/src/services/admin/admin-notification-service.ts b/apps/backend/src/services/admin/admin-notification-service.ts index 1771e762..9c7aa6d8 100644 --- a/apps/backend/src/services/admin/admin-notification-service.ts +++ b/apps/backend/src/services/admin/admin-notification-service.ts @@ -77,19 +77,51 @@ export async function createWorkerAcceptanceAdminNotification(input: { productName: string workerName: string imageCount: number + /** 是否提交了验收视频:视频与图片至少一个,通知里要体现,避免显示成“0 张图片”。 */ + hasVideo?: boolean + /** 拼单份额:每份提交各生成一条待办(按 shareId 去重) */ + shareId?: number | null }) { + const shareId = Number(input.shareId || 0) return createConfiguredWorkerPlatformNotification('acceptance_submitted', { notificationType: 'worker_acceptance_submitted', priority: 'normal', entityType: 'work_order', entityId: input.workOrderId, - dedupeKey: acceptanceNotificationKey(input.workOrderId), + dedupeKey: + shareId > 0 + ? acceptanceNotificationKey(input.workOrderId, shareId) + : acceptanceNotificationKey(input.workOrderId), title: '有订单待验收', body: [ `订单:${input.workOrderNo || input.workOrderId}`, `商品:${input.productName || '-'}`, `打手:${input.workerName || '-'}`, + ...(shareId > 0 ? [`份额:#${shareId}`] : []), `验收图片:${input.imageCount} 张`, + ...(input.hasVideo ? ['验收视频:已上传'] : []), + ].join(';'), + actionPath: `${ADMIN_WORKER_PLATFORM_PATH}?tab=orders`, + now: new Date().toISOString(), + }) +} + +/** 整单(含拼单全部份额)验收完成:单独一条完成通知,不与待验收待办混用。 */ +export async function createWorkOrderFullyAcceptedAdminNotification(input: { + workOrderId: number + workOrderNo: string + productName: string +}) { + return createConfiguredWorkerPlatformNotification('order_accepted', { + notificationType: 'worker_order_accepted', + priority: 'normal', + entityType: 'work_order', + entityId: input.workOrderId, + dedupeKey: `work_order_accepted:${input.workOrderId}`, + title: '订单已全部验收', + body: [ + `订单:${input.workOrderNo || input.workOrderId}`, + `商品:${input.productName || '-'}`, ].join(';'), actionPath: `${ADMIN_WORKER_PLATFORM_PATH}?tab=orders`, now: new Date().toISOString(), @@ -279,8 +311,10 @@ async function createConfiguredWorkerPlatformNotification( return notification } -function acceptanceNotificationKey(workOrderId: number) { - return `work_order_acceptance:${workOrderId}` +function acceptanceNotificationKey(workOrderId: number, shareId = 0) { + return shareId > 0 + ? `work_order_acceptance:${workOrderId}:share:${shareId}` + : `work_order_acceptance:${workOrderId}` } function formatAmount(amountFen: number) { diff --git a/apps/backend/src/services/file-storage/file-storage-service.ts b/apps/backend/src/services/file-storage/file-storage-service.ts index 8423c7fc..2d01e067 100644 --- a/apps/backend/src/services/file-storage/file-storage-service.ts +++ b/apps/backend/src/services/file-storage/file-storage-service.ts @@ -8,7 +8,9 @@ import { createFileAsset, type FileAssetRow } from '../../repositories/file-asse import { createHttpError } from '../../utils/http.js' import { nowIso } from '../../utils/time.js' import { getObjectStorage, normalizeObjectKey } from './object-storage.js' +import { readVideoDurationSeconds } from './video-metadata.js' import { logWarn } from '../../utils/logger.js' +import { getWorkerAcceptanceConfig } from '../worker-platform/worker-acceptance-config-service.js' /** * libvips 并发与操作缓存调优(进程级,必须早于任何 sharp 调用执行)。 @@ -41,7 +43,21 @@ const IMAGE_VARIANT_MEDIUM = 'medium' const THUMB_MAX_SIDE = 480 const MEDIUM_MAX_SIDE = 1280 -const ALLOWED_CONTENT_TYPES = new Set(['image/jpeg', 'image/png', 'image/webp', 'application/pdf']) +/** + * 验收视频:前端已用 Canvas + MediaRecorder 压到 720p/1.2Mbps,这里只接受这两种容器。 + * 对象 key 统一落在 `worker-acceptance-video/` 前缀下,验收服务据此校验来源。 + */ +const VIDEO_CONTENT_TYPES = new Set(['video/mp4', 'video/webm']) +const ACCEPTANCE_VIDEO_SCENE = 'worker-acceptance-video' + +const ALLOWED_CONTENT_TYPES = new Set([ + 'image/jpeg', + 'image/png', + 'image/webp', + 'application/pdf', + 'video/mp4', + 'video/webm', +]) const IMAGE_CONTENT_TYPES = new Set(['image/jpeg', 'image/png', 'image/webp']) @@ -53,6 +69,8 @@ export type UploadedFileDto = { filename: string contentType: string size: number + /** 仅视频:解析到的时长(秒);WebM 未写 Duration 时为客户端上报值 */ + durationSeconds?: number } type UploadFileAssetInput = { @@ -60,6 +78,8 @@ type UploadFileAssetInput = { scene: unknown uploaderType: string uploaderId: string + /** 前端读取到的视频时长(秒),仅在容器里解析不到时作为兜底 */ + durationSeconds?: unknown } export async function uploadFileAsset(input: UploadFileAssetInput) { @@ -71,23 +91,31 @@ export async function uploadFileAsset(input: UploadFileAssetInput) { }) } - const maxUploadSize = getMaxUploadSizeBytes() - if (file.buffer.length > maxUploadSize) { - throw createHttpError(`文件不能超过 ${runtimeConfig.storage.maxUploadSizeMb}MB`, { - statusCode: 400, - errorCode: 'upload_file_too_large', - }) - } - const contentType = normalizeContentType(file.mimetype, file.buffer) if (!ALLOWED_CONTENT_TYPES.has(contentType)) { - throw createHttpError('文件不符合规则,仅支持 JPG、PNG、WebP 或 PDF', { + throw createHttpError('文件不符合规则,仅支持 JPG、PNG、WebP、PDF 或 MP4/WebM 视频', { statusCode: 400, errorCode: 'upload_file_type_invalid', }) } - const scene = normalizeScene(input.scene) + const isVideo = VIDEO_CONTENT_TYPES.has(contentType) + // 视频按验收配置单独限制体积/时长;图片与 PDF 仍走全局体积上限。 + const videoDurationSeconds = isVideo + ? resolveVideoDuration(file.buffer, contentType, input.durationSeconds) + : 0 + if (!isVideo) { + const maxUploadSize = getMaxUploadSizeBytes() + if (file.buffer.length > maxUploadSize) { + throw createHttpError(`文件不能超过 ${runtimeConfig.storage.maxUploadSizeMb}MB`, { + statusCode: 400, + errorCode: 'upload_file_too_large', + }) + } + } + + // 视频统一落到验收视频目录,验收服务据此校验来源,避免引用到其它场景的对象。 + const scene = isVideo ? ACCEPTANCE_VIDEO_SCENE : normalizeScene(input.scene) const objectKey = createObjectKey(scene, file.originalname, contentType) const storage = getObjectStorage() const uploadedKeys: string[] = [] @@ -147,6 +175,7 @@ export async function uploadFileAsset(input: UploadFileAssetInput) { metadataJson: JSON.stringify({ originalMimeType: file.mimetype, originalSize: file.size, + ...(isVideo ? { durationSeconds: videoDurationSeconds } : {}), }), now, }) @@ -155,15 +184,18 @@ export async function uploadFileAsset(input: UploadFileAssetInput) { } return { - file: mapUploadedFile(asset, { - objectKey, - fileUrl, - thumbnailUrl, - mediumUrl, - filename: file.originalname, - contentType, - size: file.buffer.length, - }), + file: { + ...mapUploadedFile(asset, { + objectKey, + fileUrl, + thumbnailUrl, + mediumUrl, + filename: file.originalname, + contentType, + size: file.buffer.length, + }), + ...(isVideo ? { durationSeconds: videoDurationSeconds } : {}), + }, } } catch (error) { const cleanupResults = await Promise.allSettled( @@ -212,6 +244,11 @@ export function getMaxUploadSizeBytes() { return Math.max(1, Number(runtimeConfig.storage.maxUploadSizeMb || 10)) * 1024 * 1024 } +/** 验收视频的体积上限(按后台配置),multer 需要用它做流式兜底。 */ +export function getVideoUploadSizeLimitBytes() { + return Math.max(1, Number(getWorkerAcceptanceConfig().maxSizeMb || 12)) * 1024 * 1024 +} + /** * 文件链接:不再带签名与有效期。 * @@ -356,9 +393,70 @@ function detectContentType(data: Buffer) { if (data.length >= 4 && data.subarray(0, 4).toString('ascii') === '%PDF') { return 'application/pdf' } + // MP4/MOV 家族:偏移 4 处是 4 字节 box 类型 `ftyp`。 + if ( + data.length >= 12 && + data.subarray(4, 8).toString('ascii') === 'ftyp' && + data.subarray(8, 10).toString('ascii') !== 'qt' + ) { + return 'video/mp4' + } + // WebM/Matroska:EBML 头 1A 45 DF A3。 + if ( + data.length >= 4 && + data[0] === 0x1a && + data[1] === 0x45 && + data[2] === 0xdf && + data[3] === 0xa3 + ) { + return 'video/webm' + } return '' } +/** + * 验收视频兜底校验:功能开关、体积、时长。 + * + * 时长优先用容器解析值(硬证据),解析不到(MediaRecorder 的 WebM 常常不写 Duration) + * 才回退客户端上报值;两者都没有则拒绝,避免放过完全未知长度的视频。 + */ +function resolveVideoDuration( + buffer: Buffer, + contentType: string, + declaredDuration: unknown, +): number { + const config = getWorkerAcceptanceConfig() + if (!config.enabled) { + throw createHttpError('验收视频功能已关闭', { + statusCode: 400, + errorCode: 'upload_video_disabled', + }) + } + const maxBytes = config.maxSizeMb * 1024 * 1024 + if (buffer.length > maxBytes) { + throw createHttpError(`验收视频不能超过 ${config.maxSizeMb}MB`, { + statusCode: 400, + errorCode: 'upload_video_too_large', + }) + } + const parsed = readVideoDurationSeconds(buffer, contentType) + const declared = Number(declaredDuration) + const duration = parsed ?? (Number.isFinite(declared) && declared > 0 ? declared : 0) + if (!duration) { + throw createHttpError('无法读取视频时长,请重新选择视频', { + statusCode: 400, + errorCode: 'upload_video_duration_unknown', + }) + } + if (duration > config.maxDurationSeconds + 0.5) { + throw createHttpError(`视频时长不能超过 ${config.maxDurationSeconds} 秒`, { + statusCode: 400, + errorCode: 'upload_video_too_long', + }) + } + return Math.round(duration * 10) / 10 +} + function normalizeScene(value: unknown) { const scene = String(value || '') .trim() @@ -382,6 +480,8 @@ function extensionForContentType(contentType: string) { if (contentType === 'image/png') return '.png' if (contentType === 'image/webp') return '.webp' if (contentType === 'application/pdf') return '.pdf' + if (contentType === 'video/mp4') return '.mp4' + if (contentType === 'video/webm') return '.webm' return '' } diff --git a/apps/backend/src/services/file-storage/video-metadata.test.ts b/apps/backend/src/services/file-storage/video-metadata.test.ts new file mode 100644 index 00000000..146c55ab --- /dev/null +++ b/apps/backend/src/services/file-storage/video-metadata.test.ts @@ -0,0 +1,73 @@ +import assert from 'node:assert/strict' +import test from 'node:test' + +import { readVideoDurationSeconds } from './video-metadata.js' + +/** MP4 box:size(4) + type(4) + payload */ +function mp4Box(type: string, payload: Buffer): Buffer { + const header = Buffer.alloc(8) + header.writeUInt32BE(payload.length + 8, 0) + header.write(type, 4, 4, 'ascii') + return Buffer.concat([header, payload]) +} + +function mvhdV0(timescale: number, duration: number): Buffer { + // version/flags(4) + creation(4) + modification(4) + timescale(4) + duration(4) + const payload = Buffer.alloc(20) + payload.writeUInt32BE(timescale, 12) + payload.writeUInt32BE(duration, 16) + return mp4Box('mvhd', payload) +} + +function ebmlId(id: number, length: number): Buffer { + const buffer = Buffer.alloc(length) + let rest = id + for (let index = length - 1; index >= 0; index -= 1) { + buffer[index] = rest % 256 + rest = Math.floor(rest / 256) + } + return buffer +} + +/** 只支持 1 字节长度(测试数据都很小)。 */ +function ebmlElement(id: number, idLength: number, payload: Buffer): Buffer { + return Buffer.concat([ebmlId(id, idLength), Buffer.from([0x80 | payload.length]), payload]) +} + +test('MP4:从 moov/mvhd 读出时长(30 秒)', () => { + const mp4 = mp4Box('moov', mvhdV0(1000, 30_000)) + const seconds = readVideoDurationSeconds(mp4, 'video/mp4') + assert.ok(seconds !== null) + assert.equal(Math.round(seconds as number), 30) +}) + +test('MP4:mvhd 缺失或损坏时返回 null(交给客户端上报值兜底)', () => { + assert.equal(readVideoDurationSeconds(Buffer.from('not-a-video'), 'video/mp4'), null) + const brokenMvhd = mp4Box('moov', mp4Box('mvhd', Buffer.alloc(4))) + assert.equal(readVideoDurationSeconds(brokenMvhd, 'video/mp4'), null) +}) + +test('WebM:从 Segment/Info 的 TimecodeScale + Duration 读出时长(30 秒)', () => { + const duration = Buffer.alloc(8) + duration.writeDoubleBE(30_000, 0) // 30000 * 1ms + const timecodeScale = Buffer.from([0x0f, 0x42, 0x40]) // 1,000,000ns + const info = ebmlElement( + 0x1549a966, + 4, + Buffer.concat([ebmlElement(0x2ad7b1, 3, timecodeScale), ebmlElement(0x4489, 2, duration)]), + ) + const segment = ebmlElement(0x18538067, 4, info) + const seconds = readVideoDurationSeconds(segment, 'video/webm') + assert.ok(seconds !== null) + assert.equal(Math.round(seconds as number), 30) +}) + +test('WebM:MediaRecorder 常见的"没有 Duration"返回 null', () => { + const info = ebmlElement(0x1549a966, 4, ebmlElement(0x2ad7b1, 3, Buffer.from([0x0f, 0x42, 0x40]))) + const segment = ebmlElement(0x18538067, 4, info) + assert.equal(readVideoDurationSeconds(segment, 'video/webm'), null) +}) + +test('非视频类型不做解析', () => { + assert.equal(readVideoDurationSeconds(Buffer.from('%PDF-1.4'), 'application/pdf'), null) +}) diff --git a/apps/backend/src/services/file-storage/video-metadata.ts b/apps/backend/src/services/file-storage/video-metadata.ts new file mode 100644 index 00000000..6a73cfaf --- /dev/null +++ b/apps/backend/src/services/file-storage/video-metadata.ts @@ -0,0 +1,191 @@ +/** + * 视频时长解析(不依赖 ffprobe)。 + * + * 验收视频由前端 Canvas + MediaRecorder 重编码后上传,服务端仍要做一次兜底校验: + * 时长超过配置上限直接拒绝,避免"前端改了参数/绕过页面"后上传超长视频。 + * + * 覆盖两种交付格式: + * - MP4:moov/mvhd 里的 timescale + duration(最可靠); + * - WebM:Segment/Info 下的 TimecodeScale + Duration。 + * MediaRecorder 生成的 WebM 在部分浏览器里不写 Duration(值为 0 或缺失), + * 此时返回 null,由调用方回退到客户端上报的时长 + 体积上限。 + */ +export function readVideoDurationSeconds(data: Buffer, contentType: string): number | null { + const normalized = String(contentType || '') + .trim() + .toLowerCase() + try { + if (normalized === 'video/mp4') return readMp4DurationSeconds(data) + if (normalized === 'video/webm') return readWebmDurationSeconds(data) + } catch { + // 损坏/截断的文件按"时长未知"处理,交给调用方决定。 + } + return null +} + +type Mp4Box = { payloadStart: number; payloadEnd: number } + +function readMp4DurationSeconds(data: Buffer): number | null { + const moov = findMp4Box(data, 0, data.length, 'moov') + if (!moov) return null + const mvhd = findMp4Box(data, moov.payloadStart, moov.payloadEnd, 'mvhd') + if (!mvhd) return null + + const payload = mvhd.payloadStart + const version = data[payload] + let timescale = 0 + let duration = 0 + if (version === 1) { + if (payload + 32 > mvhd.payloadEnd) return null + timescale = data.readUInt32BE(payload + 4 + 8 + 8) + duration = Number(data.readBigUInt64BE(payload + 4 + 8 + 8 + 4)) + } else { + if (payload + 20 > mvhd.payloadEnd) return null + timescale = data.readUInt32BE(payload + 4 + 4 + 4) + duration = data.readUInt32BE(payload + 4 + 4 + 4 + 4) + } + if (!timescale || !Number.isFinite(duration) || duration <= 0) return null + const seconds = duration / timescale + return Number.isFinite(seconds) && seconds > 0 ? seconds : null +} + +function findMp4Box(data: Buffer, start: number, end: number, type: string): Mp4Box | null { + let offset = start + while (offset + 8 <= end) { + let size = data.readUInt32BE(offset) + const boxType = data.toString('ascii', offset + 4, offset + 8) + let headerSize = 8 + if (size === 1) { + if (offset + 16 > end) return null + size = Number(data.readBigUInt64BE(offset + 8)) + headerSize = 16 + } else if (size === 0) { + size = end - offset + } + if (size < headerSize || offset + size > end) return null + if (boxType === type) { + return { payloadStart: offset + headerSize, payloadEnd: offset + size } + } + offset += size + } + return null +} + +const EBML_ID_SEGMENT = 0x18538067 +const EBML_ID_INFO = 0x1549a966 +const EBML_ID_TIMECODE_SCALE = 0x2ad7b1 +const EBML_ID_DURATION = 0x4489 +const DEFAULT_TIMECODE_SCALE_NS = 1_000_000 + +type EbmlElement = { payloadStart: number; payloadEnd: number } + +function readWebmDurationSeconds(data: Buffer): number | null { + const segment = findEbmlChild(data, 0, data.length, EBML_ID_SEGMENT) + if (!segment) return null + const info = findEbmlChild(data, segment.payloadStart, segment.payloadEnd, EBML_ID_INFO) + if (!info) return null + + const durationElement = findEbmlChild(data, info.payloadStart, info.payloadEnd, EBML_ID_DURATION) + if (!durationElement) return null + const durationValue = readEbmlFloat(data, durationElement) + if (!Number.isFinite(durationValue) || durationValue <= 0) return null + + const scaleElement = findEbmlChild( + data, + info.payloadStart, + info.payloadEnd, + EBML_ID_TIMECODE_SCALE, + ) + const scaleNs = scaleElement + ? readEbmlUint(data, scaleElement) || DEFAULT_TIMECODE_SCALE_NS + : DEFAULT_TIMECODE_SCALE_NS + // Duration 的单位是 TimecodeScale(纳秒),换算成秒。 + const seconds = (durationValue * scaleNs) / 1_000_000_000 + return Number.isFinite(seconds) && seconds > 0 ? seconds : null +} + +function findEbmlChild( + data: Buffer, + start: number, + end: number, + targetId: number, +): EbmlElement | null { + let offset = start + while (offset < end) { + const idInfo = readEbmlId(data, offset, end) + if (!idInfo) return null + const sizeInfo = readEbmlSize(data, idInfo.next, end) + if (!sizeInfo) return null + const payloadStart = sizeInfo.next + const payloadEnd = sizeInfo.unknown ? end : Math.min(end, payloadStart + sizeInfo.size) + if (idInfo.id === targetId) return { payloadStart, payloadEnd } + if (payloadEnd <= payloadStart) return null + offset = payloadEnd + } + return null +} + +function readEbmlId( + data: Buffer, + offset: number, + end: number, +): { id: number; next: number } | null { + if (offset >= end) return null + const first = data[offset] as number + let length = 0 + for (let index = 0; index < 4; index += 1) { + if (first & (0x80 >> index)) { + length = index + 1 + break + } + } + if (!length || offset + length > end) return null + let id = 0 + for (let index = 0; index < length; index += 1) { + id = id * 256 + (data[offset + index] as number) + } + return { id, next: offset + length } +} + +function readEbmlSize( + data: Buffer, + offset: number, + end: number, +): { size: number; next: number; unknown: boolean } | null { + if (offset >= end) return null + const first = data[offset] as number + let length = 0 + for (let index = 0; index < 8; index += 1) { + if (first & (0x80 >> index)) { + length = index + 1 + break + } + } + if (!length || offset + length > end) return null + const mask = 0xff >> length + let value = first & mask + let unknown = value === mask + for (let index = 1; index < length; index += 1) { + const byte = data[offset + index] as number + if (byte !== 0xff) unknown = false + value = value * 256 + byte + } + return { size: value, next: offset + length, unknown } +} + +function readEbmlFloat(data: Buffer, element: EbmlElement): number { + const size = element.payloadEnd - element.payloadStart + if (size === 4) return data.readFloatBE(element.payloadStart) + if (size === 8) return data.readDoubleBE(element.payloadStart) + return Number.NaN +} + +function readEbmlUint(data: Buffer, element: EbmlElement): number { + const size = element.payloadEnd - element.payloadStart + if (size <= 0 || size > 6) return 0 + let value = 0 + for (let index = 0; index < size; index += 1) { + value = value * 256 + (data[element.payloadStart + index] as number) + } + return value +} diff --git a/apps/backend/src/services/realtime/realtime-event-service.ts b/apps/backend/src/services/realtime/realtime-event-service.ts index 2d23aef4..b7ce85fc 100644 --- a/apps/backend/src/services/realtime/realtime-event-service.ts +++ b/apps/backend/src/services/realtime/realtime-event-service.ts @@ -3,9 +3,13 @@ import type { Response } from 'express' import { WORK_ORDER_STATUS } from '../../domain/work-order-status.js' import { + getLatestMaterialEventAtByOrderIds, + getPendingCancelRequestOrderIds, + getPendingFeedbackOrderIds, getWorkerFinanceRequestById, getWorkerUserById, getWorkOrderById, + listWorkerTimeoutEventsByOrderIds, listWorkerWorkOrderNotes, listWorkerWorkOrderViews, listWorkOrderShares, @@ -23,11 +27,14 @@ import { mapAdminOrderListItem } from '../admin/admin-order-read-helpers.js' import { mapAdminTaskListItem } from '../admin/admin-task-read-helpers.js' import { isWorkOrderSharingFilled, + isWorkerOrderVipEvidencePending, mapFinanceRequest, mapWorkOrderAdmin, mapWorkOrderForHall, mapWorkOrderForWorker, mapWorkerUser, + pickWorkerRepresentativeShare, + resolveWorkerOrderStatusTabs, resolveWorkerPermissions, } from '../worker-platform/mappers.js' import { invalidateWorkerMyOrderCaches } from '../worker-platform/worker-my-order-cache.js' @@ -79,6 +86,24 @@ let nextSubscriberId = 1 let heartbeatTimer: NodeJS.Timeout | null = null let publishQueue: Promise = Promise.resolve() +/** + * 单次补发的事件上限,与 realtime_events 表保留的条数一致 + * (见 realtime-event-repo 的清理策略:只保留最近 500 条)。 + */ +export const REALTIME_REPLAY_EVENT_LIMIT = 500 + +/** + * 是否应该补发历史事件。 + * + * 只有断线重连带上了 Last-Event-ID(游标 > 0)才补发。新连接(游标 = 0)**不补发**: + * 补发的每一条都携带「事件发生那一刻」的工单快照,直接打进刚由列表接口加载好的缓存, + * 会把已涨价/已拼满的订单覆盖回旧值(表现为「进大厅显示旧单价旧进度,点一次刷新才对」)。 + */ +export function shouldReplayRealtimeEvents(lastEventId: number): boolean { + const normalized = Number(lastEventId) + return Number.isFinite(normalized) && normalized > 0 +} + /** 建立后台实时事件流,事件快照用于前端定点更新缓存。 */ export function openAdminRealtimeStream(response: Response, lastEventId = 0) { return openRealtimeStream(response, null, lastEventId) @@ -249,6 +274,9 @@ async function publishWorkOrderSnapshots(input: { hallChanged?: boolean }) { const workOrderId = Math.max(0, Number(input.workOrderId) || 0) + // 调用方显式给出的受影响打手必须同步失效:下面的快照生成要先查库, + // 若等都查完再失效,紧接着到达的「我的订单」请求会命中旧缓存(30s TTL)。 + invalidateWorkerMyOrderCaches(input.workerIds || []) const workOrder = await getWorkOrderById(workOrderId) if (!workOrder) { // 工单已不存在(删除):调用方传入的打手集合仍需失效缓存, @@ -338,33 +366,124 @@ async function publishWorkOrderSnapshots(input: { }) } + // 资料更新时间只与工单有关,提到循环外只查一次(参与人数多时省掉 N 次查询)。 + const materialEventAt = + (await getLatestMaterialEventAtByOrderIds([workOrderId])).get(workOrderId) || '' + for (const workerId of new Set( (input.workerIds || []).map((item) => Number(item)).filter(Boolean), )) { const worker = await getWorkerUserById(workerId) if (!worker) continue - const [notes, views] = await Promise.all([ - listWorkerWorkOrderNotes(workerId, [workOrderId]), - listWorkerWorkOrderViews(workerId, [workOrderId]), - ]) - const note = notes.find((item) => Number(item.work_order_id) === workOrderId)?.note || '' - const viewedAt = - views.find((item) => Number(item.work_order_id) === workOrderId)?.viewed_at || null - const share = shares.find((item) => Number(item.worker_id) === workerId) + + // 同一打手在同一工单可能有多份份额(多次拼单),必须按「全部份额」还原这一行: + // 旧实现用 shares.find(...) 只取最早一条,多份额时会把可见性、个人状态、金额都算错, + // 还会让实时补丁把行从错误的页签删掉(打手端偶发「页签里没有数据」)。 const isAssignedWorker = Number(workOrder.assigned_worker_id || 0) === workerId - const workerOrderVisible = isAssignedWorker || Boolean(share && share.status !== 'cancelled') + const workerShares = shares.filter((item) => Number(item.worker_id) === workerId) + const activeWorkerShares = workerShares.filter((item) => item.status !== 'cancelled') + const representativeShare = pickWorkerRepresentativeShare( + activeWorkerShares.length > 0 ? activeWorkerShares : workerShares, + ) + const cancelledShare = workerShares + .filter((item) => item.status === 'cancelled') + .sort((left, right) => Number(right.id) - Number(left.id))[0] + const isOwnerCurrently = isAssignedWorker || activeWorkerShares.length > 0 + const workerOrderVisible = isOwnerCurrently + + // 只有「已不持有该单」时才需要判断超时归属,避免给正常在办的单多打一次查询。 + const needsTimeoutCheck = !isOwnerCurrently + const [notes, views, pendingCancelOrderIds, pendingFeedbackOrderIds, timeoutEventsByOrderId] = + await Promise.all([ + listWorkerWorkOrderNotes(workerId, [workOrderId]), + listWorkerWorkOrderViews(workerId, [workOrderId]), + getPendingCancelRequestOrderIds(workerId, [workOrderId]), + getPendingFeedbackOrderIds(workerId, [workOrderId]), + needsTimeoutCheck + ? listWorkerTimeoutEventsByOrderIds(workerId, [workOrderId]) + : Promise.resolve(new Map()), + ]) + const note = notes.find((item) => Number(item.work_order_id) === workOrderId)?.note || '' + const view = views.find((item) => Number(item.work_order_id) === workOrderId) + const viewedAt = view?.viewed_at || null + const timeoutEvent = timeoutEventsByOrderId.get(workOrderId) + const isTimedOutForMe = + !isOwnerCurrently && Boolean(timeoutEvent || (cancelledShare && workOrder.sharing_enabled)) + + const timeoutNotice = isTimedOutForMe + ? { + isTimedOut: true, + timedOutAt: + timeoutEvent?.createdAt || + (workOrder.deadline_at ? String(workOrder.deadline_at) : null) || + workOrder.updated_at, + warningMessage: + (workOrder.assigned_worker_id != null && + Number(workOrder.assigned_worker_id) !== workerId) || + ['in_progress', 'submitted', 'pending_acceptance', 'accepted'].includes( + workOrder.status, + ) + ? '该订单已超时返回大厅并被其他打手接手,请勿继续在游戏内赠送!若私自继续赠送,造成的财产损失平台概不负责。' + : '该订单已超时退回抢单大厅,您已失去接单资格,请勿在游戏内赠送!若私自继续赠送,造成的财产损失平台概不负责。', + isTakenByOther: + (workOrder.assigned_worker_id != null && + Number(workOrder.assigned_worker_id) !== workerId) || + ['in_progress', 'submitted', 'pending_acceptance', 'accepted'].includes( + workOrder.status, + ), + } + : undefined + + const hasPendingHandling = + pendingCancelOrderIds.has(workOrderId) || pendingFeedbackOrderIds.has(workOrderId) + const materialSeenAt = view?.material_seen_at || '' + const vipEvidencePending = isWorkerOrderVipEvidencePending(workOrder) + publishRealtimeEvent({ type: 'work_order.changed', entityId: workOrderId, scopes: ['worker_my_orders'], operation: workerOrderVisible ? 'upsert' : 'remove', - data: mapWorkOrderForWorker( - workOrder, - resolveWorkerPermissions(worker), - share, - note, - viewedAt, - ), + data: { + ...mapWorkOrderForWorker( + workOrder, + resolveWorkerPermissions(worker), + representativeShare, + note, + viewedAt, + { + isTimedOutForMe, + ...(timeoutNotice ? { timeoutNotice } : {}), + // 与列表一致:只带未取消份额,按 id DESC(参与先后倒序) + myShares: [...activeWorkerShares].sort( + (left, right) => Number(right.id) - Number(left.id), + ), + }, + ), + /** + * 这张单在该打手「我的订单」里属于哪些状态页签。 + * 多份额工单可同时属于代练中/待验收,单个 status 字段表达不了,前端据此判归属。 + */ + workerStatuses: resolveWorkerOrderStatusTabs({ + workOrderStatus: workOrder.status, + isAssignedWorker, + shares: workerShares, + hasPendingHandling, + hasTimeoutForMe: Boolean(timeoutEvent), + vipEvidencePending, + }), + problemMarked: + !isTimedOutForMe && + (workOrder.status === WORK_ORDER_STATUS.PROBLEM || + activeWorkerShares.some((share) => Boolean(share.problem_marked_at))), + 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], }) } @@ -429,7 +548,10 @@ function openRealtimeStream(response: Response, workerId: number | null, lastEve connectedAt: new Date().toISOString(), lastEventId: Math.max(0, Number(lastEventId) || 0), }) - void replayEvents(subscriber, Math.max(0, Number(lastEventId) || 0)) + // 新连接不补发历史事件,只有断线重连(带游标)才回放缺失的增量。 + if (shouldReplayRealtimeEvents(lastEventId)) { + void replayEvents(subscriber, Number(lastEventId)) + } ensureHeartbeat() response.on('close', () => { @@ -439,7 +561,16 @@ function openRealtimeStream(response: Response, workerId: number | null, lastEve } async function replayEvents(subscriber: RealtimeSubscriber, lastEventId: number) { - const events = await listRealtimeEventsAfter(lastEventId) + const events = await listRealtimeEventsAfter(lastEventId, REALTIME_REPLAY_EVENT_LIMIT) + // 补发窗口被拿满 = 更早的事件已经从事件表里滚掉(断线太久)。 + // 这时逐条套用旧快照只会把列表写回旧值,直接让客户端整体重拉一次。 + if (events.length >= REALTIME_REPLAY_EVENT_LIMIT) { + writeEvent(subscriber, 'resync', { + headEventId: Number(events[events.length - 1]?.id || 0), + reason: 'replay_window_overflow', + }) + return + } for (const row of events) { const event = mapStoredEvent(row) if (event.eventId <= lastEventId || !isEventVisibleToSubscriber(event, subscriber)) continue diff --git a/apps/backend/src/services/realtime/realtime-replay.test.ts b/apps/backend/src/services/realtime/realtime-replay.test.ts new file mode 100644 index 00000000..de60e5ee --- /dev/null +++ b/apps/backend/src/services/realtime/realtime-replay.test.ts @@ -0,0 +1,56 @@ +import assert from 'node:assert/strict' +import fs from 'node:fs' +import test from 'node:test' + +import { shouldReplayRealtimeEvents } from './realtime-event-service.js' + +/** + * 回归:整页刷新后大厅列表重复、可抢订单数越刷越多。 + * + * 根因是新连接(lastEventId=0)也会补发最近 500 条历史事件。大厅 upsert 广播带的是 + * 当时的工单快照,被重新打进刚加载的列表后,已抢走/已出队的订单会复活并被插到第 1 页, + * 前端还会按「插入行」把 pagination.total 加一。 + */ +test('新连接(lastEventId=0)不补发历史事件,避免旧大厅快照复活订单', () => { + assert.equal(shouldReplayRealtimeEvents(0), false) + assert.equal(shouldReplayRealtimeEvents(Number.NaN), false) + assert.equal(shouldReplayRealtimeEvents(-1), false) +}) + +test('断线重连(带 Last-Event-ID)才补发事件', () => { + assert.equal(shouldReplayRealtimeEvents(1), true) + assert.equal(shouldReplayRealtimeEvents(3993659), true) +}) + +test('补发窗口取最新的一段,而不是最旧的一段', () => { + const source = fs.readFileSync( + new URL('../../repositories/realtime-event-repo.ts', import.meta.url), + 'utf8', + ) + const block = source.slice( + source.indexOf('export async function listRealtimeEventsAfter('), + source.indexOf('\n}', source.indexOf('export async function listRealtimeEventsAfter(')), + ) + assert.ok(block.length > 0, 'listRealtimeEventsAfter 片段必须存在') + assert.ok( + /ORDER BY id DESC\s+LIMIT \$2\s+\)\s*replay_window\s+ORDER BY id ASC/s.test(block), + '补发必须「内层 id DESC 取最新 N 条、外层 id ASC 回放」;否则断线久了窗口会停在旧事件上', + ) +}) + +test('补发窗口溢出时改为让客户端整体重拉', () => { + const source = fs.readFileSync(new URL('./realtime-event-service.ts', import.meta.url), 'utf8') + const replayBlock = source.slice( + source.indexOf('async function replayEvents('), + source.indexOf('function mapStoredEvent('), + ) + assert.ok(replayBlock.length > 0, 'replayEvents 片段必须存在') + assert.ok( + replayBlock.includes('events.length >= REALTIME_REPLAY_EVENT_LIMIT'), + '补发窗口被拿满时必须判定为溢出,不能继续逐条套用旧快照', + ) + assert.ok( + replayBlock.includes("'resync'"), + '溢出时要给客户端发 resync,让列表整体重拉(接口才是权威数据源)', + ) +}) diff --git a/apps/backend/src/services/worker-platform/admin-mock-work-order-service.ts b/apps/backend/src/services/worker-platform/admin-mock-work-order-service.ts index 1a48e5ec..25fc980d 100644 --- a/apps/backend/src/services/worker-platform/admin-mock-work-order-service.ts +++ b/apps/backend/src/services/worker-platform/admin-mock-work-order-service.ts @@ -9,6 +9,7 @@ import { createWorkOrderEvent, getWorkCategoryById, listWorkOrdersByPlatformOrderId, + listWorkProductRules, } from '../../repositories/worker-platform/index.js' import type { JsonObject } from '../../types/json.js' import { createHttpError } from '../../utils/http.js' @@ -27,6 +28,7 @@ import { normalizeRequirementFields, } from './mappers.js' import { rememberAdminMockCustomShop } from './admin-mock-custom-shop-service.js' +import { resolveMockWorkOrderProductRule } from './mock-work-order-rule-match.js' import { ensureWorkerPlatformDefaults } from './worker-platform-defaults.js' import { normalizeWorkOrderTimeoutPolicy } from './work-order-timeout-policy.js' @@ -64,6 +66,14 @@ export async function createAdminMockWorkOrder(payload: JsonObject = {}) { const materialComplete = payload.materialComplete === true || payload.materialComplete === 'true' const fields = normalizeRequirementFields(payload.fields) const { platformOrderId, productName, shop } = input + // Mock 工单同样要挂到接单模板上:后台「同步模板价格」按 product_rule_id 精确匹配, + // 不绑定的话改模板价永远命中不到 Mock 单(真实单在 sync-worker-orders-service 已绑定)。 + // 这里只做商品名文本匹配,命中多条同分视为歧义、宁可不绑定。 + const productRuleMatch = resolveMockWorkOrderProductRule({ + rules: await listWorkProductRules({ enabled: true }), + productName, + categoryId, + }) // 订单号全局唯一:与自动同步的真实工单也不能撞号,否则打手按单号回填资料会命中多条。 const existingOrders = await listWorkOrdersByPlatformOrderId(platformOrderId) if (existingOrders.length > 0) { @@ -104,6 +114,7 @@ export async function createAdminMockWorkOrder(payload: JsonObject = {}) { } const workOrder = await createWorkOrder({ workOrderNo: generateWorkOrderNo(), + productRuleId: productRuleMatch.rule ? Number(productRuleMatch.rule.id) : null, platformOrderId, productName, categoryId, @@ -143,6 +154,9 @@ export async function createAdminMockWorkOrder(payload: JsonObject = {}) { rewardAmount, sellerId: String(shop.sellerId || '').trim(), shopName: String(shop.shopName || '').trim(), + productRuleId: productRuleMatch.rule ? Number(productRuleMatch.rule.id) : 0, + productRuleKey: productRuleMatch.rule ? String(productRuleMatch.rule.rule_key || '') : '', + productRuleMatch: productRuleMatch.reason, }), now, }) diff --git a/apps/backend/src/services/worker-platform/admin-work-order-acceptance-service.ts b/apps/backend/src/services/worker-platform/admin-work-order-acceptance-service.ts index 19cd2373..57dd7989 100644 --- a/apps/backend/src/services/worker-platform/admin-work-order-acceptance-service.ts +++ b/apps/backend/src/services/worker-platform/admin-work-order-acceptance-service.ts @@ -5,13 +5,17 @@ import { cancelWorkOrderShare, cancelWorkOrderSharing, countWorkOrderPendingSharingSubmissions, + reduceWorkOrderShareQuantity, updateWorkOrder, } from '../../repositories/worker-platform/index.js' import type { JsonObject } from '../../types/json.js' import { createHttpError } from '../../utils/http.js' import { getWorkerFinanceConfig } from './worker-finance-config-service.js' import { nowIso } from '../../utils/time.js' -import { resolveAdminNotificationEntity } from '../admin/admin-notification-service.js' +import { + createWorkOrderFullyAcceptedAdminNotification, + resolveAdminNotificationEntity, +} from '../admin/admin-notification-service.js' import { publishWorkerWalletRealtimeChange, publishWorkOrderRealtimeChange, @@ -80,6 +84,11 @@ export async function acceptAdminWorkOrder(workOrderId: number | string, actorNa }) } await resolveAdminNotificationEntity('work_order', Number(workOrder.id), 'accepted') + await createWorkOrderFullyAcceptedAdminNotification({ + workOrderId: Number(workOrder.id), + workOrderNo: workOrder.work_order_no, + productName: workOrder.product_name, + }) const workerIds = await resolveWorkOrderRealtimeWorkerIds(workOrder) publishWorkOrderRealtimeChange({ workOrderId: Number(workOrder.id), workerIds }) for (const workerId of workerIds) publishWorkerWalletRealtimeChange(workerId) @@ -119,6 +128,11 @@ export async function acceptAdminWorkOrderShare( if (workerId > 0) publishWorkerWalletRealtimeChange(workerId) if (result.order.status === WORK_ORDER_STATUS.ACCEPTED) { await resolveAdminNotificationEntity('work_order', Number(workOrderId), 'accepted') + await createWorkOrderFullyAcceptedAdminNotification({ + workOrderId: Number(workOrderId), + workOrderNo: result.order.work_order_no, + productName: result.order.product_name, + }) } return { order: mapWorkOrderAdmin(result.order), share: mapWorkOrderShare(result.share) } } @@ -176,6 +190,70 @@ export async function cancelAdminWorkOrderShare( } } +/** + * 后台按份数撤销某个打手的部分份额。 + * + * 减少的份数会随「大厅余量 = 总份数 − Σ有效份额数量」自动回到大厅;押金保持原冻结额, + * 母单状态与验收资料都不动(之后仍可对该份额「单独验收」)。 + */ +export async function reduceAdminWorkOrderShareQuantity( + workOrderId: number | string, + shareId: number | string, + payload: JsonObject = {}, + actorName = '', +) { + const reduceQuantity = Math.floor(Number(payload.reduceQuantity ?? payload.quantity ?? 0)) + if (!Number.isFinite(reduceQuantity) || reduceQuantity <= 0) { + throw createHttpError('请填写要撤掉的份数', { + statusCode: 400, + errorCode: 'work_order_sharing_share_reduce_quantity_required', + }) + } + + const result = await reduceWorkOrderShareQuantity({ + workOrderId: Number(workOrderId), + shareId: Number(shareId), + reduceQuantity, + note: String(payload.note || payload.reason || '').trim(), + actorName, + now: nowIso(), + }) + if (result.failureReason === 'work_order_not_reducible') { + throw createHttpError('当前拼单状态不能撤销份额', { + statusCode: 409, + errorCode: 'work_order_sharing_share_reduce_status_invalid', + }) + } + if (result.failureReason === 'share_not_found') { + throw createHttpError('未找到该拼单参与记录', { + statusCode: 404, + errorCode: 'work_order_sharing_share_not_found', + }) + } + if (result.failureReason === 'share_status_not_reducible' || !result.share || !result.order) { + throw createHttpError('只有已参与或已提交验收的拼单份额可以撤销份数', { + statusCode: 409, + errorCode: 'work_order_sharing_share_reduce_share_status_invalid', + }) + } + if (result.failureReason === 'share_quantity_invalid') { + throw createHttpError('撤掉的份数必须在 1 到「当前份数 − 1」之间;要全部撤销请用「单人撤单」', { + statusCode: 409, + errorCode: 'work_order_sharing_share_reduce_quantity_invalid', + }) + } + + publishWorkOrderRealtimeChange({ + workOrderId: Number(workOrderId), + workerIds: await resolveWorkOrderRealtimeWorkerIds(result.order), + hallChanged: true, + }) + return { + order: mapWorkOrderAdmin(result.order), + share: mapWorkOrderShare(result.share), + } +} + export async function cancelAdminWorkOrderSharing( workOrderId: number | string, payload: JsonObject = {}, diff --git a/apps/backend/src/services/worker-platform/admin-work-order-management-service.ts b/apps/backend/src/services/worker-platform/admin-work-order-management-service.ts index e2831c88..70014207 100644 --- a/apps/backend/src/services/worker-platform/admin-work-order-management-service.ts +++ b/apps/backend/src/services/worker-platform/admin-work-order-management-service.ts @@ -2,8 +2,10 @@ import { WORK_ORDER_STATUS } from '../../domain/work-order-status.js' import { createWorkOrderEvent, deleteWorkOrder, + disposeWorkOrderShareProblem, listWorkOrderShares, listWorkOrders, + markWorkOrderShareProblem, publishUnassignedWorkOrder, reopenCancelledWorkOrder, resolveProblemWorkOrder, @@ -22,6 +24,7 @@ import { } from './publish-voucher-service.js' import { mapWorkOrderAdmin, + mapWorkOrderShare, normalizeAmountFen, normalizeInteger, normalizeOptionalId, @@ -436,3 +439,118 @@ export async function resolveAdminProblemWorkOrder( }) return { order: mapWorkOrderAdmin(updated) } } + +/** + * 后台「拼单明细」按份额标记问题单。 + * + * 只影响该份额持有人:母单状态/备注不动,同工单其他打手无感。 + * 备注选填;重复标记视为更新备注(幂等)。 + */ +export async function markAdminWorkOrderShareProblem( + workOrderId: number | string, + shareId: number | string, + payload: JsonObject = {}, + actorName = '', +) { + const note = String(payload.note || payload.problemNote || '').trim() + const result = await markWorkOrderShareProblem({ + workOrderId: Number(workOrderId), + shareId: Number(shareId), + note, + now: nowIso(), + actorName, + }) + if (result.failureReason === 'work_order_not_markable') { + throw createHttpError('当前订单状态不能按份额标记问题单', { + statusCode: 409, + errorCode: 'work_order_share_problem_status_invalid', + }) + } + if (result.failureReason === 'share_not_found' || !result.share) { + throw createHttpError('未找到该拼单份额', { + statusCode: 404, + errorCode: 'work_order_share_problem_share_not_found', + }) + } + if (result.failureReason === 'share_status_not_markable') { + throw createHttpError('已撤单的份额不能标记问题单', { + statusCode: 409, + errorCode: 'work_order_share_problem_share_status_invalid', + }) + } + // 只通知该份额持有人刷新「我的订单」;后台列表由快照广播覆盖。 + publishWorkOrderRealtimeChange({ + workOrderId: Number(workOrderId), + workerIds: [Number(result.share.worker_id || 0)].filter((workerId) => workerId > 0), + }) + return { + order: result.order ? mapWorkOrderAdmin(result.order) : null, + share: mapWorkOrderShare(result.share), + } +} + +/** + * 后台「拼单明细」处置份额级问题单。 + * + * 处置方式与母单处置完全一致(退回打手继续代练 / 释放押金回大厅 / + * 取消退押金 / 取消扣押金),但只作用于这一份份额:母单状态/备注不动, + * 同工单其他打手无感。备注必填,与母单处置同口径。 + */ +export async function disposeAdminWorkOrderShareProblem( + workOrderId: number | string, + shareId: number | string, + payload: JsonObject = {}, + actorName = '', +) { + const action = normalizeProblemResolutionAction(payload.action) + const note = String(payload.note || '').trim() + if (!note) { + throw createHttpError('请填写处置备注', { + statusCode: 400, + errorCode: 'work_order_share_problem_disposal_note_required', + }) + } + + const result = await disposeWorkOrderShareProblem({ + workOrderId: Number(workOrderId), + shareId: Number(shareId), + action, + note, + now: nowIso(), + actorName, + }) + if (result.failureReason === 'work_order_not_disposable') { + throw createHttpError('当前订单状态不能处置份额问题单', { + statusCode: 409, + errorCode: 'work_order_share_problem_disposal_status_invalid', + }) + } + if (result.failureReason === 'share_not_found' || !result.share) { + throw createHttpError('未找到该拼单份额', { + statusCode: 404, + errorCode: 'work_order_share_problem_share_not_found', + }) + } + if (result.failureReason === 'share_not_marked') { + throw createHttpError('该份额当前不是问题单', { + statusCode: 409, + errorCode: 'work_order_share_problem_not_marked', + }) + } + if (result.failureReason === 'share_status_not_disposable') { + throw createHttpError('已验收或已取消的份额不能处置,请走售后或单人撤单', { + statusCode: 409, + errorCode: 'work_order_share_problem_share_status_invalid', + }) + } + + publishWorkOrderRealtimeChange({ + workOrderId: Number(workOrderId), + workerIds: [Number(result.share.worker_id || 0)].filter((workerId) => workerId > 0), + }) + return { + order: result.order ? mapWorkOrderAdmin(result.order) : null, + share: mapWorkOrderShare(result.share), + depositAmount: result.depositAmount, + } +} diff --git a/apps/backend/src/services/worker-platform/index.ts b/apps/backend/src/services/worker-platform/index.ts index a24ef7ef..39a70d4f 100644 --- a/apps/backend/src/services/worker-platform/index.ts +++ b/apps/backend/src/services/worker-platform/index.ts @@ -54,8 +54,10 @@ export { acceptAdminWorkOrders, cancelAdminWorkOrderShare, cancelAdminWorkOrderSharing, + reduceAdminWorkOrderShareQuantity, } from './admin-work-order-acceptance-service.js' export * from './worker-product-match-config-service.js' export * from './worker-announcement-config-service.js' export * from './worker-hall-config-service.js' +export * from './worker-acceptance-config-service.js' export * from './sync-worker-orders-service.js' diff --git a/apps/backend/src/services/worker-platform/mappers.ts b/apps/backend/src/services/worker-platform/mappers.ts index 3ac8325d..697729fe 100644 --- a/apps/backend/src/services/worker-platform/mappers.ts +++ b/apps/backend/src/services/worker-platform/mappers.ts @@ -83,6 +83,7 @@ export { } from './worker-finance-mappers.js' export { + isWorkerOrderVipEvidencePending, mapCollectLookupOrder, mapWorkCategory, mapWorkOrderAdmin, @@ -92,6 +93,8 @@ export { mapWorkOrderPublic, mapWorkOrderShare, mapWorkOrderSharingWorkers, + pickWorkerRepresentativeShare, + resolveWorkerOrderStatusTabs, resolveWorkOrderOperationalStatus, resolveWorkerShareProductName, resolveWorkerShareStatus, diff --git a/apps/backend/src/services/worker-platform/mock-work-order-rule-match.test.ts b/apps/backend/src/services/worker-platform/mock-work-order-rule-match.test.ts new file mode 100644 index 00000000..5a44b115 --- /dev/null +++ b/apps/backend/src/services/worker-platform/mock-work-order-rule-match.test.ts @@ -0,0 +1,99 @@ +import assert from 'node:assert/strict' +import test from 'node:test' + +import type { WorkProductRuleRow } from '../../repositories/worker-platform/types.js' +import { resolveMockWorkOrderProductRule } from './mock-work-order-rule-match.js' + +function rule( + input: Partial & { id: number; product_name: string }, +): WorkProductRuleRow { + return { + id: input.id, + rule_key: input.rule_key ?? `rule-${input.id}`, + product_name: input.product_name, + category_id: input.category_id ?? null, + enabled: input.enabled ?? true, + sort_order: input.sort_order ?? 100, + unit_price_fen: input.unit_price_fen ?? 0, + reward_amount: input.reward_amount ?? 0, + sharing_enabled: input.sharing_enabled ?? false, + sharing_total_quantity: input.sharing_total_quantity ?? 1, + sharing_unit_reward: input.sharing_unit_reward ?? 0, + sharing_auto_from_order: input.sharing_auto_from_order ?? false, + timeout_minutes: input.timeout_minutes ?? 0, + timeout_policy: input.timeout_policy ?? 'reopen', + } as WorkProductRuleRow +} + +test('Mock 模板匹配:商品名互相包含即命中(忽略空白)', () => { + const rules = [rule({ id: 9, product_name: '指挥官密钥', category_id: 1, unit_price_fen: 1100 })] + const result = resolveMockWorkOrderProductRule({ + rules, + productName: '指挥官密钥10个', + categoryId: 1, + }) + assert.equal(result.reason, 'matched') + assert.equal(result.rule?.id, 9) + + const withSpaces = resolveMockWorkOrderProductRule({ + rules, + productName: ' 指挥官 密钥 3个 ', + categoryId: 1, + }) + assert.equal(withSpaces.rule?.id, 9) +}) + +test('Mock 模板匹配:同分类优先,其次更具体的商品名', () => { + const rules = [ + rule({ id: 1, product_name: '指挥官密钥', category_id: 5, sort_order: 1 }), + rule({ id: 2, product_name: '指挥官密钥', category_id: 1, sort_order: 9 }), + rule({ id: 3, product_name: '指挥官密钥-皮肤链接', category_id: 1, sort_order: 9 }), + ] + const base = resolveMockWorkOrderProductRule({ + rules, + productName: '指挥官密钥10个', + categoryId: 1, + }) + assert.equal(base.rule?.id, 2, '同分类的模板必须优先于其他分类') + + const specific = resolveMockWorkOrderProductRule({ + rules, + productName: '指挥官密钥-皮肤链接1个', + categoryId: 1, + }) + assert.equal(specific.rule?.id, 3, '更长的商品名(更具体)优先') +}) + +test('Mock 模板匹配:歧义或未命中不绑定', () => { + const ambiguous = resolveMockWorkOrderProductRule({ + rules: [ + rule({ id: 1, product_name: '指挥官密钥', category_id: 1 }), + rule({ id: 2, product_name: '指挥官密钥', category_id: 1 }), + ], + productName: '指挥官密钥10个', + categoryId: 1, + }) + assert.equal(ambiguous.rule, null) + assert.equal(ambiguous.reason, 'ambiguous') + + const unmatched = resolveMockWorkOrderProductRule({ + rules: [rule({ id: 9, product_name: '指挥官密钥', category_id: 1 })], + productName: '指挥官9999', + categoryId: 1, + }) + assert.equal(unmatched.rule, null) + assert.equal(unmatched.reason, 'unmatched') + + const empty = resolveMockWorkOrderProductRule({ rules: [], productName: ' ', categoryId: 1 }) + assert.equal(empty.reason, 'unmatched') +}) + +test('Mock 模板匹配:停用的模板不参与匹配', () => { + const result = resolveMockWorkOrderProductRule({ + rules: [rule({ id: 9, product_name: '指挥官密钥', category_id: 1, enabled: false })], + productName: '指挥官密钥10个', + categoryId: 1, + }) + assert.equal(result.rule, null) + assert.equal(result.reason, 'unmatched') +}) diff --git a/apps/backend/src/services/worker-platform/mock-work-order-rule-match.ts b/apps/backend/src/services/worker-platform/mock-work-order-rule-match.ts new file mode 100644 index 00000000..12cb44a7 --- /dev/null +++ b/apps/backend/src/services/worker-platform/mock-work-order-rule-match.ts @@ -0,0 +1,76 @@ +import type { WorkProductRuleRow } from '../../repositories/worker-platform/types.js' + +/** + * Mock 工单的接单模板匹配(文本匹配)。 + * + * 快手真实单走 `resolveMatchingProductRule`(依赖 sellerId/itemId/skuId 等平台字段), + * 而本地 Mock 单只有商品名和分类,所以这里用**商品名文本**做确定性匹配, + * 让 Mock 单也能挂上 `product_rule_id`,从而被「同步模板价格」命中。 + * + * 规则: + * - 只考虑启用中的模板; + * - 模板商品名与 Mock 商品名互相包含即视为命中(忽略大小写/空白); + * - 同分类优先,其次更长的商品名(更具体)优先,最后按 sort_order; + * - 同分类且同长度的多条模板属于歧义,宁可不绑定,避免把 Mock 单挂到错误模板上。 + */ +export type MockWorkOrderRuleMatchReason = 'matched' | 'unmatched' | 'ambiguous' + +export type MockWorkOrderRuleMatch = { + rule: WorkProductRuleRow | null + reason: MockWorkOrderRuleMatchReason + candidates: string[] +} + +export function normalizeMockProductMatchText(value: unknown): string { + return String(value || '') + .replace(/[\s\u3000]+/g, '') + .trim() + .toLowerCase() +} + +export function resolveMockWorkOrderProductRule(input: { + rules: WorkProductRuleRow[] + productName: string + categoryId?: number | null +}): MockWorkOrderRuleMatch { + const productName = normalizeMockProductMatchText(input.productName) + if (!productName) return { rule: null, reason: 'unmatched', candidates: [] } + const categoryId = Number(input.categoryId || 0) + + const scored = input.rules + .filter((rule) => rule.enabled !== false) + .map((rule) => { + const ruleName = normalizeMockProductMatchText(rule.product_name) + if (!ruleName) return null + if (!productName.includes(ruleName) && !ruleName.includes(productName)) return null + return { + rule, + categoryMatch: categoryId > 0 && Number(rule.category_id || 0) === categoryId ? 1 : 0, + nameLength: ruleName.length, + sortOrder: Number(rule.sort_order || 100), + } + }) + .filter((item): item is NonNullable => item !== null) + + if (scored.length === 0) return { rule: null, reason: 'unmatched', candidates: [] } + + scored.sort( + (a, b) => + b.categoryMatch - a.categoryMatch || + b.nameLength - a.nameLength || + a.sortOrder - b.sortOrder || + Number(a.rule.id) - Number(b.rule.id), + ) + const [best] = scored + if (!best) return { rule: null, reason: 'unmatched', candidates: [] } + const candidates = scored.map((item) => String(item.rule.rule_key || item.rule.id)) + + const equallySpecific = scored.filter( + (item) => item.categoryMatch === best.categoryMatch && item.nameLength === best.nameLength, + ) + if (new Set(equallySpecific.map((item) => Number(item.rule.id))).size > 1) { + return { rule: null, reason: 'ambiguous', candidates } + } + + return { rule: best.rule, reason: 'matched', candidates } +} diff --git a/apps/backend/src/services/worker-platform/work-order-display-mappers.test.ts b/apps/backend/src/services/worker-platform/work-order-display-mappers.test.ts new file mode 100644 index 00000000..b592aa2c --- /dev/null +++ b/apps/backend/src/services/worker-platform/work-order-display-mappers.test.ts @@ -0,0 +1,250 @@ +import assert from 'node:assert/strict' +import test from 'node:test' + +import type { WorkOrderShareRow } from '../../repositories/worker-platform/types.js' +import { + isWorkerOrderVipEvidencePending, + pickWorkerRepresentativeShare, + resolveWorkerOrderStatusTabs, + summarizeWorkerShares, +} from './work-order-display-mappers.js' + +function share(overrides: Record): WorkOrderShareRow { + return { + id: 1, + work_order_id: 1, + worker_id: 1, + quantity: 1, + unit_reward: 10, + share_reward: 10, + share_deposit: 0, + status: 'joined', + deadline_at: null, + timeout_minutes: 0, + created_at: '2026-10-23T10:00:00.000Z', + ...overrides, + } as unknown as WorkOrderShareRow +} + +test('同一工单多份份额:数量/奖励/押金按份额求和,已取消份额不计入金额', () => { + const summary = summarizeWorkerShares([ + share({ + id: 1, + quantity: 2, + share_reward: 20, + share_deposit: 5, + status: 'joined', + created_at: '2026-10-23T10:00:00.000Z', + deadline_at: '2026-10-23T12:00:00.000Z', + }), + share({ + id: 2, + quantity: 3, + share_reward: 30, + share_deposit: 5, + status: 'joined', + created_at: '2026-10-23T10:10:00.000Z', + deadline_at: '2026-10-23T11:00:00.000Z', + }), + share({ + id: 3, + quantity: 9, + share_reward: 90, + share_deposit: 99, + status: 'cancelled', + created_at: '2026-10-23T09:00:00.000Z', + }), + ]) + + assert.equal(summary.quantity, 5) + assert.equal(summary.rewardAmount, 50) + assert.equal(summary.depositAmount, 10) + // 有有效份额时,接单时间取第一份有效份额,不取更早的已取消记录。 + assert.equal(summary.firstJoinedAt, '2026-10-23T10:00:00.000Z') + // 父行倒计时取未完成份额中最早的截止时间。 + assert.equal(summary.earliestActiveDeadlineAt, '2026-10-23T11:00:00.000Z') +}) + +test('历史份额没有 deadline_at 时,用份额自己的 timeout_minutes 兜底', () => { + const summary = summarizeWorkerShares([ + share({ + id: 1, + quantity: 2, + share_reward: 20, + status: 'joined', + created_at: '2026-10-23T10:00:00.000Z', + deadline_at: null, + timeout_minutes: 30, + }), + ]) + + assert.equal(summary.earliestActiveDeadlineAt, '2026-10-23T10:30:00.000Z') +}) + +test('只剩已取消份额时,金额归零但保留最早参与时间', () => { + const summary = summarizeWorkerShares([ + share({ + id: 1, + quantity: 2, + share_reward: 20, + status: 'cancelled', + created_at: '2026-10-23T09:00:00.000Z', + }), + share({ + id: 2, + quantity: 3, + share_reward: 30, + status: 'cancelled', + created_at: '2026-10-23T09:30:00.000Z', + }), + ]) + + assert.equal(summary.quantity, 0) + assert.equal(summary.rewardAmount, 0) + assert.equal(summary.depositAmount, 0) + assert.equal(summary.firstJoinedAt, '2026-10-23T09:00:00.000Z') + assert.equal(summary.earliestActiveDeadlineAt, null) +}) + +test('代表份额与列表 LATERAL 同序:joined > submitted > accepted > cancelled,再按创建时间/id 倒序', () => { + const shares = [ + share({ id: 1, status: 'cancelled', created_at: '2026-10-23T09:00:00.000Z' }), + share({ id: 2, status: 'submitted', created_at: '2026-10-23T09:30:00.000Z' }), + share({ id: 3, status: 'joined', created_at: '2026-10-23T10:00:00.000Z' }), + ] + assert.equal(pickWorkerRepresentativeShare(shares)?.id, 3) + + // 同状态取更晚创建的一份(旧实现取的是「最早一条」,多份额时必然挑错) + const sameStatus = [ + share({ id: 10, status: 'submitted', created_at: '2026-10-23T09:00:00.000Z' }), + share({ id: 11, status: 'submitted', created_at: '2026-10-23T10:00:00.000Z' }), + share({ id: 12, status: 'submitted', created_at: '2026-10-23T10:00:00.000Z' }), + ] + assert.equal(pickWorkerRepresentativeShare(sameStatus)?.id, 12) +}) + +test('多份额工单(一份进行中 + 一份待验收)必须同时属于两个页签', () => { + const tabs = resolveWorkerOrderStatusTabs({ + workOrderStatus: 'in_progress', + isAssignedWorker: false, + shares: [share({ id: 1, status: 'joined' }), share({ id: 2, status: 'submitted' })], + }) + assert.deepEqual(tabs.sort(), ['in_progress', 'pending_acceptance']) +}) + +test('有待审核取消申请/未处理反馈时,进行中份额归问题单,但待验收份额仍留在待验收', () => { + const tabs = resolveWorkerOrderStatusTabs({ + workOrderStatus: 'in_progress', + isAssignedWorker: false, + shares: [share({ id: 1, status: 'joined' }), share({ id: 2, status: 'submitted' })], + hasPendingHandling: true, + }) + assert.deepEqual(tabs.sort(), ['pending_acceptance', 'problem']) +}) + +test('母单进入终态后只按母单状态归类(与 SQL 关闭 anyShare 分支一致)', () => { + const tabs = resolveWorkerOrderStatusTabs({ + workOrderStatus: 'problem', + isAssignedWorker: true, + shares: [share({ id: 1, status: 'submitted' })], + }) + assert.deepEqual(tabs, ['problem']) +}) + +test('份额全部取消时只归已取消,不得混进待验收', () => { + const tabs = resolveWorkerOrderStatusTabs({ + workOrderStatus: 'pending_acceptance', + isAssignedWorker: true, + shares: [share({ id: 1, status: 'cancelled' })], + }) + assert.deepEqual(tabs, ['cancelled']) +}) + +test('没有份额的指派单按母单状态归类;超时退回后归已超时', () => { + assert.deepEqual( + resolveWorkerOrderStatusTabs({ + workOrderStatus: 'pending_acceptance', + isAssignedWorker: true, + shares: [], + }), + ['pending_acceptance'], + ) + assert.deepEqual( + resolveWorkerOrderStatusTabs({ + workOrderStatus: 'pending_acceptance', + isAssignedWorker: false, + shares: [share({ id: 1, status: 'cancelled' })], + hasTimeoutForMe: true, + }), + ['open'], + ) +}) + +test('VIP 免审核待补图:只有已验收 + vip_auto + 待补图 + 未到期才归入该页签', () => { + const now = Date.parse('2026-10-23T10:00:00.000Z') + const base = { + status: 'accepted', + acceptance_json: { + reviewMode: 'vip_auto', + evidenceStatus: 'pending', + evidenceDueAt: '2026-10-24T10:00:00.000Z', + }, + } + assert.equal(isWorkerOrderVipEvidencePending(base, now), true) + assert.equal( + isWorkerOrderVipEvidencePending( + { ...base, acceptance_json: { ...base.acceptance_json, evidenceStatus: 'submitted' } }, + now, + ), + false, + ) + assert.equal( + isWorkerOrderVipEvidencePending( + { + ...base, + acceptance_json: { ...base.acceptance_json, evidenceDueAt: '2026-10-22T10:00:00.000Z' }, + }, + now, + ), + false, + ) + assert.deepEqual( + resolveWorkerOrderStatusTabs({ + workOrderStatus: 'accepted', + isAssignedWorker: true, + shares: [share({ id: 1, status: 'accepted' })], + vipEvidencePending: true, + }).sort(), + ['accepted', 'vip_evidence_pending'], + ) +}) + +test('份额级问题单:被标记的份额只归问题单,同单其他份额仍按原状态归类', () => { + const tabs = resolveWorkerOrderStatusTabs({ + workOrderStatus: 'in_progress', + isAssignedWorker: false, + shares: [ + share({ id: 1, status: 'joined', problem_marked_at: '2026-10-23T11:00:00.000Z' }), + share({ id: 2, status: 'submitted' }), + ], + }) + assert.deepEqual(tabs.sort(), ['pending_acceptance', 'problem']) +}) + +test('份额级问题单:唯一份额被标记后不得再出现在原状态页签', () => { + const tabs = resolveWorkerOrderStatusTabs({ + workOrderStatus: 'in_progress', + isAssignedWorker: true, + shares: [share({ id: 1, status: 'joined', problem_marked_at: '2026-10-23T11:00:00.000Z' })], + }) + assert.deepEqual(tabs, ['problem']) +}) + +test('份额级问题单:已验收份额被标记后只归问题单,母单仍为进行中', () => { + const tabs = resolveWorkerOrderStatusTabs({ + workOrderStatus: 'in_progress', + isAssignedWorker: true, + shares: [share({ id: 1, status: 'accepted', problem_marked_at: '2026-10-23T11:00:00.000Z' })], + }) + assert.deepEqual(tabs, ['problem']) +}) diff --git a/apps/backend/src/services/worker-platform/work-order-display-mappers.ts b/apps/backend/src/services/worker-platform/work-order-display-mappers.ts index 5199250d..f3ae7f6c 100644 --- a/apps/backend/src/services/worker-platform/work-order-display-mappers.ts +++ b/apps/backend/src/services/worker-platform/work-order-display-mappers.ts @@ -428,6 +428,8 @@ export function mapWorkOrderAdmin( export type WorkerOrderMapOptions = { isTimedOutForMe?: boolean + /** 同一打手在该工单的全部有效份额;不传时回退为 myShare 单条 */ + myShares?: WorkOrderShareRow[] timeoutNotice?: | { isTimedOut: boolean @@ -440,6 +442,72 @@ export type WorkerOrderMapOptions = { | undefined } +/** 同一打手在一个工单里的多份份额汇总(列表一行一工单展示用)。 */ +export type WorkerShareSummary = { + quantity: number + rewardAmount: number + depositAmount: number + firstJoinedAt: string | null + /** 未完成份额里最早的截止时间;用于父行倒计时,避免只看到其中一份。 */ + earliestActiveDeadlineAt: string | null +} + +export function summarizeWorkerShares( + shares: WorkOrderShareRow[], + fallbackTimeoutMinutes = 0, +): WorkerShareSummary { + let quantity = 0 + let rewardAmount = 0 + let depositAmount = 0 + /** 有有效份额时取第一份有效份额的参与时间;只剩已取消份额时回退到最早的取消份额。 */ + let firstActiveJoinedAtMs = Number.POSITIVE_INFINITY + let firstAnyJoinedAtMs = Number.POSITIVE_INFINITY + let earliestDeadlineMs = Number.POSITIVE_INFINITY + + for (const share of shares) { + const createdAtMs = Date.parse(String(share.created_at || '')) + if (Number.isFinite(createdAtMs) && createdAtMs < firstAnyJoinedAtMs) { + firstAnyJoinedAtMs = createdAtMs + } + + if (share.status === 'cancelled') continue + + if (Number.isFinite(createdAtMs) && createdAtMs < firstActiveJoinedAtMs) { + firstActiveJoinedAtMs = createdAtMs + } + + quantity += Number(share.quantity || 0) + rewardAmount += Number(share.share_reward || 0) + depositAmount += Number(share.share_deposit || 0) + + if (share.status === 'joined') { + const timeoutMinutes = Number(share.timeout_minutes || fallbackTimeoutMinutes || 0) + const deadlineMs = share.deadline_at + ? Date.parse(String(share.deadline_at)) + : Number.isFinite(createdAtMs) && timeoutMinutes > 0 + ? createdAtMs + timeoutMinutes * 60_000 + : Number.NaN + if (Number.isFinite(deadlineMs) && deadlineMs < earliestDeadlineMs) { + earliestDeadlineMs = deadlineMs + } + } + } + + return { + quantity, + rewardAmount, + depositAmount, + firstJoinedAt: Number.isFinite(firstActiveJoinedAtMs) + ? new Date(firstActiveJoinedAtMs).toISOString() + : Number.isFinite(firstAnyJoinedAtMs) + ? new Date(firstAnyJoinedAtMs).toISOString() + : null, + earliestActiveDeadlineAt: Number.isFinite(earliestDeadlineMs) + ? new Date(earliestDeadlineMs).toISOString() + : null, + } +} + export function mapWorkOrderForWorker( workOrder: WorkOrderRow, permissions: { depositFreeAmount: number }, @@ -463,38 +531,66 @@ export function mapWorkOrderForWorker( } = mapped const isTimedOutForMe = options?.isTimedOutForMe === true || Boolean(options?.timeoutNotice?.isTimedOut) + const workerShares = + options?.myShares && options.myShares.length > 0 ? options.myShares : myShare ? [myShare] : [] + /** + * 份额级问题单:该打手在本单只要有一份被后台标记,这张单就显示为问题单 + * (母单仍是 accepted / cancelled / problem 时以母单为准,与列表 SQL 同序)。 + */ + const problemShare = workerShares.find( + (share) => share.status !== 'cancelled' && Boolean(share.problem_marked_at), + ) const personalStatus = isTimedOutForMe ? WORK_ORDER_STATUS.OPEN - : resolveWorkerShareStatus(workOrder.status, myShare?.status) + : problemShare && !['accepted', 'cancelled', 'problem'].includes(String(workOrder.status || '')) + ? WORK_ORDER_STATUS.PROBLEM + : resolveWorkerShareStatus(workOrder.status, myShare?.status) + + const shareSummary = summarizeWorkerShares(workerShares, Number(workOrder.timeout_minutes || 0)) + const hasActiveShares = workerShares.some((share) => share.status !== 'cancelled') + return { ...visibleOrder, - productName: myShare - ? resolveWorkerShareProductName(workOrder.product_name, Number(myShare.quantity || 0)) + productName: hasActiveShares + ? resolveWorkerShareProductName(workOrder.product_name, shareSummary.quantity) : visibleOrder.productName, status: personalStatus, + /** 行上展示的问题备注:母单问题单用母单备注,否则用被标记份额的备注 */ + problemNote: + String(workOrder.status || '') === WORK_ORDER_STATUS.PROBLEM + ? visibleOrder.problemNote + : String(problemShare?.problem_note || ''), + /** 是否为「份额级问题单」(区别于平台整单标记) */ + shareProblemMarked: Boolean(problemShare), rewardAmount: isTimedOutForMe ? 0 - : myShare - ? Number(myShare.share_reward || 0) + : hasActiveShares + ? shareSummary.rewardAmount : visibleOrder.rewardAmount, freezeDepositAmount: isTimedOutForMe ? 0 - : myShare - ? Number(myShare.share_deposit || 0) + : hasActiveShares + ? shareSummary.depositAmount : resolveFreezeDepositAmount(workOrder, permissions), /** 用于大厅拼单按当前剩余份数准确计算个人免押后的押金。 */ depositFreeAmount: Number(permissions.depositFreeAmount || 0), - assignedAt: myShare?.created_at || visibleOrder.assignedAt, + assignedAt: shareSummary.firstJoinedAt || visibleOrder.assignedAt, submittedAt: myShare?.submitted_at || visibleOrder.submittedAt, acceptedAt: myShare?.accepted_at || visibleOrder.acceptedAt, - deadlineAt: - myShare && Number(workOrder.timeout_minutes || 0) > 0 - ? new Date( - new Date(myShare.created_at).getTime() + Number(workOrder.timeout_minutes) * 60 * 1000, - ).toISOString() - : visibleOrder.deadlineAt, + deadlineAt: isTimedOutForMe + ? visibleOrder.deadlineAt + : shareSummary.earliestActiveDeadlineAt || + (myShare && Number(workOrder.timeout_minutes || 0) > 0 + ? String(myShare.deadline_at || '') || + new Date( + new Date(myShare.created_at).getTime() + + Number(workOrder.timeout_minutes) * 60 * 1000, + ).toISOString() + : visibleOrder.deadlineAt), workerNote, myShare: myShare ? mapWorkOrderShare(myShare) : null, + /** 同一打手在该工单的全部有效份额(一行一工单 + 展开份额) */ + myShares: workerShares.map(mapWorkOrderShare), ...(viewedAt === undefined ? {} : { isUnread: !viewedAt }), ...(options?.timeoutNotice ? { timeoutNotice: options.timeoutNotice } : {}), } @@ -512,6 +608,7 @@ export function mapWorkOrderForHall( permissions: { depositFreeAmount: number }, options: { myShare?: WorkOrderShareRow | null + myShares?: WorkOrderShareRow[] sharingProgress?: { joinedQuantity: number; pendingSubmissionCount: number } } = {}, ) { @@ -528,6 +625,7 @@ export function mapWorkOrderForHall( } const gift = 'gift' in mapped && mapped.gift ? mapped.gift : null const myShare = options.myShare + const myShares = options.myShares || (myShare ? [myShare] : []) return { workOrderId: mapped.workOrderId, workOrderNo: mapped.workOrderNo, @@ -576,6 +674,16 @@ export function mapWorkOrderForHall( status: myShare.status, } : null, + myShares: myShares.map((s) => ({ + shareId: Number(s.id), + workOrderId: Number(s.work_order_id), + workerId: Number(s.worker_id), + quantity: Number(s.quantity || 0), + unitReward: Number(s.unit_reward || 0), + shareReward: Number(s.share_reward || 0), + shareDeposit: Number(s.share_deposit || 0), + status: s.status, + })), // 关键词搜索依赖这些字段;不返回完整 material,避免把 1~2KB 的 JSON 和图片数组带进大厅。 material: { collect: { fields: keywordFields }, @@ -611,6 +719,155 @@ export function resolveWorkerShareStatus(workOrderStatus: string, shareStatus: u return workOrderStatus } +/** 打手端「我的订单」状态页签键,与 buildWorkOrderWhere 的打手状态筛选同口径。 */ +export type WorkerOrderStatusTab = + | 'in_progress' + | 'pending_acceptance' + | 'problem' + | 'open' + | 'accepted' + | 'cancelled' + | 'vip_evidence_pending' + +/** 母单进入终态后,列表只按母单状态归类(任一命中分支会被 wo.status NOT IN (...) 关掉)。 */ +const TERMINAL_WORK_ORDER_STATUSES = new Set(['accepted', 'cancelled', 'problem']) + +/** 代表份额优先级,与 SQL LATERAL 的 CASE 顺序一致:joined > submitted > accepted > cancelled。 */ +const WORKER_SHARE_STATUS_PRIORITY: Record = { + joined: 0, + submitted: 1, + accepted: 2, + cancelled: 3, +} + +type WorkerShareLike = { + status?: unknown + created_at?: unknown + id?: unknown + /** 份额级问题单标记时间;非空表示该份额被后台标记问题单 */ + problem_marked_at?: unknown +} + +/** + * 同一打手在一个工单里的「代表份额」。 + * + * 实时广播过去用 `shares.find(worker_id === ...)`(= 最早一条),同一打手多次拼单时会挑到 + * 已取消/进行中的那份,进而把快照算成「不在可见范围」或错误的个人状态,导致打手端某个 + * 状态页签的行被实时补丁删掉(表现为「待验收有时候没有数据」)。 + * 这里与 `work-order-query-repo` 的 LATERAL 排序完全一致: + * status 优先级 ASC, created_at DESC, id DESC + */ +export function pickWorkerRepresentativeShare( + shares: readonly T[], +): T | null { + let representative: T | null = null + for (const share of shares) { + if (!representative) { + representative = share + continue + } + const priority = WORKER_SHARE_STATUS_PRIORITY[String(share.status || '')] ?? 4 + const representativePriority = + WORKER_SHARE_STATUS_PRIORITY[String(representative.status || '')] ?? 4 + if (priority !== representativePriority) { + if (priority < representativePriority) representative = share + continue + } + const createdMs = Date.parse(String(share.created_at || '')) + const representativeCreatedMs = Date.parse(String(representative.created_at || '')) + if (Number.isFinite(createdMs) && Number.isFinite(representativeCreatedMs)) { + if (createdMs !== representativeCreatedMs) { + if (createdMs > representativeCreatedMs) representative = share + continue + } + } + if (Number(share.id || 0) > Number(representative.id || 0)) representative = share + } + return representative +} + +/** + * 一个工单在该打手「我的订单」里会出现在哪些状态页签。 + * + * 为什么需要它:多份额工单可以同时属于「代练中」和「待验收」(一份 joined + 一份 submitted), + * 但实时快照只能带一个 `status` 字段。前端旧逻辑只要「快照状态 ≠ 当前页签」就把行从缓存删掉, + * 于是同一张单在其中一个页签凭空消失。广播里带上完整页签集合后,前端按集合判归属即可。 + * + * 口径逐条对齐 `buildWorkOrderWhere`(打手状态筛选): + * - 母单 accepted/cancelled/problem:只看代表状态(母单状态); + * - 其余:代表状态 + 「任一有效份额命中」(joined 需无待处理申请,submitted/accepted 直接命中); + * - 有该打手的超时事件且他已不持有订单:代表状态记 open。 + */ +export function resolveWorkerOrderStatusTabs(input: { + workOrderStatus: string + isAssignedWorker: boolean + /** 该打手在该工单的全部份额(含已取消),用于还原代表份额。 */ + shares?: readonly WorkerShareLike[] + /** 存在待审核取消申请或未处理反馈(进行中的份额会归入问题单)。 */ + hasPendingHandling?: boolean + /** 该打手在这张单上有超时事件。 */ + hasTimeoutForMe?: boolean + /** 命中「VIP 免审核待补图」筛选(列表里它是独立筛选,不参与 status 参数)。 */ + vipEvidencePending?: boolean +}): WorkerOrderStatusTab[] { + const tabs = new Set() + const workOrderStatus = String(input.workOrderStatus || '') + if (input.vipEvidencePending) tabs.add('vip_evidence_pending') + + if (TERMINAL_WORK_ORDER_STATUSES.has(workOrderStatus)) { + tabs.add(workOrderStatus as WorkerOrderStatusTab) + return [...tabs] + } + + const shares = input.shares || [] + const activeShares = shares.filter( + (share) => + share.status === 'joined' || share.status === 'submitted' || share.status === 'accepted', + ) + // 份额级问题单:只要有一份被后台标记,这张单就归问题单(与 SQL 的 workerShareProblemExists 同义)。 + const problemShare = activeShares.find((share) => Boolean(share.problem_marked_at)) + 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 (problemShare) { + representativeStatus = WORK_ORDER_STATUS.PROBLEM + } 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) { + // 被标记问题单的份额只归问题单页签,不再按原状态出现(与 SQL 的 anyShare 分支一致)。 + if (share.problem_marked_at) continue + if (share.status === 'joined') { + // 挂待处理申请/反馈的进行中份额归问题单,不再计入代练中(与 SQL 的 anyShare 分支一致)。 + if (!input.hasPendingHandling) tabs.add('in_progress') + } else if (share.status === 'submitted') { + tabs.add('pending_acceptance') + } else if (share.status === 'accepted') { + tabs.add('accepted') + } + } + + return [...tabs] +} + +/** 「VIP 免审核,待补图」判定,口径同 `buildWorkOrderWhere` 的 vipEvidencePendingWhere。 */ +export function isWorkerOrderVipEvidencePending( + workOrder: Pick, + nowMs = Date.now(), +): boolean { + if (String(workOrder.status || '') !== WORK_ORDER_STATUS.ACCEPTED) return false + const acceptance = mapAcceptanceForResponse(workOrder.acceptance_json) as Record + if (String(acceptance.reviewMode || '') !== 'vip_auto') return false + if (String(acceptance.evidenceStatus || '') !== 'pending') return false + const dueAtMs = Date.parse(String(acceptance.evidenceDueAt || '')) + return Number.isFinite(dueAtMs) && dueAtMs > nowMs +} + export function mapWorkOrderShare(share: WorkOrderShareRow) { return { shareId: Number(share.id), @@ -621,12 +878,26 @@ export function mapWorkOrderShare(share: WorkOrderShareRow) { shareReward: Number(share.share_reward || 0), shareDeposit: Number(share.share_deposit || 0), status: share.status, + /** 份额级问题单:只影响该份额持有人,母单状态不变 */ + problemMarked: Boolean(share.problem_marked_at), + problemNote: share.problem_note || '', + problemMarkedAt: share.problem_marked_at || null, + problemMarkedBy: share.problem_marked_by || '', acceptance: mapAcceptanceForResponse(share.acceptance_json), draftAcceptance: mapAcceptanceForResponse(share.draft_acceptance_json), submittedAt: share.submitted_at, acceptedAt: share.accepted_at, createdAt: share.created_at, updatedAt: share.updated_at, + /** 本份额独立截止时间(参与时固化);null = 不限时 */ + deadlineAt: share.deadline_at || null, + timeoutMinutes: Number(share.timeout_minutes || 0), + timeoutPolicy: share.timeout_policy || '', + cancelledReason: share.cancelled_reason || '', + /** 后台撤单/撤份额时填写的原因(自由文本,选填) */ + cancelNote: share.cancel_note || '', + /** 被撤掉的份数:撤份额累计,单人撤单为整份份数;0 = 未撤过 */ + cancelQuantity: Math.max(0, Number(share.cancel_quantity || 0)), worker: share.worker_id ? { workerId: Number(share.worker_id), diff --git a/apps/backend/src/services/worker-platform/worker-acceptance-config-service.test.ts b/apps/backend/src/services/worker-platform/worker-acceptance-config-service.test.ts new file mode 100644 index 00000000..1d16de7a --- /dev/null +++ b/apps/backend/src/services/worker-platform/worker-acceptance-config-service.test.ts @@ -0,0 +1,61 @@ +import assert from 'node:assert/strict' +import test from 'node:test' + +import { + createDefaultWorkerAcceptanceConfig, + normalizeWorkerAcceptanceConfig, +} from './worker-acceptance-config-service.js' + +test('验收视频配置:空值回落到默认值(30 秒 / 12MB / 720p)', () => { + const config = normalizeWorkerAcceptanceConfig(undefined) + assert.deepEqual(config, createDefaultWorkerAcceptanceConfig()) + assert.equal(config.enabled, true) + assert.equal(config.maxDurationSeconds, 30) + assert.equal(config.maxSizeMb, 12) + assert.equal(config.targetHeight, 720) +}) + +test('验收视频配置:数值被夹在允许区间内', () => { + const tooSmall = normalizeWorkerAcceptanceConfig({ + maxDurationSeconds: 1, + maxSizeMb: 0, + targetHeight: 100, + bitrateKbps: 10, + audioBitrateKbps: 1, + fps: 1, + }) + assert.equal(tooSmall.maxDurationSeconds, 5) + assert.equal(tooSmall.maxSizeMb, 1) + assert.equal(tooSmall.targetHeight, 360) + assert.equal(tooSmall.bitrateKbps, 300) + assert.equal(tooSmall.audioBitrateKbps, 32) + assert.equal(tooSmall.fps, 15) + + const tooLarge = normalizeWorkerAcceptanceConfig({ + maxDurationSeconds: 999, + maxSizeMb: 999, + targetHeight: 4320, + bitrateKbps: 99999, + audioBitrateKbps: 999, + fps: 120, + }) + assert.equal(tooLarge.maxDurationSeconds, 120) + assert.equal(tooLarge.maxSizeMb, 50) + assert.equal(tooLarge.targetHeight, 1080) + assert.equal(tooLarge.bitrateKbps, 4000) + assert.equal(tooLarge.audioBitrateKbps, 128) + assert.equal(tooLarge.fps, 30) +}) + +test('验收视频配置:支持字符串开关与数字字符串(.env 读出来都是字符串)', () => { + const config = normalizeWorkerAcceptanceConfig({ + enabled: 'false', + maxDurationSeconds: '45', + maxSizeMb: '8', + targetHeight: '540', + }) + assert.equal(config.enabled, false) + assert.equal(config.maxDurationSeconds, 45) + assert.equal(config.maxSizeMb, 8) + assert.equal(config.targetHeight, 540) +}) diff --git a/apps/backend/src/services/worker-platform/worker-acceptance-config-service.ts b/apps/backend/src/services/worker-platform/worker-acceptance-config-service.ts new file mode 100644 index 00000000..92914067 --- /dev/null +++ b/apps/backend/src/services/worker-platform/worker-acceptance-config-service.ts @@ -0,0 +1,99 @@ +import { runtimeConfig } from '../../config/runtime.js' + +/** + * 打手端「验收视频」配置。 + * + * 只从 `.env`(`WORKER_ACCEPTANCE_VIDEO_*`)读取,**后台不提供配置界面**: + * 视频压缩参数属于部署/运维级参数,改动频率低,放在配置文件里更容易随环境一起版本化。 + * + * 前端按这些参数在本机压缩,服务端用同一份配置做时长/体积兜底校验,避免两边口径不一致。 + */ +export type WorkerAcceptanceConfig = { + enabled: boolean + maxDurationSeconds: number + maxSizeMb: number + targetHeight: number + bitrateKbps: number + audioBitrateKbps: number + fps: number +} + +export const WORKER_ACCEPTANCE_CONFIG_LIMITS = { + maxDurationSeconds: { min: 5, max: 120 }, + maxSizeMb: { min: 1, max: 50 }, + targetHeight: { min: 360, max: 1080 }, + bitrateKbps: { min: 300, max: 4000 }, + audioBitrateKbps: { min: 32, max: 128 }, + fps: { min: 15, max: 30 }, +} as const + +export function createDefaultWorkerAcceptanceConfig(): WorkerAcceptanceConfig { + return { + enabled: true, + maxDurationSeconds: 30, + maxSizeMb: 12, + targetHeight: 720, + bitrateKbps: 1200, + audioBitrateKbps: 64, + fps: 30, + } +} + +export function getWorkerAcceptanceConfig(): WorkerAcceptanceConfig { + return normalizeWorkerAcceptanceConfig(runtimeConfig.worker.acceptanceVideo) +} + +export function normalizeWorkerAcceptanceConfig(rawValue: unknown): WorkerAcceptanceConfig { + const source = isPlainObject(rawValue) ? rawValue : {} + const fallback = createDefaultWorkerAcceptanceConfig() + return { + enabled: readBoolean(source.enabled, fallback.enabled), + maxDurationSeconds: readInt( + source.maxDurationSeconds, + WORKER_ACCEPTANCE_CONFIG_LIMITS.maxDurationSeconds, + fallback.maxDurationSeconds, + ), + maxSizeMb: readInt( + source.maxSizeMb, + WORKER_ACCEPTANCE_CONFIG_LIMITS.maxSizeMb, + fallback.maxSizeMb, + ), + targetHeight: readInt( + source.targetHeight, + WORKER_ACCEPTANCE_CONFIG_LIMITS.targetHeight, + fallback.targetHeight, + ), + bitrateKbps: readInt( + source.bitrateKbps, + WORKER_ACCEPTANCE_CONFIG_LIMITS.bitrateKbps, + fallback.bitrateKbps, + ), + audioBitrateKbps: readInt( + source.audioBitrateKbps, + WORKER_ACCEPTANCE_CONFIG_LIMITS.audioBitrateKbps, + fallback.audioBitrateKbps, + ), + fps: readInt(source.fps, WORKER_ACCEPTANCE_CONFIG_LIMITS.fps, fallback.fps), + } +} + +function readBoolean(value: unknown, fallback: boolean) { + if (value === undefined || value === null || value === '') return fallback + if (typeof value === 'boolean') return value + const normalized = String(value).trim().toLowerCase() + if (['1', 'true', 'yes', 'on'].includes(normalized)) return true + if (['0', 'false', 'no', 'off'].includes(normalized)) return false + return fallback +} + +function readInt(value: unknown, range: { min: number; max: number }, fallback: number): number { + // 空值走默认值;有值(哪怕是 0)则夹到合法区间,避免误填 0 导致"上传全部被拒"。 + if (value === undefined || value === null || String(value).trim() === '') return fallback + const parsed = Number(value) + if (!Number.isFinite(parsed)) return fallback + return Math.min(range.max, Math.max(range.min, Math.floor(parsed))) +} + +function isPlainObject(value: unknown): value is Record { + return Object.prototype.toString.call(value) === '[object Object]' +} diff --git a/apps/backend/src/services/worker-platform/worker-acceptance-service.ts b/apps/backend/src/services/worker-platform/worker-acceptance-service.ts index 4b6291ab..77b6b191 100644 --- a/apps/backend/src/services/worker-platform/worker-acceptance-service.ts +++ b/apps/backend/src/services/worker-platform/worker-acceptance-service.ts @@ -1,3 +1,4 @@ +import { hasAcceptanceEvidence } from '../../domain/work-order-acceptance-evidence.js' import { WORK_ORDER_STATUS } from '../../domain/work-order-status.js' import { isFriendGiftAcceptanceMode, @@ -9,7 +10,7 @@ import { acceptWorkOrderShareAndSettle, countWorkOrderPendingSharingSubmissions, createWorkOrderEvent, - getWorkOrderShare, + listWorkOrderSharesByWorkerInOrder, settleOverdueWorkOrder, settleOverdueWorkOrderShare, submitWorkOrderShareAcceptance, @@ -25,6 +26,10 @@ import type { JsonObject } from '../../types/json.js' import { createHttpError } from '../../utils/http.js' import { addHours, nowIso } from '../../utils/time.js' import { safeParseJson } from '../admin/admin-query-utils.js' +import { + extractObjectKeyFromFileUrl, + normalizeStoredFileUrl, +} from '../file-storage/file-storage-service.js' import { createWorkerAcceptanceAdminNotification, getWorkerAcceptanceReminder, @@ -43,6 +48,7 @@ import { resolveWorkerPermissions, } from './mappers.js' import { getWorkerFinanceConfig } from './worker-finance-config-service.js' +import { getWorkerAcceptanceConfig } from './worker-acceptance-config-service.js' import { publishWorkOrderParticipantChange } from './worker-order-realtime-service.js' import { getRequiredWorkOrder, @@ -74,9 +80,21 @@ export async function submitWorkerOrderAcceptance( const workerId = Number(session.workerId) const worker = await getRequiredWorker(session.workerId) if (!options.asConsignmentSupplier) { - const sharingShare = await getWorkOrderShare(workOrder.id, workerId) - if (sharingShare) { - return submitWorkerSharingAcceptance(workOrder, sharingShare, payload, session) + const target = await resolveWorkerTargetShare(workOrder.id, workerId, payload) + if (target.error === 'required') { + throw createHttpError('该工单有多条拼单份额,请指定 shareId', { + statusCode: 400, + errorCode: 'work_order_share_id_required', + }) + } + if (target.error === 'not_found') { + throw createHttpError('未找到该拼单份额', { + statusCode: 404, + errorCode: 'work_order_sharing_share_not_found', + }) + } + if (target.share) { + return submitWorkerSharingAcceptance(workOrder, target.share, payload, session) } } const canAutoAcceptWithoutEvidence = isFriendGiftAcceptanceMode(workOrder.acceptance_mode) @@ -137,8 +155,11 @@ export async function submitWorkerOrderAcceptance( payload, workOrder.draft_acceptance_json, ) - if (imageUrls.length === 0 && !canAutoAcceptWithoutEvidence) { - throw createHttpError('请上传验收图片', { + const video = resolveAcceptanceVideo(payload, workOrder.draft_acceptance_json) + // 验收图片与验收视频至少上传一个;有视频时视同有图片,后续口径完全一致。 + const hasEvidence = hasAcceptanceEvidence({ imageUrls, video }) + if (!hasEvidence && !canAutoAcceptWithoutEvidence) { + throw createHttpError('请上传验收图片或验收视频', { statusCode: 400, errorCode: 'work_order_acceptance_image_required', }) @@ -147,13 +168,13 @@ export async function submitWorkerOrderAcceptance( note, files, imageUrls: [...new Set(imageUrls)], + ...(video ? { video } : {}), submittedAt: now, ...(canAutoAcceptWithoutEvidence ? { reviewMode: 'vip_auto', - evidenceStatus: imageUrls.length > 0 ? 'complete' : 'pending', - evidenceDueAt: - imageUrls.length > 0 ? null : addHours(now, VIP_AUTO_ACCEPT_EVIDENCE_WINDOW_HOURS), + evidenceStatus: hasEvidence ? 'complete' : 'pending', + evidenceDueAt: hasEvidence ? null : addHours(now, VIP_AUTO_ACCEPT_EVIDENCE_WINDOW_HOURS), } : {}), } @@ -173,6 +194,7 @@ export async function submitWorkerOrderAcceptance( toStatus: WORK_ORDER_STATUS.PENDING_ACCEPTANCE, payloadJson: JSON.stringify({ imageCount: imageUrls.length, + hasVideo: Boolean(video), autoAccepted: canAutoAcceptWithoutEvidence, evidenceDueAt: acceptance.evidenceDueAt || null, }), @@ -232,6 +254,7 @@ export async function submitWorkerOrderAcceptance( productName: workOrder.product_name, workerName: worker.display_name || worker.username, imageCount: imageUrls.length, + hasVideo: Boolean(video), }) publishWorkOrderRealtimeChange({ workOrderId: Number(workOrder.id), @@ -283,6 +306,7 @@ export async function remindWorkerOrderAcceptance( productName: workOrder.product_name, workerName: worker.display_name || worker.username, imageCount: normalizeStringArray(safeParseJson(workOrder.acceptance_json).imageUrls).length, + hasVideo: Boolean(safeParseJson(workOrder.acceptance_json).video), }) notification = await getWorkerAcceptanceReminder(Number(workOrder.id)) } @@ -340,9 +364,21 @@ export async function supplementWorkerAcceptedOrderEvidence( requireActiveWorkerSession(session) const workOrder = await getRequiredWorkOrder(workOrderId) const worker = await getRequiredWorker(session.workerId) - const sharingShare = await getWorkOrderShare(workOrder.id, session.workerId) - if (sharingShare) { - return supplementWorkerAcceptedSharingEvidence(workOrder, sharingShare, payload, session) + const target = await resolveWorkerTargetShare(workOrder.id, session.workerId, payload) + if (target.error === 'required') { + throw createHttpError('该工单有多条拼单份额,请指定 shareId', { + statusCode: 400, + errorCode: 'work_order_share_id_required', + }) + } + if (target.error === 'not_found') { + throw createHttpError('未找到该拼单份额', { + statusCode: 404, + errorCode: 'work_order_sharing_share_not_found', + }) + } + if (target.share) { + return supplementWorkerAcceptedSharingEvidence(workOrder, target.share, payload, session) } if (Number(workOrder.assigned_worker_id || 0) !== Number(session.workerId)) { throw createHttpError('只能补充自己的订单验收图片', { @@ -360,8 +396,11 @@ export async function supplementWorkerAcceptedOrderEvidence( const now = nowIso() const previous = safeParseJson(workOrder.acceptance_json) const mergedEvidence = mergeAcceptanceEvidence(previous, payload.files) - if (!mergedEvidence.changed) { - throw createHttpError('请上传新的验收图片', { + // 已提交后只允许替换视频,不允许删除证据。 + const payloadVideo = resolveAcceptanceVideoFromPayload(payload) + const video = payloadVideo || readAcceptanceVideoLenient(previous) + if (!mergedEvidence.changed && !payloadVideo) { + throw createHttpError('请上传新的验收图片或新的验收视频', { statusCode: 400, errorCode: 'work_order_evidence_supplement_image_required', }) @@ -370,6 +409,7 @@ export async function supplementWorkerAcceptedOrderEvidence( ...previous, files: mergedEvidence.files, imageUrls: mergedEvidence.imageUrls, + ...(video ? { video } : {}), evidenceStatus: 'complete', evidenceSupplementedAt: now, updatedAt: now, @@ -389,6 +429,7 @@ export async function supplementWorkerAcceptedOrderEvidence( payloadJson: JSON.stringify({ addedImageCount: mergedEvidence.addedFileCount, totalImageCount: mergedEvidence.imageUrls.length, + hasVideo: Boolean(video), }), now, }) @@ -411,16 +452,23 @@ async function submitWorkerSharingAcceptance( }) } const now = nowIso() - const timeoutMinutes = Number(workOrder.timeout_minutes || 0) + const timeoutMinutes = Number(sharingShare.timeout_minutes ?? workOrder.timeout_minutes ?? 0) + const snapshotDeadline = String(sharingShare.deadline_at || '').trim() + const deadlineMs = snapshotDeadline + ? new Date(snapshotDeadline).getTime() + : new Date(sharingShare.created_at).getTime() + timeoutMinutes * 60 * 1000 if ( sharingShare.status === 'joined' && timeoutMinutes > 0 && - new Date(sharingShare.created_at).getTime() + timeoutMinutes * 60 * 1000 <= Date.now() + Number.isFinite(deadlineMs) && + deadlineMs <= Date.now() ) { await settleOverdueWorkOrderShare({ workOrderId: workOrder.id, shareId: sharingShare.id, - policy: normalizeWorkOrderTimeoutPolicy(workOrder.timeout_policy), + policy: normalizeWorkOrderTimeoutPolicy( + sharingShare.timeout_policy || workOrder.timeout_policy, + ), now, }) publishWorkOrderRealtimeChange({ @@ -440,8 +488,11 @@ async function submitWorkerSharingAcceptance( payload, sharingShare.draft_acceptance_json, ) - if (imageUrls.length === 0 && !canAutoAcceptWithoutEvidence) { - throw createHttpError('请上传验收图片', { + const video = resolveAcceptanceVideo(payload, sharingShare.draft_acceptance_json) + // 验收图片与验收视频至少上传一个;有视频时视同有图片,后续口径完全一致。 + const hasEvidence = hasAcceptanceEvidence({ imageUrls, video }) + if (!hasEvidence && !canAutoAcceptWithoutEvidence) { + throw createHttpError('请上传验收图片或验收视频', { statusCode: 400, errorCode: 'work_order_acceptance_image_required', }) @@ -450,19 +501,20 @@ async function submitWorkerSharingAcceptance( note, files, imageUrls: [...new Set(imageUrls)], + ...(video ? { video } : {}), submittedAt: now, ...(canAutoAcceptWithoutEvidence ? { reviewMode: 'vip_auto', - evidenceStatus: imageUrls.length > 0 ? 'complete' : 'pending', - evidenceDueAt: - imageUrls.length > 0 ? null : addHours(now, VIP_AUTO_ACCEPT_EVIDENCE_WINDOW_HOURS), + evidenceStatus: hasEvidence ? 'complete' : 'pending', + evidenceDueAt: hasEvidence ? null : addHours(now, VIP_AUTO_ACCEPT_EVIDENCE_WINDOW_HOURS), } : {}), } const result = await submitWorkOrderShareAcceptance({ workOrderId: Number(workOrder.id), workerId: Number(session.workerId), + shareId: Number(sharingShare.id), acceptanceJson: JSON.stringify(acceptance), now, }) @@ -509,6 +561,15 @@ async function submitWorkerSharingAcceptance( sharingPendingCount: await countWorkOrderPendingSharingSubmissions(Number(workOrder.id)), } } + await createWorkerAcceptanceAdminNotification({ + workOrderId: Number(workOrder.id), + workOrderNo: workOrder.work_order_no, + productName: workOrder.product_name, + workerName: worker.display_name || worker.username, + imageCount: acceptance.imageUrls.length, + hasVideo: Boolean(acceptance.video), + shareId: Number(sharingShare.id), + }) await publishWorkOrderParticipantChange(Number(workOrder.id), { workerIds: [Number(session.workerId)], }) @@ -535,8 +596,10 @@ async function supplementWorkerAcceptedSharingEvidence( const now = nowIso() const previous = safeParseJson(sharingShare.acceptance_json) const mergedEvidence = mergeAcceptanceEvidence(previous, payload.files) - if (!mergedEvidence.changed) { - throw createHttpError('请上传新的验收图片', { + const payloadVideo = resolveAcceptanceVideoFromPayload(payload) + const video = payloadVideo || readAcceptanceVideoLenient(previous) + if (!mergedEvidence.changed && !payloadVideo) { + throw createHttpError('请上传新的验收图片或新的验收视频', { statusCode: 400, errorCode: 'work_order_sharing_evidence_supplement_image_required', }) @@ -545,6 +608,7 @@ async function supplementWorkerAcceptedSharingEvidence( ...previous, files: mergedEvidence.files, imageUrls: mergedEvidence.imageUrls, + ...(video ? { video } : {}), evidenceStatus: 'complete', evidenceSupplementedAt: now, updatedAt: now, @@ -552,6 +616,7 @@ async function supplementWorkerAcceptedSharingEvidence( const updated = await updateWorkOrderShareAcceptanceRecord({ workOrderId: Number(workOrder.id), workerId: Number(session.workerId), + shareId: Number(sharingShare.id), acceptanceJson: JSON.stringify(acceptance), now, }) @@ -566,6 +631,7 @@ async function supplementWorkerAcceptedSharingEvidence( workerId: Number(session.workerId), addedImageCount: mergedEvidence.addedFileCount, totalImageCount: mergedEvidence.imageUrls.length, + hasVideo: Boolean(video), }), now, }) @@ -575,7 +641,160 @@ async function supplementWorkerAcceptedSharingEvidence( return { share: mapWorkOrderShare(updated || sharingShare) } } -/** 从提交或草稿载荷中解析验收图片与说明。 */ +/** + * 定位本次操作要处理的份额: + * - 传了 shareId:必须是本人该工单下的有效份额(否则 not_found) + * - 没传且只有 1 条:直接用 + * - 没传且有多条:要求前端明确指定(required) + * - 没有任何份额:返回空(走整单逻辑) + */ +async function resolveWorkerTargetShare( + workOrderId: number, + workerId: number | string, + payload: JsonObject, +): Promise<{ share: WorkOrderShareRow | null; error: 'required' | 'not_found' | null }> { + const requested = Number(payload.shareId ?? payload.workOrderShareId ?? 0) + const shares = await listWorkOrderSharesByWorkerInOrder(workOrderId, workerId) + if (Number.isFinite(requested) && requested > 0) { + const found = shares.find((share) => Number(share.id) === requested) || null + return { share: found, error: found ? null : 'not_found' } + } + if (shares.length === 0) { + return { share: null, error: null } + } + if (shares.length === 1) { + return { share: shares[0] ?? null, error: null } + } + return { share: null, error: 'required' } +} + +const ACCEPTANCE_VIDEO_SCENE = 'worker-acceptance-video' +const ACCEPTANCE_VIDEO_CONTENT_TYPES = new Set(['video/mp4', 'video/webm']) + +/** + * 归一化验收视频(可选、单个)。 + * + * - 只接受本系统上传到 `worker-acceptance-video/` 的对象,避免把其它场景的文件当验收证据; + * - 格式/时长/体积按后台配置再校验一次(上传接口已校验,这里防篡改 payload); + * - `allowRemove` 只在草稿阶段为 true:显式传 null 表示删除;已提交/补图阶段只能替换,不能删证据。 + */ +function resolveAcceptanceVideo( + payload: JsonObject, + fallback: unknown, + options: { allowRemove?: boolean } = {}, +): Record | null { + const hasVideoKey = Object.prototype.hasOwnProperty.call(payload, 'video') + const rawValue = (payload as Record).video + if (hasVideoKey && rawValue === null) { + return options.allowRemove ? null : readAcceptanceVideoLenient(fallback) + } + return resolveAcceptanceVideoFromPayload(payload) ?? readAcceptanceVideoLenient(fallback) +} + +/** 只取本次请求里带的视频(没有则为 null),用于判断"这次是否真的提交了新视频"。 */ +function resolveAcceptanceVideoFromPayload(payload: JsonObject): Record | null { + const hasVideoKey = Object.prototype.hasOwnProperty.call(payload, 'video') + const rawValue = (payload as Record).video + if (!hasVideoKey || rawValue === undefined || rawValue === null || rawValue === '') return null + return readAcceptanceVideoStrict(rawValue) +} + +function readAcceptanceVideoStrict(value: unknown): Record | null { + if (!value || typeof value !== 'object' || Array.isArray(value)) { + throw createHttpError('验收视频参数无效,请重新上传', { + statusCode: 400, + errorCode: 'work_order_acceptance_video_invalid', + }) + } + const source = value as JsonObject + const rawUrl = String(source.url || '').trim() + const objectKey = + String(source.objectKey || source.object_key || '').trim() || + extractObjectKeyFromFileUrl(rawUrl) + if (!objectKey || !objectKey.startsWith(`${ACCEPTANCE_VIDEO_SCENE}/`)) { + throw createHttpError('验收视频来源无效,请重新上传', { + statusCode: 400, + errorCode: 'work_order_acceptance_video_invalid', + }) + } + const contentType = String(source.contentType || source.content_type || '') + .trim() + .toLowerCase() + if (!ACCEPTANCE_VIDEO_CONTENT_TYPES.has(contentType)) { + throw createHttpError('验收视频格式不支持,请重新上传', { + statusCode: 400, + errorCode: 'work_order_acceptance_video_invalid', + }) + } + const config = getWorkerAcceptanceConfig() + if (!config.enabled) { + throw createHttpError('验收视频功能已关闭', { + statusCode: 400, + errorCode: 'work_order_acceptance_video_disabled', + }) + } + const durationSeconds = Number(source.durationSeconds ?? source.duration_seconds ?? 0) + if (!Number.isFinite(durationSeconds) || durationSeconds <= 0) { + throw createHttpError('无法读取视频时长,请重新上传', { + statusCode: 400, + errorCode: 'work_order_acceptance_video_invalid', + }) + } + if (durationSeconds > config.maxDurationSeconds + 0.5) { + throw createHttpError(`视频时长不能超过 ${config.maxDurationSeconds} 秒`, { + statusCode: 400, + errorCode: 'work_order_acceptance_video_too_long', + }) + } + const size = Number(source.size ?? 0) + if (Number.isFinite(size) && size > config.maxSizeMb * 1024 * 1024) { + throw createHttpError(`验收视频不能超过 ${config.maxSizeMb}MB`, { + statusCode: 400, + errorCode: 'work_order_acceptance_video_too_large', + }) + } + return { + objectKey, + url: normalizeStoredFileUrl(rawUrl, objectKey), + filename: String(source.filename || '').trim(), + contentType, + size: Number.isFinite(size) && size > 0 ? Math.floor(size) : 0, + durationSeconds: Math.round(durationSeconds * 10) / 10, + width: readPositiveInt(source.width), + height: readPositiveInt(source.height), + } +} + +/** 读已落库的视频(不再校验,避免历史数据因配置收紧而读不出来)。 */ +function readAcceptanceVideoLenient(value: unknown): Record | null { + if (!value || typeof value !== 'object' || Array.isArray(value)) return null + const source = value as JsonObject + const rawUrl = String(source.url || '').trim() + const objectKey = + String(source.objectKey || source.object_key || '').trim() || + extractObjectKeyFromFileUrl(rawUrl) + const durationSeconds = Number(source.durationSeconds ?? source.duration_seconds ?? 0) + if (!objectKey || !Number.isFinite(durationSeconds) || durationSeconds <= 0) return null + const size = Number(source.size ?? 0) + return { + objectKey, + url: normalizeStoredFileUrl(rawUrl, objectKey), + filename: String(source.filename || '').trim(), + contentType: String(source.contentType || '') + .trim() + .toLowerCase(), + size: Number.isFinite(size) && size > 0 ? Math.floor(size) : 0, + durationSeconds: Math.round(durationSeconds * 10) / 10, + width: readPositiveInt(source.width), + height: readPositiveInt(source.height), + } +} + +function readPositiveInt(value: unknown) { + const parsed = Number(value) + return Number.isFinite(parsed) && parsed > 0 ? Math.floor(parsed) : 0 +} + function resolveAcceptanceImages(payload: JsonObject, draftValue: unknown) { const files = normalizeUploadedFiles(payload.files) const imageUrls = [ @@ -606,9 +825,21 @@ export async function saveWorkerOrderAcceptanceDraft( requireActiveWorkerSession(session) const workOrder = await getRequiredWorkOrder(workOrderId) const workerId = Number(session.workerId) - const sharingShare = await getWorkOrderShare(workOrder.id, workerId) - if (sharingShare) { - return saveWorkerSharingAcceptanceDraft(workOrder, sharingShare, payload, session) + const target = await resolveWorkerTargetShare(workOrder.id, workerId, payload) + if (target.error === 'required') { + throw createHttpError('该工单有多条拼单份额,请指定 shareId', { + statusCode: 400, + errorCode: 'work_order_share_id_required', + }) + } + if (target.error === 'not_found') { + throw createHttpError('未找到该拼单份额', { + statusCode: 404, + errorCode: 'work_order_sharing_share_not_found', + }) + } + if (target.share) { + return saveWorkerSharingAcceptanceDraft(workOrder, target.share, payload, session) } if (Number(workOrder.assigned_worker_id || 0) !== workerId) { throw createHttpError('只能操作自己的订单', { @@ -621,17 +852,21 @@ export async function saveWorkerOrderAcceptanceDraft( const worker = await getRequiredWorker(session.workerId) if (workOrder.status === WORK_ORDER_STATUS.PENDING_ACCEPTANCE) { - if (imageUrls.length === 0) { - throw createHttpError('请至少保留一张验收图片', { + const previous = safeParseJson(workOrder.acceptance_json) + // 已提交后只允许替换视频,不允许删除证据。 + const video = resolveAcceptanceVideo(payload, previous) + // 验收图片与验收视频至少保留一个。 + if (!hasAcceptanceEvidence({ imageUrls, video })) { + throw createHttpError('请至少保留一张验收图片或一段验收视频', { statusCode: 400, errorCode: 'work_order_acceptance_image_required', }) } - const previous = safeParseJson(workOrder.acceptance_json) const acceptance = { note: String(payload.note ?? previous.note ?? '').trim(), files, imageUrls: [...new Set(imageUrls)], + ...(video ? { video } : {}), submittedAt: previous.submittedAt || workOrder.submitted_at || now, updatedAt: now, } @@ -647,7 +882,7 @@ export async function saveWorkerOrderAcceptanceDraft( eventType: 'acceptance_updated', fromStatus: workOrder.status, toStatus: workOrder.status, - payloadJson: JSON.stringify({ imageCount: imageUrls.length }), + payloadJson: JSON.stringify({ imageCount: imageUrls.length, hasVideo: Boolean(video) }), now, }) await publishWorkOrderParticipantChange(Number(workOrder.id), { @@ -666,10 +901,12 @@ export async function saveWorkerOrderAcceptanceDraft( errorCode: 'work_order_draft_status_invalid', }) } + const draftVideo = resolveAcceptanceVideo(payload, {}, { allowRemove: true }) const draft = { note: String(payload.note || '').trim(), files, imageUrls: [...new Set(imageUrls)], + ...(draftVideo ? { video: draftVideo } : {}), updatedAt: now, } const updated = await updateWorkOrderDraftAcceptance({ @@ -684,7 +921,10 @@ export async function saveWorkerOrderAcceptanceDraft( eventType: 'acceptance_draft_saved', fromStatus: workOrder.status, toStatus: workOrder.status, - payloadJson: JSON.stringify({ imageCount: imageUrls.length }), + payloadJson: JSON.stringify({ + imageCount: imageUrls.length, + hasVideo: Boolean(draftVideo), + }), now, }) await publishWorkOrderParticipantChange(Number(workOrder.id), { @@ -711,23 +951,28 @@ async function saveWorkerSharingAcceptanceDraft( const { files, imageUrls } = resolveAcceptanceImages(payload, {}) if (sharingShare.status === 'submitted') { - if (imageUrls.length === 0) { - throw createHttpError('请至少保留一张验收图片', { + const previous = safeParseJson(sharingShare.acceptance_json) + // 已提交后只允许替换视频,不允许删除证据。 + const video = resolveAcceptanceVideo(payload, previous) + // 验收图片与验收视频至少保留一个。 + if (!hasAcceptanceEvidence({ imageUrls, video })) { + throw createHttpError('请至少保留一张验收图片或一段验收视频', { statusCode: 400, errorCode: 'work_order_acceptance_image_required', }) } - const previous = safeParseJson(sharingShare.acceptance_json) const acceptance = { note: String(payload.note ?? previous.note ?? '').trim(), files, imageUrls: [...new Set(imageUrls)], + ...(video ? { video } : {}), submittedAt: previous.submittedAt || sharingShare.submitted_at || now, updatedAt: now, } const share = await updateWorkOrderShareAcceptanceRecord({ workOrderId: Number(workOrder.id), workerId: Number(session.workerId), + shareId: Number(sharingShare.id), acceptanceJson: JSON.stringify(acceptance), now, }) @@ -741,6 +986,7 @@ async function saveWorkerSharingAcceptanceDraft( payloadJson: JSON.stringify({ workerId: Number(session.workerId), imageCount: imageUrls.length, + hasVideo: Boolean(video), }), now, }) @@ -756,15 +1002,18 @@ async function saveWorkerSharingAcceptanceDraft( errorCode: 'work_order_sharing_draft_status_invalid', }) } + const draftVideo = resolveAcceptanceVideo(payload, {}, { allowRemove: true }) const draft = { note: String(payload.note || '').trim(), files, imageUrls: [...new Set(imageUrls)], + ...(draftVideo ? { video: draftVideo } : {}), updatedAt: now, } const share = await updateWorkOrderShareDraftAcceptance({ workOrderId: Number(workOrder.id), workerId: Number(session.workerId), + shareId: Number(sharingShare.id), draftJson: JSON.stringify(draft), now, }) @@ -778,6 +1027,7 @@ async function saveWorkerSharingAcceptanceDraft( payloadJson: JSON.stringify({ workerId: Number(session.workerId), imageCount: imageUrls.length, + hasVideo: Boolean(draftVideo), }), now, }) diff --git a/apps/backend/src/services/worker-platform/worker-hall-action-service.ts b/apps/backend/src/services/worker-platform/worker-hall-action-service.ts index 5e3e879e..a53dd758 100644 --- a/apps/backend/src/services/worker-platform/worker-hall-action-service.ts +++ b/apps/backend/src/services/worker-platform/worker-hall-action-service.ts @@ -18,6 +18,7 @@ import { throwGrabWorkOrderFailure, } from './mappers.js' import { getWorkerHallConfig } from './worker-hall-config-service.js' +import { invalidateWorkerHallListCache } from './worker-hall-service.js' import { publishWorkOrderParticipantChange } from './worker-order-realtime-service.js' import { getRequiredWorkOrder, @@ -30,12 +31,17 @@ import { publishWorkOrderRealtimeChange, } from '../realtime/realtime-event-service.js' -export async function grabWorkerHallOrder(workOrderId: number | string, session: WorkerSession) { +export async function grabWorkerHallOrder( + workOrderId: number | string, + session: WorkerSession, + payload: JsonObject = {}, +) { requireActiveWorkerSession(session) const worker = await getRequiredWorker(session.workerId) const permissions = resolveWorkerPermissions(worker) const hallConfig = getWorkerHallConfig() const now = nowIso() + const requestId = String(payload.requestId || '').trim() const visibleDelaySeconds = Number(permissions.visibleDelaySeconds || 0) const visibleAfterIso = visibleDelaySeconds > 0 ? new Date(Date.now() - visibleDelaySeconds * 1000).toISOString() : '' @@ -58,17 +64,25 @@ export async function grabWorkerHallOrder(workOrderId: number | string, session: hallCandidateLimit: hallConfig.maxVisibleOrders, hallCategoryLimits: hallConfig.categoryMaxVisibleOrders, visibleAfterIso, + ...(requestId ? { requestId } : {}), now, }) if (!result.share) { throw resolveWorkOrderShareJoinError(result.failureReason) } + // 参与成功后立刻失效该打手的大厅缓存:前端紧接着会重拉列表, + // 2s 缓存若命中会返回「参与前」的行(没有 myShare),按钮就不会变成「再拼一次」。 + invalidateWorkerHallListCache([worker.id]) await publishWorkOrderParticipantChange(Number(workOrder.id), { workerIds: [Number(worker.id)], hallChanged: true, walletChangedWorkerIds: [Number(worker.id)], }) - return { order: mapWorkOrderForWorker(workOrder, permissions, result.share) } + return { + order: mapWorkOrderForWorker(workOrder, permissions, result.share), + share: mapWorkOrderShare(result.share), + duplicated: result.duplicated, + } } const activeCount = await countWorkerActiveOrders(worker.id) @@ -102,6 +116,10 @@ export async function grabWorkerHallOrder(workOrderId: number | string, session: throwGrabWorkOrderFailure(grabbed.failureReason) } + // 抢单成功后同样立刻失效大厅缓存:否则紧接着的列表重拉可能命中 2s 旧缓存, + // 把已经抢走的单又显示出来(用户会以为还能再抢)。 + invalidateWorkerHallListCache([worker.id]) + await createWorkOrderEvent({ workOrderId: workOrder.id, actorType: 'worker', @@ -139,6 +157,7 @@ export async function joinWorkerSharingOrder( const visibleAfterIso = visibleDelaySeconds > 0 ? new Date(Date.now() - visibleDelaySeconds * 1000).toISOString() : '' const quantity = normalizePositiveInteger(payload.quantity, 1) + const requestId = String(payload.requestId || '').trim() const now = nowIso() const result = await joinWorkOrderShare({ workOrderId: Number(workOrderId), @@ -149,17 +168,20 @@ export async function joinWorkerSharingOrder( hallCandidateLimit: hallConfig.maxVisibleOrders, hallCategoryLimits: hallConfig.categoryMaxVisibleOrders, visibleAfterIso, + ...(requestId ? { requestId } : {}), now, }) if (!result.share) { throw resolveWorkOrderShareJoinError(result.failureReason) } + // 同上:拼单成功后要让紧接着的列表请求看到最新份额。 + invalidateWorkerHallListCache([worker.id]) await publishWorkOrderParticipantChange(Number(workOrderId), { workerIds: [Number(worker.id)], hallChanged: true, walletChangedWorkerIds: [Number(worker.id)], }) - return { share: mapWorkOrderShare(result.share) } + return { share: mapWorkOrderShare(result.share), duplicated: result.duplicated } } function resolveWorkOrderDeadlineAt(workOrder: WorkOrderRow, now: string): string | null { @@ -174,8 +196,6 @@ function resolveWorkOrderShareJoinError( | 'sharing_disabled' | 'work_order_owner_conflict' | 'sharing_quantity_full' - | 'worker_share_exists' - | 'worker_share_locked' | 'worker_active_order_limit' | 'worker_deposit_insufficient' | null, @@ -185,8 +205,6 @@ function resolveWorkOrderShareJoinError( sharing_disabled: '该订单未启用拼单', work_order_owner_conflict: '你已整单接取该订单,无需拼单', sharing_quantity_full: '拼单数量已满或超出剩余数量', - worker_share_exists: '你已参与该拼单,剩余份数可用「立即抢单」追加', - worker_share_locked: '你的拼单份额已提交或已验收,不能再追加份数', worker_active_order_limit: '已达到当前等级最大同时接单量', worker_deposit_insufficient: '余额不足,无法冻结所需押金', } diff --git a/apps/backend/src/services/worker-platform/worker-hall-service.ts b/apps/backend/src/services/worker-platform/worker-hall-service.ts index f51a741c..328e5912 100644 --- a/apps/backend/src/services/worker-platform/worker-hall-service.ts +++ b/apps/backend/src/services/worker-platform/worker-hall-service.ts @@ -75,13 +75,17 @@ export async function listWorkerHallOrders(query: JsonObject = {}, session: Work const hasSharingOrders = items.some((item) => item.sharing_enabled === true) const orderIds = items.map((item) => Number(item.id)) const shares = hasSharingOrders ? await listWorkOrderSharesByOrderIds(orderIds) : [] - const myShareByOrderId = new Map( - shares - .filter( - (share) => share.status !== 'cancelled' && Number(share.worker_id) === Number(worker.id), - ) - .map((share) => [Number(share.work_order_id), share]), - ) + // 同一打手可能在一个工单里有多份份额(多次拼单)。大厅卡片靠这些字段判断 + // 是否已参与(按钮显示「再拼一次」),所以必须按工单收集全部有效份额。 + const mySharesByOrderId = new Map() + for (const share of shares) { + if (share.status === 'cancelled') continue + if (Number(share.worker_id) !== Number(worker.id)) continue + const orderId = Number(share.work_order_id) + const current = mySharesByOrderId.get(orderId) || [] + current.push(share) + mySharesByOrderId.set(orderId, current) + } const sharingProgressByOrderId = new Map< number, { joinedQuantity: number; pendingSubmissionCount: number } @@ -100,8 +104,12 @@ export async function listWorkerHallOrders(query: JsonObject = {}, session: Work return { items: items.map((item) => { const orderId = Number(item.id) + const myShares = mySharesByOrderId.get(orderId) || [] + // `listWorkOrderSharesByOrderIds` 按 id ASC,最后一条即最近一次参与。 + const myShare = myShares[myShares.length - 1] || null return mapWorkOrderForHall(item, permissions, { - myShare: myShareByOrderId.get(orderId) || null, + myShare, + myShares, sharingProgress: sharingProgressByOrderId.get(orderId) || { joinedQuantity: 0, pendingSubmissionCount: 0, @@ -164,3 +172,26 @@ export function maskWorkerLeaderboardDisplayName(value: unknown): string { const firstCharacter = Array.from(String(value || '').trim())[0] return firstCharacter ? `${firstCharacter}****` : '****' } + +/** + * 逐出指定打手的大厅列表缓存。 + * + * 大厅列表缓存 TTL 只有 2s,但拼单成功后的即时重拉正好落在窗口内: + * 拿回来的是「参与前」的行(没有 myShare、拼单进度也是旧的),按钮就停在 + * 「拼单 ¥x/份」不变。参与/抢单成功后按打手整体失效,保证紧接着的列表是新数据。 + * 缓存键形如 `[workerId,page,pageSize,keyword,categoryId]`,用 `[id,` 作前缀可避免 123/1234 误伤。 + */ +export function invalidateWorkerHallListCache( + workerIds: Array, +): number { + const normalized = new Set( + workerIds + .map((item) => Number(item || 0)) + .filter((workerId) => Number.isFinite(workerId) && workerId > 0), + ) + let evicted = 0 + for (const workerId of normalized) { + evicted += hallListCache.evictByPrefix(`[${workerId},`) + } + return evicted +} diff --git a/apps/backend/src/services/worker-platform/worker-my-order-service.ts b/apps/backend/src/services/worker-platform/worker-my-order-service.ts index 4b372edb..76f837a9 100644 --- a/apps/backend/src/services/worker-platform/worker-my-order-service.ts +++ b/apps/backend/src/services/worker-platform/worker-my-order-service.ts @@ -118,8 +118,14 @@ export async function listWorkerMyOrders(query: JsonObject = {}, session: Worker sort: 'worker_claimed_at_desc', }) const workOrderIds = items.map((item) => Number(item.id)) - const myShares = await listWorkerSharesByWorker(worker.id, workOrderIds) - const myShareByOrderId = new Map(myShares.map((share) => [Number(share.work_order_id), share])) + const workerShares = await listWorkerSharesByWorker(worker.id, workOrderIds) + const sharesByOrderId = new Map() + for (const share of workerShares) { + const orderId = Number(share.work_order_id) + const current = sharesByOrderId.get(orderId) || [] + current.push(share) + sharesByOrderId.set(orderId, current) + } const [ notes, views, @@ -152,15 +158,20 @@ export async function listWorkerMyOrders(query: JsonObject = {}, session: Worker return { items: items.map((item) => { const workOrderId = Number(item.id) - const activeShare = myShareByOrderId.get(workOrderId) + const activeShares = sharesByOrderId.get(workOrderId) || [] + // 主份额用于列表摘要:优先未完成(joined/submitted),否则最新一条 + const activeShare = + activeShares.find((share) => ['joined', 'submitted'].includes(String(share.status))) || + activeShares[0] || + null const cancelledShare = cancelledShareByOrderId.get(workOrderId) const timeoutEvent = timeoutEventsByOrderId.get(workOrderId) - // 判断当前打手是否仍持有该单 + // 判断当前打手是否仍持有该单(任一有效份额即算持有) const isOwnerCurrently = (item.assigned_worker_id != null && Number(item.assigned_worker_id) === Number(worker.id)) || - Boolean(activeShare) + activeShares.length > 0 // 若不是当前持有人,但有该打手的超时事件或取消份额,则判定为该打手的已超时历史单 const isTimedOutForMe = @@ -211,10 +222,16 @@ export async function listWorkerMyOrders(query: JsonObject = {}, session: Worker activeShare || cancelledShare || null, noteByOrderId.get(workOrderId) || '', viewedAtByOrderId.get(workOrderId) || null, - { isTimedOutForMe, timeoutNotice }, + { isTimedOutForMe, timeoutNotice, myShares: activeShares }, ), // 问题单分组内的来源角标:平台标记 / 退单审核中 / 反馈处理中。 - problemMarked: !isTimedOutForMe && item.status === 'problem', + // 份额级问题单也算「平台标记」:母单状态没变,但该打手这份被后台标记了。 + problemMarked: + !isTimedOutForMe && + (item.status === 'problem' || + activeShares.some( + (share) => share.status !== 'cancelled' && Boolean(share.problem_marked_at), + )), pendingCancelRequest: !isTimedOutForMe && pendingCancelOrderIds.has(workOrderId), pendingFeedback: !isTimedOutForMe && pendingFeedbackOrderIds.has(workOrderId), materialUpdated, @@ -234,25 +251,31 @@ export async function getWorkerMyOrderOverview(session: WorkerSession) { buildMyOrderCacheKey(worker.id, 'overview', 0), () => getWorkerOrderOverview(worker.id), ) - const items = rows.map((row) => ({ + const statusRows = rows.filter((row) => row.status !== 'all') + const items = statusRows.map((row) => ({ status: row.status, orderCount: row.orderCount, settlementAmount: row.settlementAmount, })) - const vipEvidencePending = rows.reduce( + const vipEvidencePending = statusRows.reduce( (summary, row) => ({ orderCount: summary.orderCount + row.vipEvidencePendingCount, settlementAmount: summary.settlementAmount + row.vipEvidencePendingSettlementAmount, }), { orderCount: 0, settlementAmount: 0 }, ) - const all = items.reduce( - (summary, item) => ({ - orderCount: summary.orderCount + item.orderCount, - settlementAmount: summary.settlementAmount + item.settlementAmount, - }), - { orderCount: 0, settlementAmount: 0 }, - ) + // 混合状态的多份额工单会同时出现在多个页签,不能再用各页签相加当「全部订单」, + // 否则同一工单会被重复计数;优先使用 SQL 里按 distinct 工单算好的 all 行。 + const allRow = rows.find((row) => row.status === 'all') + const all = allRow + ? { orderCount: allRow.orderCount, settlementAmount: allRow.settlementAmount } + : items.reduce( + (summary, item) => ({ + orderCount: summary.orderCount + item.orderCount, + settlementAmount: summary.settlementAmount + item.settlementAmount, + }), + { orderCount: 0, settlementAmount: 0 }, + ) return { items: [ ...items, @@ -372,9 +395,13 @@ export async function submitWorkerCancelRequest( }) } const now = nowIso() + const requestedShareId = Number(payload.shareId ?? payload.workOrderShareId ?? 0) + const shareId = + Number.isFinite(requestedShareId) && requestedShareId > 0 ? requestedShareId : null const result = await createWorkerCancelRequest({ workOrderId: Number(workOrderId), workerId: Number(session.workerId), + shareId, reason, now, }) diff --git a/apps/backend/src/services/worker-platform/worker-platform-notification-config-service.ts b/apps/backend/src/services/worker-platform/worker-platform-notification-config-service.ts index 76c7e604..9655be06 100644 --- a/apps/backend/src/services/worker-platform/worker-platform-notification-config-service.ts +++ b/apps/backend/src/services/worker-platform/worker-platform-notification-config-service.ts @@ -7,6 +7,7 @@ export const WORKER_PLATFORM_NOTIFICATION_EVENT_KEYS = [ 'recharge_requested', 'acceptance_submitted', 'acceptance_reminded', + 'order_accepted', 'material_required', 'cancel_requested', 'feedback_submitted', @@ -48,6 +49,7 @@ export function createDefaultWorkerPlatformNotificationConfig(): WorkerPlatformN recharge_requested: createEnabledEventConfig(), acceptance_submitted: createEnabledEventConfig(), acceptance_reminded: createEnabledEventConfig(), + order_accepted: createEnabledEventConfig(), material_required: { todoEnabled: false, soundEnabled: false, diff --git a/apps/backend/src/services/worker-platform/worker-platform-service.test.ts b/apps/backend/src/services/worker-platform/worker-platform-service.test.ts index 7c598e81..0075d444 100644 --- a/apps/backend/src/services/worker-platform/worker-platform-service.test.ts +++ b/apps/backend/src/services/worker-platform/worker-platform-service.test.ts @@ -18,11 +18,11 @@ import { buildWorkOrderWhere, buildWorkerUserWhere, resolveWorkOrderShareCancellationStatus, - resolveWorkOrderShareDepositAmount, + resolveWorkOrderShareDepositPlan, resolveWorkOrderSharingCancellationPlan, - resolveWorkOrderShareJoinMode, resolveWorkOrderShareJoinQuantity, resolveOpenSharingSyncReward, + recomputeWorkOrderSharingStatusWithClient, } from '../../repositories/worker-platform/index.js' import { resolveProblemReturnToWorkerStatus } from '../../repositories/worker-platform/work-order-issue-repo.js' import type { WorkOrderRow } from '../../repositories/worker-platform/types.js' @@ -1473,25 +1473,63 @@ test('个人免押额度与等级免押额度叠加', () => { ) }) -test('拼单按打手累计份额扣除个人免押额度', () => { - assert.equal( - resolveWorkOrderShareDepositAmount({ +test('拼单按打手在母单内的累计份额扣除免押额度', () => { + // 首次参与 2 份:累计需求 2000,本次冻结 2000 + assert.deepEqual( + resolveWorkOrderShareDepositPlan({ requiredDepositAmount: 10_000, totalQuantity: 10, - nextQuantity: 5, - depositFreeAmount: 3_000, + quantityBefore: 0, + nextQuantity: 2, + depositFreeAmount: 0, }), - 2_000, + { cumulativeRequired: 2_000, cumulativeDeposit: 2_000, increment: 2_000 }, ) - assert.equal( - resolveWorkOrderShareDepositAmount({ + + // 已冻结 1500 后再追加 1 份:累计需求 2000,只补差额 500,不回退 + assert.deepEqual( + resolveWorkOrderShareDepositPlan({ requiredDepositAmount: 10_000, totalQuantity: 10, - nextQuantity: 5, - depositFreeAmount: 8_000, - currentFrozenDeposit: 1_500, + quantityBefore: 1, + nextQuantity: 1, + depositFreeAmount: 0, + frozenDepositSoFar: 1_500, }), - 1_500, + { cumulativeRequired: 2_000, cumulativeDeposit: 2_000, increment: 500 }, + ) +}) + +test('拼单免押额度跟着母单走,在整个工单内只抵扣一次', () => { + const base = { + requiredDepositAmount: 10_000, + totalQuantity: 10, + depositFreeAmount: 5_000, + } + // 累计 2 份:需求 2000 < 免押 5000,不用冻结 + assert.equal( + resolveWorkOrderShareDepositPlan({ ...base, quantityBefore: 0, nextQuantity: 2 }).increment, + 0, + ) + // 累计 4 份:需求 4000 仍低于免押额度,继续不冻结(免押额没有被重复消耗) + assert.deepEqual( + resolveWorkOrderShareDepositPlan({ + ...base, + quantityBefore: 2, + nextQuantity: 2, + frozenDepositSoFar: 0, + }), + { cumulativeRequired: 4_000, cumulativeDeposit: 0, increment: 0 }, + ) + // 累计 6 份:需求 6000 超出免押 5000,仅对超出的 1000 冻结 + assert.deepEqual( + resolveWorkOrderShareDepositPlan({ + ...base, + quantityBefore: 4, + nextQuantity: 2, + frozenDepositSoFar: 0, + }), + { cumulativeRequired: 6_000, cumulativeDeposit: 1_000, increment: 1_000 }, ) }) @@ -1661,57 +1699,33 @@ test('拼单份数全部承接后不再显示在抢单大厅', () => { assert.equal(isWorkOrderSharingFilled(false, 15, 15), false) }) -test('拼单份额取消后允许原打手重新加入', () => { - assert.equal(resolveWorkOrderShareJoinMode({ existingShareStatus: 'cancelled' }), 'rejoin') - assert.equal( - resolveWorkOrderShareJoinMode({ existingShareStatus: 'cancelled', takeRemaining: true }), - 'rejoin', - ) -}) - -test('拼单份额未提交时只有立即抢单可以追加份数', () => { - assert.equal( - resolveWorkOrderShareJoinMode({ existingShareStatus: 'joined', takeRemaining: true }), - 'append', - ) - assert.equal(resolveWorkOrderShareJoinMode({ existingShareStatus: 'joined' }), 'reject') -}) - -test('拼单份额已提交或已验收后不再允许追加份数', () => { - assert.equal( - resolveWorkOrderShareJoinMode({ existingShareStatus: 'submitted', takeRemaining: true }), - 'reject', - ) - assert.equal( - resolveWorkOrderShareJoinMode({ existingShareStatus: 'accepted', takeRemaining: true }), - 'reject', - ) -}) - -test('打手首次参与拼单时新建份额', () => { - assert.equal(resolveWorkOrderShareJoinMode({ existingShareStatus: null }), 'create') -}) - -test('取消的拼单份额重新加入必须复用原行并重置超时与验收状态', async () => { +/** + * 同一打手可以多次拼单(需求),所以「取消后重新加入」不再靠复用原份额行: + * 迁移 109 删掉了「一人一条」唯一键,每次参与都插入新份额,取消的历史份额保留追溯。 + */ +test('拼单份额取消后原打手可以重新参与(一人一条唯一键已移除)', async () => { const { readFile } = await import('node:fs/promises') + const migration = await readFile( + new URL('../../db/migrations/109_work_order_share_participation.sql', import.meta.url), + 'utf8', + ) + assert.ok( + migration.includes('DROP CONSTRAINT work_order_shares_work_order_id_worker_id_key'), + '必须移除「同一工单同一打手只能一条份额」唯一键,否则取消后再次参与会 23505', + ) const shareRepoSrc = await readFile( new URL('../../repositories/worker-platform/work-order-share-repo.ts', import.meta.url), 'utf8', ) assert.ok( - shareRepoSrc.includes('resolveWorkOrderShareJoinMode'), - 'joinWorkOrderShare 必须按份额状态区分新建、追加、重入与拒绝', + !shareRepoSrc.includes('worker_share_locked') && !shareRepoSrc.includes('worker_share_exists'), + '再次参与不得因已有(已取消)份额被拒绝,取消的份额只是历史记录', ) assert.ok( - shareRepoSrc.includes("status = 'joined',") && - shareRepoSrc.includes('created_at = $5') && - shareRepoSrc.includes('submitted_at = NULL') && - shareRepoSrc.includes('accepted_at = NULL'), - '复用 cancelled 份额时必须重置 created_at(超时时钟)与验收时间,避免重入后立即被判超时', - ) - assert.ok( - shareRepoSrc.includes('const currentFrozenDeposit = revivingCancelledShare'), - '取消份额的押金已退还,重新加入时必须按已有押金为 0 重新计算', + shareRepoSrc.includes( + "FROM work_order_shares\n WHERE work_order_id = $1 AND worker_id = $2 AND status != 'cancelled'", + ), + '累计份数与已冻押金只能统计未取消份额,取消后重入才会从 0 开始', ) }) @@ -1862,7 +1876,10 @@ test('打手订单状态筛选使用个人拼单份额状态', () => { assert.deepEqual(params, [42, ['in_progress', 'pending_acceptance']]) assert.match(joins, /work_order_shares share_source/) - assert.match(joins, /worker_share\.worker_id = \$1/) + // 份额关联改为按工单取一条代表份额,避免多份拼单把列表/统计行放大。 + assert.match(joins, /LEFT JOIN LATERAL \(/) + assert.match(joins, /wos\.worker_id = \$1/) + assert.match(joins, /\) worker_share ON true/) assert.match(whereClause, /WHEN 'joined' THEN 'in_progress'/) assert.match(whereClause, /WHEN 'submitted' THEN 'pending_acceptance'/) assert.match(whereClause, /worker_share\.status/) @@ -3151,22 +3168,22 @@ test('打手端「我的订单」必须走"先取 id 再取详情"的延迟分 assert.ok(src.includes('rowsById.get(id)'), 'ANY() 不保证顺序,必须按 id 顺序还原分页结果') }) -test('大厅候选集必须优先保留置顶单,不能被容量上限挤出', async () => { +test('大厅候选集必须把置顶单无条件并入,不能被 FIFO 容量上限挤出', async () => { const { readFile } = await import('node:fs/promises') const src = await readFile( new URL('../../repositories/worker-platform/work-order-query-repo.ts', import.meta.url), 'utf8', ) const start = src.indexOf('function buildHallCandidateIdsSql') - const hallSql = src.slice(start, start + 2600) - assert.equal( - (hallSql.match(/candidate\.pinned_at DESC NULLS LAST/g) || []).length, - 2, - '主分类与子分类的窗口排名都必须优先置顶单', + const hallSql = src.slice(start, start + 4200) + assert.ok(hallSql.includes('UNION'), '置顶单没有并入候选集:FIFO 窗口外就抢不到/拼不了') + assert.ok( + hallSql.includes('pinned.pinned_at IS NOT NULL'), + '置顶分支必须只取 status=open 且 pinned_at 非空的单', ) assert.ok( - hallSql.includes('ranked.pinned_at DESC NULLS LAST'), - '最终候选集 LIMIT 前必须优先置顶单,否则置顶单会被新单挤出大厅', + /LIMIT \$\{limitPlaceholder\}/.test(hallSql), + 'FIFO 窗口仍要受容量上限约束,置顶单只做额外并入', ) }) @@ -3238,6 +3255,41 @@ test('接单排行榜必须有短缓存(单次全表聚合 277MB/742ms,独 ) }) +test('大厅卡片要带个人份额,拼单成功后立刻失效大厅缓存(按钮显示「再拼一次」)', async () => { + const { readFile } = await import('node:fs/promises') + const hallSrc = await readFile(new URL('./worker-hall-service.ts', import.meta.url), 'utf8') + const listBody = hallSrc.slice( + hallSrc.indexOf('export async function listWorkerHallOrders'), + hallSrc.indexOf('export async function getWorkerHallCategories'), + ) + assert.ok(listBody.includes('mySharesByOrderId'), '大厅没有按工单收集个人份额') + assert.ok( + listBody.includes('mapWorkOrderForHall(') && /myShares,/.test(listBody), + '大厅响应缺少 myShares(必须经统一映射器输出,避免手写字段漏项)', + ) + const mapperSrc = await readFile( + new URL('./work-order-display-mappers.ts', import.meta.url), + 'utf8', + ) + assert.ok(mapperSrc.includes('myShares: myShares.map('), '大厅映射器缺少 myShares') + assert.ok( + hallSrc.includes('evictByPrefix(`[${workerId},`)'), + '大厅缓存缺少按打手失效的入口(拼单后的列表会命中 2s 旧缓存)', + ) + + const actionSrc = await readFile( + new URL('./worker-hall-action-service.ts', import.meta.url), + 'utf8', + ) + const joinBody = actionSrc.slice( + actionSrc.indexOf('export async function joinWorkerSharingOrder'), + ) + assert.ok( + joinBody.includes('invalidateWorkerHallListCache([worker.id])'), + '拼单成功后必须失效大厅缓存,否则按钮不会变成「再拼一次」', + ) +}) + test('已冻结打手调级或变更资料不应因未传 freezeReason 报错', async () => { const { readFile } = await import('node:fs/promises') const src = await readFile(new URL('./admin-worker-user-service.ts', import.meta.url), 'utf8') @@ -3311,3 +3363,250 @@ test('拼单超时处置链路完整性验证', async () => { 'settleOverdueWorkOrderShare 必须将超时份额置为 cancelled 并释放/扣除押金', ) }) + +type ShareAggregate = { + allocated: number + joined_count: number + submitted_count: number + accepted_count: number +} + +async function runRecompute( + workOrder: Partial, + aggregate: ShareAggregate, +): Promise<{ status: string | null; updates: unknown[][]; shareQueries: number }> { + const updates: unknown[][] = [] + let shareQueries = 0 + const client = { + query: async (sql: string, params: unknown[] = []) => { + if (sql.includes('UPDATE work_orders')) { + updates.push(params) + return { rows: [{ ...buildWorkOrderRow(workOrder), status: params[0] }] } + } + if (sql.includes('FROM work_orders')) { + return { rows: [buildWorkOrderRow(workOrder)] } + } + if (sql.includes('FROM work_order_shares')) { + shareQueries += 1 + return { rows: [aggregate] } + } + return { rows: [] } + }, + } as unknown as PoolClient + + const updated = await recomputeWorkOrderSharingStatusWithClient( + client, + 1, + '2026-10-23T12:00:00.000Z', + ) + return { status: updated ? String(updated.status) : null, updates, shareQueries } +} + +test('拼单母单状态由份额集合唯一推导:满额且无待履约份额才转待验收', async () => { + const pending = await runRecompute( + { status: 'open', sharing_total_quantity: 10 }, + { allocated: 10, joined_count: 0, submitted_count: 2, accepted_count: 0 }, + ) + assert.equal(pending.status, 'pending_acceptance') + assert.equal(pending.updates.length, 1) + + // 一份超时后另一份仍在履约(joined_count > 0):母单回到 open,不能提前待验收 + const stillOpen = await runRecompute( + { status: 'pending_acceptance', sharing_total_quantity: 10 }, + { allocated: 9, joined_count: 1, submitted_count: 1, accepted_count: 0 }, + ) + assert.equal(stillOpen.status, 'open') + assert.equal(stillOpen.updates.length, 1) + + // 未满额也必须是 open + const partial = await runRecompute( + { status: 'pending_acceptance', sharing_total_quantity: 10 }, + { allocated: 4, joined_count: 0, submitted_count: 1, accepted_count: 0 }, + ) + assert.equal(partial.status, 'open') +}) + +test('拼单母单已验收或已取消时 recompute 不回头改状态', async () => { + for (const status of ['accepted', 'cancelled']) { + const result = await runRecompute( + { status, sharing_total_quantity: 10 }, + { allocated: 10, joined_count: 0, submitted_count: 0, accepted_count: 3 }, + ) + assert.equal(result.status, status) + assert.equal(result.updates.length, 0) + assert.equal(result.shareQueries, 0, '终态母单连份额聚合都不该再跑') + } +}) + +test('后台单人撤单不改母单状态;份额超时仍交给 recompute 收敛', async () => { + const src = fs.readFileSync( + new URL('../../repositories/worker-platform/work-order-settlement-repo.ts', import.meta.url), + 'utf8', + ) + const cancelBlock = src.slice( + src.indexOf('export async function cancelWorkOrderShare('), + src.indexOf('export async function settleOverdueWorkOrderShare('), + ) + assert.ok(cancelBlock.length > 0, 'cancelWorkOrderShare 片段必须存在') + assert.ok( + !cancelBlock.includes('recomputeWorkOrderSharingStatusWithClient('), + '后台单人撤单不得重算母单状态(撤一份会把待验收的母单踢回大厅)', + ) + assert.ok( + !/UPDATE\s+work_orders\s+SET[\s\S]{0,80}?status\s*=/i.test(cancelBlock), + '后台单人撤单不得自己写母单 status', + ) + assert.ok(cancelBlock.includes('toStatus: workOrder.status'), '撤单事件不得记录母单状态迁移') + + const timeoutBlock = src.slice( + src.indexOf('export async function settleOverdueWorkOrderShare('), + src.indexOf('export async function cancelWorkOrderSharing('), + ) + assert.ok(timeoutBlock.length > 0, 'settleOverdueWorkOrderShare 片段必须存在') + assert.ok( + timeoutBlock.includes('recomputeWorkOrderSharingStatusWithClient('), + '份额超时必须用 recompute 收敛母单状态', + ) + assert.ok( + !/UPDATE\s+work_orders\s+SET[\s\S]{0,80}?status\s*=/i.test(timeoutBlock), + '份额超时不得自己写母单 status(只能由 recompute 推导)', + ) +}) + +test('撤份额与单人撤单都把原因和份数写进份额行,列表才能显示「撤 N 份 · 原因」', () => { + const settlementSrc = fs.readFileSync( + new URL('../../repositories/worker-platform/work-order-settlement-repo.ts', import.meta.url), + 'utf8', + ) + const cancelBlock = settlementSrc.slice( + settlementSrc.indexOf('export async function cancelWorkOrderShare('), + settlementSrc.indexOf('export async function settleOverdueWorkOrderShare('), + ) + assert.ok(cancelBlock.length > 0, 'cancelWorkOrderShare 片段必须存在') + assert.ok(cancelBlock.includes('cancel_note = $'), '单人撤单必须把原因写进 cancel_note') + assert.ok( + cancelBlock.includes("String(input.reason || '').trim()"), + '单人撤单写入 cancel_note 的必须是运营填写的自由文本原因', + ) + assert.ok( + cancelBlock.includes("cancelled_reason = 'admin'"), + 'cancelled_reason 仍保留枚举口径,自由文本不得塞进去', + ) + assert.ok( + cancelBlock.includes('cancel_quantity = COALESCE(quantity, 0)'), + '单人撤单要按整份数量记录撤销份数,列表才能显示「撤 N 份」', + ) + + const shareSrc = fs.readFileSync( + new URL('../../repositories/worker-platform/work-order-share-repo.ts', import.meta.url), + 'utf8', + ) + const reduceBlock = shareSrc.slice( + shareSrc.indexOf('export async function reduceWorkOrderShareQuantity('), + shareSrc.indexOf('export async function submitWorkOrderShareAcceptance('), + ) + assert.ok(reduceBlock.length > 0, 'reduceWorkOrderShareQuantity 片段必须存在') + assert.ok(reduceBlock.includes('cancel_note = $'), '撤份额必须把原因写进 cancel_note') + assert.ok( + reduceBlock.includes('String(share.cancel_note'), + '后续撤份额没填原因时必须保留上一次填的说明', + ) + assert.ok( + reduceBlock.includes('cancel_quantity = $') && + reduceBlock.includes('Number(share.cancel_quantity'), + '撤份额必须按「已有撤销份数 + 本次份数」累计写入 cancel_quantity', + ) + + const mapperSrc = fs.readFileSync( + new URL('./work-order-display-mappers.ts', import.meta.url), + 'utf8', + ) + assert.ok( + mapperSrc.includes('cancelNote: share.cancel_note'), + '拼单明细接口必须下发 cancelNote,否则列表显示不出来', + ) + assert.ok( + mapperSrc.includes('cancelQuantity: Math.max(0, Number(share.cancel_quantity'), + '拼单明细接口必须下发 cancelQuantity,列表才能拼「撤 N 份」前缀', + ) +}) + +test('份额级问题单标记/处置只写份额字段,不得改母单状态或触发整单重算', () => { + const src = fs.readFileSync( + new URL('../../repositories/worker-platform/work-order-issue-repo.ts', import.meta.url), + 'utf8', + ) + const markBlock = src.slice( + src.indexOf('export async function markWorkOrderShareProblem('), + src.indexOf('export async function disposeWorkOrderShareProblem('), + ) + assert.ok(markBlock.length > 0, 'markWorkOrderShareProblem 片段必须存在') + assert.ok(markBlock.includes('UPDATE work_order_shares'), '标记必须写份额表') + assert.ok( + !/UPDATE\s+work_orders/i.test(markBlock), + '份额级问题单不得写母单(含 status / problem_note)', + ) + assert.ok( + !markBlock.includes('recomputeWorkOrderSharingStatusWithClient('), + '份额级问题单不得触发整单状态重算', + ) + assert.ok( + markBlock.includes('toStatus: workOrder.status') && + markBlock.includes('fromStatus: workOrder.status'), + '标记事件不得记录母单状态迁移', + ) + + const disposeBlock = src.slice( + src.indexOf('export async function disposeWorkOrderShareProblem('), + src.indexOf('export async function settleOverdueWorkOrder('), + ) + assert.ok(disposeBlock.length > 0, 'disposeWorkOrderShareProblem 片段必须存在') + assert.ok(disposeBlock.includes('UPDATE work_order_shares'), '处置必须写份额表') + assert.ok(!/UPDATE\s+work_orders/i.test(disposeBlock), '份额处置不得改母单') + assert.ok( + !disposeBlock.includes('recomputeWorkOrderSharingStatusWithClient('), + '份额处置不得触发整单状态重算', + ) + assert.ok( + disposeBlock.includes('toStatus: workOrder.status') && + disposeBlock.includes('fromStatus: workOrder.status'), + '处置事件不得记录母单状态迁移', + ) + assert.ok( + disposeBlock.includes("status = CASE WHEN status = 'submitted' THEN 'joined'"), + '退回打手必须把已提交的份额恢复为进行中', + ) + assert.ok( + disposeBlock.includes("shouldDeduct ? 'deposit_deduction' : 'deposit_release'"), + '取消类处置必须按方式退还或扣除押金', + ) + assert.ok( + disposeBlock.includes("cancelled_reason = 'problem'"), + '取消类处置必须记录问题单处置来源', + ) +}) + +test('拼单每次参与都新增一条份额,并按 requestId 幂等、按参与时间固化截止时间', () => { + const src = fs.readFileSync( + new URL('../../repositories/worker-platform/work-order-share-repo.ts', import.meta.url), + 'utf8', + ) + assert.ok( + src.includes('wos.worker_id = $1 AND wos.request_id = $2'), + '参与前必须按 (worker_id, request_id) 查既有份额', + ) + assert.ok(src.includes('duplicated: true'), '命中幂等键必须返回 duplicated 标记') + assert.ok( + src.includes('INSERT INTO work_order_shares (') && + !/UPDATE\s+work_order_shares[\s\S]{0,120}?quantity\s*=\s*quantity\s*\+/i.test(src), + '每次参与都必须 INSERT 新份额,不得把份数加到原记录上', + ) + assert.ok( + src.includes('timeoutMinutes * 60 * 1000') && src.includes('deadline_at, timeout_minutes'), + 'deadline 必须按本次参与时间 + 母单当时的 timeout_minutes 固化', + ) + assert.ok( + src.includes('quantityBefore') && src.includes('frozenDepositSoFar: frozenBefore'), + '押金必须按该打手在本工单的累计份数与已冻结合计计算增量', + ) +}) diff --git a/apps/backend/src/types/runtime-config.ts b/apps/backend/src/types/runtime-config.ts index 0479584a..0239e9c4 100644 --- a/apps/backend/src/types/runtime-config.ts +++ b/apps/backend/src/types/runtime-config.ts @@ -126,6 +126,21 @@ export type RuntimeConfig = { sessionIdleTtlHours: number /** 打手会话从登录起算的绝对上限,滑动续期不能突破。 */ sessionAbsoluteTtlHours: number + /** + * 验收视频(打手端验收弹窗,选填)。 + * + * 只用 `.env` 配置,不提供后台管理界面;前端按这些参数在本机压缩, + * 服务端用同一份参数做时长/体积兜底校验。 + */ + acceptanceVideo: { + enabled: boolean + maxDurationSeconds: number + maxSizeMb: number + targetHeight: number + bitrateKbps: number + audioBitrateKbps: number + fps: number + } } cors: { allowedOrigins: string[] diff --git a/apps/frontend/src/components/WorkOrderEventTimeline.tsx b/apps/frontend/src/components/WorkOrderEventTimeline.tsx index 95f9da5c..c5393fa0 100644 --- a/apps/frontend/src/components/WorkOrderEventTimeline.tsx +++ b/apps/frontend/src/components/WorkOrderEventTimeline.tsx @@ -34,6 +34,18 @@ function formatStatusLabel(status: string) { return STATUS_LABELS[status] || status || '未知' } +/** 份额级问题单处置动作中文名(与后台弹窗的四个选项一一对应) */ +const SHARE_PROBLEM_ACTION_LABELS: Record = { + return_to_worker: '退回打手继续处理', + reopen: '取消该份额并退回接单大厅', + cancel_release: '取消该份额并退还押金', + cancel_deduct: '取消该份额并扣除押金', +} + +function formatShareProblemAction(action: string) { + return SHARE_PROBLEM_ACTION_LABELS[action] || '' +} + function readPayload(event: WorkOrderEvent, key: string): string { const value = event.payload?.[key] if (value === undefined || value === null) return '' @@ -311,6 +323,88 @@ export function formatWorkOrderEvent(event: WorkOrderEvent): EventNodeStyle { } case 'sharing_config_updated': return { title: '更新了拼单配置', color: 'default' } + case 'sharing_joined': { + const workerLabel = resolveEventWorkerLabel(event) + const quantity = readPayloadAmount(event, 'quantity') + const deadlineAt = readPayload(event, 'deadlineAt') + const timeoutMinutes = readPayloadAmount(event, 'timeoutMinutes') + const deadlineText = deadlineAt + ? `截止 ${dayjs(deadlineAt).format('MM-DD HH:mm')}` + : timeoutMinutes > 0 + ? `限时 ${Math.round(timeoutMinutes)} 分钟` + : '不限时' + return { + title: workerLabel ? `打手参与拼单(${workerLabel})` : '打手参与拼单', + description: [quantity > 0 ? `参与 ${quantity} 份` : '', deadlineText] + .filter(Boolean) + .join(' · '), + color: 'purple', + } + } + case 'sharing_share_quantity_reduced': { + const workerLabel = resolveEventWorkerLabel(event) + const fromQuantity = readPayloadAmount(event, 'fromQuantity') + const toQuantity = readPayloadAmount(event, 'toQuantity') + const reduceQuantity = readPayloadAmount(event, 'reduceQuantity') + const details = [ + fromQuantity > 0 && toQuantity > 0 ? `${fromQuantity} 份 → ${toQuantity} 份` : '', + reduceQuantity > 0 ? `撤销 ${reduceQuantity} 份` : '', + note ? `原因:${note}` : '', + '撤掉的份数已退回接单大厅,押金保持冻结', + ].filter(Boolean) + return { + title: workerLabel ? `撤销部分拼单份额(${workerLabel})` : '撤销部分拼单份额', + description: details.join(';'), + color: 'orange', + } + } + case 'sharing_share_marked_problem': { + const workerLabel = resolveEventWorkerLabel(event) + return { + title: workerLabel ? `标记拼单份额问题单(${workerLabel})` : '标记拼单份额问题单', + description: note + ? `原因:${note};仅该份额受影响,母单状态不变` + : '未填备注;仅该份额受影响,母单状态不变', + color: 'red', + } + } + case 'sharing_share_problem_disposed': { + const workerLabel = resolveEventWorkerLabel(event) + const actionLabel = formatShareProblemAction(readPayload(event, 'action')) + const depositAmount = readPayloadAmount(event, 'depositAmount') + const details = [ + actionLabel ? `处理方式:${actionLabel}` : '', + depositAmount > 0 ? `涉及押金 ${formatFenAmount(depositAmount)}` : '', + note ? `备注:${note}` : '', + ].filter(Boolean) + return { + title: workerLabel ? `处置拼单份额问题单(${workerLabel})` : '处置拼单份额问题单', + description: details.length > 0 ? details.join(';') : undefined, + color: 'orange', + } + } + case 'sharing_acceptance_draft_saved': + case 'sharing_acceptance_updated': { + const workerLabel = resolveEventWorkerLabel(event) + const imageCount = readPayloadAmount(event, 'imageCount') + const hasVideo = Boolean(event.payload?.hasVideo) + const details = [ + imageCount > 0 ? `验收图 ${imageCount} 张` : '', + hasVideo ? '含验收视频' : '', + ].filter(Boolean) + return { + title: + type === 'sharing_acceptance_draft_saved' + ? workerLabel + ? `保存了拼单验收草稿(${workerLabel})` + : '保存了拼单验收草稿' + : workerLabel + ? `更新了拼单验收资料(${workerLabel})` + : '更新了拼单验收资料', + description: details.length > 0 ? details.join(' · ') : undefined, + color: 'purple', + } + } case 'price_synced_from_rule': { const rewardAmount = Number(readPayload(event, 'rewardAmount') || 0) const unitReward = Number(readPayload(event, 'sharingUnitReward') || 0) diff --git a/apps/frontend/src/components/files/ImageUpload.tsx b/apps/frontend/src/components/files/ImageUpload.tsx index 6ecaf21e..18611d43 100644 --- a/apps/frontend/src/components/files/ImageUpload.tsx +++ b/apps/frontend/src/components/files/ImageUpload.tsx @@ -1,11 +1,13 @@ -import { CloudUploadOutlined, PlusOutlined } from '@ant-design/icons' -import { App, Typography, Upload } from 'antd' +import { CameraOutlined, CloudUploadOutlined, PlusOutlined } from '@ant-design/icons' +import { App, Button, Typography, Upload } from 'antd' import type { UploadFile, UploadProps } from 'antd' import { useEffect, useRef, useState } from 'react' -import type { ClipboardEvent, DragEvent, FocusEvent, MouseEvent } from 'react' +import type { ChangeEvent, ClipboardEvent, DragEvent, FocusEvent, MouseEvent } from 'react' import { uploadFile } from '@/services/files' import type { UploadedFile } from '@/types/worker-platform' +import { cameraCaptureInputProps, resolveCameraEntryEnabled } from '@/utils/camera-capture' +import { useIsMobile } from '@/utils/use-is-mobile' type ImageUploadProps = { value?: UploadedFile[] @@ -16,6 +18,8 @@ type ImageUploadProps = { showPasteHint?: boolean /** 开启后,页面任意位置粘贴剪贴板截图都会上传到本组件(多实例时以最近点击/挂载的为准) */ captureGlobalPaste?: boolean + /** 是否显示移动端「拍照」入口;不传时按「打手端 + 移动端」自动判断 */ + allowCamera?: boolean } type GlobalPasteTarget = { @@ -62,14 +66,19 @@ export default function ImageUpload({ maxCount = 6, showPasteHint = false, captureGlobalPaste = false, + allowCamera, }: ImageUploadProps) { const { message } = App.useApp() + const isMobile = useIsMobile() const [isDragging, setIsDragging] = useState(false) const dragCounterRef = useRef(0) const targetIdRef = useRef(0) const valueRef = useRef(value) valueRef.current = value + const cameraEnabled = resolveCameraEntryEnabled({ isMobile, scope, allowCamera }) + const cameraInputProps = cameraCaptureInputProps('image') + const fileList: UploadFile[] = value.map((file) => ({ uid: file.objectKey || file.url, name: file.filename || '图片', @@ -105,6 +114,15 @@ export default function ImageUpload({ await uploadImages(images, source) } + /** 系统相机拍照:一次一张,省去 multiple(部分浏览器在 capture 模式下会忽略多选)。 */ + async function handleCameraSelect(event: ChangeEvent) { + const file = event.target.files?.[0] + // 让「再拍同一张」也能再次触发 change。 + event.target.value = '' + if (!file) return + await uploadImages([file]) + } + function handlePaste(event: ClipboardEvent) { const files = Array.from(event.clipboardData?.files || []) if (files.length === 0) return @@ -237,9 +255,34 @@ export default function ImageUpload({ )} + {cameraEnabled && value.length < maxCount ? ( +
+ {/* + 相机入口用「透明原生 input 铺满按钮」的方式: + 用户手指直接点在 input 上,浏览器才会把它当作拍照手势; + 旧实现把 input 移到 left:-9999px 再由按钮脚本 .click(),部分安卓 WebView + 会把相机 intent 降级成文件选择(表现为点了「拍照」却弹出文件浏览)。 + */} + + void handleCameraSelect(event)} + /> + + +
+ ) : null} {showPasteHint ? ( - 点击选择图片上传;截图后可直接按 Ctrl+V / ⌘V 粘贴,或拖拽图片到此处 + {cameraEnabled + ? '点击「上传」从相册选择,或点「拍照」直接调用相机' + : '点击选择图片上传;截图后可直接按 Ctrl+V / ⌘V 粘贴,或拖拽图片到此处'} ) : null} diff --git a/apps/frontend/src/components/files/VideoPreview.tsx b/apps/frontend/src/components/files/VideoPreview.tsx new file mode 100644 index 00000000..8362872f --- /dev/null +++ b/apps/frontend/src/components/files/VideoPreview.tsx @@ -0,0 +1,57 @@ +import { VideoCameraOutlined } from '@ant-design/icons' +import { Typography } from 'antd' + +import type { WorkerAcceptanceVideo } from '@/types/worker-platform' + +type VideoPreviewProps = { + video?: WorkerAcceptanceVideo | null + maxHeight?: number + emptyText?: string + showMeta?: boolean +} + +/** 验收视频只读预览(打手端详情、后台工单详情、售后固化快照共用)。 */ +export default function VideoPreview({ + video = null, + maxHeight = 220, + emptyText = '未上传验收视频', + showMeta = true, +}: VideoPreviewProps) { + if (!video?.url) { + return {emptyText} + } + return ( +
+
+ ) +} + +export function formatAcceptanceVideoDuration(seconds?: number) { + const total = Math.max(0, Math.round(Number(seconds) || 0)) + if (!total) return '时长未知' + const minutes = Math.floor(total / 60) + const rest = total % 60 + return minutes > 0 ? `${minutes}分${String(rest).padStart(2, '0')}秒` : `${rest}秒` +} + +function formatAcceptanceVideoSize(bytes: number) { + const size = Number(bytes || 0) + if (!size) return '' + if (size >= 1024 * 1024) return `${(size / (1024 * 1024)).toFixed(1)}MB` + return `${Math.max(1, Math.round(size / 1024))}KB` +} diff --git a/apps/frontend/src/components/files/VideoUpload.tsx b/apps/frontend/src/components/files/VideoUpload.tsx new file mode 100644 index 00000000..7bf54d7c --- /dev/null +++ b/apps/frontend/src/components/files/VideoUpload.tsx @@ -0,0 +1,270 @@ +import { + CameraOutlined, + DeleteOutlined, + LoadingOutlined, + VideoCameraOutlined, +} from '@ant-design/icons' +import { App, Button, Progress, Space, Typography } from 'antd' +import { useQuery } from '@tanstack/react-query' +import { useRef, useState, type ChangeEvent } from 'react' + +import { uploadVideoFile } from '@/services/files' +import { fetchWorkerAcceptanceConfig } from '@/services/worker' +import type { WorkerAcceptanceVideo } from '@/types/worker-platform' +import { cameraCaptureInputProps, resolveCameraEntryEnabled } from '@/utils/camera-capture' +import { useIsMobile } from '@/utils/use-is-mobile' +import { + compressVideoForUpload, + isVideoCompressionSupported, + probeVideoFile, + resolveVideoDurationLimitError, + type AcceptanceVideoConfig, +} from '@/utils/video-compress' + +type VideoUploadProps = { + value?: WorkerAcceptanceVideo | null + onChange?: (video: WorkerAcceptanceVideo | null) => void + disabled?: boolean + /** 是否显示移动端「拍摄」入口;不传时按移动端自动判断(本组件仅用于打手端) */ + allowCamera?: boolean +} + +const VIDEO_ACCEPT = 'video/mp4,video/webm,video/quicktime,video/*' + +/** + * 验收视频上传(非必填,单个)。 + * + * 流程:选片 → 读时长/分辨率 → 超过配置上限直接拒绝(不裁剪)→ Canvas 重编码为 + * 720p/1.2Mbps(见 utils/video-compress)→ 上传 → 回填视频信息。 + * 浏览器不支持 MediaRecorder / captureStream 时直接提示更换浏览器,不上传原片。 + * + * 移动端额外提供「拍摄 / 重拍」入口(capture=environment 调起系统相机); + * 录制时长由系统相机控制,HTML 无法强制限制,超长由时长校验拦下。 + */ +export default function VideoUpload({ + value = null, + onChange, + disabled = false, + allowCamera, +}: VideoUploadProps) { + const { message } = App.useApp() + const isMobile = useIsMobile() + const inputRef = useRef(null) + const [phase, setPhase] = useState<'idle' | 'compressing' | 'uploading'>('idle') + const [progress, setProgress] = useState(0) + + const cameraEnabled = resolveCameraEntryEnabled({ isMobile, scope: 'worker', allowCamera }) + const cameraInputProps = cameraCaptureInputProps('video') + + const configQuery = useQuery({ + queryKey: ['worker-acceptance-config'], + queryFn: fetchWorkerAcceptanceConfig, + staleTime: 5 * 60_000, + refetchOnWindowFocus: false, + retry: false, + }) + const config = configQuery.data?.data as AcceptanceVideoConfig | undefined + const supported = isVideoCompressionSupported() + const busy = phase !== 'idle' + const canPick = Boolean(config?.enabled) && supported && !disabled && !busy + + async function processFile(file: File | undefined) { + if (!file || !config) return + if (!config.enabled) { + message.error('验收视频功能暂未开启') + return + } + if (!supported) { + message.error('当前浏览器不支持视频压缩,请使用 Chrome / Edge,或先裁剪视频后再上传') + return + } + + setPhase('compressing') + setProgress(0) + try { + const probe = await probeVideoFile(file) + const limitError = resolveVideoDurationLimitError( + probe.durationSeconds, + config.maxDurationSeconds, + ) + if (limitError) throw new Error(limitError) + + const compressed = await compressVideoForUpload(file, config, setProgress) + if (compressed.blob.size > config.maxSizeMb * 1024 * 1024) { + throw new Error(`压缩后仍超过 ${config.maxSizeMb}MB,请缩短视频后重试`) + } + + setPhase('uploading') + setProgress(0) + const uploadName = `acceptance-${Date.now()}.${compressed.extension}` + const uploaded = await uploadVideoFile( + new File([compressed.blob], uploadName, { type: compressed.contentType }), + 'worker-acceptance-video', + compressed.durationSeconds, + ) + onChange?.({ + objectKey: uploaded.objectKey, + url: uploaded.url, + filename: uploaded.filename || uploadName, + contentType: uploaded.contentType || compressed.contentType, + size: uploaded.size || compressed.blob.size, + durationSeconds: uploaded.durationSeconds || compressed.durationSeconds, + width: compressed.width, + height: compressed.height, + }) + message.success('验收视频已上传') + } catch (error) { + message.error(error instanceof Error ? error.message : '视频处理失败,请重试') + } finally { + setPhase('idle') + setProgress(0) + } + } + + function handleSelect(event: ChangeEvent) { + const file = event.target.files?.[0] + // 让「选择/拍摄同一个文件」也能再次触发 change。 + event.target.value = '' + void processFile(file) + } + + const hint = config + ? cameraEnabled + ? `选填,拍摄请控制在 ${config.maxDurationSeconds} 秒内(超时会被拒绝);上传前自动压缩到 ${config.targetHeight}p,约 ${estimatedSizeMb(config)}MB 以内` + : `选填,最长 ${config.maxDurationSeconds} 秒;上传前会自动压缩到 ${config.targetHeight}p,约 ${estimatedSizeMb(config)}MB 以内` + : '选填;正在读取验收视频配置…' + + return ( +
+ + + {value ? ( +
+
+ ) : ( + + {busy ? ( + + ) : ( + + {cameraEnabled ? ( + // 同「重拍」:透明原生 input 铺满按钮,靠真实手势调起系统相机。 + + + + + ) : null} + + + )} + + {hint} + + + )} + + {busy ? ( + `${phase === 'compressing' ? '压缩中' : '上传中'} ${percent ?? 0}%`} + style={{ marginTop: 8 }} + /> + ) : null} +
+ ) +} + +function estimatedSizeMb(config: AcceptanceVideoConfig) { + const bytesPerSecond = ((config.bitrateKbps + config.audioBitrateKbps) * 1000) / 8 + const estimated = (bytesPerSecond * config.maxDurationSeconds) / (1024 * 1024) + return Math.max(1, Math.ceil(estimated)) +} + +function formatDuration(seconds: number) { + const total = Math.max(0, Math.round(Number(seconds) || 0)) + const minutes = Math.floor(total / 60) + const rest = total % 60 + return minutes > 0 ? `${minutes}分${String(rest).padStart(2, '0')}秒` : `${rest}秒` +} + +function formatSize(bytes?: number) { + const size = Number(bytes || 0) + if (!size) return '大小未知' + if (size >= 1024 * 1024) return `${(size / (1024 * 1024)).toFixed(1)}MB` + return `${Math.max(1, Math.round(size / 1024))}KB` +} diff --git a/apps/frontend/src/components/worker/WorkerAnnouncementPopup.tsx b/apps/frontend/src/components/worker/WorkerAnnouncementPopup.tsx new file mode 100644 index 00000000..c64d0e2d --- /dev/null +++ b/apps/frontend/src/components/worker/WorkerAnnouncementPopup.tsx @@ -0,0 +1,76 @@ +import { SoundOutlined } from '@ant-design/icons' +import { useQuery } from '@tanstack/react-query' +import { Button, Modal, Typography } from 'antd' +import { useEffect, useState } from 'react' + +import { fetchWorkerAnnouncement } from '@/services/worker' +import { + readAnnouncementAck, + resolveAnnouncementVersion, + shouldShowAnnouncementPopup, + writeAnnouncementAck, +} from '@/utils/announcement-ack' + +/** + * 打手端公告弹窗。 + * + * 后台「打手端滚动公告」一改动正文,就会在打手端下次进入时弹出;必须点「我已知晓」 + * 才能关闭(遮罩/ESC 不生效)。确认结果按公告内容指纹记在 localStorage, + * 同一份公告只弹一次,改了正文会重新弹。 + */ +export default function WorkerAnnouncementPopup() { + const [acknowledgedVersion, setAcknowledgedVersion] = useState(() => readAnnouncementAck()) + const [open, setOpen] = useState(false) + + const announcementQuery = useQuery({ + queryKey: ['worker-announcement'], + queryFn: fetchWorkerAnnouncement, + retry: false, + // 全局默认 staleTime: Infinity,公告需要在下一次进入页面时重新拉取 + staleTime: 5 * 60_000, + }) + const announcement = announcementQuery.data?.data + const content = String(announcement?.content || '').trim() + + useEffect(() => { + const shouldShow = shouldShowAnnouncementPopup({ + enabled: announcement?.enabled, + content, + acknowledgedVersion, + }) + if (shouldShow) setOpen(true) + }, [announcement?.enabled, content, acknowledgedVersion]) + + function acknowledge() { + const version = resolveAnnouncementVersion(content) + writeAnnouncementAck(version) + setAcknowledgedVersion(version) + setOpen(false) + } + + return ( + + 公告 + + } + closable={false} + maskClosable={false} + keyboard={false} + centered + width={460} + className="worker-announcement-popup" + footer={ + + } + > + + {content} + + + ) +} diff --git a/apps/frontend/src/hooks/useRealtimeEvents.ts b/apps/frontend/src/hooks/useRealtimeEvents.ts index f22d5900..ea3c88cc 100644 --- a/apps/frontend/src/hooks/useRealtimeEvents.ts +++ b/apps/frontend/src/hooks/useRealtimeEvents.ts @@ -21,6 +21,13 @@ type UseRealtimeEventsOptions = { token: string onEvent: (event: RealtimeEvent) => void onConnected?: () => void + /** + * 服务端告知「补发窗口溢出,历史事件已不连续」。 + * + * 此时不能再依赖逐条快照补丁(会把列表写回旧值),调用方应整体失效对应查询, + * 让列表接口重新给出权威数据。 + */ + onResync?: (info: { headEventId: number; reason: string }) => void /** 鉴权失效(401/403)时触发:连接停止重连,调用方应清理会话并跳转登录页。 */ onAuthExpired?: () => void } @@ -33,13 +40,16 @@ export function useRealtimeEvents({ token, onEvent, onConnected, + onResync, onAuthExpired, }: UseRealtimeEventsOptions) { const onEventRef = useRef(onEvent) const onConnectedRef = useRef(onConnected) + const onResyncRef = useRef(onResync) const onAuthExpiredRef = useRef(onAuthExpired) onEventRef.current = onEvent onConnectedRef.current = onConnected + onResyncRef.current = onResync onAuthExpiredRef.current = onAuthExpired useEffect(() => { @@ -79,6 +89,15 @@ export function useRealtimeEvents({ } onConnectedRef.current?.() await readSseStream(response.body, (eventName, data) => { + // 补发窗口溢出:历史事件不连续,不能逐条复位快照,交给调用方整体重拉。 + if (eventName === 'resync') { + const notice = normalizeResyncNotice(data) + if (notice) { + lastEventId = Math.max(lastEventId, notice.headEventId) + onResyncRef.current?.(notice) + } + return + } if (eventName !== 'realtime') return const event = normalizeRealtimeEvent(data) if (event) { @@ -197,3 +216,16 @@ function normalizeRealtimeEvent(value: string): RealtimeEvent | null { return null } } + +/** 服务端 resync 通知:补发窗口溢出,客户端应把游标推进到 headEventId 并整体重拉。 */ +function normalizeResyncNotice(value: string): { headEventId: number; reason: string } | null { + try { + const parsed = JSON.parse(value) as { headEventId?: unknown; reason?: unknown } + return { + headEventId: Math.max(0, Number(parsed.headEventId) || 0), + reason: String(parsed.reason || ''), + } + } catch { + return null + } +} diff --git a/apps/frontend/src/layouts/AdminLayout.tsx b/apps/frontend/src/layouts/AdminLayout.tsx index 9e567ecd..b7394296 100644 --- a/apps/frontend/src/layouts/AdminLayout.tsx +++ b/apps/frontend/src/layouts/AdminLayout.tsx @@ -161,6 +161,16 @@ export default function AdminLayout() { url: '/api/v1/admin/realtime', token: role === 'finance' ? '' : getAdminToken(), onEvent: (event) => applyAdminRealtimeEvent(event, queryClient), + // 补发窗口溢出(断线太久):历史事件已不连续,整体失效让接口重新给权威数据。 + onResync: () => { + void queryClient.invalidateQueries({ queryKey: ['admin-worker-platform-orders'] }) + void queryClient.invalidateQueries({ queryKey: ['admin-worker-platform-after-sales-cases'] }) + void queryClient.invalidateQueries({ queryKey: ['admin-worker-platform-finance-requests'] }) + void queryClient.invalidateQueries({ queryKey: ['admin-notifications'] }) + void queryClient.invalidateQueries({ queryKey: ['admin-tasks'] }) + void queryClient.invalidateQueries({ queryKey: ['admin-orders'] }) + void queryClient.invalidateQueries({ queryKey: ['admin-dashboard-summary'] }) + }, // 会话过期时清登录态回登录页,避免 SSE 带着失效 token 无限重连刷 401。 onAuthExpired: () => { expireAdminSession() diff --git a/apps/frontend/src/layouts/WorkerLayout.tsx b/apps/frontend/src/layouts/WorkerLayout.tsx index 8e1c9649..40752692 100644 --- a/apps/frontend/src/layouts/WorkerLayout.tsx +++ b/apps/frontend/src/layouts/WorkerLayout.tsx @@ -16,6 +16,7 @@ import { fetchWorkerAfterSalesCases, fetchWorkerProfile, logoutWorker } from '@/ import { useIsMobile } from '@/utils/use-is-mobile' import { isExplicitOrderNoSearch } from '@/utils/admin-realtime-work-orders' import { + compareWorkerHallOrderRows, evaluateWorkerKeywordVerdict, patchWorkerWorkOrderInfiniteEnvelope, patchWorkerWorkOrderListEnvelope, @@ -34,6 +35,7 @@ import { } from '@/utils/worker-auth' import { useRealtimeEvents, type RealtimeEvent } from '@/hooks/useRealtimeEvents' import WorkerAnnouncementBanner from '@/components/worker/WorkerAnnouncementBanner' +import WorkerAnnouncementPopup from '@/components/worker/WorkerAnnouncementPopup' import WorkerNewOrderSpeech from '@/components/worker/WorkerNewOrderSpeech' const menuItems: MenuProps['items'] = [ @@ -147,6 +149,16 @@ export default function WorkerLayout() { url: '/api/v1/worker/realtime', token: getWorkerToken(), onEvent: (event) => applyWorkerRealtimeEvent(event, queryClient), + // 补发窗口溢出(断线太久):历史事件已不连续,逐条套旧快照会把列表写回旧值, + // 这里整体失效,让列表接口重新给权威数据。 + onResync: () => { + void queryClient.invalidateQueries({ queryKey: ['worker-hall-orders'] }) + void queryClient.invalidateQueries({ queryKey: ['worker-my-orders'] }) + void queryClient.invalidateQueries({ queryKey: ['worker-my-orders-overview'] }) + void queryClient.invalidateQueries({ queryKey: ['worker-after-sales-cases'] }) + void queryClient.invalidateQueries({ queryKey: ['worker-finance-requests'] }) + void queryClient.invalidateQueries({ queryKey: ['worker-profile'] }) + }, // 会话过期时清登录态回登录页,避免 SSE 带着失效 token 无限重连刷 401。 onAuthExpired: () => { expireWorkerSession() @@ -201,6 +213,7 @@ export default function WorkerLayout() {
+
@@ -295,6 +308,7 @@ export default function WorkerLayout() { + @@ -446,6 +460,14 @@ function applyWorkerHallCachePatch( current, { ...event, data: workerSnapshot, operation }, shouldInsert, + // 大厅广播是「所有打手通用」的快照,不含当前打手自己的份额; + // 覆盖行时必须保留,否则「再拼一次」会被冲回「拼单 ¥x/份」。 + ['myShare', 'myShares'], + // 大厅展示顺序是「置顶优先,再按工单号倒序」(与列表 SQL 的 + // `pinned_at DESC NULLS LAST, id DESC` 同口径)。广播补丁只做就地覆盖 + // 或顶部插入,不重排缓存,这里传入排序比较器,保证新插入的行 + // 和快照里带 pinnedAt 的行按大厅口径归位,不会跑到置顶行前面。 + compareWorkerHallOrderRows, ), ) } diff --git a/apps/frontend/src/pages/admin/panels/AfterSalesPanel.tsx b/apps/frontend/src/pages/admin/panels/AfterSalesPanel.tsx index 8eb07e99..b7ed4dea 100644 --- a/apps/frontend/src/pages/admin/panels/AfterSalesPanel.tsx +++ b/apps/frontend/src/pages/admin/panels/AfterSalesPanel.tsx @@ -1,4 +1,4 @@ -import { ReloadOutlined } from '@ant-design/icons' +import { ReloadOutlined, VideoCameraOutlined } from '@ant-design/icons' import { useQuery, useQueryClient } from '@tanstack/react-query' import { App, @@ -26,8 +26,14 @@ import { repayAdminAfterSalesCaseDebt, resolveAdminAfterSalesCase, } from '@/services/admin' -import type { UploadedFile, WorkerAfterSalesCase, WorkOrder } from '@/types/worker-platform' +import type { + UploadedFile, + WorkerAcceptanceVideo, + WorkerAfterSalesCase, + WorkOrder, +} from '@/types/worker-platform' import { ADMIN_DEFAULT_PAGE_SIZE, buildAdminTablePagination } from '@/utils/admin-pagination' +import { normalizeSearchText } from '@/utils/admin-realtime-work-orders' import { formatAdminDateTime } from '@/utils/admin-time' import { uniqueUploadedFiles } from '@/utils/image-identity' import { asRecord, formatMoney } from './shared' @@ -49,6 +55,14 @@ type AfterSalesRepayFormValues = { note?: string } +/** 售后单状态筛选(与列表标签同一套文案)。 */ +const AFTER_SALES_STATUS_OPTIONS = [ + { value: 'awaiting_worker_response', label: '待打手说明' }, + { value: 'under_review', label: '客服处理中' }, + { value: 'recovery_pending', label: '追缴中' }, + { value: 'closed', label: '已关闭' }, +] + export type AfterSalesPanelHandle = { openCreateModal: (row: WorkOrder) => void } @@ -62,6 +76,10 @@ const AfterSalesPanel = forwardRef(function AfterSalesPan const [afterSalesFiles, setAfterSalesFiles] = useState([]) const [afterSalesPage, setAfterSalesPage] = useState(1) const [afterSalesPageSize, setAfterSalesPageSize] = useState(ADMIN_DEFAULT_PAGE_SIZE) + /** 搜索框输入值;点搜索/回车后才写入 afterSalesKeyword,避免每敲一个字就请求。 */ + const [afterSalesKeywordInput, setAfterSalesKeywordInput] = useState('') + const [afterSalesKeyword, setAfterSalesKeyword] = useState('') + const [afterSalesStatus, setAfterSalesStatus] = useState('') const [afterSalesCreateForm] = Form.useForm() const [afterSalesResolutionForm] = Form.useForm() const [afterSalesRepayForm] = Form.useForm() @@ -72,9 +90,20 @@ const AfterSalesPanel = forwardRef(function AfterSalesPan enabled: Boolean(afterSalesOrder?.sharing?.enabled), }) const afterSalesCasesQuery = useQuery({ - queryKey: ['admin-worker-platform-after-sales-cases', afterSalesPage, afterSalesPageSize], + queryKey: [ + 'admin-worker-platform-after-sales-cases', + afterSalesPage, + afterSalesPageSize, + afterSalesKeyword, + afterSalesStatus, + ], queryFn: () => - fetchAdminAfterSalesCases({ page: afterSalesPage, pageSize: afterSalesPageSize }), + fetchAdminAfterSalesCases({ + page: afterSalesPage, + pageSize: afterSalesPageSize, + ...(afterSalesKeyword ? { keyword: afterSalesKeyword } : {}), + ...(afterSalesStatus ? { status: afterSalesStatus } : {}), + }), }) useImperativeHandle(ref, () => ({ @@ -195,6 +224,41 @@ const AfterSalesPanel = forwardRef(function AfterSalesPan } bordered={false} > +
+ + { + const nextValue = event.target.value + setAfterSalesKeywordInput(nextValue) + // 清空输入立即恢复全量,不用再点一次搜索。 + if (!nextValue.trim()) { + setAfterSalesKeyword('') + setAfterSalesPage(1) + } + }} + onSearch={(value) => { + // 归一化(全角→半角、去零宽字符):粘贴来的单号常夹带不可见字符。 + setAfterSalesKeyword(normalizeSearchText(value)) + setAfterSalesPage(1) + }} + /> + + + + + + + ) : null} + + = { + timeout: '超时失效', + admin: '后台撤单', + worker_request: '打手退单', + problem: '问题单处置', + } + return labels[reason] || reason +} + export function formatAcceptanceMode(mode: string) { if (mode === 'friend_gift') return '好友赠送' return '标准验收' diff --git a/apps/frontend/src/pages/worker/WorkerHallPage.tsx b/apps/frontend/src/pages/worker/WorkerHallPage.tsx index 2e9d5e3e..1fff52a8 100644 --- a/apps/frontend/src/pages/worker/WorkerHallPage.tsx +++ b/apps/frontend/src/pages/worker/WorkerHallPage.tsx @@ -67,6 +67,7 @@ import { resolveHallRewardAmount, } from '@/utils/worker-product-name' import { formatDateTime, formatPublishedElapsedTime } from '@/utils/date-time' +import { createRequestId } from '@/utils/request-id' type HallSummary = { pendingAcceptanceCount: number @@ -312,7 +313,8 @@ export default function WorkerHallPage() { async (order: WorkOrder) => { setGrabbingId(order.workOrderId) try { - await grabWorkerOrder(order.workOrderId) + // 幂等键:重复点击/网络重试不会重复抢单(服务端按 requestId 去重)。 + await grabWorkerOrder(order.workOrderId, { requestId: createRequestId('grab') }) message.success('抢单成功,已进入我的订单') removeHallOrderLocally(order.workOrderId) await refreshAfterMutation() @@ -356,6 +358,7 @@ export default function WorkerHallPage() { try { const result = await joinWorkerSharingOrder(sharingOrder.workOrderId, { quantity: sharingQuantity, + requestId: createRequestId('share'), }) message.success('拼单成功,已进入我的订单') const currentProgress = sharingOrder.sharingProgress @@ -1159,6 +1162,20 @@ type HallCardProps = { onOpenSharing: (order: WorkOrder) => void } +/** + * 我在该工单的份额摘要:参与次数 + 累计份数。 + * + * 同一打手在同一工单可以多次拼单,`myShares` 是全部有效份额;旧数据只有 `myShare`。 + * 大厅按钮据此固定显示「再拼一次」,不再随参与次数改文案。 + */ +function resolveHallMyShareSummary(order: WorkOrder): { count: number; quantity: number } { + const shares = order.myShares?.length ? order.myShares : order.myShare ? [order.myShare] : [] + return { + count: shares.length, + quantity: shares.reduce((total, share) => total + Number(share.quantity || 0), 0), + } +} + /** * 已加载 100+ 单时,任何一条实时变更都会替换大厅缓存中的一条订单。 * 卡片拆成 memo 组件后,只有真正变化的那条会重渲染,其余卡片直接复用。 @@ -1177,6 +1194,7 @@ const HallDesktopCard = memo(function HallDesktopCard({ const sharingRemaining = resolveHallRemainingQuantity(order) const depositAmount = resolveHallDepositAmount(order) const rewardAmount = resolveHallRewardAmount(order) + const myShareSummary = resolveHallMyShareSummary(order) return (
@@ -1250,16 +1268,21 @@ const HallDesktopCard = memo(function HallDesktopCard({
- {order.sharing?.enabled && (sharingRemaining > 0 || order.myShare) && !disabledReason ? ( + {order.sharing?.enabled && sharingRemaining > 0 && !disabledReason ? ( ) : null} @@ -1293,6 +1316,7 @@ const HallMobileCard = memo(function HallMobileCard({ const disabledReason = resolveGrabDisabledReason(order, worker, remainingSlots) const productName = resolveHallProductName(order) || '未命名商品' const sharingRemaining = resolveHallRemainingQuantity(order) + const myShareSummary = resolveHallMyShareSummary(order) return (
@@ -1352,16 +1376,21 @@ const HallMobileCard = memo(function HallMobileCard({
- {order.sharing?.enabled && (sharingRemaining > 0 || order.myShare) && !disabledReason ? ( + {order.sharing?.enabled && sharingRemaining > 0 && !disabledReason ? ( ) : null} diff --git a/apps/frontend/src/pages/worker/WorkerOrderDesktopList.tsx b/apps/frontend/src/pages/worker/WorkerOrderDesktopList.tsx index 6956861a..03c6f89e 100644 --- a/apps/frontend/src/pages/worker/WorkerOrderDesktopList.tsx +++ b/apps/frontend/src/pages/worker/WorkerOrderDesktopList.tsx @@ -9,6 +9,7 @@ import { import { Alert, Button, Card, Empty, Input, Space, Tabs, Typography } from 'antd' import PercentTable from '@/components/common/PercentTable' import type { TableColumnsType } from 'antd' +import type { ReactNode } from 'react' import type { Pagination, WorkerOrderOverviewItem, WorkOrder } from '@/types/worker-platform' @@ -30,6 +31,7 @@ export function WorkerOrderDesktopList({ page, pageSize, columns, + renderExpandedRow, loading, searching, refreshing, @@ -50,6 +52,7 @@ export function WorkerOrderDesktopList({ page: number pageSize: number columns: TableColumnsType + renderExpandedRow?: (order: WorkOrder) => ReactNode loading: boolean searching: boolean refreshing: boolean @@ -263,6 +266,15 @@ export function WorkerOrderDesktopList({ dataSource={orders} columns={columns} className="worker-orders-table" + expandable={ + renderExpandedRow + ? { + expandedRowRender: renderExpandedRow, + rowExpandable: (order) => (order.myShares?.length || 0) > 1, + columnWidth: 42, + } + : undefined + } locale={{ emptyText: error ? ( diff --git a/apps/frontend/src/pages/worker/WorkerOrderDetailDrawer.tsx b/apps/frontend/src/pages/worker/WorkerOrderDetailDrawer.tsx index ebab51c9..074d350d 100644 --- a/apps/frontend/src/pages/worker/WorkerOrderDetailDrawer.tsx +++ b/apps/frontend/src/pages/worker/WorkerOrderDetailDrawer.tsx @@ -3,6 +3,7 @@ import { Alert, Button, Card, Descriptions, Drawer, Space, Steps, Tag, Typograph import WorkOrderEventTimeline from '@/components/WorkOrderEventTimeline' import ImagePreviewList from '@/components/files/ImagePreviewList' +import VideoPreview, { formatAcceptanceVideoDuration } from '@/components/files/VideoPreview' import type { WorkOrder } from '@/types/worker-platform' import { formatDateTime } from '@/utils/date-time' import { resolveOrderConsignmentScreenshotUrls } from '@/utils/consignment-display' @@ -104,7 +105,7 @@ export function WorkerOrderDetailDrawer({ icon={} onClick={() => onOpenAcceptance(order)} > - {hasSubmittedAcceptance(order) ? '补充图片' : '上传图片 / 提交验收'} + {hasSubmittedAcceptance(order) ? '补充验收资料' : '上传图片/视频 · 提交验收'} ) : null} {canRequestCancel(order) ? ( @@ -322,6 +323,7 @@ function GiftProgressCard({ order }: { order: WorkOrder }) { function AcceptanceSummary({ order }: { order: WorkOrder }) { const acceptanceFiles = getUniqueAcceptanceFiles(getWorkerAcceptance(order)) const draftFiles = getUniqueAcceptanceFiles(getWorkerDraft(order)) + const acceptanceVideo = getWorkerAcceptance(order)?.video ?? getWorkerDraft(order)?.video ?? null const hasAcceptance = acceptanceFiles.length > 0 const hasDraft = draftFiles.length > 0 && !hasSubmittedAcceptance(order) @@ -329,7 +331,12 @@ function AcceptanceSummary({ order }: { order: WorkOrder }) {
{acceptanceFiles.length} - + + {acceptanceVideo + ? formatAcceptanceVideoDuration(acceptanceVideo.durationSeconds) + : '未上传'} + + {formatDateTime(getAcceptanceSubmittedAt(order))} @@ -345,6 +352,8 @@ function AcceptanceSummary({ order }: { order: WorkOrder }) { 暂无已提交验收图片 )} + + {hasDraft ? (
}> diff --git a/apps/frontend/src/pages/worker/WorkerOrderMobileList.tsx b/apps/frontend/src/pages/worker/WorkerOrderMobileList.tsx index 2327befc..1e090dee 100644 --- a/apps/frontend/src/pages/worker/WorkerOrderMobileList.tsx +++ b/apps/frontend/src/pages/worker/WorkerOrderMobileList.tsx @@ -7,7 +7,8 @@ import { ReloadOutlined, SearchOutlined, } from '@ant-design/icons' -import { Alert, Button, Empty, Input, Pagination, Spin, Tag } from 'antd' +import { Alert, Button, Empty, Input, Pagination, Spin, Tag, Typography } from 'antd' +import type { ReactNode } from 'react' import { DeadlineCountdown } from '@/components/DeadlineCountdown' import ImagePreviewList from '@/components/files/ImagePreviewList' @@ -53,6 +54,7 @@ export function WorkerOrderMobileList({ pagination, page, pageSize, + renderShareTable, loading, searching, refreshing, @@ -81,6 +83,7 @@ export function WorkerOrderMobileList({ pagination: PaginationData | undefined page: number pageSize: number + renderShareTable?: (order: WorkOrder) => ReactNode loading: boolean searching: boolean refreshing: boolean @@ -173,6 +176,7 @@ export function WorkerOrderMobileList({ - {order.myShare ? ( + {order.myShare || order.myShares?.length ? ( - 已拼 {order.myShare.quantity} 份 ({formatMoney(order.myShare.shareReward)}) + 已参与 {order.myShares?.length || 1} 次 · 共{' '} + {order.myShares?.reduce((total, share) => total + share.quantity, 0) || + order.myShare?.quantity || + 0}{' '} + 份 ) : null} {formatDateTime(order.assignedAt)}
+ {(order.myShares?.length || 0) > 1 && renderShareTable ? ( +
+ + 拼单明细({order.myShares?.length} 份) + + {renderShareTable(order)} +
+ ) : null} + {order.status === 'problem' && String(order.problemNote || '').trim() ? (
问题: @@ -458,7 +476,7 @@ function WorkerOrderMobileCard({ className="worker-order-mobile-action-submit" onClick={() => onOpenAcceptance(order)} > - {hasSubmittedAcceptance(order) ? '补充图片' : '提交验收'} + {hasSubmittedAcceptance(order) ? '补充验收资料' : '验收'} ) : null} {canRemindAcceptance(order) ? ( diff --git a/apps/frontend/src/pages/worker/WorkerOrderModals.tsx b/apps/frontend/src/pages/worker/WorkerOrderModals.tsx index cdcd2ecb..503c595e 100644 --- a/apps/frontend/src/pages/worker/WorkerOrderModals.tsx +++ b/apps/frontend/src/pages/worker/WorkerOrderModals.tsx @@ -3,8 +3,10 @@ import { Button, Form, Input, Modal, Radio, Space, Tag, Typography } from 'antd' import type { FormInstance } from 'antd' import ImageUpload from '@/components/files/ImageUpload' +import VideoUpload from '@/components/files/VideoUpload' import type { UploadedFile, + WorkerAcceptanceVideo, WorkerCancelRequestRisk, WorkerOrderFeedback, WorkOrder, @@ -59,10 +61,12 @@ export function WorkerOrderModals({ onSaveNote, editingOrder, acceptanceFiles, + acceptanceVideo, acceptanceForm, workerCanAutoAcceptWithoutEvidence, onCloseAcceptance, onAcceptanceFilesChange, + onAcceptanceVideoChange, onSubmitAcceptance, onSaveAcceptanceImages, giftMaterialOrder, @@ -89,10 +93,12 @@ export function WorkerOrderModals({ onSaveNote: (values: WorkerNoteFormValues) => void | Promise editingOrder: WorkOrder | null acceptanceFiles: UploadedFile[] + acceptanceVideo: WorkerAcceptanceVideo | null acceptanceForm: FormInstance workerCanAutoAcceptWithoutEvidence: boolean onCloseAcceptance: () => void onAcceptanceFilesChange: (files: UploadedFile[]) => void + onAcceptanceVideoChange: (video: WorkerAcceptanceVideo | null) => void onSubmitAcceptance: (values: AcceptanceFormValues) => void | Promise onSaveAcceptanceImages: (values: AcceptanceFormValues) => void | Promise giftMaterialOrder: WorkOrder | null @@ -261,17 +267,18 @@ export function WorkerOrderModals({ + + + = { + joined: { label: '进行中', color: 'processing' }, + submitted: { label: '待验收', color: 'orange' }, + accepted: { label: '已验收', color: 'green' }, + cancelled: { label: '已取消', color: 'default' }, +} + +/** + * 一个工单一行,展开后展示该打手在这个工单里的每一份份额。 + * 数量 / 奖励 / 押金直接取份额字段,与后台「拼单明细」同口径。 + */ +export function WorkerOrderShareTable({ + order, + onOpenAcceptance, + onOpenCancelRequest, + onRemindAcceptance, +}: { + order: WorkOrder + onOpenAcceptance: (order: WorkOrder, shareId: number) => void + onOpenCancelRequest: (order: WorkOrder, shareId: number) => void + onRemindAcceptance: (order: WorkOrder) => void +}) { + const shares = getOrderShares(order) + .slice() + .sort((left, right) => { + const byCreatedAt = String(left.createdAt || '').localeCompare(String(right.createdAt || '')) + return byCreatedAt !== 0 ? byCreatedAt : left.shareId - right.shareId + }) + const isTimedOut = order.status === 'open' || Boolean(order.timeoutNotice?.isTimedOut) + + if (shares.length === 0) return null + + const columns: TableColumnsType = [ + { + title: '份额', + width: 92, + render: (_, share) => ( +
+ + 第 {shares.findIndex((item) => item.shareId === share.shareId) + 1} 份 + + + #{share.shareId} + +
+ ), + }, + { + title: '数量', + width: 80, + render: (_, share) => {share.quantity} 份, + }, + { + title: '奖励', + width: 110, + render: (_, share) => ( + + {formatMoney(share.shareReward)} + + ), + }, + { + title: '押金', + width: 100, + render: (_, share) => formatMoney(share.shareDeposit), + }, + { + title: '状态', + width: 96, + render: (_, share) => { + const meta = SHARE_STATUS_META[String(share.status)] || { + label: String(share.status || '-'), + color: 'default', + } + return {meta.label} + }, + }, + { + title: '问题', + width: 150, + render: (_, share) => { + if (!share.problemMarked) { + return ( + + — + + ) + } + const note = String(share.problemNote || '').trim() + return ( +
+ + 问题单 + + {note ? ( + + {note} + + ) : null} +
+ ) + }, + }, + { + title: '截止时间', + width: 170, + render: (_, share) => { + if (!share.deadlineAt) { + return ( + + 不限时 + + ) + } + return ( +
+ + {formatDateTime(share.deadlineAt)} + + {share.status === 'joined' && !isTimedOut ? ( + + ) : null} +
+ ) + }, + }, + { + title: '验收信息', + width: 220, + render: (_, share) => , + }, + { + title: '操作', + width: 190, + render: (_, share) => { + const canAccept = !isTimedOut && share.status === 'joined' + const canEdit = !isTimedOut && ['joined', 'submitted'].includes(String(share.status)) + const canRemind = !isTimedOut && share.status === 'submitted' + const canCancel = !isTimedOut && ['joined', 'submitted'].includes(String(share.status)) + if (!canAccept && !canEdit && !canRemind && !canCancel) { + return ( + + 无 + + ) + } + return ( + + {canAccept ? ( + + ) : null} + {canEdit && share.status === 'submitted' ? ( + + ) : null} + {canRemind ? ( + + ) : null} + {canCancel ? ( + + ) : null} + + ) + }, + }, + ] + + return ( + + rowKey="shareId" + size="small" + bordered={false} + pagination={false} + dataSource={shares} + columns={columns} + className="worker-order-share-table" + /> + ) +} + +function ShareAcceptanceCell({ share }: { share: WorkOrderShare }) { + const acceptanceFiles = getUniqueAcceptanceFiles(share.acceptance) + const draftFiles = getUniqueAcceptanceFiles(share.draftAcceptance) + if (acceptanceFiles.length === 0 && draftFiles.length === 0 && !share.submittedAt) { + return ( + + 未提交 + + ) + } + return ( +
+ {acceptanceFiles.length > 0 ? ( + + ) : null} + {share.submittedAt ? ( + + 提交:{formatDateTime(share.submittedAt)} + + ) : null} + {draftFiles.length > 0 && !share.submittedAt ? ( + 已暂存 {draftFiles.length} 张 + ) : null} + {String(share.acceptance?.note || '').trim() ? ( + + {share.acceptance.note} + + ) : null} +
+ ) +} diff --git a/apps/frontend/src/pages/worker/WorkerOrderTableColumns.tsx b/apps/frontend/src/pages/worker/WorkerOrderTableColumns.tsx index e1e84592..461e602c 100644 --- a/apps/frontend/src/pages/worker/WorkerOrderTableColumns.tsx +++ b/apps/frontend/src/pages/worker/WorkerOrderTableColumns.tsx @@ -83,7 +83,9 @@ export function buildWorkerOrderTableColumns({ {row.categoryName ? {row.categoryName} : null} - {row.myShare ? 拼单 : null} + {row.myShares?.length || row.myShare ? ( + 拼单 {row.myShares?.length || 1} 次 + ) : null} 押金:{formatMoney(row.freezeDepositAmount)} - {row.myShare ? ( + {row.myShare || row.myShares?.length ? ( - {row.myShare.quantity} 份 · {formatMoney(row.myShare.shareReward)} + {row.myShares?.length || 1} 次 ·{' '} + {row.myShares?.reduce((total, share) => total + share.quantity, 0) || + row.myShare?.quantity || + 0}{' '} + 份 ) : null}
@@ -239,48 +245,57 @@ export function buildWorkerOrderTableColumns({ { title: '操作', width: 150, - render: (_, row) => ( -
- - - - ) : null} - {canEditAcceptanceImages(row) ? ( - - ) : null} - {canRemindAcceptance(row) ? ( - - ) : null} - {canRequestCancel(row) ? ( - - ) : null} - {canSubmitFeedback(row) ? ( - - + render: (_, row) => { + const shareCount = row.myShares?.length || (row.myShare ? 1 : 0) + const hasMultipleShares = shareCount > 1 + return ( +
+ + +
- ), + {canSubmitGiftMaterial(row) ? ( + + ) : null} + {!hasMultipleShares && canEditAcceptanceImages(row) ? ( + + ) : null} + {!hasMultipleShares && canRemindAcceptance(row) ? ( + + ) : null} + {!hasMultipleShares && canRequestCancel(row) ? ( + + ) : null} + {hasMultipleShares ? ( + + 展开行查看 {shareCount} 份份额操作 + + ) : null} + {canSubmitFeedback(row) ? ( + + + + ) : null} +
+
+ ) + }, }, ] } diff --git a/apps/frontend/src/pages/worker/WorkerOrdersPage.tsx b/apps/frontend/src/pages/worker/WorkerOrdersPage.tsx index 9b6b9a49..39ef4697 100644 --- a/apps/frontend/src/pages/worker/WorkerOrdersPage.tsx +++ b/apps/frontend/src/pages/worker/WorkerOrdersPage.tsx @@ -1,6 +1,6 @@ -import { useQuery, useQueryClient } from '@tanstack/react-query' +import { keepPreviousData, useQuery, useQueryClient } from '@tanstack/react-query' import { useNavigate } from 'react-router' -import { App, Badge, Button, Form, Space, Typography } from 'antd' +import { App, Badge, Button, Form, Modal, Radio, Space, Tag, Typography } from 'antd' import { useState } from 'react' import { @@ -20,13 +20,15 @@ import { submitWorkerOrderFeedback, supplementWorkerAcceptedOrderEvidence, } from '@/services/worker' -import type { UploadedFile, WorkOrder } from '@/types/worker-platform' +import type { UploadedFile, WorkOrder, WorkerAcceptanceVideo } from '@/types/worker-platform' import { isExplicitOrderNoSearch, normalizeSearchText } from '@/utils/admin-realtime-work-orders' +import { formatDateTime } from '@/utils/date-time' import { useIsMobile } from '@/utils/use-is-mobile' import { buildWorkerOrderQuery } from '@/utils/worker-order-query' import { WorkerOrderDesktopList } from './WorkerOrderDesktopList' import { WorkerOrderDetailDrawer } from './WorkerOrderDetailDrawer' import { WorkerOrderMobileList } from './WorkerOrderMobileList' +import { WorkerOrderShareTable } from './WorkerOrderShareTable' import { type AcceptanceFormValues, type CancelRequestFormValues, @@ -41,6 +43,7 @@ import { canSupplementAcceptedEvidence, getCopyOrderInfoItems, getMaterialScreenshots, + getOrderShares, getUniqueAcceptanceFiles, getWorkerAcceptance, getWorkerDraft, @@ -165,10 +168,22 @@ export default function WorkerOrdersPage() { const [pageSize, setPageSize] = useState(20) const [detailOrder, setDetailOrder] = useState(null) const [editingOrder, setEditingOrder] = useState(null) + /** 本次验收/补图针对的份额;null = 整单 */ + const [editingShareId, setEditingShareId] = useState(null) + /** 多份额时先让打手选择要操作哪一份 */ + const [sharePicker, setSharePicker] = useState<{ + order: WorkOrder + action: 'accept' | 'cancel' + } | null>(null) + const [pickedShareId, setPickedShareId] = useState(0) + const [cancelShareId, setCancelShareId] = useState(null) const [noteOrder, setNoteOrder] = useState(null) const [cancelRequestOrder, setCancelRequestOrder] = useState(null) const [feedbackOrder, setFeedbackOrder] = useState(null) const [acceptanceFiles, setAcceptanceFiles] = useState([]) + /** 验收视频(单条,非必填);dirty 表示本次弹窗里换过视频,用于补图时允许"只补视频" */ + const [acceptanceVideo, setAcceptanceVideo] = useState(null) + const [acceptanceVideoDirty, setAcceptanceVideoDirty] = useState(false) const [giftMaterialOrder, setGiftMaterialOrder] = useState(null) const [giftMaterialFiles, setGiftMaterialFiles] = useState([]) const [form] = Form.useForm() @@ -181,6 +196,8 @@ export default function WorkerOrdersPage() { queryKey: ['worker-my-orders', status, keyword, page, pageSize], queryFn: () => fetchWorkerMyOrders(buildWorkerOrderQuery(status, keyword, page, pageSize)), staleTime: 2_000, + // 切页签时保留上一页数据,避免请求返回前先闪一下空列表。 + placeholderData: keepPreviousData, refetchOnWindowFocus: false, retry: false, }) @@ -292,21 +309,54 @@ export default function WorkerOrdersPage() { } } + /** 把订单对象替换成「指定份额」视角,让验收弹窗读写该份额的验收/草稿。 */ + function buildShareEditingOrder(order: WorkOrder, shareId: number | null): WorkOrder { + if (!shareId) return order + const share = getOrderShares(order).find((item) => item.shareId === shareId) + if (!share) return order + return { + ...order, + myShare: share, + // 已验收的份额走补图分支;其它份额沿用母单状态。 + status: share.status === 'accepted' ? 'accepted' : order.status, + } + } + function openAcceptanceModal(order: WorkOrder) { markOrderAsRead(order) - setEditingOrder(order) - if (canSupplementAcceptedEvidence(order)) { - // 已验收补图只上传新增文件,原有验收图片由服务端保留。 + const shares = getOrderShares(order) + if (shares.length > 1) { + setPickedShareId(shares[0]?.shareId || 0) + setSharePicker({ order, action: 'accept' }) + return + } + openAcceptanceWithShare(order, shares[0]?.shareId ?? null) + } + + /** 子表里直接对某一份份额发起验收/补图。 */ + function openAcceptanceForShare(order: WorkOrder, shareId: number) { + markOrderAsRead(order) + openAcceptanceWithShare(order, shareId) + } + + function openAcceptanceWithShare(order: WorkOrder, shareId: number | null) { + const editingTarget = buildShareEditingOrder(order, shareId) + setEditingShareId(shareId) + setEditingOrder(editingTarget) + setAcceptanceVideoDirty(false) + const submitted = getWorkerAcceptance(editingTarget) + if (canSupplementAcceptedEvidence(editingTarget)) { + // 已验收补图只上传新增文件,原有验收图片由服务端保留;视频只能替换、不能删除。 setAcceptanceFiles([]) + setAcceptanceVideo(submitted?.video ?? null) form.resetFields() setDetailOrder(null) return } // 已提交:以已提交的验收资料为初始内容;未提交:以暂存草稿为初始内容 - const source = hasSubmittedAcceptance(order) - ? getWorkerAcceptance(order) - : getWorkerDraft(order) + const source = hasSubmittedAcceptance(editingTarget) ? submitted : getWorkerDraft(editingTarget) setAcceptanceFiles(getUniqueAcceptanceFiles(source)) + setAcceptanceVideo(source?.video ?? null) form.setFieldsValue({ note: String(source?.note || '') }) setDetailOrder(null) } @@ -356,6 +406,21 @@ export default function WorkerOrdersPage() { function openCancelRequestModal(order: WorkOrder) { markOrderAsRead(order) + const shares = getOrderShares(order) + if (shares.length > 1) { + setPickedShareId(shares[0]?.shareId || 0) + setSharePicker({ order, action: 'cancel' }) + return + } + setCancelShareId(shares[0]?.shareId ?? null) + setCancelRequestOrder(order) + cancelRequestForm.resetFields() + } + + /** 子表里直接对某一份份额申请取消。 */ + function openCancelRequestForShare(order: WorkOrder, shareId: number) { + markOrderAsRead(order) + setCancelShareId(shareId) setCancelRequestOrder(order) cancelRequestForm.resetFields() } @@ -365,6 +430,7 @@ export default function WorkerOrdersPage() { try { await submitWorkerCancelRequest(cancelRequestOrder.workOrderId, { reason: String(values.reason || '').trim(), + shareId: cancelShareId, }) message.success('取消申请已提交,等待客服审核') setCancelRequestOrder(null) @@ -423,21 +489,25 @@ export default function WorkerOrdersPage() { if (!editingOrder) return const isAcceptedEvidenceSupplement = canSupplementAcceptedEvidence(editingOrder) if (isAcceptedEvidenceSupplement) { - if (acceptanceFiles.length === 0) { - message.error('请上传新的验收图片') + if (acceptanceFiles.length === 0 && !acceptanceVideoDirty) { + message.error('请上传新的验收图片,或替换验收视频') return } try { await supplementWorkerAcceptedOrderEvidence(editingOrder.workOrderId, { files: acceptanceFiles, + ...(acceptanceVideoDirty ? { video: acceptanceVideo } : {}), + ...(editingShareId ? { shareId: editingShareId } : {}), }) - message.success('验收图片已补充') + message.success('验收资料已补充') setEditingOrder(null) setAcceptanceFiles([]) + setAcceptanceVideo(null) + setAcceptanceVideoDirty(false) form.resetFields() await refreshAll() } catch (error) { - message.error(error instanceof Error ? error.message : '补充验收图片失败') + message.error(error instanceof Error ? error.message : '补充验收资料失败') } return } @@ -448,18 +518,31 @@ export default function WorkerOrdersPage() { } const canAutoAccept = canAutoAcceptOrder(editingOrder, workerCanAutoAcceptWithoutEvidence) - if (acceptanceFiles.length === 0 && !canAutoAccept) { - message.error('请上传验收图片') + // 验收图片与验收视频至少上传一个;有视频时视同有图片,后续口径完全一致。 + const hasEvidence = acceptanceFiles.length > 0 || Boolean(acceptanceVideo) + if (!hasEvidence && !canAutoAccept) { + message.error('请上传验收图片或验收视频') return } try { await submitWorkerAcceptance(editingOrder.workOrderId, { note: values.note, files: acceptanceFiles, + video: acceptanceVideo, + ...(editingShareId ? { shareId: editingShareId } : {}), }) - message.success(canAutoAccept ? '已自动验收,可在 24 小时内补充图片' : '已提交验收') + // 已有图片或视频时证据即算齐备,不再提示补图。 + message.success( + canAutoAccept + ? hasEvidence + ? '已自动验收' + : '已自动验收,可在 24 小时内补充图片或视频' + : '已提交验收', + ) setEditingOrder(null) setAcceptanceFiles([]) + setAcceptanceVideo(null) + setAcceptanceVideoDirty(false) form.resetFields() await refreshAll() } catch (error) { @@ -469,20 +552,24 @@ export default function WorkerOrdersPage() { async function saveAcceptanceImages(values: AcceptanceFormValues) { if (!editingOrder) return - if (hasSubmittedAcceptance(editingOrder) && acceptanceFiles.length === 0) { - message.error('请至少保留一张验收图片') + if (hasSubmittedAcceptance(editingOrder) && acceptanceFiles.length === 0 && !acceptanceVideo) { + message.error('请至少保留一张验收图片或一段验收视频') return } try { await saveWorkerAcceptanceDraft(editingOrder.workOrderId, { note: values.note, files: acceptanceFiles, + video: acceptanceVideo, + ...(editingShareId ? { shareId: editingShareId } : {}), }) message.success( - hasSubmittedAcceptance(editingOrder) ? '验收图片已更新' : '验收图片已暂存,可继续补充', + hasSubmittedAcceptance(editingOrder) ? '验收资料已更新' : '验收资料已暂存,可继续补充', ) setEditingOrder(null) setAcceptanceFiles([]) + setAcceptanceVideo(null) + setAcceptanceVideoDirty(false) form.resetFields() await refreshAll() } catch (error) { @@ -501,6 +588,19 @@ export default function WorkerOrdersPage() { setPage(1) } + function renderShareTable(order: WorkOrder) { + return ( + { + void submitAcceptanceReminder(target) + }} + /> + ) + } + const columns = buildWorkerOrderTableColumns({ onOpenDetail: openOrderDetail, onOpenAcceptance: openAcceptanceModal, @@ -562,6 +662,7 @@ export default function WorkerOrdersPage() { pagination={pagination} page={page} pageSize={pageSize} + renderShareTable={renderShareTable} loading={ordersQuery.isLoading} searching={ordersQuery.isFetching} refreshing={ordersQuery.isFetching || overviewQuery.isFetching} @@ -611,6 +712,7 @@ export default function WorkerOrdersPage() { page={page} pageSize={pageSize} columns={columns} + renderExpandedRow={renderShareTable} loading={ordersQuery.isLoading} searching={ordersQuery.isFetching} refreshing={ordersQuery.isFetching || overviewQuery.isFetching} @@ -674,14 +776,21 @@ export default function WorkerOrdersPage() { onSaveNote={saveOrderNote} editingOrder={editingOrder} acceptanceFiles={acceptanceFiles} + acceptanceVideo={acceptanceVideo} acceptanceForm={form} workerCanAutoAcceptWithoutEvidence={workerCanAutoAcceptWithoutEvidence} onCloseAcceptance={() => { setEditingOrder(null) setAcceptanceFiles([]) + setAcceptanceVideo(null) + setAcceptanceVideoDirty(false) form.resetFields() }} onAcceptanceFilesChange={setAcceptanceFiles} + onAcceptanceVideoChange={(video) => { + setAcceptanceVideo(video) + setAcceptanceVideoDirty(true) + }} onSubmitAcceptance={submitAcceptance} onSaveAcceptanceImages={saveAcceptanceImages} giftMaterialOrder={giftMaterialOrder} @@ -695,6 +804,57 @@ export default function WorkerOrdersPage() { onGiftMaterialFilesChange={setGiftMaterialFiles} onSubmitGiftMaterial={submitGiftMaterial} /> + + setSharePicker(null)} + onOk={() => { + if (!sharePicker) return + if (sharePicker.action === 'accept') { + openAcceptanceWithShare(sharePicker.order, pickedShareId || null) + } else { + setCancelShareId(pickedShareId || null) + setCancelRequestOrder(sharePicker.order) + cancelRequestForm.resetFields() + } + setSharePicker(null) + }} + > + setPickedShareId(Number(event.target.value))} + style={{ display: 'flex', flexDirection: 'column', gap: 10 }} + > + {sharePicker && sharePicker.action === 'cancel' ? ( + 全部有效份额(整单退单) + ) : null} + {sharePicker + ? getOrderShares(sharePicker.order).map((share) => ( + + + #{share.shareId} + {share.quantity} 份 + + {share.status === 'joined' + ? '进行中' + : share.status === 'submitted' + ? '待验收' + : share.status === 'accepted' + ? '已验收' + : share.status} + + + {share.deadlineAt ? `${formatDateTime(share.deadlineAt)} 截止` : '不限时'} + + + + )) + : null} + + ) } diff --git a/apps/frontend/src/pages/worker/worker-order-utils.tsx b/apps/frontend/src/pages/worker/worker-order-utils.tsx index e9234e02..61b6b75d 100644 --- a/apps/frontend/src/pages/worker/worker-order-utils.tsx +++ b/apps/frontend/src/pages/worker/worker-order-utils.tsx @@ -6,6 +6,7 @@ import type { UploadedFile, WorkerAcceptance, WorkOrder, + WorkOrderShare, } from '@/types/worker-platform' import { getImageIdentity } from '@/utils/image-identity' @@ -33,6 +34,13 @@ export function patchWorkOrderReadState(current: unknown, workOrderId: number) { } } +/** 同一打手在该工单的全部有效份额;单份额订单回退为 myShare 一条。 */ +export function getOrderShares(order: WorkOrder | null | undefined): WorkOrderShare[] { + if (!order) return [] + if (order.myShares?.length) return order.myShares + return order.myShare ? [order.myShare] : [] +} + /** 好友赠送验收模式(整单,不参与拼单)。 */ export function isFriendGiftOrder(order: WorkOrder) { return order.acceptanceMode === 'friend_gift' && !order.myShare @@ -89,7 +97,12 @@ export function getOrderFlagTags(order: WorkOrder) { } return flags } - if (order.problemMarked) flags.push({ key: 'problem', label: '平台标记', color: 'red' }) + if (order.problemMarked) + flags.push( + order.shareProblemMarked + ? { key: 'share_problem', label: '份额问题', color: 'red' } + : { key: 'problem', label: '平台标记', color: 'red' }, + ) if (order.pendingCancelRequest) flags.push({ key: 'cancel', label: '退单审核中', color: 'orange' }) if (order.pendingFeedback) flags.push({ key: 'feedback', label: '反馈处理中', color: 'orange' }) diff --git a/apps/frontend/src/services/admin/worker-platform.ts b/apps/frontend/src/services/admin/worker-platform.ts index bbf65e49..8eef0e52 100644 --- a/apps/frontend/src/services/admin/worker-platform.ts +++ b/apps/frontend/src/services/admin/worker-platform.ts @@ -892,6 +892,41 @@ export function cancelAdminWorkOrderShare( }>(`/api/v1/admin/worker-platform/orders/${workOrderId}/shares/${shareId}/cancel`, payload) } +/** 按份数撤销某个打手的部分份额:减少的份数回到大厅,押金保持原冻结额。 */ +export function reduceAdminWorkOrderShareQuantity( + workOrderId: number, + shareId: number, + payload: { reduceQuantity: number; note?: string }, +) { + return apiPost<{ order: WorkOrder; share: WorkOrderShare }>( + `/api/v1/admin/worker-platform/orders/${workOrderId}/shares/${shareId}/reduce-quantity`, + payload, + ) +} + +/** 按份额标记问题单:只影响该份额持有人,母单状态不变。备注选填。 */ +export function markAdminWorkOrderShareProblem(workOrderId: number, shareId: number, note = '') { + return apiPost<{ order: WorkOrder; share: WorkOrderShare }>( + `/api/v1/admin/worker-platform/orders/${workOrderId}/shares/${shareId}/problem`, + { note }, + ) +} + +/** 处置份额级问题单:方式与母单处置一致,但只作用于该份额。 */ +export function disposeAdminWorkOrderShareProblem( + workOrderId: number, + shareId: number, + payload: { + action: 'return_to_worker' | 'reopen' | 'cancel_release' | 'cancel_deduct' + note: string + }, +) { + return apiPost<{ order: WorkOrder; share: WorkOrderShare; depositAmount: number }>( + `/api/v1/admin/worker-platform/orders/${workOrderId}/shares/${shareId}/problem/dispose`, + payload, + ) +} + export function cancelAdminWorkOrderSharing( workOrderId: number, payload: { diff --git a/apps/frontend/src/services/files.ts b/apps/frontend/src/services/files.ts index db31fddd..59aa00e4 100644 --- a/apps/frontend/src/services/files.ts +++ b/apps/frontend/src/services/files.ts @@ -18,3 +18,16 @@ export async function uploadFile(file: File, scene: string, scope: UploadScope) const response = await apiPostForm<{ file: UploadedFile }>(endpoint, form) return response.data.file } + +/** + * 验收视频上传:视频已在浏览器内压好,这里不再做图片压缩。 + * 带上 durationSeconds 供服务端在容器解析不到时长时兜底(见 resolveVideoDuration)。 + */ +export async function uploadVideoFile(file: File, scene: string, durationSeconds: number) { + const form = new FormData() + form.append('file', file) + form.append('scene', scene) + form.append('durationSeconds', String(durationSeconds)) + const response = await apiPostForm<{ file: UploadedFile }>('/api/v1/worker/files/upload', form) + return response.data.file +} diff --git a/apps/frontend/src/services/worker.ts b/apps/frontend/src/services/worker.ts index 338a2f6a..272f0b52 100644 --- a/apps/frontend/src/services/worker.ts +++ b/apps/frontend/src/services/worker.ts @@ -9,6 +9,8 @@ import type { WorkerCancelRequest, WorkerCancelRequestRisk, WorkerAnnouncementConfig, + WorkerAcceptanceConfig, + WorkerAcceptanceVideo, WorkerOrderFeedback, WorkerAfterSalesCase, WorkerHallCategoriesResponse, @@ -144,6 +146,11 @@ export function fetchWorkerAnnouncement() { return apiGet('/api/v1/worker/announcement') } +/** 验收视频配置(后台可改):时长上限、目标高度、码率等,压缩前读取。 */ +export function fetchWorkerAcceptanceConfig() { + return apiGet('/api/v1/worker/acceptance/config') +} + export function fetchWorkerWalletLedgers(params?: Record) { return apiGet>( '/api/v1/worker/profile/wallet-ledgers', @@ -221,12 +228,18 @@ export function fetchWorkerHallLeaderboard(period: WorkerLeaderboardPeriod = 'al return apiGet('/api/v1/worker/hall/leaderboard', { period }) } -export function grabWorkerOrder(workOrderId: number) { - return apiPost<{ order: WorkOrder }>(`/api/v1/worker/hall/orders/${workOrderId}/grab`) +export function grabWorkerOrder(workOrderId: number, payload: { requestId?: string } = {}) { + return apiPost<{ order: WorkOrder; duplicated?: boolean }>( + `/api/v1/worker/hall/orders/${workOrderId}/grab`, + payload, + ) } -export function joinWorkerSharingOrder(workOrderId: number, payload: { quantity: number }) { - return apiPost<{ share: WorkOrderShare }>( +export function joinWorkerSharingOrder( + workOrderId: number, + payload: { quantity: number; requestId?: string }, +) { + return apiPost<{ share: WorkOrderShare; duplicated?: boolean }>( `/api/v1/worker/hall/orders/${workOrderId}/join-sharing`, payload, ) @@ -250,7 +263,10 @@ export function fetchWorkerCancelRequests() { ) } -export function submitWorkerCancelRequest(workOrderId: number, payload: { reason: string }) { +export function submitWorkerCancelRequest( + workOrderId: number, + payload: { reason: string; shareId?: number | null }, +) { return apiPost<{ request: WorkerCancelRequest; risk: WorkerCancelRequestRisk }>( `/api/v1/worker/orders/${workOrderId}/cancel-requests`, payload, @@ -293,11 +309,17 @@ export function saveWorkerOrderNote(workOrderId: number, note: string) { return apiPost<{ order: WorkOrder }>(`/api/v1/worker/orders/${workOrderId}/note`, { note }) } -export function submitWorkerAcceptance( - workOrderId: number, - payload: { note?: string; imageUrls?: string[]; files?: UploadedFile[] }, -) { - return apiPost<{ order: WorkOrder }>( +export type WorkerAcceptancePayload = { + note?: string + imageUrls?: string[] + files?: UploadedFile[] + /** 验收视频(非必填);null 在草稿阶段表示删除,已提交阶段服务端只接受替换 */ + video?: WorkerAcceptanceVideo | null + shareId?: number +} + +export function submitWorkerAcceptance(workOrderId: number, payload: WorkerAcceptancePayload) { + return apiPost<{ order: WorkOrder; share?: WorkOrderShare }>( `/api/v1/worker/orders/${workOrderId}/submit-acceptance`, payload, ) @@ -320,11 +342,8 @@ export function remindWorkerAcceptance(workOrderId: number) { ) } -export function saveWorkerAcceptanceDraft( - workOrderId: number, - payload: { note?: string; imageUrls?: string[]; files?: UploadedFile[] }, -) { - return apiPost<{ order: WorkOrder }>( +export function saveWorkerAcceptanceDraft(workOrderId: number, payload: WorkerAcceptancePayload) { + return apiPost<{ order: WorkOrder; share?: WorkOrderShare }>( `/api/v1/worker/orders/${workOrderId}/acceptance-draft`, payload, ) @@ -332,9 +351,9 @@ export function saveWorkerAcceptanceDraft( export function supplementWorkerAcceptedOrderEvidence( workOrderId: number, - payload: { files: UploadedFile[] }, + payload: { files: UploadedFile[]; video?: WorkerAcceptanceVideo | null; shareId?: number }, ) { - return apiPost<{ order: WorkOrder }>( + return apiPost<{ order: WorkOrder; share?: WorkOrderShare }>( `/api/v1/worker/orders/${workOrderId}/acceptance-evidence`, payload, ) diff --git a/apps/frontend/src/styles/admin.css b/apps/frontend/src/styles/admin.css index f1045a61..bc8e7ffb 100644 --- a/apps/frontend/src/styles/admin.css +++ b/apps/frontend/src/styles/admin.css @@ -1607,3 +1607,31 @@ grid-column: auto; } } + +/* 拼单明细:操作按钮保持一行 */ +.admin-share-actions { + flex-wrap: nowrap; + white-space: nowrap; +} + +/* 拼单明细:问题单行强调、已撤单行弱化 */ +.admin-share-row-problem > td { + background: #fff1f0 !important; +} + +.admin-share-row-problem > td:first-child { + box-shadow: inset 3px 0 0 #ff4d4f; +} + +.admin-share-row-problem:hover > td { + background: #ffe7e6 !important; +} + +.admin-share-row-cancelled > td { + color: rgba(0, 0, 0, 0.45); + background: #fafafa; +} + +.admin-share-row-cancelled:hover > td { + background: #f5f5f5 !important; +} diff --git a/apps/frontend/src/styles/base.css b/apps/frontend/src/styles/base.css index 4a725157..8e2aff49 100644 --- a/apps/frontend/src/styles/base.css +++ b/apps/frontend/src/styles/base.css @@ -305,6 +305,78 @@ select { .image-upload-hint { font-size: 12px; line-height: 1.5; + clear: both; +} + +/* antd 的 picture-card 列表可能是浮动/行内块,让外层独占整行,避免相机按钮被挤到同一行 */ +.image-upload-droppable > .ant-upload-wrapper { + display: block; + width: 100%; +} + +/* 移动端系统相机入口:隐藏原生 file input,仅保留按钮 */ +.image-upload-camera { + display: flex; + align-items: center; + gap: 8px; + width: 100%; + /* 强制独占一行:缩略图列表可能是行内块/浮动,clear + flex-basis 保证按钮落到下一行 */ + clear: both; + flex: 1 0 100%; + justify-self: stretch; + margin-top: 8px; +} + +/** + * 移动端系统相机入口: + * 原生 input 透明铺满按钮,用户手指直接点在 input 上(真实手势), + * 浏览器才更愿意按 capture="environment" 调起相机。 + * + * 旧实现把 input 移到 left:-9999px 再由按钮脚本 .click(): + * iOS Safari 会点了没反应,部分安卓 WebView 则把相机 intent 降级成文件选择 + * (表现为点「拍照」却弹出文件浏览)。 + */ +.image-upload-camera-input, +.worker-acceptance-video-camera-input { + position: absolute; + inset: 0; + width: 100%; + height: 100%; + opacity: 0; + cursor: pointer; +} + +.camera-pick-trigger { + position: relative; + display: inline-flex; + align-items: center; +} + +/* 视觉按钮只负责外观,点击统一由上层透明 input 接收(避免脚本 click 触发降级) */ +.camera-pick-trigger .ant-btn { + pointer-events: none; +} + +.worker-acceptance-video-preview { + display: flex; + flex-direction: column; + gap: 8px; +} + +.worker-acceptance-video-player { + width: 100%; + max-width: 360px; + max-height: 240px; + border-radius: 6px; + background: #000; +} + +.worker-acceptance-video-meta { + display: flex; + align-items: center; + justify-content: space-between; + flex-wrap: wrap; + gap: 8px; } .image-preview-list { diff --git a/apps/frontend/src/styles/worker.css b/apps/frontend/src/styles/worker.css index 5d3b89b7..833729ed 100644 --- a/apps/frontend/src/styles/worker.css +++ b/apps/frontend/src/styles/worker.css @@ -73,6 +73,24 @@ } } +/* 公告弹窗:正文保留换行,长公告可滚动 */ +.worker-announcement-popup-title { + display: inline-flex; + align-items: center; + gap: 6px; +} + +.worker-announcement-popup-content { + margin: 0 !important; + max-height: 50vh; + overflow-y: auto; + color: #1f2937; + font-size: 14px; + line-height: 1.75; + white-space: pre-wrap; + word-break: break-word; +} + .worker-page-head h3, .worker-page-head > .ant-typography { margin: 0 !important; @@ -4858,3 +4876,26 @@ body { padding-top: 6px; border-top: 1px dashed #bfdbfe; } + +/* 我的订单:一行一工单,展开后的份额子表 */ +.worker-orders-table .ant-table-expanded-row > td { + background: #fafafa; +} + +.worker-order-share-table .ant-table { + font-size: 12px; +} + +.worker-order-share-table .ant-table-thead > tr > th { + background: #f5f5f5; + font-size: 12px; +} + +.worker-order-mobile-shares { + display: flex; + flex-direction: column; + gap: 6px; + margin: 8px 12px 4px; + padding-top: 8px; + border-top: 1px dashed #f0f0f0; +} diff --git a/apps/frontend/src/types/worker-platform.ts b/apps/frontend/src/types/worker-platform.ts index 9b6a7eb9..fab417b3 100644 --- a/apps/frontend/src/types/worker-platform.ts +++ b/apps/frontend/src/types/worker-platform.ts @@ -297,6 +297,7 @@ export type WorkerPlatformNotificationEventKey = | 'recharge_requested' | 'acceptance_submitted' | 'acceptance_reminded' + | 'order_accepted' | 'material_required' | 'cancel_requested' | 'feedback_submitted' @@ -491,6 +492,31 @@ export type UploadedFile = { filename: string contentType: string size: number + /** 仅验收视频:服务端解析(或客户端上报)的时长,单位秒 */ + durationSeconds?: number +} + +/** 验收视频(单条,非必填)。 */ +export type WorkerAcceptanceVideo = { + objectKey: string + url: string + filename?: string + contentType?: string + size?: number + durationSeconds: number + width?: number + height?: number +} + +/** 验收视频配置(来自 .env 的 WORKER_ACCEPTANCE_VIDEO_*,后台不提供配置界面)。 */ +export type WorkerAcceptanceConfig = { + enabled: boolean + maxDurationSeconds: number + maxSizeMb: number + targetHeight: number + bitrateKbps: number + audioBitrateKbps: number + fps: number } export type WorkOrderStatus = @@ -515,6 +541,8 @@ export type WorkerAcceptance = { note?: string files?: UploadedFile[] imageUrls?: string[] + /** 验收视频(非必填):打手压缩后上传的单个视频 */ + video?: WorkerAcceptanceVideo | null submittedAt?: string updatedAt?: string reviewMode?: 'vip_auto' | string @@ -539,6 +567,20 @@ export type WorkOrderShare = { acceptedAt: string | null createdAt: string updatedAt: string + /** 本份额独立截止时间(参与时固化);null = 不限时 */ + deadlineAt?: string | null + timeoutMinutes?: number + timeoutPolicy?: string + cancelledReason?: string + /** 后台撤单/撤份额时填写的原因(自由文本,选填) */ + cancelNote?: string + /** 被撤掉的份数(撤份额累计 / 单人撤单为整份份数;0 = 未撤过) */ + cancelQuantity?: number + /** 份额级问题单:只影响该份额持有人,母单状态不变 */ + problemMarked?: boolean + problemNote?: string + problemMarkedAt?: string | null + problemMarkedBy?: string worker: null | { workerId: number username: string @@ -620,6 +662,8 @@ export type WorkOrder = { freezeDepositAmount?: number sharing?: WorkOrderSharingConfig myShare?: WorkOrderShare | null + /** 同一打手在该工单的全部有效份额(一行一工单 + 展开份额) */ + myShares?: WorkOrderShare[] sharingProgress?: { joinedQuantity: number pendingSubmissionCount: number @@ -643,6 +687,8 @@ export type WorkOrder = { problemNote: string /** 问题单分组来源角标:平台标记 / 退单审核中 / 反馈处理中 / 资料已更新。 */ problemMarked?: boolean + /** 份额级问题单(后台按份额标记,只有本人可见;母单状态不变) */ + shareProblemMarked?: boolean pendingCancelRequest?: boolean pendingFeedback?: boolean materialUpdated?: boolean diff --git a/apps/frontend/src/utils/announcement-ack.ts b/apps/frontend/src/utils/announcement-ack.ts new file mode 100644 index 00000000..d0442ec7 --- /dev/null +++ b/apps/frontend/src/utils/announcement-ack.ts @@ -0,0 +1,60 @@ +/** + * 打手端公告弹窗的「已确认」记忆。 + * + * 后台公告只有一条全局配置(`worker_announcement = { enabled, content }`),没有公告 ID, + * 因此这里用**内容指纹**充当版本号:只要运营改了公告正文,指纹就变,打手端会重新弹一次。 + * 确认记录存在 localStorage(方案 B:服务端不落库),换设备或清缓存后会再弹一次。 + */ + +const ANNOUNCEMENT_ACK_STORAGE_KEY = 'order-site-worker-announcement-ack' + +type AnnouncementPopupState = { + enabled?: boolean + content?: string + /** 本地已确认过的公告版本(来自 localStorage) */ + acknowledgedVersion?: string +} + +/** 公告正文的内容指纹(FNV-1a 32 位),空内容返回空串表示「没有可确认的公告」。 */ +export function resolveAnnouncementVersion(content: string): string { + const normalized = String(content || '').trim() + if (!normalized) return '' + let hash = 0x811c9dc5 + for (let index = 0; index < normalized.length; index += 1) { + hash ^= normalized.charCodeAt(index) + hash = Math.imul(hash, 0x01000193) >>> 0 + } + return `v1-${hash.toString(16).padStart(8, '0')}` +} + +/** + * 是否需要弹窗:公告开启 + 有正文 + 当前版本还没被这台设备确认过。 + * 已确认过的版本不再弹;运营改了正文(版本变化)后会重新弹。 + */ +export function shouldShowAnnouncementPopup(state: AnnouncementPopupState): boolean { + if (state.enabled !== true) return false + const version = resolveAnnouncementVersion(state.content || '') + if (!version) return false + return state.acknowledgedVersion !== version +} + +/** 读取本机已确认的公告版本(隐私模式/禁用存储时返回空串,页面照常可用)。 */ +export function readAnnouncementAck(): string { + try { + if (typeof window === 'undefined') return '' + return window.localStorage.getItem(ANNOUNCEMENT_ACK_STORAGE_KEY) || '' + } catch { + return '' + } +} + +/** 记录本机已确认的公告版本。 */ +export function writeAnnouncementAck(version: string): void { + if (!version) return + try { + if (typeof window === 'undefined') return + window.localStorage.setItem(ANNOUNCEMENT_ACK_STORAGE_KEY, version) + } catch { + // 存储不可用(隐私模式/配额满)时忽略,下一次打开会再弹一次。 + } +} diff --git a/apps/frontend/src/utils/camera-capture.ts b/apps/frontend/src/utils/camera-capture.ts new file mode 100644 index 00000000..2fe60893 --- /dev/null +++ b/apps/frontend/src/utils/camera-capture.ts @@ -0,0 +1,48 @@ +/** 上传场景:只有打手端(worker)在移动设备上需要直接调起系统相机。 */ +export type CameraEntryScope = 'admin' | 'worker' | 'collect' + +/** 相机捕获类型:图片走拍照,视频走拍摄。 */ +export type CameraCaptureKind = 'image' | 'video' + +type ResolveCameraEntryOptions = { + /** 当前是否为移动端布局(useIsMobile,宽度 <= 768) */ + isMobile: boolean + /** 上传作用域 */ + scope?: CameraEntryScope | (string & {}) + /** 显式开关:传了就以此为准(true 强制显示,false 强制隐藏) */ + allowCamera?: boolean +} + +/** + * 是否显示「拍照 / 拍摄」入口。 + * + * 规则:默认仅「打手端 + 移动端」显示。管理端/采集端仍只保留相册选择; + * 桌面端(宽度 > 768)隐藏,避免出现点了只会打开文件选择的无效入口。 + * + * 说明:这里用 `` 调起系统相机, + * 不依赖 getUserMedia,因此在纯 HTTP(如 http://worker.localhost/)下同样可用。 + */ +export function resolveCameraEntryEnabled(options: ResolveCameraEntryOptions): boolean { + if (typeof options.allowCamera === 'boolean') return options.allowCamera + return options.isMobile && options.scope === 'worker' +} + +/** + * 系统相机 file input 的属性。 + * + * `capture="environment"` 让移动浏览器优先打开后置摄像头;iOS Safari 的部分版本会 + * 忽略该属性并弹出「拍照 / 从相册选择」菜单,属于预期内的降级行为。 + * + * 用法约束(安卓兼容性):input 必须**透明铺满按钮**、由用户手指直接点到它 + * (见 base.css 的 `.camera-pick-trigger` / `*-camera-input`)。离屏 + 脚本 `.click()` + * 在部分安卓 WebView 上会让浏览器把相机 intent 退化成文件选择。 + */ +export function cameraCaptureInputProps(kind: CameraCaptureKind): { + accept: string + capture: 'environment' +} { + return { + accept: kind === 'image' ? 'image/*' : 'video/*', + capture: 'environment', + } +} diff --git a/apps/frontend/src/utils/request-id.ts b/apps/frontend/src/utils/request-id.ts new file mode 100644 index 00000000..846ed500 --- /dev/null +++ b/apps/frontend/src/utils/request-id.ts @@ -0,0 +1,8 @@ +/** 客户端幂等键:一次用户点击一个,用于拼单参与/抢单的服务端去重。 */ +export function createRequestId(prefix = 'req'): string { + const random = + typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function' + ? crypto.randomUUID() + : `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 10)}` + return `${prefix}-${random}` +} diff --git a/apps/frontend/src/utils/video-compress.ts b/apps/frontend/src/utils/video-compress.ts new file mode 100644 index 00000000..70db2c77 --- /dev/null +++ b/apps/frontend/src/utils/video-compress.ts @@ -0,0 +1,306 @@ +/** + * 验收视频:浏览器内探测 + 重编码(Canvas + MediaRecorder)。 + * + * 为什么不用 ffmpeg.wasm:核心包 31MB、需要 COOP/COEP,iOS 内存易崩; + * 这里只是把「游戏中录屏的 30 秒片段」压到 720p/1.2Mbps,WebM(VP8) 足够, + * 且零依赖、不上传原片(打手多为手机流量)。 + * + * 时长策略(产品已确认):**超过上限直接拒绝,不自动裁剪**。 + */ +export type AcceptanceVideoConfig = { + enabled: boolean + maxDurationSeconds: number + maxSizeMb: number + targetHeight: number + bitrateKbps: number + audioBitrateKbps: number + fps: number +} + +export type VideoProbeResult = { + durationSeconds: number + width: number + height: number +} + +export type CompressedVideo = { + blob: Blob + durationSeconds: number + width: number + height: number + contentType: string + extension: 'webm' | 'mp4' +} + +/** 按兼容性从高到低尝试;实际可用性由 MediaRecorder.isTypeSupported 决定。 */ +const MIME_CANDIDATES = [ + 'video/webm;codecs=vp8,opus', + 'video/webm;codecs=vp9,opus', + 'video/webm', + 'video/mp4;codecs=avc1.42E01E,mp4a.40.2', + 'video/mp4', +] + +export function pickSupportedVideoMimeType( + isSupported: (mime: string) => boolean = defaultMimeSupport, +): string { + for (const candidate of MIME_CANDIDATES) { + if (isSupported(candidate)) return candidate + } + return '' +} + +/** 时长是否超出配置上限;返回空串表示通过。 */ +export function resolveVideoDurationLimitError( + durationSeconds: number, + maxDurationSeconds: number, +): string { + if (!Number.isFinite(durationSeconds) || durationSeconds <= 0) + return '无法读取视频时长,请更换视频' + if (durationSeconds > maxDurationSeconds + 0.5) { + return `视频时长不能超过 ${maxDurationSeconds} 秒,请先裁剪后再上传` + } + return '' +} + +/** 按目标高度等比缩放(取偶数值,编码器要求宽高为偶数)。 */ +export function resolveVideoTargetSize( + width: number, + height: number, + targetHeight: number, +): { width: number; height: number } { + const safeWidth = Math.max(2, Math.round(width || 0)) + const safeHeight = Math.max(2, Math.round(height || 0)) + const limit = Math.max(2, Math.round(targetHeight || 720)) + const scale = Math.min(1, limit / safeHeight) + return { + width: ensureEven(safeWidth * scale), + height: ensureEven(safeHeight * scale), + } +} + +export function isVideoCompressionSupported(): boolean { + if (typeof window === 'undefined' || typeof document === 'undefined') return false + if (typeof MediaRecorder === 'undefined') return false + if (typeof HTMLCanvasElement === 'undefined') return false + if (typeof HTMLCanvasElement.prototype.captureStream !== 'function') return false + return Boolean(pickSupportedVideoMimeType()) +} + +/** 读取视频时长/分辨率;部分 WebM 的 duration 为 Infinity,这里做一次定位探测。 */ +export function probeVideoFile(file: File): Promise { + return new Promise((resolve, reject) => { + const url = URL.createObjectURL(file) + const video = document.createElement('video') + let settled = false + const cleanup = () => { + URL.revokeObjectURL(url) + video.removeAttribute('src') + video.load() + } + const finish = () => { + if (settled) return + settled = true + const durationSeconds = Number.isFinite(video.duration) ? video.duration : 0 + const width = video.videoWidth + const height = video.videoHeight + cleanup() + if (!durationSeconds || durationSeconds <= 0 || !width || !height) { + reject(new Error('无法读取视频信息,请更换视频或先裁剪')) + return + } + resolve({ durationSeconds, width, height }) + } + + video.preload = 'metadata' + video.muted = true + video.src = url + video.onloadedmetadata = () => { + if (Number.isFinite(video.duration) && video.duration > 0) { + finish() + return + } + // WebM 常见:duration = Infinity。跳到极远处逼浏览器计算真实时长。 + video.currentTime = 1e101 + const timer = setTimeout(finish, 1500) + video.ondurationchange = () => { + clearTimeout(timer) + finish() + } + } + video.onerror = () => { + if (settled) return + settled = true + cleanup() + reject(new Error('无法读取视频信息,请更换视频或先裁剪')) + } + }) +} + +/** + * 压缩到配置参数并返回新的 Blob;超过时长上限直接抛错(不裁剪)。 + * onProgress 回调 0~1,用于展示"压缩中"进度。 + */ +export async function compressVideoForUpload( + file: File, + config: AcceptanceVideoConfig, + onProgress?: (ratio: number) => void, +): Promise { + if (!isVideoCompressionSupported()) { + throw new Error('当前浏览器不支持视频压缩,请使用 Chrome / Edge,或先裁剪视频后再上传') + } + const mimeType = pickSupportedVideoMimeType() + const probe = await probeVideoFile(file) + const limitError = resolveVideoDurationLimitError( + probe.durationSeconds, + config.maxDurationSeconds, + ) + if (limitError) throw new Error(limitError) + + const target = resolveVideoTargetSize(probe.width, probe.height, config.targetHeight) + const url = URL.createObjectURL(file) + const video = document.createElement('video') + const canvas = document.createElement('canvas') + let audioContext: AudioContext | null = null + let stopped = false + let frameHandle = 0 + + const stopRecording = () => { + if (stopped) return + stopped = true + try { + video.pause() + } catch { + // ignore + } + } + + try { + video.src = url + video.muted = true + video.playsInline = true + video.preload = 'auto' + await waitForEvent(video, 'loadeddata') + + canvas.width = target.width + canvas.height = target.height + const context = canvas.getContext('2d') + if (!context) throw new Error('当前浏览器不支持视频压缩,请更换浏览器') + + const stream = canvas.captureStream(Math.max(15, Math.min(30, config.fps || 30))) + try { + const AudioContextCtor = + window.AudioContext || + (window as unknown as { webkitAudioContext?: typeof AudioContext }).webkitAudioContext + if (AudioContextCtor) { + audioContext = new AudioContextCtor() + const source = audioContext.createMediaElementSource(video) + const destination = audioContext.createMediaStreamDestination() + source.connect(destination) + for (const track of destination.stream.getAudioTracks()) stream.addTrack(track) + } + } catch { + // 视频无音轨或浏览器限制:退化为无声视频,不阻断压缩。 + } + + const recorder = new MediaRecorder(stream, { + mimeType, + videoBitsPerSecond: Math.max(300, Number(config.bitrateKbps) || 1200) * 1000, + audioBitsPerSecond: Math.max(32, Number(config.audioBitrateKbps) || 64) * 1000, + }) + const chunks: Blob[] = [] + recorder.ondataavailable = (event) => { + if (event.data && event.data.size > 0) chunks.push(event.data) + } + const recorderStopped = new Promise((resolve) => { + recorder.onstop = () => resolve() + }) + + const drawFrame = () => { + if (stopped) return + if (video.readyState >= 2) { + context.drawImage(video, 0, 0, target.width, target.height) + } + onProgress?.(Math.max(0, Math.min(1, video.currentTime / probe.durationSeconds))) + if (video.ended || video.currentTime >= config.maxDurationSeconds) { + stopRecording() + return + } + frameHandle = requestAnimationFrame(drawFrame) + } + + recorder.start(1000) + await video.play() + frameHandle = requestAnimationFrame(drawFrame) + video.addEventListener('ended', stopRecording, { once: true }) + + // 防呆:视频卡住时最多等「时长 + 10 秒」,避免压缩界面永久转圈。 + const timeoutHandle = setTimeout(stopRecording, (probe.durationSeconds + 10) * 1000) + await new Promise((resolve) => { + const interval = setInterval(() => { + if (stopped) { + clearInterval(interval) + resolve() + } + }, 200) + }) + clearTimeout(timeoutHandle) + if (recorder.state !== 'inactive') recorder.stop() + await recorderStopped + if (frameHandle) cancelAnimationFrame(frameHandle) + + const blob = new Blob(chunks, { type: mimeType.split(';')[0] }) + if (!blob.size) throw new Error('视频压缩失败,请重试或更换视频') + const contentType = blob.type || 'video/webm' + onProgress?.(1) + return { + blob, + durationSeconds: Math.min(probe.durationSeconds, config.maxDurationSeconds), + width: target.width, + height: target.height, + contentType, + extension: contentType.includes('mp4') ? 'mp4' : 'webm', + } + } finally { + if (frameHandle) cancelAnimationFrame(frameHandle) + try { + void audioContext?.close() + } catch { + // ignore + } + URL.revokeObjectURL(url) + video.removeAttribute('src') + video.load() + } +} + +function defaultMimeSupport(mime: string): boolean { + if (typeof MediaRecorder === 'undefined') return false + return typeof MediaRecorder.isTypeSupported === 'function' + ? MediaRecorder.isTypeSupported(mime) + : false +} + +function ensureEven(value: number): number { + const rounded = Math.max(2, Math.round(value)) + return rounded % 2 === 0 ? rounded : rounded - 1 +} + +function waitForEvent(target: HTMLMediaElement, eventName: string): Promise { + return new Promise((resolve, reject) => { + const onEvent = () => { + cleanup() + resolve() + } + const onError = () => { + cleanup() + reject(new Error('视频读取失败,请更换文件')) + } + const cleanup = () => { + target.removeEventListener(eventName, onEvent) + target.removeEventListener('error', onError) + } + target.addEventListener(eventName, onEvent, { once: true }) + target.addEventListener('error', onError, { once: true }) + }) +} diff --git a/apps/frontend/src/utils/worker-realtime-work-orders.ts b/apps/frontend/src/utils/worker-realtime-work-orders.ts index 34ede87c..11630eae 100644 --- a/apps/frontend/src/utils/worker-realtime-work-orders.ts +++ b/apps/frontend/src/utils/worker-realtime-work-orders.ts @@ -89,7 +89,20 @@ export function resolveWorkerMyOrderRealtimeVerdict(input: { }): { operation: 'upsert' | 'remove'; shouldInsert: boolean } { const item = isRecord(input.item) ? input.item : {} const statusFilter = String(input.statusFilter || '') - const statusMatches = !statusFilter || statusFilter === String(item.status || '') + /** + * 服务端快照里的 `workerStatuses` 才是「这张单属于哪些页签」的准确答案。 + * + * 同一打手在同一工单可能有多份份额(多次拼单),一张单可以同时出现在「代练中」和「待验收」; + * 而快照的 `status` 只能是一个值。旧逻辑拿单个 status 跟页签比对,只要不相等就删行, + * 于是多份额工单总会在其中一个页签里被删掉(表现为「待验收有时候没有数据」)。 + * 老事件/其它来源没有该字段时,退回原来的单状态比对。 + */ + const tabs = Array.isArray(item.workerStatuses) + ? item.workerStatuses.map((tab) => String(tab || '')).filter(Boolean) + : [] + const statusMatches = + !statusFilter || + (tabs.length > 0 ? tabs.includes(statusFilter) : statusFilter === String(item.status || '')) const statusProvable = input.explicitOrderNo || statusMatches const shouldRemove = String(input.operation || '') === 'remove' || !statusProvable @@ -103,11 +116,60 @@ export function resolveWorkerMyOrderRealtimeVerdict(input: { } } +/** + * 大厅行的实时排序:与后端列表 SQL(pinnedFirst 时 `pinned_at DESC NULLS LAST, id DESC`) + * 保持同一口径。大厅广播是「就地覆盖 + 第 1 页顶部插入」的缓存补丁,快照来自事件 + * 而不是重新查询,行会带着事件发生时的快字段停在缓存里;排序只影响展示位置。 + * + * 这里必须带置顶维度:广播快照的 pinnedAt 参与不了 SQL 排序,若只按 workOrderId 排, + * 每次广播变更(拼单进度、材料更新等)都会把置顶行挤到未置顶行后面 —— + * 表现为「进大厅过一会儿,排名靠后的订单跑到前面去」。 + */ +export function compareWorkerHallOrderRows(left: unknown, right: unknown): number { + const leftPinnedAtMs = resolveHallPinnedAtMs(left) + const rightPinnedAtMs = resolveHallPinnedAtMs(right) + if (leftPinnedAtMs !== rightPinnedAtMs) { + // 有一方置顶即置顶行在前;双方都置顶时按置顶时间倒序(同列表 SQL 的 NULLS LAST)。 + if (leftPinnedAtMs !== null && rightPinnedAtMs !== null) { + return rightPinnedAtMs - leftPinnedAtMs + } + return leftPinnedAtMs !== null ? -1 : 1 + } + // 双方置顶状态一致:按工单号倒序(与列表 SQL 的 `id DESC` 一致)。 + return ( + toNumber((right as { workOrderId?: unknown })?.workOrderId) - + toNumber((left as { workOrderId?: unknown })?.workOrderId) + ) +} + +/** 置顶行的时间戳;未置顶返回 null(对应 SQL 的 NULLS LAST)。 */ +function resolveHallPinnedAtMs(item: unknown): number | null { + if (!isRecord(item)) return null + const parsed = Date.parse(String(item.pinnedAt || '')) + return Number.isFinite(parsed) ? parsed : null +} + /** 按工单号原地增删行,并同步分页总数(结构同后端 `{ data: { items, pagination } }`)。 */ export function patchWorkerWorkOrderListEnvelope( current: unknown, event: WorkerListEvent, insert = false, + /** + * 就地更新时,快照里没有的「本打手私有字段」保留旧值。 + * + * 大厅广播是发给所有打手的通用快照,天然没有 `myShare`/`myShares`(那是每个打手自己的份额)。 + * 整行替换会把「再拼一次」冲回「拼单 ¥x/份」,所以覆盖行时必须保留。 + */ + preserveFields: readonly string[] = [], + /** + * 插行后的行内排序比较器。 + * + * 插入的新行(大厅广播的 upsert)落在第 1 页顶部,如果大厅缓存里已有置顶行, + * 只按「插到最前面」处理会把新行插到置顶行前面;传入比较器可以按大厅的 + * 排序口径(置顶优先,然后工单号倒序)重新排一次。「我的订单」等按服务端 + * 顺序追加的列表不传该参数,保持原行为。 + */ + sortCompare?: (left: unknown, right: unknown) => number, ): unknown { if (!isRecord(current) || !isRecord(current.data) || !Array.isArray(current.data.items)) { return current @@ -128,20 +190,24 @@ export function patchWorkerWorkOrderListEnvelope( ) if (index >= 0) { // 大厅广播是轻量快照,可能不含 myShare 等个性化字段;就地合并而不是整行替换。 - nextItems = items.map((entry, itemIndex) => - itemIndex === index - ? { - ...(isRecord(entry) ? entry : {}), - ...(isRecord(event.data) ? event.data : {}), - } - : entry, - ) + // preserveFields 是调用方对「这些键必须保留」的显式声明;整行合并已经覆盖, + // 这里逐项兜底,避免将来改成浅覆盖时静默丢字段。 + const existing = isRecord(items[index]) ? items[index] : null + const merged: Record = { + ...(existing || {}), + ...(isRecord(event.data) ? event.data : {}), + } + for (const field of preserveFields) { + if (existing && !(field in merged) && field in existing) merged[field] = existing[field] + } + nextItems = items.map((entry, itemIndex) => (itemIndex === index ? merged : entry)) } else if (insert) { nextItems = [event.data, ...items] } else { return current } } + if (sortCompare) nextItems = [...nextItems].sort(sortCompare) const delta = nextItems.length - items.length const pagination = isRecord(current.data.pagination) ? current.data.pagination : null @@ -198,10 +264,18 @@ export function patchWorkerWorkOrderInfiniteEnvelope( current: unknown, event: WorkerListEvent, insert = false, + preserveFields: readonly string[] = [], + sortCompare?: (left: unknown, right: unknown) => number, ): unknown { if (!isRecord(current) || !Array.isArray(current.pages)) return current const pages = current.pages.map((page: unknown, index: number) => - patchWorkerWorkOrderListEnvelope(page, event, insert && index === 0), + patchWorkerWorkOrderListEnvelope( + page, + event, + insert && index === 0, + preserveFields, + sortCompare, + ), ) const lists: unknown[][] = [] diff --git a/apps/frontend/tests/announcement-ack.test.ts b/apps/frontend/tests/announcement-ack.test.ts new file mode 100644 index 00000000..ec086374 --- /dev/null +++ b/apps/frontend/tests/announcement-ack.test.ts @@ -0,0 +1,54 @@ +import assert from 'node:assert/strict' +import test from 'node:test' + +import { + resolveAnnouncementVersion, + shouldShowAnnouncementPopup, +} from '../src/utils/announcement-ack.ts' + +test('公告版本:内容不变版本不变,忽略首尾空白', () => { + const version = resolveAnnouncementVersion('平台担保交易,拒绝私下转账') + assert.ok(version.startsWith('v1-')) + assert.equal(resolveAnnouncementVersion(' 平台担保交易,拒绝私下转账 '), version) +}) + +test('公告版本:内容变化即新版本,空内容没有版本', () => { + const before = resolveAnnouncementVersion('旧公告') + const after = resolveAnnouncementVersion('旧公告(已更新)') + assert.notEqual(before, after) + assert.equal(resolveAnnouncementVersion(''), '') + assert.equal(resolveAnnouncementVersion(' '), '') +}) + +test('弹窗判定:关闭/空正文不弹', () => { + assert.equal( + shouldShowAnnouncementPopup({ enabled: false, content: '公告内容', acknowledgedVersion: '' }), + false, + ) + assert.equal( + shouldShowAnnouncementPopup({ enabled: true, content: '', acknowledgedVersion: '' }), + false, + ) + assert.equal(shouldShowAnnouncementPopup({ content: '公告内容' }), false) +}) + +test('弹窗判定:未确认真弹,确认过同一版本不再弹,改内容后重新弹', () => { + const content = '严禁私下交易' + const version = resolveAnnouncementVersion(content) + assert.equal( + shouldShowAnnouncementPopup({ enabled: true, content, acknowledgedVersion: '' }), + true, + ) + assert.equal( + shouldShowAnnouncementPopup({ enabled: true, content, acknowledgedVersion: version }), + false, + ) + assert.equal( + shouldShowAnnouncementPopup({ + enabled: true, + content: '严禁私下交易(2026-10-25 更新)', + acknowledgedVersion: version, + }), + true, + ) +}) diff --git a/apps/frontend/tests/camera-capture.test.ts b/apps/frontend/tests/camera-capture.test.ts new file mode 100644 index 00000000..21c15e46 --- /dev/null +++ b/apps/frontend/tests/camera-capture.test.ts @@ -0,0 +1,34 @@ +import assert from 'node:assert/strict' +import test from 'node:test' + +import { cameraCaptureInputProps, resolveCameraEntryEnabled } from '../src/utils/camera-capture.ts' + +test('相机入口:仅打手端移动端显示', () => { + assert.equal(resolveCameraEntryEnabled({ isMobile: true, scope: 'worker' }), true) + assert.equal(resolveCameraEntryEnabled({ isMobile: false, scope: 'worker' }), false) + assert.equal(resolveCameraEntryEnabled({ isMobile: true, scope: 'admin' }), false) + assert.equal(resolveCameraEntryEnabled({ isMobile: true, scope: 'collect' }), false) + assert.equal(resolveCameraEntryEnabled({ isMobile: true }), false) +}) + +test('相机入口:allowCamera 显式开关优先', () => { + assert.equal( + resolveCameraEntryEnabled({ isMobile: true, scope: 'worker', allowCamera: false }), + false, + ) + assert.equal( + resolveCameraEntryEnabled({ isMobile: false, scope: 'admin', allowCamera: true }), + true, + ) +}) + +test('相机构造:图片/视频分别使用对应 accept 与后置摄像头', () => { + assert.deepEqual(cameraCaptureInputProps('image'), { + accept: 'image/*', + capture: 'environment', + }) + assert.deepEqual(cameraCaptureInputProps('video'), { + accept: 'video/*', + capture: 'environment', + }) +}) diff --git a/apps/frontend/tests/video-compress.test.ts b/apps/frontend/tests/video-compress.test.ts new file mode 100644 index 00000000..2eb84d90 --- /dev/null +++ b/apps/frontend/tests/video-compress.test.ts @@ -0,0 +1,49 @@ +import assert from 'node:assert/strict' +import test from 'node:test' + +import { + pickSupportedVideoMimeType, + resolveVideoDurationLimitError, + resolveVideoTargetSize, +} from '../src/utils/video-compress.ts' + +test('时长策略:超过上限直接拒绝(不自动裁剪)', () => { + assert.equal(resolveVideoDurationLimitError(30, 30), '') + assert.equal(resolveVideoDurationLimitError(29.4, 30), '') + assert.match(resolveVideoDurationLimitError(31, 30), /不能超过 30 秒/) + assert.match(resolveVideoDurationLimitError(30.6, 30), /不能超过 30 秒/) +}) + +test('时长策略:读不到时长也要拒绝,避免上传未知长度视频', () => { + assert.match(resolveVideoDurationLimitError(0, 30), /无法读取视频时长/) + assert.match(resolveVideoDurationLimitError(Number.NaN, 30), /无法读取视频时长/) +}) + +test('压缩尺寸:按目标高度等比缩放且宽高为偶数', () => { + assert.deepEqual(resolveVideoTargetSize(1920, 1080, 720), { width: 1280, height: 720 }) + assert.deepEqual(resolveVideoTargetSize(1080, 1920, 720), { width: 404, height: 720 }) + // 原视频比目标更小:不放大 + assert.deepEqual(resolveVideoTargetSize(640, 360, 720), { width: 640, height: 360 }) + const odd = resolveVideoTargetSize(1081, 721, 720) + assert.equal(odd.width % 2, 0) + assert.equal(odd.height % 2, 0) +}) + +test('容器选择:优先 WebM(VP8),其次 VP9/WebM,最后 MP4', () => { + assert.equal( + pickSupportedVideoMimeType((mime) => true), + 'video/webm;codecs=vp8,opus', + ) + assert.equal( + pickSupportedVideoMimeType((mime) => mime === 'video/webm' || mime.startsWith('video/mp4')), + 'video/webm', + ) + assert.equal( + pickSupportedVideoMimeType((mime) => mime.startsWith('video/mp4')), + 'video/mp4;codecs=avc1.42E01E,mp4a.40.2', + ) + assert.equal( + pickSupportedVideoMimeType(() => false), + '', + ) +}) diff --git a/apps/frontend/tests/worker-realtime-work-orders.test.ts b/apps/frontend/tests/worker-realtime-work-orders.test.ts index f6c54b1d..b38bcb89 100644 --- a/apps/frontend/tests/worker-realtime-work-orders.test.ts +++ b/apps/frontend/tests/worker-realtime-work-orders.test.ts @@ -2,6 +2,7 @@ import assert from 'node:assert/strict' import test from 'node:test' import { + compareWorkerHallOrderRows, evaluateWorkerKeywordVerdict, matchesWorkerWorkOrderKeyword, patchWorkerWorkOrderInfiniteEnvelope, @@ -288,3 +289,188 @@ test('非第 1 页不得插入,只做就地更新', () => { assert.equal(verdict.operation, 'upsert') assert.equal(verdict.shouldInsert, false) }) + +/** + * 同一打手在同一个工单多次拼单:一张单可以同时属于「代练中」和「待验收」, + * 但快照的 status 只能是一个值。前端若按单个 status 删行,另一个页签就会莫名其妙变空 + * (线上「待验收有时候没有数据」)。服务端现在带 workerStatuses,按集合判归属。 + */ +test('多份额工单:快照 status 是进行中,但属于待验收时不得从待验收页签删行', () => { + const snapshot = buildRow({ + status: 'in_progress', + workerStatuses: ['in_progress', 'pending_acceptance'], + }) + const verdict = resolveWorkerMyOrderRealtimeVerdict({ + item: snapshot, + statusFilter: 'pending_acceptance', + keyword: '', + operation: 'upsert', + page: 1, + explicitOrderNo: false, + }) + assert.equal(verdict.operation, 'upsert', '待验收页签必须保留这张多份额工单') + assert.equal(verdict.shouldInsert, true) + + const updated = patchWorkerWorkOrderListEnvelope( + buildEnvelope([buildRow({ status: 'pending_acceptance' })]), + { operation: verdict.operation, data: snapshot }, + verdict.shouldInsert, + ) + assert.equal(itemsOf(updated).length, 1) + assert.equal((itemsOf(updated)[0] as { status?: string }).status, 'in_progress') +}) + +test('多份额工单:workerStatuses 同时也是代练中页签的归属依据', () => { + const verdict = resolveWorkerMyOrderRealtimeVerdict({ + item: buildRow({ + status: 'in_progress', + workerStatuses: ['in_progress', 'pending_acceptance'], + }), + statusFilter: 'in_progress', + keyword: '', + operation: 'upsert', + page: 1, + explicitOrderNo: false, + }) + assert.equal(verdict.operation, 'upsert') +}) + +test('不在页签集合里时才允许删行(例如已验收后离开待验收页签)', () => { + const verdict = resolveWorkerMyOrderRealtimeVerdict({ + item: buildRow({ status: 'accepted', workerStatuses: ['accepted'] }), + statusFilter: 'pending_acceptance', + keyword: '', + operation: 'upsert', + page: 1, + explicitOrderNo: false, + }) + assert.equal(verdict.operation, 'remove') + assert.equal(verdict.shouldInsert, false) +}) + +test('VIP 待补图页签也靠 workerStatuses 判定,不会被当成状态不符删掉', () => { + const verdict = resolveWorkerMyOrderRealtimeVerdict({ + item: buildRow({ status: 'accepted', workerStatuses: ['accepted', 'vip_evidence_pending'] }), + statusFilter: 'vip_evidence_pending', + keyword: '', + operation: 'upsert', + page: 1, + explicitOrderNo: false, + }) + assert.equal(verdict.operation, 'upsert') +}) + +test('没有 workerStatuses 的旧快照仍按单个 status 比对(保持既有行为)', () => { + const mismatch = resolveWorkerMyOrderRealtimeVerdict({ + item: buildRow({ status: 'accepted' }), + statusFilter: 'pending_acceptance', + keyword: '', + operation: 'upsert', + page: 1, + explicitOrderNo: false, + }) + assert.equal(mismatch.operation, 'remove') + + const match = resolveWorkerMyOrderRealtimeVerdict({ + item: buildRow({ status: 'pending_acceptance' }), + statusFilter: 'pending_acceptance', + keyword: '', + operation: 'upsert', + page: 1, + explicitOrderNo: false, + }) + assert.equal(match.operation, 'upsert') +}) + +/** + * 大厅按钮需求:拼过一次后按钮固定显示「再拼一次」,不再随次数变化。 + * 这依赖 myShare/myShares 一直留在行上;而大厅广播是「所有打手通用」的快照, + * 不含个人份额,覆盖行时必须保留,否则按钮会被冲回「拼单 ¥x/份」。 + */ +test('大厅广播覆盖行时必须保留本打手的 myShare/myShares,并更新拼单进度', () => { + const cachedRow = buildRow({ + status: 'open', + myShare: { shareId: 77, quantity: 1, status: 'joined' }, + myShares: [{ shareId: 77, quantity: 1, status: 'joined' }], + sharingProgress: { joinedQuantity: 1, pendingSubmissionCount: 1 }, + }) + const envelope = { pages: [buildEnvelope([cachedRow])], pageParams: [1] } + const snapshot = buildRow({ + status: 'open', + sharingProgress: { joinedQuantity: 2, pendingSubmissionCount: 2 }, + }) + const patched = patchWorkerWorkOrderInfiniteEnvelope( + envelope, + { operation: 'upsert', data: snapshot }, + true, + ['myShare', 'myShares'], + ) as { pages: unknown[] } + const row = itemsOf(patched.pages[0])[0] as { + myShare?: { shareId: number } + myShares?: unknown[] + sharingProgress?: { joinedQuantity: number } + } + assert.equal(row.myShare?.shareId, 77, '不能把「再拼一次」的依据冲掉') + assert.equal(row.myShares?.length, 1) + assert.equal(row.sharingProgress?.joinedQuantity, 2, '公开进度仍要用广播里的最新值') + + // 新插入的行本来就不属于我,不能凭空补上旧字段 + const inserted = patchWorkerWorkOrderInfiniteEnvelope( + envelope, + { operation: 'upsert', data: buildRow({ workOrderId: 9999, status: 'open' }) }, + true, + ['myShare', 'myShares'], + ) as { pages: unknown[] } + const insertedRow = itemsOf(inserted.pages[0]).find((item) => item.workOrderId === 9999) as + | { myShare?: unknown; myShares?: unknown } + | undefined + assert.equal(insertedRow?.myShare ?? null, null) + assert.equal(insertedRow?.myShares ?? null, null) +}) + +/** + * 线上坑:进大厅后过一会儿「排名靠后的订单跑到前面去」。 + * + * 根因是大厅广播补丁只做「就地覆盖 + 顶部插入」、不重排缓存: + * 1) 列表 SQL 按置顶时间排序时,广播快照没带 pinnedAt,排序口径直接丢失; + * 2) 广播 upsert 插到第 1 页顶部,会把已置顶的行挤到后面。 + * 修复 = 后端快照带 pinnedAt + 前端按「置顶优先 → 工单号倒序」重排插行后的列表。 + */ +test('大厅排序比较器:置顶行恒在未置顶行前,同置顶按置顶时间倒序', () => { + const pinnedNewer = buildRow({ workOrderId: 1, pinnedAt: '2026-10-24T10:00:00Z' }) + const pinnedOlder = buildRow({ workOrderId: 2, pinnedAt: '2026-10-24T09:00:00Z' }) + const unpinned = buildRow({ workOrderId: 3, pinnedAt: null }) + + assert.equal(compareWorkerHallOrderRows(pinnedNewer, pinnedOlder) < 0, true) + assert.equal(compareWorkerHallOrderRows(pinnedOlder, pinnedNewer) > 0, true) + assert.equal(compareWorkerHallOrderRows(pinnedNewer, unpinned) < 0, true) + assert.equal(compareWorkerHallOrderRows(unpinned, pinnedNewer) > 0, true) + // 双方都未置顶:按工单号倒序(与列表 SQL 的 id DESC 一致) + const newer = buildRow({ workOrderId: 10, pinnedAt: null }) + const older = buildRow({ workOrderId: 9, pinnedAt: null }) + assert.equal(compareWorkerHallOrderRows(newer, older) < 0, true) +}) + +test('广播 upsert 插入新行后必须按大厅口径重排,不能把置顶行挤下去', () => { + const pinnedRow = buildRow({ + workOrderId: 100, + status: 'open', + pinnedAt: '2026-10-24T09:00:00Z', + }) + const envelope = { pages: [buildEnvelope([pinnedRow])], pageParams: [1] } + const patched = patchWorkerWorkOrderInfiniteEnvelope( + envelope, + { + operation: 'upsert', + data: buildRow({ workOrderId: 200, status: 'open', pinnedAt: null }), + }, + true, + ['myShare', 'myShares'], + compareWorkerHallOrderRows, + ) as { pages: unknown[] } + assert.deepEqual( + itemsOf(patched.pages[0]).map((item) => item.workOrderId), + [100, 200], + '新单排在置顶单后面,而不是插到最前面', + ) +}) diff --git a/apps/frontend/vite.config.ts b/apps/frontend/vite.config.ts index 48dbd160..780847ec 100644 --- a/apps/frontend/vite.config.ts +++ b/apps/frontend/vite.config.ts @@ -55,6 +55,15 @@ export default defineConfig({ server: { host: '0.0.0.0', port: 5173, + /** + * Windows 下 Docker 绑定挂载(D:\... -> /app)收不到 inotify 事件,Vite 的文件监听 + * 会失效:代码改了页面却一直不更新(和后端 tsx watch 同类问题)。 + * 改用轮询,改文件后 HMR 才能正常触发。 + */ + watch: { + usePolling: true, + interval: 300, + }, // 隧道/自定义域名访问 dev server 时需加入白名单,否则 Vite 会拦截 Host allowedHosts, proxy: { diff --git a/deploy/caddy/Caddyfile.dev b/deploy/caddy/Caddyfile.dev index 5bc55e7c..42080bdb 100644 --- a/deploy/caddy/Caddyfile.dev +++ b/deploy/caddy/Caddyfile.dev @@ -1,19 +1,49 @@ +# 本地开发网关(仅 dev,生产用 Caddyfile.prod / 其它部署文件): +# 管理端:http:/// 或 http://localhost/ +# 打手端:http://:8080/ +# +# 前端是同一份 SPA,靠 hostname 判断站点(apps/frontend/src/utils/site.ts)。 +# 因此这里用「两个 Vite dev server」而不是只加端口: +# :80 → frontend:5173 (其 VITE_WORKER_SITE_URL 指向 worker.localhost,所以 IP 访问=管理端) +# :8080 → frontend-worker:5174 (其 VITE_WORKER_SITE_URL 指向 http://,所以 IP 访问=打手端) +# 这样无需改动任何业务代码,生产构建也不受影响。 + :80 { - route { - handle /api/* { - reverse_proxy backend:3000 - } + route { + handle /api/* { + reverse_proxy backend:3000 + } - handle /health* { - reverse_proxy backend:3000 - } + handle /health* { + reverse_proxy backend:3000 + } - handle /s/* { - reverse_proxy backend:3000 - } + handle /s/* { + reverse_proxy backend:3000 + } - handle { - reverse_proxy frontend:5173 - } - } + handle { + reverse_proxy frontend:5173 + } + } +} + +:8080 { + route { + handle /api/* { + reverse_proxy backend:3000 + } + + handle /health* { + reverse_proxy backend:3000 + } + + handle /s/* { + reverse_proxy backend:3000 + } + + handle { + reverse_proxy frontend-worker:5174 + } + } } diff --git a/docker-compose.dev.yml b/docker-compose.dev.yml index 382dd9e7..93adc571 100644 --- a/docker-compose.dev.yml +++ b/docker-compose.dev.yml @@ -152,6 +152,33 @@ services: - ./.prettierignore:/.prettierignore:ro - frontend_node_modules:/app/node_modules + # 打手端专用 dev server:同一份代码,只把「打手端 hostname」设成 IP, + # 由 Caddy 的 8080 端口反代过来(前端按 hostname 判站点,见 utils/site.ts)。 + frontend-worker: + image: ${BASE_REGISTRY:-docker.m.daocloud.io}/library/node:22-bookworm-slim + working_dir: /app + restart: unless-stopped + logging: *json-log-rotation + depends_on: + backend: + condition: service_healthy + command: >- + sh -lc + "[ -d node_modules/vite ] || npm install --no-fund --no-audit; + npm run dev -- --host 0.0.0.0 --port 5174" + environment: + TZ: ${TZ:-Asia/Shanghai} + CHOKIDAR_USEPOLLING: "true" + NPM_CONFIG_REGISTRY: https://registry.npmmirror.com + VITE_API_TARGET: http://backend:3000 + VITE_ALLOWED_HOSTS: ${VITE_ALLOWED_HOSTS:-localhost,127.0.0.1} + VITE_WORKER_SITE_URL: ${WORKER_SITE_ADDR_IP:-http://192.168.10.8} + volumes: + - ./apps/frontend:/app + - ./.prettierrc.json:/.prettierrc.json:ro + - ./.prettierignore:/.prettierignore:ro + - frontend_worker_node_modules:/app/node_modules + web: image: ${BASE_REGISTRY:-docker.m.daocloud.io}/library/caddy:2-alpine restart: unless-stopped @@ -161,9 +188,13 @@ services: condition: service_healthy frontend: condition: service_started + frontend-worker: + condition: service_started ports: - "80:80" - "443:443" + # 打手端(IP 访问):http://:8080/ + - "${WORKER_LAN_PORT:-8080}:8080" environment: TZ: ${TZ:-Asia/Shanghai} volumes: @@ -174,3 +205,4 @@ volumes: minio_dev_data: backend_node_modules: frontend_node_modules: + frontend_worker_node_modules: diff --git a/docs/工单拼单多次参与改造方案-2026-10-23.md b/docs/工单拼单多次参与改造方案-2026-10-23.md new file mode 100644 index 00000000..ce3b024e --- /dev/null +++ b/docs/工单拼单多次参与改造方案-2026-10-23.md @@ -0,0 +1,429 @@ +# 拼单多次参与改造方案(同一打手可多次参与、每份独立计时) + +> 状态:**方案(待确认,未执行)** +> 日期:2026-10-23 +> 代码基线:工作区当前 HEAD(迁移 001–110) +> 目标:同一打手对同一拼单工单可重复参与,每次参与新增一条 `work_order_shares`;每份固定自己的截止时间;超时/验收/撤单按份额独立。 + +--- + +## 一、结论速览 + +**已确认的七项口径(2026-10-23)** + +| 决策 | 确认结果 | 落地要点 | +| --- | --- | --- | +| D1 免押额度 | **跟着母单走**(工单维度累计,等价方案 B) | 免押额度在该工单只抵扣一次:`累计需求 − 免押额度 − 本工单已冻结`,只有差额进入本份 `share_deposit` | +| D2 活跃单计数 | **按不同工单数** | 同一工单拼 3 份只占 1 个名额;改为 `COUNT(DISTINCT work_order_id)` | +| D3 撤单粒度 | **整单 + 单份额都要支持** | 不传 `shareId` = 撤销该打手在该工单的全部有效份额;传 `shareId` = 只撤这一份 | +| D4 验收方式 | **全部验收 + 单独验收都要有** | 统一验收 = 逐份额结算的批处理;单独验收按 `shareId` | +| D5 我的订单列表 | **一行一工单 + 展开份额** | 列表不拆行,工单行内嵌 `myShares[]`,每份独立展示与操作 | +| D6 防误触 | **客户端 `requestId` 幂等** | 份额表加 `request_id` + 唯一索引;重复请求返回原份额 | +| D7 验收通知 | **拼单按 `shareId` 去重;统一验收也要通知** | 每份提交各一条待办;统一验收完成再发一条整单完成通知 | + + +| 项 | 结论 | +| --- | --- | +| 能不能做 | 能,改造集中在工单模块,不触及订单/履约/凭证链路 | +| 最大阻塞 | `work_order_shares` 上的 `UNIQUE(work_order_id, worker_id)`(迁移 008),以及 7 处 `getWorkOrderShare(orderId, workerId)` 假设「一人一条」 | +| 需要新增的列 | `deadline_at`(本份截止)、`timeout_minutes` / `timeout_policy`(参与时快照)、`cancelled_reason` | +| 需要新增的索引 | 份额到期扫描索引、打手份额列表索引;**删除**唯一键 | +| 影响接口 | 打手端 5 个(参与、提交验收、草稿、补证据、退单申请)+ 返回结构 `myShare → myShares` | +| 影响前端 | 大厅「已拼」按钮逻辑、我的订单列表/详情、验收弹窗、后台工单详情份额表 | +| 数据迁移 | 只加列 + 回填 + 删唯一键,**不改历史行**;历史合并份额保持原样 | +| 工作量估算 | 后端 2 人日、前端 2 人日、测试 1 人日(不含评审与联调) | +| 需要你拍板 | 押金/免押分摊方式、活跃单计数口径、退单申请粒度等 7 项(见第十三节) | + +--- + +## 二、现状:四个硬阻塞 + +| # | 阻塞 | 位置 | 影响 | +| --- | --- | --- | --- | +| B1 | `UNIQUE(work_order_id, worker_id)` | 迁移 `008_work_order_sharing.sql:32`,约束名 `work_order_shares_work_order_id_worker_id_key` | 第二次参与直接违反唯一键 | +| B2 | 「按工单 + 打手」只取一条份额 | `getWorkOrderShare(workOrderId, workerId)`,被 7 处调用:验收提交、补证据、草稿、流转记录、备注、已读、我的订单 | 多份额后行为不确定(当前 `LIMIT 1` 取任意一条) | +| B3 | 超时时间由「母单当前配置」实时算 | `listOverdueWorkOrderShares`:`wos.created_at + wo.timeout_minutes`;`submitWorkerSharingAcceptance` 同口径 | 母单改时限会追溯影响已参与份额;且不是「参与时固化」 | +| B4 | 前端单值模型 | `myShare` 在 `worker-order-utils.tsx`、`WorkerHallPage`、`WorkerOrderMobileList`、`WorkerOrderTableColumns`、`types/worker-platform.ts` 共 30+ 处 | 大厅按钮 `disabled={… || 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` 是该份额实际冻结的增量金额,释放/扣除都只针对它;同一打手在同一工单的多条份额互不覆盖。 + +--- + +## 四、数据库方案(迁移 `109_work_order_share_participation.sql`) + +### 4.1 DDL(最终实现) + +原「多次参与 / 份额级问题单 / 撤销原因 / 撤销份数」四个增量迁移已合并重写为一个文件: +[`109_work_order_share_participation.sql`](../apps/backend/src/db/migrations/109_work_order_share_participation.sql)。 + +迁移内容: + +1. **新增列**:`deadline_at` / `timeout_minutes` / `timeout_policy` / `cancelled_reason` / `request_id` / `problem_note` / `problem_marked_at` / `problem_marked_by` / `cancel_note` / `cancel_quantity`; +2. **回填历史份额**:按参与时母单配置一次性固化截止时间与超时策略(只碰 `timeout_minutes = 0` 的历史行); +3. **删除 `(work_order_id, worker_id)` 唯一约束**:取消的历史份额保留追溯,重新参与直接新增一条; +4. **索引**:到期扫描(`joined` + `deadline_at`)、打手份额列表、母单状态推导、`request_id` 幂等唯一索引、份额级问题单探测。 + +> 项目约定:迁移必须幂等、不改历史文件;因此所有列/索引都用 `IF NOT EXISTS`,删约束用 `DO` 块判存在。 + +大厅置顶索引与后台店铺筛选索引合并到 +[`110_work_order_hall_and_shop_indexes.sql`](../apps/backend/src/db/migrations/110_work_order_hall_and_shop_indexes.sql)。 + +### 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` 下完全兼容,**不需要拆历史行**。拆行会重写历史资金归属,风险大于收益。 + +--- + +## 五、押金与免押额度(**需要你选择**) + +现状:`resolveWorkOrderShareDepositPlan`(原 `resolveWorkOrderShareDepositAmount` 的重写)按「该打手在本工单累计份额」计算应冻结总额,并按已冻结合计只补差额(不回退)。拆成多行后,必须明确免押额度怎么分摊,否则可被拆分套利。 + +| 方案 | 规则 | 优点 | 缺点 | +| --- | --- | --- | --- | +| **A 每份独立全额免押** | 每份 `max(0, round(required × q/total) − free)` | 实现最简单 | **可套利**:免押 5000、每份需求 1000 时可无限拆成 0 押金份额 | +| **B 工单累计口径(已确认)** | 先算本工单累计需求 `round(required × 累计份数/total) − 免押额度`,再减去该打手在该工单已冻结合计,差额即本份 `share_deposit`(不为负) | 经济语义与现在完全一致;不会因拆分多冻结或多免押 | 需要一次 SUM 查询;「已冻结不回退」的既有特性保留 | +| **C 按份数比例分摊免押** | `freePerUnit = floor(free / total)`,每份 `max(0, round(required×q/total) − freePerUnit×q)`,尾差归最后一份 | 每份可独立解释 | 需要记录「免押已分摊额度」,多一张表或加列;尾差处理易错 | + +**已确认采用 B(免押额度跟着母单走)**:当前线上语义就是「累计抵扣、只补差额」,B 不改变任何已验证的金额结果;A 会出现新的套利路径;C 引入尾差与额外状态。 + +### 5.1 计算式(定稿) + +```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/109_work_order_share_participation.sql` | 新增(10 列 + 回填 + 删唯一键 + 5 个索引,合并多次参与/份额问题/撤销原因/撤销份数) | +| `db/migrations/110_work_order_hall_and_shop_indexes.sql` | 新增(大厅置顶候选集索引 + 后台店铺筛选表达式索引) | +| `repositories/worker-platform/types.ts` | `WorkOrderShareRow` 加 10 个字段 | +| `repositories/worker-platform/work-order-share-repo.ts` | **核心**:`joinWorkOrderShare` 去掉「合并到 existingShare」分支,改为每次都 INSERT;押金改累计口径;写入 deadline 快照;新增 `getWorkOrderShareById`、`listWorkOrderSharesByWorkerInOrder`;`getWorkOrderShare` 保留但改名语义为「主份额」 | +| `repositories/worker-platform/work-order-share-repo.ts` | `submitWorkOrderShareAcceptance` / `updateWorkOrderShareDraftAcceptance` / `updateWorkOrderShareAcceptanceRecord` 改为接收 `shareId`(workerId 仅做归属校验) | +| `repositories/worker-platform/work-order-query-repo.ts` | `listOverdueWorkOrderShares` 改按 `deadline_at`;`countWorkerActiveOrdersWithClient` 改为按不同工单计数(见 9.1) | +| `repositories/worker-platform/work-order-settlement-repo.ts` | `settleOverdueWorkOrderShare` 用份额快照 + 调用 `recompute`;`acceptWorkOrderShareAndSettle` 结尾调用 `recompute`;`cancelWorkOrderShare` 调用 `recompute` | +| `repositories/worker-platform/worker-cancel-request-repo.ts` | 创建退单申请必须定位到具体 `shareId`;审核通过后 `recompute` | +| `services/worker-platform/worker-hall-action-service.ts` | 拼单参与不再阻塞已参与;deadline 由 repo 计算 | +| `services/worker-platform/worker-acceptance-service.ts` | 3 处 `getWorkOrderShare` 改为「按 shareId 或唯一份额解析」;超时判定用 `share.deadline_at` | +| `services/worker-platform/worker-my-order-service.ts` | 3 处授权判断改为「存在任一有效份额」;响应补 `myShares` | +| `services/worker-platform/work-order-display-mappers.ts` | `mapWorkOrderForWorker` 接收 `myShares[]`;`myShare` 取主份额;`timeoutAt` 用 `share.deadline_at`;`mapWorkOrderShare` 输出新字段;`mapWorkOrderSharingWorkers` 增加明细 | +| `services/worker-platform/admin-work-order-sharing-service.ts` | 返回 `sharingShares` 明细 | +| `routes/worker/hall.ts`、`routes/worker/orders.ts` | 透传 `shareId` | +| `routes/admin/worker-platform/orders.ts` | 无新增(沿用 shareId 路由) | +| `services/worker-platform/index.ts` | 导出新增函数 | + +**新增内部函数**:`recomputeWorkOrderSharingStatusWithClient`(建议放 `work-order-share-repo.ts`,因为它只读份额表)。 + +### 9.1 活跃单计数(D2 已确认:按不同工单) + +```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. 迁移 109(加列 → 回填 → 删唯一键 → 建索引)+ 110(大厅置顶 / 店铺筛选索引)。 +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 条历史脏状态仅记录未动资金 | +| 迁移 109 + 110 | ✅ | 四个增量脚本合并重写;临时库全量执行成功、重跑幂等;psql 确认 10 列、唯一键已删、5 个份额索引 + 2 个性能索引;宿主机 `npm run check:sql` 通过 | +| 仓库层 | ✅ | 每次参与 INSERT 新份额;`(worker_id, request_id)` 先查后插幂等;押金按累计口径只补差额;`deadline_at/timeout_minutes/timeout_policy` 参与时快照;`recomputeWorkOrderSharingStatusWithClient` 成为母单状态唯一出口 | +| 超时 / 撤单 | ✅ | 超时只处置单份并写 `cancelled_reason='timeout'`;单份撤单 `admin`、打手退单 `worker_request`;整单 + 单份额两种粒度都支持;均不再各自 UPDATE 母单状态 | +| 服务层 / 路由 | ✅ | 大厅与我的订单透传 `requestId`;多份额验收未传 `shareId` 时返回 400 `work_order_share_id_required`;活跃单按不同工单去重 | +| 通知 | ✅ | 拼单验收待办 dedupeKey 带 `shareId`;新增 `order_accepted` 整单完成通知(含统一验收) | +| 前端 | ✅ | 大厅可重复参与(每次生成 `requestId`,显示「已参与 N 次 · 共 M 份」);我的订单一行一工单 + 展开份额 + 逐份验收/退单;后台份额表新增「状态+撤单原因 / 参与时间 / 截止时间 / 超时策略」并保留逐份验收与单人撤单 | +| 测试 | ✅ | 后端 750 项:735 通过、10 跳过、5 项为既有 Windows/CRLF 环境失败(与本次改动文件无关);前端 143 项全通过 | +| 冒烟走查 | ✅ | 真实库脚本化验证:同一打手 2 次参与生成 2 条份额(押金 2000 + 3000,累计口径)→ `requestId` 重放返回原份额且不重复扣款 → 母单改超时配置不影响已固化 deadline → 单份超时只取消该份、另一份保持 joined、母单仍 open → 补满并逐份提交后母单才转 `pending_acceptance`;脚本数据已全部清理(0 残留) | + +### 已知遗留 + +- 后台 `mapWorkOrderSharingWorkers` 仍按打手汇总一行(用于列表角标),明细操作走份额表,属设计保留。 +- 5 项后端既有测试失败:`backup-db.sh` 在容器内缺 `/deploy` 文件;3 项为 CRLF 下源码字符串守卫断言;1 项 `http-cache` 路由守卫。均未触及本次改动文件,建议后续统一把源码守卫改成忽略行尾。 + diff --git a/docs/打手工单深挖-抢单拼单好友赠送结算-2026-10-23.md b/docs/打手工单深挖-抢单拼单好友赠送结算-2026-10-23.md new file mode 100644 index 00000000..027052bc --- /dev/null +++ b/docs/打手工单深挖-抢单拼单好友赠送结算-2026-10-23.md @@ -0,0 +1,467 @@ +# 打手工单深挖:抢单 / 拼单 / 好友赠送 / 结算(2026-10-23) + +> 状态:**梳理稿(待评审,未登记索引)** +> 落笔日期:2026-10-23 +> 代码基线:工作区当前 HEAD(迁移 001–110) +> 范围:`work_orders` 从「可抢」到「已验收 + 资金结算」的完整闭环,含拼单份额、好友赠送、超时/问题单/退单申请、售后与欠款 +> 不在范围:寄卖提报与匹配算法、订单进单、领取页(见 `项目全流程与数据库设计梳理-2026-10-23.md`) + +--- + +## 一、一页结论 + +1. **一条工单只有两个资金入口**:接单时冻结押金(`deposit_freeze`),验收时结算报酬 + 释放押金(`reward_settlement` / `deposit_release`,或延迟解冻)。 +2. **押金不是「按单」而是「按接单关系的未结押金」**:`getOutstandingDepositAmountWithClient(workerId, workOrderId)` 是唯一口径,同一工单多次接取/退回也只处理当前仍冻结的部分。 +3. **拼单的免押额度按「该打手在本工单的累计份额」抵扣一次**,且**已冻结金额不会因为追加份数变少而自动回退**(`Math.max(当前冻结, 新要求)`)。 +4. **好友赠送是「三态叠加」**:`work_orders.status`(生命周期)+ `gift_phase`(赠送阶段)+ `gift_available_at`(冷却到点)。`gift_ready` **不落库**,由到点实时计算。 +5. **确认加好友是一道资金分水岭**:`friend_added_at` 一旦写入,任何「释放押金」的处置都会被强制改成「扣除押金」,寄卖卖家的发布押金同口径一并没收。 +6. **超时、问题单、退单申请三条处置路径共用一套押金策略词表**:`reopen / cancel_release / cancel_deduct`(问题单另有 `return_to_worker`,不碰押金)。 +7. **验收有两条免审通道**:内部打手(`worker_type='internal'`)与 VIP(`autoAcceptWithoutEvidence`,可无图提交,但留 24 小时补证据窗口);**好友赠送单一律人工验收**。 +8. **退单是唯一会掉等级/封号的常规路径**:VIP1 累计 5 次通过 → 直接禁用账号并吊销会话;其他等级每 10 次通过 → 降一级并把 `level_penalty_accepted_count` 抬高,变相提高升级门槛。 +9. **资金全部落在 `worker_wallet_ledgers`**(只追加),但**余额没有「由流水重算」的校验**,也没有会计期间;它仍然是业务钱包,不是总账。 +10. **锁序不统一**:抢单先锁钱包再更新工单,指派先锁工单再锁钱包,拼单先锁工单再锁钱包——`grabWorkOrder` 是唯一的「钱包优先」路径。 + +--- + +## 二、单据与资金模型 + +```mermaid +flowchart LR + subgraph 单据 + WO["work_orders
生命周期状态"] + WS["work_order_shares
拼单份额"] + EV["work_order_events
只追加时间线"] + end + subgraph 资金 + W["worker_wallets
available / frozen_deposit
pending_unfreeze / frozen_withdraw"] + L["worker_wallet_ledgers
只追加流水"] + U["worker_deposit_unfreezes
延迟解冻队列"] + end + WO -->|"整单接取"| D1["deposit_freeze"] + WS -->|"按份额接取"| D1 + WO -->|"验收"| D2["reward_settlement / deposit_release"] + WS -->|"份额验收"| D2 + D2 -->|"unfreezeDays > 0"| U + U -->|"到期任务"| D3["deposit_unfreeze"] + WO -->|"超时/问题单/退单"| D4["deposit_release 或 deposit_deduction"] + D1 --> L + D2 --> L + D3 --> L + D4 --> L + L --> W +``` + +**唯一的余额写入方式**(除提现外)都是「`SELECT … FOR UPDATE` 读钱包 → 计算 next → `UPDATE` → `INSERT ledger`」,没有任何一处只更新余额而不写流水。 + +--- + +## 三、抢单 + +### 3.1 入口与校验顺序 + +`POST /api/v1/worker/hall/orders/:workOrderId/grab` → `grabWorkerHallOrder` + +```mermaid +sequenceDiagram + participant W as 打手 + participant S as hall-action-service + participant DB as PostgreSQL + W->>S: grab(workOrderId) + S->>S: 会话有效 → 读打手 + 等级权限 + 大厅配置 + S->>S: visibleAfterIso = now - visibleDelaySeconds(等级可见延迟) + S->>DB: 读工单 + alt workOrder.sharing_enabled + S->>DB: joinWorkOrderShare(takeRemaining=true) + else 整单抢单 + S->>DB: countWorkerActiveOrders + S->>S: 校验 maxActiveOrders / 可用余额 ≥ 冻结额 + S->>DB: grabWorkOrder(...)(单事务) + S->>DB: work_order_events grabbed + 实时事件 + end +``` + +整单抢单在**事务内**再次做全部校验(防并发),并条件更新: + +```sql +UPDATE work_orders +SET status='in_progress', assigned_worker_id=$1, last_assigned_worker_id=$1, + assigned_at=$2, deadline_at=$3, + gift_phase = CASE WHEN acceptance_mode='friend_gift' THEN 'material_required' ELSE '' END, + booster_material_json='{}', friend_added_at=NULL, gift_available_at=NULL, + , updated_at=$2 +WHERE id=$4 AND status='open' AND assigned_worker_id IS NULL +``` + +`CLEAR_DELIVERY_RESIDUE_SQL` 清掉上一手的 `submitted_at / accepted_at / acceptance_json / draft_acceptance_json / problem_note / worker_note`,但**不动买家资料**(`material.collect`)和寄卖留痕(`consignmentHistory`)。 + +### 3.2 冻结押金怎么算 + +```text +整单:freezeAmount = max(0, work_order.required_deposit_amount - depositFreeAmount) +拼单:nextRequired = round(required_deposit_amount × nextQuantity / totalQuantity) + requiredShareDeposit = max(0, nextRequired - depositFreeAmount) + share_deposit = max(已冻结的 share_deposit, requiredShareDeposit) // 不回退 + 本次冻结 = share_deposit - 已冻结 +``` + +`depositFreeAmount = 等级 permission_json.depositFreeAmount + worker_users.personal_deposit_free_amount`(两者叠加,个人额度是等级之上的追加额度)。 + +> 注意:`deposit_threshold_amount`(押金门槛)目前只读写/展示,不参与冻结计算;自动生成的工单把 `required_deposit_amount` 与 `deposit_threshold_amount` 都写成 0(见风险 W1)。 + +### 3.3 大厅可见性 + +| 规则 | 实现 | +| --- | --- | +| 等级可见延迟 | `permission_json.visibleDelaySeconds`;未配置时按等级键默认:vip1 20s / vip2 15s / vip3 10s / vip4 5s / vip5 0s | +| 大厅总量 | `worker_hall.maxVisibleOrders`(默认 50,上限 500) | +| 分类上限 | `worker_hall.categoryMaxVisibleOrders`(分类 ID → 上限) | +| 拼满隐藏 | `excludeFilledSharing` | + +可见延迟**不是拦截**,而是「只把发布时间早于 `now - delay` 的单放进候选集」,抢单时在事务内再校验一次容量,防止延迟期内被绕过。 + +--- + +## 四、拼单 + +### 4.1 数据模型 + +`work_order_shares`:`UNIQUE(work_order_id, worker_id)`,一份行代表一个打手在该工单的全部份额。 + +| 列 | 含义 | +| --- | --- | +| `quantity` | 该打手累计份数(同一工单可追加) | +| `unit_reward` / `share_reward` | 单份报酬 / 累计报酬 | +| `share_deposit` | 该打手是否该单的累计冻结额(不是本次增量) | +| `status` | `joined → submitted → accepted / cancelled` | +| `acceptance_json` / `draft_acceptance_json` | 验收资料 / 草稿 | + +### 4.2 参与规则 + +- 普通参与:指定 `quantity`(默认 1),不得超过剩余份数 `sharing_total_quantity - Σ(非 cancelled 份额)`。 +- 「立即抢单」:`takeRemaining=true`,在事务锁内**承接全部剩余份数**;如果已有自己的 `joined` 份额,则在此基础上追加,否则报「数量已满」。 +- 整单接取者不能再拼单(`work_order_owner_conflict`)。 +- 重复参与被唯一键挡住;同一打手多次参与只能走「追加份数」路径。 +- 活跃单上限只在**新建份额**时校验(追加不重复计入)。 + +### 4.3 母单状态收敛 + +```text +份额 joined → 母单保持 open(可继续参与) +份额 submitted → 仍 open;若「份数已全部分配」且「没有 joined 份额」→ 母单转 pending_acceptance +单份额验收 → 该份额 accepted;若这是最后一个未结算份额 → 母单 accepted 并补跑寄卖钩子 +全部取消 → 母单回 open +``` + +`submitWorkOrderShareAcceptance` 里那句 `WHERE id=$2 AND status='open'` 是关键:只有母单还在 `open` 时才允许被自动推进,避免与后台手动验收并发覆盖。 + +### 4.4 两种验收姿势 + +| 方式 | 入口 | 行为 | +| --- | --- | --- | +| 单独验收某份额 | `POST /admin/worker-platform/orders/:id/shares/:shareId/accept` | 只结算该份额:份额报酬 + 份额押金;已结算份额不会在统一验收时重复结算 | +| 统一验收母单 | `POST /admin/worker-platform/orders/:id/accept` | 先检查所有份额都已 `submitted`(有任一 `joined` 直接 409 `sharing_pending_submissions`),再逐个结算未结算份额,最后结算整单打手(如有) | + +> 设计上的一个细节:统一验收时 `settledShareCount` 用「`status !== 'accepted'`」统计,已经单独验收过的份额会被跳过——这是避免重复发钱的唯一保护,没有幂等键。 + +### 4.5 已提交份额不能绕过 + +问题单的 `reopen / cancel_*` 与超时处置都会先检查 `getActiveWorkOrderShareCountWithClient`;有活跃份额就拒绝整单处置(`work_order_has_active_shares`)。这是为了防止「母单被整单带走,份额验收入口全部锁死」。 + +--- + +## 五、好友赠送(friend_gift) + +### 5.1 三态叠加 + +```mermaid +stateDiagram-v2 + [*] --> material_required: 建单时 acceptance_mode=friend_gift + material_required --> material_submitted: 打手提交本人资料(昵称/ID/区服/系统 + 主页截图) + material_submitted --> friend_countdown: 后台确认已加好友(记 friend_added_at,算 gift_available_at) + friend_countdown --> gift_ready: 到点(不落库,实时计算) + gift_ready --> pending_acceptance: 打手提交赠送凭证 + pending_acceptance --> accepted: 后台人工验收(必审) + material_submitted --> material_required: 后台重置资料 + friend_countdown --> material_required: 后台重置资料 +``` + +- `work_orders.status` 在整条链路上保持 `in_progress`(或 `problem`),**不因赠送阶段改变**;前端展示用 `resolveWorkOrderGiftPhase()`。 +- 冷却时长 = `gift_cooldown_hours` 小时 + `gift_cooldown_minutes` 分钟;两者都为 0 时按默认 **72 小时** 兜底(`DEFAULT_GIFT_COOLDOWN_HOURS`),上限 30 天。 +- 确认加好友时,`deadline_at` 被顺延为「冷却结束 + `timeout_minutes`」,即**冷却期不计入打手超时**。 +- 提交验收时如果 `gift_phase !== friend_countdown` 或未到点,直接 409 `work_order_gift_cooldown_active`。 + +### 5.2 押金分水岭 + +```text +friend_added_at IS NULL → 取消/退回/超时:按策略正常退还 +friend_added_at 有值 → 取消/退回/超时:释放类一律转为扣除(deposit_deduction) + 寄卖卖家的发布押金同口径没收 +``` + +实现集中在 `isWorkOrderGiftDepositForfeitable()`,被超时处置、问题单处置、退单审核三处共用。语义是:**好友已经加了,赠送动作由打手/卖家完成,此时脱离订单属于套利**。 + +### 5.3 内部现货变体(寄卖) + +寄卖库存绑定工单走 `internal_stock` 时没有接单打手,流程被压缩: + +```text +assigned_worker_id = null,material.consignment.fulfillmentType = internal_stock + → parkWorkOrderWaitingFriendAdd:status=in_progress,gift_phase=material_required(语义变成「待加好友」) + → 客服线下通知买家加好友 → 手动确认 → 进入冷却 + → 冷却结束:后台可直接验收(无需打手交图) +``` + +`hasInternalConsignmentFulfillment()` 是识别标志:`assigned_worker_id=0` + `material.consignment.inventoryId` + 非 `worker_deposit` + 无 `consignment.workerId`。 + +--- + +## 六、验收 + +### 6.1 打手提交(整单) + +| 规则 | 细节 | +| --- | --- | +| 归属 | 必须是 `assigned_worker_id` 本人(寄卖卖家走 `asConsignmentSupplier` 例外) | +| 状态 | `in_progress` / `problem` | +| 超时前置 | 已过期则先执行超时处置再抛 409(接口惰性判定) | +| 图片 | 默认必须至少 1 张(`work_order_acceptance_image_required`);VIP/内部打手可不交图 | +| 赠送单 | 必须 `friend_countdown` 且已到点;**一律人工验收**,VIP/内部免审不适用 | +| 草稿 | 可先存 `draft_acceptance_json`,提交时优先取「本次上传 + 草稿里已有的图」并去重 | + +免审条件:`worker_type === 'internal'` **或** 等级 `permission_json.autoAcceptWithoutEvidence === true`(且不是拼单份额参与,`canWorkerAutoAcceptWholeOrder` 明确排除拼单)。 + +免审且无图时,验收资料写入: + +```json +{ "reviewMode": "vip_auto", "evidenceStatus": "pending", "evidenceDueAt": "" } +``` + +补证据走 `supplementWorkerAcceptedOrderEvidence`(验收后仍可补)。 + +### 6.2 催促 + +- 提交满 **10 分钟** 才能催; +- 每 **30 分钟** 一次,最多 **3 次**; +- 只支持整单(拼单份额不能催); +- 催的是 `admin_notifications` 的同一个待办(`reminder_count` 累加),不会刷屏。 + +### 6.3 后台验收 + +- 只接受 `pending_acceptance`(`capabilities.canAccept`)。 +- 历史拼单母单如果还是 `open`,验收前自动推成 `pending_acceptance`。 +- 内部现货好友赠送在 `gift_ready` 且 `in_progress` 时,后台可直接验收(自动补 `submitted_at`)。 +- 有未提交份额 → 409 `work_order_sharing_pending_submissions`。 + +--- + +## 七、结算(核心) + +### 7.1 一个事务里做完 8 件事 + +`acceptWorkOrderAndSettle`(整单)与 `acceptWorkOrderShareAndSettle`(份额)结构一致: + +```mermaid +flowchart TD + A["锁工单(pending_acceptance)+ 锁全部非取消份额"] --> B{"还有 joined 份额?"} + B -->|是| X["409 sharing_pending_submissions"] + B -->|否| C["工单置 accepted / 份额置 accepted"] + C --> D["释放寄卖发布押金(仅整单,按 material.consignment.sourceSubmissionId)"] + D --> E["逐份额结算:售后欠款抵扣 → 报酬 → 押金"] + E --> F["整单打手结算:押金 → 报酬 → 欠款抵扣"] + F --> G["寄卖供货货款(internal_stock 时补给卖家)"] + G --> H["等级提升判定"] + H --> I["写 work_order_events accepted"] +``` + +### 7.2 资金等式(单次结算) + +```text +releaseAmount = min(该工单未结押金, 钱包 frozen_deposit_amount) +debtOffset = min(售后未结欠款, 本次报酬) // FIFO,按欠款创建时间 +shouldDelay = rewardUnfreezeEnabled && unfreezeDays > 0 +pendingDelta = (shouldDelay ? 报酬 : 0) + (unfreezeDays > 0 ? 押金 : 0) +available' = available + (shouldDelay ? 0 : 报酬) + (unfreezeDays > 0 ? 0 : 押金) − debtOffset +frozen' = max(0, frozen − releaseAmount) +total_settled += 报酬 +``` + +默认财务配置(`worker_finance` 配置键): + +| 项 | 默认 | 范围 | +| --- | --- | --- | +| `depositUnfreezeDays` | 3 天 | 0–30 | +| `rewardUnfreezeEnabled` | 开启 | 布尔 | +| `consignmentPublishDepositFen` | 2000 分(¥20) | 0–1,000,000 | +| `consignmentWhitelistEnabled` | 开启 | 布尔 | + +### 7.3 流水类型对照 + +| 场景 | ledger_type | 金额符号 | +| --- | --- | --- | +| 接单冻结 | `deposit_freeze` | − | +| 验收即释放押金 | `deposit_release` | + | +| 验收延迟解冻(入队) | `deposit_pending_unfreeze` | +(余额不加,进 pending_unfreeze) | +| 到期解冻 | `deposit_unfreeze` | + | +| 验收即结算报酬 | `reward_settlement` | + | +| 验收延迟解冻报酬 | `reward_pending_unfreeze` | + | +| 拼单报酬 | `sharing_reward` | + | +| 售后抵扣 | `after_sales_debt_offset` | − | +| 售后从可用余额扣 | `after_sales_balance_deduction` | − | +| 超时/退单/问题单扣押金 | `deposit_deduction` | − | +| 提现冻结 / 支付 / 解冻 | `withdraw_freeze` / `withdraw_paid` / `withdraw_release` | − / − / + | +| 后台手动加款 | `manual_credit` | + | + +### 7.4 延迟解冻 + +- 入队:`worker_deposit_unfreezes(worker_id, work_order_id, amount, fund_type='deposit'|'reward', unfreeze_at, status='pending')`。 +- 出队:定时任务 `deposit-unfreeze`(60s,scanLimit 100)→ `settleDueDepositUnfreezes`。 +- **到期判定按「当前配置天数」回溯**:管理员把 `depositUnfreezeDays` 调小后,存量队列会提前视为到期(`retroactiveDays`),不会卡在旧到期时间。 +- 提现前后台会先跑一次 `settleDueDepositUnfreezes({workerId})`,避免「到期了但还没解冻、余额显示不足」。 + +### 7.5 售后抵扣顺序 + +```text +验收报酬 → 先抵扣 open 欠款(FIFO)→ 抵扣部分写 after_sales_debt_offset + → 欠款结清时 cases.debt_amount 归零、status 从 recovery_pending 变 closed + → 报酬不足抵扣的部分继续留在 worker_after_sales_debts.outstanding +``` + +### 7.6 等级提升 / 降级 + +| 方向 | 触发 | 规则 | +| --- | --- | --- | +| 升 | 每次验收后 `maybeUpgradeWorkerLevelWithClient` | `有效验收数 = 累计已验收单数 − level_penalty_accepted_count`;取 `upgradeThreshold ≤ 有效数` 的最高等级;`is_level_locked` 时完全不动;只升不降 | +| 降 | 退单申请审核通过 | 非 VIP1:每 **10 次**通过降一级,并把 `level_penalty_accepted_count` 抬到「实际验收数 − 新等级门槛」,提高重新升级成本 | +| 封 | 退单申请审核通过 | VIP1 累计 **5 次**通过 → `status=disabled`、`session_version+1`、吊销全部会话 | + +`worker_level_logs.reason` 取值:`auto` / `cancel_request_penalty` / `cancel_request_ban`。 + +### 7.7 寄卖钩子 + +| 钩子 | 时机 | 作用 | +| --- | --- | --- | +| `releaseConsignmentPublishDepositBySubmissionWithClient` | 整单验收 | 释放卖家发布押金 | +| `settleConsignmentSupplierPayoutByWorkOrderWithClient` | 验收后 | 内部现货履约时订单没有接单打手,货款补给供货卖家;接单人就是卖家本人时不重复付款 | +| `releaseConsignmentInventoryBindingForWorkOrderWithClient` | 超时/退单/问题单/取消 | 把占用的现货放回在库池,避免永久 `consumed` | + +--- + +## 八、三条处置路径的押金矩阵 + +| 处置 | 入口 | 目标状态 | `reopen` | `cancel_release` | `cancel_deduct` | `return_to_worker` | +| --- | --- | --- | --- | --- | --- | --- | +| 超时 | 定时任务 / 接口惰性 | open / cancelled | 退押金 | 退押金 | 扣押金 | — | +| 问题单 | 后台 `problem/resolve` | open / cancelled / 恢复 | 退押金 | 退押金 | 扣押金 | **不动押金**,恢复到 `marked_problem` 的 `from_status` | +| 退单申请 | 后台审核通过 | open(重新上架) | — | 退押金 | — | — | + +补充规则: + +- 三种路径都先检查「是否还有活跃份额」;有则拒绝整单处置。 +- **已确认加好友的赠送单**:`cancel_release` 会被改写成 `cancel_deduct`,事件类型也按实际资金动作记(`timeout_cancel_deduct` / `problem_cancel_deduct`)。 +- 超时与问题单会释放寄卖现货绑定;`return_to_worker` 不会(单还在进行)。 +- 退单申请:待审核期间订单与押金保持原状;审核通过**在同一事务**里撤单、退押金、执行等级处罚。 +- 打手每天最多提 **5 次**退单申请(按 Asia/Shanghai 自然日,`pending + approved` 计数),同一工单+份额不能有两条 pending。 +- 拼单份额超时只在母单 `open` 时生效(份额 `joined` 且 `created_at + timeout_minutes` 已过),否则跳过。 + +--- + +## 九、售后与欠款 + +### 9.1 生命周期 + +```mermaid +stateDiagram-v2 + [*] --> awaiting_worker_response: 后台建单(必须已验收) + awaiting_worker_response --> under_review: 打手提交说明 + 证据 + awaiting_worker_response --> under_review: 后台直接处置 + under_review --> closed: not_upheld(不成立) + under_review --> closed: remedied(已补救) + under_review --> recovery_pending: upheld 且追缴 > 报酬/可扣押金 → 产生欠款 + under_review --> closed: upheld 且全额扣到 + recovery_pending --> closed: 欠款结清(验收抵扣 / 充值抵扣 / 后台代还) +``` + +### 9.2 建单约束 + +| 约束 | 说明 | +| --- | --- | +| 工单必须 `accepted` | `work_order_not_accepted` | +| 指定份额时必须已验收 | `work_order_share_not_accepted` | +| 必须能定位责任打手 | `worker_missing`;内部现货寄卖单没有接单打手时,自动反查供货卖家(`getConsignmentSupplierByWorkOrderWithClient`)作为责任人 | +| 同一「工单 + 打手 + 份额」同时只能有 1 条进行中 case | 由部分唯一索引 `idx_worker_after_sales_cases_active_target` 保证:`UNIQUE(work_order_id, worker_id, COALESCE(work_order_share_id,0)) WHERE status IN ('awaiting_worker_response','under_review','recovery_pending')`,插入用 `ON CONFLICT DO NOTHING`,冲突即 `active_case_exists` | +| 投诉说明必填、≤ 2000 字 | | +| 问题类型默认 `fake_evidence` | | + +### 9.3 追缴顺序 + +```text +1. 该工单尚未解冻的押金(worker_deposit_unfreezes pending, fund_type='deposit') + → 走 deductPendingDepositUnfreeze,直接从队列扣,不进可用余额 +2. 可用余额(available_amount) +3. 仍不足 → 写入 worker_after_sales_debts(status='open', outstanding_amount) + → case.status = recovery_pending +``` + +后续抵扣: + +- 该打手下一次验收报酬 → 自动 FIFO 抵扣(`offsetWorkerAfterSalesDebtsWithClient`); +- 充值入账 → `repayWorkerAfterSalesDebtsFromAvailableWithClient`; +- 后台手动代还 → `repayAfterSalesCaseDebt`。 + +每一笔抵扣都会写 `worker_after_sales_case_events`(`debt_offset` / `debt_settled`)+ 对应钱包流水,两边都有痕迹。 + +--- + +## 十、发现的隐患(按资金风险排序) + +| # | 隐患 | 证据 | 后果 | +| --- | --- | --- | --- | +| W1 | 自动建单硬编码 `required_deposit_amount=0`、`deposit_threshold_amount=0` | `sync-worker-orders-service.ts` | 自动单永远无押金约束,模板里的押金配置是死配置 | +| W2 | 抢单先锁钱包、指派/拼单先锁工单 | `work-order-grab-repo.ts` vs 同文件 `assignWorkOrderToWorker` / `work-order-share-repo.ts` | 并发抢单+指派可 40P01 死锁;目前只靠客户端重试 | +| W3 | 统一验收的「已结算份额」判断只看 `status !== 'accepted'`,没有幂等键 | `acceptWorkOrderAndSettle` | 极端并发下(两次验收同时进入)可能重复发报酬;`FOR UPDATE` 锁工单能挡住大部分,但没有业务幂等 | +| W4 | 发单金额 > 用户付款只在前端提示 | `publishAdminWorkOrder` 只校验 `reward_amount > 0` | 直接调 API 可发亏损单;`source_order_amount_fen` 只是记录,不参与校验 | +| W5 | 发布工单时电子凭证核销失败只写事件,不回滚发布 | `publishAdminWorkOrder` 的 `voucherConsume.ok=false` 分支 | 券没核销但工单已在大厅;指派路径却会拦截,同一规则两套行为 | +| W6 | 钱包余额没有「由流水重算」校验,也没有月度关账 | `worker_wallets` 可变字段 | 任何一次漏写/重复写流水都不会被系统发现,只能人工对账 | +| W7 | `work_orders.task_id` 是历史遗留列(迁移 091 已注释废弃) | 仍参与部分 mapper | 新人容易误以为「工单必须挂履约任务」 | + +--- + +## 十一、两笔完整流水示例 + +> 先记一条硬约束:**好友赠送与拼单互斥**。建单时 `sharingEnabled = isFriendGiftAcceptanceMode(rule.acceptance_mode) ? false : ...`,所以不存在「好友赠送 + 拼单份额」的单子。 + +### 11.1 拼单单 + +设定:`sharing_total_quantity=3`、`unit_reward=3000 分`、`required_deposit_amount=6000 分`;打手 A 等级免押 1000 分、活跃上限 3;`depositUnfreezeDays=3`、`rewardUnfreezeEnabled=true`。 + +| 步骤 | 动作 | 钱包变化 | 关键字段/流水 | +| --- | --- | --- | --- | +| 1 | A 参与 2 份 | nextRequired = 6000×2/3 = 4000;requiredShare = 4000−1000 = 3000;冻结 3000 | `work_order_shares(quantity=2, share_deposit=3000)`,`deposit_freeze −3000` | +| 2 | A 追加 1 份 | nextRequired = 6000;requiredShare = 5000;本次冻结 2000 | `quantity=3, share_deposit=5000`,`deposit_freeze −2000` | +| 3 | 提交验收 | 份数已满且无 `joined` 份额 → 母单 `open → pending_acceptance` | `share.status=submitted` | +| 4 | 验收该份额 | 报酬 9000 延迟解冻;押金 5000 延迟解冻 | `pending_unfreeze +14000`,`reward_pending_unfreeze +9000`,`deposit_pending_unfreeze +5000` | +| 5 | 3 天后任务 | 到期释放 | `deposit_unfreeze +14000`,`pending_unfreeze −14000` | + +### 11.2 好友赠送单 + +设定:`acceptance_mode=friend_gift`、`required_deposit_amount=5000`、免押 0、冷却 72h、`timeout_minutes=120`。 + +| 步骤 | 动作 | 结果 | +| --- | --- | --- | +| 1 | 打手抢单 | 冻结 5000(`deposit_freeze`);`gift_phase=material_required`;`deadline_at=now+2h` | +| 2 | 提交本人资料 | `gift_phase=material_submitted`;`friend_added_at=NULL` | +| 3 | 客服确认加好友 | `friend_added_at=now`;`gift_available_at=now+72h`;`deadline_at=now+72h+2h`(冷却不计超时) | +| 4 | 冷却中尝试提交 | 409 `work_order_gift_cooldown_active` | +| 5 | 到点后提交赠送凭证 | `status=pending_acceptance`;**不触发免审**(赠送单强制人工) | +| 6 | 客服验收 | 押金 5000 → 延迟解冻;报酬按 `reward_amount` 入账(同样按 `rewardUnfreezeEnabled` 决定是否延迟) | +| 7′ | 若第 3 步之后打手退单 | 押金从「应释放」改为 `deposit_deduction −5000`;寄卖发布押金同口径没收 | + +--- + +## 十二、待确认问题(等你回答后我再补口径) + +1. **押金策略的业务意图**:`required_deposit_amount` 现在是「按单金额」还是「按 SKU 单价 × 份数」?自动单为什么固定为 0——是刻意豁免,还是历史遗留? +2. **延迟解冻的初衷**:`depositUnfreezeDays=3` + `rewardUnfreezeEnabled=true` 是为了防售后,还是为了压打手现金流?这决定售后追缴是否应该优先扣「待解冻队列」而不是可用余额。 +3. **VIP1 五次退单封号**这条规则现在还在执行吗?线上是否有误封申诉流程(代码里只有 `review_note`)? +4. **好友赠送冷却 72h** 是否要按品类差异化?现在只有模板上的 `gift_cooldown_hours/minutes` 两个字段。 +5. **拼单是否允许「一人多份 + 全部单人完成」**?当前逻辑允许,且验收后一次性结算全部份额——如果业务上要求分散风险,这条需要改。 diff --git a/docs/项目全流程与数据库设计梳理-2026-10-23.md b/docs/项目全流程与数据库设计梳理-2026-10-23.md new file mode 100644 index 00000000..59edf8d6 --- /dev/null +++ b/docs/项目全流程与数据库设计梳理-2026-10-23.md @@ -0,0 +1,876 @@ +# order_site 全流程与数据库设计梳理(2026-10-23) + +> 状态:**梳理稿(待评审,尚未登记到 `docs/README.md`)** +> 落笔日期:2026-10-23 +> 代码基线:工作区当前 HEAD;数据库迁移 `001`–`110`(共 110 个文件) +> 核对方式:源码走查 + 本地 PostgreSQL 实读表结构 / 约束 / 索引 + 本地库状态与数据量统计 +> 说明:本地库与生产同量级(`orders` 约 37.5 万行),本文引用的数据量均为**本地库实测**,不与生产承诺一致,仅用于判断结构合理性。 + +--- + +## 一、先看结论(10 条) + +1. 系统早已不是「快手自动发货工具」,而是**三来源订单履约 + 打手众包接单 + 寄卖仓库 + 电子凭证核销 + 客服备用金台账**的一体化平台。 +2. 订单来源只有三个:`kuaishou-91`(91 回调 + 快手 send-code 两条通道,**同一个来源**)、`pdd`、`douyin-mashida`。代码层的来源键定义在 `apps/backend/src/services/product-match/types.ts`。 +3. **核心不变式:一个订单 = 骨架(`orders` + `order_items`)+ 买家凭证(快手电子凭证 / 抖音券)+ 一个执行载体**。载体只有三种:`affiliate_dash` 自动发货、`work_orders` 工单、`manual` 人工(当前无显式表,靠「无任务且无工单」判定)。 +4. 履约任务按「订单项 × 数量」拆单元:`fulfillment_tasks` 的 `UNIQUE(order_item_id, unit_index)` 是整套领取链接体系的基座。 +5. 领取链接 = `claimLinkBase + '/' + claim_tokens.token`,一任务一 token(`claim_tokens.task_id` 唯一);`CLAIM_TOKEN_TTL_HOURS=0` 表示永久有效。 +6. 打手侧是一条**独立业务线**:工单状态机 `pending_material → unassigned → open → in_progress → pending_acceptance → accepted`,另有 `problem`、`cancelled`;资金走「钱包 + 流水」模型,不是会计总账。 +7. 寄卖是第三个业务面:提报 → 审核入库(`INV…`)→ 规格匹配(系统/区服/系列/钻级/璀璨)→ 履约(内部现货 or 指派打手)→ 结算与撤销押金策略。 +8. 备用金台账(迁移 100)已落地:`fund_accounts`(主管池 / 客服账户)+ `fund_entries`(只追加流水,余额只能由流水变动)+ `manual_expenses` + `fund_reconciliations`。 +9. 数据库现状:**60 张业务表 + `schema_migrations`**,113 个外键、21 个索引的 `work_orders` 是全库索引最多的表;配置不入表结构,统一存 `app_config_entries`(本地 18 个配置键)。 +10. 主要架构性风险仍是 2026-10-17 报告列的那几处:资金门禁只在前端、Webhook 先落库后处理、`/collect` `/redeem` 无第二因子、抢单/指派锁序相反、单实例依赖进程内存。 + +--- + +## 二、系统地图 + +### 2.1 运行拓扑 + +```mermaid +flowchart LR + U1["外部平台
91卡券 / 快手 / 拼多多 / 抖音码速达"] --> C + U2["买家
领取页 / H5 / 兑换页"] --> C + U3["打手
worker.khhao.com"] --> C + U4["后台运营 / 客服 / 财务"] --> C + C["Caddy :80/443
反代 + 静态缓存 + 压缩"] --> F["frontend
React 19 + Vite(一套产物三个入口)"] + C --> B["backend
Express 5 + TS
routes → services → repositories → pg"] + B --> P[("PostgreSQL 16")] + B --> O["对象存储
生产 OSS / 本地 MinIO"] + B --> X["外部 API
affiliate-dash / 快手 OpenAPI / 抖音码速达 / PDD"] + B --> M["进程内存
限流桶 / 调度器 / SSE / 列表缓存"] +``` + +单实例假设写在代码里:限流、定时任务、SSE 订阅、列表缓存全部在进程内存(`middleware/rate-limit.ts`、`services/realtime/`),扩容前必须先外置。 + +### 2.2 后端入口与路由挂载 + +入口 `apps/backend/src/app.ts`: + +| 挂载点 | 说明 | +| --- | --- | +| `/health`、`/health/live`、`/health/ready` | 健康检查;未 ready 时其余请求统一 503 | +| `/api/v1/open/douyin-mashida` | **必须在全局 body parser 之前**挂载,签名基于原始 JSON 字符串 | +| `/api/v1/open/affiliate-dash` | 发货平台异步通知(验签 + 时间戳容差) | +| `/api/v1/open/91` | 91 卡券异步下单 / 查询(验签) | +| `/api/v1/open/kuaishou-industry` | 快手行业电子凭证 send-code / destroy / query / consume | +| `/api/v1/redeem` | 抖音券兑换页接口 | +| `/api/v1/claim` | 买家领取页(token 与 H5 两种入口) | +| `/api/v1/collect` | 待补全订单资料收集 | +| `/api/v1/pdd/extension` | 拼多多浏览器扩展上报 Cookie / 公钥 | +| `/api/v1/files` | 文件对象读取 | +| `/api/v1/worker` | 打手端(公开鉴权路由 + 会话路由) | +| `/api/dcauth` | 设备认证(dcauth 协议) | +| `/api/v1/admin` | 后台 API(auth 路由放行,其余全部 `requireAdminSession`) | +| `/s` | 短链跳转 | + +### 2.3 分层与模块职责 + +| 层 | 目录 | 职责 | +| --- | --- | --- | +| 路由 | `src/routes/**` | 参数解析、限流、鉴权、调用 service | +| 领域 | `src/domain/**` | 状态机与纯策略(任务、工单、寄卖、押金、验收模式) | +| 服务 | `src/services/**` | 订单、履约、工单平台、平台对接、财务、寄卖、调度 | +| 仓储 | `src/repositories/**` | 全部 SQL(含 `worker-platform/*` 大仓储) | +| 迁移 | `src/db/migrations` | 001 基线 + 109 个增量 | +| 前端 | `apps/frontend/src/pages/**` | `admin` 后台 / `worker` 打手端 / `claim` 领取页 / `redeem` 兑换页 / `collect` 补资料 | + +--- + +## 三、端到端主流程 + +### 3.1 总览(一条单的完整生命周期) + +```mermaid +flowchart TD + A["来源进单
91 / PDD / 抖音"] --> B["upsertOrderFromSource
advisory lock 去重 + 状态合并"] + B --> C["order_items 落库
resolveOrderItemForFulfillment 判定载体"] + C --> D{"isConfigured?"} + D -->|否| E["待补全
91 返回 orderStatus=10"] + C -->|是| F["syncDeliveryTasksForOrder
按 order_item × quantity 建任务单元"] + F --> G{"executor_key"} + G -->|affiliate_dash| H["自动发货
建 affiliate-dash 单 + 领取链接"] + G -->|manual_dispatch| I["人工履约
manual_review 待办"] + C --> J["syncWorkerOrdersForSourceOrder
product-match 匹配接单模板"] + J -->|命中且 auto_create| K["work_orders
待补素材 / 待发布"] + K --> L["打手大厅抢单 / 后台指派"] + L --> M["代练履约 → 提交验收"] + M --> N["验收 → 结算报酬 / 释放押金 / 等级进度"] + H --> O["买家领取页 claimUrl
填 UID → 绑定 → 提交发货"] + O --> P["affiliate-dash delivered 回调"] + P --> Q["任务 completed / redeemed
快手电子凭证核销闭环"] + N --> R["钱包流水 + 售后 / 欠款"] + Q --> S["后台报表 / 备用金台账 / 审计"] + R --> S +``` + +### 3.2 三个来源的进单通道 + +| sourceKey(业务口径) | 实际 provider / platform | 入口 | 数据形态 | 备注 | +| --- | --- | --- | --- | --- | +| `kuaishou-91` | `91kaquan` / `kuaishou` | `POST /api/v1/open/91/orders/create` | 一单一条,`productNo` 解析出商品名与店铺 ID | 91 是订单骨架权威源 | +| `kuaishou-91` | `91kaquan` / `kuaishou` | `POST /api/v1/open/kuaishou-industry/send-code` | 快手电子凭证补全/兜底 | 与 91 共享店铺键空间,**不是新来源** | +| `pdd` | `pdd` / `pdd` | 定时任务 `pdd_order_sync`(20s)拉取 | 两段式 `pdd_raw_orders → orders` | 浏览器扩展上报 Cookie | +| `douyin-mashida` | `douyin_mashida` / `doudian` | `POST /api/v1/open/douyin-mashida/message` | 消息推送,先落 `webhook_events` 去重 | 抖店订单 + DYC 券 | + +本地库实测分布:91 368,355 单;PDD 5,563 单;抖音 1,541 单。 + +#### 3.2.1 91 卡券(下单 + 查询) + +```mermaid +sequenceDiagram + participant K as 91卡券 + participant API as backend /open/91 + participant S as order-service + participant DB as PostgreSQL + K->>API: POST /orders/create(验签) + API->>API: buildOpen91SourceEvent
provider=91kaquan, platform=kuaishou
totalAmount=maxAmount(分) + API->>S: upsertOrderFromSource(event) + S->>DB: pg_advisory_xact_lock(hashtextextended('provider|platform|orderNo')) + S->>DB: upsert orders + replace order_items + S->>S: 载体判定 → 建任务 / 建工单 + API-->>K: orderStatus=10(未生成 claimUrl)或成功 + K->>API: POST /orders/query + API-->>K: orderStatus=20 + cards(claimUrl 加密卡密)
或 10(处理中)/ 30(失败) +``` + +要点: + +- 订单幂等键是 `UNIQUE(provider, platform, shop_id, platform_order_id)`,事务内先取 advisory lock,避免并发重复建单。 +- 状态合并用优先级表:`created(0) < paid(1) < closed(2) < refunded(3)`,`pay_status` 同理;`pending_config` 这类历史状态优先级最低,会被新事件覆盖。 +- 91 查询的三种状态:`10` 处理中/未就绪,`20` 已发货(`cards` 为加密后的 claimUrl),`30` 失败(`pending_config`、`manual_failed` 或任务失败)。 +- 历史遗留:库中仍有 25 条 `order_status='pending_config'` 的 91 老单(最近一条 2026-08-11),当前代码不再写入该状态,只作为查询口径识别。 + +#### 3.2.2 快手行业电子凭证(send-code 四条接口 + 两条出站回调) + +- 券号与 91 订单解耦:`voucher_code = 'KSV' + Crockford Base32(10 字节随机)`,`UNIQUE(voucher_code)`,冲突重试 5 次。 +- **幂等键是 `UNIQUE(oid, unit_index)`**:重复 `send-code` 返回同一批 `eticket.id`;`unit_index = 1..num`。 +- 未找到 91 任务时券先落库(`task_id` 为空),后续由 `voucher-binding-service` 绑定,支持「快手先发码、91 后建单」。 +- 出站回调用 `send_callback_status/attempt_count/last_error/response_json/sent_at` 记录,失败可重推。 +- 核销流水号幂等:`consume_serial_num = CONSUME-{voucher_code}`;状态只有 `UNUSED / CONSUMED / DESTROYED`。 +- 本地库实测:`CONSUMED` 376,179、`DESTROYED` 26,815、`UNUSED` 106 —— 说明核销链路是真实主路径。 + +#### 3.2.3 拼多多(两段式) + +```text +pdd_order_sync 定时任务(默认 20s / pageSize 10) + → pdd-client 拉订单(Cookie + anti-content) + → upsert pdd_raw_orders(UNIQUE(order_sn)) + → processPendingPddRawOrders 逐条消费 + → pdd-order-mapper 归一化 + → upsertOrderFromSource + → sync_status: pending → synced / ignored / failed + → 后台可对 failed 的原始单做 replay +``` + +设计意图:原始报文与业务订单分离,失败可重放,不会因为映射问题丢单。断路器与 Cookie 过期识别在 `pdd-sync-circuit-breaker.ts` / `isPddCookieExpiredError`。 + +#### 3.2.4 抖音码速达(推送 → 建单 → 发券 → 领取) + +```mermaid +sequenceDiagram + participant D as 抖音码速达 + participant N as notify-service + participant O as order-service + participant C as certificate-service + D->>N: POST /message?topic&pub_time&sign(原始 body) + N->>N: 验签(data 原始字符串) + N->>N: eventId = f(topic, pub_time, data) + N->>N: insertWebhookEventOnce(先落库去重) + alt topic = trade_TradePaid + N->>D: 拉订单详情(网关) + N->>O: upsertOrderFromSource + N->>C: 生成 / 同步 DYC 券(redeem_certificates) + N->>D: 推送给买家的领取链接 + else trade_TradeCanceled / refund_RefundSuccess + N->>C: 券失效 + 订单状态收敛 + end +``` + +- 抖音 `sku_order_list[].code` 是**内部 SKU**,`product_name` 是引流标题;`sku_code` 恒存内部 SKU,标题只进 `snapshot.productTitle`,永不参与匹配。 +- 券表 `redeem_certificates` 自带状态机 `issued / synced / verified / invalid` 与同步状态 `pending / success / failed`,核销走 `coupons.verifyV3`。 +- **已知缺陷**:`webhook_events` 先插入后处理,处理失败时平台重推会被判 `duplicate` 丢弃(详见风险清单)。 + +### 3.3 载体判定(本系统最关键的一步) + +```mermaid +flowchart TD + A["order_item"] --> B{"来源"} + B -->|91 / 快手| C["productNo → affiliate-dash sku 映射
常量表 + 后台 skuMapping 覆盖"] + B -->|抖音| D["内部 SKU → affiliate-dash sku 映射"] + C --> E["resolveFulfillmentRoute"] + D --> E + E --> F{"路由顺序"} + F --> G["1. 履约路由总开关
关闭 = 停止自动履约"] + F --> H["2. 指定匹配(显式映射)"] + F --> I["3. 商品名规则 exact / contains"] + F --> J["4. 内部 SKU 规则"] + F --> K["5. 全局执行器优先级"] + F --> L["6. unmatchedExecutorKey(生产为空)"] + G --> M["snapshot.fulfillmentRoute
selectedExecutorKey / reason / candidates / skipped"] + H --> M + I --> M + J --> M + K --> M + L --> M + M --> N{"selectedExecutorKey"} + N -->|affiliate_dash| O["isConfigured=true
建自动履约任务"] + N -->|manual_dispatch| P["isConfigured=true
建人工任务"] + N -->|空| Q["isConfigured=false
待补全 / 交给发单层判定"] +``` + +判定结果全部写进 `order_items.item_snapshot_json`: + +| 快照键 | 内容 | +| --- | --- | +| `fulfillmentRoute` | `selectedExecutorKey`、`reason`、`candidates`、`skipped`、`matchedRule` | +| `classification`(抖音) | `channel: affiliate_dash / unmatched`、`canonicalSku` | +| `affiliateDash` | 命中的 `sku`、`productNo`、`skuName`、`matchMode` | +| `isConfigured` | 是否已配置可执行载体 | +| `productTitle`(抖音) | 引流长标题(只读展示) | +| `kuaishouSendCode` | 快手 send-code 补全的卖家/SKU 上下文 | + +**载体互斥守护**:`sync-worker-orders-service.isAffiliateDashCarrierSnapshot()` 会跳过 `classification.channel === 'affiliate_dash'` 的订单项,避免自动发货与工单双通道同时消费一个订单项。 + +### 3.4 履约任务与领取链接 + +1. `syncDeliveryTasksForOrder` 对每个订单项按 `quantity` 补建 `unit_index = 1..n`,重复执行只补缺口。 +2. 执行器来自 `snapshot.fulfillmentRoute`: + - `affiliate_dash` → 绑定 `fulfillment_profiles.profile_key='affiliate_dash'`,`requires_claim=true`,初始状态 `paid`; + - `manual_dispatch` → `profile_key='manual_review'`,初始状态 `manual_review`; + - 未支付 → `pending_payment`。 +3. 已支付订单立即 `preparePaidFulfillmentTask`(执行器注册表:`affiliate_dash` / `manual` / `legacy-retired` 只读桩)。 +4. 领取 token:`createTaskClaimToken` 写 `claim_tokens`(`task_id` 唯一、`token` 唯一、`max_use_count=1`),`claimUrl = claimBaseUrl + '/' + token`。 +5. 抖音走独立基址 `claimLinkBase`(后台配置),**刻意不回退** `orders.claimBaseUrl`。 +6. `orders.claim_status`(迁移 099)= `active / paused / closed`,用于暂停或关闭整单兑换,是后台处置链接失效问题的新开关。 + +### 3.5 affiliate-dash 自动履约 + +```text +建任务(executor=affiliate_dash,requires_claim=true) + → preparePaidFulfillmentTask:调 affiliate-dash 建单,拿到 orderNo / bindUrl / qrUrl + → 生成 claim token 与 claimUrl(91 查询即可返回 20) + → 建单成功时即核销快手电子凭证(consumeStatus=success;失败只告警) + → 买家在领取页:查 delivery → 填 UID → bind → 轮询 bind-result → submit + → affiliate-dash 回调 order.shipping.updated(delivered) + → 任务 completed / redeemed;回调只做状态收敛与兜底补核销 +``` + +任务上下文固定形状 `context_json.affiliateDash`(`AffiliateDashFlow`),包含 `sku / orderNo / clientOrderNo / orderStatus / canShip / bindUrl / qrUrl / bound / submitStatus / consumeStatus / lastSyncedAt`。 + +### 3.6 买家侧三个页面 + +| 入口 | 路由 | 用途 | +| --- | --- | --- | +| 主站领取页 | `/#/claim/:token` | 快手渠道主路径,按 `executorKey` 渲染不同步骤 | +| H5 领取页 | `/h5/:orderNo`(BrowserRouter 分支) | 抖音等按订单号承接,`/api/v1/claim/h5/...` | +| 兑换页 | `/redeem` | 抖音 DYC 券兑换/核销 | +| 补资料页 | `/collect` | 待补全订单资料收集(**仅凭订单号,无第二因子**) | + +领取页快照与轮询逻辑在 `pages/claim/claim-snapshot.ts`、`claim-poll.ts`;`affiliate-dash` 的具体步骤在 `ClaimAffiliateDashSteps.tsx`(查 delivery → UID → bind → 轮询 → submit)。 + +### 3.7 打手众包:从匹配到结算 + +#### 3.7.1 接单模板匹配(product-match 引擎) + +匹配引擎是**纯函数**(`services/product-match/engine.ts`),来源差异只在适配器: + +| 概念 | 说明 | +| --- | --- | +| 条目 `entry` | 一条接单模板(`work_product_rules`)或其映射(`work_product_rule_mappings`)编译而来 | +| 作用域 | `sourceKeys`、`shopKeys`、`qtyMin/qtyMax`:配了就必须命中 | +| 条件组 | `all`(全部命中)、`any`(至少一条)、`bonus`(命中加分、不中不淘汰) | +| 匹配模式 | `exact`、`contains`、`series`(剥离「N个」后比系列,兼容秘钥/密钥)、`series-exact` | +| 权重 | `skuId/relSkuId 900 > skuText/skuNick 800 > series 600 > skuCode 500 > itemId 300 > itemTitle 200`(contains 降档,文本另有「具体度」加成上限 80) | +| 结果 | 唯一最高分 → `matched`;并列最高 → `ambiguous`(**不猜**);无候选 → `unmatched` | + +每次匹配都会写 `work_product_match_logs`(含 `candidates_json`、`raw_payload_json`),后台有「匹配测试 / 匹配日志 / 日志清理」三个入口。 + +#### 3.7.2 建单规则 + +```text +for each order_item: + if snapshot.classification.channel == 'affiliate_dash' → skip(载体已被自动发货占用) + if 该订单项已有工单 → skip(already_exists,不纠正旧单) + match = 匹配引擎 + 无命中/歧义 → skip + 日志 + 命中但 auto_create=false 且 autoOnly → skip + platform_order_id 已挂在**其他**订单下 → skip(防撞号) + 建单: + status = 素材必填项齐全 ? unassigned : pending_material + reward = 单价 × SKU 数量(unit_price_fen)或固定 reward_amount + sharing(拼单)按模板 sharing_total_quantity / unit_reward 计算 + acceptance_mode = normal / friend_gift(好友赠送) + timeout_minutes + timeout_policy(reopen / cancel_release / cancel_deduct) + material = 来源摘要 +(抖音)已有券快照 +pending_material → 生成后台「待补素材」通知 +``` + +#### 3.7.3 工单生命周期 + +```mermaid +stateDiagram-v2 + [*] --> pending_material + pending_material --> unassigned: 素材补全 + pending_material --> cancelled: 取消 + unassigned --> open: 发布到大厅 + unassigned --> in_progress: 后台指派 + open --> in_progress: 打手抢单 / 拼单参与 + in_progress --> pending_acceptance: 打手提交验收 + pending_acceptance --> accepted: 验收通过(结算) + pending_acceptance --> problem: 发起问题单 + problem --> open: 退回大厅 + problem --> cancelled: 取消 + in_progress --> open: 超时按 reopen 策略 + in_progress --> cancelled: 超时按 cancel 策略 + open --> cancelled: 取消 + accepted --> [*] + cancelled --> [*] +``` + +- **抢单**:单事务内 `ensureWallet → 校验活跃单上限 → 校验可用余额 ≥ 押金 → 条件更新 `WHERE status='open' AND assigned_worker_id IS NULL` → 冻结押金(`deposit_freeze` 流水)→ 写 `work_order_events`。 +- **指派**:先 `SELECT … FOR UPDATE` 锁工单,再锁钱包(与抢单顺序相反,见风险 R4)。 +- **拼单**:`work_order_shares` 一份一行,`UNIQUE(work_order_id, worker_id)`;份额状态 `joined → submitted → accepted / cancelled`;母单可长期 `open`,最后一个份额验收后母单转 `accepted`。 +- **好友赠送**:`acceptance_mode='friend_gift'`,`gift_phase`:`material_required → material_submitted → friend_countdown → gift_ready`,冷却结束才能提交赠送凭证;确认加好友后不再退押金(防套利)。 +- **超时**:定时任务 `work-order-timeout`(默认 60s,scanLimit 50)扫描 `deadline_at`,按 `timeout_policy`(`reopen` 退回大厅 / `cancel_release` 取消并退押金 / `cancel_deduct` 取消并扣押金)处置;已确认加好友的赠送单即使配 `cancel_release` 也会转为扣款。超时只写 `timeout_*` 事件并释放寄卖现货,**不直接改等级计数**(等级处罚来自退单审核通过次数,见 3.7.6)。 +- **验收与结算**(`acceptWorkOrderAndSettle`):锁工单与份额 → 报酬入账(可延迟解冻)→ 释放/延迟解冻押金(`worker_deposit_unfreezes` 队列)→ 抵扣售后欠款 → 升级等级 → 写 `worker_wallet_ledgers`。 +- **售后**:`worker_after_sales_cases`(问题类型、投诉、验收快照、处置动作、退款)+ `worker_after_sales_debts`(欠款余额)+ `worker_after_sales_case_events`(时间线)。 + +#### 3.7.4 打手资金模型 + +| 表 | 作用 | +| --- | --- | +| `worker_wallets` | 可用余额、冻结押金、累计入账、累计结算、待解冻、提现冻结 | +| `worker_wallet_ledgers` | 只追加流水,13 种类型(见下) | +| `worker_finance_requests` | 充值 / 提现申请与审核 | +| `worker_withdrawal_accounts` | 提现账户(微信 / 支付宝二维码) | +| `worker_deposit_unfreezes` | 延迟解冻队列(押金、报酬两类) | + +实测流水类型分布(本地库):`reward_settlement 108,623`、`sharing_reward 34,295`、`deposit_freeze 8,283`、`deposit_release 6,705`、`withdraw_paid 6,381`、`withdraw_freeze 5,833`、`deposit_deduction 920`、`deposit_pending_unfreeze / deposit_unfreeze 各 621`、`manual_credit 398`、`withdraw_release 121`、`after_sales_balance_deduction 20`、`after_sales_debt_offset 9`。 + +#### 3.7.5 等级、权限与风控 + +- `worker_levels.permission_json` 承载:免押额度、最大同时接单量、大厅可见延迟、自动验收(VIP)。 +- `worker_level_logs` 记录升降级;`worker_users.is_level_locked` 可锁定等级。 +- `worker_sessions` 会话落库 + `session_version`,设备数与设备类型可限制。 +- `worker_risk_events` 记录刷单/异常行为与处置(ban / unban / release),`middleware/worker-risk-control.ts` 做抢单前置限流与拦截。 +- 提现黑名单:`worker_users.is_withdrawal_blacklisted` + 原因 + 时间。 + +> 抢单/拼单/好友赠送/验收/结算的逐行细节(含押金矩阵、资金等式、退单处罚规则)见 [打手工单深挖-抢单拼单好友赠送结算-2026-10-23.md](打手工单深挖-抢单拼单好友赠送结算-2026-10-23.md)。 + +### 3.8 寄卖仓库 + +```mermaid +flowchart TD + A["卖家提报 consignment_submissions
游戏/系统/区服/系列/钻级/璀璨/要价/凭证"] --> B{"客服审核"} + B -->|驳回| C["rejected(可改后重提)"] + B -->|通过| D["入库 consignment_inventories
inventory_no = INV…
status=in_stock"] + D --> E{"订单匹配"} + E -->|规格匹配| F["占用 consumed
bound_order_id / bound_work_order_id"] + E -->|不匹配| D + F --> G{"履约方式"} + G -->|internal_stock| H["内部现货交付
客服操作"] + G -->|worker_deposit| I["指派打手代练
冻结打手押金"] + H --> J["验收 → 释放发布押金 + 结货款"] + I --> J + F -.->|撤销| K["revoke:打手押金 + 卖家押金
auto / refund / forfeit / keep_frozen"] +``` + +匹配口径(`consignment-matching-service.ts`): + +- **系统 / 区服 / 系列必须一致**(区服「互通」、系统「通用」视为通配); +- **钻级与璀璨低配不充高配**:号主 2 钻不能配 1 钻库存; +- 三态比对:`match` / `mismatch` / `unknown`,`unknown`(任一侧没值)**不算精确匹配**,只能进部分匹配候选; +- 人工指定时用 `classifyConsignmentMatch` 返回 `conflicts`,把「差在哪」显式告诉客服; +- 建议价从接单模板反推(`work_product_rules` 商品名 → 规格解析 → 最接近档位兜底,命中多条取最高价)。 + +卖家视角的单一阶段由 `deriveConsignmentItemStage` 从「提报 + 库存 + 工单」三套状态推导:`pending_review → in_stock → matched_waiting_friend → gift_cooldown → gift_ready → gift_submitted → fulfilling → pending_acceptance → completed`,终态 `rejected / archived / cancelled`。 + +押金策略(`domain/consignment-deposit-policy.ts`): + +| 场景 | `auto` 行为 | +| --- | --- | +| 打手履约押金撤销 | 全额退还;要扣必须显式 `forfeit` | +| 卖家发布押金撤销 | 作废(下架)→ 退还;回库(继续挂卖)→ 保持冻结 | +| 显式覆盖 | 必须填原因(`isDepositHandlingOverridden` 校验) | + +### 3.9 客服备用金台账(迁移 100) + +```text +公司 → 主管池(fund_accounts.account_type='supervisor') +主管 → 客服账户('agent',responsible_supervisor_id 指向主管) +客服 → 支出明细(manual_expenses:平台充值 / 采购 / 赔付 / 运费 / 其他) +``` + +四条硬约束(写在建表注释里): + +1. 余额只能由 `fund_entries` 变动,禁止手工改; +2. 发放 / 缴回必须同时写主管与客服两条方向相反的流水,共用 `transfer_no`; +3. 支出必须同时写客服负数流水与 `manual_expenses` 明细(`UNIQUE(fund_entry_id)`); +4. 已记账流水不修改不删除,差异走 `adjustment`。 + +对账:`fund_reconciliations`(`pending / confirmed / discrepancy`),DB 级校验 `difference_fen = actual_balance_fen - system_balance_fen`。 + +权限点:`fund.view / issue / expense / reconcile / adjust / export`,按角色(admin / finance / operator / support)分别在迁移里授权。 + +### 3.10 后台治理 + +| 能力 | 实现 | +| --- | --- | +| 登录 | 用户名密码 + 短信二次(可选) + 设备认证(`admin_user_devices` / `admin_device_challenges`) | +| 会话 | `admin_sessions` 落库 + `session_version`,可远程踢设备 | +| RBAC | 4 个内置角色(admin / finance / operator / support)+ 29 个权限点 + 72 条角色授权 | +| 审计 | `admin_audit_logs`(actor / action / target / payload),实测 55 万行 | +| 通知 | `admin_notifications`(类型 / 优先级 / 去重键 / 提醒次数 / 可见性 / 声音) | +| 实时 | `realtime_events` + SSE,后台列表与工单详情增量刷新 | +| 定时任务 | 后台可视化管理(启用、间隔、立即执行、运行状态) | + +### 3.11 定时任务 + +| id | 类型 | 默认间隔 | 作用 | +| --- | --- | --- | --- | +| `work-order-timeout` | `work_order_timeout` | 60s(scanLimit 50) | 工单超时处置 + 打手超时计数 | +| `deposit-unfreeze` | `deposit_unfreeze` | 60s(scanLimit 100) | 押金/报酬延迟解冻出队 | +| `pdd_order_sync` | `pdd_order_sync` | 20s(pageSize 10) | 拼多多订单拉取 | +| `data-retention` | `data_retention` | 24h | 清理原始报文、匹配日志、任务事件、webhook、审计;回填 `source_order_amount_fen` | + +调度器在进程内(`services/scheduler/scheduler-service.ts`),同一任务运行中会跳过本轮;配置存 `app_config_entries.scheduled_jobs`。 + +--- + +## 四、状态机一页汇总 + +### 4.1 订单 `orders` + +| 字段 | 取值 | 备注 | +| --- | --- | --- | +| `order_status` | `created / paid / closed / refunded`(另有历史值 `pending_config`、`manual_failed`) | 合并时按优先级只升不降 | +| `pay_status` | `unpaid / failed / paid / refunded` | 与 `paid_at` 联动 | +| `claim_status` | `active / paused / closed` | 兑换有效性开关(迁移 099) | + +> 注意:`order_status` **不能代表履约完成**。本地库中 375,432 条 `paid` 订单里,绝大多数并没有「订单级完成」状态,履约进度只能看 `fulfillment_tasks.task_status` 或 `work_orders.status`。 + +### 4.2 履约任务 `fulfillment_tasks` + +`pending_payment → paid → link_generated → claimed → pending_binding_prepare → waiting_binding → role_confirmed → redeeming → dispatched_pending_return → completed / redeemed`,旁支 `retry_pending`、`manual_review`、`failed`、`expired`、`closed`。 + +状态转移约束定义在 `domain/task-status.ts`(`TASK_TRANSITIONS`),终态 `completed / redeemed / expired / closed`。 + +### 4.3 接单工单 `work_orders` / 份额 `work_order_shares` + +- 工单:`pending_material / unassigned / open / in_progress / pending_acceptance / problem / accepted / cancelled`(终态 accepted、cancelled)。 +- 份额:`joined / submitted / accepted / cancelled`。 +- 好友赠送额外有 `gift_phase`:`material_required / material_submitted / friend_countdown / gift_ready`。 + +### 4.4 寄卖 + +| 对象 | 取值 | +| --- | --- | +| `consignment_submissions.status` | `pending_review / approved / rejected` | +| `consignment_inventories.status` | `in_stock / consumed / archived` | +| `consignment_inventories.allocation_status` | `待安排 / 已安排`(中文枚举,历史遗留) | +| `source_type` | `manual / worker_portal / recycle` | +| 卖家可感知阶段 | `pending_review / in_stock / matched_waiting_friend / gift_cooldown / gift_ready / gift_submitted / fulfilling / pending_acceptance / completed / rejected / archived / cancelled` | + +### 4.5 凭证与券 + +| 对象 | 取值 | +| --- | --- | +| `kuaishou_industry_vouchers.status` | `UNUSED / CONSUMED / DESTROYED` | +| `kuaishou_industry_vouchers.send_callback_status` | `success / failed / pending`(回调快手发码结果) | +| `redeem_certificates.status` | `issued / synced / verified / invalid` | +| `redeem_certificates.sync_status` | `pending / success / failed` | +| `kuaishou_cloud_task_states.ticket_status` | 快手云端取号/绑号状态(配合 `dispatch_status`、`consume_status`) | + +### 4.6 资金与售后 + +| 对象 | 取值 | +| --- | --- | +| `worker_finance_requests.status` | `pending / approved / rejected / cancelled` | +| `worker_finance_requests.request_type` | `recharge / withdraw` | +| `worker_deposit_unfreezes.status` | 待解冻 / 已释放(`released_at` 落地) | +| `worker_after_sales_cases.status` | 待响应 / 已响应 / 已处置 / 已退款(配合 `resolution_action`) | +| `worker_after_sales_debts.status` | 未结清 / 已结清 | +| `fund_entries.entry_type` | `opening / funding / issue / expense / return / adjustment` | +| `fund_reconciliations.status` | `pending / confirmed / discrepancy` | + +--- + +## 五、数据库设计 + +### 5.1 总览(本地库实读) + +| 指标 | 数值 | +| --- | --- | +| 业务表 | **59** 张(+ `schema_migrations`) | +| 迁移文件 | 110 个(001 基线 + 002–110 增量) | +| 外键 | 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–110) + +| 区段 | 主题 | +| --- | --- | +| 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 | 订单兑换有效性、**备用金台账**、后台显示名、短信/设备认证 | +| 104–108 | **成本中心与财务履约**:商品目录、成本中心、销售事实、退款、人工履约、成本异常归档 | +| 109–110 | **工单拼单多次参与**(份额模型重写、份额级问题单、撤销原因/份数)+ 大厅置顶候选集索引、后台店铺筛选索引 | + +约定(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` 是所有链路的锚点)与**打手钱包**(工单与寄卖都用它)。 + +--- + +## 九、需要你确认的问题 + +见对话中的提问(不在文档内固化,确认后我再补「口径确认」章节)。 +