Преглед на файлове

perf(wordbook): index word lookups and reveal meanings after audio

彭峰 преди 14 часа
родител
ревизия
45443271f0

+ 9 - 0
perf/wordbook-2026-11-12/BRIEF.md

@@ -0,0 +1,9 @@
+# 生词本加载与释义显示
+
+- 范围:学生生词本、老师选择学员后的生词本、老师学员名单加载。
+- 能用:真实词条显示,选词与发音按钮可点击;老师端已确定授权学员。以 `.word-select-list > button strong` 非空、加载状态结束、选择动作能立即更新勾选为准。
+- 目标:相同数据与网络条件下,打开到可操作的 p75 至少降低 50%;不以占位或提前结束加载计数。
+- 保留:学员与帐套隔离、未学/已学计数、分页、20/50/100 批选、学习进度及课时/收费行为。
+- 用户指定的显示变化:默认仅英文,点击后完成读音再显示中文;播放失败或中断不显示,切换学员或重新打开默认隐藏。
+- 测量:至少 10 对 A/B 交替,冷上下文,Fast 4G(20 ms/4 Mbps/3 Mbps);另记录真实网关和数据库执行计划。鉴权页无公开 CrUX 数据,不将实验室结果称为真人设备体验。
+- 发布:用户明确要求修改后直接上线;备份完整线上函数和静态清单,限定替换与本需求有关的函数,保留可回滚版本,回读哈希与 CDN 状态后检查真实入口。

+ 34 - 0
perf/wordbook-2026-11-12/REPORT.md

@@ -0,0 +1,34 @@
+# 生词本优化验证(2026-11-12)
+
+## 配对结果
+
+基线:`fc1633a`。两个本地生产构建服务,冷浏览器上下文、390px 宽、Fast 4G(20ms / 4Mbps / 3Mbps)、CPU 不限速,按 A、B 交替,学生/老师各 10 对。起点为导航开始,终点为 30 个真实词条显示且点击能实际勾选。
+
+两个服务通过同一个受保护诊断入口,读取同一生产数据库和学员。老师端也实际查询带练名单并校验名单归属;浏览器会话为隔离测试身份。结果表示受控入口的加载时间,不能替代真实学生手机和真实账号登录的现场体验。鉴权页面没有可用的公开 CrUX 数据。
+
+| 入口 | 前 p50 | 前 p75 | 前 p95 | 后 p50 | 后 p75 | 后 p95 | p75 降幅 |
+|---|---:|---:|---:|---:|---:|---:|---:|
+| 学生 | 17.050s | 17.189s | 18.138s | 1.899s | 1.931s | 1.992s | 88.8% |
+| 老师 | 16.747s | 16.805s | 17.234s | 2.122s | 2.182s | 2.314s | 87.0% |
+
+`paired-samples.json` 保存每次时间、接口时间、资源数、字节和长任务;`paired-summary.json` 保存统计。`ratchet.json` 将已测 p75 作为上限,`node perf/wordbook-2026-11-12/ratchet.mjs` 检查上限和样本数。复测时给 `bench.mjs` 传入两个已准备好的只读隔离代理地址,并通过 `CHROME_BIN` 指定 Chrome。
+
+## 根因与确定性指标
+
+真实网关原先一页约 14.9 秒。EXPLAIN ANALYZE 显示行查询约 14,039ms:文字转换使模型编号及关联编号无法组成完整原生索引条件,210 次词条关联中每次约 65ms,另外扫描了约 45 万练习映射和 10 万词条。
+
+增加可使用原生 `(company,modelId,generalId)`、`(company,modelId,itemId)` 和词库编号索引的等值条件,同时保留原文字等值条件。候选计数约 2.8ms,行查询约 18.8ms。没有新增索引或改变表结构。
+
+守护测试执行实际 SQL 的 EXPLAIN,确认词条查找使用模型和编号两项索引键;同一测试已在基线失败、候选通过。分页、已学/未学、越界、非法状态、老师名单、越权和历史文本编号均有回归。
+
+## 内容与交互护栏
+
+- 学生和老师返回的 30 条完整词条内容、分页和状态计数,与基线一致。原排序在相同更新时间且练习编号为空时存在并列,因此黄金比较按规范化内容排序后逐项验证;生产排序表达式未改动。
+- 初始仅英文;点击勾选仍立即生效,中文必须等读音完成后出现。喇叭只发音,不改变选中数量。
+- 播放开始、失败、中断不揭示新释义;迟到的旧播放完成回调不能揭示其他词条。重开、切换学员默认隐藏;切栏目/分页会停止旧播放。
+- 380 项移动端测试、21 项相关服务端测试、生产构建通过;浏览器验证覆盖 320/390/1024px、放大字体、错误及中断。
+- 视觉记录:[学生修改前](student-before.png)、[修改后](student-after.png);[老师修改前](teacher-before.png)、[修改后](teacher-after.png)。
+
+## 发布与回滚
+
+用户已明确要求直接上线。仅更新移动端和 `newWordPage` 云端函数;发布前保留完整线上函数及静态文件清单。先确认服务端哈希,再上传哈希资源、最后切换入口,刷新并回读 CDN;出现回归按备份恢复函数及静态入口。

+ 19 - 0
perf/wordbook-2026-11-12/bench.mjs

@@ -0,0 +1,19 @@
+import {chromium}from'playwright';import fs from'node:fs';import assert from'node:assert/strict';
+const root=new URL('.',import.meta.url).pathname,browser=await chromium.launch({headless:true,...(process.env.CHROME_BIN?{executablePath:process.env.CHROME_BIN}:{})}),samples=[];
+try{
+ for(let pair=0;pair<10;pair++)for(const role of ['student','coach'])for(const version of ['A','B']){
+  const context=await browser.newContext({viewport:{width:390,height:844}}),page=await context.newPage(),api=[];
+  await context.addInitScript(role=>{localStorage.setItem('xiaoshu.angular.session',JSON.stringify({token:'r:qa-'+role,user:{userId:role==='coach'?300:1713,groupId:role==='coach'?3:1,honeyName:'测试账号'},student:null}));HTMLMediaElement.prototype.load=()=>{};HTMLMediaElement.prototype.play=()=>Promise.resolve();window.qaLongTasks=[];new PerformanceObserver(list=>window.qaLongTasks.push(...list.getEntries().map(x=>x.duration))).observe({entryTypes:['longtask']});},role);
+  const cdp=await context.newCDPSession(page);await cdp.send('Network.enable');await cdp.send('Network.emulateNetworkConditions',{offline:false,latency:20,downloadThroughput:4e6/8,uploadThroughput:3e6/8,connectionType:'cellular4g'});
+  page.on('response',response=>{if(response.url().includes('gateway'))api.push({action:response.request().postDataJSON()?.params?.action,serverMs:Number(response.headers()['x-qa-read-ms']||0)});});
+  const start=performance.now();await page.goto((version==='A'?(process.argv[2]||'http://127.0.0.1:4210'):(process.argv[3]||'http://127.0.0.1:4211'))+'/pages/member/starshop'+(role==='coach'?'?wordbookStudentId=1713':''),{waitUntil:'domcontentloaded'});
+  await page.waitForFunction(()=>{const r=document.querySelectorAll('.word-select-list > button');return r.length===30 && r[0].querySelector('strong')?.textContent.trim() && !document.querySelector('.word-column-empty');},{},{timeout:60000});
+  await page.locator('.word-select-list > button').first().click();await page.waitForFunction(()=>document.querySelector('.word-select-list > button')?.classList.contains('selected'));
+  const ms=Math.round(performance.now()-start),metrics=await page.evaluate(()=>({resources:performance.getEntriesByType('resource').map(x=>({bytes:x.encodedBodySize||0,type:x.initiatorType})),longTaskMs:window.qaLongTasks.reduce((a,b)=>a+b,0)}));
+  samples.push({pair,role,version,ms,api,requests:metrics.resources.length,bytes:metrics.resources.reduce((n,r)=>n+r.bytes,0),longTaskMs:metrics.longTaskMs});
+  fs.writeFileSync(root+'paired-samples.json',JSON.stringify(samples,null,2));console.log(JSON.stringify({pair,role,version,ms,api}));await context.close();
+ }
+ const percentile=(values,p)=>values.sort((a,b)=>a-b)[Math.ceil(values.length*p)-1],summary=[];
+ for(const role of ['student','coach']){const before=samples.filter(x=>x.role===role&&x.version==='A'),after=samples.filter(x=>x.role===role&&x.version==='B');const row={role,n:before.length,before:{p50:percentile(before.map(x=>x.ms),.5),p75:percentile(before.map(x=>x.ms),.75),p95:percentile(before.map(x=>x.ms),.95)},after:{p50:percentile(after.map(x=>x.ms),.5),p75:percentile(after.map(x=>x.ms),.75),p95:percentile(after.map(x=>x.ms),.95)}};row.improvement=1-row.after.p75/row.before.p75;assert.ok(row.improvement>=.5,role+' must improve by at least 50%');summary.push(row);}
+ fs.writeFileSync(root+'paired-summary.json',JSON.stringify(summary,null,2));console.log(JSON.stringify({summary}));
+}finally{await browser.close();}

+ 682 - 0
perf/wordbook-2026-11-12/paired-samples.json

@@ -0,0 +1,682 @@
+[
+  {
+    "pair": 0,
+    "role": "student",
+    "version": "A",
+    "ms": 18138,
+    "api": [
+      {
+        "action": "e_words_list",
+        "serverMs": 16036
+      }
+    ],
+    "requests": 24,
+    "bytes": 700836,
+    "longTaskMs": 65
+  },
+  {
+    "pair": 0,
+    "role": "student",
+    "version": "B",
+    "ms": 1987,
+    "api": [
+      {
+        "action": "e_words_list",
+        "serverMs": 33
+      }
+    ],
+    "requests": 23,
+    "bytes": 698778,
+    "longTaskMs": 54
+  },
+  {
+    "pair": 0,
+    "role": "coach",
+    "version": "A",
+    "ms": 16747,
+    "api": [
+      {
+        "action": "e_user_list",
+        "serverMs": 85
+      },
+      {
+        "action": "e_words_list",
+        "serverMs": 14543
+      }
+    ],
+    "requests": 24,
+    "bytes": 698725,
+    "longTaskMs": 84
+  },
+  {
+    "pair": 0,
+    "role": "coach",
+    "version": "B",
+    "ms": 2088,
+    "api": [
+      {
+        "action": "e_user_list",
+        "serverMs": 79
+      },
+      {
+        "action": "e_words_list",
+        "serverMs": 32
+      }
+    ],
+    "requests": 24,
+    "bytes": 699097,
+    "longTaskMs": 0
+  },
+  {
+    "pair": 1,
+    "role": "student",
+    "version": "A",
+    "ms": 17050,
+    "api": [
+      {
+        "action": "e_words_list",
+        "serverMs": 15173
+      }
+    ],
+    "requests": 23,
+    "bytes": 698406,
+    "longTaskMs": 0
+  },
+  {
+    "pair": 1,
+    "role": "student",
+    "version": "B",
+    "ms": 1899,
+    "api": [
+      {
+        "action": "e_words_list",
+        "serverMs": 18
+      }
+    ],
+    "requests": 23,
+    "bytes": 698778,
+    "longTaskMs": 0
+  },
+  {
+    "pair": 1,
+    "role": "coach",
+    "version": "A",
+    "ms": 16766,
+    "api": [
+      {
+        "action": "e_user_list",
+        "serverMs": 81
+      },
+      {
+        "action": "e_words_list",
+        "serverMs": 14648
+      }
+    ],
+    "requests": 24,
+    "bytes": 698725,
+    "longTaskMs": 0
+  },
+  {
+    "pair": 1,
+    "role": "coach",
+    "version": "B",
+    "ms": 2314,
+    "api": [
+      {
+        "action": "e_user_list",
+        "serverMs": 80
+      },
+      {
+        "action": "e_words_list",
+        "serverMs": 33
+      }
+    ],
+    "requests": 24,
+    "bytes": 699097,
+    "longTaskMs": 0
+  },
+  {
+    "pair": 2,
+    "role": "student",
+    "version": "A",
+    "ms": 17189,
+    "api": [
+      {
+        "action": "e_words_list",
+        "serverMs": 15325
+      }
+    ],
+    "requests": 23,
+    "bytes": 698406,
+    "longTaskMs": 0
+  },
+  {
+    "pair": 2,
+    "role": "student",
+    "version": "B",
+    "ms": 1918,
+    "api": [
+      {
+        "action": "e_words_list",
+        "serverMs": 23
+      }
+    ],
+    "requests": 23,
+    "bytes": 698778,
+    "longTaskMs": 0
+  },
+  {
+    "pair": 2,
+    "role": "coach",
+    "version": "A",
+    "ms": 16620,
+    "api": [
+      {
+        "action": "e_user_list",
+        "serverMs": 81
+      },
+      {
+        "action": "e_words_list",
+        "serverMs": 14577
+      }
+    ],
+    "requests": 24,
+    "bytes": 698725,
+    "longTaskMs": 0
+  },
+  {
+    "pair": 2,
+    "role": "coach",
+    "version": "B",
+    "ms": 2243,
+    "api": [
+      {
+        "action": "e_user_list",
+        "serverMs": 84
+      },
+      {
+        "action": "e_words_list",
+        "serverMs": 35
+      }
+    ],
+    "requests": 27,
+    "bytes": 706387,
+    "longTaskMs": 0
+  },
+  {
+    "pair": 3,
+    "role": "student",
+    "version": "A",
+    "ms": 17117,
+    "api": [
+      {
+        "action": "e_words_list",
+        "serverMs": 15285
+      }
+    ],
+    "requests": 23,
+    "bytes": 698406,
+    "longTaskMs": 0
+  },
+  {
+    "pair": 3,
+    "role": "student",
+    "version": "B",
+    "ms": 1992,
+    "api": [
+      {
+        "action": "e_words_list",
+        "serverMs": 25
+      }
+    ],
+    "requests": 23,
+    "bytes": 698778,
+    "longTaskMs": 0
+  },
+  {
+    "pair": 3,
+    "role": "coach",
+    "version": "A",
+    "ms": 16925,
+    "api": [
+      {
+        "action": "e_user_list",
+        "serverMs": 105
+      },
+      {
+        "action": "e_words_list",
+        "serverMs": 14836
+      }
+    ],
+    "requests": 24,
+    "bytes": 698725,
+    "longTaskMs": 0
+  },
+  {
+    "pair": 3,
+    "role": "coach",
+    "version": "B",
+    "ms": 2139,
+    "api": [
+      {
+        "action": "e_user_list",
+        "serverMs": 82
+      },
+      {
+        "action": "e_words_list",
+        "serverMs": 37
+      }
+    ],
+    "requests": 24,
+    "bytes": 699097,
+    "longTaskMs": 0
+  },
+  {
+    "pair": 4,
+    "role": "student",
+    "version": "A",
+    "ms": 17069,
+    "api": [
+      {
+        "action": "e_words_list",
+        "serverMs": 15191
+      }
+    ],
+    "requests": 23,
+    "bytes": 698406,
+    "longTaskMs": 0
+  },
+  {
+    "pair": 4,
+    "role": "student",
+    "version": "B",
+    "ms": 1860,
+    "api": [
+      {
+        "action": "e_words_list",
+        "serverMs": 17
+      }
+    ],
+    "requests": 23,
+    "bytes": 698778,
+    "longTaskMs": 0
+  },
+  {
+    "pair": 4,
+    "role": "coach",
+    "version": "A",
+    "ms": 16656,
+    "api": [
+      {
+        "action": "e_user_list",
+        "serverMs": 88
+      },
+      {
+        "action": "e_words_list",
+        "serverMs": 14604
+      }
+    ],
+    "requests": 24,
+    "bytes": 698725,
+    "longTaskMs": 0
+  },
+  {
+    "pair": 4,
+    "role": "coach",
+    "version": "B",
+    "ms": 2155,
+    "api": [
+      {
+        "action": "e_user_list",
+        "serverMs": 81
+      },
+      {
+        "action": "e_words_list",
+        "serverMs": 37
+      }
+    ],
+    "requests": 24,
+    "bytes": 699097,
+    "longTaskMs": 0
+  },
+  {
+    "pair": 5,
+    "role": "student",
+    "version": "A",
+    "ms": 16939,
+    "api": [
+      {
+        "action": "e_words_list",
+        "serverMs": 15072
+      }
+    ],
+    "requests": 23,
+    "bytes": 698406,
+    "longTaskMs": 0
+  },
+  {
+    "pair": 5,
+    "role": "student",
+    "version": "B",
+    "ms": 1931,
+    "api": [
+      {
+        "action": "e_words_list",
+        "serverMs": 18
+      }
+    ],
+    "requests": 23,
+    "bytes": 698778,
+    "longTaskMs": 0
+  },
+  {
+    "pair": 5,
+    "role": "coach",
+    "version": "A",
+    "ms": 16497,
+    "api": [
+      {
+        "action": "e_user_list",
+        "serverMs": 93
+      },
+      {
+        "action": "e_words_list",
+        "serverMs": 14482
+      }
+    ],
+    "requests": 24,
+    "bytes": 698725,
+    "longTaskMs": 0
+  },
+  {
+    "pair": 5,
+    "role": "coach",
+    "version": "B",
+    "ms": 2106,
+    "api": [
+      {
+        "action": "e_user_list",
+        "serverMs": 91
+      },
+      {
+        "action": "e_words_list",
+        "serverMs": 33
+      }
+    ],
+    "requests": 24,
+    "bytes": 699097,
+    "longTaskMs": 0
+  },
+  {
+    "pair": 6,
+    "role": "student",
+    "version": "A",
+    "ms": 17556,
+    "api": [
+      {
+        "action": "e_words_list",
+        "serverMs": 15630
+      }
+    ],
+    "requests": 23,
+    "bytes": 698406,
+    "longTaskMs": 0
+  },
+  {
+    "pair": 6,
+    "role": "student",
+    "version": "B",
+    "ms": 1886,
+    "api": [
+      {
+        "action": "e_words_list",
+        "serverMs": 20
+      }
+    ],
+    "requests": 23,
+    "bytes": 698778,
+    "longTaskMs": 0
+  },
+  {
+    "pair": 6,
+    "role": "coach",
+    "version": "A",
+    "ms": 16584,
+    "api": [
+      {
+        "action": "e_user_list",
+        "serverMs": 82
+      },
+      {
+        "action": "e_words_list",
+        "serverMs": 14540
+      }
+    ],
+    "requests": 24,
+    "bytes": 698725,
+    "longTaskMs": 0
+  },
+  {
+    "pair": 6,
+    "role": "coach",
+    "version": "B",
+    "ms": 2111,
+    "api": [
+      {
+        "action": "e_user_list",
+        "serverMs": 83
+      },
+      {
+        "action": "e_words_list",
+        "serverMs": 34
+      }
+    ],
+    "requests": 24,
+    "bytes": 699097,
+    "longTaskMs": 0
+  },
+  {
+    "pair": 7,
+    "role": "student",
+    "version": "A",
+    "ms": 16812,
+    "api": [
+      {
+        "action": "e_words_list",
+        "serverMs": 14977
+      }
+    ],
+    "requests": 23,
+    "bytes": 698406,
+    "longTaskMs": 0
+  },
+  {
+    "pair": 7,
+    "role": "student",
+    "version": "B",
+    "ms": 1923,
+    "api": [
+      {
+        "action": "e_words_list",
+        "serverMs": 20
+      }
+    ],
+    "requests": 23,
+    "bytes": 698778,
+    "longTaskMs": 0
+  },
+  {
+    "pair": 7,
+    "role": "coach",
+    "version": "A",
+    "ms": 17234,
+    "api": [
+      {
+        "action": "e_user_list",
+        "serverMs": 79
+      },
+      {
+        "action": "e_words_list",
+        "serverMs": 15186
+      }
+    ],
+    "requests": 24,
+    "bytes": 698725,
+    "longTaskMs": 0
+  },
+  {
+    "pair": 7,
+    "role": "coach",
+    "version": "B",
+    "ms": 2182,
+    "api": [
+      {
+        "action": "e_user_list",
+        "serverMs": 88
+      },
+      {
+        "action": "e_words_list",
+        "serverMs": 34
+      }
+    ],
+    "requests": 24,
+    "bytes": 699097,
+    "longTaskMs": 0
+  },
+  {
+    "pair": 8,
+    "role": "student",
+    "version": "A",
+    "ms": 17019,
+    "api": [
+      {
+        "action": "e_words_list",
+        "serverMs": 15093
+      }
+    ],
+    "requests": 23,
+    "bytes": 698406,
+    "longTaskMs": 0
+  },
+  {
+    "pair": 8,
+    "role": "student",
+    "version": "B",
+    "ms": 1836,
+    "api": [
+      {
+        "action": "e_words_list",
+        "serverMs": 18
+      }
+    ],
+    "requests": 23,
+    "bytes": 698778,
+    "longTaskMs": 0
+  },
+  {
+    "pair": 8,
+    "role": "coach",
+    "version": "A",
+    "ms": 16793,
+    "api": [
+      {
+        "action": "e_user_list",
+        "serverMs": 93
+      },
+      {
+        "action": "e_words_list",
+        "serverMs": 14640
+      }
+    ],
+    "requests": 24,
+    "bytes": 698725,
+    "longTaskMs": 0
+  },
+  {
+    "pair": 8,
+    "role": "coach",
+    "version": "B",
+    "ms": 2122,
+    "api": [
+      {
+        "action": "e_user_list",
+        "serverMs": 78
+      },
+      {
+        "action": "e_words_list",
+        "serverMs": 41
+      }
+    ],
+    "requests": 24,
+    "bytes": 699097,
+    "longTaskMs": 0
+  },
+  {
+    "pair": 9,
+    "role": "student",
+    "version": "A",
+    "ms": 16882,
+    "api": [
+      {
+        "action": "e_words_list",
+        "serverMs": 15030
+      }
+    ],
+    "requests": 23,
+    "bytes": 698406,
+    "longTaskMs": 0
+  },
+  {
+    "pair": 9,
+    "role": "student",
+    "version": "B",
+    "ms": 1873,
+    "api": [
+      {
+        "action": "e_words_list",
+        "serverMs": 22
+      }
+    ],
+    "requests": 23,
+    "bytes": 698778,
+    "longTaskMs": 0
+  },
+  {
+    "pair": 9,
+    "role": "coach",
+    "version": "A",
+    "ms": 16805,
+    "api": [
+      {
+        "action": "e_user_list",
+        "serverMs": 85
+      },
+      {
+        "action": "e_words_list",
+        "serverMs": 14744
+      }
+    ],
+    "requests": 24,
+    "bytes": 698725,
+    "longTaskMs": 0
+  },
+  {
+    "pair": 9,
+    "role": "coach",
+    "version": "B",
+    "ms": 2112,
+    "api": [
+      {
+        "action": "e_user_list",
+        "serverMs": 85
+      },
+      {
+        "action": "e_words_list",
+        "serverMs": 34
+      }
+    ],
+    "requests": 24,
+    "bytes": 699097,
+    "longTaskMs": 113
+  }
+]

+ 32 - 0
perf/wordbook-2026-11-12/paired-summary.json

@@ -0,0 +1,32 @@
+[
+  {
+    "role": "student",
+    "n": 10,
+    "before": {
+      "p50": 17050,
+      "p75": 17189,
+      "p95": 18138
+    },
+    "after": {
+      "p50": 1899,
+      "p75": 1931,
+      "p95": 1992
+    },
+    "improvement": 0.8876607132468439
+  },
+  {
+    "role": "coach",
+    "n": 10,
+    "before": {
+      "p50": 16747,
+      "p75": 16805,
+      "p95": 17234
+    },
+    "after": {
+      "p50": 2122,
+      "p75": 2182,
+      "p95": 2314
+    },
+    "improvement": 0.8701576911633442
+  }
+]

+ 8 - 0
perf/wordbook-2026-11-12/ratchet.json

@@ -0,0 +1,8 @@
+{
+  "samplesPerVariant": 10,
+  "minimumImprovement": 0.5,
+  "maximumP75Ms": {
+    "student": 1931,
+    "coach": 2182
+  }
+}

+ 3 - 0
perf/wordbook-2026-11-12/ratchet.mjs

@@ -0,0 +1,3 @@
+import fs from 'node:fs';import assert from 'node:assert/strict';
+const root=new URL('.',import.meta.url),limits=JSON.parse(fs.readFileSync(new URL('ratchet.json',root))),rows=JSON.parse(fs.readFileSync(process.argv[2]||new URL('paired-summary.json',root)));
+for(const role of ['student','coach']){const row=rows.find(x=>x.role===role);assert.ok(row&&row.n>=limits.samplesPerVariant,role+' needs 10 paired samples');assert.ok(row.improvement>=limits.minimumImprovement,role+' must improve at least 50%');assert.ok(row.after.p75<=limits.maximumP75Ms[role],role+' p75 regressed');}console.log('Wordbook performance ratchet passed');

BIN
perf/wordbook-2026-11-12/student-after.png


BIN
perf/wordbook-2026-11-12/student-before.png


BIN
perf/wordbook-2026-11-12/teacher-after.png


BIN
perf/wordbook-2026-11-12/teacher-before.png


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

@@ -1089,6 +1089,36 @@ describe('home learning modules', () => {
     expect(post).toHaveBeenCalledWith('e_add_words', jasmine.objectContaining({ uid: 100, wordsId: '702', ifnew: 0 }));
   });
 
+  for (const role of [1,3]) {
+    it(`reveals a wordbook meaning only after completed pronunciation for role ${role}`,async()=>{
+      user.set({userId:role===3?300:100,groupId:role});session.groupId.set(role);
+      const api=TestBed.inject(ApiService),original=api.get.bind(api);
+      spyOn(api,'get').and.callFake(((action:string,params:Record<string,unknown>={})=>{
+        if(action==='e_user_list')return of({retcode:0,result:[{userId:100,honeyName:'学员甲'},{userId:101,honeyName:'学员乙'}]});
+        if(action==='e_words_list')return of({retcode:0,result:[{GeneralID:601,xxcs:params['learningStatus']==='completed'?2:1,detail:[{GeneralID:701,Title:'apple',sy:'苹果',yp:'apple.mp3'}]},{GeneralID:602,xxcs:params['learningStatus']==='completed'?2:1,detail:[{GeneralID:702,Title:'banana',sy:'香蕉',yp:'banana.mp3'}]}],...(role===3?{page:{itemCount:2,pendingCount:2,completedCount:2}}:{})});
+        return original(action,params);
+      }) as typeof api.get);
+      const platform=TestBed.inject(PlatformService),play=spyOn(platform,'play').and.resolveTo('played');spyOn(platform,'toast');
+      const harness=await RouterTestingHarness.create('/pages/member/starshop');await harness.fixture.whenStable();harness.fixture.detectChanges();
+      const component=harness.fixture.debugElement.query(By.directive(LearningPageComponent)).componentInstance as LearningPageComponent;
+      const rows=()=>harness.routeNativeElement!.querySelectorAll<HTMLButtonElement>('.word-select-list > button');
+      const meanings=()=>Array.from(harness.routeNativeElement!.querySelectorAll('.word-select-list small'),node=>node.textContent);
+      expect(meanings()).toEqual([]);
+      rows()[0].click();await harness.fixture.whenStable();harness.fixture.detectChanges();expect(meanings()).toEqual([]);
+      const old=play.calls.mostRecent().args[2]!;expect(old).toBeDefined();if(!old)return;
+      rows()[1].click();await harness.fixture.whenStable();harness.fixture.detectChanges();
+      old.onEnded();harness.fixture.detectChanges();expect(meanings()).toEqual([]);
+      play.calls.mostRecent().args[2]!.onError?.();harness.fixture.detectChanges();expect(meanings()).toEqual([]);
+      rows()[0].querySelector<HTMLElement>('.audio')!.click();await harness.fixture.whenStable();
+      play.calls.mostRecent().args[2]!.onEnded();harness.fixture.detectChanges();expect(meanings()).toEqual(['苹果']);
+      expect(component.selected().size).toBe(2);
+      component.load();await harness.fixture.whenStable();harness.fixture.detectChanges();expect(meanings()).toEqual([]);
+      rows()[0].click();await harness.fixture.whenStable();const late=play.calls.mostRecent().args[2]!;
+      if(role===3)component.selectWordbookStudent(101);else {component.selectNewWordBucket('completed');component.selectNewWordBucket('pending');}
+      await harness.fixture.whenStable();late.onEnded();harness.fixture.detectChanges();expect(meanings()).toEqual([]);
+    });
+  }
+
   it('pronounces a wordbook word on row click and only once on speaker click', async () => {
     const api = TestBed.inject(ApiService) as unknown as LearningModulesApiService;
     const get = api.get.bind(api);

+ 1 - 1
projects/xiaoshu-mobile/src/app/features/learning/learning-page.component.html

@@ -170,7 +170,7 @@
             @for (word of visibleNewWords(); track word.id) {
               <button type="button" [class.selected]="selected().has(word.id)" (click)="toggleWord(word); play(word)">
                 <span class="check">@if (selected().has(word.id)) { <lucide-icon [img]="icons.Check" [size]="16" /> }</span>
-                <span><strong>{{ word.word }}</strong><small>{{ word.meaning || '暂无释义' }}</small></span>
+                <span><strong>{{ word.word }}</strong>@if (selectionWordRevealed(word)) { <small>{{ word.meaning || '暂无释义' }}</small> }</span>
                 <span class="audio" (click)="$event.stopPropagation(); play(word)"><lucide-icon [img]="icons.Volume2" [size]="18" /></span>
               </button>
             } @empty {

+ 5 - 2
projects/xiaoshu-mobile/src/app/features/learning/learning-page.component.ts

@@ -964,11 +964,12 @@ export class LearningPageComponent implements OnInit, OnDestroy {
     this.audioPreparing.set(false);
     if (this.isHistoryWordView) this.historyAudioWordId.set(word.id);
     const playAudio = async (url: string, silentFailure = false) => {
-      const status = await (this.isHistoryWordView
+      const status = await (this.isHistoryWordView || this.isNewWordSelectionPage
         ? this.platform.play(url, silentFailure, {
           onEnded: () => {
             if (!active()) return;
-            this.historyWordRevealed.update(current => new Set(current).add(word.id));
+            if (this.isNewWordSelectionPage) this.selectionRevealed.update(current => new Set(current).add(word.id));
+            else this.historyWordRevealed.update(current => new Set(current).add(word.id));
             this.historyAudioWordId.set('');
           },
           onError: () => {
@@ -1533,6 +1534,7 @@ export class LearningPageComponent implements OnInit, OnDestroy {
   }
   selectNewWordBucket(bucket: 'pending' | 'completed'): void {
     if (this.activeSelectionBucket() === bucket) return;
+    this.audioRequest++;this.platform.stopAudio();this.audioPreparing.set(false);
     this.selectionRequest?.unsubscribe();
     this.activeSelectionBucket.set(bucket);
     this.selectionPage.set(1);
@@ -1544,6 +1546,7 @@ export class LearningPageComponent implements OnInit, OnDestroy {
 
   goToNewWordPage(page: number): void {
     if (this.selectionLoading() || page < 1 || page > this.newWordPageCount() || page === this.selectionPage()) return;
+    this.audioRequest++;this.platform.stopAudio();this.audioPreparing.set(false);
     this.selectionPage.set(page);
     if (this.legacyNewWords) { this.showLegacyNewWordPage(); return; }
     this.loadSelection();

+ 2 - 2
scripts/deploy-admin-functions.mjs

@@ -2675,12 +2675,12 @@ async function newWordPage(input, current) {
   const status = String(input.learningStatus ?? '').trim();
   if (status && status !== 'pending' && status !== 'completed') fail(400, '生词学习状态无效');
   const values = [DEFAULT_COMPANY_ID, String(uid)];
-  const from = ' FROM "PracticeRecord" p JOIN "CommonModel" c ON c."company" = $1 AND CAST(c."modelId" AS text) = \'52\' AND CAST(c."generalId" AS text) = CAST(p."scid" AS text)';
+  const from = ' FROM "PracticeRecord" p JOIN "CommonModel" c ON c."company" = $1 AND c."modelId" = 52 AND CAST(c."modelId" AS text) = \'52\' AND c."generalId" = CASE WHEN CAST(p."scid" AS text) ~ \'^-?[0-9]+(?:\\.[0-9]+)?$\' THEN CAST(p."scid" AS numeric) END AND CAST(c."generalId" AS text) = CAST(p."scid" AS text)';
   const where = ' WHERE p."company" = $1 AND CAST(p."yhid" AS text) = $2 AND LOWER(CAST(p."jrscb" AS text)) IN (\'1\',\'true\')';
   const learned = 'CASE WHEN CAST(p."xxcs" AS text) ~ \'^[0-9]+(?:\\.[0-9]+)?$\' THEN CAST(p."xxcs" AS numeric) ELSE 0 END >= 2';
   const countRow = await Psql.one('SELECT COUNT(*) FILTER (WHERE NOT (' + learned + '))::int AS "pendingCount", COUNT(*) FILTER (WHERE ' + learned + ')::int AS "completedCount"' + from + where, values);
   const filter = status ? ' AND ' + (status === 'completed' ? learned : 'NOT (' + learned + ')') : '';
-  const rows = await Psql.query('SELECT p.*, pc."generalId" AS "__practiceGeneralId", c.*, row_to_json(v) AS "__addon"' + from + ' LEFT JOIN "VocabularyWord" v ON v."company" = $1 AND CAST(v."id" AS text) = CAST(c."itemId" AS text) LEFT JOIN "CommonModel" pc ON pc."company" = $1 AND CAST(pc."modelId" AS text) = \'53\' AND CAST(pc."itemId" AS text) = CAST(p."id" AS text)' + where + filter + ' ORDER BY p."updatedAt" DESC, p."id" DESC LIMIT $3 OFFSET $4', values.concat([paging.size, (paging.index - 1) * paging.size]));
+  const rows = await Psql.query('SELECT p.*, pc."generalId" AS "__practiceGeneralId", c.*, row_to_json(v) AS "__addon"' + from + ' LEFT JOIN "VocabularyWord" v ON v."company" = $1 AND v."id" = c."itemId" AND CAST(v."id" AS text) = CAST(c."itemId" AS text) LEFT JOIN "CommonModel" pc ON pc."company" = $1 AND pc."modelId" = 53 AND pc."itemId" = CAST(CAST(p."id" AS text) AS numeric) AND CAST(pc."modelId" AS text) = \'53\' AND CAST(pc."itemId" AS text) = CAST(p."id" AS text)' + where + filter + ' ORDER BY p."updatedAt" DESC, p."id" DESC LIMIT $3 OFFSET $4', values.concat([paging.size, (paging.index - 1) * paging.size]));
   const result = rows.map((source) => { const practice = { id: source.id, kcid: source.kcid, scid: source.scid, xxcs: source.xxcs, yhid: source.yhid, jrscb: source.jrscb }; const detail = normalizeWordRow(source); return { ...practice, GeneralID: number(source.__practiceGeneralId || source.id), Title: detail.Title || detail.title || '', detail: [detail] }; });
   const pendingCount = number(countRow.pendingCount), completedCount = number(countRow.completedCount);
   const total = status === 'pending' ? pendingCount : status === 'completed' ? completedCount : pendingCount + completedCount;

+ 24 - 7
scripts/tests/wordbook-gateway.test.mjs

@@ -12,9 +12,9 @@ async function setup(t) {
   const db = new PGlite();
   t.after(() => db.close());
   await db.exec(`
-    CREATE TABLE "PracticeRecord" (id int, company text, yhid text, scid text, kcid text, xxcs text, jrscb text, "updatedAt" timestamptz);
-    CREATE TABLE "CommonModel" (company text, "modelId" int, "generalId" int, "itemId" int, title text);
-    CREATE TABLE "VocabularyWord" (company text, id int, sy text);
+    CREATE TABLE "PracticeRecord" (id double precision, company text, yhid text, scid text, kcid text, xxcs text, jrscb text, "updatedAt" timestamptz);
+    CREATE TABLE "CommonModel" (company text, "modelId" numeric, "generalId" numeric, "itemId" numeric, title text);
+    CREATE TABLE "VocabularyWord" (company text, id numeric, sy text);
     INSERT INTO "CommonModel" VALUES
       ('7pIbDBJmKx',52,101,1,'apple'), ('7pIbDBJmKx',52,102,2,'banana'),
       ('7pIbDBJmKx',52,103,3,'orange'), ('7pIbDBJmKx',53,901,11,'practice'),
@@ -29,11 +29,11 @@ async function setup(t) {
       (16,'other','42','101','100','0','1','2026-10-14'),
       (17,'7pIbDBJmKx','42','999','100','0','1','2026-10-14');
   `);
-  let queries = 0;
+  let queries = 0;const calls=[];
   const context = vm.createContext({
     Psql: {
-      one: async (sql, values) => { queries++; return (await db.query(sql, values)).rows[0]; },
-      query: async (sql, values) => { queries++; return (await db.query(sql, values)).rows; },
+      one: async (sql, values) => { queries++;calls.push({sql,values}); return (await db.query(sql, values)).rows[0]; },
+      query: async (sql, values) => { queries++;calls.push({sql,values}); return (await db.query(sql, values)).rows; },
     },
   });
   // Execute the actual app scope without injecting helpers from the admin gateway.
@@ -42,7 +42,7 @@ async function setup(t) {
     if (uid !== 42) throw new Error('无权访问该学员');
     return uid;
   };
-  return { read: input => context.newWordPage({ uid: 42, ...input }, { get: () => 1 }), queryCount: () => queries };
+  return { read: input => context.newWordPage({ uid: 42, ...input }, { get: () => 1 }), queryCount: () => queries, db, calls };
 }
 
 test('生词本未学分页保留全量状态计数,并隔离其他学员和帐套', async t => {
@@ -79,3 +79,20 @@ test('生词本拒绝非法状态和越权访问,不执行查询', async t =>
   await assert.rejects(read({ uid: 99 }), /无权访问/);
   assert.equal(queryCount(), 0);
 });
+
+test('生词分页使用公司、模型和词条编号完整索引键,而非逐词扫描全部记录',async t=>{
+ const {read,db,calls}=await setup(t);
+ await db.exec('INSERT INTO "CommonModel" SELECT \'7pIbDBJmKx\',52,i,i,\'extra-word\' FROM generate_series(10000,20000) i;CREATE INDEX wordbook_word_lookup ON "CommonModel"(company,"modelId","generalId");CREATE INDEX wordbook_practice_lookup ON "PracticeRecord"(company,yhid);ANALYZE;SET enable_seqscan=off;SET enable_hashjoin=off;SET enable_mergejoin=off;');
+ await read({learningStatus:'pending'});
+ const {sql,values}=calls.find(call=>call.sql.startsWith('SELECT p.*'));
+ const result=await db.query('EXPLAIN (FORMAT JSON) '+sql,values),conditions=[];
+ const walk=node=>{if(node['Index Name']==='wordbook_word_lookup'&&node.Alias==='c')conditions.push(node['Index Cond']||'');for(const child of node.Plans||[])walk(child);};
+ walk(result.rows[0]['QUERY PLAN'][0].Plan);
+ assert.ok(conditions.some(condition=>condition.includes('modelId')&&condition.includes('generalId')),'词条查找必须使用索引中的模型和编号,避免随词库规模逐词扫描');
+});
+
+test('索引优化保留原文本编号匹配,不把带前导零或小数的历史键误配成单词',async t=>{
+ const {read,db}=await setup(t);
+ await db.exec(`INSERT INTO "PracticeRecord" VALUES (21,'7pIbDBJmKx','42','00101','100','0','1',now()),(22,'7pIbDBJmKx','42','101.0','100','0','1',now()),(23,'7pIbDBJmKx','42','invalid','100','0','1',now());`);
+ const result=await read({learningStatus:'pending'});assert.equal(result.page.itemCount,2);assert.deepEqual(result.rows.map(row=>row.Title),['apple','banana']);
+});