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

14 KiB
Raw Blame History

TASK · P1:星盘页开一次要等很久、失败只给一句「过一会儿再打开」(BUG-715~717)

  • 日期:2026-09-15
  • 基线 commitorigin/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.pyversion 6.9.16swisseph_available: true),按 chart-view-load.ts完全相同的参数逐个计时:

端点 HTTP success
/api/chart 0.60 200 true
/api/dasha/chara 0.00 200 true
/api/varga_full17 个分盘) 0.00 200 true
/api/western 0.00 200 true
/api/qizheng 0.15 200 true

五个调用合计 0.75 秒。 引擎本身不是瓶颈。

再把这五个真实响应存盘,直接喂给 buildChartViewfrontend/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 的这一句:

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-715postEngine 把所有失败原因碾成一个 null,而且一行日志都不打

frontend/src/lib/chart-view-service.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 {} 同理:

} 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

-    router.push("/chart");
+    window.location.assign(path);

两件事叠起来是这样的时序:

  1. 点「星盘」→ 整个文档卸载重载React 树全部销毁重建
  2. 服务端渲染 /chartawait loadChartView() → 先串行 /api/chart,再并行四个
  3. 在这一切返回之前,浏览器里什么都没有——没有骨架、没有等待态、没有「星盘」两个字。动态路由的 SSR 就是全有或全无。
  4. /api/chart 若慢,用户就盯着空白等,最长 45 秒engineTimeoutMs),然后拿到那句「算不出来」

这正是「等待的时间很长」+「没结果」的完整链路。maxDuration = 60,所以 45 秒超时是能真正跑满的。

2.4 BUG-717|开一次页面就吃掉引擎全部重计算配额;文案还印在失败页上

配额scripts/api_heavy_compute_gate.pyHEAVY_COMPUTE_PATHS 里包含 /api/western/api/qizhengDEFAULT_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 / busy429/ 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 engineTimeoutMs45 秒下调。实测单次最慢 0.60 秒,45 秒比它大两个数量级,只会让用户多盯 45 秒空白。建议 810 秒,并在 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.mddocs/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. 开工前置命令

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 的实测(可选,但强烈建议自己跑一遍):

.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.mdBUG-710712同属星盘 / 星历页,都可能改 chart-view-*app-sidebar.tsx本单排在它之后,以它合入后的 origin/staging 为基线。
  • 与校正线(BUG-699~709)无文件重叠。

9. BUG 编号

  • 本单占 BUG-715 / 716 / 717
  • origin/staging@2533d5a3 当前最大号 BUG-714。开工时再核一次。