Files
Jyotisha/docs/tasks/TASK-chart-page-blocking-open-20260915.md
T
Jesse_ChenandClaude Fable 5 8caa2a2b4c docs(tasks): P1 — the chart page blocks the whole document on five engine calls
先排除了两个嫌疑,都有实测:本机起 6.9.16 引擎,按 chart-view-load 的同一
套参数逐个计时,/api/chart 0.60s、chara 0.00s、varga_full(17 分盘) 0.00s、
western 0.00s、qizheng 0.15s,合计 0.75 秒;把这五个真实响应喂给
buildChartView 跑 13 种形态(四种 birthTimeStatus、三种 ayanamsa、缺
placeLabel/timezoneId/name,以及 chara/varga/western/qizheng 分别为 null),
13 种全部 status=ok,一次没抛。引擎不慢,mapper 不抛。

瓶颈是结构:/chart 在构建里是 ƒ Dynamic,而 c6ecb86f 把侧栏入口从
router.push 改成 window.location.assign,于是点一下就整文档重载,服务端
SSR 里 await 完 1 串 4 并才开始画,浏览器在此之前什么都没有;engineTimeoutMs
是 45 秒,maxDuration 60,白屏能真跑满 45 秒(BUG-716)。

失败又不可诊断:postEngine 的 !response.ok 与 catch{} 把 429、500、超时、
坏 JSON 全碾成同一个 null,零日志;chart-view-load 末尾还有一个 catch{}。
「引擎忙等一下会好」和「这张盘有 bug 等多久都不会好」共用一句「过一会儿
再打开」(BUG-715)。

开页还并行打 /api/western 与 /api/qizheng——两个都在 HEAVY_COMPUTE_PATHS
里、配额只有 2,一次开页吃满整台容器的重计算配额,而这两路只喂给用户
可能根本不点的两个 tab;cache: "no-store" 且无服务端缓存。eyebrow
「直接计算 · 打开即有 · 不消耗点数」在失败分支也印一遍,正压在「算不出来」
上面(BUG-717)。

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0155nFCgCHtoA7jhSDGmZmMu
2026-09-15 14:13:59 +00:00

283 lines
14 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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 ← Dynamicserver-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-710712)**同属星盘 / 星历页**,都可能改 `chart-view-*``app-sidebar.tsx`。**本单排在它之后**,以它合入后的 `origin/staging` 为基线。
- 与校正线(BUG-699~709)无文件重叠。
---
## 9. BUG 编号
- 本单占 **BUG-715 / 716 / 717**
- `origin/staging@2533d5a3` 当前最大号 **BUG-714**。开工时再核一次。