diff --git a/docs/tasks/README.md b/docs/tasks/README.md index 41425978..d951caed 100644 --- a/docs/tasks/README.md +++ b/docs/tasks/README.md @@ -227,6 +227,7 @@ | `TASK-chart-page-20260915.md` | `PROGRESS-chart-page-20260915.md` | **前端单(独占 `app-sidebar.tsx`,同时加星盘与星历两个入口)**:P0 只读星盘页,五个 Tab(星盘 / 基础信息 / 大运 / 西洋盘 / 七政四余),中宫排盘参数卡,三套坐标系各自标注且禁止互相换算。不扣点不调模型不出 spinner;不碰 `page.tsx`(1951/2000);**不搬 `vedic-chart-svg.tsx`**(rectification-board 也在用)。任务书预占 704–706,Bug 历史未写入;校正 P0 落地占用了 704–706 / 708–709 | 已验收(带修复单) | `830799fa` | | `TASK-ephemeris-page-20260915.md` | `PROGRESS-ephemeris-page-20260915.md` | **前端单**:P1 星历页,今日五要素 + 当日行运(相对本命宫位)+ 未来九十天换座与停滞,底部「带这天去提问」出口。页面不得出现任何运势判断。含实证缺陷:panchanga 写死 Lahiri 与账户 Raman 分裂(关联 BUG-703;本单标注为 BUG-707)。侧边栏入口由 chart-page 单交付。BUG 段 707–709 | 已验收(带修复单) | `d3a2c48b` | | `TASK-readonly-pages-fix-20260916.md` | `PROGRESS-readonly-pages-fix-20260916.md` | 三份只读页单的验收修复:**BUG-710** 七政 `ketu_mode`/`sidereal_mode` 收了请求却从不传给引擎,`calculation.ketu_mode` 回写请求值而非实际值(实测请求 descending-node 仍返回 apogee 盘,无警告);**BUG-711** 星历单断言 sidebar 不得含 `/ephemeris`,与星盘单按任务书添加的入口直接冲突,staging 现在是红的;**BUG-712** `ephemeris_events` golden 存全精度浮点跨机不稳,且 golden 缺失时自动重建。另附部署缺口:`deployment.gitCommit` 仍是 `2d7698ea`。BUG 段 710+ | 待验收 | `codex/readonly-pages-fix-20260916` | +| `TASK-chart-page-blocking-open-20260915.md` | — | **P1**:星盘页开一次要等很久且常常只给一句「过一会儿再打开」。实测引擎五个调用合计 0.75 秒、mapper 13 种形态零抛出——瓶颈在 `/chart` 是动态路由 + 侧栏改成硬文档跳转,整页 SSR 等完 1 串 4 并才开始画,白屏最长 45 秒(BUG-716);`postEngine` 把 429/500/超时/坏 JSON 全碾成 `null` 且零日志,两种性质相反的故障共用一句文案(BUG-715);开页并行打两个重计算限流端点(配额 2)、无缓存,且「打开即有」印在失败页上(BUG-717)。**串行在 readonly-pages-fix 之后** | 待领取 | `codex/chart-page-blocking-open-20260915` | | `TASK-api-server-decomposition-20260916.md` | `PROGRESS-api-server-decomposition-20260916.md` | **重构单(串行在 qizheng 单之后)**:把业务逻辑搬出 `JyotishAPIHandler`。核心不是行数,是全仓 3 处靠 `JyotishAPIHandler.__new__` 伪造空壳 handler 借方法(`consultation_workflow_service` ×2、`capture_report_blocked_repairs_golden`、`local_accuracy_report`,MCP 也走这条),依赖方向反了、handler 没有 `headers`/`wfile` 随时可炸。四阶段:拆 `__new__` 后门 → 抽 ≥150 行业务方法 → `do_POST`/`do_GET` 改路由表 → 重新冻结行数 baseline(余量 300→50)。纯搬运不改行为,`test_api_server_security.py` 3841 行断言一条不许改。预计 11,314 → 约 9,230 行。BUG 段 710+ | 待领取 | — | ## 命名与归档 diff --git a/docs/tasks/TASK-chart-page-blocking-open-20260915.md b/docs/tasks/TASK-chart-page-blocking-open-20260915.md new file mode 100644 index 00000000..411b808f --- /dev/null +++ b/docs/tasks/TASK-chart-page-blocking-open-20260915.md @@ -0,0 +1,282 @@ +# TASK · P1:星盘页开一次要等很久、失败只给一句「过一会儿再打开」(BUG-715~717) + +- 日期:2026-09-15 +- 基线 commit:`origin/staging` @ `2533d5a3`(= staging 当前部署) +- 执行分支:`codex/chart-page-blocking-open-20260915` +- 工作树:`.worktrees/chart-page-blocking-open-20260915` + +--- + +## 1. 用户现象(原话) + +> 星盘进去等待的时间很长,而且进入后返回的是「直接计算 · 打开即有 · 不消耗点数 / 星盘 / 这张盘这会儿算不出来。资料还在,过一会儿再打开。」首先有点过度提示,其次没结果。 + +--- + +## 2. 事故实证 + +行号按符号定位。核对于 `origin/staging@2533d5a3`。**以下带数字的都是本机实测,不是推断。** + +### 2.1 先排除两个嫌疑:引擎不慢,mapper 不抛 + +用本仓 golden 里已有的 1990-04-09 资料(`tests/golden/qizheng_stem_branch_19900409.json` 同一天,公开测试数据),在本机起 `scripts/jyotish_api_server.py`(`version 6.9.16`,`swisseph_available: true`),按 `chart-view-load.ts` 的**完全相同的参数**逐个计时: + +| 端点 | 秒 | HTTP | success | +| --- | ---: | ---: | --- | +| `/api/chart` | **0.60** | 200 | true | +| `/api/dasha/chara` | 0.00 | 200 | true | +| `/api/varga_full`(17 个分盘) | 0.00 | 200 | true | +| `/api/western` | 0.00 | 200 | true | +| `/api/qizheng` | 0.15 | 200 | true | + +**五个调用合计 0.75 秒。** 引擎本身不是瓶颈。 + +再把这五个真实响应存盘,直接喂给 `buildChartView`(`frontend/src/lib/chart-view-mapper.ts`),跑 13 种形态: + +- `birthTimeStatus` = verified / unverified / declared_window / null +- `ayanamsa` = raman / lahiri / undefined +- 缺 `placeLabel` / 缺 `timezoneId` / 空 `name` +- `chara` / `varga` / `western` / `qizheng` **分别为 null**(模拟那一路引擎调用失败) + +**13 种全部 `status = ok`,一次都没抛。** mapper 对四个后续调用失败是优雅降级的——这一点很重要,任务 2 要用。 + +所以「算不出来」只可能来自 `chart-view-load.ts` 的这一句: + +```ts +const chart = await input.postEngine("/api/chart", payload); +if (!chart || chart.success === false || !engineChartHasPlanets(chart)) { + return { httpStatus: 200, body: message("chart_unavailable", "这张盘这会儿算不出来。资料还在,过一会儿再打开。") }; +} +``` + +即 `/api/chart` 在 HTTP 层没拿到可用结果。**而现在的代码让人无法知道是为什么。** + +### 2.2 BUG-715|`postEngine` 把所有失败原因碾成一个 `null`,而且一行日志都不打 + +`frontend/src/lib/chart-view-service.ts`: + +```ts +const engineTimeoutMs = 45_000; + +async function postEngine(path, body) { + try { + const response = await fetch(`${jyotishApiBase}${path}`, { + …, cache: "no-store", signal: AbortSignal.timeout(engineTimeoutMs), + }); + if (!response.ok) return null; // ← 429 / 500 / 404 全变成 null + const payload = await response.json().catch(() => null); + if (!payload || typeof payload !== "object" || Array.isArray(payload)) return null; + return payload; + } catch { // ← 超时 / 连接失败全变成 null + return null; + } +} +``` + +429(引擎忙)、500(引擎错)、45 秒超时、JSON 解析失败——**四种完全不同的故障返回同一个 `null`**,并且**没有任何 `console.warn` / 日志**。服务端日志里查不到,用户看到的也是同一句话。 + +`chart-view-load.ts` 末尾那个 `catch {}` 同理: + +```ts +} catch { + return { httpStatus: 200, body: message("chart_unavailable", "这张盘这会儿算不出来。…") }; +} +``` + +**「引擎忙,等一下真的会好」和「这张盘有 bug,等多久都不会好」共用一句文案**,而文案写的是「过一会儿再打开」——把可能永远不会好的情况说成了暂时的。 + +### 2.3 BUG-716|`/chart` 是动态路由,整页在服务端等完五个引擎调用才开始画 + +`npm run build` 的路由表: + +``` +┌ ○ / +├ ƒ /chart ← Dynamic,server-rendered on demand +├ ○ /ephemeris +``` + +而侧栏进入星盘的方式在 `c6ecb86f` 被改成了**硬文档跳转**: + +`frontend/src/components/app-sidebar.tsx` + +```diff +- router.push("/chart"); ++ window.location.assign(path); +``` + +两件事叠起来是这样的时序: + +1. 点「星盘」→ **整个文档卸载重载**,React 树全部销毁重建 +2. 服务端渲染 `/chart` → `await loadChartView()` → 先串行 `/api/chart`,再并行四个 +3. **在这一切返回之前,浏览器里什么都没有**——没有骨架、没有等待态、没有「星盘」两个字。动态路由的 SSR 就是全有或全无。 +4. `/api/chart` 若慢,用户就盯着空白等,**最长 45 秒**(`engineTimeoutMs`),然后拿到那句「算不出来」 + +这正是「等待的时间很长」+「没结果」的完整链路。`maxDuration = 60`,所以 45 秒超时是能真正跑满的。 + +### 2.4 BUG-717|开一次页面就吃掉引擎全部重计算配额;文案还印在失败页上 + +**配额**:`scripts/api_heavy_compute_gate.py` 的 `HEAVY_COMPUTE_PATHS` 里包含 `/api/western` 和 `/api/qizheng`,`DEFAULT_CONCURRENCY = 2`。 + +星盘页一开就**并行**打这两个,**一次开页就占满整台 API 容器的重计算配额**。同时有第二个人开星盘、或有校正 / 咨询在跑,就会有人拿到 429 → `postEngine` 吞成 `null`。生产与 staging 都是 2 vCPU / 4 GB。 + +而且这两路结果只喂给「西洋」「七政」两个 tab,**用户不点根本不看**。 + +**无缓存**:`cache: "no-store"`,服务端也没有任何记忆。同一个人同一张盘,开十次算十次。 + +**文案**:`frontend/src/components/chart-page/chart-page-view.tsx` 里 `直接计算 · 打开即有 · 不消耗点数` 出现两次——第 41 行在**失败分支**,第 48 行在成功分支。于是失败页长这样: + +``` +直接计算 · 打开即有 · 不消耗点数 +星盘 +这张盘这会儿算不出来。资料还在,过一会儿再打开。 +``` + +「打开即有」正印在「算不出来」上面。且实测这是 5 个引擎调用、1 串 4 并、两个走限流队列——**「打开即有」本身就不成立**,不只是失败页上尴尬。 + +--- + +## 3. 决策记录(产品已授权) + +1. **开页不得整页阻塞在引擎上。** 先把页面画出来,数据后填。 +2. **`/api/chart` 之外的四路改成按需**:进页只算主盘,分盘 / 大运 / 西洋 / 七政在用户点到对应 tab 时再算。mapper 已经对它们为 null 优雅降级(§2.1 实测),这条改动不需要动 mapper。 +3. **失败必须可诊断**:服务端记录真实原因(状态码 / 超时 / 解析失败),用户侧区分「引擎忙,稍后重试」与「这张盘出错了」。 +4. **文案去掉「打开即有」**,并且 eyebrow 不得出现在失败分支。 +5. 本轮**不引入点数计费**,「不消耗点数」这半句是对的,保留。 + +--- + +## 4. 硬红线 + +1. **不得为了加速去掉任何一个体系的数据**(分盘 / 大运 / 西洋 / 七政都要留,只是改成按需)。 +2. **不得放宽 `HEAVY_COMPUTE_PATHS` 或调高 `JYOTISH_HEAVY_COMPUTE_CONCURRENCY`** 来「解决」429。生产是 2 vCPU,限流是保护,不是障碍。 +3. **不得再写 `catch {}`**。任何吞掉的异常必须留下服务端日志。 +4. `frontend/src/app/page.tsx` 不得增长(`AGENTS.md` §6,上限 1951 行,`chart-view-route.test.ts` 锁着)。 +5. `tsc --noEmit` 0 错;`npm run lint` 0 error;测试总数不降;改既有断言写三栏。 +6. 全量 `npm test` 失败清单与基线逐条一致。 +7. 新文案对照 `frontend/docs/VOICE.md`。 + +--- + +## 5. 任务分解 + +### 任务 1 · BUG-715:让失败可诊断 + +**1.1** `postEngine` 改为返回**带原因的结果**而不是 `null`,至少区分:`ok` / `busy`(429)/ `http_error`(其它非 2xx,带状态码)/ `timeout` / `bad_payload`。 + +**1.2** 每一种非 `ok` 都**打一条服务端日志**(`console.warn`),含:路径、状态码或错误名、耗时毫秒。**不得含出生资料、坐标、姓名**(`AGENTS.md` §8)——只记路径与失败类型。 + +**1.3** 用户文案按原因分开,至少两档: + +- `busy`(429)→ 「算盘的服务正忙,稍等几秒再打开就好。」——这是**真的**过一会儿会好 +- 其它 → 「这张盘算不出来,我们已经记录下来了。」——**不要**说「过一会儿再打开」,因为可能永远不会好 + +具体措辞对照 `VOICE.md` 定稿。 + +**1.4** `chart-view-load.ts` 末尾的 `catch {}` 改成记录异常(名称 + message,不含资料)再返回。 + +**验收标准**:构造 429 / 500 / 超时 / 坏 JSON 四种 `postEngine` 桩,四种各产生一条可区分的日志,且 429 与其它走不同的用户文案。新增测试覆盖四种。 + +### 任务 2 · BUG-716:开页不阻塞 + +**2.1** 进页时**只**算 `/api/chart`,并把页面尽快画出来。四个后续调用改为按需:用户点到「分盘」「大运」「西洋」「七政」哪个 tab,才请求哪一路。 + +mapper 对这四路为 `null` 已经是 `status = ok` 的优雅降级(§2.1 实测 4/4 通过),所以首屏可以直接用「四路皆 null」的 view 渲染,不需要改 mapper 的契约。 + +**2.2** 首屏在 `/api/chart` 返回前**必须有可见的页面**:标题、返回入口、出生资料行、以及一个安静的等待态。不得再出现「整页空白直到全部算完」。 + +⚠️ 等待态必须遵守 `AGENTS.md` §6:揭幕后不得出现 spinner / 骨架 / 「正在加载」。星盘页是**独立文档**,它自己的首次揭幕不受该条约束,但**揭幕之后**填 tab 数据时不得再转圈——用行内文字或占位,形式对照 `frontend/DESIGN.md` 既有等待态一节,**不要发明第五套加载动画**。 + +**2.3** 具体实现路线由执行方定(`/chart` 改为先渲染外壳、数据走客户端 fetch;或保留 SSR 但只等 `/api/chart`)。两条要求: + +- `/chart` 的**首字节到可见内容**不再取决于四个后续引擎调用。 +- 若改成客户端取数,`page.tsx` 不得增长(红线 4),新逻辑进 `frontend/src/lib/` 或 `chart-page/` 组件。 + +**2.4** `engineTimeoutMs` 从 **45 秒**下调。实测单次最慢 0.60 秒,45 秒比它大两个数量级,只会让用户多盯 45 秒空白。建议 **8–10 秒**,并在 PROGRESS 里写明取值与理由。 + +**验收标准** + +- 点侧栏「星盘」后,**1 秒内**能看到页面外壳(标题、返回、等待态),不再是白屏。 +- `/api/chart` 返回后主盘立刻可见;四个 tab 各自按需加载,未点的 tab 不发请求。 +- 模拟 `/api/chart` 挂起:页面外壳仍在,超时后给出任务 1.3 的文案,不是白屏 45 秒。 + +### 任务 3 · BUG-717:不再一开页就占满重计算配额 + 文案 + +**3.1** 任务 2.1 落地后,`/api/western` 与 `/api/qizheng` 只在用户点到对应 tab 时才发,**开页不再占用那 2 个配额槽**。确认这一点并在 PROGRESS 里写明。 + +**3.2** 加一层**服务端缓存**:同一账户 + 同一出生资料指纹 + 同一 ayanamsa 的结果可复用。 + +- key 必须含 ayanamsa 与 `node_mode`,避免 BUG-707 那类「两个页面用不同岁差」的问题。 +- 出生资料一改,缓存必须失效。 +- TTL 与落点(内存 / `api_scratch` 卷)由执行方按既有做法定,**不要新引依赖**。 +- 做不了就写进 `BLOCKED.md`,任务 2 的收益本身已经足够。 + +**3.3** 文案: + +- `chart-page-view.tsx` 里**失败分支**(第 41 行那处)**删掉** eyebrow。失败页只留「星盘」和原因。 +- 成功分支的 eyebrow 去掉「打开即有」。剩下的口径按实际:本轮改完是「主盘直接算、分盘按需、不消耗点数」这个意思,具体措辞对照 `VOICE.md`。 +- **不得**写成承诺速度的话。 + +**验收标准** + +- 开一次星盘页,抓到的引擎请求只有 `/api/chart`。 +- 失败页上不出现任何 eyebrow。 +- 成功页 eyebrow 不含「打开即有」或任何速度承诺。 +- 缓存命中时不重复打引擎(若 3.2 落地)。 + +### 任务 4 · 测试与文档 + +**4.1** 基线:`npm ci && npm test 2>&1 | tail -20`,三个数字进 PROGRESS。 + +**4.2** 文档: + +- `docs/BUG_HISTORY.md` 新增 **BUG-715 / 716 / 717**,字段齐全。BUG-715 的「防复发」必须写死:**引擎调用不得用 `catch {}` 吞掉原因,失败必须留服务端日志且用户文案按原因分档**。 +- `frontend/DESIGN.md`:星盘页一节写明「外壳先画、主盘先到、其余 tab 按需」,以及等待态形式。 +- `frontend/docs/VOICE.md`:把「打开即有」当坏例加一行对照(承诺速度的文案不写)。 +- `CHANGELOG.md`、`docs/tasks/PROGRESS-chart-page-blocking-open-20260915.md`。 +- `docs/testing/chart-page-open-20260915.md`:真机清单——点星盘后多久看到外壳、多久看到主盘、切四个 tab 各自的表现、失败时的文案。 + +--- + +## 6. 让步顺序 + +1. 任务 2.3 若把 `/chart` 改成客户端取数牵动过大,**退到「保留 SSR 但只 await `/api/chart`」**,四路后续调用仍改按需。首屏不阻塞在四路上是底线。 +2. 任务 3.2 缓存做不了就记 `BLOCKED.md`,不阻塞本单。 +3. 任务 1.3 的文案分档若产品对措辞有异议,**先把日志(1.1/1.2)落地**,文案单独再定。可诊断性不让。 +4. **绝不让步**:不得删任何体系的数据;不得放宽限流;不得再写 `catch {}`;`page.tsx` 不得增长。 + +--- + +## 7. 开工前置命令 + +```bash +cd /workspace/Jyotisha +git status -sb | head -1 +git fetch origin --prune +git worktree add -b codex/chart-page-blocking-open-20260915 \ + .worktrees/chart-page-blocking-open-20260915 origin/staging +cd .worktrees/chart-page-blocking-open-20260915/frontend +npm ci && npm test 2>&1 | tail -20 +``` + +复现本单 §2.1 的实测(可选,但强烈建议自己跑一遍): + +```bash +.venv/bin/python scripts/jyotish_api_server.py --port 5299 & +# 按 chart-view-load.ts 的 birthPayload 形状逐个 POST 计时 +``` + +交付:`git push origin HEAD:staging`,推完核对远端 SHA。 + +--- + +## 8. 串行与依赖 + +- 与 `TASK-readonly-pages-fix-20260916.md`(BUG-710~712)**同属星盘 / 星历页**,都可能改 `chart-view-*`、`app-sidebar.tsx`。**本单排在它之后**,以它合入后的 `origin/staging` 为基线。 +- 与校正线(BUG-699~709)无文件重叠。 + +--- + +## 9. BUG 编号 + +- 本单占 **BUG-715 / 716 / 717**。 +- `origin/staging@2533d5a3` 当前最大号 **BUG-714**。开工时再核一次。