From 81edf530dba0c76f6ebc895e048200c9f13593c9 Mon Sep 17 00:00:00 2001 From: yml2213 Date: Sun, 25 Oct 2026 13:08:19 +0800 Subject: [PATCH] =?UTF-8?q?fix(worker-platform):=20=E6=8B=BC=E5=8D=95?= =?UTF-8?q?=E5=8F=96=E6=B6=88=E5=90=8E=E5=8F=AF=E9=87=8D=E5=85=A5=EF=BC=8C?= =?UTF-8?q?=E5=A4=A7=E5=8E=85=E7=BD=AE=E9=A1=B6=E5=A7=8B=E7=BB=88=E4=BC=98?= =?UTF-8?q?=E5=85=88?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../repositories/worker-platform-repo.test.ts | 5 +- .../worker-platform/work-order-query-repo.ts | 10 +- .../worker-platform/work-order-share-repo.ts | 134 +++++++++++++----- .../worker-hall-action-service.ts | 4 + .../worker-platform-service.test.ts | 74 ++++++++++ .../src/utils/worker-realtime-work-orders.ts | 58 +++++++- .../tests/worker-realtime-work-orders.test.ts | 78 ++++++++++ 7 files changed, 315 insertions(+), 48 deletions(-) diff --git a/apps/backend/src/repositories/worker-platform-repo.test.ts b/apps/backend/src/repositories/worker-platform-repo.test.ts index be70e943..b6f2fe0b 100644 --- a/apps/backend/src/repositories/worker-platform-repo.test.ts +++ b/apps/backend/src/repositories/worker-platform-repo.test.ts @@ -120,7 +120,10 @@ test('大厅容量先确定全局候选集合,再应用页面筛选条件', () }) assert.match(query.whereClause, /candidate\.status = 'open'/) - assert.match(query.whereClause, /ORDER BY candidate\.hall_queued_at ASC NULLS LAST/) + assert.match( + query.whereClause, + /ORDER BY candidate\.pinned_at DESC NULLS LAST, candidate\.hall_queued_at ASC NULLS LAST/, + ) 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/work-order-query-repo.ts b/apps/backend/src/repositories/worker-platform/work-order-query-repo.ts index 60d7f2d5..49ef1738 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 @@ -1464,7 +1464,7 @@ export function buildWorkOrderWhere({ } } -/** 大厅容量先选出全局队首,分类、关键词和 VIP 延迟只能在该集合内继续筛选。 */ +/** 大厅容量先选出全局队首(置顶单优先占位),分类、关键词和 VIP 延迟只能在该集合内继续筛选。 */ function buildHallCandidateIdsSql(limitPlaceholder: string, categoryLimitsPlaceholder: string) { return ` SELECT ranked.id @@ -1473,14 +1473,15 @@ function buildHallCandidateIdsSql(limitPlaceholder: string, categoryLimitsPlaceh 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.hall_queued_at ASC NULLS LAST, candidate.id ASC + 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.hall_queued_at ASC NULLS LAST, candidate.id ASC + 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 @@ -1509,7 +1510,8 @@ function buildHallCandidateIdsSql(limitPlaceholder: string, categoryLimitsPlaceh 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。 + ORDER BY ranked.pinned_at DESC NULLS LAST, ranked.hall_queued_at ASC NULLS LAST, ranked.id ASC LIMIT ${limitPlaceholder} ` } 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 417be135..6e56b837 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 @@ -111,6 +111,24 @@ export function resolveWorkOrderShareJoinQuantity(input: { return input.takeRemaining ? Math.max(0, input.remaining) : input.quantity } +/** + * 同一打手再次参与同一拼单工单时的处理方式。 + * 份额表有 UNIQUE(work_order_id, worker_id),取消的份额会保留用于追溯, + * 所以重新拼单必须复用该行并重置为一条全新份额,不能直接插入。 + */ +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' +} + /** * 计算打手当前累计拼单份额实际需要冻结的押金。 * 等级与个人叠加后的免押额度针对该打手在本工单的累计份额生效;已经冻结的金额不自动回退。 @@ -155,6 +173,8 @@ export async function joinWorkOrderShare(input: { | 'sharing_disabled' | 'work_order_owner_conflict' | 'sharing_quantity_full' + | 'worker_share_exists' + | 'worker_share_locked' | 'worker_active_order_limit' | 'worker_deposit_insufficient' | null @@ -213,6 +233,7 @@ export async function joinWorkOrderShare(input: { return { share: null, failureReason: 'sharing_quantity_full' } } + // 取消的份额也一并查出:UNIQUE(work_order_id, worker_id) 下只能复用该行重新加入。 const existingShareResult = await client.query( `SELECT * FROM work_order_shares @@ -221,10 +242,19 @@ export async function joinWorkOrderShare(input: { [input.workOrderId, input.workerId], ) const existingShare = existingShareResult.rows[0] || null - if (existingShare && (!input.takeRemaining || existingShare.status !== 'joined')) { - return { share: null, failureReason: 'sharing_quantity_full' } + 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', + } } - if (!existingShare) { + const revivingCancelledShare = joinMode === 'rejoin' + if (joinMode === 'create' || revivingCancelledShare) { const activeOrderCount = await countWorkerActiveOrdersWithClient(client, input.workerId) if (activeOrderCount >= input.maxActiveOrders) { return { share: null, failureReason: 'worker_active_order_limit' } @@ -232,7 +262,12 @@ export async function joinWorkOrderShare(input: { } const unitReward = toPositiveInteger(workOrder.sharing_unit_reward, 0) - const nextQuantity = Number(existingShare?.quantity || 0) + requestedQuantity + // 取消的份额已释放份数与押金,重新加入时按一条全新份额计算。 + 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({ requiredDepositAmount: Number(workOrder.required_deposit_amount || 0), @@ -241,12 +276,9 @@ export async function joinWorkOrderShare(input: { ...(input.depositFreeAmount === undefined ? {} : { depositFreeAmount: input.depositFreeAmount }), - currentFrozenDeposit: Number(existingShare?.share_deposit || 0), + currentFrozenDeposit, }) - const depositToFreeze = Math.max( - 0, - nextShareDeposit - Number(existingShare?.share_deposit || 0), - ) + const depositToFreeze = Math.max(0, nextShareDeposit - currentFrozenDeposit) const wallet = await getWorkerWalletWithClient(client, input.workerId) const available = Number(wallet?.available_amount || 0) @@ -254,39 +286,66 @@ export async function joinWorkOrderShare(input: { return { share: null, failureReason: 'worker_deposit_insufficient' } } - const shareResult = existingShare - ? 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], - ) - : await client.query<{ id: number }>( - ` + let shareId = 0 + if (!existingShare) { + const inserted = 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) RETURNING id `, - [ - input.workOrderId, - input.workerId, - requestedQuantity, - unitReward, - shareReward, - nextShareDeposit, - input.now, - ], - ) + [ + 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) + } if (depositToFreeze > 0) { const nextAvailable = available - depositToFreeze @@ -330,13 +389,14 @@ export async function joinWorkOrderShare(input: { workerId: input.workerId, quantity: requestedQuantity, takeRemaining: input.takeRemaining === true, + rejoined: revivingCancelledShare, }), now: input.now, }) const share = await client.query( `${WORK_ORDER_SHARE_SELECT} WHERE wos.id = $1 LIMIT 1`, - [Number(shareResult.rows[0]?.id || 0)], + [shareId], ) return { share: share.rows[0] || null, failureReason: null } }) 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 4c59b43f..5e3e879e 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 @@ -174,6 +174,8 @@ 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, @@ -183,6 +185,8 @@ 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-platform-service.test.ts b/apps/backend/src/services/worker-platform/worker-platform-service.test.ts index e2d839e3..b64880f3 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 @@ -20,6 +20,7 @@ import { resolveWorkOrderShareCancellationStatus, resolveWorkOrderShareDepositAmount, resolveWorkOrderSharingCancellationPlan, + resolveWorkOrderShareJoinMode, resolveWorkOrderShareJoinQuantity, resolveOpenSharingSyncReward, } from '../../repositories/worker-platform/index.js' @@ -1660,6 +1661,60 @@ 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 () => { + const { readFile } = await import('node:fs/promises') + const shareRepoSrc = await readFile( + new URL('../../repositories/worker-platform/work-order-share-repo.ts', import.meta.url), + 'utf8', + ) + assert.ok( + shareRepoSrc.includes('resolveWorkOrderShareJoinMode'), + 'joinWorkOrderShare 必须按份额状态区分新建、追加、重入与拒绝', + ) + 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 重新计算', + ) +}) + function buildWorkOrderRow(overrides: Partial = {}): WorkOrderRow { return { id: 1, @@ -3092,6 +3147,25 @@ test('打手端「我的订单」必须走"先取 id 再取详情"的延迟分 assert.ok(src.includes('rowsById.get(id)'), 'ANY() 不保证顺序,必须按 id 顺序还原分页结果') }) +test('大厅候选集必须优先保留置顶单,不能被容量上限挤出', 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, + '主分类与子分类的窗口排名都必须优先置顶单', + ) + assert.ok( + hallSql.includes('ranked.pinned_at DESC NULLS LAST'), + '最终候选集 LIMIT 前必须优先置顶单,否则置顶单会被新单挤出大厅', + ) +}) + test('打手等级筛选必须先把等级解析成打手 id,并用 UNION 形式(不能用 OR + IN 子查询)', async () => { const { readFile } = await import('node:fs/promises') const src = await readFile( diff --git a/apps/frontend/src/utils/worker-realtime-work-orders.ts b/apps/frontend/src/utils/worker-realtime-work-orders.ts index 01c414f3..34ede87c 100644 --- a/apps/frontend/src/utils/worker-realtime-work-orders.ts +++ b/apps/frontend/src/utils/worker-realtime-work-orders.ts @@ -163,17 +163,63 @@ export function patchWorkerWorkOrderListEnvelope( } } -/** 无限下拉(大厅):第 1 页允许插入,其余页只做就地更新。 */ +function readWorkOrderId(item: unknown): number { + return isRecord(item) ? toNumber(item.workOrderId) : 0 +} + +function readPinnedAtMs(item: unknown): number | null { + if (!isRecord(item)) return null + const pinnedAt = String(item.pinnedAt || '').trim() + if (!pinnedAt) return null + const parsed = Date.parse(pinnedAt) + // 有置顶标记但时间不可解析时仍算置顶,宁可排前面也不能被普通单挤下去。 + return Number.isFinite(parsed) ? parsed : 0 +} + +/** + * 大厅排序必须与服务端一致:置顶单永远在最前(pinned_at DESC),其余按 id DESC。 + * 实时事件把新单插入第 1 页后必须重排,否则新单会插到置顶单上面。 + */ +export function sortWorkerHallItems(items: unknown[]): unknown[] { + return items.slice().sort((left, right) => { + const leftPinnedAt = readPinnedAtMs(left) + const rightPinnedAt = readPinnedAtMs(right) + if (leftPinnedAt !== null && rightPinnedAt === null) return -1 + if (leftPinnedAt === null && rightPinnedAt !== null) return 1 + if (leftPinnedAt !== null && rightPinnedAt !== null && leftPinnedAt !== rightPinnedAt) { + return rightPinnedAt - leftPinnedAt + } + return readWorkOrderId(right) - readWorkOrderId(left) + }) +} + +/** 无限下拉(大厅):第 1 页允许插入,其余页只做就地更新;最终按置顶优先重排。 */ export function patchWorkerWorkOrderInfiniteEnvelope( current: unknown, event: WorkerListEvent, insert = false, ): unknown { if (!isRecord(current) || !Array.isArray(current.pages)) return current - return { - ...current, - pages: current.pages.map((page: unknown, index: number) => - patchWorkerWorkOrderListEnvelope(page, event, insert && index === 0), - ), + const pages = current.pages.map((page: unknown, index: number) => + patchWorkerWorkOrderListEnvelope(page, event, insert && index === 0), + ) + + const lists: unknown[][] = [] + for (const page of pages) { + if (!isRecord(page) || !isRecord(page.data) || !Array.isArray(page.data.items)) { + return { ...current, pages } + } + lists.push(page.data.items) } + + // 第 2 页之后被置顶的单也要提到第 1 页最前,所以跨页统一排序再按原页长度切回。 + const sorted = sortWorkerHallItems(lists.flat()) + let cursor = 0 + const sortedPages = pages.map((page, index) => { + const length = lists[index]?.length || 0 + const items = sorted.slice(cursor, cursor + length) + cursor += length + return isRecord(page) && isRecord(page.data) ? { ...page, data: { ...page.data, items } } : page + }) + return { ...current, pages: sortedPages } } diff --git a/apps/frontend/tests/worker-realtime-work-orders.test.ts b/apps/frontend/tests/worker-realtime-work-orders.test.ts index 41cade1e..f6c54b1d 100644 --- a/apps/frontend/tests/worker-realtime-work-orders.test.ts +++ b/apps/frontend/tests/worker-realtime-work-orders.test.ts @@ -140,6 +140,84 @@ test('无限下拉:只有第 1 页允许插入,其余页只就地更新', () assert.equal(itemsOf(patched.pages[1]).length, 2, '第 2 页不得插入') }) +test('大厅新单插入后,置顶单必须仍在最前方', () => { + const current = { + pages: [ + buildEnvelope([ + buildRow({ workOrderId: 100, status: 'open', pinnedAt: '2026-10-25T10:00:00+08:00' }), + buildRow({ workOrderId: 200, status: 'open' }), + ]), + ], + pageParams: [1], + } + const patched = patchWorkerWorkOrderInfiniteEnvelope( + current, + { operation: 'upsert', data: buildRow({ workOrderId: 300, status: 'open' }) }, + true, + ) as { pages: unknown[] } + assert.deepEqual( + itemsOf(patched.pages[0]).map((item) => item.workOrderId), + [100, 300, 200], + '新单必须排在置顶单之后,未置顶部分仍按 id 倒序', + ) +}) + +test('多个置顶单之间按置顶时间倒序,且都排在普通单之前', () => { + const current = { + pages: [ + buildEnvelope([ + buildRow({ workOrderId: 100, status: 'open', pinnedAt: '2026-10-25T09:00:00+08:00' }), + buildRow({ workOrderId: 200, status: 'open' }), + buildRow({ workOrderId: 300, status: 'open', pinnedAt: '2026-10-25T11:00:00+08:00' }), + ]), + ], + pageParams: [1], + } + const patched = patchWorkerWorkOrderInfiniteEnvelope( + current, + { operation: 'upsert', data: buildRow({ workOrderId: 400, status: 'open' }) }, + true, + ) as { pages: unknown[] } + assert.deepEqual( + itemsOf(patched.pages[0]).map((item) => item.workOrderId), + [300, 100, 400, 200], + ) +}) + +test('第 2 页的工单被置顶后必须提到第 1 页最前', () => { + const current = { + pages: [ + buildEnvelope([ + buildRow({ workOrderId: 300, status: 'open' }), + buildRow({ workOrderId: 299, status: 'open' }), + ]), + buildEnvelope([buildRow({ workOrderId: 100, status: 'open' })]), + ], + pageParams: [1, 2], + } + const patched = patchWorkerWorkOrderInfiniteEnvelope( + current, + { + operation: 'upsert', + data: buildRow({ + workOrderId: 100, + status: 'open', + pinnedAt: '2026-10-25T12:00:00+08:00', + }), + }, + false, + ) as { pages: unknown[] } + assert.deepEqual( + itemsOf(patched.pages[0]).map((item) => item.workOrderId), + [100, 300], + '置顶单要在第 1 页最前,被顶下去的普通单顺延到第 2 页', + ) + assert.deepEqual( + itemsOf(patched.pages[1]).map((item) => item.workOrderId), + [299], + ) +}) + /** * 「我的订单」状态页签的实时删行判定。 *