Эх сурвалжийг харах

fix(review): preserve coach identity and reconcile Shi review income

彭峰 3 цаг өмнө
parent
commit
03ce2cdcf5

+ 60 - 0
docs/qa/shi-yuanjian-review-data-and-fees-2026-11-11.md

@@ -0,0 +1,60 @@
+# 史远见抗遗忘记录与费用全量核对(2026-11-11)
+
+截至北京时间 2026-11-11 21:16,正式数据库、管理端费用接口及老师端费用列表已回读。史远见老师 ID 1535;本轮修复 8 条旧系统完成状态漏同步、补计 8.00 元。修复后任务与旧来源无字段差异,2026 年 9 月起收入与旧费用逐条一致,未发现重复计薪。
+
+## 覆盖范围与完整性
+
+- 覆盖授课或复习关联的 14 名学员,182 条老师课后记录、184 条老师学习记录,以及含转交来源在内的 317 条相关每日学习记录。182 条课后记录均能对应学习来源。
+- 老系统老师学习记录完整一页 184 条,与新库逐一匹配;核对学员、老师、预约、学习日期、单词内容、数量和复习日历(数值 0 与字符串 0 按同值处理)。
+- 317 条来源的复习日历列出 3,225 个日期。刘泽鑫预约 474101 的学习记录 513461、513462、513463 是同一课、同一批 22 个单词、相同日历的三次保存;旧来源同样存在三份。实际 10 条复习均关联 513462,且完整覆盖日历。另两份带来的 20 个表面缺口不应再次建任务或计薪。保留原记录,未删除或追加重复任务。
+- 去除上述重复来源后,3,205 条相关复习任务完整覆盖有效日历,无同一学员、学习记录、计划日期重复任务。其中实际分配史老师的任务 1,829 条,涉及 11 名学员。
+- 老系统任务完整抓取 19 页 1,829 条,费用完整抓取 16 页 1,569 条,均校验总数及唯一编号。修复后每条任务的学生、老师、预约、课程、学习来源、计划日期、完成状态和完成时间与旧来源相同。
+
+## 修复明细
+
+杨文青(1412)以下任务旧系统已完成,新系统仍为未完成。逐条核实旧费用为 1 元,来源课次均为 60 分钟,均在计划当天完成:
+
+| 计划日期 | 任务编号 | 原完成时间 | 补计费用 |
+| --- | --- | --- | ---: |
+| 2026-11-10 | 890030、887785、890018、887773 | 2026-11-10 13:09 | 4.00 元 |
+| 2026-11-11 | 890031、885156、890019、885144 | 2026-11-11 12:48 | 4.00 元 |
+
+使用生产既有 `xs_legacy_review_patch` 补齐状态和原完成时间,并通过 `xs_review_resolve` 按已确认的历史绑定老师口径核定收入。保留历史实际完成账号未知;没有将维护管理员或老师伪造为实际操作人。执行前使用事务回滚预览,正式执行带同步租约检查、行锁和版本核对。
+
+## 核对后的任务与收入
+
+| 学员 | 任务 | 已完成 | 未完成 | 新系统计薪金额(元) |
+| --- | ---: | ---: | ---: | ---: |
+| 刘泽鑫(113) | 434 | 294 | 140 | 0.00 |
+| 杨文青(1412) | 1022 | 932 | 90 | 142.00 |
+| 曾浩铭(1495) | 16 | 16 | 0 | 0.00 |
+| 舒静宽(1505) | 6 | 6 | 0 | 0.00 |
+| 丁子昕高中(1539) | 48 | 48 | 0 | 0.00 |
+| 胡桢高中(1559) | 65 | 65 | 0 | 0.00 |
+| 万喻欣(1563) | 28 | 28 | 0 | 0.00 |
+| 罗文瀚(1627) | 96 | 68 | 28 | 0.00 |
+| 刘忆萱高中(1687) | 88 | 88 | 0 | 32.00 |
+| 徐铭艺高中(1742) | 6 | 6 | 0 | 0.00 |
+| 维恩付宸儿(1777) | 20 | 18 | 2 | 0.00 |
+| 合计 | 1829 | 1569 | 260 | 174.00 |
+
+未完成 260 条中,236 条是历史逾期未完成,24 条尚未到期;当天未完成为 0。新旧系统一致,不能将未完成任务推定为漏记完成或直接计费。
+
+| 费用期间 | 已完成/计薪条数 | 新系统计薪金额 |
+| --- | ---: | ---: |
+| 2026-09 | 62 | 62.00 元 |
+| 2026-10 | 76 | 76.00 元 |
+| 2026-11(截至核对时) | 36 | 36.00 元 |
+| 合计 | 174 | 174.00 元 |
+
+另有 2025-07 至 2026-08 的 1,395 条完成及旧费用记录,旧系统费用合计 1,395 元;新系统按既有 2026-09-01 计薪切点保留为历史凭据、当前收入为零。全部已核对存在,未将切点前旧账重复转入本期工资。本次核对的是记录与收入凭据,不能凭这些记录断言旧费用已经实际支付。当前该老师没有抗遗忘工资批次明细,本次也没有执行工资发放。
+
+## 防重复与验证
+
+- 1,569 条已完成任务均有且仅有对应费用凭据;174 条当前已计薪任务无重复业务键、无错老师、错学员、错计划日期或错完成时间;来源时长和金额一致。
+- 原有 1,561 条收入凭据逐条未变,未涉及的复习任务逐条未变。老师及杨文青账号完整快照、学员课时余额、已有工资明细均未改变。
+- 8 条修复均有原始完成来源、同步审计及独立费用核定凭据;同步状态、来源指纹和主附表基线全部更新并回读一致。已完成及计薪任务不能再被旧同步覆盖。
+- 同一补齐过程再次执行,修改任务 0、生成核定 0,没有重复补计。管理端正式接口返回 total=1569、reviewCount=174、reviewAmount=174、historicalCount=1395、exceptionCount=0;老师端真实费用列表返回这 8 条、合计 8 元。
+- 此前“当前账号不是有效陪练”的根因和发布验证另见 [身份修复记录](shi-yuanjian-review-identity-2026-11-11.md)。本轮补齐的是有旧系统完成证据的历史状态,不是模拟老师点击结束复习。
+
+完整原始来源、前后快照、逐条核对结果、事务预览、写入回执、重放验证及公开接口回执保存在受保护的本机目录 `~/.local/state/xiaoshu/qa/2026-11-11-shi-yuanjian-review/`。不向仓库提交账号口令、会话或原始个人数据。

+ 25 - 0
docs/qa/shi-yuanjian-review-identity-2026-11-11.md

@@ -0,0 +1,25 @@
+# 史远见结束复习身份校验修复(2026-11-11)
+
+史远见老师可以使用手机号登录、读取数据,但点击“结束复习”提示“当前账号不是有效陪练”。此前登录验证没有覆盖实际复习完成权限,不能据此判断授课操作全部可用。
+
+## 原因与证据
+
+正式账号 1535 的新旧分组均为老师(3),账户启用,但 `identityType=conflict`。现有授课记录 182 条、老师预约 226 条;学员预约、学员课后记录、每日学习记录和课程绑定均为 0,只有 18 条自用单词练习。历史 `refreshIdentityProjection` 将 `PracticeRecord` 与正式学员证据混为一类,因此误判双重身份。生产 `xs_complete_assigned_review` 要求被分配老师的身份为 `coach`,精确触发了截图报错。
+
+## 修复
+
+- 通过有审计的事务将该账号身份修正为 `coach`,其余账号字段逐字段校验不变。没有再次修改手机号、密码、登录状态、课时或历史记录。
+- 调整身份同步:有老师业务证据且正式分组为 3 的账号,单独的自用单词练习不再构成学员身份冲突;存在学员预约、每日学习、课程绑定或学员课后记录时继续保留冲突。普通练习学员、分组不明的双重身份、门店角色及帐套隔离保持原有规则。
+- 仅替换两个管理网关的 `refreshIdentityProjection` 函数,逐项比较其他函数未变。夜间 direct-v1 的既有账号分支保留现有身份,不需要重新导入账号。
+
+## 验证与操作边界
+
+修复前使用生产完成函数复现同一错误;事务回滚演练中改正身份后,老师当天 4 个待复习任务均能完成。正式修正身份后再次验证同样通过。每次探测的复习完成状态和计薪变化都回滚,未代替老师完成真实任务。老师可刷新页面后再次点击“结束复习”。
+
+新增数据库集成回归测试,先观察老师被误判及真实双重身份漏判的失败,再验证修复通过。回归覆盖个人练习、重复投影、真实学员课程及课后记录、非老师分组、门店和跨帐套证据。生产前像、精确复现、事务演练、身份修复审计、完成函数回滚验证和发布回读保存在私有目录 `~/.local/state/xiaoshu/qa/2026-11-11-shi-yuanjian-review/`,不包含密码明文。
+
+审计键:`audit:coach-identity:1535:20261111`。若需回退,先重新核实账号的最新身份及后续业务,不能盲目恢复错误的冲突标记;函数发布前备份保存在上述目录。
+
+## 发布与自动检查结果
+
+完整后台测试 557/557 通过,cloud:validate 的 11 个函数定义通过;diff 检查通过。两个管理网关仅替换身份投影函数,发布后源代码逐字回读一致,其余函数保持发布前版本。后续发现的旧系统完成记录与费用同步滞后另见 [抗遗忘数据与费用核对](shi-yuanjian-review-data-and-fees-2026-11-11.md)。

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

@@ -4107,8 +4107,9 @@ async function pendingSyncDeadLetters(context,input,dataset,legacyKey){const que
 async function resolveSyncDeadLetters(context,input,dataset,legacyKey){const rows=await pendingSyncDeadLetters(context,input,dataset,legacyKey);if(!rows.length)return;for(const row of rows){row.set('resolved',true);row.set('resolvedAt',new Date());}await Parse.Object.saveAll(rows,{useMasterKey:true});}
 async function writeSyncDeadLetter(context,input,dataset,item,error){const existing=await pendingSyncDeadLetters(context,input,dataset,item.key),company=companyPointer(context,input),row=existing[0]||new Parse.Object('LegacySyncDeadLetter');if(!row.id){if(company)row.set('company',company);row.set('sourceKey','cloud:legacy-sync-dead:'+companyIdOf(context,input)+':'+dataset+':'+text(item.key));row.set('dataset',dataset);row.set('legacyKey',text(item.key));row.set('attempts',0);}row.set('recordClasses',(item.records||[]).map((record)=>text(record.className)));row.set('errorCode',number(error.status||error.code));row.set('errorMessage',text(error.message).slice(0,1000));row.increment('attempts',1);row.set('resolved',false);row.set('lastAttemptAt',new Date());await row.save(null,{useMasterKey:true});}
 async function refreshIdentityProjection(context,input){
-  const companyId=companyIdOf(context,input),users=await Psql.query('SELECT "objectId",COALESCE(CAST("legacyUserId" AS text),"legacyUserData"->>\'UserID\') uid,COALESCE(CAST("legacyGroupId" AS text),"legacyUserData"->>\'GroupID\') gid,COALESCE("isAdmin",FALSE) admin FROM "_User" WHERE ($1::text IS NULL OR "company"=$1) AND ("isDeleted" IS NULL OR "isDeleted"=FALSE)',[companyId]),coachRows=await Psql.query('SELECT DISTINCT uid FROM (SELECT NULLIF(CAST("pl" AS text),\'\') uid FROM "CourseAppointment" WHERE ($1::text IS NULL OR "company"=$1) UNION SELECT NULLIF(CAST("fxpl" AS text),\'\') FROM "CourseAppointment" WHERE ($1::text IS NULL OR "company"=$1) UNION SELECT NULLIF(CAST("jsmz" AS text),\'\') FROM "LessonRecord" WHERE ($1::text IS NULL OR "company"=$1)) x WHERE uid IS NOT NULL',[companyId]),studentRows=await Psql.query('SELECT DISTINCT uid FROM (SELECT NULLIF(CAST("szyh" AS text),\'\') uid FROM "CourseAppointment" WHERE ($1::text IS NULL OR "company"=$1) UNION SELECT NULLIF(CAST("userId" AS text),\'\') FROM "DailyStudyRecord" WHERE ($1::text IS NULL OR "company"=$1) UNION SELECT NULLIF(CAST("yhid" AS text),\'\') FROM "CourseBinding" WHERE ($1::text IS NULL OR "company"=$1) UNION SELECT NULLIF(CAST("yhid" AS text),\'\') FROM "PracticeRecord" WHERE ($1::text IS NULL OR "company"=$1)) x WHERE uid IS NOT NULL',[companyId]),coachIds=new Set(coachRows.map((row)=>text(row.uid))),studentIds=new Set(studentRows.map((row)=>text(row.uid)));let conflicts=0;
-  for(const row of users){if(row.admin===true)continue;const uid=text(row.uid),gid=number(row.gid),coach=coachIds.has(uid),student=studentIds.has(uid),identity=gid===2?'store':coach&&student?'conflict':coach?'coach':student||gid===1?'member':'unknown',target=await new Parse.Query('_User').get(text(row.objectId),{useMasterKey:true});if(text(target.get('identityType'))!==identity){target.set('identityType',identity);await target.save(null,{useMasterKey:true});}if(identity==='conflict'){conflicts++;const existing=await Psql.oneOrNone('SELECT "objectId" FROM "LegacySyncConflict" WHERE "company"=$1 AND "dataset"=\'users\' AND "legacyKey"=$2 AND COALESCE("resolved",FALSE)=FALSE LIMIT 1',[companyId,uid]).catch(()=>null);if(!existing){const issue=new Parse.Object('LegacySyncConflict');issue.set('company',companyPointer(context,input));issue.set('sourceKey','cloud:legacy-sync-conflict:'+companyId+':user:'+uid);issue.set('dataset','users');issue.set('legacyKey',uid);issue.set('conflictType','identity');issue.set('message','账号同时出现在学员和陪练业务字段中');issue.set('resolved',false);await issue.save(null,{useMasterKey:true});}}}
+  // A confirmed coach can practise words; only actual student course evidence creates a dual-role conflict.
+  const companyId=companyIdOf(context,input),users=await Psql.query('SELECT "objectId",COALESCE(CAST("legacyUserId" AS text),"legacyUserData"->>\'UserID\') uid,COALESCE(CAST("legacyGroupId" AS text),"legacyUserData"->>\'GroupID\') gid,COALESCE("isAdmin",FALSE) admin FROM "_User" WHERE ($1::text IS NULL OR "company"=$1) AND ("isDeleted" IS NULL OR "isDeleted"=FALSE)',[companyId]),coachRows=await Psql.query('SELECT DISTINCT uid FROM (SELECT NULLIF(CAST("pl" AS text),\'\') uid FROM "CourseAppointment" WHERE ($1::text IS NULL OR "company"=$1) UNION SELECT NULLIF(CAST("fxpl" AS text),\'\') FROM "CourseAppointment" WHERE ($1::text IS NULL OR "company"=$1) UNION SELECT NULLIF(CAST("jsmz" AS text),\'\') FROM "LessonRecord" WHERE ($1::text IS NULL OR "company"=$1)) x WHERE uid IS NOT NULL',[companyId]),studentRows=await Psql.query('SELECT uid,bool_or(formal) AS formal FROM (SELECT NULLIF(CAST("szyh" AS text),\'\') uid,true AS formal FROM "CourseAppointment" WHERE ($1::text IS NULL OR "company"=$1) UNION ALL SELECT NULLIF(CAST("userId" AS text),\'\'),true FROM "DailyStudyRecord" WHERE ($1::text IS NULL OR "company"=$1) UNION ALL SELECT NULLIF(CAST("yhid" AS text),\'\'),true FROM "CourseBinding" WHERE ($1::text IS NULL OR "company"=$1) UNION ALL SELECT NULLIF(CAST("xymz" AS text),\'\'),true FROM "LessonRecord" WHERE ($1::text IS NULL OR "company"=$1) UNION ALL SELECT NULLIF(CAST("yhid" AS text),\'\'),false FROM "PracticeRecord" WHERE ($1::text IS NULL OR "company"=$1)) x WHERE uid IS NOT NULL GROUP BY uid',[companyId]),coachIds=new Set(coachRows.map((row)=>text(row.uid))),studentIds=new Set(studentRows.map((row)=>text(row.uid))),formalStudentIds=new Set(studentRows.filter((row)=>row.formal===true).map((row)=>text(row.uid)));let conflicts=0;
+  for(const row of users){if(row.admin===true)continue;const uid=text(row.uid),gid=number(row.gid),coach=coachIds.has(uid),student=studentIds.has(uid),identity=gid===2?'store':coach&&student&&(gid!==3||formalStudentIds.has(uid))?'conflict':coach?'coach':student||gid===1?'member':'unknown',target=await new Parse.Query('_User').get(text(row.objectId),{useMasterKey:true});if(text(target.get('identityType'))!==identity){target.set('identityType',identity);await target.save(null,{useMasterKey:true});}if(identity==='conflict'){conflicts++;const existing=await Psql.oneOrNone('SELECT "objectId" FROM "LegacySyncConflict" WHERE "company"=$1 AND "dataset"=\'users\' AND "legacyKey"=$2 AND COALESCE("resolved",FALSE)=FALSE LIMIT 1',[companyId,uid]).catch(()=>null);if(!existing){const issue=new Parse.Object('LegacySyncConflict');issue.set('company',companyPointer(context,input));issue.set('sourceKey','cloud:legacy-sync-conflict:'+companyId+':user:'+uid);issue.set('dataset','users');issue.set('legacyKey',uid);issue.set('conflictType','identity');issue.set('message','账号同时出现在学员和陪练业务字段中');issue.set('resolved',false);await issue.save(null,{useMasterKey:true});}}}
   return conflicts;
 }
 async function runSyncPage(context,input,internalAuthorized=false){

+ 70 - 0
scripts/tests/coach-identity-projection.test.mjs

@@ -0,0 +1,70 @@
+import { before, after, test } 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();
+const gateway = {};
+vm.createContext(gateway);
+const source = readFileSync(new URL('../deploy-admin-functions.mjs', import.meta.url), 'utf8');
+vm.runInContext(source.match(/const operationsGatewayCode = String.raw`([\s\S]*?)\n`;/)[1], gateway);
+gateway.companyIdOf = () => 'co';
+gateway.companyPointer = () => ({ id: 'co' });
+gateway.Psql = {
+  query: async (sql, args) => (await db.query(sql, args)).rows,
+  oneOrNone: async (sql, args) => (await db.query(sql, args)).rows[0] || null,
+};
+// Parse is external; SQL evidence queries and persisted identity changes use the real database.
+gateway.Parse = {
+  Query: class {
+    async get(id) {
+      const row = (await db.query('SELECT * FROM "_User" WHERE "objectId"=$1', [id])).rows[0];
+      return {
+        get: key => row[key], set: (key, value) => { row[key] = value; },
+        save: async () => db.query('UPDATE "_User" SET "identityType"=$2 WHERE "objectId"=$1', [id, row.identityType]),
+      };
+    }
+  },
+  Object: class { set() {} async save() {} },
+};
+before(async () => {
+  await db.exec(`
+    CREATE TABLE "_User"("objectId" text,company text,"legacyUserId" int,"legacyGroupId" int,"legacyUserData" jsonb,"isAdmin" boolean,"isDeleted" boolean,"identityType" text);
+    CREATE TABLE "CourseAppointment"(company text,pl text,fxpl text,szyh text);
+    CREATE TABLE "LessonRecord"(company text,jsmz text,xymz text);
+    CREATE TABLE "DailyStudyRecord"(company text,"userId" int);
+    CREATE TABLE "CourseBinding"(company text,yhid text);
+    CREATE TABLE "PracticeRecord"(company text,yhid text);
+    CREATE TABLE "LegacySyncConflict"("objectId" text,company text,dataset text,"legacyKey" text,resolved boolean);
+    INSERT INTO "_User" VALUES
+      ('teacher','co',1535,3,'{}',false,false,'conflict'),
+      ('student','co',2,1,'{}',false,false,'member'),
+      ('ambiguous','co',3,1,'{}',false,false,'conflict'),
+      ('dual-binding','co',4,3,'{}',false,false,'conflict'),
+      ('dual-lesson','co',5,3,'{}',false,false,'conflict'),
+      ('store','co',6,2,'{}',false,false,'store');
+    INSERT INTO "CourseAppointment" VALUES ('co','1535','1535','99'),('co','3',null,'99'),('co','4',null,'99'),('co','6',null,'6'),('other','99',null,'1535');
+    INSERT INTO "LessonRecord" VALUES ('co','5','5');
+    INSERT INTO "CourseBinding" VALUES ('co','4');
+    INSERT INTO "PracticeRecord" VALUES ('co','1535'),('co','2'),('co','3');
+  `);
+  await gateway.refreshIdentityProjection({}, {});
+});
+after(() => db.close());
+const identity = async id => (await db.query('SELECT "identityType" FROM "_User" WHERE "objectId"=$1', [id])).rows[0].identityType;
+
+test('正式老师自己的单词练习不再把老师误标为冲突,其他帐套学员证据不串入', async () => {
+  assert.equal(await identity('teacher'), 'coach');
+  await gateway.refreshIdentityProjection({}, {});
+  assert.equal(await identity('teacher'), 'coach');
+});
+test('仅练习的普通学员仍是学员,分组未经确认的双重身份仍需核实', async () => {
+  assert.equal(await identity('student'), 'member');
+  assert.equal(await identity('ambiguous'), 'conflict');
+});
+test('老师同时有正式学员课程或课后记录时仍保留冲突,门店角色不改变', async () => {
+  assert.equal(await identity('dual-binding'), 'conflict');
+  assert.equal(await identity('dual-lesson'), 'conflict');
+  assert.equal(await identity('store'), 'store');
+});