Browse Source

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

彭峰 3 hours ago
parent
commit
1d581fed9b
33 changed files with 1184 additions and 52 deletions
  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 { HttpClient, HttpErrorResponse } from '@angular/common/http';
 import { inject, Injectable } from '@angular/core';
 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 { CLOUD_FUNCTION_BASE_URL } from './admin.constants';
 import { AdminSessionService } from './admin-session.service';
 import { AdminSessionService } from './admin-session.service';
 import { CloudEnvelope, LegacyCloudEnvelope } from './admin.models';
 import { CloudEnvelope, LegacyCloudEnvelope } from './admin.models';
@@ -13,6 +14,8 @@ export class CloudFunctionError extends Error {
 
 
 @Injectable({ providedIn: 'root' })
 @Injectable({ providedIn: 'root' })
 export class CloudFunctionsService {
 export class CloudFunctionsService {
+  private readonly scheduler = new RequestScheduler();
+  requestActivity() { return this.scheduler.snapshot(); }
   readonly dataChanges$ = new Subject<'local' | 'broadcast'>();
   readonly dataChanges$ = new Subject<'local' | 'broadcast'>();
   private readonly http = inject(HttpClient);
   private readonly http = inject(HttpClient);
   private readonly sessions = inject(AdminSessionService);
   private readonly sessions = inject(AdminSessionService);
@@ -40,7 +43,7 @@ export class CloudFunctionsService {
 
 
   // Explicitly allow only reads. Unknown operations and all submissions must never replay.
   // Explicitly allow only reads. Unknown operations and all submissions must never replay.
   private isReadOperation(operation: string): boolean {
   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 {
   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 {
     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))) }),
         timeout({ first: timeoutMs, with: () => throwError(() => new CloudFunctionError(this.transportMessage(readOnly))) }),
         retry({ count: 1, delay: (error) => {
         retry({ count: 1, delay: (error) => {
           const transient = error instanceof HttpErrorResponse && [0, 502, 503, 504].includes(error.status);
           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.
           // 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);
           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) {
     } catch (error) {
       if (error instanceof HttpErrorResponse && [0, 502, 503, 504].includes(error.status)) {
       if (error instanceof HttpErrorResponse && [0, 502, 503, 504].includes(error.status)) {
         throw new CloudFunctionError(this.transportMessage(readOnly), error.status);
         throw new CloudFunctionError(this.transportMessage(readOnly), error.status);
       }
       }
       throw error;
       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> {
   private async handleAuthenticationFailure(token: string, status: number, payload: unknown): Promise<void> {
@@ -78,13 +100,14 @@ export class CloudFunctionsService {
   }
   }
 
 
   private isMutation(operation: string): boolean {
   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);
     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 {
   private createDataChannel(): BroadcastChannel | null {
     if (typeof globalThis.BroadcastChannel !== 'function') return null;
     if (typeof globalThis.BroadcastChannel !== 'function') return null;
     const channel = new globalThis.BroadcastChannel('xiaoshu-live-data-v1');
     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;
     return channel;
   }
   }
 
 
@@ -105,20 +128,24 @@ export class CloudFunctionsService {
   }
   }
 
 
   private announceDataChange(): void {
   private announceDataChange(): void {
+    this.scheduler.invalidate();
     if (this.changeBatchDepth) { this.pendingDataChange = true; return; }
     if (this.changeBatchDepth) { this.pendingDataChange = true; return; }
     this.dataChanges$.next('local');
     this.dataChanges$.next('local');
     this.dataChannel?.postMessage({ changedAt: new Date().toISOString() });
     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;
     const token = this.sessions.session()?.sessionToken;
     if (!token) throw new CloudFunctionError('管理员会话已失效', 401);
     if (!token) throw new CloudFunctionError('管理员会话已失效', 401);
     try {
     try {
       const envelope = await this.request<CloudEnvelope<T>>('xiaoshu/ops/gateway-v3', token,
       const envelope = await this.request<CloudEnvelope<T>>('xiaoshu/ops/gateway-v3', token,
         { ...params, operation }, this.isReadOperation(operation),
         { ...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 (!envelope.success) { await this.handleAuthenticationFailure(token, 0, envelope); throw new CloudFunctionError(envelope.message || envelope.error || '运营云函数执行失败'); }
       if (this.isMutation(operation)) this.announceDataChange();
       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;
       return envelope.data;
     } catch (error) {
     } catch (error) {
       if (error instanceof CloudFunctionError) throw 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 });
     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> {
   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;
  score:number;coachComment:string;studentComment:string;feedbackSummary:string;pendingReviews:number;
  reviewCoachLabel:string;reviewCoaches:{id:string;name:string;count:number}[];duplicateCount: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 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 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}[]}
 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">
 <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()">
 <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>}
 @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" />
 <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>
 <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(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">
 <section class="content-card">
   <div class="table-scroll" role="region" aria-label="训练记录列表" tabindex="0">
   <div class="table-scroll" role="region" aria-label="训练记录列表" tabindex="0">
     <table class="data-table lesson-table">
     <table class="data-table lesson-table">
@@ -60,6 +66,6 @@
     </table>
     </table>
   </div>
   </div>
 </section>
 </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()" />}
 @if(detailId()){<app-lesson-record-detail [lessonId]="detailId()" (closed)="detailId.set('')" (changed)="load()" />}
 </section>
 </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();
   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();
   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='钱老师';
   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;
   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('网络中断')));
   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()=>{
  it('查询默认没有月份限制;切换条件清除查看全部',async()=>{
   const c=TestBed.runInInjectionContext(()=>new LessonRecordsComponent());expect(c.params()['dateFrom']).toBe('');expect(c.params()['dateTo']).toBe('');
   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 {OperationsService} from '../operations.service';
 import {OperationsDateFilterComponent} from '../components/operations-date-filter.component';
 import {OperationsDateFilterComponent} from '../components/operations-date-filter.component';
 import {LessonRecordDetailComponent} from './lesson-record-detail.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})
 @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 {
 export class LessonRecordsComponent implements OnInit,OnChanges,OnDestroy {
  @Input() studentId=0;@Input() embedded=false;@Input() refreshVersion=0;
  @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);
  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;
  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 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;});
  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);}
  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();}}
  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};}
  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();}
  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();}
  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();}
  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' });
       .flush('Bad Gateway', { status: 502, statusText: 'Bad Gateway' });
     tick(800);
     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 instanceof ApiError).toBeTrue();
     expect((actual as ApiError).message).toBe('请求暂未完成,请检查网络后重试;已填写内容会保留。');
     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', () => {
   it('nests reserved id parameters inside params', () => {
     api.get('node_get', { id: 81 }).subscribe();
     api.get('node_get', { id: 81 }).subscribe();
     const request = http.expectOne(API_CONFIG.cloudFunctionUrl);
     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 { HttpClient, HttpErrorResponse } from '@angular/common/http';
 import { HomeDailyCacheService } from './home-daily-cache.service';
 import { HomeDailyCacheService } from './home-daily-cache.service';
 import { inject, Injectable } from '@angular/core';
 import { inject, Injectable } from '@angular/core';
@@ -17,6 +18,8 @@ export class ApiError extends Error {
 
 
 @Injectable({ providedIn: 'root' })
 @Injectable({ providedIn: 'root' })
 export class ApiService {
 export class ApiService {
+  private readonly scheduler = new RequestScheduler();
+  requestActivity() { return this.scheduler.snapshot(); }
   private readonly http = inject(HttpClient);
   private readonly http = inject(HttpClient);
   private readonly sessions = inject(SessionService);
   private readonly sessions = inject(SessionService);
   private readonly dailyCache = inject(HomeDailyCacheService);
   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>> {
   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 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);
     const requestIdentity=this.parseSessionToken()+'|'+action+'|'+this.stableJson(params);
     if(teachingWrite && !params['requestKey']) {
     if(teachingWrite && !params['requestKey']) {
@@ -48,7 +52,7 @@ export class ApiService {
     }
     }
     const body = {
     const body = {
       ...(sessionToken ? { token: sessionToken } : {}),
       ...(sessionToken ? { token: sessionToken } : {}),
-      params: { action, ...params },
+      params: { ...params, action },
     };
     };
     const request = this.http.post<unknown>(API_CONFIG.cloudFunctionUrl, body, {
     const request = this.http.post<unknown>(API_CONFIG.cloudFunctionUrl, body, {
       headers: {
       headers: {
@@ -57,13 +61,22 @@ export class ApiService {
       },
       },
     });
     });
     const requestTimeout = action === 'reading/ai' ? 130_000 : action === 'app_word_audio_ensure' ? 60_000 : 20_000;
     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') {
       if (!this.isReadAction(action) && action !== 'app_word_audio_ensure') {
         this.invalidateOverviews();
         this.invalidateOverviews();
         this.announceDataChange();
         this.announceDataChange();
       }
       }
       this.pendingWrites.delete(requestIdentity);
       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>> {
   upload(file: File): Observable<ApiEnvelope<string>> {
@@ -87,7 +100,7 @@ export class ApiService {
     return `${API_CONFIG.baseUrl}${value.startsWith('/') ? value : `/${value}`}`;
     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 {
   private isReadAction(action: string): boolean {
     return /(?:_list|_get|_detail|_overview|_tongji|_tj|_is)$/.test(action)
     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>> {
   private resilient<T>(request: Observable<unknown>, authenticatedRequest = false, retryRead = true, requestToken = ''): Observable<ApiEnvelope<T>> {
     return request.pipe(
     return request.pipe(
       retry({
       retry({
-        count: retryRead ? 2 : 0,
+        count: retryRead ? 1 : 0,
         delay: (error, retryCount) => this.isTransientError(error)
         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),
           : throwError(() => error),
       }),
       }),
       map((response) => this.normalize<T>(response)),
       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 * 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
  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",
  SELECT l.*,c."objectId" AS "targetId",c."generalId",c."createdAt" AS "commonCreatedAt",
  COALESCE(NULLIF(NULLIF(l.yyds,'0'),''),'lesson:'||l."objectId") AS "classKey",
  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",
  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
  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()}
 ${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
  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)`;}
  ), 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=[];
 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)));}};
  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);
  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 '):'';}
  return conditions.length?' WHERE '+conditions.join(' AND '):'';}
 // Resolve only the displayed page; preserve the original completed-lesson snapshot when present.
 // 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);
  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;
  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",
  SELECT w.company,w."appointmentId"::text AS "appointmentId",w."studentId",
  r."courseCategoryKey" AS "reportKey",r."courseCategoryName" AS "reportName",r."durationMinutes" AS "reportMinutes",
  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"
  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);
  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||'无待复习任务'};}
 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){
 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,'缺少帐套');
  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 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
  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 (
  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
  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"
  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
  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`;
  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){
 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);
  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/stores/credit-accounts','ops/stores/credit-ledger',
  'ops/coaches/list','ops/coaches/detail','ops/coaches/create','ops/coaches/update','ops/coaches/delete',
  '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/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/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/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',
  '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 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 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 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 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 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');
 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) {
 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 {
   try {
     const input = inputOf(request);
     const input = inputOf(request);
     const operation = String(input.operation || 'meta');
     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 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, '\\$&'); }
 function escapeRegex(value) { return String(value).replace(/[\\^$.*+?()[\]{}|]/g, '\\$&'); }
 async function handler(request, response) {
 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 {
   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));
     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') } });
     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']);
 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);}
 function operationsVersion(row){return(dateIso(row&&row.changed_at)||'')+':'+number(row&&row.total);}
 async function operationsScopeVersion(scope,companyId){
 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==='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==='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]));
   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 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) {
 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;
   let context=null;
   try {
   try {
     const input=inputOf(request); const operation=text(input.operation); const payload=input.payload&&typeof input.payload==='object'?input.payload:{};
     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/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-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/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/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-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)});
     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/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}}); }
     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,'不支持的运营后台操作');
     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 = [
 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: '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.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 }] },
   { 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';
 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 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))})};
 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);
 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 manager={company:'c',current:{id:'manager'},role:'ops-manager'};
 const input={studentId:20,appointmentId:'1000',scope:'lesson',coachId:11};
 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','{}')`);
  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}]){
  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)));
   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++){
   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);
    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;
    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 分钟');
  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'},{}),/无权/);
  await assert.rejects(c.prepareStoreRequest(ctx,{operation:'ops/dashboard/summary'},{}),/无权/);
  }finally{await db.close();}
  }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();}