Browse Source

perf(classroom): reuse word audio and speed up vocabulary reads

彭峰 15 giờ trước cách đây
mục cha
commit
da94b677bf
28 tập tin đã thay đổi với 1054 bổ sung và 14 xóa
  1. 11 0
      perf/teacher-concurrency-2026-11-07/BRIEF.md
  2. BIN
      perf/teacher-concurrency-2026-11-07/after-1000.png
  3. BIN
      perf/teacher-concurrency-2026-11-07/after-390.png
  4. BIN
      perf/teacher-concurrency-2026-11-07/before-1000.png
  5. BIN
      perf/teacher-concurrency-2026-11-07/before-390.png
  6. 71 0
      perf/teacher-concurrency-2026-11-07/bench-browser.mjs
  7. 20 0
      perf/teacher-concurrency-2026-11-07/bench-sql.mjs
  8. 64 0
      perf/teacher-concurrency-2026-11-07/browser-final-smoke.json
  9. 226 0
      perf/teacher-concurrency-2026-11-07/browser-paired.json
  10. 26 0
      perf/teacher-concurrency-2026-11-07/live-read-baseline.json
  11. 9 0
      perf/teacher-concurrency-2026-11-07/ratchet.mjs
  12. 44 0
      perf/teacher-concurrency-2026-11-07/sql-paired.json
  13. 16 0
      perf/teacher-concurrency-2026-11-07/visual-verification.json
  14. 23 0
      perf/teacher-concurrency-2026-11-07/基线.md
  15. 8 0
      perf/teacher-concurrency-2026-11-07/实验日志.md
  16. 47 0
      perf/teacher-concurrency-2026-11-07/报告.md
  17. 29 0
      projects/xiaoshu-mobile/src/app/core/home-learning-modules.integration.spec.ts
  18. 46 0
      projects/xiaoshu-mobile/src/app/core/listening-pool.service.spec.ts
  19. 34 0
      projects/xiaoshu-mobile/src/app/core/listening-pool.service.ts
  20. 94 0
      projects/xiaoshu-mobile/src/app/core/platform.service.spec.ts
  21. 108 7
      projects/xiaoshu-mobile/src/app/core/platform.service.ts
  22. 1 1
      projects/xiaoshu-mobile/src/app/core/word-audio.service.spec.ts
  23. 17 3
      projects/xiaoshu-mobile/src/app/features/learning/learning-page.component.ts
  24. 5 3
      scripts/deploy-admin-functions.mjs
  25. 55 0
      scripts/prepare-word-classroom-performance.mjs
  26. 19 0
      scripts/tests/fixtures/word-classroom-before.js
  27. 42 0
      scripts/tests/word-classroom-performance.test.mjs
  28. 39 0
      scripts/tests/word-classroom-release.test.mjs

+ 11 - 0
perf/teacher-concurrency-2026-11-07/BRIEF.md

@@ -0,0 +1,11 @@
+# 老师单词课堂提速 · 2026-11-07
+
+用户将首轮范围收敛到带学生学习单词的页面:显示延迟和发音卡顿,尤其多人同时使用时。保持词义、出题规则、学习进度、保存/计费及音频播放结束才揭示答案的行为。目标是典型重复点读与听音选义就绪 p75 减半;不把预加载请求隐去,分别统计关键路径和总请求。
+
+指标:点击到 audio playing;听音选义导航到四个真实可答选项显示。实验室同一时段交替 A/B 各至少 10 次,Fast 4G、冷浏览器上下文,区分提前准备和冷启动。真实接口只做有界读取,不在真实课次保存或扣费。
+
+当前健康接口约 92ms。首次词库100条约982ms,听音干扰项60条约1599ms,仅为诊断样本,不是 p75。基础网络不能据此代表老师网络。线上音频抽样发现大小差异显著,不能无界预取整本词库。
+
+管理员会话可读取词库和调用正式应用接口;原有发布脚本依赖的 masterKey 当前不可用,无法读取数据库计划/连接负载或发布云函数。不能据代码默认值声称生产合成并发为1。
+
+候选:有界音频准备与播放器复用;匹配关提前准备听音干扰项,同一轮请求合并,失败可重试。所有缓存有界且不缓存个人进度。先完成回归与可复现对照,再准备可回滚发布。按 huashu-flash 的明确要求,上线需用户批准;仓库 AGENTS.md 已授权本任务完成后提交并推送相关文件。

BIN
perf/teacher-concurrency-2026-11-07/after-1000.png


BIN
perf/teacher-concurrency-2026-11-07/after-390.png


BIN
perf/teacher-concurrency-2026-11-07/before-1000.png


BIN
perf/teacher-concurrency-2026-11-07/before-390.png


+ 71 - 0
perf/teacher-concurrency-2026-11-07/bench-browser.mjs

@@ -0,0 +1,71 @@
+// Actual built Angular pages + actual HTMLAudioElement. Isolated API/audio fixtures, not a production speed claim.
+import {chromium} from 'playwright';
+import {createServer} from 'node:http';
+import fs from 'node:fs';
+import {resolve,extname} from 'node:path';
+import assert from 'node:assert/strict';
+import {fileURLToPath} from 'node:url';
+const baseline=process.argv[2];if(!baseline)throw Error('Pass baseline browser build directory');
+const runs=Number(process.env.PERF_RUNS||10),out=new URL('./',import.meta.url);
+const wav=Buffer.alloc(44+19200);wav.write('RIFF');wav.writeUInt32LE(wav.length-8,4);wav.write('WAVEfmt ',8);wav.writeUInt32LE(16,16);wav.writeUInt16LE(1,20);wav.writeUInt16LE(1,22);wav.writeUInt32LE(16000,24);wav.writeUInt32LE(32000,28);wav.writeUInt16LE(2,32);wav.writeUInt16LE(16,34);wav.write('data',36);wav.writeUInt32LE(19200,40);for(let i=0;i<9600;i++)wav.writeInt16LE(Math.round(Math.sin(i/16000*440*2*Math.PI)*1500),44+i*2);
+const extras=Array.from({length:60},(_,i)=>({GeneralID:3000+i,Title:'extra-'+i,sy:'拓展释义'+i}));
+const servers=[],origins={};
+for(const [variant,root] of Object.entries({before:resolve(baseline),after:resolve('dist/xiaoshu-mobile/browser')})){
+ const server=createServer(async(req,res)=>{
+  const path=new URL(req.url,'http://local').pathname;
+  if(path.startsWith('/cloud-functions/')){
+   let body='';for await(const chunk of req)body+=chunk;const p=JSON.parse(body).params;
+   if(p.action==='content_list'&&p.orders==='NEWID()'){
+    await new Promise(r=>setTimeout(r,1600));res.setHeader('Content-Type','application/json');res.end(JSON.stringify({retcode:1,result:extras}));return;
+   }
+   // This harness never permits a teaching write or a real credential.
+   if(p.action==='app_support_unread'){res.setHeader('Content-Type','application/json');res.end(JSON.stringify({retcode:1,result:{unread:0}}));return;}
+   res.statusCode=400;res.end(JSON.stringify({retcode:-1,retmsg:'Unexpected fixture request '+p.action}));return;
+  }
+  if(path.startsWith('/audio/')){res.setHeader('Content-Type','audio/wav');res.setHeader('Cache-Control','no-store');res.end(wav);return;}
+  let file=resolve(root,'.'+decodeURIComponent(path));if(!file.startsWith(root+'/')||!fs.existsSync(file)||!extname(file))file=root+'/index.html';
+  try{res.setHeader('Content-Type',({'.html':'text/html','.js':'text/javascript','.css':'text/css','.svg':'image/svg+xml','.wav':'audio/wav'})[extname(file)]||'application/octet-stream');res.end(fs.readFileSync(file));}catch{res.statusCode=404;res.end();}
+ });await new Promise(r=>server.listen(0,'127.0.0.1',r));servers.push(server);origins[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'}),samples={before:[],after:[]};
+try{
+ for(let i=0;i<runs;i++)for(const variant of i%2?['after','before']:['before','after']){
+  const origin=origins[variant],context=await browser.newContext({viewport:{width:390,height:844}}),page=await context.newPage(),errors=[];
+  page.on('pageerror',e=>errors.push(e.message));
+  const requests=[];page.on('request',r=>{if(r.url().includes('/audio/')||r.url().includes('/cloud-functions/'))requests.push({path:new URL(r.url()).pathname,action:r.postDataJSON()?.params?.action});});
+  await context.addInitScript(({origin})=>{
+   localStorage.setItem('xiaoshu.angular.session',JSON.stringify({token:'r:fixture-only',user:{userId:300,groupId:3,honeyName:'测试老师'},student:{userId:100,groupId:1}}));
+   const words=['apple','banana','orange','pear'].map((word,i)=>({GeneralID:1001+i,Title:word,sy:['苹果','香蕉','橙子','梨'][i],yp:origin+'/audio/'+i+'.wav'}));
+   const prefix='xiaoshu.learning.300.100.practice.';
+   localStorage.setItem(prefix+'learn_mode','word');localStorage.setItem(prefix+'_page_word_orilist',JSON.stringify([words]));localStorage.setItem(prefix+'_page_current_learn','1');
+   window.__audio=[];window.__click=0;document.addEventListener('click',()=>window.__click=performance.now(),true);
+   const Original=window.Audio;window.Audio=function(url){const audio=new Original(url);audio.addEventListener('playing',()=>window.__audio.push({url,ms:performance.now()-window.__click}));return audio;};window.Audio.prototype=Original.prototype;
+   // Make order identical for visual/data comparison; media and rendering stay real.
+   Math.random=()=>0.5;
+  },{origin});
+  await context.route('**/*',route=>new URL(route.request().url()).origin===origin?route.continue():route.abort());
+  const 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});
+  await page.goto(origin+'/pages/stu/stu_con');await page.locator('.repeat-list button').first().waitFor();
+  // Same one-second reading pause for A and B; include all background requests in totals.
+  await page.waitForTimeout(1000);
+  const audioTimes=[];
+  for(let n=0;n<2;n++){
+   const count=await page.evaluate(()=>window.__audio.length);await page.locator('.repeat-list button').first().click();
+   await page.waitForFunction(count=>window.__audio.length>count,count);audioTimes.push(await page.evaluate(()=>window.__audio.at(-1).ms));
+   await page.waitForTimeout(650);
+  }
+  await page.getByRole('button',{name:'跳过这一关',exact:false}).click();await page.locator('.match-grid').waitFor();
+  await page.waitForTimeout(1800); // The same short matching-stage dwell for both versions.
+  const at=await page.evaluate(()=>performance.now());
+  await page.getByRole('button',{name:'跳过这一关',exact:true}).click();
+  await page.waitForFunction(()=>document.querySelectorAll('.option-list button').length===4);
+  const readyMs=await page.evaluate(at=>performance.now()-at,at);
+  assert.equal(await page.locator('.option-list button').count(),4);assert.deepEqual(errors,[]);
+  if(i===0){await page.mouse.move(0,0);await page.waitForTimeout(250);for(const width of [390,1000]){await page.setViewportSize({width,height:844});await page.waitForTimeout(350);assert.ok(await page.evaluate(()=>document.documentElement.scrollWidth<=innerWidth+1));await page.screenshot({path:fileURLToPath(new URL(variant+'-'+width+'.png',out)),fullPage:true,animations:'disabled'});}}
+  const row={run:i,firstAudioMs:audioTimes[0],repeatAudioMs:audioTimes[1],listeningReadyMs:readyMs,audioRequests:requests.filter(r=>r.path.startsWith('/audio/')).length,poolRequests:requests.filter(r=>r.action==='content_list').length,errors};samples[variant].push(row);console.log(JSON.stringify({variant,...row}));
+  await context.close();
+ }
+ const q=(rows,key,p)=>rows.map(r=>r[key]).sort((a,b)=>a-b)[Math.ceil(rows.length*p)-1];
+ const summary=Object.fromEntries(Object.entries(samples).map(([variant,rows])=>[variant,Object.fromEntries(['firstAudioMs','repeatAudioMs','listeningReadyMs'].map(key=>[key,{p50:q(rows,key,.5),p75:q(rows,key,.75),p95:q(rows,key,.95)}]))]));
+ fs.writeFileSync(new URL(process.env.PERF_OUTPUT || 'browser-paired.json',out),JSON.stringify({scope:'isolated actual Angular + HTMLAudioElement; 20ms/4Mbps; 19KB generated WAV no-store; fixed 1600ms fixture API; 1s reading/1.8s matching dwell; not real-user production measurements',runs,summary,samples},null,2));console.log(JSON.stringify(summary,null,2));
+}finally{await browser.close();for(const server of servers)await new Promise(r=>server.close(r));}

+ 20 - 0
perf/teacher-concurrency-2026-11-07/bench-sql.mjs

@@ -0,0 +1,20 @@
+// Alternating actual gateway SQL on isolated PostgreSQL (PGlite), never a production throughput claim.
+import fs from 'node:fs';
+import assert from 'node:assert/strict';
+import {classroomFixture} from '../../scripts/tests/word-classroom-performance.test.mjs';
+const before=process.argv[2];if(!before)throw Error('Pass baseline deploy-admin-functions.mjs');
+const sources={before:fs.readFileSync(before,'utf8'),after:fs.readFileSync(new URL('../../scripts/deploy-admin-functions.mjs',import.meta.url),'utf8')};
+const variants={};for(const [key,source] of Object.entries(sources))variants[key]=await classroomFixture(source);
+const samples={before:[],after:[]},params={modelId:52,nid:40,psize:100,cpage:1,orders:'GeneralID=ASC',__publishedOnly:true};
+try{
+ let golden;
+ for(let i=0;i<14;i++)for(const key of i%2?['after','before']:['before','after']){
+  const t=performance.now(),result=JSON.parse(JSON.stringify(await variants[key].read(params))),ms=performance.now()-t;
+  if(!golden)golden=result;assert.deepEqual(result,golden);
+  if(i>=4)samples[key].push(ms);
+ }
+ const q=(v,p)=>[...v].sort((a,b)=>a-b)[Math.ceil(v.length*p)-1];
+ const summary=Object.fromEntries(Object.entries(samples).map(([k,v])=>[k,{runs:v.length,p50:q(v,.5),p75:q(v,.75),p95:q(v,.95)}]));
+ const result={scope:'isolated SQL + serialization, 12000 model rows, same 100 word rows; not live server',alternating:true,summary,samples};
+ fs.writeFileSync(new URL('sql-paired.json',import.meta.url),JSON.stringify(result,null,2));console.log(JSON.stringify(summary,null,2));
+}finally{for(const v of Object.values(variants))await v.db.close();}

+ 64 - 0
perf/teacher-concurrency-2026-11-07/browser-final-smoke.json

@@ -0,0 +1,64 @@
+{
+  "scope": "isolated actual Angular + HTMLAudioElement; 20ms/4Mbps; 19KB generated WAV no-store; fixed 1600ms fixture API; 1s reading/1.8s matching dwell; not real-user production measurements",
+  "runs": 1,
+  "summary": {
+    "before": {
+      "firstAudioMs": {
+        "p50": 429.19999998807907,
+        "p75": 429.19999998807907,
+        "p95": 429.19999998807907
+      },
+      "repeatAudioMs": {
+        "p50": 83.10000002384186,
+        "p75": 83.10000002384186,
+        "p95": 83.10000002384186
+      },
+      "listeningReadyMs": {
+        "p50": 1683.2999999523163,
+        "p75": 1683.2999999523163,
+        "p95": 1683.2999999523163
+      }
+    },
+    "after": {
+      "firstAudioMs": {
+        "p50": 18.399999976158142,
+        "p75": 18.399999976158142,
+        "p95": 18.399999976158142
+      },
+      "repeatAudioMs": {
+        "p50": 16.19999998807907,
+        "p75": 16.19999998807907,
+        "p95": 16.19999998807907
+      },
+      "listeningReadyMs": {
+        "p50": 53.700000047683716,
+        "p75": 53.700000047683716,
+        "p95": 53.700000047683716
+      }
+    }
+  },
+  "samples": {
+    "before": [
+      {
+        "run": 0,
+        "firstAudioMs": 429.19999998807907,
+        "repeatAudioMs": 83.10000002384186,
+        "listeningReadyMs": 1683.2999999523163,
+        "audioRequests": 3,
+        "poolRequests": 1,
+        "errors": []
+      }
+    ],
+    "after": [
+      {
+        "run": 0,
+        "firstAudioMs": 18.399999976158142,
+        "repeatAudioMs": 16.19999998807907,
+        "listeningReadyMs": 53.700000047683716,
+        "audioRequests": 4,
+        "poolRequests": 1,
+        "errors": []
+      }
+    ]
+  }
+}

+ 226 - 0
perf/teacher-concurrency-2026-11-07/browser-paired.json

@@ -0,0 +1,226 @@
+{
+  "scope": "isolated actual Angular + HTMLAudioElement; 20ms/4Mbps; 19KB generated WAV no-store; fixed 1600ms fixture API; 1s reading/1.8s matching dwell; not real-user production measurements",
+  "runs": 10,
+  "summary": {
+    "before": {
+      "firstAudioMs": {
+        "p50": 84.89999997615814,
+        "p75": 87.39999997615814,
+        "p95": 730.2999999523163
+      },
+      "repeatAudioMs": {
+        "p50": 123,
+        "p75": 140.30000001192093,
+        "p95": 159.80000001192093
+      },
+      "listeningReadyMs": {
+        "p50": 1691.0999999642372,
+        "p75": 1699.0999999642372,
+        "p95": 1708.5999999642372
+      }
+    },
+    "after": {
+      "firstAudioMs": {
+        "p50": 10.199999988079071,
+        "p75": 22,
+        "p95": 23.400000035762787
+      },
+      "repeatAudioMs": {
+        "p50": 16.100000023841858,
+        "p75": 16.899999976158142,
+        "p95": 16.899999976158142
+      },
+      "listeningReadyMs": {
+        "p50": 58.10000002384186,
+        "p75": 64.19999998807907,
+        "p95": 76.40000003576279
+      }
+    }
+  },
+  "samples": {
+    "before": [
+      {
+        "run": 0,
+        "firstAudioMs": 730.2999999523163,
+        "repeatAudioMs": 123.80000001192093,
+        "listeningReadyMs": 1708.5999999642372,
+        "audioRequests": 3,
+        "poolRequests": 1,
+        "errors": []
+      },
+      {
+        "run": 1,
+        "firstAudioMs": 86.39999997615814,
+        "repeatAudioMs": 123,
+        "listeningReadyMs": 1699.0999999642372,
+        "audioRequests": 3,
+        "poolRequests": 1,
+        "errors": []
+      },
+      {
+        "run": 2,
+        "firstAudioMs": 84.89999997615814,
+        "repeatAudioMs": 142.69999998807907,
+        "listeningReadyMs": 1689.699999988079,
+        "audioRequests": 3,
+        "poolRequests": 1,
+        "errors": []
+      },
+      {
+        "run": 3,
+        "firstAudioMs": 84.69999998807907,
+        "repeatAudioMs": 131.70000004768372,
+        "listeningReadyMs": 1694.2999999523163,
+        "audioRequests": 3,
+        "poolRequests": 1,
+        "errors": []
+      },
+      {
+        "run": 4,
+        "firstAudioMs": 87.40000003576279,
+        "repeatAudioMs": 122,
+        "listeningReadyMs": 1681.2000000476837,
+        "audioRequests": 3,
+        "poolRequests": 1,
+        "errors": []
+      },
+      {
+        "run": 5,
+        "firstAudioMs": 86.30000001192093,
+        "repeatAudioMs": 140.30000001192093,
+        "listeningReadyMs": 1689.699999988079,
+        "audioRequests": 3,
+        "poolRequests": 1,
+        "errors": []
+      },
+      {
+        "run": 6,
+        "firstAudioMs": 84.59999996423721,
+        "repeatAudioMs": 159.80000001192093,
+        "listeningReadyMs": 1704.5999999642372,
+        "audioRequests": 3,
+        "poolRequests": 1,
+        "errors": []
+      },
+      {
+        "run": 7,
+        "firstAudioMs": 87.39999997615814,
+        "repeatAudioMs": 119.5,
+        "listeningReadyMs": 1691.2000000476837,
+        "audioRequests": 3,
+        "poolRequests": 1,
+        "errors": []
+      },
+      {
+        "run": 8,
+        "firstAudioMs": 83.40000003576279,
+        "repeatAudioMs": 105.5,
+        "listeningReadyMs": 1678.3999999761581,
+        "audioRequests": 3,
+        "poolRequests": 1,
+        "errors": []
+      },
+      {
+        "run": 9,
+        "firstAudioMs": 80.69999998807907,
+        "repeatAudioMs": 105.89999997615814,
+        "listeningReadyMs": 1691.0999999642372,
+        "audioRequests": 3,
+        "poolRequests": 1,
+        "errors": []
+      }
+    ],
+    "after": [
+      {
+        "run": 0,
+        "firstAudioMs": 15.5,
+        "repeatAudioMs": 13.699999988079071,
+        "listeningReadyMs": 64.19999998807907,
+        "audioRequests": 4,
+        "poolRequests": 1,
+        "errors": []
+      },
+      {
+        "run": 1,
+        "firstAudioMs": 7.199999988079071,
+        "repeatAudioMs": 13.399999976158142,
+        "listeningReadyMs": 55.39999997615814,
+        "audioRequests": 4,
+        "poolRequests": 1,
+        "errors": []
+      },
+      {
+        "run": 2,
+        "firstAudioMs": 10.199999988079071,
+        "repeatAudioMs": 15.399999976158142,
+        "listeningReadyMs": 67.30000001192093,
+        "audioRequests": 4,
+        "poolRequests": 1,
+        "errors": []
+      },
+      {
+        "run": 3,
+        "firstAudioMs": 3.5,
+        "repeatAudioMs": 16.100000023841858,
+        "listeningReadyMs": 53.60000002384186,
+        "audioRequests": 4,
+        "poolRequests": 1,
+        "errors": []
+      },
+      {
+        "run": 4,
+        "firstAudioMs": 9.099999964237213,
+        "repeatAudioMs": 16.30000001192093,
+        "listeningReadyMs": 76.40000003576279,
+        "audioRequests": 4,
+        "poolRequests": 1,
+        "errors": []
+      },
+      {
+        "run": 5,
+        "firstAudioMs": 23.400000035762787,
+        "repeatAudioMs": 16.899999976158142,
+        "listeningReadyMs": 57.89999997615814,
+        "audioRequests": 4,
+        "poolRequests": 1,
+        "errors": []
+      },
+      {
+        "run": 6,
+        "firstAudioMs": 8.599999964237213,
+        "repeatAudioMs": 15.300000011920929,
+        "listeningReadyMs": 60.80000001192093,
+        "audioRequests": 4,
+        "poolRequests": 1,
+        "errors": []
+      },
+      {
+        "run": 7,
+        "firstAudioMs": 10.199999988079071,
+        "repeatAudioMs": 16.899999976158142,
+        "listeningReadyMs": 60.5,
+        "audioRequests": 4,
+        "poolRequests": 1,
+        "errors": []
+      },
+      {
+        "run": 8,
+        "firstAudioMs": 22.600000023841858,
+        "repeatAudioMs": 16.30000001192093,
+        "listeningReadyMs": 57.60000002384186,
+        "audioRequests": 4,
+        "poolRequests": 1,
+        "errors": []
+      },
+      {
+        "run": 9,
+        "firstAudioMs": 22,
+        "repeatAudioMs": 16.899999976158142,
+        "listeningReadyMs": 58.10000002384186,
+        "audioRequests": 4,
+        "poolRequests": 1,
+        "errors": []
+      }
+    ]
+  }
+}

+ 26 - 0
perf/teacher-concurrency-2026-11-07/live-read-baseline.json

@@ -0,0 +1,26 @@
+{
+  "scope": "production content_list randomized 60 public words; same authorized operator, request concurrency not actual teacher count; two batches per level; no writes; not p75",
+  "results": [
+    {
+      "concurrency": 1,
+      "requests": 2,
+      "errors": 0,
+      "minMs": 1594,
+      "maxMs": 1937
+    },
+    {
+      "concurrency": 3,
+      "requests": 6,
+      "errors": 0,
+      "minMs": 2165,
+      "maxMs": 2692
+    },
+    {
+      "concurrency": 5,
+      "requests": 10,
+      "errors": 0,
+      "minMs": 3961,
+      "maxMs": 4793
+    }
+  ]
+}

+ 9 - 0
perf/teacher-concurrency-2026-11-07/ratchet.mjs

@@ -0,0 +1,9 @@
+import fs from 'node:fs';
+import assert from 'node:assert/strict';
+const read=name=>JSON.parse(fs.readFileSync(new URL(name,import.meta.url),'utf8'));
+const browser=read('browser-paired.json'),sql=read('sql-paired.json');
+assert.ok(browser.runs>=10&&sql.summary.before.runs>=10&&sql.summary.after.runs>=10);
+for(const key of ['firstAudioMs','repeatAudioMs','listeningReadyMs'])assert.ok(browser.summary.after[key].p75<browser.summary.before[key].p75*.5,key+' did not halve');
+assert.ok(sql.summary.after.p75<sql.summary.before.p75*.6,'SQL regression');
+for(const row of browser.samples.after){assert.equal(row.poolRequests,1);assert.ok(row.audioRequests<=4);assert.deepEqual(row.errors,[]);}
+console.log('PASS: paired p75 improvements, one pool request, bounded media requests, no page errors');

+ 44 - 0
perf/teacher-concurrency-2026-11-07/sql-paired.json

@@ -0,0 +1,44 @@
+{
+  "scope": "isolated SQL + serialization, 12000 model rows, same 100 word rows; not live server",
+  "alternating": true,
+  "summary": {
+    "before": {
+      "runs": 10,
+      "p50": 15.485074000000168,
+      "p75": 15.708397999999761,
+      "p95": 15.917120000000068
+    },
+    "after": {
+      "runs": 10,
+      "p50": 7.288997000000563,
+      "p75": 7.564905000000181,
+      "p95": 8.354150999999547
+    }
+  },
+  "samples": {
+    "before": [
+      15.451857000000018,
+      15.183962999999494,
+      15.369109000000208,
+      15.091972000000169,
+      15.917120000000068,
+      15.870745999999599,
+      15.485074000000168,
+      15.588681999999608,
+      15.500802999999905,
+      15.708397999999761
+    ],
+    "after": [
+      7.540586000000076,
+      7.221180000000459,
+      7.247005000000172,
+      7.154531000000134,
+      7.564905000000181,
+      8.354150999999547,
+      7.288997000000563,
+      7.998083999999835,
+      7.360811000000467,
+      7.057674000000588
+    ]
+  }
+}

+ 16 - 0
perf/teacher-concurrency-2026-11-07/visual-verification.json

@@ -0,0 +1,16 @@
+{
+  "390": {
+    "sameDimensions": true,
+    "pixelIdentical": true,
+    "changedPixels": 0,
+    "fraction": 0.0,
+    "bounds": null
+  },
+  "1000": {
+    "sameDimensions": true,
+    "pixelIdentical": true,
+    "changedPixels": 0,
+    "fraction": 0.0,
+    "bounds": null
+  }
+}

+ 23 - 0
perf/teacher-concurrency-2026-11-07/基线.md

@@ -0,0 +1,23 @@
+# 基线与证据边界
+
+用户明确首轮优先「带学生学习单词」页面的发音和显示卡顿。
+
+正式接口只读抽样(同一有效运营会话,请求并发不是独立老师设备):
+
+| 同时请求 | 请求数 | 耗时范围 | 失败 |
+|---|---:|---:|---:|
+| 1 | 2 | 1.594–1.937s | 0 |
+| 3 | 6 | 2.165–2.692s | 0 |
+| 5 | 10 | 3.961–4.793s | 0 |
+
+接口为听音选义原有 `content_list / NEWID() / 60条`。每档两批,样本不足以报告线上 p75/p95,也不能据此判定连接池耗尽、CPU饱和或合成服务限流。当前健康请求约92ms;一次100条词库读取约982ms。没有取得老师设备瀑布图或真实用户 CrUX 数据。
+
+可重现问题:
+
+1. 每次点读都 new Audio(url),缺少播放器/已缓冲媒体复用,且没有提前准备后续单词。此处正常已有音频不走 TTS。
+2. 听音选义进入页面后才开始读取干扰词;准备期间禁答,接口延迟直接进入交互关键路径。
+3. 词库 contentPage 把数字 modelId/nodeId/id/itemId 转为 text 筛选和关联。隔离 PostgreSQL 的12000行模型表、100个目标词用例中,旧查询累计过滤23804行;性能护栏要求过滤少于1000行,新实现通过。查询耗时包含SQL及序列化,不含生产负载。
+
+生产管理凭据不在当前可用配置中,无法读取 pg_stat_activity、执行生产 EXPLAIN 或确认 TTS 环境配置。Parse Config 的相关字段缺失不代表环境变量未配置,因此不能把代码默认 concurrency=1 当作生产配置。
+
+执行计划方法参考 [PostgreSQL EXPLAIN](https://www.postgresql.org/docs/current/using-explain.html)。没有修改连接池、索引、服务器、CDN、计费或业务数据。

+ 8 - 0
perf/teacher-concurrency-2026-11-07/实验日志.md

@@ -0,0 +1,8 @@
+# 实验日志
+
+- 音频复用:先增加重复点读、预加载队列及旧 ended 回调测试,旧实现4项失败;改为最多12个音频元素的LRU,邻近3词、最多2个后台准备任务;播放始终用户当前词优先。不提前合成TTS,已知URL才预读。准备监听器/计时器在 Angular zone 外注册。
+- 选项准备:先增加跨匹配关/听音关复用测试,旧实现失败。新增单份5分钟缓存,以会话及排除词表为键,合并正在进行的读取。匹配页面已可操作后准备,失败清缓存、听音关重试,手动重载强制重新请求;现有两项外部干扰词规则保留。
+- 词库查询:先跑真实SQL效率护栏,旧实现丢弃23804行而失败。仅model52使用原生数字条件和关联,参数类型由列推断,其他模型不改。新实现通过隔离/分页/排除词与扫描量护栏;同一时段交替20次、预热各4次后保留各10次,响应逐项一致,p75缩短约52%。
+- 浏览器:独立上下文,A/B交替各10轮,实际Angular和HTMLAudioElement,统一20ms往返、4Mbps下载、3Mbps上传;预读等待计入流程说明,总请求也记录。所有轮次无脚本错误。没有用延长动画、空壳或禁用按钮的状态作为完成终点。
+- 独立审查:没有发现阻止合并的课堂功能问题;修复了切换省流/2G后旧队列仍继续的小问题,补充迟到的同URL播放Promise测试,11项平台测试通过。
+- 发布准备审查:放弃未经原子条件保护的GET/hash/PUT写入口。交付只读准备工具,拒绝覆盖已有回滚材料,并通过HTTP fixture验证准备全过程仅GET、第二次准备拒绝覆盖。后续发布必须采用串行运营发布通道;准备工具本身不部署。

+ 47 - 0
perf/teacher-concurrency-2026-11-07/报告.md

@@ -0,0 +1,47 @@
+# 单词课堂性能修复报告 · 2026-11-07
+
+已完成代码修复、回归及实验室前后对照,尚未发布生产。
+
+## 改了什么
+
+- 复用已缓冲的单词播放器,每次明确从头播放;提前准备附近3词,并发最多2个后台准备任务,缓存最多12项。离开/切页、超时、失败、省流网络会清理相应任务,旧回调不会串到新词。
+- 在单词配对关提前准备听音选义干扰词,同一会话/词表只读一次,5分钟过期、失败可重试。保留题目规则、四个真实选项及当前词正确答案。
+- 仅优化公共词库 contentPage 的数字索引条件及关联,不改学习保存、课次、工资、计费、权限或其他内容模型。
+
+## 前后结果
+
+同一时段 A/B 交替、每版本10轮,报告 p75:
+
+| 操作 | 优化前 | 优化后 | 缩短 |
+|---|---:|---:|---:|
+| 页面停留1秒后的首次点读 | 87.4ms | 22.0ms | 75% |
+| 重复点读同一单词 | 140.3ms | 16.9ms | 88% |
+| 配对关进入听音选义,四个选项可答 | 1699.1ms | 64.2ms | 96% |
+| 相同100词的数据库查询及序列化 | 15.7ms | 7.6ms | 52% |
+
+**前三项是隔离浏览器实验:** 实际Angular生产构建、实际HTMLAudioElement,统一20ms/4Mbps,19KB自制WAV音频(no-store),干扰词接口固定1600ms,进入配对关统一停留1.8秒。该1600ms用于重现已测到的接口等待,不是重新测得的生产响应。初次点读包含1秒提前准备机会,不能解释为无准备冷点读。数据库项是12000行隔离PGlite数据,不是生产吞吐。
+
+原始p50/p75/p95、20轮样本在 `browser-paired.json` / `sql-paired.json`。流程总干扰词请求仍为1;测试流程提前准备的音频由3次请求增加到4次(包括原先未点的后续词),有界提前下载换取即点即播,不能说总网络流量下降。多次播放同一个词复用音频,不重复创建播放器。
+
+线上旧版的独立只读测试确认:1个选项请求约1.6–1.9秒,5个并发约4.0–4.8秒。尚无生产新版测量,不能把以上96%当作老师手机/微信端已经获得的提升。
+
+## 验证
+
+- 移动端全量343项、管理端全量350项、服务端全量444项通过。
+- 最后NgZone调整后的相关95项通过;审查补充后平台11项通过;最终发布准备工具4项通过。
+- 词库真实SQL测试验证分页/释义/租户隔离/排除词和有效索引过滤;A/B的100词响应逐项一致。
+- 手机端生产构建通过;11个云函数定义校验通过。保留既有CSS预算/CommonJS构建告警。
+- 390px和1000px页面截图逐像素一致,无横向溢出检查通过;样式、主要文字及答题规则未改。截图、像素比较见本目录。
+- 性能棘轮 `node perf/teacher-concurrency-2026-11-07/ratchet.mjs` 通过。时间阈值只用于同环境配对实验;CI使用数据等价、请求合并及扫描量护栏。
+
+## 发布状态、回滚和未验证项
+
+当前仅本地与Git交付。没有改生产函数/业务表/索引/服务器/CDN;没有让真实老师/学生账号执行保存、扣费或结课。
+
+正式用户端新版本需要沿用现有静态站发布流程并保留旧入口/资源清单;数据库读取优化只替换线上 app gateway 的 `contentPage`。`scripts/prepare-word-classroom-performance.mjs --prepare` 可在有效发布凭据下读取线上原件、确认精确前驱并生成候选,其他线上函数逐字保留;任何已有准备材料都拒绝覆盖。该工具仅GET,明确不负责部署/回滚,避免和同网关其他任务并发写入。实际发布需通过串行运营发布流程,发布前重新核对线上版本,发布后回读哈希并重跑正式接口和实际设备课堂。
+
+回退只恢复本轮发布前的前端入口/资源和网关函数;先核对发布后是否已有其他更新,避免覆盖后续修复。不删除音频文件/TTS缓存,不撤销学习记录和业务数据。
+
+阻塞:当前可用的是运营读取会话,原有后台发布脚本所需 `XIAOSHU_MASTER_KEY` 未就绪,无法核对生产查询计划、连接池/CPU负载,也尚未生成基于实时线上代码的准备包。另按所用 huashu-flash 技能的明确要求「上线必须用户批准」,本轮用户尚未明确批准生产发布。请通过本机安全配置恢复发布权限,不在聊天或仓库中发送密钥。
+
+仍需发布后验证:生产索引是否实际使用;多人分别登录的端到端负载;iOS/Android/微信音频预加载策略和自动播放限制;老师具体词库的大音频/缺音频合成路径。已有正常音频不进入TTS队列,本轮没有盲目提高付费合成并发限额。

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

@@ -733,6 +733,35 @@ describe('home learning modules', () => {
     expect(play).toHaveBeenCalledTimes(2);
   });
 
+  it('prepares listening choices during matching and reuses them when advancing', async () => {
+    const prefix = 'xiaoshu.learning.100.100.practice.';
+    const words = [{GeneralID:1001,Title:'apple',sy:'苹果'},{GeneralID:1002,Title:'banana',sy:'香蕉'}];
+    localStorage.setItem(prefix+'learn_mode','newword');
+    localStorage.setItem(prefix+'choose_newword_learn',JSON.stringify(words));
+    localStorage.setItem(prefix+'_page_word_orilist',JSON.stringify([words]));
+    localStorage.setItem(prefix+'_page_current_learn','1');
+    const response = new Subject<ApiEnvelope<any>>();
+    const api = TestBed.inject(ApiService) as unknown as LearningModulesApiService;
+    const get = api.get.bind(api);
+    let count = 0;
+    spyOn(api,'get').and.callFake((action,params) => {
+      if (action==='content_list' && params?.['orders']==='NEWID()') {count++;return response;}
+      return get(action,params);
+    });
+    spyOn(TestBed.inject(PlatformService),'play');
+    const harness = await RouterTestingHarness.create();
+    await harness.navigateByUrl('/pages/stu/stu3');
+    expect(harness.routeNativeElement?.querySelector('.match-grid')).not.toBeNull();
+    expect(count).toBe(1);
+    response.next({retcode:1,result:[{GeneralID:301,Title:'cat',sy:'猫'},{GeneralID:302,Title:'dog',sy:'狗'},{GeneralID:303,Title:'bird',sy:'鸟'}]});
+    response.complete();
+    await harness.navigateByUrl('/pages/stu/stu4');
+    expect(count).toBe(1);
+    const learning = harness.fixture.debugElement.query(By.directive(LearningPageComponent)).componentInstance as LearningPageComponent;
+    expect(learning.choicesLoading()).toBeFalse();
+    expect(harness.routeNativeElement?.querySelectorAll('.option-list button').length).toBe(4);
+  });
+
   it('keeps the spoken word among listening choices and discards answers from an earlier question', async () => {
     const prefix = 'xiaoshu.learning.100.100.practice.';
     const selectedWords = [

+ 46 - 0
projects/xiaoshu-mobile/src/app/core/listening-pool.service.spec.ts

@@ -0,0 +1,46 @@
+import { TestBed } from '@angular/core/testing';
+import { Subject, of, throwError } from 'rxjs';
+import { ApiService } from './api.service';
+import { ListeningPoolService } from './listening-pool.service';
+import { SessionService } from './session.service';
+
+describe('听音选项提前准备', () => {
+  let service: ListeningPoolService, get: jasmine.Spy, token: string;
+  beforeEach(() => {
+    token = 'r:first';
+    get = jasmine.createSpy().and.returnValue(of({result:[{GeneralID:3,sy:'外部词'}]}));
+    TestBed.configureTestingModule({providers:[
+      {provide:ApiService,useValue:{get}},
+      {provide:SessionService,useValue:{token:()=>token}},
+    ]});
+    service = TestBed.inject(ListeningPoolService);
+  });
+  it('匹配关和听音关共享同一次读取,页面离开不取消其他订阅', () => {
+    const response = new Subject<any>(); get.and.returnValue(response);
+    const a = service.load(['2','1','2']).subscribe();
+    let actual: any;
+    service.load(['1','2']).subscribe(value => actual = value);
+    a.unsubscribe();
+    response.next({result:[{GeneralID:3,sy:'外部词'}]}); response.complete();
+    service.load(['2','1']).subscribe(value => actual = value);
+    expect(actual.result[0].GeneralID).toBe(3);
+    expect(get).toHaveBeenCalledTimes(1);
+    expect(get.calls.first().args[1].excludeWordIds).toBe('1,2');
+  });
+  it('预读失败后重试,换词表或切换账号都重新读取', () => {
+    get.and.returnValue(throwError(()=>new Error('offline')));
+    service.load(['1']).subscribe({error:()=>{}});
+    get.and.returnValue(of({result:[]}));
+    service.load(['1']).subscribe();
+    service.load(['2']).subscribe();
+    token = 'r:second'; service.load(['2']).subscribe();
+    expect(get).toHaveBeenCalledTimes(4);
+  });
+  it('超过五分钟重新读取且只保留当前词表', () => {
+    const time = spyOn(Date, 'now').and.returnValue(1000);
+    service.load(['1']).subscribe();
+    time.and.returnValue(301001); service.load(['1']).subscribe();
+    service.load(['2']).subscribe(); service.load(['1']).subscribe();
+    expect(get).toHaveBeenCalledTimes(4);
+  });
+});

+ 34 - 0
projects/xiaoshu-mobile/src/app/core/listening-pool.service.ts

@@ -0,0 +1,34 @@
+import { inject, Injectable } from '@angular/core';
+import { catchError, Observable, shareReplay, take, throwError } from 'rxjs';
+import { ApiService } from './api.service';
+import { ApiEnvelope } from './models';
+import { SessionService } from './session.service';
+
+/** One short-lived public vocabulary pool, shared across the two adjacent stages. */
+@Injectable({ providedIn: 'root' })
+export class ListeningPoolService {
+  private readonly api = inject(ApiService);
+  private readonly session = inject(SessionService);
+  private cached?: { key: string; until: number; response: Observable<ApiEnvelope<unknown>> };
+
+  load(excluded: string[], refresh = false): Observable<ApiEnvelope<unknown>> {
+    const ids = [...new Set(excluded)].sort().join(',');
+    const key = this.session.token() + '|' + ids;
+    if (!refresh && this.cached?.key === key && this.cached.until > Date.now()) return this.cached.response;
+    const entry = {
+      key, until: Date.now() + 5 * 60_000,
+      response: this.api.get<unknown>('content_list', {
+        modelId: 52, orders: 'NEWID()', cpage: 1, psize: 60, excludeWordIds: ids,
+      }).pipe(
+        take(1),
+        catchError(error => {
+          if (this.cached === entry) this.cached = undefined;
+          return throwError(() => error);
+        }),
+        shareReplay({ bufferSize: 1, refCount: false }),
+      ),
+    };
+    this.cached = entry;
+    return entry.response;
+  }
+}

+ 94 - 0
projects/xiaoshu-mobile/src/app/core/platform.service.spec.ts

@@ -1,3 +1,5 @@
+import { NgZone } from '@angular/core';
+import { TestBed } from '@angular/core/testing';
 import { PlatformService } from './platform.service';
 describe('学习提示音', () => {
   it('新音效使用本地资源,轻音量,播放单词不会打断音效', () => {
@@ -53,3 +55,95 @@ describe('单词播放完成通知', () => {
     expect(callbacks.onEnded).not.toHaveBeenCalled();
   });
 });
+
+describe('课堂点读资源复用', () => {
+  let audios: HTMLAudioElement[], service: PlatformService;
+  beforeEach(() => {
+    audios = [];
+    spyOn(window, 'Audio').and.callFake((function(url: string) {
+      const audio = document.createElement('audio');
+      audio.src = url;
+      spyOn(audio, 'play').and.resolveTo();
+      spyOn(audio, 'pause');
+      spyOn(audio, 'load');
+      audios.push(audio);
+      return audio;
+    }) as any);
+    service = new PlatformService();
+  });
+  afterEach(() => service.stopAudio());
+  it('后台预加载不占用 Angular 的导航稳定性计时器', () => {
+    const zone = TestBed.inject(NgZone);
+    service = new PlatformService(zone);
+    zone.run(() => service.preloadAudio(['/a.mp3','/b.mp3']));
+    expect(zone.hasPendingMacrotasks).toBeFalse();
+  });
+  it('切到省流网络时取消旧预加载队列', () => {
+    const descriptor = Object.getOwnPropertyDescriptor(navigator,'connection');
+    try {
+      Object.defineProperty(navigator,'connection',{configurable:true,value:{saveData:false}});
+      service.preloadAudio(['/a.mp3','/b.mp3','/c.mp3']);
+      expect(audios.length).toBe(2);
+      Object.defineProperty(navigator,'connection',{configurable:true,value:{saveData:true}});
+      service.preloadAudio(['/a.mp3','/b.mp3','/c.mp3']);
+      audios[0].dispatchEvent(new Event('canplaythrough'));
+      expect(audios.length).toBe(2);
+      expect(audios[0].getAttribute('src')).toBeNull();
+    } finally {
+      if(descriptor)Object.defineProperty(navigator,'connection',descriptor);
+      else delete (navigator as any).connection;
+    }
+  });
+  it('旧播放 Promise 晚到不会覆盖同词重播的完成回调', async () => {
+    let resolve!: () => void;
+    service.preloadAudio(['/a.mp3']);
+    (audios[0].play as jasmine.Spy).and.returnValues(new Promise<void>(done=>resolve=done),Promise.resolve());
+    const old = service.play('/a.mp3',true,{onEnded:jasmine.createSpy(),onError:jasmine.createSpy()});
+    const callbacks = {onEnded:jasmine.createSpy(),onError:jasmine.createSpy()};
+    expect(await service.play('/a.mp3',true,callbacks)).toBe('played');
+    resolve();expect(await old).toBe('cancelled');
+    audios[0].dispatchEvent(new Event('ended'));
+    expect(callbacks.onEnded).toHaveBeenCalledTimes(1);
+  });
+  it('同一词重复点读复用已准备的播放器并从头播放', async () => {
+    await service.play('/apple.mp3');
+    audios[0].currentTime = 1;
+    await service.play('/apple.mp3');
+    expect(audios.length).toBe(1);
+    expect(audios[0].currentTime).toBe(0);
+    expect(audios[0].play).toHaveBeenCalledTimes(2);
+  });
+  it('预加载不播放,限制同时下载,点击时复用准备好的音频', async () => {
+    (service as any).preloadAudio(['/a.mp3','/b.mp3','/c.mp3','/a.mp3']);
+    expect(audios.length).toBeLessThanOrEqual(2);
+    expect(audios.length).toBeGreaterThan(0);
+    for (const audio of audios) expect(audio.play).not.toHaveBeenCalled();
+    audios[0].dispatchEvent(new Event('canplaythrough'));
+    const prepared = audios[0];
+    await service.play('/a.mp3');
+    expect(prepared.play).toHaveBeenCalledTimes(1);
+    expect(audios.filter(a => a.src.endsWith('/a.mp3')).length).toBe(1);
+  });
+  it('停止准备后不继续下载旧队列,失败的音频允许重新获取', async () => {
+    (service as any).preloadAudio(['/a.mp3','/b.mp3','/c.mp3']);
+    const failed = audios[0];
+    failed.dispatchEvent(new Event('error'));
+    (service as any).clearAudioPreloads();
+    const count = audios.length;
+    for (const audio of [...audios]) audio.dispatchEvent(new Event('canplaythrough'));
+    expect(audios.length).toBe(count);
+    await service.play('/a.mp3');
+    expect(audios.at(-1)).not.toBe(failed);
+  });
+  it('重播时旧 ended 回调不能揭示上一次词义', async () => {
+    const old = {onEnded:jasmine.createSpy(),onError:jasmine.createSpy()};
+    const next = {onEnded:jasmine.createSpy(),onError:jasmine.createSpy()};
+    await service.play('/a.mp3',false,old);
+    const stale = audios[0].onended;
+    await service.play('/a.mp3',false,next);
+    stale?.call(audios[0],new Event('ended'));
+    expect(old.onEnded).not.toHaveBeenCalled();
+    audios[0].dispatchEvent(new Event('ended'));
+    expect(next.onEnded).toHaveBeenCalledTimes(1);
+  });
+});

+ 108 - 7
projects/xiaoshu-mobile/src/app/core/platform.service.ts

@@ -1,4 +1,4 @@
-import { Injectable, signal } from '@angular/core';
+import { Injectable, NgZone, Optional, signal } from '@angular/core';
 
 export interface ToastState {
   message: string;
@@ -17,6 +17,93 @@ export class PlatformService {
   private toastTimer: ReturnType<typeof setTimeout> | null = null;
   private audio: HTMLAudioElement | null = null;
   private effectAudio: HTMLAudioElement | null = null;
+  private playbackVersion = 0;
+  private readonly audioCache = new Map<string, HTMLAudioElement>();
+  private readonly audioPreloads = new Map<string, () => void>();
+  private preloadQueue: string[] = [];
+
+  constructor(@Optional() private readonly zone: NgZone | null = null) {}
+
+  /** Only prepare the nearby words; never synthesize audio or download a whole course. */
+  preloadAudio(urls: string[]): void {
+    const connection = (navigator as Navigator & { connection?: { saveData?: boolean; effectiveType?: string } }).connection;
+    if (connection?.saveData || ['slow-2g', '2g'].includes(connection?.effectiveType || '')) {
+      this.clearAudioPreloads();
+      return;
+    }
+    const nearby = [...new Set(urls.filter(Boolean))].slice(0, 3);
+    this.preloadQueue = nearby.filter(url => !this.audioCache.has(url));
+    for (const [url, cancel] of this.audioPreloads) {
+      if (!nearby.includes(url)) {
+        cancel();
+        this.releaseCachedAudio(url);
+      }
+    }
+    this.prepareNextAudio();
+  }
+
+  clearAudioPreloads(): void {
+    this.preloadQueue = [];
+    for (const [url, cancel] of this.audioPreloads) {
+      cancel();
+      this.releaseCachedAudio(url);
+    }
+  }
+
+  private prepareNextAudio(): void {
+    // Background media readiness must not keep Angular/navigation waiting on a timer.
+    if (this.zone && NgZone.isInAngularZone()) {
+      this.zone.runOutsideAngular(() => this.prepareNextAudio());
+      return;
+    }
+    while (this.audioPreloads.size < 2 && this.preloadQueue.length) {
+      const url = this.preloadQueue.shift()!;
+      if (this.audioCache.has(url)) continue;
+      const audio = this.cachedAudio(url);
+      const finish = (failed = false) => {
+        cancel();
+        if (failed) this.releaseCachedAudio(url);
+        this.prepareNextAudio();
+      };
+      const ready = () => finish();
+      const failed = () => finish(true);
+      const timer = setTimeout(failed, 8000);
+      const cancel = () => {
+        clearTimeout(timer);
+        audio.removeEventListener('canplaythrough', ready);
+        audio.removeEventListener('error', failed);
+        this.audioPreloads.delete(url);
+      };
+      this.audioPreloads.set(url, cancel);
+      audio.addEventListener('canplaythrough', ready, { once: true });
+      audio.addEventListener('error', failed, { once: true });
+      audio.preload = 'auto';
+      audio.load();
+    }
+  }
+
+  private cachedAudio(url: string): HTMLAudioElement {
+    const existing = this.audioCache.get(url);
+    const audio = existing || new Audio(url);
+    this.audioCache.delete(url);
+    this.audioCache.set(url, audio);
+    for (const [key, candidate] of this.audioCache) {
+      if (this.audioCache.size <= 12) break;
+      if (candidate !== this.audio && key !== url) this.releaseCachedAudio(key);
+    }
+    return audio;
+  }
+
+  private releaseCachedAudio(url: string): void {
+    this.audioPreloads.get(url)?.();
+    const audio = this.audioCache.get(url);
+    this.audioCache.delete(url);
+    if (audio && audio !== this.audio) {
+      audio.pause();
+      audio.removeAttribute('src');
+      audio.load();
+    }
+  }
 
   toast(message: string, tone: ToastState['tone'] = 'info'): void {
     if (this.toastTimer) clearTimeout(this.toastTimer);
@@ -42,29 +129,41 @@ export class PlatformService {
   }
 
   async play(url: string, silentFailure = false, callbacks?: AudioPlaybackCallbacks): Promise<'played' | 'blocked' | 'failed' | 'cancelled'> {
+    const version = ++this.playbackVersion;
     if (this.audio) this.audio.onended = this.audio.onerror = null;
     this.audio?.pause();
-    const audio = this.audio = new Audio(url);
+    this.audioPreloads.get(url)?.();
+    const audio = this.audio = this.cachedAudio(url);
+    const active = () => this.audio === audio && this.playbackVersion === version;
+    // Reusing an element retains its buffered media; rewind for each deliberate click.
+    try { audio.currentTime = 0; } catch { /* Metadata may not be available yet. */ }
     if (callbacks) {
       audio.onended = () => {
+        if (!active()) return;
         audio.onended = audio.onerror = null;
-        if (this.audio === audio && !audio.error) callbacks.onEnded();
+        if (!audio.error) callbacks.onEnded();
       };
     }
+    this.prepareNextAudio();
     try {
       await audio.play();
-      if (callbacks && this.audio === audio) audio.onerror = () => {
+      if (!active()) return 'cancelled';
+      audio.onerror = () => {
+        if (!active()) return;
         audio.onended = audio.onerror = null;
-        if (this.audio === audio) callbacks.onError();
+        this.releaseCachedAudio(url);
+        callbacks?.onError();
       };
-      return this.audio === audio ? 'played' : 'cancelled';
+      return 'played';
     } catch (error) {
+      if (!active()) return 'cancelled';
       audio.onended = audio.onerror = null;
-      if (this.audio !== audio || (error as DOMException)?.name === 'AbortError') return 'cancelled';
+      if ((error as DOMException)?.name === 'AbortError') return 'cancelled';
       if ((error as DOMException)?.name === 'NotAllowedError') {
         this.toast('请再点一次喇叭播放读音', 'info');
         return 'blocked';
       }
+      this.releaseCachedAudio(url);
       if (!silentFailure) this.toast('音频播放失败', 'error');
       return 'failed';
     }
@@ -79,6 +178,8 @@ export class PlatformService {
   }
 
   stopAudio(): void {
+    this.playbackVersion++;
+    this.clearAudioPreloads();
     this.effectAudio?.pause();
     this.effectAudio = null;
     if (this.audio) this.audio.onended = this.audio.onerror = null;

+ 1 - 1
projects/xiaoshu-mobile/src/app/core/word-audio.service.spec.ts

@@ -44,7 +44,7 @@ describe('学习页面自动读音', () => {
   let component: LearningPageComponent, platform: any, audio: any;
   const word = (id = '101', url = '') => ({ id, wordId: id, word: 'apple', audio: url, phonetic: '', meaning: '', nodeId: '' });
   beforeEach(() => {
-    platform = { play: jasmine.createSpy().and.resolveTo('played'), stopAudio: jasmine.createSpy(), toast: jasmine.createSpy() };
+    platform = { preloadAudio: jasmine.createSpy(), play: jasmine.createSpy().and.resolveTo('played'), stopAudio: jasmine.createSpy(), toast: jasmine.createSpy() };
     audio = { ensure: jasmine.createSpy().and.resolveTo('https://cdn.test/apple.mp3') };
     TestBed.configureTestingModule({ providers: [
       { provide: ActivatedRoute, useValue: { snapshot: { data: { meta: { path: 'pages/his/his_words' } }, queryParamMap: convertToParamMap({}) } } },

+ 17 - 3
projects/xiaoshu-mobile/src/app/features/learning/learning-page.component.ts

@@ -1,3 +1,4 @@
+import { ListeningPoolService } from '../../core/listening-pool.service';
 import { WordAudioService } from '../../core/word-audio.service';
 import { lessonDateLabel } from '../appointment/lesson-display';
 import { TeacherTodayReviewService } from '../../core/teacher-today-review.service';
@@ -327,6 +328,7 @@ export class LearningPageComponent implements OnInit, OnDestroy {
   private readonly session = inject(SessionService);
   private readonly todayReviews = inject(TeacherTodayReviewService);
   private readonly platform = inject(PlatformService);
+  private readonly listeningPool = inject(ListeningPoolService);
   private readonly wordAudio = inject(WordAudioService);
   readonly audioPreparing = signal(false);
   private audioRequest = 0;
@@ -862,6 +864,7 @@ export class LearningPageComponent implements OnInit, OnDestroy {
 
   async play(word = this.current()): Promise<void> {
     if (this.isReadOnlySelectionPage || (this.isReadOnlyWordView && !this.isHistoryWordView) || !word) return;
+    this.prepareWordAudio(word);
     const request = ++this.audioRequest, focusedWord = this.current(), step = this.studyStep();
     const active = () => request === this.audioRequest && this.current() === focusedWord && this.studyStep() === step;
     this.audioPreparing.set(false);
@@ -1625,12 +1628,16 @@ export class LearningPageComponent implements OnInit, OnDestroy {
     }
     const words = this.isStudyListPage ? source : this.shuffle(source);
     this.words.set(words);
+    this.prepareWordAudio(words[0]);
     if (this.isStudyListPage) {
       this.studyStep.set(1);
       this.repetitions.set({});
     } else if (this.isMatchingPage) {
       this.matchLeft.set(this.shuffle(words));
       this.matchRight.set(this.shuffle(words));
+      // Load public distractors while the matching activity is already usable.
+      const round = [...this.readAllLearningWords(), ...words];
+      this.listeningPool.load(round.map(word => word.id)).subscribe({ error: () => undefined });
     } else if (this.isListeningPage) {
       this.prepareChoiceOptions();
       setTimeout(() => this.play(), 120);
@@ -1652,9 +1659,16 @@ export class LearningPageComponent implements OnInit, OnDestroy {
     this.navigateStage('/pages/stu/stu3');
   }
 
-  retryListeningChoices(): void { this.outsideChoicePool = null; this.prepareChoiceOptions(); }
+  private prepareWordAudio(word: Word | undefined): void {
+    if (!word || this.isReadOnlySelectionPage || (this.isReadOnlyWordView && !this.isHistoryWordView)) return;
+    const words = this.words(), index = words.findIndex(item => item.id === word.id);
+    const nearby = [word, ...words.slice(Math.max(0, index + 1), Math.max(0, index + 1) + 2)];
+    this.platform.preloadAudio(nearby.filter(item => item.audio?.trim()).map(item => this.wordAudioUrl(item.audio)));
+  }
+
+  retryListeningChoices(): void { this.outsideChoicePool = null; this.prepareChoiceOptions(true); }
 
-  private prepareChoiceOptions(): void {
+  private prepareChoiceOptions(refresh = false): void {
     this.choiceOptionsRequest?.unsubscribe();
     const version = ++this.choiceOptionsVersion;
     const current = this.current();
@@ -1671,7 +1685,7 @@ export class LearningPageComponent implements OnInit, OnDestroy {
     if (this.outsideChoicePool !== null) { apply(this.outsideChoicePool); return; }
     this.choiceOptions.set([]);
     this.choicesLoading.set(true);
-    this.choiceOptionsRequest = this.api.get<unknown>('content_list', { modelId: 52, orders: 'NEWID()', cpage: 1, psize: 60, excludeWordIds: [...new Set(round.map(w => w.id))].join(',') }).subscribe({
+    this.choiceOptionsRequest = this.listeningPool.load(round.map(word => word.id), refresh).subscribe({
       next: ({ result }) => {
         if (version !== this.choiceOptionsVersion || this.current() !== current || this.completed()) return;
         this.outsideChoicePool = this.findList(result).map(item => this.toWord(item));

+ 5 - 3
scripts/deploy-admin-functions.mjs

@@ -2496,14 +2496,16 @@ async function contentPage(input, publicOnly = false) {
   if (input.objectId) { values.push(String(input.objectId)); clauses.push('c."objectId" = $' + values.length); }
   if (input.generalId) { values.push(String(input.generalId)); clauses.push('CAST(c."generalId" AS text) = $' + values.length); }
   const nodeIds = String(input.nodeid || input.nid || input.nodes || '').split(',').map((value) => String(number(value))).filter((value) => value !== '0');
-  if (nodeIds.length) { values.push(nodeIds); clauses.push('CAST(c."nodeId" AS text) = ANY($' + values.length + '::text[])'); }
-  const modelId = contentModel(input, nodeIds); const config = CONTENT_ADDONS[modelId] || null; if(modelId===56)clauses.push("COALESCE(to_jsonb(a)->>'recordScope','')<>'lesson-alias'"); if(modelId===58)clauses.push("a.\"enrollmentStatus\"='active'"); if (modelId) { values.push(String(modelId)); clauses.push('CAST(c."modelId" AS text) = $' + values.length); }
+  const modelId = contentModel(input, nodeIds), vocabulary = modelId === 52;
+  // Vocabulary IDs are numeric: preserve indexable joins in the high-traffic classroom reads.
+  if (nodeIds.length) { values.push(nodeIds); clauses.push(vocabulary ? 'c."nodeId" = ANY($' + values.length + ')' : 'CAST(c."nodeId" AS text) = ANY($' + values.length + '::text[])'); }
+  const config = CONTENT_ADDONS[modelId] || null; if(modelId===56)clauses.push("COALESCE(to_jsonb(a)->>'recordScope','')<>'lesson-alias'"); if(modelId===58)clauses.push("a.\"enrollmentStatus\"='active'"); if (modelId) { values.push(String(modelId)); clauses.push((vocabulary ? 'c."modelId" = $' : 'CAST(c."modelId" AS text) = $') + values.length); }
   if (modelId === 52 && input.excludeWordIds) { const excluded = [...new Set(String(input.excludeWordIds).split(',').filter(id => /^[0-9]+$/.test(id)))]; if(excluded.length>3000)fail(400,'排除词表过长'); values.push(excluded); clauses.push('c."generalId"::text <> ALL($' + values.length + '::text[])'); }
   if (publicOnly && !modelId) { values.push([...PRIVATE_CONTENT_MODELS].map(String)); clauses.push('CAST(c."modelId" AS text) <> ALL($' + values.length + '::text[])'); }
   const filters = legacyFilterPairs(input); if (filters.length && !config) fail(501, '该内容模型的 addon 联表尚未映射');
   for (const filter of filters) { const field = addonField(config, filter.name); if (!field) fail(501, 'addon 字段尚未映射: ' + filter.name); values.push(String(filter.value)); clauses.push('CAST(a."' + field + '" AS text) = $' + values.length); }
   if (input.skey) { values.push('%' + String(input.skey).replace(/[%_]/g, '') + '%'); clauses.push('c."title" ILIKE $' + values.length); }
-  const join = config ? ' LEFT JOIN "' + config.className + '" a ON a."company" = $1 AND CAST(a."id" AS text) = CAST(c."itemId" AS text)' : ''; const where = clauses.join(' AND ');
+  const join = config ? ' LEFT JOIN "' + config.className + '" a ON a."company" = $1 AND ' + (vocabulary ? 'a."id" = c."itemId"' : 'CAST(a."id" AS text) = CAST(c."itemId" AS text)') : ''; const where = clauses.join(' AND ');
   const countRow = await Psql.one('SELECT COUNT(*)::int AS total FROM "CommonModel" c' + join + ' WHERE ' + where, values); const rowValues = values.concat([paging.size, (paging.index - 1) * paging.size]);
   const select = config ? 'c.*, row_to_json(a) AS "__addon"' : 'c.*'; const rows = await Psql.query('SELECT ' + select + ' FROM "CommonModel" c' + join + ' WHERE ' + where + ' ORDER BY ' + contentOrder(input, config) + ' LIMIT $' + (values.length + 1) + ' OFFSET $' + (values.length + 2), rowValues);
   const normalized = rows.filter(isVisible).map((source) => { const addon = source.__addon || {}; const row = { ...source, ...addon }; delete row.__addon; if (source.objectId) row.objectId = source.objectId;if(modelId===58)row.bindingId=addon.objectId; return legacyAliases(row, 'CommonModel'); }); const total = number(countRow.total);

+ 55 - 0
scripts/prepare-word-classroom-performance.mjs

@@ -0,0 +1,55 @@
+#!/usr/bin/env node
+// Read-only release preparation. Publishing is handled by the serialized operator release workflow.
+import fs from 'node:fs';
+import {createHash} from 'node:crypto';
+import {homedir} from 'node:os';
+import {join,resolve} from 'node:path';
+import {fileURLToPath} from 'node:url';
+import ts from 'typescript';
+
+function body(code) {
+ const ast=ts.createSourceFile('gateway.js',code,ts.ScriptTarget.Latest,true,ts.ScriptKind.JS);
+ const nodes=ast.statements.filter(n=>ts.isFunctionDeclaration(n)&&n.name?.text==='contentPage');
+ if(nodes.length!==1)throw Error('Expected exactly one contentPage function');
+ const node=nodes[0];return{start:node.getStart(ast),end:node.end,text:code.slice(node.getStart(ast),node.end)};
+}
+export function patchWordClassroomRead(code) {
+ const source=fs.readFileSync(new URL('./deploy-admin-functions.mjs',import.meta.url),'utf8');
+ const local=source.match(/const appGatewayCode = String.raw`([\s\S]*?)\n`;/)?.[1];
+ if(!local)throw Error('Missing local app gateway');
+ const target=body(local).text,old=body(code);
+ if(old.text===target)return code;
+ // Only accept the known predecessor. Any independent online change requires review.
+ const expected=target
+  .replace('  const modelId = contentModel(input, nodeIds), vocabulary = modelId === 52;\n  // Vocabulary IDs are numeric: preserve indexable joins in the high-traffic classroom reads.\n','')
+  .replace("clauses.push(vocabulary ? 'c.\"nodeId\" = ANY($' + values.length + ')' : 'CAST(c.\"nodeId\" AS text) = ANY($' + values.length + '::text[])');", () => "clauses.push('CAST(c.\"nodeId\" AS text) = ANY($' + values.length + '::text[])');")
+  .replace('  const config = CONTENT_ADDONS[modelId]', '  const modelId = contentModel(input, nodeIds); const config = CONTENT_ADDONS[modelId]')
+  .replace("clauses.push((vocabulary ? 'c.\"modelId\" = $' : 'CAST(c.\"modelId\" AS text) = $') + values.length);", () => "clauses.push('CAST(c.\"modelId\" AS text) = $' + values.length);")
+  .replace("a ON a.\"company\" = $1 AND ' + (vocabulary ? 'a.\"id\" = c.\"itemId\"' : 'CAST(a.\"id\" AS text) = CAST(c.\"itemId\" AS text)')", () => "a ON a.\"company\" = $1 AND CAST(a.\"id\" AS text) = CAST(c.\"itemId\" AS text)'");
+ if(old.text!==expected)throw Error('Online contentPage differs from the reviewed predecessor; inspect drift before release');
+ const patched=code.slice(0,old.start)+target+code.slice(old.end);new Function(patched);return patched;
+}
+
+async function main() {
+ const mode='prepare';
+ if(process.argv.some(arg=>['--deploy','--rollback'].includes(arg)))throw Error('Preparation only: use the serialized operator release workflow to publish or roll back');
+ const master=process.env.XIAOSHU_MASTER_KEY;if(!master)throw Error('Missing XIAOSHU_MASTER_KEY; no production changes made');
+ const base=(process.env.XIAOSHU_PARSE_URL||'https://server.xiaoshu.pro/parse').replace(/\/$/,''),app=process.env.XIAOSHU_PARSE_APP_ID||'7pIbDBJmKx_main';
+ const dir=process.env.XIAOSHU_WORD_RELEASE_DIR||join(homedir(),'.local/state/xiaoshu/cloud-releases/2026-11-07-word-classroom-release');
+ fs.mkdirSync(dir,{recursive:true,mode:0o700});
+ if(['manifest.json','original.json','candidate.json'].some(name=>fs.existsSync(join(dir,name))))throw Error('Prepared release artifacts already exist; choose a new release directory, never overwrite the rollback baseline');
+ const hash=x=>createHash('sha256').update(x).digest('hex'),save=(name,data)=>fs.writeFileSync(join(dir,name),JSON.stringify(data,null,2),{mode:0o600,flag:'wx'});
+ const parse=async(path,method='GET',data)=>{
+  const response=await fetch(base+path,{method,headers:{'Content-Type':'application/json','X-Parse-Application-Id':app,'X-Parse-Master-Key':master},body:data?JSON.stringify(data):undefined,signal:AbortSignal.timeout(30000)});
+  const result=await response.json();if(!response.ok||result.error)throw Error('Release API HTTP '+response.status);return result;
+ };
+ if(mode==='prepare') {
+  const rows=(await parse('/classes/Function?'+new URLSearchParams({where:JSON.stringify({name:'xiaoshu.app.gateway'}),limit:'2'}))).results;
+  if(rows?.length!==1)throw Error('Ambiguous app gateway');
+  const original=rows[0],candidate=patchWordClassroomRead(original.code);
+  save('original.json',original);save('candidate.json',{code:candidate});
+  const manifest={objectId:original.objectId,beforeHash:hash(original.code),afterHash:hash(candidate),functions:['contentPage'],preparedAt:new Date().toISOString()};
+  save('manifest.json',manifest);console.log(JSON.stringify({mode,dir,...manifest}));
+ }
+}
+if(process.argv[1]&&resolve(process.argv[1])===fileURLToPath(import.meta.url))await main();

+ 19 - 0
scripts/tests/fixtures/word-classroom-before.js

@@ -0,0 +1,19 @@
+async function contentPage(input, publicOnly = false) {
+  const paging = pageInput(input); const values = [DEFAULT_COMPANY_ID]; const clauses = ['c."company" = $1'];
+  if (input.__publishedOnly === true) clauses.push('CAST(COALESCE(c."status",99) AS text) = \'99\'');
+  if (input.objectId) { values.push(String(input.objectId)); clauses.push('c."objectId" = $' + values.length); }
+  if (input.generalId) { values.push(String(input.generalId)); clauses.push('CAST(c."generalId" AS text) = $' + values.length); }
+  const nodeIds = String(input.nodeid || input.nid || input.nodes || '').split(',').map((value) => String(number(value))).filter((value) => value !== '0');
+  if (nodeIds.length) { values.push(nodeIds); clauses.push('CAST(c."nodeId" AS text) = ANY($' + values.length + '::text[])'); }
+  const modelId = contentModel(input, nodeIds); const config = CONTENT_ADDONS[modelId] || null; if(modelId===56)clauses.push("COALESCE(to_jsonb(a)->>'recordScope','')<>'lesson-alias'"); if(modelId===58)clauses.push("a.\"enrollmentStatus\"='active'"); if (modelId) { values.push(String(modelId)); clauses.push('CAST(c."modelId" AS text) = $' + values.length); }
+  if (modelId === 52 && input.excludeWordIds) { const excluded = [...new Set(String(input.excludeWordIds).split(',').filter(id => /^[0-9]+$/.test(id)))]; if(excluded.length>3000)fail(400,'排除词表过长'); values.push(excluded); clauses.push('c."generalId"::text <> ALL($' + values.length + '::text[])'); }
+  if (publicOnly && !modelId) { values.push([...PRIVATE_CONTENT_MODELS].map(String)); clauses.push('CAST(c."modelId" AS text) <> ALL($' + values.length + '::text[])'); }
+  const filters = legacyFilterPairs(input); if (filters.length && !config) fail(501, '该内容模型的 addon 联表尚未映射');
+  for (const filter of filters) { const field = addonField(config, filter.name); if (!field) fail(501, 'addon 字段尚未映射: ' + filter.name); values.push(String(filter.value)); clauses.push('CAST(a."' + field + '" AS text) = $' + values.length); }
+  if (input.skey) { values.push('%' + String(input.skey).replace(/[%_]/g, '') + '%'); clauses.push('c."title" ILIKE $' + values.length); }
+  const join = config ? ' LEFT JOIN "' + config.className + '" a ON a."company" = $1 AND CAST(a."id" AS text) = CAST(c."itemId" AS text)' : ''; const where = clauses.join(' AND ');
+  const countRow = await Psql.one('SELECT COUNT(*)::int AS total FROM "CommonModel" c' + join + ' WHERE ' + where, values); const rowValues = values.concat([paging.size, (paging.index - 1) * paging.size]);
+  const select = config ? 'c.*, row_to_json(a) AS "__addon"' : 'c.*'; const rows = await Psql.query('SELECT ' + select + ' FROM "CommonModel" c' + join + ' WHERE ' + where + ' ORDER BY ' + contentOrder(input, config) + ' LIMIT $' + (values.length + 1) + ' OFFSET $' + (values.length + 2), rowValues);
+  const normalized = rows.filter(isVisible).map((source) => { const addon = source.__addon || {}; const row = { ...source, ...addon }; delete row.__addon; if (source.objectId) row.objectId = source.objectId;if(modelId===58)row.bindingId=addon.objectId; return legacyAliases(row, 'CommonModel'); }); const total = number(countRow.total);
+  return { rows: normalized, page: { itemCount: total, pageCount: Math.ceil(total / paging.size), pageIndex: paging.index, pageSize: paging.size } };
+}

+ 42 - 0
scripts/tests/word-classroom-performance.test.mjs

@@ -0,0 +1,42 @@
+import {test} from 'node:test';
+import assert from 'node:assert/strict';
+import fs from 'node:fs';
+import vm from 'node:vm';
+import {PGlite} from '@electric-sql/pglite';
+
+export async function classroomFixture(source) {
+ const db=new PGlite();
+ await db.exec(`CREATE TABLE "CommonModel" ("objectId" text,company text,"modelId" double precision,"nodeId" double precision,"generalId" double precision,"itemId" double precision,title text,status double precision,"updatedAt" timestamptz);
+ CREATE TABLE "VocabularyWord" ("objectId" text,company text,id double precision,sy text,yb text,yp text);
+ INSERT INTO "CommonModel" SELECT 'c'||i,'7pIbDBJmKx',CASE WHEN i<=1000 THEN 52 ELSE 56 END,CASE WHEN i<=100 THEN 40 ELSE 41 END,i,i,'word-'||i,99,'2026-11-07' FROM generate_series(1,12000) i;
+ INSERT INTO "VocabularyWord" SELECT 'v'||i,'7pIbDBJmKx',i,'meaning-'||i,'','word-'||i||'.mp3' FROM generate_series(1,1000) i;
+ INSERT INTO "CommonModel" VALUES ('foreign','other',52,40,99999,1,'foreign',99,'2026-11-07');
+ INSERT INTO "VocabularyWord" VALUES ('foreign-v','other',1,'wrong','','wrong.mp3');
+ CREATE INDEX cm_model_node ON "CommonModel" (company,"modelId","nodeId");
+ CREATE INDEX vocab_id ON "VocabularyWord" (company,id); ANALYZE;`);
+ const queries=[];
+ const gateway=source.match(/const appGatewayCode = String.raw`([\s\S]*?)\n`;/)[1];
+ const context=vm.createContext({Psql:{one:async(sql,args)=>{queries.push({sql,args});return(await db.query(sql,args)).rows[0];},query:async(sql,args)=>{queries.push({sql,args});return(await db.query(sql,args)).rows;}}});
+ vm.runInContext(gateway,context);
+ return{db,queries,read:input=>context.contentPage(input,true)};
+}
+
+if(process.argv[1]===new URL(import.meta.url).pathname || process.env.NODE_TEST_CONTEXT) {
+ const source=fs.readFileSync(new URL('../deploy-admin-functions.mjs',import.meta.url),'utf8');
+ test('词库课堂查询只扫描选中单元,保留分页、释义和租户隔离',async t=>{
+  const {db,queries,read}=await classroomFixture(source);t.after(()=>db.close());
+  const page=await read({modelId:52,nid:40,psize:10,cpage:2,orders:'GeneralID=ASC',__publishedOnly:true});
+  assert.equal(page.page.itemCount,100);assert.equal(page.rows.length,10);
+  assert.equal(page.rows[0].GeneralID,11);assert.equal(page.rows[0].sy,'meaning-11');assert.equal(page.rows.at(-1).GeneralID,20);
+  let discarded=0;
+  const walk=p=>{discarded+=Number(p['Rows Removed by Filter']||0)*Number(p['Actual Loops']||1);for(const child of p.Plans||[])walk(child);};
+  for(const q of queries){const plan=(await db.query('EXPLAIN (ANALYZE,FORMAT JSON) '+q.sql,q.args)).rows[0]['QUERY PLAN'][0].Plan;walk(plan);}
+  assert.ok(discarded<1000,`单元读取丢弃了 ${discarded} 行,应使用已有数字索引而不是全表转换过滤`);
+ });
+ test('随机干扰词排除本轮词表且保留正确释义',async t=>{
+  const {db,read}=await classroomFixture(source);t.after(()=>db.close());
+  const result=await read({modelId:52,nid:'40',psize:60,orders:'NEWID()',excludeWordIds:'1,2,3',__publishedOnly:true});
+  assert.equal(result.page.itemCount,97);assert.equal(result.rows.length,60);
+  assert.ok(result.rows.every(r=>r.GeneralID>3&&r.GeneralID<=100&&r.sy===`meaning-${r.GeneralID}`));
+ });
+}

+ 39 - 0
scripts/tests/word-classroom-release.test.mjs

@@ -0,0 +1,39 @@
+import {test} from 'node:test';
+import assert from 'node:assert/strict';
+import fs from 'node:fs';
+import {spawnSync} from 'node:child_process';
+import {fileURLToPath} from 'node:url';
+import {patchWordClassroomRead} from '../prepare-word-classroom-performance.mjs';
+const original=fs.readFileSync(new URL('./fixtures/word-classroom-before.js',import.meta.url),'utf8').trimEnd();
+test('词库发布只替换经过审查的读取函数,保留其他线上修复且支持重复准备',()=>{
+ const prefix='const onlineOnly = "keep";\n',suffix='\nasync function unrelatedFix(){return "preserve newer work";}';
+ const patched=patchWordClassroomRead(prefix+original+suffix);
+ assert.ok(patched.startsWith(prefix));assert.ok(patched.endsWith(suffix));
+ assert.notEqual(patched,prefix+original+suffix);assert.equal(patchWordClassroomRead(patched),patched);
+ new Function(patched);
+});
+test('同一读取函数被其他任务改动时拒绝覆盖',()=>{
+ assert.throws(()=>patchWordClassroomRead(original.replace('const paging = pageInput(input);','const paging = pageInput(input); const newerFix = true;')),/differs from the reviewed predecessor/);
+});
+test('缺少发布凭据时明确停止,不能宣称已上线',()=>{
+ const env={...process.env};delete env.XIAOSHU_MASTER_KEY;
+ const run=spawnSync(process.execPath,[fileURLToPath(new URL('../prepare-word-classroom-performance.mjs',import.meta.url)),'--prepare'],{env,encoding:'utf8'});
+ assert.notEqual(run.status,0);assert.match(run.stderr,/Missing XIAOSHU_MASTER_KEY; no production changes made/);
+});
+
+test('重复准备不能覆盖回滚原件,准备流程不向服务器写入',async t=>{
+ const {createServer}=await import('node:http'),{tmpdir}=await import('node:os'),{join}=await import('node:path'),{spawn}=await import('node:child_process');
+ const dir=fs.mkdtempSync(join(tmpdir(),'xiaoshu-word-release-'));t.after(()=>fs.rmSync(dir,{recursive:true,force:true}));
+ const methods=[];
+ const server=createServer((req,res)=>{methods.push(req.method);res.setHeader('Content-Type','application/json');res.end(JSON.stringify({results:[{objectId:'fixture-gateway',code:original}]}));});
+ await new Promise(done=>server.listen(0,'127.0.0.1',done));t.after(()=>new Promise(done=>server.close(done)));
+ const run=()=>new Promise(done=>{
+  const child=spawn(process.execPath,[fileURLToPath(new URL('../prepare-word-classroom-performance.mjs',import.meta.url)),'--prepare'],{env:{...process.env,XIAOSHU_MASTER_KEY:'fixture-only',XIAOSHU_PARSE_URL:'http://127.0.0.1:'+server.address().port,XIAOSHU_WORD_RELEASE_DIR:dir}});
+  let stderr='';child.stderr.on('data',chunk=>stderr+=chunk);child.on('close',status=>done({status,stderr}));
+ });
+ assert.equal((await run()).status,0);
+ const saved=fs.readFileSync(join(dir,'original.json'),'utf8');
+ const repeat=await run();assert.notEqual(repeat.status,0);assert.match(repeat.stderr,/already exist/);
+ assert.equal(fs.readFileSync(join(dir,'original.json'),'utf8'),saved);
+ assert.deepEqual(methods,['GET']);
+});