Forráskód Böngészése

fix: harden backend migration and verify live launch release

彭峰 7 órája
szülő
commit
f82356bc3c

+ 44 - 0
docs/qa/launch-backend-release-2026-10-13/README.md

@@ -0,0 +1,44 @@
+# 配套后端发布与真实数据验证(2026-10-13)
+
+用户追加授权直接发布云函数和数据库迁移;前端仍由用户发布。本次接续 `bf7c8b0` 的三端审查修复,生产网关回读版本为 **2026-10-13-launch-security-v7**。
+
+## 已发布
+
+- 五个云函数:`xiaoshu.app.gateway`、`xiaoshu.ops.gateway`、`xiaoshu.ops.gateway.v3`、`xiaoshu.admin.gateway`、`xiaoshu.sync.direct-v1`。发布后核对实际保存代码;直接同步继续使用旧系统直接接口。
+- 四份 SQL:`account-security.sql`、`payroll-integrity.sql`、`payroll-cutoff.sql`、`account-deletion.sql`;最终逐一回读 24 个函数体,与当前文件一致。
+- 新增 `AccountDeletionRequest`、`PayrollCutoffApproval`、`PayrollCutoffDecision`,账号凭据字段和密码保护触发器。
+- 迁移前核对没有活动同步/教学/工资写任务;迁移使用数据库事务、同步与工资锁及执行超时。修正旧 SQL 内层 BEGIN/COMMIT,避免迁移部分提前提交。临时操作入口限定当前操作员、十分钟有效期,执行后删除并核对。
+
+发布前云函数、Parse schema、受影响 SQL 定义和相关工资数据备份在本机私有目录 `/Users/mac/.local/state/xiaoshu/cloud-releases/2026-10-13-launch-security-v7/`,目录 0700、文件 0600。这是针对本次变更的基线备份,不是全库灾备。工资批次、工资行及工资规则迁移前后比较一致。
+
+## 验证证据
+
+- 线上隔离账号真实链路 **53 项通过**:72 位密码、强制改密、旧密码拒绝、旧会话撤销、课程开通、排课、当天上课、并发结课仅扣一次、报告修改/重试/旧版本拒绝、学生评价、余额与流水对账、注销申请与后台处理、退出撤销。详见 `online-smoke-pass.txt`。
+- 隔离测试账号和业务测试数据已清理;额外回读测试用户、临时函数剩余数均为 0。测试审计轨迹按正常业务保留。没有对真实客户执行结课、改密、扣费或发薪。
+- 服务端完整测试 171 项通过(最终 SQL 性能优化前);优化后迁移/安全/工资针对性测试 **28 项通过,0 失败**。迁移入口、线上测试脚本及实际 SQL 生成的临时函数均语法检查通过。
+- 线上 action 清单:requested 118、implemented 104、blocked 21、missing 0;这些分类有交集,不能相加当覆盖率。既有关闭功能不因此视为已实现。
+- 前端沿用前一轮本地验证:移动端 209、后台 261 项及双端生产构建通过,浏览器模拟矩阵见前一份报告。本次新增证据是后端真实接口验证,不替代手机/微信 WebView 实机验证。
+
+## 真实数据与工资状态
+
+| 来源 | 业务编号数量 | 新库匹配 |
+|---|---:|---:|
+| 预约(54) | 31,254 | 31,254 |
+| 上课(59) | 19,411 | 19,411 |
+| 抗遗忘(60) | 200,355 | 200,355 |
+
+以上是最近成功直接同步批次的业务编号、投影和校验和核查,不代表本次重新执行全量同步。汇总及校验和见 `release-verification.json`。
+
+十月工资总览回读为 28 名陪练、206 次课程、**工资异常 0 条**。但截止来源还有 **674 条待核对差异**,截止确认尚未批准,`settlementReady=false`。这是独立的来源冲突保护,不能把工资异常为 0 当作可以直接审核发放。应在后台逐条核对并留依据;本次未代替运营批准冲突。
+
+真实查询暴露工资总览约 30.6 秒,超过客户端 20 秒超时。修复 SQL 关联字段转文本导致的低效查询,使用原生完整行计算变更指纹,并复用一次来源版本结果。最终完整请求两次为 **14,582 / 14,915 ms**;所有字段变更检测和业务保护保留。只有两次抽测,不是 p75 或高并发性能承诺。
+
+## 前端上线交接
+
+1. 本报告所列数据库和云函数已完成发布,无需重复迁移;先核对 `migration_status` 返回上述版本。
+2. 从当前交付代码执行 `npm ci && npm run verify:local`,发布 `dist/xiaoshu-mobile/browser` 和 `dist/xiaoshu-admin/browser`。本次没有发布这两个前端目录。
+3. 配置正式 HTTPS 域名、代理/CORS、SPA 回退及缓存;实机核对登录、音频、学习、报告和后台操作。
+4. 既有账号若密码迁移历史不明,默认不允许旧密码覆盖;确有登录问题时由管理员重置,不批量开放回退。
+5. 原 Mac 定时任务本次未重新安装。若要启用上一轮新增告警模块,按本地修复报告的运行器安装步骤处理,并核对自然 23 点触发回执。
+
+更早的本地修复报告中“云函数和 SQL 未发布”描述是当时验收边界,当前后端发布状态以本报告为准。回滚需要使用兼容的配套版本并重新检查凭据、会话及工资保护,不能单独撤除安全触发器。

+ 55 - 0
docs/qa/launch-backend-release-2026-10-13/online-smoke-pass.txt

@@ -0,0 +1,55 @@
+PASS 创建学员并关联门店
+PASS 零余额开课资格被拒绝
+PASS 总部入账与双边划拨重试不重复记账
+PASS 四类课时门店划拨后学员余额一致
+PASS 六个新分类来自线上教学标准
+PASS primary_writing 学阶限制
+PASS middle_writing 学阶限制
+PASS high_writing 学阶限制
+PASS 完整目录存在可开内容
+PASS 重复开课请求幂等
+PASS 同词表跨分类分别开课
+PASS 零余额实际开课请求被拒绝
+PASS 同分类重复开课被拒绝
+PASS 临时密码禁止教学接口
+PASS 改密撤销原登录会话
+PASS 旧密码不能通过迁移覆盖新密码
+PASS 72位新密码登录成功并解除临时密码状态
+PASS 用户端分别展示两个开课分类
+PASS 同词表跨分类沿用共享词汇进度
+PASS 旧排课请求遇分类歧义明确拒绝
+PASS 教师端预约保留开课分类
+PASS 老师带读归属所选学生并记录老师
+PASS 学生读取老师带读文章
+PASS 详情返回 allowedActions、blockedReason、validToday 和 serverNow
+PASS 当天可提前开始并允许结课
+PASS 测试课程有可学习单元
+PASS 读取真实词库内容用于隔离课堂
+PASS 并发结课均返回已结课
+PASS 学生评价前已结课且可读报告
+PASS 新库报告使用已保存课堂成果
+PASS 其他用户无法结课
+PASS 评价仅首次生效
+PASS 已结课老师获得修改权限和精确报告版本
+PASS 学生不能修改老师报告
+PASS 老师修改保存且重试幂等
+PASS 旧报告版本拒绝覆盖新内容
+PASS 学生报告回读更新后的反馈
+PASS 修改保留学习成果、学生评价和结课状态
+PASS 其他学生不能评价
+PASS 旧客户端不能回退结课状态
+PASS 后台对账显示唯一记录、报告、评价与消费预留
+PASS 跨设备更新版本随课堂推进变化
+PASS 教师完课与管理员重试只扣一次
+PASS 取消预约禁止开课
+PASS 充值与完课并发都成功
+PASS 充值与完课并发余额无覆盖
+PASS 服务器多连接争抢最后一节课仅一单成功
+PASS 退款和预留并发互斥
+PASS 门店学员四类余额与流水逐项对账
+PASS 全部课时业务单均有事务审计
+PASS 真实注销重复请求幂等
+PASS 后台处理状态在学生端回读
+PASS 退出后原token服务端已撤销
+ONLINE_SMOKE_PASSED
+FIXTURES_CLEANED

+ 193 - 0
docs/qa/launch-backend-release-2026-10-13/release-verification.json

@@ -0,0 +1,193 @@
+{
+  "final": {
+    "at": "2026-10-13T08:37:37.402Z",
+    "sqlRoutinesReadback": [
+      "xs_credential_guard",
+      "xs_enable_initial_legacy_login",
+      "xs_payroll_time",
+      "xs_payroll_sources",
+      "xs_payroll_source_version",
+      "xs_payroll_audit",
+      "xs_review_reward_confirm",
+      "xiaoshu_payroll_reward_guard",
+      "xs_payroll_batch_write_guard",
+      "xiaoshu_payroll_totals_guard",
+      "xs_payroll_command",
+      "xs_payroll_rule",
+      "xs_payroll_rule_immutable",
+      "xs_payroll_legacy_rate",
+      "xs_payroll_reservations",
+      "xs_payroll_refund_rows",
+      "xs_payroll_refunds",
+      "xs_payroll_writing_mapping",
+      "xs_payroll_cutoff_conflicts",
+      "xs_payroll_cutoff_snapshot",
+      "xs_payroll_cutoff_confirm",
+      "xs_payroll_assert_cutoff",
+      "xs_request_deletion",
+      "xs_process_deletion"
+    ],
+    "version": "2026-10-13-launch-security-v7",
+    "overviewMs": [
+      14582,
+      14915
+    ],
+    "coaches": 28,
+    "lessons": 206,
+    "exceptions": 0,
+    "cutoff": {
+      "complete": true,
+      "pending": 674,
+      "approved": false,
+      "datasets": [
+        {
+          "model": 54,
+          "source": 31254,
+          "matched": 31254,
+          "checksum": "ce77ca3b89925ad9784992bebb386010"
+        },
+        {
+          "model": 59,
+          "source": 19411,
+          "matched": 19411,
+          "checksum": "b0558eb240361311b902006847786a31"
+        },
+        {
+          "model": 60,
+          "source": 200355,
+          "matched": 200355,
+          "checksum": "4ffd25e47dd9960487e0ba970b0fe127"
+        }
+      ]
+    },
+    "underClientTimeout": true
+  },
+  "publishedFunctions": {
+    "at": "2026-10-13T05:43:04.304Z",
+    "functions": [
+      {
+        "name": "xiaoshu.ops.gateway",
+        "hash": "d265ec0459d0fa4addf593ad5f48b351581d787b3373954da7722bf3d141c699"
+      },
+      {
+        "name": "xiaoshu.app.gateway",
+        "hash": "f5bdea03757808ef06d88a236ac652a66b579301f539d06f85738fb85b75c8f5"
+      },
+      {
+        "name": "xiaoshu.ops.gateway.v3",
+        "hash": "d265ec0459d0fa4addf593ad5f48b351581d787b3373954da7722bf3d141c699"
+      },
+      {
+        "name": "xiaoshu.admin.gateway",
+        "hash": "184d8dbf8b431c4b6fc8458e911d9ba8f68ceab0083016d5280f3688f82431a7"
+      },
+      {
+        "name": "xiaoshu.sync.direct-v1",
+        "hash": "8ee8b999e1fa45abcd2044bf102f160cba8b3308bc959eb50a3d8a88c41d0c7a"
+      }
+    ]
+  },
+  "initialHealth": {
+    "codeChecks": [
+      {
+        "name": "xiaoshu.ops.gateway",
+        "sha256": "d265ec0459d0fa4addf593ad5f48b351581d787b3373954da7722bf3d141c699",
+        "matches": true
+      },
+      {
+        "name": "xiaoshu.ops.gateway.v3",
+        "sha256": "d265ec0459d0fa4addf593ad5f48b351581d787b3373954da7722bf3d141c699",
+        "matches": true
+      },
+      {
+        "name": "xiaoshu.admin.gateway",
+        "sha256": "184d8dbf8b431c4b6fc8458e911d9ba8f68ceab0083016d5280f3688f82431a7",
+        "matches": true
+      },
+      {
+        "name": "xiaoshu.app.gateway",
+        "sha256": "f5bdea03757808ef06d88a236ac652a66b579301f539d06f85738fb85b75c8f5",
+        "matches": true
+      }
+    ],
+    "checks": [
+      {
+        "operation": "meta",
+        "ok": true,
+        "ms": 404
+      },
+      {
+        "operation": "ops/payroll/overview",
+        "ok": true,
+        "ms": 30577,
+        "coaches": 28,
+        "lessons": 206,
+        "exceptions": 0,
+        "settlementReady": false
+      },
+      {
+        "operation": "ops/privacy/list",
+        "ok": true,
+        "ms": 365
+      },
+      {
+        "operation": "status",
+        "ok": true,
+        "ms": 338
+      }
+    ],
+    "fixtureUsersRemaining": 0,
+    "helpersRemaining": 0
+  },
+  "migration": {
+    "trigger": [
+      {
+        "tgname": "xs_credential_guard_trigger"
+      }
+    ],
+    "newTables": [
+      {
+        "table_name": "AccountDeletionRequest"
+      },
+      {
+        "table_name": "PayrollCutoffApproval"
+      },
+      {
+        "table_name": "PayrollCutoffDecision"
+      }
+    ],
+    "cutoff": {
+      "complete": true,
+      "pending": 674,
+      "approved": false,
+      "datasets": [
+        {
+          "model": 54,
+          "source": 31254,
+          "matched": 31254,
+          "checksum": "ce77ca3b89925ad9784992bebb386010"
+        },
+        {
+          "model": 59,
+          "source": 19411,
+          "matched": 19411,
+          "checksum": "b0558eb240361311b902006847786a31"
+        },
+        {
+          "model": 60,
+          "source": 200355,
+          "matched": 200355,
+          "checksum": "4ffd25e47dd9960487e0ba970b0fe127"
+        }
+      ]
+    },
+    "businessRowsUnchanged": true
+  },
+  "tests": {
+    "backendBeforeFinalSqlOptimization": 171,
+    "finalTargeted": 28,
+    "liveAssertions": 53,
+    "liveFixturesCleaned": true,
+    "frontendPublished": false
+  }
+}

+ 2 - 0
docs/qa/launch-fixes-2026-10-13/README.md

@@ -1,5 +1,7 @@
 # 三端审查问题修复与本地验收(2026-10-13)
 
+> 后续状态:用户追加授权后,配套云函数和数据库迁移已发布并完成真实接口验证,见 [后端发布报告](../launch-backend-release-2026-10-13/README.md)。下文保留本地修复阶段的验证记录;前端仍由用户发布。
+
 对应 `docs/qa/launch-audit-2026-10-13/README.md`,修复基线 `6d4e042`。学生和老师共用移动应用,分别验证身份路径;后台单独构建和测试。本次没有发布前端、云函数、SQL,没有修改真实会员、课时、工资或同步数据,也没有更新正在运行的 Mac 定时任务。
 
 ## 优先级与处理结果

+ 6 - 0
scripts/lib/launch-migration.mjs

@@ -0,0 +1,6 @@
+import {readFile} from 'node:fs/promises';
+export const launchMigrationFiles=['account-security.sql','payroll-integrity.sql','payroll-cutoff.sql','account-deletion.sql'];
+// A legacy standalone file has BEGIN/COMMIT. Remove only its top-level wrapper so
+// the publisher's transaction also protects the routines installed before/after it.
+export function withoutTransactionWrapper(source){return source.replace(/^(?:BEGIN|COMMIT);[ \t]*\r?$/gm,'');}
+export async function launchMigrationSql(){return (await Promise.all(launchMigrationFiles.map(name=>readFile(new URL('../sql/'+name,import.meta.url),'utf8')))).map(withoutTransactionWrapper).join('\n');}

+ 10 - 7
scripts/migrate-launch-fixes.mjs

@@ -1,18 +1,21 @@
 #!/usr/bin/env node
 // --plan is offline. --apply is a publisher action and requires a backup plus a real operator session.
-import {readFile} from 'node:fs/promises';import {randomUUID} from 'node:crypto';
-const files=['account-security.sql','payroll-integrity.sql','payroll-cutoff.sql','account-deletion.sql'];
-const sql=(await Promise.all(files.map(name=>readFile(new URL('./sql/'+name,import.meta.url),'utf8')))).join('\n');
+import {randomUUID} from 'node:crypto';
+import {launchMigrationFiles as files,launchMigrationSql} from './lib/launch-migration.mjs';
+const sql=await launchMigrationSql();
+const activeSyncSql = `SELECT count(*)::int n FROM "LegacyDirectRun" WHERE status='running'`;
 const fields={legacyPasswordMigrationAllowed:{type:'Boolean'},credentialRevision:{type:'Number'},credentialExpectedRevision:{type:'Number'},credentialMigrationAttempt:{type:'Boolean'},passwordResetRequired:{type:'Boolean'}};
 if(!process.argv.includes('--apply')){console.log(JSON.stringify({apply:false,files,parseClass:'_User',fields:Object.keys(fields),requires:'Back up database, apply existing payroll and direct-sync prerequisites, then run --apply before publishing gateways.'},null,2));process.exit(0);}
 const master=process.env.XIAOSHU_MASTER_KEY,token=process.env.XIAOSHU_MIGRATION_OPERATOR_TOKEN;
 if(!master||!token)throw Error('Require XIAOSHU_MASTER_KEY and XIAOSHU_MIGRATION_OPERATOR_TOKEN in environment');
 const base=(process.env.XIAOSHU_PARSE_URL||'https://server.xiaoshu.pro/parse').replace(/\/$/,''),app=process.env.XIAOSHU_PARSE_APP_ID||'7pIbDBJmKx_main';
 async function api(path,method='GET',body,session=false){const r=await fetch(base+path,{method,headers:{'Content-Type':'application/json','X-Parse-Application-Id':app,...(session?{'X-Parse-Session-Token':token}:{'X-Parse-Master-Key':master})},body:body?JSON.stringify(body):undefined,signal:AbortSignal.timeout(120000)});const data=await r.json();if(!r.ok)throw Error('Migration API failed ('+r.status+')');return data;}
-const user=await api('/users/me','GET',undefined,true);if(user.isDisabled||user.isDeleted||!(user.roles||[]).includes('super-admin'))throw Error('An active super-admin session is required');
+const user=await api('/users/me','GET',undefined,true);if(user.isDisabled||user.isDeleted||!(user.adminRoleKey==='super-admin'||(user.roles||[]).includes('super-admin')))throw Error('An active super-admin session is required');
+const companyId=user.company?.objectId;if(!companyId)throw Error('A scoped company is required');
 const schema=await api('/schemas/_User');const missing=Object.fromEntries(Object.entries(fields).filter(([key])=>!schema.fields[key]));if(Object.keys(missing).length)await api('/schemas/_User','PUT',{fields:missing});
-const path='xiaoshu/system/launch-fixes-'+randomUUID();let id;
-try{const code=`async function handler(request,response){if(request.user?.id!==${JSON.stringify(user.objectId)})return response.status(403).json({success:false});try{await Psql.tx(t=>t.none(${JSON.stringify(sql)}));return response.json({success:true});}catch(e){return response.status(500).json({success:false,message:String(e.message)});}}`;
+const path='xiaoshu/system/launch-fixes-'+randomUUID();let id;const expires=Date.now()+600000;
+try{const code=`async function handler(request,response){if(request.user?.id!==${JSON.stringify(user.objectId)}||Date.now()>${expires})return response.status(403).json({success:false});try{await Psql.tx(async t=>{await t.none("SET LOCAL lock_timeout='15s';SET LOCAL statement_timeout='90s'");await t.one("SELECT pg_advisory_xact_lock(hashtext('direct-sync:'||$1)),pg_advisory_xact_lock(hashtext('xs-payroll:'||$1))",[${JSON.stringify(companyId)}]);const running=await t.one(${JSON.stringify(activeSyncSql)});if(running.n)throw Error('Active sync; migration postponed');await t.none(${JSON.stringify(sql)});});return response.json({success:true});}catch(e){return response.status(500).json({success:false,message:String(e.message)});}}`;
+ new Function(code);
  id=(await api('/classes/Function','POST',{name:path,path,code,type:'standalone',enabled:true,params:[],paramList:[],respType:'json'})).objectId;
  const r=await fetch(base.replace(/\/parse$/,'/api/functions/')+path,{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({token,params:{}}),signal:AbortSignal.timeout(120000)});const result=await r.json();if(!r.ok||!result.success)throw Error(result.message||'Migration failed');console.log(JSON.stringify({applied:true,files}));
-}finally{if(id)await api('/classes/Function/'+id,'DELETE');}
+}finally{if(id){await api('/classes/Function/'+id,'DELETE');const remaining=await api('/classes/Function?'+new URLSearchParams({where:JSON.stringify({objectId:id}),count:'1',limit:'0'}));if(remaining.count)throw Error('Temporary migration helper cleanup failed');}}

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

@@ -6,7 +6,7 @@ import {resolve,join} from 'node:path';
 const co=process.env.XIAOSHU_COMPANY_ID||'7pIbDBJmKx',base=(process.env.XIAOSHU_SERVER_URL||'https://server.xiaoshu.pro').replace(/\/$/,''),appId=process.env.XIAOSHU_PARSE_APP_ID||'7pIbDBJmKx_main',suffix=Date.now(),state={users:[],co,admin:null,helper:null};
 if(!process.env.XIAOSHU_MASTER_KEY)throw Error('缺少 XIAOSHU_MASTER_KEY');
 const fixtureDirectory=resolve('tmp/mobile-teaching-flow-'+suffix);mkdirSync(fixtureDirectory,{recursive:true,mode:0o700});const statePath=join(fixtureDirectory,'fixture.json');
-const password=randomBytes(24).toString('base64url'),company={__type:'Pointer',className:'Company',objectId:co};
+const password=randomBytes(54).toString('base64url'),company={__type:'Pointer',className:'Company',objectId:co};
 function persist(){writeFileSync(statePath,JSON.stringify(state),{mode:0o600});}
 async function parse(path,method='GET',data){const r=await fetch(base+'/parse'+path,{method,headers:{'Content-Type':'application/json','X-Parse-Application-Id':appId,'X-Parse-Master-Key':process.env.XIAOSHU_MASTER_KEY},...(data?{body:JSON.stringify(data)}:{})});const d=await r.json();if(!r.ok)throw Error('Parse '+path.split('?')[0]+': '+(d.error||r.status));return d;}
 async function call(path,token,params,allowFailure=false){const r=await fetch(base+'/api/functions/'+path,{method:'POST',headers:{'Content-Type':'application/json','X-Parse-Application-Id':appId},body:JSON.stringify({token,params}),signal:AbortSignal.timeout(60000)});const d=await r.json();if(!allowFailure&&(!r.ok||d.success===false||d.retcode<0))throw Error((params.operation||params.action||path)+': '+(d.message||d.retmsg||d.error||r.status));return {status:r.status,data:d.data??d.result,raw:d};}
@@ -35,7 +35,12 @@ try{
  const self=(await mutate('ops/course-bindings/save','',{...payload,categoryKey:'word_self_study'})).data;expect(self.bindingIds[0]!==word.bindingIds[0],'同词表跨分类分别开课');
  const zeroSave=await mutate('ops/course-bindings/save','',{...payload,studentId:empty.legacyUserId},randomUUID(),true);expect(zeroSave.status===409,'零余额实际开课请求被拒绝');
  const duplicate=await mutate('ops/course-bindings/save','',payload,randomUUID(),true);expect(duplicate.status===409,'同分类重复开课被拒绝');
- const studentToken=(await parse('/login','POST',{username:'release_member_'+suffix,password})).sessionToken;
+ const temporaryToken=(await parse('/login','POST',{username:'release_member_'+suffix,password})).sessionToken;
+ const forced=await call('xiaoshu/app/gateway',temporaryToken,{action:'app_learning_overview'},true);expect(forced.status===403&&forced.raw.code==='PASSWORD_RESET_REQUIRED','临时密码禁止教学接口');
+ const finalPassword=randomBytes(54).toString('base64url');await call('xiaoshu/app/gateway',temporaryToken,{action:'app_password_change',currentPassword:password,newPassword:finalPassword});
+ const oldSession=await fetch(base+'/parse/users/me',{headers:{'X-Parse-Application-Id':appId,'X-Parse-Session-Token':temporaryToken}});expect(!oldSession.ok,'改密撤销原登录会话');
+ const oldLogin=await call('xiaoshu/app/gateway','',{action:'user_login_passwd',name:'release_member_'+suffix,passwd:password},true);expect(oldLogin.status===401,'旧密码不能通过迁移覆盖新密码');
+ const relogin=(await call('xiaoshu/app/gateway','',{action:'user_login_passwd',name:'release_member_'+suffix,passwd:finalPassword})).data;const studentToken=relogin.sessionToken;expect(studentToken?.startsWith('r:')&&!relogin.passwordResetRequired,'72位新密码登录成功并解除临时密码状态');
  const learning=(await call('xiaoshu/app/gateway',studentToken,{action:'app_learning_overview'})).data;expect(learning.courses.length===2&&new Set(learning.courses.map(c=>c.categoryKey)).size===2,'用户端分别展示两个开课分类');
  const units=await Promise.all([word,self].map(b=>call('xiaoshu/app/gateway',studentToken,{action:'node_list',ifunit:1,pid:course.courseId,bindingId:b.bindingIds[0]})));expect(JSON.stringify(units[0].data)===JSON.stringify(units[1].data),'同词表跨分类沿用共享词汇进度');
  const date=new Date().toLocaleDateString('sv-SE',{timeZone:'Asia/Shanghai'}),draft={studentId:student.legacyUserId,coachId:coach.legacyUserId,courseId:course.courseId,durationMinutes:60,deliveryMode:'online',date,startTime:'10:00',recurrence:'once',occurrences:1};
@@ -95,6 +100,10 @@ try{
  const race2=await Promise.all([mutate('ops/appointments/create','',drafts[0],randomUUID(),true),mutate('ops/users/adjust-balance',student.objectId,{account:'Purse',amount:1,payMethod:'运营记账',direction:'deduct',sourceTransactionId:transfer.objectId},randomUUID(),true)]);expect(race2.filter(r=>r.status===200).length===1,'退款和预留并发互斥');
  const reconciliation=(await call(state.helper.path,state.admin.sessionToken,{mode:'reconcile'})).data;expect(reconciliation.every(r=>Number(r.balance)===Number(r.ledger)),'门店学员四类余额与流水逐项对账');
  const audit=(await call(state.helper.path,state.admin.sessionToken,{mode:'audit'})).data;expect(audit[0].missing===0,'全部课时业务单均有事务审计');
+ const deletion=await call('xiaoshu/app/gateway',empty.sessionToken,{action:'app_deletion_request',reason:'隔离发布验证注销申请'}),deletionRetry=await call('xiaoshu/app/gateway',empty.sessionToken,{action:'app_deletion_request',reason:'隔离发布验证重复申请'});expect(deletion.data.id===deletionRetry.data.id,'真实注销重复请求幂等');
+ await mutate('ops/privacy/process',deletion.data.id,{version:deletion.data.version,status:'processing'});
+ const deletionStatus=(await call('xiaoshu/app/gateway',empty.sessionToken,{action:'app_deletion_status'})).data;expect(deletionStatus.items.some(r=>r.id===deletion.data.id&&r.status==='processing'),'后台处理状态在学生端回读');
+ await call('xiaoshu/app/gateway',empty.sessionToken,{action:'app_logout'});const revoked=await fetch(base+'/parse/users/me',{headers:{'X-Parse-Application-Id':appId,'X-Parse-Session-Token':empty.sessionToken}});expect(!revoked.ok,'退出后原token服务端已撤销');
  const snapshot=(await call(state.helper.path,state.admin.sessionToken,{mode:'counts'})).data;console.log('ENROLLMENT_COUNTS',snapshot);console.log('ONLINE_SMOKE_PASSED');
 }catch(e){console.error('ONLINE_SMOKE_FAILED',e.message);process.exitCode=1;}
 finally{let clean=true;if(state.helper)try{await call(state.helper.path,state.admin.sessionToken,{mode:'cleanup'});}catch(e){console.error('CLEANUP_FAILED',e.message);clean=false;}if(clean){if(state.helper)await parse('/classes/Function/'+state.helper.objectId,'DELETE');for(const u of [...state.users].reverse())await parse('/users/'+u.objectId,'DELETE');rmSync(statePath,{force:true});console.log('FIXTURES_CLEANED');}else{console.error('隔离资源待清理,受限状态文件:'+statePath);process.exitCode=1;}}
@@ -102,6 +111,7 @@ finally{let clean=true;if(state.helper)try{await call(state.helper.path,state.ad
 function helperCode(state){
 const ids=state.users.map(u=>u.legacyUserId),objects=state.users.map(u=>u.objectId),texts=ids.map(String),co=state.co,admin=state.admin.objectId;
 const statements=[
+['DELETE FROM \"AccountDeletionRequest\" WHERE company=$1 AND \"userId\"=ANY($2::text[])',[co,objects]],
 ['DELETE FROM "SurveyLog" WHERE "learnerLegacyId"=ANY($1::numeric[])',[ids]],
 ['DELETE FROM "SurveyItem" WHERE "learnerLegacyId"=ANY($1::numeric[])',[ids]],
 ['DELETE FROM "MobileTeachingCommand" WHERE "company"=$1 AND "actorId"=ANY($2::text[])',[co,texts]],

+ 14 - 12
scripts/sql/payroll-cutoff.sql

@@ -5,11 +5,11 @@ REVOKE ALL ON "PayrollCutoffDecision","PayrollCutoffApproval" FROM PUBLIC;
 CREATE OR REPLACE FUNCTION xs_payroll_cutoff_conflicts(co text, mon text) RETURNS SETOF jsonb LANGUAGE plpgsql AS $$
 DECLARE r record; item jsonb; payload jsonb; fields jsonb; dt text; v_fingerprint text; cosmetic boolean;
 BEGIN
- FOR r IN SELECT f.*,c."modelId",c."itemId",c.title,c.subtitle FROM "LegacySyncConflict" f LEFT JOIN "CommonModel" c ON c.company=f.company AND c."generalId"::text=f."legacyKey" AND c."modelId"::text=f.dataset WHERE f.company=co AND f.dataset IN ('54','59','60') AND NOT COALESCE(f.resolved,false) ORDER BY f."objectId" LOOP
+ FOR r IN SELECT f.*,c."modelId",c."itemId",c.title,c.subtitle FROM "LegacySyncConflict" f LEFT JOIN "CommonModel" c ON c.company=f.company AND c."generalId"::text=f."legacyKey" AND c."modelId"=CASE f.dataset WHEN '54' THEN 54 WHEN '59' THEN 59 WHEN '60' THEN 60 END WHERE f.company=co AND f.dataset IN ('54','59','60') AND NOT COALESCE(f.resolved,false) ORDER BY f."objectId" LOOP
   item:=NULL;
-  IF r.dataset='54' THEN SELECT to_jsonb(a) INTO item FROM "CourseAppointment" a WHERE a.company=co AND a.id::text=r."itemId"::text;
-  ELSIF r.dataset='59' THEN SELECT to_jsonb(a) INTO item FROM "LessonRecord" a WHERE a.company=co AND a.id::text=r."itemId"::text;
-  ELSE SELECT to_jsonb(a) INTO item FROM "MemoryPracticeRecord" a WHERE a.company=co AND a.id::text=r."itemId"::text; END IF;
+  IF r.dataset='54' THEN SELECT to_jsonb(a) INTO item FROM "CourseAppointment" a WHERE a.company=co AND a.id=r."itemId";
+  ELSIF r.dataset='59' THEN SELECT to_jsonb(a) INTO item FROM "LessonRecord" a WHERE a.company=co AND a.id=r."itemId";
+  ELSE SELECT to_jsonb(a) INTO item FROM "MemoryPracticeRecord" a WHERE a.company=co AND a.id=r."itemId"; END IF;
   dt:=CASE r.dataset WHEN '54' THEN item->>'yysj' WHEN '59' THEN COALESCE(NULLIF(item->>'lessonAt',''),NULLIF(item->>'plpjsj','')) ELSE COALESCE(NULLIF(item->>'wcsj',''),NULLIF(item->>'kywrq','')) END;
   -- All older unresolved rewards can enter a later payroll; unknown dates always block.
   IF xs_payroll_time(dt) IS NOT NULL AND to_char(xs_payroll_time(dt) AT TIME ZONE 'Asia/Shanghai','YYYY-MM')>mon THEN CONTINUE; END IF;
@@ -22,7 +22,7 @@ BEGIN
  END LOOP;
 END $$;
 CREATE OR REPLACE FUNCTION xs_payroll_cutoff_snapshot(co text, mon text) RETURNS jsonb LANGUAGE plpgsql AS $$
-DECLARE run "LegacyDirectRun"; ev record; table_name text; matches integer; complete boolean:=true; conflicts jsonb; version text; approval "PayrollCutoffApproval"; data_version text; pending integer; datasets jsonb:='[]'; ready boolean;
+DECLARE run "LegacyDirectRun"; ev record; table_name text; matches integer; complete boolean:=true; conflicts jsonb; version text; approval "PayrollCutoffApproval"; data_version text; source_version text; pending integer; datasets jsonb:='[]'; ready boolean;
 BEGIN
  IF mon !~ '^\d{4}-(0[1-9]|1[0-2])$' THEN RAISE EXCEPTION '工资月份无效'; END IF;
  SELECT * INTO run FROM "LegacyDirectRun" WHERE company=co AND status IN ('completed','completed-with-preserved-conflicts') ORDER BY "finishedAt" DESC LIMIT 1;
@@ -30,21 +30,23 @@ BEGIN
  IF (SELECT count(*) FROM "LegacyDirectEvidence" WHERE company=co AND "runId"=run."runId" AND model IN (54,59,60))<>3 THEN complete:=false; END IF;
  FOR ev IN SELECT * FROM "LegacyDirectEvidence" WHERE company=co AND "runId"=run."runId" AND model IN (54,59,60) LOOP
   table_name:=CASE ev.model WHEN 54 THEN 'CourseAppointment' WHEN 59 THEN 'LessonRecord' ELSE 'MemoryPracticeRecord' END;
-  EXECUTE format('SELECT count(*)::int FROM (SELECT c."generalId" FROM "CommonModel" c JOIN %I a ON a.company=c.company AND a.id::text=c."itemId"::text WHERE c.company=$1 AND c."modelId"=$2 AND c."generalId"::text IN (SELECT jsonb_array_elements_text($3)) GROUP BY c."generalId" HAVING count(*)=1) x',table_name) INTO matches USING co,ev.model,ev.ids;
+  EXECUTE format('SELECT count(*)::int FROM (SELECT c."generalId" FROM "CommonModel" c JOIN %I a ON a.company=c.company AND a.id=c."itemId" WHERE c.company=$1 AND c."modelId"=$2 AND c."generalId"::text IN (SELECT jsonb_array_elements_text($3)) GROUP BY c."generalId" HAVING count(*)=1) x',table_name) INTO matches USING co,ev.model,ev.ids;
   complete:=complete AND matches=ev."sourceCount" AND ev."sourceChecksum"=ev."targetChecksum" AND jsonb_array_length(ev.ids)=ev."sourceCount" AND ev."sourceChecksum"=md5((SELECT COALESCE(string_agg(v,E'\n' ORDER BY v::numeric),'') FROM jsonb_array_elements_text(ev.ids) v));
   datasets:=datasets||jsonb_build_array(jsonb_build_object('model',ev.model,'source',ev."sourceCount",'matched',matches,'checksum',ev."sourceChecksum"));
  END LOOP;
  SELECT COALESCE(jsonb_agg(c ORDER BY c->>'id'),'[]') INTO conflicts FROM xs_payroll_cutoff_conflicts(co,mon) c;
  SELECT count(*) INTO pending FROM jsonb_array_elements(conflicts) c WHERE NOT (c->>'resolved')::boolean;
+ -- Native row serialization covers every column without constructing a JSON object per row.
  SELECT md5(COALESCE(string_agg(hash,'' ORDER BY kind,hash),'')) INTO data_version FROM (
- SELECT 'common' kind,md5(to_jsonb(c)::text) hash FROM "CommonModel" c WHERE company=co AND "modelId" IN (54,59,60)
- UNION ALL SELECT 'appointment',md5(to_jsonb(c)::text) FROM "CourseAppointment" c WHERE company=co
- UNION ALL SELECT 'lesson',md5(to_jsonb(c)::text) FROM "LessonRecord" c WHERE company=co
- UNION ALL SELECT 'review',md5(to_jsonb(c)::text) FROM "MemoryPracticeRecord" c WHERE company=co) current_rows;
- version:=md5(jsonb_build_array(data_version,xs_payroll_source_version(co),run."runId",run."finishedAt",datasets,conflicts)::text);
+ SELECT 'common' kind,md5(c::text) hash FROM "CommonModel" c WHERE company=co AND "modelId" IN (54,59,60)
+ UNION ALL SELECT 'appointment',md5(c::text) FROM "CourseAppointment" c WHERE company=co
+ UNION ALL SELECT 'lesson',md5(c::text) FROM "LessonRecord" c WHERE company=co
+ UNION ALL SELECT 'review',md5(c::text) FROM "MemoryPracticeRecord" c WHERE company=co) current_rows;
+ source_version:=xs_payroll_source_version(co);
+ version:=md5(jsonb_build_array(data_version,source_version,run."runId",run."finishedAt",datasets,conflicts)::text);
  SELECT * INTO approval FROM "PayrollCutoffApproval" WHERE company=co AND month=mon;
  ready:=complete AND pending=0 AND approval.version=version AND NOT EXISTS(SELECT 1 FROM "LegacyDirectRun" WHERE company=co AND status='running');
- RETURN jsonb_build_object('month',mon,'version',version,'sourceVersion',xs_payroll_source_version(co),'runId',run."runId",'cutoffAt',run."finishedAt",'complete',complete,'pending',pending,'conflicts',conflicts,'datasets',datasets,'approved',COALESCE(ready,false),'monthClosed',COALESCE(run."finishedAt">=((mon||'-01')::date+interval '1 month') AT TIME ZONE 'Asia/Shanghai',false),'approval',to_jsonb(approval));
+ RETURN jsonb_build_object('month',mon,'version',version,'sourceVersion',source_version,'runId',run."runId",'cutoffAt',run."finishedAt",'complete',complete,'pending',pending,'conflicts',conflicts,'datasets',datasets,'approved',COALESCE(ready,false),'monthClosed',COALESCE(run."finishedAt">=((mon||'-01')::date+interval '1 month') AT TIME ZONE 'Asia/Shanghai',false),'approval',to_jsonb(approval));
 END $$;
 CREATE OR REPLACE FUNCTION xs_payroll_cutoff_confirm(co text,actor text,mon text,expected text,reason text,cid text DEFAULT '',fp text DEFAULT '') RETURNS jsonb LANGUAGE plpgsql AS $$
 DECLARE snapshot jsonb; row jsonb;

+ 6 - 0
scripts/tests/launch-migration.test.mjs

@@ -0,0 +1,6 @@
+import {test} from 'node:test';import assert from 'node:assert/strict';import {PGlite} from '@electric-sql/pglite';
+import {withoutTransactionWrapper,launchMigrationSql} from '../lib/launch-migration.mjs';
+test('standalone SQL transaction wrappers cannot commit part of the release',async()=>{
+ const db=new PGlite();try{await assert.rejects(db.transaction(async tx=>{await tx.exec('CREATE TABLE release_first(id int)');await tx.exec(withoutTransactionWrapper('BEGIN;\nCREATE TABLE release_middle(id int);\nCOMMIT;'));await tx.exec('SELECT release_deliberate_missing_function()');}),/does not exist/);const r=await db.query("SELECT tablename FROM pg_tables WHERE tablename IN ('release_first','release_middle')");assert.equal(r.rows.length,0);}finally{await db.close();}
+});
+test('prepared release SQL has no top-level transaction boundary and keeps routine bodies',async()=>{const sql=await launchMigrationSql();assert(!/^(BEGIN|COMMIT);\s*$/m.test(sql));for(const fn of ['xs_credential_guard','xs_payroll_command','xs_payroll_cutoff_confirm','xs_process_deletion'])assert(sql.includes('FUNCTION '+fn));});