Jelajahi Sumber

test(perf): reject incomplete home loads and audit cache compatibility

彭峰 19 jam lalu
induk
melakukan
0e371fdd16

+ 61 - 0
perf/recovery-performance-2026-11-14/CODE-REVIEW.md

@@ -0,0 +1,61 @@
+# 回退后的代码复核与验收失败解释
+
+2026-11-14。审查候选 `81f8b44..d7cb829`、调用链、上线原始样本,并作独立审查和本地故障注入。此次不重新上线、不修改业务数据,也没有修改共享服务配置。
+
+结论:验收失败的直接原因是业务读取超过前端20秒期限、整页长等待未消除。确认了两处缓存/同步提示遗漏和一处验收假通过;尚不能将生产长等待定位到鉴权、worker调度、连接排队或具体SQL,不能用“连接池只有2”代替根因证据。
+
+## 确定的代码问题
+
+### 1. 新响应没有隔离旧缓存格式(上线前应修)
+
+`home-page.component.ts:186` 继续使用 `review` 键。`home-daily-cache.service.ts:116` 仅验证 items/dates/summary 的形状,没有校验新 `view: home` / `snapshot` 契约。`home-page.component.ts:53` 只对 snapshot 显示新提示。
+
+同一天用过旧版首页的用户升级后,直接命中旧响应,不请求新首页接口,也没有新增的“已有同步记录”提示。候选写入的快照反过来也会被旧版接受,回退并不会清掉这一持久缓存。结构仍兼容,未发现由此产生统计数字或扣费错误。
+
+真实缓存服务本地复现:旧响应存在时 fetch=0、state=current、无新home标记。应只迁移/失效首页复习这一个缓存格式,不能粗暴清空全部课时、余额或业务缓存。
+
+### 2. 同步提示会把上午状态沿用到下午(上线前应修)
+
+`home-daily-cache.service.ts:41` 缓存优先返回,首页的 syncStatus 与统计一起存一整天。服务上午正常、下午停止,同日重入仍拿上午的 current,不能保证显示“同步延迟”。本地模拟09:00缓存、15:00重入:普通读取 fetch=0/current;手动刷新 fetch=1/delayed。
+
+应明确状态表示“某时刻的检查结果”,按现有时间戳推导过期/未知,或另做有界的状态复核;不要为了刷新提示重新触发旧系统大批同步。业务统计的当日快照策略与状态的新鲜度须分别定义。
+
+## 确定的验收漏洞(本轮已修正工具)
+
+原 `benchmark.mjs` 只检查抗遗忘区域错误和刷新按钮是否可点,漏掉 `home-page.component.html:104` 的统计/余额错误提示,也允许统计卡片出现“—”。按钮在请求失败后也会恢复可点,因此失败页会满足原条件。
+
+真实候选构建、合成账号、全部网络限定本地,三场景结果:
+
+| 场景 | 原门槛 | 修正后门槛 |
+|---|---|---|
+| 全部数据正常 | 通过 | 通过 |
+| 余额接口失败、余额显示— | **错误通过** | 拒绝 |
+| 统计接口失败、统计显示— | **错误通过** | 拒绝 |
+
+新增共享 `readiness.mjs`,生产/本地采样都使用它;必须没有余额/统计错误、五项数字有效、页面稳定500ms。生产脚本补记 requestfailed,保留 action、耗时和失败原因,不记录令牌。新增 `home-readiness-audit.mjs` 可复现旧门槛缺陷并验证新门槛。
+
+生产原始学生首页第7次样本被旧门槛判成功,但只捕获复习/统计两项响应;第三个请求耗时20.004秒,无资料接口成功响应。这与上述缺陷吻合。原始日志未采集该错误提示,因此不能伪造补全旧页面实况;原“首页20/20”只代表旧门槛下显示成功,不能作为20次完整业务数据成功。
+
+## 优化未覆盖的阻塞链路
+
+- 老师首页仍需要 `e_order_tongji` 和今日复习列表。学生首页还要等待统计及余额资料。本批只减少复习读取;真实统计请求曾耗时18.124秒,老师复习请求曾耗时19.075秒。减少SQL条数并不保证整页可用。
+- `mobile-teaching.js:45–48` 仍等待 `appLiveSyncStatus`。`deploy-admin-functions.mjs:2977–2979` 在环境配置齐全时先读 checkpoint 再读 dead-letter,无独立截止时间;因此状态读取可以阻塞已完成的汇总。基线也有这一依赖,这是优化覆盖不足,不是已证实的新超时根因。环境未配置时则立即 unavailable,不能说它每次都查数据库。
+- 仅显示数据库已有任务是本批明确设计:学生首页手动刷新也不拉旧系统新任务。后台同步停滞时,需详情核对或同步器恢复,不能把“刷新首页”承诺成“已追平旧系统”。
+- 当天课表 `course-center-page.component.ts:139–145` 使用 dailyCache.watchDay;只有历史课表、预约详情启用恢复轮询。失败样本来自当天课表,**并没有执行本批的恢复合并/退避 watcher**。其服务端函数也未改动。
+- `api.service.ts:59–60` 20秒到期后抛 ApiError。`isTransientError` 只把 HttpErrorResponse 的0/502/503/504当作可重试,故这种前端超时不会自动重试。它是既有行为,不是本批新增,也不能靠把等待加长来证明优化成功。若后续调整,只能针对明确只读调用设计有限恢复,保持写入幂等及禁止盲目重复提交。
+
+## 为什么本地测试通过,线上仍不能验收
+
+本地SQL测试使用PGlite合成数据;鉴权与状态读取被替代为固定结果,旧系统只注入固定1.5秒延迟。浏览器实验的统计和余额也是本地固定响应,未模拟生产认证、worker分配、2连接池的等待以及20秒超时。原1,366项测试验证的是既定用例,不代表覆盖了这些生产瓶颈。
+
+生产两次课表样本的请求分别在20.009秒和20.006秒结束,无成功业务响应,页面明确显示“请求暂未完成”。这证明前端读取超时,不证明返回过HTTP503,也不能仅凭这些信息判定SQL慢或连接池耗尽。4份池快照都没有抓到waiting>0。
+
+较早基线和上线后采样不同时段,期间还有独立UI/对照采样重叠。旧/新静态资源对照仍使用同一候选网关,所以只对前端提供有限对照,不能充当完整后端A/B。对于未改动的课表函数有排除代码差异的价值,但仍不能把差值全部算作候选回归或候选收益。
+
+因此回退是因为稳定性门槛未满足且归因证据不足,**不是已经证明这两个读取函数造成了故障**。
+
+## 费用与下一步
+
+本轮未发现新增扣课时/费用写入;只读回归5/5通过,包含数据前后不变及教师任务/金额结果比较。本轮没有重新进行生产账务审计;上轮两账号余额和一份全量学生流水的核对范围不变。
+
+再发布前应完成:缓存格式迁移与状态过期语义;使用修正门槛覆盖余额/统计/复习三类失败及老用户热缓存;对完整请求加入关联ID与鉴权、worker等待、SQL等待/执行、旧系统访问的分段记录;相同条件下单独交替候选/基线,失败率与成功耗时分开统计。此次只改验收工具并保留复现证据,业务缓存修复及后端分段诊断尚未实施,没有重新发布。

+ 2 - 0
perf/recovery-performance-2026-11-14/RELEASE-REPORT.md

@@ -63,3 +63,5 @@ H5 已恢复 `mobile-course-word-speed-0b7d9d5f89c7-20261112T074222208Z`,105
 回退消除的是本次候选的不确定性,不代表原有慢响应已解决。下一次上线前,需补充故障时段请求关联、鉴权/worker分配/连接等待/SQL/旧系统调用的分段耗时;维护方确认共享服务稳定后,再对候选进行同条件连续验收。当前不自动再次发布,保留本批源码和构建证据。
 
 回退后真实老师/学生课表 API 分别在 1.763秒/2.786秒返回成功,余额字段仍与发布前一致;这是最终只读冒烟,不是长期稳定性证明。发布与回退记录已随源码归档。
+
+后续代码复核发现原首页门槛会漏报余额/统计失败;“首页20/20显示成功”不能作为数据完整成功率。缓存迁移/状态新鲜度及本地复现见 [CODE-REVIEW.md](CODE-REVIEW.md),本轮仅修正验收工具,未重新上线。

+ 6 - 9
perf/recovery-performance-2026-11-14/benchmark.mjs

@@ -2,6 +2,7 @@
 // Run from repository root: DIRECT=1 RUNS=10 OUT=browser-direct node perf/recovery-performance-2026-11-14/benchmark.mjs /absolute/private/session-directory
 // Never place session files or raw responses in Git. This script does not log request payloads or tokens.
 import fs from 'node:fs';
+import {pageReadyGate} from './readiness.mjs';
 import {createRequire} from 'node:module';
 import path from 'node:path';
 const require=createRequire(path.resolve('package.json'));
@@ -18,20 +19,16 @@ try {for(let run=1;run<=count;run++) for(const cohort of (run%2?['teacher-home',
  await context.addInitScript(s=>{localStorage.setItem('xiaoshu.angular.session',JSON.stringify(s));window.__long=[];performance.setResourceTimingBufferSize(2000);new PerformanceObserver(l=>window.__long.push(...l.getEntries().map(x=>x.duration))).observe({type:'longtask',buffered:true});},session);
  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=Date.now(),apis=[],pending=[],starts=new Map();let shellMs=null;
+ const start=Date.now(),apis=[],failedRequests=[],pending=[],starts=new Map();let shellMs=null;
  page.on('request',req=>starts.set(req,Date.now()));
+ page.on('requestfailed',req=>{if(!req.url().includes('/api/functions/'))return;let action='unknown';try{action=req.postDataJSON()?.params?.action||'preflight';}catch{}failedRequests.push({action,method:req.method(),ms:Date.now()-(starts.get(req)||start),error:req.failure()?.errorText||'unknown'});});
  page.on('response',res=>{const req=res.request();if(!res.url().includes('/api/functions/')||req.method()==='OPTIONS')return;pending.push((async()=>{let action='unknown';try{action=req.postDataJSON()?.params?.action||'unknown'}catch{};await res.finished().catch(()=>{});let body;try{body=await res.body()}catch{};let d;try{d=JSON.parse(body)}catch{};apis.push({action,startMs:(starts.get(req)||start)-start,ms:Date.now()-(starts.get(req)||start),status:res.status(),retcode:d?.retcode,bytes:body?.length??null,timing:req.timing(),reviewMeta:d?.result?.legacyReviewSync?Object.fromEntries(Object.entries(d.result.legacyReviewSync).filter(([k])=>['state','pulled','written','sourceTotal','complete','conflicts'].includes(k))):undefined});})());});
  let ok=false,error=null,readyMs=null,metrics=null;
  try {await page.goto('https://h5.xiaoshu.pro/pages/home/'+(course?'shop':'index'),{waitUntil:'domcontentloaded',timeout:45000});
  await page.waitForSelector(course?'.mobile-pagination':role==='teacher'?'.coach-hero':'.memory-hero',{timeout:45000});shellMs=Date.now()-start;
- await page.waitForFunction(({role,course})=>{const q=s=>document.querySelector(s);let valid=false,key='';
- if(course){const p=q('.mobile-pagination');valid=!!p&&!p.textContent.includes('—')&&!q('.center-state.error')&&!Array.from(document.querySelectorAll('button')).some(b=>b.textContent.trim()==='刷新课表'&&b.disabled);key=p?.textContent+'|'+document.querySelectorAll('[data-schedule-id]').length;}
- else if(role==='teacher'){valid=!!q('.coach-grid:not(.loading)')&&!!q('.coach-memory-summary')&&!q('.coach-hero-error')&&!q('.coach-memory-state.error')&&!q('button[aria-label="刷新抗遗忘任务"]:disabled');key=q('.coach-memory-summary')?.textContent+'|'+document.querySelectorAll('.coach-memory-list article').length;}
- else{valid=!!q('.memory-content')&&!!q('button[aria-label="刷新学习数据"]:not(:disabled)')&&!q('.hero-error');key=q('.student-stats')?.textContent+'|'+q('.today-panel')?.textContent;}
- if(!valid){window.__ready=null;return false}if(window.__ready?.key!==key){window.__ready={key,at:performance.now()};return false}return performance.now()-window.__ready.at>=500;
- },{role,course},{timeout:65000});readyMs=Date.now()-start;ok=true;
+ await page.waitForFunction(pageReadyGate,{role,course},{timeout:65000});readyMs=Date.now()-start;ok=true;
  }catch(e){error=String(e.message).split('\n')[0];}
- try{metrics=await page.evaluate(()=>({longTaskMs:window.__long?.reduce((a,b)=>a+b,0)||0,resources:performance.getEntriesByType('resource').map(r=>({name:r.name.split('?')[0],startMs:r.startTime,endMs:r.responseEnd,ms:r.duration,transferBytes:r.transferSize,encodedBytes:r.encodedBodySize,type:r.initiatorType})),reviewRows:document.querySelectorAll('.coach-memory-list article').length,scheduleRows:document.querySelectorAll('[data-schedule-id]').length,errors:Array.from(document.querySelectorAll('.center-state.error,.coach-hero-error,.coach-memory-state.error,.hero-error')).map(e=>e.textContent)}));}catch{}
- await Promise.allSettled(pending);const row={cohort,run,at:new Date().toISOString(),ok,error,readyMs,shellMs,elapsedMs:Date.now()-start,apis,metrics};results.push(row);fs.writeFileSync(root+'/'+out+'.json',JSON.stringify(results,null,2),{mode:0o600});console.log(JSON.stringify({cohort,run,ok,readyMs,shellMs,apis:apis.map(({action,ms,status,retcode})=>({action,ms,status,retcode})),error}));await context.close();
+ try{metrics=await page.evaluate(()=>({longTaskMs:window.__long?.reduce((a,b)=>a+b,0)||0,resources:performance.getEntriesByType('resource').map(r=>({name:r.name.split('?')[0],startMs:r.startTime,endMs:r.responseEnd,ms:r.duration,transferBytes:r.transferSize,encodedBytes:r.encodedBodySize,type:r.initiatorType})),reviewRows:document.querySelectorAll('.coach-memory-list article').length,scheduleRows:document.querySelectorAll('[data-schedule-id]').length,errors:Array.from(document.querySelectorAll('.center-state.error,.coach-hero-error,.coach-memory-state.error,.hero-error,.memory-hero + p[role="status"]')).map(e=>e.textContent)}));}catch{}
+ await Promise.allSettled(pending);const row={cohort,run,at:new Date().toISOString(),ok,error,readyMs,shellMs,elapsedMs:Date.now()-start,apis,failedRequests,metrics};results.push(row);fs.writeFileSync(root+'/'+out+'.json',JSON.stringify(results,null,2),{mode:0o600});console.log(JSON.stringify({cohort,run,ok,readyMs,shellMs,apis:apis.map(({action,ms,status,retcode})=>({action,ms,status,retcode})),error}));await context.close();
  if(results.slice(-4).filter(r=>!r.ok).length>=3)throw Error('Stop after repeated unavailable pages');
  }}finally{await browser.close();}

+ 115 - 0
perf/recovery-performance-2026-11-14/cache-contract-audit.mjs

@@ -0,0 +1,115 @@
+// Local diagnostic: transpile the real cache service, using synthetic identity
+// and in-memory storage. No application bundle, server, network or real session.
+import assert from 'node:assert/strict';
+import { readFileSync } from 'node:fs';
+import vm from 'node:vm';
+import ts from 'typescript';
+import { firstValueFrom, of } from 'rxjs';
+import * as rxjs from 'rxjs';
+
+const servicePath = new URL('../../projects/xiaoshu-mobile/src/app/core/home-daily-cache.service.ts', import.meta.url);
+const compiled = ts.transpileModule(readFileSync(servicePath, 'utf8'), {
+  compilerOptions: {
+    target: ts.ScriptTarget.ES2022,
+    module: ts.ModuleKind.CommonJS,
+    experimentalDecorators: true,
+  },
+}).outputText;
+const STORAGE_KEY = 'xiaoshu.learning.homeDaily.v1';
+const morning = '2026-11-14T01:00:00.000Z';
+const afternoon = '2026-11-14T07:00:00.000Z';
+const identity = '20|1|2026-11-14';
+const oldPage = {
+  items: [], dates: [],
+  summary: { total: 0, completed: 0, due: 0, today: 0, income: 0 },
+  legacyReviewSync: { state: 'current' },
+};
+
+function fixture(value) {
+  const storage = new Map([[STORAGE_KEY, JSON.stringify({
+    identity, values: { review: { value, updatedAt: morning } },
+  })]]);
+  class LocalDate extends Date {
+    constructor(...args) { super(...(args.length ? args : [afternoon])); }
+    static now() { return Date.parse(afternoon); }
+  }
+  const session = {
+    user: () => ({ userId: 20, groupId: 1 }),
+    token: () => 'isolated-cache-audit-fixture',
+  };
+  const sandbox = {
+    exports: {}, Date: LocalDate,
+    localStorage: {
+      getItem: key => storage.get(key) ?? null,
+      setItem: (key, item) => storage.set(key, item),
+      removeItem: key => storage.delete(key),
+    },
+    require: name => {
+      if (name === '@angular/core') return {
+        Injectable: () => target => target,
+        inject: token => {
+          if (token === 'SessionService') return session;
+          if (token === 'NgZone') return {};
+          throw new Error(`Unexpected injection: ${token}`);
+        },
+        NgZone: 'NgZone',
+      };
+      if (name === 'rxjs') return rxjs;
+      if (name === './session.service') return { SessionService: 'SessionService' };
+      if (name === './teacher-today-review.service') return { beijingToday: () => '2026-11-14' };
+      throw new Error(`Unexpected module: ${name}`);
+    },
+  };
+  vm.runInNewContext(compiled, sandbox, { filename: servicePath.pathname });
+  return new sandbox.exports.HomeDailyCacheService();
+}
+
+const reports = [];
+{
+  const cache = fixture(oldPage);
+  let fetches = 0;
+  const snapshot = await firstValueFrom(cache.load('review', () => {
+    fetches++;
+    return of({ ...oldPage, view: 'home', legacyReviewSync: { state: 'snapshot' } });
+  }));
+  assert.equal(fetches, 0);
+  assert.equal(snapshot.value.legacyReviewSync.state, 'current');
+  assert.equal(snapshot.value.view, undefined);
+  reports.push({
+    case: 'same-day cache from old release bypasses new home endpoint',
+    fetches, retainedLegacyReviewSyncState: snapshot.value.legacyReviewSync.state,
+    hasNewHomeView: snapshot.value.view === 'home',
+    implication: 'HomePage reviewSyncNotice requires snapshot state, so this cached response does not show the new notice.',
+  });
+}
+{
+  const cache = fixture({
+    ...oldPage, view: 'home',
+    legacyReviewSync: { state: 'snapshot', lastSuccessAt: morning },
+    syncStatus: { state: 'current', lastSuccessAt: morning },
+  });
+  let fetches = 0;
+  const fetch = () => {
+    fetches++;
+    return of({ ...oldPage, view: 'home', legacyReviewSync: { state: 'snapshot' }, syncStatus: { state: 'delayed' } });
+  };
+  const snapshot = await firstValueFrom(cache.load('review', fetch));
+  assert.equal(fetches, 0);
+  assert.equal(snapshot.value.syncStatus.state, 'current');
+  assert.equal(snapshot.updatedAt, morning);
+  const forced = await firstValueFrom(cache.load('review', fetch, true));
+  assert.equal(fetches, 1);
+  assert.equal(forced.value.syncStatus.state, 'delayed');
+  assert.equal(forced.updatedAt, afternoon);
+  reports.push({
+    case: 'same-day cached sync status stays current until forced reload',
+    simulatedNow: afternoon, cachedAt: snapshot.updatedAt,
+    ordinaryFetches: 0, ordinaryState: snapshot.value.syncStatus.state,
+    forcedFetches: fetches, forcedState: forced.value.syncStatus.state,
+    implication: 'The stored sync status describes the morning read, not a live check at the later visit.',
+  });
+}
+console.log(JSON.stringify({
+  scope: 'Local service/cache contract only; no network, production identity, business writes or production-cause inference.',
+  passed: reports.length, reports,
+}, null, 2));

+ 24 - 0
perf/recovery-performance-2026-11-14/home-readiness-audit.mjs

@@ -0,0 +1,24 @@
+// Reproduce the old acceptance gate using a real candidate build and synthetic API data.
+// No production sessions, credentials or network are used.
+// node perf/recovery-performance-2026-11-14/home-readiness-audit.mjs /absolute/build/browser /absolute/private/output
+import {pageReadyGate} from './readiness.mjs';
+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';
+const [build,out]=process.argv.slice(2);if(!build||!out||!path.isAbsolute(build)||!path.isAbsolute(out))throw Error('Pass absolute build and evidence directories');
+const {chromium}=createRequire(path.resolve('package.json'))('playwright');fs.mkdirSync(out,{recursive:true,mode:0o700});
+const today=new Intl.DateTimeFormat('en-CA',{timeZone:'Asia/Shanghai'}).format(new Date()),results=[],external=[];let scenario='healthy';
+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 {action}=JSON.parse(body).params;let result;
+ if((scenario==='balance-error'&&action==='app_account_overview')||(scenario==='stats-error'&&action==='e_order_tongji')){res.writeHead(200,{'Content-Type':'application/json'});res.end(JSON.stringify({retcode:-1,retmsg:'isolated injected failure'}));return;}
+ if(action==='app_account_overview')result={profile:{UserPoint:12.5,Purse:8,SilverCoin:7}};
+ else if(action==='e_order_tongji')result={t_total:18,t_shichang:13};
+ else if(action==='app_review_page')result={items:[],dates:[{date:today,total:2,completed:1}],page:{itemCount:0,pageCount:0},summary:{total:2,completed:1,due:1,today:2,income:0},serverToday:today,legacyReviewSync:{state:'snapshot'},syncStatus:{state:'unavailable'}};
+ else throw Error('unexpected action '+action);
+ res.writeHead(200,{'Content-Type':'application/json'});res.end(JSON.stringify({retcode:0,result}));return;}
+ let file=path.resolve(build,'.'+url.pathname);if(!file.startsWith(build+path.sep)){res.writeHead(403);res.end();return;}if(!fs.existsSync(file)||fs.statSync(file).isDirectory())file=path.join(build,'index.html');const mime={'.html':'text/html','.js':'text/javascript','.css':'text/css','.svg':'image/svg+xml','.png':'image/png','.woff2':'font/woff2'}[path.extname(file)]||'application/octet-stream';res.writeHead(200,{'Content-Type':mime});res.end(fs.readFileSync(file));
+ }catch(error){res.writeHead(500);res.end(String(error.message));}});
+await new Promise(resolve=>server.listen(0,'127.0.0.1',resolve));const origin='http://127.0.0.1:'+server.address().port,browser=await chromium.launch({headless:true,executablePath:process.env.CHROME_PATH||'/Applications/Google Chrome.app/Contents/MacOS/Google Chrome',args:['--no-proxy-server']});
+try{for(scenario of ['healthy','balance-error','stats-error']){const context=await browser.newContext({viewport:{width:390,height:844},timezoneId:'Asia/Shanghai'});await context.addInitScript(()=>localStorage.setItem('xiaoshu.angular.session',JSON.stringify({token:'r:local-audit-only',user:{userId:20,groupId:1,userName:'本地测试学生'},student:null})));await context.route('**/*',route=>{if(new URL(route.request().url()).origin===origin)return route.continue();external.push(route.request().url());return route.abort();});const page=await context.newPage();await page.goto(origin+'/pages/home/index',{waitUntil:'domcontentloaded'});await page.waitForSelector('.memory-content');await page.waitForFunction(()=>!!document.querySelector('.student-home-refresh:not(:disabled)'));
+ const observed=await page.evaluate(()=>{const q=s=>document.querySelector(s),stats=q('.student-stats'),previous=stats?.previousElementSibling;return{oldGateAccepts:!!q('.memory-content')&&!!q('button[aria-label="刷新学习数据"]:not(:disabled)')&&!q('.hero-error'),error:previous?.matches('p[role="status"]')?previous.textContent.trim():'',values:Array.from(stats?.querySelectorAll('strong')||[]).map(x=>x.textContent.trim())};});
+ const correctedGateAccepts=await page.evaluate(pageReadyGate,{role:'student',stableMs:0});assert.equal(correctedGateAccepts,scenario==='healthy');observed.correctedGateAccepts=correctedGateAccepts;assert.equal(observed.oldGateAccepts,true);assert.equal(Boolean(observed.error),scenario!=='healthy');if(scenario!=='healthy')assert.ok(observed.values.includes('—'));results.push({scenario,...observed});await page.screenshot({path:path.join(out,scenario+'.png'),fullPage:true});await context.close();}
+ assert.deepEqual(external,[]);fs.writeFileSync(path.join(out,'home-readiness-audit.json'),JSON.stringify({externalRequests:0,results},null,2));console.log(JSON.stringify({externalRequests:0,results}));
+}finally{await browser.close();await new Promise(resolve=>server.close(resolve));}

+ 2 - 1
perf/recovery-performance-2026-11-14/local-benchmark.mjs

@@ -1,4 +1,5 @@
 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 {pageReadyGate} from './readiness.mjs';
 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];
@@ -57,7 +58,7 @@ if(mode==='sql'){
     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});
+    await page.waitForFunction(pageReadyGate,{role:coach?'teacher':'student'},{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');}

+ 21 - 0
perf/recovery-performance-2026-11-14/readiness.mjs

@@ -0,0 +1,21 @@
+// Self-contained so Playwright can serialize it into the browser context.
+export function pageReadyGate({role,course=false,stableMs=500}) {
+ const q=s=>document.querySelector(s);let valid=false,key='';
+ if(course){
+  const pagination=q('.mobile-pagination');
+  valid=!!pagination&&!pagination.textContent.includes('—')&&!q('.center-state.error')&&!Array.from(document.querySelectorAll('button')).some(b=>b.textContent.trim()==='刷新课表'&&b.disabled);
+  key=pagination?.textContent+'|'+document.querySelectorAll('[data-schedule-id]').length;
+ }else if(role==='teacher'){
+  valid=!!q('.coach-grid:not(.loading)')&&!!q('.coach-memory-summary')&&!q('.coach-hero-error')&&!q('.coach-memory-state.error')&&!q('button[aria-label="刷新抗遗忘任务"]:disabled');
+  key=q('.coach-grid')?.textContent+'|'+q('.coach-memory-summary')?.textContent+'|'+document.querySelectorAll('.coach-memory-list article').length;
+ }else{
+  const stats=q('.student-stats'),status=stats?.previousElementSibling;
+  const values=Array.from(stats?.querySelectorAll('strong')||[]).map(x=>x.textContent.trim());
+  valid=!!q('.memory-content')&&!!q('.student-home-refresh:not(:disabled)')&&!q('.hero-error')
+   &&!status?.matches('p[role="status"]')&&values.length===5&&values.every(v=>v!==''&&v!=='—'&&Number.isFinite(Number(v)));
+  key=stats?.textContent+'|'+q('.today-panel')?.textContent;
+ }
+ if(!valid){window.__ready=null;return false;}
+ if(window.__ready?.key!==key){window.__ready={key,at:performance.now()};return stableMs===0;}
+ return performance.now()-window.__ready.at>=stableMs;
+}

+ 85 - 0
perf/recovery-performance-2026-11-14/review-audit-results.json

@@ -0,0 +1,85 @@
+{
+  "scope": "local audit; no production requests, business source changes or deployment",
+  "cacheContract": {
+    "scope": "Local service/cache contract only; no network, production identity, business writes or production-cause inference.",
+    "passed": 2,
+    "reports": [
+      {
+        "case": "same-day cache from old release bypasses new home endpoint",
+        "fetches": 0,
+        "retainedLegacyReviewSyncState": "current",
+        "hasNewHomeView": false,
+        "implication": "HomePage reviewSyncNotice requires snapshot state, so this cached response does not show the new notice."
+      },
+      {
+        "case": "same-day cached sync status stays current until forced reload",
+        "simulatedNow": "2026-11-14T07:00:00.000Z",
+        "cachedAt": "2026-11-14T01:00:00.000Z",
+        "ordinaryFetches": 0,
+        "ordinaryState": "current",
+        "forcedFetches": 1,
+        "forcedState": "delayed",
+        "implication": "The stored sync status describes the morning read, not a live check at the later visit."
+      }
+    ]
+  },
+  "homeReadiness": {
+    "externalRequests": 0,
+    "results": [
+      {
+        "scenario": "healthy",
+        "oldGateAccepts": true,
+        "error": "",
+        "values": [
+          "18",
+          "12.5",
+          "13",
+          "8",
+          "7"
+        ],
+        "correctedGateAccepts": true
+      },
+      {
+        "scenario": "balance-error",
+        "oldGateAccepts": true,
+        "error": "课时余额暂时无法更新 重新加载",
+        "values": [
+          "18",
+          "—",
+          "13",
+          "—",
+          "—"
+        ],
+        "correctedGateAccepts": false
+      },
+      {
+        "scenario": "stats-error",
+        "oldGateAccepts": true,
+        "error": "统计暂时无法加载 重新加载",
+        "values": [
+          "—",
+          "12.5",
+          "—",
+          "8",
+          "7"
+        ],
+        "correctedGateAccepts": false
+      }
+    ]
+  },
+  "healthyBuildSmoke": {
+    "samples": 4,
+    "allCompleted": true,
+    "externalRequests": 0,
+    "note": "one baseline/candidate sample per role; correctness smoke only, not a new performance estimate"
+  },
+  "readOnlyRegression": {
+    "passed": 5,
+    "failed": 0
+  },
+  "remaining": [
+    "Cache format migration and sync-status freshness are identified but not fixed in business code.",
+    "Production timeout stage is not yet determined.",
+    "requestfailed logging covers API function requests occurring before context teardown, not all network traffic."
+  ]
+}