Просмотр исходного кода

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

彭峰 15 часов назад
Родитель
Сommit
da94b677bf
28 измененных файлов с 1054 добавлено и 14 удалено
  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']);
+});