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

fix: aggregate lesson study rounds and restore complete review sources

彭峰 7 часов назад
Родитель
Сommit
9c8b8bb223
41 измененных файлов с 912 добавлено и 147 удалено
  1. 69 0
      docs/qa/lesson-study-batches-2026-10-13/README.md
  2. 3 0
      docs/qa/lesson-study-batches-2026-10-13/ai-live-check.txt
  3. 38 0
      docs/qa/lesson-study-batches-2026-10-13/backend-verification.json
  4. 22 0
      docs/qa/lesson-study-batches-2026-10-13/browser-check.cjs
  5. 15 0
      docs/qa/lesson-study-batches-2026-10-13/browser-result.json
  6. 4 0
      docs/qa/lesson-study-batches-2026-10-13/cleanup-verification.json
  7. BIN
      docs/qa/lesson-study-batches-2026-10-13/history-375.png
  8. 69 0
      docs/qa/lesson-study-batches-2026-10-13/online-smoke.txt
  9. 27 0
      docs/qa/lesson-study-batches-2026-10-13/recovery-api-verification.json
  10. BIN
      docs/qa/lesson-study-batches-2026-10-13/report-375.png
  11. BIN
      docs/qa/lesson-study-batches-2026-10-13/round-375.png
  12. BIN
      docs/qa/lesson-study-batches-2026-10-13/round-812.png
  13. BIN
      docs/qa/lesson-study-batches-2026-10-13/summary-375.png
  14. BIN
      docs/qa/lesson-study-batches-2026-10-13/summary-812.png
  15. 42 6
      projects/xiaoshu-mobile/src/app/core/home-learning-modules.integration.spec.ts
  16. 31 1
      projects/xiaoshu-mobile/src/app/features/appointment/appointment-page.component.html
  17. 6 0
      projects/xiaoshu-mobile/src/app/features/appointment/appointment-page.component.scss
  18. 28 0
      projects/xiaoshu-mobile/src/app/features/appointment/appointment-page.component.ts
  19. 1 0
      projects/xiaoshu-mobile/src/app/features/appointment/lesson-report.component.html
  20. 6 1
      projects/xiaoshu-mobile/src/app/features/appointment/lesson-report.component.ts
  21. 12 0
      projects/xiaoshu-mobile/src/app/features/appointment/lesson-study-sources.spec.ts
  22. 19 0
      projects/xiaoshu-mobile/src/app/features/appointment/lesson-study-sources.ts
  23. 7 1
      projects/xiaoshu-mobile/src/app/features/english-reading/english-reading.service.spec.ts
  24. 10 4
      projects/xiaoshu-mobile/src/app/features/english-reading/english-reading.service.ts
  25. 4 4
      projects/xiaoshu-mobile/src/app/features/english-reading/fmode-ai.service.ts
  26. 4 0
      projects/xiaoshu-mobile/src/app/features/english-reading/reading-cloud.service.ts
  27. 2 0
      projects/xiaoshu-mobile/src/app/features/english-reading/reading.models.ts
  28. 5 2
      projects/xiaoshu-mobile/src/app/features/learning/learning-page.component.html
  29. 4 0
      projects/xiaoshu-mobile/src/app/features/learning/learning-page.component.scss
  30. 79 95
      projects/xiaoshu-mobile/src/app/features/learning/learning-page.component.ts
  31. 32 0
      scripts/cloud/lesson-study.js
  32. 1 1
      scripts/cloud/mobile-teaching.js
  33. 10 23
      scripts/deploy-admin-functions.mjs
  34. 24 0
      scripts/migrate-lesson-study.mjs
  35. 31 4
      scripts/smoke-mobile-teaching-flow.mjs
  36. 107 0
      scripts/sql/lesson-study-batches.sql
  37. 10 1
      scripts/sql/lesson-workflow.sql
  38. 38 0
      scripts/sql/repair-confirmed-lesson-839154386019743.sql
  39. 8 4
      scripts/tests/lesson-course-selection.test.mjs
  40. 10 0
      scripts/tests/lesson-reading-source.test.mjs
  41. 134 0
      scripts/tests/lesson-study-batches.test.mjs

+ 69 - 0
docs/qa/lesson-study-batches-2026-10-13/README.md

@@ -0,0 +1,69 @@
+# 多词本课堂记录修复与真实数据验收(2026-10-13)
+
+已完成本地实现、后端发布和小黄指定课次恢复。前端未发布,由项目负责人发布学生/老师端和管理后台构建。
+
+## 根因与现在的行为
+
+原 `stu_record_update_v2` 以预约+开课记录定位并替换 `xxqs`。七/八/九年级是同一个开课下的三个单元,所以后两次保存覆盖了前面的成果。结束页只读取当前开课,且新课未生成复习日期。
+
+现在按课堂批次保存,确认掌握情况后在事务中合并成一条 `DailyStudyRecord`。同一词库 GeneralID 去重,重复轮次保留来源;新版本确认可以更新状态,旧版本及被较新轮次覆盖的迟到请求不能覆盖新状态。草稿不计入正式词数,存在草稿时不能结课。旧客户端采用保守合并,已有词不被删除。
+
+老师确认页仅显示英文,提供「保存并继续选词」「保存并结束课程」。每轮有服务器确认累计数,预约详情和结课反馈显示各词本/单元及最终累计数;学习历史详情和学习报告同时显示各单元去重后的来源拆分。刷新可恢复服务器草稿与本机勾选;保存失败不跳走、不清草稿。恢复草稿使用批次实际词本记录词汇进度。
+
+结课使用与批次保存相同的数据库锁,冻结课堂汇总,按北京时间结课日期的第 1、2、3、5、7、9、11、14、17、21 天生成 10 次复习,每次引用同一份完整词表。重复结课不重复扣课时或产生复习任务。无词课堂不产生空复习。
+
+课堂 AI 阅读在缓存查询和模型调用前,由服务端根据学员、记录 ID、版本读取完整词表。缓存包含来源版本和阅读配置;旧版本请求在模型调用前被拒绝。历史文章保留,不自动重生成;独立手动选词入口沿用原用途。
+
+## 已发布后端
+
+- 线上 `migration_status.version`:`2026-10-13-lesson-batches-v8`。
+- 4 个网关:`xiaoshu.app.gateway`、`xiaoshu.admin.gateway`、`xiaoshu.ops.gateway`、`xiaoshu.ops.gateway.v3`。
+- 新增 `LessonStudyBatch`、`LessonStudyCommand`,以及课堂汇总字段和唯一索引。
+- 3 个数据库函数:`xs_lesson_study_save`、`xs_lesson_study_rebuild`、`xs_lesson_complete`。
+- 线上源码与本地最终源码逐一相等核验,见 `backend-verification.json`。旧系统直连接口同步函数未修改。
+- 发布前备份在本机权限受限目录 `/Users/mac/.local/state/xiaoshu/cloud-releases/2026-10-13-lesson-batches-v8/`,包括原云函数、SQL 函数、Schema 和定向恢复前后数据。凭据及原始客户快照未提交 Git。
+- `scripts/migrate-lesson-study.mjs` 默认只输出计划;`--apply` 需要现有超级管理员会话、环境凭据和私有备份目录。迁移与同步/课时操作互斥,临时执行函数用完即删。
+
+## 指定历史课次恢复
+
+预约 `839154386019743`(钱老师 69/小黄 110),原学习记录 ID `666765721179035` 保留:
+
+| 来源 | 恢复词数 | 掌握状态 |
+| --- | ---: | --- |
+| 七年级 | 9 | 待老师补标 |
+| 八年级 | 5 | 待老师补标 |
+| 九年级 | 3 | 保留已掌握 |
+| 合计 | 17 | 已掌握 3,待确认 14 |
+
+按用户明确确认的词表恢复,排除 `how`、`quite a few`。生成 10 次复习:10 月 14、15、16、18、20、22、24、27、30 日及 11 月 3 日。
+
+恢复脚本重跑前后完全一致;余额、四类扣费流水、课时预留、预约、已有复习工资记录均不变。上课记录除词表及更新时间外字段不变,未重新结课。线上学习报告为一条 17 词记录,复习和 AI 来源均指向这条完整记录。见 `recovery-api-verification.json`。
+
+老师可在预约详情的恢复批次中点击「补充掌握情况」。该入口只补标已核实恢复词,不得改词、覆盖已确认状态或产生财务动作;新课仍须逐轮全部确认后才计入成果。
+
+## 验证结果
+
+| 验证 | 结果 |
+| --- | --- |
+| 服务端测试 | 180 通过;最终来源细化后的相关 13 项再次通过 |
+| 学生/老师端测试 | 214 通过 |
+| 管理后台测试 | 261 通过 |
+| 双端生产构建 | 通过;保留原有样式预算和 CommonJS 提示 |
+| 云函数语法/入口检查 | 11 个定义通过;119 个调用无缺失 action |
+| 原场景 | 同一开课的三个单元 9→14→17,唯一汇总 |
+| 跨词本 | 三个独立课程、重复词、重新标记、旧请求冲突测试通过 |
+| 浏览器 | 375/812 宽度无横向溢出;英文确认、失败保留、同键重试、累计 17 通过 |
+| 真实隔离链路 | 67 项通过:开课、保存、并发结课、报告、评价、修改反馈、课时和对账等 |
+| AI 外部调用 | 实际课堂 17 词来源成功生成文章;未保存文章或覆盖客户历史文章 |
+| 清理 | 隔离账号 0、测试/迁移临时函数 0 |
+
+真实 AI 首次检查已成功;首轮验收随后遇到旧脚本仍期望报告为「1 词」的断言,修正为 17 后完整链路重跑通过。重跑未再次调用收费生成服务。模型请求词表另由适配器测试检查 17 词全部进入请求体。相关证据见 `online-smoke.txt`、`ai-live-check.txt`、`browser-result.json` 和截图。
+
+浏览器为本机 Chrome 隔离数据验证,真实链路为隔离账号调用已发布后端;本次未声称微信真机验收或前端已上线。
+
+## 前端发布与复验
+
+1. 使用本次修复代码执行 `npm run build`,分别发布 `dist/xiaoshu-mobile`、`dist/xiaoshu-admin` 中对应站点产物,沿用既有发布配置。
+2. 老师刷新页面重新进入预约,分三轮确认学习后应看到累计 9、14、17;结课前核对最终 17。
+3. 学生学习历史和报告应只有一条课堂记录;10 次复习应各含完整词表;从记录进入 AI 阅读会使用汇总来源。
+4. 本次后端和定向历史修复已执行,无需发布前端时再次恢复历史数据。保留既有文章;用户主动生成新文章时才调用 AI。

+ 3 - 0
docs/qa/lesson-study-batches-2026-10-13/ai-live-check.txt

@@ -0,0 +1,3 @@
+PASS AI阅读服务器来源包含完整17词
+PASS 旧课堂词表版本不能触发AI生成
+PASS 完整17词课堂来源真实AI请求成功返回文章(不保存、不覆盖历史文章)

+ 38 - 0
docs/qa/lesson-study-batches-2026-10-13/backend-verification.json

@@ -0,0 +1,38 @@
+{
+  "functions": [
+    {
+      "name": "xiaoshu.admin.gateway",
+      "sha256": "bdb6bf4c35f66e6526a6d67d1c7439c95a04b5b2b9b16db14a1e74107c179fa9"
+    },
+    {
+      "name": "xiaoshu.ops.gateway",
+      "sha256": "a357c9f540d2b3cf97a9a991c825895616c0d95965b165f44d63d91c36375928"
+    },
+    {
+      "name": "xiaoshu.ops.gateway.v3",
+      "sha256": "a357c9f540d2b3cf97a9a991c825895616c0d95965b165f44d63d91c36375928"
+    },
+    {
+      "name": "xiaoshu.app.gateway",
+      "sha256": "a89a462fa679057d475434b8dbf429843ac492dac62c96826472973261214498"
+    }
+  ],
+  "routines": [
+    {
+      "name": "xs_lesson_complete",
+      "sha256": "51a4f3467aab8a7a05193ba192d31281654adabe89bc565d683e2d674d53aee5"
+    },
+    {
+      "name": "xs_lesson_study_rebuild",
+      "sha256": "e7ecc353e404026d90240c5c93056763d2e30654ef1fbd4b32852e47077ae2c8"
+    },
+    {
+      "name": "xs_lesson_study_save",
+      "sha256": "e8e447025f7652a0daf9c4c1be8b7037f40253b679773bdbb9148d8d66ca9d81"
+    }
+  ],
+  "directSyncUnchanged": true,
+  "status": {
+    "version": "2026-10-13-lesson-batches-v8"
+  }
+}

+ 22 - 0
docs/qa/lesson-study-batches-2026-10-13/browser-check.cjs

@@ -0,0 +1,22 @@
+const {chromium}=require(process.env.PLAYWRIGHT_MODULE||'playwright');const fs=require('fs');
+(async()=>{const browser=await chromium.launch({channel:'chrome',headless:true}),page=await browser.newPage();const errors=[];page.on('pageerror',e=>errors.push(e.message));
+await page.addInitScript(()=>localStorage.setItem('xiaoshu.angular.session',JSON.stringify({token:'r:fixture',user:{userId:30,groupId:3,honeyName:'测试老师'},student:{userId:20,groupId:1,honeyName:'测试学员'}})));
+const batches=[9,5,3].map((count,index)=>({batchId:'round-'+index,version:1,status:index<2?'confirmed':'draft',bindingId:'binding',courseName:'初中词库',words:Array.from({length:count},(_,i)=>({GeneralID:index*100+i+1,Title:'word'+index+'-'+i,nodeId:172+index,nodeName:['七年级','八年级','九年级'][index],check:index<2?1:0}))}));
+const order={GeneralID:901,szyh:20,pl:30,dszt:10,HoneyName:'测试学员',bindingId:'binding',kcid:161,allowedActions:['continue','complete'],updatedAt:new Date().toISOString()};let failSave=true,saves=0;const keys=[];
+await page.route('**/*',r=>['127.0.0.1','localhost'].includes(new URL(r.request().url()).hostname)?r.continue():r.abort());
+await page.route('**/cloud-functions/**',async r=>{const p=r.request().postDataJSON()?.params||{};let result=[];
+if(p.action==='e_order_detail')result=[order];
+if(p.action==='app_lesson_study')result={record:{learned:batches.filter(b=>b.status==='confirmed').reduce((n,b)=>n+b.words.length,0)},batches,pending:batches.filter(b=>b.status==='draft').length};
+if(p.action==='e_record_detail')result=batches.flatMap(b=>b.words);
+if(p.action==='app_lesson_report')result={order,feedback:{contentSummary:'三个单元的词汇学习',trainingSummary:'累计17词'},records:[{learned:17,ygg:17,con:'本节累计学习17个单词'}],antiForget:{total:10,completed:0},refreshedAt:new Date().toISOString()};
+if(p.action==='app_lesson_courses')result={items:[{bindingId:'binding',kcid:161,Title:'初中词库'}]};
+if(p.action==='app_live_versions')result={version:'fixture',scopeVersions:{},syncStatus:{state:'current'}};
+if(p.action==='stu_record_update_v2'){keys.push(p.requestKey);if(failSave){failSave=false;return r.fulfill({status:503,json:{retcode:-1,retmsg:'隔离断网测试'}});}const b=batches.find(b=>b.batchId===p.batchId);b.words=JSON.parse(p.addon).xxqs.map(w=>({...b.words.find(old=>String(old.GeneralID)===String(w.GeneralID)),...w}));b.status='confirmed';b.version++;saves++;result={GeneralID:9901,batchVersion:b.version,learned:17};}
+await r.fulfill({json:{retcode:0,result,...(p.action==='e_record_detail'?{addon:[order]}:{})}});});
+const out='docs/qa/lesson-study-batches-2026-10-13';
+for(const width of [375,812]){await page.setViewportSize({width,height:812});await page.goto('http://127.0.0.1:4200/pages/stu/pl_end?orderId=901&bindingId=binding&batchId=round-2');await page.getByRole('button',{name:'保存并继续选词'}).waitFor();if(await page.getByRole('button',{name:'全部掌握',exact:true}).count())await page.getByRole('button',{name:'全部掌握',exact:true}).click();await page.screenshot({path:out+'/round-'+width+'.png',fullPage:true});if(await page.evaluate(()=>document.documentElement.scrollWidth>innerWidth))throw Error('round overflow');}
+await page.getByRole('button',{name:'保存并继续选词'}).click();await page.getByText('请求暂未完成,请检查网络后重试;已填写内容会保留。',{exact:true}).waitFor();if(!page.url().includes('/pl_end'))throw Error('failed save left page');await page.getByRole('button',{name:'保存并继续选词'}).click();await page.getByRole('heading',{name:'本节学习成果 · 17 个词'}).waitFor();if(saves!==1||keys.length!==2||keys[0]!==keys[1])throw Error('retry mismatch');
+for(const width of [375,812]){await page.setViewportSize({width,height:812});await page.screenshot({path:out+'/summary-'+width+'.png',fullPage:true});if(await page.evaluate(()=>document.documentElement.scrollWidth>innerWidth))throw Error('summary overflow');}
+await page.setViewportSize({width:375,height:812});
+for(const [route,name] of [['/pages/his/his_words?id=9901','history'],['/pages/shop/baobiao?id=901','report']]){await page.goto('http://127.0.0.1:4200'+route);const sources=page.getByLabel('课堂词表来源');await sources.waitFor();const label=await sources.innerText();for(const part of ['七年级 9','八年级 5','九年级 3','17'])if(!label.includes(part))throw Error('missing source '+part);await page.screenshot({path:out+'/'+name+'-375.png',fullPage:true});if(await page.evaluate(()=>document.documentElement.scrollWidth>innerWidth))throw Error(name+' overflow');}
+if(errors.length)throw Error(errors.join('\n'));const diagnostics=await page.evaluate(()=>JSON.parse(sessionStorage.getItem('xiaoshu.safe-diagnostics.v1')||'[]'));if(diagnostics.some(d=>d.kind==='runtime'))throw Error('Angular error');fs.writeFileSync(out+'/browser-result.json',JSON.stringify({viewports:[375,812],confirmedWords:17,historyAndReportSources:[9,5,3],saves,retrySameKey:true,pageErrors:errors},null,2));await browser.close();console.log('Browser batch confirmation, offline retry, summary and responsive checks passed');})().catch(e=>{console.error(e);process.exit(1)});

+ 15 - 0
docs/qa/lesson-study-batches-2026-10-13/browser-result.json

@@ -0,0 +1,15 @@
+{
+  "viewports": [
+    375,
+    812
+  ],
+  "confirmedWords": 17,
+  "historyAndReportSources": [
+    9,
+    5,
+    3
+  ],
+  "saves": 1,
+  "retrySameKey": true,
+  "pageErrors": []
+}

+ 4 - 0
docs/qa/lesson-study-batches-2026-10-13/cleanup-verification.json

@@ -0,0 +1,4 @@
+{
+  "fixtureUsers": 0,
+  "fixtureHelpers": 0
+}

BIN
docs/qa/lesson-study-batches-2026-10-13/history-375.png


+ 69 - 0
docs/qa/lesson-study-batches-2026-10-13/online-smoke.txt

@@ -0,0 +1,69 @@
+PASS 创建学员并关联门店
+PASS 零余额开课资格被拒绝
+PASS 总部入账与双边划拨重试不重复记账
+PASS 四类课时门店划拨后学员余额一致
+PASS 六个新分类来自线上教学标准
+PASS primary_writing 学阶限制
+PASS middle_writing 学阶限制
+PASS high_writing 学阶限制
+PASS 完整目录存在可开内容
+PASS 重复开课请求幂等
+PASS 同词表跨分类分别开课
+PASS 零余额实际开课请求被拒绝
+PASS 同分类重复开课被拒绝
+PASS 临时密码禁止教学接口
+PASS 改密撤销原登录会话
+PASS 旧密码不能通过迁移覆盖新密码
+PASS 72位新密码登录成功并解除临时密码状态
+PASS 用户端分别展示两个开课分类
+PASS 同词表跨分类沿用共享词汇进度
+PASS 旧排课请求遇分类歧义明确拒绝
+PASS 教师端预约保留开课分类
+PASS 老师带读归属所选学生并记录老师
+PASS 学生读取老师带读文章
+PASS 详情返回 allowedActions、blockedReason、validToday 和 serverNow
+PASS 当天可提前开始并允许结课
+PASS 测试课程有可学习单元
+PASS 读取真实词库内容用于隔离课堂
+PASS 真实词库提供17个隔离学习词
+PASS 第1轮累计9个词
+PASS 第1轮重试幂等
+PASS 第2轮累计14个词
+PASS 第2轮重试幂等
+PASS 第3轮累计17个词
+PASS 第3轮重试幂等
+PASS 真实多连接保存与结课共享锁,不丢批次
+PASS 并发结课均返回已结课
+PASS 已成功批次在并发结课后重试仍返回原结果
+PASS 学生评价前已结课且可读报告
+PASS 新库报告使用已保存课堂成果
+PASS 整节课只显示一条17词学习记录
+PASS 整节课生成10次抗遗忘
+PASS AI阅读服务器来源包含完整17词
+PASS 旧课堂词表版本不能触发AI生成
+PASS 十次复习均指向完整课堂词表
+PASS 其他用户无法结课
+PASS 评价仅首次生效
+PASS 已结课老师获得修改权限和精确报告版本
+PASS 学生不能修改老师报告
+PASS 老师修改保存且重试幂等
+PASS 旧报告版本拒绝覆盖新内容
+PASS 学生报告回读更新后的反馈
+PASS 修改保留学习成果、学生评价和结课状态
+PASS 其他学生不能评价
+PASS 旧客户端不能回退结课状态
+PASS 后台对账显示唯一记录、报告、评价与消费预留
+PASS 跨设备更新版本随课堂推进变化
+PASS 教师完课与管理员重试只扣一次
+PASS 取消预约禁止开课
+PASS 充值与完课并发都成功
+PASS 充值与完课并发余额无覆盖
+PASS 服务器多连接争抢最后一节课仅一单成功
+PASS 退款和预留并发互斥
+PASS 门店学员四类余额与流水逐项对账
+PASS 全部课时业务单均有事务审计
+PASS 真实注销重复请求幂等
+PASS 后台处理状态在学生端回读
+PASS 退出后原token服务端已撤销
+ONLINE_SMOKE_PASSED
+FIXTURES_CLEANED

+ 27 - 0
docs/qa/lesson-study-batches-2026-10-13/recovery-api-verification.json

@@ -0,0 +1,27 @@
+{
+  "appointmentId": "839154386019743",
+  "sourceStudyId": "666765721179035",
+  "sourceVersion": "2",
+  "studyRows": 1,
+  "learned": 17,
+  "mastered": 3,
+  "masteryPending": 14,
+  "reviews": 10,
+  "reviewDates": [
+    "2026-10-14",
+    "2026-10-15",
+    "2026-10-16",
+    "2026-10-18",
+    "2026-10-20",
+    "2026-10-22",
+    "2026-10-24",
+    "2026-10-27",
+    "2026-10-30",
+    "2026-11-03"
+  ],
+  "reviewSourceConsistent": true,
+  "financialStateUnchanged": true,
+  "recoveryRerunUnchanged": true,
+  "oldRecordIdPreserved": true,
+  "aiWordCount": 17
+}

BIN
docs/qa/lesson-study-batches-2026-10-13/report-375.png


BIN
docs/qa/lesson-study-batches-2026-10-13/round-375.png


BIN
docs/qa/lesson-study-batches-2026-10-13/round-812.png


BIN
docs/qa/lesson-study-batches-2026-10-13/summary-375.png


BIN
docs/qa/lesson-study-batches-2026-10-13/summary-812.png


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

@@ -30,6 +30,8 @@ class LearningModulesApiService {
   completionResponse = new Subject<ApiEnvelope<unknown>>();
   lastAppointmentRecordParams: Record<string, unknown> = {};
   studyUpdates: Record<string, unknown>[] = [];
+  studyBatches: any[] = [];
+  lastStudyQuery: Record<string, unknown> = {};
   reviewPageResponse: Subject<ApiEnvelope<unknown>> | null = null;
   unitRows: Record<string, unknown>[] = [
     { NodeID: 811, NodeName: 'Unit 1', yx_word: 1, total: 20 },
@@ -42,6 +44,10 @@ class LearningModulesApiService {
       if (!String(params['myfield2']).includes('|pl=300')) return throwError(() => new Error('无权查看该学员的内容记录'));
       return this.result<T>([{ GeneralID: 9901 }]);
     }
+    if (action === 'app_lesson_study') {
+      this.lastStudyQuery = params;
+      return this.result<T>({record: {learned: this.studyBatches.filter(b => b.status === 'confirmed').reduce((n, b) => n + b.words.length, 0)}, batches: this.studyBatches, pending: this.studyBatches.filter(b => b.status === 'draft').length});
+    }
     if (action === 'e_record_detail') {
       return this.result<T>([{ GeneralID: 1001, NodeID: 811, Title: 'apple', sy: '苹果', check: 0 }]);
     }
@@ -141,7 +147,10 @@ class LearningModulesApiService {
   post<T>(action: string, params: Record<string, unknown> = {}): Observable<ApiEnvelope<T>> {
     if (action === 'stu_record_update_v2') {
       this.studyUpdates.push(params);
-      return this.result<T>({ GeneralID: 9901 });
+      const batchId=String(params['batchId']), words=JSON.parse(String(params['addon'])).xxqs;
+      const version=Number(params['expectedVersion']||0)+1;
+      this.studyBatches=this.studyBatches.filter(b=>b.batchId!==batchId).concat({batchId,words,status:params['saveMode']==='confirm'?'confirmed':'draft',version,bindingId:'binding-801'});
+      return this.result<T>({ GeneralID: 9901, batchVersion:version, learned:new Set(this.studyBatches.filter(b=>b.status==='confirmed').flatMap(b=>b.words.map((w:any)=>w.GeneralID))).size });
     }
     if (action === 'app_lesson_complete') {
       this.completionRequests.push(params);
@@ -1338,7 +1347,7 @@ describe('home learning modules', () => {
     const courseSelect = harness.routeNativeElement?.querySelector('.course-section select') as HTMLSelectElement;
     expect(courseSelect).not.toBeNull();
     expect(courseSelect.options.length).toBeGreaterThanOrEqual(2);
-    expect(harness.routeNativeElement?.querySelector('.course-section')?.textContent).toContain('七年级上册');
+    expect(courseSelect.closest('.course-section')?.textContent).toContain('七年级上册');
     expect(harness.routeNativeElement?.querySelectorAll('.unit-grid article').length).toBe(2);
     const unitButtons = harness.routeNativeElement?.querySelectorAll<HTMLButtonElement>('.unit-select');
     expect(unitButtons?.[0].textContent).toContain('已学 5%');
@@ -1489,6 +1498,33 @@ describe('home learning modules', () => {
     expect(harness.routeNativeElement?.querySelector('.pending-column')).toBeNull();
   });
 
+  it('confirms three rounds into 17 words and can continue selecting before ending the lesson', async () => {
+    user.set({userId:300,groupId:3}); session.groupId.set(3);session.student.set({userId:100,groupId:1});
+    const api=TestBed.inject(ApiService) as unknown as LearningModulesApiService;api.appointmentStatus=10;
+    api.studyBatches=[9,5,3].map((count,index)=>({batchId:'round-'+index,version:1,status:'draft',bindingId:'binding-801',words:Array.from({length:count},(_,i)=>({GeneralID:index*100+i+1,Title:'word'+index+'-'+i,check:0}))}));
+    const harness=await RouterTestingHarness.create();
+    for(let index=0;index<3;index++){
+      await harness.navigateByUrl('/pages/stu/pl_end?orderId=901&bindingId=binding-801&batchId=round-'+index);await harness.fixture.whenStable();harness.fixture.detectChanges();
+      const component=harness.fixture.debugElement.query(By.directive(LearningPageComponent)).componentInstance as LearningPageComponent;
+      expect(component.words().length).toBe([9,5,3][index]);component.markAllKnown();component.submitReview(index<2);await harness.fixture.whenStable();harness.fixture.detectChanges();
+      expect(TestBed.inject(Router).url).toBe('/pages/shop/get_order?id=901'+(index===2?'&feedback=1':''));
+      expect(harness.routeNativeElement?.textContent).toContain('本节学习成果 · '+[9,14,17][index]+' 个词');
+    }
+    expect(api.completionRequests.length).toBe(0);
+  });
+
+  it('keeps round choices on save failure and retries with the same command key', async () => {
+    user.set({userId:300,groupId:3});session.groupId.set(3);session.student.set({userId:100,groupId:1});
+    const api=TestBed.inject(ApiService) as unknown as LearningModulesApiService;api.appointmentStatus=10;
+    api.studyBatches=[{batchId:'retry-round',version:1,status:'draft',bindingId:'binding-801',courseId:'802',words:[{GeneralID:1001,Title:'apple',check:0}]}];
+    const post=api.post.bind(api);let fail=true;const spy=spyOn(api,'post').and.callFake((action,params)=>action==='stu_record_update_v2'&&fail?(fail=false,throwError(()=>new Error('断网'))):post(action,params));
+    const harness=await RouterTestingHarness.create();await harness.navigateByUrl('/pages/stu/pl_end?orderId=901&bindingId=binding-801&batchId=retry-round');await harness.fixture.whenStable();harness.fixture.detectChanges();
+    const component=harness.fixture.debugElement.query(By.directive(LearningPageComponent)).componentInstance as LearningPageComponent;component.markAllKnown();component.submitReview(true);await harness.fixture.whenStable();
+    expect(component.words().length).toBe(1);expect(component.reviewCount(1)).toBe(1);expect(TestBed.inject(Router).url).toContain('/pl_end');
+    component.submitReview(true);await harness.fixture.whenStable();const requests=spy.calls.allArgs().filter(([action])=>action==='stu_record_update_v2');expect(requests.length).toBe(2);expect(requests[0][1]?.['requestKey']).toBe(requests[1][1]?.['requestKey']);
+    expect(api.lastLearningWordUpdate['kcid']).toBe('802'); // Resume uses the batch book, not the scheduled book.
+  });
+
   it('finishes the last vocabulary group, loads the coach record and opens completion feedback', async () => {
     user.set({ userId: 300, userName: 'coach', honeyName: '张老师', groupId: 3 });
     session.groupId.set(3);
@@ -1530,7 +1566,7 @@ describe('home learning modules', () => {
     harness.fixture.detectChanges();
     harness.fixture.detectChanges();
     expect(router.url).toContain('/pages/stu/pl_end');
-    expect(api.lastAppointmentRecordParams['myfield2']).toBe('UserId=100|dsid=901|pl=300');
+    expect(api.lastStudyQuery['orderId']).toBe('901');
     expect(harness.routeNativeElement?.querySelector('.empty-state.error')).toBeNull();
     expect(learning().words()[0].word).toBe('apple');
     learning().markAllKnown();
@@ -1543,7 +1579,7 @@ describe('home learning modules', () => {
     expect(api.appointmentStatus).toBe(10);
     expect(api.completionRequests.length).toBe(0);
     expect(api.studyUpdates.length).toBe(2);
-    expect(JSON.parse(String(api.studyUpdates[1]['addon']))['ygg']).toBe(1);
+    expect(JSON.parse(String(api.studyUpdates[1]['addon'])).xxqs[0].check).toBe(1);
     expect(navigate.calls.all().every(call => call.args[1]?.replaceUrl === true)).toBeTrue();
   }, 20000);
 
@@ -1617,7 +1653,7 @@ describe('home learning modules', () => {
     session.student.set({ userId: 999, honeyName: '之前的学员', groupId: 1 });
     const api = TestBed.inject(ApiService) as unknown as LearningModulesApiService;
     const get = api.get.bind(api);
-    spyOn(api, 'get').and.callFake((action, params) => action === 'e_record_detail'
+    spyOn(api, 'get').and.callFake((action, params) => action === 'app_lesson_study'
       ? throwError(() => new Error('学习记录暂时无法加载')) : get(action, params));
     const harness = await RouterTestingHarness.create();
     await harness.navigateByUrl('/pages/stu/pl_end?id=901');
@@ -1625,7 +1661,7 @@ describe('home learning modules', () => {
     harness.fixture.detectChanges();
     harness.fixture.detectChanges();
     expect(session.student()?.userId).toBe(100);
-    expect(api.lastAppointmentRecordParams['myfield2']).toBe('UserId=100|dsid=901|pl=300');
+    expect(api.get).toHaveBeenCalledWith('app_lesson_study', {orderId:'901'});
     expect(harness.routeNativeElement?.querySelector('.empty-state.error')?.textContent).toContain('学习记录暂时无法加载');
     (harness.routeNativeElement?.querySelector('.empty-state.error .primary-button') as HTMLButtonElement).click();
     await harness.fixture.whenStable();

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

@@ -24,6 +24,25 @@
     </dl>
     </section>
 
+    @if (classStudy(); as study) {
+      <section class="course-section lesson-study-summary" aria-label="本节学习成果">
+        <h2>本节学习成果 · {{ study.record?.learned || 0 }} 个词</h2>
+        <p>已确认的单词会合并为一条课堂记录,结课后统一生成复习计划。</p>
+        @for (batch of study.batches; track batch.batchId) {
+          <details><summary>{{ batchSource(batch) }} · {{ batch.words.length }} 个词 · {{ batch.status === 'draft' ? '待确认' : '已保存' }}</summary>
+            <p>@for (word of batch.words; track word.GeneralID) { <span>{{ word.Title }}({{ word.nodeName || '学习单元' }}) </span> }</p>
+            @if (!isStudentView && needsMastery(batch) && can('editFeedback')) { <button type="button" class="secondary-button" (click)="resumeBatch(batch)">补充掌握情况</button> }
+            @if (!isStudentView && batch.status === 'draft' && can('complete')) {
+              <button type="button" class="secondary-button" (click)="resumeBatch(batch)">确认掌握情况</button>
+              <button type="button" class="secondary-button" [disabled]="submitting()" (click)="discardBatch(batch)">放弃未确认批次</button>
+            }
+          </details>
+        }
+        @if (study.pending) { <p role="alert">还有 {{ study.pending }} 个批次待确认,处理后才能结课。</p> }
+      </section>
+    }
+    @if (classStudyError()) { <p class="detail-warning" role="alert">学习成果加载失败:{{ classStudyError() }} <button type="button" (click)="loadClassStudy()">重试</button></p> }
+
     @if (!order()['cancelled'] && !isStudentView && status() <= 10) {
       <section class="course-section">
         <label><span>本次学习课本 / 词库</span>
@@ -70,6 +89,17 @@
             <button type="button" class="feedback-close" aria-label="关闭完课反馈" [disabled]="submitting()" (click)="closeCompletionFeedback()"><lucide-icon [img]="icons.X" [size]="20" /></button>
           </header>
           <p class="feedback-hint">{{ editingFeedback() ? '本次修改仅更新反馈内容,课时和学习记录保持不变。' : '日期、时间、课时扣减、生词和抗遗忘数据由系统自动记录' }}</p>
+          @if (!editingFeedback()) {
+            <section class="feedback-hint" aria-label="结课词表核对">
+              @if (classStudyLoading()) { <p>正在核对本节学习成果…</p> }
+              @else if (classStudyError()) { <p>{{ classStudyError() }}</p><button type="button" (click)="loadClassStudy()">重新核对</button> }
+              @else if (classStudy(); as study) {
+                <strong>本节累计 {{ study.record?.learned || 0 }} 个词(已去重)</strong>
+                @for (batch of study.batches; track batch.batchId) { <p>{{ batchSource(batch) }} · {{ batch.words.length }} 个词 · {{ batch.status === 'draft' ? '待确认' : '已保存' }}</p> }
+                @if (study.pending) { <p role="alert">还有 {{ study.pending }} 个批次待确认,请返回课程处理后再结课。</p> }
+              }
+            </section>
+          }
           <fieldset class="feedback-fields" [disabled]="submitting()">
             <label><span>本次学习的课本或强化内容 <b>*</b></span><textarea autofocus [(ngModel)]="contentSummary" maxlength="500" aria-required="true" placeholder="例:人教版三年级 Unit 3,重点词汇与句型"></textarea></label>
             <div class="feedback-grid">
@@ -90,7 +120,7 @@
           @if (feedbackError()) { <p class="feedback-error" role="alert">{{ feedbackError() }}</p> }
           <footer class="feedback-actions">
             <button type="button" class="secondary-button" [disabled]="submitting()" (click)="closeCompletionFeedback()">{{ editingFeedback() ? '取消修改' : '返回课程' }}</button>
-            <button type="button" class="primary-button" [disabled]="submitting()" (click)="finishCoachLesson()">{{ submitting() ? (editingFeedback() ? '正在保存…' : '正在提交…') : (editingFeedback() ? '保存修改' : '提交并结束课程') }}</button>
+            <button type="button" class="primary-button" [disabled]="submitting() || (!editingFeedback() && (classStudyLoading() || !!classStudyError() || !!classStudy()?.pending))" (click)="finishCoachLesson()">{{ submitting() ? (editingFeedback() ? '正在保存…' : '正在提交…') : (editingFeedback() ? '保存修改' : '提交并结束课程') }}</button>
           </footer>
         </section>
       </dialog>

+ 6 - 0
projects/xiaoshu-mobile/src/app/features/appointment/appointment-page.component.scss

@@ -98,3 +98,9 @@ button:active:not(:disabled) { opacity: .8; }
   .feedback-actions { bottom: -16px; }
   .feedback-fields input,.feedback-fields textarea { font-size:16px; }
 }
+
+.lesson-study-summary h2 { margin: 0 0 8px; font-size: 18px; }
+.lesson-study-summary p { color: var(--ink-soft); line-height: 1.7; overflow-wrap: anywhere; }
+.lesson-study-summary details { padding: 12px 0; border-top: 1px solid var(--line); }
+.lesson-study-summary summary { min-height: 32px; cursor: pointer; line-height: 1.6; }
+.lesson-study-summary button { min-height: 44px; margin: 6px 8px 0 0; }

+ 28 - 0
projects/xiaoshu-mobile/src/app/features/appointment/appointment-page.component.ts

@@ -44,6 +44,9 @@ export class AppointmentPageComponent implements OnInit, OnDestroy {
   readonly error = signal('');
   readonly detailWarning = signal('');
   readonly feedbackError = signal('');
+  readonly classStudy = signal<any>(null);
+  readonly classStudyError = signal('');
+  readonly classStudyLoading = signal(false);
   readonly editingFeedback = signal(false);
   readonly feedbackSaved = signal(false);
   private feedbackRevision = '';
@@ -124,6 +127,7 @@ export class AppointmentPageComponent implements OnInit, OnDestroy {
         if (!Array.isArray(detail['allowedActions'])) Object.assign(detail, this.legacyStartActions(detail));
         if (background) { const changed = this.order()['bindingId'] !== detail['bindingId']; this.order.set({...this.order(),...detail}); if (changed && !this.isStudentView) this.loadCourses(); return; }
         this.applyOrder({ ...fallback, ...detail });
+        this.loadClassStudy();
       },
       error: (error: ApiError) => {
         this.detailVerified.set(false);
@@ -139,6 +143,27 @@ export class AppointmentPageComponent implements OnInit, OnDestroy {
     });
   }
 
+  loadClassStudy(): void {
+    this.classStudyError.set('');
+    this.classStudyLoading.set(true);
+    this.api.get<any>('app_lesson_study', {orderId: this.orderId}).pipe(finalize(() => this.classStudyLoading.set(false))).subscribe({next: ({result}) => this.classStudy.set(result), error: (e: ApiError) => this.classStudyError.set(e.message)});
+  }
+
+  resumeBatch(batch: any): void {
+    void this.router.navigate(['/pages/stu/pl_end'], {queryParams: {orderId: this.orderId, bindingId: batch.bindingId || this.order()['bindingId'], batchId: batch.batchId, ...(batch.provenance === 'user-confirmed-recovery' ? {mastery:'1'} : {})}});
+  }
+
+  batchSource(batch: any): string { return [...new Set(batch.words.map((w: any) => w.nodeName).filter(Boolean))].join('、') || batch.courseName || '课堂词表'; }
+
+  needsMastery(batch: any): boolean { return batch.provenance === 'user-confirmed-recovery' && batch.words.some((w: any) => !Number(w.check)); }
+
+  discardBatch(batch: any): void {
+    this.submitting.set(true);
+    this.api.post('stu_record_update_v2', {orderId: this.orderId, uid: this.order()['szyh'], batchId: batch.batchId, saveMode: 'discard', expectedVersion: batch.version,
+      requestKey: `discard:${this.orderId}:${batch.batchId}:${batch.version}`, addon: JSON.stringify({bindingId: batch.bindingId || this.order()['bindingId'], xxqs: []})
+    }).pipe(finalize(() => this.submitting.set(false))).subscribe({next: () => this.loadClassStudy(),error: (e: ApiError) => this.classStudyError.set(e.message)});
+  }
+
   private legacyStartActions(detail: SourceItem): SourceItem {
     const result: SourceItem = { allowedActions: [], blockedReason: '' };
     if (!this.detailVerified() || this.isStudentView || detail['cancelled']) return result;
@@ -278,6 +303,7 @@ export class AppointmentPageComponent implements OnInit, OnDestroy {
     if(this.order()['cancelled']===true)return;
     if (this.isStudentView || !this.can('complete') || this.submitting()) return;
     this.editingFeedback.set(false);
+    this.loadClassStudy();
     this.feedbackError.set('');
     this.completionDialog()?.nativeElement.showModal();
   }
@@ -309,6 +335,8 @@ export class AppointmentPageComponent implements OnInit, OnDestroy {
   finishCoachLesson(): void {
     if (this.submitting() || !this.can(this.editingFeedback() ? 'editFeedback' : 'complete') || !this.completionDialog()?.nativeElement.open) return;
     this.feedbackError.set('');
+    if (!this.editingFeedback() && (this.classStudyLoading() || this.classStudyError() || !this.classStudy())) { this.feedbackError.set('请先加载本节学习成果,再提交结课'); return; }
+    if (!this.editingFeedback() && this.classStudy()?.pending) { this.feedbackError.set('还有未确认的学习批次,请返回课程先处理'); return; }
     if (!this.contentSummary.trim()) { this.feedbackError.set('请填写本次学习内容'); return; }
     if (!this.trainingSummary.trim()) { this.feedbackError.set('请填写进度说明'); return; }
     if (!this.noHomework && !this.homework.trim()) { this.feedbackError.set('请填写作业安排,或选择“无作业”'); return; }

+ 1 - 0
projects/xiaoshu-mobile/src/app/features/appointment/lesson-report.component.html

@@ -22,6 +22,7 @@
     </section>
     <section class="report-card">
       <header><h2><lucide-icon [img]="icons.BookOpen" [size]="19" aria-hidden="true" />学习记录</h2><span>{{ r.records.length }} 条记录</span></header>
+      @if (sources().length) { <p class="empty-copy" aria-label="课堂词表来源">@for (source of sources(); track $index) { {{ $index ? '+' : '' }}{{ source.label }} {{ source.count }} 词 } = {{ count('learned') }} 词</p> }
       <div class="study-records">
         @for (item of r.records; track $index) {
           <article><time>{{ dateLabel(item['dqrq']) }}</time><p>{{ item['con'] || '单词学习' }}</p><div><span>已学 <b>{{ item['learned'] || 0 }}</b> 词</span><span>已掌握 <b>{{ item['ygg'] || 0 }}</b> 词</span><span>待加强 <b>{{ item['djq'] || 0 }}</b> 词</span></div></article>

+ 6 - 1
projects/xiaoshu-mobile/src/app/features/appointment/lesson-report.component.ts

@@ -1,3 +1,4 @@
+import { lessonStudySources } from './lesson-study-sources';
 import { CommonModule } from '@angular/common';
 import { ChangeDetectionStrategy, Component, inject, OnInit, signal } from '@angular/core';
 import { ActivatedRoute, RouterLink } from '@angular/router';
@@ -21,6 +22,7 @@ interface Report {
 })
 export class LessonReportComponent implements OnInit {
   readonly report = signal<Report | null>(null);
+  readonly sources = signal<Array<{label: string; count: number}>>([]);
   readonly loading = signal(false);
   readonly error = signal('');
   readonly icons = {BookOpen, BrainCircuit, ClipboardList, RefreshCw, Star};
@@ -34,7 +36,10 @@ export class LessonReportComponent implements OnInit {
     if (!this.orderId) { this.error.set('缺少预约编号,请从课程详情进入'); return; }
     this.loading.set(true); this.error.set('');
     this.api.get<Report>('app_lesson_report', {id:this.orderId}).pipe(finalize(() => this.loading.set(false))).subscribe({
-      next: r => this.report.set(r.result), error: e => this.error.set(e.message),
+      next: r => {
+        this.report.set(r.result); this.sources.set([]);
+        this.api.get<any>('app_lesson_study', {orderId:this.orderId}).subscribe({next: ({result}) => this.sources.set(lessonStudySources(result)), error: () => this.sources.set([])});
+      }, error: e => this.error.set(e.message),
     });
   }
   count(field: 'learned' | 'ygg'): number {

+ 12 - 0
projects/xiaoshu-mobile/src/app/features/appointment/lesson-study-sources.spec.ts

@@ -0,0 +1,12 @@
+import {lessonStudySources} from './lesson-study-sources';
+describe('canonical lesson sources',()=>{
+ it('shows 9 + 5 + 3 and excludes drafts and repeated words',()=>{
+  const batches=[9,5,3].map((count,index)=>({status:'confirmed',words:Array.from({length:count},(_,i)=>({GeneralID:index*100+i,nodeId:index+1,nodeName:['七年级','八年级','九年级'][index]}))}));
+  const canonical=batches.flatMap(b=>b.words);
+  const actual=lessonStudySources({record:{words:canonical},batches:[...batches,batches[0],{status:'draft',words:[{GeneralID:999,nodeId:9,nodeName:'未确认'}]}]});
+  expect(actual).toEqual([{label:'七年级',count:9},{label:'八年级',count:5},{label:'九年级',count:3}]);
+ });
+ it('does not display stale rounds omitted by the canonical record',()=>{
+  expect(lessonStudySources({record:{words:[]},batches:[{status:'confirmed',words:[{GeneralID:1}]}]})).toEqual([]);
+ });
+});

+ 19 - 0
projects/xiaoshu-mobile/src/app/features/appointment/lesson-study-sources.ts

@@ -0,0 +1,19 @@
+/** Group the canonical word set, rather than adding potentially repeated rounds. */
+export function lessonStudySources(study: any): Array<{label: string; count: number}> {
+  const words = new Map<string, any>();
+  for (const batch of study?.batches || []) {
+    if (batch.status !== 'confirmed') continue;
+    for (const word of batch.words || []) words.set(String(word.GeneralID), {...word, sourceName: word.nodeName || batch.courseName || '课堂词表'});
+  }
+  const canonical = Array.isArray(study?.record?.words) ? study.record.words : [...words.values()];
+  const groups = new Map<string, {label: string; count: number}>();
+  const seen = new Set<string>();
+  for (const word of canonical) {
+    const id = String(word.GeneralID); if (seen.has(id)) continue; seen.add(id);
+    const source = words.get(id) || word;
+    const key = String(word.nodeId || source.nodeId || word.bindingId || source.bindingId || 'lesson');
+    const group = groups.get(key) || {label: source.sourceName || source.nodeName || '课堂词表', count: 0};
+    group.count++; groups.set(key, group);
+  }
+  return [...groups.values()];
+}

+ 7 - 1
projects/xiaoshu-mobile/src/app/features/english-reading/english-reading.service.spec.ts

@@ -4,8 +4,14 @@ import {FmodeAiService} from './fmode-ai.service';
 import {ReadingArticle,ReadingQuestion,ReadingRouteParams}from './reading.models';
 describe('reading article reuse',()=>{
  const params={sourceStudyId:'201',level:'初一',type:'记叙文',theme:'校园',wordList:['apple','river'],renew:false} as ReadingRouteParams;
- function setup(){const cloud={userPointer:{__type:'Pointer',className:'_User',objectId:'student'},query:jasmine.createSpy('query').and.resolveTo([]),create:jasmine.createSpy('create').and.callFake(async(_name:string,data:object)=>({...data,objectId:'new'}))};const s=new EnglishReadingService(cloud as unknown as ReadingCloudService,{} as FmodeAiService);spyOn(s as any,'formatPrompt').and.resolveTo('prompt');spyOn(s as any,'collectStream').and.resolveTo('A new story\napple river');return{s,cloud};}
+ function setup(){const cloud={studySource:jasmine.createSpy('studySource').and.resolveTo({sourceStudyId:'201',sourceVersion:'2',wordIds:['1','2'],wordList:['apple','river'],studentId:20}),userPointer:{__type:'Pointer',className:'_User',objectId:'student'},query:jasmine.createSpy('query').and.resolveTo([]),create:jasmine.createSpy('create').and.callFake(async(_name:string,data:object)=>({...data,objectId:'new'}))};const s=new EnglishReadingService(cloud as unknown as ReadingCloudService,{} as FmodeAiService);spyOn(s as any,'formatPrompt').and.resolveTo('prompt');spyOn(s as any,'collectStream').and.resolveTo('A new story\napple river');return{s,cloud};}
  it('queries the complete normalized word set, source study and reading configuration',async()=>{const{s,cloud}=setup();cloud.query.and.resolveTo([{objectId:'existing',content:'apple river'}]);const article=await s.createArticle(params,()=>{});expect(article.objectId).toBe('existing');const where=cloud.query.calls.first().args[1];expect(where['createOptions.params.wordListKey']).toBe('apple|river');expect(where['createOptions.params.sourceStudyId']).toBe('201');expect(where['createOptions.params.level']).toBe('初一');expect(where['createOptions.params.type']).toBe('记叙文');expect(where['createOptions.params.theme']).toBe('校园');expect(where['createOptions.params.wordList']).toBeUndefined();expect(cloud.create).not.toHaveBeenCalled();});
+ it('replaces a stale three-word route with all seventeen server words before cache lookup and generation',async()=>{
+  const {s,cloud}=setup(),wordList=Array.from({length:17},(_,i)=>'word'+i);cloud.studySource.and.resolveTo({sourceStudyId:'201',sourceVersion:'9',wordIds:wordList.map((_,i)=>String(i)),wordList,studentId:20});
+  const article=await s.createArticle({...params,wordList:['old1','old2','old3']},()=>{});
+  expect(article.createOptions.params.wordList).toEqual(wordList);expect(cloud.query.calls.first().args[1]['createOptions.params.sourceVersion']).toBe('9');
+  expect((s as any).collectStream).toHaveBeenCalledWith('prompt',jasmine.any(Function),jasmine.objectContaining({wordList,sourceVersion:'9'}));
+ });
  it('does not reuse an article that only overlaps part of the requested vocabulary',async()=>{const{s,cloud}=setup();cloud.query.and.callFake(async(_n:string,w:Record<string,unknown>)=>w['createOptions.params.wordList']?[{objectId:'old-apple-tree'}]:[]);const article=await s.createArticle(params,()=>{});expect(article.objectId).toBe('new');expect(cloud.create).toHaveBeenCalled();});
  it('regenerates on explicit request without querying a reusable article',async()=>{const{s,cloud}=setup();const article=await s.createArticle({...params,renew:true},()=>{});expect(article.objectId).toBe('new');expect(cloud.query).not.toHaveBeenCalled();});
  it('saves the learner name with each newly generated article',async()=>{const{s,cloud}=setup();const article=await s.createArticle({...params,name:' 林同学 ',renew:true},()=>{});expect(article.createOptions.params.studentName).toBe('林同学');expect(cloud.create).toHaveBeenCalledWith('SurveyItem',jasmine.objectContaining({createOptions:jasmine.objectContaining({params:jasmine.objectContaining({studentName:'林同学'})})}));});

+ 10 - 4
projects/xiaoshu-mobile/src/app/features/english-reading/english-reading.service.ts

@@ -64,9 +64,15 @@ export class EnglishReadingService {
   }
 
   async createArticle(params: ReadingRouteParams, onProgress: (progress: ReadingProgress) => void): Promise<ReadingArticle> {
+    if (params.sourceStudyId) {
+      const source = await this.cloud.studySource(params.sourceStudyId);
+      params = {...params, sourceStudyId: source.sourceStudyId, sourceVersion: source.sourceVersion, sourceWordIds: source.wordIds, wordList: source.wordList, studentId: source.studentId};
+    }
     const createParams = {
       studentName: String(params.name || '').trim(),
       sourceStudyId:params.sourceStudyId||'',
+      sourceVersion:params.sourceVersion||'',
+      sourceWordIds:params.sourceWordIds||[],
       wordListKey: [...new Set(params.wordList.map(w=>w.trim().toLowerCase()))].sort().join('|'),
       wordList: params.wordList,
       count: readingWordLimit(params.level, params.wordList.length),
@@ -85,7 +91,7 @@ export class EnglishReadingService {
         user: this.cloud.userPointer,
         isDeleted: { '$ne': true },
       };
-      if (params.sourceStudyId) where['createOptions.params.sourceStudyId'] = params.sourceStudyId;
+      if (params.sourceStudyId) { where['createOptions.params.sourceStudyId'] = params.sourceStudyId; where['createOptions.params.sourceVersion'] = params.sourceVersion; }
       const existing = await this.cloud.query<ReadingArticle>('SurveyItem', where, { order: '-createdAt', limit: 1 });
       if (existing[0]) {
         onProgress({ task: 'article', message: '已加载历史文章', content: existing[0].content });
@@ -96,7 +102,7 @@ export class EnglishReadingService {
     const prompt = await this.formatPrompt(ARTICLE_TEMPLATE, createParams);
     const content = await this.collectStream(prompt, (value) => {
       onProgress({ task: 'article', message: `正在撰写短文 ${value.length} 字符`, content: value });
-    });
+    }, params);
     const lines = content.split('\n');
     const title = (lines.shift() ?? 'Reading Exercise').replace(/<[^>]*>|[*_]{1,3}/g, '').trim();
     const body = lines.join('\n');
@@ -260,7 +266,7 @@ export class EnglishReadingService {
     return Mustache.render(template, values);
   }
 
-  private collectStream(prompt: string, onContent: (content: string) => void): Promise<string> {
+  private collectStream(prompt: string, onContent: (content: string) => void, source?: ReadingRouteParams): Promise<string> {
     return new Promise((resolve, reject) => {
       let timer: ReturnType<typeof setTimeout>;
       let subscription: Subscription | undefined;
@@ -272,7 +278,7 @@ export class EnglishReadingService {
         }, 120000);
       };
       resetTimer();
-      subscription = this.ai.stream(prompt).subscribe({
+      subscription = this.ai.stream(prompt, source?.sourceStudyId ? {sourceStudyId: source.sourceStudyId, sourceVersion: source.sourceVersion, studentId: source.studentId} : undefined).subscribe({
         next: (message) => {
           resetTimer();
           onContent(message.content);

+ 4 - 4
projects/xiaoshu-mobile/src/app/features/english-reading/fmode-ai.service.ts

@@ -25,9 +25,9 @@ export class FmodeAiService {
   private readonly api=inject(ApiService);
   constructor(private readonly http: HttpClient, private readonly sessions: SessionService) {}
 
-  stream(prompt: string): Observable<AiStreamMessage> {
+  stream(prompt: string, source?: {sourceStudyId: string; sourceVersion?: string; studentId?: number}): Observable<AiStreamMessage> {
     return new Observable((observer) => {
-      const subscription = this.request(prompt, false).subscribe({
+      const subscription = this.request(prompt, false, source).subscribe({
         next: (response) => {
           try {
             const content = this.unwrap(response);
@@ -58,10 +58,10 @@ export class FmodeAiService {
     throw new Error('题目生成失败');
   }
 
-  private request(prompt: string, jsonMode: boolean): Observable<CloudEnvelope<AiResult>> {
+  private request(prompt: string, jsonMode: boolean, source?: {sourceStudyId: string; sourceVersion?: string; studentId?: number}): Observable<CloudEnvelope<AiResult>> {
     const token = String(this.sessions.token() || '').trim();
     if (!token.startsWith('r:')) throw new Error('阅读学习需要登录,请退出后重新登录');
-    return this.api.callCloud<AiResult>('reading/ai',{prompt,jsonMode},token);
+    return this.api.callCloud<AiResult>('reading/ai',{prompt,jsonMode,...source},token);
   }
 
   private unwrap(response: CloudEnvelope<AiResult>): string {

+ 4 - 0
projects/xiaoshu-mobile/src/app/features/english-reading/reading-cloud.service.ts

@@ -74,6 +74,10 @@ export class ReadingCloudService {
     return { __type: 'Pointer', className, objectId };
   }
 
+  async studySource(sourceStudyId: string): Promise<{sourceStudyId: string; sourceVersion: string; wordIds: string[]; wordList: string[]; studentId: number}> {
+    return this.call('app_reading_source', {sourceStudyId});
+  }
+
   private async call<T>(action: string, params: Record<string, unknown> = {}): Promise<T> {
     const token = String(this.sessions.token() || '').trim();
     if (!token.startsWith('r:')) throw new Error('阅读学习账户需要新的登录会话,请退出后重新登录');

+ 2 - 0
projects/xiaoshu-mobile/src/app/features/english-reading/reading.models.ts

@@ -72,6 +72,8 @@ export interface ReadingLog extends ReadingRecord {
 export interface ReadingRouteParams {
   studentId?: number;
   sourceStudyId?: string;
+  sourceVersion?: string;
+  sourceWordIds?: string[];
   returnUrl?: string;
   name: string;
   phone: string;

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

@@ -66,6 +66,7 @@
         <div><strong id="readonly-word-title">单词列表</strong><span>第 {{ wordPreviewRangeStart() }}–{{ wordPreviewRangeEnd() }} 条 · 共 {{ wordPreviewTotal() }} 个单词</span></div>
         <em>{{ isHistoryWordView && canStartPointReading ? '历史记录' : '学生只读' }}</em>
       </header>
+      @if (isHistoryWordView && historySources().length) { <p class="readonly-word-note" aria-label="课堂词表来源">@for (source of historySources(); track $index) { {{ $index ? '+' : '' }}{{ source.label }} {{ source.count }} 词 } = {{ wordPreviewTotal() }} 词</p> }
       <div class="readonly-word-list" [class.is-updating]="wordPreviewLoading()" [attr.aria-busy]="wordPreviewLoading()" aria-live="polite">
         @for (word of words(); track word.id) {
           <article>
@@ -326,6 +327,7 @@
       }
     }
   } @else if (isReviewPage) {
+    @if (meta.path === 'pages/stu/pl_end') { <p class="notice">本轮 {{ words().length }} 个词 · 本节已确认 {{ lessonLearnedCount() }} 个词。标记掌握情况后保存。</p> }
     <div class="review-summary">
       <div><strong>{{ words().length }}</strong><span>学习词数</span></div>
       <div><strong>{{ reviewCount(1) }}</strong><span>已掌握</span></div>
@@ -342,9 +344,10 @@
         </article>
       }
     </div>
-    <div class="sticky-actions">
+    <div class="sticky-actions" [class.batch-actions]="meta.path === 'pages/stu/pl_end'">
       <button type="button" class="secondary-button" (click)="markAllKnown()">{{ reviewCount(1) === words().length ? '取消全选' : '全部掌握' }}</button>
-      <button type="button" class="primary-button" [disabled]="submitting()" (click)="submitReview()">{{ submitting() ? '正在保存…' : meta.path === 'pages/stu/pl_end' ? '结束课程' : '提交检测' }}</button>
+      @if (meta.path === 'pages/stu/pl_end') { <button type="button" class="secondary-button" [disabled]="submitting()" (click)="submitReview(true)">保存并继续选词</button> }
+      <button type="button" class="primary-button" [disabled]="submitting()" (click)="submitReview()">{{ submitting() ? '正在保存…' : meta.path === 'pages/stu/pl_end' ? '保存并结束课程' : '提交检测' }}</button>
     </div>
   } @else if (isCalendarPage) {
     <section class="memory-overview" aria-label="抗遗忘计划概览">

+ 4 - 0
projects/xiaoshu-mobile/src/app/features/learning/learning-page.component.scss

@@ -1371,3 +1371,7 @@ button {
     grid-column: 2;
   }
 }
+
+.batch-actions { grid-template-columns: repeat(2,minmax(0,1fr)); }
+.batch-actions > button:first-child { grid-column: 1 / -1; min-height: 36px; }
+.learning-page:has(.batch-actions) { padding-bottom: 135px; }

+ 79 - 95
projects/xiaoshu-mobile/src/app/features/learning/learning-page.component.ts

@@ -1,3 +1,4 @@
+import { lessonStudySources } from '../appointment/lesson-study-sources';
 import { DropdownDirective } from '../../shared/dropdown.directive';
 import { LearningDraft } from '../../core/learning-draft';
 import { ReviewPage, reviewCalendar } from '../../core/review-page';
@@ -26,7 +27,7 @@ import {
   Volume2,
   X,
 } from 'lucide-angular';
-import { catchError, finalize, forkJoin, map, Observable, of, Subscription, switchMap, throwError } from 'rxjs';
+import { catchError, finalize, forkJoin, map, Observable, of, Subscription, switchMap, tap, throwError } from 'rxjs';
 import { GAME_AUDIO } from '../../core/app.constants';
 import { ApiError, ApiService } from '../../core/api.service';
 import {
@@ -130,6 +131,8 @@ export class LearningPageComponent implements OnInit, OnDestroy {
   readonly currentIndex = signal(0);
   readonly revealed = signal(false);
   readonly completed = signal(false);
+  readonly lessonLearnedCount = signal(0);
+  readonly historySources = signal<Array<{label: string; count: number}>>([]);
   readonly correct = signal(0);
   readonly unknown = signal<Word[]>([]);
   readonly loading = signal(false);
@@ -846,6 +849,7 @@ export class LearningPageComponent implements OnInit, OnDestroy {
 
   markReview(word: Word, state: 1 | 2): void {
     this.reviewState.update((value) => ({ ...value, [word.id]: state }));
+    if (this.query('orderId')) this.storageSet('roundReview:' + this.storageGet('batchId'), this.reviewState());
   }
 
   markAllKnown(): void {
@@ -853,20 +857,21 @@ export class LearningPageComponent implements OnInit, OnDestroy {
     const next: Record<string, 0 | 1 | 2> = {};
     for (const word of this.words()) next[word.id] = allKnown ? 0 : 1;
     this.reviewState.set(next);
+    if (this.query('orderId')) this.storageSet('roundReview:' + this.storageGet('batchId'), next);
   }
 
   reviewCount(state: 1 | 2): number {
     return this.words().filter((word) => this.reviewValue(word) === state).length;
   }
 
-  submitReview(): void {
+  submitReview(continueLearning = false): void {
     if (this.submitting() || !this.words().length) return;
     if (this.words().some((word) => this.reviewValue(word) === 0)) {
       this.platform.toast('请为每个单词选择掌握情况', 'error');
       return;
     }
     if (this.meta.path === 'pages/stu/pl_end') {
-      this.submitPracticeEnd();
+      this.submitPracticeEnd(continueLearning);
       return;
     }
     this.saveLearningRecord();
@@ -1266,7 +1271,10 @@ export class LearningPageComponent implements OnInit, OnDestroy {
           this.words.set(words.slice(0, this.wordPreviewPageSize));
           this.wordPreviewTotal.set(words.length);
           const sourceAddon = this.findList(addon)[0];
-          if (sourceAddon) this.orderInfo.set(sourceAddon);
+          if (sourceAddon) {
+            this.orderInfo.set(sourceAddon);
+            if (sourceAddon['GeneralID']) this.api.get<any>('app_lesson_study', {orderId: String(sourceAddon['GeneralID'])}).subscribe({next: ({result}) => this.historySources.set(lessonStudySources(result)), error: () => this.historySources.set([])});
+          }
         },
         error: (error: ApiError) => this.error.set(error.message),
       });
@@ -1566,105 +1574,62 @@ export class LearningPageComponent implements OnInit, OnDestroy {
 
   private loadPracticeEnd(): void {
     const orderId = this.query('orderId') || this.query('id');
-    const coachId = this.session.user()?.userId;
-    if (!orderId || !coachId) {
-      this.loading.set(false);
-      this.error.set('缺少预约或老师信息,请返回课程重新进入');
-      return;
-    }
-    this.api.get<unknown>('e_order_detail', { id: orderId }).pipe(
-      switchMap(({ result }) => {
-        const order = this.findList(result)[0] ?? (result as SourceItem);
-        const studentId = Number(order?.['szyh'] ?? order?.['yhid'] ?? 0);
-        if (!studentId) return throwError(() => new ApiError('预约记录缺少学员信息,请返回课程重试'));
-        this.orderInfo.set(order);
-        if (this.session.student()?.userId !== studentId) {
-          this.session.setStudent({ userId: studentId, groupId: 1, honeyName: String(order['HoneyName'] ?? order['honeyName'] ?? '学员') });
-        }
-        // Restrict the read to this appointment, its student and the signed-in coach.
-        return this.api.get<unknown>('content_list', {
-          modelId: 56, psize: 1,
-          myfield2: `UserId=${studentId}|dsid=${orderId}|pl=${coachId}${this.query('bindingId') ? '|bindingId=' + this.query('bindingId') : ''}`,
-        });
-      }),
-      switchMap(({ result }) => {
-        const record = this.findList(result)[0] ?? {};
-        this.learnedRecord.set(record);
-        if (!record['GeneralID']) return throwError(() => new ApiError('未找到本次陪练学习记录,请返回课程重试'));
-        return this.api.get<unknown>('e_record_detail', { id: record['GeneralID'] });
-      }),
-      finalize(() => this.loading.set(false)),
-    ).subscribe({
-      next: ({ result }) => {
-        const words = this.findList(result).map((item) => this.toWord(item)).filter((word) => word.word);
+    this.api.get<unknown>('e_order_detail', {id: orderId}).pipe(switchMap(({result}) => {
+      const order = this.findList(result)[0] ?? (result as SourceItem);
+      const studentId = Number(order?.['szyh']);
+      if (!studentId) return throwError(() => new ApiError('预约记录缺少学员信息'));
+      this.orderInfo.set(order);
+      if (this.session.student()?.userId !== studentId) this.session.setStudent({userId: studentId, groupId: 1, honeyName: String(order['HoneyName'] || '学员')});
+      return this.api.get<any>('app_lesson_study', {orderId});
+    }), finalize(() => this.loading.set(false))).subscribe({
+      next: ({result}) => {
+        this.lessonLearnedCount.set(Number(result.record?.learned || 0));
+        const batch = result.batches.find((b: any) => b.batchId === (this.query('batchId') || this.storageGet('batchId')))
+          || result.batches.find((b: any) => b.status === 'draft' && (!this.query('bindingId') || b.bindingId === this.query('bindingId')));
+        if (!batch) { this.error.set('未找到待确认批次,请返回课程查看已保存成果'); return; }
+        this.storageSet('batchId', batch.batchId); this.storageSet('batchVersion', batch.version);
+        this.orderInfo.update(order => ({...order, learningCourseId: batch.courseId || this.query('pnid') || this.query('kcid') || order['kcid']}));
+        const words = batch.words.map((w: SourceItem) => this.toWord(w));
         this.words.set(words);
-        this.reviewState.set(Object.fromEntries(words.map((word) => [word.id, Number(word['check']) === 1 ? 1 : Number(word['check']) === 2 ? 2 : 0])));
-      },
-      error: (error: ApiError) => this.error.set(error.message),
+        const local = (this.storageGet('roundReview:' + batch.batchId) || {}) as Record<string, 0 | 1 | 2>;
+        this.reviewState.set(Object.fromEntries(words.map((w: Word) => [w.id, local[w.id] ?? Number(w['check']) ?? 0])));
+      }, error: (e: ApiError) => this.error.set(e.message),
     });
   }
 
-  private submitPracticeEnd(): void {
-    const user = this.session.user();
-    const student = this.session.student();
-    const owner = Number(user?.groupId) === 3 ? student : user;
-    const xxqs = this.words().map((word) => ({ GeneralID: word.wordId, Title: word.word, check: this.reviewValue(word) }));
-    const addon = {
-      bindingId: this.query('bindingId') || undefined,
-      learned: xxqs.length,
-      ygg: xxqs.filter((item) => item.check === 1).length,
-      djq: xxqs.filter((item) => item.check === 2).length,
-      xxqs: JSON.stringify(xxqs),
-    };
+  private submitPracticeEnd(continueLearning = false): void {
     this.submitting.set(true);
-    this.api.post('stu_record_update_v2', {
-      orderId: this.query('orderId') || this.query('id'),
-      uid: owner?.userId,
-      inputer: owner?.honeyName,
-      addon: JSON.stringify(addon),
-    }).pipe(finalize(() => this.submitting.set(false))).subscribe({
-      next: () => {
-        this.clearLearningStorage();
-        void this.router.navigate(['/pages/shop/get_order'], {
-          queryParams: { id: this.query('orderId') || this.query('id'), feedback: '1' },
-          replaceUrl: true,
-        });
-      },
-      error: (error: ApiError) => this.platform.toast(error.message, 'error'),
-    });
+    this.saveLessonBatch(this.query('mastery') ? 'mastery' : 'confirm', this.words()).pipe(
+      switchMap(() => this.query('mastery') ? of(null) : this.persistWordsAsLearned(this.words(), this.session.student())),
+      finalize(() => this.submitting.set(false)),
+    ).subscribe({next: () => {
+      this.clearLearningStorage();
+      this.platform.toast(`本轮已保存,本节累计 ${this.lessonLearnedCount()} 个词`, 'success');
+      void this.router.navigate(['/pages/shop/get_order'], {queryParams: {id: this.query('orderId') || this.query('id'), ...(continueLearning || this.query('mastery') ? {} : {feedback: '1'})}, replaceUrl: true});
+    }, error: (e: ApiError) => this.platform.toast(e.message, 'error')});
+  }
+
+  private saveLessonBatch(mode: 'draft' | 'confirm' | 'mastery', words: Word[]): Observable<unknown> {
+    const batchId = String(this.storageGet('batchId') || crypto.randomUUID());
+    this.storageSet('batchId', batchId);
+    const version = Number(this.storageGet('batchVersion') || 0);
+    const xxqs = words.map(w => ({GeneralID: w.wordId, Title: w.word, check: mode !== 'draft' ? this.reviewValue(w) : 0}));
+    return this.api.post<any>('stu_record_update_v2', {
+      orderId: this.query('orderId') || this.query('id'), uid: this.session.student()?.userId,
+      batchId, saveMode: mode, expectedVersion: version,
+      requestKey: this.draft().command('batch:' + batchId + ':' + version + ':' + mode + ':' + JSON.stringify(xxqs)),
+      addon: JSON.stringify({bindingId: this.query('bindingId'), xxqs}),
+    }).pipe(tap(({result}) => {
+      this.storageSet('batchVersion', result.batchVersion);
+      this.lessonLearnedCount.set(Number(result.learned || 0));
+    }));
   }
 
   private createPracticeLearningRecord(): void {
-    const allWords = this.readAllLearningWords();
-    const user = this.session.user();
-    const student = this.session.student();
-    const owner = Number(user?.groupId) === 3 ? student : user;
-    const xxqs = allWords.map((word) => ({ GeneralID: word.wordId, Title: word.word, check: 0 }));
-    const addon = {
-      bindingId: this.query('bindingId') || undefined,
-      con: `完成了${xxqs.length}个单词的学习@${this.dateTime()}`,
-      szmdid: this.orderInfo()['szmd'],
-      dqrq: this.compactDate(),
-      dsid: this.query('orderId'),
-      pl: Number(user?.groupId) === 3 ? user?.userId : 0,
-      UserID: owner?.userId,
-      learned: xxqs.length,
-      ygg: 0,
-      djq: 0,
-      xxqs: JSON.stringify(xxqs),
-    };
     this.submitting.set(true);
-    this.persistWordsAsLearned(allWords, owner).pipe(
-      switchMap(() => this.api.post('stu_record_update_v2', {
-        orderId: this.query('orderId'),
-        uid: owner?.userId,
-        inputer: user?.honeyName,
-        addon: JSON.stringify(addon),
-      })),
-      finalize(() => this.submitting.set(false)),
-    ).subscribe({
+    this.saveLessonBatch('draft', this.readAllLearningWords()).pipe(finalize(() => this.submitting.set(false))).subscribe({
       next: () => this.navigateStage('/pages/stu/pl_end'),
-      error: (error: ApiError) => this.platform.toast(error.message, 'error'),
+      error: (e: ApiError) => this.platform.toast(e.message, 'error'),
     });
   }
 
@@ -1988,7 +1953,25 @@ export class LearningPageComponent implements OnInit, OnDestroy {
   }
 
   private beginSelectedFlow(chosen: Word[], mode: '' | 'newword' | 'history_word'): void {
-    this.clearLearningStorage();
+    if (this.query('orderId') && !mode) {
+      this.submitting.set(true);
+      this.api.get<any>('app_lesson_study', {orderId: this.query('orderId')}).pipe(finalize(() => this.submitting.set(false))).subscribe({
+        next: ({result}) => {
+          const pending = result.batches.find((b: any) => b.status === 'draft' && (!this.query('bindingId') || b.bindingId === this.query('bindingId')));
+          if (pending) {
+            this.storageSet('batchId', pending.batchId); this.storageSet('batchVersion', pending.version);
+            this.platform.toast('还有待确认批次,请先确认掌握状态'); this.navigateStage('/pages/stu/pl_end'); return;
+          }
+          this.clearLearningStorage(); this.submitting.set(true);
+          this.saveLessonBatch('draft', chosen).pipe(finalize(() => this.submitting.set(false))).subscribe({next: () => this.beginSelectedStages(chosen, mode, true),error: (e: ApiError) => this.platform.toast(e.message, 'error')});
+        }, error: (e: ApiError) => this.platform.toast(e.message, 'error'),
+      }); return;
+    }
+    this.beginSelectedStages(chosen, mode);
+  }
+
+  private beginSelectedStages(chosen: Word[], mode: '' | 'newword' | 'history_word', preserveBatch = false): void {
+    if (!preserveBatch) this.clearLearningStorage();
     if (mode) this.storageSet('learn_mode', mode);
     const pages: Word[][] = [];
     for (let index = 0; index < chosen.length; index += 6) pages.push(chosen.slice(index, index + 6));
@@ -2063,6 +2046,7 @@ export class LearningPageComponent implements OnInit, OnDestroy {
     this.selectedNodes.set(new Set());
     this.words.set([]);
     this.historyPreviewWords = [];
+    this.historySources.set([]);
     this.wordPreviewPage.set(1);
     this.wordPreviewTotal.set(0);
     this.wordPreviewLoading.set(false);
@@ -2173,7 +2157,7 @@ export class LearningPageComponent implements OnInit, OnDestroy {
     return this.api.post('e_add_words', {
       uid: owner?.userId,
       wordsId: words.map((word) => word.wordId).join(','),
-      kcid: this.query('pnid') || this.query('kcid') || this.orderInfo()['kcid'],
+      kcid: (this.meta.path === 'pages/stu/pl_end' ? this.orderInfo()['learningCourseId'] : '') || this.query('pnid') || this.query('kcid') || this.orderInfo()['kcid'],
       save: 1,
       requestKey:this.draft().command('learn:'+words.map(w=>w.wordId).sort().join(',')),
     });

+ 32 - 0
scripts/cloud/lesson-study.js

@@ -0,0 +1,32 @@
+async function upsertStudyRecord(input,current) {
+ assertLessonTenant(current);
+ const uid=await authorizeRequestedUser(current,input.uid),gid=String(input.orderId||input.id||'');
+ const order=await orderDetailRow(gid,current);
+ if(!order||number(order.szyh)!==uid||ownLegacyId(current)!==number(order.pl))fail(403,'仅预约老师可以保存课堂学习');
+ const addon=parseObject(input.addon,'学习记录'),words=await validateStudyWords(addon.xxqs||[]);
+ // The transaction checks lesson state after command replay, so a confirmed
+ // request can be retried after concurrent completion without a second write.
+ const binding=await lessonStudyBinding(addon,current,uid,{...order,dszt:10},words);
+ const mode=String(input.saveMode||'legacy'),batchId=String(input.batchId||('legacy:'+binding.objectId));
+ const key=mobileCommandKey(input)||('legacy:'+randomObjectId());
+ try{return (await Psql.one('SELECT xs_lesson_study_save($1,$2,$3,$4,$5,$6::jsonb) result',[DEFAULT_COMPANY_ID,gid,current.id,ownLegacyId(current),key,JSON.stringify({mode,batchId,bindingId:binding.objectId,expectedVersion:input.expectedVersion??0,words})])).result;}
+ catch(e){fail(409,String(e.message||e));}
+}
+async function appLessonStudy(input,current) {
+ assertLessonTenant(current);
+ const gid=String(input.orderId||''),order=await orderDetailRow(gid,current);
+ if(!order)fail(404,'预约不存在');
+ const batches=await Psql.query('SELECT b.*,cb.kcid AS "courseId",n."nodeName" AS "courseName" FROM "LessonStudyBatch" b LEFT JOIN "CourseBinding" cb ON cb.company=b.company AND cb."objectId"=b."bindingId" LEFT JOIN "Node" n ON n.company=cb.company AND n."nodeId"::text=cb.kcid WHERE b.company=$1 AND b."orderId"=$2 AND b.status<>\'discarded\' ORDER BY b."updatedAt",b."batchId"',[DEFAULT_COMPANY_ID,gid]);
+ const record=await Psql.oneOrNone('SELECT d.*,c."generalId" AS "GeneralID" FROM "DailyStudyRecord" d JOIN "CommonModel" c ON c.company=d.company AND c."modelId"=56 AND c."itemId"=d.id WHERE d.company=$1 AND d.dsid::text=$2 AND d."recordScope"=\'lesson\'',[DEFAULT_COMPANY_ID,gid]);
+ const nodes=await Psql.query('SELECT "nodeId","nodeName" FROM "Node" WHERE company=$1 AND "nodeId"::text=ANY($2::text[])',[DEFAULT_COMPANY_ID,[...new Set(batches.flatMap(b=>parseArray(b.words).map(w=>String(w.nodeId||''))))]]);
+ const names=new Map(nodes.map(n=>[String(n.nodeId),n.nodeName]));
+ return {orderId:gid,record:record?{...record,words:parseArray(record.xxqs)}:null,batches:batches.map(b=>({...b,words:parseArray(b.words).map(w=>({...w,nodeName:names.get(String(w.nodeId))||''}))})),pending:batches.filter(b=>b.status==='draft').length};
+}
+async function appReadingSource(input,current) {
+ const detail=await recordDetailData({id:input.sourceStudyId,studentId:input.studentId},current);
+ if(!detail?.record)fail(404,'学习记录不存在');
+ const record=detail.record,order=record.dsid?await orderDetailRow(record.dsid,current):null;
+ if(record.recordScope==='lesson'&&number(order?.dszt)<30)fail(409,'请先完成课程,再使用完整课堂词表生成阅读');
+ const words=detail.words||[];
+ return {sourceStudyId:String(record.GeneralID||record.generalId),sourceVersion:String(record.studyVersion||record.updatedAt||''),wordIds:words.map(w=>String(w.GeneralID)),wordList:words.map(w=>w.Title),studentId:number(record.userId??record.UserID)};
+}

+ 1 - 1
scripts/cloud/mobile-teaching.js

@@ -15,7 +15,7 @@ async function appLessonReport(input,current) {
   const id=String(order.GeneralID||input.id||input.orderId);
   const [feedback,records,reviews]=await Promise.all([
     Psql.oneOrNone('SELECT * FROM "LessonCompletionReport" WHERE "company"=$1 AND "appointmentGeneralId"::text=$2 ORDER BY "submittedAt" DESC LIMIT 1',[DEFAULT_COMPANY_ID,id]),
-    Psql.query('SELECT c."generalId",d."con",d."dqrq",d."learned",d."ygg",d."djq" FROM "DailyStudyRecord" d LEFT JOIN "CommonModel" c ON c."company"=d."company" AND c."modelId"=56 AND c."itemId"=d."id" WHERE d."company"=$1 AND d."dsid"::text=$2 AND d."userId"=$3 ORDER BY d."dqrq" DESC',[DEFAULT_COMPANY_ID,id,number(order.szyh)]),
+    Psql.query('SELECT c."generalId",d."con",d."dqrq",d."learned",d."ygg",d."djq" FROM "DailyStudyRecord" d LEFT JOIN "CommonModel" c ON c."company"=d."company" AND c."modelId"=56 AND c."itemId"=d."id" WHERE d."company"=$1 AND d."dsid"::text=$2 AND d."userId"=$3 AND COALESCE(to_jsonb(d)->>\'recordScope\',\'\')<>\'lesson-alias\' ORDER BY d."dqrq" DESC',[DEFAULT_COMPANY_ID,id,number(order.szyh)]),
     Psql.one('SELECT COUNT(*)::int total,COUNT(*) FILTER(WHERE "fxzt"=\'1\')::int completed FROM "MemoryPracticeRecord" WHERE "company"=$1 AND "orderId"::text=$2 AND "yhid"=$3',[DEFAULT_COMPANY_ID,id,number(order.szyh)])
   ]);
   return {order:await lessonOrderCapabilities(order,current),feedback:feedback?safe(feedback):null,records,antiForget:reviews,...mobileSource()};

Разница между файлами не показана из-за своего большого размера
+ 10 - 23
scripts/deploy-admin-functions.mjs


+ 24 - 0
scripts/migrate-lesson-study.mjs

@@ -0,0 +1,24 @@
+#!/usr/bin/env node
+import {readFileSync,writeFileSync,mkdirSync} from 'node:fs';
+import {randomUUID} from 'node:crypto';
+const sql=readFileSync(new URL('./sql/lesson-study-batches.sql',import.meta.url),'utf8')+'\n'+readFileSync(new URL('./sql/lesson-workflow.sql',import.meta.url),'utf8').match(/CREATE OR REPLACE FUNCTION xs_lesson_complete\([\s\S]*?END \$\$;/)[0];
+if(!process.argv.includes('--apply')){console.log('Plan: install lesson study batches, command journal, canonical record fields and guarded completion; no historical backfill.');process.exit(0);}
+const master=process.env.XIAOSHU_MASTER_KEY,token=process.env.XIAOSHU_SYNC_OPERATOR_TOKEN||process.env.XIAOSHU_MIGRATION_OPERATOR_TOKEN,dir=process.env.XIAOSHU_LESSON_STUDY_BACKUP_DIR;
+if(!master||!token||!dir)throw Error('Require master key, existing operator session and private backup directory');
+mkdirSync(dir,{recursive:true,mode:0o700});
+const base=(process.env.XIAOSHU_PARSE_URL||'https://server.xiaoshu.pro/parse').replace(/\/$/,''),app=process.env.XIAOSHU_PARSE_APP_ID||'7pIbDBJmKx_main',co=process.env.XIAOSHU_COMPANY_ID||'7pIbDBJmKx';
+async function api(path,method='GET',body,session=false){const r=await fetch(base+path,{method,headers:{'Content-Type':'application/json','X-Parse-Application-Id':app,...(session?{'X-Parse-Session-Token':token}:{'X-Parse-Master-Key':master})},body:body?JSON.stringify(body):undefined,signal:AbortSignal.timeout(120000)});const j=await r.json();if(!r.ok||j.error)throw Error('Migration API failed '+r.status);return j;}
+const user=await api('/users/me','GET',undefined,true);if(user.company?.objectId!==co||user.isDisabled||user.isDeleted||user.adminRoleKey!=='super-admin')throw Error('Active scoped super-admin required');
+const names=[...sql.matchAll(/CREATE OR REPLACE FUNCTION (\w+)/g)].map(m=>m[1]);
+const schema=await api('/schemas/DailyStudyRecord');writeFileSync(dir+'/study-schema-before.json',JSON.stringify(schema),{mode:0o600});
+const fields={recordScope:{type:'String'},studyVersion:{type:'Number'},canonicalStudyId:{type:'String'}};
+const missing=Object.fromEntries(Object.entries(fields).filter(([k])=>!schema.fields[k]));
+const path='xiaoshu/system/lesson-study-migrate-'+randomUUID();let helper;
+try{
+ const code=`async function handler(request,response){if(request.user?.id!==${JSON.stringify(user.objectId)}||Date.now()>${Date.now()+600000})return response.status(403).json({success:false});try{const p=request.body.params||{};if(p.apply)await Psql.tx(async t=>{await t.none("SET LOCAL lock_timeout='15s';SET LOCAL statement_timeout='90s'");await t.one("SELECT pg_advisory_xact_lock(hashtext($1)),pg_advisory_xact_lock(hashtext($2))",['direct-sync:'+${JSON.stringify(co)},'xs-credit:'+${JSON.stringify(co)}]);const r=await t.one('SELECT count(*)::int n FROM "LegacyDirectRun" WHERE company=$1 AND status=$2',[${JSON.stringify(co)},'running']);if(r.n)throw Error('Direct sync is running');await t.none(${JSON.stringify(sql)});});return response.json({success:true,data:await Psql.query('SELECT proname,pg_get_functiondef(oid) definition FROM pg_proc WHERE proname=ANY($1::text[])',[${JSON.stringify(names)}])});}catch(e){return response.status(500).json({success:false,message:e.message});}}`;
+ new Function(code);helper=(await api('/classes/Function','POST',{name:path,path,code,type:'standalone',enabled:true,params:[],paramList:[],respType:'json'})).objectId;
+ async function call(apply){const r=await fetch(base.replace(/\/parse$/,'/api/functions/')+path,{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({token,params:{apply}}),signal:AbortSignal.timeout(120000)}),j=await r.json();if(!r.ok||!j.success)throw Error(j.message||'Migration failed');return j.data;}
+ const before=await call(false);writeFileSync(dir+'/study-routines-before.json',JSON.stringify(before,null,2),{mode:0o600});
+ if(Object.keys(missing).length)await api('/schemas/DailyStudyRecord','PUT',{fields:missing});
+ const after=await call(true);if(after.length!==names.length)throw Error('Routine count mismatch');writeFileSync(dir+'/study-routines-after.json',JSON.stringify(after,null,2),{mode:0o600});console.log('Installed '+after.length+' routines; historical lessons unchanged.');
+}finally{if(helper){await api('/classes/Function/'+helper,'DELETE');const r=await api('/classes/Function?'+new URLSearchParams({where:JSON.stringify({objectId:helper}),count:'1',limit:'0'}));if(r.count)throw Error('Migration helper cleanup failed');}}

+ 31 - 4
scripts/smoke-mobile-teaching-flow.mjs

@@ -57,16 +57,41 @@ try{
  expect(Array.isArray(classroom.allowedActions)&&typeof classroom.blockedReason==='string'&&typeof classroom.validToday==='boolean'&&Number.isFinite(Date.parse(classroom.serverNow)),'详情返回 allowedActions、blockedReason、validToday 和 serverNow');
  expect(classroom.allowedActions.includes('complete'),'当天可提前开始并允许结课');
  const unit=(Array.isArray(units[0].data)?units[0].data:[])[0];expect(!!unit?.NodeID,'测试课程有可学习单元');
- const vocabulary=(await call('xiaoshu/app/gateway',studentToken,{action:'content_list',modelId:52,nodeid:unit.NodeID,psize:1})).data;
+ const vocabulary=(await call('xiaoshu/app/gateway',studentToken,{action:'content_list',modelId:52,nodeid:unit.NodeID,psize:30})).data;
  const learnedWord=vocabulary[0];expect(Number(learnedWord?.GeneralID)>0,'读取真实词库内容用于隔离课堂');
- await call('xiaoshu/app/gateway',coach.sessionToken,{action:'stu_record_update_v2',orderId:generalId,uid:student.legacyUserId,requestKey:randomUUID(),addon:JSON.stringify({learned:1,ygg:1,djq:0,xxqs:JSON.stringify([{GeneralID:learnedWord.GeneralID,Title:learnedWord.Title,check:1}])})});
+ const lessonWords=vocabulary.slice(0,17);expect(lessonWords.length===17,'真实词库提供17个隔离学习词');
+ let studyId,lastStudyRequest;for(const [index,range] of [[0,[0,9]],[1,[9,14]],[2,[14,17]]]){
+  const batchId='smoke-round-'+index,words=lessonWords.slice(...range).map(w=>({GeneralID:w.GeneralID,Title:w.Title,check:1}));
+  const request={action:'stu_record_update_v2',orderId:generalId,uid:student.legacyUserId,batchId,saveMode:'confirm',expectedVersion:0,requestKey:randomUUID(),addon:JSON.stringify({bindingId:word.bindingIds[0],xxqs:words})};
+  lastStudyRequest=request;const saved=(await call('xiaoshu/app/gateway',coach.sessionToken,request)).data;studyId=saved.GeneralID;
+  expect(saved.learned===range[1],'第'+(index+1)+'轮累计'+range[1]+'个词');
+  const repeated=(await call('xiaoshu/app/gateway',coach.sessionToken,request)).data;expect(repeated.GeneralID===studyId&&repeated.learned===range[1],'第'+(index+1)+'轮重试幂等');
+ }
  const feedback={action:'app_lesson_complete',orderId:generalId,expectedUpdatedAt:classroom.updatedAt,requestKey:randomUUID(),feedback:{contentSummary:'隔离教学验收',reviewStatus:'完成',reviewSummary:'复习已完成',trainingStatus:'正常进行',trainingSummary:'已完成本单元',homework:'无作业',noHomework:true,notes:'隔离账号验收',coachComment:'隔离老师点评'}};
+ const pending={action:'stu_record_update_v2',orderId:generalId,uid:student.legacyUserId,batchId:'smoke-save-complete-race',saveMode:'draft',expectedVersion:0,requestKey:randomUUID(),addon:JSON.stringify({bindingId:word.bindingIds[0],xxqs:[{GeneralID:lessonWords[0].GeneralID,check:0}]})};
+ const pendingSaved=(await call('xiaoshu/app/gateway',coach.sessionToken,pending)).data;
+ const raceConfirm={...pending,saveMode:'confirm',expectedVersion:pendingSaved.batchVersion,requestKey:randomUUID(),addon:JSON.stringify({bindingId:word.bindingIds[0],xxqs:[{GeneralID:lessonWords[0].GeneralID,check:1}]})};
+ const saveCompleteRace=await Promise.all([call('xiaoshu/app/gateway',coach.sessionToken,raceConfirm,true),call('xiaoshu/app/gateway',coach.sessionToken,feedback,true)]);
+ expect(saveCompleteRace[0].status===200&&[200,409].includes(saveCompleteRace[1].status),'真实多连接保存与结课共享锁,不丢批次');
  const completed=await Promise.all([call('xiaoshu/app/gateway',coach.sessionToken,feedback),call('xiaoshu/app/gateway',coach.sessionToken,feedback)]);
  expect(completed.every(r=>r.data.status===30),'并发结课均返回已结课');
+ const lateRetry=(await call('xiaoshu/app/gateway',coach.sessionToken,lastStudyRequest)).data;expect(lateRetry.learned===17&&lateRetry.GeneralID===studyId,'已成功批次在并发结课后重试仍返回原结果');
  const detail=(await call('xiaoshu/app/gateway',studentToken,{action:'e_order_detail',id:generalId})).data;
  expect(Number(detail[0]?.dszt)===30&&detail[0]?.feedback?.contentSummary==='隔离教学验收'&&detail[0]?.allowedActions.includes('review'),'学生评价前已结课且可读报告');
  const lessonReport=(await call('xiaoshu/app/gateway',studentToken,{action:'app_lesson_report',id:generalId})).data;
- expect(lessonReport.feedback?.contentSummary==='隔离教学验收'&&Number(lessonReport.feedback?.vocabularyCount)===1,'新库报告使用已保存课堂成果');
+ expect(lessonReport.feedback?.contentSummary==='隔离教学验收'&&Number(lessonReport.feedback?.vocabularyCount)===17,'新库报告使用已保存课堂成果');
+ expect(lessonReport.records.length===1&&Number(lessonReport.records[0].learned)===17,'整节课只显示一条17词学习记录');
+ expect(lessonReport.antiForget.total===10,'整节课生成10次抗遗忘');
+ const source=(await call('xiaoshu/app/gateway',coach.sessionToken,{action:'app_reading_source',sourceStudyId:studyId,studentId:student.legacyUserId})).data;
+ expect(source.wordIds.length===17&&source.wordList.length===17,'AI阅读服务器来源包含完整17词');
+ const staleAi=await call('xiaoshu/app/gateway',coach.sessionToken,{action:'reading/ai',sourceStudyId:studyId,sourceVersion:'stale',studentId:student.legacyUserId,prompt:'Do not generate on stale version.'},true);expect(staleAi.status===409,'旧课堂词表版本不能触发AI生成');
+ if(process.env.XIAOSHU_SMOKE_AI==='1'){
+  const article=(await call('xiaoshu/app/gateway',coach.sessionToken,{action:'reading/ai',sourceStudyId:studyId,sourceVersion:source.sourceVersion,studentId:student.legacyUserId,prompt:'Write one short English story for a student. Include every classroom target word below. Return the story only. Target words: '+source.wordList.join(', ')})).data;
+  expect(typeof article.content==='string'&&article.content.length>50,'完整17词课堂来源真实AI请求成功返回文章(不保存、不覆盖历史文章)');
+ }
+
+ const reviewRows=await parse('/classes/MemoryPracticeRecord?'+new URLSearchParams({where:JSON.stringify({company,yhid:student.legacyUserId,orderId:Number(generalId)}),limit:'20'}));
+ expect(reviewRows.results.length===10&&reviewRows.results.every(r=>Number(r.xxjlid)===Number(studyId)),'十次复习均指向完整课堂词表');
  const feedbackForeign=await call('xiaoshu/app/gateway',empty.sessionToken,feedback,true);expect(feedbackForeign.status===403||feedbackForeign.status===409,'其他用户无法结课');
  const review={action:'app_lesson_review',orderId:generalId,score:5,comment:'隔离学生评价',requestKey:randomUUID()};
  const firstReview=(await call('xiaoshu/app/gateway',studentToken,review)).data;
@@ -81,7 +106,7 @@ try{
  const staleEdit=await call('xiaoshu/app/gateway',coach.sessionToken,{...amendment,requestKey:randomUUID()},true);expect(staleEdit.status===409,'旧报告版本拒绝覆盖新内容');
  const amendedReport=(await call('xiaoshu/app/gateway',studentToken,{action:'app_lesson_report',id:generalId})).data;
  expect(amendedReport.feedback.contentSummary==='隔离验收修改后的学习内容'&&amendedReport.feedback.coachComment==='修改后的点评','学生报告回读更新后的反馈');
- expect(Number(amendedReport.feedback.vocabularyCount)===1&&Number(amendedReport.order.studentReview.score)===5&&Number(amendedReport.order.dszt)===30,'修改保留学习成果、学生评价和结课状态');
+ expect(Number(amendedReport.feedback.vocabularyCount)===17&&Number(amendedReport.order.studentReview.score)===5&&Number(amendedReport.order.dszt)===30,'修改保留学习成果、学生评价和结课状态');
  const deniedReview=await call('xiaoshu/app/gateway',empty.sessionToken,review,true);expect(deniedReview.status===403||deniedReview.status===409,'其他学生不能评价');
  const oldWrite=await call('xiaoshu/app/gateway',coach.sessionToken,{action:'e_order_update_v2',status:11,content:JSON.stringify({GeneralID:generalId})},true);expect(oldWrite.status===409,'旧客户端不能回退结课状态');
  const trace=(await ops('ops/lesson-feedback/detail',{targetId:appointment})).data;
@@ -111,6 +136,8 @@ finally{let clean=true;if(state.helper)try{await call(state.helper.path,state.ad
 function helperCode(state){
 const ids=state.users.map(u=>u.legacyUserId),objects=state.users.map(u=>u.objectId),texts=ids.map(String),co=state.co,admin=state.admin.objectId;
 const statements=[
+['DELETE FROM \"LessonStudyBatch\" WHERE company=$1 AND \"studentId\"=ANY($2::numeric[])',[co,ids]],
+['DELETE FROM \"LessonStudyCommand\" WHERE company=$1 AND actor=ANY($2::text[])',[co,objects]],
 ['DELETE FROM \"AccountDeletionRequest\" WHERE company=$1 AND \"userId\"=ANY($2::text[])',[co,objects]],
 ['DELETE FROM "SurveyLog" WHERE "learnerLegacyId"=ANY($1::numeric[])',[ids]],
 ['DELETE FROM "SurveyItem" WHERE "learnerLegacyId"=ANY($1::numeric[])',[ids]],

+ 107 - 0
scripts/sql/lesson-study-batches.sql

@@ -0,0 +1,107 @@
+-- Install after lesson-workflow.sql. Additive: no historical lessons are rewritten.
+ALTER TABLE "DailyStudyRecord" ADD COLUMN IF NOT EXISTS "recordScope" text;
+ALTER TABLE "DailyStudyRecord" ADD COLUMN IF NOT EXISTS "studyVersion" integer DEFAULT 0;
+ALTER TABLE "DailyStudyRecord" ADD COLUMN IF NOT EXISTS "canonicalStudyId" text;
+CREATE UNIQUE INDEX IF NOT EXISTS xs_lesson_study_unique ON "DailyStudyRecord"(company,dsid,"userId") WHERE "recordScope"='lesson';
+CREATE TABLE IF NOT EXISTS "LessonStudyBatch" (
+ company text NOT NULL, "orderId" text NOT NULL, "batchId" text NOT NULL,
+ "studentId" numeric NOT NULL, "coachId" numeric NOT NULL, "bindingId" text,
+ words jsonb NOT NULL DEFAULT '[]', status text NOT NULL CHECK(status IN ('draft','confirmed','discarded')),
+ version integer NOT NULL DEFAULT 1, "updatedAt" timestamptz NOT NULL DEFAULT now(),
+ provenance text, PRIMARY KEY(company,"orderId","batchId")
+);
+CREATE TABLE IF NOT EXISTS "LessonStudyCommand" (
+ company text NOT NULL, actor text NOT NULL, "requestKey" text NOT NULL,
+ fingerprint jsonb NOT NULL, result jsonb NOT NULL, "createdAt" timestamptz DEFAULT now(),
+ PRIMARY KEY(company,actor,"requestKey")
+);
+REVOKE ALL ON "LessonStudyBatch","LessonStudyCommand" FROM PUBLIC;
+
+CREATE OR REPLACE FUNCTION xs_lesson_study_rebuild(co text,gid text) RETURNS jsonb LANGUAGE plpgsql AS $$
+DECLARE a "CourseAppointment"; d "DailyStudyRecord"; old record; w jsonb; cid numeric; rid numeric; oid text;
+BEGIN
+ SELECT a0.* INTO a FROM "CourseAppointment" a0 JOIN "CommonModel" c ON c.company=a0.company AND c."modelId"=54 AND c."itemId"=a0.id WHERE c.company=co AND c."generalId"::text=gid;
+ IF a."objectId" IS NULL THEN RAISE EXCEPTION '预约不存在'; END IF;
+ -- Adopt old per-book records once, retaining their IDs as read aliases.
+ FOR old IN SELECT d0.* FROM "DailyStudyRecord" d0 WHERE d0.company=co AND d0.dsid::text=gid AND d0."userId"::text=a.szyh::text AND d0."recordScope" IS NULL LOOP
+  INSERT INTO "LessonStudyBatch"(company,"orderId","batchId","studentId","coachId","bindingId",words,status,provenance)
+  VALUES(co,gid,'import:'||old."objectId",a.szyh::numeric,a.pl::numeric,old."bindingId",COALESCE(NULLIF(old.xxqs,'')::jsonb,'[]'),CASE WHEN EXISTS(SELECT 1 FROM jsonb_array_elements(COALESCE(NULLIF(old.xxqs,'')::jsonb,'[]')) v WHERE COALESCE(v->>'check','0') NOT IN ('1','2')) THEN 'draft' ELSE 'confirmed' END,'existing-record') ON CONFLICT DO NOTHING;
+ END LOOP;
+ SELECT * INTO d FROM "DailyStudyRecord" WHERE company=co AND dsid::text=gid AND "userId"::text=a.szyh::text AND "recordScope"='lesson';
+ IF d."objectId" IS NULL THEN
+  SELECT * INTO d FROM "DailyStudyRecord" WHERE company=co AND dsid::text=gid AND "userId"::text=a.szyh::text AND "recordScope" IS NULL ORDER BY "createdAt",id LIMIT 1;
+  IF d."objectId" IS NULL THEN
+   rid=nextval('xiaoshu_business_id');oid=xs_object_id();cid=nextval('xiaoshu_business_id');
+   INSERT INTO "DailyStudyRecord" SELECT * FROM jsonb_populate_record(NULL::"DailyStudyRecord",jsonb_build_object('objectId',oid,'company',co,'id',rid,'createdAt',now(),'updatedAt',now(),'sourceKey','cloud:lesson-study:'||gid,'userId',a.szyh::numeric,'pl',a.pl,'dsid',gid,'recordScope','lesson','studyVersion',0,'dqrq',to_char(now() AT TIME ZONE 'Asia/Shanghai','YYYYMMDD')));
+   PERFORM xs_insert('CommonModel',jsonb_build_object('objectId',xs_object_id(),'company',co,'generalId',cid,'itemId',rid,'modelId',56,'nodeId',291,'tableName','ZL_C_ss','title','课堂学习记录','status',99,'createdAt',now(),'updatedAt',now(),'sourceKey','cloud:lesson-study:'||gid,'teachingManaged',true));
+   SELECT * INTO d FROM "DailyStudyRecord" WHERE "objectId"=oid;
+  ELSE
+   UPDATE "DailyStudyRecord" SET "recordScope"='lesson',"bindingId"=NULL WHERE "objectId"=d."objectId";
+  END IF;
+ END IF;
+ SELECT "generalId" INTO cid FROM "CommonModel" WHERE company=co AND "modelId"=56 AND "itemId"=d.id LIMIT 1;
+ UPDATE "DailyStudyRecord" SET "recordScope"='lesson-alias',"canonicalStudyId"=cid::text WHERE company=co AND dsid::text=gid AND "userId"::text=a.szyh::text AND "objectId"<>d."objectId";
+ SELECT COALESCE(jsonb_agg(value ORDER BY value->>'GeneralID'),'[]') INTO w FROM (
+  SELECT DISTINCT ON (value->>'GeneralID') value FROM "LessonStudyBatch" b CROSS JOIN LATERAL jsonb_array_elements(b.words) value
+  WHERE b.company=co AND b."orderId"=gid AND b.status='confirmed'
+  ORDER BY value->>'GeneralID',b."updatedAt" DESC,b."batchId" DESC
+ ) merged;
+ UPDATE "DailyStudyRecord" SET xxqs=w::text,con='本节累计学习'||jsonb_array_length(w)||'个单词',learned=jsonb_array_length(w),ygg=(SELECT count(*) FROM jsonb_array_elements(w) v WHERE v->>'check'='1'),djq=(SELECT count(*) FROM jsonb_array_elements(w) v WHERE v->>'check'='2'),"studyVersion"=COALESCE("studyVersion",0)+1,"updatedAt"=now() WHERE "objectId"=d."objectId" RETURNING * INTO d;
+ UPDATE "CommonModel" SET hits=jsonb_array_length(w),"updatedAt"=now(),"teachingManaged"=true WHERE company=co AND "modelId"=56 AND "itemId"=d.id;
+ RETURN jsonb_build_object('GeneralID',cid,'recordObjectId',d."objectId",'version',d."studyVersion",'words',w,'learned',jsonb_array_length(w));
+END $$;
+
+CREATE OR REPLACE FUNCTION xs_lesson_study_save(co text,gid text,actor text,actor_uid numeric,request_key text,p jsonb) RETURNS jsonb LANGUAGE plpgsql AS $$
+DECLARE a "CourseAppointment"; b "LessonStudyBatch"; prior "LessonStudyCommand"; result jsonb; bid text=p->>'batchId'; mode text=p->>'mode'; w jsonb=p->'words'; fingerprint jsonb; valid_count integer;
+BEGIN
+ IF length(COALESCE(request_key,'')) NOT BETWEEN 10 AND 160 OR length(COALESCE(bid,'')) NOT BETWEEN 1 AND 160 OR mode IS NULL OR mode NOT IN ('draft','confirm','discard','legacy','mastery') OR jsonb_typeof(w) IS DISTINCT FROM 'array' OR jsonb_array_length(w)>500 THEN RAISE EXCEPTION '学习批次参数无效'; END IF;
+ PERFORM pg_advisory_xact_lock(hashtext('xs-credit:'||co));
+ SELECT a0.* INTO a FROM "CourseAppointment" a0 JOIN "CommonModel" c ON c.company=a0.company AND c."modelId"=54 AND c."itemId"=a0.id AND c.status=99 WHERE c.company=co AND c."generalId"::text=gid FOR UPDATE OF a0;
+ IF a."objectId" IS NULL OR a.pl::text<>actor_uid::text OR NOT EXISTS(SELECT 1 FROM "_User" WHERE "objectId"=actor AND company=co AND "legacyUserId"=actor_uid AND "legacyGroupId"=3 AND NOT COALESCE("isDisabled",false)) THEN RAISE EXCEPTION '仅预约老师可以保存课堂学习'; END IF;
+ fingerprint=jsonb_build_object('orderId',gid,'payload',p);
+ SELECT * INTO prior FROM "LessonStudyCommand" WHERE company=co AND "LessonStudyCommand".actor=xs_lesson_study_save.actor AND "requestKey"=request_key;
+ IF FOUND THEN IF prior.fingerprint<>fingerprint THEN RAISE EXCEPTION '请求编号已用于其他内容'; END IF; RETURN prior.result; END IF;
+ IF a.dszt<>'10' AND NOT (mode='mastery' AND a.dszt IN ('30','40')) THEN RAISE EXCEPTION '请在课程进行中保存学习记录'; END IF;
+ IF NOT EXISTS(SELECT 1 FROM "CourseBinding" b0 JOIN "CommonModel" c ON c.company=b0.company AND c."modelId"=58 AND c."itemId"=b0.id AND c.status=99 WHERE b0.company=co AND b0."objectId"=p->>'bindingId' AND b0.yhid::text=a.szyh::text AND b0."enrollmentStatus"='active') THEN RAISE EXCEPTION '所选课程尚未开通或已停用'; END IF;
+ WITH RECURSIVE nodes AS (SELECT n."nodeId" FROM "Node" n JOIN "CourseBinding" cb ON cb.company=n.company AND cb.kcid=n."nodeId"::text WHERE cb.company=co AND cb."objectId"=p->>'bindingId' AND COALESCE(n.zstatus,99)=99 UNION SELECT n."nodeId" FROM "Node" n JOIN nodes parent ON n."parentId"=parent."nodeId" WHERE n.company=co AND COALESCE(n.zstatus,99)=99)
+ SELECT count(DISTINCT c."generalId") INTO valid_count FROM "CommonModel" c JOIN nodes n ON n."nodeId"=c."nodeId" WHERE c.company=co AND c."modelId"=52 AND c.status=99 AND c."generalId"::text IN (SELECT value->>'GeneralID' FROM jsonb_array_elements(w));
+ IF valid_count<>(SELECT count(DISTINCT value->>'GeneralID') FROM jsonb_array_elements(w)) OR EXISTS(SELECT 1 FROM jsonb_array_elements(w) v WHERE NULLIF(v->>'GeneralID','') IS NULL) THEN RAISE EXCEPTION '学习单词与所选课程不匹配'; END IF;
+ SELECT * INTO b FROM "LessonStudyBatch" WHERE company=co AND "orderId"=gid AND "batchId"=bid;
+ IF b."batchId" IS NOT NULL AND (b."bindingId" IS DISTINCT FROM p->>'bindingId' OR (mode<>'legacy' AND b.version IS DISTINCT FROM (p->>'expectedVersion')::int)) THEN RAISE EXCEPTION '学习批次已更新,请刷新后重试'; END IF;
+ IF b."batchId" IS NULL AND COALESCE((p->>'expectedVersion')::int,0)<>0 THEN RAISE EXCEPTION '学习批次版本无效'; END IF;
+ -- A stale round cannot overwrite a newer confirmation from another round.
+ IF mode='confirm' AND b."batchId" IS NOT NULL AND EXISTS(
+  SELECT 1 FROM "LessonStudyBatch" newer CROSS JOIN LATERAL jsonb_array_elements(newer.words) other
+  JOIN jsonb_array_elements(w) incoming ON other->>'GeneralID'=incoming->>'GeneralID'
+  WHERE newer.company=co AND newer."orderId"=gid AND newer."batchId"<>bid AND newer.status='confirmed'
+   AND newer."updatedAt">b."updatedAt" AND other->>'check' IS DISTINCT FROM incoming->>'check'
+ ) THEN RAISE EXCEPTION '其他轮次已更新掌握情况,请刷新后重新学习确认'; END IF;
+ IF mode='mastery' THEN
+  IF b.provenance IS DISTINCT FROM 'user-confirmed-recovery' OR b.status IS DISTINCT FROM 'confirmed' THEN RAISE EXCEPTION '只能补充已核实恢复记录的掌握情况'; END IF;
+  IF (SELECT array_agg(value->>'GeneralID' ORDER BY value->>'GeneralID') FROM jsonb_array_elements(w)) IS DISTINCT FROM (SELECT array_agg(value->>'GeneralID' ORDER BY value->>'GeneralID') FROM jsonb_array_elements(b.words)) THEN RAISE EXCEPTION '补标不能更改课堂词表'; END IF;
+  IF EXISTS(SELECT 1 FROM jsonb_array_elements(b.words) old JOIN jsonb_array_elements(w) incoming ON old->>'GeneralID'=incoming->>'GeneralID' WHERE old->>'check' IN ('1','2') AND old->>'check' IS DISTINCT FROM incoming->>'check') THEN RAISE EXCEPTION '补标不能覆盖已确认掌握状态'; END IF;
+ END IF;
+ IF b.status='confirmed' AND mode IN ('draft','discard') THEN RAISE EXCEPTION '已确认批次不能退回草稿或丢弃'; END IF;
+ IF mode IN ('confirm','mastery') AND (jsonb_array_length(w)=0 OR EXISTS(SELECT 1 FROM jsonb_array_elements(w) v WHERE COALESCE(v->>'check','0') NOT IN ('1','2'))) THEN RAISE EXCEPTION '请为每个单词选择掌握情况'; END IF;
+ -- Legacy clients send snapshots without revisions. Keep existing words and refuse a
+ -- conflicting mastery overwrite; new clients use versioned batches for corrections.
+ IF mode='legacy' AND EXISTS(SELECT 1 FROM jsonb_array_elements(w) v WHERE v->>'check' IN ('1','2')) THEN
+  IF EXISTS(SELECT 1 FROM jsonb_array_elements(COALESCE(b.words,'[]')) old JOIN jsonb_array_elements(w) incoming ON old->>'GeneralID'=incoming->>'GeneralID' WHERE old->>'check' IN ('1','2') AND incoming->>'check' IN ('1','2') AND old->>'check'<>incoming->>'check') THEN RAISE EXCEPTION '请刷新新版页面后修改掌握状态'; END IF;
+  SELECT COALESCE(jsonb_agg(value),'[]') INTO w FROM (SELECT DISTINCT ON(value->>'GeneralID') value FROM jsonb_array_elements(COALESCE(b.words,'[]')||w) value WHERE value->>'check' IN ('1','2') ORDER BY value->>'GeneralID',(value->>'check') DESC) merged;
+ ELSIF mode='legacy' THEN
+  IF b.status='confirmed' THEN RAISE EXCEPTION '已保存成果不能由未确认词表覆盖,请刷新新版页面'; END IF;
+  mode='draft';
+ END IF;
+ -- Enrich provenance from authoritative vocabulary rows, not client labels.
+ SELECT COALESCE(jsonb_agg(v||jsonb_build_object('nodeId',c."nodeId",'Title',c.title,'bindingId',p->>'bindingId')),'[]') INTO w FROM jsonb_array_elements(w) v JOIN "CommonModel" c ON c.company=co AND c."modelId"=52 AND c."generalId"::text=v->>'GeneralID';
+ INSERT INTO "LessonStudyBatch"(company,"orderId","batchId","studentId","coachId","bindingId",words,status,version,"updatedAt")
+ VALUES(co,gid,bid,a.szyh::numeric,actor_uid,p->>'bindingId',w,CASE WHEN mode IN ('confirm','legacy','mastery') THEN 'confirmed' WHEN mode='discard' THEN 'discarded' ELSE 'draft' END,COALESCE(b.version,0)+1,clock_timestamp())
+ ON CONFLICT(company,"orderId","batchId") DO UPDATE SET words=EXCLUDED.words,status=EXCLUDED.status,version=EXCLUDED.version,"updatedAt"=EXCLUDED."updatedAt";
+ result=xs_lesson_study_rebuild(co,gid)||jsonb_build_object('batchId',bid,'batchVersion',COALESCE(b.version,0)+1);
+ IF mode='mastery' THEN
+  UPDATE "LessonRecord" SET kzsj=(result->'words')::text,"updatedAt"=now() WHERE company=co AND yyds::text=gid;
+ END IF;
+ INSERT INTO "LessonStudyCommand" VALUES(co,actor,request_key,fingerprint,result,now());
+ RETURN result;
+END $$;
+REVOKE ALL ON FUNCTION xs_lesson_study_rebuild(text,text),xs_lesson_study_save(text,text,text,numeric,text,jsonb) FROM PUBLIC;

+ 10 - 1
scripts/sql/lesson-workflow.sql

@@ -51,6 +51,15 @@ BEGIN
  IF a.dszt='30' AND report."objectId" IS NOT NULL THEN RETURN jsonb_build_object('GeneralID',gid,'status',30,'unchanged',true,'feedbackId',report."objectId");END IF;
  IF expected IS NULL OR a."updatedAt" IS DISTINCT FROM expected THEN RAISE EXCEPTION '预约已更新,请刷新详情后重试';END IF;
  IF a.dszt NOT IN ('10','11','20') THEN RAISE EXCEPTION '请先开始课程';END IF;
+ -- Versioned batches and completion serialize on the same xs-credit lock.
+ IF to_regclass('"LessonStudyBatch"') IS NOT NULL THEN
+  IF EXISTS(SELECT 1 FROM "LessonStudyBatch" WHERE company=co AND "orderId"=gid AND status='draft') THEN RAISE EXCEPTION '还有未确认的学习批次,请先确认掌握状态或放弃该批次'; END IF;
+  IF EXISTS(SELECT 1 FROM "LessonStudyBatch" WHERE company=co AND "orderId"=gid AND status='confirmed') OR EXISTS(SELECT 1 FROM "DailyStudyRecord" WHERE company=co AND dsid::text=gid AND "userId"::text=a.szyh::text) THEN
+   PERFORM xs_lesson_study_rebuild(co,gid);
+   IF EXISTS(SELECT 1 FROM "LessonStudyBatch" WHERE company=co AND "orderId"=gid AND status='draft') THEN RAISE EXCEPTION '历史学习批次尚未确认,请先确认掌握状态'; END IF;
+   UPDATE "DailyStudyRecord" SET fxrl=CASE WHEN COALESCE(learned,0)>0 THEN (SELECT string_agg(to_char((now() AT TIME ZONE 'Asia/Shanghai')::date+offset_days,'YYYYMMDD'),',' ORDER BY offset_days) FROM unnest(ARRAY[1,2,3,5,7,9,11,14,17,21]) offset_days) ELSE '' END WHERE company=co AND dsid::text=gid AND "recordScope"='lesson';
+  END IF;
+ END IF;
  SELECT count(*) INTO lesson_count FROM "LessonRecord" WHERE company=co AND yyds::text=gid;
  IF lesson_count>1 THEN RAISE EXCEPTION '历史凭据存在重复上课记录,请由后台核对';END IF;
  SELECT * INTO l FROM "LessonRecord" WHERE company=co AND yyds::text=gid LIMIT 1;
@@ -79,7 +88,7 @@ BEGIN
  FOR study IN SELECT d.*,sc."generalId" AS study_general_id,b.kcid AS study_course_id FROM "DailyStudyRecord" d
  JOIN "CommonModel" sc ON sc.company=d.company AND sc."modelId"=56 AND sc."itemId"=d.id AND sc.status=99
  LEFT JOIN "CourseBinding" b ON b.company=d.company AND b."objectId"=to_jsonb(d)->>'bindingId' AND b.yhid::text=a.szyh::text
- WHERE d.company=co AND d."userId"::text=a.szyh::text AND d.dsid::text=gid ORDER BY d."updatedAt" DESC,d.id DESC LOOP
+ WHERE d.company=co AND d."userId"::text=a.szyh::text AND d.dsid::text=gid AND COALESCE(to_jsonb(d)->>'recordScope','')<>'lesson-alias' ORDER BY d."updatedAt" DESC,d.id DESC LOOP
   IF NOT found_study THEN words='[]';found_study=true;END IF;
   study_words=COALESCE(NULLIF(study.xxqs,'')::jsonb,'[]');
   IF jsonb_typeof(study_words)<>'array' THEN RAISE EXCEPTION '课堂学习记录格式异常,请核对后重试';END IF;

+ 38 - 0
scripts/sql/repair-confirmed-lesson-839154386019743.sql

@@ -0,0 +1,38 @@
+-- User-confirmed recovery only: teacher 69, student 110, exactly 9+5+3 words.
+-- Caller must back up the affected rows and run this in a transaction.
+DO $$
+DECLARE co text='7pIbDBJmKx';gid text='839154386019743';a "CourseAppointment";l "LessonRecord";d "DailyStudyRecord";r jsonb;w jsonb;group_node numeric;review_day text;key text;rid numeric;cid numeric;
+ ids numeric[]=ARRAY[25163,25164,25165,25166,25167,25168,25169,25170,25171,26120,26121,26122,26123,26124,27143,27144,27145];
+BEGIN
+ PERFORM pg_advisory_xact_lock(hashtext('xs-credit:'||co));
+ SELECT a0.* INTO a FROM "CourseAppointment" a0 JOIN "CommonModel" c ON c.company=a0.company AND c."modelId"=54 AND c."itemId"=a0.id WHERE c.company=co AND c."generalId"::text=gid FOR UPDATE OF a0;
+ IF a.pl<>'69' OR a.szyh<>'110' OR a.dszt NOT IN ('30','40') OR a."objectId" IS NULL THEN RAISE EXCEPTION 'Recovery ownership/state mismatch';END IF;
+ SELECT * INTO l FROM "LessonRecord" WHERE company=co AND yyds::text=gid;
+ IF l."objectId" IS NULL THEN RAISE EXCEPTION 'Completed lesson missing';END IF;
+ IF EXISTS(SELECT 1 FROM "LessonStudyBatch" WHERE company=co AND "orderId"=gid AND provenance='user-confirmed-recovery') THEN
+  IF (SELECT learned FROM "DailyStudyRecord" WHERE company=co AND dsid::text=gid AND "recordScope"='lesson')<>17 OR (SELECT count(*) FROM "MemoryPracticeRecord" WHERE company=co AND "orderId"::text=gid)<>10 THEN RAISE EXCEPTION 'Existing repair needs manual review';END IF;
+  RETURN;
+ END IF;
+ IF (SELECT count(*) FROM "DailyStudyRecord" WHERE company=co AND dsid::text=gid)<>1 OR jsonb_array_length(COALESCE(NULLIF(l.kzsj,'')::jsonb,'[]'))<>3 OR EXISTS(SELECT 1 FROM jsonb_array_elements(l.kzsj::jsonb) v WHERE (v->>'GeneralID')::numeric<>ALL(ARRAY[27143,27144,27145])) THEN RAISE EXCEPTION 'Source lesson changed since audit';END IF;
+ IF EXISTS(SELECT 1 FROM "MemoryPracticeRecord" WHERE company=co AND "orderId"::text=gid) THEN RAISE EXCEPTION 'Existing reviews must be reconciled first';END IF;
+ IF (SELECT count(*) FROM "CommonModel" WHERE company=co AND "modelId"=52 AND "generalId"=ANY(ids) AND "nodeId"=ANY(ARRAY[172,173,174]))<>17 THEN RAISE EXCEPTION 'Recovery words mismatch';END IF;
+ -- First adopt the original three-word record, then replace its imported batch with
+ -- the specifically authorized recovery batches, preserving the original record ID.
+ PERFORM xs_lesson_study_rebuild(co,gid);
+ UPDATE "LessonStudyBatch" SET status='discarded' WHERE company=co AND "orderId"=gid;
+ FOREACH group_node IN ARRAY ARRAY[172,173,174] LOOP
+  SELECT jsonb_agg(jsonb_build_object('GeneralID',c."generalId",'Title',c.title,'nodeId',c."nodeId",'bindingId','nq2u6S3Lc5','check',CASE WHEN group_node=174 THEN 1 ELSE 0 END) ORDER BY c."generalId") INTO w FROM "CommonModel" c WHERE c.company=co AND c."modelId"=52 AND c."generalId"=ANY(ids) AND c."nodeId"=group_node;
+  INSERT INTO "LessonStudyBatch"(company,"orderId","batchId","studentId","coachId","bindingId",words,status,provenance) VALUES(co,gid,'recovery:'||group_node,110,69,'nq2u6S3Lc5',w,'confirmed','user-confirmed-recovery');
+ END LOOP;
+ r=xs_lesson_study_rebuild(co,gid);cid=(r->>'GeneralID')::numeric;
+ SELECT * INTO d FROM "DailyStudyRecord" WHERE "objectId"=r->>'recordObjectId';
+ UPDATE "DailyStudyRecord" SET fxrl=(SELECT string_agg(to_char((COALESCE(NULLIF(a.jssj,'')::timestamptz,l."createdAt") AT TIME ZONE 'Asia/Shanghai')::date+offset_days,'YYYYMMDD'),',' ORDER BY offset_days) FROM unnest(ARRAY[1,2,3,5,7,9,11,14,17,21]) offset_days) WHERE "objectId"=d."objectId" RETURNING * INTO d;
+ FOR review_day IN SELECT unnest(string_to_array(d.fxrl,',')) LOOP
+  key='cloud:e_order_update:'||gid||':review:'||review_day;rid=nextval('xiaoshu_business_id');
+  INSERT INTO "MemoryPracticeRecord" SELECT * FROM jsonb_populate_record(NULL::"MemoryPracticeRecord",jsonb_build_object('objectId',xs_object_id(),'company',co,'id',rid,'createdAt',now(),'updatedAt',now(),'sourceKey',key,'yhid',110,'plid',COALESCE(NULLIF(a.fxpl,''),a.pl),'orderId',gid,'xxjlid',cid,'kcid',161,'fxzt','0','kywrq',to_char(to_date(review_day,'YYYYMMDD'),'YYYY-MM-DD'),'kywsj',to_char(to_date(review_day,'YYYYMMDD'),'YYYY-MM-DD')||' '||COALESCE(a.sdsd,'00:00')));
+  PERFORM xs_insert('CommonModel',jsonb_build_object('objectId',xs_object_id(),'company',co,'createdAt',now(),'updatedAt',now(),'sourceKey',key,'generalId',nextval('xiaoshu_business_id'),'itemId',rid,'modelId',60,'nodeId',388,'tableName','ZL_C_gywjl','title','课堂抗遗忘@'||review_day,'status',99,'teachingManaged',true));
+ END LOOP;
+ UPDATE "LessonRecord" SET kzsj=(r->'words')::text,"updatedAt"=now() WHERE "objectId"=l."objectId";
+ UPDATE "LessonCompletionReport" SET "vocabularyCount"=17,"antiForgetSummary"=jsonb_build_object('total',10,'completed',0,'pending',10),"updatedAt"=now() WHERE company=co AND "appointmentGeneralId"::text=gid;
+ INSERT INTO "LessonStudyCommand" VALUES(co,'authorized-recovery','recovery-20261013-'||gid,jsonb_build_object('reason','用户确认9+5+3个已学词;前14个掌握状态待老师补标,排除how及quite a few','wordIds',to_jsonb(ids)),r,now());
+END $$;

+ 8 - 4
scripts/tests/lesson-course-selection.test.mjs

@@ -23,18 +23,22 @@ test('教师按预约所属学员获取所有有效开课,包括不同课程
 test('开始时验证实际选择的课程但保留排课课程、工资和扣减快照;旧版本和停用课程拒绝',async()=>{await assert.rejects(c.startLessonWithContent(teacher,'appointment','b2','2000-01-01'),/已更新/);await assert.rejects(c.startLessonWithContent(teacher,'appointment','pending',version));const before=await one('SELECT * FROM "CourseAppointment"');assert.equal((await c.startLessonWithContent(teacher,'appointment','b2',version)).bindingId,'b2');const after=await one('SELECT * FROM "CourseAppointment"');assert.equal(after.dszt,'10');for(const k of ['kcid','yykcid','bindingId','teacherPaySnapshot','creditCostsSnapshot'])assert.deepEqual(after[k],before[k]);});
 test('学习记录接受本学员所选开课而非强制排课开课;拒绝他人开课和非所选词库的单词',async()=>{const order={szyh:20,pl:30,dszt:10,bindingId:'b1'};assert.equal((await c.lessonStudyBinding({bindingId:'b2'},teacher,20,order,[{GeneralID:1002}])).objectId,'b2');await assert.rejects(c.lessonStudyBinding({bindingId:'foreign'},teacher,20,order),/未开通/);await assert.rejects(c.lessonStudyBinding({bindingId:'b2'},teacher,20,order,[{GeneralID:1001}]),/不匹配/);await assert.rejects(c.lessonStudyBinding({bindingId:'b2'},teacher,20,{...order,dszt:30}),/进行中/);});
 
-test('实际学习写入事务按所选开课隔离记录,重试和更新保留业务ID且不覆盖另一词库',async()=>{
+test('实际学习写入事务跨开课累计为一条记录,重试保持业务ID和两词库成果',async()=>{
  await db.exec(`CREATE TABLE "DailyStudyRecord"("objectId" text primary key,company text,id numeric,"sourceKey" text,"createdAt" timestamptz,"updatedAt" timestamptz,"userId" numeric,dsid text,pl text,"bindingId" text,xxqs text,learned numeric,ygg numeric,djq numeric,con text,dqrq text,szmdid text,fxrl text);
  ALTER TABLE "CommonModel" ADD "createdAt" timestamptz,ADD "sourceKey" text,ADD "tableName" text,ADD inputer text,ADD hits numeric;`);
  await db.exec(read('../sql/mobile-teaching.sql'));
+ const enrollment=read('../sql/member-enrollment.sql');for(const name of ['xs_object_id','xs_insert'])await db.exec(enrollment.match(new RegExp('CREATE OR REPLACE FUNCTION '+name+'\\([\\s\\S]*?\\$\\$;'))[0]);
+ await db.exec('CREATE SEQUENCE xiaoshu_business_id START 9000');
+ await db.exec(read('../sql/lesson-study-batches.sql'));
+ vm.runInContext(read('../cloud/lesson-study.js'),c);
  class Obj{constructor(name,data={}){this.className=name;this.data=data;this.id=data.objectId;}set(k,v){this.data[k]=v;}get(k){return this.data[k];}toJSON(){return{...this.data,company:this.data.company?.id?{__type:'Pointer',className:'Company',objectId:this.data.company.id}:this.data.company};}}
  c.Parse={Object:Obj,Query:class{constructor(name){this.name=name;}async get(id){return new Obj(this.name,await one('SELECT * FROM "'+this.name+'" WHERE "objectId"=$1',[id]));}}};
  vm.runInContext(read('../cloud/mobile-teaching.js')+';authorizeRequestedUser=async(current,uid)=>Number(uid);',c);
- const run=(bind,word,key,check=0)=>c.upsertStudyRecord({uid:20,orderId:901,requestKey:key,addon:JSON.stringify({bindingId:bind,xxqs:[{GeneralID:word,Title:'word',check}]})},teacher);
+ const run=(bind,word,key,check=1)=>c.upsertStudyRecord({uid:20,orderId:901,requestKey:key,addon:JSON.stringify({bindingId:bind,xxqs:[{GeneralID:word,Title:'word',check}]})},teacher);
  const first=await run('b1',1001,'study-first-0001');await run('b2',1002,'study-second-0001');
  const updated=await run('b1',1001,'study-first-0002',1);assert.equal(first.GeneralID,updated.GeneralID);
  await run('b1',1001,'study-first-0002',1);
- const rows=await q('SELECT "bindingId",ygg,id FROM "DailyStudyRecord" ORDER BY "bindingId"');assert.equal(rows.length,2);assert.equal(Number(rows[0].ygg),1);assert.equal(Number(rows[1].ygg),0);
- assert.equal((await one('SELECT count(*)::int n FROM "CommonModel" WHERE "modelId"=56')).n,2);
+ const rows=await q('SELECT "bindingId",ygg,id FROM "DailyStudyRecord" ORDER BY "bindingId"');assert.equal(rows.length,1);assert.equal(Number(rows[0].ygg),2);
+ assert.equal((await one('SELECT count(*)::int n FROM "CommonModel" WHERE "modelId"=56')).n,1);
  assert.equal((await one('SELECT "teacherPaySnapshot" FROM "CourseAppointment"')).teacherPaySnapshot,'37');
 });

+ 10 - 0
scripts/tests/lesson-reading-source.test.mjs

@@ -0,0 +1,10 @@
+import {test} from 'node:test';import assert from 'node:assert/strict';import {readFileSync} from 'node:fs';import vm from 'node:vm';
+const source=readFileSync(new URL('../deploy-admin-functions.mjs',import.meta.url),'utf8').match(/const appGatewayCode = String.raw`([\s\S]*?)\n`;/)[1];
+test('real AI adapter appends all server-resolved words and rejects stale source before provider call',async()=>{
+ const sent=[],wordList=Array.from({length:17},(_,i)=>'word'+i);
+ const ctx=vm.createContext({Parse:{Config:{get:async()=>({get:()=> 'test-provider-token'})}},fetch:async(url,init)=>{sent.push(JSON.parse(init.body));return{ok:true,text:async()=>JSON.stringify({choices:[{message:{content:'article'}}]})}}});
+ vm.runInContext(source,ctx);ctx.appReadingSource=async()=>({sourceVersion:'4',wordList});
+ await ctx.readingAi({sourceStudyId:'fixture',sourceVersion:'4',prompt:'Write a story'},{});
+ assert.equal(sent.length,1);for(const word of wordList)assert(sent[0].messages[0].content.includes(word));
+ await assert.rejects(ctx.readingAi({sourceStudyId:'fixture',sourceVersion:'3',prompt:'Write a story'},{}),/已更新/);assert.equal(sent.length,1);
+});

+ 134 - 0
scripts/tests/lesson-study-batches.test.mjs

@@ -0,0 +1,134 @@
+import {test,before,after} from 'node:test';
+import assert from 'node:assert/strict';
+import {readFileSync} from 'node:fs';
+import {PGlite} from '@electric-sql/pglite';
+const db=new PGlite();
+const enrollmentSql=readFileSync(new URL('../sql/member-enrollment.sql',import.meta.url),'utf8');
+const workflowSql=readFileSync(new URL('../sql/lesson-workflow.sql',import.meta.url),'utf8');
+const one=async(sql,args=[])=>(await db.query(sql,args)).rows[0];
+const feedback={contentSummary:'学习 Unit 1',reviewStatus:'完成',reviewSummary:'复习两个单词',trainingStatus:'正常进行',trainingSummary:'完成课堂练习',noHomework:true,homework:'',notes:'',coachComment:'继续保持'};
+const complete=async(gid,version,key='request-complete-001',actor='teacher',uid=30,f=feedback)=>(await one('SELECT xs_lesson_complete($1,$2,$3,$4,$5,$6,$7::jsonb) result',['co',gid,actor,uid,version,key,JSON.stringify(f)])).result;
+const review=async(gid,rating=5,comment='很好',actor='student',uid=20)=>(await one('SELECT xs_lesson_review($1,$2,$3,$4,$5,$6) result',['co',gid,actor,uid,rating,comment])).result;
+let binding;
+before(async()=>{
+
+ await db.exec(`CREATE TABLE "_User" ("objectId" text PRIMARY KEY,"company" text,"legacyUserId" double precision,"legacyGroupId" double precision,"identityType" text,"isDisabled" boolean,"legacyUserData" jsonb,"updatedAt" timestamptz);
+ CREATE TABLE "CourseBinding" ("objectId" text PRIMARY KEY,"company" text,"id" double precision,"createdAt" timestamptz,"updatedAt" timestamptz,"sourceKey" text,"yhid" text,"kcid" text,"yxx" double precision,"cksl" double precision,"syjd" text);
+ CREATE TABLE "CommonModel" ("objectId" text PRIMARY KEY,"company" text,"createdAt" timestamptz,"updatedAt" timestamptz,"createTime" timestamptz,"sourceKey" text,"generalId" double precision,"itemId" double precision,"modelId" double precision,"nodeId" double precision,"tableName" text,"title" text,"subtitle" text,"status" double precision,"inputer" text);
+ CREATE TABLE "CourseAppointment" ("objectId" text PRIMARY KEY,"company" text,"id" double precision,"szyh" text,"kcid" text,"pl" text,"fxpl" text,"dszt" text,"dslx" text,"jffs" text,"jssj" text,"updatedAt" timestamptz,"creditCostsSnapshot" jsonb,"creditHoldStatus" text,"courseCategoryKey" text,"courseCategoryName" text,"teacherPaySnapshot" double precision);
+ CREATE TABLE "LessonRecord" ("objectId" text PRIMARY KEY,"company" text,"id" double precision,"createdAt" timestamptz,"updatedAt" timestamptz,"sourceKey" text,"xymz" text,"jsmz" text,"kcid" text,"kclx" text,"kcmc" text,"yyds" text,"jffs" text,"kzsj" text,"lessonAt" timestamptz);
+ CREATE TABLE "DailyStudyRecord" ("objectId" text);
+ CREATE TABLE "CourseTeachingProfile" ("company" text,"courseId" double precision,"categoryKey" text,"confirmed" boolean);
+ CREATE TABLE "CourseCreditReservation" ("objectId" text PRIMARY KEY,"company" text,"studentId" double precision,"appointmentId" text,"creditCosts" jsonb,"state" text,"consumedAt" timestamptz,"updatedAt" timestamptz);`);
+ for(const table of ['UserExpDomP','UserSIcon','UserExpHis','UserUserPoint'])await db.exec(`CREATE TABLE "${table}" ("objectId" text PRIMARY KEY,"company" text,"sourceKey" text UNIQUE,"createdAt" timestamptz,"updatedAt" timestamptz,"userId" double precision,"score" double precision,"scoreBefore" double precision,"hisTime" timestamptz,"operator" double precision,"scoreType" double precision,"detail" text,"remark" text,"payMethod" text)`);
+ await db.exec(`INSERT INTO "_User" VALUES ('store','co',10,2,'store',false,'{}',now()),('student','co',20,1,'member',false,'{"ParentUserID":10}',now()),('empty','co',21,1,'member',false,'{}',now()),('foreign','other',22,1,'member',false,'{}',now());`);
+ await db.exec(`ALTER TABLE "CourseAppointment" ADD COLUMN "creditHoldReason" text,ADD COLUMN "kssj" text,ADD COLUMN "plxm" text,ADD COLUMN "yykcid" text,ADD COLUMN "durationMinutes" numeric,ADD COLUMN "deliveryMode" text,ADD COLUMN "teachingRuleId" text,ADD COLUMN "bxrq" text,ADD COLUMN "sdsd" text,ADD COLUMN "yysj" text,ADD COLUMN "sourceKey" text,ADD COLUMN "createdAt" timestamptz;
+ ALTER TABLE "CourseCreditReservation" ADD COLUMN "sourceKey" text,ADD COLUMN "createdAt" timestamptz,ADD COLUMN "appointmentGeneralId" numeric,ADD COLUMN "ruleId" text,ADD COLUMN "reservedAt" timestamptz;`);
+ await db.exec(`CREATE TABLE "Node" ("company" text,"nodeId" numeric,"parentId" numeric,"nodeName" text,"zstatus" numeric);
+ CREATE TABLE "SysLog" ("objectId" text PRIMARY KEY,"company" text,"createdAt" timestamptz,"updatedAt" timestamptz,"sourceKey" text NOT NULL UNIQUE,"id" double precision,"cdate" timestamptz,"cname" text,"type1" text,"type2" text,"type3" text,"clevel" double precision,"cadminId" double precision,"detail" text,"content1" text,"content2" text);
+ INSERT INTO "Node" VALUES ('co',100,8,'词表',99),('co',101,8,'空词表',99);
+ INSERT INTO "CommonModel" ("objectId","company","nodeId","modelId","status") SELECT 'word-'||i,'co',100,52,99 FROM generate_series(1,200) i;`);
+ await db.exec(enrollmentSql);
+ await db.exec(`ALTER TABLE "LessonRecord" ADD pf numeric,ADD pjnr text,ADD pldp text,ADD plpjsj text;
+ ALTER TABLE "DailyStudyRecord" ADD company text,ADD id numeric,ADD "userId" numeric,ADD dsid text,ADD xxqs text,ADD fxrl text,ADD "updatedAt" timestamptz;
+ CREATE TABLE "MemoryPracticeRecord" ("objectId" text primary key,company text,id numeric,"sourceKey" text,"createdAt" timestamptz,"updatedAt" timestamptz,yhid numeric,plid numeric,"orderId" numeric,xxjlid numeric,kcid numeric,fxzt text,kywrq text,kywsj text);
+ CREATE TABLE "LessonCompletionReport" ("objectId" text primary key,company text,"createdAt" timestamptz,"updatedAt" timestamptz,"sourceKey" text,"appointmentId" text,"appointmentGeneralId" numeric,"lessonId" text,"studentId" numeric,"coachId" numeric,"courseCategoryKey" text,"courseCategoryName" text,"deliveryMode" text,"durationMinutes" numeric,"actualStartAt" timestamptz,"actualEndAt" timestamptz,"creditCosts" jsonb,"contentSummary" text,"vocabularyCount" numeric,"reviewStatus" text,"reviewSummary" text,"trainingStatus" text,"trainingSummary" text,"antiForgetSummary" jsonb,homework text,"noHomework" boolean,notes text,"submittedBy" text,"submittedAt" timestamptz);
+ INSERT INTO "_User" VALUES('teacher','co',30,3,'coach',false,'{}',now()),('other-teacher','co',31,3,'coach',false,'{}',now());`);
+ await db.exec(workflowSql);
+ await db.exec(`ALTER TABLE "DailyStudyRecord" ADD "createdAt" timestamptz,ADD "sourceKey" text,ADD COLUMN IF NOT EXISTS "bindingId" text,ADD pl text,ADD learned numeric,ADD ygg numeric,ADD djq numeric,ADD dqrq text,ADD con text;
+ ALTER TABLE "CommonModel" ADD hits numeric;
+ UPDATE "CommonModel" SET "generalId"=replace("objectId",'word-','')::numeric,title='word'||replace("objectId",'word-','') WHERE "modelId"=52;`);
+ await db.exec(`INSERT INTO "Node" VALUES ('co',102,100,'七年级',99),('co',103,100,'八年级',99),('co',104,100,'九年级',99);
+ UPDATE "CommonModel" SET "nodeId"=CASE WHEN "generalId"<=9 THEN 102 WHEN "generalId"<=14 THEN 103 ELSE 104 END WHERE "modelId"=52 AND "generalId" BETWEEN 1 AND 17;`);
+ await db.exec(readFileSync(new URL('../sql/lesson-study-batches.sql',import.meta.url),'utf8'));
+ await db.query("SELECT xs_credit_post('co','admin','fund','store','Purse',30,'','初始课时','转账','fund-workflow')");
+ await db.query("SELECT xs_credit_post('co','admin','transfer','student','Purse',30,'','学员课时','转账','transfer-workflow')");
+ binding=(await one(`SELECT xs_enroll('co','admin',20,'word','[{"courseId":100,"courseName":"词表","wordCount":200,"categoryName":"单词课"}]','[[{"account":"Purse","amount":1}]]','测试开课','enroll-workflow') result`)).result.bindingIds[0];
+});
+after(()=>db.close());
+let serial=0;
+async function appointment(start=true){
+ const date=(await one("SELECT to_char(now() AT TIME ZONE 'Asia/Shanghai','YYYY-MM-DD') AS day")).day;
+ const time=String(5+(serial++%16)).padStart(2,'0')+':00';
+ const entries=[{common:{title:'学员',subtitle:'词表'},addon:{szyh:'20',pl:'30',fxpl:'30',plxm:'老师',kcid:'100',yykcid:'100',dslx:'1',jffs:'线上',bindingId:binding,courseCategoryKey:'word',durationMinutes:30,deliveryMode:'online',creditCostsSnapshot:[{account:'Purse',amount:1}],teacherPaySnapshot:20,bxrq:date,sdsd:time,yysj:date+' '+time,dszt:'0'}}];
+ const result=(await one("SELECT xs_schedule_save('co','admin','{}',$1::jsonb,$2) result",[JSON.stringify(entries),'schedule-'+serial])).result;
+ const common=result.targetIds[0];
+ if(start) await db.query("SELECT xs_start_appointment('co',$1)",[common]);
+ const row=await one('SELECT c."generalId"::text gid,a."objectId",a."updatedAt" FROM "CommonModel" c JOIN "CourseAppointment" a ON a.company=c.company AND a.id=c."itemId" WHERE c."objectId"=$1',[common]);
+ return {...row,common};
+}
+const balance=async()=>Number((await one(`SELECT "legacyUserData"->>'Purse' balance FROM "_User" WHERE "objectId"='student'`)).balance);
+
+const save=async(a,batchId,words,mode='confirm',version=0,key='save-'+crypto.randomUUID(),bind=binding)=>(await one('SELECT xs_lesson_study_save($1,$2,$3,$4,$5,$6::jsonb) result',['co',a.gid,'teacher',30,key,JSON.stringify({batchId,bindingId:bind,mode,expectedVersion:version,words})])).result;
+const words=(start,count,check=1)=>Array.from({length:count},(_,i)=>({GeneralID:start+i,Title:'word'+(start+i),check}));
+const studies=async a=>(await db.query('SELECT * FROM "DailyStudyRecord" WHERE company=\'co\' AND dsid=$1 AND "recordScope"=\'lesson\'',[a.gid])).rows;
+test('same enrollment three rounds retain 9+5+3 words and produce one record with ten complete reviews',async()=>{
+ const a=await appointment();
+ assert.equal((await save(a,'round1',words(1,9))).learned,9);
+ assert.equal((await save(a,'round2',words(10,5))).learned,14);
+ const r=await save(a,'round3',words(15,3));assert.equal(r.learned,17);
+ const ds=await studies(a);assert.equal(ds.length,1);assert.equal(JSON.parse(ds[0].xxqs).length,17);
+ const before=await balance();await complete(a.gid,a.updatedAt,'complete-multi-'+serial);
+ assert.equal(await balance(),before-1);
+ const reviews=(await db.query('SELECT * FROM "MemoryPracticeRecord" WHERE "orderId"=$1',[a.gid])).rows;
+ assert.equal(reviews.length,10);assert(reviews.every(v=>String(v.xxjlid)===String(r.GeneralID)));
+ assert.equal((await one('SELECT "vocabularyCount" FROM "LessonCompletionReport" WHERE "appointmentGeneralId"=$1',[a.gid])).vocabularyCount,'17');
+ const updated=await studies(a);assert.equal(updated[0].fxrl.split(',').length,10);
+ await complete(a.gid,a.updatedAt,'complete-multi-'+serial);assert.equal(await balance(),before-1);
+});
+test('draft does not count, blocks completion, and can be confirmed without losing earlier words',async()=>{
+ const a=await appointment();await save(a,'first',words(1,9));const r=await save(a,'pending',words(10,5,0),'draft');assert.equal(r.learned,9);
+ await assert.rejects(complete(a.gid,a.updatedAt,'pending-complete'),/未确认/);
+ await assert.rejects(save(a,'pending',words(10,5,0),'confirm',1),/掌握情况/);
+ assert.equal((await save(a,'pending',words(10,5),'confirm',1)).learned,14);
+});
+test('duplicate words deduplicate and stale versions cannot overwrite mastery; retry is idempotent',async()=>{
+ const a=await appointment();const key='retry-batch-key';const first=await save(a,'first',words(1,9),'confirm',0,key);assert.deepEqual(await save(a,'first',words(1,9),'confirm',0,key),first);
+ await save(a,'second',words(5,5,2));assert.equal((await studies(a))[0].learned,'9');
+ await save(a,'first',words(1,9,2),'confirm',1);
+ await assert.rejects(save(a,'first',words(1,9),'confirm',1),/已更新/);
+ await assert.rejects(save(a,'first',words(1,9),'draft',2),/不能退回/);
+});
+test('legacy snapshots merge rather than replace and cannot reset known mastery',async()=>{
+ const a=await appointment();await save(a,'legacy',words(1,9),'legacy');assert.equal((await save(a,'legacy',words(10,5),'legacy')).learned,14);
+ await assert.rejects(save(a,'legacy',words(1,9,2),'legacy'),/新版页面/);
+});
+test('discarding an unconfirmed round removes the completion blocker without counting words',async()=>{
+ const a=await appointment();await save(a,'discard',words(1,5,0),'draft');await save(a,'discard',[],'discard',1);
+ assert.equal((await studies(a))[0].learned,'0');await complete(a.gid,a.updatedAt,'discard-complete');
+});
+test('other teachers, unrelated course words and writes after completion are rejected',async()=>{
+ const a=await appointment();await assert.rejects(one('SELECT xs_lesson_study_save($1,$2,$3,$4,$5,$6::jsonb) result',['co',a.gid,'other-teacher',31,'foreign-request',JSON.stringify({batchId:'bad',bindingId:binding,mode:'confirm',words:words(1,1)})]),/仅预约老师/);
+ await assert.rejects(save(a,'bad',words(9999,1)),/不匹配/);
+ await save(a,'ok',words(1,1));await complete(a.gid,a.updatedAt,'closed-save');await assert.rejects(save(a,'late',words(2,1)),/进行中/);
+});
+test('confirmed historical recovery restores exactly 17 words, unknown mastery, ten reviews and is financially idempotent',async()=>{
+ const a=await appointment();
+ await db.exec(`INSERT INTO "Node" VALUES('co',172,100,'七年级',99),('co',173,100,'八年级',99),('co',174,100,'九年级',99);UPDATE "CommonModel" SET "nodeId"=CASE WHEN "generalId"<=9 THEN 172 WHEN "generalId"<=14 THEN 173 ELSE 174 END WHERE "modelId"=52 AND "generalId" BETWEEN 1 AND 17;`);
+ await save(a,'old-last',words(15,3));await complete(a.gid,a.updatedAt,'recovery-complete');
+ await db.query('DELETE FROM "CommonModel" WHERE "modelId"=60 AND "itemId" IN (SELECT id FROM "MemoryPracticeRecord" WHERE "orderId"=$1)',[a.gid]);await db.query('DELETE FROM "MemoryPracticeRecord" WHERE "orderId"=$1',[a.gid]);
+ const before=await balance(),ledgerBefore=(await db.query('SELECT * FROM "UserExpDomP" ORDER BY "objectId"')).rows;
+ let sql=readFileSync(new URL('../sql/repair-confirmed-lesson-839154386019743.sql',import.meta.url),'utf8').replaceAll('7pIbDBJmKx','co').replaceAll('839154386019743',a.gid).replaceAll("'69'","'30'").replaceAll("'110'","'20'").replaceAll(',110,69,',',20,30,').replaceAll("'yhid',110","'yhid',20").replaceAll('nq2u6S3Lc5',binding).replace('ARRAY[25163,25164,25165,25166,25167,25168,25169,25170,25171,26120,26121,26122,26123,26124,27143,27144,27145]','ARRAY[1,2,3,4,5,6,7,8,9,10,11,12,13,14,15,16,17]').replaceAll('ARRAY[27143,27144,27145]','ARRAY[15,16,17]');
+ await db.transaction(tx=>tx.exec(sql));const snapshot=await studies(a);assert.equal(snapshot[0].learned,'17');const restored=JSON.parse(snapshot[0].xxqs);assert.equal(restored.filter(w=>w.check===0).length,14);assert.equal(restored.filter(w=>w.check===1).length,3);
+ await db.transaction(tx=>tx.exec(sql));assert.deepEqual(await studies(a),snapshot);assert.equal(await balance(),before);assert.deepEqual((await db.query('SELECT * FROM "UserExpDomP" ORDER BY "objectId"')).rows,ledgerBefore);
+ assert.equal((await one('SELECT count(*)::int n FROM "MemoryPracticeRecord" WHERE "orderId"=$1',[a.gid])).n,10);
+ await save(a,'recovery:172',words(1,9),'mastery',1);assert.equal((await studies(a))[0].ygg,'12');assert.equal(await balance(),before);
+ await assert.rejects(save(a,'recovery:173',words(10,4),'mastery',1),/不能更改/);
+});
+
+test('three bindings accumulate and stale confirmation cannot replace a newer round mastery',async()=>{
+ const a=await appointment();
+ const extra=[];
+ for(const [course,start,count] of [[105,201,5],[106,206,3]]){
+  await db.query('INSERT INTO "Node" VALUES ($1,$2,8,$3,99)',['co',course,'词本'+course]);
+  for(let id=start;id<start+count;id++)await db.query(`INSERT INTO "CommonModel" ("objectId",company,"nodeId","modelId",status,"generalId",title) VALUES($1,'co',$2,52,99,$3,$4)`,['word-'+id,course,id,'word'+id]);
+  const r=(await one(`SELECT xs_enroll('co','admin',20,'word',$1::jsonb,'[[{"account":"Purse","amount":1}]]','测试开课',$2) result`,[JSON.stringify([{courseId:course,courseName:'词本'+course,wordCount:count,categoryName:'单词课'}]),'enroll-'+course])).result;
+  extra.push(r.bindingIds[0]);
+ }
+ await save(a,'book1',words(1,9));await save(a,'book2',words(201,5),'confirm',0,undefined,extra[0]);
+ assert.equal((await save(a,'book3',words(206,3),'confirm',0,undefined,extra[1])).learned,17);
+ const draft=await save(a,'old-draft',words(1,1,0),'draft');
+ await save(a,'newer',words(1,1,2));
+ await assert.rejects(()=>save(a,'old-draft',words(1,1,1),'confirm',draft.batchVersion),/其他轮次已更新/);
+ assert.equal(JSON.parse((await studies(a))[0].xxqs).find(w=>w.GeneralID===1).check,2);
+});

Некоторые файлы не были показаны из-за большого количества измененных файлов