From 1b170d3f455bb315013688fd06c6b30ec3cef505 Mon Sep 17 00:00:00 2001 From: yml2213 Date: Fri, 30 Oct 2026 19:30:56 +0800 Subject: [PATCH] =?UTF-8?q?perf(worker-platform):=20=E5=BA=97=E9=93=BA?= =?UTF-8?q?=E7=AD=9B=E9=80=89=E4=B8=8B=E6=8B=89=E5=8A=A0=205=20=E5=88=86?= =?UTF-8?q?=E9=92=9F=E7=BC=93=E5=AD=98=EF=BC=8C=E9=81=BF=E5=85=8D=E5=85=A8?= =?UTF-8?q?=E8=A1=A8=E6=89=AB=E6=8F=8F=E6=8B=96=E5=9E=AE=E7=94=9F=E4=BA=A7?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../routes/admin/worker-platform/orders.ts | 2 + .../admin-work-order-management-service.ts | 3 + .../admin-work-order-query-service.ts | 10 ++- .../sync-worker-orders-service.ts | 3 + .../work-order-shop-options-cache.ts | 60 +++++++++++++++ .../worker-platform/worker-list-cache.test.ts | 16 ++++ .../worker-platform/worker-list-cache.ts | 7 ++ .../worker-platform-service.test.ts | 77 +++++++++++++++++++ 8 files changed, 176 insertions(+), 2 deletions(-) create mode 100644 apps/backend/src/services/worker-platform/work-order-shop-options-cache.ts diff --git a/apps/backend/src/routes/admin/worker-platform/orders.ts b/apps/backend/src/routes/admin/worker-platform/orders.ts index 53266cb7..51c5aec3 100644 --- a/apps/backend/src/routes/admin/worker-platform/orders.ts +++ b/apps/backend/src/routes/admin/worker-platform/orders.ts @@ -49,9 +49,11 @@ router.get( ) // 「店铺」筛选下拉:固定全量候选,不随列表筛选变化(前端 Select 自带搜索过滤)。 +// 服务端有 5 分钟缓存,这里再给浏览器 30 秒:后台切换页签时不必每次都回源。 router.get( '/worker-platform/orders/shop-options', requireAdminPermission('worker_order.view'), + setHttpCache(30), createJsonHandler(() => listAdminWorkOrderShopNames(), { successMessage: 'ok', errorMessage: '读取店铺筛选选项失败', diff --git a/apps/backend/src/services/worker-platform/admin-work-order-management-service.ts b/apps/backend/src/services/worker-platform/admin-work-order-management-service.ts index 70014207..ed516f58 100644 --- a/apps/backend/src/services/worker-platform/admin-work-order-management-service.ts +++ b/apps/backend/src/services/worker-platform/admin-work-order-management-service.ts @@ -34,6 +34,7 @@ import { } from './mappers.js' import { resolveWorkOrderRealtimeWorkerIds } from './admin-work-order-realtime-service.js' import { getWorkerHallConfig } from './worker-hall-config-service.js' +import { invalidateWorkOrderShopOptionsCache } from './work-order-shop-options-cache.js' import { getRequiredWorkOrder } from './worker-session-context-service.js' import { normalizeWorkOrderTimeoutPolicy } from './work-order-timeout-policy.js' @@ -182,6 +183,8 @@ export async function deleteAdminWorkOrder(workOrderId: number | string) { errorCode: 'work_order_delete_failed', }) } + // 删掉某店铺的最后一张工单后,它不该继续出现在「店铺」筛选候选里。 + invalidateWorkOrderShopOptionsCache() publishWorkOrderRealtimeChange({ workOrderId: Number(workOrder.id), hallChanged: workOrder.status === WORK_ORDER_STATUS.OPEN, diff --git a/apps/backend/src/services/worker-platform/admin-work-order-query-service.ts b/apps/backend/src/services/worker-platform/admin-work-order-query-service.ts index 7a3876b9..4a17333e 100644 --- a/apps/backend/src/services/worker-platform/admin-work-order-query-service.ts +++ b/apps/backend/src/services/worker-platform/admin-work-order-query-service.ts @@ -3,7 +3,6 @@ import { listConsignmentInventoriesByIds, listWorkOrderEventsByOrderId, listWorkOrderSharesByOrderIds, - listWorkOrderShopNames, listWorkOrders, sumPendingUnfreezeByOrderIds, type ConsignmentInventoryRow, @@ -27,6 +26,7 @@ import { normalizeOptionalId, normalizeStatuses, } from './mappers.js' +import { listCachedWorkOrderShopNames } from './work-order-shop-options-cache.js' import { WorkerListCache } from './worker-list-cache.js' import { getRequiredWorkOrder } from './worker-session-context-service.js' @@ -194,7 +194,13 @@ export async function listAdminWorkOrders(query: JsonObject = {}) { * * 固定全量去重(不随状态/日期等筛选变化),下拉里带搜索框由前端过滤即可; * 数据口径见 repository 的 listWorkOrderShopNames(orders.shop_name,精确匹配)。 + * + * 结果走 work-order-shop-options-cache:这条 SQL 每次要并行全表扫 orders 并与 + * work_orders 做半连接(生产实测 1.6 秒 / 200MB 磁盘读),而候选只有十几个、 + * 极少变化,绝不能每次打开页面都重算。 */ export async function listAdminWorkOrderShopNames(): Promise<{ items: string[] }> { - return { items: await listWorkOrderShopNames() } + const items = await listCachedWorkOrderShopNames() + // 返回副本:缓存里的是共享数组,调用方(含序列化前的映射)不该改到它。 + return { items: [...items] } } diff --git a/apps/backend/src/services/worker-platform/sync-worker-orders-service.ts b/apps/backend/src/services/worker-platform/sync-worker-orders-service.ts index 252271df..88911900 100644 --- a/apps/backend/src/services/worker-platform/sync-worker-orders-service.ts +++ b/apps/backend/src/services/worker-platform/sync-worker-orders-service.ts @@ -44,6 +44,7 @@ import { resolveWorkOrderSharingEnabled, } from './mappers.js' import { getWorkerProductMatchConfig } from './worker-product-match-config-service.js' +import { registerWorkOrderShopName } from './work-order-shop-options-cache.js' import { decideOrderItemsFulfillmentModes } from '../fulfillment/fulfillment-mode-service.js' export async function syncWorkerOrdersForSourceOrder( @@ -271,6 +272,8 @@ export async function syncWorkerOrdersForSourceOrder( } created.push(workOrder) + // 店铺第一次产生工单时要能出现在后台「店铺」筛选里;老店铺出单不会动缓存。 + registerWorkOrderShopName(order.shop_name) decisions.push( buildWorkerConsumerDecision(item, { result: 'created', diff --git a/apps/backend/src/services/worker-platform/work-order-shop-options-cache.ts b/apps/backend/src/services/worker-platform/work-order-shop-options-cache.ts new file mode 100644 index 00000000..3c1863de --- /dev/null +++ b/apps/backend/src/services/worker-platform/work-order-shop-options-cache.ts @@ -0,0 +1,60 @@ +import { listWorkOrderShopNames } from '../../repositories/worker-platform/index.js' +import { WorkerListCache } from './worker-list-cache.js' + +/** + * 后台工单「店铺」筛选下拉的缓存归属模块。 + * + * 为什么要缓存:这条 SQL 从 orders(约 43 万行)出发做并行全表扫描,再与 work_orders + * 做哈希半连接,生产实测单次 1.6 秒、读 22~28 万块(≈200MB 磁盘),而结果只有十几个 + * 店铺名,且只在「某个店铺第一次产生工单」时才会变。后台工单页每次挂载、每次改单后的 + * refreshAll 都会重新全表扫一遍,几条并发就能吃掉 4 核里的一到两个核,连接池随即见底 + * (2026-10-30 18:57 的生产事故就是这么放大的)。 + * + * 为什么单独一个文件:缓存实例要同时被「读」(admin-work-order-query-service)和 + * 「写」(建单/删单路径主动失效)引用,抽成只依赖 WorkerListCache 的中立模块, + * 服务层之间不会形成循环依赖。 + * + * 失效策略是「精确」而不是「无脑作废」:高峰期每 10~20 秒就建一张新工单, + * 如果建单就整体失效,等于没缓存。所以记住当前候选集合,只有真的出现新店铺名才作废。 + * TTL 取 5 分钟只是兜底,正常情况下靠 registerWorkOrderShopName 即时对齐。 + */ +const WORK_ORDER_SHOP_OPTIONS_CACHE_TTL_MS = 5 * 60_000 +const workOrderShopOptionsCache = new WorkerListCache(WORK_ORDER_SHOP_OPTIONS_CACHE_TTL_MS, 1) + +/** 全量固定口径只有一份结果,键用常量即可;getOrSet 同时合并并发请求。 */ +const WORK_ORDER_SHOP_OPTIONS_CACHE_KEY = 'worker-platform:order-shop-options' + +/** 当前候选集合的快照,用来判断「这次建单是否引入了新店铺」。 */ +let knownShopNames: Set | null = null + +export function listCachedWorkOrderShopNames(): Promise { + return workOrderShopOptionsCache.getOrSet(WORK_ORDER_SHOP_OPTIONS_CACHE_KEY, async () => { + const items = await listWorkOrderShopNames() + knownShopNames = new Set(items) + return items + }) +} + +/** + * 建单后登记店铺名,返回是否因此作废了缓存。 + * + * 只有「候选里从没出现过的店铺」才需要重算:老店铺出单不影响下拉内容。 + * 缓存尚未建立时直接忽略(下一次读取自然会带上新店铺)。 + */ +export function registerWorkOrderShopName(shopName: string): boolean { + const name = String(shopName || '').trim() + if (!name || !knownShopNames || knownShopNames.has(name)) return false + // 先记账再作废:并发的同店铺建单不会重复触发全表扫描。 + knownShopNames.add(name) + workOrderShopOptionsCache.clear() + return true +} + +/** + * 删单可能让某个店铺彻底不再挂着工单,此时直接作废(删单是低频操作)。 + * 返回逐出的条目数,便于测试与观测。 + */ +export function invalidateWorkOrderShopOptionsCache(): number { + knownShopNames = null + return workOrderShopOptionsCache.clear() +} diff --git a/apps/backend/src/services/worker-platform/worker-list-cache.test.ts b/apps/backend/src/services/worker-platform/worker-list-cache.test.ts index c86024fe..7797599a 100644 --- a/apps/backend/src/services/worker-platform/worker-list-cache.test.ts +++ b/apps/backend/src/services/worker-platform/worker-list-cache.test.ts @@ -24,3 +24,19 @@ test('WorkerListCache coalesces concurrent reads and expires entries', async () assert.deepEqual(await cache.getOrSet('same', factory), { calls: 2 }) assert.equal(calls, 2) }) + +test('WorkerListCache.clear 逐出全部条目(含在途 Promise),并返回逐出数量', async () => { + const cache = new WorkerListCache(60_000) + let calls = 0 + const factory = async () => { + calls += 1 + return calls + } + + assert.equal(await cache.getOrSet('a', factory), 1) + assert.equal(await cache.getOrSet('b', factory), 2) + assert.equal(cache.clear(), 2) + // 清空后同一个键要重新计算,而不是继续命中旧值 + assert.equal(await cache.getOrSet('a', factory), 3) + assert.equal(cache.clear(), 1) +}) diff --git a/apps/backend/src/services/worker-platform/worker-list-cache.ts b/apps/backend/src/services/worker-platform/worker-list-cache.ts index 2bb61583..891d320d 100644 --- a/apps/backend/src/services/worker-platform/worker-list-cache.ts +++ b/apps/backend/src/services/worker-platform/worker-list-cache.ts @@ -45,4 +45,11 @@ export class WorkerListCache { } return evicted } + + /** 逐出全部条目(只有一份结果的配置类缓存用),返回逐出数量。 */ + clear(): number { + const evicted = this.entries.size + this.entries.clear() + return evicted + } } 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 d383f12b..97af762e 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 @@ -2913,6 +2913,83 @@ test('筛选统计必须有短缓存 + 并发合并,避免成本随工单量 } }) +test('后台「店铺」筛选下拉必须缓存 + 写路径失效(单次全表扫约 200MB)', async () => { + const { readFile } = await import('node:fs/promises') + + // 背景:listWorkOrderShopNames 从 orders(43 万行)做并行全表扫描,再与 work_orders + // 做哈希半连接。生产实测单次 1.6 秒、读 22~28 万块(≈200MB),结果却只有十几个店铺名。 + // 2026-10-30 18:57 的事故里,后台工单页每次挂载、每次改单后的 refreshAll 都重算一遍, + // 几条并发就把 4 核打满并拖垮连接池,所以这里必须有缓存 + 主动失效。 + const cacheModule = await readFile( + new URL('./work-order-shop-options-cache.ts', import.meta.url), + 'utf8', + ) + assert.ok(cacheModule.includes('new WorkerListCache('), '缺少店铺下拉缓存实例') + assert.ok( + cacheModule.includes('WORK_ORDER_SHOP_OPTIONS_CACHE_TTL_MS = 5 * 60_000'), + 'TTL 必须拉长到 5 分钟:这份候选只在「新店铺第一次出单」时变化', + ) + assert.ok( + cacheModule.includes('workOrderShopOptionsCache.getOrSet('), + '必须走 getOrSet:多人同时刷新要合并成一次全表扫描', + ) + assert.ok(cacheModule.includes('clear()'), '缺少主动失效入口,只能等 TTL 自然过期') + assert.ok( + cacheModule.includes('registerWorkOrderShopName('), + '缺少建单登记入口:新店铺必须立刻可选中', + ) + assert.ok( + cacheModule.includes('knownShopNames.has(name)'), + '建单必须只对「新店铺」作废缓存:高峰期每 10~20 秒一单,无条件作废等于没缓存', + ) + + const service = await readFile( + new URL('./admin-work-order-query-service.ts', import.meta.url), + 'utf8', + ) + assert.ok( + service.includes('listCachedWorkOrderShopNames()'), + '店铺下拉没走缓存,仍会每次全表扫 orders', + ) + assert.ok( + service.includes('items: [...items]'), + '缓存里的数组必须复制后返回,否则调用方改到共享数组会污染缓存', + ) + + // 浏览器侧也挡住:后台切页签时不必每次都回源(服务端缓存 5 分钟,这里只加 30 秒) + const route = await readFile( + new URL('../../routes/admin/worker-platform/orders.ts', import.meta.url), + 'utf8', + ) + const routeBody = route.slice( + route.indexOf("'/worker-platform/orders/shop-options'"), + route.indexOf("'/worker-platform/orders/mock/custom-shops'"), + ) + assert.ok(routeBody.includes('setHttpCache('), '店铺下拉接口缺少 Cache-Control') + + // 写路径精确失效:建单只在「新店铺」时重算,删单可能让某个店铺的候选消失 + const syncSrc = await readFile( + new URL('./sync-worker-orders-service.ts', import.meta.url), + 'utf8', + ) + assert.ok( + syncSrc.includes('registerWorkOrderShopName(order.shop_name)'), + '建单路径缺少店铺登记:新店铺要等 5 分钟才出现在下拉里', + ) + assert.ok( + !syncSrc.includes('invalidateWorkOrderShopOptionsCache()'), + '建单不能整体作废缓存:老店铺出单同样会触发,等于没缓存', + ) + const manageSrc = await readFile( + new URL('./admin-work-order-management-service.ts', import.meta.url), + 'utf8', + ) + assert.ok( + manageSrc.includes('invalidateWorkOrderShopOptionsCache()'), + '删单路径缺少失效:删掉某店铺最后一张工单后它仍留在下拉里', + ) +}) + test('残留字段 work_orders.task_id 不得再被当成关联键 JOIN(迁移 091)', async () => { const { readFile } = await import('node:fs/promises')