From 25756901cc1e9406c5deb536792560782ec9a487 Mon Sep 17 00:00:00 2001 From: zhaoxing <2558691827@qq.com> Date: Sat, 24 Oct 2026 13:44:34 +0800 Subject: [PATCH] =?UTF-8?q?add:=E6=96=B0=E5=A2=9E=E5=90=8C=E4=B8=80?= =?UTF-8?q?=E4=B8=AA=E5=B7=A5=E5=8D=95=E5=90=8C=E4=B8=80=E4=BA=BA=E5=8F=AF?= =?UTF-8?q?=E4=BB=A5=E5=A4=9A=E6=AC=A1=E6=8B=BC=E5=8D=95=EF=BC=9A=E6=8A=A2?= =?UTF-8?q?=E5=8D=95=E5=A4=A7=E5=8E=85=E4=BC=98=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../106_work_order_hall_pinned_index.sql | 13 ++ .../share-summary-scope.test.ts | 18 +++ .../worker-platform/work-order-query-repo.ts | 123 +++++++++++------- .../realtime/realtime-event-service.ts | 4 + .../services/realtime/realtime-replay.test.ts | 22 ++++ apps/frontend/src/layouts/WorkerLayout.tsx | 6 + .../src/utils/worker-realtime-work-orders.ts | 52 +++++++- .../tests/worker-realtime-work-orders.test.ts | 48 +++++++ deploy/caddy/Caddyfile.dev | 3 +- docker-compose.dev.yml | 2 + 10 files changed, 245 insertions(+), 46 deletions(-) create mode 100644 apps/backend/src/db/migrations/106_work_order_hall_pinned_index.sql create mode 100644 apps/backend/src/services/realtime/realtime-replay.test.ts diff --git a/apps/backend/src/db/migrations/106_work_order_hall_pinned_index.sql b/apps/backend/src/db/migrations/106_work_order_hall_pinned_index.sql new file mode 100644 index 00000000..2e60eab8 --- /dev/null +++ b/apps/backend/src/db/migrations/106_work_order_hall_pinned_index.sql @@ -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'; diff --git a/apps/backend/src/repositories/worker-platform/share-summary-scope.test.ts b/apps/backend/src/repositories/worker-platform/share-summary-scope.test.ts index 8611395c..6dccfc56 100644 --- a/apps/backend/src/repositories/worker-platform/share-summary-scope.test.ts +++ b/apps/backend/src/repositories/worker-platform/share-summary-scope.test.ts @@ -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) diff --git a/apps/backend/src/repositories/worker-platform/work-order-query-repo.ts b/apps/backend/src/repositories/worker-platform/work-order-query-repo.ts index d24cbe29..c95ff3c3 100644 --- a/apps/backend/src/repositories/worker-platform/work-order-query-repo.ts +++ b/apps/backend/src/repositories/worker-platform/work-order-query-repo.ts @@ -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: { diff --git a/apps/backend/src/services/realtime/realtime-event-service.ts b/apps/backend/src/services/realtime/realtime-event-service.ts index 799b6616..c99eee1f 100644 --- a/apps/backend/src/services/realtime/realtime-event-service.ts +++ b/apps/backend/src/services/realtime/realtime-event-service.ts @@ -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 }, diff --git a/apps/backend/src/services/realtime/realtime-replay.test.ts b/apps/backend/src/services/realtime/realtime-replay.test.ts new file mode 100644 index 00000000..7df17f5c --- /dev/null +++ b/apps/backend/src/services/realtime/realtime-replay.test.ts @@ -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) +}) diff --git a/apps/frontend/src/layouts/WorkerLayout.tsx b/apps/frontend/src/layouts/WorkerLayout.tsx index c7d41fa0..9afdf8cc 100644 --- a/apps/frontend/src/layouts/WorkerLayout.tsx +++ b/apps/frontend/src/layouts/WorkerLayout.tsx @@ -16,6 +16,7 @@ import { fetchWorkerAfterSalesCases, fetchWorkerProfile, logoutWorker } from '@/ import { useIsMobile } from '@/utils/use-is-mobile' import { isExplicitOrderNoSearch } from '@/utils/admin-realtime-work-orders' import { + compareWorkerHallOrderRows, evaluateWorkerKeywordVerdict, patchWorkerWorkOrderInfiniteEnvelope, patchWorkerWorkOrderListEnvelope, @@ -438,6 +439,11 @@ function applyWorkerHallCachePatch( // 大厅广播是「所有打手通用」的快照,不含当前打手自己的份额; // 覆盖行时必须保留,否则「再拼一次」会被冲回「拼单 ¥x/份」。 ['myShare', 'myShares'], + // 大厅展示顺序是「置顶优先,再按工单号倒序」(与列表 SQL 的 + // `pinned_at DESC NULLS LAST, id DESC` 同口径)。广播补丁只做就地覆盖 + // 或顶部插入,不重排缓存,这里传入排序比较器,保证新插入的行 + // 和快照里带 pinnedAt 的行按大厅口径归位,不会跑到置顶行前面。 + compareWorkerHallOrderRows, ), ) } diff --git a/apps/frontend/src/utils/worker-realtime-work-orders.ts b/apps/frontend/src/utils/worker-realtime-work-orders.ts index 4a2d48f3..dc68dc55 100644 --- a/apps/frontend/src/utils/worker-realtime-work-orders.ts +++ b/apps/frontend/src/utils/worker-realtime-work-orders.ts @@ -116,6 +116,39 @@ export function resolveWorkerMyOrderRealtimeVerdict(input: { } } +/** + * 大厅行的实时排序:与后端列表 SQL(pinnedFirst 时 `pinned_at DESC NULLS LAST, id DESC`) + * 保持同一口径。大厅广播是「就地覆盖 + 第 1 页顶部插入」的缓存补丁,快照来自事件 + * 而不是重新查询,行会带着事件发生时的快字段停在缓存里;排序只影响展示位置。 + * + * 这里必须带置顶维度:广播快照的 pinnedAt 参与不了 SQL 排序,若只按 workOrderId 排, + * 每次广播变更(拼单进度、材料更新等)都会把置顶行挤到未置顶行后面 —— + * 表现为「进大厅过一会儿,排名靠后的订单跑到前面去」。 + */ +export function compareWorkerHallOrderRows(left: unknown, right: unknown): number { + const leftPinnedAtMs = resolveHallPinnedAtMs(left) + const rightPinnedAtMs = resolveHallPinnedAtMs(right) + if (leftPinnedAtMs !== rightPinnedAtMs) { + // 有一方置顶即置顶行在前;双方都置顶时按置顶时间倒序(同列表 SQL 的 NULLS LAST)。 + if (leftPinnedAtMs !== null && rightPinnedAtMs !== null) { + return rightPinnedAtMs - leftPinnedAtMs + } + return leftPinnedAtMs !== null ? -1 : 1 + } + // 双方置顶状态一致:按工单号倒序(与列表 SQL 的 `id DESC` 一致)。 + return ( + toNumber((right as { workOrderId?: unknown })?.workOrderId) - + toNumber((left as { workOrderId?: unknown })?.workOrderId) + ) +} + +/** 置顶行的时间戳;未置顶返回 null(对应 SQL 的 NULLS LAST)。 */ +function resolveHallPinnedAtMs(item: unknown): number | null { + if (!isRecord(item)) return null + const parsed = Date.parse(String(item.pinnedAt || '')) + return Number.isFinite(parsed) ? parsed : null +} + /** 按工单号原地增删行,并同步分页总数(结构同后端 `{ data: { items, pagination } }`)。 */ export function patchWorkerWorkOrderListEnvelope( current: unknown, @@ -128,6 +161,15 @@ export function patchWorkerWorkOrderListEnvelope( * 整行替换会把「再拼一次」冲回「拼单 ¥x/份」,所以覆盖行时必须保留。 */ preserveFields: readonly string[] = [], + /** + * 插行后的行内排序比较器。 + * + * 插入的新行(大厅广播的 upsert)落在第 1 页顶部,如果大厅缓存里已有置顶行, + * 只按「插到最前面」处理会把新行插到置顶行前面;传入比较器可以按大厅的 + * 排序口径(置顶优先,然后工单号倒序)重新排一次。「我的订单」等按服务端 + * 顺序追加的列表不传该参数,保持原行为。 + */ + sortCompare?: (left: unknown, right: unknown) => number, ): unknown { if (!isRecord(current) || !isRecord(current.data) || !Array.isArray(current.data.items)) { return current @@ -161,6 +203,7 @@ export function patchWorkerWorkOrderListEnvelope( return current } } + if (sortCompare) nextItems = [...nextItems].sort(sortCompare) const delta = nextItems.length - items.length const pagination = isRecord(current.data.pagination) ? current.data.pagination : null @@ -188,12 +231,19 @@ export function patchWorkerWorkOrderInfiniteEnvelope( event: WorkerListEvent, insert = false, preserveFields: readonly string[] = [], + sortCompare?: (left: unknown, right: unknown) => number, ): 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, preserveFields), + patchWorkerWorkOrderListEnvelope( + page, + event, + insert && index === 0, + preserveFields, + sortCompare, + ), ), } } diff --git a/apps/frontend/tests/worker-realtime-work-orders.test.ts b/apps/frontend/tests/worker-realtime-work-orders.test.ts index 5c796cb5..de14af0f 100644 --- a/apps/frontend/tests/worker-realtime-work-orders.test.ts +++ b/apps/frontend/tests/worker-realtime-work-orders.test.ts @@ -2,6 +2,7 @@ import assert from 'node:assert/strict' import test from 'node:test' import { + compareWorkerHallOrderRows, evaluateWorkerKeywordVerdict, matchesWorkerWorkOrderKeyword, patchWorkerWorkOrderInfiniteEnvelope, @@ -326,3 +327,50 @@ test('大厅广播覆盖行时必须保留本打手的 myShare/myShares,并更 assert.equal(insertedRow?.myShare ?? null, null) assert.equal(insertedRow?.myShares ?? null, null) }) + +/** + * 线上坑:进大厅后过一会儿「排名靠后的订单跑到前面去」。 + * + * 根因是大厅广播补丁只做「就地覆盖 + 顶部插入」、不重排缓存: + * 1) 列表 SQL 按置顶时间排序时,广播快照没带 pinnedAt,排序口径直接丢失; + * 2) 广播 upsert 插到第 1 页顶部,会把已置顶的行挤到后面。 + * 修复 = 后端快照带 pinnedAt + 前端按「置顶优先 → 工单号倒序」重排插行后的列表。 + */ +test('大厅排序比较器:置顶行恒在未置顶行前,同置顶按置顶时间倒序', () => { + const pinnedNewer = buildRow({ workOrderId: 1, pinnedAt: '2026-10-24T10:00:00Z' }) + const pinnedOlder = buildRow({ workOrderId: 2, pinnedAt: '2026-10-24T09:00:00Z' }) + const unpinned = buildRow({ workOrderId: 3, pinnedAt: null }) + + assert.equal(compareWorkerHallOrderRows(pinnedNewer, pinnedOlder) < 0, true) + assert.equal(compareWorkerHallOrderRows(pinnedOlder, pinnedNewer) > 0, true) + assert.equal(compareWorkerHallOrderRows(pinnedNewer, unpinned) < 0, true) + assert.equal(compareWorkerHallOrderRows(unpinned, pinnedNewer) > 0, true) + // 双方都未置顶:按工单号倒序(与列表 SQL 的 id DESC 一致) + const newer = buildRow({ workOrderId: 10, pinnedAt: null }) + const older = buildRow({ workOrderId: 9, pinnedAt: null }) + assert.equal(compareWorkerHallOrderRows(newer, older) < 0, true) +}) + +test('广播 upsert 插入新行后必须按大厅口径重排,不能把置顶行挤下去', () => { + const pinnedRow = buildRow({ + workOrderId: 100, + status: 'open', + pinnedAt: '2026-10-24T09:00:00Z', + }) + const envelope = { pages: [buildEnvelope([pinnedRow])], pageParams: [1] } + const patched = patchWorkerWorkOrderInfiniteEnvelope( + envelope, + { + operation: 'upsert', + data: buildRow({ workOrderId: 200, status: 'open', pinnedAt: null }), + }, + true, + ['myShare', 'myShares'], + compareWorkerHallOrderRows, + ) as { pages: unknown[] } + assert.deepEqual( + itemsOf(patched.pages[0]).map((item) => item.workOrderId), + [100, 200], + '新单排在置顶单后面,而不是插到最前面', + ) +}) diff --git a/deploy/caddy/Caddyfile.dev b/deploy/caddy/Caddyfile.dev index 5bc55e7c..b0766c0f 100644 --- a/deploy/caddy/Caddyfile.dev +++ b/deploy/caddy/Caddyfile.dev @@ -1,4 +1,5 @@ -:80 { +# 局域网联调:8080 端口给打手端用(无需改客户端 hosts/DNS),80 仍给后台/管理端。 +:80, :8080 { route { handle /api/* { reverse_proxy backend:3000 diff --git a/docker-compose.dev.yml b/docker-compose.dev.yml index 382dd9e7..d52ff921 100644 --- a/docker-compose.dev.yml +++ b/docker-compose.dev.yml @@ -164,6 +164,8 @@ services: ports: - "80:80" - "443:443" + # 局域网联调:打手端用 8080 端口区分(无需改客户端 hosts/DNS) + - "${WORKER_LAN_PORT:-8080}:8080" environment: TZ: ${TZ:-Asia/Shanghai} volumes: