فهرست منبع

fix(learning): handle duplicate legacy word progress on save

彭峰 8 ساعت پیش
والد
کامیت
9953989ed6

+ 35 - 0
docs/qa/selection-save-duplicate-practice-fix-2026-11-03.md

@@ -0,0 +1,35 @@
+# 选词“重试保存”失败:历史重复学习记录(2026-11-03)
+
+## 诊断
+
+截图入口为预约 `881692`、单元 `158`、词库 `80`、开课 `587pMttl5y`;预约关联学员业务 ID `1719`。仅页面通用错误不能区分失败原因,因此先读取真实保存回执、关联词表及记录,再用回滚事务复现。
+
+该学员的 `game`(词库 ID `586395`)存在两条历史导入 `PracticeRecord`:
+
+- `dMxDC1ASKU`,来源 `legacy:model:53:general:764022`
+- `tTpsjInteC`,来源 `legacy:model:53:general:764023`
+
+两条记录都创建于 `2026-09-01T02:40:40.843Z`,次数均为 1。网关之前把全部重复行转成 Map,后返回的记录覆盖前一条;事务按创建时间、objectId 取第一条。于是把第二条记录的唯一 sourceKey 拷到第一条记录上,触发 `23505 / PracticeRecord_sourceKey_key`,整批回滚。前端收到通用“云函数执行失败”,重试同一请求会稳定重现,并非又碰到 100 词限制。
+
+今天同一学员已有 10 词和 6 词的成功回执,但它们并非截图所显示的六个例外词,不能据此认定截图这批已经保存。
+
+## 修复
+
+- `updateLearningWords` 按单词去重,优先选择最高学习次数,再按创建时间、objectId 稳定选择;与课程词表原有 MAX 进度读取逻辑一致。
+- `xs_mobile_teaching_write` 对 PracticeRecord 使用相同的选择规则,并保留被更新行已有的唯一来源键。其他教学表的选择逻辑保持不变。
+- 保留所有历史记录,不删重复行、不倒退较高进度;保存仍为单事务,原请求标识的重试只增加一次。
+
+## 验证
+
+- 新增 4 项回归测试。旧代码已复现来源键唯一约束错误;修复后覆盖整批保存、最大进度、同创建时间/同次数的稳定选择、来源键与历史 ID、生词标记、新词创建及重复请求。
+- 相关教学写入、单元进度、课程选择、同步与历史进度读取测试共 48 项通过。
+- `npm run cloud:validate` 验证 11 个云函数定义通过。
+- 独立只读审查未发现阻塞问题。
+- 使用真实学员数据,在同一事务内临时安装候选函数,验证包含问题词的 73 个词整批保存,以及同一请求重试不再次增加进度;随后回滚所有写入及候选函数。验证耗时约 7 秒。
+- 回滚后重新从另一请求读取确认:线上网关哈希仍为 `a8f27027e749f44b9ba28b9e3b7a4eebbe09026f19a797536562c9b2b97d29a9`,教学事务函数体哈希仍为 `9a24362b5ee24f7aced29bcf258f3a9391f4a923b43f33f511749d92fa4d6cdd`,诊断请求未留下回执。
+
+私有诊断与验证证据:`/Users/mac/.local/state/xiaoshu/cloud-releases/2026-11-03-selection-save-fix/`。候选函数体哈希:`2ef95cc5c2959dc3d10d16a198ed06e551afcaa5289767b305db3c3924d051b6`。
+
+## 交付边界
+
+本次仅提交、推送修复代码。回滚验证不等于正式发布;没有更新长期在线的网关或 SQL 函数,没有删除、归并或永久修改学员历史记录。本次没有前端改动,不需重发前端包。后续正式发布需一并选择性更新网关的 `updateLearningWords` 和 SQL 的 `xs_mobile_teaching_write`,再做线上回读与请求验证。

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

@@ -2691,7 +2691,7 @@ async function courseWordPage(input, current) {
 async function updateLearningWords(input, current) {
   const uid = await authorizeRequestedUser(current, input.uid); const wordIds = [...new Set(String(input.wordsId || input.wordIds || '').split(',').map((value) => String(number(value))).filter((value) => value !== '0'))]; if (!wordIds.length) fail(400, '请选择单词'); if (wordIds.length > 100) fail(400, '单次最多操作 100 个单词'); const saveLearning = number(input.save) === 1; const hasNewWordFlag = input.ifnew !== undefined && input.ifnew !== ''; const newWordFlag = hasNewWordFlag ? (number(input.ifnew) === 1 ? '1' : '0') : null; if (!saveLearning && !hasNewWordFlag) fail(400, '缺少学习或生词操作类型'); const kcid = number(input.kcid);
   const validRows = await Psql.query('SELECT CAST("generalId" AS text) AS id FROM "CommonModel" WHERE "company" = $1 AND CAST("modelId" AS text) = \'52\' AND CAST("generalId" AS text) = ANY($2::text[])', [DEFAULT_COMPANY_ID, wordIds]); const validIds = new Set(validRows.map((row) => String(row.id))); const invalid = wordIds.filter((id) => !validIds.has(id)); if (invalid.length) fail(400, '包含不存在的词库 ID: ' + invalid.join(','));
-  const existingRows = await Psql.query('SELECT "objectId", CAST("scid" AS text) AS "scid" FROM "PracticeRecord" WHERE "company" = $1 AND CAST("yhid" AS text) = $2 AND CAST("scid" AS text) = ANY($3::text[])', [DEFAULT_COMPANY_ID, String(uid), wordIds]); const existing = await Promise.all(existingRows.map((row) => new Parse.Query('PracticeRecord').get(row.objectId, { useMasterKey: true }))); const byWord = new Map(existing.map((item) => [String(item.get('scid')), item])); const changed = []; let created = 0;
+  const existingRows = await Psql.query('SELECT DISTINCT ON (pr.scid) pr."objectId", CAST(pr.scid AS text) AS "scid" FROM "PracticeRecord" pr WHERE pr.company = $1 AND CAST(pr.yhid AS text) = $2 AND CAST(pr.scid AS text) = ANY($3::text[]) ORDER BY pr.scid, COALESCE(pr.xxcs,0) DESC, pr."createdAt", pr."objectId"', [DEFAULT_COMPANY_ID, String(uid), wordIds]); const existing = await Promise.all(existingRows.map((row) => new Parse.Query('PracticeRecord').get(row.objectId, { useMasterKey: true }))); const byWord = new Map(existing.map((item) => [String(item.get('scid')), item])); const changed = []; let created = 0;
   for (const wordId of wordIds) { let item = byWord.get(wordId); if (!item) { if (!kcid) fail(400, '首次记录单词时缺少课程 ID'); item = new Parse.Object('PracticeRecord'); item.set('company', current.get('company')); item.set('sourceKey', 'cloud:e_add_words:' + uid + ':' + wordId); item.set('yhid', uid); item.set('scid', number(wordId)); item.set('kcid', String(kcid)); item.set('xxcs', 0); item.set('jrscb', '0'); created += 1; } else if (kcid && !item.get('kcid')) item.set('kcid', String(kcid)); if (saveLearning) item.set('xxcs', number(item.get('xxcs')) + 1); if (newWordFlag !== null) item.set('jrscb', newWordFlag); changed.push(item); }
   const operations=changed.map(item=>{const data=mobileObjectData(item);if(saveLearning)data.xxcs=1;return{table:'PracticeRecord',data,match:{company:DEFAULT_COMPANY_ID,yhid:uid,scid:number(item.get('scid'))},increment:saveLearning?['xxcs']:[]};});
   const saved=await mobileAtomicWrite(input,current,operations,String(uid));return{affected:saved.length,created,learnedIncremented:saveLearning?saved.length:0,newWordState:newWordFlag===null?undefined:number(newWordFlag),objectIds:saved.map(item=>item.objectId)};

+ 8 - 1
scripts/sql/mobile-teaching.sql

@@ -36,13 +36,20 @@ BEGIN
       IF typ IS NULL THEN RAISE EXCEPTION 'Unknown match field %',field; END IF;
       where_sql:=where_sql||CASE WHEN where_sql='' THEN '' ELSE ' AND ' END||format('%I = ($1->>%L)::%s',field,field,typ);
     END LOOP;
-    EXECUTE format('SELECT to_jsonb(t) FROM %I t WHERE %s ORDER BY "createdAt", "objectId" LIMIT 1 FOR UPDATE',tab,where_sql) INTO existing USING criteria;
+    -- Course-word reads use the maximum count when legacy imports contain duplicates.
+    -- Select the same effective progress row; stable timestamps/IDs break ties.
+    EXECUTE format('SELECT to_jsonb(t) FROM %I t WHERE %s ORDER BY %s"createdAt", "objectId" LIMIT 1 FOR UPDATE',tab,where_sql,
+      CASE WHEN tab='PracticeRecord' THEN 'COALESCE(xxcs,0) DESC, ' ELSE '' END) INTO existing USING criteria;
     IF existing IS NOT NULL THEN
       data := data || jsonb_build_object('objectId',existing->'objectId','createdAt',existing->'createdAt');
       -- Stable legacy IDs must survive retries and concurrent upserts.
       FOREACH field IN ARRAY ARRAY['id','generalId','itemId'] LOOP
         IF existing ? field THEN data:=jsonb_set(data,ARRAY[field],existing->field); END IF;
       END LOOP;
+      -- A stale snapshot from another duplicate must not transfer its unique provenance.
+      IF tab='PracticeRecord' AND existing ? 'sourceKey' THEN
+        data:=jsonb_set(data,ARRAY['sourceKey'],existing->'sourceKey');
+      END IF;
     END IF;
     FOR field IN SELECT jsonb_array_elements_text(COALESCE(op->'increment','[]')) LOOP
       data:=jsonb_set(data,ARRAY[field],to_jsonb(COALESCE((existing->>field)::numeric,0)+COALESCE((data->>field)::numeric,0)));

+ 60 - 0
scripts/tests/learning-word-duplicates.test.mjs

@@ -0,0 +1,60 @@
+import {test,before,after} from 'node:test';
+import assert from 'node:assert/strict';
+import {readFileSync} from 'node:fs';
+import vm from 'node:vm';
+import {PGlite} from '@electric-sql/pglite';
+const db=new PGlite(),read=n=>readFileSync(new URL(n,import.meta.url),'utf8');
+const q=async(s,p=[])=>(await db.query(s,p)).rows,one=async(s,p=[])=>(await q(s,p))[0];
+let ctx;
+class Row {
+ constructor(name,data={}){this.className=name;this.data={...data};this.id=data.objectId;}
+ get(k){return this.data[k];}set(k,v){this.data[k]=v;}
+ toJSON(){return JSON.parse(JSON.stringify({...this.data,...(this.id?{objectId:this.id}:{})}));}
+}
+before(async()=>{
+ await db.exec(`CREATE TABLE "PracticeRecord"("objectId" text primary key,"sourceKey" text UNIQUE,company text,"createdAt" timestamptz,"updatedAt" timestamptz,id numeric,yhid numeric,scid numeric,kcid text,xxcs numeric,jrscb text);
+ CREATE TABLE "CommonModel"(company text,"modelId" numeric,"generalId" numeric);
+ INSERT INTO "CommonModel" VALUES('co',52,101),('co',52,102),('co',52,103);
+ INSERT INTO "PracticeRecord" VALUES('old','legacy:old','co','2025-01-01','2025-01-01',1001,20,101,'80',1,'0'),('new','legacy:new','co','2026-01-01','2026-01-01',1002,20,101,'80',2,'1'),('normal','legacy:normal','co','2026-01-01','2026-01-01',1003,20,102,'80',1,'0');`);
+ await db.exec(read('../sql/mobile-teaching.sql'));
+ const app=read('../deploy-admin-functions.mjs').match(/const appGatewayCode = String.raw`([\s\S]*?)\n`;/)[1].replace("const DEFAULT_COMPANY_ID = '7pIbDBJmKx'","const DEFAULT_COMPANY_ID = 'co'");
+ ctx=vm.createContext({Psql:{query:q,one},Parse:{Object:Row,Query:class{constructor(name){this.name=name;}async get(id){return new Row(this.name,await one('SELECT * FROM "PracticeRecord" WHERE "objectId"=$1',[id]));}}}});
+ vm.runInContext(app+read('../cloud/mobile-teaching.js'),ctx);ctx.authorizeRequestedUser=async()=>20;
+});
+after(()=>db.close());
+const teacher={id:'teacher',get:k=>({legacyUserId:30,legacyGroupId:3,company:{id:'co'}}[k])};
+const save=(key,ids='101,102',extra={})=>ctx.updateLearningWords({action:'e_add_words',uid:20,wordsId:ids,kcid:'80',save:1,requestKey:key,...extra},teacher);
+test('重复历史记录的整批保存保留各自身份与生词标记,推进最高学习次数,重试不重复增加',async()=>{
+ await db.exec('BEGIN');try{
+  const result=await save('duplicate-batch-request');assert.equal(result.affected,2);
+  const rows=await q('SELECT "objectId","sourceKey",id,xxcs,jrscb FROM "PracticeRecord" ORDER BY "objectId"');
+  assert.deepEqual(rows.map(r=>[r.objectId,r.sourceKey,Number(r.id),Number(r.xxcs),r.jrscb]),[['new','legacy:new',1002,3,'1'],['normal','legacy:normal',1003,2,'0'],['old','legacy:old',1001,1,'0']]);
+  await save('duplicate-batch-request');assert.deepEqual(await q('SELECT "objectId","sourceKey",id,xxcs,jrscb FROM "PracticeRecord" ORDER BY "objectId"'),rows);
+  assert.equal((await one('SELECT count(*)::int n FROM "MobileTeachingCommand"')).n,1);
+ }finally{await db.exec('ROLLBACK');}
+});
+test('底层事务防止旧快照串改来源键,重复记录按最高次数稳定选择',async()=>{
+ await db.exec('BEGIN');try{
+  const op={table:'PracticeRecord',match:{company:'co',yhid:20,scid:101},data:{objectId:'old',company:'co',yhid:20,scid:101,sourceKey:'legacy:old',id:1001,xxcs:1},increment:['xxcs']};
+  await q('SELECT xs_mobile_teaching_write($1,$2,$3,$4,$5::jsonb)',['co','20','stale-practice-snapshot','same',JSON.stringify([op])]);
+  assert.equal((await one('SELECT xxcs FROM "PracticeRecord" WHERE "objectId"=\'new\'')).xxcs,'3');
+  assert.equal((await one('SELECT "sourceKey" FROM "PracticeRecord" WHERE "objectId"=\'new\'')).sourceKey,'legacy:new');
+ }finally{await db.exec('ROLLBACK');}
+});
+test('新词首次创建与只改生词标记保持兼容,不降低现有学习次数',async()=>{
+ await db.exec('BEGIN');try{
+  assert.equal((await save('create-new-practice','103')).created,1);
+  await save('mark-duplicate-new-word','101',{save:0,ifnew:1});
+  assert.equal((await one('SELECT xxcs FROM "PracticeRecord" WHERE "objectId"=\'new\'')).xxcs,'2');
+  assert.equal((await one('SELECT jrscb FROM "PracticeRecord" WHERE "objectId"=\'new\'')).jrscb,'1');
+  await assert.rejects(save('invalid-vocabulary','999'),/不存在/);
+ }finally{await db.exec('ROLLBACK');}
+});
+test('与线上相同的同时间、同次数重复记录按 objectId 稳定选择,保留两份历史',async()=>{
+ await db.exec('BEGIN');try{
+  await db.exec(`INSERT INTO "PracticeRecord" VALUES('a-tie','legacy:tie-a','co','2025-01-01','2025-01-01',1004,20,103,'80',1,'1'),('z-tie','legacy:tie-z','co','2025-01-01','2025-01-01',1005,20,103,'80',1,'0')`);
+  assert.equal((await save('equal-progress-duplicate','103')).affected,1);
+  const rows=await q('SELECT "objectId","sourceKey",xxcs,jrscb FROM "PracticeRecord" WHERE scid=103 ORDER BY "objectId"');
+  assert.deepEqual(rows.map(r=>[r.objectId,r.sourceKey,Number(r.xxcs),r.jrscb]),[['a-tie','legacy:tie-a',2,'1'],['z-tie','legacy:tie-z',1,'0']]);
+ }finally{await db.exec('ROLLBACK');}
+});