add:新增同一个工单同一人可以多次拼单:拼单后按钮固定显示「再拼一次」
This commit is contained in:
@@ -18,6 +18,7 @@ import {
|
|||||||
throwGrabWorkOrderFailure,
|
throwGrabWorkOrderFailure,
|
||||||
} from './mappers.js'
|
} from './mappers.js'
|
||||||
import { getWorkerHallConfig } from './worker-hall-config-service.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 { publishWorkOrderParticipantChange } from './worker-order-realtime-service.js'
|
||||||
import {
|
import {
|
||||||
getRequiredWorkOrder,
|
getRequiredWorkOrder,
|
||||||
@@ -69,6 +70,9 @@ export async function grabWorkerHallOrder(
|
|||||||
if (!result.share) {
|
if (!result.share) {
|
||||||
throw resolveWorkOrderShareJoinError(result.failureReason)
|
throw resolveWorkOrderShareJoinError(result.failureReason)
|
||||||
}
|
}
|
||||||
|
// 参与成功后立刻失效该打手的大厅缓存:前端紧接着会重拉列表,
|
||||||
|
// 2s 缓存若命中会返回「参与前」的行(没有 myShare),按钮就不会变成「再拼一次」。
|
||||||
|
invalidateWorkerHallListCache([worker.id])
|
||||||
await publishWorkOrderParticipantChange(Number(workOrder.id), {
|
await publishWorkOrderParticipantChange(Number(workOrder.id), {
|
||||||
workerIds: [Number(worker.id)],
|
workerIds: [Number(worker.id)],
|
||||||
hallChanged: true,
|
hallChanged: true,
|
||||||
@@ -112,6 +116,10 @@ export async function grabWorkerHallOrder(
|
|||||||
throwGrabWorkOrderFailure(grabbed.failureReason)
|
throwGrabWorkOrderFailure(grabbed.failureReason)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 抢单成功后同样立刻失效大厅缓存:否则紧接着的列表重拉可能命中 2s 旧缓存,
|
||||||
|
// 把已经抢走的单又显示出来(用户会以为还能再抢)。
|
||||||
|
invalidateWorkerHallListCache([worker.id])
|
||||||
|
|
||||||
await createWorkOrderEvent({
|
await createWorkOrderEvent({
|
||||||
workOrderId: workOrder.id,
|
workOrderId: workOrder.id,
|
||||||
actorType: 'worker',
|
actorType: 'worker',
|
||||||
@@ -166,6 +174,8 @@ export async function joinWorkerSharingOrder(
|
|||||||
if (!result.share) {
|
if (!result.share) {
|
||||||
throw resolveWorkOrderShareJoinError(result.failureReason)
|
throw resolveWorkOrderShareJoinError(result.failureReason)
|
||||||
}
|
}
|
||||||
|
// 同上:拼单成功后要让紧接着的列表请求看到最新份额。
|
||||||
|
invalidateWorkerHallListCache([worker.id])
|
||||||
await publishWorkOrderParticipantChange(Number(workOrderId), {
|
await publishWorkOrderParticipantChange(Number(workOrderId), {
|
||||||
workerIds: [Number(worker.id)],
|
workerIds: [Number(worker.id)],
|
||||||
hallChanged: true,
|
hallChanged: true,
|
||||||
|
|||||||
@@ -65,13 +65,17 @@ export async function listWorkerHallOrders(query: JsonObject = {}, session: Work
|
|||||||
const categories = (await listWorkCategories()).map(mapWorkCategory)
|
const categories = (await listWorkCategories()).map(mapWorkCategory)
|
||||||
const orderIds = items.map((item) => Number(item.id))
|
const orderIds = items.map((item) => Number(item.id))
|
||||||
const shares = await listWorkOrderSharesByOrderIds(orderIds)
|
const shares = await listWorkOrderSharesByOrderIds(orderIds)
|
||||||
const myShareByOrderId = new Map(
|
// 同一打手可能在一个工单里有多份份额(多次拼单)。大厅卡片靠这些字段判断
|
||||||
shares
|
// 是否已参与(按钮显示「再拼一次」),所以必须按工单收集全部有效份额。
|
||||||
.filter(
|
const mySharesByOrderId = new Map<number, typeof shares>()
|
||||||
(share) => share.status !== 'cancelled' && Number(share.worker_id) === Number(worker.id),
|
for (const share of shares) {
|
||||||
)
|
if (share.status === 'cancelled') continue
|
||||||
.map((share) => [Number(share.work_order_id), share]),
|
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<
|
const sharingProgressByOrderId = new Map<
|
||||||
number,
|
number,
|
||||||
{ joinedQuantity: number; pendingSubmissionCount: 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'
|
const isInternalWorker = String(worker.worker_type || '').trim() === 'internal'
|
||||||
return {
|
return {
|
||||||
items: items.map((item) => {
|
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)
|
const mappedOrder = mapWorkOrderForWorker(item, permissions)
|
||||||
return {
|
return {
|
||||||
...mappedOrder,
|
...mappedOrder,
|
||||||
@@ -98,6 +104,7 @@ export async function listWorkerHallOrders(query: JsonObject = {}, session: Work
|
|||||||
? item.published_at || item.hall_queued_at || item.created_at || null
|
? item.published_at || item.hall_queued_at || item.created_at || null
|
||||||
: null,
|
: null,
|
||||||
myShare: myShare ? mapWorkOrderShare(myShare) : null,
|
myShare: myShare ? mapWorkOrderShare(myShare) : null,
|
||||||
|
myShares: myShares.map(mapWorkOrderShare),
|
||||||
sharingProgress: sharingProgressByOrderId.get(Number(item.id)) || {
|
sharingProgress: sharingProgressByOrderId.get(Number(item.id)) || {
|
||||||
joinedQuantity: 0,
|
joinedQuantity: 0,
|
||||||
pendingSubmissionCount: 0,
|
pendingSubmissionCount: 0,
|
||||||
@@ -154,3 +161,26 @@ export function maskWorkerLeaderboardDisplayName(value: unknown): string {
|
|||||||
const firstCharacter = Array.from(String(value || '').trim())[0]
|
const firstCharacter = Array.from(String(value || '').trim())[0]
|
||||||
return firstCharacter ? `${firstCharacter}****` : '****'
|
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 () => {
|
test('已冻结打手调级或变更资料不应因未传 freezeReason 报错', async () => {
|
||||||
const { readFile } = await import('node:fs/promises')
|
const { readFile } = await import('node:fs/promises')
|
||||||
const src = await readFile(new URL('./admin-worker-user-service.ts', import.meta.url), 'utf8')
|
const src = await readFile(new URL('./admin-worker-user-service.ts', import.meta.url), 'utf8')
|
||||||
|
|||||||
@@ -435,6 +435,9 @@ function applyWorkerHallCachePatch(
|
|||||||
current,
|
current,
|
||||||
{ ...event, data: workerSnapshot, operation },
|
{ ...event, data: workerSnapshot, operation },
|
||||||
shouldInsert,
|
shouldInsert,
|
||||||
|
// 大厅广播是「所有打手通用」的快照,不含当前打手自己的份额;
|
||||||
|
// 覆盖行时必须保留,否则「再拼一次」会被冲回「拼单 ¥x/份」。
|
||||||
|
['myShare', 'myShares'],
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -635,6 +635,8 @@ export default function WorkerHallPage() {
|
|||||||
const disabledReason = resolveGrabDisabledReason(order, worker, remainingSlots)
|
const disabledReason = resolveGrabDisabledReason(order, worker, remainingSlots)
|
||||||
const productName = resolveHallProductName(order) || '未命名商品'
|
const productName = resolveHallProductName(order) || '未命名商品'
|
||||||
const sharingRemaining = resolveSharingRemaining(order)
|
const sharingRemaining = resolveSharingRemaining(order)
|
||||||
|
/** 已参与过就用固定文案「再拼一次」,拼几次都不再改按钮文字(需求)。 */
|
||||||
|
const myShareSummary = resolveMyShareSummary(order)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<article key={order.workOrderId} className="worker-hall-mobile-card">
|
<article key={order.workOrderId} className="worker-hall-mobile-card">
|
||||||
@@ -717,12 +719,15 @@ export default function WorkerHallPage() {
|
|||||||
size="middle"
|
size="middle"
|
||||||
className="worker-hall-sharing-button"
|
className="worker-hall-sharing-button"
|
||||||
disabled={Boolean(disabledReason)}
|
disabled={Boolean(disabledReason)}
|
||||||
|
title={
|
||||||
|
myShareSummary.count > 0
|
||||||
|
? `已参与 ${myShareSummary.count} 次 · 共 ${myShareSummary.quantity} 份`
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
onClick={() => openSharingModal(order)}
|
onClick={() => openSharingModal(order)}
|
||||||
>
|
>
|
||||||
{resolveMyShareSummary(order).count > 0
|
{myShareSummary.count > 0
|
||||||
? `已参与 ${resolveMyShareSummary(order).count} 次 · 共 ${
|
? '再拼一次'
|
||||||
resolveMyShareSummary(order).quantity
|
|
||||||
} 份 / 再拼`
|
|
||||||
: `拼单 ${formatMoney(order.sharing.unitReward)}/份`}
|
: `拼单 ${formatMoney(order.sharing.unitReward)}/份`}
|
||||||
</Button>
|
</Button>
|
||||||
) : null}
|
) : null}
|
||||||
@@ -1073,6 +1078,8 @@ export default function WorkerHallPage() {
|
|||||||
const disabledReason = resolveGrabDisabledReason(order, worker, remainingSlots)
|
const disabledReason = resolveGrabDisabledReason(order, worker, remainingSlots)
|
||||||
const productName = resolveHallProductName(order) || '未命名商品'
|
const productName = resolveHallProductName(order) || '未命名商品'
|
||||||
const sharingRemaining = resolveSharingRemaining(order)
|
const sharingRemaining = resolveSharingRemaining(order)
|
||||||
|
/** 已参与过就用固定文案「再拼一次」,拼几次都不再改按钮文字(需求)。 */
|
||||||
|
const myShareSummary = resolveMyShareSummary(order)
|
||||||
const depositAmount = resolveHallDepositAmount(order)
|
const depositAmount = resolveHallDepositAmount(order)
|
||||||
const rewardAmount = resolveHallRewardAmount(order)
|
const rewardAmount = resolveHallRewardAmount(order)
|
||||||
|
|
||||||
@@ -1169,12 +1176,15 @@ export default function WorkerHallPage() {
|
|||||||
size="large"
|
size="large"
|
||||||
className="worker-hall-sharing-button"
|
className="worker-hall-sharing-button"
|
||||||
disabled={Boolean(disabledReason)}
|
disabled={Boolean(disabledReason)}
|
||||||
|
title={
|
||||||
|
myShareSummary.count > 0
|
||||||
|
? `已参与 ${myShareSummary.count} 次 · 共 ${myShareSummary.quantity} 份`
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
onClick={() => openSharingModal(order)}
|
onClick={() => openSharingModal(order)}
|
||||||
>
|
>
|
||||||
{resolveMyShareSummary(order).count > 0
|
{myShareSummary.count > 0
|
||||||
? `已参与 ${resolveMyShareSummary(order).count} 次 · 共 ${
|
? '再拼一次'
|
||||||
resolveMyShareSummary(order).quantity
|
|
||||||
} 份 / 再拼`
|
|
||||||
: `拼单 ${formatMoney(order.sharing.unitReward)}/份`}
|
: `拼单 ${formatMoney(order.sharing.unitReward)}/份`}
|
||||||
</Button>
|
</Button>
|
||||||
) : null}
|
) : null}
|
||||||
|
|||||||
@@ -121,6 +121,13 @@ export function patchWorkerWorkOrderListEnvelope(
|
|||||||
current: unknown,
|
current: unknown,
|
||||||
event: WorkerListEvent,
|
event: WorkerListEvent,
|
||||||
insert = false,
|
insert = false,
|
||||||
|
/**
|
||||||
|
* 就地更新时,快照里没有的「本打手私有字段」保留旧值。
|
||||||
|
*
|
||||||
|
* 大厅广播是发给所有打手的通用快照,天然没有 `myShare`/`myShares`(那是每个打手自己的份额)。
|
||||||
|
* 整行替换会把「再拼一次」冲回「拼单 ¥x/份」,所以覆盖行时必须保留。
|
||||||
|
*/
|
||||||
|
preserveFields: readonly string[] = [],
|
||||||
): unknown {
|
): unknown {
|
||||||
if (!isRecord(current) || !isRecord(current.data) || !Array.isArray(current.data.items)) {
|
if (!isRecord(current) || !isRecord(current.data) || !Array.isArray(current.data.items)) {
|
||||||
return current
|
return current
|
||||||
@@ -140,7 +147,14 @@ export function patchWorkerWorkOrderListEnvelope(
|
|||||||
(entry) => toNumber((entry as { workOrderId?: unknown })?.workOrderId) === id,
|
(entry) => toNumber((entry as { workOrderId?: unknown })?.workOrderId) === id,
|
||||||
)
|
)
|
||||||
if (index >= 0) {
|
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) {
|
} else if (insert) {
|
||||||
nextItems = [event.data, ...items]
|
nextItems = [event.data, ...items]
|
||||||
} else {
|
} else {
|
||||||
@@ -173,12 +187,13 @@ export function patchWorkerWorkOrderInfiniteEnvelope(
|
|||||||
current: unknown,
|
current: unknown,
|
||||||
event: WorkerListEvent,
|
event: WorkerListEvent,
|
||||||
insert = false,
|
insert = false,
|
||||||
|
preserveFields: readonly string[] = [],
|
||||||
): unknown {
|
): unknown {
|
||||||
if (!isRecord(current) || !Array.isArray(current.pages)) return current
|
if (!isRecord(current) || !Array.isArray(current.pages)) return current
|
||||||
return {
|
return {
|
||||||
...current,
|
...current,
|
||||||
pages: current.pages.map((page: unknown, index: number) =>
|
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')
|
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)
|
||||||
|
})
|
||||||
|
|||||||
Reference in New Issue
Block a user