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:
@@ -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<string, string> = {}
|
||||
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 切换开关再读会拿到旧值,界面看起来没生效',
|
||||
)
|
||||
})
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user