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 7b12e56b..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, @@ -69,6 +70,9 @@ export async function grabWorkerHallOrder( if (!result.share) { throw resolveWorkOrderShareJoinError(result.failureReason) } + // 参与成功后立刻失效该打手的大厅缓存:前端紧接着会重拉列表, + // 2s 缓存若命中会返回「参与前」的行(没有 myShare),按钮就不会变成「再拼一次」。 + invalidateWorkerHallListCache([worker.id]) await publishWorkOrderParticipantChange(Number(workOrder.id), { workerIds: [Number(worker.id)], hallChanged: true, @@ -112,6 +116,10 @@ export async function grabWorkerHallOrder( throwGrabWorkOrderFailure(grabbed.failureReason) } + // 抢单成功后同样立刻失效大厅缓存:否则紧接着的列表重拉可能命中 2s 旧缓存, + // 把已经抢走的单又显示出来(用户会以为还能再抢)。 + invalidateWorkerHallListCache([worker.id]) + await createWorkOrderEvent({ workOrderId: workOrder.id, actorType: 'worker', @@ -166,6 +174,8 @@ export async function joinWorkerSharingOrder( if (!result.share) { throw resolveWorkOrderShareJoinError(result.failureReason) } + // 同上:拼单成功后要让紧接着的列表请求看到最新份额。 + invalidateWorkerHallListCache([worker.id]) await publishWorkOrderParticipantChange(Number(workOrderId), { workerIds: [Number(worker.id)], hallChanged: true, 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 dfae9a6c..4cba412b 100644 --- a/apps/backend/src/services/worker-platform/worker-hall-service.ts +++ b/apps/backend/src/services/worker-platform/worker-hall-service.ts @@ -65,13 +65,17 @@ export async function listWorkerHallOrders(query: JsonObject = {}, session: Work const categories = (await listWorkCategories()).map(mapWorkCategory) const orderIds = items.map((item) => Number(item.id)) const shares = 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 } @@ -90,7 +94,9 @@ export async function listWorkerHallOrders(query: JsonObject = {}, session: Work const isInternalWorker = String(worker.worker_type || '').trim() === 'internal' return { items: items.map((item) => { - const myShare = myShareByOrderId.get(Number(item.id)) + const myShares = mySharesByOrderId.get(Number(item.id)) || [] + // `listWorkOrderSharesByOrderIds` 按 id ASC,最后一条即最近一次参与。 + const myShare = myShares[myShares.length - 1] || null const mappedOrder = mapWorkOrderForWorker(item, permissions) return { ...mappedOrder, @@ -98,6 +104,7 @@ export async function listWorkerHallOrders(query: JsonObject = {}, session: Work ? item.published_at || item.hall_queued_at || item.created_at || null : null, myShare: myShare ? mapWorkOrderShare(myShare) : null, + myShares: myShares.map(mapWorkOrderShare), sharingProgress: sharingProgressByOrderId.get(Number(item.id)) || { joinedQuantity: 0, pendingSubmissionCount: 0, @@ -154,3 +161,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-platform-service.test.ts b/apps/backend/src/services/worker-platform/worker-platform-service.test.ts index d1430dfa..46e6870b 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 @@ -3154,6 +3154,33 @@ 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.match(listBody, /myShares: myShares\.map\(mapWorkOrderShare\)/, '大厅响应缺少 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') diff --git a/apps/frontend/src/layouts/WorkerLayout.tsx b/apps/frontend/src/layouts/WorkerLayout.tsx index 68658ec5..c7d41fa0 100644 --- a/apps/frontend/src/layouts/WorkerLayout.tsx +++ b/apps/frontend/src/layouts/WorkerLayout.tsx @@ -435,6 +435,9 @@ function applyWorkerHallCachePatch( current, { ...event, data: workerSnapshot, operation }, shouldInsert, + // 大厅广播是「所有打手通用」的快照,不含当前打手自己的份额; + // 覆盖行时必须保留,否则「再拼一次」会被冲回「拼单 ¥x/份」。 + ['myShare', 'myShares'], ), ) } diff --git a/apps/frontend/src/pages/worker/WorkerHallPage.tsx b/apps/frontend/src/pages/worker/WorkerHallPage.tsx index 59be1699..ea04e956 100644 --- a/apps/frontend/src/pages/worker/WorkerHallPage.tsx +++ b/apps/frontend/src/pages/worker/WorkerHallPage.tsx @@ -635,6 +635,8 @@ export default function WorkerHallPage() { const disabledReason = resolveGrabDisabledReason(order, worker, remainingSlots) const productName = resolveHallProductName(order) || '未命名商品' const sharingRemaining = resolveSharingRemaining(order) + /** 已参与过就用固定文案「再拼一次」,拼几次都不再改按钮文字(需求)。 */ + const myShareSummary = resolveMyShareSummary(order) return (
@@ -717,12 +719,15 @@ export default function WorkerHallPage() { size="middle" className="worker-hall-sharing-button" disabled={Boolean(disabledReason)} + title={ + myShareSummary.count > 0 + ? `已参与 ${myShareSummary.count} 次 · 共 ${myShareSummary.quantity} 份` + : undefined + } onClick={() => openSharingModal(order)} > - {resolveMyShareSummary(order).count > 0 - ? `已参与 ${resolveMyShareSummary(order).count} 次 · 共 ${ - resolveMyShareSummary(order).quantity - } 份 / 再拼` + {myShareSummary.count > 0 + ? '再拼一次' : `拼单 ${formatMoney(order.sharing.unitReward)}/份`} ) : null} @@ -1073,6 +1078,8 @@ export default function WorkerHallPage() { const disabledReason = resolveGrabDisabledReason(order, worker, remainingSlots) const productName = resolveHallProductName(order) || '未命名商品' const sharingRemaining = resolveSharingRemaining(order) + /** 已参与过就用固定文案「再拼一次」,拼几次都不再改按钮文字(需求)。 */ + const myShareSummary = resolveMyShareSummary(order) const depositAmount = resolveHallDepositAmount(order) const rewardAmount = resolveHallRewardAmount(order) @@ -1169,12 +1176,15 @@ export default function WorkerHallPage() { size="large" className="worker-hall-sharing-button" disabled={Boolean(disabledReason)} + title={ + myShareSummary.count > 0 + ? `已参与 ${myShareSummary.count} 次 · 共 ${myShareSummary.quantity} 份` + : undefined + } onClick={() => openSharingModal(order)} > - {resolveMyShareSummary(order).count > 0 - ? `已参与 ${resolveMyShareSummary(order).count} 次 · 共 ${ - resolveMyShareSummary(order).quantity - } 份 / 再拼` + {myShareSummary.count > 0 + ? '再拼一次' : `拼单 ${formatMoney(order.sharing.unitReward)}/份`} ) : null} diff --git a/apps/frontend/src/utils/worker-realtime-work-orders.ts b/apps/frontend/src/utils/worker-realtime-work-orders.ts index 911335f4..4a2d48f3 100644 --- a/apps/frontend/src/utils/worker-realtime-work-orders.ts +++ b/apps/frontend/src/utils/worker-realtime-work-orders.ts @@ -121,6 +121,13 @@ export function patchWorkerWorkOrderListEnvelope( current: unknown, event: WorkerListEvent, insert = false, + /** + * 就地更新时,快照里没有的「本打手私有字段」保留旧值。 + * + * 大厅广播是发给所有打手的通用快照,天然没有 `myShare`/`myShares`(那是每个打手自己的份额)。 + * 整行替换会把「再拼一次」冲回「拼单 ¥x/份」,所以覆盖行时必须保留。 + */ + preserveFields: readonly string[] = [], ): unknown { if (!isRecord(current) || !isRecord(current.data) || !Array.isArray(current.data.items)) { return current @@ -140,7 +147,14 @@ export function patchWorkerWorkOrderListEnvelope( (entry) => toNumber((entry as { workOrderId?: unknown })?.workOrderId) === id, ) if (index >= 0) { - nextItems = items.map((entry, itemIndex) => (itemIndex === index ? event.data : entry)) + const existing = items[index] + const merged: Record = { ...data } + if (isRecord(existing)) { + for (const field of preserveFields) { + if (!(field in merged)) merged[field] = existing[field] + } + } + nextItems = items.map((entry, itemIndex) => (itemIndex === index ? merged : entry)) } else if (insert) { nextItems = [event.data, ...items] } else { @@ -173,12 +187,13 @@ export function patchWorkerWorkOrderInfiniteEnvelope( current: unknown, event: WorkerListEvent, insert = false, + preserveFields: readonly string[] = [], ): 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), + patchWorkerWorkOrderListEnvelope(page, event, insert && index === 0, preserveFields), ), } } diff --git a/apps/frontend/tests/worker-realtime-work-orders.test.ts b/apps/frontend/tests/worker-realtime-work-orders.test.ts index 4b832e36..5c796cb5 100644 --- a/apps/frontend/tests/worker-realtime-work-orders.test.ts +++ b/apps/frontend/tests/worker-realtime-work-orders.test.ts @@ -280,3 +280,49 @@ test('没有 workerStatuses 的旧快照仍按单个 status 比对(保持既 }) 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) +})