Procházet zdrojové kódy

perf(mobile): lighten home review reads and coalesce recovery refreshes

彭峰 před 5 hodinami
rodič
revize
d7cb829843
27 změnil soubory, kde provedl 1232 přidání a 14 odebrání
  1. 73 0
      perf/recovery-performance-2026-11-14/LOCAL-REPORT.md
  2. 72 0
      perf/recovery-performance-2026-11-14/local-benchmark.mjs
  3. binární
      perf/recovery-performance-2026-11-14/local-evidence/after-student-1024.png
  4. binární
      perf/recovery-performance-2026-11-14/local-evidence/after-student-390.png
  5. binární
      perf/recovery-performance-2026-11-14/local-evidence/after-teacher-1024.png
  6. binární
      perf/recovery-performance-2026-11-14/local-evidence/after-teacher-390.png
  7. binární
      perf/recovery-performance-2026-11-14/local-evidence/before-student-1024.png
  8. binární
      perf/recovery-performance-2026-11-14/local-evidence/before-student-390.png
  9. binární
      perf/recovery-performance-2026-11-14/local-evidence/before-teacher-1024.png
  10. binární
      perf/recovery-performance-2026-11-14/local-evidence/before-teacher-390.png
  11. 268 0
      perf/recovery-performance-2026-11-14/local-evidence/local-browser.json
  12. 445 0
      perf/recovery-performance-2026-11-14/local-evidence/local-sql.json
  13. 80 0
      perf/recovery-performance-2026-11-14/local-finance-guard.json
  14. 31 0
      perf/recovery-performance-2026-11-14/local-runtime.mjs
  15. 65 0
      perf/recovery-performance-2026-11-14/local-verification.json
  16. 21 0
      projects/xiaoshu-mobile/src/app/core/home-learning-modules.integration.spec.ts
  17. 28 2
      projects/xiaoshu-mobile/src/app/core/live-data-refresh.service.spec.ts
  18. 22 4
      projects/xiaoshu-mobile/src/app/core/live-data-refresh.service.ts
  19. 2 1
      projects/xiaoshu-mobile/src/app/core/review-page.ts
  20. 1 1
      projects/xiaoshu-mobile/src/app/core/teacher-today-review.service.spec.ts
  21. 1 1
      projects/xiaoshu-mobile/src/app/core/teacher-today-review.service.ts
  22. 5 0
      projects/xiaoshu-mobile/src/app/features/home/home-page.component.html
  23. 9 0
      projects/xiaoshu-mobile/src/app/features/home/home-page.component.scss
  24. 8 1
      projects/xiaoshu-mobile/src/app/features/home/home-page.component.ts
  25. 28 3
      scripts/cloud/mobile-teaching.js
  26. 72 0
      scripts/tests/home-review-read.test.mjs
  27. 1 1
      scripts/tests/teacher-earnings.test.mjs

+ 73 - 0
perf/recovery-performance-2026-11-14/LOCAL-REPORT.md

@@ -0,0 +1,73 @@
+# 本地优化候选与验证
+
+用户要求:按既定方案先完成本地优化和测试,课时、费用不能出问题;**未获得上线授权**。本批没有部署云函数、上传 H5、执行生产迁移、修改公共服务或生产配置。
+
+## 本批实现
+
+1. 学生首页请求 `app_review_page` 的显式 `view: home, source: database` 模式。鉴权和租户/学员过滤仍先执行;一次日期聚合生成原有统计和日历,不拉取任务正文、费用凭据或全局版本,不同步旧系统。首页显示“已有同步记录”和同步状态;进入复习详情时仍使用原来的核对流程。其他调用默认保持原行为。
+2. 老师当天列表复用已有汇总中的 today 数量,减少一次重复 COUNT。仅在没有 status/month 额外过滤时复用,其他情况仍使用原查询。老师首页不再请求未使用的版本数据;收入统计接口、费用明细接口保持原计算方式。
+3. 排课相关页面的 focus/visibility/online 事件在 100ms 内合并为一次重新读取和一次版本检查;**保留每次恢复可见后的数据核对**,不能把版本接口成功误认为页面数据已更新。版本轮询连续失败时按 30/60/120 秒退避;恢复后重新读取一次。本地写入、其他标签页写入仍立即刷新。
+4. 新增汇总模式的状态提示;在 390px、1024px 及放大文字下检查可读性和横向溢出。
+
+没有新增长期缓存、业务数据库迁移、生产索引、后台队列、扣费算法或自动补偿规则。后台增量同步继续依赖既有带租约的导入器,本批**没有新建或启用独立实时同步任务**。若导入器停滞,首页会显示现有记录及延迟/不可用状态;这不是“旧系统已经同步成功”的承诺。
+
+## 课时和费用保护
+
+- `home-review-read.test.mjs` 在隔离 PostgreSQL 中执行真实 SELECT,拒绝任何非 SELECT 调用。核对 `_User.legacyUserData` 余额、预约、课后记录、复习记录及 `ReviewRewardIncome` 前后完全一致。
+- 原有扣费、预约预留/释放、结课、奖励和结算 SQL/模块共 15 个源文件与基线 `81f8b44` 字节一致,详见 `local-finance-guard.json`。只读函数的任务、统计、日期、分页和费用字段另做结果对照。
+- 回归范围包含:重复/并发结课只扣一次,结课失败事务回滚,取消预约释放预留,复习奖励重复提交不重复发放,奖励写入失败回滚,历史零元/逾期/归属保护,工资规则与合法零元快照,以及租户和身份权限。
+- 独立审查发现并修正了“版本未变时不刷新导致失败页/旧缓存无法恢复”的问题;复审确认无剩余阻断项。另修正了测试比较动态 `refreshedAt` 导致的偶发失败,仅排除响应时间元数据,业务字段仍完整对比。
+
+## 测量与解释
+
+本地 SQL 测量使用 5,051 条合成任务的 PGlite 数据库,前后交替各 10 次,保留 EXPLAIN (ANALYZE, BUFFERS)。版本和同步状态 I/O 使用固定测试数据,**未包括生产认证、连接池、旧系统网络或共享服务器耗时**。
+
+| 核心读取 | 优化前 p75 | 优化后 p75 | 变化 |
+|---|---:|---:|---|
+| 学生首页复习核心函数 | 331.60ms | 26.34ms | 核心 SQL 4→1;省去同步调用和版本读取 |
+| 老师今日复习核心函数 | 64.85ms | 64.17ms | 核心 SQL 3→2;省去版本读取,本地耗时差不足以宣称显著提速 |
+
+学生合成数据响应从 10,597 字节减至 637 字节;统计和日期结果一致。实际同步状态读取的查询次数不包含在上述核心 SQL 数量中。
+
+浏览器使用基线/候选的真实 Angular 构建、相同隔离数据库、Fast 4G、冷上下文、正常 CPU,并交替 A/B、B/A;统计/余额/费用和稳定 500ms 的可操作条件一致。旧系统依赖注入固定 1,500ms 延迟,验证首页已摆脱这段等待。所有请求限定本地回环地址。最终数值与截图见 `local-verification.json` 及 `local-evidence/`;这是受控故障注入实验,**不能按比例推算生产提速**。
+
+## 复现
+
+在仓库根目录运行,使用不含生产会话的私有目录:
+
+```sh
+npm run test:ci
+npm run test:server
+npm run cloud:validate
+MODE=sql RUNS=10 node perf/recovery-performance-2026-11-14/local-benchmark.mjs /absolute/private/evidence
+MODE=browser RUNS=10 node perf/recovery-performance-2026-11-14/local-benchmark.mjs /absolute/private/evidence
+```
+
+浏览器实验要求该目录下有 `before/browser` 和 `after/browser`:分别由基线 `81f8b44` 与当前候选的 `ng build xiaoshu-mobile --output-path ...` 生成。两端都只连接脚本的本地测试服务器,不使用真实账号。
+
+## 尚待上线阶段处理
+
+- 维护方确认恢复后的 worker/连接池指标、连接等待、慢查询及导入器运行状态。不能将今早 max=2 的旧快照当作当前值直接调参。
+- 验证独立增量同步的新鲜度/冲突处理,再决定是否扩展首页以外的同步解耦;不以覆盖旧系统冲突或陈旧余额换速度。
+- 本批没有宣称老师首页的生产认证/统计长等待已被解决,也没有进行真实设备、高并发或生产扣费操作验收。
+- 得到用户明确上线通知后,先核对当前线上改动与备份,选择性发布 app gateway 和 H5。没有数据库迁移。发布后再做真实角色只读验收;任何账务异常、错误率上升或尾延迟回退都停止发布并回滚本批。
+
+## 最终验证记录
+
+- 移动端 387、管理后台 393、服务端 586,共 **1,366 项测试通过,0 失败**。移动端生产构建、11 项云函数定义检查通过;已有 CommonJS 构建警告仍在。
+- 最终浏览器配对测试共 40 次,没有尝试任何外部网络。页面可见统计、余额、费用文字逐组一致,新增同步提示单独检查。
+
+| 本地页面 | 优化前 p75 | 优化后 p75 | 解释 |
+|---|---:|---:|---|
+| 学生首页 | 3.289秒 | 1.786秒 | 在旧系统固定延迟1.5秒的隔离条件下,去除了首页等待 |
+| 老师首页 | 1.776秒 | 1.785秒 | 差约9毫秒;未证明整页提速,只确认减少无用读取 |
+
+两组候选本地 p75 均低于既定 3 秒目标、p95 低于 5 秒;这只作为隔离回归门槛,不能替代上线后的验收。生产中资料/统计接口数秒等待仍需要维护方的连接池与分阶段耗时证据。
+
+预览:
+
+- [学生首页 390px](local-evidence/after-student-390.png)
+- [学生首页 1024px](local-evidence/after-student-1024.png)
+- [老师首页 390px](local-evidence/after-teacher-390.png)
+
+**交付状态:本地代码、测试、构建和证据已就绪;等待用户明确上线通知。**

+ 72 - 0
perf/recovery-performance-2026-11-14/local-benchmark.mjs

@@ -0,0 +1,72 @@
+import fs from 'node:fs';import path from 'node:path';import http from 'node:http';import assert from 'node:assert/strict';import {createRequire} from 'node:module';
+import {localRuntime} from './local-runtime.mjs';
+const root=process.argv[2];if(!root||!path.isAbsolute(root))throw Error('Pass a private build/evidence directory');
+const mode=process.env.MODE||'sql',runs=Number(process.env.RUNS||10),q=(a,p)=>[...a].sort((a,b)=>a-b)[Math.ceil(a.length*p)-1];
+if(mode==='sql'){
+ const rt=await localRuntime({scale:5000});const rows=[];
+ try{
+  for(let run=0;run<runs;run++)for(const coach of [false,true]){
+   const values={};
+   for(const variant of (run%2?['after','before']:['before','after'])){
+    const input=coach?{uid:30,scope:'today',includeMetadata:false,includeIncome:false,...(variant==='after'?{includeVersions:false}:{})}:variant==='before'?{uid:20,pageSize:20,status:'pending'}:{uid:20,view:'home',source:'database'};
+    const {value,metrics}=await rt.review(variant,input,coach);values[variant]=value;
+    rows.push({run:run+1,variant,coach,ms:metrics.ms,queryCount:metrics.queries.length,versionCalls:metrics.versionCalls,legacyCalls:metrics.legacyCalls,bytes:metrics.bytes});
+   }
+   assert.deepEqual(values.after.summary,values.before.summary);
+   assert.deepEqual(values.after.dates,values.before.dates);
+   if(coach){assert.deepEqual(values.after.items,values.before.items);assert.deepEqual(values.after.page,values.before.page);}
+  }
+  const plans=[];
+  for(const variant of ['before','after']){
+   const metrics=rt.results.find(r=>r.variant===variant&&!r.coach);
+   for(const query of metrics.queries)plans.push({variant,sql:query.sql,plan:(await rt.db.query('EXPLAIN (ANALYZE,BUFFERS,FORMAT JSON) '+query.sql,query.args)).rows});
+  }
+  const summary={};for(const coach of [false,true])for(const variant of ['before','after']){const a=rows.filter(r=>r.coach===coach&&r.variant===variant);summary[`${coach?'teacher':'student'}-${variant}`]={p50:q(a.map(r=>r.ms),.5),p75:q(a.map(r=>r.ms),.75),p95:q(a.map(r=>r.ms),.95),queries:a[0].queryCount,versionCalls:a[0].versionCalls,legacyCalls:a[0].legacyCalls,bytes:a[0].bytes};}
+  fs.writeFileSync(root+'/local-sql.json',JSON.stringify({scope:'PGlite synthetic 5051 task fixture, no network delay; version/status I/O replaced by fixed metadata, not production timings',summary,rows,plans},null,2));console.log(JSON.stringify(summary));
+ }finally{await rt.close();}
+}else{
+ const rt=await localRuntime({legacyDelayMs:1500}),servers=[],rows=[],blocked=[];
+ const {chromium}=createRequire(path.resolve('package.json'))('playwright');
+ const browser=await chromium.launch({headless:true,executablePath:'/Applications/Google Chrome.app/Contents/MacOS/Google Chrome',args:['--no-proxy-server']});
+ try{
+  const origins={};
+  for(const variant of ['before','after']){
+   const directory=path.join(root,variant,'browser');
+   const server=http.createServer(async(req,res)=>{try{
+    const url=new URL(req.url,'http://localhost');
+    if(url.pathname==='/cloud-functions/xiaoshu/app/gateway'){
+     let body='';for await(const chunk of req)body+=chunk;const input=JSON.parse(body).params,coach=Number(input.uid)===30;let result;
+     if(input.action==='app_review_page')result=(await rt.review(variant,input,coach)).value;
+     else if(input.action==='e_order_tongji')result={t30:'10',t60:'8',t_tiyan:'0',t_total:'18',t_shichang:'13',totalIncome:96,lessonIncome:95,reviewIncome:1};
+     else if(input.action==='app_account_overview')result={profile:{userId:20,groupId:1,UserPoint:12.5,Purse:8,SilverCoin:7}};
+     else throw Error('Unexpected action '+input.action);
+     res.writeHead(200,{'Content-Type':'application/json'});res.end(JSON.stringify({retcode:0,result}));return;
+    }
+    let file=path.resolve(directory,'.'+url.pathname);if(!file.startsWith(directory+path.sep)){res.writeHead(403);res.end();return;}
+    if(!fs.existsSync(file)||fs.statSync(file).isDirectory())file=path.join(directory,'index.html');
+    const mime={'.html':'text/html','.js':'text/javascript','.css':'text/css','.png':'image/png','.svg':'image/svg+xml','.woff2':'font/woff2'}[path.extname(file)]||'application/octet-stream';
+    const data=fs.readFileSync(file);res.writeHead(200,{'Content-Type':mime,'Content-Length':data.length,'Cache-Control':'no-store'});res.end(data);
+   }catch(error){res.writeHead(500,{'Content-Type':'application/json'});res.end(JSON.stringify({retcode:-1,retmsg:error.message}));}});
+   await new Promise(resolve=>server.listen(0,'127.0.0.1',resolve));servers.push(server);origins[variant]='http://127.0.0.1:'+server.address().port;
+  }
+  for(let run=0;run<runs;run++)for(const coach of [false,true]){
+   const views={};
+   for(const variant of (run%2?['after','before']:['before','after'])){
+    const context=await browser.newContext({viewport:{width:390,height:844},timezoneId:'Asia/Shanghai'});
+    await context.addInitScript(coach=>{localStorage.setItem('xiaoshu.angular.session',JSON.stringify({token:'r:isolated-fixture',user:{userId:coach?30:20,groupId:coach?3:1,userName:coach?'测试老师':'测试学生'},student:null}));},coach);
+    await context.route('**/*',route=>{if(new URL(route.request().url()).hostname==='127.0.0.1')return route.continue();blocked.push(route.request().url());return route.abort();});
+    const page=await context.newPage(),cdp=await context.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();await page.goto(origins[variant]+'/pages/home/index',{waitUntil:'domcontentloaded'});
+    await page.waitForFunction(coach=>{const q=s=>document.querySelector(s);const ok=coach?!!q('.coach-grid:not(.loading)')&&!!q('.coach-memory-summary')&&!q('button[aria-label="刷新抗遗忘任务"]:disabled'):!!q('.memory-content')&&!!q('.student-home-refresh:not(:disabled)');const text=coach?q('.coach-memory-summary')?.textContent:q('.today-panel')?.textContent;if(!ok){window.__gate=null;return false;}if(window.__gate?.text!==text){window.__gate={text,at:performance.now()};return false;}return performance.now()-window.__gate.at>=500;},coach,{timeout:20000});
+    const ms=performance.now()-start;views[variant]=await page.locator(coach?'.coach-grid,.coach-memory-summary':'.today-panel,.student-stats').allTextContents();
+    rows.push({run:run+1,coach,variant,ms});console.log(JSON.stringify(rows.at(-1)));
+    if(run===0){for(const width of [390,1024]){await page.setViewportSize({width,height:900});await page.screenshot({path:`${root}/${variant}-${coach?'teacher':'student'}-${width}.png`,fullPage:true});assert.ok(await page.evaluate(()=>document.documentElement.scrollWidth<=innerWidth+1),'horizontal overflow');}await page.setViewportSize({width:390,height:900});await page.addStyleTag({content:'html {font-size:20px} .review-sync-notice {font-size:20px}'});assert.ok(await page.evaluate(()=>document.documentElement.scrollWidth<=innerWidth+1),'large text overflow');}
+    await context.close();
+   }
+   assert.deepEqual(views.after,views.before,'visible counts, balances and fees must match');
+  }
+  assert.deepEqual(blocked,[],'benchmark must not attempt external network');
+  const summary={};for(const coach of [false,true])for(const variant of ['before','after']){const a=rows.filter(r=>r.coach===coach&&r.variant===variant).map(r=>r.ms);summary[`${coach?'teacher':'student'}-${variant}`]={p50:q(a,.5),p75:q(a,.75),p95:q(a,.95)};}
+  fs.writeFileSync(root+'/local-browser.json',JSON.stringify({scope:'isolated real builds + PGlite data; legacy dependency fault injected at 1500ms, not production benefit',summary,rows,blocked},null,2));
+ }finally{await browser.close();for(const server of servers)await new Promise(resolve=>server.close(resolve));await rt.close();}
+}

binární
perf/recovery-performance-2026-11-14/local-evidence/after-student-1024.png


binární
perf/recovery-performance-2026-11-14/local-evidence/after-student-390.png


binární
perf/recovery-performance-2026-11-14/local-evidence/after-teacher-1024.png


binární
perf/recovery-performance-2026-11-14/local-evidence/after-teacher-390.png


binární
perf/recovery-performance-2026-11-14/local-evidence/before-student-1024.png


binární
perf/recovery-performance-2026-11-14/local-evidence/before-student-390.png


binární
perf/recovery-performance-2026-11-14/local-evidence/before-teacher-1024.png


binární
perf/recovery-performance-2026-11-14/local-evidence/before-teacher-390.png


+ 268 - 0
perf/recovery-performance-2026-11-14/local-evidence/local-browser.json

@@ -0,0 +1,268 @@
+{
+  "scope": "isolated real builds + PGlite data; legacy dependency fault injected at 1500ms, not production benefit",
+  "summary": {
+    "student-before": {
+      "p50": 3287.7542140000005,
+      "p75": 3289.491289999991,
+      "p95": 3302.77942
+    },
+    "student-after": {
+      "p50": 1782.9718100000027,
+      "p75": 1785.9912719999993,
+      "p95": 1791.5813879999987
+    },
+    "teacher-before": {
+      "p50": 1773.794925000002,
+      "p75": 1775.532148000002,
+      "p95": 1789.1544399999984
+    },
+    "teacher-after": {
+      "p50": 1778.1663249999983,
+      "p75": 1784.9026940000003,
+      "p95": 1788.8110429999942
+    }
+  },
+  "rows": [
+    {
+      "run": 1,
+      "coach": false,
+      "variant": "before",
+      "ms": 3302.77942
+    },
+    {
+      "run": 1,
+      "coach": false,
+      "variant": "after",
+      "ms": 1785.9912719999993
+    },
+    {
+      "run": 1,
+      "coach": true,
+      "variant": "before",
+      "ms": 1774.5884970000006
+    },
+    {
+      "run": 1,
+      "coach": true,
+      "variant": "after",
+      "ms": 1787.058454
+    },
+    {
+      "run": 2,
+      "coach": false,
+      "variant": "after",
+      "ms": 1780.3158109999986
+    },
+    {
+      "run": 2,
+      "coach": false,
+      "variant": "before",
+      "ms": 3288.683871000001
+    },
+    {
+      "run": 2,
+      "coach": true,
+      "variant": "after",
+      "ms": 1784.9026940000003
+    },
+    {
+      "run": 2,
+      "coach": true,
+      "variant": "before",
+      "ms": 1775.532148000002
+    },
+    {
+      "run": 3,
+      "coach": false,
+      "variant": "before",
+      "ms": 3289.7005309999986
+    },
+    {
+      "run": 3,
+      "coach": false,
+      "variant": "after",
+      "ms": 1782.9718100000027
+    },
+    {
+      "run": 3,
+      "coach": true,
+      "variant": "before",
+      "ms": 1775.1223000000027
+    },
+    {
+      "run": 3,
+      "coach": true,
+      "variant": "after",
+      "ms": 1774.4090720000022
+    },
+    {
+      "run": 4,
+      "coach": false,
+      "variant": "after",
+      "ms": 1773.5031729999973
+    },
+    {
+      "run": 4,
+      "coach": false,
+      "variant": "before",
+      "ms": 3287.7542140000005
+    },
+    {
+      "run": 4,
+      "coach": true,
+      "variant": "after",
+      "ms": 1782.2774219999992
+    },
+    {
+      "run": 4,
+      "coach": true,
+      "variant": "before",
+      "ms": 1778.1538349999973
+    },
+    {
+      "run": 5,
+      "coach": false,
+      "variant": "before",
+      "ms": 3273.3384439999936
+    },
+    {
+      "run": 5,
+      "coach": false,
+      "variant": "after",
+      "ms": 1780.4582720000035
+    },
+    {
+      "run": 5,
+      "coach": true,
+      "variant": "before",
+      "ms": 1773.794925000002
+    },
+    {
+      "run": 5,
+      "coach": true,
+      "variant": "after",
+      "ms": 1763.5167290000027
+    },
+    {
+      "run": 6,
+      "coach": false,
+      "variant": "after",
+      "ms": 1791.5813879999987
+    },
+    {
+      "run": 6,
+      "coach": false,
+      "variant": "before",
+      "ms": 3289.256659999999
+    },
+    {
+      "run": 6,
+      "coach": true,
+      "variant": "after",
+      "ms": 1773.918651
+    },
+    {
+      "run": 6,
+      "coach": true,
+      "variant": "before",
+      "ms": 1760.5914609999963
+    },
+    {
+      "run": 7,
+      "coach": false,
+      "variant": "before",
+      "ms": 3270.628952999992
+    },
+    {
+      "run": 7,
+      "coach": false,
+      "variant": "after",
+      "ms": 1781.8886079999938
+    },
+    {
+      "run": 7,
+      "coach": true,
+      "variant": "before",
+      "ms": 1771.3539489999966
+    },
+    {
+      "run": 7,
+      "coach": true,
+      "variant": "after",
+      "ms": 1772.3424899999955
+    },
+    {
+      "run": 8,
+      "coach": false,
+      "variant": "after",
+      "ms": 1785.4870599999995
+    },
+    {
+      "run": 8,
+      "coach": false,
+      "variant": "before",
+      "ms": 3276.166188999996
+    },
+    {
+      "run": 8,
+      "coach": true,
+      "variant": "after",
+      "ms": 1788.8110429999942
+    },
+    {
+      "run": 8,
+      "coach": true,
+      "variant": "before",
+      "ms": 1767.6426479999936
+    },
+    {
+      "run": 9,
+      "coach": false,
+      "variant": "before",
+      "ms": 3289.491289999991
+    },
+    {
+      "run": 9,
+      "coach": false,
+      "variant": "after",
+      "ms": 1784.082773000002
+    },
+    {
+      "run": 9,
+      "coach": true,
+      "variant": "before",
+      "ms": 1789.1544399999984
+    },
+    {
+      "run": 9,
+      "coach": true,
+      "variant": "after",
+      "ms": 1778.1663249999983
+    },
+    {
+      "run": 10,
+      "coach": false,
+      "variant": "after",
+      "ms": 1790.128475999998
+    },
+    {
+      "run": 10,
+      "coach": false,
+      "variant": "before",
+      "ms": 3274.6587569999974
+    },
+    {
+      "run": 10,
+      "coach": true,
+      "variant": "after",
+      "ms": 1781.3020160000015
+    },
+    {
+      "run": 10,
+      "coach": true,
+      "variant": "before",
+      "ms": 1763.2703709999914
+    }
+  ],
+  "blocked": []
+}

Rozdílová data souboru nebyla zobrazena, protože soubor je příliš velký
+ 445 - 0
perf/recovery-performance-2026-11-14/local-evidence/local-sql.json


+ 80 - 0
perf/recovery-performance-2026-11-14/local-finance-guard.json

@@ -0,0 +1,80 @@
+{
+  "baseCommit": "81f8b44",
+  "files": [
+    {
+      "path": "scripts/sql/member-enrollment.sql",
+      "unchanged": true,
+      "sha256": "4af4a2139e79e818b390fbe9ab6c4113afcb9c10a18f670250f5f9ba8dedfdc6"
+    },
+    {
+      "path": "scripts/sql/lesson-records.sql",
+      "unchanged": true,
+      "sha256": "1cd3cdd9979b52094acc1e5d34fba355909cf2e75473bd4bd437849faff5b093"
+    },
+    {
+      "path": "scripts/sql/legacy-review-sync.sql",
+      "unchanged": true,
+      "sha256": "af2593bb4faad042e6ce6e5c0634c6aa46612ffb25dfbb4c53c0bc923005cee9"
+    },
+    {
+      "path": "scripts/sql/review-rewards.sql",
+      "unchanged": true,
+      "sha256": "9b8f8b8f4902acfb6975529213ed705e73fc103793a7d34efb9049cc95a909a3"
+    },
+    {
+      "path": "scripts/sql/review-reconciliation.sql",
+      "unchanged": true,
+      "sha256": "b2c159f9f425bc564481be1553e24e8c1c25550377c34e9fccd0ac51994fd9a1"
+    },
+    {
+      "path": "scripts/sql/lesson-workflow.sql",
+      "unchanged": true,
+      "sha256": "3024d54144fd2abad590907f7d91e31d000a3bd4a84089e339cba512e31e650c"
+    },
+    {
+      "path": "scripts/sql/payroll-integrity.sql",
+      "unchanged": true,
+      "sha256": "f8b9d2f03dc6d276e61e145bb4d6052b407ebe36fc614b5ac923408ad726d4f1"
+    },
+    {
+      "path": "scripts/cloud/lesson-workflow.js",
+      "unchanged": true,
+      "sha256": "70763638b52e3e3f9018596a35280906bbf5e0f45bb96ff769b21cf3f9a19dc3"
+    },
+    {
+      "path": "scripts/cloud/review-rewards.js",
+      "unchanged": true,
+      "sha256": "b43aec7309226fe72d27dc1a719b56f020e2a5889ea09e3cd46f0b3c5c309ca9"
+    },
+    {
+      "path": "scripts/cloud/payroll-integrity.js",
+      "unchanged": true,
+      "sha256": "7ebb5cbeac0acd0b746bfd10760d1c05d98cb532514e916d62d92699c7ee37cc"
+    },
+    {
+      "path": "scripts/cloud/teacher-earnings.js",
+      "unchanged": true,
+      "sha256": "08a9f5c9cd08442643aef6a5ca2b9f282c5d9cfc2613a32574f5423f8ca6ae58"
+    },
+    {
+      "path": "scripts/cloud/lesson-workflow-ops.js",
+      "unchanged": true,
+      "sha256": "06dabca05d6f2095825b4985e1f51faa43b569a1d29cba72b5bc679d8df3cca1"
+    },
+    {
+      "path": "scripts/cloud/member-credit-ledger.js",
+      "unchanged": true,
+      "sha256": "82e97ef8d65b7b02c3fb32bfa483d58756780130d0981d378c28dc4218da33f5"
+    },
+    {
+      "path": "scripts/cloud/legacy-finance.js",
+      "unchanged": true,
+      "sha256": "eb6be7d4431572c89f67978cdbf77e243fced07018def95808fa597f470174f6"
+    },
+    {
+      "path": "scripts/deploy-admin-functions.mjs",
+      "unchanged": true,
+      "sha256": "0aeffa97ad9278b04769332ff10c9a3cfc256ceb46c76c7c997e4ae26da8c1c4"
+    }
+  ]
+}

+ 31 - 0
perf/recovery-performance-2026-11-14/local-runtime.mjs

@@ -0,0 +1,31 @@
+// Isolated PostgreSQL fixture. No production credentials or network are used.
+import fs from 'node:fs';
+import vm from 'node:vm';
+import {execFileSync} from 'node:child_process';
+import {PGlite} from '@electric-sql/pglite';
+export async function localRuntime({scale=0,legacyDelayMs=0}={}){
+ const db=new PGlite();
+ const today=new Intl.DateTimeFormat('en-CA',{timeZone:'Asia/Shanghai'}).format(new Date());
+ const schema=fs.readFileSync('scripts/tests/fixtures/teacher-review-schema.sql','utf8').replaceAll('2026-10-14',today);
+ await db.exec(schema);
+ if(scale)await db.exec(`INSERT INTO "MemoryPracticeRecord" SELECT 'scale'||i,'co',i,30,20,100,1,'0','2026-10-20','2026-10-20' FROM generate_series(2000,${1999+Number(scale)}) i;
+ INSERT INTO "CommonModel" (company,"modelId","generalId","itemId") SELECT company,60,id+100000,id FROM "MemoryPracticeRecord" WHERE id>=2000;`);
+ await db.exec(`CREATE INDEX ON "MemoryPracticeRecord"(company,yhid,xxjlid,kywsj,id);CREATE INDEX ON "CommonModel"(company,"modelId","itemId");CREATE INDEX ON "CommonModel"(company,"modelId","generalId");CREATE INDEX ON "DailyStudyRecord"(company,id);CREATE INDEX ON "ReviewRewardIncome"(company,"taskId");ANALYZE;`);
+ const sources={before:execFileSync('git',['show','81f8b44:scripts/cloud/mobile-teaching.js'],{encoding:'utf8'}),after:fs.readFileSync('scripts/cloud/mobile-teaching.js','utf8')};
+ const results=[];
+ async function review(variant,input,coach=false){
+  const metrics={variant,coach,legacyCalls:0,queries:[],versionCalls:0},start=performance.now();
+  const query=async(sql,args)=>{if(!sql.startsWith('SELECT '))throw Error('Unexpected write in local benchmark');const at=performance.now();const result=await db.query(sql,args);metrics.queries.push({ms:performance.now()-at,sql,args});return result.rows;};
+  const context=vm.createContext({Date,Promise,DEFAULT_COMPANY_ID:'co',number:(v,f=0)=>Number.isFinite(Number(v))?Number(v):f,
+   authorizeRequestedUser:async()=>coach?30:20,appReviewTarget:async()=>20,appOverviewToday:()=>today,legacyAliases:r=>r,
+   syncLegacyReviews:async()=>{metrics.legacyCalls++;if(legacyDelayMs)await new Promise(r=>setTimeout(r,legacyDelayMs));return {state:'unavailable'};},
+   appLiveSyncStatus:async()=>({state:'delayed',lastSuccessAt:'',lagSeconds:null,pending:0}),
+   appLiveVersionSnapshot:async()=>{metrics.versionCalls++;return {version:'v1',scopeVersions:{review:'v1'},syncStatus:{state:'delayed'},refreshedAt:new Date().toISOString()};},
+   Psql:{query,one:async(s,a)=>(await query(s,a))[0]}});
+  vm.runInContext(sources[variant],context);
+  const value=JSON.parse(JSON.stringify(await context.appReviewPage(input,{get:()=>coach?3:1})));
+  metrics.ms=performance.now()-start;metrics.bytes=Buffer.byteLength(JSON.stringify(value));results.push(metrics);
+  return {value,metrics};
+ }
+ return {db,today,review,results,close:()=>db.close()};
+}

+ 65 - 0
perf/recovery-performance-2026-11-14/local-verification.json

@@ -0,0 +1,65 @@
+{
+  "scope": "local-only; no production deployment or configuration change",
+  "baselineCommit": "81f8b44",
+  "tests": {
+    "mobile": 387,
+    "admin": 393,
+    "server": 586,
+    "total": 1366,
+    "failed": 0
+  },
+  "cloudDefinitions": 11,
+  "mobileBuild": "passed; existing CommonJS warnings",
+  "browser": {
+    "student-before": {
+      "p50": 3287.7542140000005,
+      "p75": 3289.491289999991,
+      "p95": 3302.77942
+    },
+    "student-after": {
+      "p50": 1782.9718100000027,
+      "p75": 1785.9912719999993,
+      "p95": 1791.5813879999987
+    },
+    "teacher-before": {
+      "p50": 1773.794925000002,
+      "p75": 1775.532148000002,
+      "p95": 1789.1544399999984
+    },
+    "teacher-after": {
+      "p50": 1778.1663249999983,
+      "p75": 1784.9026940000003,
+      "p95": 1788.8110429999942
+    }
+  },
+  "browserSamples": 40,
+  "externalNetworkAttempts": 0,
+  "visualWidths": [
+    390,
+    1024
+  ],
+  "largeTextChecked": true,
+  "review": "independent read-only review approved after recovery and timestamp fixes",
+  "logs": [
+    {
+      "name": "test-ci-final.log",
+      "sha256": "9284b57ae52842b1abbf1812e049f2e95aec3d513cbad31fc86a4af3948ef1a0"
+    },
+    {
+      "name": "test-server-verified.log",
+      "sha256": "c97bd9ffe878bf5c96db111e70f5bc74b14c01823ac003031b8927e0b3676ddd"
+    },
+    {
+      "name": "read-final.log",
+      "sha256": "7deb6b73760954df42e3fa37551d0319ebbe8c8c7740e57697f39ab98fc83ade"
+    },
+    {
+      "name": "build-final.log",
+      "sha256": "d0f2b4178583ba45559649845c0b5ecb2b5956127e27d79c3cd77998e431d3d0"
+    },
+    {
+      "name": "browser-verified.log",
+      "sha256": "24f2589e38edbbbcb66ec7460f3e309cfa9745e35cefe38ce416fb60acd03d72"
+    }
+  ]
+}

+ 21 - 0
projects/xiaoshu-mobile/src/app/core/home-learning-modules.integration.spec.ts

@@ -554,6 +554,27 @@ describe('home learning modules', () => {
     expect(count()).toBe(2);
   });
 
+  it('student home requests a read-only summary and exposes stale sync without blocking learning or altering balances', async () => {
+    user.set({userId:100,groupId:1});session.groupId.set(1);
+    const api=TestBed.inject(ApiService) as unknown as LearningModulesApiService;
+    const original=api.post.bind(api);
+    spyOn(api,'post').and.callFake((action,params={})=>{
+      if(action==='app_account_overview')return of({retcode:0,result:{profile:{UserPoint:12.5,Purse:8,SilverCoin:7}}}) as any;
+      if(action!=='app_review_page')return original(action,params);
+      api.lastMemoryParams=params;
+      return of({retcode:0,result:{view:'home',items:[],page:{itemCount:0,pageCount:0},summary:{total:50,completed:1,due:2,today:2,income:0},dates:[],legacyReviewSync:{state:'snapshot'},syncStatus:{state:'unavailable'}}}) as any;
+    });
+    const harness=await RouterTestingHarness.create('/pages/home/index');
+    harness.fixture.detectChanges();
+    expect(api.lastMemoryParams).toEqual(jasmine.objectContaining({view:'home',source:'database'}));
+    expect(harness.routeNativeElement?.querySelector('.review-sync-notice')?.textContent).toContain('已同步');
+    expect(harness.routeNativeElement?.querySelector('.review-sync-notice')?.textContent).toContain('暂不可用');
+    expect(harness.routeNativeElement?.querySelector('.continue-button')).not.toBeNull();
+    expect((harness.routeNativeElement?.querySelector('.student-home-refresh') as HTMLButtonElement).disabled).toBeFalse();
+    expect(harness.routeNativeElement?.querySelector('.today-panel')?.textContent).toContain('2');
+    expect(Array.from(harness.routeNativeElement!.querySelectorAll('.student-stats strong')).slice(1).map(e=>e.textContent?.trim())).toEqual(['12.5','9','8','7']);
+  });
+
   for (const groupId of [1, 3]) {
     it(`caches homepage overview for role ${groupId} until an explicit refresh`, async () => {
       user.set({ userId: groupId === 3 ? 300 : 100, groupId });

+ 28 - 2
projects/xiaoshu-mobile/src/app/core/live-data-refresh.service.spec.ts

@@ -1,5 +1,5 @@
 import { fakeAsync, TestBed, tick } from '@angular/core/testing';
-import { of, Subject } from 'rxjs';
+import { of, Subject, throwError } from 'rxjs';
 import { ApiService } from './api.service';
 import { LiveDataRefreshService, LiveVersions } from './live-data-refresh.service';
 import { SessionService } from './session.service';
@@ -68,6 +68,32 @@ describe('LiveDataRefreshService', () => {
     stop();
   }));
 
+  it('coalesces resume events while revalidating unchanged versions to recover failed or stale cached data', fakeAsync(() => {
+    spyOnProperty(document,'visibilityState','get').and.returnValue('visible');
+    let version='v1';post.and.callFake(()=>of(envelope(version)));
+    const refresh=jasmine.createSpy('refresh');
+    const stop=TestBed.inject(LiveDataRefreshService).watch({scopes:['appointments'],studentId:()=>undefined,appointmentPolling:true,onRefresh:refresh});
+    window.dispatchEvent(new Event('focus'));document.dispatchEvent(new Event('visibilitychange'));window.dispatchEvent(new Event('online'));
+    tick(250);
+    expect(refresh).toHaveBeenCalledOnceWith('version');expect(post).toHaveBeenCalledTimes(2);
+    version='v2';window.dispatchEvent(new Event('focus'));window.dispatchEvent(new Event('online'));tick(250);
+    expect(refresh.calls.allArgs()).toEqual([['version'],['version']]);expect(post).toHaveBeenCalledTimes(3);
+    stop();tick(30_000);
+  }));
+
+  it('backs off failed polling and refreshes once when the version endpoint recovers', fakeAsync(() => {
+    spyOnProperty(document,'visibilityState','get').and.returnValue('visible');
+    post.and.returnValue(throwError(()=>new Error('offline')));
+    const refresh=jasmine.createSpy('refresh');
+    const stop=TestBed.inject(LiveDataRefreshService).watch({scopes:['appointments'],studentId:()=>undefined,appointmentPolling:true,onRefresh:refresh});
+    tick(15_000);expect(post).toHaveBeenCalledTimes(1);
+    tick(15_000);expect(post).toHaveBeenCalledTimes(2);
+    tick(45_000);expect(post).toHaveBeenCalledTimes(2);
+    post.and.returnValue(of(envelope('v1')));
+    tick(15_000);expect(post).toHaveBeenCalledTimes(3);expect(refresh).toHaveBeenCalledOnceWith('version');
+    stop();
+  }));
+
   it('polls appointments across devices, pauses when hidden, resumes online and releases listeners', fakeAsync(() => {
     let version = 'v1';
     const visible = spyOnProperty(document, 'visibilityState', 'get').and.returnValue('visible');
@@ -81,7 +107,7 @@ describe('LiveDataRefreshService', () => {
     expect(refresh).toHaveBeenCalledOnceWith('version');
     visible.and.returnValue('hidden'); tick(30_000);
     expect(post).toHaveBeenCalledTimes(2);
-    visible.and.returnValue('visible'); window.dispatchEvent(new Event('online'));
+    visible.and.returnValue('visible'); window.dispatchEvent(new Event('online'));tick(100);
     expect(post).toHaveBeenCalledTimes(3);
     stop(); tick(30_000); window.dispatchEvent(new Event('online'));
     expect(post).toHaveBeenCalledTimes(3);

+ 22 - 4
projects/xiaoshu-mobile/src/app/core/live-data-refresh.service.ts

@@ -45,6 +45,9 @@ export class LiveDataRefreshService {
     let checking = false;
     let identity = '';
     let version = '';
+    let failures = 0;
+    let nextCheckAt = 0;
+    let resumeTimer: ReturnType<typeof setTimeout> | undefined;
     let request: Subscription | undefined;
     const state: LiveRefreshState = {
       checking: false,
@@ -56,8 +59,8 @@ export class LiveDataRefreshService {
       options.onState?.({ ...state, syncStatus: { ...state.syncStatus } });
     };
     const canCheck = () => this.session.isLoggedIn() && (!options.appointmentPolling || document.visibilityState !== 'hidden');
-    const check = () => {
-      if (disposed || checking || !canCheck()) return;
+    const check = (resume = false) => {
+      if (disposed || checking || !canCheck() || (!resume && Date.now() < nextCheckAt)) return;
       const studentId = options.studentId();
       const nextIdentity = `${studentId ?? ''}|${options.scopes.join(',')}`;
       if (nextIdentity !== identity) {
@@ -73,20 +76,23 @@ export class LiveDataRefreshService {
       });
       if (!response || typeof response.subscribe !== 'function') {
         checking = false;
+        failures++;nextCheckAt=Date.now()+Math.min(120_000,15_000*2**Math.min(failures,3));
         publish({ checking: false, syncStatus: { ...state.syncStatus, state: 'unavailable' } });
         return;
       }
       request = response.subscribe({
         next: ({ result }) => {
           if (disposed) return;
-          const changed = Boolean(version && result.version && version !== result.version);
+          const changed = failures > 0 || Boolean(version && result.version && version !== result.version);
           version = result.version || version;
+          failures=0;nextCheckAt=0;
           checking = false;
           publish({ checking: false, checkedAt: result.refreshedAt, syncStatus: result.syncStatus });
           if (changed) options.onRefresh('version');
         },
         error: () => {
           checking = false;
+          failures++;nextCheckAt=Date.now()+Math.min(120_000,15_000*2**Math.min(failures,3));
           publish({ checking: false, checkedAt: new Date().toISOString(), syncStatus: { ...state.syncStatus, state: 'unavailable' } });
         },
       });
@@ -94,12 +100,23 @@ export class LiveDataRefreshService {
     const refreshAfterChange = (source: 'local-write' | 'broadcast') => {
       if (disposed || !canCheck()) return;
       version = '';
+      failures=0;
+      nextCheckAt=0;
       options.onRefresh(source);
       check();
     };
     const dataChanges = this.api.dataChanges$?.subscribe((event) => refreshAfterChange(event.source === 'local' ? 'local-write' : 'broadcast')) ?? new Subscription();
     const onResume = () => {
-      if (canCheck()) { options.onRefresh('version'); check(); }
+      if (!canCheck()) return;
+      if (resumeTimer) clearTimeout(resumeTimer);
+      // A current version response does not prove the displayed cache is current
+      // or that its last load succeeded. Preserve one revalidation on resume.
+      resumeTimer=this.zone.runOutsideAngular(()=>setTimeout(()=>this.zone.run(()=>{
+        resumeTimer=undefined;
+        if(disposed||!canCheck())return;
+        version='';failures=0;nextCheckAt=0;
+        options.onRefresh('version');check(true);
+      }),100));
     };
     const interval = options.appointmentPolling ? this.zone.runOutsideAngular(() => setInterval(() => this.zone.run(check), 15_000)) : undefined;
     if (options.appointmentPolling) {
@@ -114,6 +131,7 @@ export class LiveDataRefreshService {
       request?.unsubscribe();
       dataChanges.unsubscribe();
       if (interval) clearInterval(interval);
+      if (resumeTimer) clearTimeout(resumeTimer);
       document.removeEventListener('visibilitychange', onResume);
       window.removeEventListener('focus', onResume);
       window.removeEventListener('online', onResume);

+ 2 - 1
projects/xiaoshu-mobile/src/app/core/review-page.ts

@@ -1,5 +1,6 @@
 import { buildAntiForgetCalendar, buildAntiForgetView } from './anti-forget';
-export interface ReviewPage { serverToday?:string;items:Record<string,unknown>[];page:{itemCount:number;pageCount:number};summary:{total:number;completed:number;due:number;today:number;income:number;incomeScope?:string;todayTotal?:number;todayCompleted?:number;todayPending?:number;overduePending?:number}; dates:Array<{date:string;total:number;completed:number}>;students?:Array<{id:string;name:string;taskCount:number}>;legacyReviewSync?: { state: 'current' | 'conflict' | 'unavailable' } }
+import { LiveSyncStatus } from './live-data-refresh.service';
+export interface ReviewPage { serverToday?:string;items:Record<string,unknown>[];page:{itemCount:number;pageCount:number};summary:{total:number;completed:number;due:number;today:number;income:number;incomeScope?:string;todayTotal?:number;todayCompleted?:number;todayPending?:number;overduePending?:number}; dates:Array<{date:string;total:number;completed:number}>;students?:Array<{id:string;name:string;taskCount:number}>;syncStatus?: LiveSyncStatus; legacyReviewSync?: { state: 'current' | 'conflict' | 'unavailable' | 'snapshot'; lastSuccessAt?: string } }
 export function reviewCalendar(dates:ReviewPage['dates'],month:Date,selected:string){
   const view=buildAntiForgetCalendar([],month,selected);const today=new Intl.DateTimeFormat('en-CA',{timeZone:'Asia/Shanghai'}).format(new Date());
   view.days=view.days.map(day=>{const row=dates.find(d=>d.date===day.date);const total=Number(row?.total)||0,completed=Number(row?.completed)||0,pending=total-completed;return{...day,total,completed,pending,state:!total?'none' as const:!pending?'completed' as const:day.date<=today?'due' as const:'scheduled' as const};});

+ 1 - 1
projects/xiaoshu-mobile/src/app/core/teacher-today-review.service.spec.ts

@@ -38,7 +38,7 @@ describe('TeacherTodayReviewService', () => {
       : [task(100, beijingToday(), 1)], 2) }) as never);
     const result = await firstValueFrom(service.load());
     expect(api.post.calls.count()).toBe(2);
-    expect(api.post.calls.first().args[1]).toEqual(jasmine.objectContaining({ date: beijingToday(), pageSize: 100, scope: 'today' }));
+    expect(api.post.calls.first().args[1]).toEqual(jasmine.objectContaining({ date: beijingToday(), pageSize: 100, scope: 'today', includeVersions: false }));
     expect(result.items.length).toBe(101);
     expect(result.summary).toEqual(jasmine.objectContaining({ todayTotal: 101, todayPending: 100, todayCompleted: 1 }));
   });

+ 1 - 1
projects/xiaoshu-mobile/src/app/core/teacher-today-review.service.ts

@@ -29,7 +29,7 @@ export class TeacherTodayReviewService {
     const uid = this.session.user()?.userId;
     const fetchPage = (page: number, selectedDate: string) => this.api.post<ReviewPage>('app_review_page', {
       uid, page, pageSize: 100,
-      scope: 'today', date: selectedDate, includeMetadata: false, includeIncome: false,
+      scope: 'today', date: selectedDate, includeMetadata: false, includeIncome: false, includeVersions: false,
       ...(force && page === 1 ? { refresh: true } : {}),
     }).pipe(take(1), map(({ result }) => ({ result, page })));
     const request = fetchPage(1, date).pipe(

+ 5 - 0
projects/xiaoshu-mobile/src/app/features/home/home-page.component.html

@@ -93,6 +93,11 @@
         </div>
       }
 
+      @if (reviewSyncNotice()) {
+        <p class="review-sync-notice" role="status">{{ reviewSyncNotice() }}
+          @if (reviewData()?.legacyReviewSync?.lastSuccessAt) { <span>最近同步:{{reviewData()?.legacyReviewSync?.lastSuccessAt | date:'MM-dd HH:mm':'+0800'}}</span> }
+        </p>
+      }
       <a class="memory-footer" routerLink="/pages/stu/stu_calendar"><span><i><lucide-icon [img]="icons.Zap" [size]="16" /></i>坚持每天学习,记得更牢,成绩更好!</span><strong>查看计划<lucide-icon [img]="icons.ChevronRight" [size]="16" /></strong></a>
     </section>
 

+ 9 - 0
projects/xiaoshu-mobile/src/app/features/home/home-page.component.scss

@@ -168,3 +168,12 @@
 @media (prefers-reduced-motion: reduce) { .state-spinner { animation-duration: 1.6s; } }
 
 .commission button{min-width:44px;min-height:44px}.today-complete{padding:12px;color:var(--brand-strong)}
+
+.review-sync-notice {
+  margin: 12px 20px;
+  color: #f0fff8;
+  font-size: 14px;
+  line-height: 1.6;
+  overflow-wrap: anywhere;
+  span { display: block; }
+}

+ 8 - 1
projects/xiaoshu-mobile/src/app/features/home/home-page.component.ts

@@ -48,6 +48,13 @@ export class HomePageComponent implements OnInit, OnDestroy {
   readonly antiForgetLoading = signal(false);
   readonly antiForgetError = signal('');
   readonly reviewData=signal<ReviewPage|null>(null);
+  readonly reviewSyncNotice = computed(() => {
+    const data=this.reviewData();
+    if(data?.legacyReviewSync?.state!=='snapshot')return '';
+    const state=data.syncStatus?.state;
+    const status=state==='unavailable'?'同步状态暂不可用。':state==='delayed'?'同步有延迟。':'';
+    return status+'当前显示已同步的复习记录,进入复习计划可核对最新任务。';
+  });
   readonly antiForgetView = computed(() => reviewOverview(this.reviewData()));
   readonly isCoach = computed(() => Number(this.session.groupId()) === 3);
   readonly coachReviewItems = computed(() => this.antiForgetItems().filter(item => antiForgetDate(item) === this.today()).sort((left, right) => {
@@ -177,7 +184,7 @@ export class HomePageComponent implements OnInit, OnDestroy {
     this.antiForgetError.set('');
     const request = this.isCoach() ? this.todayReviews.load(force) :
       this.dailyCache.load('review', () => this.api.post<ReviewPage>('app_review_page', {
-        uid, page: 1, pageSize: 20, status: 'pending',
+        uid, view: 'home', source: 'database',
       }).pipe(map(({ result }) => result)), force).pipe(map(snapshot => snapshot.value));
     this.reviewRequest = request.pipe(finalize(() => this.antiForgetLoading.set(false))).subscribe({
       next: result => {

+ 28 - 3
scripts/cloud/mobile-teaching.js

@@ -39,12 +39,34 @@ function notesReviewSourceSql() {
           AND (a.dszt='0' OR (NULLIF(BTRIM(to_jsonb(a)->>'kssj'),'') IS NOT NULL
             AND NULLIF(BTRIM(to_jsonb(a)->>'jssj'),'') IS NOT NULL AND to_jsonb(l)->>'jsmz'=to_jsonb(a)->>'pl'))))))))`;
 }
+// The home card needs aggregates, not task bodies or reward receipts. It reads the
+// existing projection; synchronization remains owned by the leased importer/detail flow.
+async function appHomeReviewSummary(where,args,dateSql,today) {
+  const [dates,syncStatus]=await Promise.all([
+    Psql.query('SELECT '+dateSql+' AS date,COUNT(*)::int total,COUNT(*) FILTER(WHERE m."fxzt"=\'1\')::int completed FROM "MemoryPracticeRecord" m WHERE '+where+' GROUP BY '+dateSql,args),
+    appLiveSyncStatus(['review']),
+  ]);
+  const summary={total:0,completed:0,due:0,today:0,todayTotal:0,todayCompleted:0,todayPending:0,overduePending:0,income:0,incomeScope:'all'};
+  for(const row of dates){
+    const total=number(row.total),completed=number(row.completed),pending=total-completed;
+    summary.total+=total;summary.completed+=completed;
+    // SQL NULL dates do not participate in comparison filters.
+    if(typeof row.date==='string'&&row.date<=today)summary.due+=pending;
+    if(typeof row.date==='string'&&row.date<today)summary.overduePending+=pending;
+    if(row.date===today){summary.today+=total;summary.todayCompleted+=completed;}
+  }
+  summary.todayTotal=summary.today;summary.todayPending=summary.today-summary.todayCompleted;
+  return {view:'home',items:[],page:{itemCount:0,pageCount:0},summary,dates,students:[],serverToday:today,
+    ...mobileSource(),syncStatus,legacyReviewSync:{state:'snapshot',lastSuccessAt:syncStatus.lastSuccessAt||''}};
+}
+
 async function appReviewPage(input,current) {
   const uid=await authorizeRequestedUser(current,input.uid),coach=number(current.get('legacyGroupId'))===3,p=mobilePage(input),includeMetadata=input.includeMetadata!==false,today=appOverviewToday();
   const reviewTarget=coach&&number(input.studentId)?await appReviewTarget(current,input.studentId):coach?0:uid;
   const refreshCoachToday=coach&&!reviewTarget&&input.scope==='today'&&p.index===1&&input.refresh===true&&uid===ownLegacyId(current);
   if(refreshCoachToday)assertLessonTenant(current);
-  const legacyReviewSync=reviewTarget?await syncLegacyReviews(reviewTarget,current,input.refresh===true):
+  const homeSnapshot=!coach&&input.view==='home'&&input.source==='database';
+  const legacyReviewSync=reviewTarget&&!homeSnapshot?await syncLegacyReviews(reviewTarget,current,input.refresh===true):
     refreshCoachToday?await syncLegacyCoachTodayReviews(uid,current,today):undefined;
   const args=[DEFAULT_COMPANY_ID,String(uid)],clauses=['m."company"=$1','m."'+(coach?'plid':'yhid')+'"=$2::text::numeric','m."'+(coach?'plid':'yhid')+'"::text=$2'];
   // Keep the exact text identity match while letting the numeric ownership index narrow candidates.
@@ -57,6 +79,7 @@ async function appReviewPage(input,current) {
   if(coach&&number(input.studentId))add('m."yhid"::text=?',String(input.studentId));
   const baseWhere=clauses.join(' AND '),baseArgs=[...args];
   const dateSql='LEFT(COALESCE(NULLIF(m."kywrq",\'\'),m."kywsj"),10)';
+  if(homeSnapshot)return appHomeReviewSummary(baseWhere,baseArgs,dateSql,today);
   const summaryPromise= Psql.one('SELECT COUNT(*)::int total,COUNT(*) FILTER(WHERE m."fxzt"=\'1\')::int completed,COUNT(*) FILTER(WHERE COALESCE(m."fxzt",\'0\')<>\'1\' AND '+dateSql+'<=$'+(args.length+1)+')::int due,COUNT(*) FILTER(WHERE '+dateSql+'=$'+(args.length+1)+')::int today,COUNT(*) FILTER(WHERE '+dateSql+'=$'+(args.length+1)+' AND m."fxzt"=\'1\')::int \"todayCompleted\",COUNT(*) FILTER(WHERE '+dateSql+'<$'+(args.length+1)+' AND COALESCE(m."fxzt",\'0\')<>\'1\')::int \"overduePending\" FROM "MemoryPracticeRecord" m WHERE '+baseWhere,[...args,today]);
   const datesPromise=includeMetadata? Psql.query('SELECT '+dateSql+' AS date,COUNT(*)::int total,COUNT(*) FILTER(WHERE m."fxzt"=\'1\')::int completed FROM "MemoryPracticeRecord" m WHERE '+baseWhere+' GROUP BY '+dateSql,baseArgs):Promise.resolve([]);
   if(input.scope==='today')add(dateSql+'=?',today);
@@ -65,12 +88,14 @@ async function appReviewPage(input,current) {
   if(input.month)add('LEFT('+dateSql+',7)=?',String(input.month));
   if(input.status==='completed')clauses.push('m."fxzt"=\'1\'');
   if(input.status==='pending')clauses.push('COALESCE(m."fxzt",\'0\')<>\'1\'');
-  const where=clauses.join(' AND '),countPromise=Psql.one('SELECT COUNT(*)::int total FROM "MemoryPracticeRecord" m WHERE '+where,args);
+  const where=clauses.join(' AND '),countPromise=input.scope==='today'&&!input.status&&!input.month
+    ?summaryPromise.then(summary=>({total:number(summary.today)}))
+    :Psql.one('SELECT COUNT(*)::int total FROM "MemoryPracticeRecord" m WHERE '+where,args);
   // Historical createdAt is the import time; the study model preserves the original learning time.
   const rowsPromise=Psql.query('SELECT c.*,row_to_json(m) AS "__addon",COALESCE(n."nodeName",CASE WHEN '+notesSource+' THEN \'语法写作复习\' END) AS "kc_title",d."dqrq" AS "learned_date",dc."createTime" AS "learned_at",r."amount" AS money,r."status" AS "rewardStatus",r."ineligibleReason" AS "ineligibleReason",r."completedAt" AS "completedAt",r."originalAmount" AS "originalAmount",r."correctionReason" AS "correctionReason",r."correctionAmount" AS "correctionAmount",'+reviewStudentNameSql()+' AS "honeyName",(SELECT COUNT(*) FROM "MemoryPracticeRecord" sibling WHERE sibling."company"=m."company" AND sibling."yhid"=m."yhid" AND sibling."xxjlid"=m."xxjlid" AND (sibling."kywsj",sibling."id")<=(m."kywsj",m."id"))::int AS "reviewNo" FROM "MemoryPracticeRecord" m JOIN LATERAL (SELECT * FROM "CommonModel" c WHERE c."company"=m."company" AND c."modelId"=60 AND c."itemId"=m."id" OFFSET 0) c ON TRUE LEFT JOIN "Node" n ON n."company"=m."company" AND n."nodeId"::text=m."kcid"::text LEFT JOIN LATERAL (SELECT * FROM "CommonModel" dc WHERE dc."company"=m."company" AND dc."modelId"=56 AND dc."generalId"::text=m."xxjlid"::text OFFSET 0) dc ON TRUE LEFT JOIN "DailyStudyRecord" d ON d."company"=m."company" AND d."id"=dc."itemId" LEFT JOIN "ReviewRewardIncome" r ON r."company"=m."company" AND r."taskId"=m."objectId" LEFT JOIN "_User" u ON u."company"=m."company" AND u."legacyUserId"=m."yhid" WHERE '+where+(input.scope==='today'?' ORDER BY CASE WHEN m."fxzt"=\'1\' THEN 1 ELSE 0 END,m."kywsj",m."id"':' ORDER BY m."kywsj" DESC,m."id" DESC')+' LIMIT $'+(args.length+1)+' OFFSET $'+(args.length+2),[...args,p.size,(p.index-1)*p.size]);
   const versionTarget=coach&&number(input.studentId)?number(input.studentId):uid;
   // Keep the receipt view correlated to the selected tasks; flattening it can scan tenant-wide history.
-  const [students,summary,dates,count,rows,income,versions]=await Promise.all([studentsPromise,summaryPromise,datesPromise,countPromise,rowsPromise,coach&&input.includeIncome!==false?Psql.one('SELECT COALESCE(SUM(r."amount"),0) AS income FROM "MemoryPracticeRecord" m JOIN LATERAL (SELECT r."amount" FROM "ReviewRewardIncome" r WHERE r."company"=m."company" AND r."taskId"=m."objectId" OFFSET 0) r ON TRUE WHERE '+where,args).then(row=>number(row.income)):0,appLiveVersionSnapshot(versionTarget,['review','account'],current)]);
+  const [students,summary,dates,count,rows,income,versions]=await Promise.all([studentsPromise,summaryPromise,datesPromise,countPromise,rowsPromise,coach&&input.includeIncome!==false?Psql.one('SELECT COALESCE(SUM(r."amount"),0) AS income FROM "MemoryPracticeRecord" m JOIN LATERAL (SELECT r."amount" FROM "ReviewRewardIncome" r WHERE r."company"=m."company" AND r."taskId"=m."objectId" OFFSET 0) r ON TRUE WHERE '+where,args).then(row=>number(row.income)):0,input.includeVersions===false?{}:appLiveVersionSnapshot(versionTarget,['review','account'],current)]);
   return {items:rows.map(row=>{const addon=row.__addon;delete row.__addon;return{...legacyAliases({...row,...addon},'CommonModel'),money:number(row.money),rewardStatus:row.rewardStatus||'pending'};}),page:mobilePageMeta(count.total,p),summary:{...summary,todayTotal:number(summary.today),todayCompleted:number(summary.todayCompleted),todayPending:number(summary.today)-number(summary.todayCompleted),overduePending:number(summary.overduePending),income,incomeScope:input.month||'all'},dates,students,legacyReviewSync,serverToday:today,...mobileSource(),...versions};
 }
 

+ 72 - 0
scripts/tests/home-review-read.test.mjs

@@ -0,0 +1,72 @@
+import {test} from 'node:test';
+import assert from 'node:assert/strict';
+import {readFileSync} from 'node:fs';
+import vm from 'node:vm';
+import {PGlite} from '@electric-sql/pglite';
+
+const source=readFileSync(new URL('../cloud/mobile-teaching.js',import.meta.url),'utf8');
+const plain=v=>JSON.parse(JSON.stringify(v));
+async function fixture(run){
+ const db=new PGlite();
+ try{
+  await db.exec(readFileSync(new URL('./fixtures/teacher-review-schema.sql',import.meta.url),'utf8'));
+  await db.exec(`INSERT INTO "ReviewRewardIncome"(company,"taskId",amount,status) VALUES('co','r2',1,'earned');
+   INSERT INTO "_User"(company,"legacyUserId","legacyUserData") VALUES('co',20,'{"UserPoint":12.5,"Purse":8,"SilverCoin":7}'::jsonb);`);
+  const calls=[];
+  const query=async(sql,args)=>{calls.push(sql);assert.match(sql,/^SELECT /,'homepage must not write');return (await db.query(sql,args)).rows;};
+  const ctx=vm.createContext({Date,Promise,DEFAULT_COMPANY_ID:'co',number:(v,f=0)=>Number.isFinite(Number(v))?Number(v):f,
+   authorizeRequestedUser:async(_c,uid)=>Number(uid),appReviewTarget:async()=>20,appOverviewToday:()=> '2026-10-14',legacyAliases:r=>r,
+   appLiveVersionSnapshot:async()=>({version:'v1'}),appLiveSyncStatus:async()=>({state:'delayed',lastSuccessAt:'2026-10-13T00:00:00Z',lagSeconds:100,pending:2}),
+   syncLegacyReviews:async()=>{throw Error('legacy sync must not run on a database-only home read');},
+   Psql:{query,one:async(s,a)=>(await query(s,a))[0]}});
+  vm.runInContext(source,ctx);
+  const snapshot=async()=>{const result={};for(const t of ['MemoryPracticeRecord','CommonModel','ReviewRewardIncome','_User','LessonRecord','CourseAppointment','DailyStudyRecord'])result[t]=(await db.query(`SELECT * FROM "${t}"`)).rows;return result;};
+  await run({db,ctx,calls,snapshot});
+ }finally{await db.close();}
+}
+
+test('student home reads synced summaries without old-system requests, fee joins or any balance/receipt mutation',async()=>fixture(async({ctx,calls,snapshot})=>{
+ const before=await snapshot();
+ const result=plain(await ctx.appReviewPage({uid:20,view:'home',source:'database',status:'pending',refresh:true},{get:()=>1}));
+ assert.deepEqual(result.summary,{total:50,completed:1,due:2,today:2,todayTotal:2,todayCompleted:1,todayPending:1,overduePending:1,income:0,incomeScope:'all'});
+ assert.equal(result.view,'home');assert.deepEqual(result.items,[]);
+ assert.deepEqual(result.dates.sort((a,b)=>a.date.localeCompare(b.date)),[{date:'2026-10-13',total:1,completed:0},{date:'2026-10-14',total:2,completed:1},{date:'2026-10-20',total:47,completed:0}]);
+ assert.equal(result.legacyReviewSync.state,'snapshot');assert.equal(result.syncStatus.state,'delayed');
+ assert.equal(calls.length,1,'one date aggregation replaces list/count/summary/version scans');
+ assert.ok(!calls.some(s=>s.includes('ReviewRewardIncome')));
+ assert.deepEqual(await snapshot(),before);
+}));
+
+test('home snapshot retains empty/undated/completed and other-tenant ownership semantics',async()=>fixture(async({db,ctx})=>{
+ await db.exec(`UPDATE "MemoryPracticeRecord" SET kywrq='',kywsj=NULL WHERE id=3;
+ INSERT INTO "MemoryPracticeRecord" VALUES('foreign','other',500,30,20,100,1,'0','2026-10-14','2026-10-14');`);
+ const result=plain(await ctx.appReviewPage({uid:20,view:'home',source:'database'},{get:()=>1}));
+ assert.equal(result.summary.total,50);assert.equal(result.summary.due,1);assert.equal(result.summary.overduePending,0);
+ const empty=plain(await ctx.appReviewPage({uid:99,view:'home',source:'database'},{get:()=>1}));
+ assert.equal(empty.summary.total,0);assert.equal(empty.summary.income,0);assert.deepEqual(empty.dates,[]);
+}));
+
+test('today teacher list reuses exact summary count while preserving rows and fee result',async()=>fixture(async({ctx,calls})=>{
+ const result=plain(await ctx.appReviewPage({uid:30,scope:'today',includeMetadata:false,includeIncome:false,pageSize:1},{get:()=>3}));
+ assert.equal(result.page.itemCount,2);assert.equal(result.page.pageCount,2);assert.equal(result.items.length,1);assert.equal(result.items[0].fxzt,'0');
+ assert.equal(result.summary.total,50);assert.equal(result.summary.completed,1);assert.equal(result.summary.income,0);
+ assert.equal(calls.length,2,'reuse today from summary rather than issuing another count');
+ calls.length=0;
+ const filtered=plain(await ctx.appReviewPage({uid:30,scope:'today',status:'completed',includeMetadata:false,includeIncome:false},{get:()=>3}));
+ assert.equal(filtered.page.itemCount,1);assert.equal(filtered.items[0].money,1);assert.equal(filtered.items[0].rewardStatus,'earned');
+}));
+
+test('database home mode cannot bypass account authorization',async()=>fixture(async({ctx,calls})=>{
+ ctx.authorizeRequestedUser=async()=>{throw Error('denied');};
+ await assert.rejects(ctx.appReviewPage({uid:99,view:'home',source:'database'},{get:()=>1}),/denied/);
+ assert.equal(calls.length,0);
+}));
+
+test('teacher home may omit unused version scans without changing tasks, summary or reward values',async()=>fixture(async({ctx})=>{
+ let versionReads=0;ctx.appLiveVersionSnapshot=async()=>{versionReads++;return {version:'v1'};};
+ const input={uid:30,scope:'today',includeMetadata:false,includeIncome:false};
+ const before=plain(await ctx.appReviewPage(input,{get:()=>3}));versionReads=0;
+ const after=plain(await ctx.appReviewPage({...input,includeVersions:false},{get:()=>3}));
+ assert.equal(versionReads,0);delete before.version;delete before.refreshedAt;delete after.refreshedAt;
+ assert.deepEqual(after,before);
+}));

+ 1 - 1
scripts/tests/teacher-earnings.test.mjs

@@ -27,7 +27,7 @@ test('今日查询在分页前筛选,超过20条未来任务仍不遮挡今日
   const ctx=vm.createContext({Date,Promise,DEFAULT_COMPANY_ID:'co',number:(v,f=0)=>Number(v)||f,authorizeRequestedUser:async()=>30,appOverviewToday:()=> '2026-10-14',legacyAliases:r=>r,appLiveVersionSnapshot:async()=>({}),reviewIncomeTotal:async()=>{throw Error('首页不应读取收入');}});
   ctx.Psql={query,one:async(s,a)=>(await query(s,a))[0]};vm.runInContext(mobile,ctx);
   const current={get:()=>3};const today=await ctx.appReviewPage({scope:'today',includeMetadata:false,includeIncome:false,pageSize:4},current);
-  assert.equal(today.items.length,2);assert.equal(today.page.itemCount,2);assert.equal(Number(today.items[0].id),1);assert.equal(today.summary.todayTotal,2);assert.equal(today.summary.todayCompleted,1);assert.equal(today.summary.todayPending,1);assert.equal(today.summary.overduePending,1);assert.equal(today.students.length,0);assert.equal(today.dates.length,0);assert.equal(calls.length,3);
+  assert.equal(today.items.length,2);assert.equal(today.page.itemCount,2);assert.equal(Number(today.items[0].id),1);assert.equal(today.summary.todayTotal,2);assert.equal(today.summary.todayCompleted,1);assert.equal(today.summary.todayPending,1);assert.equal(today.summary.overduePending,1);assert.equal(today.students.length,0);assert.equal(today.dates.length,0);assert.ok(calls.length<=2,'今日总数复用汇总,不重复 COUNT');
   const overdue=await ctx.appReviewPage({scope:'overdue',includeMetadata:false,includeIncome:false},current);assert.equal(overdue.items.length,1);assert.equal(overdue.items[0].kywrq,'2026-10-13');
  }finally{await db.close();}
 });

Některé soubory nejsou zobrazeny, neboť je v těchto rozdílových datech změněno mnoho souborů