Ver Fonte

fix(mobile): isolate home review cache and expire sync status safely

彭峰 há 19 horas atrás
pai
commit
6d6f284662

+ 67 - 0
perf/recovery-performance-2026-11-14/CACHE-FIX-AND-TIMEOUT.md

@@ -0,0 +1,67 @@
+# 缓存修复及20秒超时本地调查
+
+2026-11-14。用户要求先修两个缓存问题,再继续查超时,全部在本地完成、课时费用安全、等待通知再上线。本批没有请求生产接口、创建生产诊断函数、修改共享服务配置或部署。
+
+## 已修复
+
+1. 首页复习独立使用 `review-home-v2`,只接受 `view: home` + `legacyReviewSync.state: snapshot` 的响应。旧 `review` 缓存不再载入;新结果持久化时不携带旧键。原有余额、统计、账户、课表缓存保留;旧版客户端也不会将新键识别为它的复习响应。
+2. 同步提示始终描述“上次检查”,显示数据读取时间。检查结果60秒后标为“已过期、当前状态尚未核对”,不据时间流逝推断同步真的停止。缺失、非法或未来时间显示未知。
+3. 页面持续显示、离开后返回、整页重载、未来时间恢复有效、设备时钟回拨都测试过。可见恢复和定时回调重新安排剩余有效期;销毁清除定时器。过期仅更新文案,不轮询、不拉旧系统、不刷新余额。请求失败时检查时间清空,保留错误提示。
+4. 新旧响应格式校验失败不会写入缓存。手动刷新仍为数据库快照读取,未扩展为旧系统同步。加载文案改为“加载复习记录”。
+
+## 测试证据
+
+- 先观察4个预期失败:新键重载、错误响应拒绝、老用户热缓存升级、持续显示状态过期;修复后针对性121项通过。
+- 独立审查发现设备校时边界;再写2个失败用例,修复重新调度后最终移动端404项通过。
+- 管理端393项、服务端586项通过,总计 **1,383项,0失败**。移动端生产构建、11个云函数定义校验通过。独立复审无遗留重要问题。
+- 真实构建、本地合成接口、390px浏览器检查4场景:正常、余额失败、统计失败、旧缓存热升级。修正门槛拒绝余额/统计错误;热升级只请求一次 app_review_page,不重读余额/统计;模拟61秒后恢复可见显示过期且请求数不变。外部网络请求0。
+- 浏览器夹具起初缺少 `view: home`,新校验正确拒绝它;按真实接口契约补全夹具后重测通过,没有放宽业务校验。过期定时与时钟回拨另有组件自动化回归覆盖。
+- 15个扣费、结课、预约释放、奖励、结算源码与此前受保护基线哈希一致。全服务端回归覆盖重复/并发结课、取消释放、奖励幂等及事务回滚。本轮没有执行真实财务操作或生产账务审计。
+
+## 超时已确定的范围
+
+已有生产样本中,profileOnly用户资料接口没有业务Psql查询,但曾等待约8.4秒、6.8秒,另一轮没有收到成功响应。当天课表的两次失败各在约20秒结束,符合客户端截止时间。**业务聚合SQL不是所有慢请求的充分解释**;现有记录仍不能区分网络、入口调度、鉴权Parse池和具体SQL。
+
+已保存的运行器源码及本地抽取执行确认,业务前置链路串行经过:
+
+`会话 → 函数定义 → 公司 → 账户 → 网关刷新用户 → 业务函数 → 响应`
+
+其中前四项使用运行器的Parse REST通道。ParseServer adapter与显式max=2的业务Psql是两个池入口;之前只看Psql瞬时快照不能证明鉴权池健康。connectionTimeoutMillis=5000是取得连接的限制,不是SQL执行截止时间。
+
+运行器原 `_request` 没有应用级AbortSignal/deadline,客户端取消未传播到内部读。运行器每次new Function执行网关,顶层旧系统同步Map也每请求重建,跨请求去重不成立;但本次数据库模式当天课表不走旧同步,不能把Map问题定为这两次失败的根因。未发现具体的手动连接未释放或请求递归证据。
+
+## 已完成的本地分段复现
+
+新增 `timeout-chain-local.mjs`:只抽取保存的运行器类,执行保存的小树网关业务函数;使用本地HTTP合成Parse数据、SDK对象转换夹具、模拟SQL连接池。拒绝非回环HTTP、非GET数据请求和非SELECT业务SQL;不记录令牌、SQL参数或真实用户。阶段日志只含随机关联ID、名称、耗时及SQL指纹。
+
+7场景全部通过:
+
+| 场景 | 可识别的阻塞阶段 | 客户端取消后内部仍继续 |
+|---|---|---|
+| 正常profileOnly | 串行前置链路;业务SQL次数0 | 不适用 |
+| 公司查询迟迟不返回headers | Parse公司headers | 是 |
+| 账户查询headers已到但正文迟迟不完成 | Parse账户body | 是 |
+| 网关刷新用户等待 | 用户刷新传输夹具 | 是 |
+| 课表取得连接等待 | 模拟业务池acquire | 是 |
+| 课表SQL执行等待 | 模拟业务池execute | 是 |
+| 会话等待21秒、客户端真实20秒取消 | 运行器原生会话读取 | 是 |
+
+最后一项使用真实20秒计时,其他故障缩时注入以便复现。所有场景没有外部请求。SDK转换/SQL池是夹具,不能据其耗时推断真实SDK、PostgreSQL池或现场性能。脚本验证诊断能区分阶段,也证明保存的原生会话读取在客户端取消后继续;**它没有证明历史失败恰好卡在会话、公司或某条SQL**。
+
+原有诊断曾将pg-promise对象包成Proxy触发不可配置属性错误;本地脚本使用明确的读接口,不把这个诊断工具错误当业务故障。
+
+## 尚未完成的生产归因
+
+现场缺少覆盖 worker入口、会话/函数/公司/账户、用户刷新、SQL取得连接/执行、响应finish/close的同一请求关联日志。因此无法在“不上线、不访问生产”的约束下补出当时没有记录的具体等待点。本轮没有改20秒阈值、自动重试或共享池配置,也没有声称超时已修好。
+
+后续获得部署通知后,应先用受控只读请求采集这些分段日志,并同时区分Parse/Psql两池;定位实测最长阶段后再窄修和连续验收。仅记录网关handler会漏掉前四项,只计await SQL总时间会混淆连接等待和执行;这两种不完整测量不可用于宣称根因。
+
+## 工件与复现
+
+- `cache-timeout-local-verification.json`:脱敏测试、浏览器及7场景日志。
+- `timeout-chain-local.mjs`:需传保存的worker源码、含app gateway的JSON快照、本地输出目录;不会自行取得生产源码或凭据。
+- `home-readiness-audit.mjs`:传本地browser构建目录与输出目录,全部请求限制在本地。
+- `cache-contract-audit.mjs`:固定读取git基线0e371fd,保留修复前问题复现;当前修复由Angular回归及浏览器热缓存场景验证。
+- 私有构建、日志和合成截图:`/Users/mac/.local/state/xiaoshu/qa/2026-11-14-cache-timeout-local/`。
+
+**状态:缓存修复、诊断工具与本地测试就绪;尚未部署,历史超时的最终现场归因仍待分段证据。**

+ 2 - 0
perf/recovery-performance-2026-11-14/CODE-REVIEW.md

@@ -1,5 +1,7 @@
 # 回退后的代码复核与验收失败解释
 
+> 后续:两个缓存问题已完成本地修复,时钟边界和回归通过,尚未部署。详细结果见 [CACHE-FIX-AND-TIMEOUT.md](CACHE-FIX-AND-TIMEOUT.md)。以下保留修复前复核证据。
+
 2026-11-14。审查候选 `81f8b44..d7cb829`、调用链、上线原始样本,并作独立审查和本地故障注入。此次不重新上线、不修改业务数据,也没有修改共享服务配置。
 
 结论:验收失败的直接原因是业务读取超过前端20秒期限、整页长等待未消除。确认了两处缓存/同步提示遗漏和一处验收假通过;尚不能将生产长等待定位到鉴权、worker调度、连接排队或具体SQL,不能用“连接池只有2”代替根因证据。

+ 4 - 3
perf/recovery-performance-2026-11-14/cache-contract-audit.mjs

@@ -1,14 +1,15 @@
 // Local diagnostic: transpile the real cache service, using synthetic identity
 // and in-memory storage. No application bundle, server, network or real session.
 import assert from 'node:assert/strict';
-import { readFileSync } from 'node:fs';
+import { execFileSync } from 'node:child_process';
 import vm from 'node:vm';
 import ts from 'typescript';
 import { firstValueFrom, of } from 'rxjs';
 import * as rxjs from 'rxjs';
 
 const servicePath = new URL('../../projects/xiaoshu-mobile/src/app/core/home-daily-cache.service.ts', import.meta.url);
-const compiled = ts.transpileModule(readFileSync(servicePath, 'utf8'), {
+// Keep the original failure reproducible after the service has been fixed.
+const compiled = ts.transpileModule(execFileSync('git',['show','0e371fd:projects/xiaoshu-mobile/src/app/core/home-daily-cache.service.ts'],{encoding:'utf8'}), {
   compilerOptions: {
     target: ts.ScriptTarget.ES2022,
     module: ts.ModuleKind.CommonJS,
@@ -110,6 +111,6 @@ const reports = [];
   });
 }
 console.log(JSON.stringify({
-  scope: 'Local service/cache contract only; no network, production identity, business writes or production-cause inference.',
+  scope: 'Historical baseline 0e371fd service/cache contract only; no network, production identity, business writes or production-cause inference.',
   passed: reports.length, reports,
 }, null, 2));

+ 2037 - 0
perf/recovery-performance-2026-11-14/cache-timeout-local-verification.json

@@ -0,0 +1,2037 @@
+{
+  "scope": "Local only. No production requests, configuration changes, migration or deployment.",
+  "baseCommit": "0e371fd",
+  "tests": {
+    "mobile": 404,
+    "admin": 393,
+    "server": 586,
+    "total": 1383,
+    "failed": 0
+  },
+  "cacheRedTests": 4,
+  "clockRedTests": 2,
+  "mobileBuild": "passed",
+  "cloudDefinitions": 11,
+  "financialSourceFilesUnchanged": 15,
+  "browser": {
+    "externalRequests": 0,
+    "results": [
+      {
+        "scenario": "healthy",
+        "oldGateAccepts": true,
+        "error": "",
+        "values": [
+          "18",
+          "12.5",
+          "13",
+          "8",
+          "7"
+        ],
+        "correctedGateAccepts": true,
+        "expiryUpdatesWithoutRequests": true,
+        "actions": [
+          "e_order_tongji",
+          "app_account_overview",
+          "app_review_page"
+        ]
+      },
+      {
+        "scenario": "balance-error",
+        "oldGateAccepts": true,
+        "error": "课时余额暂时无法更新 重新加载",
+        "values": [
+          "18",
+          "—",
+          "13",
+          "—",
+          "—"
+        ],
+        "correctedGateAccepts": false,
+        "actions": [
+          "e_order_tongji",
+          "app_account_overview",
+          "app_review_page"
+        ]
+      },
+      {
+        "scenario": "stats-error",
+        "oldGateAccepts": true,
+        "error": "统计暂时无法加载 重新加载",
+        "values": [
+          "—",
+          "12.5",
+          "—",
+          "8",
+          "7"
+        ],
+        "correctedGateAccepts": false,
+        "actions": [
+          "e_order_tongji",
+          "app_account_overview",
+          "app_review_page"
+        ]
+      },
+      {
+        "scenario": "warm-legacy-cache",
+        "oldGateAccepts": true,
+        "error": "",
+        "values": [
+          "18",
+          "12.5",
+          "13",
+          "8",
+          "7"
+        ],
+        "correctedGateAccepts": true,
+        "expiryUpdatesWithoutRequests": true,
+        "actions": [
+          "app_review_page"
+        ]
+      }
+    ]
+  },
+  "diagnostics": {
+    "workerSha256": "7b1871b5d051c72381aa14aea827bf7a78013d9b31b7c65a7982c373793e7623",
+    "gatewaySha256": "000818f3add2578a1c9cc24a47121fc3dc4fd04914b09df31fa3b48bbd4bfc98",
+    "scope": "offline saved worker routing and gateway; synthetic Parse data/SDK conversion and simulated SQL pool, fault injection is not a production root cause",
+    "reports": [
+      {
+        "name": "healthy-profile",
+        "requestId": "eda761fe-2a11-4db6-a35d-6d219f8ba067",
+        "deadlineReached": false,
+        "dbCalls": 0,
+        "externalAttempts": 0,
+        "readOnlyGuards": true,
+        "events": [
+          {
+            "phase": "worker.total",
+            "atMs": 38.787313999999924,
+            "event": "start"
+          },
+          {
+            "phase": "worker.session",
+            "atMs": 38.99704299999996,
+            "event": "start"
+          },
+          {
+            "phase": "parse.session.headers",
+            "atMs": 39.935957999999914,
+            "event": "start"
+          },
+          {
+            "phase": "parse.session.headers",
+            "atMs": 51.27292699999998,
+            "event": "end",
+            "ms": 11.335630000000037
+          },
+          {
+            "phase": "parse.session.body",
+            "atMs": 51.37259199999994,
+            "event": "start"
+          },
+          {
+            "phase": "parse.session.body",
+            "atMs": 52.68759699999998,
+            "event": "end",
+            "ms": 1.3157749999999169
+          },
+          {
+            "phase": "worker.session",
+            "atMs": 53.1115299999999,
+            "event": "end",
+            "ms": 14.117855000000077
+          },
+          {
+            "phase": "worker.function",
+            "atMs": 53.11952899999994,
+            "event": "start"
+          },
+          {
+            "phase": "parse.function.headers",
+            "atMs": 53.32553199999995,
+            "event": "start"
+          },
+          {
+            "phase": "parse.function.headers",
+            "atMs": 65.57732899999996,
+            "event": "end",
+            "ms": 12.239607999999976
+          },
+          {
+            "phase": "parse.function.body",
+            "atMs": 65.59620199999995,
+            "event": "start"
+          },
+          {
+            "phase": "parse.function.body",
+            "atMs": 70.016751,
+            "event": "end",
+            "ms": 4.420451999999955
+          },
+          {
+            "phase": "worker.function",
+            "atMs": 71.52095399999996,
+            "event": "end",
+            "ms": 18.40163599999994
+          },
+          {
+            "phase": "worker.execute",
+            "atMs": 71.52887599999997,
+            "event": "start"
+          },
+          {
+            "phase": "worker.companyAccount",
+            "atMs": 71.67697699999997,
+            "event": "start"
+          },
+          {
+            "phase": "parse.company.headers",
+            "atMs": 71.90338699999995,
+            "event": "start"
+          },
+          {
+            "phase": "parse.company.headers",
+            "atMs": 74.40707099999997,
+            "event": "end",
+            "ms": 2.503492999999935
+          },
+          {
+            "phase": "parse.company.body",
+            "atMs": 74.41918899999996,
+            "event": "start"
+          },
+          {
+            "phase": "parse.company.body",
+            "atMs": 74.87051499999995,
+            "event": "end",
+            "ms": 0.4502899999999954
+          },
+          {
+            "phase": "parse.account.headers",
+            "atMs": 75.00893199999996,
+            "event": "start"
+          },
+          {
+            "phase": "parse.account.headers",
+            "atMs": 83.41753799999992,
+            "event": "end",
+            "ms": 8.40825899999993
+          },
+          {
+            "phase": "parse.account.body",
+            "atMs": 83.43082199999992,
+            "event": "start"
+          },
+          {
+            "phase": "parse.account.body",
+            "atMs": 83.87645599999996,
+            "event": "end",
+            "ms": 0.4452919999999949
+          },
+          {
+            "phase": "worker.companyAccount",
+            "atMs": 83.90172699999994,
+            "event": "end",
+            "ms": 12.22696499999995
+          },
+          {
+            "phase": "gateway.currentUser",
+            "atMs": 94.55864599999995,
+            "event": "start"
+          },
+          {
+            "phase": "parse.user.headers",
+            "atMs": 94.86130299999991,
+            "event": "start"
+          },
+          {
+            "phase": "parse.user.headers",
+            "atMs": 96.88011799999992,
+            "event": "end",
+            "ms": 2.019356000000016
+          },
+          {
+            "phase": "parse.user.body",
+            "atMs": 96.89249799999993,
+            "event": "start"
+          },
+          {
+            "phase": "parse.user.body",
+            "atMs": 97.33202199999994,
+            "event": "end",
+            "ms": 0.43840800000009494
+          },
+          {
+            "phase": "gateway.currentUser",
+            "atMs": 97.50687499999992,
+            "event": "end",
+            "ms": 2.95054099999993
+          },
+          {
+            "phase": "business.profile",
+            "atMs": 97.64067999999997,
+            "event": "start"
+          },
+          {
+            "phase": "business.authorize",
+            "atMs": 97.97509699999989,
+            "event": "start"
+          },
+          {
+            "phase": "business.authorize",
+            "atMs": 98.155168,
+            "event": "end",
+            "ms": 0.18305199999997512
+          },
+          {
+            "phase": "business.profile",
+            "atMs": 126.54991799999993,
+            "event": "end",
+            "ms": 28.910198000000037
+          },
+          {
+            "phase": "response.attempt",
+            "atMs": 126.68117799999993,
+            "success": true
+          },
+          {
+            "phase": "worker.execute",
+            "atMs": 127.07442199999991,
+            "event": "end",
+            "ms": 55.54601100000002
+          },
+          {
+            "phase": "worker.total",
+            "atMs": 127.0773529999999,
+            "event": "end",
+            "ms": 88.33208300000001
+          }
+        ]
+      },
+      {
+        "name": "company-headers",
+        "requestId": "9497d710-05f9-4c81-9bfc-0ebd68afe04a",
+        "deadlineReached": true,
+        "dbCalls": 0,
+        "externalAttempts": 0,
+        "readOnlyGuards": true,
+        "events": [
+          {
+            "phase": "worker.total",
+            "atMs": 7.8626520000000255,
+            "event": "start"
+          },
+          {
+            "phase": "worker.session",
+            "atMs": 7.870977000000039,
+            "event": "start"
+          },
+          {
+            "phase": "parse.session.headers",
+            "atMs": 8.079897000000074,
+            "event": "start"
+          },
+          {
+            "phase": "parse.session.headers",
+            "atMs": 11.130672000000004,
+            "event": "end",
+            "ms": 3.047176000000036
+          },
+          {
+            "phase": "parse.session.body",
+            "atMs": 11.161899000000062,
+            "event": "start"
+          },
+          {
+            "phase": "parse.session.body",
+            "atMs": 11.60645599999998,
+            "event": "end",
+            "ms": 0.4449210000000221
+          },
+          {
+            "phase": "worker.session",
+            "atMs": 11.773897000000034,
+            "event": "end",
+            "ms": 3.9028039999999464
+          },
+          {
+            "phase": "worker.function",
+            "atMs": 11.779247000000055,
+            "event": "start"
+          },
+          {
+            "phase": "parse.function.headers",
+            "atMs": 11.910305999999991,
+            "event": "start"
+          },
+          {
+            "phase": "parse.function.headers",
+            "atMs": 19.088377000000037,
+            "event": "end",
+            "ms": 7.177137000000016
+          },
+          {
+            "phase": "parse.function.body",
+            "atMs": 19.102701000000025,
+            "event": "start"
+          },
+          {
+            "phase": "parse.function.body",
+            "atMs": 21.807132000000024,
+            "event": "end",
+            "ms": 2.7027519999999186
+          },
+          {
+            "phase": "worker.function",
+            "atMs": 23.114874999999984,
+            "event": "end",
+            "ms": 11.335096000000021
+          },
+          {
+            "phase": "worker.execute",
+            "atMs": 23.120259000000033,
+            "event": "start"
+          },
+          {
+            "phase": "worker.companyAccount",
+            "atMs": 23.123819000000026,
+            "event": "start"
+          },
+          {
+            "phase": "parse.company.headers",
+            "atMs": 23.18505200000004,
+            "event": "start"
+          },
+          {
+            "phase": "fault.injected",
+            "atMs": 24.21138300000007,
+            "target": "company.headers"
+          },
+          {
+            "phase": "client.deadline",
+            "atMs": 154.558811
+          },
+          {
+            "phase": "client.disconnected",
+            "atMs": 155.88367100000005
+          },
+          {
+            "phase": "parse.company.headers",
+            "atMs": 326.037143,
+            "event": "end",
+            "ms": 302.8508069999999
+          },
+          {
+            "phase": "parse.company.body",
+            "atMs": 326.05542400000013,
+            "event": "start"
+          },
+          {
+            "phase": "parse.company.body",
+            "atMs": 326.5420260000001,
+            "event": "end",
+            "ms": 0.48614299999985633
+          },
+          {
+            "phase": "parse.account.headers",
+            "atMs": 326.71709899999996,
+            "event": "start"
+          },
+          {
+            "phase": "parse.account.headers",
+            "atMs": 328.2893240000001,
+            "event": "end",
+            "ms": 1.5720219999998335
+          },
+          {
+            "phase": "parse.account.body",
+            "atMs": 328.391171,
+            "event": "start"
+          },
+          {
+            "phase": "parse.account.body",
+            "atMs": 328.6826840000001,
+            "event": "end",
+            "ms": 0.29072199999995973
+          },
+          {
+            "phase": "worker.companyAccount",
+            "atMs": 328.7118909999999,
+            "event": "end",
+            "ms": 305.58780000000013
+          },
+          {
+            "phase": "gateway.currentUser",
+            "atMs": 339.302551,
+            "event": "start"
+          },
+          {
+            "phase": "parse.user.headers",
+            "atMs": 339.4522810000001,
+            "event": "start"
+          },
+          {
+            "phase": "parse.user.headers",
+            "atMs": 340.58597800000007,
+            "event": "end",
+            "ms": 1.1335579999999936
+          },
+          {
+            "phase": "parse.user.body",
+            "atMs": 340.6089710000001,
+            "event": "start"
+          },
+          {
+            "phase": "parse.user.body",
+            "atMs": 340.801189,
+            "event": "end",
+            "ms": 0.19204500000000735
+          },
+          {
+            "phase": "gateway.currentUser",
+            "atMs": 340.83183600000007,
+            "event": "end",
+            "ms": 1.5308330000000296
+          },
+          {
+            "phase": "business.profile",
+            "atMs": 340.96274399999993,
+            "event": "start"
+          },
+          {
+            "phase": "business.authorize",
+            "atMs": 341.3045260000001,
+            "event": "start"
+          },
+          {
+            "phase": "business.authorize",
+            "atMs": 341.469744,
+            "event": "end",
+            "ms": 0.1654040000000805
+          },
+          {
+            "phase": "business.profile",
+            "atMs": 342.31640300000004,
+            "event": "end",
+            "ms": 1.3532279999999446
+          },
+          {
+            "phase": "response.attempt",
+            "atMs": 342.36903099999995,
+            "success": true
+          },
+          {
+            "phase": "worker.execute",
+            "atMs": 342.5322490000001,
+            "event": "end",
+            "ms": 319.41184899999996
+          },
+          {
+            "phase": "worker.total",
+            "atMs": 342.53434100000004,
+            "event": "end",
+            "ms": 334.67608500000006
+          }
+        ]
+      },
+      {
+        "name": "account-body",
+        "requestId": "9e3e6e92-11a0-4770-8854-52a603a7b140",
+        "deadlineReached": true,
+        "dbCalls": 0,
+        "externalAttempts": 0,
+        "readOnlyGuards": true,
+        "events": [
+          {
+            "phase": "worker.total",
+            "atMs": 4.1743629999998575,
+            "event": "start"
+          },
+          {
+            "phase": "worker.session",
+            "atMs": 4.179766999999856,
+            "event": "start"
+          },
+          {
+            "phase": "parse.session.headers",
+            "atMs": 4.322922999999946,
+            "event": "start"
+          },
+          {
+            "phase": "parse.session.headers",
+            "atMs": 6.341533000000027,
+            "event": "end",
+            "ms": 2.0183640000000196
+          },
+          {
+            "phase": "parse.session.body",
+            "atMs": 6.352175999999872,
+            "event": "start"
+          },
+          {
+            "phase": "parse.session.body",
+            "atMs": 6.540147999999817,
+            "event": "end",
+            "ms": 0.18768199999999524
+          },
+          {
+            "phase": "worker.session",
+            "atMs": 6.666733999999906,
+            "event": "end",
+            "ms": 2.4866890000000694
+          },
+          {
+            "phase": "worker.function",
+            "atMs": 6.671147000000019,
+            "event": "start"
+          },
+          {
+            "phase": "parse.function.headers",
+            "atMs": 6.761842999999999,
+            "event": "start"
+          },
+          {
+            "phase": "parse.function.headers",
+            "atMs": 12.046270999999933,
+            "event": "end",
+            "ms": 5.28363800000011
+          },
+          {
+            "phase": "parse.function.body",
+            "atMs": 12.055157000000008,
+            "event": "start"
+          },
+          {
+            "phase": "parse.function.body",
+            "atMs": 14.217993999999862,
+            "event": "end",
+            "ms": 2.1621130000000903
+          },
+          {
+            "phase": "worker.function",
+            "atMs": 15.538393000000042,
+            "event": "end",
+            "ms": 8.866731999999956
+          },
+          {
+            "phase": "worker.execute",
+            "atMs": 15.543061999999964,
+            "event": "start"
+          },
+          {
+            "phase": "worker.companyAccount",
+            "atMs": 15.546188999999913,
+            "event": "start"
+          },
+          {
+            "phase": "parse.company.headers",
+            "atMs": 15.60371699999996,
+            "event": "start"
+          },
+          {
+            "phase": "parse.company.headers",
+            "atMs": 16.91184599999997,
+            "event": "end",
+            "ms": 1.3075189999999566
+          },
+          {
+            "phase": "parse.company.body",
+            "atMs": 16.918772999999874,
+            "event": "start"
+          },
+          {
+            "phase": "parse.company.body",
+            "atMs": 17.102916999999934,
+            "event": "end",
+            "ms": 0.18379800000002433
+          },
+          {
+            "phase": "parse.account.headers",
+            "atMs": 17.399584999999888,
+            "event": "start"
+          },
+          {
+            "phase": "fault.injected",
+            "atMs": 17.895761999999877,
+            "target": "account.body"
+          },
+          {
+            "phase": "parse.account.headers",
+            "atMs": 18.20389799999998,
+            "event": "end",
+            "ms": 0.8041399999999612
+          },
+          {
+            "phase": "parse.account.body",
+            "atMs": 18.210063999999875,
+            "event": "start"
+          },
+          {
+            "phase": "client.deadline",
+            "atMs": 152.30815599999983
+          },
+          {
+            "phase": "client.disconnected",
+            "atMs": 152.641926
+          },
+          {
+            "phase": "parse.account.body",
+            "atMs": 319.4199879999999,
+            "event": "end",
+            "ms": 301.2089470000001
+          },
+          {
+            "phase": "worker.companyAccount",
+            "atMs": 319.450053,
+            "event": "end",
+            "ms": 303.90356700000007
+          },
+          {
+            "phase": "gateway.currentUser",
+            "atMs": 320.427784,
+            "event": "start"
+          },
+          {
+            "phase": "parse.user.headers",
+            "atMs": 320.47650799999997,
+            "event": "start"
+          },
+          {
+            "phase": "parse.user.headers",
+            "atMs": 321.60430699999984,
+            "event": "end",
+            "ms": 1.1274600000001556
+          },
+          {
+            "phase": "parse.user.body",
+            "atMs": 321.61114799999996,
+            "event": "start"
+          },
+          {
+            "phase": "parse.user.body",
+            "atMs": 321.8681569999999,
+            "event": "end",
+            "ms": 0.2563999999999851
+          },
+          {
+            "phase": "gateway.currentUser",
+            "atMs": 321.90005399999995,
+            "event": "end",
+            "ms": 1.4729119999999511
+          },
+          {
+            "phase": "business.profile",
+            "atMs": 321.94516999999996,
+            "event": "start"
+          },
+          {
+            "phase": "business.authorize",
+            "atMs": 321.948979,
+            "event": "start"
+          },
+          {
+            "phase": "business.authorize",
+            "atMs": 321.9682819999998,
+            "event": "end",
+            "ms": 0.01911500000005617
+          },
+          {
+            "phase": "business.profile",
+            "atMs": 322.45480099999986,
+            "event": "end",
+            "ms": 0.5093460000000505
+          },
+          {
+            "phase": "response.attempt",
+            "atMs": 322.46524699999986,
+            "success": true
+          },
+          {
+            "phase": "worker.execute",
+            "atMs": 322.55017599999996,
+            "event": "end",
+            "ms": 307.00685
+          },
+          {
+            "phase": "worker.total",
+            "atMs": 322.55238799999984,
+            "event": "end",
+            "ms": 318.37924499999986
+          }
+        ]
+      },
+      {
+        "name": "user-refresh",
+        "requestId": "dbed716c-aa96-4c14-9c03-977d0f779f40",
+        "deadlineReached": true,
+        "dbCalls": 0,
+        "externalAttempts": 0,
+        "readOnlyGuards": true,
+        "events": [
+          {
+            "phase": "worker.total",
+            "atMs": 3.9770719999999073,
+            "event": "start"
+          },
+          {
+            "phase": "worker.session",
+            "atMs": 3.9832329999999274,
+            "event": "start"
+          },
+          {
+            "phase": "parse.session.headers",
+            "atMs": 4.138617999999951,
+            "event": "start"
+          },
+          {
+            "phase": "parse.session.headers",
+            "atMs": 5.589034999999967,
+            "event": "end",
+            "ms": 1.4498430000001008
+          },
+          {
+            "phase": "parse.session.body",
+            "atMs": 5.596783999999843,
+            "event": "start"
+          },
+          {
+            "phase": "parse.session.body",
+            "atMs": 5.783314999999902,
+            "event": "end",
+            "ms": 0.18645400000013979
+          },
+          {
+            "phase": "worker.session",
+            "atMs": 5.923716999999897,
+            "event": "end",
+            "ms": 1.940161999999873
+          },
+          {
+            "phase": "worker.function",
+            "atMs": 5.9275780000000395,
+            "event": "start"
+          },
+          {
+            "phase": "parse.function.headers",
+            "atMs": 6.02173700000003,
+            "event": "start"
+          },
+          {
+            "phase": "parse.function.headers",
+            "atMs": 11.406099999999924,
+            "event": "end",
+            "ms": 5.383557999999994
+          },
+          {
+            "phase": "parse.function.body",
+            "atMs": 11.414833999999928,
+            "event": "start"
+          },
+          {
+            "phase": "parse.function.body",
+            "atMs": 13.684087999999974,
+            "event": "end",
+            "ms": 2.268579000000045
+          },
+          {
+            "phase": "worker.function",
+            "atMs": 17.149071999999933,
+            "event": "end",
+            "ms": 11.220911999999998
+          },
+          {
+            "phase": "worker.execute",
+            "atMs": 17.153360999999904,
+            "event": "start"
+          },
+          {
+            "phase": "worker.companyAccount",
+            "atMs": 17.15632000000005,
+            "event": "start"
+          },
+          {
+            "phase": "parse.company.headers",
+            "atMs": 17.213219999999865,
+            "event": "start"
+          },
+          {
+            "phase": "parse.company.headers",
+            "atMs": 18.281195000000025,
+            "event": "end",
+            "ms": 1.067553000000089
+          },
+          {
+            "phase": "parse.company.body",
+            "atMs": 18.287806000000046,
+            "event": "start"
+          },
+          {
+            "phase": "parse.company.body",
+            "atMs": 18.460233000000017,
+            "event": "end",
+            "ms": 0.1721469999999954
+          },
+          {
+            "phase": "parse.account.headers",
+            "atMs": 18.57423700000004,
+            "event": "start"
+          },
+          {
+            "phase": "parse.account.headers",
+            "atMs": 19.56255999999985,
+            "event": "end",
+            "ms": 0.987999999999829
+          },
+          {
+            "phase": "parse.account.body",
+            "atMs": 19.568805999999995,
+            "event": "start"
+          },
+          {
+            "phase": "parse.account.body",
+            "atMs": 19.738934999999856,
+            "event": "end",
+            "ms": 0.17000599999983024
+          },
+          {
+            "phase": "worker.companyAccount",
+            "atMs": 19.760291000000052,
+            "event": "end",
+            "ms": 2.6038720000001376
+          },
+          {
+            "phase": "gateway.currentUser",
+            "atMs": 20.74059999999986,
+            "event": "start"
+          },
+          {
+            "phase": "parse.user.headers",
+            "atMs": 20.787495000000035,
+            "event": "start"
+          },
+          {
+            "phase": "fault.injected",
+            "atMs": 21.320514000000003,
+            "target": "user.headers"
+          },
+          {
+            "phase": "client.deadline",
+            "atMs": 151.47472199999993
+          },
+          {
+            "phase": "client.disconnected",
+            "atMs": 151.79144399999996
+          },
+          {
+            "phase": "parse.user.headers",
+            "atMs": 322.95603099999994,
+            "event": "end",
+            "ms": 302.16724899999986
+          },
+          {
+            "phase": "parse.user.body",
+            "atMs": 322.967805,
+            "event": "start"
+          },
+          {
+            "phase": "parse.user.body",
+            "atMs": 323.22448399999985,
+            "event": "end",
+            "ms": 0.2563719999998284
+          },
+          {
+            "phase": "gateway.currentUser",
+            "atMs": 323.2692629999999,
+            "event": "end",
+            "ms": 302.5293079999999
+          },
+          {
+            "phase": "business.profile",
+            "atMs": 323.32602599999996,
+            "event": "start"
+          },
+          {
+            "phase": "business.authorize",
+            "atMs": 323.332097,
+            "event": "start"
+          },
+          {
+            "phase": "business.authorize",
+            "atMs": 323.357577,
+            "event": "end",
+            "ms": 0.02543100000002596
+          },
+          {
+            "phase": "business.profile",
+            "atMs": 323.95318199999997,
+            "event": "end",
+            "ms": 0.6268720000000485
+          },
+          {
+            "phase": "response.attempt",
+            "atMs": 323.96642399999996,
+            "success": true
+          },
+          {
+            "phase": "worker.execute",
+            "atMs": 324.06020999999987,
+            "event": "end",
+            "ms": 306.90663300000006
+          },
+          {
+            "phase": "worker.total",
+            "atMs": 324.06221400000004,
+            "event": "end",
+            "ms": 320.08616699999993
+          }
+        ]
+      },
+      {
+        "name": "timetable-connection-acquire",
+        "requestId": "753eac2c-b62c-4102-b4a9-d20dcfe44809",
+        "deadlineReached": true,
+        "dbCalls": 5,
+        "externalAttempts": 0,
+        "readOnlyGuards": true,
+        "events": [
+          {
+            "phase": "worker.total",
+            "atMs": 4.337631999999985,
+            "event": "start"
+          },
+          {
+            "phase": "worker.session",
+            "atMs": 4.3452870000000985,
+            "event": "start"
+          },
+          {
+            "phase": "parse.session.headers",
+            "atMs": 4.49979600000006,
+            "event": "start"
+          },
+          {
+            "phase": "parse.session.headers",
+            "atMs": 6.145289000000048,
+            "event": "end",
+            "ms": 1.6446719999999004
+          },
+          {
+            "phase": "parse.session.body",
+            "atMs": 6.154058999999961,
+            "event": "start"
+          },
+          {
+            "phase": "parse.session.body",
+            "atMs": 6.338301999999885,
+            "event": "end",
+            "ms": 0.1840679999997974
+          },
+          {
+            "phase": "worker.session",
+            "atMs": 6.493739999999889,
+            "event": "end",
+            "ms": 2.1481229999999414
+          },
+          {
+            "phase": "worker.function",
+            "atMs": 6.497548000000052,
+            "event": "start"
+          },
+          {
+            "phase": "parse.function.headers",
+            "atMs": 6.610112999999956,
+            "event": "start"
+          },
+          {
+            "phase": "parse.function.headers",
+            "atMs": 11.286923999999999,
+            "event": "end",
+            "ms": 4.676091000000042
+          },
+          {
+            "phase": "parse.function.body",
+            "atMs": 11.295542000000069,
+            "event": "start"
+          },
+          {
+            "phase": "parse.function.body",
+            "atMs": 13.825984999999946,
+            "event": "end",
+            "ms": 2.528575000000046
+          },
+          {
+            "phase": "worker.function",
+            "atMs": 15.152732999999898,
+            "event": "end",
+            "ms": 8.654121000000032
+          },
+          {
+            "phase": "worker.execute",
+            "atMs": 15.15880900000002,
+            "event": "start"
+          },
+          {
+            "phase": "worker.companyAccount",
+            "atMs": 15.162626000000046,
+            "event": "start"
+          },
+          {
+            "phase": "parse.company.headers",
+            "atMs": 15.229698000000099,
+            "event": "start"
+          },
+          {
+            "phase": "parse.company.headers",
+            "atMs": 16.30590099999995,
+            "event": "end",
+            "ms": 1.0757000000000971
+          },
+          {
+            "phase": "parse.company.body",
+            "atMs": 16.312732000000096,
+            "event": "start"
+          },
+          {
+            "phase": "parse.company.body",
+            "atMs": 16.48700299999996,
+            "event": "end",
+            "ms": 0.1739869999998973
+          },
+          {
+            "phase": "parse.account.headers",
+            "atMs": 16.600048000000015,
+            "event": "start"
+          },
+          {
+            "phase": "parse.account.headers",
+            "atMs": 17.450088999999934,
+            "event": "end",
+            "ms": 0.8499159999998938
+          },
+          {
+            "phase": "parse.account.body",
+            "atMs": 17.456566000000066,
+            "event": "start"
+          },
+          {
+            "phase": "parse.account.body",
+            "atMs": 17.623192000000017,
+            "event": "end",
+            "ms": 0.1664859999998498
+          },
+          {
+            "phase": "worker.companyAccount",
+            "atMs": 17.645557000000053,
+            "event": "end",
+            "ms": 2.4827990000001137
+          },
+          {
+            "phase": "gateway.currentUser",
+            "atMs": 18.69371000000001,
+            "event": "start"
+          },
+          {
+            "phase": "parse.user.headers",
+            "atMs": 18.745120999999926,
+            "event": "start"
+          },
+          {
+            "phase": "parse.user.headers",
+            "atMs": 19.7796679999999,
+            "event": "end",
+            "ms": 1.0343210000000909
+          },
+          {
+            "phase": "parse.user.body",
+            "atMs": 19.785618999999997,
+            "event": "start"
+          },
+          {
+            "phase": "parse.user.body",
+            "atMs": 19.975611999999956,
+            "event": "end",
+            "ms": 0.18975799999998344
+          },
+          {
+            "phase": "gateway.currentUser",
+            "atMs": 20.005280999999968,
+            "event": "end",
+            "ms": 1.3125229999998282
+          },
+          {
+            "phase": "business.timetable",
+            "atMs": 20.126338000000032,
+            "event": "start"
+          },
+          {
+            "phase": "business.authorize",
+            "atMs": 20.504194000000098,
+            "event": "start"
+          },
+          {
+            "phase": "business.authorize",
+            "atMs": 20.532314999999926,
+            "event": "end",
+            "ms": 0.028489000000035958
+          },
+          {
+            "phase": "db.acquire",
+            "atMs": 21.351605000000063,
+            "event": "start",
+            "query": 1,
+            "sqlFingerprint": "2f2c365ef978b4ac",
+            "pool": "simulated-business-pool"
+          },
+          {
+            "phase": "fault.injected",
+            "atMs": 21.40311900000006,
+            "target": "db.acquire"
+          },
+          {
+            "phase": "db.acquire",
+            "atMs": 21.6860200000001,
+            "event": "start",
+            "query": 2,
+            "sqlFingerprint": "56b2c3c37a41a3aa",
+            "pool": "simulated-business-pool"
+          },
+          {
+            "phase": "db.acquire",
+            "atMs": 21.790482999999995,
+            "event": "start",
+            "query": 3,
+            "sqlFingerprint": "0fc420a265cdc1c7",
+            "pool": "simulated-business-pool"
+          },
+          {
+            "phase": "db.acquire",
+            "atMs": 21.829091999999946,
+            "event": "end",
+            "ms": 0.13837000000012267,
+            "query": 2,
+            "sqlFingerprint": "56b2c3c37a41a3aa",
+            "pool": "simulated-business-pool"
+          },
+          {
+            "phase": "db.execute",
+            "atMs": 21.837492999999995,
+            "event": "start",
+            "query": 2,
+            "sqlFingerprint": "56b2c3c37a41a3aa",
+            "pool": "simulated-business-pool"
+          },
+          {
+            "phase": "db.execute",
+            "atMs": 21.884082000000035,
+            "event": "end",
+            "ms": 0.04618099999993319,
+            "query": 2,
+            "sqlFingerprint": "56b2c3c37a41a3aa",
+            "pool": "simulated-business-pool"
+          },
+          {
+            "phase": "db.acquire",
+            "atMs": 21.960626000000047,
+            "event": "start",
+            "query": 4,
+            "sqlFingerprint": "d54dea99e21f1d19",
+            "pool": "simulated-business-pool"
+          },
+          {
+            "phase": "db.acquire",
+            "atMs": 21.969835999999987,
+            "event": "end",
+            "ms": 0.17981399999985115,
+            "query": 3,
+            "sqlFingerprint": "0fc420a265cdc1c7",
+            "pool": "simulated-business-pool"
+          },
+          {
+            "phase": "db.execute",
+            "atMs": 21.973915000000034,
+            "event": "start",
+            "query": 3,
+            "sqlFingerprint": "0fc420a265cdc1c7",
+            "pool": "simulated-business-pool"
+          },
+          {
+            "phase": "db.execute",
+            "atMs": 21.976499999999987,
+            "event": "end",
+            "ms": 0.0026920000000245636,
+            "query": 3,
+            "sqlFingerprint": "0fc420a265cdc1c7",
+            "pool": "simulated-business-pool"
+          },
+          {
+            "phase": "db.acquire",
+            "atMs": 22.014435999999932,
+            "event": "end",
+            "ms": 0.05393200000003162,
+            "query": 4,
+            "sqlFingerprint": "d54dea99e21f1d19",
+            "pool": "simulated-business-pool"
+          },
+          {
+            "phase": "db.execute",
+            "atMs": 22.020627000000104,
+            "event": "start",
+            "query": 4,
+            "sqlFingerprint": "d54dea99e21f1d19",
+            "pool": "simulated-business-pool"
+          },
+          {
+            "phase": "db.execute",
+            "atMs": 22.023359000000028,
+            "event": "end",
+            "ms": 0.0028600000000551518,
+            "query": 4,
+            "sqlFingerprint": "d54dea99e21f1d19",
+            "pool": "simulated-business-pool"
+          },
+          {
+            "phase": "client.deadline",
+            "atMs": 153.83840699999996
+          },
+          {
+            "phase": "client.disconnected",
+            "atMs": 154.13245800000004
+          },
+          {
+            "phase": "db.acquire",
+            "atMs": 321.9954759999998,
+            "event": "end",
+            "ms": 300.643953,
+            "query": 1,
+            "sqlFingerprint": "2f2c365ef978b4ac",
+            "pool": "simulated-business-pool"
+          },
+          {
+            "phase": "db.execute",
+            "atMs": 322.01516000000015,
+            "event": "start",
+            "query": 1,
+            "sqlFingerprint": "2f2c365ef978b4ac",
+            "pool": "simulated-business-pool"
+          },
+          {
+            "phase": "db.execute",
+            "atMs": 322.0404310000001,
+            "event": "end",
+            "ms": 0.025086000000101194,
+            "query": 1,
+            "sqlFingerprint": "2f2c365ef978b4ac",
+            "pool": "simulated-business-pool"
+          },
+          {
+            "phase": "business.appointmentVersions",
+            "atMs": 322.1619509999998,
+            "event": "start"
+          },
+          {
+            "phase": "db.acquire",
+            "atMs": 322.54797299999996,
+            "event": "start",
+            "query": 5,
+            "sqlFingerprint": "357c2c176560c3e2",
+            "pool": "simulated-business-pool"
+          },
+          {
+            "phase": "db.acquire",
+            "atMs": 322.55676500000004,
+            "event": "end",
+            "ms": 0.009571000000050844,
+            "query": 5,
+            "sqlFingerprint": "357c2c176560c3e2",
+            "pool": "simulated-business-pool"
+          },
+          {
+            "phase": "db.execute",
+            "atMs": 322.56625199999985,
+            "event": "start",
+            "query": 5,
+            "sqlFingerprint": "357c2c176560c3e2",
+            "pool": "simulated-business-pool"
+          },
+          {
+            "phase": "db.execute",
+            "atMs": 322.5721329999999,
+            "event": "end",
+            "ms": 0.010560999999597698,
+            "query": 5,
+            "sqlFingerprint": "357c2c176560c3e2",
+            "pool": "simulated-business-pool"
+          },
+          {
+            "phase": "business.appointmentVersions",
+            "atMs": 322.6209299999998,
+            "event": "end",
+            "ms": 0.45917699999972683
+          },
+          {
+            "phase": "business.timetable",
+            "atMs": 322.7663759999998,
+            "event": "end",
+            "ms": 302.63998300000003
+          },
+          {
+            "phase": "response.attempt",
+            "atMs": 322.78113800000006,
+            "success": true
+          },
+          {
+            "phase": "worker.execute",
+            "atMs": 322.89386899999977,
+            "event": "end",
+            "ms": 307.7348220000001
+          },
+          {
+            "phase": "worker.total",
+            "atMs": 322.896559,
+            "event": "end",
+            "ms": 318.56025999999997
+          }
+        ]
+      },
+      {
+        "name": "timetable-query-execute",
+        "requestId": "cad65b64-baa1-40d2-9288-34227b454e64",
+        "deadlineReached": true,
+        "dbCalls": 5,
+        "externalAttempts": 0,
+        "readOnlyGuards": true,
+        "events": [
+          {
+            "phase": "worker.total",
+            "atMs": 4.054090999999971,
+            "event": "start"
+          },
+          {
+            "phase": "worker.session",
+            "atMs": 4.065747999999985,
+            "event": "start"
+          },
+          {
+            "phase": "parse.session.headers",
+            "atMs": 4.232794000000013,
+            "event": "start"
+          },
+          {
+            "phase": "parse.session.headers",
+            "atMs": 5.89841000000024,
+            "event": "end",
+            "ms": 1.6649819999997817
+          },
+          {
+            "phase": "parse.session.body",
+            "atMs": 5.910498000000189,
+            "event": "start"
+          },
+          {
+            "phase": "parse.session.body",
+            "atMs": 6.115701000000172,
+            "event": "end",
+            "ms": 0.20507399999996778
+          },
+          {
+            "phase": "worker.session",
+            "atMs": 6.263707000000068,
+            "event": "end",
+            "ms": 2.1977099999999155
+          },
+          {
+            "phase": "worker.function",
+            "atMs": 6.267692000000352,
+            "event": "start"
+          },
+          {
+            "phase": "parse.function.headers",
+            "atMs": 6.372477000000345,
+            "event": "start"
+          },
+          {
+            "phase": "parse.function.headers",
+            "atMs": 12.377380000000358,
+            "event": "end",
+            "ms": 6.004136999999901
+          },
+          {
+            "phase": "parse.function.body",
+            "atMs": 12.386940000000322,
+            "event": "start"
+          },
+          {
+            "phase": "parse.function.body",
+            "atMs": 14.491833000000042,
+            "event": "end",
+            "ms": 2.10430499999984
+          },
+          {
+            "phase": "worker.function",
+            "atMs": 15.755857000000105,
+            "event": "end",
+            "ms": 9.487625000000207
+          },
+          {
+            "phase": "worker.execute",
+            "atMs": 15.760685000000194,
+            "event": "start"
+          },
+          {
+            "phase": "worker.companyAccount",
+            "atMs": 15.767375000000357,
+            "event": "start"
+          },
+          {
+            "phase": "parse.company.headers",
+            "atMs": 15.828852000000097,
+            "event": "start"
+          },
+          {
+            "phase": "parse.company.headers",
+            "atMs": 16.762407000000167,
+            "event": "end",
+            "ms": 0.9331769999998869
+          },
+          {
+            "phase": "parse.company.body",
+            "atMs": 16.76797600000009,
+            "event": "start"
+          },
+          {
+            "phase": "parse.company.body",
+            "atMs": 16.945210000000316,
+            "event": "end",
+            "ms": 0.1768609999999171
+          },
+          {
+            "phase": "parse.account.headers",
+            "atMs": 17.060891000000083,
+            "event": "start"
+          },
+          {
+            "phase": "parse.account.headers",
+            "atMs": 17.94168000000036,
+            "event": "end",
+            "ms": 0.8804269999995995
+          },
+          {
+            "phase": "parse.account.body",
+            "atMs": 17.94789000000037,
+            "event": "start"
+          },
+          {
+            "phase": "parse.account.body",
+            "atMs": 18.130407000000105,
+            "event": "end",
+            "ms": 0.17765099999996892
+          },
+          {
+            "phase": "worker.companyAccount",
+            "atMs": 18.15286600000036,
+            "event": "end",
+            "ms": 2.3853970000000118
+          },
+          {
+            "phase": "gateway.currentUser",
+            "atMs": 19.155881000000136,
+            "event": "start"
+          },
+          {
+            "phase": "parse.user.headers",
+            "atMs": 19.206481000000167,
+            "event": "start"
+          },
+          {
+            "phase": "parse.user.headers",
+            "atMs": 20.382240000000365,
+            "event": "end",
+            "ms": 1.1753570000000764
+          },
+          {
+            "phase": "parse.user.body",
+            "atMs": 20.394142999999985,
+            "event": "start"
+          },
+          {
+            "phase": "parse.user.body",
+            "atMs": 20.602256000000125,
+            "event": "end",
+            "ms": 0.20779700000002777
+          },
+          {
+            "phase": "gateway.currentUser",
+            "atMs": 20.645240000000285,
+            "event": "end",
+            "ms": 1.489947999999913
+          },
+          {
+            "phase": "business.timetable",
+            "atMs": 20.730383999999958,
+            "event": "start"
+          },
+          {
+            "phase": "business.authorize",
+            "atMs": 20.744832000000315,
+            "event": "start"
+          },
+          {
+            "phase": "business.authorize",
+            "atMs": 20.771688000000267,
+            "event": "end",
+            "ms": 0.026728999999704683
+          },
+          {
+            "phase": "db.acquire",
+            "atMs": 20.88367700000026,
+            "event": "start",
+            "query": 1,
+            "sqlFingerprint": "2f2c365ef978b4ac",
+            "pool": "simulated-business-pool"
+          },
+          {
+            "phase": "db.acquire",
+            "atMs": 20.91621000000032,
+            "event": "start",
+            "query": 2,
+            "sqlFingerprint": "56b2c3c37a41a3aa",
+            "pool": "simulated-business-pool"
+          },
+          {
+            "phase": "db.acquire",
+            "atMs": 20.95783700000038,
+            "event": "start",
+            "query": 3,
+            "sqlFingerprint": "0fc420a265cdc1c7",
+            "pool": "simulated-business-pool"
+          },
+          {
+            "phase": "db.acquire",
+            "atMs": 20.969166000000314,
+            "event": "end",
+            "ms": 0.0858290000001034,
+            "query": 1,
+            "sqlFingerprint": "2f2c365ef978b4ac",
+            "pool": "simulated-business-pool"
+          },
+          {
+            "phase": "db.acquire",
+            "atMs": 20.970698999999968,
+            "event": "end",
+            "ms": 0.05480400000033114,
+            "query": 2,
+            "sqlFingerprint": "56b2c3c37a41a3aa",
+            "pool": "simulated-business-pool"
+          },
+          {
+            "phase": "db.execute",
+            "atMs": 20.974245000000337,
+            "event": "start",
+            "query": 1,
+            "sqlFingerprint": "2f2c365ef978b4ac",
+            "pool": "simulated-business-pool"
+          },
+          {
+            "phase": "fault.injected",
+            "atMs": 20.97580700000026,
+            "target": "db.execute"
+          },
+          {
+            "phase": "db.execute",
+            "atMs": 21.018809000000147,
+            "event": "start",
+            "query": 2,
+            "sqlFingerprint": "56b2c3c37a41a3aa",
+            "pool": "simulated-business-pool"
+          },
+          {
+            "phase": "db.execute",
+            "atMs": 21.022739,
+            "event": "end",
+            "ms": 0.004073000000062166,
+            "query": 2,
+            "sqlFingerprint": "56b2c3c37a41a3aa",
+            "pool": "simulated-business-pool"
+          },
+          {
+            "phase": "db.acquire",
+            "atMs": 21.05734500000017,
+            "event": "start",
+            "query": 4,
+            "sqlFingerprint": "d54dea99e21f1d19",
+            "pool": "simulated-business-pool"
+          },
+          {
+            "phase": "db.acquire",
+            "atMs": 21.060955000000376,
+            "event": "end",
+            "ms": 0.10352599999987433,
+            "query": 3,
+            "sqlFingerprint": "0fc420a265cdc1c7",
+            "pool": "simulated-business-pool"
+          },
+          {
+            "phase": "db.execute",
+            "atMs": 21.06842000000006,
+            "event": "start",
+            "query": 3,
+            "sqlFingerprint": "0fc420a265cdc1c7",
+            "pool": "simulated-business-pool"
+          },
+          {
+            "phase": "db.execute",
+            "atMs": 21.070490000000063,
+            "event": "end",
+            "ms": 0.0022120000003269524,
+            "query": 3,
+            "sqlFingerprint": "0fc420a265cdc1c7",
+            "pool": "simulated-business-pool"
+          },
+          {
+            "phase": "db.acquire",
+            "atMs": 21.075372000000243,
+            "event": "end",
+            "ms": 0.018378999999640655,
+            "query": 4,
+            "sqlFingerprint": "d54dea99e21f1d19",
+            "pool": "simulated-business-pool"
+          },
+          {
+            "phase": "db.execute",
+            "atMs": 21.08195999999998,
+            "event": "start",
+            "query": 4,
+            "sqlFingerprint": "d54dea99e21f1d19",
+            "pool": "simulated-business-pool"
+          },
+          {
+            "phase": "db.execute",
+            "atMs": 21.084501000000273,
+            "event": "end",
+            "ms": 0.002739000000019587,
+            "query": 4,
+            "sqlFingerprint": "d54dea99e21f1d19",
+            "pool": "simulated-business-pool"
+          },
+          {
+            "phase": "client.deadline",
+            "atMs": 153.50598000000036
+          },
+          {
+            "phase": "client.disconnected",
+            "atMs": 154.1209130000002
+          },
+          {
+            "phase": "db.execute",
+            "atMs": 321.07715700000017,
+            "event": "end",
+            "ms": 300.0994390000001,
+            "query": 1,
+            "sqlFingerprint": "2f2c365ef978b4ac",
+            "pool": "simulated-business-pool"
+          },
+          {
+            "phase": "business.appointmentVersions",
+            "atMs": 321.1277320000004,
+            "event": "start"
+          },
+          {
+            "phase": "db.acquire",
+            "atMs": 321.2415130000004,
+            "event": "start",
+            "query": 5,
+            "sqlFingerprint": "357c2c176560c3e2",
+            "pool": "simulated-business-pool"
+          },
+          {
+            "phase": "db.acquire",
+            "atMs": 321.25885800000015,
+            "event": "end",
+            "ms": 0.017934999999852153,
+            "query": 5,
+            "sqlFingerprint": "357c2c176560c3e2",
+            "pool": "simulated-business-pool"
+          },
+          {
+            "phase": "db.execute",
+            "atMs": 321.2655050000003,
+            "event": "start",
+            "query": 5,
+            "sqlFingerprint": "357c2c176560c3e2",
+            "pool": "simulated-business-pool"
+          },
+          {
+            "phase": "db.execute",
+            "atMs": 321.2709500000001,
+            "event": "end",
+            "ms": 0.0056719999997767445,
+            "query": 5,
+            "sqlFingerprint": "357c2c176560c3e2",
+            "pool": "simulated-business-pool"
+          },
+          {
+            "phase": "business.appointmentVersions",
+            "atMs": 321.32442900000024,
+            "event": "end",
+            "ms": 0.19708799999989424
+          },
+          {
+            "phase": "business.timetable",
+            "atMs": 321.3791820000001,
+            "event": "end",
+            "ms": 300.649852
+          },
+          {
+            "phase": "response.attempt",
+            "atMs": 321.3916100000001,
+            "success": true
+          },
+          {
+            "phase": "worker.execute",
+            "atMs": 321.51357700000017,
+            "event": "end",
+            "ms": 305.752387
+          },
+          {
+            "phase": "worker.total",
+            "atMs": 321.51576300000033,
+            "event": "end",
+            "ms": 317.46316700000034
+          }
+        ]
+      },
+      {
+        "name": "session-real-20-second-deadline",
+        "requestId": "f5cb6735-ef07-4fad-ace9-2b68b487a3b7",
+        "deadlineReached": true,
+        "dbCalls": 0,
+        "externalAttempts": 0,
+        "readOnlyGuards": true,
+        "events": [
+          {
+            "phase": "worker.total",
+            "atMs": 3.839104000000134,
+            "event": "start"
+          },
+          {
+            "phase": "worker.session",
+            "atMs": 3.844778000000133,
+            "event": "start"
+          },
+          {
+            "phase": "parse.session.headers",
+            "atMs": 3.9857090000000426,
+            "event": "start"
+          },
+          {
+            "phase": "fault.injected",
+            "atMs": 4.8980790000000525,
+            "target": "session.headers"
+          },
+          {
+            "phase": "client.deadline",
+            "atMs": 20002.995986
+          },
+          {
+            "phase": "client.disconnected",
+            "atMs": 20003.270067
+          },
+          {
+            "phase": "parse.session.headers",
+            "atMs": 21007.177757,
+            "event": "end",
+            "ms": 21003.190775000003
+          },
+          {
+            "phase": "parse.session.body",
+            "atMs": 21007.188892000002,
+            "event": "start"
+          },
+          {
+            "phase": "parse.session.body",
+            "atMs": 21007.450733,
+            "event": "end",
+            "ms": 0.2614419999990787
+          },
+          {
+            "phase": "worker.session",
+            "atMs": 21007.664378,
+            "event": "end",
+            "ms": 21003.819098
+          },
+          {
+            "phase": "worker.function",
+            "atMs": 21007.66908,
+            "event": "start"
+          },
+          {
+            "phase": "parse.function.headers",
+            "atMs": 21007.837039000002,
+            "event": "start"
+          },
+          {
+            "phase": "parse.function.headers",
+            "atMs": 21013.121461000002,
+            "event": "end",
+            "ms": 5.284276000002137
+          },
+          {
+            "phase": "parse.function.body",
+            "atMs": 21013.128686,
+            "event": "start"
+          },
+          {
+            "phase": "parse.function.body",
+            "atMs": 21015.759637000003,
+            "event": "end",
+            "ms": 2.6298679999999877
+          },
+          {
+            "phase": "worker.function",
+            "atMs": 21017.133607,
+            "event": "end",
+            "ms": 9.463952999998583
+          },
+          {
+            "phase": "worker.execute",
+            "atMs": 21017.139558000003,
+            "event": "start"
+          },
+          {
+            "phase": "worker.companyAccount",
+            "atMs": 21017.143042,
+            "event": "start"
+          },
+          {
+            "phase": "parse.company.headers",
+            "atMs": 21017.219548,
+            "event": "start"
+          },
+          {
+            "phase": "parse.company.headers",
+            "atMs": 21018.350687000002,
+            "event": "end",
+            "ms": 1.1309259999979986
+          },
+          {
+            "phase": "parse.company.body",
+            "atMs": 21018.357662000002,
+            "event": "start"
+          },
+          {
+            "phase": "parse.company.body",
+            "atMs": 21018.558085,
+            "event": "end",
+            "ms": 0.2001189999973576
+          },
+          {
+            "phase": "parse.account.headers",
+            "atMs": 21018.684037000003,
+            "event": "start"
+          },
+          {
+            "phase": "parse.account.headers",
+            "atMs": 21019.685052,
+            "event": "end",
+            "ms": 1.0009009999994305
+          },
+          {
+            "phase": "parse.account.body",
+            "atMs": 21019.696397,
+            "event": "start"
+          },
+          {
+            "phase": "parse.account.body",
+            "atMs": 21019.89985,
+            "event": "end",
+            "ms": 0.20792700000311015
+          },
+          {
+            "phase": "worker.companyAccount",
+            "atMs": 21019.92231,
+            "event": "end",
+            "ms": 2.779061000001093
+          },
+          {
+            "phase": "gateway.currentUser",
+            "atMs": 21021.09491,
+            "event": "start"
+          },
+          {
+            "phase": "parse.user.headers",
+            "atMs": 21021.150088000002,
+            "event": "start"
+          },
+          {
+            "phase": "parse.user.headers",
+            "atMs": 21022.250624,
+            "event": "end",
+            "ms": 1.1000910000002477
+          },
+          {
+            "phase": "parse.user.body",
+            "atMs": 21022.257546,
+            "event": "start"
+          },
+          {
+            "phase": "parse.user.body",
+            "atMs": 21022.464937,
+            "event": "end",
+            "ms": 0.20712300000013784
+          },
+          {
+            "phase": "gateway.currentUser",
+            "atMs": 21022.496352000002,
+            "event": "end",
+            "ms": 1.4023039999992761
+          },
+          {
+            "phase": "business.profile",
+            "atMs": 21022.536707,
+            "event": "start"
+          },
+          {
+            "phase": "business.authorize",
+            "atMs": 21022.540685,
+            "event": "start"
+          },
+          {
+            "phase": "business.authorize",
+            "atMs": 21022.564047,
+            "event": "end",
+            "ms": 0.023218000002088957
+          },
+          {
+            "phase": "business.profile",
+            "atMs": 21023.028587,
+            "event": "end",
+            "ms": 0.4914299999982177
+          },
+          {
+            "phase": "response.attempt",
+            "atMs": 21023.039821000002,
+            "success": true
+          },
+          {
+            "phase": "worker.execute",
+            "atMs": 21023.120934000002,
+            "event": "end",
+            "ms": 5.981363999999303
+          },
+          {
+            "phase": "worker.total",
+            "atMs": 21023.123088,
+            "event": "end",
+            "ms": 21019.289082
+          }
+        ]
+      }
+    ]
+  },
+  "limitations": [
+    "Trace uses the saved November 14 worker, not live runtime verification.",
+    "Parse data, SDK conversion and SQL pool are simulated; native worker routing and gateway business functions execute from the saved source.",
+    "Controlled faults prove stage attribution and cancellation behavior locally; they cannot identify which stage caused the historical production timeouts.",
+    "No production financial audit performed in this task."
+  ]
+}

+ 5 - 5
perf/recovery-performance-2026-11-14/home-readiness-audit.mjs

@@ -5,20 +5,20 @@ import {pageReadyGate} from './readiness.mjs';
 import fs from 'node:fs';import path from 'node:path';import http from 'node:http';import assert from 'node:assert/strict';import {createRequire} from 'node:module';
 const [build,out]=process.argv.slice(2);if(!build||!out||!path.isAbsolute(build)||!path.isAbsolute(out))throw Error('Pass absolute build and evidence directories');
 const {chromium}=createRequire(path.resolve('package.json'))('playwright');fs.mkdirSync(out,{recursive:true,mode:0o700});
-const today=new Intl.DateTimeFormat('en-CA',{timeZone:'Asia/Shanghai'}).format(new Date()),results=[],external=[];let scenario='healthy';
+const today=new Intl.DateTimeFormat('en-CA',{timeZone:'Asia/Shanghai'}).format(new Date()),results=[],external=[];let scenario='healthy',actions=[];
 const server=http.createServer(async(req,res)=>{try{const url=new URL(req.url,'http://localhost');if(url.pathname==='/cloud-functions/xiaoshu/app/gateway'){
- let body='';for await(const chunk of req)body+=chunk;const {action}=JSON.parse(body).params;let result;
+ let body='';for await(const chunk of req)body+=chunk;const {action}=JSON.parse(body).params;actions.push(action);let result;
  if((scenario==='balance-error'&&action==='app_account_overview')||(scenario==='stats-error'&&action==='e_order_tongji')){res.writeHead(200,{'Content-Type':'application/json'});res.end(JSON.stringify({retcode:-1,retmsg:'isolated injected failure'}));return;}
  if(action==='app_account_overview')result={profile:{UserPoint:12.5,Purse:8,SilverCoin:7}};
  else if(action==='e_order_tongji')result={t_total:18,t_shichang:13};
- else if(action==='app_review_page')result={items:[],dates:[{date:today,total:2,completed:1}],page:{itemCount:0,pageCount:0},summary:{total:2,completed:1,due:1,today:2,income:0},serverToday:today,legacyReviewSync:{state:'snapshot'},syncStatus:{state:'unavailable'}};
+ else if(action==='app_review_page')result={view:'home',items:[],dates:[{date:today,total:2,completed:1}],page:{itemCount:0,pageCount:0},summary:{total:2,completed:1,due:1,today:2,income:0},serverToday:today,legacyReviewSync:{state:'snapshot'},syncStatus:{state:'unavailable'}};
  else throw Error('unexpected action '+action);
  res.writeHead(200,{'Content-Type':'application/json'});res.end(JSON.stringify({retcode:0,result}));return;}
  let file=path.resolve(build,'.'+url.pathname);if(!file.startsWith(build+path.sep)){res.writeHead(403);res.end();return;}if(!fs.existsSync(file)||fs.statSync(file).isDirectory())file=path.join(build,'index.html');const mime={'.html':'text/html','.js':'text/javascript','.css':'text/css','.svg':'image/svg+xml','.png':'image/png','.woff2':'font/woff2'}[path.extname(file)]||'application/octet-stream';res.writeHead(200,{'Content-Type':mime});res.end(fs.readFileSync(file));
  }catch(error){res.writeHead(500);res.end(String(error.message));}});
 await new Promise(resolve=>server.listen(0,'127.0.0.1',resolve));const origin='http://127.0.0.1:'+server.address().port,browser=await chromium.launch({headless:true,executablePath:process.env.CHROME_PATH||'/Applications/Google Chrome.app/Contents/MacOS/Google Chrome',args:['--no-proxy-server']});
-try{for(scenario of ['healthy','balance-error','stats-error']){const context=await browser.newContext({viewport:{width:390,height:844},timezoneId:'Asia/Shanghai'});await context.addInitScript(()=>localStorage.setItem('xiaoshu.angular.session',JSON.stringify({token:'r:local-audit-only',user:{userId:20,groupId:1,userName:'本地测试学生'},student:null})));await context.route('**/*',route=>{if(new URL(route.request().url()).origin===origin)return route.continue();external.push(route.request().url());return route.abort();});const page=await context.newPage();await page.goto(origin+'/pages/home/index',{waitUntil:'domcontentloaded'});await page.waitForSelector('.memory-content');await page.waitForFunction(()=>!!document.querySelector('.student-home-refresh:not(:disabled)'));
+try{for(scenario of ['healthy','balance-error','stats-error','warm-legacy-cache']){actions=[];const context=await browser.newContext({viewport:{width:390,height:844},timezoneId:'Asia/Shanghai'});await context.addInitScript(({scenario,today})=>{localStorage.setItem('xiaoshu.angular.session',JSON.stringify({token:'r:local-audit-only',user:{userId:20,groupId:1,userName:'本地测试学生'},student:null}));if(scenario==='warm-legacy-cache'){const updatedAt=today+'T03:00:00Z';localStorage.setItem('xiaoshu.learning.homeDaily.v1',JSON.stringify({identity:'20|1|'+today,values:{review:{updatedAt,value:{items:[],dates:[],summary:{total:99,completed:0,due:99,today:99,income:0}}},stats:{updatedAt,value:{t_total:18,t_shichang:13}},balances:{updatedAt,value:{UserPoint:12.5,Purse:8,SilverCoin:7}}}}));}},{scenario,today});await context.route('**/*',route=>{if(new URL(route.request().url()).origin===origin)return route.continue();external.push(route.request().url());return route.abort();});const page=await context.newPage();await page.goto(origin+'/pages/home/index',{waitUntil:'domcontentloaded'});try{await page.waitForSelector('.memory-content',{timeout:10000});}catch(error){await page.screenshot({path:path.join(out,'failed-'+scenario+'.png'),fullPage:true});console.log(JSON.stringify({scenario,actions,body:await page.locator('body').innerText()}));throw error;}await page.waitForFunction(()=>!!document.querySelector('.student-home-refresh:not(:disabled)'));
  const observed=await page.evaluate(()=>{const q=s=>document.querySelector(s),stats=q('.student-stats'),previous=stats?.previousElementSibling;return{oldGateAccepts:!!q('.memory-content')&&!!q('button[aria-label="刷新学习数据"]:not(:disabled)')&&!q('.hero-error'),error:previous?.matches('p[role="status"]')?previous.textContent.trim():'',values:Array.from(stats?.querySelectorAll('strong')||[]).map(x=>x.textContent.trim())};});
- const correctedGateAccepts=await page.evaluate(pageReadyGate,{role:'student',stableMs:0});assert.equal(correctedGateAccepts,scenario==='healthy');observed.correctedGateAccepts=correctedGateAccepts;assert.equal(observed.oldGateAccepts,true);assert.equal(Boolean(observed.error),scenario!=='healthy');if(scenario!=='healthy')assert.ok(observed.values.includes('—'));results.push({scenario,...observed});await page.screenshot({path:path.join(out,scenario+'.png'),fullPage:true});await context.close();}
+ const correctedGateAccepts=await page.evaluate(pageReadyGate,{role:'student',stableMs:0});assert.equal(correctedGateAccepts,['healthy','warm-legacy-cache'].includes(scenario));observed.correctedGateAccepts=correctedGateAccepts;assert.equal(observed.oldGateAccepts,true);assert.equal(Boolean(observed.error),!['healthy','warm-legacy-cache'].includes(scenario));if(!['healthy','warm-legacy-cache'].includes(scenario))assert.ok(observed.values.includes('—'));if(scenario==='warm-legacy-cache')assert.deepEqual(actions,['app_review_page']);if(['healthy','warm-legacy-cache'].includes(scenario)){const count=actions.length;await page.evaluate(()=>{const original=Date.now;Date.now=()=>original()+61_001;document.dispatchEvent(new Event('visibilitychange'));});assert.match(await page.locator('.review-sync-notice').innerText(),/已过期/);assert.equal(actions.length,count);observed.expiryUpdatesWithoutRequests=true;}results.push({scenario,...observed,actions:[...actions]});await page.screenshot({path:path.join(out,scenario+'.png'),fullPage:true});await context.close();}
  assert.deepEqual(external,[]);fs.writeFileSync(path.join(out,'home-readiness-audit.json'),JSON.stringify({externalRequests:0,results},null,2));console.log(JSON.stringify({externalRequests:0,results}));
 }finally{await browser.close();await new Promise(resolve=>server.close(resolve));}

+ 69 - 0
perf/recovery-performance-2026-11-14/timeout-chain-local.mjs

@@ -0,0 +1,69 @@
+// Offline mechanism audit of a saved worker + gateway, not a production diagnosis.
+// Usage: node .../timeout-chain-local.mjs WORKER_SNAPSHOT GATEWAY_SNAPSHOT OUTPUT_DIR
+// All requests are restricted to two loopback test servers; data and credentials are synthetic.
+import fs from 'node:fs';import path from 'node:path';import http from 'node:http';import vm from 'node:vm';import ts from 'typescript';import assert from 'node:assert/strict';import {createHash,randomUUID} from 'node:crypto';
+const [workerFile,gatewayFile,out]=process.argv.slice(2);if(![workerFile,gatewayFile,out].every(p=>p&&path.isAbsolute(p)))throw Error('Pass absolute snapshot and output paths');
+const worker=fs.readFileSync(workerFile,'utf8'),saved=JSON.parse(fs.readFileSync(gatewayFile,'utf8'));
+const gateway=(Array.isArray(saved)?saved:saved.results).find(r=>r.path==='xiaoshu/app/gateway')?.code;assert.ok(gateway);fs.mkdirSync(out,{recursive:true,mode:0o700});
+const hash=s=>createHash('sha256').update(s).digest('hex'),delay=ms=>new Promise(r=>setTimeout(r,ms));
+const ast=ts.createSourceFile('worker.js',worker,ts.ScriptTarget.Latest,true,ts.ScriptKind.JS);
+const names=['encodeValue','decodeValue','resolveClassName','FmodeRestObject','makeObjectSubclass','FmodeRestQuery','transportHeadersByParse','FmodeRestParse','_FmodeParse','FmodeParse','IsolatedFunctionExecutor'];
+const declarations=new Map();for(const n of ast.statements){if(ts.isFunctionDeclaration(n)&&n.name)declarations.set(n.name.text,n.getText(ast));if(ts.isVariableStatement(n))for(const d of n.declarationList.declarations)declarations.set(d.name.getText(ast),'var '+d.getText(ast)+';');}
+const extracted=names.map(n=>{assert.ok(declarations.has(n),'worker shape '+n);return declarations.get(n)}).join('\n');
+const COMPANY='7pIbDBJmKx',APP='7pIbDBJmKx_main';
+function trace(){const start=performance.now(),events=[];return{events,event(phase,extra={}){events.push({phase,atMs:performance.now()-start,...extra});},async span(phase,run,extra={}){const begin=performance.now();this.event(phase,{event:'start',...extra});try{const value=await run();this.event(phase,{event:'end',ms:performance.now()-begin,...extra});return value;}catch(e){this.event(phase,{event:'error',ms:performance.now()-begin,errorType:e?.name||'Error',...extra});throw e;}}};}
+function instrument(source){
+ const loader='await resolveXiaoshuRuntime(Parse, Psql, request)';assert.equal(source.split(loader).length,2,'runtime loader shape');source=source.replace(loader,'await resolveXiaoshuRuntime(Parse, Psql, request, () => LOCAL_RUNTIME_ENV)');
+ for(const [name,phase] of [['currentUser','gateway.currentUser'],['authorizeRequestedUser','business.authorize'],['appAccountOverview','business.profile'],['appCompanionOverview','business.timetable'],['databaseAppointmentVersions','business.appointmentVersions']]){
+  const pattern=new RegExp('async function '+name+'\\(');assert.ok(pattern.test(source),name);source=source.replace(pattern,'async function __local_'+name+'(')+`\nasync function ${name}(...args){return LOCAL_TRACE.span(${JSON.stringify(phase)},()=>__local_${name}(...args));}`;
+ }
+ return source;
+}
+const candidate=instrument(gateway),reports=[];
+const cases=[
+ {name:'healthy-profile',action:'profile'},
+ {name:'company-headers',action:'profile',phase:'company.headers',delayMs:300,deadlineMs:150},
+ {name:'account-body',action:'profile',phase:'account.body',delayMs:300,deadlineMs:150},
+ {name:'user-refresh',action:'profile',phase:'user.headers',delayMs:300,deadlineMs:150},
+ {name:'timetable-connection-acquire',action:'timetable',phase:'db.acquire',delayMs:300,deadlineMs:150},
+ {name:'timetable-query-execute',action:'timetable',phase:'db.execute',delayMs:300,deadlineMs:150},
+ {name:'session-real-20-second-deadline',action:'profile',phase:'session.headers',delayMs:21_000,deadlineMs:20_000},
+];
+for(const scenario of cases){
+ const t=trace(),user={objectId:'local-student',legacyUserId:20,legacyGroupId:1,username:'local-fixture',nickname:'本地测试学生',company:{__type:'Pointer',className:'Company',objectId:COMPANY},legacyUserData:{UserID:20,GroupID:1,UserPoint:12.5,Purse:8,SilverCoin:7},isDisabled:false,isDeleted:false};
+ let apiOrigin='',parseOrigin='',injected=false,dbCalls=0,externalAttempts=0,active=0;const waiting=[];
+ async function fault(phase){if(!injected&&scenario.phase===phase){injected=true;t.event('fault.injected',{target:phase});await delay(scenario.delayMs);}}
+ function stage(url){const cls=new URL(url).pathname.split('/')[3];return({_Session:'session',Function:'function',Company:'company',Account:'account',_User:'user'})[cls]||'unexpected';}
+ const dataServer=http.createServer(async(req,res)=>{const phase=stage(parseOrigin+req.url);try{assert.equal(req.method,'GET');let data;if(phase==='session')data={results:[{objectId:'local-session',user}]};else if(phase==='function')data={results:[{objectId:'local-function',type:'standalone',code:candidate}]};else if(phase==='company')data={objectId:COMPANY};else if(phase==='account')data={results:[]};else if(phase==='user')data=user;else throw Error('Unexpected data route');await fault(phase+'.headers');res.writeHead(200,{'Content-Type':'application/json'});res.flushHeaders();await fault(phase+'.body');res.end(JSON.stringify(data));}catch{res.writeHead(500);res.end('{}');}});
+ await new Promise(r=>dataServer.listen(0,'127.0.0.1',r));parseOrigin='http://127.0.0.1:'+dataServer.address().port;
+ const safeFetch=async(url,options)=>{if(new URL(url).origin!==parseOrigin){externalAttempts++;throw Error('Non-loopback request blocked');}assert.equal(options.method,'GET');const phase=stage(url);assert.equal(options.signal,undefined,'saved worker unexpectedly added cancellation');const result=await t.span('parse.'+phase+'.headers',()=>fetch(url,options));return{ok:result.ok,status:result.status,text:()=>t.span('parse.'+phase+'.body',()=>result.text())};};
+ const context=vm.createContext({URL,Date,Promise,Map,WeakMap,fetch:safeFetch,console:{log(){},warn(){},error(){}},process:{env:{}},Buffer,setTimeout,clearTimeout});
+ vm.runInContext(extracted+'\nthis.classes={FmodeParse,IsolatedFunctionExecutor};',context);const {FmodeParse,IsolatedFunctionExecutor}=context.classes;
+ FmodeParse.initialize({config:COMPANY,appId:APP,masterKey:'local-master-fixture',serverURL:parseOrigin+'/data',headers:{'x-fmode-worker-token':'local-worker-fixture'}});
+ async function query(sql,one){assert.match(sql,/^SELECT\b/);assert.doesNotMatch(sql,/\b(INSERT|UPDATE|DELETE|DROP|ALTER|TRUNCATE)\b/i);const fingerprint=hash(sql).slice(0,16),id=++dbCalls;
+  await t.span('db.acquire',async()=>{if(active>=2)await new Promise(r=>waiting.push(r));active++;await fault('db.acquire');},{query:id,sqlFingerprint:fingerprint,pool:'simulated-business-pool'});
+  try{return await t.span('db.execute',async()=>{await fault('db.execute');return one?{total:0,changed_at:null}:[];},{query:id,sqlFingerprint:fingerprint,pool:'simulated-business-pool'});}finally{active--;waiting.shift()?.();}
+ }
+ const db={query:sql=>query(sql,false),one:sql=>query(sql,true),oneOrNone:sql=>query(sql,true),tx:()=>{throw Error('Transactions forbidden in offline read audit');}};
+ const executor=new IsolatedFunctionExecutor(COMPANY,{db});const native=executor.getParse();
+ // SDK conversion/transport is represented by a read-only fixture; real worker metadata
+ // routing and real Xiaoshu currentUser/business functions are executed unchanged.
+ function rich(json){let value={...json};return{id:json.objectId,get(k){const v=value[k];return v?.__type==='Pointer'?{id:v.objectId}:v;},toJSON(){return value;},async fetch(){value=await native._request('GET','classes/'+(json.className||'_User')+'/'+json.objectId,undefined,{useMasterKey:true});return this;}};}
+ const SDK={User:function(){},Object:{fromJSON:rich},initialize(){}};
+ executor.setGlobalContext('LOCAL_RUNTIME_ENV',{SDK,CoreManager:{set(){}},workerData:{companyId:COMPANY,token:'local-worker-fixture',config:{appId:APP,masterKey:'local-master-fixture'}}});executor.setGlobalContext('LOCAL_TRACE',t);
+ for(const [name,phase] of [['getUserByToken','worker.session'],['getFunctionByGlobalPath','worker.function'],['authenticateUser','worker.companyAccount'],['executeFunction','worker.execute']]){const original=executor[name].bind(executor);executor[name]=(...args)=>t.span(phase,()=>original(...args));}
+ let finish;const finished=new Promise(r=>finish=r);
+ const apiServer=http.createServer(async(req,res)=>{try{let body='';for await(const chunk of req)body+=chunk;req.body=JSON.parse(body);req.query={};res.on('close',()=>{if(!res.writableFinished)t.event('client.disconnected');});const response={get headersSent(){return res.headersSent;},status(code){res.statusCode=code;return this;},json(value){t.event('response.attempt',{success:value.retcode===0});res.setHeader('Content-Type','application/json');res.end(JSON.stringify(value));return this;}};await t.span('worker.total',()=>executor.executeStandalone(req,response,'xiaoshu/app/gateway'));}finally{finish();}});
+ await new Promise(r=>apiServer.listen(0,'127.0.0.1',r));apiOrigin='http://127.0.0.1:'+apiServer.address().port;
+ let deadlineReached=false,result;
+ try{
+  const params=scenario.action==='profile'?{action:'app_account_overview',uid:20,profileOnly:true}:{action:'app_companion_overview',uid:20,source:'database',history:false,page:1,pageSize:20,dateStart:'2026-11-14',dateEnd:'2026-11-14'};
+  try{const response=await fetch(apiOrigin+'/gateway',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({token:'r:local-session-fixture',params}),signal:AbortSignal.timeout(scenario.deadlineMs||5000)});result=await response.json();assert.equal(result.retcode,0);}catch(error){if(!scenario.deadlineMs)throw error;assert.ok(['TimeoutError','AbortError'].includes(error.name),error.message);deadlineReached=true;t.event('client.deadline');}
+  await finished;assert.equal(externalAttempts,0);assert.equal(active,0);assert.equal(waiting.length,0);
+  const order=t.events.filter(e=>e.event==='start'&&/^parse\..*\.headers$/.test(e.phase)).map(e=>e.phase);
+  assert.deepEqual(order,['parse.session.headers','parse.function.headers','parse.company.headers','parse.account.headers','parse.user.headers']);
+  if(scenario.action==='profile')assert.equal(dbCalls,0,'profile-only must not run business SQL');else assert.ok(dbCalls>=5);
+  if(scenario.phase){assert.equal(injected,true);assert.equal(deadlineReached,true);const deadline=t.events.find(e=>e.phase==='client.deadline').atMs,end=t.events.find(e=>e.phase==='response.attempt').atMs;assert.ok(end>deadline,'internal read continued after client timeout');}
+  const report={name:scenario.name,requestId:randomUUID(),deadlineReached,dbCalls,externalAttempts,readOnlyGuards:true,events:t.events};reports.push(report);fs.writeFileSync(path.join(out,'timeout-chain-local.json'),JSON.stringify({workerSha256:hash(worker),gatewaySha256:hash(gateway),scope:'offline saved worker routing and gateway; synthetic Parse data/SDK conversion and simulated SQL pool, fault injection is not a production root cause',reports},null,2));console.log(JSON.stringify({name:scenario.name,passed:true,deadlineReached,dbCalls,externalAttempts}));
+ }finally{apiServer.closeAllConnections();dataServer.closeAllConnections();await Promise.all([new Promise(r=>apiServer.close(r)),new Promise(r=>dataServer.close(r))]);}
+}

+ 25 - 0
projects/xiaoshu-mobile/src/app/core/home-daily-cache.service.spec.ts

@@ -18,6 +18,31 @@ describe('HomeDailyCacheService', () => {
   });
   afterEach(() => localStorage.removeItem(storageKey));
 
+
+  it('persists the new review contract across reloads without retaining legacy review or invalidating balances', async () => {
+    const today=new Intl.DateTimeFormat('en-CA',{timeZone:'Asia/Shanghai'}).format(new Date());
+    const value={view:'home',items:[],dates:[],page:{itemCount:0,pageCount:0},summary:{total:0},legacyReviewSync:{state:'snapshot'}};
+    localStorage.setItem(storageKey,JSON.stringify({identity:`100|1|${today}`,values:{
+      review:{value:{items:[],dates:[],summary:{total:99}},updatedAt:new Date().toISOString()},
+      balances:{value:{UserPoint:12.5,Purse:8,SilverCoin:7},updatedAt:new Date().toISOString()}
+    }}));
+    const fetch=jasmine.createSpy('review').and.returnValue(of(value));
+    await firstValueFrom(cache.load('review-home-v2',fetch));
+    const restored=TestBed.runInInjectionContext(()=>new HomeDailyCacheService());
+    expect((await firstValueFrom(restored.load('review-home-v2',fetch))).value).toEqual(value);
+    expect(fetch).toHaveBeenCalledTimes(1);
+    const balances=jasmine.createSpy('balances');
+    expect((await firstValueFrom(restored.load('balances',balances))).value).toEqual({UserPoint:12.5,Purse:8,SilverCoin:7});
+    expect(balances).not.toHaveBeenCalled();
+    expect(JSON.parse(localStorage.getItem(storageKey)!).values.review).toBeUndefined();
+  });
+
+  it('rejects a legacy response for the new review key without persisting a misleading snapshot', async () => {
+    const legacy={items:[],dates:[],summary:{total:0}};
+    await expectAsync(firstValueFrom(cache.load('review-home-v2',()=>of(legacy)))).toBeRejected();
+    expect(localStorage.getItem(storageKey)).toBeNull();
+  });
+
   it('reuses the same day after navigation and page reload, and forces a manual refresh', async () => {
     const fetch = jasmine.createSpy('fetch').and.returnValue(of({ totalIncome: 25 }));
     const first = await firstValueFrom(cache.load<{ totalIncome: number }>('stats', fetch));

+ 5 - 3
projects/xiaoshu-mobile/src/app/core/home-daily-cache.service.ts

@@ -1,11 +1,12 @@
 import { inject, Injectable, NgZone } from '@angular/core';
 import { defer, filter, finalize, map, Observable, of, shareReplay, take, tap } from 'rxjs';
+import { HOME_REVIEW_CACHE_KEY, isHomeReviewSnapshot } from './home-review-snapshot';
 import { SessionService } from './session.service';
 import { beijingToday } from './teacher-today-review.service';
 
 const STORAGE_KEY = 'xiaoshu.learning.homeDaily.v1';
-type Section = 'stats' | 'review' | 'balances' | 'account' | `companion:${string}`;
-const FIXED_SECTIONS = ['stats', 'review', 'balances', 'account'];
+type Section = 'stats' | typeof HOME_REVIEW_CACHE_KEY | 'balances' | 'account' | `companion:${string}`;
+const FIXED_SECTIONS = ['stats', HOME_REVIEW_CACHE_KEY, 'balances', 'account'];
 const MAX_COMPANION_PAGES = 80;
 const COMPANION_CACHE_VERSION = 2;
 export interface HomeSnapshot<T> { value: T; updatedAt: string; }
@@ -49,6 +50,7 @@ export class HomeDailyCacheService {
       filter(() => !section.startsWith('companion:') || companionRevision === this.companionRevision),
       tap(value => {
         if (!value || typeof value !== 'object' || Array.isArray(value)) throw new Error('页面数据暂时无法加载');
+        if (section === HOME_REVIEW_CACHE_KEY && !isHomeReviewSnapshot(value)) throw new Error('复习记录格式已更新,请重新加载');
       }),
       // Keep the successful read time, including after a page reload.
       map(value => ({ value, updatedAt: new Date().toISOString() })),
@@ -113,7 +115,7 @@ export class HomeDailyCacheService {
             if (section.startsWith('companion:') && saved.companionCacheVersion !== COMPANION_CACHE_VERSION) continue;
             const snapshot = saved.values?.[section];
             if (snapshot?.value && typeof snapshot.value === 'object' && !Array.isArray(snapshot.value) && Number.isFinite(Date.parse(snapshot.updatedAt))) {
-              if (section === 'review' && (!Array.isArray(snapshot.value.items) || !Array.isArray(snapshot.value.dates) || !snapshot.value.summary)) continue;
+              if (section === HOME_REVIEW_CACHE_KEY && !isHomeReviewSnapshot(snapshot.value)) continue;
               if (section.startsWith('companion:') && (!Array.isArray(snapshot.value.courses) || !Array.isArray(snapshot.value.schedules))) continue;
               this.values[section] = snapshot;
               this.trimCompanionPages();

+ 35 - 2
projects/xiaoshu-mobile/src/app/core/home-learning-modules.integration.spec.ts

@@ -1,6 +1,6 @@
 import { provideHttpClient } from '@angular/common/http';
 import { signal } from '@angular/core';
-import { TestBed } from '@angular/core/testing';
+import { fakeAsync, tick, TestBed } from '@angular/core/testing';
 import { By } from '@angular/platform-browser';
 import { provideRouter, Router } from '@angular/router';
 import { RouterTestingHarness } from '@angular/router/testing';
@@ -265,7 +265,7 @@ class LearningModulesApiService {
         ...(Number(params['shouru']) === 1
           ? [{ GeneralID: 103, Title: '李同学@任务', kc_title: '小学英语复习', kywsj: '2026-08-02 10:00', learned_at: '2026-07-21T02:20:00.000Z', fxzt: 0, yhid: 101, xxjlid: 901 }]
           : []),
-      ];return this.result<T>({items,page:{itemCount:items.length,pageCount:1},summary:{total:items.length,completed:items.filter(i=>i.fxzt===1).length,due:items.filter(i=>i.fxzt!==1).length,today:0,income:0},dates:items.map(i=>({date:i.kywsj.slice(0,10),total:1,completed:i.fxzt===1?1:0}))});
+      ];return this.result<T>({...(params['view']==='home'?{view:'home',legacyReviewSync:{state:'snapshot'},syncStatus:{state:'unavailable'}}:{}),items,page:{itemCount:items.length,pageCount:1},summary:{total:items.length,completed:items.filter(i=>i.fxzt===1).length,due:items.filter(i=>i.fxzt!==1).length,today:0,income:0},dates:items.map(i=>({date:i.kywsj.slice(0,10),total:1,completed:i.fxzt===1?1:0}))});
     }
     if (action === 'e_get_21list') {
       this.lastMemoryParams = params;
@@ -554,6 +554,39 @@ describe('home learning modules', () => {
     expect(count()).toBe(2);
   });
 
+
+  it('upgrades a warm legacy home review while preserving existing balance and stats snapshots', async () => {
+    user.set({userId:100,groupId:1});session.groupId.set(1);
+    const today=new Intl.DateTimeFormat('en-CA',{timeZone:'Asia/Shanghai'}).format(new Date()),at=new Date().toISOString();
+    localStorage.setItem('xiaoshu.learning.homeDaily.v1',JSON.stringify({identity:`100|1|${today}`,values:{
+      review:{value:{items:[],dates:[],page:{itemCount:0,pageCount:0},summary:{total:99,completed:0,due:99,today:99,income:0},legacyReviewSync:{state:'current'}},updatedAt:at},
+      balances:{value:{UserPoint:12.5,Purse:8,SilverCoin:7},updatedAt:at},
+      stats:{value:{t_total:18,t_shichang:9},updatedAt:at}
+    }}));
+    const api=TestBed.inject(ApiService),original=api.post.bind(api);
+    const post=spyOn(api,'post').and.callFake(((action:string,params:Record<string,unknown>={})=>action==='app_review_page'?of({retcode:0,result:{view:'home',items:[],dates:[],page:{itemCount:0,pageCount:0},summary:{total:2,completed:0,due:2,today:2,income:0},legacyReviewSync:{state:'snapshot'},syncStatus:{state:'current'}}}):original(action,params)) as typeof api.post);
+    const harness=await RouterTestingHarness.create('/pages/home/index');harness.fixture.detectChanges();
+    expect(post.calls.allArgs().filter(([action])=>action==='app_review_page').length).toBe(1);
+    expect(harness.routeNativeElement?.querySelector('.review-sync-notice')).not.toBeNull();
+    expect(Array.from(harness.routeNativeElement!.querySelectorAll('.student-stats strong')).map(e=>e.textContent?.trim())).toEqual(['18','12.5','9','8','7']);
+    expect(harness.routeNativeElement?.querySelector('.today-panel')?.textContent).not.toContain('99');
+  });
+
+  it('ages the sync check on a mounted or restored home without reloading balances or synchronizing tasks', fakeAsync(() => {
+    user.set({userId:100,groupId:1});session.groupId.set(1);
+    const api=TestBed.inject(ApiService),original=api.post.bind(api);
+    const post=spyOn(api,'post').and.callFake(((action:string,params:Record<string,unknown>={})=>action==='app_review_page'?of({retcode:0,result:{view:'home',items:[],dates:[],page:{itemCount:0,pageCount:0},summary:{total:2,completed:0,due:2,today:2,income:0},legacyReviewSync:{state:'snapshot'},syncStatus:{state:'current'}}}):original(action,params)) as typeof api.post);
+    let fixture=TestBed.createComponent(HomePageComponent);fixture.detectChanges();
+    expect(fixture.nativeElement.querySelector('.review-sync-notice').textContent).toContain('上次检查');
+    tick(61_000);fixture.detectChanges();
+    expect(fixture.nativeElement.querySelector('.review-sync-notice').textContent).toContain('已过期');
+    const requests=post.calls.count();fixture.destroy();
+    fixture=TestBed.createComponent(HomePageComponent);fixture.detectChanges();
+    expect(fixture.nativeElement.querySelector('.review-sync-notice').textContent).toContain('已过期');
+    expect(post.calls.count()).toBe(requests);
+    fixture.destroy();
+  }));
+
   it('student home requests a read-only summary and exposes stale sync without blocking learning or altering balances', async () => {
     user.set({userId:100,groupId:1});session.groupId.set(1);
     const api=TestBed.inject(ApiService) as unknown as LearningModulesApiService;

+ 34 - 0
projects/xiaoshu-mobile/src/app/core/home-review-clock.spec.ts

@@ -0,0 +1,34 @@
+import { signal } from '@angular/core';
+import { fakeAsync, TestBed, tick } from '@angular/core/testing';
+import { provideRouter } from '@angular/router';
+import { of } from 'rxjs';
+import { HomePageComponent } from '../features/home/home-page.component';
+import { ApiService } from './api.service';
+import { SessionService } from './session.service';
+import { PlatformService } from './platform.service';
+
+const key='xiaoshu.learning.homeDaily.v1';
+const snapshot={view:'home',items:[],dates:[],page:{itemCount:0,pageCount:0},summary:{total:0,completed:0,due:0,today:0,income:0},legacyReviewSync:{state:'snapshot'},syncStatus:{state:'current'}};
+describe('Home review clock recovery',()=>{
+ beforeEach(()=>{localStorage.removeItem(key);const api={post:()=>of({retcode:0,result:snapshot}),get:()=>of({retcode:0,result:{profile:{UserPoint:12.5,Purse:8,SilverCoin:7}}})};TestBed.configureTestingModule({imports:[HomePageComponent],providers:[provideRouter([]),{provide:ApiService,useValue:api},{provide:SessionService,useValue:{user:signal({userId:20,groupId:1}),groupId:signal(1),token:signal('r:clock-fixture')}},{provide:PlatformService,useValue:{toast(){}}}]});});
+ afterEach(()=>localStorage.removeItem(key));
+ it('rearms expiry when a future cached timestamp becomes valid on visible resume',fakeAsync(()=>{
+  const today=new Intl.DateTimeFormat('en-CA',{timeZone:'Asia/Shanghai'}).format(new Date());
+  localStorage.setItem(key,JSON.stringify({identity:`20|1|${today}`,values:{'review-home-v2':{value:snapshot,updatedAt:new Date(Date.now()+30_000).toISOString()}}}));
+  spyOnProperty(document,'visibilityState','get').and.returnValue('visible');
+  const fixture=TestBed.createComponent(HomePageComponent);fixture.detectChanges();
+  expect(fixture.componentInstance.reviewSyncNotice()).toContain('尚未核对');
+  tick(40_000);document.dispatchEvent(new Event('visibilitychange'));
+  expect(fixture.componentInstance.reviewSyncNotice()).toContain('同步正常');
+  tick(50_001);expect(fixture.componentInstance.reviewSyncNotice()).toContain('已过期');
+  fixture.destroy();
+ }));
+ it('rearms a fired timer if wall-clock correction makes the prior check younger again',fakeAsync(()=>{
+  const base=Date.now();const fixture=TestBed.createComponent(HomePageComponent);fixture.detectChanges();
+  const now=spyOn(Date,'now').and.returnValue(base+30_000);
+  tick(60_000);expect(fixture.componentInstance.reviewSyncNotice()).toContain('同步正常');
+  now.and.returnValue(base+60_001);tick(30_001);
+  expect(fixture.componentInstance.reviewSyncNotice()).toContain('已过期');
+  fixture.destroy();
+ }));
+});

+ 20 - 0
projects/xiaoshu-mobile/src/app/core/home-review-snapshot.spec.ts

@@ -0,0 +1,20 @@
+import { homeReviewSyncNotice } from './home-review-snapshot';
+import { ReviewPage } from './review-page';
+
+describe('home review status freshness', () => {
+  const data = (state: 'current' | 'delayed' | 'unavailable'): ReviewPage => ({view:'home',items:[],dates:[],page:{itemCount:0,pageCount:0},summary:{total:0,completed:0,due:0,today:0,income:0},legacyReviewSync:{state:'snapshot'},syncStatus:{state}});
+  const checked='2026-11-14T01:00:00Z';
+  it('does not present missing or future check times as a current synchronization check', () => {
+    for (const time of ['', 'invalid', '2026-11-14T01:01:00Z']) {
+      expect(homeReviewSyncNotice(data('current'),time,Date.parse(checked))).toContain('尚未核对');
+      expect(homeReviewSyncNotice(data('current'),time,Date.parse(checked))).not.toContain('同步正常');
+    }
+  });
+  it('describes fresh results as a past check and expired results as unknown, without claiming a real outage', () => {
+    expect(homeReviewSyncNotice(data('current'),checked,Date.parse(checked)+59_999)).toContain('上次检查同步正常');
+    for (const state of ['current','delayed','unavailable'] as const) {
+      const notice=homeReviewSyncNotice(data(state),checked,Date.parse(checked)+60_000);
+      expect(notice).toContain('已过期');expect(notice).toContain('尚未核对');expect(notice).not.toContain('同步正常');
+    }
+  });
+});

+ 28 - 0
projects/xiaoshu-mobile/src/app/core/home-review-snapshot.ts

@@ -0,0 +1,28 @@
+import { ReviewPage } from './review-page';
+
+// A separate section prevents both upgrades and rollbacks from reusing a different contract.
+export const HOME_REVIEW_CACHE_KEY = 'review-home-v2' as const;
+export const HOME_REVIEW_STATUS_MAX_AGE_MS = 60_000;
+
+export function isHomeReviewSnapshot(value: unknown): value is ReviewPage {
+  if (!value || typeof value !== 'object') return false;
+  const page = value as ReviewPage;
+  return page.view === 'home' && page.legacyReviewSync?.state === 'snapshot'
+    && Array.isArray(page.items) && Array.isArray(page.dates)
+    && !!page.summary && typeof page.summary === 'object' && !Array.isArray(page.summary);
+}
+
+/** A cached check describes the last read; age alone cannot prove that synchronization stopped. */
+export function homeReviewSyncNotice(data: ReviewPage | null, checkedAt: string, now: number): string {
+  if (data?.legacyReviewSync?.state !== 'snapshot') return '';
+  const age = now - Date.parse(checkedAt);
+  const state = data.syncStatus?.state;
+  const status = !Number.isFinite(age) || age < 0
+    ? '同步状态尚未核对。'
+    : age >= HOME_REVIEW_STATUS_MAX_AGE_MS
+      ? '同步状态检查已过期,当前状态尚未核对。'
+      : state === 'current' ? '上次检查同步正常。'
+        : state === 'delayed' ? '上次检查同步有延迟。'
+          : state === 'unavailable' ? '上次检查同步状态暂不可用。' : '同步状态尚未核对。';
+  return status + '当前显示已同步的复习记录,进入复习计划可核对最新任务。';
+}

+ 1 - 1
projects/xiaoshu-mobile/src/app/core/review-page.ts

@@ -1,6 +1,6 @@
 import { buildAntiForgetCalendar, buildAntiForgetView } from './anti-forget';
 import { LiveSyncStatus } from './live-data-refresh.service';
-export interface ReviewPage { serverToday?:string;items:Record<string,unknown>[];page:{itemCount:number;pageCount:number};summary:{total:number;completed:number;due:number;today:number;income:number;incomeScope?:string;todayTotal?:number;todayCompleted?:number;todayPending?:number;overduePending?:number}; dates:Array<{date:string;total:number;completed:number}>;students?:Array<{id:string;name:string;taskCount:number}>;syncStatus?: LiveSyncStatus; legacyReviewSync?: { state: 'current' | 'conflict' | 'unavailable' | 'snapshot'; lastSuccessAt?: string } }
+export interface ReviewPage { view?:'home'; serverToday?:string;items:Record<string,unknown>[];page:{itemCount:number;pageCount:number};summary:{total:number;completed:number;due:number;today:number;income:number;incomeScope?:string;todayTotal?:number;todayCompleted?:number;todayPending?:number;overduePending?:number}; dates:Array<{date:string;total:number;completed:number}>;students?:Array<{id:string;name:string;taskCount:number}>;syncStatus?: LiveSyncStatus; legacyReviewSync?: { state: 'current' | 'conflict' | 'unavailable' | 'snapshot'; lastSuccessAt?: string } }
 export function reviewCalendar(dates:ReviewPage['dates'],month:Date,selected:string){
   const view=buildAntiForgetCalendar([],month,selected);const today=new Intl.DateTimeFormat('en-CA',{timeZone:'Asia/Shanghai'}).format(new Date());
   view.days=view.days.map(day=>{const row=dates.find(d=>d.date===day.date);const total=Number(row?.total)||0,completed=Number(row?.completed)||0,pending=total-completed;return{...day,total,completed,pending,state:!total?'none' as const:!pending?'completed' as const:day.date<=today?'due' as const:'scheduled' as const};});

+ 2 - 1
projects/xiaoshu-mobile/src/app/features/home/home-page.component.html

@@ -68,7 +68,7 @@
       </div>
 
       @if (antiForgetLoading()) {
-        <div class="hero-state" role="status"><span class="state-spinner"></span><span>正在同步学习计划</span></div>
+        <div class="hero-state" role="status"><span class="state-spinner"></span><span>正在加载复习记录</span></div>
       } @else if (antiForgetError()) {
         <button class="hero-state hero-error" type="button" (click)="retryAntiForget()"><lucide-icon [img]="icons.AlertCircle" [size]="20" /><span>{{ antiForgetError() }},点击重试</span></button>
       } @else {
@@ -95,6 +95,7 @@
 
       @if (reviewSyncNotice()) {
         <p class="review-sync-notice" role="status">{{ reviewSyncNotice() }}
+          @if (reviewCheckedAt()) { <span>数据读取:{{reviewCheckedAt() | date:'MM-dd HH:mm':'+0800'}}</span> }
           @if (reviewData()?.legacyReviewSync?.lastSuccessAt) { <span>最近同步:{{reviewData()?.legacyReviewSync?.lastSuccessAt | date:'MM-dd HH:mm':'+0800'}}</span> }
         </p>
       }

+ 27 - 11
projects/xiaoshu-mobile/src/app/features/home/home-page.component.ts

@@ -9,6 +9,7 @@ import { antiForgetCompleted, antiForgetDate, buildAntiForgetView, findAntiForge
 import { ApiService } from '../../core/api.service';
 import { PlatformService } from '../../core/platform.service';
 import { SessionService } from '../../core/session.service';
+import { HOME_REVIEW_CACHE_KEY, HOME_REVIEW_STATUS_MAX_AGE_MS, homeReviewSyncNotice } from '../../core/home-review-snapshot';
 import { HomeDailyCacheService } from '../../core/home-daily-cache.service';
 
 type Item = Record<string, unknown>;
@@ -48,13 +49,9 @@ export class HomePageComponent implements OnInit, OnDestroy {
   readonly antiForgetLoading = signal(false);
   readonly antiForgetError = signal('');
   readonly reviewData=signal<ReviewPage|null>(null);
-  readonly reviewSyncNotice = computed(() => {
-    const data=this.reviewData();
-    if(data?.legacyReviewSync?.state!=='snapshot')return '';
-    const state=data.syncStatus?.state;
-    const status=state==='unavailable'?'同步状态暂不可用。':state==='delayed'?'同步有延迟。':'';
-    return status+'当前显示已同步的复习记录,进入复习计划可核对最新任务。';
-  });
+  readonly reviewCheckedAt = signal('');
+  private readonly reviewClock = signal(Date.now());
+  readonly reviewSyncNotice = computed(() => homeReviewSyncNotice(this.reviewData(), this.reviewCheckedAt(), this.reviewClock()));
   readonly antiForgetView = computed(() => reviewOverview(this.reviewData()));
   readonly isCoach = computed(() => Number(this.session.groupId()) === 3);
   readonly coachReviewItems = computed(() => this.antiForgetItems().filter(item => antiForgetDate(item) === this.today()).sort((left, right) => {
@@ -75,10 +72,13 @@ export class HomePageComponent implements OnInit, OnDestroy {
   private readonly zone=inject(NgZone);
   private readonly todayReviews = inject(TeacherTodayReviewService);
   private reviewRequest?: Subscription;
+  private reviewStatusTimer?: ReturnType<typeof setTimeout>;
   private midnightTimer?: ReturnType<typeof setTimeout>;
   private homeLoadedDate = '';
   private readonly resumeToday = () => {
-    if (document.visibilityState === 'visible' && this.homeLoadedDate !== beijingToday()) this.loadDailyHome();
+    if (document.visibilityState !== 'visible') return;
+    this.setReviewCheckedAt(this.reviewCheckedAt());
+    if (this.homeLoadedDate !== beijingToday()) this.loadDailyHome();
   };
 
   private loadDailyHome(): void {
@@ -108,6 +108,7 @@ export class HomePageComponent implements OnInit, OnDestroy {
   ngOnDestroy(): void {
     this.requests.unsubscribe();
     this.reviewRequest?.unsubscribe();
+    if (this.reviewStatusTimer) clearTimeout(this.reviewStatusTimer);
     if (this.midnightTimer) clearTimeout(this.midnightTimer);
     document.removeEventListener('visibilitychange', this.resumeToday);
   }
@@ -183,19 +184,34 @@ export class HomePageComponent implements OnInit, OnDestroy {
     this.antiForgetLoading.set(true);
     this.antiForgetError.set('');
     const request = this.isCoach() ? this.todayReviews.load(force) :
-      this.dailyCache.load('review', () => this.api.post<ReviewPage>('app_review_page', {
+      this.dailyCache.load(HOME_REVIEW_CACHE_KEY, () => this.api.post<ReviewPage>('app_review_page', {
         uid, view: 'home', source: 'database',
-      }).pipe(map(({ result }) => result)), force).pipe(map(snapshot => snapshot.value));
+      }).pipe(map(({ result }) => result)), force).pipe(map(snapshot => { this.setReviewCheckedAt(snapshot.updatedAt); return snapshot.value; }));
     this.reviewRequest = request.pipe(finalize(() => this.antiForgetLoading.set(false))).subscribe({
       next: result => {
         this.reviewData.set(result);
         this.antiForgetItems.set(result.items);
         this.antiForgetError.set('');
       },
-      error: () => this.antiForgetError.set('抗遗忘计划暂时无法加载'),
+      error: () => { this.setReviewCheckedAt(''); this.antiForgetError.set('抗遗忘计划暂时无法加载'); },
     });
   }
 
+  private setReviewCheckedAt(checkedAt: string): void {
+    if (this.reviewStatusTimer) clearTimeout(this.reviewStatusTimer);
+    this.reviewStatusTimer = undefined;
+    this.reviewCheckedAt.set(checkedAt);
+    const now = Date.now();
+    this.reviewClock.set(now);
+    const age = now - Date.parse(checkedAt);
+    if (!Number.isFinite(age) || age < 0 || age >= HOME_REVIEW_STATUS_MAX_AGE_MS) return;
+    // Expire only the label. No polling, legacy synchronization or financial requests.
+    this.reviewStatusTimer = this.zone.runOutsideAngular(() => setTimeout(() => this.zone.run(() => {
+      this.reviewStatusTimer = undefined;
+      this.setReviewCheckedAt(this.reviewCheckedAt());
+    }), HOME_REVIEW_STATUS_MAX_AGE_MS - age));
+  }
+
   retryStudentHome(): void {
     this.retryStudentStats();
     this.retryAntiForget();