Просмотр исходного кода

perf(admin): replace repeated course and cancellation scans with set joins

彭峰 15 часов назад
Родитель
Сommit
0c9aa557c5

+ 10 - 0
perf/admin-data-2026-10-15/BRIEF.md

@@ -0,0 +1,10 @@
+# 后台业务数据加载提速
+
+- 用户确认范围:打开页面和切换菜单后,列表及统计数据出来慢;不以文件导出为主。
+- 基线提交:2dd872a。覆盖全部运营菜单的首次打开与返回;测量后选择最慢的关键路径。
+- 可用终点:业务响应成功、真实列表或空态和统计已经渲染、筛选控件可以操作。页面标题、骨架或缓存空壳不计为首次加载完成。
+- 先进行真实授权接口/页面调查,拆分服务端等待、响应下载、代码资源与浏览器渲染;小样本只用于定位。最终针对选定路径交替配对至少 10 轮,报告 p50/p75/p95,并保留实际请求数和字节数。
+- 目标:优先争取主要慢路径 p75 减半,以实测决定保留;不减少数据、不伪造统计、不改变权限或业务含义。
+- 护栏:账号/租户隔离、数据字段与行集合等价、写入后新鲜度、筛选分页、错误恢复、两种宽度界面一致。
+- 保留已有布局及功能;不修改 DNS/CDN/服务器配置。先完成可复核的优化与验证;生产部署单独确认适用的授权。
+- 无公开匿名后台地址或 CrUX 数据,不能把本机实验数字当作真实用户群体统计。

+ 4 - 0
perf/admin-data-2026-10-15/legacy-read-queries.json

@@ -0,0 +1,4 @@
+{
+  "courses": "WITH RECURSIVE courses AS (SELECT DISTINCT n.\"company\",n.\"nodeId\",n.\"parentId\",n.\"nodeName\" FROM \"Node\" n WHERE ($1::text IS NULL OR n.\"company\"=$1) AND (CAST(n.\"nodeId\" AS text)='398' OR EXISTS(SELECT 1 FROM \"CourseBinding\" b WHERE b.\"company\"=n.\"company\" AND CAST(b.\"kcid\" AS text)=CAST(n.\"nodeId\" AS text)) OR EXISTS(SELECT 1 FROM \"CourseAppointment\" a WHERE a.\"company\"=n.\"company\" AND CAST(a.\"kcid\" AS text)=CAST(n.\"nodeId\" AS text)))), ancestors AS (SELECT c.\"company\",c.\"nodeId\" AS course_id,c.\"nodeId\",c.\"parentId\",c.\"nodeName\",0 AS depth FROM courses c UNION ALL SELECT a.\"company\",a.course_id,p.\"nodeId\",p.\"parentId\",p.\"nodeName\",a.depth+1 FROM ancestors a JOIN \"Node\" p ON p.\"company\"=a.\"company\" AND CAST(p.\"nodeId\" AS text)=CAST(a.\"parentId\" AS text) WHERE a.depth<12) SELECT CAST(c.\"nodeId\" AS text) AS id,c.\"nodeName\" AS name,COALESCE(ARRAY_AGG(a.\"nodeName\" ORDER BY a.depth) FILTER(WHERE COALESCE(a.\"nodeName\",'')<>''),ARRAY[]::text[]) AS ancestor_names FROM courses c LEFT JOIN ancestors a ON a.\"company\"=c.\"company\" AND a.course_id=c.\"nodeId\" GROUP BY c.\"nodeId\",c.\"nodeName\" ORDER BY c.\"nodeName\" LIMIT 2000",
+  "appointments": "SELECT a.\"szyh\"::text AS uid,COUNT(*)::int AS appointment_count,COUNT(*) FILTER(WHERE active AND COALESCE(a.\"dszt\",'0')::text IN ('0','10','11','20'))::int AS unfinished,COUNT(*) FILTER(WHERE active AND a.\"dslx\"::text='3')::int AS trial_count,MIN(CASE WHEN active AND COALESCE(a.\"dszt\",'0')::text='0' AND COALESCE(a.\"yysj\",a.\"bxrq\")>=to_char(CURRENT_DATE,'YYYY-MM-DD') THEN COALESCE(a.\"yysj\",a.\"bxrq\") END) AS next_at,COUNT(*) FILTER(WHERE active AND COALESCE(a.\"dszt\",'0')::text NOT IN ('0','10','11','20','30','40'))::int AS abnormal FROM (SELECT source.*,NOT EXISTS(SELECT 1 FROM \"CommonModel\" c WHERE c.\"company\"=source.\"company\" AND c.\"modelId\"::text='54' AND c.\"itemId\"::text=source.\"id\"::text AND c.\"status\"::text='-2') AS active FROM \"CourseAppointment\" source WHERE ($1::text IS NULL OR source.\"company\"=$1)) a GROUP BY a.\"szyh\"::text"
+}

Разница между файлами не показана из-за своего большого размера
+ 2 - 1
scripts/deploy-admin-functions.mjs


+ 33 - 0
scripts/tests/admin-read-performance.test.mjs

@@ -0,0 +1,33 @@
+import {test,after} from 'node:test';
+import assert from 'node:assert/strict';
+import {readFileSync} from 'node:fs';
+import vm from 'node:vm';
+import {PGlite} from '@electric-sql/pglite';
+const source=readFileSync(new URL('../deploy-admin-functions.mjs',import.meta.url),'utf8');
+const baseline=JSON.parse(readFileSync(new URL('../../perf/admin-data-2026-10-15/legacy-read-queries.json',import.meta.url),'utf8'));
+const db=new PGlite(),query=async(sql,args)=>(await db.query(sql,args)).rows;
+after(()=>db.close());
+const ctx=vm.createContext({Psql:{query},companyIdOf:()=> 'a'});
+vm.runInContext(source.match(/const operationsGatewayCode = String.raw`([\s\S]*?)\n`;/)[1],ctx);
+const sorted=rows=>rows.toSorted((a,b)=>JSON.stringify(a).localeCompare(JSON.stringify(b)));
+test('set-based course and appointment reads preserve tenant, cancellation, duplicates and missing metadata',async()=>{
+ await db.exec(`CREATE TABLE "CommonModel"(company text,"modelId" numeric,"itemId" numeric,status numeric);
+ CREATE TABLE "CourseAppointment"(company text,id numeric,szyh text,dszt text,dslx text,yysj text,bxrq text,kcid text);
+ CREATE TABLE "CourseBinding"(company text,kcid text);
+ CREATE TABLE "Node"(company text,"nodeId" numeric,"parentId" numeric,"nodeName" text);
+ INSERT INTO "Node" VALUES ('a',1,0,'小学'),('a',2,1,'课程甲'),('a',3,1,'课程乙'),('a',398,1,'写作'),('a',8,1,'未使用'),('b',2,0,'其他帐套课程');
+ INSERT INTO "CourseBinding" VALUES('a','2'),('a','2'),('a','bad'),('b','8');
+ INSERT INTO "CourseAppointment" VALUES('a',1,'s1','0','3','2099-01-02',null,'3'),('a',2,'s1','30','1','2026-10-01',null,'3'),('a',3,'s2','11','3',null,'2099-01-03','3'),('a',4,'s2','99','1',null,null,null),('a',5,null,null,null,null,null,null),('b',1,'s1','20','3',null,null,'2');
+ INSERT INTO "CommonModel" VALUES('a',54,1,99),('a',54,1,-2),('a',54,1,-2),('a',54,2,99),('a',59,3,-2),('b',54,3,-2),('b',54,1,99);`);
+ const courseSql=vm.runInNewContext(source.match(/courseSql=('[\s\S]*?'),\[categories/)[1]);
+ for(const co of ['a','b',null]){
+  assert.deepEqual(sorted(await ctx.memberAppointmentStats(co)),sorted(await query(baseline.appointments,[co])));
+  assert.deepEqual(await query(courseSql,[co]),await query(baseline.courses,[co]));
+ }
+ const members=await ctx.memberAppointmentStats('a');
+ assert.equal(members.find(r=>r.uid==='s1').appointment_count,2);
+ assert.equal(members.find(r=>r.uid==='s1').unfinished,0);
+ assert.equal(members.find(r=>r.uid==='s2').unfinished,1);
+ assert.equal(members.find(r=>r.uid==='s2').abnormal,1);
+ assert.deepEqual((await query(courseSql,['a'])).map(r=>r.id).sort(),['2','3','398']);
+});

Некоторые файлы не были показаны из-за большого количества измененных файлов