add:新增同一个工单同一人可以多次拼单:拼单后按钮固定显示「再拼一次」
This commit is contained in:
@@ -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')
|
||||
|
||||
Reference in New Issue
Block a user