fix(worker-platform): 拼单取消后可重入,大厅置顶始终优先

This commit is contained in:
yml2213
2026-10-25 13:08:19 +08:00
parent 7fdef88ba6
commit 81edf530db
7 changed files with 315 additions and 48 deletions
@@ -120,7 +120,10 @@ test('大厅容量先确定全局候选集合,再应用页面筛选条件', ()
})
assert.match(query.whereClause, /candidate\.status = 'open'/)
assert.match(query.whereClause, /ORDER BY candidate\.hall_queued_at ASC NULLS LAST/)
assert.match(
query.whereClause,
/ORDER BY candidate\.pinned_at DESC NULLS LAST, candidate\.hall_queued_at ASC NULLS LAST/,
)
assert.match(query.whereClause, /LIMIT \$4/)
const candidateSql = query.whereClause.slice(query.whereClause.indexOf('candidate.status'))
assert.doesNotMatch(candidateSql, /candidate\.category_id|candidate.*测试商品/)
@@ -1464,7 +1464,7 @@ export function buildWorkOrderWhere({
}
}
/** 大厅容量先选出全局队首,分类、关键词和 VIP 延迟只能在该集合内继续筛选。 */
/** 大厅容量先选出全局队首(置顶单优先占位),分类、关键词和 VIP 延迟只能在该集合内继续筛选。 */
function buildHallCandidateIdsSql(limitPlaceholder: string, categoryLimitsPlaceholder: string) {
return `
SELECT ranked.id
@@ -1473,14 +1473,15 @@ function buildHallCandidateIdsSql(limitPlaceholder: string, categoryLimitsPlaceh
candidate.id,
candidate.category_id,
candidate.hall_queued_at,
candidate.pinned_at,
COALESCE(candidate_wc.parent_id, candidate.category_id) AS primary_category_id,
ROW_NUMBER() OVER (
PARTITION BY COALESCE(candidate_wc.parent_id, candidate.category_id)
ORDER BY candidate.hall_queued_at ASC NULLS LAST, candidate.id ASC
ORDER BY candidate.pinned_at DESC NULLS LAST, candidate.hall_queued_at ASC NULLS LAST, candidate.id ASC
) AS primary_category_rank,
ROW_NUMBER() OVER (
PARTITION BY candidate.category_id
ORDER BY candidate.hall_queued_at ASC NULLS LAST, candidate.id ASC
ORDER BY candidate.pinned_at DESC NULLS LAST, candidate.hall_queued_at ASC NULLS LAST, candidate.id ASC
) AS sub_category_rank
FROM work_orders candidate
LEFT JOIN work_categories candidate_wc ON candidate_wc.id = candidate.category_id
@@ -1509,7 +1510,8 @@ function buildHallCandidateIdsSql(limitPlaceholder: string, categoryLimitsPlaceh
OR ranked.category_id = ranked.primary_category_id
OR ranked.sub_category_rank <= NULLIF(${categoryLimitsPlaceholder}::jsonb ->> ranked.category_id::text, '')::int
)
ORDER BY ranked.hall_queued_at ASC NULLS LAST, ranked.id ASC
-- 置顶单必须占住最终候选集的席位,不能被后面的新单挤出 limit。
ORDER BY ranked.pinned_at DESC NULLS LAST, ranked.hall_queued_at ASC NULLS LAST, ranked.id ASC
LIMIT ${limitPlaceholder}
`
}
@@ -111,6 +111,24 @@ export function resolveWorkOrderShareJoinQuantity(input: {
return input.takeRemaining ? Math.max(0, input.remaining) : input.quantity
}
/**
* 同一打手再次参与同一拼单工单时的处理方式。
* 份额表有 UNIQUE(work_order_id, worker_id),取消的份额会保留用于追溯,
* 所以重新拼单必须复用该行并重置为一条全新份额,不能直接插入。
*/
export function resolveWorkOrderShareJoinMode(input: {
existingShareStatus?: string | null
takeRemaining?: boolean
}): 'create' | 'append' | 'rejoin' | 'reject' {
const status = String(input.existingShareStatus || '')
if (!status) return 'create'
// 取消即结束该次参与,允许打手把同一行重新启用为份额。
if (status === 'cancelled') return 'rejoin'
// 立即抢单承接剩余份数时才允许在未提交的份额上追加。
if (status === 'joined' && input.takeRemaining === true) return 'append'
return 'reject'
}
/**
* 计算打手当前累计拼单份额实际需要冻结的押金。
* 等级与个人叠加后的免押额度针对该打手在本工单的累计份额生效;已经冻结的金额不自动回退。
@@ -155,6 +173,8 @@ export async function joinWorkOrderShare(input: {
| 'sharing_disabled'
| 'work_order_owner_conflict'
| 'sharing_quantity_full'
| 'worker_share_exists'
| 'worker_share_locked'
| 'worker_active_order_limit'
| 'worker_deposit_insufficient'
| null
@@ -213,6 +233,7 @@ export async function joinWorkOrderShare(input: {
return { share: null, failureReason: 'sharing_quantity_full' }
}
// 取消的份额也一并查出:UNIQUE(work_order_id, worker_id) 下只能复用该行重新加入。
const existingShareResult = await client.query<WorkOrderShareRow>(
`SELECT *
FROM work_order_shares
@@ -221,10 +242,19 @@ export async function joinWorkOrderShare(input: {
[input.workOrderId, input.workerId],
)
const existingShare = existingShareResult.rows[0] || null
if (existingShare && (!input.takeRemaining || existingShare.status !== 'joined')) {
return { share: null, failureReason: 'sharing_quantity_full' }
const joinMode = resolveWorkOrderShareJoinMode({
existingShareStatus: existingShare?.status ?? null,
takeRemaining: input.takeRemaining === true,
})
if (joinMode === 'reject') {
return {
share: null,
failureReason:
existingShare?.status === 'joined' ? 'worker_share_exists' : 'worker_share_locked',
}
}
if (!existingShare) {
const revivingCancelledShare = joinMode === 'rejoin'
if (joinMode === 'create' || revivingCancelledShare) {
const activeOrderCount = await countWorkerActiveOrdersWithClient(client, input.workerId)
if (activeOrderCount >= input.maxActiveOrders) {
return { share: null, failureReason: 'worker_active_order_limit' }
@@ -232,7 +262,12 @@ export async function joinWorkOrderShare(input: {
}
const unitReward = toPositiveInteger(workOrder.sharing_unit_reward, 0)
const nextQuantity = Number(existingShare?.quantity || 0) + requestedQuantity
// 取消的份额已释放份数与押金,重新加入时按一条全新份额计算。
const currentQuantity = revivingCancelledShare ? 0 : Number(existingShare?.quantity || 0)
const currentFrozenDeposit = revivingCancelledShare
? 0
: Number(existingShare?.share_deposit || 0)
const nextQuantity = currentQuantity + requestedQuantity
const shareReward = nextQuantity * unitReward
const nextShareDeposit = resolveWorkOrderShareDepositAmount({
requiredDepositAmount: Number(workOrder.required_deposit_amount || 0),
@@ -241,12 +276,9 @@ export async function joinWorkOrderShare(input: {
...(input.depositFreeAmount === undefined
? {}
: { depositFreeAmount: input.depositFreeAmount }),
currentFrozenDeposit: Number(existingShare?.share_deposit || 0),
currentFrozenDeposit,
})
const depositToFreeze = Math.max(
0,
nextShareDeposit - Number(existingShare?.share_deposit || 0),
)
const depositToFreeze = Math.max(0, nextShareDeposit - currentFrozenDeposit)
const wallet = await getWorkerWalletWithClient(client, input.workerId)
const available = Number(wallet?.available_amount || 0)
@@ -254,39 +286,66 @@ export async function joinWorkOrderShare(input: {
return { share: null, failureReason: 'worker_deposit_insufficient' }
}
const shareResult = existingShare
? await client.query<{ id: number }>(
`
UPDATE work_order_shares
SET
quantity = $1,
unit_reward = $2,
share_reward = $3,
share_deposit = $4,
updated_at = $5
WHERE id = $6
RETURNING id
`,
[nextQuantity, unitReward, shareReward, nextShareDeposit, input.now, existingShare.id],
)
: await client.query<{ id: number }>(
`
let shareId = 0
if (!existingShare) {
const inserted = await client.query<{ id: number }>(
`
INSERT INTO work_order_shares (
work_order_id, worker_id, quantity, unit_reward, share_reward,
share_deposit, status, acceptance_json, created_at, updated_at
) VALUES ($1, $2, $3, $4, $5, $6, 'joined', '{}'::jsonb, $7, $7)
RETURNING id
`,
[
input.workOrderId,
input.workerId,
requestedQuantity,
unitReward,
shareReward,
nextShareDeposit,
input.now,
],
)
[
input.workOrderId,
input.workerId,
requestedQuantity,
unitReward,
shareReward,
nextShareDeposit,
input.now,
],
)
shareId = Number(inserted.rows[0]?.id || 0)
} else if (revivingCancelledShare) {
const revived = await client.query<{ id: number }>(
`
UPDATE work_order_shares
SET
status = 'joined',
quantity = $1,
unit_reward = $2,
share_reward = $3,
share_deposit = $4,
acceptance_json = '{}'::jsonb,
draft_acceptance_json = '{}'::jsonb,
submitted_at = NULL,
accepted_at = NULL,
created_at = $5,
updated_at = $5
WHERE id = $6
RETURNING id
`,
[nextQuantity, unitReward, shareReward, nextShareDeposit, input.now, existingShare.id],
)
shareId = Number(revived.rows[0]?.id || 0)
} else {
const appended = await client.query<{ id: number }>(
`
UPDATE work_order_shares
SET
quantity = $1,
unit_reward = $2,
share_reward = $3,
share_deposit = $4,
updated_at = $5
WHERE id = $6
RETURNING id
`,
[nextQuantity, unitReward, shareReward, nextShareDeposit, input.now, existingShare.id],
)
shareId = Number(appended.rows[0]?.id || 0)
}
if (depositToFreeze > 0) {
const nextAvailable = available - depositToFreeze
@@ -330,13 +389,14 @@ export async function joinWorkOrderShare(input: {
workerId: input.workerId,
quantity: requestedQuantity,
takeRemaining: input.takeRemaining === true,
rejoined: revivingCancelledShare,
}),
now: input.now,
})
const share = await client.query<WorkOrderShareRow>(
`${WORK_ORDER_SHARE_SELECT} WHERE wos.id = $1 LIMIT 1`,
[Number(shareResult.rows[0]?.id || 0)],
[shareId],
)
return { share: share.rows[0] || null, failureReason: null }
})
@@ -174,6 +174,8 @@ function resolveWorkOrderShareJoinError(
| 'sharing_disabled'
| 'work_order_owner_conflict'
| 'sharing_quantity_full'
| 'worker_share_exists'
| 'worker_share_locked'
| 'worker_active_order_limit'
| 'worker_deposit_insufficient'
| null,
@@ -183,6 +185,8 @@ function resolveWorkOrderShareJoinError(
sharing_disabled: '该订单未启用拼单',
work_order_owner_conflict: '你已整单接取该订单,无需拼单',
sharing_quantity_full: '拼单数量已满或超出剩余数量',
worker_share_exists: '你已参与该拼单,剩余份数可用「立即抢单」追加',
worker_share_locked: '你的拼单份额已提交或已验收,不能再追加份数',
worker_active_order_limit: '已达到当前等级最大同时接单量',
worker_deposit_insufficient: '余额不足,无法冻结所需押金',
}
@@ -20,6 +20,7 @@ import {
resolveWorkOrderShareCancellationStatus,
resolveWorkOrderShareDepositAmount,
resolveWorkOrderSharingCancellationPlan,
resolveWorkOrderShareJoinMode,
resolveWorkOrderShareJoinQuantity,
resolveOpenSharingSyncReward,
} from '../../repositories/worker-platform/index.js'
@@ -1660,6 +1661,60 @@ test('拼单份数全部承接后不再显示在抢单大厅', () => {
assert.equal(isWorkOrderSharingFilled(false, 15, 15), false)
})
test('拼单份额取消后允许原打手重新加入', () => {
assert.equal(resolveWorkOrderShareJoinMode({ existingShareStatus: 'cancelled' }), 'rejoin')
assert.equal(
resolveWorkOrderShareJoinMode({ existingShareStatus: 'cancelled', takeRemaining: true }),
'rejoin',
)
})
test('拼单份额未提交时只有立即抢单可以追加份数', () => {
assert.equal(
resolveWorkOrderShareJoinMode({ existingShareStatus: 'joined', takeRemaining: true }),
'append',
)
assert.equal(resolveWorkOrderShareJoinMode({ existingShareStatus: 'joined' }), 'reject')
})
test('拼单份额已提交或已验收后不再允许追加份数', () => {
assert.equal(
resolveWorkOrderShareJoinMode({ existingShareStatus: 'submitted', takeRemaining: true }),
'reject',
)
assert.equal(
resolveWorkOrderShareJoinMode({ existingShareStatus: 'accepted', takeRemaining: true }),
'reject',
)
})
test('打手首次参与拼单时新建份额', () => {
assert.equal(resolveWorkOrderShareJoinMode({ existingShareStatus: null }), 'create')
})
test('取消的拼单份额重新加入必须复用原行并重置超时与验收状态', async () => {
const { readFile } = await import('node:fs/promises')
const shareRepoSrc = await readFile(
new URL('../../repositories/worker-platform/work-order-share-repo.ts', import.meta.url),
'utf8',
)
assert.ok(
shareRepoSrc.includes('resolveWorkOrderShareJoinMode'),
'joinWorkOrderShare 必须按份额状态区分新建、追加、重入与拒绝',
)
assert.ok(
shareRepoSrc.includes("status = 'joined',") &&
shareRepoSrc.includes('created_at = $5') &&
shareRepoSrc.includes('submitted_at = NULL') &&
shareRepoSrc.includes('accepted_at = NULL'),
'复用 cancelled 份额时必须重置 created_at(超时时钟)与验收时间,避免重入后立即被判超时',
)
assert.ok(
shareRepoSrc.includes('const currentFrozenDeposit = revivingCancelledShare'),
'取消份额的押金已退还,重新加入时必须按已有押金为 0 重新计算',
)
})
function buildWorkOrderRow(overrides: Partial<WorkOrderRow> = {}): WorkOrderRow {
return {
id: 1,
@@ -3092,6 +3147,25 @@ test('打手端「我的订单」必须走"先取 id 再取详情"的延迟分
assert.ok(src.includes('rowsById.get(id)'), 'ANY() 不保证顺序,必须按 id 顺序还原分页结果')
})
test('大厅候选集必须优先保留置顶单,不能被容量上限挤出', async () => {
const { readFile } = await import('node:fs/promises')
const src = await readFile(
new URL('../../repositories/worker-platform/work-order-query-repo.ts', import.meta.url),
'utf8',
)
const start = src.indexOf('function buildHallCandidateIdsSql')
const hallSql = src.slice(start, start + 2600)
assert.equal(
(hallSql.match(/candidate\.pinned_at DESC NULLS LAST/g) || []).length,
2,
'主分类与子分类的窗口排名都必须优先置顶单',
)
assert.ok(
hallSql.includes('ranked.pinned_at DESC NULLS LAST'),
'最终候选集 LIMIT 前必须优先置顶单,否则置顶单会被新单挤出大厅',
)
})
test('打手等级筛选必须先把等级解析成打手 id,并用 UNION 形式(不能用 OR + IN 子查询)', async () => {
const { readFile } = await import('node:fs/promises')
const src = await readFile(
@@ -163,17 +163,63 @@ export function patchWorkerWorkOrderListEnvelope(
}
}
/** 无限下拉(大厅):第 1 页允许插入,其余页只做就地更新。 */
function readWorkOrderId(item: unknown): number {
return isRecord(item) ? toNumber(item.workOrderId) : 0
}
function readPinnedAtMs(item: unknown): number | null {
if (!isRecord(item)) return null
const pinnedAt = String(item.pinnedAt || '').trim()
if (!pinnedAt) return null
const parsed = Date.parse(pinnedAt)
// 有置顶标记但时间不可解析时仍算置顶,宁可排前面也不能被普通单挤下去。
return Number.isFinite(parsed) ? parsed : 0
}
/**
* 大厅排序必须与服务端一致:置顶单永远在最前(pinned_at DESC),其余按 id DESC。
* 实时事件把新单插入第 1 页后必须重排,否则新单会插到置顶单上面。
*/
export function sortWorkerHallItems(items: unknown[]): unknown[] {
return items.slice().sort((left, right) => {
const leftPinnedAt = readPinnedAtMs(left)
const rightPinnedAt = readPinnedAtMs(right)
if (leftPinnedAt !== null && rightPinnedAt === null) return -1
if (leftPinnedAt === null && rightPinnedAt !== null) return 1
if (leftPinnedAt !== null && rightPinnedAt !== null && leftPinnedAt !== rightPinnedAt) {
return rightPinnedAt - leftPinnedAt
}
return readWorkOrderId(right) - readWorkOrderId(left)
})
}
/** 无限下拉(大厅):第 1 页允许插入,其余页只做就地更新;最终按置顶优先重排。 */
export function patchWorkerWorkOrderInfiniteEnvelope(
current: unknown,
event: WorkerListEvent,
insert = false,
): 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),
),
const pages = current.pages.map((page: unknown, index: number) =>
patchWorkerWorkOrderListEnvelope(page, event, insert && index === 0),
)
const lists: unknown[][] = []
for (const page of pages) {
if (!isRecord(page) || !isRecord(page.data) || !Array.isArray(page.data.items)) {
return { ...current, pages }
}
lists.push(page.data.items)
}
// 第 2 页之后被置顶的单也要提到第 1 页最前,所以跨页统一排序再按原页长度切回。
const sorted = sortWorkerHallItems(lists.flat())
let cursor = 0
const sortedPages = pages.map((page, index) => {
const length = lists[index]?.length || 0
const items = sorted.slice(cursor, cursor + length)
cursor += length
return isRecord(page) && isRecord(page.data) ? { ...page, data: { ...page.data, items } } : page
})
return { ...current, pages: sortedPages }
}
@@ -140,6 +140,84 @@ test('无限下拉:只有第 1 页允许插入,其余页只就地更新', ()
assert.equal(itemsOf(patched.pages[1]).length, 2, '第 2 页不得插入')
})
test('大厅新单插入后,置顶单必须仍在最前方', () => {
const current = {
pages: [
buildEnvelope([
buildRow({ workOrderId: 100, status: 'open', pinnedAt: '2026-10-25T10:00:00+08:00' }),
buildRow({ workOrderId: 200, status: 'open' }),
]),
],
pageParams: [1],
}
const patched = patchWorkerWorkOrderInfiniteEnvelope(
current,
{ operation: 'upsert', data: buildRow({ workOrderId: 300, status: 'open' }) },
true,
) as { pages: unknown[] }
assert.deepEqual(
itemsOf(patched.pages[0]).map((item) => item.workOrderId),
[100, 300, 200],
'新单必须排在置顶单之后,未置顶部分仍按 id 倒序',
)
})
test('多个置顶单之间按置顶时间倒序,且都排在普通单之前', () => {
const current = {
pages: [
buildEnvelope([
buildRow({ workOrderId: 100, status: 'open', pinnedAt: '2026-10-25T09:00:00+08:00' }),
buildRow({ workOrderId: 200, status: 'open' }),
buildRow({ workOrderId: 300, status: 'open', pinnedAt: '2026-10-25T11:00:00+08:00' }),
]),
],
pageParams: [1],
}
const patched = patchWorkerWorkOrderInfiniteEnvelope(
current,
{ operation: 'upsert', data: buildRow({ workOrderId: 400, status: 'open' }) },
true,
) as { pages: unknown[] }
assert.deepEqual(
itemsOf(patched.pages[0]).map((item) => item.workOrderId),
[300, 100, 400, 200],
)
})
test('第 2 页的工单被置顶后必须提到第 1 页最前', () => {
const current = {
pages: [
buildEnvelope([
buildRow({ workOrderId: 300, status: 'open' }),
buildRow({ workOrderId: 299, status: 'open' }),
]),
buildEnvelope([buildRow({ workOrderId: 100, status: 'open' })]),
],
pageParams: [1, 2],
}
const patched = patchWorkerWorkOrderInfiniteEnvelope(
current,
{
operation: 'upsert',
data: buildRow({
workOrderId: 100,
status: 'open',
pinnedAt: '2026-10-25T12:00:00+08:00',
}),
},
false,
) as { pages: unknown[] }
assert.deepEqual(
itemsOf(patched.pages[0]).map((item) => item.workOrderId),
[100, 300],
'置顶单要在第 1 页最前,被顶下去的普通单顺延到第 2 页',
)
assert.deepEqual(
itemsOf(patched.pages[1]).map((item) => item.workOrderId),
[299],
)
})
/**
* 「我的订单」状态页签的实时删行判定。
*