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')
@@ -435,6 +435,9 @@ function applyWorkerHallCachePatch(
current,
{ ...event, data: workerSnapshot, operation },
shouldInsert,
// 大厅广播是「所有打手通用」的快照,不含当前打手自己的份额;
// 覆盖行时必须保留,否则「再拼一次」会被冲回「拼单 ¥x/份」。
['myShare', 'myShares'],
),
)
}
@@ -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 (
<article key={order.workOrderId} className="worker-hall-mobile-card">
@@ -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)}/份`}
</Button>
) : 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)}/份`}
</Button>
) : null}
@@ -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<string, unknown> = { ...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),
),
}
}
@@ -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)
})