Explorar o código

fix(enrollment): restore historical word courses for scheduling

彭峰 hai 8 horas
pai
achega
790ca78c78

+ 34 - 0
docs/qa/legacy-enrollment-schedule-2026-11-07.md

@@ -0,0 +1,34 @@
+# 历史单词开课在排课中缺失的修复与发布
+
+北京时间 2026-11-07 00:06,生产应用 `7pIbDBJmKx_main` 已应用独立数据库补丁 `scripts/sql/legacy-enrollment-reconciliation.sql`。
+
+## 原因与范围
+
+课程管理的 `businessRows('course-bindings')` 展示历史开课记录,而排课的 `enrollmentBindings` 要求 `enrollmentStatus='active'`、有效主记录与有效内容。历史迁移和课程映射确认只在执行当时补齐分类/状态:学生后续重新启用不会重新激活待确认开课,直接旧库同步新增的开课也没有补齐新系统分类/状态。删除再开课会新建 `active` 记录,因此暂时绕过问题,同时存在重置原开课进度的风险。
+
+生产只读核对 1577 条开课,1574 条主记录与内容有效。其中 586 条 active、949 条 pending、39 条状态为空;不能将这些 pending/null 全量改为 active,大部分属于停用学生,另有缺失用户、重复开课和非单词分类。
+
+本次准确恢复 **74 条单词开课,涉及 46 位正常学生**:43 条 pending、31 条分类/状态缺失。保留原开课 objectId、业务编号、来源、词数和学习进度;仅补齐 word/单词课/active 并更新时间。没有新建或删除开课,没有修改学生状态、余额、预约、课时预留或工资。
+
+## 持续修复
+
+`xs_reconcile_legacy_word_enrollments` 只处理来源可确认的历史 pending/null 开课,并同时要求正常 member、有效主记录和内容、唯一已确认 word 映射、没有其他非回收的同分类开课。保留新系统开课、回收记录、重复历史记录、未确认/冲突分类及停用/删除用户。现有唯一索引继续保护同一学生、词库、分类的有效开课;并发显式开课胜出时保留其结果。
+
+五个字段范围受限的触发器覆盖 CourseBinding 与 CommonModel 的两种到达顺序、学生资料补入/重新启用、映射确认和内容恢复。激活操作不更新学习进度,active 行不再递归处理,重复迁移不产生额外变更。
+
+这是独立补丁,应在事务中应用并设置锁等待/执行超时;**不通过重新运行 `member-enrollment.sql` 或整个基础开课迁移来修复线上数据**,因为旧回填有更宽的行为边界。本次没有重新部署任何网关或前端,也没有覆盖同工作区其他任务的同步/财务修改。
+
+## 验证与发布
+
+- 先运行新增回归用例,确认原逻辑会在历史 pending/null、后续同步和学生重新启用场景失败;修复后新增 11 项 PGlite 用例通过。
+- 58 项相关测试全部通过:历史开课补齐、开课与课时业务、运营开课/排课网关、教学映射、教师选课和学习记录。补齐教师选课测试夹具缺失的 CourseAppointment.sourceKey 字段,使其符合现有生产例程。
+- 独立代码审查通过。审查发现的旧迁移拼接风险已通过保留独立补丁解决。
+- 真实 PostgreSQL 回滚事务验证:准确恢复 74 条,全部其他开课字段不变,所有用户的四类课时余额哈希不变;再次调用返回 0;事务内模拟学生停用、开课元数据缺失、重新启用后自动恢复,最终回滚。
+- 正式应用前备份开课数据及现有触发器,检查新例程不存在;在 repeatable-read 事务中设置 3 秒 lock_timeout 和 45 秒 statement_timeout 后应用。实际只更新相同 74 条开课,重复调用仍返回 0,余额哈希不变。
+- 发布后回读两个例程的正文并与源文件逐一匹配,确认五个触发器存在;74 条开课状态和原编号/学习进度逐一核对通过。
+- `xiaoshu/ops/gateway` 与 `xiaoshu/ops/gateway-v3` 的真实 `ops/schedule/calendar` 响应均包含全部 74 条恢复开课,归属 46 位学生,分类为 word、映射有效,均支持 30/60 分钟。各接口返回 658 条单词开课、788 条课程选项。
+- 没有通过浏览器手动新增预约,也没有代学生/老师实际开课、扣费或完课;本次验收覆盖数据库及线上排课接口。
+
+私有备份、候选回滚、正式发布和接口核对凭据保存在 `/Users/mac/.local/state/xiaoshu/cloud-releases/2026-11-06-enrollment-schedule/`(目录按排查开始日期命名)。临时管理函数在各次查询/发布后均已删除并回读确认。
+
+回滚时先移除本补丁的五个触发器及两个例程,再按私有发布凭据核对开课版本后恢复本次改动的分类/状态字段;发生后续学习或业务更新的开课必须单独核对,不能整行覆盖历史备份。

+ 96 - 0
scripts/sql/legacy-enrollment-reconciliation.sql

@@ -0,0 +1,96 @@
+-- Historical authorizations already have an identity and learning progress.
+-- Complete only an unambiguous, confirmed WORD classification; never recreate them.
+CREATE OR REPLACE FUNCTION xs_reconcile_legacy_word_enrollments(co text,uid text DEFAULT NULL,course text DEFAULT NULL)
+RETURNS integer LANGUAGE plpgsql AS $$
+DECLARE candidate record; changed integer=0; affected integer;
+BEGIN
+ FOR candidate IN
+  SELECT b."objectId" FROM "CourseBinding" b
+  WHERE b.company=co AND (uid IS NULL OR b.yhid=uid) AND (course IS NULL OR b.kcid=course)
+   AND COALESCE(b."enrollmentStatus",'pending')='pending'
+   AND (b."categoryKey" IS NULL OR b."categoryKey"='word')
+   AND (b."sourceKey" ~ '^legacy[:\-]' OR b."sourceKey" ~ '^\[\["(ID|GeneralID)",[0-9]+\]\]$')
+  ORDER BY b."objectId"
+ LOOP
+  BEGIN
+   UPDATE "CourseBinding" b SET "categoryKey"='word',"categoryName"='单词课',"enrollmentStatus"='active',"updatedAt"=now()
+   WHERE b.company=co AND b."objectId"=candidate."objectId"
+    AND COALESCE(b."enrollmentStatus",'pending')='pending'
+    AND (b."categoryKey" IS NULL OR b."categoryKey"='word')
+    AND (b."sourceKey" ~ '^legacy[:\-]' OR b."sourceKey" ~ '^\[\["(ID|GeneralID)",[0-9]+\]\]$')
+    AND EXISTS(SELECT 1 FROM "CommonModel" c WHERE c.company=b.company AND c."modelId"=58 AND c."itemId"=b.id AND c.status=99
+     AND (c."sourceKey" ~ '^legacy[:\-]' OR c."sourceKey" ~ '^\[\["(ID|GeneralID)",[0-9]+\]\]$'))
+    AND EXISTS(SELECT 1 FROM "Node" n WHERE n.company=b.company AND n."nodeId"::text=b.kcid AND COALESCE(n.zstatus,99)=99)
+    AND EXISTS(SELECT 1 FROM "_User" u WHERE u.company=b.company AND u."legacyUserId"::text=b.yhid
+     AND u."identityType"='member' AND NOT COALESCE(u."isDisabled",false) AND NOT COALESCE(u."isDeleted",false))
+    AND EXISTS(SELECT 1 FROM "CourseTeachingProfile" p WHERE p.company=b.company AND p."courseId"::text=b.kcid AND p.confirmed=true AND p."categoryKey"='word')
+    AND NOT EXISTS(SELECT 1 FROM "CourseTeachingProfile" p WHERE p.company=b.company AND p."courseId"::text=b.kcid AND p.confirmed=true AND p."categoryKey" IS DISTINCT FROM 'word')
+    AND NOT EXISTS(SELECT 1 FROM "CourseBinding" other WHERE other.company=b.company AND other.yhid=b.yhid AND other.kcid=b.kcid
+     AND other."objectId"<>b."objectId" AND COALESCE(other."enrollmentStatus",'pending')<>'recycled'
+     AND (other."categoryKey" IS NULL OR other."categoryKey"='word'));
+   GET DIAGNOSTICS affected=ROW_COUNT;
+   changed=changed+affected;
+  EXCEPTION WHEN unique_violation THEN
+   -- A concurrent explicit enrollment won the active-identity index. Keep its
+   -- new-system authorization and this historical record/progress unchanged.
+   NULL;
+  END;
+ END LOOP;
+ RETURN changed;
+END $$;
+
+CREATE OR REPLACE FUNCTION xs_legacy_word_enrollment_changed() RETURNS trigger LANGUAGE plpgsql AS $$
+DECLARE b record;
+BEGIN
+ IF TG_TABLE_NAME='CourseBinding' THEN
+  IF COALESCE(NEW."enrollmentStatus",'pending')='pending' AND (NEW."categoryKey" IS NULL OR NEW."categoryKey"='word') THEN
+   PERFORM xs_reconcile_legacy_word_enrollments(NEW.company,NEW.yhid,NEW.kcid);
+  END IF;
+ ELSIF TG_TABLE_NAME='CommonModel' THEN
+  IF NEW."modelId"=58 AND NEW.status=99 THEN
+   FOR b IN SELECT yhid,kcid FROM "CourseBinding" WHERE company=NEW.company AND id=NEW."itemId" LOOP
+    PERFORM xs_reconcile_legacy_word_enrollments(NEW.company,b.yhid,b.kcid);
+   END LOOP;
+  END IF;
+ ELSIF TG_TABLE_NAME='_User' THEN
+  IF NEW."identityType"='member' AND NOT COALESCE(NEW."isDisabled",false) AND NOT COALESCE(NEW."isDeleted",false) THEN
+   PERFORM xs_reconcile_legacy_word_enrollments(NEW.company,NEW."legacyUserId"::text,NULL);
+  END IF;
+ ELSIF TG_TABLE_NAME='CourseTeachingProfile' THEN
+  IF NEW.confirmed=true AND NEW."categoryKey"='word' THEN
+   PERFORM xs_reconcile_legacy_word_enrollments(NEW.company,NULL,NEW."courseId"::text);
+  END IF;
+ ELSIF TG_TABLE_NAME='Node' THEN
+  IF COALESCE(NEW.zstatus,99)=99 THEN
+   PERFORM xs_reconcile_legacy_word_enrollments(NEW.company,NULL,NEW."nodeId"::text);
+  END IF;
+ END IF;
+ RETURN NEW;
+END $$;
+
+DROP TRIGGER IF EXISTS xs_legacy_word_binding_changed ON "CourseBinding";
+CREATE TRIGGER xs_legacy_word_binding_changed AFTER INSERT OR UPDATE OF company,yhid,kcid,"categoryKey","enrollmentStatus","sourceKey" ON "CourseBinding"
+ FOR EACH ROW EXECUTE FUNCTION xs_legacy_word_enrollment_changed();
+DROP TRIGGER IF EXISTS xs_legacy_word_common_changed ON "CommonModel";
+CREATE TRIGGER xs_legacy_word_common_changed AFTER INSERT OR UPDATE OF company,"modelId","itemId",status,"sourceKey" ON "CommonModel"
+ FOR EACH ROW EXECUTE FUNCTION xs_legacy_word_enrollment_changed();
+DROP TRIGGER IF EXISTS xs_legacy_word_member_changed ON "_User";
+CREATE TRIGGER xs_legacy_word_member_changed AFTER INSERT OR UPDATE OF company,"legacyUserId","identityType","isDisabled","isDeleted" ON "_User"
+ FOR EACH ROW EXECUTE FUNCTION xs_legacy_word_enrollment_changed();
+DROP TRIGGER IF EXISTS xs_legacy_word_profile_changed ON "CourseTeachingProfile";
+CREATE TRIGGER xs_legacy_word_profile_changed AFTER INSERT OR UPDATE OF company,"courseId","categoryKey",confirmed ON "CourseTeachingProfile"
+ FOR EACH ROW EXECUTE FUNCTION xs_legacy_word_enrollment_changed();
+DROP TRIGGER IF EXISTS xs_legacy_word_node_changed ON "Node";
+CREATE TRIGGER xs_legacy_word_node_changed AFTER INSERT OR UPDATE OF company,"nodeId",zstatus ON "Node"
+ FOR EACH ROW EXECUTE FUNCTION xs_legacy_word_enrollment_changed();
+
+REVOKE ALL ON FUNCTION xs_reconcile_legacy_word_enrollments(text,text,text) FROM PUBLIC;
+REVOKE ALL ON FUNCTION xs_legacy_word_enrollment_changed() FROM PUBLIC;
+
+DO $$
+DECLARE tenant record;
+BEGIN
+ FOR tenant IN SELECT DISTINCT company FROM "CourseBinding" LOOP
+  PERFORM xs_reconcile_legacy_word_enrollments(tenant.company);
+ END LOOP;
+END $$;

+ 87 - 0
scripts/tests/legacy-enrollment-reconciliation.test.mjs

@@ -0,0 +1,87 @@
+import { test } from 'node:test';
+import assert from 'node:assert/strict';
+import { readFileSync } from 'node:fs';
+import { PGlite } from '@electric-sql/pglite';
+import vm from 'node:vm';
+
+const migration = new URL('../sql/legacy-enrollment-reconciliation.sql', import.meta.url);
+const sql = readFileSync(migration, 'utf8');
+const gateway = readFileSync(new URL('../cloud/member-enrollment.js', import.meta.url), 'utf8');
+async function fixture(run) {
+  const db = new PGlite();
+  try {
+    await db.exec(`
+      CREATE TABLE "_User" (company text,"legacyUserId" numeric,"identityType" text,"isDisabled" boolean,"isDeleted" boolean,"legacyUserData" jsonb);
+      CREATE TABLE "Node" (company text,"nodeId" numeric,zstatus numeric,"nodeName" text);
+      CREATE TABLE "CourseTeachingProfile" (company text,"courseId" numeric,"categoryKey" text,confirmed boolean);
+      CREATE TABLE "CommonModel" (company text,"modelId" numeric,"itemId" numeric,status numeric,"objectId" text,"sourceKey" text);
+      CREATE TABLE "CourseBinding" (company text,"objectId" text PRIMARY KEY,id numeric,yhid text,kcid text,"categoryKey" text,"categoryName" text,"enrollmentStatus" text,"sourceKey" text,yxx numeric,cksl numeric,syjd text,"updatedAt" timestamptz);
+      CREATE UNIQUE INDEX enrollment_active_identity ON "CourseBinding" (company,yhid,"categoryKey",kcid) WHERE "enrollmentStatus"='active';
+      INSERT INTO "_User" VALUES ('co',20,'member',false,false,'{"Purse":10}');
+      INSERT INTO "Node" VALUES ('co',100,99,'词表');
+      INSERT INTO "CourseTeachingProfile" VALUES ('co',100,'word',true);
+    `);
+    const binding = async (id, status = 'pending', category = 'word', source = `legacy:model:58:general:${id}`) => db.query(
+      `INSERT INTO "CourseBinding" VALUES ('co',$1,$2,'20','100',$3,'单词课',$4,$5,123,500,'{"101":"101@第一本@1"}',now())`,
+      [String(id),id,category,status,source]);
+    const common = async (id, status = 99) => db.query(`INSERT INTO "CommonModel" VALUES ('co',58,$1,$2,$3,$4)`, [id,status,'c'+id,`legacy:model:58:general:${id}`]);
+    const get = async id => (await db.query('SELECT * FROM "CourseBinding" WHERE "objectId"=$1',[String(id)])).rows[0];
+    const install = () => db.exec(sql);
+    await run({db,binding,common,get,install});
+  } finally { await db.close(); }
+}
+
+test('历史待确认开课恢复后进入真实排课查询,保留编号、进度和余额,重复迁移无变化', () => fixture(async ({db,binding,common,get,install}) => {
+  await binding(1); await common(1); const before = await get(1);
+  await install();
+  const after = await get(1);
+  assert.equal(after.enrollmentStatus, 'active');
+  for (const key of ['objectId','id','sourceKey','yxx','cksl','syjd']) assert.equal(after[key],before[key]);
+  const c = { Psql: { query: async (s,p) => (await db.query(s,p)).rows }, companyIdOf: () => 'co', text: x => String(x ?? ''), number: Number, TEACHING_CATEGORY_LABELS: new Map([['word','单词课']]) };
+  vm.createContext(c); vm.runInContext(gateway,c);
+  const courses = await c.enrollmentBindings({}, {},20);
+  assert.equal(courses.length,1); assert.equal(courses[0].bindingId,'1');
+  await install(); assert.deepEqual(await get(1),after);
+  assert.deepEqual((await db.query('SELECT "legacyUserData" FROM "_User"')).rows[0].legacyUserData,{Purse:10});
+}));
+
+for (const order of ['binding-first','common-first']) test(`未来同步两张表按${order}顺序写入时自动激活`, () => fixture(async ({binding,common,get,install}) => {
+  await install();
+  if(order==='binding-first'){await binding(1,null,null);assert.equal((await get(1)).enrollmentStatus,null);await common(1);}
+  else {await common(1);await binding(1,null,null);}
+  assert.equal((await get(1)).enrollmentStatus,'active'); assert.equal((await get(1)).categoryKey,'word');
+}));
+
+test('停用学生保留原状,重新启用后无需删除重开;恢复删除的学生同样生效', () => fixture(async ({db,binding,common,get,install}) => {
+  await db.exec('UPDATE "_User" SET "isDisabled"=true,"isDeleted"=true');
+  await binding(1);await common(1);await install();
+  assert.equal((await get(1)).enrollmentStatus,'pending');
+  await db.exec('UPDATE "_User" SET "isDisabled"=false');assert.equal((await get(1)).enrollmentStatus,'pending');
+  await db.exec('UPDATE "_User" SET "isDeleted"=false');assert.equal((await get(1)).enrollmentStatus,'active');
+}));
+
+test('后续补入学生资料、确认映射和恢复内容时补齐开课', () => fixture(async ({db,binding,common,get,install}) => {
+  await db.exec('DELETE FROM "_User";UPDATE "CourseTeachingProfile" SET confirmed=false;UPDATE "Node" SET zstatus=0');
+  await binding(1,null,null);await common(1);await install();
+  await db.exec(`INSERT INTO "_User" VALUES ('co',20,'member',false,false,'{}');UPDATE "CourseTeachingProfile" SET confirmed=true`);
+  assert.equal((await get(1)).enrollmentStatus,null);
+  await db.exec('UPDATE "Node" SET zstatus=99');assert.equal((await get(1)).enrollmentStatus,'active');
+}));
+
+test('重复历史开课、已存在有效开课、回收记录及新系统记录保持原状', () => fixture(async ({db,binding,common,get,install}) => {
+  await binding(1);await common(1);await binding(2);await common(2);await install();
+  assert.equal((await get(1)).enrollmentStatus,'pending');assert.equal((await get(2)).enrollmentStatus,'pending');
+  await db.exec(`UPDATE "CourseBinding" SET "enrollmentStatus"='active' WHERE "objectId"='2'`);
+  assert.equal((await get(1)).enrollmentStatus,'pending');
+  await binding(3,'recycled');await common(3,-2);assert.equal((await get(3)).enrollmentStatus,'recycled');
+  await db.exec('DELETE FROM "CourseBinding";DELETE FROM "CommonModel"');
+  await binding(4,'pending','word','enroll:4');await common(4);assert.equal((await get(4)).enrollmentStatus,'pending');
+}));
+
+for(const kind of ['unconfirmed','conflicting-profile','explicit-other-category','deleted-common','foreign-tenant']) test(`${kind}不自动授权`, () => fixture(async ({db,binding,common,get,install}) => {
+  if(kind==='unconfirmed')await db.exec('UPDATE "CourseTeachingProfile" SET confirmed=false');
+  if(kind==='conflicting-profile')await db.exec(`INSERT INTO "CourseTeachingProfile" VALUES ('co',100,'trial',true)`);
+  if(kind==='foreign-tenant')await db.exec(`UPDATE "CourseTeachingProfile" SET company='other'`);
+  await binding(1,'pending',kind==='explicit-other-category'?'trial':null);await common(1,kind==='deleted-common'?-2:99);await install();
+  assert.equal((await get(1)).enrollmentStatus,'pending');
+}));

+ 1 - 1
scripts/tests/lesson-course-selection.test.mjs

@@ -15,7 +15,7 @@ let version;before(async()=>{
  INSERT INTO "CommonModel"("objectId",company,"modelId","itemId","generalId",status) VALUES('appointment','co',54,1,901,99);
  INSERT INTO "CourseAppointment" VALUES('addon','co',1,'20','30','100','100','0',to_char(now() AT TIME ZONE 'Asia/Shanghai','YYYY-MM-DD'),'','', 'b1',false,now(),'ready','[{"account":"Purse","amount":1}]',37);
  INSERT INTO "CourseCreditReservation" VALUES('co',20,'appointment','reserved','[{"account":"Purse","amount":1}]');
- ALTER TABLE "CourseAppointment" ADD COLUMN "courseCategoryKey" text DEFAULT 'word';`);
+ ALTER TABLE "CourseAppointment" ADD COLUMN "courseCategoryKey" text DEFAULT 'word', ADD COLUMN "sourceKey" text;`);
  const enrollment=read('../sql/member-enrollment.sql');for(const name of ['xs_appointment_day','xs_appointment_valid_at','xs_reserved','xs_course_size','xs_validate_appointment','xs_start_appointment'])await db.exec(enrollment.match(new RegExp('CREATE OR REPLACE FUNCTION '+name+'\\([\\s\\S]*?\\$\\$;'))[0]);
  await db.exec(read('../sql/lesson-workflow.sql').match(/CREATE OR REPLACE FUNCTION xs_start_appointment_for_actor\([\s\S]*?\$\$;/)[0]);
  const app=source.match(/const appGatewayCode = String.raw`([\s\S]*?)\n`;/)[1].replace("const DEFAULT_COMPANY_ID = '7pIbDBJmKx'","const DEFAULT_COMPANY_ID = 'co'");vm.runInContext(app+read('../cloud/lesson-workflow.js')+read('../cloud/lesson-courses.js'),c);version=(await one('SELECT "updatedAt"::text v FROM "CourseAppointment"')).v;