perf(admin): 打手等级筛选用 UNION 形式、概览加短缓存、静态配置接口加缓存头

三处都出自审计清单,改动互不重叠。

1) 打手等级筛选(生产实测 1119ms)
   原写法是对 worker_users 的两个相关 EXISTS(接单人 / 拼单参与人),规划器无法用它
   驱动索引,只能顺序扫完整张 work_orders。改成先把等级解析成打手 id
   (worker_users(level_id) 有索引,等级只有 6 个、解析出几百个 id),再走索引。
   注意:**不能**写成 `assigned_worker_id = ANY(...) OR id IN (...)` —— 实测 1852ms,
   比原写法还慢(OR 右支要逐行回表取 id)。用 UNION 形式:
     wo.id IN (SELECT id FROM work_orders WHERE assigned_worker_id = ANY(...) UNION
               SELECT work_order_id FROM work_order_shares WHERE worker_id = ANY(...) AND status != 'cancelled')
   实测 61ms(count),语义与原来完全一致(含"空数组=过滤掉全部")。
   三个入口 listWorkOrders / countWorkOrders / getWorkOrderStatistics 都先解析;
   未解析时保留原相关 EXISTS 兜底。

2) 平台概览短缓存(5 秒)
   一次并发 5 条 count(4 条打 work_orders,一条是运营态 CASE),而池只有 8 条连接,
   后台刷新时与其他请求互相争抢(抓包显示整批 9 个请求同时发出、它自身 167ms)。
   看板计数滞后几秒无感,用 WorkerListCache 缓存并合并并发。

3) 三个静态配置接口加 HTTP 缓存头(private, max-age=300)
   接单分类、快手行业店铺、自定义店铺此前没有任何 Cache-Control,每次刷新都真发一次
   请求(抓包里 4 个这类请求)。带 private 是因为这些是带鉴权的后台接口。
   **dev-mock/status 故意不加**:它是可切换状态,缓存会让"切了开关界面没变"。

验证:后端 547 项单测全绿(新增 4 项守卫,分别锁住 UNION 形式、三个入口都解析、
概览缓存、以及 dev-mock 不得加缓存)。
This commit is contained in:
yml2213
2026-10-01 15:36:46 +08:00
parent 21cd9135a2
commit 5092e88618
9 changed files with 208 additions and 3 deletions
@@ -2,9 +2,25 @@ import { WORK_ORDER_STATUS } from '../../domain/work-order-status.js'
import { countWorkOrders, listWorkerUsers } from '../../repositories/worker-platform/index.js'
import { refreshStoredFileUrl } from '../file-storage/file-storage-service.js'
import { publishWorkerHallRealtimeRefresh } from '../realtime/realtime-event-service.js'
import { WorkerListCache } from './worker-list-cache.js'
import { getWorkerHallConfig, saveWorkerHallConfig } from './worker-hall-config-service.js'
/**
* 平台概览的短缓存。
*
* 这个接口一次并发 5 条 count(其中 4 条打 work_orders,一条还是运营态 CASE),
* 而数据库连接池只有 8 条 —— 后台刷新页面时会和其他请求挤在一起(生产抓包显示
* 整批 9 个请求同时发出,这个接口自身耗时 167ms 且独占 5 条连接)。
* 它是看板计数,滞后几秒无感,因此按 5 秒缓存并合并并发请求。
*/
const ADMIN_SUMMARY_CACHE_TTL_MS = 5_000
const adminSummaryCache = new WorkerListCache(ADMIN_SUMMARY_CACHE_TTL_MS, 4)
export async function getAdminWorkerPlatformSummary() {
return adminSummaryCache.getOrSet('summary', computeAdminWorkerPlatformSummary)
}
async function computeAdminWorkerPlatformSummary() {
const hallConfig = getWorkerHallConfig()
const [
pendingWorkers,
@@ -2808,3 +2808,44 @@ test('打手端「我的订单」必须走"先取 id 再取详情"的延迟分
)
assert.ok(src.includes('rowsById.get(id)'), 'ANY() 不保证顺序,必须按 id 顺序还原分页结果')
})
test('打手等级筛选必须先把等级解析成打手 id,并用 UNION 形式(不能用 OR + IN 子查询)', 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',
)
assert.ok(
src.includes('resolveWorkerLevelFilterIds'),
'缺少等级→打手 id 的解析:相关 EXISTS 无法走索引,会顺序扫整张 work_orders',
)
assert.ok(
/SELECT id FROM worker_users WHERE level_id = ANY/.test(src),
'解析必须只查 worker_users(level_id)(该列有 idx_worker_users_level)',
)
const branch = src.slice(
src.indexOf('if (workerIds) {'),
src.indexOf('} else if (workerLevelIds.length > 0) {'),
)
assert.ok(branch.includes('UNION'), '必须用 UNION 形式(生产实测 61ms)')
assert.ok(
!/OR\s+wo\.id IN/.test(branch),
'不能写成 `assigned_worker_id = ANY(...) OR id IN (...)`:OR 右支逐行回表,实测 1852ms 反而更慢',
)
// 三个入口都要先解析再建 where
const resolved = src.split('resolveWorkerLevelFilterIds(').length - 1
assert.ok(resolved >= 4, `解析调用点应为 1 处定义 + 3 个入口,实际 ${resolved}`)
})
test('平台概览必须有短缓存(一次并发 5 条 count,池只有 8 条连接)', async () => {
const { readFile } = await import('node:fs/promises')
const src = await readFile(
new URL('./admin-worker-platform-summary-service.ts', import.meta.url),
'utf8',
)
assert.ok(src.includes('new WorkerListCache('), '缺少概览缓存:一次刷新会独占 5 条数据库连接')
assert.ok(
src.includes("adminSummaryCache.getOrSet('summary'"),
'概览必须走 getOrSet 合并并发请求',
)
})