add:新增同一个工单同一人可以多次拼单:抢单大厅优化
This commit is contained in:
@@ -0,0 +1,13 @@
|
||||
-- 106_work_order_hall_pinned_index.sql —— 抢单大厅置顶候选集索引。
|
||||
--
|
||||
-- 背景:大厅候选集 = FIFO 队首 N 个(idx_work_orders_hall_queue)+ 全部置顶单。
|
||||
-- 旧实现漏了置顶分支:置顶单排在队列窗口之外时不在候选集里,列表端按 pinned_at
|
||||
-- 排序仍会展示它,但抢单/拼单复核容量时被判「不可抢/不可拼单」(生产已复现)。
|
||||
--
|
||||
-- 置顶单数量由运营手工控制(通常个位数),用部分索引即可避免扫全表:
|
||||
-- 只索引处于 open 且已置顶的工单 id。
|
||||
CREATE INDEX IF NOT EXISTS idx_work_orders_hall_pinned
|
||||
ON work_orders(id)
|
||||
WHERE status = 'open' AND pinned_at IS NOT NULL;
|
||||
|
||||
COMMENT ON INDEX idx_work_orders_hall_pinned IS '抢单大厅置顶候选集:open 且 pinned_at 非空的工单 id';
|
||||
@@ -63,6 +63,24 @@ test('大厅候选集(buildHallCandidateIdsSql)不再聚合整表拼单', as
|
||||
)
|
||||
})
|
||||
|
||||
/**
|
||||
* 生产现象:置顶单 WK26102208267560(FIFO 队列第 92 名,窗口只有 50)在列表里看得到,
|
||||
* 点「拼单」却报「该订单当前不可拼单」。根因是候选集只截 FIFO 队首 N 个,
|
||||
* 漏掉了置顶单,而抢单/拼单在事务内用同一段 SQL 复核容量。
|
||||
* 置顶是运营显式优先级,必须无条件并入候选集,否则置顶功能形同虚设。
|
||||
*/
|
||||
test('大厅候选集必须无条件并进置顶单(置顶单排在 FIFO 窗口外也要能抢/拼)', async () => {
|
||||
const source = stripComments(await readRepoSource())
|
||||
const start = source.indexOf('function buildHallCandidateIdsSql')
|
||||
const hallSql = source.slice(start, start + 4200)
|
||||
|
||||
assert.ok(hallSql.includes('UNION'), '候选集没有把置顶单并进 FIFO 窗口')
|
||||
assert.ok(
|
||||
hallSql.includes('pinned.pinned_at IS NOT NULL'),
|
||||
'候选集缺少「status=open 且 pinned_at 非空」的置顶分支',
|
||||
)
|
||||
})
|
||||
|
||||
test('全仓库只允许保留一处「整表拼单聚合」,且必须是无打手范围的全表统计分支', async () => {
|
||||
const raw = await readRepoSource()
|
||||
const source = stripComments(raw)
|
||||
|
||||
@@ -1591,57 +1591,92 @@ export function buildWorkOrderWhere({
|
||||
}
|
||||
}
|
||||
|
||||
/** 大厅容量先选出全局队首,分类、关键词和 VIP 延迟只能在该集合内继续筛选。 */
|
||||
/**
|
||||
* 大厅候选集:FIFO 队首 N 个 + 全部置顶单。
|
||||
*
|
||||
* 旧实现只按 `hall_queued_at ASC, id ASC` 取全局队首 N 个,置顶单若入厅时间偏早
|
||||
* (排在队列 N 名之后)就不在候选集里:列表端按 `pinned_at DESC` 排序、置顶广播
|
||||
* 又会让它出现在打手眼前,但抢单/拼单在事务内复核容量时会判「不在候选集」,
|
||||
* 前端表现为「置顶订单当前不可抢 / 不可拼单」(生产已复现:5 个置顶单里 4 个
|
||||
* 排在 FIFO 窗口 50 名之外)。置顶是运营显式给的最高优先级,必须无条件进入
|
||||
* 候选集;置顶名额不占用 FIFO 容量,保持原队列语义不变。
|
||||
*/
|
||||
function buildHallCandidateIdsSql(limitPlaceholder: string, categoryLimitsPlaceholder: string) {
|
||||
return `
|
||||
SELECT ranked.id
|
||||
SELECT visible.id
|
||||
FROM (
|
||||
SELECT
|
||||
candidate.id,
|
||||
candidate.category_id,
|
||||
candidate.hall_queued_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
|
||||
) AS primary_category_rank,
|
||||
ROW_NUMBER() OVER (
|
||||
PARTITION BY candidate.category_id
|
||||
ORDER BY 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
|
||||
WHERE candidate.status = 'open'
|
||||
AND (
|
||||
candidate.sharing_enabled IS NOT TRUE
|
||||
-- 只按候选工单取拼单份额。历史写法是先把整张 work_order_shares 聚合一遍
|
||||
-- (GROUP BY work_order_id)再 join:生产实测那次聚合 22,987 行 / 35ms / 5,025 buffer,
|
||||
-- 而候选只有 38 笔,聚合结果 99% 被丢掉;而且它是这条 COUNT 的主成本
|
||||
-- (EXPLAIN: Aggregate 82.8ms → Index Only Scan + GroupAggregate 35ms)。
|
||||
-- 关联子查询走 idx_work_order_shares_work_order,每笔候选一次索引探测即可。
|
||||
OR COALESCE((
|
||||
SELECT SUM(wos.quantity)
|
||||
FROM work_order_shares wos
|
||||
WHERE wos.work_order_id = candidate.id
|
||||
AND wos.status != 'cancelled'
|
||||
), 0) < candidate.sharing_total_quantity
|
||||
(
|
||||
SELECT ranked.id
|
||||
FROM (
|
||||
SELECT
|
||||
candidate.id,
|
||||
candidate.category_id,
|
||||
candidate.hall_queued_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
|
||||
) AS primary_category_rank,
|
||||
ROW_NUMBER() OVER (
|
||||
PARTITION BY candidate.category_id
|
||||
ORDER BY 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
|
||||
WHERE candidate.status = 'open'
|
||||
AND (
|
||||
candidate.sharing_enabled IS NOT TRUE
|
||||
-- 只按候选工单取拼单份额。历史写法是先把整张 work_order_shares 聚合一遍
|
||||
-- (GROUP BY work_order_id)再 join:生产实测那次聚合 22,987 行 / 35ms / 5,025 buffer,
|
||||
-- 而候选只有 38 笔,聚合结果 99% 被丢掉;而且它是这条 COUNT 的主成本
|
||||
-- (EXPLAIN: Aggregate 82.8ms → Index Only Scan + GroupAggregate 35ms)。
|
||||
-- 关联子查询走 idx_work_order_shares_work_order,每笔候选一次索引探测即可。
|
||||
OR COALESCE((
|
||||
SELECT SUM(wos.quantity)
|
||||
FROM work_order_shares wos
|
||||
WHERE wos.work_order_id = candidate.id
|
||||
AND wos.status != 'cancelled'
|
||||
), 0) < candidate.sharing_total_quantity
|
||||
)
|
||||
) ranked
|
||||
WHERE ranked.primary_category_rank <= COALESCE(
|
||||
NULLIF(${categoryLimitsPlaceholder}::jsonb ->> ranked.primary_category_id::text, '')::int,
|
||||
${limitPlaceholder}
|
||||
)
|
||||
) ranked
|
||||
WHERE ranked.primary_category_rank <= COALESCE(
|
||||
NULLIF(${categoryLimitsPlaceholder}::jsonb ->> ranked.primary_category_id::text, '')::int,
|
||||
${limitPlaceholder}
|
||||
)
|
||||
AND (
|
||||
NULLIF(${categoryLimitsPlaceholder}::jsonb ->> ranked.category_id::text, '') IS NULL
|
||||
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 ${limitPlaceholder}
|
||||
AND (
|
||||
NULLIF(${categoryLimitsPlaceholder}::jsonb ->> ranked.category_id::text, '') IS NULL
|
||||
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 ${limitPlaceholder}
|
||||
)
|
||||
UNION
|
||||
(
|
||||
-- 置顶单无条件入选:不参与 FIFO 截断,也不受分类容量限制。
|
||||
-- 部分索引 idx_work_orders_hall_pinned 支撑这段(见 106 迁移)。
|
||||
SELECT pinned.id
|
||||
FROM work_orders pinned
|
||||
WHERE pinned.status = 'open'
|
||||
AND pinned.pinned_at IS NOT NULL
|
||||
AND (
|
||||
pinned.sharing_enabled IS NOT TRUE
|
||||
OR COALESCE((
|
||||
SELECT SUM(wos.quantity)
|
||||
FROM work_order_shares wos
|
||||
WHERE wos.work_order_id = pinned.id
|
||||
AND wos.status != 'cancelled'
|
||||
), 0) < pinned.sharing_total_quantity
|
||||
)
|
||||
)
|
||||
) visible
|
||||
`
|
||||
}
|
||||
|
||||
/** 抢单操作在事务中复核容量与当前打手的可见延迟,避免通过订单 ID 绕过大厅队列。 */
|
||||
/**
|
||||
* 抢单操作在事务中复核容量与当前打手的可见延迟,避免通过订单 ID 绕过大厅队列。
|
||||
* 候选集与列表接口同源(FIFO 窗口 + 全部置顶单),所以置顶单即使排在窗口外也能正常抢单/拼单。
|
||||
*/
|
||||
export async function isWorkOrderWithinHallCapacityWithClient(
|
||||
client: PoolClient,
|
||||
input: {
|
||||
|
||||
@@ -339,6 +339,10 @@ async function publishWorkOrderSnapshots(input: {
|
||||
requiredDepositAmount: Number(workOrder.required_deposit_amount || 0),
|
||||
// 广播无法按打手等级过滤可见延迟,携带入厅时间由客户端按各自延迟推迟展示。
|
||||
hallQueuedAt: workOrder.hall_queued_at || null,
|
||||
// 置顶时间:客户端按 pinned_at DESC NULLS LAST 排序广播行,与列表 SQL 同序。
|
||||
// 不带它的话,这条广播行会以 undefined 参与排序,任何广播变更(拼单进度、
|
||||
// 置顶/取消置顶等)都会把已置顶的行挤到未置顶行后面。
|
||||
pinnedAt: workOrder.pinned_at || null,
|
||||
sharingProgress: { joinedQuantity, pendingSubmissionCount },
|
||||
}
|
||||
: { workOrderId },
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import test from 'node:test'
|
||||
|
||||
import { shouldReplayRealtimeEvents } from './realtime-event-service.js'
|
||||
|
||||
/**
|
||||
* 回归:整页刷新后大厅列表重复、可抢订单数越刷越多。
|
||||
*
|
||||
* 根因是新连接(lastEventId=0)也会补发最近 500 条历史事件。大厅 upsert 广播带的是
|
||||
* 当时的工单快照,被重新打进刚加载的列表后,已抢走/已出队的订单会复活并被插到第 1 页,
|
||||
* 前端还会按「插入行」把 pagination.total 加一。
|
||||
*/
|
||||
test('新连接(lastEventId=0)不补发历史事件,避免旧大厅快照复活订单', () => {
|
||||
assert.equal(shouldReplayRealtimeEvents(0), false)
|
||||
assert.equal(shouldReplayRealtimeEvents(Number.NaN), false)
|
||||
assert.equal(shouldReplayRealtimeEvents(-1), false)
|
||||
})
|
||||
|
||||
test('断线重连(带 Last-Event-ID)才补发事件', () => {
|
||||
assert.equal(shouldReplayRealtimeEvents(1), true)
|
||||
assert.equal(shouldReplayRealtimeEvents(3993659), true)
|
||||
})
|
||||
Reference in New Issue
Block a user