Parcourir la source

perf(admin): reduce module loading work and progressively verify payroll

彭峰 il y a 1 heure
Parent
commit
7b8eabab05
32 fichiers modifiés avec 824 ajouts et 37 suppressions
  1. 14 0
      perf/admin-speed-2026-11-10/BRIEF.md
  2. 21 0
      perf/admin-speed-2026-11-10/README.md
  3. 76 0
      perf/admin-speed-2026-11-10/browser-case.mjs
  4. 253 0
      perf/admin-speed-2026-11-10/browser-summary.json
  5. 72 0
      perf/admin-speed-2026-11-10/lesson-history-baseline.js
  6. 35 0
      perf/admin-speed-2026-11-10/paired-api.mjs
  7. 12 0
      perf/admin-speed-2026-11-10/ratchet.mjs
  8. 15 0
      perf/admin-speed-2026-11-10/summarize.mjs
  9. 42 0
      perf/admin-speed-2026-11-10/verify-live.mjs
  10. 23 0
      perf/admin-speed-2026-11-10/基线.md
  11. 12 0
      perf/admin-speed-2026-11-10/实验日志.md
  12. 43 0
      perf/admin-speed-2026-11-10/报告.md
  13. 2 2
      projects/xiaoshu-admin/src/app/admin.routes.ts
  14. 10 0
      projects/xiaoshu-admin/src/app/operations-route-reuse.strategy.spec.ts
  15. 7 3
      projects/xiaoshu-admin/src/app/operations-route-reuse.strategy.ts
  16. 2 2
      projects/xiaoshu-admin/src/app/operations.service.ts
  17. 7 0
      projects/xiaoshu-admin/src/app/pages/lesson-records.component.spec.ts
  18. 6 3
      projects/xiaoshu-admin/src/app/pages/lesson-records.component.ts
  19. 3 3
      projects/xiaoshu-admin/src/app/pages/operations-payroll.component.html
  20. 32 0
      projects/xiaoshu-admin/src/app/pages/operations-payroll.component.spec.ts
  21. 0 0
      projects/xiaoshu-admin/src/app/pages/operations-payroll.component.ts
  22. 0 0
      projects/xiaoshu-admin/src/app/pages/operations-special-training.component.html
  23. 8 0
      projects/xiaoshu-admin/src/app/pages/support-requests.component.spec.ts
  24. 9 5
      projects/xiaoshu-admin/src/app/pages/support-requests.component.ts
  25. 13 0
      scripts/admin-speed-patch.mjs
  26. 16 12
      scripts/cloud/lesson-records.js
  27. 5 3
      scripts/cloud/payroll-integrity.js
  28. 3 3
      scripts/deploy-admin-functions.mjs
  29. 31 0
      scripts/release-admin-speed.mjs
  30. 1 1
      scripts/tests/admin-read-performance.test.mjs
  31. 42 0
      scripts/tests/admin-speed.test.mjs
  32. 9 0
      scripts/tests/lesson-records.test.mjs

+ 14 - 0
perf/admin-speed-2026-11-10/BRIEF.md

@@ -0,0 +1,14 @@
+# 管理端第二轮加载优化 · 2026-11-10
+
+范围为截图 11 个入口:工作台、会员、门店、排课、陪练老师、费用结算、训练记录、学习报表、反馈与休假、词库、专项训练(首次统计误称 10 个,以此清单为准)。
+
+可用终点为主要业务请求成功、数据/统计实际渲染、筛选可操作;不把标题/骨架屏当完成。目标争取主要慢页面 p75 降半,继续消除有证据的重复开销。保持布局、筛选、财务计算、权限、来源完整性和导出一致;不手动设置 settlementReady。主测同机交替 10 轮,区分服务端/API、冷启动与菜单返回,真实账号数据只写 tmp 私有目录。
+
+方案:先真实线上只读调查及服务端分段计时;建立日期/数据等价、权限和财务护栏;逐项优化热点、独立读取串行依赖、路由重复加载;生产构建、单测、真实数据 A/B、视觉检查及回滚材料。Git 按 AGENTS.md 提交推送;用户已明确授权“优化完了可以上线一版”,本批验证通过后直接发布。
+
+## 交付与复测约束
+
+- 原接口读模式 displayOnly=true 仅用于金额展示,永远不返回已核验结算权限;旧接口默认调用不变。
+- 浏览器主测 11 页各 10 对;门店精简查询和专项图片延迟加载在主测后追加同条件各 10 对,最终汇总只取最终实现的数据。
+- 汇总剔除刷新时间、延迟秒数、缓存命中等时钟字段;陪练页只剔除它不再使用的结算核验状态(sync/complete/stale),保留金额、人数、列表、归属与其他字段逐项比较。费用页比较完整核验返回后的最终数据,另检查首批真实金额展示和按钮禁用。
+- 发布顺序:先验证并发布 20 个读取函数及两个标签查询 helper,再上传管理端静态资源、切换入口。保留原版哈希资源与云函数原件;任何线上版本漂移均阻止整包覆盖。

+ 21 - 0
perf/admin-speed-2026-11-10/README.md

@@ -0,0 +1,21 @@
+# 复测说明
+
+需要现有授权运营会话和主密钥(环境变量 XIAOSHU_MASTER_KEY);密钥不进入仓库。原网关和生产构建位于本机忽略目录 tmp/admin-speed-2026-11-10,最终候选使用当前源码精确替换读取函数,不覆盖其他线上代码。
+
+```sh
+# 先运行相关功能护栏和生产构建,再做配对实验;不要同时运行重负载测试。
+node --test scripts/tests/admin-speed.test.mjs scripts/tests/admin-read-performance.test.mjs scripts/tests/payroll-integrity.test.mjs scripts/tests/lesson-records.test.mjs
+npm run test:admin:ci
+npm run build:admin
+npm run cloud:validate
+PERF_BROWSER=1 PERF_RUNS=10 PERF_OUTPUT=browser-paired-final.json node perf/admin-speed-2026-11-10/paired-api.mjs
+PERF_BROWSER=1 PERF_RUNS=10 PERF_PAGES=stores,special-training PERF_OUTPUT=browser-paired-supplemental.json node perf/admin-speed-2026-11-10/paired-api.mjs
+node perf/admin-speed-2026-11-10/summarize.mjs
+node perf/admin-speed-2026-11-10/ratchet.mjs
+```
+
+浏览器代码与真实接口分别经过统一的本地 gzip 代理,浏览器 CDP 对所有本地/远程请求施加 20 ms 往返、4 Mbps 下行、3 Mbps 上行,每次新上下文。实验室到后端还经过当前机器真实网络,故不能称为线上真实用户 p75。旧新版本每页交替、每轮反转顺序;不只测标题,不把骨架屏当可用。
+
+临时网关仅指定运营会话可调、限读操作、有一小时到期检查、禁用缓存写入,正常/异常退出的 finally 会删除。被强制终止进程时,应由操作者检查并删除创建的过期诊断 Function 记录,不能删除不属于本次的记录。脚本不会发布正式业务网关。
+
+截图及原始 JSON 含业务数据,仅存在 tmp;公开 browser-summary.json 只含页面名、耗时、请求数和数据是否一致。时间门禁须在同环境运行;正常 CI 依赖金额/数据等价、月份竞态、会话隔离与结算权限等确定性用例。

+ 76 - 0
perf/admin-speed-2026-11-10/browser-case.mjs

@@ -0,0 +1,76 @@
+import {chromium} from 'playwright';
+import {createServer} from 'node:http';
+import fs from 'node:fs';
+import {resolve,extname} from 'node:path';
+import {gzipSync} from 'node:zlib';
+import assert from 'node:assert/strict';
+export async function browserHarness({functions,token,me,base,out}){
+ const servers=[],origins={},requests={};
+ for(const fn of functions){
+  const root=resolve(fn.variant==='before'?out+'/baseline-browser':'dist/xiaoshu-admin/browser');
+  const server=createServer(async(req,res)=>{
+   const path=new URL(req.url,'http://local').pathname;
+   try{
+    let body,contentType='application/json';
+    if(path.startsWith('/cloud-functions/')){
+     let raw='';for await(const chunk of req)raw+=chunk;const input=JSON.parse(raw),op=input.params?.operation;
+     if(op==='ops/coaches/list')input.params.refresh=true;
+     const primary=['ops/dashboard/summary','ops/users/list','ops/stores/list','ops/schedule/calendar','ops/coaches/list','ops/payroll/overview','ops/lessons/list','ops/learning-reports/list','ops/support-requests/list','ops/vocabulary/tree','ops/vocabulary/list','ops/special-training/list'].includes(op);
+     // This harness cannot send business mutations, even if a test accidentally clicks one.
+     if(!primary&&!['ops/live/versions','ops/payroll/list','ops/pay-rates/list','ops/teaching-rules/list','ops/payroll/coaches'].includes(op)){res.writeHead(403);res.end('{}');return;}
+     const r=await fetch(base+'/api/functions/'+(primary?fn.path:path.slice('/cloud-functions/'.length)),{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(input),signal:AbortSignal.timeout(60000)});
+     body=Buffer.from(await r.arrayBuffer());res.statusCode=r.status;
+    }else{
+     let file=resolve(root,'.'+decodeURIComponent(path));if(!file.startsWith(root+'/')||!fs.existsSync(file)||!extname(file))file=root+'/index.html';
+     body=fs.readFileSync(file);contentType=({'.html':'text/html','.js':'text/javascript','.css':'text/css','.svg':'image/svg+xml','.png':'image/png','.json':'application/json'})[extname(file)]||'application/octet-stream';
+    }
+    res.setHeader('Content-Type',contentType);res.setHeader('Content-Encoding','gzip');res.setHeader('Cache-Control','no-store');res.end(gzipSync(body));
+   }catch{res.writeHead(502);res.end('{}');}
+  });await new Promise(r=>server.listen(0,'127.0.0.1',r));servers.push(server);origins[fn.variant]='http://127.0.0.1:'+server.address().port;
+ }
+ const browser=await chromium.launch({headless:true,executablePath:'/Applications/Google Chrome.app/Contents/MacOS/Google Chrome'});
+ const selectors={dashboard:'.overview-metrics',members:'app-operations-users tbody tr',stores:'app-operations-stores tbody tr',schedule:'.content-card[aria-busy="false"]',coaches:'app-operations-coaches tbody tr',payroll:'.payroll-summary-table tbody tr',lessons:'app-lesson-records tbody tr','learning-reports':'app-operations-learning-reports tbody tr','support-requests':'.request-list',vocabulary:'app-operations-vocabulary tbody tr','special-training':'.special-card'};
+ return {
+  async measure(fn,item,round){
+   const ctx=await browser.newContext({viewport:{width:1440,height:1000}}),page=await ctx.newPage(),records=[],errors=[];
+   await ctx.addInitScript(auth=>{localStorage.setItem('xiaoshu_admin_session_v1',JSON.stringify(auth));window.__perfLong=[];new PerformanceObserver(list=>window.__perfLong.push(...list.getEntries().map(e=>e.duration))).observe({type:'longtask',buffered:true});},{objectId:me.objectId,sessionToken:token,username:'性能验收',roles:me.roles,company:me.company,operationsRole:'ops-manager',isSuperAdmin:true});
+   page.on('pageerror',e=>errors.push(e.message));
+   const responseJobs=[];page.on('response',response=>{if(!response.url().includes('/cloud-functions/'))return;responseJobs.push((async()=>{const request=response.request(),p=request.postDataJSON()?.params||{};try{const data=await response.json();records.push({operation:p.operation,displayOnly:p.displayOnly===true,data:data.data,success:response.ok()&&data.success===true,ms:Math.round(performance.now()-start)});}catch{errors.push('Invalid response');}})());});
+   const cdp=await ctx.newCDPSession(page);await cdp.send('Network.enable');await cdp.send('Network.emulateNetworkConditions',{offline:false,latency:20,downloadThroughput:4*1024*1024/8,uploadThroughput:3*1024*1024/8});
+   const start=performance.now();
+   const primaryResponse=page.waitForResponse(r=>(r.request().postData()?r.request().postDataJSON():null)?.params?.operation===item.params.operation,{timeout:60000});
+   const wordResponse=item.page==='vocabulary'?page.waitForResponse(r=>(r.request().postData()?r.request().postDataJSON():null)?.params?.operation==='ops/vocabulary/list',{timeout:60000}):null;
+   try{
+    await page.goto(origins[fn.variant]+'/admin/'+item.page,{waitUntil:'domcontentloaded'});
+    await (await primaryResponse).finished();if(wordResponse)await (await wordResponse).finished();await Promise.all(responseJobs);
+    await page.locator(selectors[item.page]).first().waitFor({state:'visible',timeout:60000});
+    // Vocabulary must have BOTH the tree and the current word page available.
+    await page.evaluate(()=>new Promise(r=>requestAnimationFrame(()=>requestAnimationFrame(r))));
+    const readyMs=Math.round(performance.now()-start);
+    const input=page.locator('main input:not([type="file"]):not([disabled])').first();let interactive=true;
+    if(await input.count()){const previous=await input.inputValue();await input.fill('测速');interactive=(await input.inputValue())==='测速';await input.fill(previous);}
+    if(item.page==='payroll'&&fn.variant==='after'){
+     assert.equal(await page.locator('.create-payroll-action').isDisabled(),true,'settlement must remain disabled during verification');
+     if(round===0)await page.screenshot({path:out+'/payroll-verifying.png',animations:'disabled'});
+     await page.waitForFunction(()=>!document.querySelector('.payroll-source-row')?.textContent?.includes('正在核验结算资格'),null,{timeout:60000});
+    }
+    const completeMs=Math.round(performance.now()-start);
+    await Promise.all(responseJobs);
+    assert.equal(interactive,true);assert.deepEqual(errors,[]);
+    const primary=records.filter(r=>r.operation===item.params.operation);assert.ok(primary.length&&primary.every(r=>r.success),'primary data must succeed');
+    if(round===0){
+     await page.mouse.move(0,0);for(const width of [1440,1000]){await page.setViewportSize({width,height:1000});await page.waitForTimeout(150);await page.screenshot({path:out+'/'+item.page+'-'+fn.variant+'-'+width+'.png',animations:'disabled'});}await page.setViewportSize({width:1440,height:1000});
+    }
+    const data=primary.at(-1).data;
+    let menuReturn;
+    if(['lessons','support-requests'].includes(item.page)){
+     await page.locator('#admin-sidebar a[href="/admin/special-training"]').last().click();await page.locator('.special-card').first().waitFor({timeout:30000});
+     const count=records.filter(r=>r.operation===item.params.operation).length,at=performance.now();await page.locator('#admin-sidebar a[href="/admin/'+item.page+'"]').last().click();await page.locator(selectors[item.page]).first().waitFor({timeout:30000});await page.evaluate(()=>new Promise(r=>requestAnimationFrame(()=>requestAnimationFrame(r))));await Promise.all(responseJobs);
+     menuReturn={ms:Math.round(performance.now()-at),requests:records.filter(r=>r.operation===item.params.operation).length-count};
+    }
+    return{data,readyMs,completeMs,menuReturn,primaryRequests:primary.length,errors,longTaskMs:Math.round(await page.evaluate(()=>window.__perfLong.reduce((a,b)=>a+b,0))),resources:await page.evaluate(()=>performance.getEntriesByType('resource').reduce((s,r)=>({requests:s.requests+1,bytes:s.bytes+r.transferSize}),{requests:0,bytes:0}))};
+   }finally{await ctx.close();}
+  },
+  async close(){await browser.close();for(const server of servers)await new Promise(r=>server.close(r));}
+ };
+}

+ 253 - 0
perf/admin-speed-2026-11-10/browser-summary.json

@@ -0,0 +1,253 @@
+{
+  "scope": "Actual production data through isolated operator-bound read gateways; production Angular builds; independent browser contexts; gzip static/API responses; 20ms/4Mbps/3Mbps; A/B order alternates by round; no deployment or real-user claim",
+  "completedAt": "2026-11-10T04:49:18.244Z",
+  "pages": [
+    {
+      "page": "dashboard",
+      "before": {
+        "runs": 10,
+        "p50": 6124,
+        "p75": 6686,
+        "p95": 7096,
+        "completeP75": 6689,
+        "requests": 1
+      },
+      "after": {
+        "runs": 10,
+        "p50": 3899,
+        "p75": 4134,
+        "p95": 4298,
+        "completeP75": 4136,
+        "requests": 1
+      },
+      "improvementPercent": 38.2,
+      "businessEqual": true
+    },
+    {
+      "page": "members",
+      "before": {
+        "runs": 10,
+        "p50": 1440,
+        "p75": 1484,
+        "p95": 1617,
+        "completeP75": 1508,
+        "requests": 1
+      },
+      "after": {
+        "runs": 10,
+        "p50": 1100,
+        "p75": 1131,
+        "p95": 1351,
+        "completeP75": 1156,
+        "requests": 1
+      },
+      "improvementPercent": 23.8,
+      "businessEqual": true
+    },
+    {
+      "page": "schedule",
+      "before": {
+        "runs": 10,
+        "p50": 3306,
+        "p75": 3565,
+        "p95": 15958,
+        "completeP75": 3603,
+        "requests": 1
+      },
+      "after": {
+        "runs": 10,
+        "p50": 1964,
+        "p75": 2062,
+        "p95": 17378,
+        "completeP75": 2100,
+        "requests": 1
+      },
+      "improvementPercent": 42.2,
+      "businessEqual": true
+    },
+    {
+      "page": "coaches",
+      "before": {
+        "runs": 10,
+        "p50": 14878,
+        "p75": 15294,
+        "p95": 22981,
+        "completeP75": 15320,
+        "requests": 1
+      },
+      "after": {
+        "runs": 10,
+        "p50": 4200,
+        "p75": 4335,
+        "p95": 5186,
+        "completeP75": 4365,
+        "requests": 1
+      },
+      "improvementPercent": 71.7,
+      "businessEqual": true
+    },
+    {
+      "page": "payroll",
+      "before": {
+        "runs": 10,
+        "p50": 14519,
+        "p75": 14644,
+        "p95": 18554,
+        "completeP75": 14665,
+        "requests": 1
+      },
+      "after": {
+        "runs": 10,
+        "p50": 4564,
+        "p75": 4620,
+        "p95": 32527,
+        "completeP75": 16412,
+        "requests": 2
+      },
+      "improvementPercent": 68.5,
+      "businessEqual": true
+    },
+    {
+      "page": "lessons",
+      "before": {
+        "runs": 10,
+        "p50": 4582,
+        "p75": 4690,
+        "p95": 5782,
+        "completeP75": 4713,
+        "requests": 1,
+        "menuReturn": {
+          "p75": 4387,
+          "maxRequests": 1
+        }
+      },
+      "after": {
+        "runs": 10,
+        "p50": 3167,
+        "p75": 3221,
+        "p95": 3277,
+        "completeP75": 3244,
+        "requests": 1,
+        "menuReturn": {
+          "p75": 82,
+          "maxRequests": 0
+        }
+      },
+      "improvementPercent": 31.3,
+      "businessEqual": true
+    },
+    {
+      "page": "learning-reports",
+      "before": {
+        "runs": 10,
+        "p50": 1992,
+        "p75": 2041,
+        "p95": 2236,
+        "completeP75": 2067,
+        "requests": 1
+      },
+      "after": {
+        "runs": 10,
+        "p50": 1341,
+        "p75": 1395,
+        "p95": 1497,
+        "completeP75": 1416,
+        "requests": 1
+      },
+      "improvementPercent": 31.7,
+      "businessEqual": true
+    },
+    {
+      "page": "support-requests",
+      "before": {
+        "runs": 10,
+        "p50": 730,
+        "p75": 743,
+        "p95": 836,
+        "completeP75": 764,
+        "requests": 1,
+        "menuReturn": {
+          "p75": 345,
+          "maxRequests": 1
+        }
+      },
+      "after": {
+        "runs": 10,
+        "p50": 719,
+        "p75": 791,
+        "p95": 871,
+        "completeP75": 814,
+        "requests": 1,
+        "menuReturn": {
+          "p75": 69,
+          "maxRequests": 0
+        }
+      },
+      "improvementPercent": -6.5,
+      "businessEqual": true
+    },
+    {
+      "page": "vocabulary",
+      "before": {
+        "runs": 10,
+        "p50": 2614,
+        "p75": 2696,
+        "p95": 2797,
+        "completeP75": 2720,
+        "requests": 1
+      },
+      "after": {
+        "runs": 10,
+        "p50": 1273,
+        "p75": 1397,
+        "p95": 1615,
+        "completeP75": 1421,
+        "requests": 1
+      },
+      "improvementPercent": 48.2,
+      "businessEqual": true
+    },
+    {
+      "page": "stores",
+      "before": {
+        "runs": 10,
+        "p50": 2889,
+        "p75": 3248,
+        "p95": 3926,
+        "completeP75": 3313,
+        "requests": 1
+      },
+      "after": {
+        "runs": 10,
+        "p50": 1818,
+        "p75": 1926,
+        "p95": 2115,
+        "completeP75": 1975,
+        "requests": 1
+      },
+      "improvementPercent": 40.7,
+      "businessEqual": true
+    },
+    {
+      "page": "special-training",
+      "before": {
+        "runs": 10,
+        "p50": 1413,
+        "p75": 1470,
+        "p95": 1628,
+        "completeP75": 1562,
+        "requests": 1
+      },
+      "after": {
+        "runs": 10,
+        "p50": 1139,
+        "p75": 1178,
+        "p95": 1215,
+        "completeP75": 1244,
+        "requests": 1
+      },
+      "improvementPercent": 19.9,
+      "businessEqual": true
+    }
+  ]
+}

+ 72 - 0
perf/admin-speed-2026-11-10/lesson-history-baseline.js

@@ -0,0 +1,72 @@
+// Shared classroom history for member detail, scheduling and the records centre.
+const LESSON_RECORDS_VERSION='2026-10-13-lesson-records-v10';
+function lessonHistoryBase(){return `WITH facts AS (
+ SELECT l.*,c."objectId" AS "targetId",c."generalId",c."createdAt" AS "commonCreatedAt",
+ COALESCE(NULLIF(NULLIF(l.yyds,'0'),''),'lesson:'||l."objectId") AS "classKey",
+ COALESCE(r."actualStartAt",l."lessonAt",xiaoshu_safe_shanghai_timestamp(l.plpjsj),c."createdAt",l."sourceCreatedAt",l."createdAt") AS "actualAt",
+ COALESCE(NULLIF(s."realName",''),NULLIF(s.nickname,''),s."legacyUserData"->>'HoneyName',s.username,l."studentName",'学员 '||l.xymz) AS "studentNameResolved",
+ COALESCE(NULLIF(p."realName",''),NULLIF(p.nickname,''),p."legacyUserData"->>'HoneyName',p.username,l."coachName",'历史陪练 '||l.jsmz) AS "coachNameResolved",
+ COALESCE(NULLIF(l.kcmc,''),NULLIF(l."courseName",''),n."nodeName",'未记录课程') AS "courseNameResolved",
+ COALESCE(NULLIF(l.szmdid,''),NULLIF(l."storeId"::text,'0'),s."legacyUserData"->>'ParentUserID') AS "storeIdResolved",
+ COALESCE(NULLIF(st."realName",''),NULLIF(st.nickname,''),st."legacyUserData"->>'HoneyName',st.username,'未记录门店') AS "storeName",
+ r."contentSummary" AS "feedbackSummary"
+ FROM "LessonRecord" l
+ LEFT JOIN LATERAL (SELECT * FROM "CommonModel" c WHERE c.company=l.company AND c."modelId"=59 AND c."itemId"=l.id ORDER BY c."objectId" LIMIT 1) c ON true
+ LEFT JOIN LATERAL (SELECT * FROM "_User" u WHERE u.company=l.company AND u."legacyUserId"::text=l.xymz ORDER BY u."objectId" LIMIT 1) s ON true
+ LEFT JOIN LATERAL (SELECT * FROM "_User" u WHERE u.company=l.company AND u."legacyUserId"::text=l.jsmz ORDER BY u."objectId" LIMIT 1) p ON true
+ LEFT JOIN LATERAL (SELECT * FROM "_User" u WHERE u.company=l.company AND u."legacyUserId"::text=COALESCE(NULLIF(l.szmdid,''),NULLIF(l."storeId"::text,'0'),s."legacyUserData"->>'ParentUserID') ORDER BY u."objectId" LIMIT 1) st ON true
+ LEFT JOIN LATERAL (SELECT "nodeName" FROM "Node" n WHERE n.company=l.company AND n."nodeId"::text=l.kcid LIMIT 1) n ON true
+ LEFT JOIN LATERAL (SELECT "contentSummary","actualStartAt" FROM "LessonCompletionReport" r WHERE r.company=l.company AND (r."lessonId"=l."objectId" OR r."appointmentGeneralId"::text=l.yyds) ORDER BY r."submittedAt" DESC LIMIT 1) r ON true
+ WHERE l.company=$1
+ ), ranked AS (SELECT facts.*,row_number() OVER(PARTITION BY company,xymz,"classKey" ORDER BY "actualAt" DESC NULLS LAST,"objectId") rn,count(*) OVER(PARTITION BY company,xymz,"classKey") duplicates FROM facts), lessons AS (SELECT * FROM ranked WHERE rn=1)`;}
+function lessonHistoryFilters(input,args){const f={...(input.filters||{}),...input},conditions=[];
+ const add=(sql,value)=>{if(value!==undefined&&value!==null&&String(value).trim()){args.push(String(value).trim());conditions.push(sql.replaceAll('?',String(args.length)));}};
+ if(f._storeId)add('EXISTS(SELECT 1 FROM \"_User\" s WHERE s.company=lessons.company AND s.\"legacyUserId\"::text=lessons.xymz AND s.\"identityType\"=\'member\' AND s.\"legacyUserData\"->>\'ParentUserID\'=$? AND NOT COALESCE(s.\"isDeleted\",false))',f._storeId);
+ add('xymz=$?::text',f.studentId);add('jsmz=$?::text',f.coachId);add('"storeIdResolved"=$?::text',f.storeId);add('kcid=$?::text',f.courseId);
+ for(const [field,key] of [['"studentNameResolved"','studentName'],['"coachNameResolved"','coachName'],['"storeName"','storeName'],['"courseNameResolved"','courseName']])add('position(lower($?) in lower('+field+'))>0',f[key]);
+ add('position(lower($?) in lower(concat_ws(\' \',xymz,"studentNameResolved")))>0',f.search);
+ for(const key of ['dateFrom','dateTo'])if(f[key]&&!/^\d{4}-\d{2}-\d{2}$/.test(f[key]))fail(400,'日期格式应为 YYYY-MM-DD');
+ add('"actualAt">=($?::date::timestamp AT TIME ZONE \'Asia/Shanghai\')',f.dateFrom);add('"actualAt"<(($?::date+1)::timestamp AT TIME ZONE \'Asia/Shanghai\')',f.dateTo);
+ return conditions.length?' WHERE '+conditions.join(' AND '):'';}
+function lessonHistoryRow(row){return {targetId:text(row.targetId||row.objectId),lessonId:row.objectId,generalId:number(row.generalId||row.id),appointmentId:text(row.yyds),studentId:number(row.xymz),studentName:row.studentNameResolved,coachId:number(row.jsmz),coachName:row.coachNameResolved,storeId:number(row.storeIdResolved),storeName:row.storeName,courseId:number(row.kcid),courseName:row.courseNameResolved,classTypeLabel:row.courseCategoryName||classType(row.kclx),lessonAt:dateIso(row.actualAt),score:number(row.pf),comment:text(row.pldp),coachComment:text(row.pldp),studentComment:text(row.pjnr),feedbackSummary:text(row.feedbackSummary||row.pldp),duplicateCount:number(row.duplicates),pendingReviews:number(row.pendingReviews),reviewCoaches:row.reviewCoaches||[],reviewCoachLabel:(row.reviewCoaches||[]).length>1?'多位陪练':row.reviewCoaches?.[0]?.name||'无待复习任务'};}
+async function lessonHistoryPage(context,input){
+ requireRole(context,['ops-manager','ops-staff','ops-auditor']);const co=companyIdOf(context,input);if(!co)fail(400,'缺少帐套');
+ const args=[co];if(context.storeId)input={...input,storeId:context.storeId,_storeId:context.storeId};const where=lessonHistoryFilters(input,args),cumulativeWhere=lessonHistoryFilters({studentId:input.studentId,studentName:input.studentName,search:input.search,_storeId:input._storeId},args),paging=pageInput(input);args.push(paging.pageSize,(paging.page-1)*paging.pageSize);
+ const q=lessonHistoryBase()+`,filtered AS MATERIALIZED (SELECT * FROM lessons${where}), page AS (SELECT * FROM filtered ORDER BY "actualAt" DESC NULLS LAST,"objectId" DESC LIMIT $${args.length-1} OFFSET $${args.length})
+ SELECT md5(COALESCE((SELECT string_agg("objectId"||COALESCE("updatedAt"::text,''),',' ORDER BY "objectId") FROM filtered),'')) AS "historyVersion",(SELECT count(*)::int FROM filtered) total,(SELECT count(*)::int FROM lessons${cumulativeWhere}) "cumulativeTotal",
+ COALESCE((SELECT jsonb_agg(to_jsonb(p)||jsonb_build_object('pendingReviews',rv.n,'reviewCoaches',rv.coaches) ORDER BY p."actualAt" DESC NULLS LAST,p."objectId" DESC) FROM page p
+ LEFT JOIN LATERAL (SELECT COALESCE(sum(n),0) n,jsonb_agg(jsonb_build_object('id',coach,'name',name,'count',n) ORDER BY name) coaches FROM (
+ SELECT m.plid::text coach,COALESCE(NULLIF(u."realName",''),NULLIF(u.nickname,''),u."legacyUserData"->>'HoneyName',u.username,'陪练 '||m.plid::text) name,count(*)::int n
+ FROM xs_review_task_link link JOIN "MemoryPracticeRecord" m ON m.company=link.company AND m."objectId"=link."taskId"
+ LEFT JOIN LATERAL(SELECT * FROM "_User" u WHERE u.company=m.company AND u."legacyUserId"::text=m.plid::text ORDER BY u."objectId" LIMIT 1) u ON true
+ WHERE link.company=p.company AND link."studentId"=p.xymz AND link."appointmentId"=p.yyds AND COALESCE(m.fxzt,'0')<>'1' AND NOT COALESCE(m."rewardDuplicate",false) GROUP BY m.plid,u."realName",u.nickname,u."legacyUserData",u.username) grouped) rv ON true),'[]'::jsonb) items`;
+ const result=await Psql.one(q,args);return {page:paging.page,pageSize:paging.pageSize,total:number(result.total),cumulativeTotal:number(result.cumulativeTotal),items:(result.items||[]).map(lessonHistoryRow),refreshedAt:new Date().toISOString(),version:LESSON_RECORDS_VERSION+':'+result.historyVersion};
+}
+async function lessonHistoryDetail(context,input){
+ requireRole(context,['ops-manager','ops-staff','ops-auditor']);const co=companyIdOf(context,input),id=text(input.lessonId||input.targetId),appointment=text(input.appointmentId);
+ if(!co||(!id&&!appointment))fail(400,'缺少课次编号');
+ const row=await Psql.oneOrNone(lessonHistoryBase()+` SELECT * FROM ranked WHERE ($2<>'' AND ("targetId"=$2 OR "objectId"=$2 OR "generalId"::text=$2)) OR ($3<>'' AND yyds=$3) LIMIT 1`,[co,id,appointment]);
+ if(!row)fail(404,'未找到实际上课记录');
+ if(context.storeId)await storeAssertMember(context,input,row.xymz);
+ const [report,review,tasks,auditRows]=await Promise.all([
+ Psql.oneOrNone('SELECT * FROM "LessonCompletionReport" WHERE company=$1 AND ("lessonId"=$2 OR "appointmentGeneralId"::text=$3) ORDER BY "submittedAt" DESC LIMIT 1',[co,row.objectId,row.yyds]),
+ Psql.oneOrNone('SELECT score,comment,"submittedAt" FROM "LessonReview" WHERE company=$1 AND "appointmentGeneralId"::text=$2 LIMIT 1',[co,row.yyds]),
+ Psql.query('SELECT m."objectId",m.plid AS "coachId",m.kywrq AS "plannedDate",m.fxzt AS status,m.wcsj AS "completedAt",COALESCE(NULLIF(u."realName",\'\'),NULLIF(u.nickname,\'\'),u."legacyUserData"->>\'HoneyName\',u.username,\'陪练 \'||m.plid::text) AS "coachName" FROM xs_review_task_link l JOIN "MemoryPracticeRecord" m ON m.company=l.company AND m."objectId"=l."taskId" LEFT JOIN LATERAL(SELECT * FROM "_User" u WHERE u.company=m.company AND u."legacyUserId"::text=m.plid::text ORDER BY u."objectId" LIMIT 1) u ON true WHERE l.company=$1 AND l."studentId"=$2 AND l."appointmentId"=$3 ORDER BY m.kywrq,m."objectId"',[co,row.xymz,row.yyds]),
+ Psql.query('SELECT a.*,c.reason,c."actorId" FROM "ReviewTransferAudit" a JOIN "ReviewTransferCommand" c ON c."objectId"=a."commandId" WHERE a.company=$1 AND a."studentId"=$2 AND a."appointmentId"=$3 ORDER BY a."createdAt" DESC',[co,row.xymz,row.yyds])]);
+ return {...lessonHistoryRow(row),report:report||null,coachComment:text(report?.coachComment||row.pldp),studentReview:review||{score:number(row.pf),comment:text(row.pjnr)},tasks,audit:auditRows,canTransfer:!context.storeId&&roleOf(context)==='ops-manager',hasReport:Boolean(report)};
+}
+async function lessonTransferPreview(context,input){
+ requireRole(context,['ops-manager']);const co=companyIdOf(context,input),student=text(input.studentId),appointment=input.scope==='student'?'':text(input.appointmentId),coach=text(input.coachId);
+ if(!co||!/^\d+$/.test(student)||!/^\d+$/.test(coach)||!['lesson','student'].includes(input.scope)||input.scope==='lesson'&&!appointment)fail(400,'请选择有效的学员、课次和目标陪练');
+ await Psql.one('SELECT xs_review_manager($1,$2),xs_review_target($1,$3)',[co,context.current.id,coach]);
+ const snap=await Psql.one('SELECT s.tasks,md5(s.tasks::text) version FROM (SELECT xs_review_transfer_snapshot($1,$2,$3,$4) tasks) s',[co,student,appointment,coach]);
+ const ids=[...new Set(snap.tasks.map(t=>t.coachId))],coaches=ids.length?await Psql.query('SELECT "legacyUserId"::text id,COALESCE(NULLIF("realName",\'\'),NULLIF(nickname,\'\'),"legacyUserData"->>\'HoneyName\',username) name FROM "_User" WHERE company=$1 AND "legacyUserId"::text=ANY($2::text[])',[co,ids]):[];
+ const tasks=snap.tasks.map(t=>({...t,coachName:coaches.find(c=>c.id===t.coachId)?.name||'陪练 '+t.coachId}));
+ return {version:snap.version,count:tasks.length,lessonCount:new Set(tasks.map(t=>t.appointmentId).filter(Boolean)).size,unlinkedCount:tasks.filter(t=>!t.appointmentId).length,dateFrom:tasks.map(t=>t.plannedDate).sort()[0]||'',dateTo:tasks.map(t=>t.plannedDate).sort().at(-1)||'',tasks};
+}
+async function lessonTransferCommit(context,input){
+ requireRole(context,['ops-manager']);if(!['student','lesson'].includes(input.scope)||input.scope==='lesson'&&!text(input.appointmentId))fail(400,'缺少转交范围');
+ try{return (await Psql.one('SELECT xs_review_transfer($1,$2,$3,$4,$5,$6,$7,$8) result',[companyIdOf(context,input),context.current.id,text(input.studentId),input.scope==='student'?'':text(input.appointmentId),text(input.coachId),text(input.version),text(input.reason),text(input.idempotencyKey)])).result;}catch(error){fail(409,text(error.message)||'转交失败,请刷新预览');}
+}
+
+async function lessonTransferCoaches(context,input){requireRole(context,['ops-manager']);const items=await Psql.query(`SELECT "legacyUserId" AS "userId",COALESCE(NULLIF("realName",''),NULLIF(nickname,''),"legacyUserData"->>'HoneyName',username) AS "displayName" FROM "_User" u WHERE company=$1 AND COALESCE(to_jsonb(u)->>'identityType','coach')='coach' AND COALESCE(to_jsonb(u)->>'legacyGroupId',to_jsonb(u)->>'groupId',u."legacyUserData"->>'GroupID')='3' AND NOT COALESCE("isDisabled",false) AND NOT COALESCE("isDeleted",false) ORDER BY "displayName","userId"`,[companyIdOf(context,input)]);return{items};}

+ 35 - 0
perf/admin-speed-2026-11-10/paired-api.mjs

@@ -0,0 +1,35 @@
+// Restricted temporary read gateways. No business writes; delete on completion.
+import fs from 'node:fs';
+import {randomBytes,createHash} from 'node:crypto';
+import {browserHarness} from './browser-case.mjs';
+import {patchAdminSpeed} from '../../scripts/admin-speed-patch.mjs';
+const env={};for(const line of fs.readFileSync('/Users/mac/.config/xiaoshu/legacy-sync-nightly.env','utf8').split('\n')){const m=line.match(/^(?:export\s+)?(XIAOSHU_[A-Z0-9_]+)=(.*)$/);if(m)env[m[1]]=m[2].replace(/^['"]|['"]$/g,'');}
+const token=env.XIAOSHU_SYNC_OPERATOR_TOKEN,app=env.XIAOSHU_PARSE_APP_ID,master=process.env.XIAOSHU_MASTER_KEY,base='https://server.xiaoshu.pro';
+if(!master||!app||!token)throw Error('Missing authorized performance configuration');
+const out='tmp/admin-speed-2026-11-10';fs.mkdirSync(out,{recursive:true,mode:0o700});
+async function parse(path,method='GET',body){const r=await fetch(base+'/parse'+path,{method,headers:{'Content-Type':'application/json','X-Parse-Application-Id':app,'X-Parse-Master-Key':master},body:body?JSON.stringify(body):undefined});if(!r.ok)throw Error('Parse HTTP '+r.status);return r.json();}
+const me=await(await fetch(base+'/parse/users/me',{headers:{'X-Parse-Application-Id':app,'X-Parse-Session-Token':token}})).json();if(!me.objectId)throw Error('Invalid operator');
+const original=JSON.parse(fs.readFileSync(out+'/online-before.json')).code;
+const survey=JSON.parse(fs.readFileSync('tmp/admin-data-2026-10-15/survey.json')).results;
+const cases=survey.map(row=>({page:row.path,params:{...row.first.requests[0].params,...(row.path==='coaches'?{refresh:true}:{})}})).filter(row=>!process.env.PERF_PAGES||process.env.PERF_PAGES.split(',').includes(row.page));
+const allowed=[...new Set([...cases.map(c=>c.params.operation),'ops/vocabulary/list','ops/special-training/list'])],functions=[],results=[];let browser;
+function clean(value){if(value==='工作台仅展示实时预估金额;结算前须在工资模块核验来源')return '金额已按实时完课记录计算,结算资格仍需完整核验';if(Array.isArray(value))return value.map(clean);if(value&&typeof value==='object')return Object.fromEntries(Object.entries(value).filter(([k])=>!['refreshedAt','payrollRefreshedAt','lagSeconds','ageMs','cacheHit'].includes(k)).map(([k,v])=>[k,clean(k==='payrollSourceStatus'?Object.fromEntries(Object.entries(v).filter(([key])=>!['sync','complete','stale'].includes(key))):v)]));return value;}
+const hash=d=>createHash('sha256').update(JSON.stringify(clean(d??null))).digest('hex');
+try{
+ for(const [variant,source] of Object.entries({before:original,after:patchAdminSpeed(original)})){
+  const path='xiaoshu/system/admin-speed-'+randomBytes(10).toString('hex');
+  const code=source+`\nrealtimeCacheWrite=async()=>{};const __readHandler=handler;handler=async function(request,response){const p=request.body?.params||{};if(request.user?.id!==${JSON.stringify(me.objectId)}||Date.now()>${Date.now()+3600000}||!${JSON.stringify(allowed)}.includes(p.operation)||p.refresh===true&&!['ops/coaches/list','ops/payroll/overview','ops/schedule/calendar'].includes(p.operation))return response.status(403).json({success:false});return __readHandler(request,response);};`;
+  new Function(code);const row=await parse('/classes/Function','POST',{name:path,path,code,type:'standalone',enabled:true,params:[],paramList:[],respType:'json'});functions.push({...row,variant,path});
+ }
+ if(process.env.PERF_BROWSER)browser=await browserHarness({functions,token,me,base,out});
+ const rounds=Number(process.env.PERF_RUNS||10);
+ for(let round=0;round<rounds;round++)for(const item of cases){
+  const pair=[];
+  for(const fn of round%2?[...functions].reverse():functions){if(browser){const measured=await browser.measure(fn,item,round);fs.writeFileSync(out+'/'+item.page+'-'+fn.variant+'-latest.json',JSON.stringify({data:measured.data}),{mode:0o600});const {data,...metrics}=measured;const result={round,page:item.page,variant:fn.variant,ms:metrics.readyMs,success:true,hash:hash(data),...metrics};pair.push(result);results.push(result);continue;}const at=performance.now();const response=await fetch(base+'/api/functions/'+fn.path,{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({token,params:item.params}),signal:AbortSignal.timeout(60000)});const body=await response.json();const result={round,page:item.page,variant:fn.variant,ms:Math.round(performance.now()-at),success:response.ok&&body.success===true,bytes:Buffer.byteLength(JSON.stringify(body)),hash:hash(body.data)};
+   if(!result.success)throw Error(item.page+' '+fn.variant+' HTTP '+response.status+' '+String(body.message||body.error||'unsuccessful').slice(0,250));
+   fs.writeFileSync(out+'/'+item.page+'-'+fn.variant+'-latest.json',JSON.stringify(body),{mode:0o600});pair.push(result);results.push(result);
+  }
+  const equal=pair[0].hash===pair[1].hash;console.log(JSON.stringify({round,page:item.page,equal,times:pair.map(r=>({variant:r.variant,ms:r.ms}))}));
+  fs.writeFileSync(out+'/'+(process.env.PERF_OUTPUT||'api-paired.json'),JSON.stringify({at:new Date().toISOString(),results},null,2),{mode:0o600});
+ }
+}finally{await browser?.close();for(const fn of functions){await parse('/classes/Function/'+fn.objectId,'DELETE');console.log('Removed '+fn.variant+' temporary gateway');}}

+ 12 - 0
perf/admin-speed-2026-11-10/ratchet.mjs

@@ -0,0 +1,12 @@
+// Run with the sanitized paired summary generated by summarize.mjs.
+import fs from 'node:fs';
+import assert from 'node:assert/strict';
+const current=JSON.parse(fs.readFileSync(process.argv[2]||new URL('./browser-summary.json',import.meta.url)));
+for(const row of current.pages){
+ assert.ok(row.before.runs>=10&&row.after.runs>=10,row.page+' needs ten paired samples');
+ assert.equal(row.businessEqual,true,row.page+' business payload differs');
+ assert.ok(row.after.p75<=row.before.p75*1.10,row.page+' cold-load regression');
+ if(['coaches','payroll'].includes(row.page))assert.ok(row.after.p75<row.before.p75*.55,row.page+' must retain substantial improvement');
+ if(row.after.menuReturn){assert.equal(row.after.menuReturn.maxRequests,0,row.page+' warm menu repeated primary reads');assert.ok(row.after.menuReturn.p75<300,row.page+' warm menu exceeds 300ms');}
+}
+console.log('Paired performance and data guardrails passed.');

+ 15 - 0
perf/admin-speed-2026-11-10/summarize.mjs

@@ -0,0 +1,15 @@
+import fs from 'node:fs';
+const root='tmp/admin-speed-2026-11-10';
+const main=JSON.parse(fs.readFileSync(root+'/browser-paired-final.json'));
+const supplemental=JSON.parse(fs.readFileSync(root+'/browser-paired-supplemental.json'));
+const replaced=new Set(supplemental.results.map(r=>r.page));
+const rows=[...main.results.filter(r=>!replaced.has(r.page)),...supplemental.results];
+const q=(values,p)=>[...values].sort((a,b)=>a-b)[Math.ceil(values.length*p)-1];
+const pages=[...new Set(rows.map(r=>r.page))].map(page=>{
+ const data=rows.filter(r=>r.page===page);const variants=Object.fromEntries(['before','after'].map(variant=>{const a=data.filter(r=>r.variant===variant);const returns=a.map(r=>r.menuReturn).filter(Boolean);return [variant,{runs:a.length,p50:q(a.map(r=>r.readyMs),.5),p75:q(a.map(r=>r.readyMs),.75),p95:q(a.map(r=>r.readyMs),.95),completeP75:q(a.map(r=>r.completeMs),.75),requests:a[0].primaryRequests,menuReturn:returns.length?{p75:q(returns.map(r=>r.ms),.75),maxRequests:Math.max(...returns.map(r=>r.requests))}:undefined}];}));
+ const equality=data.filter(r=>r.variant==='before').every(a=>a.hash===data.find(b=>b.variant==='after'&&b.round===a.round)?.hash);
+ return{page,...variants,improvementPercent:Math.round((1-variants.after.p75/variants.before.p75)*1000)/10,businessEqual:equality};
+});
+const result={scope:'Actual production data through isolated operator-bound read gateways; production Angular builds; independent browser contexts; gzip static/API responses; 20ms/4Mbps/3Mbps; A/B order alternates by round; no deployment or real-user claim',completedAt:supplemental.at,pages};
+fs.writeFileSync(new URL('./browser-summary.json',import.meta.url),JSON.stringify(result,null,2));
+console.log(JSON.stringify(result,null,2));

+ 42 - 0
perf/admin-speed-2026-11-10/verify-live.mjs

@@ -0,0 +1,42 @@
+// Public hashes + authenticated read-only browser acceptance. No business mutations.
+import fs from 'node:fs';
+import assert from 'node:assert/strict';
+import {createHash} from 'node:crypto';
+import {chromium} from 'playwright';
+const env={};for(const line of fs.readFileSync('/Users/mac/.config/xiaoshu/legacy-sync-nightly.env','utf8').split('\n')){const m=line.match(/^(?:export\s+)?(XIAOSHU_[A-Z0-9_]+)=(.*)$/);if(m)env[m[1]]=m[2].replace(/^['"]|['"]$/g,'');}
+const origin='https://admin.xiaoshu.pro',base='https://server.xiaoshu.pro',token=env.XIAOSHU_SYNC_OPERATOR_TOKEN;
+const manifest=JSON.parse(fs.readFileSync(process.argv[2],'utf8'));
+const sha=data=>createHash('sha256').update(data).digest('hex');
+async function get(url){const r=await fetch(url,{signal:AbortSignal.timeout(30000)});assert.ok(r.ok,'HTTP '+r.status);return r;}
+const current=await(await get(origin+'/deployment-version.json')).json();assert.equal(current.releaseId,manifest.releaseId);assert.equal(current.commit,manifest.commit);
+for(let i=0;i<manifest.files.length;i+=6)await Promise.all(manifest.files.slice(i,i+6).map(async f=>assert.equal(sha(Buffer.from(await(await get(origin+'/'+f.path)).arrayBuffer())),f.sha256,'Public hash mismatch: '+f.path)));
+const selectors={dashboard:'.overview-metrics',members:'app-operations-users tbody tr',stores:'app-operations-stores tbody tr',schedule:'.content-card[aria-busy="false"]',coaches:'app-operations-coaches tbody tr',payroll:'.payroll-summary-table tbody tr',lessons:'app-lesson-records tbody tr','learning-reports':'app-operations-learning-reports tbody tr','support-requests':'.request-list',vocabulary:'app-operations-vocabulary tbody tr','special-training':'.special-card'};
+const operations={dashboard:'ops/dashboard/summary',members:'ops/users/list',stores:'ops/stores/list',schedule:'ops/schedule/calendar',coaches:'ops/coaches/list',payroll:'ops/payroll/overview',lessons:'ops/lessons/list','learning-reports':'ops/learning-reports/list','support-requests':'ops/support-requests/list',vocabulary:'ops/vocabulary/tree','special-training':'ops/special-training/list'};
+const indexHash=manifest.files.find(f=>f.path==='index.html').sha256;
+for(const page of ['login',...Object.keys(selectors)])assert.equal(sha(Buffer.from(await(await get(origin+'/admin/'+page)).arrayBuffer())),indexHash,'Deep route: '+page);
+console.log(JSON.stringify({publicAssets:manifest.files.length,deepRoutes:12,releaseId:manifest.releaseId}));
+const meResponse=await fetch(base+'/parse/users/me',{headers:{'X-Parse-Application-Id':env.XIAOSHU_PARSE_APP_ID,'X-Parse-Session-Token':token}}),me=await meResponse.json();assert.ok(me.objectId,'Operator session invalid');
+const browser=await chromium.launch({headless:true,executablePath:'/Applications/Google Chrome.app/Contents/MacOS/Google Chrome'}),pages=[];
+try{
+for(const [name,selector] of Object.entries(selectors)){
+ const ctx=await browser.newContext({viewport:{width:1440,height:1000}}),page=await ctx.newPage(),errors=[],records=[],jobs=[];
+ await ctx.addInitScript(auth=>localStorage.setItem('xiaoshu_admin_session_v1',JSON.stringify(auth)),{objectId:me.objectId,sessionToken:token,username:'上线验收',roles:me.roles,company:me.company,operationsRole:'ops-manager',isSuperAdmin:true});
+ const allowed=new Set([...Object.values(operations),'ops/vocabulary/list','ops/live/versions','ops/payroll/list','ops/pay-rates/list','ops/teaching-rules/list','ops/payroll/coaches']);
+ await page.route('**/*',async route=>{const req=route.request();if(req.method()==='POST'&&req.url().includes('/api/functions/')){const op=req.postDataJSON()?.params?.operation;if(!allowed.has(op)){errors.push('Blocked unexpected operation');await route.abort();return;}}await route.continue();});
+ page.on('pageerror',()=>errors.push('Browser runtime error'));
+ const start=performance.now();
+ page.on('response',res=>{const req=res.request();if(req.method()!=='POST'||!req.url().includes('/api/functions/'))return;jobs.push((async()=>{try{const body=await res.json(),params=req.postDataJSON()?.params||{};records.push({operation:params.operation,displayOnly:params.displayOnly===true,success:res.ok()&&body.success===true,data:body.data});}catch{errors.push('Invalid API response');}})());});
+ const primary=page.waitForResponse(res=>res.request().method()==='POST'&&res.request().postDataJSON()?.params?.operation===operations[name],{timeout:60000});
+ const words=name==='vocabulary'?page.waitForResponse(res=>res.request().method()==='POST'&&res.request().postDataJSON()?.params?.operation==='ops/vocabulary/list',{timeout:60000}):null;
+ await page.goto(origin+'/admin/'+name,{waitUntil:'domcontentloaded'});await(await primary).finished();if(words)await(await words).finished();await page.locator(selector).first().waitFor({state:'visible',timeout:60000});await page.evaluate(()=>new Promise(r=>requestAnimationFrame(()=>requestAnimationFrame(r))));
+ const readyMs=Math.round(performance.now()-start);let verificationDisabled;
+ if(name==='payroll'){verificationDisabled=await page.locator('.create-payroll-action').isDisabled();assert.equal(verificationDisabled,true);await page.waitForFunction(()=>!document.querySelector('.payroll-source-row')?.textContent?.includes('正在核验结算资格'),null,{timeout:60000});}
+ await Promise.all(jobs);assert.deepEqual(errors,[]);assert.ok(records.length&&records.every(r=>r.success),'Unsuccessful API: '+name);
+ const selected=records.filter(r=>r.operation===operations[name]);assert.ok(selected.length);if(name==='payroll'){assert.ok(selected.some(r=>r.displayOnly&&r.data.sourceStatus.sync.settlementReady===false));assert.ok(selected.some(r=>!r.displayOnly&&r.data.sourceStatus.sync.state!=='not_checked'));}
+ let menuReturn;
+ if(['lessons','support-requests'].includes(name)){await page.locator('#admin-sidebar a[href="/admin/special-training"]').last().click();await page.locator('.special-card').first().waitFor({timeout:30000});await Promise.all(jobs);const count=records.filter(r=>r.operation===operations[name]).length,at=performance.now();await page.locator('#admin-sidebar a[href="/admin/'+name+'"]').last().click();await page.locator(selector).first().waitFor({timeout:30000});await page.evaluate(()=>new Promise(r=>requestAnimationFrame(()=>requestAnimationFrame(r))));await Promise.all(jobs);menuReturn={ms:Math.round(performance.now()-at),primaryRequests:records.filter(r=>r.operation===operations[name]).length-count};assert.equal(menuReturn.primaryRequests,0);}
+ const result={page:name,readyMs,passed:true,primaryRequests:selected.length,verificationDisabled,menuReturn};pages.push(result);console.log(JSON.stringify(result));await ctx.close();
+}
+}finally{await browser.close();}
+const receipt={at:new Date().toISOString(),origin,releaseId:manifest.releaseId,commit:manifest.commit,filesVerified:manifest.files.length,deepRoutesVerified:12,readOnly:true,measurement:'One production smoke sample per page; not comparable to controlled p75',pages};
+fs.writeFileSync('perf/admin-speed-2026-11-10/release-verification.json',JSON.stringify(receipt,null,2)+'\n');

+ 23 - 0
perf/admin-speed-2026-11-10/基线.md

@@ -0,0 +1,23 @@
+# 基线与瓶颈
+
+2026-11-10,当前正式运营账号只读访问实际业务库。浏览器首次调查为开发构建,以下仅用于定位;最终结论改用生产构建、冷浏览器上下文、20 ms / 4 Mbps、旧/新交替各 10 次。
+
+| 模块 | 首轮页面请求完成(秒) | 主接口(秒) |
+|---|---:|---:|
+| 工作台 | 8.39 | 7.70 |
+| 会员 | 2.64 | 1.84 |
+| 门店 | 3.02 | 2.29 |
+| 排课 | 3.27 | 2.49 |
+| 陪练 | 15.39 | 14.67 |
+| 费用 | 15.31 | 14.65 |
+| 训练记录 | 5.09 | 4.47 |
+| 学习报表 | 2.17 | 1.52 |
+| 反馈与休假 | 0.76 | 0.20 |
+| 词库 | 2.96 | 2.37(词条) |
+| 专项 | 1.45 | 0.85 |
+
+服务端分段:费用来源截止核验约 9.68 秒;来源全量汇总及来源版本约 4.83 秒(并行);数据库课程/预约关联存在对数字索引列转换文本;大量记录逐条创建 ICU 时区格式器。陪练名单等待完整财务核验,训练记录为约 1.98 万条候选记录逐条关联人名、门店及课程后才分页。会员、门店、排课还存在独立读取串行。
+
+正式列类型已只读核实:CommonModel modelId/itemId/generalId/nodeId、课程附表 id、Node nodeId/parentId 均为 numeric;只消除这些已知数字字段之间的转换,保留旧系统文本编号与回退逻辑。
+
+已登录后台未取得 CrUX 数据,不用登录页 Lighthouse 分数替代真实业务页面。原始业务响应、截图、网关原件和明细只保存在 git 忽略的 tmp/admin-speed-2026-11-10,公开报告仅含汇总。

+ 12 - 0
perf/admin-speed-2026-11-10/实验日志.md

@@ -0,0 +1,12 @@
+# 优化实验
+
+1. **日期热点**:同一函数复用 Intl.DateTimeFormat,避免逐条重新构造 ICU 格式器。1000 多个日期含跨月、闰日、历史上海夏令时、无效值,结果与旧版一致。两条日期路径合计约 2012 次慢调用降为 0 次,只构造两个格式器。
+2. **索引关联与独立读取并行**:会员/老师/预约/报表/词库/专项只在已核实的 numeric 列间直接比较;会员、门店、排课和基础老师查询并行启动。原始 API 单轮工作台 6150→3321 ms,会员 736→427 ms,门店 2326→1172 ms,排课 2684→1125 ms;最终百分比以重复浏览器试验为准。
+3. **淘汰的 SQL 改法**:全局物化老师/会员维表导致训练记录约 4.5→22.9 秒;EXPLAIN 显示重复嵌套循环,已撤销。将财务来源查询改成集合 CTE,完整截止核验仍约 9 秒,收益不足且扩大 SQL 发布范围,已撤销,不含在本次发布中。
+4. **先分页再补标签**:训练记录在不依赖人名/门店名称的筛选下,先按原口径去重、统计、排序、分页,再为当前页补人名与课程;按姓名等筛选及门店权限路径保留完整关联。单轮 API 4203→2612 ms,数据/版本完全一致。包括跨帐套、重复用户、缺失关联、日期、分页的差异测试通过。
+5. **费用展示与授权核验分开**:陪练列表只要当前实时预估金额,不运行结算资格核验;费用页先取得同公式的实时金额/全部明细,自动继续请求完整概览。核验未完成、失败、旧请求迟到均不能提前开启结算。生成/审核接口仍重新核验,默认普通概览和导出不降级。初轮浏览器费用 16072→4532 ms 可查看;完整核验另计,不能把它说成 4.5 秒完成结算。
+6. **补齐菜单复用**:训练记录与反馈页加入延迟代码预加载和会话内路由复用;30 秒内返回不重复请求,较旧快照后台刷新;写入通知使快照过期;退出/换账号销毁,指定 studentId 的训练记录深链不复用普通列表。单轮返回训练记录 4343→58 ms、反馈 356→59 ms,重复主请求 1→0。
+
+基线本地相关单测先通过,再添加日期热点/并行读取等失败用例;按阶段运行数据等价、权限、工资完整性及前端异步测试。所有线上诊断入口只允许指定现有会话与读操作、设失效时间、测试后删除;未发布正式网关或修改业务记录。缓存写入在临时网关内关闭;老师缓存强制失效以测真实首次计算。
+7. **门店只读取老师归属关系**:门店统计仅使用 userId/parentUserId,改为精确投影,消除三组无用的老师预约/上课统计/可用时间查询。保持旧账号编号回退、删除过滤和重复编号的 createdAt 顺序;在主轮实验后单独进行同条件配对复测,最终门店数据使用这一批。
+8. **专项封面按需解码与加载**:列表封面启用浏览器原生 lazy/async,优先加载可视区域附近图片,减少首屏与 20 张封面同时争用带宽。保留原图地址、尺寸和预览功能。单独与原基线交替复测;首屏指标仍以内容与操作可用为准,不把封面尚未下载说成已完成。

+ 43 - 0
perf/admin-speed-2026-11-10/报告.md

@@ -0,0 +1,43 @@
+# 管理端 11 模块加载优化验收
+
+已完成代码、生产构建及真实数据对照,用户已授权本批上线;发布与公开回读结果以 release-verification.json 为准。
+
+## 配对结果
+
+每页旧新各 10 次,顺序按轮交替;全新浏览器上下文、真实同一帐套、相同生产构建方式、gzip、20 ms / 4 Mbps 下行 / 3 Mbps 上行。主轮 220 次,最终门店及专项追加 40 次;下表每页只取最终版本 10 对。所有最终业务结果等价(仅排除刷新时间等时钟元数据及陪练页不用的结算核验状态)。单位 ms,数值为 p50 / p75 / p95。
+
+| 模块 | 优化前 | 优化后 | p75 缩短 |
+| --- | --- | --- | --- |
+| 工作台 | 6124 / 6686 / 7096 | 3899 / 4134 / 4298 | 38.2% |
+| 会员管理 | 1440 / 1484 / 1617 | 1100 / 1131 / 1351 | 23.8% |
+| 门店管理 | 2889 / 3248 / 3926 | 1818 / 1926 / 2115 | 40.7% |
+| 排课中心 | 3306 / 3565 / 15958 | 1964 / 2062 / 17378 | 42.2% |
+| 陪练老师 | 14878 / 15294 / 22981 | 4200 / 4335 / 5186 | 71.7% |
+| 费用结算(金额与明细可用) | 14519 / 14644 / 18554 | 4564 / 4620 / 32527 | 68.5% |
+| 训练记录 | 4582 / 4690 / 5782 | 3167 / 3221 / 3277 | 31.3% |
+| 学习报表 | 1992 / 2041 / 2236 | 1341 / 1395 / 1497 | 31.7% |
+| 反馈与休假申请 | 730 / 743 / 836 | 719 / 791 / 871 | -6.5% |
+| 词库管理 | 2614 / 2696 / 2797 | 1273 / 1397 / 1615 | 48.2% |
+| 专项训练 | 1413 / 1470 / 1628 | 1139 / 1178 / 1215 | 19.9% |
+
+费用首批显示的是同口径真实金额与完整明细,结算资格在后台自动完整核验,生成按钮保持禁用。完整核验 p75 为旧版 14665 ms、新版 16412 ms,因先取得可用金额再核验增加了请求与总时间;不能把 4620 ms 当成可以结算。费用与排课存在个别长尾,p95 未改善,尚不能宣称全链路秒开。费用 UI 与生成/审核接口均保留财务核验护栏。
+
+反馈首开 p75 743→791 ms,未取得明确收益;30 秒内菜单返回从 345→69 ms。训练记录菜单返回从 4387→82 ms,两页重复主请求均 1→0。过期后台刷新、变更通知失效、切换账号销毁,指定学生的深层链接不会复用错误列表。
+
+## 实际改动
+
+- 复用上海业务日期格式器,消除逐条 ICU 构造;numeric 关联保留原类型,恢复可用索引路径。
+- 独立读取并行,门店只读取必需的老师归属关系,取消无用的历史统计。
+- 训练记录在权限/筛选允许时先分页,再补当前页关联标签;名称筛选与门店权限路径保留原逻辑。
+- 陪练列表不等待结算资格核验;费用先展示真实金额,完整资格核验继续执行。
+- 补齐训练记录和反馈的会话内菜单复用;专项封面延迟加载与异步解码。
+
+两项实测无效方案已撤回:训练记录全局物化关联造成明显回退;工资来源 SQL 重写未解决截止核验瓶颈。此次不发布数据库函数或索引修改。
+
+## 验证范围
+
+管理端 356 项单测通过;服务端主要回归组 59 项、扩展组 19 项、最终门店/日期/补丁专项 6 项通过(组间有重叠,不相加)。11 个云函数定义静态校验及生产构建通过。已有 CSS 体积与 html2pdf CommonJS 警告保留。日期边界、金额/数据等价、月份请求竞态、过期刷新、会话隔离、结算授权均有护栏。1440 / 1000 宽度进行截图检查;金额核验中状态可见,无布局改版。
+
+这组 p75 是受控浏览器测量,使用真实生产数据,但不是生产真实用户统计。线上验收另记录正式域名无查询参数的文件哈希、12 条深层路由(包含登录)和 11 模块真实登录只读流程;不生成、审核或发放任何费用。原始响应和业务截图仅在本机忽略目录 tmp,仓库只保存脱敏统计。
+
+复测方法见 [README.md](./README.md),确定性门禁与配对性能棘轮均已通过。

+ 2 - 2
projects/xiaoshu-admin/src/app/admin.routes.ts

@@ -3,7 +3,7 @@ import { adminGuard, operatorManagerGuard, superAdminGuard } from './admin.guard
 
 const operationsChildren: Routes = [
   {path:'store-home',data:{title:'本店工作台',parentUrl:null},loadComponent:()=>import('./pages/store-home.component').then(m=>m.StoreHomeComponent)},
-  { path: 'support-requests', data: { title: '意见反馈与休假申请', parentUrl: '/admin/dashboard' }, loadComponent: () => import('./pages/support-requests.component').then(m => m.SupportRequestsComponent) },
+  { path: 'support-requests', data: { title: '意见反馈与休假申请', parentUrl: '/admin/dashboard', preloadOperationsMenu: true, reuseOperationsMenu: true }, loadComponent: () => import('./pages/support-requests.component').then(m => m.SupportRequestsComponent) },
   {path:'privacy-requests',data:{title:'注销申请',parentUrl:'/admin/dashboard'},canActivate:[operatorManagerGuard],loadComponent:()=>import('./pages/privacy-requests.component').then(m=>m.PrivacyRequestsComponent)},
   { path: '', pathMatch: 'full', redirectTo: 'dashboard' },
   { path: 'dashboard', data: { title: '工作台', parentUrl: null, preloadOperationsMenu: true, reuseOperationsMenu: true }, loadComponent: () => import('./pages/admin-dashboard.component').then((module) => module.AdminDashboardComponent) },
@@ -14,7 +14,7 @@ const operationsChildren: Routes = [
   { path: 'stores', data: { title: '门店管理', parentUrl: '/admin/dashboard', preloadOperationsMenu: true, reuseOperationsMenu: true }, loadComponent: () => import('./pages/operations-stores.component').then((module) => module.OperationsStoresComponent) },
   { path: 'stores/:objectId', data: { title: '门店详情', parentUrl: '/admin/stores', parentTitle: '门店管理' }, loadComponent: () => import('./pages/operations-store-detail.component').then((module) => module.OperationsStoreDetailComponent) },
   { path: 'schedule', data: { title: '排课中心', parentUrl: '/admin/dashboard', preloadOperationsMenu: true, reuseOperationsMenu: true }, loadComponent: () => import('./pages/operations-schedule.component').then((module) => module.OperationsScheduleComponent) },
-  { path: 'lessons', data: { title: '训练记录', parentUrl: '/admin/dashboard' }, loadComponent: () => import('./pages/lesson-records.component').then(m => m.LessonRecordsComponent) },
+  { path: 'lessons', data: { title: '训练记录', parentUrl: '/admin/dashboard', preloadOperationsMenu: true, reuseOperationsMenu: true }, loadComponent: () => import('./pages/lesson-records.component').then(m => m.LessonRecordsComponent) },
   { path: 'learning-reports', data: { title: '学习报表', parentUrl: '/admin/dashboard', preloadOperationsMenu: true, reuseOperationsMenu: true }, loadComponent: () => import('./pages/operations-learning-reports.component').then((module) => module.OperationsLearningReportsComponent) },
   { path: 'appointments', pathMatch: 'full', redirectTo: 'schedule' },
   { path: 'coaches', data: { title: '陪练老师', parentUrl: '/admin/dashboard', preloadOperationsMenu: true, reuseOperationsMenu: true }, loadComponent: () => import('./pages/operations-coaches.component').then((module) => module.OperationsCoachesComponent) },

+ 10 - 0
projects/xiaoshu-admin/src/app/operations-route-reuse.strategy.spec.ts

@@ -46,3 +46,13 @@ describe('OperationsRouteReuseStrategy', () => {
     expect(strategy.shouldDetach(route('members/:objectId', false))).toBeFalse();
   });
 });
+
+describe('lesson route cache isolation',()=>{
+ it('does not attach an unfiltered list to a student deep link',()=>{
+  sessionStorage.setItem(ADMIN_SESSION_KEY,JSON.stringify({sessionToken:'same'}));
+  const strategy=new OperationsRouteReuseStrategy();
+  const route={routeConfig:{path:'lessons'},data:{reuseOperationsMenu:true},queryParamMap:{has:()=>true}} as unknown as ActivatedRouteSnapshot;
+  expect(strategy.shouldDetach(route)).toBeFalse();expect(strategy.shouldAttach(route)).toBeFalse();
+  sessionStorage.removeItem(ADMIN_SESSION_KEY);
+ });
+});

+ 7 - 3
projects/xiaoshu-admin/src/app/operations-route-reuse.strategy.ts

@@ -2,7 +2,7 @@ import { ComponentRef, Injectable } from '@angular/core';
 import { ActivatedRouteSnapshot, BaseRouteReuseStrategy, DetachedRouteHandle } from '@angular/router';
 import { ADMIN_SESSION_KEY } from './admin.constants';
 
-/** Keep the nine main operations views alive for fast back-and-forth navigation. */
+/** Keep the main operations views alive for fast back-and-forth navigation. */
 @Injectable()
 export class OperationsRouteReuseStrategy extends BaseRouteReuseStrategy {
   private readonly saved = new Map<string, { token: string; handle: DetachedRouteHandle }>();
@@ -11,7 +11,7 @@ export class OperationsRouteReuseStrategy extends BaseRouteReuseStrategy {
   override shouldDetach(route: ActivatedRouteSnapshot): boolean {
     const token = this.token();
     this.clearIfSessionChanged(token);
-    return Boolean(token && route.data['reuseOperationsMenu']);
+    return Boolean(token && this.reusable(route));
   }
 
   override store(route: ActivatedRouteSnapshot, handle: DetachedRouteHandle | null): void {
@@ -29,7 +29,7 @@ export class OperationsRouteReuseStrategy extends BaseRouteReuseStrategy {
   override shouldAttach(route: ActivatedRouteSnapshot): boolean {
     const token = this.token();
     this.clearIfSessionChanged(token);
-    return Boolean(token && route.data['reuseOperationsMenu'] && this.saved.get(route.routeConfig?.path || '')?.token === token);
+    return Boolean(token && this.reusable(route) && this.saved.get(route.routeConfig?.path || '')?.token === token);
   }
 
   override retrieve(route: ActivatedRouteSnapshot): DetachedRouteHandle | null {
@@ -41,6 +41,10 @@ export class OperationsRouteReuseStrategy extends BaseRouteReuseStrategy {
     return saved.handle;
   }
 
+  private reusable(route: ActivatedRouteSnapshot): boolean {
+    return Boolean(route.data['reuseOperationsMenu']) && !(route.routeConfig?.path === 'lessons' && route.queryParamMap?.has('studentId'));
+  }
+
   private token(): string {
     try { return JSON.parse(sessionStorage.getItem(ADMIN_SESSION_KEY) || 'null')?.sessionToken || ''; }
     catch { return ''; }

+ 2 - 2
projects/xiaoshu-admin/src/app/operations.service.ts

@@ -164,8 +164,8 @@ export class OperationsService {
     return this.functions.operations<PayrollDetail>('ops/payroll/preview', { month });
   }
 
-  payrollOverview(month: string, storeId = 0, search = '', refresh = false): Promise<PayrollOverview> {
-    return this.functions.operations<PayrollOverview>('ops/payroll/overview', { month, storeId, search, refresh });
+  payrollOverview(month: string, storeId = 0, search = '', refresh = false, displayOnly = false): Promise<PayrollOverview> {
+    return this.functions.operations<PayrollOverview>('ops/payroll/overview', { month, storeId, search, refresh, ...(displayOnly ? { displayOnly: true } : {}) });
   }
 
   payrollDetail(targetId: string): Promise<PayrollDetail> {

+ 7 - 0
projects/xiaoshu-admin/src/app/pages/lesson-records.component.spec.ts

@@ -7,6 +7,13 @@ describe('训练记录与复习转交',()=>{
  let ops:jasmine.SpyObj<OperationsService>;
  beforeEach(()=>{ops=jasmine.createSpyObj<OperationsService>('OperationsService',['request']);TestBed.configureTestingModule({providers:[{provide:OperationsService,useValue:ops},{provide:ActivatedRoute,useValue:{snapshot:{queryParamMap:convertToParamMap({})}}}]});});
  const page=(items:unknown[],total=2)=>({items,total,cumulativeTotal:51,page:1,pageSize:100,version:'snapshot1'});
+ it('菜单返回保留筛选和已读数据,30 秒后后台更新失败仍可查看',async()=>{
+  let now=100_000;spyOn(Date,'now').and.callFake(()=>now);
+  const c=TestBed.runInInjectionContext(()=>new LessonRecordsComponent());c.coachName='钱老师';ops.request.and.resolveTo(page([{lessonId:'a'}]));await c.load();
+  c.onOperationsRouteAttach();expect(ops.request).toHaveBeenCalledTimes(1);expect(c.coachName).toBe('钱老师');
+  now+=31_000;ops.request.and.rejectWith(new Error('暂时断网'));c.onOperationsRouteAttach();await Promise.resolve();
+  expect(c.data()?.items.length).toBe(1);expect(c.busy()).toBeFalse();expect(c.error()).toBe('暂时断网');c.ngOnDestroy();
+ });
  it('单个会员查看全部连续读取分页,保持筛选与实际总课次数',async()=>{
   const c=TestBed.runInInjectionContext(()=>new LessonRecordsComponent());c.studentId=20;c.all=true;c.coachName='钱老师';
   ops.request.and.returnValues(Promise.resolve(page([{lessonId:'a'}])),Promise.resolve(page([{lessonId:'b'}])));

+ 6 - 3
projects/xiaoshu-admin/src/app/pages/lesson-records.component.ts

@@ -1,5 +1,5 @@
 import {CommonModule} from '@angular/common';
-import {Component,ChangeDetectionStrategy,Input,OnChanges,OnInit,inject,signal} from '@angular/core';
+import {Component,ChangeDetectionStrategy,Input,OnChanges,OnInit,OnDestroy,inject,signal} from '@angular/core';
 import {FormsModule} from '@angular/forms';
 import {ActivatedRoute} from '@angular/router';
 import {OperationsService} from '../operations.service';
@@ -7,15 +7,18 @@ import {OperationsDateFilterComponent} from '../components/operations-date-filte
 import {LessonRecordDetailComponent} from './lesson-record-detail.component';
 import {LessonHistoryPage,LessonHistoryRow} from './lesson-record.models';
 @Component({selector:'app-lesson-records',standalone:true,imports:[CommonModule,FormsModule,LessonRecordDetailComponent,OperationsDateFilterComponent],templateUrl:'./lesson-records.component.html',styleUrls:['./operations-professional.scss','./lesson-records.component.scss'],changeDetection:ChangeDetectionStrategy.OnPush})
-export class LessonRecordsComponent implements OnInit,OnChanges {
+export class LessonRecordsComponent implements OnInit,OnChanges,OnDestroy {
  @Input() studentId=0;@Input() embedded=false;@Input() refreshVersion=0;
  readonly data=signal<LessonHistoryPage|null>(null);readonly busy=signal(false);readonly error=signal('');readonly detailId=signal('');readonly exporting=signal(false);
  page=1;pageSize=20;search='';memberId='';coachName='';storeName='';courseName='';dateFrom='';dateTo='';all=false;
  private readonly ops=inject(OperationsService);private readonly route=inject(ActivatedRoute);private serial=0;private ready=false;
+ private loadedAt=0;private readonly changeSubscription=this.ops.dataChanges$?.subscribe(()=>{this.loadedAt=0;});
+ ngOnDestroy():void{this.changeSubscription?.unsubscribe();}
+ onOperationsRouteAttach():void{if(!this.busy()&&!this.exporting()&&Date.now()-this.loadedAt>30_000)void this.load(true);}
  ngOnInit():void{this.ready=true;this.memberId=this.route.snapshot.queryParamMap.get('studentId')||'';void this.load();}
  ngOnChanges():void{if(this.ready){this.page=1;this.all=false;void this.load();}}
  params():Record<string,unknown>{return{studentId:this.studentId||this.memberId,search:this.search,coachName:this.coachName,storeName:this.storeName,courseName:this.courseName,dateFrom:this.dateFrom,dateTo:this.dateTo};}
- async load():Promise<void>{const serial=++this.serial;this.busy.set(true);this.error.set('');const params=this.params();try{let d=await this.ops.request<LessonHistoryPage>('ops/lessons/list',{...params,page:this.page,pageSize:this.all?100:this.pageSize});if(this.all){const items=[...d.items];for(let p=2;items.length<d.total;p++){if(serial!==this.serial)return;const next=await this.ops.request<LessonHistoryPage>('ops/lessons/list',{...params,page:p,pageSize:100});if(!next.items.length||next.total!==d.total||next.version!==d.version)throw Error('记录发生变化,请重新查询');items.push(...next.items);}d={...d,items};}if(serial===this.serial)this.data.set(d);}catch(e){if(serial===this.serial){this.data.set(null);this.error.set(e instanceof Error?e.message:'查询失败');}}finally{if(serial===this.serial)this.busy.set(false);}}
+ async load(background=false):Promise<void>{const serial=++this.serial;if(!background)this.busy.set(true);this.error.set('');const params=this.params();try{let d=await this.ops.request<LessonHistoryPage>('ops/lessons/list',{...params,page:this.page,pageSize:this.all?100:this.pageSize});if(this.all){const items=[...d.items];for(let p=2;items.length<d.total;p++){if(serial!==this.serial)return;const next=await this.ops.request<LessonHistoryPage>('ops/lessons/list',{...params,page:p,pageSize:100});if(!next.items.length||next.total!==d.total||next.version!==d.version)throw Error('记录发生变化,请重新查询');items.push(...next.items);}d={...d,items};}if(serial===this.serial){this.data.set(d);this.loadedAt=Date.now();}}catch(e){if(serial===this.serial){if(!background)this.data.set(null);this.error.set(e instanceof Error?e.message:'查询失败');}}finally{if(serial===this.serial)this.busy.set(false);}}
  query():void{this.page=1;this.all=false;void this.load();}
  reset():void{this.search='';this.memberId='';this.coachName='';this.storeName='';this.courseName='';this.dateFrom='';this.dateTo='';this.query();}
  showAll():void{if(!(this.studentId||this.memberId))return;this.all=true;this.page=1;void this.load();}

+ 3 - 3
projects/xiaoshu-admin/src/app/pages/operations-payroll.component.html

@@ -5,7 +5,7 @@
 @if(tab()==='overview'){
  <div class="payroll-source-row" [class.delayed]="!overview()?.sourceStatus?.sync?.settlementReady">
   @if(overview();as data){
-   @if(data.sourceStatus.sync;as sync){<div class="sync-status" role="status"><strong>{{sync.settlementReady?'费用截止来源已确认':sync.state==='batch_verified'?'旧接口已同步 · 结算待核对':'结算暂不可用'}}</strong><span>来源同步:{{sync.lastSuccessAt?(sync.lastSuccessAt|date:'yyyy-MM-dd HH:mm:ss':'+0800'):'尚无成功记录'}} · 延迟 {{sync.lagSeconds==null?'未知':sync.lagSeconds+' 秒'}}</span><small>{{sync.message}}</small></div>}@else{<p class="sync-status" role="alert">无法核验旧系统同步状态,当前统计仅供查看,不能结算。</p>}
+   @if(data.sourceStatus.sync;as sync){<div class="sync-status" role="status"><strong>{{verifying()?'金额可查看 · 正在核验结算资格':sync.settlementReady?'费用截止来源已确认':sync.state==='batch_verified'?'旧接口已同步 · 结算待核对':'结算暂不可用'}}</strong>@if(verifying()){<span>核验完成后自动更新结算状态,当前可查看金额与明细。</span>}@else{<span>来源同步:{{sync.lastSuccessAt?(sync.lastSuccessAt|date:'yyyy-MM-dd HH:mm:ss':'+0800'):'尚无成功记录'}} · 延迟 {{sync.lagSeconds==null?'未知':sync.lagSeconds+' 秒'}}</span>}<small>{{sync.message}}</small></div>}@else{<p class="sync-status" role="alert">无法核验旧系统同步状态,当前统计仅供查看,不能结算。</p>}
   }
   <app-payroll-cutoff [month]="month()" [canConfirm]="isManager()&&!isStore()" (changed)="load(false)" />
  </div>
@@ -14,7 +14,7 @@
 @if(tab()==='overview'){
  @if(overview();as data){
   <section class="payroll-metrics overview-metrics" aria-label="费用收益摘要"><article><span>参与陪练</span><strong>{{data.coachCount}}</strong><small>本月有课酬或抗遗忘费用</small></article><article><span>有效课次</span><strong>{{data.lessonCount}}</strong><small>含课型4 {{data.type4Count||0}} 节</small></article><article><span>总工时</span><strong>{{data.totalHours|number:'1.1-1'}}h</strong><small>课型4不计工时</small></article><article><span>基础费用</span><strong>¥{{data.baseAmount|number:'1.2-2'}}</strong><small>优先使用已保存的费用快照</small></article><article><span>抗遗忘费用</span><strong>¥{{data.reviewAmount||0|number:'1.2-2'}}</strong><small>有效复习 {{data.reviewCount||0}} 次</small></article><article><span>当月收入</span><strong>¥{{data.payableAmount|number:'1.2-2'}}</strong><small>按收入月份汇总,结算调整另列</small></article><article><span>批次结算应发</span><strong>{{data.settlementAmount==null?'未生成':'¥'+(data.settlementAmount|number:'1.2-2')}}</strong><small>调整 ¥{{data.settlementAdjustmentAmount||0|number:'1.2-2'}} · 补结 ¥{{data.settlementCarryAmount||0|number:'1.2-2'}}</small></article><article class="exception-card"><span>异常记录</span><strong>{{data.exceptionCount}}</strong><small>审核费用前必须处理</small></article></section>
-  <section class="content-card payroll-table-card"><header><div><strong>{{data.month}} 老师收益汇总</strong><span>点击老师查看课酬与抗遗忘费用明细</span></div>@if(isManager()){<button class="ui-button primary create-payroll-action" type="button" (click)="create()" [disabled]="saving()||!data.sourceStatus.sync?.settlementReady"><lucide-icon [img]="icons.Plus" [size]="17" />生成{{isStore()?'本店':'全公司'}}费用草稿</button>}</header><div class="table-scroll"><table class="data-table payroll-summary-table"><thead><tr><th>陪练老师</th><th>门店</th><th>30分钟</th><th>60分钟</th><th>体验训练</th><th>课型4</th><th>总课次</th><th>工时</th><th>基础费用</th><th>复习次数</th><th>抗遗忘费用</th><th>当月收入</th><th>明细</th></tr></thead><tbody>@for(row of data.coachItems;track row.coachId){<tr><td><strong>{{coachLabel(row.coachName,row.coachId)}}</strong><small>ID {{row.coachId}}</small></td><td>{{row.storeName}}</td><td>{{row.type30Count}}</td><td>{{row.type60Count}}</td><td>{{row.trialCount}}</td><td>{{row.type4Count}}</td><td>{{row.lessonCount}}</td><td>{{row.totalHours|number:'1.1-1'}}h</td><td>¥{{row.baseAmount|number:'1.2-2'}}</td><td>{{row.reviewCount||0}}</td><td>¥{{row.reviewAmount||0|number:'1.2-2'}}</td><td class="money">¥{{row.payableAmount|number:'1.2-2'}}</td><td><button class="icon-action view-action" type="button" (click)="openCoach(row)" aria-label="查看老师训练明细" title="查看明细"><lucide-icon [img]="icons.Eye" [size]="17" /></button></td></tr>}@empty{<tr><td colspan="13"><div class="empty">当前筛选范围没有有效计薪记录</div></td></tr>}</tbody></table></div>@if(data.type4Count){<p class="type4-note">课型4“陪练”按原系统口径只计入课次,不计算费用和工时。</p>}</section>
+  <section class="content-card payroll-table-card"><header><div><strong>{{data.month}} 老师收益汇总</strong><span>点击老师查看课酬与抗遗忘费用明细</span></div>@if(isManager()){<button class="ui-button primary create-payroll-action" type="button" (click)="create()" [disabled]="saving()||loading()||verifying()||!data.sourceStatus.sync?.settlementReady"><lucide-icon [img]="icons.Plus" [size]="17" />生成{{isStore()?'本店':'全公司'}}费用草稿</button>}</header><div class="table-scroll"><table class="data-table payroll-summary-table"><thead><tr><th>陪练老师</th><th>门店</th><th>30分钟</th><th>60分钟</th><th>体验训练</th><th>课型4</th><th>总课次</th><th>工时</th><th>基础费用</th><th>复习次数</th><th>抗遗忘费用</th><th>当月收入</th><th>明细</th></tr></thead><tbody>@for(row of data.coachItems;track row.coachId){<tr><td><strong>{{coachLabel(row.coachName,row.coachId)}}</strong><small>ID {{row.coachId}}</small></td><td>{{row.storeName}}</td><td>{{row.type30Count}}</td><td>{{row.type60Count}}</td><td>{{row.trialCount}}</td><td>{{row.type4Count}}</td><td>{{row.lessonCount}}</td><td>{{row.totalHours|number:'1.1-1'}}h</td><td>¥{{row.baseAmount|number:'1.2-2'}}</td><td>{{row.reviewCount||0}}</td><td>¥{{row.reviewAmount||0|number:'1.2-2'}}</td><td class="money">¥{{row.payableAmount|number:'1.2-2'}}</td><td><button class="icon-action view-action" type="button" (click)="openCoach(row)" aria-label="查看老师训练明细" title="查看明细"><lucide-icon [img]="icons.Eye" [size]="17" /></button></td></tr>}@empty{<tr><td colspan="13"><div class="empty">当前筛选范围没有有效计薪记录</div></td></tr>}</tbody></table></div>@if(data.type4Count){<p class="type4-note">课型4“陪练”按原系统口径只计入课次,不计算费用和工时。</p>}</section>
  }
 } @else if(tab()==='rewards'){
  @if(rewardCoachId()){<p class="notice">当前查看老师 ID {{rewardCoachId()}} <button class="ui-button secondary compact" (click)="clearRewardCoach()">查看全部老师</button></p>}
@@ -50,5 +50,5 @@
 @if(detail()||selectedCoachId()){
  <div class="backdrop" (click)="closeDrawer()"></div><aside class="drawer payroll-drawer" appAccessibleDialog (dialogEscape)="closeDrawer()" role="dialog" aria-modal="true" aria-label="费用明细"><header><div><p>{{detail()?.objectId?'PAYROLL BATCH':'COACH LESSONS'}}</p><h2>@if(detail();as batch){ {{batch.month}} · {{batch.statusLabel}} }@else{ {{selectedCoachName()||'老师训练明细'}} }</h2><span>{{coachLines().length}} 条来源记录</span></div><button class="icon-action" type="button" aria-label="关闭" (click)="closeDrawer()"><lucide-icon [img]="icons.X" [size]="20" /></button></header>@if(detail()?.exceptionCount){<p class="notice error">存在 {{detail()?.exceptionCount}} 条异常,处理完成前不能审核。</p>}<div class="table-scroll"><table class="data-table"><thead><tr><th>陪练</th><th>学员 / 课程</th><th>日期</th><th>课型</th><th>工时</th><th>单价</th><th>金额</th><th>来源ID</th><th>说明</th></tr></thead><tbody>@for(line of coachLines();track line.objectId){<tr [class.muted]="line.excluded"><td>{{line.coachName}}<small>{{line.storeName}}</small></td><td>{{line.studentName}}<small>{{line.courseName}}</small></td><td>{{line.lessonAt|date:'MM-dd HH:mm':'+0800'}}</td><td>{{line.kind==='review_reward'?'抗遗忘费用':line.classTypeLabel}}</td><td>{{line.durationHours}}h</td><td>¥{{line.rateAmount|number:'1.2-2'}}</td><td class="money">¥{{line.amount|number:'1.2-2'}}</td><td>{{line.reviewTaskId||line.legacyGeneralId||'—'}}</td><td [class.exception]="line.exception&&!line.excluded">{{line.excluded?'已排除:'+(line['exclusionReason']||'未填写原因'):line.exception||line.description||'—'}} @if(detail()?.status==='draft'&&isManager()&&line.exception&&!line.excluded){<button class="ui-button danger compact" type="button" (click)="excludeLine(line.objectId)">排除</button>}</td></tr>}@empty{<tr><td colspan="9"><div class="empty">暂无可查看记录</div></td></tr>}</tbody></table></div>
  @if(detail();as batch){@if(batch.status==='draft'&&isManager()){<section class="panel"><h3>新增费用调整</h3><div class="form-grid"><label>陪练老师<select [ngModel]="adjustmentCoachId()" (ngModelChange)="adjustmentCoachId.set(+$event)"><option [ngValue]="0">请选择陪练</option>@for(coach of adjustmentCoaches();track coach.coachId){<option [ngValue]="coach.coachId">{{coachLabel(coach.coachName,coach.coachId)}}</option>}</select></label><label>调整金额<input type="number" step="0.01" [ngModel]="adjustmentAmount()" (ngModelChange)="adjustmentAmount.set(+$event)" placeholder="负数表示扣减" /></label><label class="wide">调整 / 排除原因<input [ngModel]="adjustmentReason()" (ngModelChange)="adjustmentReason.set($event)" placeholder="必填,将写入审计日志" /></label><button class="ui-button secondary" type="button" (click)="addAdjustment()" [disabled]="saving()">添加调整</button></div></section>}@if(batch.status==='reviewed'&&isManager()){<section class="panel"><h3>登记线下发放</h3><div class="form-grid"><label>发放方式<input [ngModel]="paymentMethod()" (ngModelChange)="paymentMethod.set($event)" /></label><label>凭证编号<input [ngModel]="paymentReference()" (ngModelChange)="paymentReference.set($event)" placeholder="银行流水或内部凭证号" /></label></div></section>}}
- <footer><button class="ui-button secondary" type="button" (click)="exportExcel()"><lucide-icon [img]="icons.Download" [size]="16" />导出 Excel</button>@if(detail();as batch){@if(isManager()&&batch.status==='draft'){<button class="ui-button secondary" type="button" (click)="refreshDraft()" [disabled]="saving()||!overview()?.sourceStatus?.sync?.settlementReady">刷新并增量重算</button><button class="ui-button primary" type="button" (click)="review()" [disabled]="batch.exceptionCount>0||saving()||!overview()?.sourceStatus?.sync?.settlementReady">审核并永久锁定</button>}@else if(isManager()&&batch.status==='reviewed'){<button class="ui-button primary" type="button" (click)="markPaid()" [disabled]="saving()">登记已发放</button>}}</footer></aside>
+ <footer><button class="ui-button secondary" type="button" (click)="exportExcel()"><lucide-icon [img]="icons.Download" [size]="16" />导出 Excel</button>@if(detail();as batch){@if(isManager()&&batch.status==='draft'){<button class="ui-button secondary" type="button" (click)="refreshDraft()" [disabled]="saving()||loading()||verifying()||!overview()?.sourceStatus?.sync?.settlementReady">刷新并增量重算</button><button class="ui-button primary" type="button" (click)="review()" [disabled]="batch.exceptionCount>0||saving()||loading()||verifying()||!overview()?.sourceStatus?.sync?.settlementReady">审核并永久锁定</button>}@else if(isManager()&&batch.status==='reviewed'){<button class="ui-button primary" type="button" (click)="markPaid()" [disabled]="saving()">登记已发放</button>}}</footer></aside>
 }

+ 32 - 0
projects/xiaoshu-admin/src/app/pages/operations-payroll.component.spec.ts

@@ -104,3 +104,35 @@ describe('payroll interactions', () => {
   await component.create();expect(service.mutate).not.toHaveBeenCalled();expect(component.error()).toContain('旧系统数据尚未追平');
  });
 });
+
+describe('payroll progressive readiness',()=>{
+ let component:OperationsPayrollComponent;
+ let ops:jasmine.SpyObj<OperationsService>;
+ const value=(month:string,ready=false)=>({month,lines:[],coachItems:[],stores:[],sourceStatus:{sync:{settlementReady:ready}}} as unknown as PayrollOverview);
+ beforeEach(()=>{
+  ops=jasmine.createSpyObj<OperationsService>('ops',['payrollOverview','payrolls','payRates','teachingStandards','request','mutate']);
+  ops.payrolls.and.resolveTo({items:[]} as never);ops.payRates.and.resolveTo({items:[]} as never);ops.teachingStandards.and.resolveTo({mappings:[],rules:[],categories:[]} as never);ops.request.and.resolveTo({items:[]});
+  TestBed.configureTestingModule({providers:[{provide:OperationsService,useValue:ops},{provide:AdminSessionService,useValue:{isStore:()=>false,session:()=>({operationsRole:'ops-manager'})}}]});
+  component=TestBed.runInInjectionContext(()=>new OperationsPayrollComponent());
+ });
+ it('shows real amounts while verification is pending and never authorizes settlement early',async()=>{
+  let finish!:(v:PayrollOverview)=>void;
+  ops.payrollOverview.and.callFake((month,_store,_search,_refresh,displayOnly)=>displayOnly?Promise.resolve(value(month)):new Promise(resolve=>finish=resolve));
+  const pending=component.load(true,false,true);
+  for(let i=0;i<8;i++)await Promise.resolve();
+  expect(component.overview()).not.toBeNull();expect(component.loading()).toBeFalse();expect(component.verifying()).toBeTrue();
+  await component.create();expect(ops.mutate).not.toHaveBeenCalled();
+  finish(value(component.month(),true));await pending;
+  expect(component.verifying()).toBeFalse();expect(component.overview()?.sourceStatus.sync?.settlementReady).toBeTrue();
+ });
+ it('retains inspectable amounts but keeps settlement closed after verification fails',async()=>{
+  ops.payrollOverview.and.callFake((month,_store,_search,_refresh,displayOnly)=>displayOnly?Promise.resolve(value(month)):Promise.reject(new Error('timeout')));
+  await component.load(true,false,true);expect(component.overview()).not.toBeNull();expect(component.overview()?.sourceStatus.sync?.settlementReady).toBeFalse();expect(component.error()).toContain('核验失败');
+ });
+ it('does not let a previous month verification replace the newly selected month',async()=>{
+  let old!:(v:PayrollOverview)=>void;
+  ops.payrollOverview.and.callFake((month,_store,_search,_refresh,displayOnly)=>displayOnly?Promise.resolve(value(month)):month==='2026-09'?new Promise(resolve=>old=resolve):Promise.resolve(value(month,true)));
+  component.month.set('2026-09');const first=component.load(true,false,true);for(let i=0;i<8;i++)await Promise.resolve();
+  component.month.set('2026-10');await component.load(true,false,true);old(value('2026-09',true));await first;expect(component.overview()?.month).toBe('2026-10');
+ });
+});

Fichier diff supprimé car celui-ci est trop grand
+ 0 - 0
projects/xiaoshu-admin/src/app/pages/operations-payroll.component.ts


Fichier diff supprimé car celui-ci est trop grand
+ 0 - 0
projects/xiaoshu-admin/src/app/pages/operations-special-training.component.html


+ 8 - 0
projects/xiaoshu-admin/src/app/pages/support-requests.component.spec.ts

@@ -10,6 +10,14 @@ describe('反馈与休假申请看板', () => {
     ops = jasmine.createSpyObj('OperationsService', ['request']);
     TestBed.configureTestingModule({providers: [{provide: OperationsService, useValue: ops}]});
   });
+  it('restores a warm menu without a request and refreshes an old snapshot in the background', async () => {
+    let now=100_000;spyOn(Date,'now').and.callFake(()=>now);
+    const c=create();ops.request.and.resolveTo(data());await c.load();
+    c.onOperationsRouteAttach();expect(ops.request).toHaveBeenCalledTimes(1);
+    now+=31_000;let finish!:(value:unknown)=>void;ops.request.and.returnValue(new Promise(r=>finish=r));
+    c.onOperationsRouteAttach();expect(c.data()?.page).toBe(1);expect(c.loading()).toBeFalse();
+    finish(data(2));await Promise.resolve();expect(c.data()?.page).toBe(2);c.ngOnDestroy();
+  });
   it('keeps applied filters when paging even if draft fields changed', async () => {
     const c = create(); ops.request.and.resolveTo(data());
     c.search = ' 老师 '; c.kind = 'leave'; c.apply();

+ 9 - 5
projects/xiaoshu-admin/src/app/pages/support-requests.component.ts

@@ -1,5 +1,5 @@
 import { CommonModule } from '@angular/common';
-import { ChangeDetectionStrategy, Component, inject, OnInit, signal } from '@angular/core';
+import { ChangeDetectionStrategy, Component, inject, OnInit, OnDestroy, signal } from '@angular/core';
 import { FormsModule } from '@angular/forms';
 import { OperationsService } from '../operations.service';
 
@@ -17,7 +17,7 @@ interface SupportPage {
   styleUrls: ['./operations-professional.scss', './support-requests.component.scss'],
   changeDetection: ChangeDetectionStrategy.OnPush,
 })
-export class SupportRequestsComponent implements OnInit {
+export class SupportRequestsComponent implements OnInit, OnDestroy {
   readonly Math = Math;
   private readonly ops = inject(OperationsService);
   readonly data = signal<SupportPage | null>(null);
@@ -29,6 +29,10 @@ export class SupportRequestsComponent implements OnInit {
   decision = ''; comment = '';
   search = ''; kind = ''; role = ''; status = ''; dateFrom = ''; dateTo = '';
   private applied = {search: '', kind: '', role: '', status: '', dateFrom: '', dateTo: ''};
+  private loadedAt = 0;
+  private readonly changeSubscription = this.ops.dataChanges$?.subscribe(() => { this.loadedAt = 0; });
+  ngOnDestroy(): void { this.changeSubscription?.unsubscribe(); }
+  onOperationsRouteAttach(): void { if (!this.loading() && !this.saving() && Date.now() - this.loadedAt > 30_000) void this.load(this.requestedPage, true); }
   private revision = 0;
   private requestedPage = 1;
   ngOnInit(): void { void this.load(); }
@@ -53,13 +57,13 @@ export class SupportRequestsComponent implements OnInit {
     void this.load(1);
   }
   reset(): void { this.search = this.kind = this.role = this.status = this.dateFrom = this.dateTo = ''; this.apply(); }
-  async load(page = this.requestedPage): Promise<void> {
+  async load(page = this.requestedPage, background = false): Promise<void> {
     this.requestedPage = page;
     const revision = ++this.revision;
-    this.loading.set(true); this.error.set(''); this.data.set(null);
+    if (!background) { this.loading.set(true); this.data.set(null); } this.error.set('');
     try {
       const data = await this.ops.request<SupportPage>('ops/support-requests/list', {...this.applied,page,pageSize:20});
-      if (revision === this.revision) this.data.set(data);
+      if (revision === this.revision) { this.data.set(data); this.loadedAt = Date.now(); }
     } catch (error) {
       if (revision === this.revision) this.error.set(error instanceof Error ? error.message : '读取失败,请重试');
     } finally { if (revision === this.revision) this.loading.set(false); }

+ 13 - 0
scripts/admin-speed-patch.mjs

@@ -0,0 +1,13 @@
+import ts from 'typescript';
+import {readFileSync} from 'node:fs';
+const gateway=readFileSync(new URL('./deploy-admin-functions.mjs',import.meta.url),'utf8').match(/const operationsGatewayCode = String.raw`([\s\S]*?)\n`;/)[1];
+const sources=readFileSync(new URL('./cloud/lesson-records.js',import.meta.url),'utf8')+'\n'+gateway+'\n'+readFileSync(new URL('./cloud/payroll-integrity.js',import.meta.url),'utf8');
+export const speedHelpers=['businessDate','payrollBusinessDate','queryUsers','coachRows','allAppointmentRows','appointmentRowsInRange','professionalScheduleData','professionalDashboardFast','scheduleTrendFast','learningReportQueryParts','vocabularyTree','vocabularyRows','specialRows','lessonHistoryBase','lessonHistoryPage','payrollOverviewData','payrollCanonicalSnapshot','professionalCoachRows','storeRows','usersPage'];
+function functions(code){const file=ts.createSourceFile('gateway.js',code,ts.ScriptTarget.Latest,true,ts.ScriptKind.JS);return new Map(file.statements.filter(ts.isFunctionDeclaration).map(node=>[node.name?.text,{start:node.getStart(file),end:node.end,text:node.getText(file)}]));}
+export function patchAdminSpeed(original){
+ const current=functions(original),next=functions(sources);
+ const edits=speedHelpers.map(name=>{if(!current.has(name)||!next.has(name))throw Error('Missing helper '+name);return {...current.get(name),text:next.get(name).text};}).sort((a,b)=>b.start-a.start);
+ let result=original;for(const edit of edits)result=result.slice(0,edit.start)+edit.text+result.slice(edit.end);
+ for(const name of ['lessonHistoryLabelColumns','lessonHistoryLabelJoins']){if(current.has(name))throw Error('Unexpected existing helper '+name);result+='\n'+next.get(name).text;}
+ new Function(result);return result;
+}

+ 16 - 12
scripts/cloud/lesson-records.js

@@ -1,21 +1,24 @@
 // Shared classroom history for member detail, scheduling and the records centre.
 const LESSON_RECORDS_VERSION='2026-10-13-lesson-records-v10';
-function lessonHistoryBase(){return `WITH facts AS (
- SELECT l.*,c."objectId" AS "targetId",c."generalId",c."createdAt" AS "commonCreatedAt",
- COALESCE(NULLIF(NULLIF(l.yyds,'0'),''),'lesson:'||l."objectId") AS "classKey",
- COALESCE(r."actualStartAt",l."lessonAt",xiaoshu_safe_shanghai_timestamp(l.plpjsj),c."createdAt",l."sourceCreatedAt",l."createdAt") AS "actualAt",
- COALESCE(NULLIF(s."realName",''),NULLIF(s.nickname,''),s."legacyUserData"->>'HoneyName',s.username,l."studentName",'学员 '||l.xymz) AS "studentNameResolved",
+function lessonHistoryLabelColumns(){return ` COALESCE(NULLIF(s."realName",''),NULLIF(s.nickname,''),s."legacyUserData"->>'HoneyName',s.username,l."studentName",'学员 '||l.xymz) AS "studentNameResolved",
  COALESCE(NULLIF(p."realName",''),NULLIF(p.nickname,''),p."legacyUserData"->>'HoneyName',p.username,l."coachName",'历史陪练 '||l.jsmz) AS "coachNameResolved",
  COALESCE(NULLIF(l.kcmc,''),NULLIF(l."courseName",''),n."nodeName",'未记录课程') AS "courseNameResolved",
  COALESCE(NULLIF(l.szmdid,''),NULLIF(l."storeId"::text,'0'),s."legacyUserData"->>'ParentUserID') AS "storeIdResolved",
- COALESCE(NULLIF(st."realName",''),NULLIF(st.nickname,''),st."legacyUserData"->>'HoneyName',st.username,'未记录门店') AS "storeName",
- r."contentSummary" AS "feedbackSummary"
- FROM "LessonRecord" l
- LEFT JOIN LATERAL (SELECT * FROM "CommonModel" c WHERE c.company=l.company AND c."modelId"=59 AND c."itemId"=l.id ORDER BY c."objectId" LIMIT 1) c ON true
- LEFT JOIN LATERAL (SELECT * FROM "_User" u WHERE u.company=l.company AND u."legacyUserId"::text=l.xymz ORDER BY u."objectId" LIMIT 1) s ON true
+ COALESCE(NULLIF(st."realName",''),NULLIF(st.nickname,''),st."legacyUserData"->>'HoneyName',st.username,'未记录门店') AS "storeName"`;}
+function lessonHistoryLabelJoins(){return ` LEFT JOIN LATERAL (SELECT * FROM "_User" u WHERE u.company=l.company AND u."legacyUserId"::text=l.xymz ORDER BY u."objectId" LIMIT 1) s ON true
  LEFT JOIN LATERAL (SELECT * FROM "_User" u WHERE u.company=l.company AND u."legacyUserId"::text=l.jsmz ORDER BY u."objectId" LIMIT 1) p ON true
  LEFT JOIN LATERAL (SELECT * FROM "_User" u WHERE u.company=l.company AND u."legacyUserId"::text=COALESCE(NULLIF(l.szmdid,''),NULLIF(l."storeId"::text,'0'),s."legacyUserData"->>'ParentUserID') ORDER BY u."objectId" LIMIT 1) st ON true
  LEFT JOIN LATERAL (SELECT "nodeName" FROM "Node" n WHERE n.company=l.company AND n."nodeId"::text=l.kcid LIMIT 1) n ON true
+`;}
+function lessonHistoryBase(lean=false){return `WITH facts AS (
+ SELECT l.*,c."objectId" AS "targetId",c."generalId",c."createdAt" AS "commonCreatedAt",
+ COALESCE(NULLIF(NULLIF(l.yyds,'0'),''),'lesson:'||l."objectId") AS "classKey",
+ COALESCE(r."actualStartAt",l."lessonAt",xiaoshu_safe_shanghai_timestamp(l.plpjsj),c."createdAt",l."sourceCreatedAt",l."createdAt") AS "actualAt",
+${lean?'':lessonHistoryLabelColumns()+','}
+ r."contentSummary" AS "feedbackSummary"
+ FROM "LessonRecord" l
+ LEFT JOIN LATERAL (SELECT * FROM "CommonModel" c WHERE c.company=l.company AND c."modelId"=59 AND c."itemId"=l.id ORDER BY c."objectId" LIMIT 1) c ON true
+${lean?'':lessonHistoryLabelJoins()}
  LEFT JOIN LATERAL (SELECT "contentSummary","actualStartAt" FROM "LessonCompletionReport" r WHERE r.company=l.company AND (r."lessonId"=l."objectId" OR r."appointmentGeneralId"::text=l.yyds) ORDER BY r."submittedAt" DESC LIMIT 1) r ON true
  WHERE l.company=$1
  ), ranked AS (SELECT facts.*,row_number() OVER(PARTITION BY company,xymz,"classKey" ORDER BY "actualAt" DESC NULLS LAST,"objectId") rn,count(*) OVER(PARTITION BY company,xymz,"classKey") duplicates FROM facts), lessons AS (SELECT * FROM ranked WHERE rn=1)`;}
@@ -32,9 +35,10 @@ function lessonHistoryRow(row){return {targetId:text(row.targetId||row.objectId)
 async function lessonHistoryPage(context,input){
  requireRole(context,['ops-manager','ops-staff','ops-auditor']);const co=companyIdOf(context,input);if(!co)fail(400,'缺少帐套');
  const args=[co];if(context.storeId)input={...input,storeId:context.storeId,_storeId:context.storeId};const where=lessonHistoryFilters(input,args),cumulativeWhere=lessonHistoryFilters({studentId:input.studentId,studentName:input.studentName,search:input.search,_storeId:input._storeId},args),paging=pageInput(input);args.push(paging.pageSize,(paging.page-1)*paging.pageSize);
- const q=lessonHistoryBase()+`,filtered AS MATERIALIZED (SELECT * FROM lessons${where}), page AS (SELECT * FROM filtered ORDER BY "actualAt" DESC NULLS LAST,"objectId" DESC LIMIT $${args.length-1} OFFSET $${args.length})
+ const filters={...(input.filters||{}),...input},lean=!context.storeId&&!['search','studentName','coachName','storeName','courseName','storeId'].some(key=>text(filters[key]));
+ const q=lessonHistoryBase(lean)+`,filtered AS MATERIALIZED (SELECT * FROM lessons${where}), page AS (SELECT * FROM filtered ORDER BY "actualAt" DESC NULLS LAST,"objectId" DESC LIMIT $${args.length-1} OFFSET $${args.length})${lean?`,display_page AS (SELECT l.*,${lessonHistoryLabelColumns()} FROM page l ${lessonHistoryLabelJoins()})`:``}
  SELECT md5(COALESCE((SELECT string_agg("objectId"||COALESCE("updatedAt"::text,''),',' ORDER BY "objectId") FROM filtered),'')) AS "historyVersion",(SELECT count(*)::int FROM filtered) total,(SELECT count(*)::int FROM lessons${cumulativeWhere}) "cumulativeTotal",
- COALESCE((SELECT jsonb_agg(to_jsonb(p)||jsonb_build_object('pendingReviews',rv.n,'reviewCoaches',rv.coaches) ORDER BY p."actualAt" DESC NULLS LAST,p."objectId" DESC) FROM page p
+ COALESCE((SELECT jsonb_agg(to_jsonb(p)||jsonb_build_object('pendingReviews',rv.n,'reviewCoaches',rv.coaches) ORDER BY p."actualAt" DESC NULLS LAST,p."objectId" DESC) FROM ${lean?'display_page':'page'} p
  LEFT JOIN LATERAL (SELECT COALESCE(sum(n),0) n,jsonb_agg(jsonb_build_object('id',coach,'name',name,'count',n) ORDER BY name) coaches FROM (
  SELECT m.plid::text coach,COALESCE(NULLIF(u."realName",''),NULLIF(u.nickname,''),u."legacyUserData"->>'HoneyName',u.username,'陪练 '||m.plid::text) name,count(*)::int n
  FROM xs_review_task_link link JOIN "MemoryPracticeRecord" m ON m.company=link.company AND m."objectId"=link."taskId"

+ 5 - 3
scripts/cloud/payroll-integrity.js

@@ -1,6 +1,8 @@
 // Payroll entry points share canonical sources and atomic SQL commands.
 function payrollBusinessDate(value) {
-  const iso=payrollDateIso(value);return iso?new Date(iso).toLocaleDateString('sv-SE',{timeZone:'Asia/Shanghai'}):'';
+  const iso=payrollDateIso(value);if(!iso)return '';
+  const formatter=payrollBusinessDate.formatter||(payrollBusinessDate.formatter=new Intl.DateTimeFormat('sv-SE',{timeZone:'Asia/Shanghai'}));
+  return formatter.format(new Date(iso));
 }
 function payrollDateIso(value) {
   if(value instanceof Date)return Number.isNaN(value.getTime())?'':value.toISOString();
@@ -30,7 +32,7 @@ async function payrollCanonicalSnapshot(context,input,options={}) {
   // A display-only estimate cannot authorize settlement. Financial reads and all
   // commands keep the full source version and cutoff checks by default.
   const forDashboard=options.forDashboard===true;
-  const [snapshot,sync]=await Promise.all([payrollSql('SELECT COALESCE(jsonb_agg(s),\'[]\'::jsonb) AS rows,'+((forDashboard||options.omitSourceVersion===true)?"''::text":'xs_payroll_source_version($1)')+' AS version FROM xs_payroll_sources($1) s',[co]),forDashboard?Promise.resolve({state:'not_checked',settlementReady:false,datasets:[],message:'工作台仅展示实时预估金额;结算前须在工资模块核验来源'}):payrollSettlementSyncStatus(context,input)]);
+  const [snapshot,sync]=await Promise.all([payrollSql('SELECT COALESCE(jsonb_agg(s),\'[]\'::jsonb) AS rows,'+((forDashboard||options.omitSourceVersion===true)?"''::text":'xs_payroll_source_version($1)')+' AS version FROM xs_payroll_sources($1) s',[co]),forDashboard?Promise.resolve({state:'not_checked',settlementReady:false,datasets:[],message:'金额已按实时完课记录计算,结算资格仍需完整核验'}):payrollSettlementSyncStatus(context,input)]);
   const users=await Psql.query('SELECT * FROM "_User" WHERE "company"=$1',[co]);
   const map=new Map(users.map(u=>[number(u.legacyUserId||legacyOf(u).UserID),u]));
   const items=(snapshot.rows||[]).map(row=>{
@@ -66,7 +68,7 @@ async function payrollPage(context,input){return storePayrollPage(context,input)
 async function payrollDetailData(context,input,targetId){const co=payrollTenant(context,input),batch=await Psql.oneOrNone('SELECT * FROM "PayrollBatch" WHERE "company"=$1 AND "objectId"=$2',[co,targetId]);if(!batch||context.storeId&&number(batch.scopeStoreId)!==context.storeId)fail(404,'工资单不存在或不属于本店');const rows=await Psql.query('SELECT * FROM "PayrollLine" WHERE "company"=$1 AND "batchId"=$2 ORDER BY "lessonAt" NULLS LAST,"objectId"',[co,targetId]),lines=rows.map(row=>({...payrollLine(row),standardLessonKey:row.standardLessonKey,exclusionReason:row.exclusionReason,coachMobile:row.coachMobile}));return{...payrollSummary(batch),version:batch.version,lines,exceptions:lines.filter(r=>r.exception&&!r.excluded),coachItems:payrollCoachItems(lines,{})};}
 async function payrollOverviewData(context,input){
  if(context.storeId)input={...input,storeId:context.storeId};
- const co=payrollTenant(context,input),month=text(input.month)||businessDate().slice(0,7),income=await payrollPreviewData(context,{...input,storeId:0,search:''},month,true,false,{omitSourceVersion:Boolean(context.storeId)}),batches=(await payrollPage(context,{...input,month,page:1,pageSize:100})).items;
+ const co=payrollTenant(context,input),month=text(input.month)||businessDate().slice(0,7),income=await payrollPreviewData(context,{...input,storeId:0,search:''},month,true,false,{forDashboard:input.displayOnly===true,omitSourceVersion:Boolean(context.storeId)}),batches=(await payrollPage(context,{...input,month,page:1,pageSize:100})).items;
  const batch=batches[0],settlement=batch?await payrollDetailData(context,{},batch.objectId):null;
  const visible=payrollResult(month,income.lines,income.sourceStatus,input),settlementTotals=settlement?payrollResult(month,settlement.lines,income.sourceStatus,input):null;
  const stores=context.storeId?[{storeId:context.storeId,storeName:storeNameOf((context.store||context.current).toJSON())}]:await Psql.query('SELECT "legacyUserId" AS "storeId",COALESCE(NULLIF("realName",\'\'),NULLIF("nickname",\'\'),"username") AS "storeName" FROM "_User" WHERE "company"=$1 AND ("identityType"=\'store\' OR "legacyGroupId"=2) ORDER BY "legacyUserId"',[co]);

Fichier diff supprimé car celui-ci est trop grand
+ 3 - 3
scripts/deploy-admin-functions.mjs


+ 31 - 0
scripts/release-admin-speed.mjs

@@ -0,0 +1,31 @@
+#!/usr/bin/env node
+// --prepare is read-only. Deploy/rollback only after the user's release decision.
+import {readFileSync,writeFileSync,mkdirSync,existsSync} from 'node:fs';
+import {createHash} from 'node:crypto';
+import {homedir} from 'node:os';
+import {join} from 'node:path';
+import {patchAdminSpeed,speedHelpers} from './admin-speed-patch.mjs';
+const mode=process.argv.includes('--deploy')?'deploy':process.argv.includes('--rollback')?'rollback':'prepare';
+const dir=process.env.XIAOSHU_SPEED_RELEASE_DIR||join(homedir(),'.local/state/xiaoshu/cloud-releases/2026-11-10-admin-speed');
+const base=process.env.XIAOSHU_PARSE_URL||'https://server.xiaoshu.pro/parse',app=process.env.XIAOSHU_PARSE_APP_ID,master=process.env.XIAOSHU_MASTER_KEY,token=process.env.XIAOSHU_SYNC_OPERATOR_TOKEN;
+if(!app||!master||!token)throw Error('Missing authorized release configuration');
+const hash=s=>createHash('sha256').update(s).digest('hex');
+async function parse(path,method='GET',body){const r=await fetch(base+path,{method,headers:{'Content-Type':'application/json','X-Parse-Application-Id':app,'X-Parse-Master-Key':master},body:body?JSON.stringify(body):undefined,signal:AbortSignal.timeout(30000)});if(!r.ok)throw Error('Release API HTTP '+r.status);const d=await r.json();if(d.error)throw Error('Release API rejected request');return d;}
+mkdirSync(dir,{recursive:true,mode:0o700});
+if(mode==='prepare'){
+ if(existsSync(join(dir,'deploy-receipt.json')))throw Error('Release already deployed; choose a new directory to prepare another release');
+ const rows=(await parse('/classes/Function?'+new URLSearchParams({where:JSON.stringify({path:'xiaoshu/ops/gateway-v3'}),limit:'2'}))).results;if(rows.length!==1)throw Error('Ambiguous gateway');
+ const original=rows[0],candidate=patchAdminSpeed(original.code),manifest={preparedAt:new Date().toISOString(),objectId:original.objectId,path:original.path,beforeHash:hash(original.code),afterHash:hash(candidate),helpers:speedHelpers,newHelpers:['lessonHistoryLabelColumns','lessonHistoryLabelJoins']};
+ writeFileSync(join(dir,'original.json'),JSON.stringify(original),{mode:0o600});writeFileSync(join(dir,'candidate.js'),candidate,{mode:0o600});writeFileSync(join(dir,'manifest.json'),JSON.stringify(manifest,null,2),{mode:0o600});console.log(JSON.stringify({mode,dir,...manifest}));
+}else{
+ const manifest=JSON.parse(readFileSync(join(dir,'manifest.json'),'utf8')),original=JSON.parse(readFileSync(join(dir,'original.json'),'utf8')),candidate=readFileSync(join(dir,'candidate.js'),'utf8');
+ if(hash(original.code)!==manifest.beforeHash||hash(candidate)!==manifest.afterHash)throw Error('Prepared artifacts changed');
+ const current=await parse('/classes/Function/'+manifest.objectId),expected=mode==='deploy'?manifest.beforeHash:manifest.afterHash,target=mode==='deploy'?candidate:original.code;
+ if(hash(current.code)===hash(target)){console.log(JSON.stringify({mode,unchanged:true}));process.exit(0);}
+ if(hash(current.code)!==expected)throw Error('Online gateway changed; reprepare and revalidate instead of overwriting later work');
+ const r=await fetch(base.replace(/\/parse\/?$/,'/api/functions/xiaoshu/sync/direct-v1'),{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({token,params:{action:'status'}}),signal:AbortSignal.timeout(30000)}),s=await r.json();
+ if(!r.ok||s.success===false||!Array.isArray(s.data?.runs)||s.data.runs.some(run=>run.status==='running'))throw Error('Cannot verify synchronization is idle');
+ await parse('/classes/Function/'+manifest.objectId,'PUT',{code:target});
+ const verified=await parse('/classes/Function/'+manifest.objectId);if(hash(verified.code)!==hash(target))throw Error('Release readback mismatch');
+ const receipt={at:new Date().toISOString(),hash:hash(target)};writeFileSync(join(dir,mode+'-receipt.json'),JSON.stringify(receipt),{mode:0o600});console.log(JSON.stringify({mode,verified:true,...receipt}));
+}

+ 1 - 1
scripts/tests/admin-read-performance.test.mjs

@@ -20,7 +20,7 @@ test('set-based course and appointment reads preserve tenant, cancellation, dupl
  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),('a',58,1,99),('a',58,1,99),('a',58,1,-2);`);
  const courseSql=vm.runInNewContext(source.match(/courseSql=('[\s\S]*?'),\[categories/)[1]);
- const scheduleSql=vm.runInNewContext(source.match(/const courseRows=await Psql.query\(('([\s\S]*?)'),\[companyId\]/)[1]);
+ const scheduleSql=vm.runInNewContext(source.match(/Psql.query\(('WITH binding_courses[\s\S]*?'),\[companyId\]/)[1]);
  for(const co of ['a','b',null]){
   assert.deepEqual(await query(scheduleSql,[co]),await query(baseline.scheduleOptions,[co]));
   assert.deepEqual(sorted(await ctx.memberAppointmentStats(co)),sorted(await query(baseline.appointments,[co])));

+ 42 - 0
scripts/tests/admin-speed.test.mjs

@@ -0,0 +1,42 @@
+import {test} from 'node:test';
+import assert from 'node:assert/strict';
+import vm from 'node:vm';
+import {readFileSync} from 'node:fs';
+const source=readFileSync(new URL('../deploy-admin-functions.mjs',import.meta.url),'utf8').match(/const operationsGatewayCode = String.raw`([\s\S]*?)\n`;/)[1];
+const payroll=readFileSync(new URL('../cloud/payroll-integrity.js',import.meta.url),'utf8');
+test('business dates preserve Shanghai boundaries and invalid values without constructing formatters per record',()=>{
+ let slowCalls=0,formatters=0;
+ class MeasuredDate extends Date {toLocaleDateString(...args){slowCalls++;return super.toLocaleDateString(...args);}}
+ const c=vm.createContext({Date:MeasuredDate,Intl:{DateTimeFormat:function(...args){formatters++;return new Intl.DateTimeFormat(...args);}}});
+ vm.runInContext(source+'\n'+payroll,c);
+ const values=['2026-10-31T15:59:59Z','2026-10-31T16:00:00Z','1990-07-01T16:00:00Z','2024-02-29T16:00:00Z','bad','',null,0];
+ for(let i=0;i<1000;i++)values.push(new Date(Date.UTC(1985+i%45,i%12,i%28+1,i%24,i%60)).toISOString());
+ for(const value of values)assert.equal(c.businessDate(value),new Date(value).toLocaleDateString('sv-SE',{timeZone:'Asia/Shanghai'}));
+ for(const value of values){const iso=c.payrollDateIso(value);assert.equal(c.payrollBusinessDate(value),iso?new Date(iso).toLocaleDateString('sv-SE',{timeZone:'Asia/Shanghai'}):'');}
+ assert.equal(slowCalls,0,'record loops must reuse the timezone formatter');
+ assert.equal(formatters,2);
+});
+test('coach list starts its independent reads together instead of serial round trips',async()=>{
+ const pending=[],calls=[];
+ const c=vm.createContext({Date,Psql:{query:sql=>{calls.push(sql);return new Promise(resolve=>pending.push(resolve));}}});vm.runInContext(source,c);
+ c.companyIdOf=()=> 'tenant';c.availabilityRows=async()=>[];
+ const result=c.coachRows({roles:['ops-manager']},{});
+ await Promise.resolve();
+ const started=calls.length;
+ // Let both old serial and new parallel implementations finish before asserting.
+ for(let i=0;i<10;i++){for(const resolve of pending.splice(0))resolve([]);await new Promise(r=>setImmediate(r));}
+ await result;
+ assert.equal(started,3,'users, appointments and lesson facts have no dependency on one another');
+});
+test('release patch changes only the named read helpers and preserves unrelated deployed fixes',async()=>{
+ const {patchAdminSpeed,speedHelpers}=await import('../admin-speed-patch.mjs');
+ const fixture='const privateDeploymentSetting="preserve";\nfunction unrelatedLatestFix(){return 71;}\n'+speedHelpers.map(name=>`function ${name}(){return "old";}`).join('\n');
+ const patched=patchAdminSpeed(fixture);assert.ok(patched.startsWith('const privateDeploymentSetting="preserve";\nfunction unrelatedLatestFix(){return 71;}'));
+ assert.match(patched,/function lessonHistoryLabelColumns/);assert.match(patched,/function lessonHistoryLabelJoins/);assert.throws(()=>patchAdminSpeed('function unrelatedLatestFix(){}'),/Missing helper/);
+});
+test('store membership does not load coach payroll or lesson history',async()=>{
+ const c=vm.createContext({Date,Psql:{query:async()=>[]}});vm.runInContext(source,c);
+ c.companyIdOf=()=> 'tenant';c.queryUsers=async()=>[];c.optionalLegacyMemberSnapshot=async()=>({available:false,items:[],coachIds:[]});c.appointmentSnapshot=async()=>({appointments:[],live:{}});c.operationalSyncIndicator=async()=>({});
+ c.coachRows=()=>{throw Error('unnecessary coach history lookup');};
+ const page=await c.storeRows({roles:['ops-manager']},{});assert.equal(page.total,0);
+});

+ 9 - 0
scripts/tests/lesson-records.test.mjs

@@ -74,3 +74,12 @@ test('全部学员范围只覆盖已有任务,不更改未来课程默认,
  assert.equal(Number((await one('SELECT plid FROM "MemoryPracticeRecord" WHERE "objectId"=\'pending5\'')).plid),10);
  await assert.rejects(ctx.lessonTransferPreview({...manager,company:'other'},input),/仅管理/);
 });
+test('history projection preserves duplicate-account precedence and every filtered page after lookup optimization',async()=>{
+ const original=readFileSync(new URL('../../perf/admin-speed-2026-11-10/lesson-history-baseline.js',import.meta.url),'utf8');
+ const old=vm.createContext({...ctx});vm.runInContext(original,old);
+ await query(`INSERT INTO "_User"("objectId",company,"legacyUserId","legacyGroupId","identityType","realName","legacyUserData") VALUES ('zz-duplicate','c',20,1,'member','duplicate account','{"ParentUserID":40}'),('cross-tenant','d',20,1,'member','other tenant','{}')`);
+ for(const input of [{},{studentId:20},{coachName:'原老师'},{dateFrom:'2026-10-01',dateTo:'2026-10-04'},{page:2,pageSize:10}]){
+  const before=JSON.parse(JSON.stringify(await old.lessonHistoryPage(manager,input))),after=JSON.parse(JSON.stringify(await ctx.lessonHistoryPage(manager,input)));
+  delete before.refreshedAt;delete after.refreshedAt;assert.deepEqual(after,before);
+ }
+});

Certains fichiers n'ont pas été affichés car il y a eu trop de fichiers modifiés dans ce diff