diff --git a/apps/backend/src/services/worker-platform/worker-hall-service.ts b/apps/backend/src/services/worker-platform/worker-hall-service.ts index a78b65cb..cddaa352 100644 --- a/apps/backend/src/services/worker-platform/worker-hall-service.ts +++ b/apps/backend/src/services/worker-platform/worker-hall-service.ts @@ -26,6 +26,15 @@ import { WorkerListCache } from './worker-list-cache.js' const HALL_LIST_CACHE_TTL_MS = 2_000 const hallListCache = new WorkerListCache(HALL_LIST_CACHE_TTL_MS) +// 排行榜每次都要对全量 work_orders + work_order_shares 做去重聚合(生产实测 +// 742ms / 35,483 块 ≈ 277MB),而结果只在工单验收完成时才变化。 +// 大厅每次刷新/窗口聚焦都会触发前端重新拉取,不缓存时该查询独占全库约 8% 的缓冲读。 +// TTL 取 30s:对排行榜而言足够新鲜(名次变化以分钟计),又能把重算频率压掉两个数量级。 +// 注意:结果里包含"当前打手自己的名次"(SQL 里 OR worker_id = $2),缓存键必须带上 +// workerId,否则会把 A 的名次错发给 B。 +const HALL_LEADERBOARD_CACHE_TTL_MS = 30_000 +const hallLeaderboardCache = new WorkerListCache(HALL_LEADERBOARD_CACHE_TTL_MS) + export async function listWorkerHallOrders(query: JsonObject = {}, session: WorkerSession) { requireActiveWorkerSession(session) const page = normalizePage(query.page) @@ -117,11 +126,14 @@ export async function getWorkerHallCategories(session: WorkerSession) { export async function getWorkerHallLeaderboard(session: WorkerSession, period: unknown = 'all') { requireActiveWorkerSession(session) const leaderboardPeriod = period === 'daily' ? 'daily' : 'all' - const entries = await listWorkerCompletionLeaderboard({ - limit: 10, - currentWorkerId: session.workerId, - period: leaderboardPeriod, - }) + const cacheKey = JSON.stringify([leaderboardPeriod, Number(session.workerId)]) + const entries = await hallLeaderboardCache.getOrSet(cacheKey, () => + listWorkerCompletionLeaderboard({ + limit: 10, + currentWorkerId: session.workerId, + period: leaderboardPeriod, + }), + ) const current = entries.find((entry) => entry.workerId === session.workerId) return { items: entries diff --git a/apps/backend/src/services/worker-platform/worker-platform-service.test.ts b/apps/backend/src/services/worker-platform/worker-platform-service.test.ts index 01015891..2ebb2476 100644 --- a/apps/backend/src/services/worker-platform/worker-platform-service.test.ts +++ b/apps/backend/src/services/worker-platform/worker-platform-service.test.ts @@ -2691,7 +2691,7 @@ test('打手端「我的订单」概览必须有覆盖索引 + 短缓存(数 // 短缓存:只缓存读取,会话校验与逾期结算必须每次照常执行 const service = await readFile(new URL('./worker-my-order-service.ts', import.meta.url), 'utf8') assert.ok( - service.includes('myOrderOverviewCache.getOrSet(String(worker.id)'), + service.includes('myOrderOverviewCache.getOrSet('), '概览缺少短缓存:调用量 0.5 次/秒,每次都要重算', ) const overviewBody = service.slice( @@ -2707,6 +2707,49 @@ test('打手端「我的订单」概览必须有覆盖索引 + 短缓存(数 ) }) +test('「我的订单」缓存必须事件驱动失效(长 TTL 的安全前提)', async () => { + const { readFile } = await import('node:fs/promises') + + // 缓存实例独立成中立模块:realtime 要引用失效入口,而 my-order-service 已 import realtime, + // 实例放任何一方都会形成循环依赖。 + const cacheModule = await readFile(new URL('./worker-my-order-cache.ts', import.meta.url), 'utf8') + assert.ok(cacheModule.includes('invalidateWorkerMyOrderCaches'), '缺少按打手批量失效的入口') + assert.ok( + cacheModule.includes('evictByPrefix'), + '失效必须能按键前缀整体逐出,否则只能等 TTL 自然过期', + ) + // 键必须带统一前缀 ["w",workerId,...],前缀失效才不会误伤其它打手 + assert.ok( + cacheModule.includes(`['w', Number(workerId) || 0, scope, detail]`), + '缓存键必须以打手 id 为前缀,事件失效才能按打手整体逐出', + ) + + // 失效必须挂在实时快照广播里:那里恰好已有工单 + 全部拼单人,受影响集合最完整, + // 且 21 处工单写入路径(含取消申请/反馈处理)都会经过。 + const realtime = await readFile( + new URL('../realtime/realtime-event-service.ts', import.meta.url), + 'utf8', + ) + assert.ok( + realtime.includes('invalidateWorkerMyOrderCaches(['), + '实时快照广播必须触发「我的订单」缓存失效', + ) + const snapshotBody = realtime.slice( + realtime.indexOf('async function publishWorkOrderSnapshots'), + realtime.indexOf('async function publishWorkerFinanceSnapshots'), + ) + for (const field of [ + 'workOrder.assigned_worker_id', + 'workOrder.last_assigned_worker_id', + 'shares.map((share) => share.worker_id)', + ]) { + assert.ok( + snapshotBody.includes(field), + `受影响打手集合缺少 ${field}:漏掉会导致该打手最长 30s 看到旧数据`, + ) + } +}) + test('按运营态筛选的计数必须有覆盖索引(生产实测每次读 248MB)', async () => { const { readFile } = await import('node:fs/promises') const migration = await readFile( @@ -2895,3 +2938,30 @@ test('平台概览必须有短缓存(一次并发 5 条 count,池只有 8 '概览必须走 getOrSet 合并并发请求', ) }) + +test('接单排行榜必须有短缓存(单次全表聚合 277MB/742ms,独占全库约 8% 缓冲读)', async () => { + const { readFile } = await import('node:fs/promises') + const src = await readFile(new URL('./worker-hall-service.ts', import.meta.url), 'utf8') + + assert.ok( + src.includes('new WorkerListCache(HALL_LEADERBOARD_CACHE_TTL_MS)'), + '排行榜缺少短缓存:大厅每次刷新/聚焦都会重新对全量工单做去重聚合', + ) + assert.ok( + src.includes('hallLeaderboardCache.getOrSet('), + '排行榜必须走 getOrSet(同时合并并发请求)', + ) + + // 结果包含"当前打手自己的名次"(SQL 里 OR worker_id = $2), + // 缓存键漏了 workerId 会把 A 的名次错发给 B —— 这是正确性问题,不是性能问题。 + const leaderboardBody = src.slice( + src.indexOf('export async function getWorkerHallLeaderboard'), + src.indexOf('export function maskWorkerLeaderboardDisplayName'), + ) + assert.ok( + leaderboardBody.includes('session.workerId') && + /cacheKey/.test(leaderboardBody) && + leaderboardBody.includes('JSON.stringify'), + '排行榜缓存键必须包含 workerId 与 period', + ) +})