add:新增同一个工单同一人可以多次拼单:拼单后按钮固定显示「再拼一次」

This commit is contained in:
2026-10-23 16:37:59 +08:00
parent 2befb57ef8
commit 7e1e1f7075
7 changed files with 159 additions and 18 deletions
@@ -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,
@@ -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<number, typeof shares>()
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 | string | null | undefined>,
): 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
}
@@ -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')