Browse Source

fix: align schedule version precision and record production web release

彭峰 1 week ago
parent
commit
4f624a0320

+ 1 - 1
docs/qa/store-scope-phase-two-2026-10-15.md

@@ -44,6 +44,6 @@
 
 真实基线、哈希和验证结果保存在本机受保护目录:`/Users/mac/.local/state/xiaoshu/cloud-releases/2026-10-15-store-scope/`。不将凭据、完整线上用户数据或私有函数快照提交到 Git。
 
-正式前端站点尚未上传本轮静态构建:仓库现有 `release-admin-web.mjs` 仅提供打包和回读校验,没有配置站点上传入口。新页面已在本地管理端可用,并提供按提交版本打包的产物;后端已生效不能等同于生产前端已上线。未进行手机或微信 WebView 验收,也未提交真实生产充值、学生创建或排课。
+正式前端已于 2026-10-15 发布到 `https://admin.xiaoshu.pro/`,前端提交为 `817b561a537fab1b0411f0581aa61200bef125a6`,发布编号 `817b561a537f-20261015T124317689Z`。后续发布及隔离测试结果见 [正式前端发布验收](store-web-release-2026-10-15.md)。手机及微信 WebView 尚未验收。
 
 回滚时先暂停门店业务访问,再基于实际发布基线恢复本次函数段,核对线上哈希以免覆盖后续发布。保留原有余额、凭据、审计和防重约束。直接 REST 权限保持关闭,不为兼容旧页面恢复公共写入。新增数据库守卫函数可保留;需要删除时先确认没有在线调用方。不得通过删交易或重写余额回滚。

+ 43 - 0
docs/qa/store-web-release-2026-10-15.md

@@ -0,0 +1,43 @@
+# 正式管理端发布与门店流程验收
+
+## 发布结果
+
+- 正式入口:`https://admin.xiaoshu.pro/admin/login`。
+- 华为云 CDN 域名详情确认源站为 `xiaoshu-admin.obs-website.cn-south-1.myhuaweicloud.com`,对应 OBS 桶 `xiaoshu-admin`,区域 `cn-south-1`。这不是学生端站点。
+- 前端提交:`817b561a537fab1b0411f0581aa61200bef125a6`;版本号:`817b561a537f-20261015T124317689Z`。
+- 发布前备份桶内 362 个对象,共约 19.14 MB。旧入口 MD5 为 `62a1fd2a225e4f1c1cae58587cf6d9ef`,与公网原入口一致。
+- 先上传资源,沿用旧站点静态对象的 `public-read` 权限;源站校验后上传版本清单,最后切换 `index.html`。旧哈希资源保留。
+- CDN 目录刷新任务:`7892998146`,回读状态 `task_done`,成功 1、失败 0。
+- `node scripts/release-admin-web.mjs --verify` 校验公网 95 个文件的 SHA-256、版本清单和登录/工资深层路由成功。
+- 另行不带查询参数读取 `/`、`/admin/login`、`/admin/store-home`、`/admin/schedule`,均为 HTTP 200 且入口哈希与本次发布相同。
+
+## 真实接口流程
+
+在正式后端创建明确标记的隔离测试门店、老师和学生,使用测试门店真实密码调用正式登录接口,返回 `store-admin`。没有修改真实门店密码,也没有使用临时会话冒充密码登录验证。
+
+通过实际业务接口验证:
+
+1. 总部给测试门店记入 1 节 30 分钟课时及 0.5 小时陪练额度,门店划拨给测试学生。无真实收款。
+2. 门店开通单词课,排课选项只包含本店测试学生、老师。
+3. 创建 2026-10-16 的一节测试预约,使用同一请求号重试后仍只有一节。正确预留 1 节课时及 0.5 小时陪练。
+4. 指定外店学生的排课请求被拒绝。
+5. 修改时间成功;使用修改前版本取消,被拒绝为过期请求。
+6. 使用最新版本取消,预留全部释放;恢复后重新预留;再次取消后预留归零。
+
+验收发现数据库 `updatedAt` 有微秒,而前端接口通过 JavaScript Date 返回毫秒;原 SQL 精确比较使新预约修改/取消误报“预约已变更”。已修改两个版本判断,与 API 的毫秒精度一致,仍保留过期版本拒绝。生产通过 `scripts/sql/schedule-version-precision.sql` 只替换现有函数的比较表达式,未覆盖其他线上热修复;更新前备份函数,更新后核对定义只发生这两处变化。
+
+## 清理与回归
+
+- 测试学生额度按原单退回,测试门店入账按原凭据冲销;双方四类余额均为零,预约预留为零。
+- 测试课程回收,三个测试账号标记删除并停用,所有测试会话撤销。保留业务流水及操作审计,不删除或改写真实交易。
+- 本轮定向测试 34 项通过:开课/课时事务、门店隔离、发布校验。包含微秒时间经 JSON 返回后的编辑/取消/恢复、拒绝旧版本、精度迁移重复执行。
+- 发布后再次运行 35 项线上权限检查通过;两个现有门店的学生范围仍隔离,总部会员总数仍为 628。门店 287 尚无本店有效老师,仍需总部配置老师归属后才能指定老师排课;门店 100 有 35 位本店有效老师。
+- 正式浏览器可读取运营工作台,但浏览器自动化出现持续超时,未完成门店表单的浏览器点击全流程。上述登录及排课验收来自正式 HTTP 接口与数据回读,不能替代完整浏览器交互验收。手机及微信 WebView 未测试。
+
+## 后续发布与回滚
+
+仓库的 `scripts/release-admin-web.mjs` 负责打包和验证。上传使用官方 `obsutil`,CDN 使用 `hcloud CDN CreateRefreshTasks/v2`,凭据从受保护的现有配置读取,不写入仓库或静态包。
+
+已确认的上传目标为 `obs://xiaoshu-admin/`,endpoint 为 `obs.cn-south-1.myhuaweicloud.com`。后续发布仍应先备份、上传资源并校验,再更新版本清单和入口,刷新 `https://admin.xiaoshu.pro/` 目录,最后运行公网校验。不得执行清空整个桶的命令。
+
+本次完整基线、上传记录、函数定义、接口验收及清理结果保存在本机受保护目录 `/Users/mac/.local/state/xiaoshu/cloud-releases/2026-10-15-store-web/`。回滚前端时恢复基线中的入口及非哈希资源,再刷新 CDN;保留旧新两套哈希资源。数据库回滚仅基于保存的两个函数定义并确认没有后续修改,不触碰课时、凭据、审计或门店隔离约束。

+ 3 - 2
scripts/sql/member-enrollment.sql

@@ -284,7 +284,8 @@ BEGIN
   SELECT * INTO old_c FROM "CommonModel" WHERE "company"=co AND "objectId"=edit_id AND "modelId"=54;
   SELECT * INTO old_a FROM "CourseAppointment" WHERE "company"=co AND "id"=old_c."itemId";
   IF old_a."objectId" IS NULL OR old_a."dszt"::text<>'0' OR old_c."status"=-2 THEN RAISE EXCEPTION '只有未开始的预约可以修改';END IF;
-  IF expected<>'' AND old_c."updatedAt"<>expected::timestamptz THEN RAISE EXCEPTION '预约已修改,请刷新';END IF;
+  -- API dates are serialized through JavaScript Date (millisecond precision).
+  IF expected<>'' AND date_trunc('milliseconds',old_c."updatedAt")<>expected::timestamptz THEN RAISE EXCEPTION '预约已修改,请刷新';END IF;
  END IF;
  -- Account locks precede reservation locks. Stable order handles changing an appointment's student.
  PERFORM 1 FROM "_User" WHERE "company"=co AND ("legacyUserId"::text=old_a."szyh"::text OR "legacyUserId"::text IN(SELECT value->'addon'->>'szyh' FROM jsonb_array_elements(entries))) ORDER BY "objectId" FOR UPDATE;
@@ -323,7 +324,7 @@ DECLARE c "CommonModel"; a "CourseAppointment"; u "_User"; requested_start times
 BEGIN
  PERFORM pg_advisory_xact_lock(hashtext('xs-credit:'||co));
  SELECT * INTO c FROM "CommonModel" WHERE "company"=co AND "objectId"=aid AND "modelId"=54;
- IF expected<>'' AND c."updatedAt"<>expected::timestamptz THEN RAISE EXCEPTION '预约已变更,请刷新';END IF;
+ IF expected<>'' AND date_trunc('milliseconds',c."updatedAt")<>expected::timestamptz THEN RAISE EXCEPTION '预约已变更,请刷新';END IF;
  SELECT * INTO a FROM "CourseAppointment" WHERE "company"=co AND "id"=c."itemId";
  SELECT * INTO u FROM "_User" WHERE "company"=co AND "legacyUserId"::text=a."szyh"::text FOR UPDATE;
  SELECT * INTO a FROM "CourseAppointment" WHERE "objectId"=a."objectId" FOR UPDATE;

+ 18 - 0
scripts/sql/schedule-version-precision.sql

@@ -0,0 +1,18 @@
+-- Patch only the version predicates, retaining deployed function hotfixes.
+-- API updatedAt uses JavaScript Date, which truncates PostgreSQL microseconds.
+DO $$
+DECLARE fn regprocedure; definition text; old_predicate text; new_predicate text;
+BEGIN
+ FOREACH fn IN ARRAY ARRAY[
+  'xs_schedule_save(text,text,jsonb,jsonb,text,text,text)'::regprocedure,
+  'xs_schedule_cancel(text,text,boolean,text,text,text)'::regprocedure
+ ] LOOP
+  definition=pg_get_functiondef(fn);
+  old_predicate=CASE WHEN fn::text LIKE 'xs_schedule_save(%' THEN 'old_c."updatedAt"<>expected::timestamptz' ELSE 'c."updatedAt"<>expected::timestamptz' END;
+  new_predicate=replace(old_predicate,'<>expected',')<>expected');
+  new_predicate='date_trunc(''milliseconds'','||new_predicate;
+  IF strpos(definition,new_predicate)>0 THEN CONTINUE;END IF;
+  IF strpos(definition,old_predicate)=0 THEN RAISE EXCEPTION 'Unknown schedule version predicate: %',fn;END IF;
+  EXECUTE replace(definition,old_predicate,new_predicate);
+ END LOOP;
+END $$;

+ 13 - 4
scripts/tests/member-enrollment.test.mjs

@@ -26,7 +26,10 @@ 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(`ALTER TABLE "Node" ADD COLUMN "sourceKey" text, ADD COLUMN "createdAt" timestamptz;`);
- await db.exec(sql);
+ const oldVersionSql=sql.replaceAll(/date_trunc\('milliseconds',(old_c|c)\."updatedAt"\)/g,'$1."updatedAt"');
+ await db.exec(oldVersionSql);
+ const precisionPatch=readFileSync(new URL('../sql/schedule-version-precision.sql',import.meta.url),'utf8');
+ await db.exec(precisionPatch);await db.exec(precisionPatch);
  const workflow=readFileSync(new URL('../sql/lesson-workflow.sql',import.meta.url),'utf8');
  for(const name of ['xs_start_appointment_for_actor','xs_admin_lesson_correction'])await db.exec(workflow.match(new RegExp('CREATE OR REPLACE FUNCTION '+name+'[\\s\\S]*?END \\$\\$;'))[0]);
 });
@@ -84,9 +87,15 @@ test('排课保存、修改、取消恢复均同步预留,重试不重复',asy
  const entry=scheduleEntry(b,'2030-01-01 10:00');const saved=await saveSchedule([entry],'schedule-001');const again=await saveSchedule([entry],'schedule-001');assert.deepEqual(again,saved);
  const aid=saved.targetIds[0];assert.equal((await one('SELECT "state" FROM "CourseCreditReservation" WHERE "appointmentId"=$1',[aid])).state,'reserved');
  await assert.rejects(saveSchedule([scheduleEntry(b,'2030-01-01 10:10')],'schedule-conflict'),/冲突/);
- await saveSchedule([scheduleEntry(b,'2030-01-01 11:00')],'schedule-update',aid);
- await query('SELECT xs_schedule_cancel(\'co\',$1,false,\'admin\',\'测试取消\')',[aid]);assert.equal((await one('SELECT "state" FROM "CourseCreditReservation" WHERE "appointmentId"=$1',[aid])).state,'released');
- await query('SELECT xs_schedule_cancel(\'co\',$1,true,\'admin\',\'测试恢复\')',[aid]);assert.equal((await one('SELECT "state" FROM "CourseCreditReservation" WHERE "appointmentId"=$1',[aid])).state,'reserved');
+ // Reproduce actual PostgreSQL microseconds round-tripped through a JSON Date.
+ await query('UPDATE "CommonModel" SET "updatedAt"=\'2026-10-15T13:34:39.758987Z\' WHERE "objectId"=$1',[aid]);
+ const version=async()=>(await one('SELECT "updatedAt" FROM "CommonModel" WHERE "objectId"=$1',[aid])).updatedAt.toISOString();
+ const initial=await version();assert.equal(initial,'2026-10-15T13:34:39.758Z');
+ await saveSchedule([scheduleEntry(b,'2030-01-01 11:00')],'schedule-update',aid,initial);
+ await assert.rejects(saveSchedule([scheduleEntry(b,'2030-01-01 12:00')],'schedule-stale',aid,initial),/已修改/);
+ await assert.rejects(query('SELECT xs_schedule_cancel(\'co\',$1,false,\'admin\',\'测试取消\',$2)',[aid,initial]),/已变更/);
+ await query('SELECT xs_schedule_cancel(\'co\',$1,false,\'admin\',\'测试取消\',$2)',[aid,await version()]);assert.equal((await one('SELECT "state" FROM "CourseCreditReservation" WHERE "appointmentId"=$1',[aid])).state,'released');
+ await query('SELECT xs_schedule_cancel(\'co\',$1,true,\'admin\',\'测试恢复\',$2)',[aid,await version()]);assert.equal((await one('SELECT "state" FROM "CourseCreditReservation" WHERE "appointmentId"=$1',[aid])).state,'reserved');
  await query('SELECT xs_schedule_cancel(\'co\',$1,false,\'admin\',\'测试取消\')',[aid]);
 });
 test('批量排课超余额整体回滚,不留下半张预约或预留',async()=>{