Explorar o código

perf: bound gateway requests and move lesson exports to durable jobs

彭峰 hai 3 horas
pai
achega
1d581fed9b
Modificáronse 33 ficheiros con 1184 adicións e 52 borrados
  1. 4 0
      deployment/systemd/lesson-export.env.example
  2. 19 0
      deployment/systemd/xiaoshu-lesson-export.service
  3. 19 0
      perf/cloud-concurrency-2026-11-15/PROGRESS.md
  4. 58 0
      perf/cloud-concurrency-2026-11-15/REPORT.md
  5. 33 0
      perf/cloud-concurrency-2026-11-15/VALIDATION.json
  6. 178 0
      perf/cloud-concurrency-2026-11-15/local-sql-benchmark.json
  7. 39 0
      perf/cloud-concurrency-2026-11-15/postgres-concurrency.json
  8. 94 0
      perf/cloud-concurrency-2026-11-15/production-baseline.json
  9. 6 0
      projects/shared/lesson-export-columns.mjs
  10. 96 0
      projects/shared/request-scheduler.ts
  11. 37 10
      projects/xiaoshu-admin/src/app/cloud-functions.service.ts
  12. 6 2
      projects/xiaoshu-admin/src/app/operations.service.ts
  13. 1 1
      projects/xiaoshu-admin/src/app/pages/lesson-record.models.ts
  14. 9 3
      projects/xiaoshu-admin/src/app/pages/lesson-records.component.html
  15. 19 5
      projects/xiaoshu-admin/src/app/pages/lesson-records.component.spec.ts
  16. 50 6
      projects/xiaoshu-admin/src/app/pages/lesson-records.component.ts
  17. 12 2
      projects/xiaoshu-mobile/src/app/core/api.service.spec.ts
  18. 18 5
      projects/xiaoshu-mobile/src/app/core/api.service.ts
  19. 22 0
      scripts/audit-cloud-request-performance.mjs
  20. 32 0
      scripts/benchmark-lesson-history.mjs
  21. 73 0
      scripts/cloud/lesson-export.js
  22. 44 8
      scripts/cloud/lesson-records.js
  23. 1 0
      scripts/cloud/store-scope.js
  24. 29 9
      scripts/deploy-admin-functions.mjs
  25. 29 0
      scripts/lesson-export-worker.mjs
  26. 17 0
      scripts/sql/lesson-export.sql
  27. 69 0
      scripts/sql/read-revisions.sql
  28. 18 0
      scripts/tests/lesson-export.test.mjs
  29. 53 1
      scripts/tests/lesson-records.test.mjs
  30. 19 0
      scripts/tests/read-revisions.test.mjs
  31. 42 0
      scripts/tests/request-scheduler.test.mjs
  32. 4 0
      scripts/tests/store-scope.test.mjs
  33. 34 0
      scripts/verify-read-concurrency.mjs

+ 4 - 0
deployment/systemd/lesson-export.env.example

@@ -0,0 +1,4 @@
+# Copy to /etc/xiaoshu/lesson-export.env, owned by xiaoshu and mode 0600.
+# A dedicated super-admin operator session is required by the worker-only actions.
+XIAOSHU_EXPORT_OPERATOR_TOKEN=
+XIAOSHU_EXPORT_GATEWAY_URL=https://server.xiaoshu.pro/api/functions/xiaoshu/ops/gateway-v3

+ 19 - 0
deployment/systemd/xiaoshu-lesson-export.service

@@ -0,0 +1,19 @@
+[Unit]
+Description=Xiaoshu durable lesson history export worker
+After=network-online.target
+Wants=network-online.target
+
+[Service]
+Type=simple
+WorkingDirectory=/opt/xiaoshu-angular
+Environment=XIAOSHU_EXPORT_ENV_FILE=/etc/xiaoshu/lesson-export.env
+ExecStart=/usr/bin/node scripts/lesson-export-worker.mjs
+Restart=always
+RestartSec=5
+User=xiaoshu
+Group=xiaoshu
+NoNewPrivileges=true
+PrivateTmp=true
+
+[Install]
+WantedBy=multi-user.target

+ 19 - 0
perf/cloud-concurrency-2026-11-15/PROGRESS.md

@@ -0,0 +1,19 @@
+# 云函数并发治理执行记录
+
+依据:本聊天已确认的五阶段计划,2026-11-15 执行。
+
+- [x] 生产训练列表只读基线:10次,p75 3282 ms;定义读取 400,生产配置仍未核对。
+- [x] 两端共享请求调度、合并、取消、明确范围的 30 秒缓存与失效。
+- [x] 训练精确学员筛选提前、短统计缓存、可选修订事件、稳定游标。
+- [x] 训练列表事务准入及 SQL 时限;其他重接口服务端准入尚待分批接入。
+- [x] 持久化后台导出、独立 worker、分批查看、恢复与权限检查。
+- [x] 独立代码审查;四项重要问题均修复并加入回归验证。
+- [x] 本地 2 万/6 万数据 AB/BA 配对测量;全局冷查询尚未减半。
+- [x] 隔离真实 PostgreSQL 18.4 多连接正确性及超载保护验证。
+- [ ] 生产峰值混合负载、30分钟稳定性和真实设备验收。
+- Git:本次改动已整合主分支最新代码验证;提交及推送结果以聊天最终回执为准。
+- [ ] 正式发布:待权限核对、容量验收和用户批准。
+
+Ruling: 修订版本采用事务专属追加事件并包含未完成旧事务,替代共享计数行;避免新的业务死锁,代价是每日维护事件表。
+Ruling: 大导出快照可单独使用60秒SQL上限,由独立worker承担;代价是首次快照仍可能因数据规模失败,不进行前台重试风暴。
+Ruling: 没有生产定义/连接池/峰值证据时不猜测扩容额度、不盲建索引;代价是全局冷查询减半及上线容量验收仍未完成。

+ 58 - 0
perf/cloud-concurrency-2026-11-15/REPORT.md

@@ -0,0 +1,58 @@
+# 云函数请求压力治理:实现与验证
+
+本轮完成首批两端请求调度、训练记录查询治理、可选修订事件迁移、事务准入以及独立后台导出代码。尚未部署,不表示线上已经提速或通过峰值容量验收。
+
+## 已确认的基线
+
+2026-11-15 使用现有运营会话,只读、顺序访问生产训练记录首页 10 次。19921 条记录,每次返回 20 条;p50 3172 ms、p75 3282 ms、p95 3574 ms,全部成功。没有向生产施加并发压力。读取 Function 定义接口返回 HTTP 400,因此未核对生产源码、数据库执行计划、现有索引、worker 数量及连接池设置。
+
+## 实现
+
+- 两端统一调度:每应用标签页最多 4 个业务请求,读取最多 3 个,重读取最多 1 个。读取合并、取消、10 秒有界排队(上限 64)、会话隔离、提交优先;未知操作不合并、不缓存、不重试。旧移动端正则读取分类保留,未声称全部 action 已重新审计。
+- 管理端对训练列表、词库树、课酬规则与教学规则启用最长 30 秒成功缓存;移动端对课程列表及 node_list 启用同样缓存。写入结果不确定时也使相应通用缓存失效;手动 refresh 跳过结果缓存。重试最多 1 次、随机退避并遵守服务端 retryAfterMs,客户端执行超时不重试。
+- 训练记录精确学员 ID 提前限制事实查询(去重分区包含学员 ID,语义不变);保留原去重、权限、名称和日期口径。累计统计可按权限/参数/修订版本短暂复用;移除已缓存统计的 SQL 参数空洞。普通分页兼容原接口,分批查看支持精确时间游标及版本核对。
+- 训练列表通过事务级 advisory lock 跨 worker 限制同时 2 个查询,SQL 使用事务内 12 秒 statement_timeout,429 返回 retryAfterMs。此保护目前覆盖训练列表,不代表所有费用/报表重查询都已完成服务端准入迁移。其他接口已受两端通用调度约束。
+- 可选 read-revisions.sql:来源表使用语句触发器记录事务专属修订事件,避免共享计数行造成业务写入锁循环;版本包含 frontier 以下的活跃事务,避免较早事务后提交被 MAX 版本漏掉。覆盖同步写入、删除、帐套移动与 TRUNCATE。worker 每日压缩已稳定事件并清理过期统计。
+- 训练导出:持久化任务及固定快照,独立 Node worker 全服务一次 1 个任务,按 500 条读取生成 XLSX。本人最多 1 个未完成任务,幂等创建、租约、权限重验、取消、三次崩溃恢复、24 小时过期;文件不发布为公开 URL,下载重新鉴权。快照语句单独允许 60 秒,用户请求只提交任务/查看进度;文件上限约 10 MB,超限明确失败。前端进度退避至 10 秒,隐藏时暂停,返回可恢复。
+- 运营网关新增请求标识与 operation/status/durationMs 日志;调度器提供有界计数快照,不记录令牌及业务正文。尚未建立生产 SQL 分段日志与线上指标面板。
+
+## 配对测量
+
+见 local-sql-benchmark.json。相同隔离 PGlite 数据库,AB/BA 交替,2 次热身后各 10 次,覆盖 2 万/6 万条记录;每次比较原始可见数据与统计一致,新增游标及版本元数据不作旧接口逐字比较。此测试只衡量 SQL 与序列化,不包含真实网络、页面渲染和生产连接池。基准命令:
+
+```sh
+git show 2503750:scripts/cloud/lesson-records.js > tmp/cloud-concurrency/lesson-records-before.js
+node scripts/benchmark-lesson-history.mjs --baseline=tmp/cloud-concurrency/lesson-records-before.js
+```
+
+按学员查询 p75 显著降低;全局翻页仍未达到降低 50% 的目标,精确数字以配对 JSON 为准。新增的前端重复请求合并、返回复用不在该 SQL 基准中计收益。
+
+真实 PostgreSQL 验证依赖只安装在独立临时目录,未改项目依赖:
+
+```sh
+npm install --prefix /tmp/xiaoshu-postgres-validation embedded-postgres@18.4.0-beta.17 pg --no-audit --no-fund
+node scripts/verify-read-concurrency.mjs
+```
+
+验证仅绑定 127.0.0.1、使用随机临时数据库目录和口令,finally 关闭并删除该集群,不创建系统用户、不连接生产。
+
+## 测试与边界
+
+已覆盖调度器的 50 个并发消费者、保留提交容量、共享读取取消、会话过期、队列超时、缓存 TTL/失效隔离;训练记录去重/权限/排序、游标变化检测;导出快照固定、旧租约隔离、worker 恢复和完整 XLSX 回读;门店只允许本人任务操作、worker 操作关闭;修订的插入/删除/移动/回滚/清理。
+
+独立审查发现并修复移动端登录新令牌尚未保存时被拦截、门店导出能力缺失、共享修订计数锁循环以及取消/完成竞态。最终运行数字见 VALIDATION.json。
+
+已在本机隔离 PostgreSQL 18.4 完成 7 个多连接检查:READ COMMITTED/REPEATABLE READ 下迟提交版本失效、原计数器锁循环场景、两槽跨连接准入、回滚/超时释放、50 连接超载和事件清理;结果见 postgres-concurrency.json。50 连接同时突发时 2 个进入、48 个快速繁忙,证明保护生效,不代表满足 50 用户容量目标。
+
+尚未完成:生产 SQL 执行计划与索引核对、按实际峰值的 1/5/10/20/50 用户压测及 30 分钟稳定性测试、真实老师/学生设备验收、全局冷查询减半目标。因此当前不具备声称全计划验收通过的证据。
+
+## 发布次序与回滚
+
+1. 已完成隔离真实 PostgreSQL 多连接正确性验证,仍需按生产数据和实际请求节奏测定合适重查询额度;当前 2 只是初始值,不是已证明的生产容量。不得直接执行已有 ensure-app-performance-indexes 脚本作为盲目补索引。
+2. 对比/备份生产两个运营网关与手机网关,保留线上额外修复;不要全量覆盖未经核对的生产定义。原始定义读取尚未成功,需具备发布权限后核对。
+3. 执行 read-revisions.sql 与 lesson-export.sql,先迁移再发布两个运营网关和手机/后台前端。只部署本次已审阅变更。
+4. 在现有 /opt/xiaoshu-angular 安装同版本依赖,配置 /etc/xiaoshu/lesson-export.env(0600,专用超级管理员会话),启用 deployment/systemd/xiaoshu-lesson-export.service;先用 --once 完成一个受控导出验收。worker 未就绪前不开放新前端导出入口。
+5. 灰度后核验训练列表、普通分页、分批查看、后台导出、教学提交、费用实时核验;记录超时/繁忙/提交成功率并观察 30 分钟。财务 settlementReady 仍只由原实时校验决定。
+6. 回滚前暂停接收新导出并等待或取消未完成任务;恢复旧前端和网关、停止导出 worker。新增任务/事件表保留作审计,不删除源业务数据。只有确认所有新版本消费者已撤回后,才移除 xs_revision_* 触发器;迁移不替换原业务函数。
+
+上线需按此前约定另行批准。本轮没有修改线上配置、云函数定义或业务数据。

+ 33 - 0
perf/cloud-concurrency-2026-11-15/VALIDATION.json

@@ -0,0 +1,33 @@
+{
+  "date": "2026-11-15",
+  "adminTests": {
+    "passed": 405,
+    "failed": 0
+  },
+  "mobileTests": {
+    "passed": 419,
+    "failed": 0
+  },
+  "serverTests": {
+    "passed": 611,
+    "failed": 0
+  },
+  "builds": [
+    "xiaoshu-admin",
+    "xiaoshu-mobile"
+  ],
+  "cloudDefinitions": 11,
+  "postgresMultiConnectionChecks": 7,
+  "productionDeployment": false,
+  "productionCapacityAcceptance": false,
+  "globalColdQueryHalfTargetMet": false,
+  "focusedFinalTests": {
+    "passed": 45,
+    "failed": 0
+  },
+  "adminFinalFocusedTests": {
+    "passed": 35,
+    "failed": 0
+  },
+  "integrationBase": "b1af307"
+}

+ 178 - 0
perf/cloud-concurrency-2026-11-15/local-sql-benchmark.json

@@ -0,0 +1,178 @@
+{
+  "scope": "isolated PGlite SQL and serialization only, not browser or production latency",
+  "alternating": true,
+  "results": [
+    {
+      "size": 20000,
+      "scenario": "student",
+      "samples": {
+        "before": [
+          211.87871900000027,
+          219.64798399999972,
+          214.0134049999997,
+          213.4748529999997,
+          215.21798500000023,
+          214.56667000000016,
+          212.50156900000002,
+          218.86762499999986,
+          211.7387209999997,
+          216.8881419999998
+        ],
+        "after": [
+          25.255822999999964,
+          25.123900999999933,
+          25.014846000000034,
+          26.001232999999957,
+          24.46826400000009,
+          24.228755000000092,
+          22.58528699999988,
+          24.417021999999633,
+          22.98859799999991,
+          21.758934999999838
+        ]
+      },
+      "summary": {
+        "before": {
+          "p50": 214.0134049999997,
+          "p75": 216.8881419999998,
+          "p95": 219.64798399999972
+        },
+        "after": {
+          "p50": 24.417021999999633,
+          "p75": 25.123900999999933,
+          "p95": 26.001232999999957
+        }
+      },
+      "p75Reduction": 0.8841619427953791
+    },
+    {
+      "size": 20000,
+      "scenario": "global-page-2",
+      "samples": {
+        "before": [
+          239.06451000000015,
+          238.32689800000026,
+          233.2264439999999,
+          239.5265819999995,
+          243.16321900000003,
+          238.78595200000018,
+          271.6379300000008,
+          231.87521799999922,
+          228.54419400000006,
+          237.33964200000082
+        ],
+        "after": [
+          212.9134839999997,
+          207.6322399999999,
+          207.81507299999976,
+          207.80482200000006,
+          199.35241700000006,
+          199.48132100000112,
+          201.9684470000011,
+          200.68187200000102,
+          211.63605300000017,
+          214.89918600000055
+        ]
+      },
+      "summary": {
+        "before": {
+          "p50": 238.32689800000026,
+          "p75": 239.5265819999995,
+          "p95": 271.6379300000008
+        },
+        "after": {
+          "p50": 207.6322399999999,
+          "p75": 211.63605300000017,
+          "p95": 214.89918600000055
+        }
+      },
+      "p75Reduction": 0.11644022457599046
+    },
+    {
+      "size": 60000,
+      "scenario": "student",
+      "samples": {
+        "before": [
+          668.3410129999993,
+          678.7264340000002,
+          730.4322900000025,
+          662.254272000002,
+          694.8378109999976,
+          633.8455910000011,
+          647.3529449999987,
+          643.5464859999993,
+          643.9080529999992,
+          666.3997000000018
+        ],
+        "after": [
+          26.577015000000756,
+          26.632595000000947,
+          26.378049999999348,
+          25.64988000000085,
+          26.167685000000347,
+          25.1687720000009,
+          24.065952000000834,
+          25.62937999999849,
+          25.614363999997295,
+          24.974324999999226
+        ]
+      },
+      "summary": {
+        "before": {
+          "p50": 662.254272000002,
+          "p75": 678.7264340000002,
+          "p95": 730.4322900000025
+        },
+        "after": {
+          "p50": 25.62937999999849,
+          "p75": 26.378049999999348,
+          "p95": 26.632595000000947
+        }
+      },
+      "p75Reduction": 0.9611359618859351
+    },
+    {
+      "size": 60000,
+      "scenario": "global-page-2",
+      "samples": {
+        "before": [
+          788.4444949999997,
+          842.46443,
+          848.7770579999997,
+          773.6882970000006,
+          781.4832590000005,
+          855.547120999996,
+          869.348739999994,
+          904.3011070000066,
+          881.7855940000009,
+          910.1078619999971
+        ],
+        "after": [
+          656.4364329999989,
+          751.819491000002,
+          683.2703089999995,
+          656.477855000001,
+          717.6912039999988,
+          781.0203849999962,
+          685.5582120000036,
+          712.7164049999992,
+          791.4512810000015,
+          752.0658089999997
+        ]
+      },
+      "summary": {
+        "before": {
+          "p50": 848.7770579999997,
+          "p75": 881.7855940000009,
+          "p95": 910.1078619999971
+        },
+        "after": {
+          "p50": 712.7164049999992,
+          "p75": 752.0658089999997,
+          "p95": 791.4512810000015
+        }
+      },
+      "p75Reduction": 0.14711034732554384
+    }
+  ]
+}

+ 39 - 0
perf/cloud-concurrency-2026-11-15/postgres-concurrency.json

@@ -0,0 +1,39 @@
+{
+  "isolated": true,
+  "productionTouched": false,
+  "checks": [
+    {
+      "name": "late-commit-READ COMMITTED",
+      "passed": true
+    },
+    {
+      "name": "late-commit-REPEATABLE READ",
+      "passed": true
+    },
+    {
+      "name": "former-revision-lock-cycle",
+      "passed": true
+    },
+    {
+      "name": "global-two-slots-and-rollback-release",
+      "passed": true
+    },
+    {
+      "name": "timeout-rollback-releases-slot",
+      "passed": true
+    },
+    {
+      "name": "50-connection-overload",
+      "passed": true,
+      "maxActive": 2,
+      "success": 2,
+      "busy": 48,
+      "note": "Intentional saturation test, not a successful 50-user capacity acceptance"
+    },
+    {
+      "name": "prune-preserves-version",
+      "passed": true
+    }
+  ],
+  "version": "PostgreSQL 18.4 on x86_64-apple-darwin24.6.0, compiled by Apple clang version 17.0.0 (clang-1700.6.3.2), 64-bit"
+}

+ 94 - 0
perf/cloud-concurrency-2026-11-15/production-baseline.json

@@ -0,0 +1,94 @@
+{
+  "at": "2026-11-15T10:23:57.178Z",
+  "readOnly": true,
+  "functions": [],
+  "samples": [
+    {
+      "ms": 3574.408981,
+      "status": 200,
+      "success": true,
+      "bytes": 16604,
+      "items": 20,
+      "total": 19921
+    },
+    {
+      "ms": 3282.2459479999998,
+      "status": 200,
+      "success": true,
+      "bytes": 16604,
+      "items": 20,
+      "total": 19921
+    },
+    {
+      "ms": 3249.451712000001,
+      "status": 200,
+      "success": true,
+      "bytes": 16604,
+      "items": 20,
+      "total": 19921
+    },
+    {
+      "ms": 3099.448137000001,
+      "status": 200,
+      "success": true,
+      "bytes": 16604,
+      "items": 20,
+      "total": 19921
+    },
+    {
+      "ms": 3162.2738689999987,
+      "status": 200,
+      "success": true,
+      "bytes": 16604,
+      "items": 20,
+      "total": 19921
+    },
+    {
+      "ms": 3171.861947999998,
+      "status": 200,
+      "success": true,
+      "bytes": 16604,
+      "items": 20,
+      "total": 19921
+    },
+    {
+      "ms": 3163.2982269999993,
+      "status": 200,
+      "success": true,
+      "bytes": 16604,
+      "items": 20,
+      "total": 19921
+    },
+    {
+      "ms": 3520.039885000002,
+      "status": 200,
+      "success": true,
+      "bytes": 16604,
+      "items": 20,
+      "total": 19921
+    },
+    {
+      "ms": 3209.7914079999973,
+      "status": 200,
+      "success": true,
+      "bytes": 16604,
+      "items": 20,
+      "total": 19921
+    },
+    {
+      "ms": 3138.313417999998,
+      "status": 200,
+      "success": true,
+      "bytes": 16604,
+      "items": 20,
+      "total": 19921
+    }
+  ],
+  "definitionReadStatus": 400,
+  "summary": {
+    "runs": 10,
+    "p50": 3171.861947999998,
+    "p75": 3282.2459479999998,
+    "p95": 3574.408981
+  }
+}

+ 6 - 0
projects/shared/lesson-export-columns.mjs

@@ -0,0 +1,6 @@
+export function lessonExportColumns(r) {
+ return {'会员 ID':r.studentId,'学员':r.studentName,'课程':r.courseName,'门店':r.storeName,'授课老师':r.coachName,
+ '训练时间(北京时间)':r.lessonAt?new Date(r.lessonAt).toLocaleString('sv-SE',{timeZone:'Asia/Shanghai'}):'未记录',
+ '课型':r.classTypeLabel,'老师点评':r.coachComment,'学生评分':r.score||'未评分','学生评价':r.studentComment,
+ '反馈摘要':r.feedbackSummary,'复习陪练':(r.reviewCoaches||[]).map(c=>c.name).join('、'),'待复习次数':r.pendingReviews};
+}

+ 96 - 0
projects/shared/request-scheduler.ts

@@ -0,0 +1,96 @@
+import { Observable, Subscription, defer, finalize, of, shareReplay, tap, throwError } from 'rxjs';
+
+export interface RequestPolicy<T> {
+  read: boolean;
+  key?: string;
+  heavy?: boolean;
+  background?: boolean;
+  valid?: () => boolean;
+  cacheMs?: number;
+  refresh?: boolean;
+  cacheable?: (value: T) => boolean;
+}
+interface Ticket { read: boolean; heavy: boolean; background: boolean; start: () => void; }
+export function stableRequestJson(value: unknown): string {
+  if (Array.isArray(value)) return '[' + value.map(stableRequestJson).join(',') + ']';
+  if (value && typeof value === 'object') return '{' + Object.entries(value).filter(([,v]) => v !== undefined).sort(([a],[b]) => a.localeCompare(b)).map(([k,v]) => JSON.stringify(k)+':'+stableRequestJson(v)).join(',') + '}';
+  return JSON.stringify(value) ?? 'null';
+}
+/** One scheduler per app: all gateway calls share capacity, including retries. */
+export class RequestScheduler {
+  private active = 0;
+  private reads = 0;
+  private heavy = 0;
+  private revision = 0;
+  private queue: Ticket[] = [];
+  private pending = new Map<string, Observable<unknown>>();
+  private cache = new Map<string, { value: unknown; expires: number }>();
+  private readonly now: () => number;
+  private readonly queueTimeoutMs: number;
+  private completed = 0;
+  private cancelled = 0;
+  private coalesced = 0;
+  constructor(options: { now?: () => number; queueTimeoutMs?: number } = {}) {
+    this.now = options.now ?? Date.now;
+    this.queueTimeoutMs = options.queueTimeoutMs ?? 10_000;
+  }
+  invalidate(): void { this.revision++; this.cache.clear(); this.pending.clear(); }
+  snapshot(): { active: number; queued: number; completed: number; cancelled: number; coalesced: number } {
+    return { active: this.active, queued: this.queue.length, completed: this.completed, cancelled: this.cancelled, coalesced: this.coalesced };
+  }
+  schedule<T>(run: () => Observable<T>, policy: RequestPolicy<T>): Observable<T> {
+    return defer(() => {
+      if (policy.valid && !policy.valid()) return throwError(() => new Error('会话已变化,请重新查询'));
+      const key = policy.read ? policy.key : undefined;
+      const cached = key && !policy.refresh ? this.cache.get(key) : undefined;
+      if (cached && cached.expires > this.now()) return of(cached.value as T);
+      const pending = key ? this.pending.get(key) : undefined;
+      if (pending) { this.coalesced++; return pending as Observable<T>; }
+      const revision = this.revision;
+      const request = new Observable<T>(subscriber => {
+        let started = false, ended = false, sub: Subscription | undefined;
+        let timer: ReturnType<typeof setTimeout> | undefined;
+        const ticket: Ticket = { read: policy.read, heavy: !!policy.heavy, background: !!policy.background, start: () => {
+          started = true; if (timer) clearTimeout(timer);
+          this.active++; if (ticket.read) this.reads++; if (ticket.heavy) this.heavy++;
+          if (policy.valid && !policy.valid()) { subscriber.error(new Error('会话已变化,请重新查询')); return; }
+          sub = defer(run).subscribe({ next: value => {
+            if (policy.valid && !policy.valid()) { subscriber.error(new Error('会话已变化,请重新查询')); return; }
+            subscriber.next(value);
+          }, error: error => subscriber.error(error), complete: () => { ended = true; this.completed++; subscriber.complete(); } });
+        } };
+        if (this.queue.length >= 64) subscriber.error(new Error('请求排队过多,请稍后重试'));
+        else {
+          this.queue.push(ticket);
+          timer = setTimeout(() => subscriber.error(new Error('请求排队暂未完成,请稍后重试')), this.queueTimeoutMs);
+          this.drain();
+        }
+        return () => {
+          if (timer) clearTimeout(timer);
+          this.queue = this.queue.filter(item => item !== ticket);
+          sub?.unsubscribe();
+          if (started) { this.active--; if (ticket.read) this.reads--; if (ticket.heavy) this.heavy--; }
+          if (!ended) this.cancelled++;
+          this.drain();
+        };
+      }).pipe(tap(value => {
+        if (key && policy.cacheMs && policy.cacheable?.(value) && revision === this.revision) {
+          // Bounded cache, timestamps originate at the response, never at a cache hit.
+          if (this.cache.size >= 100) this.cache.delete(this.cache.keys().next().value!);
+          this.cache.set(key, { value, expires: this.now() + Math.min(30_000, policy.cacheMs) });
+        }
+      }), finalize(() => { if (key && this.pending.get(key) === shared) this.pending.delete(key); }));
+      const shared = request.pipe(shareReplay({ bufferSize: 1, refCount: true }));
+      if (key) this.pending.set(key, shared);
+      return shared;
+    });
+  }
+  private drain(): void {
+    while (this.active < 4) {
+      const candidates = this.queue.filter(t => (!t.read || this.reads < 3) && (!t.heavy || this.heavy < 1));
+      const next = candidates.find(t => !t.read) ?? candidates.find(t => !t.background) ?? candidates[0];
+      if (!next) return;
+      this.queue.splice(this.queue.indexOf(next), 1); next.start();
+    }
+  }
+}

+ 37 - 10
projects/xiaoshu-admin/src/app/cloud-functions.service.ts

@@ -1,6 +1,7 @@
+import { RequestScheduler, stableRequestJson } from '../../../shared/request-scheduler';
 import { HttpClient, HttpErrorResponse } from '@angular/common/http';
 import { inject, Injectable } from '@angular/core';
-import { firstValueFrom, Subject, timeout, throwError, retry, timer, mergeMap, of } from 'rxjs';
+import { firstValueFrom, Subject, timeout, throwError, retry, timer, mergeMap, of, Observable } from 'rxjs';
 import { CLOUD_FUNCTION_BASE_URL } from './admin.constants';
 import { AdminSessionService } from './admin-session.service';
 import { CloudEnvelope, LegacyCloudEnvelope } from './admin.models';
@@ -13,6 +14,8 @@ export class CloudFunctionError extends Error {
 
 @Injectable({ providedIn: 'root' })
 export class CloudFunctionsService {
+  private readonly scheduler = new RequestScheduler();
+  requestActivity() { return this.scheduler.snapshot(); }
   readonly dataChanges$ = new Subject<'local' | 'broadcast'>();
   private readonly http = inject(HttpClient);
   private readonly sessions = inject(AdminSessionService);
@@ -40,7 +43,7 @@ export class CloudFunctionsService {
 
   // Explicitly allow only reads. Unknown operations and all submissions must never replay.
   private isReadOperation(operation: string): boolean {
-    return /^ops\/(?:users\/(?:list|detail|notes|audit|credit-preview|credit-accounts|credit-ledger|credit-status)|schedule\/calendar|live\/versions|dashboard\/(?:summary|schedule-trend)|coaches\/(?:list|detail)|stores\/(?:list|detail)|coach-availability\/list|learning-reports\/(?:list|detail)|vocabulary\/(?:tree|list)|teaching-rules\/list|pay-rates\/list|appointments\/(?:preflight|list))$/.test(operation);
+    return /^ops\/(?:payroll\/(?:overview|list|coaches|detail|export|review-rewards)|lessons\/(?:list|detail|export\/(?:status|download))|users\/(?:list|detail|notes|audit|credit-preview|credit-accounts|credit-ledger|credit-status)|schedule\/calendar|live\/versions|dashboard\/(?:summary|schedule-trend)|coaches\/(?:list|detail)|stores\/(?:list|detail)|coach-availability\/list|learning-reports\/(?:list|detail)|vocabulary\/(?:tree|list)|teaching-rules\/list|pay-rates\/list|appointments\/(?:preflight|list))$/.test(operation);
   }
 
   private transportMessage(readOnly: boolean): string {
@@ -49,23 +52,42 @@ export class CloudFunctionsService {
       : '连接暂未完成,提交结果待核对;请先刷新核对,避免重复提交,已填写内容请保留。';
   }
 
-  private async request<T>(path: string, token: string, params: Record<string, unknown>, readOnly: boolean, timeoutMs = 20000): Promise<T> {
+  private async request<T>(path: string, token: string, params: Record<string, unknown>, readOnly: boolean, timeoutMs = 20000, signal?: AbortSignal): Promise<T> {
     try {
-      return await firstValueFrom(this.http.post<T>(`${CLOUD_FUNCTION_BASE_URL}/${path}`, { token, params }).pipe(
+      const operation = String(params['operation'] || params['action'] || '');
+      const identity = stableRequestJson(this.sessions.session());
+      const {refresh: _refresh,...cacheParams}=params;
+      const key = stableRequestJson([path, identity, cacheParams]);
+      const execute = () => this.http.post<T>(`${CLOUD_FUNCTION_BASE_URL}/${path}`, { token, params }).pipe(
         timeout({ first: timeoutMs, with: () => throwError(() => new CloudFunctionError(this.transportMessage(readOnly))) }),
         retry({ count: 1, delay: (error) => {
           const transient = error instanceof HttpErrorResponse && [0, 502, 503, 504].includes(error.status);
           // A timeout may leave work running on the server; do not add another slow request.
           if (!readOnly || !transient || this.sessions.session()?.sessionToken !== token) return throwError(() => error);
-          return timer(500).pipe(mergeMap(() => this.sessions.session()?.sessionToken === token ? of(0) : throwError(() => error)));
+          const suggested=Number(error.error?.retryAfterMs);
+          return timer(Math.max(400,Number.isFinite(suggested)?Math.min(10000,suggested):0)+Math.floor(Math.random()*100)).pipe(mergeMap(() => this.sessions.session()?.sessionToken === token ? of(0) : throwError(() => error)));
         } }),
-      ));
+      );
+      const scheduled = this.scheduler.schedule(execute, {
+        read: readOnly, key, heavy: /^ops\/(?:lessons\/(?:list|detail|export\/download)|payroll\/(?:overview|export))$/.test(operation),
+        background: operation === 'ops/live/versions', valid: () => stableRequestJson(this.sessions.session()) === identity,
+        cacheMs: ['ops/lessons/list', 'ops/vocabulary/tree', 'ops/pay-rates/list', 'ops/teaching-rules/list'].includes(operation) ? 30_000 : 0,
+        refresh: params['refresh'] === true,
+        cacheable: value => (value as CloudEnvelope<unknown>)?.success === true,
+      });
+      return await firstValueFrom(signal ? new Observable<T>(subscriber => {
+        if (signal.aborted) { subscriber.error(new Error('查询已取消')); return; }
+        const sub = scheduled.subscribe(subscriber);
+        const abort = () => subscriber.error(new Error('查询已取消'));
+        signal.addEventListener('abort', abort, { once: true });
+        return () => { signal.removeEventListener('abort', abort); sub.unsubscribe(); };
+      }) : scheduled);
     } catch (error) {
       if (error instanceof HttpErrorResponse && [0, 502, 503, 504].includes(error.status)) {
         throw new CloudFunctionError(this.transportMessage(readOnly), error.status);
       }
       throw error;
-    }
+    } finally { if (!readOnly && !String(params['operation']||'').startsWith('ops/lessons/export/')) this.scheduler.invalidate(); }
   }
 
   private async handleAuthenticationFailure(token: string, status: number, payload: unknown): Promise<void> {
@@ -78,13 +100,14 @@ export class CloudFunctionsService {
   }
 
   private isMutation(operation: string): boolean {
+    if(operation.startsWith('ops/lessons/export/'))return false;
     return /\/(?:create|save|update|delete|recycle|restore|complete|adjust|post|commit|confirm|cancel|review|mark-paid|deactivate|run|retry|refresh|add-adjustment|exclude-line|import-commit)$/.test(operation);
   }
 
   private createDataChannel(): BroadcastChannel | null {
     if (typeof globalThis.BroadcastChannel !== 'function') return null;
     const channel = new globalThis.BroadcastChannel('xiaoshu-live-data-v1');
-    channel.addEventListener('message', () => this.dataChanges$.next('broadcast'));
+    channel.addEventListener('message', () => { this.scheduler.invalidate(); this.dataChanges$.next('broadcast'); });
     return channel;
   }
 
@@ -105,20 +128,24 @@ export class CloudFunctionsService {
   }
 
   private announceDataChange(): void {
+    this.scheduler.invalidate();
     if (this.changeBatchDepth) { this.pendingDataChange = true; return; }
     this.dataChanges$.next('local');
     this.dataChannel?.postMessage({ changedAt: new Date().toISOString() });
   }
 
-  async operations<T>(operation: string, params: Record<string, unknown> = {}): Promise<T> {
+  async operations<T>(operation: string, params: Record<string, unknown> = {}, options: { signal?: AbortSignal } = {}): Promise<T> {
     const token = this.sessions.session()?.sessionToken;
     if (!token) throw new CloudFunctionError('管理员会话已失效', 401);
     try {
       const envelope = await this.request<CloudEnvelope<T>>('xiaoshu/ops/gateway-v3', token,
         { ...params, operation }, this.isReadOperation(operation),
-        /^ops\/vocabulary\/import-(preview|commit)$/.test(operation) ? 60000 : 20000);
+        /^ops\/vocabulary\/import-(preview|commit)$/.test(operation) ? 60000 : /^ops\/lessons\/export\/snapshot$/.test(operation) ? 90000 : 20000, options.signal);
       if (!envelope.success) { await this.handleAuthenticationFailure(token, 0, envelope); throw new CloudFunctionError(envelope.message || envelope.error || '运营云函数执行失败'); }
       if (this.isMutation(operation)) this.announceDataChange();
+      if(operation==='ops/lessons/list'&&envelope.data&&typeof envelope.data==='object'){
+        const data=envelope.data as {clientFetchedAt?:number};data.clientFetchedAt??=Date.now();
+      }
       return envelope.data;
     } catch (error) {
       if (error instanceof CloudFunctionError) throw error;

+ 6 - 2
projects/xiaoshu-admin/src/app/operations.service.ts

@@ -57,8 +57,12 @@ export class OperationsService {
     return this.functions.operations<ScheduleTrendData>('ops/dashboard/schedule-trend', { period });
   }
 
-  request<T>(operation: string, params: Record<string, unknown> = {}): Promise<T> {
-    return this.functions.operations<T>(operation, params);
+  lessonExportStorageKey(): string {
+    const session=this.sessions.session();return 'xiaoshu:lesson-export:'+JSON.stringify([session?.company?.objectId,session?.objectId,session?.storeId,session?.operationsRole]);
+  }
+
+  request<T>(operation: string, params: Record<string, unknown> = {}, options: { signal?: AbortSignal } = {}): Promise<T> {
+    return this.functions.operations<T>(operation, params, options);
   }
 
   users(page: number, pageSize: number, search: string, filters: Record<string, unknown> = {}, sort: MemberListSort = { field: 'registeredAt', direction: 'desc' }, refresh = false): Promise<MemberPage> {

+ 1 - 1
projects/xiaoshu-admin/src/app/pages/lesson-record.models.ts

@@ -4,7 +4,7 @@ export interface LessonHistoryRow {
  score:number;coachComment:string;studentComment:string;feedbackSummary:string;pendingReviews:number;
  reviewCoachLabel:string;reviewCoaches:{id:string;name:string;count:number}[];duplicateCount:number;
 }
-export interface LessonHistoryPage {items:LessonHistoryRow[];total:number;cumulativeTotal:number;page:number;pageSize:number;refreshedAt:string;version:string}
+export interface LessonHistoryPage {clientFetchedAt?:number;nextCursor?:{at:string|null;id:string;version:string}|null;items:LessonHistoryRow[];total:number;cumulativeTotal:number;page:number;pageSize:number;refreshedAt:string;version:string}
 export interface ReviewTask {objectId:string;coachId:number;coachName:string;plannedDate:string;status:string;completedAt:string}
 export interface LessonHistoryDetail extends LessonHistoryRow {report:Record<string,unknown>|null;studentReview:{score:number;comment:string};tasks:ReviewTask[];audit:Record<string,unknown>[];canTransfer:boolean;hasReport:boolean}
 export interface TransferPreview {version:string;count:number;lessonCount:number;unlinkedCount:number;dateFrom:string;dateTo:string;tasks:{taskId:string;coachId:string;coachName:string;plannedDate:string;appointmentId:string}[]}

+ 9 - 3
projects/xiaoshu-admin/src/app/pages/lesson-records.component.html

@@ -1,11 +1,17 @@
 <section class="lesson-history">
-<header class="page-header"><div><h1>{{embedded?'训练记录':'记录管理 · 训练记录'}}</h1><p>查询完整训练历史、查看课堂反馈与转交抗遗忘复习</p></div><button class="ui-button secondary" (click)="export()" [disabled]="exporting()||busy()">{{exporting()?'正在导出…':'导出 Excel'}}</button></header>
+<header class="page-header"><div><h1>{{embedded?'训练记录':'记录管理 · 训练记录'}}</h1><p>查询完整训练历史、查看课堂反馈与转交抗遗忘复习</p></div><button class="ui-button secondary" (click)="export()" [disabled]="exporting()||busy()">{{exporting()?'后台正在导出…':'导出 Excel'}}</button></header>
+@if(exportJob();as job){<div class="notice" role="status">
+ @if(job.status==='ready'){导出已完成 <button class="ui-button secondary" (click)="downloadExport()">下载 Excel</button>}
+ @else if(job.status==='queued'||job.status==='running'){{{job.status==='queued'?'导出排队中':'正在生成导出文件'}} · {{job.processed}} / {{job.total||'待统计'}} 条
+ <button class="ui-button secondary" (click)="cancelExport()">取消导出</button>@if(!exporting()){<button class="ui-button secondary" (click)="continueExport()">继续查看进度</button>}}
+ @else{导出{{job.status==='cancelled'?'已取消':'未完成'}}}
+ </div>}
 <form class="lesson-filters" (ngSubmit)="query()">
 @if(!embedded){<label>学员姓名<input name="search" [(ngModel)]="search" placeholder="输入学员姓名"></label><label>会员 ID<input name="member" [(ngModel)]="memberId" inputmode="numeric" placeholder="精确查询"></label>}
 <label>授课老师<input name="coach" [(ngModel)]="coachName" placeholder="老师姓名"></label><label>门店<input name="store" [(ngModel)]="storeName" placeholder="门店名称"></label><label>课程<input name="course" [(ngModel)]="courseName" placeholder="课程名称"></label><app-operations-date-filter class="start-date-filter" label="开始日期" [value]="dateFrom" (valueChange)="dateFrom=$event" /><app-operations-date-filter label="结束日期" [value]="dateTo" (valueChange)="dateTo=$event" />
 <div class="filter-actions"><button type="submit" class="ui-button" [disabled]="busy()">查询</button><button type="button" class="ui-button secondary" [disabled]="busy()" (click)="reset()">重置</button></div></form>
 @if(error()){<p role="alert" class="notice error">{{error()}}</p>}@if(busy()){<p class="notice" role="status">正在查询训练记录…</p>}
-@if(data();as d){<div class="record-count"><strong>累计已训练 {{d.cumulativeTotal}} 节</strong><span>当前筛选 {{d.total}} 节 · 时间均为北京时间</span><label>每页<select [(ngModel)]="pageSize" (ngModelChange)="query()" [disabled]="busy()"><option [ngValue]="20">20 条</option><option [ngValue]="50">50 条</option><option [ngValue]="100">100 条</option></select></label>@if(studentId||memberId){<button class="ui-button secondary" [disabled]="busy()" (click)="showAll()">查看该学员全部结果</button>}</div>
+@if(data();as d){<div class="record-count"><strong>累计已训练 {{d.cumulativeTotal}} 节</strong><span>当前筛选 {{d.total}} 节 · 时间均为北京时间</span><label>每页<select [(ngModel)]="pageSize" (ngModelChange)="query()" [disabled]="busy()"><option [ngValue]="20">20 条</option><option [ngValue]="50">50 条</option><option [ngValue]="100">100 条</option></select></label>@if(studentId||memberId){<button class="ui-button secondary" [disabled]="busy()" (click)="showAll()">分批查看该学员全部结果</button>}</div>
 <section class="content-card">
   <div class="table-scroll" role="region" aria-label="训练记录列表" tabindex="0">
     <table class="data-table lesson-table">
@@ -60,6 +66,6 @@
     </table>
   </div>
 </section>
-<div class="lesson-pagination"><span>已显示 {{d.items.length}} 条 / 共 {{d.total}} 条</span>@if(!all){<div><button class="ui-button secondary" [disabled]="busy()||page<=1" (click)="turn(-1)">上一页</button><span>第 {{page}} 页</span><button class="ui-button secondary" [disabled]="busy()||page*pageSize>=d.total" (click)="turn(1)">下一页</button></div>}</div>}
+<div class="lesson-pagination"><span>已显示 {{d.items.length}} 条 / 共 {{d.total}} 条</span>@if(all&&d.items.length<d.total){<button class="ui-button secondary" [disabled]="busy()" (click)="loadMore()">继续加载 100 条</button>}@if(!all){<div><button class="ui-button secondary" [disabled]="busy()||page<=1" (click)="turn(-1)">上一页</button><span>第 {{page}} 页</span><button class="ui-button secondary" [disabled]="busy()||page*pageSize>=d.total" (click)="turn(1)">下一页</button></div>}</div>}
 @if(detailId()){<app-lesson-record-detail [lessonId]="detailId()" (closed)="detailId.set('')" (changed)="load()" />}
 </section>

+ 19 - 5
projects/xiaoshu-admin/src/app/pages/lesson-records.component.spec.ts

@@ -35,15 +35,29 @@ describe('训练记录与复习转交',()=>{
   now+=31_000;ops.request.and.rejectWith(new Error('暂时断网'));c.onOperationsRouteAttach();await Promise.resolve();
   expect(c.data()?.items.length).toBe(1);expect(c.busy()).toBeFalse();expect(c.error()).toBe('暂时断网');c.ngOnDestroy();
  });
- it('单个会员查看全部连续读取分页,保持筛选与实际总课次数',async()=>{
+ it('查看全部只加载一批,用户继续加载才请求下一批',async()=>{
   const c=TestBed.runInInjectionContext(()=>new LessonRecordsComponent());c.studentId=20;c.all=true;c.coachName='钱老师';
-  ops.request.and.returnValues(Promise.resolve(page([{lessonId:'a'}])),Promise.resolve(page([{lessonId:'b'}])));
-  await c.load();expect(c.data()?.items.length).toBe(2);expect(c.data()?.cumulativeTotal).toBe(51);expect(ops.request.calls.mostRecent().args[1]).toEqual(jasmine.objectContaining({studentId:20,coachName:'钱老师',page:2}));
+  ops.request.and.returnValues(Promise.resolve(page([{lessonId:'a'}])),Promise.resolve({...page([{lessonId:'b'}]),page:2}));
+  await c.load();expect(c.data()?.items.length).toBe(1);expect(ops.request).toHaveBeenCalledTimes(1);
+  await c.loadMore();expect(c.data()?.items.length).toBe(2);expect(c.data()?.cumulativeTotal).toBe(51);
  });
- it('后续页失败时清除不完整结果并允许重试',async()=>{
+ it('后续批失败仍保留已加载内容,重复和版本变化不能混入结果',async()=>{
   const c=TestBed.runInInjectionContext(()=>new LessonRecordsComponent());c.studentId=20;c.all=true;
   ops.request.and.returnValues(Promise.resolve(page([{lessonId:'a'}])),Promise.reject(new Error('网络中断')));
-  await c.load();expect(c.data()).toBeNull();expect(c.error()).toBe('网络中断');expect(c.busy()).toBeFalse();
+  await c.load();await c.loadMore();expect(c.data()?.items.length).toBe(1);expect(c.error()).toBe('网络中断');expect(c.busy()).toBeFalse();
+  ops.request.and.resolveTo({...page([{lessonId:'a'}]),page:2});await c.loadMore();expect(c.data()?.items.length).toBe(1);expect(c.error()).toContain('变化');
+ });
+ it('新查询和销毁取消旧查询',async()=>{
+  const c=TestBed.runInInjectionContext(()=>new LessonRecordsComponent());ops.request.and.resolveTo(page([]));await c.load();const signal=ops.request.calls.mostRecent().args[2]?.signal;
+  await c.load();expect(signal?.aborted).toBeTrue();const next=ops.request.calls.mostRecent().args[2]?.signal;c.ngOnDestroy();expect(next?.aborted).toBeTrue();
+ });
+ it('取消时服务端已经完成仍可下载',async()=>{
+  const c=TestBed.runInInjectionContext(()=>new LessonRecordsComponent());c.exportJob.set({jobId:'j',status:'running',total:2,processed:1});
+  ops.request.and.resolveTo({jobId:'j',status:'ready',total:2,processed:2});await c.cancelExport();expect(c.exportJob()?.status).toBe('ready');
+ });
+ it('复用缓存不重置来源时间,防止页面复用叠加超过30秒',async()=>{
+  let now=129000;spyOn(Date,'now').and.callFake(()=>now);ops.request.and.resolveTo({...page([]),clientFetchedAt:100000});
+  const c=TestBed.runInInjectionContext(()=>new LessonRecordsComponent());await c.load();now=131000;c.onOperationsRouteAttach();expect(ops.request).toHaveBeenCalledTimes(2);await Promise.resolve();c.ngOnDestroy();
  });
  it('查询默认没有月份限制;切换条件清除查看全部',async()=>{
   const c=TestBed.runInInjectionContext(()=>new LessonRecordsComponent());expect(c.params()['dateFrom']).toBe('');expect(c.params()['dateTo']).toBe('');

+ 50 - 6
projects/xiaoshu-admin/src/app/pages/lesson-records.component.ts

@@ -5,23 +5,67 @@ import {ActivatedRoute} from '@angular/router';
 import {OperationsService} from '../operations.service';
 import {OperationsDateFilterComponent} from '../components/operations-date-filter.component';
 import {LessonRecordDetailComponent} from './lesson-record-detail.component';
-import {LessonHistoryPage,LessonHistoryRow} from './lesson-record.models';
+import {LessonHistoryPage} from './lesson-record.models';
 @Component({selector:'app-lesson-records',standalone:true,imports:[CommonModule,FormsModule,LessonRecordDetailComponent,OperationsDateFilterComponent],templateUrl:'./lesson-records.component.html',styleUrls:['./operations-professional.scss','./lesson-records.component.scss'],changeDetection:ChangeDetectionStrategy.OnPush})
 export class LessonRecordsComponent implements OnInit,OnChanges,OnDestroy {
  @Input() studentId=0;@Input() embedded=false;@Input() refreshVersion=0;
+ readonly exportJob=signal<{jobId:string;status:string;total:number;processed:number;error?:string}|null>(null);
+ private exportController?:AbortController;private exportKey='';
  readonly data=signal<LessonHistoryPage|null>(null);readonly busy=signal(false);readonly error=signal('');readonly detailId=signal('');readonly exporting=signal(false);
  page=1;pageSize=20;search='';memberId='';coachName='';storeName='';courseName='';dateFrom='';dateTo='';all=false;
  private readonly ops=inject(OperationsService);private readonly route=inject(ActivatedRoute);private serial=0;private ready=false;
+ private controller?:AbortController;private loadedPages=1;
  private loadedAt=0;private readonly changeSubscription=this.ops.dataChanges$?.subscribe(()=>{this.loadedAt=0;});
- ngOnDestroy():void{this.changeSubscription?.unsubscribe();}
+ ngOnDestroy():void{this.serial++;this.controller?.abort();this.exportController?.abort();this.changeSubscription?.unsubscribe();}
  onOperationsRouteAttach():void{if(!this.busy()&&!this.exporting()&&Date.now()-this.loadedAt>30_000)void this.load(true);}
- ngOnInit():void{this.ready=true;this.memberId=this.route.snapshot.queryParamMap.get('studentId')||'';void this.load();}
+ ngOnInit():void{this.ready=true;this.memberId=this.route.snapshot.queryParamMap.get('studentId')||'';void this.load();this.resumeExport();}
  ngOnChanges():void{if(this.ready){this.page=1;this.all=false;void this.load();}}
  params():Record<string,unknown>{return{studentId:this.studentId||this.memberId,search:this.search,coachName:this.coachName,storeName:this.storeName,courseName:this.courseName,dateFrom:this.dateFrom,dateTo:this.dateTo};}
- async load(background=false):Promise<void>{const serial=++this.serial;if(!background)this.busy.set(true);this.error.set('');const params=this.params();try{let d=await this.ops.request<LessonHistoryPage>('ops/lessons/list',{...params,page:this.page,pageSize:this.all?100:this.pageSize});if(this.all){const items=[...d.items];for(let p=2;items.length<d.total;p++){if(serial!==this.serial)return;const next=await this.ops.request<LessonHistoryPage>('ops/lessons/list',{...params,page:p,pageSize:100});if(!next.items.length||next.total!==d.total||next.version!==d.version)throw Error('记录发生变化,请重新查询');items.push(...next.items);}d={...d,items};}if(serial===this.serial){this.data.set(d);this.loadedAt=Date.now();}}catch(e){if(serial===this.serial){if(!background)this.data.set(null);this.error.set(e instanceof Error?e.message:'查询失败');}}finally{if(serial===this.serial)this.busy.set(false);}}
- query():void{this.page=1;this.all=false;void this.load();}
+ async load(background=false,refresh=false):Promise<void>{
+  const serial=++this.serial;this.controller?.abort();const controller=this.controller=new AbortController();
+  if(!background)this.busy.set(true);this.error.set('');
+  try{const d=await this.ops.request<LessonHistoryPage>('ops/lessons/list',{...this.params(),page:this.page,pageSize:this.all?100:this.pageSize,...(refresh?{refresh:true}:{})},{signal:controller.signal});
+   if(serial===this.serial){this.data.set(d);this.loadedPages=1;this.loadedAt=d.clientFetchedAt??Date.now();}
+  }catch(e){if(serial===this.serial){if(!background)this.data.set(null);this.error.set(e instanceof Error?e.message:'查询失败');}}
+  finally{if(serial===this.serial)this.busy.set(false);}
+ }
+ async loadMore():Promise<void>{
+  const previous=this.data();if(this.busy()||!this.all||!previous||previous.items.length>=previous.total)return;
+  const serial=++this.serial;this.controller?.abort();const controller=this.controller=new AbortController();this.busy.set(true);this.error.set('');
+  try{const next=await this.ops.request<LessonHistoryPage>('ops/lessons/list',{...this.params(),page:this.loadedPages+1,pageSize:100,...(previous.nextCursor?{cursor:previous.nextCursor}:{})},{signal:controller.signal});
+   if(serial!==this.serial)return;
+   const ids=new Set(previous.items.map(r=>r.lessonId));
+   if(!next.items.length||next.total!==previous.total||next.version!==previous.version||next.items.some(r=>ids.has(r.lessonId)))throw Error('记录发生变化,请重新查询');
+   this.data.set({...previous,nextCursor:next.nextCursor,items:[...previous.items,...next.items]});this.loadedPages++;
+  }catch(e){if(serial===this.serial)this.error.set(e instanceof Error?e.message:'加载失败');}
+  finally{if(serial===this.serial)this.busy.set(false);}
+ }
+ query():void{this.page=1;this.all=false;void this.load(false,true);}
  reset():void{this.search='';this.memberId='';this.coachName='';this.storeName='';this.courseName='';this.dateFrom='';this.dateTo='';this.query();}
  showAll():void{if(!(this.studentId||this.memberId))return;this.all=true;this.page=1;void this.load();}
  turn(delta:number):void{this.page+=delta;void this.load();}
- async export():Promise<void>{if(this.exporting())return;this.exporting.set(true);this.error.set('');const params=this.params();try{const first=await this.ops.request<LessonHistoryPage>('ops/lessons/list',{...params,page:1,pageSize:100});const rows:LessonHistoryRow[]=[...first.items];for(let page=2;rows.length<first.total;page++){const next=await this.ops.request<LessonHistoryPage>('ops/lessons/list',{...params,page,pageSize:100});if(!next.items.length||next.total!==first.total||next.version!==first.version)throw Error('导出期间记录发生变化,请重试');rows.push(...next.items);}if(new Set(rows.map(r=>r.lessonId)).size!==rows.length)throw Error('导出期间排序变化,请重试');const xlsx=await import('xlsx');const sheet=xlsx.utils.json_to_sheet(rows.map(r=>({'会员 ID':r.studentId,'学员':r.studentName,'课程':r.courseName,'门店':r.storeName,'授课老师':r.coachName,'训练时间(北京时间)':r.lessonAt?new Date(r.lessonAt).toLocaleString('sv-SE',{timeZone:'Asia/Shanghai'}):'未记录','课型':r.classTypeLabel,'老师点评':r.coachComment,'学生评分':r.score||'未评分','学生评价':r.studentComment,'反馈摘要':r.feedbackSummary,'复习陪练':r.reviewCoaches.map(c=>c.name).join('、'),'待复习次数':r.pendingReviews})));const book=xlsx.utils.book_new();xlsx.utils.book_append_sheet(book,sheet,'训练记录');xlsx.writeFile(book,'训练记录.xlsx');}catch(e){this.error.set(e instanceof Error?e.message:'导出失败');}finally{this.exporting.set(false);}}
+ private rememberExport(jobId:string):void{try{sessionStorage.setItem(this.ops.lessonExportStorageKey?.()||'xiaoshu:lesson-export',jobId);}catch{}}
+ private resumeExport():void{try{const jobId=sessionStorage.getItem(this.ops.lessonExportStorageKey?.()||'xiaoshu:lesson-export');if(jobId)void this.monitorExport(jobId);}catch{}}
+ async export():Promise<void>{
+  if(this.exporting())return;this.exporting.set(true);this.error.set('');this.exportKey||=crypto.randomUUID();this.exportController?.abort();const controller=this.exportController=new AbortController();
+  try{const job=await this.ops.request<{jobId:string;status:string;total:number;processed:number}>('ops/lessons/export/create',{...this.params(),idempotencyKey:this.exportKey},{signal:controller.signal});
+   if(controller.signal.aborted)return;this.exportJob.set(job);this.rememberExport(job.jobId);this.exportKey='';void this.monitorExport(job.jobId);
+  }catch(e){if(!controller.signal.aborted){this.exporting.set(false);this.error.set(e instanceof Error?e.message:'导出失败');}}
+ }
+ private async exportWait(ms:number,signal:AbortSignal):Promise<void>{
+  await new Promise<void>(resolve=>{const timer=setTimeout(done,ms);function done(){clearTimeout(timer);signal.removeEventListener('abort',done);resolve();}signal.addEventListener('abort',done,{once:true});if(signal.aborted)done();});
+  if(document.hidden&&!signal.aborted)await new Promise<void>(resolve=>{const done=()=>{if(!document.hidden||signal.aborted){document.removeEventListener('visibilitychange',done);signal.removeEventListener('abort',done);resolve();}};document.addEventListener('visibilitychange',done);signal.addEventListener('abort',done,{once:true});done();});
+ }
+ private async monitorExport(jobId:string):Promise<void>{
+  this.exportController?.abort();const controller=this.exportController=new AbortController();this.exporting.set(true);let delay=2000;
+  try{while(!controller.signal.aborted){const job=await this.ops.request<{jobId:string;status:string;total:number;processed:number;error?:string}>('ops/lessons/export/status',{jobId},{signal:controller.signal});
+    if(controller.signal.aborted)return;this.exportJob.set(job);if(!['queued','running'].includes(job.status)){if(job.status==='failed')this.error.set(job.error||'导出失败');return;}
+    await this.exportWait(delay,controller.signal);delay=Math.min(10000,delay+2000);
+   }
+  }catch(e){if(!controller.signal.aborted)this.error.set(e instanceof Error?e.message:'导出状态读取失败,请点击继续查看');}
+  finally{if(this.exportController===controller)this.exporting.set(false);}
+ }
+ continueExport():void{const job=this.exportJob();if(job)void this.monitorExport(job.jobId);else this.resumeExport();}
+ async cancelExport():Promise<void>{const job=this.exportJob();if(!job)return;try{const result=await this.ops.request<{jobId:string;status:string;total:number;processed:number}>('ops/lessons/export/cancel',{jobId:job.jobId});if(this.exportJob()?.jobId!==job.jobId)return;this.exportController?.abort();this.exporting.set(false);this.exportJob.set(result);}catch(e){this.error.set(e instanceof Error?e.message:'取消失败');}}
+ async downloadExport():Promise<void>{const job=this.exportJob();if(!job||job.status!=='ready')return;try{const file=await this.ops.request<{base64:string;fileName:string;contentType:string}>('ops/lessons/export/download',{jobId:job.jobId});const bytes=Uint8Array.from(atob(file.base64),c=>c.charCodeAt(0)),url=URL.createObjectURL(new Blob([bytes],{type:file.contentType}));const link=document.createElement('a');link.href=url;link.download=file.fileName;link.click();setTimeout(()=>URL.revokeObjectURL(url),1000);}catch(e){this.error.set(e instanceof Error?e.message:'下载失败');}}
 }

+ 12 - 2
projects/xiaoshu-mobile/src/app/core/api.service.spec.ts

@@ -117,13 +117,23 @@ describe('ApiService', () => {
       .flush('Bad Gateway', { status: 502, statusText: 'Bad Gateway' });
     tick(800);
 
-    http.expectOne(API_CONFIG.cloudFunctionUrl)
-      .flush('Bad Gateway', { status: 502, statusText: 'Bad Gateway' });
+    http.expectNone(API_CONFIG.cloudFunctionUrl);
 
     expect(actual instanceof ApiError).toBeTrue();
     expect((actual as ApiError).message).toBe('请求暂未完成,请检查网络后重试;已填写内容会保留。');
   }));
 
+  it('accepts an explicit login token while the originating session is still anonymous',()=>{
+    api.callCloud('user_get',{},'r:new-login-session').subscribe();
+    const request=http.expectOne(API_CONFIG.cloudFunctionUrl);expect(request.request.body.token).toBe('r:new-login-session');
+    request.flush({retcode:0,result:{UserID:12}});
+  });
+
+  it('cannot disguise a write as a cached read through the action parameter',()=>{
+    api.get('node_list',{action:'content_add'}).subscribe();
+    const request=http.expectOne(API_CONFIG.cloudFunctionUrl);expect(request.request.body.params.action).toBe('node_list');request.flush({retcode:0,result:[]});
+  });
+
   it('nests reserved id parameters inside params', () => {
     api.get('node_get', { id: 81 }).subscribe();
     const request = http.expectOne(API_CONFIG.cloudFunctionUrl);

+ 18 - 5
projects/xiaoshu-mobile/src/app/core/api.service.ts

@@ -1,3 +1,4 @@
+import { RequestScheduler, stableRequestJson } from '../../../../shared/request-scheduler';
 import { HttpClient, HttpErrorResponse } from '@angular/common/http';
 import { HomeDailyCacheService } from './home-daily-cache.service';
 import { inject, Injectable } from '@angular/core';
@@ -17,6 +18,8 @@ export class ApiError extends Error {
 
 @Injectable({ providedIn: 'root' })
 export class ApiService {
+  private readonly scheduler = new RequestScheduler();
+  requestActivity() { return this.scheduler.snapshot(); }
   private readonly http = inject(HttpClient);
   private readonly sessions = inject(SessionService);
   private readonly dailyCache = inject(HomeDailyCacheService);
@@ -40,6 +43,7 @@ export class ApiService {
   }
 
   callCloud<T>(action: string, params: Record<string, unknown> = {}, sessionToken = this.parseSessionToken()): Observable<ApiEnvelope<T>> {
+    const originatingSession=this.parseSessionToken();
     const teachingWrite=['app_review_complete','app_lesson_feedback_update','app_lesson_complete','app_lesson_review','e_add_words','content_add_zt','content_update','content_add','stu_record_update_v2','unit_record_update','vote_add','reading/create'].includes(action);
     const requestIdentity=this.parseSessionToken()+'|'+action+'|'+this.stableJson(params);
     if(teachingWrite && !params['requestKey']) {
@@ -48,7 +52,7 @@ export class ApiService {
     }
     const body = {
       ...(sessionToken ? { token: sessionToken } : {}),
-      params: { action, ...params },
+      params: { ...params, action },
     };
     const request = this.http.post<unknown>(API_CONFIG.cloudFunctionUrl, body, {
       headers: {
@@ -57,13 +61,22 @@ export class ApiService {
       },
     });
     const requestTimeout = action === 'reading/ai' ? 130_000 : action === 'app_word_audio_ensure' ? 60_000 : 20_000;
-    return this.resilient<T>(request.pipe(timeout({first:requestTimeout,with:()=>throwError(()=>new ApiError(this.isReadAction(action)?'请求暂未完成,请刷新重试。':'请求暂未完成,结果待核对;请刷新核对后重试,已填写内容会保留。'))})), sessionToken.startsWith('r:') || requiresCloudSession(action, params), this.isReadAction(action), sessionToken).pipe(tap(() => {
+    const execute = () => this.resilient<T>(request.pipe(timeout({first:requestTimeout,with:()=>throwError(()=>new ApiError(this.isReadAction(action)?'请求暂未完成,请刷新重试。':'请求暂未完成,结果待核对;请刷新核对后重试,已填写内容会保留。'))})), sessionToken.startsWith('r:') || requiresCloudSession(action, params), this.isReadAction(action), sessionToken).pipe(tap(() => {
       if (!this.isReadAction(action) && action !== 'app_word_audio_ensure') {
         this.invalidateOverviews();
         this.announceDataChange();
       }
       this.pendingWrites.delete(requestIdentity);
     }));
+    const read = this.isReadAction(action);
+    return this.scheduler.schedule(execute, {
+      read, key: stableRequestJson([sessionToken, action, params]),
+      heavy: ['app_review_page', 'app_exam_history'].includes(action),
+      background: action === 'app_live_versions',
+      cacheMs: ['node_list','app_lesson_courses'].includes(action) ? 30_000 : 0,
+      refresh: params['refresh'] === true, cacheable: value => value.retcode === 0,
+      valid: () => this.parseSessionToken() === originatingSession,
+    }).pipe(finalize(() => { if (!read) this.scheduler.invalidate(); }));
   }
 
   upload(file: File): Observable<ApiEnvelope<string>> {
@@ -87,7 +100,7 @@ export class ApiService {
     return `${API_CONFIG.baseUrl}${value.startsWith('/') ? value : `/${value}`}`;
   }
 
-  invalidateOverviews(): void { this.overviewRequests.clear(); }
+  invalidateOverviews(): void { this.overviewRequests.clear(); this.scheduler.invalidate(); }
 
   private isReadAction(action: string): boolean {
     return /(?:_list|_get|_detail|_overview|_tongji|_tj|_is)$/.test(action)
@@ -123,9 +136,9 @@ export class ApiService {
   private resilient<T>(request: Observable<unknown>, authenticatedRequest = false, retryRead = true, requestToken = ''): Observable<ApiEnvelope<T>> {
     return request.pipe(
       retry({
-        count: retryRead ? 2 : 0,
+        count: retryRead ? 1 : 0,
         delay: (error, retryCount) => this.isTransientError(error)
-          ? timer(retryCount * 400)
+          ? timer(Math.max(300,Math.min(10000,Number((error as HttpErrorResponse).error?.retryAfterMs)||0))+Math.floor(Math.random()*100))
           : throwError(() => error),
       }),
       map((response) => this.normalize<T>(response)),

+ 22 - 0
scripts/audit-cloud-request-performance.mjs

@@ -0,0 +1,22 @@
+#!/usr/bin/env node
+// Read-only baseline. Never creates accounts/functions or changes production data.
+import {readFileSync,writeFileSync,mkdirSync} from 'node:fs';
+import {createHash} from 'node:crypto';
+const env={...process.env};
+for(const line of readFileSync('/Users/mac/.config/xiaoshu/legacy-sync-nightly.env','utf8').split('\n')){
+ const m=line.match(/^(?:export\s+)?(XIAOSHU_[A-Z0-9_]+)=(.*)$/);if(m&&!env[m[1]])env[m[1]]=m[2].replace(/^['"]|['"]$/g,'');
+}
+const base='https://server.xiaoshu.pro',report={at:new Date().toISOString(),readOnly:true,functions:[],samples:[]};
+try{
+ const paths=['xiaoshu/ops/gateway-v3','xiaoshu/ops/gateway','xiaoshu/app/gateway'];
+ const response=await fetch(base+'/parse/classes/Function?'+new URLSearchParams({where:JSON.stringify({path:{$in:paths}}),keys:'path,code,updatedAt',limit:'20'}),{headers:{'X-Parse-Application-Id':env.XIAOSHU_PARSE_APP_ID,...(env.XIAOSHU_MASTER_KEY?{'X-Parse-Master-Key':env.XIAOSHU_MASTER_KEY}:{'X-Parse-Session-Token':env.XIAOSHU_SYNC_OPERATOR_TOKEN})},signal:AbortSignal.timeout(15_000)});
+ const data=await response.json();if(!response.ok)report.definitionReadStatus=response.status;report.functions=(data.results||[]).map(r=>({path:r.path,updatedAt:r.updatedAt,sha256:createHash('sha256').update(r.code||'').digest('hex'),isolatedRuntime:r.code?.includes('XIAOSHU_ISOLATED_RUNTIME_COMPAT_V1')}));
+ for(let i=0;i<10;i++){
+  const start=performance.now();const r=await fetch(base+'/api/functions/xiaoshu/ops/gateway-v3',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({token:env.XIAOSHU_SYNC_OPERATOR_TOKEN,params:{operation:'ops/lessons/list',page:1,pageSize:20}}),signal:AbortSignal.timeout(20_000)});
+  const body=await r.text();let json;try{json=JSON.parse(body);}catch{}
+  report.samples.push({ms:performance.now()-start,status:r.status,success:json?.success===true,bytes:Buffer.byteLength(body),items:json?.data?.items?.length,total:json?.data?.total});
+  if(!r.ok||!json?.success)break;
+ }
+}catch(e){report.error=e.name==='TimeoutError'?'timeout':String(e.message).slice(0,100);}
+const times=report.samples.filter(s=>s.success).map(s=>s.ms).sort((a,b)=>a-b);report.summary={runs:times.length,p50:times[Math.ceil(times.length*.5)-1],p75:times[Math.ceil(times.length*.75)-1],p95:times[Math.ceil(times.length*.95)-1]};
+mkdirSync('perf/cloud-concurrency-2026-11-15',{recursive:true});writeFileSync('perf/cloud-concurrency-2026-11-15/production-baseline.json',JSON.stringify(report,null,2));console.log(JSON.stringify(report,null,2));

+ 32 - 0
scripts/benchmark-lesson-history.mjs

@@ -0,0 +1,32 @@
+#!/usr/bin/env node
+// Isolated local SQL benchmark; no production writes or network calls.
+import {PGlite} from '@electric-sql/pglite';import {readFileSync,writeFileSync} from 'node:fs';import vm from 'node:vm';import assert from 'node:assert/strict';
+const baseline=process.argv.find(a=>a.startsWith('--baseline='))?.slice(11);if(!baseline)throw Error('--baseline required');
+const report={scope:'isolated PGlite SQL and serialization only, not browser or production latency',alternating:true,results:[]};
+for(const size of [20_000,60_000]){
+ const db=new PGlite();try{
+ await db.exec(readFileSync(new URL('./tests/fixtures/lesson-records-schema.sql',import.meta.url),'utf8'));
+ await db.exec(readFileSync(new URL('./sql/review-rewards.sql',import.meta.url),'utf8'));await db.exec(readFileSync(new URL('./sql/lesson-records.sql',import.meta.url),'utf8'));
+ await db.exec(`INSERT INTO "_User"("objectId",company,"legacyUserId","realName") SELECT 'u'||i,'c',i,'姓名'||i FROM generate_series(1,202) i;
+ INSERT INTO "LessonRecord"("objectId",company,id,xymz,jsmz,kcid,yyds,"lessonAt","updatedAt") SELECT 'l'||i,'c',i,(3+i%200)::text,'1','30',i::text,'2026-11-01'::timestamptz+(i||' seconds')::interval,now() FROM generate_series(1,${size}) i;
+ CREATE INDEX ON "LessonRecord"(company,xymz);CREATE INDEX ON "CommonModel"(company,"modelId","itemId");CREATE INDEX ON "LessonCompletionReport"(company,"lessonId");CREATE INDEX ON "LessonCompletionReport"(company,"appointmentGeneralId");ANALYZE;`);
+ const adapt=tx=>({query:async(s,p=[])=>(await tx.query(s,p)).rows,one:async(s,p=[])=>(await tx.query(s,p)).rows[0],none:async(s,p=[])=>{await tx.query(s,p);}});
+ const make=source=>{const ctx=vm.createContext({Psql:{...adapt(db),tx:work=>db.transaction(t=>work(adapt(t)))},text:v=>String(v??''),number:(v,d=0)=>Number(v)||d,dateIso:v=>v?new Date(v).toISOString():'',classType:v=>v,TEACHING_CATEGORY_LABELS:new Map(),companyIdOf:c=>c.company,roleOf:c=>c.role,requireRole:()=>{},fail:(_,m)=>{throw Error(m);},pageInput:i=>({page:i.page||1,pageSize:i.pageSize||20})});vm.runInContext(source,ctx);return ctx;};
+ const before=make(readFileSync(baseline,'utf8')),after=make(readFileSync(new URL('./cloud/lesson-records.js',import.meta.url),'utf8'));
+ await db.exec(readFileSync(new URL('./sql/read-revisions.sql',import.meta.url),'utf8'));
+ for(const [scenario,input] of [['student',{studentId:20}],['global-page-2',{page:2}]]){
+  const samples={before:[],after:[]};
+  for(let round=0;round<12;round++){
+   const results={};for(const variant of round%2?['after','before']:['before','after']){
+    const start=performance.now(),result=await({before,after}[variant]).lessonHistoryPage({company:'c',current:{id:'manager'},role:'ops-manager'},input);const ms=performance.now()-start;
+    results[variant]=JSON.parse(JSON.stringify(result));delete results[variant].nextCursor;delete results[variant].version;delete results[variant].refreshedAt;if(round>=2)samples[variant].push(ms);
+   }assert.deepEqual(results.before,results.after);
+  }
+  const quantile=(a,p)=>[...a].sort((a,b)=>a-b)[Math.ceil(a.length*p)-1];
+  const summary=Object.fromEntries(Object.entries(samples).map(([key,values])=>[key,{p50:quantile(values,.5),p75:quantile(values,.75),p95:quantile(values,.95)}]));
+  report.results.push({size,scenario,samples,summary,p75Reduction:1-summary.after.p75/summary.before.p75});console.log(JSON.stringify({size,scenario,summary}));
+ }
+ }finally{await db.close();}
+}
+writeFileSync('perf/cloud-concurrency-2026-11-15/local-sql-benchmark.json',JSON.stringify(report,null,2));
+for(const result of report.results)assert.ok(result.summary.after.p75<=result.summary.before.p75*1.1,`p75 regression: ${result.size}/${result.scenario}`);

+ 73 - 0
scripts/cloud/lesson-export.js

@@ -0,0 +1,73 @@
+// Persistent jobs are processed by scripts/lesson-export-worker.mjs, never by detached request work.
+function lessonExportProjection(job){return{jobId:job.id,status:job.status,total:number(job.total),processed:number(job.processed),error:job.error||'',expiresAt:job.expires_at};}
+function lessonExportScope(context){return JSON.stringify([companyIdOf(context,{}),context.storeId||0,roleOf(context)]);}
+function lessonExportFilters(input){const out={};for(const key of ['studentId','search','coachName','storeName','courseName','dateFrom','dateTo'])if(input[key]!==undefined)out[key]=input[key];return out;}
+async function lessonExportCreate(context,input){
+ requireRole(context,['ops-manager','ops-staff','ops-auditor']);const company=companyIdOf(context,input),actor=text(context.current.id),key=text(input.idempotencyKey),filters=lessonExportFilters(input),scope=lessonExportScope(context);
+ if(!company||key.length<16||key.length>100)fail(400,'导出请求编号无效');
+ return Psql.tx(async db=>{
+  await db.one('SELECT pg_advisory_xact_lock(hashtext($1),hashtext($2))',[company,actor]);
+  await db.none('DELETE FROM xs_history_export_job WHERE company=$1 AND actor=$2 AND expires_at<=now()',[company,actor]);
+  const prior=(await db.query('SELECT * FROM xs_history_export_job WHERE company=$1 AND actor=$2 AND request_key=$3 AND expires_at>now()',[company,actor,key]))[0];
+  if(prior){if(JSON.stringify(prior.filters)!==JSON.stringify(JSON.parse(JSON.stringify(filters)))||prior.scope!==scope){
+    // PostgreSQL jsonb canonicalizes key order; compare its canonical representation instead.
+    const same=await db.one('SELECT filters=$2::jsonb same FROM xs_history_export_job WHERE id=$1',[prior.id,JSON.stringify(filters)]);if(!same.same||prior.scope!==scope)fail(409,'导出请求条件已变化');
+   }return lessonExportProjection(prior);}
+  const active=(await db.query("SELECT * FROM xs_history_export_job WHERE company=$1 AND actor=$2 AND status IN('queued','running') AND expires_at>now()",[company,actor]))[0];
+  if(active){const same=await db.one('SELECT filters=$2::jsonb same FROM xs_history_export_job WHERE id=$1',[active.id,JSON.stringify(filters)]);if(!same.same||active.scope!==scope)fail(409,'已有导出进行中,请等待或取消');return lessonExportProjection(active);}
+  const job=await db.one('INSERT INTO xs_history_export_job(company,actor,scope,request_key,filters) VALUES($1,$2,$3,$4,$5::jsonb) RETURNING *',[company,actor,scope,key,JSON.stringify(filters)]);return lessonExportProjection(job);
+ });
+}
+async function lessonExportOwned(context,input,db=Psql){
+ requireRole(context,['ops-manager','ops-staff','ops-auditor']);
+ const job=(await db.query('SELECT * FROM xs_history_export_job WHERE id=$1 AND company=$2 AND actor=$3 AND scope=$4 AND expires_at>now()',[text(input.jobId),companyIdOf(context,input),text(context.current.id),lessonExportScope(context)]))[0];
+ if(!job)fail(404,'未找到有效导出任务');return job;
+}
+async function lessonExportStatus(context,input){return lessonExportProjection(await lessonExportOwned(context,input));}
+async function lessonExportCancel(context,input){const job=await lessonExportOwned(context,input);await Psql.none("UPDATE xs_history_export_job SET status='cancelled',lease=NULL,lease_until=NULL,file_base64=NULL WHERE id=$1 AND status IN('queued','running')",[job.id]);return lessonExportStatus(context,input);}
+async function lessonExportDownload(context,input){const job=await lessonExportOwned(context,input);if(job.status!=='ready'||!job.file_base64)fail(409,'导出尚未完成');return{fileName:'训练记录.xlsx',contentType:'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',base64:job.file_base64};}
+async function lessonExportClaim(context){
+ requireSuperAdmin(context);
+ return Psql.tx(async db=>{
+  await db.one('SELECT pg_advisory_xact_lock(1736201,1)');
+  await db.none('DELETE FROM xs_history_export_job WHERE expires_at<=now()');
+  await db.none("UPDATE xs_history_export_job SET status='failed',error='后台任务多次中断,请重新导出',lease=NULL,lease_until=NULL WHERE status='running' AND lease_until<now() AND attempts>=3");
+  const active=await db.one("SELECT EXISTS(SELECT 1 FROM xs_history_export_job WHERE status='running' AND lease_until>now()) active");if(active.active)return{};
+  const job=(await db.query("SELECT * FROM xs_history_export_job WHERE status='queued' OR (status='running' AND lease_until<now() AND attempts<3) ORDER BY created_at LIMIT 1 FOR UPDATE SKIP LOCKED"))[0];if(!job)return{};
+  const claimed=await db.one("UPDATE xs_history_export_job SET status='running',processed=0,lease=md5(random()::text||clock_timestamp()::text),lease_until=now()+interval '2 minutes',attempts=attempts+1 WHERE id=$1 RETURNING *",[job.id]);
+  return{...lessonExportProjection(claimed),lease:claimed.lease,snapshotReady:claimed.snapshot_ready};
+ });
+}
+async function lessonExportLeased(context,input,db=Psql){
+ requireSuperAdmin(context);const job=(await db.query("SELECT * FROM xs_history_export_job WHERE id=$1 AND lease=$2 AND status='running' AND lease_until>now() AND expires_at>now() FOR UPDATE",[text(input.jobId),text(input.lease)]))[0];if(!job)fail(409,'导出租约已失效');return job;
+}
+async function lessonExportSnapshot(context,input){
+ return Psql.tx(async db=>{
+  await db.none("SET TRANSACTION ISOLATION LEVEL REPEATABLE READ");await db.none("SET LOCAL statement_timeout='60s'");
+  const job=await lessonExportLeased(context,input,db);if(job.snapshot_ready)return lessonExportProjection(job);
+  const user=await new Parse.Query('_User').get(job.actor,{useMasterKey:true}),owner=await auth({user});
+  if(lessonExportScope(owner)!==job.scope||companyIdOf(owner,{})!==job.company)fail(403,'导出账号权限已变化');
+  requireRole(owner,['ops-manager','ops-staff','ops-auditor']);
+  const plan=lessonHistoryPlan(owner,job.filters,null,null,true),result=await db.one(plan.q,plan.args);
+  // Resolve categories in the same snapshot as the history, then freeze final exported fields.
+  const raw=result.items||[],rows=[];for(let i=0;i<raw.length;i+=500)rows.push(...(await lessonHistoryTypeRows(raw.slice(i,i+500),db)).map(lessonHistoryRow));
+  await db.none('INSERT INTO xs_history_export_row(job_id,position,value) SELECT $1,ordinality::int,value FROM jsonb_array_elements($2::jsonb) WITH ORDINALITY',[job.id,JSON.stringify(rows)]);
+  const updated=await db.one("UPDATE xs_history_export_job SET total=$2,snapshot_ready=true,lease_until=now()+interval '2 minutes' WHERE id=$1 RETURNING *",[job.id,rows.length]);return lessonExportProjection(updated);
+ });
+}
+async function lessonExportChunk(context,input){
+ return Psql.tx(async db=>{const job=await lessonExportLeased(context,input,db);if(!job.snapshot_ready)fail(409,'导出快照尚未完成');const offset=Math.max(0,Math.floor(number(input.offset)));
+ const rows=await db.query('SELECT value FROM xs_history_export_row WHERE job_id=$1 AND position>$2 ORDER BY position LIMIT 500',[job.id,offset]);
+ await db.none("UPDATE xs_history_export_job SET processed=$2,lease_until=now()+interval '2 minutes' WHERE id=$1",[job.id,Math.min(job.total,offset+rows.length)]);
+ return{items:rows.map(r=>r.value),total:job.total,offset};});
+}
+async function lessonExportFinish(context,input){
+ return Psql.tx(async db=>{const job=await lessonExportLeased(context,input,db);const data=text(input.base64);
+ if(!job.snapshot_ready||number(input.rowCount)!==job.total||!data||data.length>14_000_000||!/^[A-Za-z0-9+/]+={0,2}$/.test(data))fail(400,'导出文件或记录数量无效');
+ await db.none("UPDATE xs_history_export_job SET status='ready',processed=total,file_base64=$2,lease=NULL,lease_until=NULL WHERE id=$1",[job.id,data]);return{jobId:job.id,status:'ready'};});
+}
+async function lessonExportFail(context,input){return Psql.tx(async db=>{const job=await lessonExportLeased(context,input,db);await db.none("UPDATE xs_history_export_job SET status='failed',error='导出未能完成,请稍后重试',lease=NULL,lease_until=NULL WHERE id=$1",[job.id]);return{jobId:job.id,status:'failed'};});}
+async function lessonExportMaintenance(context){
+ requireSuperAdmin(context);const installed=await Psql.one("SELECT to_regprocedure('xs_prune_read_revisions()') IS NOT NULL installed");
+ if(installed.installed)await Psql.none('SELECT xs_prune_read_revisions()');return{completed:true};
+}

+ 44 - 8
scripts/cloud/lesson-records.js

@@ -10,7 +10,7 @@ function lessonHistoryLabelJoins(){return ` LEFT JOIN LATERAL (SELECT * FROM "_U
  LEFT JOIN LATERAL (SELECT * FROM "_User" u WHERE u.company=l.company AND u."legacyUserId"::text=COALESCE(NULLIF(l.szmdid,''),NULLIF(l."storeId"::text,'0'),s."legacyUserData"->>'ParentUserID') ORDER BY u."objectId" LIMIT 1) st ON true
  LEFT JOIN LATERAL (SELECT "nodeName" FROM "Node" n WHERE n.company=l.company AND n."nodeId"::text=l.kcid LIMIT 1) n ON true
 `;}
-function lessonHistoryBase(lean=false){return `WITH facts AS (
+function lessonHistoryBase(lean=false,studentParameter=0){return `WITH facts AS (
  SELECT l.*,c."objectId" AS "targetId",c."generalId",c."createdAt" AS "commonCreatedAt",
  COALESCE(NULLIF(NULLIF(l.yyds,'0'),''),'lesson:'||l."objectId") AS "classKey",
  COALESCE(r."actualStartAt",l."lessonAt",xiaoshu_safe_shanghai_timestamp(l.plpjsj),c."createdAt",l."sourceCreatedAt",l."createdAt") AS "actualAt",
@@ -20,7 +20,7 @@ ${lean?'':lessonHistoryLabelColumns()+','}
  LEFT JOIN LATERAL (SELECT * FROM "CommonModel" c WHERE c.company=l.company AND c."modelId"=59 AND c."itemId"=l.id ORDER BY c."objectId" LIMIT 1) c ON true
 ${lean?'':lessonHistoryLabelJoins()}
  LEFT JOIN LATERAL (SELECT "contentSummary","actualStartAt" FROM "LessonCompletionReport" r WHERE r.company=l.company AND (r."lessonId"=l."objectId" OR r."appointmentGeneralId"::text=l.yyds) ORDER BY r."submittedAt" DESC LIMIT 1) r ON true
- WHERE l.company=$1 AND c.status IS DISTINCT FROM -2
+ WHERE l.company=$1 ${studentParameter?`AND l.xymz=$${studentParameter}`:``} AND c.status IS DISTINCT FROM -2
  ), ranked AS (SELECT facts.*,row_number() OVER(PARTITION BY company,xymz,"classKey" ORDER BY "actualAt" DESC NULLS LAST,"objectId") rn,count(*) OVER(PARTITION BY company,xymz,"classKey") duplicates FROM facts), lessons AS (SELECT * FROM ranked WHERE rn=1)`;}
 function lessonHistoryFilters(input,args){const f={...(input.filters||{}),...input},conditions=[];
  const add=(sql,value)=>{if(value!==undefined&&value!==null&&String(value).trim()){args.push(String(value).trim());conditions.push(sql.replaceAll('?',String(args.length)));}};
@@ -32,10 +32,10 @@ function lessonHistoryFilters(input,args){const f={...(input.filters||{}),...inp
  add('"actualAt">=($?::date::timestamp AT TIME ZONE \'Asia/Shanghai\')',f.dateFrom);add('"actualAt"<(($?::date+1)::timestamp AT TIME ZONE \'Asia/Shanghai\')',f.dateTo);
  return conditions.length?' WHERE '+conditions.join(' AND '):'';}
 // Resolve only the displayed page; preserve the original completed-lesson snapshot when present.
-async function lessonHistoryTypeRows(rows) {
+async function lessonHistoryTypeRows(rows,db=Psql) {
  const wanted=rows.map(row=>({company:row.company,appointmentId:text(row.yyds),studentId:text(row.xymz)})).filter(row=>row.company&&/^\d+$/.test(row.appointmentId)&&row.studentId);
  if(!wanted.length)return rows;
- const saved=await Psql.query(`WITH wanted AS (SELECT DISTINCT * FROM jsonb_to_recordset($1::jsonb) AS w(company text,"appointmentId" numeric,"studentId" text))
+ const saved=await db.query(`WITH wanted AS (SELECT DISTINCT * FROM jsonb_to_recordset($1::jsonb) AS w(company text,"appointmentId" numeric,"studentId" text))
  SELECT w.company,w."appointmentId"::text AS "appointmentId",w."studentId",
  r."courseCategoryKey" AS "reportKey",r."courseCategoryName" AS "reportName",r."durationMinutes" AS "reportMinutes",
  a.kcid AS "appointmentCourseId",a."courseCategoryKey" AS "appointmentKey",a."courseCategoryName" AS "appointmentName",a."durationMinutes" AS "appointmentMinutes"
@@ -61,19 +61,55 @@ function lessonHistoryClassType(row) {
  return name?name+(minutes?' '+minutes+' 分钟':''):classType(row.kclx);
 }
 function lessonHistoryRow(row){return {targetId:text(row.targetId||row.objectId),lessonId:row.objectId,generalId:number(row.generalId||row.id),appointmentId:text(row.yyds),studentId:number(row.xymz),studentName:row.studentNameResolved,coachId:number(row.jsmz),coachName:row.coachNameResolved,storeId:number(row.storeIdResolved),storeName:row.storeName,courseId:number(row.kcid),courseName:row.courseNameResolved,classTypeLabel:lessonHistoryClassType(row),lessonAt:dateIso(row.actualAt),score:number(row.pf),comment:text(row.pldp),coachComment:text(row.pldp),studentComment:text(row.pjnr),feedbackSummary:text(row.feedbackSummary||row.pldp),duplicateCount:number(row.duplicates),pendingReviews:number(row.pendingReviews),reviewCoaches:row.reviewCoaches||[],reviewCoachLabel:(row.reviewCoaches||[]).length>1?'多位陪练':row.reviewCoaches?.[0]?.name||'无待复习任务'};}
+// Locks live for the actual database transaction, so cancellation/death releases capacity.
+async function lessonHistoryRead(work){
+ return Psql.tx(async db=>{
+  await db.none("SET LOCAL statement_timeout='12s'");
+  let admitted=false;
+  for(const slot of [1,2]){const row=await db.one('SELECT pg_try_advisory_xact_lock(1736200,$1) acquired',[slot]);if(row.acquired){admitted=true;break;}}
+  if(!admitted)fail(429,'查询较多,请稍后重试');
+  return work(db);
+ });
+}
 async function lessonHistoryPage(context,input){
+ return lessonHistoryRead(db=>lessonHistoryPageData(context,input,db));
+}
+async function lessonHistoryPageData(context,input,db=Psql){
  requireRole(context,['ops-manager','ops-staff','ops-auditor']);const co=companyIdOf(context,input);if(!co)fail(400,'缺少帐套');
- const args=[co];if(context.storeId)input={...input,storeId:context.storeId,_storeId:context.storeId};const where=lessonHistoryFilters(input,args),cumulativeWhere=lessonHistoryFilters({studentId:input.studentId,studentName:input.studentName,search:input.search,_storeId:input._storeId},args),paging=pageInput(input);args.push(paging.pageSize,(paging.page-1)*paging.pageSize);
+ const installed=await db.one("SELECT to_regprocedure('xs_read_version(text,text)') IS NOT NULL installed");
+ const revision=installed.installed?(await db.one("SELECT xs_read_version($1,'lessons') revision",[co])).revision:null;
+ const {page,pageSize,refresh,cursor,...filters}=input;
+ const key=JSON.stringify([context.storeId||0,roleOf(context),filters]),actor=text(context.current.id);
+ const cached=revision&&!refresh?(await db.query('SELECT value FROM xs_history_summary WHERE company=$1 AND actor=$2 AND key=md5($3) AND revision=$4 AND expires_at>now()',[co,actor,key,revision]))[0]?.value:null;
+ const plan=lessonHistoryPlan(context,input,cached,revision),result=await db.one(plan.q,plan.args);
+ const version=LESSON_RECORDS_VERSION+':'+result.historyVersion;
+ if(input.cursor&&input.cursor.version!==version)fail(409,'记录发生变化,请重新查询');
+ const last=(result.items||[]).at(-1),nextCursor=last?{at:last.actualAt||null,id:last.objectId,version}:null;
+ if(revision&&!cached)await db.none("INSERT INTO xs_history_summary(company,actor,key,revision,value,expires_at) VALUES($1,$2,md5($3),$4,$5::jsonb,now()+interval '30 seconds') ON CONFLICT(company,actor,key) DO UPDATE SET revision=excluded.revision,value=excluded.value,expires_at=excluded.expires_at",[co,actor,key,revision,JSON.stringify({total:result.total,cumulativeTotal:result.cumulativeTotal})]);
+ return {page:plan.paging.page,pageSize:plan.paging.pageSize,total:number(result.total),cumulativeTotal:number(result.cumulativeTotal),items:(await lessonHistoryTypeRows(result.items||[],db)).map(lessonHistoryRow),refreshedAt:new Date().toISOString(),version,nextCursor};
+}
+function lessonHistoryPlan(context,input,summary=null,revision=null,snapshot=false){
+ const co=companyIdOf(context,input);
+ const args=[co];if(context.storeId)input={...input,storeId:context.storeId,_storeId:context.storeId};const where=lessonHistoryFilters(input,args),cumulativeWhere=lessonHistoryFilters({studentId:input.studentId,studentName:input.studentName,search:input.search,_storeId:input._storeId},args),paging=pageInput(input);const studentParameter=text(input.studentId)?args.push(text(input.studentId)):0;const revisionParameter=revision!==null?args.push(revision):0;
+ let cursorWhere='';
+ if(input.cursor&&!snapshot){const cursor=input.cursor;
+  if(typeof cursor!=='object'||!text(cursor.id)||text(cursor.id).length>200||(cursor.at!==null&&(typeof cursor.at!=='string'||cursor.at.length>80||!Number.isFinite(Date.parse(cursor.at)))))fail(400,'分页游标无效');
+  const at=args.push(cursor.at),id=args.push(text(cursor.id));
+  cursorWhere=` WHERE (($${at}::timestamptz IS NOT NULL AND ("actualAt"<$${at}::timestamptz OR "actualAt" IS NULL OR ("actualAt"=$${at}::timestamptz AND "objectId"<$${id}::text))) OR ($${at}::timestamptz IS NULL AND "actualAt" IS NULL AND "objectId"<$${id}::text))`;
+ }
+ args.push(snapshot?2147483647:paging.pageSize,snapshot||input.cursor?0:(paging.page-1)*paging.pageSize);
  const filters={...(input.filters||{}),...input},lean=!context.storeId&&!['search','studentName','coachName','storeName','courseName','storeId'].some(key=>text(filters[key]));
- const q=lessonHistoryBase(lean)+`,filtered AS MATERIALIZED (SELECT * FROM lessons${where}), page AS (SELECT * FROM filtered ORDER BY "actualAt" DESC NULLS LAST,"objectId" DESC LIMIT $${args.length-1} OFFSET $${args.length})${lean?`,display_page AS (SELECT l.*,${lessonHistoryLabelColumns()} FROM page l ${lessonHistoryLabelJoins()})`:``}
- SELECT md5(COALESCE((SELECT string_agg("objectId"||COALESCE("updatedAt"::text,''),',' ORDER BY "objectId") FROM filtered),'')) AS "historyVersion",(SELECT count(*)::int FROM filtered) total,(SELECT count(*)::int FROM lessons${cumulativeWhere}) "cumulativeTotal",
+ const q=lessonHistoryBase(lean,studentParameter)+`,filtered AS MATERIALIZED (SELECT * FROM lessons${where}), page AS (SELECT * FROM filtered${cursorWhere} ORDER BY "actualAt" DESC NULLS LAST,"objectId" DESC LIMIT $${args.length-1} OFFSET $${args.length})${lean?`,display_page AS (SELECT l.*,${lessonHistoryLabelColumns()} FROM page l ${lessonHistoryLabelJoins()})`:``}
+ SELECT ${revisionParameter?`md5($${revisionParameter}::text)`:`md5(COALESCE((SELECT string_agg("objectId"||COALESCE("updatedAt"::text,''),',' ORDER BY "objectId") FROM filtered),''))`} AS "historyVersion",${summary?number(summary.total):`(SELECT count(*)::int FROM filtered)`} total,${summary?number(summary.cumulativeTotal):`(SELECT count(*)::int FROM lessons${cumulativeWhere})`} "cumulativeTotal",
  COALESCE((SELECT jsonb_agg(to_jsonb(p)||jsonb_build_object('pendingReviews',rv.n,'reviewCoaches',rv.coaches) ORDER BY p."actualAt" DESC NULLS LAST,p."objectId" DESC) FROM ${lean?'display_page':'page'} p
  LEFT JOIN LATERAL (SELECT COALESCE(sum(n),0) n,jsonb_agg(jsonb_build_object('id',coach,'name',name,'count',n) ORDER BY name) coaches FROM (
  SELECT m.plid::text coach,COALESCE(NULLIF(u."realName",''),NULLIF(u.nickname,''),u."legacyUserData"->>'HoneyName',u.username,'陪练 '||m.plid::text) name,count(*)::int n
  FROM xs_review_task_link link JOIN "MemoryPracticeRecord" m ON m.company=link.company AND m."objectId"=link."taskId"
  LEFT JOIN LATERAL(SELECT * FROM "_User" u WHERE u.company=m.company AND u."legacyUserId"::text=m.plid::text ORDER BY u."objectId" LIMIT 1) u ON true
  WHERE link.company=p.company AND link."studentId"=p.xymz AND link."appointmentId"=p.yyds AND COALESCE(m.fxzt,'0')<>'1' AND NOT COALESCE(m."rewardDuplicate",false) GROUP BY m.plid,u."realName",u.nickname,u."legacyUserData",u.username) grouped) rv ON true),'[]'::jsonb) items`;
- const result=await Psql.one(q,args);return {page:paging.page,pageSize:paging.pageSize,total:number(result.total),cumulativeTotal:number(result.cumulativeTotal),items:(await lessonHistoryTypeRows(result.items||[])).map(lessonHistoryRow),refreshedAt:new Date().toISOString(),version:LESSON_RECORDS_VERSION+':'+result.historyVersion};
+ // Cached totals remove placeholders used only by the cumulative count. Compact them.
+ const used=[...new Set([...q.matchAll(/\$(\d+)/g)].map(match=>Number(match[1])))].sort((a,b)=>a-b);
+ return {q:q.replace(/\$(\d+)/g,(_,n)=>'$'+(used.indexOf(Number(n))+1)),args:used.map(n=>args[n-1]),paging};
 }
 async function lessonHistoryDetail(context,input){
  requireRole(context,['ops-manager','ops-staff','ops-auditor']);const co=companyIdOf(context,input),id=text(input.lessonId||input.targetId),appointment=text(input.appointmentId);

+ 1 - 0
scripts/cloud/store-scope.js

@@ -8,6 +8,7 @@ const STORE_OPERATIONS = new Set([
  'ops/stores/credit-accounts','ops/stores/credit-ledger',
  'ops/coaches/list','ops/coaches/detail','ops/coaches/create','ops/coaches/update','ops/coaches/delete',
  'ops/coach-availability/list','ops/coach-availability/save','ops/coach-availability/delete',
+ 'ops/lessons/export/create','ops/lessons/export/status','ops/lessons/export/cancel','ops/lessons/export/download',
  'ops/lessons/list','ops/lessons/detail','ops/learning-reports/list','ops/learning-reports/detail','ops/learning-reports/export',
  'ops/support-requests/list','ops/support-requests/process',
  'ops/payroll/overview','ops/payroll/preview','ops/payroll/list','ops/payroll/detail','ops/payroll/export','ops/payroll/coaches',

+ 29 - 9
scripts/deploy-admin-functions.mjs

@@ -8,6 +8,7 @@ const legacyAppointmentCode = readFileSync(new URL('./cloud/legacy-appointment.j
 const storeScopeCode = readFileSync(new URL('./cloud/store-scope.js', import.meta.url), 'utf8');
 const creditLedgerCode = readFileSync(new URL('./cloud/member-credit-ledger.js', import.meta.url), 'utf8') + readFileSync(new URL('./cloud/legacy-finance.js', import.meta.url), 'utf8');
 const enrollmentOpsCode = readFileSync(new URL('./cloud/member-enrollment.js', import.meta.url), 'utf8') + '\n' + readFileSync(new URL('./cloud/store-credit.js', import.meta.url), 'utf8');
+const lessonExportCode = readFileSync(new URL('./cloud/lesson-export.js', import.meta.url), 'utf8');
 const lessonRecordsCode = readFileSync(new URL('./cloud/lesson-records.js', import.meta.url), 'utf8');
 const supportRequestsAppCode = readFileSync(new URL('./cloud/support-requests-app.js', import.meta.url), 'utf8');
 const supportRequestsCode = readFileSync(new URL('./cloud/support-requests.js', import.meta.url), 'utf8');
@@ -228,8 +229,10 @@ async function audit(context, action, className, objectId) {
 }
 
 async function handler(request, response) {
-  const diagnosticId=String(request.body?.params?.requestId||'');
-  if(/^[a-zA-Z0-9-]{1,64}$/.test(diagnosticId)&&typeof response.on==='function')response.on('finish',()=>{if(response.statusCode>=400)console.warn(JSON.stringify({event:'gateway_error',requestId:diagnosticId,status:response.statusCode,version:'2026-10-13-lesson-batches-v8'}));});
+  const startedAt=Date.now(),suppliedId=String(request.body?.params?.requestId||'');
+  const diagnosticId=/^[a-zA-Z0-9-]{1,64}$/.test(suppliedId)?suppliedId:Date.now().toString(36)+'-'+Math.random().toString(36).slice(2,10);
+  if(typeof response.setHeader==='function')response.setHeader('X-Request-Id',diagnosticId);
+  if(typeof response.on==='function')response.on('finish',()=>{console.info(JSON.stringify({event:'gateway_request',requestId:diagnosticId,operation:String(request.body?.params?.operation||'').slice(0,100),status:response.statusCode,durationMs:Date.now()-startedAt}));});
   try {
     const input = inputOf(request);
     const operation = String(input.operation || 'meta');
@@ -2362,8 +2365,10 @@ function tenant(query, fields, context) { if (fields.company && context.company)
 function stripHtml(content) { return String(content == null ? '' : content).replace(/<!--[\s\S]*?-->/g, ' ').replace(/<script\b[^>]*>[\s\S]*?<\/script>/gi, ' ').replace(/<style\b[^>]*>[\s\S]*?<\/style>/gi, ' ').replace(/<[^>]+>/g, ' ').replace(/&nbsp;|&#160;/gi, ' ').replace(/&amp;/gi, '&').replace(/&lt;/gi, '<').replace(/&gt;/gi, '>').replace(/&quot;/gi, '"').replace(/&#39;|&apos;/gi, "'").replace(/\s+/g, ' ').trim(); }
 function escapeRegex(value) { return String(value).replace(/[\\^$.*+?()[\]{}|]/g, '\\$&'); }
 async function handler(request, response) {
-  const diagnosticId=String(request.body?.params?.requestId||'');
-  if(/^[a-zA-Z0-9-]{1,64}$/.test(diagnosticId)&&typeof response.on==='function')response.on('finish',()=>{if(response.statusCode>=400)console.warn(JSON.stringify({event:'gateway_error',requestId:diagnosticId,status:response.statusCode,version:'2026-10-13-lesson-batches-v8'}));});
+  const startedAt=Date.now(),suppliedId=String(request.body?.params?.requestId||'');
+  const diagnosticId=/^[a-zA-Z0-9-]{1,64}$/.test(suppliedId)?suppliedId:Date.now().toString(36)+'-'+Math.random().toString(36).slice(2,10);
+  if(typeof response.setHeader==='function')response.setHeader('X-Request-Id',diagnosticId);
+  if(typeof response.on==='function')response.on('finish',()=>{console.info(JSON.stringify({event:'gateway_request',requestId:diagnosticId,operation:String(request.body?.params?.operation||'').slice(0,100),status:response.statusCode,durationMs:Date.now()-startedAt}));});
   try {
     const input = inputOf(request); const context = await auth(request); const page = Math.max(1, Number(input.page) || 1); const pageSize = Math.min(100, Math.max(1, Number(input.pageSize) || 20));
     if (MODE === 'content-normalizer') return response.json({ success: true, data: { content: stripHtml(input.content), mode: String(input.mode || 'strip') } });
@@ -4148,6 +4153,9 @@ async function retrySync(context,input){requireSuperAdmin(context);let dataset=t
 const OPS_LIVE_SCOPES=new Set(['learning','courses','appointments','reading','review','account']);
 function operationsVersion(row){return(dateIso(row&&row.changed_at)||'')+':'+number(row&&row.total);}
 async function operationsScopeVersion(scope,companyId){
+  const installed=await Psql.one("SELECT to_regprocedure('xs_read_version(text,text)') IS NOT NULL installed");
+  if(installed.installed)return(await Psql.one('SELECT xs_read_version($1,$2) version',[companyId,scope])).version;
+
   if(scope==='learning')return operationsVersion(await Psql.one('SELECT MAX(GREATEST(COALESCE(d."updatedAt",d."createdAt"),COALESCE(c."updatedAt",c."createdAt"))) changed_at,COUNT(*)::int total FROM "DailyStudyRecord" d LEFT JOIN "CommonModel" c ON c."company"=d."company" AND c."modelId"=56 AND c."itemId"=d."id" WHERE d."company"=$1',[companyId]));
   if(scope==='courses')return operationsVersion(await Psql.one('SELECT MAX(COALESCE("updatedAt","createdAt")) changed_at,COUNT(*)::int total FROM "CourseBinding" WHERE "company"=$1',[companyId]));
   if(scope==='appointments')return operationsVersion(await Psql.one('SELECT MAX(COALESCE("updatedAt","createdAt")) changed_at,COUNT(*)::int total FROM "CourseAppointment" WHERE "company"=$1',[companyId]));
@@ -4161,8 +4169,10 @@ async function operationsLiveVersions(context,input){
 }
 async function repairMemberIdentity(context,input,payload){requireRole(context,['ops-manager']);const reason=reasonOf(input),target=await scopedObject('_User',text(input.targetId),context,input);assertVersion(target,input.expectedUpdatedAt);const legacy={...(target.get('legacyUserData')||{})},userId=number(target.get('legacyUserId')||legacy.UserID),companyId=pointerId(target.get('company')||context.company),evidence=await Psql.oneOrNone('SELECT 1 AS found WHERE EXISTS(SELECT 1 FROM "CourseAppointment" WHERE ($1::text IS NULL OR "company"=$1) AND (CAST("pl" AS text)=$2 OR CAST("fxpl" AS text)=$2)) OR EXISTS(SELECT 1 FROM "LessonRecord" WHERE ($1::text IS NULL OR "company"=$1) AND CAST("jsmz" AS text)=$2)',[companyId,String(userId)]).catch(()=>null);if(number(target.get('legacyGroupId')||legacy.GroupID)!==1||(!evidence&&!/陪练/.test(displayName(target.toJSON()))))fail(409,'该账号没有可确认的陪练身份冲突');const before={groupId:1,identityType:text(target.get('identityType'))};legacy.GroupID=3;target.set('legacyGroupId',3);target.set('identityType','coach');target.set('legacyUserData',legacy);await target.save(null,{useMasterKey:true});await audit(context,'repair-member-identity','_User',target.id,reason,before,{groupId:3,identityType:'coach'});return{objectId:target.id,userId,groupId:3,updatedAt:dateIso(target.updatedAt)};}
 async function handler(request,response) {
-  const diagnosticId=String(request.body?.params?.requestId||'');
-  if(/^[a-zA-Z0-9-]{1,64}$/.test(diagnosticId)&&typeof response.on==='function')response.on('finish',()=>{if(response.statusCode>=400)console.warn(JSON.stringify({event:'gateway_error',requestId:diagnosticId,status:response.statusCode,version:'2026-10-13-lesson-batches-v8'}));});
+  const startedAt=Date.now(),suppliedId=String(request.body?.params?.requestId||'');
+  const diagnosticId=/^[a-zA-Z0-9-]{1,64}$/.test(suppliedId)?suppliedId:Date.now().toString(36)+'-'+Math.random().toString(36).slice(2,10);
+  if(typeof response.setHeader==='function')response.setHeader('X-Request-Id',diagnosticId);
+  if(typeof response.on==='function')response.on('finish',()=>{console.info(JSON.stringify({event:'gateway_request',requestId:diagnosticId,operation:String(request.body?.params?.operation||'').slice(0,100),status:response.statusCode,durationMs:Date.now()-startedAt}));});
   let context=null;
   try {
     const input=inputOf(request); const operation=text(input.operation); const payload=input.payload&&typeof input.payload==='object'?input.payload:{};
@@ -4293,6 +4303,16 @@ async function handler(request,response) {
     if(operation==='ops/lessons/detail') return response.json({success:true,data:await lessonHistoryDetail(context,input)});
     if(operation==='ops/lessons/transfer-preview') return response.json({success:true,data:await lessonTransferPreview(context,input)});
     if(operation==='ops/lessons/transfer') return response.json({success:true,data:await lessonTransferCommit(context,input)});
+    if(operation==='ops/lessons/export/create') return response.json({success:true,data:await lessonExportCreate(context,input)});
+    if(operation==='ops/lessons/export/status') return response.json({success:true,data:await lessonExportStatus(context,input)});
+    if(operation==='ops/lessons/export/cancel') return response.json({success:true,data:await lessonExportCancel(context,input)});
+    if(operation==='ops/lessons/export/download') return response.json({success:true,data:await lessonExportDownload(context,input)});
+    if(operation==='ops/lessons/export/maintenance') return response.json({success:true,data:await lessonExportMaintenance(context,input)});
+    if(operation==='ops/lessons/export/claim') return response.json({success:true,data:await lessonExportClaim(context,input)});
+    if(operation==='ops/lessons/export/snapshot') return response.json({success:true,data:await lessonExportSnapshot(context,input)});
+    if(operation==='ops/lessons/export/chunk') return response.json({success:true,data:await lessonExportChunk(context,input)});
+    if(operation==='ops/lessons/export/finish') return response.json({success:true,data:await lessonExportFinish(context,input)});
+    if(operation==='ops/lessons/export/fail') return response.json({success:true,data:await lessonExportFail(context,input)});
     if(operation==='ops/lessons/list') return response.json({success:true,data:await lessonHistoryPage(context,input)});
     if(operation==='ops/learning-records/list') return response.json({success:true,data:await businessRows('learning-records',context,input)});
     if(operation==='ops/learning-reports/list') return response.json({success:true,data:await learningReportPage(context,input)});
@@ -4305,13 +4325,13 @@ async function handler(request,response) {
     if(operation==='ops/relations/save') { requireRole(context,['ops-manager','ops-staff']); const reason=reasonOf(input); const parentUserId=number(payload.parentUserId); if(!Number.isInteger(parentUserId)||parentUserId<0) fail(400,'代理用户 ID 无效'); const target=await memberTarget(context,input,text(input.targetId)); assertVersion(target,input.expectedUpdatedAt); const ownId=number(target.get('legacyUserId')||(target.get('legacyUserData')||{}).UserID); if(parentUserId===ownId) fail(400,'用户不能归属于自己'); if(parentUserId&&!await userByLegacyId(context,input,parentUserId,2))fail(404,'上级代理不存在'); const legacy={...(target.get('legacyUserData')||{})}; const before={parentUserId:number(legacy.ParentUserID)}; legacy.ParentUserID=parentUserId; target.set('legacyUserData',legacy); await target.save(null,{useMasterKey:true}); await audit(context,'save-relation','_User',target.id,reason,before,{parentUserId}); return response.json({success:true,data:{objectId:target.id,parentUserId}}); }
     if(operation==='ops/appointments/transition') { requireRole(context,['ops-manager']); const reason=reasonOf(input); const target=await scopedObject('CommonModel',text(input.targetId),context,input); assertVersion(target,input.expectedUpdatedAt); if(number(target.get('modelId'))!==54) fail(400,'目标不是预约记录'); const addon=await findAddon('CourseAppointment',target.get('company')||context.company,target.get('itemId')); if(!addon) fail(404,'预约附表不存在'); const previous=number(addon.get('dszt')); const status=number(payload.status); if(status===11)fail(409,'不能直接修改为已完课,请使用“完成课程”操作,以原子校验课时、扣减余额、生成上课记录并保存计薪快照'); const allowed=({10:[0,10],20:[11,20],30:[20,30]})[status]; if(!allowed||!allowed.includes(previous)) fail(409,'预约状态不能从 '+previous+' 更新为 '+status); if(status===10){await enrollmentSql('SELECT xs_start_appointment($1,$2) AS result',[pointerId(target.get('company')),target.id]);}else{addon.set('dszt',String(status));await addon.save(null,{useMasterKey:true});} await audit(context,'appointment-transition-correction','CourseAppointment',addon.id,reason,{status:previous},{status}); return response.json({success:true,data:{targetId:target.id,previousStatus:previous,status}}); }
     fail(400,'不支持的运营后台操作');
-  } catch(error) { const status=Number(error.status||(error.code===101?404:500)),message=status>=500&&!(context&&context.isSuperAdmin)?'运营云函数执行失败':text(error.message)||'运营云函数执行失败'; return response.status(status).json({success:false,error:status>=500?'cloud_function_error':'request_rejected',message}); }
+  } catch(error) { const status=Number(error.status||(error.code===101?404:500)),message=status>=500&&!(context&&context.isSuperAdmin)?'运营云函数执行失败':text(error.message)||'运营云函数执行失败'; return response.status(status).json({success:false,error:status===429?'read_capacity_busy':status>=500?'cloud_function_error':'request_rejected',message,...(status===429?{retryAfterMs:2000}:{})}); }
 }
 `;
 
 const definitions = [
-  { name: 'xiaoshu.ops.gateway', desc: '小树陪练运营管理后台专用网关(业务投影、分级隐私、三级权限与审计)', path: 'xiaoshu/ops/gateway', code: operationsGatewayCode + storeScopeCode + accountDeletionOpsCode + adminRolePolicyCode + reviewRewardCode + enrollmentOpsCode + creditLedgerCode + payrollIntegrityCode + lessonWorkflowOpsCode + lessonRecordsCode + supportRequestsCode, params: [{ name: 'operation', type: 'String', required: true }] },
-  { name: 'xiaoshu.ops.gateway.v3', desc: '小树陪练运营管理后台专用网关 V3(含历史排课投影、上海业务日期与陪练排课汇总)', path: 'xiaoshu/ops/gateway-v3', code: operationsGatewayCode + storeScopeCode + accountDeletionOpsCode + adminRolePolicyCode + reviewRewardCode + enrollmentOpsCode + creditLedgerCode + payrollIntegrityCode + lessonWorkflowOpsCode + lessonRecordsCode + supportRequestsCode, params: [{ name: 'operation', type: 'String', required: true }] },
+  { name: 'xiaoshu.ops.gateway', desc: '小树陪练运营管理后台专用网关(业务投影、分级隐私、三级权限与审计)', path: 'xiaoshu/ops/gateway', code: operationsGatewayCode + storeScopeCode + accountDeletionOpsCode + adminRolePolicyCode + reviewRewardCode + enrollmentOpsCode + creditLedgerCode + payrollIntegrityCode + lessonWorkflowOpsCode + lessonRecordsCode + lessonExportCode + supportRequestsCode, params: [{ name: 'operation', type: 'String', required: true }] },
+  { name: 'xiaoshu.ops.gateway.v3', desc: '小树陪练运营管理后台专用网关 V3(含历史排课投影、上海业务日期与陪练排课汇总)', path: 'xiaoshu/ops/gateway-v3', code: operationsGatewayCode + storeScopeCode + accountDeletionOpsCode + adminRolePolicyCode + reviewRewardCode + enrollmentOpsCode + creditLedgerCode + payrollIntegrityCode + lessonWorkflowOpsCode + lessonRecordsCode + lessonExportCode + supportRequestsCode, params: [{ name: 'operation', type: 'String', required: true }] },
   { name: 'xiaoshu.admin.gateway', desc: '小树陪练 Angular 管理后台统一数据网关(权限、帐套、CRUD、统计)', path: 'xiaoshu/admin/gateway', code: adminGatewayCode + adminRolePolicyCode + operatorAccountsCode, params: [{ name: 'operation', type: 'String', required: true }] },
   { name: 'cms.content-normalizer', desc: '替代 ZL_StripeHtmlTag/ZL_StripeTrimstr,保守输出纯文本', path: 'xiaoshu/cms/content-normalizer', code: cmsReadCode('content-normalizer'), params: [{ name: 'content', type: 'String', required: false }] },
   { name: 'cms.users.extended', desc: '替代 ZL_EX_UserView,返回安全用户扩展资料', path: 'xiaoshu/cms/users/extended', code: cmsReadCode('user-extended'), params: [{ name: 'userId', type: 'String', required: true }] },

+ 29 - 0
scripts/lesson-export-worker.mjs

@@ -0,0 +1,29 @@
+#!/usr/bin/env node
+import {readFileSync} from 'node:fs';
+import {pathToFileURL} from 'node:url';
+import * as XLSX from 'xlsx';
+import {lessonExportColumns} from '../projects/shared/lesson-export-columns.mjs';
+export async function processLessonExport(call){
+ const job=await call('claim',{});if(!job.jobId)return false;
+ const identity={jobId:job.jobId,lease:job.lease};
+ try{
+  if(!job.snapshotReady)await call('snapshot',identity);
+  const sheet=XLSX.utils.json_to_sheet([]);let offset=0,total=Infinity;
+  while(offset<total){const page=await call('chunk',{...identity,offset});total=page.total;if(!page.items.length&&offset<total)throw Error('incomplete_snapshot');
+   XLSX.utils.sheet_add_json(sheet,page.items.map(lessonExportColumns),{origin:offset===0?0:-1,skipHeader:offset!==0});offset+=page.items.length;
+  }
+  const book=XLSX.utils.book_new();XLSX.utils.book_append_sheet(book,sheet,'训练记录');
+  const base64=XLSX.write(book,{type:'base64',bookType:'xlsx',compression:true});
+  await call('finish',{...identity,rowCount:offset,base64});return true;
+ }catch(e){await call('fail',identity).catch(()=>{});throw Error('export_job_failed');}
+}
+async function main(){
+ const config=process.env.XIAOSHU_EXPORT_ENV_FILE||'/Users/mac/.config/xiaoshu/legacy-sync-nightly.env',env={...process.env};
+ for(const line of readFileSync(config,'utf8').split('\n')){const m=line.match(/^(?:export\s+)?(XIAOSHU_[A-Z0-9_]+)=(.*)$/);if(m&&!env[m[1]])env[m[1]]=m[2].replace(/^['"]|['"]$/g,'');}
+ const token=env.XIAOSHU_EXPORT_OPERATOR_TOKEN||env.XIAOSHU_SYNC_OPERATOR_TOKEN;if(!token)throw Error('export_operator_missing');
+ const base=env.XIAOSHU_EXPORT_GATEWAY_URL||'https://server.xiaoshu.pro/api/functions/xiaoshu/ops/gateway-v3';
+ const call=async(action,params)=>{const r=await fetch(base,{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({token,params:{operation:'ops/lessons/export/'+action,...params}}),signal:AbortSignal.timeout(action==='snapshot'?90_000:20_000)});const data=await r.json();if(!r.ok||!data.success)throw Error('export_gateway_'+r.status);return data.data;};
+ let lastMaintenance=0;let stopped=false;for(const event of ['SIGINT','SIGTERM'])process.on(event,()=>{stopped=true;});
+ do{try{if(Date.now()-lastMaintenance>86_400_000){await call('maintenance',{});lastMaintenance=Date.now();}await processLessonExport(call);}catch{console.error('导出任务失败,错误状态已回写或将由租约恢复');}if(process.argv.includes('--once'))break;if(!stopped)await new Promise(r=>setTimeout(r,5000));}while(!stopped);
+}
+if(process.argv[1]&&import.meta.url===pathToFileURL(process.argv[1]).href)main().catch(()=>{console.error('导出 worker 启动失败,请检查私密配置和权限');process.exitCode=1;});

+ 17 - 0
scripts/sql/lesson-export.sql

@@ -0,0 +1,17 @@
+BEGIN;
+CREATE TABLE IF NOT EXISTS xs_history_export_job (
+ id text PRIMARY KEY DEFAULT md5(random()::text||clock_timestamp()::text),
+ company text NOT NULL,actor text NOT NULL,scope text NOT NULL,request_key text NOT NULL,filters jsonb NOT NULL,
+ status text NOT NULL DEFAULT 'queued' CHECK(status IN('queued','running','ready','failed','cancelled')),
+ total integer NOT NULL DEFAULT 0,processed integer NOT NULL DEFAULT 0,snapshot_ready boolean NOT NULL DEFAULT false,
+ lease text,lease_until timestamptz,attempts integer NOT NULL DEFAULT 0,error text NOT NULL DEFAULT '',file_base64 text,
+ created_at timestamptz NOT NULL DEFAULT now(),expires_at timestamptz NOT NULL DEFAULT now()+interval '24 hours',
+ UNIQUE(company,actor,request_key)
+);
+CREATE UNIQUE INDEX IF NOT EXISTS xs_history_export_one_per_owner ON xs_history_export_job(company,actor) WHERE status IN('queued','running');
+CREATE INDEX IF NOT EXISTS xs_history_export_queue ON xs_history_export_job(status,created_at);
+CREATE TABLE IF NOT EXISTS xs_history_export_row (
+ job_id text NOT NULL REFERENCES xs_history_export_job(id) ON DELETE CASCADE,position integer NOT NULL,value jsonb NOT NULL,
+ PRIMARY KEY(job_id,position)
+);
+COMMIT;

+ 69 - 0
scripts/sql/read-revisions.sql

@@ -0,0 +1,69 @@
+BEGIN;
+-- Append one event per writer transaction/domain. No shared counter-row locks.
+CREATE TABLE IF NOT EXISTS xs_read_revision_event (
+ company text NOT NULL,domain text NOT NULL,revision bigint NOT NULL,
+ PRIMARY KEY(company,domain,revision)
+);
+CREATE TABLE IF NOT EXISTS xs_history_summary (company text NOT NULL,actor text NOT NULL,key text NOT NULL,revision text NOT NULL,value jsonb NOT NULL,expires_at timestamptz NOT NULL,PRIMARY KEY(company,actor,key));
+CREATE OR REPLACE FUNCTION xs_read_version(co text,scope text) RETURNS text LANGUAGE sql STABLE AS $$
+ WITH frontier AS (SELECT GREATEST(
+  COALESCE((SELECT max(revision) FROM xs_read_revision_event WHERE company=co AND domain=scope),0),
+  COALESCE((SELECT max(revision) FROM xs_read_revision_event WHERE company='*' AND domain=scope),0)) revision)
+ SELECT revision::text||':'||COALESCE((SELECT string_agg(x::text,',' ORDER BY x)
+  FROM txid_snapshot_xip(txid_current_snapshot()) x WHERE x<=frontier.revision),'') FROM frontier
+$$;
+-- Include older in-flight writer IDs in the version. If an older writer commits
+-- after the frontier, its disappearance invalidates the cache (MAX alone cannot).
+CREATE OR REPLACE FUNCTION xs_prune_read_revisions() RETURNS void LANGUAGE sql AS $$
+ DELETE FROM xs_read_revision_event e USING (
+  SELECT company,domain,max(revision) keep FROM xs_read_revision_event
+  WHERE revision<txid_snapshot_xmin(txid_current_snapshot()) GROUP BY company,domain
+ ) k WHERE e.company=k.company AND e.domain=k.domain AND e.revision<k.keep;
+ DELETE FROM xs_history_summary WHERE expires_at<=now();
+$$;
+CREATE OR REPLACE FUNCTION xs_bump_read_revision() RETURNS trigger LANGUAGE plpgsql AS $$
+DECLARE companies text[];co text;scope text;
+BEGIN
+ IF TG_OP='TRUNCATE' THEN
+  INSERT INTO xs_read_revision_event(company,domain,revision) SELECT '*',domain,txid_current() FROM unnest(string_to_array(TG_ARGV[0],',')) domain ON CONFLICT DO NOTHING;
+  RETURN NULL;
+ ELSIF TG_OP='INSERT' THEN
+  SELECT array_agg(DISTINCT COALESCE(to_jsonb(n)->>'company','*')) INTO companies FROM new_rows n;
+ ELSIF TG_OP='DELETE' THEN
+  SELECT array_agg(DISTINCT COALESCE(to_jsonb(o)->>'company','*')) INTO companies FROM old_rows o;
+ ELSE
+  SELECT array_agg(DISTINCT company ORDER BY company) INTO companies FROM (
+   SELECT COALESCE(to_jsonb(n)->>'company','*') company FROM new_rows n
+   UNION SELECT COALESCE(to_jsonb(o)->>'company','*') FROM old_rows o
+  ) x;
+ END IF;
+ -- Keys are transaction-specific; different writers never contend on a counter.
+ FOR co IN SELECT unnest(companies) ORDER BY 1 LOOP
+  FOR scope IN SELECT unnest(string_to_array(TG_ARGV[0],',')) ORDER BY 1 LOOP
+   INSERT INTO xs_read_revision_event(company,domain,revision) VALUES(co,scope,txid_current()) ON CONFLICT DO NOTHING;
+  END LOOP;
+ END LOOP;
+ RETURN NULL;
+END $$;
+DO $$
+DECLARE source record;
+BEGIN
+ FOR source IN SELECT * FROM (VALUES
+ ('LessonRecord','account,lessons'),('CommonModel','learning,lessons'),
+ ('LessonCompletionReport','lessons'),('CourseAppointment','appointments,lessons'),
+ ('_User','account,lessons'),('Node','courses,lessons'),('CourseBinding','courses'),
+ ('DailyStudyRecord','learning,lessons'),('MemoryPracticeRecord','lessons,review'),
+ ('CourseCreditReservation','account'),('SurveyItem','reading'),('SurveyLog','reading')
+ ) s(tbl,domains) LOOP
+  IF to_regclass(format('%I',source.tbl)) IS NULL THEN CONTINUE;END IF;
+  EXECUTE format('DROP TRIGGER IF EXISTS xs_revision_insert ON %I',source.tbl);
+  EXECUTE format('DROP TRIGGER IF EXISTS xs_revision_update ON %I',source.tbl);
+  EXECUTE format('DROP TRIGGER IF EXISTS xs_revision_delete ON %I',source.tbl);
+  EXECUTE format('DROP TRIGGER IF EXISTS xs_revision_truncate ON %I',source.tbl);
+  EXECUTE format('CREATE TRIGGER xs_revision_insert AFTER INSERT ON %I REFERENCING NEW TABLE AS new_rows FOR EACH STATEMENT EXECUTE FUNCTION xs_bump_read_revision(%L)',source.tbl,source.domains);
+  EXECUTE format('CREATE TRIGGER xs_revision_update AFTER UPDATE ON %I REFERENCING OLD TABLE AS old_rows NEW TABLE AS new_rows FOR EACH STATEMENT EXECUTE FUNCTION xs_bump_read_revision(%L)',source.tbl,source.domains);
+  EXECUTE format('CREATE TRIGGER xs_revision_delete AFTER DELETE ON %I REFERENCING OLD TABLE AS old_rows FOR EACH STATEMENT EXECUTE FUNCTION xs_bump_read_revision(%L)',source.tbl,source.domains);
+  EXECUTE format('CREATE TRIGGER xs_revision_truncate AFTER TRUNCATE ON %I FOR EACH STATEMENT EXECUTE FUNCTION xs_bump_read_revision(%L)',source.tbl,source.domains);
+ END LOOP;
+END $$;
+COMMIT;

+ 18 - 0
scripts/tests/lesson-export.test.mjs

@@ -0,0 +1,18 @@
+import {test} from 'node:test';import assert from 'node:assert/strict';import {PGlite} from '@electric-sql/pglite';import {readFileSync,existsSync} from 'node:fs';import vm from 'node:vm';
+const file=new URL('../cloud/lesson-export.js',import.meta.url);
+test('export jobs are idempotent, owner scoped, globally leased and cancellation fences workers',async()=>{
+ assert.ok(existsSync(file),'durable export implementation exists');const db=new PGlite();
+ try{
+ await db.exec(readFileSync(new URL('../sql/lesson-export.sql',import.meta.url),'utf8'));
+ const adapter=tx=>({query:async(s,p=[])=>(await tx.query(s,p)).rows,one:async(s,p=[])=>(await tx.query(s,p)).rows[0],none:async(s,p=[])=>{await tx.query(s,p);}});
+ const Psql={...adapter(db),tx:work=>db.transaction(tx=>work(adapter(tx)))};
+ const context={current:{id:'owner'},company:'co',roles:['ops-manager'],isSuperAdmin:true};
+ const ctx=vm.createContext({Psql,text:v=>String(v??''),number:v=>Number(v)||0,companyIdOf:c=>c.company,roleOf:c=>c.roles[0],requireRole:()=>{},requireSuperAdmin:c=>{if(!c.isSuperAdmin)throw Error('forbidden');},fail:(_,m)=>{throw Error(m);},Date,JSON});vm.runInContext(readFileSync(file,'utf8'),ctx);
+ const input={idempotencyKey:'export-key-12345678',search:'林'};
+ const first=await ctx.lessonExportCreate(context,input),again=await ctx.lessonExportCreate(context,input);assert.equal(first.jobId,again.jobId);
+ await assert.rejects(ctx.lessonExportStatus({...context,current:{id:'other'}},{jobId:first.jobId}),/未找到/);
+ const claim=await ctx.lessonExportClaim(context);assert.equal(claim.jobId,first.jobId);assert.ok(claim.lease);assert.equal((await ctx.lessonExportClaim(context)).jobId,undefined);
+ await ctx.lessonExportCancel(context,{jobId:first.jobId});await assert.rejects(ctx.lessonExportChunk(context,{jobId:first.jobId,lease:claim.lease,offset:0}),/租约/);
+ assert.equal((await ctx.lessonExportStatus(context,{jobId:first.jobId})).status,'cancelled');
+ }finally{await db.close();}
+});

+ 53 - 1
scripts/tests/lesson-records.test.mjs

@@ -5,6 +5,7 @@ import {PGlite} from '@electric-sql/pglite';
 import vm from 'node:vm';
 const db=new PGlite(),query=async(s,p=[])=>(await db.query(s,p)).rows,one=async(s,p=[])=>(await query(s,p))[0];
 const ctx={TEACHING_CATEGORY_LABELS:new Map([['word','单词课'],['high_writing','高中语法写作课'],['middle_writing','中学语法写作课'],['trial','体验课']]),Psql:{query,one,oneOrNone:one},text:v=>String(v??'').trim(),number:(v,d=0)=>Number.isFinite(Number(v))?Number(v):d,dateIso:v=>v?new Date(v).toISOString():'',classType:v=>({'2':'60 分钟'})[v]||v,companyIdOf:c=>c.company,roleOf:c=>c.role,requireRole:(c,r)=>{if(!r.includes(c.role))throw Error('forbidden');},fail:(_,m)=>{throw Error(m);},pageInput:i=>({page:Math.max(1,Number(i.page)||1),pageSize:Math.min(100,Math.max(1,Number(i.pageSize)||20))})};
+ctx.Psql.tx=work=>db.transaction(tx=>{const query=async(s,p=[])=>(await tx.query(s,p)).rows;return work({query,none:async(s,p)=>{await query(s,p);},one:async(s,p)=>(await query(s,p))[0]});});
 vm.createContext(ctx);vm.runInContext(readFileSync(new URL('../cloud/lesson-records.js',import.meta.url),'utf8'),ctx);
 const manager={company:'c',current:{id:'manager'},role:'ops-manager'};
 const input={studentId:20,appointmentId:'1000',scope:'lesson',coachId:11};
@@ -94,7 +95,7 @@ test('history projection preserves duplicate-account precedence and every filter
  await query(`INSERT INTO "_User"("objectId",company,"legacyUserId","legacyGroupId","identityType","realName","legacyUserData") VALUES ('zz-duplicate','c',20,1,'member','duplicate account','{"ParentUserID":40}'),('cross-tenant','d',20,1,'member','other tenant','{}')`);
  for(const input of [{},{studentId:20},{coachName:'原老师'},{dateFrom:'2026-10-01',dateTo:'2026-10-04'},{page:2,pageSize:10}]){
   const before=JSON.parse(JSON.stringify(await old.lessonHistoryPage(manager,input))),after=JSON.parse(JSON.stringify(await ctx.lessonHistoryPage(manager,input)));
-  delete before.refreshedAt;delete after.refreshedAt;
+  delete before.refreshedAt;delete after.refreshedAt;delete after.nextCursor;
   for(let index=0;index<after.items.length;index++){
    assert.equal(after.items[index].classTypeLabel,before.items[index].classTypeLabel==='60 分钟'?'单词课 60 分钟':before.items[index].classTypeLabel);
    delete before.items[index].classTypeLabel;delete after.items[index].classTypeLabel;
@@ -136,3 +137,54 @@ test('已确认预约补齐历史写作课型,按原学员和帐套关联,
  assert.equal(ctx.lessonHistoryClassType((await ctx.lessonHistoryTypeRows([{...base,kcid:'',company:'other'}]))[0]),'60 分钟');
 
 });
+test('exact student filtering enters facts before expensive history joins without changing totals',async()=>{
+ let captured='';const original=ctx.Psql.tx;ctx.Psql.tx=work=>original(db=>work({...db,one:async(sql,args)=>{if(sql.includes('WITH facts'))captured=sql;return db.one(sql,args);}}));
+ try{const p=await ctx.lessonHistoryPage(manager,{studentId:20});assert.equal(p.cumulativeTotal,56);assert.match(captured,/WHERE l.company=\$1 AND l.xymz=\$\d+/);}finally{ctx.Psql.tx=original;}
+});
+test('history admission rejects overload before querying and uses transaction-local SQL deadline',async()=>{
+ assert.equal(typeof ctx.lessonHistoryRead,'function');
+ let ran=false;await ctx.lessonHistoryRead(async()=>{ran=true;return 1;});assert.equal(ran,true);
+});
+test('durable export freezes history, resumes expired workers and produces a complete Excel workbook',async()=>{
+ await db.exec(readFileSync(new URL('../sql/lesson-export.sql',import.meta.url),'utf8'));
+ ctx.requireSuperAdmin=c=>{if(!c.isSuperAdmin)throw Error('forbidden');};ctx.auth=async()=>manager;ctx.Parse={Query:class{async get(){return{};}}};
+ vm.runInContext(readFileSync(new URL('../cloud/lesson-export.js',import.meta.url),'utf8'),ctx);
+ const worker={...manager,isSuperAdmin:true};
+ const created=await ctx.lessonExportCreate(manager,{studentId:20,idempotencyKey:'e2e-history-export-01'});
+ const leased=await ctx.lessonExportClaim(worker);assert.equal(leased.jobId,created.jobId);
+ await ctx.lessonExportSnapshot(worker,leased);
+ const first=await ctx.lessonExportChunk(worker,{...leased,offset:0});assert.equal(first.items.length,56);
+ await query(`UPDATE "LessonRecord" SET pldp='snapshot must stay unchanged' WHERE company='c'`);
+ await query("UPDATE xs_history_export_job SET lease_until=now()-interval '1 second' WHERE id=$1",[created.jobId]);
+ const replacement=await ctx.lessonExportClaim(worker);assert.equal(replacement.snapshotReady,true);assert.notEqual(replacement.lease,leased.lease);
+ await assert.rejects(ctx.lessonExportChunk(worker,{...leased,offset:0}),/租约/);
+ const {processLessonExport}=await import('../lesson-export-worker.mjs');let claimed=false;
+ const call=async(action,input)=>{if(action==='claim'){if(claimed)return{};claimed=true;return replacement;}return ctx['lessonExport'+action[0].toUpperCase()+action.slice(1)](worker,input);};
+ assert.equal(await processLessonExport(call),true);
+ const file=await ctx.lessonExportDownload(manager,{jobId:created.jobId});
+ const XLSX=await import('xlsx'),book=XLSX.read(file.base64,{type:'base64'}),rows=XLSX.utils.sheet_to_json(book.Sheets[book.SheetNames[0]]);
+ assert.equal(rows.length,56);assert.ok(rows.every(r=>r['老师点评']!=='snapshot must stay unchanged'));
+ await assert.rejects(ctx.lessonExportDownload({...manager,current:{id:'other'}},{jobId:created.jobId}),/未找到/);
+});
+test('revision-aware history reuses totals on later pages and invalidates on metadata and source writes',async()=>{
+ await db.exec(readFileSync(new URL('../sql/read-revisions.sql',import.meta.url),'utf8'));
+ let queries=[];const original=ctx.Psql.tx;ctx.Psql.tx=work=>original(db=>work({...db,one:async(sql,args)=>{queries.push(sql);return db.one(sql,args);}}));
+ try{
+  const first=await ctx.lessonHistoryPage(manager,{studentId:20,pageSize:10});queries=[];
+  const second=await ctx.lessonHistoryPage(manager,{studentId:20,pageSize:10,page:2});assert.equal(first.version,second.version);assert.equal(first.total,second.total);
+  const sql=queries.find(q=>q.includes('WITH facts'));assert.ok(sql);assert.ok(!sql.includes('string_agg'));assert.ok(!sql.includes('SELECT count(*)::int FROM filtered'));
+  await query(`UPDATE "_User" SET "realName"='新老师名字' WHERE "objectId"='old'`);
+  const changed=await ctx.lessonHistoryPage(manager,{studentId:20});assert.notEqual(changed.version,first.version);assert.equal(changed.total,first.total);
+ }finally{ctx.Psql.tx=original;}
+});
+test('cursor batches preserve stable ordering, exact counts and reject a changed revision',async()=>{
+ const first=await ctx.lessonHistoryPage(manager,{studentId:20,pageSize:10});assert.ok(first.nextCursor);
+ const next=await ctx.lessonHistoryPage(manager,{studentId:20,pageSize:10,cursor:first.nextCursor});assert.equal(next.total,first.total);assert.equal(next.items.length,10);
+ assert.ok(next.items.every(row=>!first.items.some(old=>old.lessonId===row.lessonId)));
+ await query(`UPDATE "LessonRecord" SET "updatedAt"=now() WHERE "objectId"='lesson3'`);
+ await assert.rejects(ctx.lessonHistoryPage(manager,{studentId:20,pageSize:10,cursor:first.nextCursor}),/变化/);
+});
+test('overload does not execute history work and transactions release their own permits',async()=>{
+ const original=ctx.Psql.tx;let ran=false;ctx.Psql.tx=work=>work({none:async()=>{},one:async()=>({acquired:false})});
+ try{await assert.rejects(ctx.lessonHistoryRead(async()=>{ran=true;}),/查询较多/);assert.equal(ran,false);}finally{ctx.Psql.tx=original;}
+});

+ 19 - 0
scripts/tests/read-revisions.test.mjs

@@ -0,0 +1,19 @@
+import {test} from 'node:test';import assert from 'node:assert/strict';import {PGlite} from '@electric-sql/pglite';import {readFileSync,existsSync} from 'node:fs';
+const file=new URL('../sql/read-revisions.sql',import.meta.url);
+test('revisions follow insert/update/delete/tenant moves and rollback across all source writers',async()=>{
+ assert.ok(existsSync(file),'revision migration exists');const db=new PGlite();
+ try{
+  await db.exec('CREATE TABLE "LessonRecord"(company text,id int);CREATE TABLE "_User"(company text,id int);');
+  await db.exec(readFileSync(file,'utf8'));
+  const version=async co=>(await db.query('SELECT xs_read_version($1,$2) v',[co,'lessons'])).rows[0].v;
+  const initial=await version('a');await db.exec("INSERT INTO \"LessonRecord\" VALUES('a',1),('a',2)");assert.notEqual(await version('a'),initial);
+  const before=await version('a');await db.exec("BEGIN;UPDATE \"LessonRecord\" SET id=3;ROLLBACK");assert.equal(await version('a'),before);
+  await db.exec("UPDATE \"LessonRecord\" SET company='b' WHERE id=1");assert.notEqual(await version('a'),before);assert.notEqual(await version('b'),'0');
+  const b=await version('b');await db.exec("DELETE FROM \"LessonRecord\" WHERE company='b'");assert.notEqual(await version('b'),b);
+  const a=await version('a');await db.exec("UPDATE \"_User\" SET id=2");assert.equal(await version('a'),a);
+  await db.exec("INSERT INTO \"_User\" VALUES('a',10)");assert.notEqual(await version('a'),a);
+  const last=await version('a');await db.exec('TRUNCATE "LessonRecord"');assert.notEqual(await version('a'),last);
+  const stable=await version('a');await db.exec('SELECT xs_prune_read_revisions()');assert.equal(await version('a'),stable);
+  await db.exec(readFileSync(file,'utf8'));assert.equal(typeof await version('a'),'string');
+ }finally{await db.close();}
+});

+ 42 - 0
scripts/tests/request-scheduler.test.mjs

@@ -0,0 +1,42 @@
+import {test} from 'node:test';
+import assert from 'node:assert/strict';
+import {readFileSync,existsSync} from 'node:fs';
+import {createRequire} from 'node:module';
+import vm from 'node:vm';
+import {Subject,of,firstValueFrom} from 'rxjs';
+import ts from 'typescript';
+const require=createRequire(import.meta.url),file=new URL('../../projects/shared/request-scheduler.ts',import.meta.url);
+function scheduler(options){assert.ok(existsSync(file),'shared scheduler must exist');const exports={};vm.runInNewContext(ts.transpileModule(readFileSync(file,'utf8'),{compilerOptions:{module:ts.ModuleKind.CommonJS,target:ts.ScriptTarget.ES2022}}).outputText,{exports,require,setTimeout,clearTimeout,Date,Error,Map,JSON});return new exports.RequestScheduler(options);}
+const flush=()=>new Promise(r=>setImmediate(r));
+test('caps reads at three, reserves a fourth slot for writes, releases queued work',async()=>{
+ const s=scheduler(),sources=Array.from({length:6},()=>new Subject()),started=[];
+ const subscriptions=sources.map((source,i)=>s.schedule(()=>{started.push(i);return source;},{read:i!==5}).subscribe());
+ assert.deepEqual(started,[0,1,2,5]);sources[0].complete();assert.deepEqual(started,[0,1,2,5,3]);subscriptions.forEach(s=>s.unsubscribe());assert.equal(s.snapshot().active,0);
+});
+test('coalesces identical reads and aborts only after the last consumer leaves',()=>{
+ const s=scheduler(),source=new Subject();let starts=0,stops=0;
+ const run=()=>{starts++;return new (require('rxjs').Observable)(subscriber=>{const sub=source.subscribe(subscriber);return()=>{stops++;sub.unsubscribe();};});};
+ const a=s.schedule(run,{read:true,key:'identity'}).subscribe(),b=s.schedule(run,{read:true,key:'identity'}).subscribe();
+ assert.equal(starts,1);a.unsubscribe();assert.equal(stops,0);b.unsubscribe();assert.equal(stops,1);
+});
+test('caches only approved successes, invalidation fences in-flight results, never caches failures',async()=>{
+ let now=0;const s=scheduler({now:()=>now}),p={read:true,key:'same',cacheMs:30_000,cacheable:v=>v.success===true};let starts=0;
+ const run=()=>{starts++;return of({success:true});};await firstValueFrom(s.schedule(run,p));now=29_999;await firstValueFrom(s.schedule(run,p));assert.equal(starts,1);now=30_000;await firstValueFrom(s.schedule(run,p));assert.equal(starts,2);
+ s.invalidate();const old=new Subject(),pending=firstValueFrom(s.schedule(()=>old,p));s.invalidate();old.next({success:true});old.complete();await pending;await firstValueFrom(s.schedule(run,p));assert.equal(starts,3);
+ s.invalidate();await firstValueFrom(s.schedule(()=>of({success:false}),p));await firstValueFrom(s.schedule(run,p));assert.equal(starts,4);
+});
+test('drops stale queued sessions and enforces one heavy read at a time',async()=>{
+ const s=scheduler(),hold=new Subject();let live=true,started=0;const one=s.schedule(()=>hold,{read:true,heavy:true}).subscribe();
+ const result=firstValueFrom(s.schedule(()=>{started++;return of(1);},{read:true,heavy:true,valid:()=>live}));live=false;hold.complete();await assert.rejects(result,/会话/);assert.equal(started,0);one.unsubscribe();await flush();assert.equal(s.snapshot().queued,0);
+});
+test('bounds queue wait without starting timed-out work',async()=>{
+ const s=scheduler({queueTimeoutMs:5}),hold=new Subject(),sub=s.schedule(()=>hold,{read:true,heavy:true}).subscribe();
+ await assert.rejects(firstValueFrom(s.schedule(()=>of(1),{read:true,heavy:true})),/排队/);sub.unsubscribe();assert.equal(s.snapshot().queued,0);
+});
+test('fifty concurrent consumers remain bounded and a submission passes a read backlog',async()=>{
+ const s=scheduler();let active=0,max=0;const order=[];
+ const read=i=>s.schedule(()=>new (require('rxjs').Observable)(subscriber=>{active++;max=Math.max(max,active);const timer=setTimeout(()=>{order.push(i);subscriber.next(i);subscriber.complete();},2);return()=>{clearTimeout(timer);active--;};}),{read:true,key:'r'+i});
+ const reads=Array.from({length:50},(_,i)=>firstValueFrom(read(i)));
+ const write=firstValueFrom(s.schedule(()=>{order.push('write');return of('saved');},{read:false}));
+ assert.equal(await write,'saved');assert.equal(order[0],'write');assert.deepEqual(await Promise.all(reads),Array.from({length:50},(_,i)=>i));assert.ok(max<=3);assert.equal(s.snapshot().active,0);assert.equal(s.snapshot().queued,0);
+});

+ 4 - 0
scripts/tests/store-scope.test.mjs

@@ -149,3 +149,7 @@ test('门店运营趋势按服务器身份隔离,去重学员、补零并排
  await assert.rejects(c.prepareStoreRequest(ctx,{operation:'ops/dashboard/summary'},{}),/无权/);
  }finally{await db.close();}
 });
+test('门店可操作本人导出,后台 worker 操作始终关闭',async()=>{
+ const c=env();for(const action of ['create','status','cancel','download'])await c.prepareStoreRequest(ctx,{operation:'ops/lessons/export/'+action},{});
+ for(const action of ['claim','snapshot','chunk','finish','fail'])await assert.rejects(c.prepareStoreRequest(ctx,{operation:'ops/lessons/export/'+action},{}));
+});

+ 34 - 0
scripts/verify-read-concurrency.mjs

@@ -0,0 +1,34 @@
+#!/usr/bin/env node
+// Explicit isolated runner; downloads belong to a separate temporary npm prefix, not this repo.
+import {createRequire} from 'node:module';import {mkdtempSync,readFileSync,writeFileSync} from 'node:fs';import {tmpdir} from 'node:os';import {join} from 'node:path';import {randomBytes} from 'node:crypto';import {createServer} from 'node:net';import assert from 'node:assert/strict';
+const runtime=process.env.XIAOSHU_EMBEDDED_PG_DIR||'/tmp/xiaoshu-postgres-validation';
+const require=createRequire(join(runtime,'package.json'));
+const {default:EmbeddedPostgres}=await import(require.resolve('embedded-postgres'));
+const probe=createServer();await new Promise(r=>probe.listen(0,'127.0.0.1',r));const port=probe.address().port;await new Promise(r=>probe.close(r));
+const cluster=new EmbeddedPostgres({databaseDir:mkdtempSync(join(tmpdir(),'xiaoshu-read-pg-')),port,user:'validation',password:randomBytes(24).toString('hex'),persistent:false,createPostgresUser:false,postgresFlags:['-h','127.0.0.1','-c','max_connections=80'],onLog:()=>{},onError:()=>{}});
+const connections=[];const connect=async()=>{const c=cluster.getPgClient('postgres','127.0.0.1');await c.connect();connections.push(c);return c;};
+const report={isolated:true,productionTouched:false,checks:[]};
+try{
+ await cluster.initialise();await cluster.start();const [a,b,c]=await Promise.all([connect(),connect(),connect()]);
+ report.version=(await c.query('SELECT version() v')).rows[0].v;
+ await c.query('CREATE TABLE "LessonRecord"(company text,id int);CREATE TABLE "CommonModel"(company text,id int);');
+ await c.query(readFileSync(new URL('./sql/read-revisions.sql',import.meta.url),'utf8'));
+ const version=async()=>(await c.query("SELECT xs_read_version('co','lessons') v")).rows[0].v;
+ for(const isolation of ['READ COMMITTED','REPEATABLE READ']){
+  await a.query('BEGIN ISOLATION LEVEL '+isolation);await a.query("INSERT INTO \"LessonRecord\" VALUES('co',1)");
+  await b.query('BEGIN');await b.query("INSERT INTO \"LessonRecord\" VALUES('co',2)");await b.query('COMMIT');const earlier=await version();
+  await a.query('COMMIT');const later=await version();assert.notEqual(earlier,later);report.checks.push({name:'late-commit-'+isolation,passed:true});
+ }
+ await a.query('BEGIN');await b.query('BEGIN');await a.query("INSERT INTO \"LessonRecord\" VALUES('co',10)");await b.query("INSERT INTO \"CommonModel\" VALUES('co',20)");
+ await a.query("SET LOCAL statement_timeout='2s'");await a.query("INSERT INTO \"CommonModel\" VALUES('co',21)");await Promise.all([a.query('COMMIT'),b.query('COMMIT')]);report.checks.push({name:'former-revision-lock-cycle',passed:true});
+ for(const x of [a,b,c])await x.query('BEGIN');
+ const lock=async(x,slot)=>(await x.query('SELECT pg_try_advisory_xact_lock(1736200,$1) acquired',[slot])).rows[0].acquired;
+ assert.equal(await lock(a,1),true);assert.equal(await lock(b,1),false);assert.equal(await lock(b,2),true);assert.equal(await lock(c,1),false);assert.equal(await lock(c,2),false);await a.query('ROLLBACK');assert.equal(await lock(c,1),true);await Promise.all([b.query('ROLLBACK'),c.query('ROLLBACK')]);report.checks.push({name:'global-two-slots-and-rollback-release',passed:true});
+ await a.query('BEGIN');await a.query("SET LOCAL statement_timeout='30ms'");await lock(a,1);await assert.rejects(a.query('SELECT pg_sleep(1)'),e=>e.code==='57014');await a.query('ROLLBACK');await b.query('BEGIN');assert.equal(await lock(b,1),true);await b.query('ROLLBACK');report.checks.push({name:'timeout-rollback-releases-slot',passed:true});
+ const clients=await Promise.all(Array.from({length:50},()=>connect()));
+ let active=0,maxActive=0;
+ const result=await Promise.all(clients.map(async client=>{await client.query('BEGIN');try{let allowed=false;for(const slot of [1,2])if(await lock(client,slot)){allowed=true;break;}if(!allowed)return 'busy';active++;maxActive=Math.max(maxActive,active);await client.query('SELECT pg_sleep(0.03)');active--;return 'success';}finally{await client.query('ROLLBACK');}}));
+ assert.ok(maxActive<=2);report.checks.push({name:'50-connection-overload',passed:true,maxActive,success:result.filter(v=>v==='success').length,busy:result.filter(v=>v==='busy').length,note:'Intentional saturation test, not a successful 50-user capacity acceptance'});
+ const before=await version();await c.query('SELECT xs_prune_read_revisions()');assert.equal(await version(),before);report.checks.push({name:'prune-preserves-version',passed:true});
+ writeFileSync('perf/cloud-concurrency-2026-11-15/postgres-concurrency.json',JSON.stringify(report,null,2));console.log(JSON.stringify(report,null,2));
+}finally{await Promise.all(connections.map(c=>c.end().catch(()=>{})));await cluster.stop();}