From 5092e88618813fb35e131b9419a0adb6687e1bda Mon Sep 17 00:00:00 2001 From: yml2213 Date: Thu, 1 Oct 2026 15:36:46 +0800 Subject: [PATCH] =?UTF-8?q?perf(admin):=20=E6=89=93=E6=89=8B=E7=AD=89?= =?UTF-8?q?=E7=BA=A7=E7=AD=9B=E9=80=89=E7=94=A8=20UNION=20=E5=BD=A2?= =?UTF-8?q?=E5=BC=8F=E3=80=81=E6=A6=82=E8=A7=88=E5=8A=A0=E7=9F=AD=E7=BC=93?= =?UTF-8?q?=E5=AD=98=E3=80=81=E9=9D=99=E6=80=81=E9=85=8D=E7=BD=AE=E6=8E=A5?= =?UTF-8?q?=E5=8F=A3=E5=8A=A0=E7=BC=93=E5=AD=98=E5=A4=B4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 三处都出自审计清单,改动互不重叠。 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 不得加缓存)。 --- .../backend/src/middleware/http-cache.test.ts | 53 +++++++++++++++ apps/backend/src/middleware/http-cache.ts | 19 ++++++ .../src/repositories/worker-platform/types.ts | 7 ++ .../worker-platform/work-order-query-repo.ts | 68 ++++++++++++++++++- .../src/routes/admin/kuaishou-industry.ts | 2 + .../routes/admin/worker-platform/config.ts | 3 + .../routes/admin/worker-platform/orders.ts | 2 + .../admin-worker-platform-summary-service.ts | 16 +++++ .../worker-platform-service.test.ts | 41 +++++++++++ 9 files changed, 208 insertions(+), 3 deletions(-) create mode 100644 apps/backend/src/middleware/http-cache.test.ts create mode 100644 apps/backend/src/middleware/http-cache.ts diff --git a/apps/backend/src/middleware/http-cache.test.ts b/apps/backend/src/middleware/http-cache.test.ts new file mode 100644 index 00000000..0477e622 --- /dev/null +++ b/apps/backend/src/middleware/http-cache.test.ts @@ -0,0 +1,53 @@ +import assert from 'node:assert/strict' +import { readFile } from 'node:fs/promises' +import test from 'node:test' + +import { setHttpCache } from './http-cache.js' + +test('setHttpCache 必须带 private(后台接口带鉴权,禁止共享缓存留存响应)', () => { + const headers: Record = {} + const res = { + setHeader: (name: string, value: string) => { + headers[name] = value + }, + } + let called = false + setHttpCache(300)( + {} as never, + res as never, + (() => { + called = true + }) as never, + ) + assert.equal(called, true, '必须把请求放行到下一个处理器') + assert.equal(headers['Cache-Control'], 'private, max-age=300') +}) + +test('静态配置接口必须挂上 HTTP 缓存头', async () => { + const cases: Array<[string, string]> = [ + ['routes/admin/worker-platform/config.ts', "router.get(\n '/worker-platform/categories',"], + ['routes/admin/kuaishou-industry.ts', "router.get(\n '/kuaishou-industry/shops',"], + [ + 'routes/admin/worker-platform/orders.ts', + "router.get(\n '/worker-platform/orders/mock/custom-shops',", + ], + ] + for (const [file, anchor] of cases) { + const src = await readFile(new URL('../' + file, import.meta.url), 'utf8') + assert.ok(src.includes('setHttpCache('), `${file} 未挂缓存头:后台刷新会重复拉取这些静态配置`) + const routeBlock = src.slice(src.indexOf(anchor), src.indexOf(anchor) + 320) + assert.ok(routeBlock.includes('setHttpCache('), `${file} 的缓存头没挂在该路由上`) + } +}) + +test('dev-mock/status 不能加 HTTP 缓存(它是可切换状态,缓存会让开关看起来失效)', async () => { + const src = await readFile(new URL('../routes/admin/dev-mock.ts', import.meta.url), 'utf8') + const block = src.slice( + src.indexOf("'/dev-mock/status'"), + src.indexOf("'/dev-mock/status'") + 320, + ) + assert.ok( + !block.includes('setHttpCache('), + 'dev-mock/status 缓存后,POST 切换开关再读会拿到旧值,界面看起来没生效', + ) +}) diff --git a/apps/backend/src/middleware/http-cache.ts b/apps/backend/src/middleware/http-cache.ts new file mode 100644 index 00000000..ca3b4816 --- /dev/null +++ b/apps/backend/src/middleware/http-cache.ts @@ -0,0 +1,19 @@ +import type { NextFunction, Request, Response } from 'express' + +/** + * 给「几乎不变」的配置类接口加 HTTP 缓存头。 + * + * 背景:后台刷新页面时前端会重新拉一遍这些静态配置(接单分类、快手行业店铺、 + * 自定义店铺、dev-mock 状态)。它们此前没有任何 Cache-Control,于是每次刷新都真的 + * 走一次网络 —— 生产抓包显示一次刷新里有 4 个这类请求,且都和业务查询挤在同一个 + * 连接池窗口内。 + * + * 必须带 `private`:这些是带鉴权的后台接口,禁止任何共享/中间缓存留存响应。 + */ +export function setHttpCache(maxAgeSeconds: number) { + const headerValue = `private, max-age=${Math.max(0, Math.floor(maxAgeSeconds))}` + return (_req: Request, res: Response, next: NextFunction): void => { + res.setHeader('Cache-Control', headerValue) + next() + } +} diff --git a/apps/backend/src/repositories/worker-platform/types.ts b/apps/backend/src/repositories/worker-platform/types.ts index 190da7cf..9440e152 100644 --- a/apps/backend/src/repositories/worker-platform/types.ts +++ b/apps/backend/src/repositories/worker-platform/types.ts @@ -457,6 +457,13 @@ export type ListInput = { levelId?: number /** 后台待验收筛选:普通单按接单人,拼单按参与打手等级。 */ workerLevelIds?: number[] + /** + * 已解析的打手 id(由 workerLevelIds 解析而来)。 + * 等级筛选若直接写成型别 EXISTS,规划器无法索引 → 全表扫 work_orders; + * 先解析成 id 就能走 `assigned_worker_id = ANY(ids)` 与拼单表 worker_id 索引。 + * 注意语义:**空数组表示"过滤掉全部"**,必须与"未提供"(undefined)区分。 + */ + workerIds?: number[] categoryId?: number /** 后台打手列表按提现黑名单筛选。 */ isWithdrawalBlacklisted?: boolean | null | undefined 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 2e5be9d3..833a5d43 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 @@ -259,12 +259,14 @@ export async function listWorkOrders({ createdTo = '', }: ListInput = {}): Promise<{ items: WorkOrderRow[]; total: number }> { const effectiveStatuses = statuses.length > 0 ? statuses : status.trim() ? [status.trim()] : [] + const resolvedWorkerIds = await resolveWorkerLevelFilterIds(workerLevelIds) const { joins, whereClause, params } = buildWorkOrderWhere({ statuses: effectiveStatuses, workOrderId, keyword, workerId, workerLevelIds, + ...(resolvedWorkerIds ? { workerIds: resolvedWorkerIds } : {}), categoryId, workerSharingId, hallCandidateLimit, @@ -346,7 +348,11 @@ export async function listWorkOrders({ /** 只返回筛选范围总数,不加载工单 JSON、取消事件和当前页关联字段。 */ export async function countWorkOrders(input: ListInput = {}): Promise { - const { joins, whereClause, params } = buildWorkOrderWhere(input) + const resolvedWorkerIds = await resolveWorkerLevelFilterIds(input.workerLevelIds) + const { joins, whereClause, params } = buildWorkOrderWhere({ + ...input, + ...(resolvedWorkerIds ? { workerIds: resolvedWorkerIds } : {}), + }) return countWorkOrdersWithWhere(joins, whereClause, params) } @@ -380,7 +386,11 @@ const WORK_ORDER_PAYMENT_AMOUNT_SQL = ` ` export async function getWorkOrderStatistics(input: ListInput = {}): Promise { - const { joins, whereClause, params } = buildWorkOrderWhere(input) + const resolvedWorkerIds = await resolveWorkerLevelFilterIds(input.workerLevelIds) + const { joins, whereClause, params } = buildWorkOrderWhere({ + ...input, + ...(resolvedWorkerIds ? { workerIds: resolvedWorkerIds } : {}), + }) // 仅勾「VIP 待补图」时才拼进 SQL。087 的覆盖索引故意不含 acceptance_json: // 这个筛选项会让统计回表变慢,是有意降级。不要把 acceptance_json 补进索引, // 否则默认统计(不勾这个筛选)会退回宽表堆访问。 @@ -744,12 +754,46 @@ export async function listWorkOrdersByIds(ids: number[]): Promise { + const levels = Array.isArray(workerLevelIds) + ? [ + ...new Set( + workerLevelIds + .map((level) => Number(level)) + .filter((level) => Number.isFinite(level) && level > 0), + ), + ] + : [] + if (levels.length === 0) return null + + const result = await query<{ id: number }>( + 'SELECT id FROM worker_users WHERE level_id = ANY($1::bigint[])', + [levels], + ) + return result.rows.map((row) => Number(row.id)).filter((id) => id > 0) +} + export function buildWorkOrderWhere({ statuses = [], workOrderId = 0, keyword = '', workerId = 0, workerLevelIds = [], + workerIds, categoryId = 0, workerSharingId = 0, hallCandidateLimit = 0, @@ -916,7 +960,25 @@ export function buildWorkOrderWhere({ ) )`) } - if (workerLevelIds.length > 0) { + if (workerIds) { + // 已解析成打手 id。这里必须用「id 子集 + IN」而不是 + // `assigned_worker_id = ANY(...) OR id IN (...)`:后者让 OR 的右支逐行回表取 id, + // 生产实测 1852ms,比原来的相关 EXISTS(555ms)还慢;UNION 形式分别走 + // idx_work_orders_worker_status 与拼单表 worker_id 索引,实测 61ms。 + // 空数组要走到这里(表示"过滤掉全部"),所以判断的是 null/undefined 而不是 length。 + params.push(workerIds) + filters.push(`wo.id IN ( + SELECT level_assigned.id + FROM work_orders level_assigned + WHERE level_assigned.assigned_worker_id = ANY($${params.length}::bigint[]) + UNION + SELECT level_share.work_order_id + FROM work_order_shares level_share + WHERE level_share.worker_id = ANY($${params.length}::bigint[]) + AND level_share.status != 'cancelled' + )`) + } else if (workerLevelIds.length > 0) { + // 兜底:调用方没有预先解析等级时仍保留原语义(相关 EXISTS,无法走索引)。 params.push(workerLevelIds) filters.push(`( EXISTS ( diff --git a/apps/backend/src/routes/admin/kuaishou-industry.ts b/apps/backend/src/routes/admin/kuaishou-industry.ts index a0cfa648..cbdf238b 100644 --- a/apps/backend/src/routes/admin/kuaishou-industry.ts +++ b/apps/backend/src/routes/admin/kuaishou-industry.ts @@ -1,4 +1,5 @@ import { Router } from 'express' +import { setHttpCache } from '../../middleware/http-cache.js' import { approveAdminKuaishouIndustryRefund, checkAdminKuaishouIndustryVoucherAvailable, @@ -41,6 +42,7 @@ router.get( router.get( '/kuaishou-industry/shops', requireAdminRoles(['admin', 'operator', 'support']), + setHttpCache(300), createJsonHandler(() => listAdminKuaishouIndustryShops(), { successMessage: 'ok', errorMessage: '查询快手行业店铺失败', diff --git a/apps/backend/src/routes/admin/worker-platform/config.ts b/apps/backend/src/routes/admin/worker-platform/config.ts index 02a6c6f4..6dff4e4c 100644 --- a/apps/backend/src/routes/admin/worker-platform/config.ts +++ b/apps/backend/src/routes/admin/worker-platform/config.ts @@ -1,4 +1,5 @@ import { Router } from 'express' +import { setHttpCache } from '../../../middleware/http-cache.js' import { deleteAdminWorkCategory, @@ -89,6 +90,8 @@ router.delete( router.get( '/worker-platform/categories', requireAdminRoles(['admin', 'operator', 'support']), + // 分类是低频配置:给浏览器 5 分钟本地缓存,避免每次刷新都发一次请求 + setHttpCache(300), createJsonHandler(() => listAdminWorkCategories(), { successMessage: 'ok', errorMessage: '读取接单分类失败', diff --git a/apps/backend/src/routes/admin/worker-platform/orders.ts b/apps/backend/src/routes/admin/worker-platform/orders.ts index 26f47107..f11f2b9f 100644 --- a/apps/backend/src/routes/admin/worker-platform/orders.ts +++ b/apps/backend/src/routes/admin/worker-platform/orders.ts @@ -1,4 +1,5 @@ import { Router } from 'express' +import { setHttpCache } from '../../../middleware/http-cache.js' import { acceptAdminWorkOrders, @@ -49,6 +50,7 @@ router.get( router.get( '/worker-platform/orders/mock/custom-shops', requireAdminRoles(['admin', 'operator', 'support']), + setHttpCache(300), createJsonHandler(() => ({ items: listAdminMockCustomShops() }), { successMessage: 'ok', errorMessage: '读取自定义店铺失败', diff --git a/apps/backend/src/services/worker-platform/admin-worker-platform-summary-service.ts b/apps/backend/src/services/worker-platform/admin-worker-platform-summary-service.ts index c9f93545..179eb1da 100644 --- a/apps/backend/src/services/worker-platform/admin-worker-platform-summary-service.ts +++ b/apps/backend/src/services/worker-platform/admin-worker-platform-summary-service.ts @@ -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, 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 ae70f176..c5ed1bf8 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 @@ -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 合并并发请求', + ) +})