Procházet zdrojové kódy

fix: deploy lesson workflow with complete SQL dependencies and numeric reviews

彭峰 před 3 hodinami
rodič
revize
983780f61f

+ 37 - 0
docs/migration/lesson-workflow-release-2026-10-12.md

@@ -0,0 +1,37 @@
+# 2026-10-12 课次流程云函数生产发布验收
+
+- 验收时间:2026-10-12T12:12:13+08:00(Asia/Shanghai)。
+- 目标:`https://server.xiaoshu.pro`,应用 `7pIbDBJmKx_main`。
+- 发布前实时版本:`2026-09-mobile-teaching-v1`。
+- 发布后实时版本:`2026-10-11-lesson-workflow-v2`。
+
+## 已部署云函数
+
+| 路径 | Function ID | 发布后代码 SHA-256 |
+| --- | --- | --- |
+| `xiaoshu/ops/gateway-v3` | `BG8QMhGY4f` | `f8af26fc0a61e9d1ce2e9617d1fb87d931961deb69cd3e85b5fa718631887b75` |
+| `xiaoshu/ops/gateway` | `qp12CGLe9m` | `f8af26fc0a61e9d1ce2e9617d1fb87d931961deb69cd3e85b5fa718631887b75` |
+| `xiaoshu/app/gateway` | `D3F5Zj8saa` | `d46ea81835f4d5d32a21f011b31cf1b465676d2ae83fb52baea60af16dcaf5e3` |
+
+## 数据库迁移与修复
+
+生产原本缺少课次流程的三张表、相关数据库函数和状态回退保护触发器,也缺少 `xs_appointment_day` / `xs_appointment_valid_at`。已运行修正后的 `scripts/migrate-lesson-workflow.mjs --apply`,补齐依赖和课次流程对象,未回填历史业务数据。
+
+首次真实验收发现 `LessonRecord.pf` 在线上是 numeric 类型。已修复 SQL 的空值判断与评分写入类型,并在本地使用 numeric 类型覆盖首次评价和重复评价。最终再次读取线上函数定义,确认评分修复已实际安装。
+
+## 验证结果
+
+- `cloud:validate`:11 个函数定义通过语法检查。
+- `test:lesson-workflow`:9 / 9 通过,包括 numeric 评分列、失败事务回滚、越权、幂等、日期和旧流程回退保护。
+- `smoke-mobile-teaching-flow.mjs`:生产隔离账号 40 项检查全部通过,输出 `ONLINE_SMOKE_PASSED`、`FIXTURES_CLEANED`。
+- `migration_status.implemented` 已包含 `app_lesson_complete` 和 `app_lesson_review`。
+- 真实课次详情已验证 `allowedActions` 为数组、`blockedReason` 为字符串、`validToday` 为布尔值、`serverNow` 为可解析时间。
+- 同日提前开课、并发结课仅扣一次、报告可读、学生仅首次评价生效、后台唯一上课记录与消费预留对账均通过。
+- 取消预约、其他用户越权、旧客户端回退均拒绝;充值与结课并发余额无覆盖、最后一节课竞争和退款预留互斥均通过。
+- 本次临时迁移/核验函数已删除,隔离业务记录和用户已清理。审计记录保留。
+
+## 证据与边界
+
+部署前函数备份、数据库函数定义与最终验收日志保存在本机受限目录 `/Users/mac/.local/state/xiaoshu/cloud-releases/2026-10-12-lesson-workflow-v2/`。不包含执行凭据。
+
+本次完成的是生产云函数与数据库课次流程发布、接口和事务验收。没有执行前端发布、真机验收或旧系统同步桥部署。

+ 7 - 3
scripts/migrate-lesson-workflow.mjs

@@ -3,9 +3,13 @@ import { readFileSync } from 'node:fs';
 import { randomBytes } from 'node:crypto';
 import { request as httpsRequest } from 'node:https';
 const enrollment=readFileSync(new URL('./sql/member-enrollment.sql',import.meta.url),'utf8');
-const startFunction=enrollment.match(/CREATE OR REPLACE FUNCTION xs_start_appointment[\s\S]*?END \$\$;/)?.[0];
-if(!startFunction)throw Error('未找到开课事务');
-const sql=readFileSync(new URL('./sql/lesson-workflow.sql',import.meta.url),'utf8')+'\n'+startFunction;
+// Existing installations may predate whole-day appointment validation.
+const prerequisites=['xs_appointment_day','xs_appointment_valid_at','xs_start_appointment'].map(name=>{
+ const definition=enrollment.match(new RegExp('CREATE OR REPLACE FUNCTION '+name+'\\([\\s\\S]*?\\$\\$;'))?.[0];
+ if(!definition)throw Error('未找到课次流程依赖:'+name);
+ return definition;
+});
+const sql=prerequisites.join('\n')+'\n'+readFileSync(new URL('./sql/lesson-workflow.sql',import.meta.url),'utf8');
 if(!process.argv.includes('--apply')) {
   console.log('只读检查:结课与评价迁移脚本已就绪。使用 --apply 执行;不修改历史教学或工资数据。');
   process.exit(0);

+ 2 - 1
scripts/smoke-mobile-teaching-flow.mjs

@@ -21,7 +21,7 @@ try{
  const existingSessions=(await parse('/classes/_Session?include=user&limit=1000&order=-updatedAt')).results;
  const authorizer=existingSessions.find(s=>s.user?.adminRoleKey==='super-admin'&&new Date(s.expiresAt.iso)>new Date());if(!authorizer)throw Error('缺少管理员会话');state.admin={objectId:authorizer.user.objectId,sessionToken:authorizer.sessionToken};
  const store=await user('store',2),coach=await user('coach',3),empty=await user('member',4);
- const created=(await mutate('ops/users/create','',{displayName:'发布验收学员',nickname:'发布验收学员',username:'release_member_'+suffix,mobile:'199'+String(suffix).slice(-8),parentUserId:store.legacyUserId,password})).data;
+ const created=(await mutate('ops/users/create','',{displayName:'发布验收学员',nickname:'发布验收学员',username:'release_member_'+suffix,email:'release_member_'+suffix+'@example.invalid',mobile:'199'+String(suffix).slice(-8),parentUserId:store.legacyUserId,password})).data;
  const student={objectId:created.objectId,legacyUserId:Number(created.userId),kind:'created-member'};state.users.push(student);persist();
  await installHelper();expect(!!created.objectId&&student.legacyUserId>0,'创建学员并关联门店');
  const zero=(await ops('ops/course-bindings/options',{studentId:empty.legacyUserId,categoryKey:'word'})).data;expect(!zero.eligible,'零余额开课资格被拒绝');
@@ -49,6 +49,7 @@ try{
  const beforeVersion=(await call('xiaoshu/app/gateway',coach.sessionToken,{action:'app_live_versions',source:'database'})).data;
  await call('xiaoshu/app/gateway',coach.sessionToken,{action:'e_order_update_v2',status:10,content:JSON.stringify({GeneralID:generalId})});
  const classroom=(await call('xiaoshu/app/gateway',coach.sessionToken,{action:'e_order_detail',id:generalId})).data[0];
+ 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;

+ 2 - 2
scripts/sql/lesson-workflow.sql

@@ -134,9 +134,9 @@ BEGIN
  IF FOUND THEN RETURN to_jsonb(existing)||jsonb_build_object('unchanged',true,'status',a.dszt::int);END IF;
  IF (SELECT count(*) FROM "LessonRecord" WHERE company=co AND yyds::text=gid)<>1 THEN RAISE EXCEPTION '上课记录缺失或重复,请由后台核对';END IF;
  SELECT * INTO l FROM "LessonRecord" WHERE company=co AND yyds::text=gid FOR UPDATE;
- IF COALESCE(l.pf,'') ~ '^[1-5](\.0+)?$' THEN RETURN jsonb_build_object('score',l.pf,'comment',l.pjnr,'unchanged',true,'status',a.dszt::int);END IF;
+ IF COALESCE(l.pf::text,'') ~ '^[1-5](\.0+)?$' THEN RETURN jsonb_build_object('score',l.pf,'comment',l.pjnr,'unchanged',true,'status',a.dszt::int);END IF;
  INSERT INTO "LessonReview" VALUES(co,gid,actor_uid,rating,review_comment,now()) RETURNING * INTO existing;
- UPDATE "LessonRecord" SET pf=rating::text,pjnr=review_comment,"teachingManaged"=true,"updatedAt"=now() WHERE "objectId"=l."objectId";
+ UPDATE "LessonRecord" SET pf=rating,pjnr=review_comment,"teachingManaged"=true,"updatedAt"=now() WHERE "objectId"=l."objectId";
  UPDATE "CourseAppointment" SET "teachingManaged"=true,"updatedAt"=now() WHERE "objectId"=a."objectId";
  UPDATE "CommonModel" SET "teachingManaged"=true,"updatedAt"=now() WHERE company=co AND (("modelId"=54 AND "itemId"=a.id) OR ("modelId"=59 AND "itemId"=l.id));
  RETURN to_jsonb(existing)||jsonb_build_object('status',a.dszt::int);

+ 11 - 2
scripts/tests/lesson-workflow.test.mjs

@@ -29,7 +29,7 @@ before(async()=>{
  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 text,ADD pjnr text,ADD pldp text,ADD plpjsj text;
+ 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);
@@ -90,7 +90,7 @@ test('旧11/20课次复用已扣凭据与评价,缺凭据不补扣',async()=>{
  await db.query(`UPDATE "LessonRecord" SET pf='4',pjnr='原评价' WHERE yyds=$1`,[a.gid]);
  await db.query(`UPDATE "CourseAppointment" SET dszt='20' WHERE "objectId"=$1`,[a.objectId]);
  const v=(await one('SELECT "updatedAt" FROM "CourseAppointment" WHERE "objectId"=$1',[a.objectId])).updatedAt,before=await balance();
- assert.equal((await complete(a.gid,v,'legacy-request-001')).status,30);assert.equal(await balance(),before);assert.equal((await review(a.gid)).score,'4');
+ assert.equal((await complete(a.gid,v,'legacy-request-001')).status,30);assert.equal(await balance(),before);assert.equal(Number((await review(a.gid)).score),4);
  const broken=await appointment();await db.query(`UPDATE "CourseAppointment" SET dszt='11' WHERE "objectId"=$1`,[broken.objectId]);
  await assert.rejects(complete(broken.gid,broken.updatedAt,'legacy-request-002'),/历史凭据/);assert.equal(await balance(),before);
 });
@@ -102,6 +102,15 @@ test('评价原子拒绝无效评分与超长文字',async()=>{
  assert.equal(Number((await one('SELECT count(*) n FROM "LessonReview" WHERE "appointmentGeneralId"=$1',[a.gid])).n),1);
 });
 
+test('线上数值型评分列允许首次评价并保留第一次评分',async()=>{
+ const schema=await one("SELECT data_type FROM information_schema.columns WHERE table_name='LessonRecord' AND column_name='pf'");
+ assert.equal(schema.data_type,'numeric');
+ const a=await appointment();await complete(a.gid,a.updatedAt,'numeric-review-request-001');
+ assert.equal((await review(a.gid,5,'首次评价')).score,5);
+ assert.equal((await review(a.gid,1,'重复评价')).score,5);
+ assert.equal(Number((await one('SELECT pf FROM "LessonRecord" WHERE yyds=$1',[a.gid])).pf),5);
+});
+
 test('同日提前开课、午夜后继续、非预约当天与改派老师在数据库校验',async()=>{
  const a=await appointment(false);
  await db.query(`UPDATE "CourseAppointment" SET sdsd='23:59',yysj=bxrq||' 23:59' WHERE "objectId"=$1`,[a.objectId]);