Browse Source

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

彭峰 21 giờ trước cách đây
mục cha
commit
7b8eaba
32 tập tin đã thay đổi với 824 bổ sung và 37 xóa
  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');
+ });
+});

Những thai đổi đã bị hủy bỏ vì nó quá lớn
+ 0 - 0
projects/xiaoshu-admin/src/app/pages/operations-payroll.component.ts


Những thai đổi đã bị hủy bỏ vì nó quá lớn
+ 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]);

Những thai đổi đã bị hủy bỏ vì nó quá lớn
+ 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);
+ }
+});

Một số tệp đã không được hiển thị bởi vì quá nhiều tập tin thay đổi trong này khác