diff --git a/CHANGELOG.md b/CHANGELOG.md index efbbbe90..b410a44d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,9 @@ # 印度占星 Skill 更新日志 +## 2026-09-15 — 星盘页先出外壳,主盘到了再填;分盘和大运按需才算 + +点侧栏「星盘」会先看到页面,不再对着白屏等五个计算一起回来。主盘先算;分盘、Chara 大运、西洋盘、七政四余点到那一栏才算,不消耗点数。服务忙时会说正忙、稍等再开;其它算不出来会记下,不再写「过一会儿再打开」。成功页眉标改为「主盘直接算 · 分盘按需 · 不消耗点数」。Skill 版本不变。 + ## 2026-09-15 — 校正会话按答题时间排序;链接打不开时先去库里找 在生时校正里答题、点选、采用或停止,这条会话会回到「今天」最上面。只是打开或刷新,位置不变。地址栏带着一条不在当前列表里的对话时,会先问服务器:还在就打开,确认没有才说「已被删除」。已经被写成今天日期的历史校正标题,按创建当天修回;自己改过名字的不动。Skill 版本不变。 diff --git a/docs/BUG_HISTORY.md b/docs/BUG_HISTORY.md index c38b136b..05652a6f 100644 --- a/docs/BUG_HISTORY.md +++ b/docs/BUG_HISTORY.md @@ -11109,3 +11109,51 @@ - 相关记录:TASK-qizheng-native-chart-20260915、TASK-readonly-pages-fix-20260916 - 复发自:无 - 修复版本:待发布 + +## BUG-715 | 星盘页引擎失败被碾成 null,忙和坏盘共用一句「过一会儿再打开」 + +- 状态:resolved +- 首次发现:2026-09-15 +- 最近更新:2026-09-15 +- 影响面:`frontend/src/lib/chart-view-service.ts`、`frontend/src/lib/chart-view-load.ts`、`frontend/src/lib/chart-view-engine.ts`、`/chart` +- 用户现象:星盘打开失败只看到「这张盘这会儿算不出来。资料还在,过一会儿再打开。」服务端日志里看不出是 429、500、超时还是坏 JSON。 +- 触发条件:`/api/chart` 返回非 2xx、超时、JSON 解析失败,或 mapper 抛错。 +- 根因:`postEngine` 把所有失败 `return null`,`catch {}` 吞掉异常且不打日志。用户文案把「等一下会好」和「这张盘有 bug」写成同一句。 +- 修复:引擎调用返回 `ok / busy / http_error / timeout / bad_payload`。每一种非 ok 打 `console.warn`(只含路径、状态码或错误名、耗时,不含出生资料)。429 文案「算盘的服务正忙,稍等几秒再打开就好。」其它「这张盘算不出来,我们已经记录下来了。」mapper 失败同样记日志。 +- 验证:`npx tsx --test tests/chart-view-engine.test.ts tests/chart-view-route.test.ts tests/chart-page-view.test.tsx`;四种桩各有可区分日志,429 与其它走不同文案。 +- 防复发:引擎调用不得用 `catch {}` 吞掉原因,失败必须留服务端日志且用户文案按原因分档。 +- 相关记录:BUG-716、BUG-717、TASK-chart-page-blocking-open-20260915 +- 复发自:无 +- 修复版本:待发布 + +## BUG-716 | `/chart` 动态 SSR 等完五个引擎调用才开始画,白屏最长 45 秒 + +- 状态:resolved +- 首次发现:2026-09-15 +- 最近更新:2026-09-15 +- 影响面:`frontend/src/app/chart/page.tsx`、`frontend/src/components/chart-page/chart-page-view.tsx`、`frontend/src/hooks/use-chart-page.ts`、`/api/chart-view` +- 用户现象:点侧栏「星盘」后整页空白很久,然后才出盘或失败句。侧栏已改成硬文档跳转,动态路由在服务端等完才吐 HTML。 +- 触发条件:从对话进 `/chart`。`/api/chart` 若慢或挂起,用户盯空白,最长 45 秒(`engineTimeoutMs`)。 +- 根因:`chart/page.tsx` `force-dynamic` 且 `await loadChartView()`,而 `loadChartView` 先串行 `/api/chart` 再并行四个后续调用。文档卸载重载期间浏览器里什么都没有。 +- 修复:`/chart` 改为静态壳,客户端先画标题和返回,再取 `/api/chart-view`。进页只算主盘;分盘 / Chara / 西洋 / 七政按 tab 或非 D1 chip 按需取。超时改为 10 秒(实测主盘 0.60 秒,10 秒约 16 倍余量,避免再盯 45 秒空白)。等待句用「还没拿到」,不转圈。 +- 验证:无 `view` 时外壳可见;挂起主盘不再依赖四个后续调用;源码合同禁止 `force-dynamic` / `loadChartView` 出现在 `chart/page.tsx`。 +- 防复发:`/chart` 首字节不得等待四个后续引擎调用。揭幕后填 tab 不得再上 spinner / 骨架 / 「正在加载」。 +- 相关记录:BUG-715、BUG-717、TASK-chart-page-blocking-open-20260915 +- 复发自:无 +- 修复版本:待发布 + +## BUG-717 | 开一次星盘页占满重计算配额,且「打开即有」印在失败页上 + +- 状态:resolved +- 首次发现:2026-09-15 +- 最近更新:2026-09-15 +- 影响面:`assembleChartView` 的 layer 参数、`chart-page-view.tsx` 眉标、服务端引擎结果缓存 +- 用户现象:打开星盘常失败;失败页上还写着「直接计算 · 打开即有 · 不消耗点数」。生产与 staging 重计算并发默认 2,开页并行打 `/api/western` 与 `/api/qizheng` 就会占满。 +- 触发条件:打开 `/chart`。同时有第二个人开星盘,或校正 / 咨询在跑,更容易 429。 +- 根因:进页无条件并行打两个 `HEAVY_COMPUTE_PATHS` 端点;`cache: "no-store"` 且无记忆;失败分支也渲染同一句速度承诺眉标。 +- 修复:开页只请求 `/api/chart`。西洋 / 七政点到对应 tab 才发,不再占开页配额。同一账户 + 出生资料指纹 + ayanamsa + `node_mode` 的引擎结果内存缓存 5 分钟,资料或岁差一变即换键。失败页删除眉标;成功眉标改为「主盘直接算 · 分盘按需 · 不消耗点数」。不放宽限流。 +- 验证:无 layers 时 `assembleChartView` 的引擎路径只有 `/api/chart`;失败页无 eyebrow;缓存键随 ayanamsa / node_mode 变化。 +- 防复发:开页不得并行打 `/api/western` 与 `/api/qizheng`。不得靠放宽 `HEAVY_COMPUTE_PATHS` 或提高 `JYOTISH_HEAVY_COMPUTE_CONCURRENCY` 来「解决」429。承诺速度的文案不写。 +- 相关记录:BUG-707、BUG-715、BUG-716、TASK-chart-page-blocking-open-20260915 +- 复发自:无 +- 修复版本:待发布 diff --git a/docs/tasks/PROGRESS-chart-page-blocking-open-20260915.md b/docs/tasks/PROGRESS-chart-page-blocking-open-20260915.md new file mode 100644 index 00000000..1121a097 --- /dev/null +++ b/docs/tasks/PROGRESS-chart-page-blocking-open-20260915.md @@ -0,0 +1,66 @@ +# PROGRESS · 星盘页开页不再整页阻塞(2026-09-15) + +工作树:`.worktrees/chart-page-blocking-open-20260915` +分支:`codex/chart-page-blocking-open-20260915` +任务书基线:`origin/staging` @ `8caa2a2b`(任务书本身;代码基线 `2533d5a3`) +本机 Windows。 + +Skill **未 bump**。未改 `scripts/**`、`deploy/**`、`vendor/**`、`frontend/src/app/page.tsx`、`vedic-chart-svg.tsx`。未放宽 `HEAVY_COMPUTE_PATHS` / `JYOTISH_HEAVY_COMPUTE_CONCURRENCY`。 + +## 开工基线 + +| 项 | 数字 | +| --- | --- | +| `frontend/src/app/page.tsx` | **1951** 行 | +| 最大 BUG 号 | **BUG-714**(本单占用 715–717) | +| `tsc --noEmit` | 收尾 0 错 | +| `npm run lint` | 0 error(全仓既有 warning 119;本单新文件 0 warning) | + +`frontend/node_modules` 用 junction 指向主仓。 + +## 任务状态 + +| 任务 | 状态 | 说明 | +| --- | --- | --- | +| 1 BUG-715 失败可诊断 | 完成 | `ok / busy / http_error / timeout / bad_payload`;非 ok 打 `console.warn`(路径、状态码或错误名、耗时;不含资料);429 与其它分档文案;`catch {}` 去掉 | +| 2 BUG-716 开页不阻塞 | 完成 | `/chart` 改为静态壳;进页只算 `/api/chart`;四个后续按 tab / 非 D1 chip 按需;超时 **10 秒** | +| 3 BUG-717 配额与文案 | 完成 | 开页不再打 western / qizheng;内存缓存 5 分钟,键含 ayanamsa + `node_mode`;失败页无眉标;成功眉标去掉「打开即有」 | +| 4 测试与文档 | 完成 | BUG-715/716/717、DESIGN、VOICE、CHANGELOG、真人清单、PROGRESS | + +## 实现要点 + +- `postEngine` 不再 `return null`。分类在 `chart-view-engine.ts`,日志在 `assembleChartView` 的 `callEngine` 包装层,mapper `catch (error)` 记 `errorName` + `message`。 +- `/chart` 不再 `force-dynamic`、不再 `await loadChartView()`。`ChartPageRoute` 先画外壳,再 `GET /api/chart-view`。未登录仍跳 `/login`。 +- `GET /api/chart-view?layers=varga,chara,western,qizheng` 按需取后续。缺省 layers 只有 `/api/chart`。 +- 等待态沿用星历页静态句:「这一张盘还没拿到。」「这一栏还没拿到。」「这一分盘还没拿到。」揭幕后无 spinner / 骨架 / 「正在加载」。 +- 引擎结果缓存在 Next 进程内存(与 rate-limit / feature-flag 同一做法)。`api_scratch` 在 API 容器上,BFF 没有这个卷,没有新依赖。失败不写缓存。TTL 5 分钟。 + +## 超时取值 + +`CHART_VIEW_ENGINE_TIMEOUT_MS = 10_000`。任务书实测 `/api/chart` 0.60 秒、五个合计 0.75 秒。45 秒大两个数量级,只会让人盯空白。10 秒大约是实测的 16 倍,给冷启动和跨容器网络留余量;挂起时十秒内落到失败文案。 + +## 既有断言改动 + +| 文件 | 原值 | 新值 | 原因 | +| --- | --- | --- | --- | +| `chart-page-view.test.tsx` 成功页眉标 | `直接计算 · 打开即有 · 不消耗点数` | `主盘直接算 · 分盘按需 · 不消耗点数` | BUG-717:打开即有是速度承诺,且曾印在失败页上 | +| `chart-view-route.test.ts` western/qizheng 是否被调用 | 无 layers 也会打 `/api/western` 与 `/api/qizheng` | 只有请求对应 layer 才打;无 layers 时路径只有 `/api/chart` | BUG-716/717 开页不得占满重计算配额 | +| `assembleChartView` 的 `postEngine` 形状 | `Record \| null` | `EngineCallResult` | BUG-715 失败必须带原因 | + +未弱化其它既有断言。未改 `page.tsx`(仍 1951 行)。 + +## 测试 + +| 命令 | 结果 | +| --- | --- | +| `npx tsx --test tests/chart-view-engine.test.ts tests/chart-view-route.test.ts tests/chart-page-view.test.tsx` | **27 pass / 0 fail**(本单相关;其中新增约 12 条) | +| `./node_modules/.bin/tsc --noEmit` | **0 错** | +| `npm run lint` | **0 error**(119 条既有 warning,本单新文件未新增) | +| `page.tsx` | 仍 1951 行,未改 | +| `npx next build --webpack` | compile + TypeScript 过;Collecting page data 死在既有 `SkillPackageRegistryError`(EPERM symlink `/api/birth-time-guide`),与本单无关。未能从本机构建表确认 `/` 与 `/chart` 的 `○ Static`。源码合同:`chart/page.tsx` 已去掉 `force-dynamic` / `loadChartView`。 | +| `npm test` 全量 | `# tests 3101 / # pass 3014 / # fail 73 / # skipped 14`。失败集中在无 Docker 的 database-*、部署/Caddy、Skill SHA、Windows 路径。失败清单不含本单文件。本机不是 Linux 验收机。 | + +## 环境缺口 + +- 无登录态、无 Chrome:真人走查见 `docs/testing/chart-page-open-20260915.md`。 +- `next build` 收集页面数据时 symlink EPERM,无法在本机核对路由表。 diff --git a/docs/tasks/README.md b/docs/tasks/README.md index d951caed..a85a111c 100644 --- a/docs/tasks/README.md +++ b/docs/tasks/README.md @@ -227,7 +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-chart-page-blocking-open-20260915.md` | `PROGRESS-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/testing/chart-page-20260915.md b/docs/testing/chart-page-20260915.md index 25ed9df4..9448e16a 100644 --- a/docs/testing/chart-page-20260915.md +++ b/docs/testing/chart-page-20260915.md @@ -15,8 +15,8 @@ ## 五个 Tab -6. 登录且出生资料齐全:打开星盘没有 spinner、骨架或「正在加载」,揭幕就是完整页。 -7. 眉标是「直接计算 · 打开即有 · 不消耗点数」。点数余额不应因打开此页而变。 +6. 登录且出生资料齐全:点「星盘」后先看到外壳(返回、标题),不是白屏。主盘到达前可以出现「这一张盘还没拿到。」,不得出现 spinner、骨架或「正在加载」。 +7. 成功页眉标是「主盘直接算 · 分盘按需 · 不消耗点数」。失败页没有眉标。点数余额不应因打开此页而变。 8. Tab 顺序:星盘 / 基础信息 / 大运 / 西洋盘 / 七政四余。都能点。 9. 星盘 Tab:D1–D30 chip 可换;盘面是北印菱形盘;右侧有行星表(星体 / 星座与度数 / 宫位 / 状态)。 10. 桌面中宫是六行参数;把窗口收到手机宽度(≤767)后,中宫只剩岁差和交点两行,完整参数出现在盘下方。 diff --git a/docs/testing/chart-page-open-20260915.md b/docs/testing/chart-page-open-20260915.md new file mode 100644 index 00000000..c8036d80 --- /dev/null +++ b/docs/testing/chart-page-open-20260915.md @@ -0,0 +1,28 @@ +# 真人验收 · 星盘页开页不再整页阻塞(2026-09-15) + +执行环境无登录态、无 Chrome。下列条目留给有真实账号的人在 staging 走查。本单自动化不替代这些。 + +## 开页外壳 + +1. 登录后点侧栏「星盘」。**大约 1 秒内**能看到页面外壳:返回对话、标题「星盘」。不要整页白屏等到引擎全部算完。 +2. 主盘还没到时,等待句是「这一张盘还没拿到。」没有转圈、骨架或「正在加载」。 +3. 主盘到达后北印盘立刻可见。出生资料行出现。成功眉标是「主盘直接算 · 分盘按需 · 不消耗点数」,没有「打开即有」。 + +## 按需加载 + +4. 开页后(还没点其它 tab)开发者工具里发给引擎的路径只有 `/api/chart`。不要同时出现 `/api/western` 或 `/api/qizheng`。 +5. 点「大运」:Vimshottari 可以立刻出现(来自主盘);Chara 未到时写「这一栏还没拿到。」,到了再填。未点之前不应请求 `/api/dasha/chara`。 +6. 点「西洋盘」才请求 `/api/western`。未点不发。等待时写「这一栏还没拿到。」 +7. 点「七政四余」才请求 `/api/qizheng`。未点不发。等待时写「这一栏还没拿到。」 +8. 星盘 tab 默认 D1。点 D9 等非 D1 chip 才请求 `/api/varga_full`。等待时写「这一分盘还没拿到。」 + +## 失败文案 + +9. 失败页没有眉标,没有「打开即有」,没有「过一会儿再打开」。 +10. 若刚好赶上引擎忙(429):「算盘的服务正忙,稍等几秒再打开就好。」稍等再开,有机会好。 +11. 其它失败:「这张盘算不出来,我们已经记录下来了。」不要暗示等一会儿就会好。 + +## 回归 + +12. 点数余额不因打开此页而变。 +13. 资料不全时仍是人话去填星盘资料,不是 500,也没有眉标。 diff --git a/frontend/DESIGN.md b/frontend/DESIGN.md index 22b1dea5..fe92ee81 100644 --- a/frontend/DESIGN.md +++ b/frontend/DESIGN.md @@ -578,11 +578,13 @@ Admin 的 antd `` 是独立设计系统,不在此表。 ## 15. 只读星盘页 -独立 route `/chart`,不进 `page.tsx`。打开即有,不扣点、不调模型、不出现 spinner / 骨架 / 「正在加载」。盘面从现有路径复用 `VedicChartSvg`,不搬家。 +独立 route `/chart`,不进 `page.tsx`。外壳先画、主盘先到、其余 tab 按需。不扣点、不调模型。盘面从现有路径复用 `VedicChartSvg`,不搬家。 + +`/chart` 是静态壳:首字节不取决于引擎。进页只请求 `/api/chart`。分盘(`/api/varga_full`)、大运 Chara(`/api/dasha/chara`)、西洋(`/api/western`)、七政(`/api/qizheng`)在用户点到对应 tab / 非 D1 chip 时才请求。未点的 tab 不发请求。 ### 结构 -页眉:眉标「直接计算 · 打开即有 · 不消耗点数」+ 标题「星盘」+ 出生资料一行。五个 Tab 按体系分:**星盘 / 基础信息 / 大运 / 西洋盘 / 七政四余**。分盘 chip(D1–D30)只出现在印度「星盘」Tab 内部,不得把西洋盘或七政四余做成第 13 个 chip。 +页眉:成功时眉标「主盘直接算 · 分盘按需 · 不消耗点数」+ 标题「星盘」+ 出生资料一行。等待外壳和失败页只有标题和原因/等待句,没有眉标。不得写「打开即有」或任何速度承诺。五个 Tab 按体系分:**星盘 / 基础信息 / 大运 / 西洋盘 / 七政四余**。分盘 chip(D1–D30)只出现在印度「星盘」Tab 内部,不得把西洋盘或七政四余做成第 13 个 chip。 侧栏在「新建对话」之后、「我的报告」之前加「星盘」「星历」两个入口;折叠轨(64px)仍用既有 `report-nav-button` 图标行,当前项用白玻璃面 + 2px 深棕标记,不新造选中态。星历入口指向 `/ephemeris`(由另一单交付,合入前 404 是预期)。 @@ -602,6 +604,18 @@ Admin 的 antd `` 是独立设计系统,不在此表。 「传统象征」是词条式释义,带边界句「不是对你个人的判断」,不得写成运势。大运 Tab 底部固定:「只有两条同时指向同一段时间才算证据,单轨命中要降一级来看。」Chara 的正式名称是 **Chara Dasha(kn_rao 变体)**,不要写成 Narayana。 +### 等待态 + +星盘页是独立文档。外壳揭幕(标题、返回)可以在主盘到达之前发生;揭幕之后填数据不得再出现 spinner / 骨架 / 「正在加载」,沿用 §9 星历页那一类静态句,不新造第五套加载动画。 + +| 时刻 | 文案 | +| --- | --- | +| 外壳已出、主盘未到 | 这一张盘还没拿到。 | +| 已点大运 / 西洋 / 七政,该路还没返回 | 这一栏还没拿到。 | +| 已点非 D1 分盘,分盘包还没返回 | 这一分盘还没拿到。 | +| 引擎 429 | 算盘的服务正忙,稍等几秒再打开就好。 | +| 其它主盘失败 | 这张盘算不出来,我们已经记录下来了。 | + ## 16. 星历页 独立 route `/ephemeris`。侧栏入口由星盘页那一单加;本页不改 `app-sidebar.tsx`,合入前用直接访问 URL 验收。不扣点、不调模型。`html, body` 仍然 `overflow: hidden`,本页用 `.ephemeris-shell` 自管滚动,和报告中心同一做法。 diff --git a/frontend/docs/VOICE.md b/frontend/docs/VOICE.md index add2eddb..cfd02c0d 100644 --- a/frontend/docs/VOICE.md +++ b/frontend/docs/VOICE.md @@ -58,6 +58,7 @@ Jyotisha 的可见文案是产品的一部分。正确性红线(真实性、 | 吻合率低时模型追问要不要把误差放到 ±2 小时 | 按你说的经历,出生时间可能比家人记的偏得更多。放宽后再比一次? | 放宽卡由服务端出,选项写「前后半小时 / 一小时 / 两小时」,不用偏移、误差、置信度、概率。 | | 只知道傍晚时直接给分钟或采用卡 | 先切成三段(写起止钟点),比完再按分钟收。分不开就说「时段分不开,直接按分钟比」。 | 超过两小时的窗口先走子段,不在 4~6 小时上铺分钟卡。 | | 记下精确到日的事后不问日子从哪来 | 刚才那个日子是查过记录,还是凭记忆? | 只问日级、只问一次;不答就跳过。 | +| 打开即有 / 直接计算 · 打开即有 · 不消耗点数 | 主盘直接算 · 分盘按需 · 不消耗点数 | 不承诺打开速度。失败页不写眉标。忙(429)说「算盘的服务正忙,稍等几秒再打开就好。」;其它失败说「这张盘算不出来,我们已经记录下来了。」不要说「过一会儿再打开」。 | ## 服务端探针 → Agent 题干 diff --git a/frontend/src/app/api/chart-view/route.ts b/frontend/src/app/api/chart-view/route.ts index 51a9da41..cada5af6 100644 --- a/frontend/src/app/api/chart-view/route.ts +++ b/frontend/src/app/api/chart-view/route.ts @@ -1,11 +1,13 @@ import { NextResponse } from "next/server"; +import { parseChartViewLayers } from "@/lib/chart-view-engine"; import { loadChartView } from "@/lib/chart-view-service"; export const runtime = "nodejs"; -export const maxDuration = 60; +export const maxDuration = 20; -export async function GET() { - const result = await loadChartView(); +export async function GET(request: Request) { + const layers = parseChartViewLayers(new URL(request.url).searchParams.get("layers")); + const result = await loadChartView({ layers }); return NextResponse.json(result.body, { status: result.httpStatus }); } diff --git a/frontend/src/app/chart/page.tsx b/frontend/src/app/chart/page.tsx index 4054051c..d2838519 100644 --- a/frontend/src/app/chart/page.tsx +++ b/frontend/src/app/chart/page.tsx @@ -1,19 +1,13 @@ import type { Metadata } from "next"; -import { redirect } from "next/navigation"; -import { ChartPageView } from "@/components/chart-page/chart-page-view"; -import { loadChartView } from "@/lib/chart-view-service"; +import { ChartPageRoute } from "@/components/chart-page/chart-page-view"; import "../site-styles"; -export const dynamic = "force-dynamic"; - export const metadata: Metadata = { title: "星盘 · Jyotisha", robots: { index: false, follow: false }, }; -export default async function ChartPage() { - const result = await loadChartView(); - if (result.httpStatus === 401) redirect("/login"); - return ; +export default function ChartPage() { + return ; } diff --git a/frontend/src/components/chart-page/chart-dasha-tab.tsx b/frontend/src/components/chart-page/chart-dasha-tab.tsx index 4b27418f..6fd398da 100644 --- a/frontend/src/components/chart-page/chart-dasha-tab.tsx +++ b/frontend/src/components/chart-page/chart-dasha-tab.tsx @@ -1,4 +1,5 @@ import type { ChartViewOk } from "@/lib/chart-view-contract"; +import { CHART_VIEW_COPY } from "@/lib/chart-view-labels"; function Track({ title, @@ -41,12 +42,24 @@ function Track({ ); } -export function ChartDashaTab({ view }: { view: ChartViewOk }) { +export function ChartDashaTab({ + view, + charaPending = false, +}: { + view: ChartViewOk; + charaPending?: boolean; +}) { return (
- + {charaPending ? ( +
+

{CHART_VIEW_COPY.waitingLayer}

+
+ ) : ( + + )}

{view.dasha.dualTrackNote}

diff --git a/frontend/src/components/chart-page/chart-page-view.tsx b/frontend/src/components/chart-page/chart-page-view.tsx index fd08adfa..496f5d56 100644 --- a/frontend/src/components/chart-page/chart-page-view.tsx +++ b/frontend/src/components/chart-page/chart-page-view.tsx @@ -4,28 +4,60 @@ import Link from "next/link"; import { ArrowLeft } from "lucide-react"; import { useState } from "react"; +import { useChartPage } from "@/hooks/use-chart-page"; import { CHART_VIEW_TABS, type ChartViewResponse, type ChartViewTabId, } from "@/lib/chart-view-contract"; +import type { ChartViewLayer } from "@/lib/chart-view-engine"; +import { CHART_VIEW_COPY } from "@/lib/chart-view-labels"; import { ChartBasicsTab } from "./chart-basics-tab"; import { ChartDashaTab } from "./chart-dasha-tab"; import { ChartQizhengTab } from "./chart-qizheng-tab"; import { ChartVedicTab } from "./chart-vedic-tab"; import { ChartWesternTab } from "./chart-western-tab"; +export function ChartPageRoute() { + const { view, pendingLayers, requestLayer } = useChartPage(); + return ( + + ); +} + +function WaitingNote({ copy }: { copy: string }) { + return

{copy}

; +} + export function ChartPageView({ view, + pendingLayers, + onNeedLayer, initialTab = "vedic", }: { - view: ChartViewResponse; + view: ChartViewResponse | null; + pendingLayers?: ReadonlySet; + onNeedLayer?: (layer: ChartViewLayer) => void; initialTab?: ChartViewTabId; }) { const [tab, setTab] = useState(initialTab); - const [vargaId, setVargaId] = useState( - view.status === "ok" ? view.vedic.vargas[0]?.id ?? "D1" : "D1", - ); + const [vargaId, setVargaId] = useState("D1"); + + function selectTab(id: ChartViewTabId) { + setTab(id); + if (id === "dasha") onNeedLayer?.("chara"); + if (id === "western") onNeedLayer?.("western"); + if (id === "qizheng") onNeedLayer?.("qizheng"); + } + + function selectVarga(id: string) { + setVargaId(id); + if (id !== "D1") onNeedLayer?.("varga"); + } return (
@@ -36,16 +68,20 @@ export function ChartPageView({ - {view.status !== "ok" ? ( + {view == null ? ( +
+

星盘

+

{CHART_VIEW_COPY.waitingChart}

+
+ ) : view.status !== "ok" ? (
-

直接计算 · 打开即有 · 不消耗点数

星盘

{view.message}

) : ( <>
-

直接计算 · 打开即有 · 不消耗点数

+

{CHART_VIEW_COPY.eyebrow}

星盘

{view.profile.name} · {view.profile.date} {view.profile.time} · {view.profile.placeLabel} @@ -59,7 +95,7 @@ export function ChartPageView({ role="tab" aria-selected={tab === item.id} className={tab === item.id ? "chart-page-tab is-current" : "chart-page-tab"} - onClick={() => setTab(item.id)} + onClick={() => selectTab(item.id)} > {item.label} @@ -69,14 +105,28 @@ export function ChartPageView({ {tab === "vedic" ? ( item.id === vargaId) ?? view.vedic.vargas[0]!} - onSelectVarga={setVargaId} + vargaId={vargaId} + onSelectVarga={selectVarga} /> ) : null} {tab === "basics" ? : null} - {tab === "dasha" ? : null} - {tab === "western" ? : null} - {tab === "qizheng" ? : null} + {tab === "dasha" ? ( + + ) : null} + {tab === "western" ? ( + pendingLayers?.has("western") ? ( + + ) : ( + + ) + ) : null} + {tab === "qizheng" ? ( + pendingLayers?.has("qizheng") ? ( + + ) : ( + + ) + ) : null}

)} diff --git a/frontend/src/components/chart-page/chart-vedic-tab.tsx b/frontend/src/components/chart-page/chart-vedic-tab.tsx index 83f1ba76..d424693f 100644 --- a/frontend/src/components/chart-page/chart-vedic-tab.tsx +++ b/frontend/src/components/chart-page/chart-vedic-tab.tsx @@ -1,76 +1,89 @@ "use client"; import { VedicChartSvg } from "@/components/personal-report/vedic-chart-svg"; -import type { ChartViewOk, ChartViewVarga } from "@/lib/chart-view-contract"; +import type { ChartViewOk } from "@/lib/chart-view-contract"; +import { CHART_VIEW_COPY, VARGA_CHIP_ORDER } from "@/lib/chart-view-labels"; import { formatDegree } from "./chart-format"; import { ChartParamLines } from "./chart-param-lines"; export function ChartVedicTab({ view, - varga, + vargaId, onSelectVarga, }: { view: ChartViewOk; - varga: ChartViewVarga; + vargaId: string; onSelectVarga: (id: string) => void; }) { + const selected = view.vedic.vargas.find((item) => item.id === vargaId); + const natal = view.vedic.vargas.find((item) => item.id === "D1") ?? view.vedic.vargas[0]; + const varga = selected ?? (vargaId === "D1" ? natal : undefined); + const waiting = vargaId !== "D1" && !selected; + return (
- {view.vedic.vargas.map((item) => ( - - ))} + {VARGA_CHIP_ORDER.map((division) => { + const id = `D${division}`; + return ( + + ); + })}
-
-
- -
-
- -
-
- + {waiting || !varga ? ( +

{CHART_VIEW_COPY.waitingVarga}

+ ) : ( +
+
+ +
+
+ +
+
+ +
-
-
- -

{view.vedic.boundary}

-
-
- - - - - - - - - - - - {view.vedic.planets.map((planet) => ( - - - - - +
+ +

{view.vedic.boundary}

+
+
+
{varga.id} · {varga.meaning}
星体星座与度数宫位状态
{planet.label}{planet.signLabel} {formatDegree(planet.degreeInSign)}{planet.house}{planet.status}
+ + + + + + + - ))} - -
{varga.id} · {varga.meaning}
星体星座与度数宫位状态
+ + + {view.vedic.planets.map((planet) => ( + + {planet.label} + {planet.signLabel} {formatDegree(planet.degreeInSign)} + {planet.house} + {planet.status} + + ))} + + +
-
-

{view.vedic.boundary}

+ )} + {varga ?

{view.vedic.boundary}

: null}
); } diff --git a/frontend/src/hooks/use-chart-page.ts b/frontend/src/hooks/use-chart-page.ts new file mode 100644 index 00000000..85792962 --- /dev/null +++ b/frontend/src/hooks/use-chart-page.ts @@ -0,0 +1,78 @@ +"use client"; + +import { useCallback, useEffect, useRef, useState } from "react"; + +import { fetchChartView } from "@/lib/chart-view-client"; +import type { ChartViewResponse } from "@/lib/chart-view-contract"; +import type { ChartViewLayer } from "@/lib/chart-view-engine"; +import { CHART_VIEW_COPY } from "@/lib/chart-view-labels"; + +function unavailableBody(): ChartViewResponse { + return { status: "chart_unavailable", billed: false, message: CHART_VIEW_COPY.unavailable }; +} + +function warnClientFailure(label: string, error: unknown, extra?: Record): void { + console.warn(label, { + errorName: error instanceof Error ? error.name : "Error", + message: error instanceof Error ? error.message : "unknown", + ...extra, + }); +} + +export function useChartPage() { + const [view, setView] = useState(null); + const [pendingLayers, setPendingLayers] = useState>(new Set()); + const loadedLayers = useRef(new Set()); + const inflightLayers = useRef(new Set()); + + useEffect(() => { + const controller = new AbortController(); + void fetchChartView({ signal: controller.signal }) + .then((result) => { + if (controller.signal.aborted) return; + if (result.httpStatus === 401) { + window.location.assign("/login"); + return; + } + setView(result.body ?? unavailableBody()); + }) + .catch((error: unknown) => { + if (controller.signal.aborted) return; + warnClientFailure("chart_page_fetch_failed", error); + setView(unavailableBody()); + }); + return () => controller.abort(); + }, []); + + const requestLayer = useCallback((layer: ChartViewLayer) => { + if (loadedLayers.current.has(layer) || inflightLayers.current.has(layer)) return; + inflightLayers.current.add(layer); + setPendingLayers((current) => { + if (current.has(layer)) return current; + const next = new Set(current); + next.add(layer); + return next; + }); + void fetchChartView({ layers: [layer] }) + .then((result) => { + if (result.body?.status === "ok") { + loadedLayers.current.add(layer); + setView(result.body); + } + }) + .catch((error: unknown) => { + warnClientFailure("chart_page_layer_failed", error, { layer }); + }) + .finally(() => { + inflightLayers.current.delete(layer); + setPendingLayers((current) => { + if (!current.has(layer)) return current; + const next = new Set(current); + next.delete(layer); + return next; + }); + }); + }, []); + + return { view, pendingLayers, requestLayer }; +} diff --git a/frontend/src/lib/chart-view-client.ts b/frontend/src/lib/chart-view-client.ts new file mode 100644 index 00000000..bbfa7c81 --- /dev/null +++ b/frontend/src/lib/chart-view-client.ts @@ -0,0 +1,25 @@ +import { chartViewResponseSchema, type ChartViewResponse } from "./chart-view-contract.ts"; +import type { ChartViewLayer } from "./chart-view-engine.ts"; + +export async function fetchChartView(input: { + layers?: readonly ChartViewLayer[]; + signal?: AbortSignal; +}): Promise<{ httpStatus: number; body: ChartViewResponse | null }> { + const query = input.layers?.length ? `?layers=${input.layers.join(",")}` : ""; + const response = await fetch(`/api/chart-view${query}`, { + credentials: "same-origin", + headers: { Accept: "application/json" }, + cache: "no-store", + signal: input.signal, + }); + let json: unknown = null; + try { + json = await response.json(); + } catch (error) { + const errorName = error instanceof Error ? error.name : "Error"; + console.warn("chart_view_client_bad_payload", { errorName, httpStatus: response.status }); + return { httpStatus: response.status, body: null }; + } + const parsed = chartViewResponseSchema.safeParse(json); + return { httpStatus: response.status, body: parsed.success ? parsed.data : null }; +} diff --git a/frontend/src/lib/chart-view-engine.ts b/frontend/src/lib/chart-view-engine.ts new file mode 100644 index 00000000..bf0b3a36 --- /dev/null +++ b/frontend/src/lib/chart-view-engine.ts @@ -0,0 +1,148 @@ +export const CHART_VIEW_ENGINE_TIMEOUT_MS = 10_000; +export const CHART_VIEW_ENGINE_CACHE_TTL_MS = 5 * 60 * 1_000; +const ENGINE_CACHE_MAX_ENTRIES = 200; + +export const CHART_VIEW_LAYERS = ["varga", "chara", "western", "qizheng"] as const; +export type ChartViewLayer = (typeof CHART_VIEW_LAYERS)[number]; + +export type EngineCallResult = + | { readonly status: "ok"; readonly payload: Record } + | { + readonly status: "busy" | "http_error" | "timeout" | "bad_payload"; + readonly path: string; + readonly elapsedMs: number; + readonly httpStatus?: number; + readonly errorName?: string; + }; + +type CacheStore = Map; expiresAt: number }>; + +const state = globalThis as typeof globalThis & { + jyotishaChartViewEngineCache?: CacheStore; +}; + +function engineCache(): CacheStore { + state.jyotishaChartViewEngineCache ??= new Map(); + return state.jyotishaChartViewEngineCache; +} + +export function parseChartViewLayers(raw: string | null | undefined): ChartViewLayer[] { + if (!raw) return []; + const wanted = new Set(); + for (const part of raw.split(",")) { + const id = part.trim(); + if ((CHART_VIEW_LAYERS as readonly string[]).includes(id)) { + wanted.add(id as ChartViewLayer); + } + } + return CHART_VIEW_LAYERS.filter((id) => wanted.has(id)); +} + +export function chartViewEngineCacheKey(input: { + userId: string; + date: string; + time: string; + latitude: number; + longitude: number; + timezoneOffset: number; + ayanamsa: string; + nodeMode: string; + path: string; +}): string { + return [ + input.userId, + input.path, + input.date, + input.time, + input.latitude.toFixed(6), + input.longitude.toFixed(6), + String(input.timezoneOffset), + input.ayanamsa, + input.nodeMode, + ].join("\u001f"); +} + +export function readChartViewEngineCache(key: string, now = Date.now()): Record | null { + const entry = engineCache().get(key); + if (!entry) return null; + if (entry.expiresAt <= now) { + engineCache().delete(key); + return null; + } + return entry.payload; +} + +export function writeChartViewEngineCache( + key: string, + payload: Record, + now = Date.now(), +): void { + const store = engineCache(); + if (store.size >= ENGINE_CACHE_MAX_ENTRIES) { + for (const [itemKey, entry] of store) { + if (entry.expiresAt <= now) store.delete(itemKey); + } + if (store.size >= ENGINE_CACHE_MAX_ENTRIES) { + const first = store.keys().next().value; + if (typeof first === "string") store.delete(first); + } + } + store.set(key, { payload, expiresAt: now + CHART_VIEW_ENGINE_CACHE_TTL_MS }); +} + +export function clearChartViewEngineCache(): void { + engineCache().clear(); +} + +export function engineCallFromHttp(input: { + path: string; + elapsedMs: number; + httpStatus: number; + payload: unknown; +}): EngineCallResult { + if (input.httpStatus === 429) { + return { + status: "busy", + path: input.path, + elapsedMs: input.elapsedMs, + httpStatus: input.httpStatus, + }; + } + if (input.httpStatus < 200 || input.httpStatus >= 300) { + return { + status: "http_error", + path: input.path, + elapsedMs: input.elapsedMs, + httpStatus: input.httpStatus, + }; + } + if (!input.payload || typeof input.payload !== "object" || Array.isArray(input.payload)) { + return { + status: "bad_payload", + path: input.path, + elapsedMs: input.elapsedMs, + httpStatus: input.httpStatus, + }; + } + return { status: "ok", payload: input.payload as Record }; +} + +export function engineCallFromThrown(path: string, elapsedMs: number, error: unknown): EngineCallResult { + const errorName = error instanceof Error ? error.name : "Error"; + const status = errorName === "TimeoutError" || errorName === "AbortError" ? "timeout" : "http_error"; + return { status, path, elapsedMs, errorName }; +} + +export function logEngineCallFailure(result: Exclude): void { + console.warn("chart_view_engine_failed", { + path: result.path, + status: result.status, + httpStatus: result.httpStatus ?? null, + errorName: result.errorName ?? null, + elapsedMs: result.elapsedMs, + }); +} + +export function enginePayload(result: EngineCallResult): Record | null { + return result.status === "ok" ? result.payload : null; +} diff --git a/frontend/src/lib/chart-view-labels.ts b/frontend/src/lib/chart-view-labels.ts index 9196131d..d67d6ad1 100644 --- a/frontend/src/lib/chart-view-labels.ts +++ b/frontend/src/lib/chart-view-labels.ts @@ -152,6 +152,18 @@ export const QIZHENG_RULES = [ "庙旺表还没闭合,这一列只能当占位,不能拿来下判断。", ] as const; +export const CHART_VIEW_COPY = { + eyebrow: "主盘直接算 · 分盘按需 · 不消耗点数", + busy: "算盘的服务正忙,稍等几秒再打开就好。", + unavailable: "这张盘算不出来,我们已经记录下来了。", + waitingChart: "这一张盘还没拿到。", + waitingLayer: "这一栏还没拿到。", + waitingVarga: "这一分盘还没拿到。", + unauthenticated: "请先登录后再看星盘。", + incomplete: "还没有可用来排盘的出生资料。把日期、时间和地点填进星盘资料后,打开就能看见盘。", + rateLimited: "刚才打开得太勤,稍等再试。", +} as const; + export function planetZh(name: string): string { return PLANET_ZH[name as PlanetName] ?? name; } diff --git a/frontend/src/lib/chart-view-load.ts b/frontend/src/lib/chart-view-load.ts index c43d1839..6f4c1bf7 100644 --- a/frontend/src/lib/chart-view-load.ts +++ b/frontend/src/lib/chart-view-load.ts @@ -3,7 +3,13 @@ import { chartViewMessageSchema, type ChartViewResponse, } from "./chart-view-contract.ts"; -import { VARGA_CHIP_ORDER } from "./chart-view-labels.ts"; +import { + enginePayload, + logEngineCallFailure, + type ChartViewLayer, + type EngineCallResult, +} from "./chart-view-engine.ts"; +import { CHART_VIEW_COPY, VARGA_CHIP_ORDER } from "./chart-view-labels.ts"; import { buildChartView, engineChartHasPlanets, @@ -13,7 +19,7 @@ import { export type ChartViewEnginePost = ( path: string, body: Record, -) => Promise | null>; +) => Promise; export type ChartViewLoadInput = { userId: string | null; @@ -21,6 +27,7 @@ export type ChartViewLoadInput = { postEngine: ChartViewEnginePost; asOf: string; rateLimited?: boolean; + layers?: readonly ChartViewLayer[]; }; function message( @@ -52,6 +59,20 @@ function birthPayload(profile: ChartViewProfileInput): Record { }; } +function wantedLayers(layers: readonly ChartViewLayer[] | undefined): Set { + return new Set(layers ?? []); +} + +async function callEngine( + postEngine: ChartViewEnginePost, + path: string, + body: Record, +): Promise { + const result = await postEngine(path, body); + if (result.status !== "ok") logEngineCallFailure(result); + return result; +} + export async function assembleChartView(input: ChartViewLoadInput): Promise<{ httpStatus: number; body: ChartViewResponse; @@ -59,29 +80,45 @@ export async function assembleChartView(input: ChartViewLoadInput): Promise<{ if (!input.userId) { return { httpStatus: 401, - body: message("unauthenticated", "请先登录后再看星盘。"), + body: message("unauthenticated", CHART_VIEW_COPY.unauthenticated), }; } if (input.rateLimited) { return { httpStatus: 429, - body: message("rate_limited", "刚才打开得太勤,稍等再试。"), + body: message("rate_limited", CHART_VIEW_COPY.rateLimited), }; } const profile = input.profile; if (!profile) { return { httpStatus: 200, - body: message("birth_profile_incomplete", "还没有可用来排盘的出生资料。把日期、时间和地点填进星盘资料后,打开就能看见盘。"), + body: message("birth_profile_incomplete", CHART_VIEW_COPY.incomplete), }; } const payload = birthPayload(profile); - const chart = await input.postEngine("/api/chart", payload); - if (!chart || chart.success === false || !engineChartHasPlanets(chart)) { + const chartResult = await callEngine(input.postEngine, "/api/chart", payload); + if (chartResult.status !== "ok") { return { httpStatus: 200, - body: message("chart_unavailable", "这张盘这会儿算不出来。资料还在,过一会儿再打开。"), + body: message( + "chart_unavailable", + chartResult.status === "busy" ? CHART_VIEW_COPY.busy : CHART_VIEW_COPY.unavailable, + ), + }; + } + const chart = chartResult.payload; + if (chart.success === false || !engineChartHasPlanets(chart)) { + logEngineCallFailure({ + status: "bad_payload", + path: "/api/chart", + elapsedMs: 0, + errorName: "engine_rejected", + }); + return { + httpStatus: 200, + body: message("chart_unavailable", CHART_VIEW_COPY.unavailable), }; } @@ -94,14 +131,27 @@ export async function assembleChartView(input: ChartViewLoadInput): Promise<{ ascendant, houses, }; - const [chara, varga, western, qizheng] = await Promise.all([ - input.postEngine("/api/dasha/chara", { ...followUp, antardasha: true, mode: "dasha" }), - input.postEngine("/api/varga_full", { - ...followUp, - divisions: VARGA_CHIP_ORDER.map((division) => `D${division}`), - }), - input.postEngine("/api/western", payload), - input.postEngine("/api/qizheng", payload), + const layers = wantedLayers(input.layers); + const [varga, chara, western, qizheng] = await Promise.all([ + layers.has("varga") + ? callEngine(input.postEngine, "/api/varga_full", { + ...followUp, + divisions: VARGA_CHIP_ORDER.map((division) => `D${division}`), + }).then(enginePayload) + : Promise.resolve(null), + layers.has("chara") + ? callEngine(input.postEngine, "/api/dasha/chara", { + ...followUp, + antardasha: true, + mode: "dasha", + }).then(enginePayload) + : Promise.resolve(null), + layers.has("western") + ? callEngine(input.postEngine, "/api/western", payload).then(enginePayload) + : Promise.resolve(null), + layers.has("qizheng") + ? callEngine(input.postEngine, "/api/qizheng", payload).then(enginePayload) + : Promise.resolve(null), ]); try { @@ -115,10 +165,13 @@ export async function assembleChartView(input: ChartViewLoadInput): Promise<{ asOf: input.asOf, }); return { httpStatus: 200, body }; - } catch { + } catch (error) { + const errorName = error instanceof Error ? error.name : "Error"; + const errorMessage = error instanceof Error ? error.message : "unknown"; + console.warn("chart_view_assemble_failed", { errorName, message: errorMessage }); return { httpStatus: 200, - body: message("chart_unavailable", "这张盘这会儿算不出来。资料还在,过一会儿再打开。"), + body: message("chart_unavailable", CHART_VIEW_COPY.unavailable), }; } } diff --git a/frontend/src/lib/chart-view-service.ts b/frontend/src/lib/chart-view-service.ts index c58f215a..cc66a208 100644 --- a/frontend/src/lib/chart-view-service.ts +++ b/frontend/src/lib/chart-view-service.ts @@ -6,12 +6,22 @@ import { globalBirthProfileFromAccountRow, } from "./server-owned-birth-profile.ts"; import { createServerSupabaseClient } from "./supabase/server.ts"; +import { resolveAyanamsa } from "./ayanamsa.ts"; import { assembleChartView, type ChartViewEnginePost } from "./chart-view-load.ts"; import type { ChartViewProfileInput } from "./chart-view-mapper.ts"; import type { ChartViewResponse } from "./chart-view-contract.ts"; +import { + CHART_VIEW_ENGINE_TIMEOUT_MS, + chartViewEngineCacheKey, + engineCallFromHttp, + engineCallFromThrown, + readChartViewEngineCache, + writeChartViewEngineCache, + type ChartViewLayer, + type EngineCallResult, +} from "./chart-view-engine.ts"; const jyotishApiBase = process.env.JYOTISH_API_BASE ?? "http://127.0.0.1:5200"; -const engineTimeoutMs = 45_000; function text(value: unknown): string | undefined { if (typeof value !== "string") return undefined; @@ -28,24 +38,71 @@ function finite(value: unknown): number | undefined { return undefined; } -async function postEngine(path: string, body: Record): Promise | null> { +async function postEngine(path: string, body: Record): Promise { + const started = Date.now(); try { const response = await fetch(`${jyotishApiBase}${path}`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(body), cache: "no-store", - signal: AbortSignal.timeout(engineTimeoutMs), + signal: AbortSignal.timeout(CHART_VIEW_ENGINE_TIMEOUT_MS), }); - if (!response.ok) return null; - const payload = await response.json().catch(() => null); - if (!payload || typeof payload !== "object" || Array.isArray(payload)) return null; - return payload as Record; - } catch { - return null; + let payload: unknown; + try { + payload = await response.json(); + } catch (error) { + const errorName = error instanceof Error ? error.name : "Error"; + if (response.ok) { + return { + status: "bad_payload", + path, + elapsedMs: Date.now() - started, + httpStatus: response.status, + errorName, + }; + } + return engineCallFromHttp({ + path, + elapsedMs: Date.now() - started, + httpStatus: response.status, + payload: null, + }); + } + return engineCallFromHttp({ + path, + elapsedMs: Date.now() - started, + httpStatus: response.status, + payload, + }); + } catch (error) { + return engineCallFromThrown(path, Date.now() - started, error); } } +function cachedPostEngine(userId: string, profile: ChartViewProfileInput): ChartViewEnginePost { + const ayanamsa = resolveAyanamsa({ ayanamsa: profile.ayanamsa }); + const nodeMode = "mean"; + return async (path, body) => { + const key = chartViewEngineCacheKey({ + userId, + date: profile.date, + time: profile.time, + latitude: profile.latitude, + longitude: profile.longitude, + timezoneOffset: profile.timezoneOffset, + ayanamsa, + nodeMode, + path, + }); + const hit = readChartViewEngineCache(key); + if (hit) return { status: "ok", payload: hit }; + const result = await postEngine(path, body); + if (result.status === "ok") writeChartViewEngineCache(key, result.payload); + return result; + }; +} + function profileFromRow(row: unknown): ChartViewProfileInput | null { const profile = globalBirthProfileFromAccountRow(row); const record = row && typeof row === "object" && !Array.isArray(row) @@ -70,18 +127,23 @@ function profileFromRow(row: unknown): ChartViewProfileInput | null { }; } -export async function loadChartView(now = new Date()): Promise<{ +export async function loadChartView(input: { + now?: Date; + layers?: readonly ChartViewLayer[]; +} = {}): Promise<{ httpStatus: number; body: ChartViewResponse; }> { + const now = input.now ?? new Date(); const supabase = await createServerSupabaseClient(); const { data: { user } } = await supabase.auth.getUser(); if (!user) { return assembleChartView({ userId: null, profile: null, - postEngine: postEngine as ChartViewEnginePost, + postEngine, asOf: now.toISOString().slice(0, 10), + layers: input.layers, }); } @@ -97,11 +159,13 @@ export async function loadChartView(now = new Date()): Promise<{ .eq("id", user.id) .maybeSingle(); + const profile = profileFromRow(row); return assembleChartView({ userId: user.id, - profile: profileFromRow(row), - postEngine, + profile, + postEngine: profile ? cachedPostEngine(user.id, profile) : postEngine, asOf: now.toISOString().slice(0, 10), rateLimited: !limited.ok, + layers: input.layers, }); } diff --git a/frontend/tests/chart-page-view.test.tsx b/frontend/tests/chart-page-view.test.tsx index 254e053a..3d0ddc40 100644 --- a/frontend/tests/chart-page-view.test.tsx +++ b/frontend/tests/chart-page-view.test.tsx @@ -7,6 +7,8 @@ import test from "node:test"; import { ChartPageView } from "../src/components/chart-page/chart-page-view.tsx"; import { assembleChartView } from "../src/lib/chart-view-load.ts"; import { CHART_VIEW_TABS, type ChartViewOk } from "../src/lib/chart-view-contract.ts"; +import { CHART_VIEW_LAYERS } from "../src/lib/chart-view-engine.ts"; +import { CHART_VIEW_COPY } from "../src/lib/chart-view-labels.ts"; import { cssDeclarations } from "./css-contract-test-support.ts"; Object.assign(globalThis, { React }); @@ -42,12 +44,13 @@ async function okView(western: Record | null = null): Promise { - if (path === "/api/chart") return golden.chart; - if (path === "/api/varga_full") return golden.varga_full; - if (path === "/api/dasha/chara") return golden.chara; - if (path === "/api/western") return western; - return null; + if (path === "/api/chart") return { status: "ok", payload: golden.chart }; + if (path === "/api/varga_full") return { status: "ok", payload: golden.varga_full }; + if (path === "/api/dasha/chara") return { status: "ok", payload: golden.chara }; + if (path === "/api/western" && western) return { status: "ok", payload: western }; + return { status: "http_error", path, elapsedMs: 1, httpStatus: 500 }; }, }); assert.equal(result.body.status, "ok"); @@ -58,7 +61,11 @@ test("the five tabs render and can each be selected", async () => { const view = await okView(); for (const tab of CHART_VIEW_TABS) { const markup = renderToStaticMarkup(React.createElement(ChartPageView, { view, initialTab: tab.id })); - assert.match(markup, /直接计算 · 打开即有 · 不消耗点数/); + // 原值: 直接计算 · 打开即有 · 不消耗点数 + // 新值: 主盘直接算 · 分盘按需 · 不消耗点数 + // 原因: BUG-717 「打开即有」是速度承诺,且印在失败页上 + assert.equal(markup.includes(CHART_VIEW_COPY.eyebrow), true); + assert.doesNotMatch(markup, /打开即有/); assert.match(markup, />星盘基础信息大运 { })); assert.match(markup, /还没有可用来排盘的出生资料/); assert.doesNotMatch(markup, /正在加载|InlineSpinner|skeleton/i); + assert.doesNotMatch(markup, /chart-page-eyebrow|打开即有/); +}); + +test("the chart page shell is visible before the natal chart arrives", () => { + const markup = renderToStaticMarkup(React.createElement(ChartPageView, { view: null })); + assert.match(markup, />星盘 { + const markup = renderToStaticMarkup(React.createElement(ChartPageView, { + view: { status: "chart_unavailable", billed: false, message: CHART_VIEW_COPY.unavailable }, + })); + assert.match(markup, new RegExp(CHART_VIEW_COPY.unavailable)); + assert.doesNotMatch(markup, /chart-page-eyebrow|打开即有|过一会儿再打开|不消耗点数/); +}); + +test("a pending western tab uses inline waiting copy instead of a spinner", async () => { + const view = await okView(); + const markup = renderToStaticMarkup(React.createElement(ChartPageView, { + view, + initialTab: "western", + pendingLayers: new Set(["western"]), + })); + assert.match(markup, /这一栏还没拿到/); + assert.doesNotMatch(markup, /正在加载|InlineSpinner|skeleton/i); }); diff --git a/frontend/tests/chart-view-engine.test.ts b/frontend/tests/chart-view-engine.test.ts new file mode 100644 index 00000000..3e4540fa --- /dev/null +++ b/frontend/tests/chart-view-engine.test.ts @@ -0,0 +1,123 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { + CHART_VIEW_ENGINE_CACHE_TTL_MS, + CHART_VIEW_ENGINE_TIMEOUT_MS, + chartViewEngineCacheKey, + clearChartViewEngineCache, + engineCallFromHttp, + engineCallFromThrown, + logEngineCallFailure, + parseChartViewLayers, + readChartViewEngineCache, + writeChartViewEngineCache, +} from "../src/lib/chart-view-engine.ts"; + +function captureWarnings(run: () => void): unknown[] { + const warnings: unknown[] = []; + const original = console.warn; + console.warn = (...args: unknown[]) => { + warnings.push(args); + }; + try { + run(); + } finally { + console.warn = original; + } + return warnings; +} + +const baseKey = { + userId: "user-1", + date: "1990-04-09", + time: "12:00", + latitude: 39.9042, + longitude: 116.4074, + timezoneOffset: 8, + ayanamsa: "raman", + nodeMode: "mean", + path: "/api/chart", +}; + +test("engine HTTP outcomes classify 429, other errors, and bad JSON separately", () => { + assert.equal(engineCallFromHttp({ + path: "/api/chart", + elapsedMs: 12, + httpStatus: 429, + payload: { error: "busy" }, + }).status, "busy"); + assert.equal(engineCallFromHttp({ + path: "/api/chart", + elapsedMs: 12, + httpStatus: 500, + payload: { error: "boom" }, + }).status, "http_error"); + assert.equal(engineCallFromHttp({ + path: "/api/chart", + elapsedMs: 12, + httpStatus: 200, + payload: "not-json-object", + }).status, "bad_payload"); + assert.equal(engineCallFromHttp({ + path: "/api/chart", + elapsedMs: 12, + httpStatus: 200, + payload: null, + }).status, "bad_payload"); +}); + +test("timeout and abort errors classify as timeout; other throws as http_error", () => { + const timeout = engineCallFromThrown("/api/chart", 10_001, Object.assign(new Error("aborted"), { name: "TimeoutError" })); + assert.equal(timeout.status, "timeout"); + const abort = engineCallFromThrown("/api/chart", 10_001, Object.assign(new Error("aborted"), { name: "AbortError" })); + assert.equal(abort.status, "timeout"); + const network = engineCallFromThrown("/api/chart", 30, Object.assign(new Error("fetch failed"), { name: "TypeError" })); + assert.equal(network.status, "http_error"); + assert.equal(network.errorName, "TypeError"); +}); + +test("each non-ok engine result logs path, status, and elapsed ms without birth fields", () => { + const cases = [ + { status: "busy" as const, httpStatus: 429 }, + { status: "http_error" as const, httpStatus: 500 }, + { status: "timeout" as const, errorName: "TimeoutError" }, + { status: "bad_payload" as const, httpStatus: 200 }, + ]; + const seen = new Set(); + for (const item of cases) { + const warnings = captureWarnings(() => { + logEngineCallFailure({ + path: "/api/chart", + elapsedMs: 18, + ...item, + }); + }); + assert.equal(warnings.length, 1); + const line = JSON.stringify(warnings[0]); + assert.match(line, /chart_view_engine_failed/); + assert.match(line, new RegExp(item.status)); + assert.match(line, /"elapsedMs":18/); + assert.doesNotMatch(line, /1990|latitude|longitude|name|birth/); + seen.add(item.status); + } + assert.deepEqual([...seen], ["busy", "http_error", "timeout", "bad_payload"]); +}); + +test("engine cache keys include ayanamsa and node mode and expire", () => { + clearChartViewEngineCache(); + const raman = chartViewEngineCacheKey(baseKey); + const lahiri = chartViewEngineCacheKey({ ...baseKey, ayanamsa: "lahiri" }); + const trueNode = chartViewEngineCacheKey({ ...baseKey, nodeMode: "true" }); + assert.notEqual(raman, lahiri); + assert.notEqual(raman, trueNode); + writeChartViewEngineCache(raman, { success: true }, 1_000); + assert.deepEqual(readChartViewEngineCache(raman, 1_000), { success: true }); + assert.equal(readChartViewEngineCache(raman, 1_000 + CHART_VIEW_ENGINE_CACHE_TTL_MS + 1), null); + assert.equal(CHART_VIEW_ENGINE_TIMEOUT_MS, 10_000); +}); + +test("layer query parsing keeps only known layers in stable order", () => { + assert.deepEqual(parseChartViewLayers(null), []); + assert.deepEqual(parseChartViewLayers("western,varga,nope,qizheng"), ["varga", "western", "qizheng"]); +}); diff --git a/frontend/tests/chart-view-route.test.ts b/frontend/tests/chart-view-route.test.ts index a54784db..b42a5a9e 100644 --- a/frontend/tests/chart-view-route.test.ts +++ b/frontend/tests/chart-view-route.test.ts @@ -2,9 +2,10 @@ import assert from "node:assert/strict"; import { readFileSync } from "node:fs"; import test from "node:test"; -import { assembleChartView } from "../src/lib/chart-view-load.ts"; +import { assembleChartView, type ChartViewEnginePost } from "../src/lib/chart-view-load.ts"; import { chartViewOkSchema, chartViewResponseSchema } from "../src/lib/chart-view-contract.ts"; -import { COORDINATE_BOUNDARY } from "../src/lib/chart-view-labels.ts"; +import { CHART_VIEW_LAYERS, type EngineCallResult } from "../src/lib/chart-view-engine.ts"; +import { CHART_VIEW_COPY, COORDINATE_BOUNDARY } from "../src/lib/chart-view-labels.ts"; const golden = JSON.parse( readFileSync(new URL("./fixtures/chart-view-golden.json", import.meta.url), "utf8"), @@ -19,6 +20,7 @@ const routeSource = readFileSync(new URL("../src/app/api/chart-view/route.ts", i const serviceSource = readFileSync(new URL("../src/lib/chart-view-service.ts", import.meta.url), "utf8"); const loadSource = readFileSync(new URL("../src/lib/chart-view-load.ts", import.meta.url), "utf8"); const pageSource = readFileSync(new URL("../src/app/page.tsx", import.meta.url), "utf8"); +const chartPageSource = readFileSync(new URL("../src/app/chart/page.tsx", import.meta.url), "utf8"); const profile = { name: "示例", @@ -33,21 +35,49 @@ const profile = { birthTimeStatus: "confirmed" as const, }; -function engine(overrides: Record | null> = {}) { +function isEngineCallResult(value: object): value is EngineCallResult { + if (!("status" in value)) return false; + const status = (value as { status?: unknown }).status; + if (status === "ok") return "payload" in value && typeof (value as { payload?: unknown }).payload === "object"; + return status === "busy" || status === "http_error" || status === "timeout" || status === "bad_payload"; +} + +function asResult(path: string, value: EngineCallResult | Record | null): EngineCallResult { + if (value === null) { + return { status: "http_error", path, elapsedMs: 1, httpStatus: 500 }; + } + if (isEngineCallResult(value)) return value; + return { status: "ok", payload: value }; +} + +function engine(overrides: Record | null> = {}) { const calls: string[] = []; - const postEngine = async (path: string) => { + const postEngine: ChartViewEnginePost = async (path) => { calls.push(path); - if (path in overrides) return overrides[path]; - if (path === "/api/chart") return golden.chart; - if (path === "/api/varga_full") return golden.varga_full; - if (path === "/api/dasha/chara") return golden.chara; - if (path === "/api/western") return null; - if (path === "/api/qizheng") return null; - return null; + if (path in overrides) return asResult(path, overrides[path]!); + if (path === "/api/chart") return asResult(path, golden.chart); + if (path === "/api/varga_full") return asResult(path, golden.varga_full); + if (path === "/api/dasha/chara") return asResult(path, golden.chara); + if (path === "/api/western") return asResult(path, null); + if (path === "/api/qizheng") return asResult(path, null); + return asResult(path, null); }; return { calls, postEngine }; } +function captureWarnings(run: () => Promise): Promise<{ result: T; warnings: unknown[] }> { + const warnings: unknown[] = []; + const original = console.warn; + console.warn = (...args: unknown[]) => { + warnings.push(args); + }; + return run() + .then((result) => ({ result, warnings })) + .finally(() => { + console.warn = original; + }); +} + test("the chart-view BFF never bills and never calls a model", () => { for (const source of [routeSource, serviceSource, loadSource]) { assert.doesNotMatch(source, /consultation-billing/); @@ -63,6 +93,10 @@ test("the chart-view BFF never bills and never calls a model", () => { assert.match(loadSource, /planets/); assert.match(loadSource, /ascendant/); assert.match(loadSource, /houses/); + assert.match(serviceSource, /CHART_VIEW_ENGINE_TIMEOUT_MS/); + assert.doesNotMatch(serviceSource, /45_000/); + assert.doesNotMatch(loadSource, /catch\s*\{\s*/); + assert.doesNotMatch(serviceSource, /catch\s*\{\s*return null/); }); test("unauthenticated chart-view requests are 401", async () => { @@ -94,7 +128,7 @@ test("an incomplete birth profile returns a structured message, not 500", async assert.equal(calls.length, 0); }); -test("western and qizheng stay unavailable without failing the Indian tabs", async () => { +test("opening the natal chart does not call follow-up engine paths", async () => { const { calls, postEngine } = engine(); const result = await assembleChartView({ userId: "user-1", @@ -106,12 +140,33 @@ test("western and qizheng stay unavailable without failing the Indian tabs", asy const body = chartViewOkSchema.parse(result.body); assert.equal(body.billed, false); assert.ok(body.vedic.vargas.some((item) => item.id === "D1")); + assert.equal(body.western.status, "unavailable"); + assert.equal(body.qizheng.status, "unavailable"); + assert.deepEqual(calls, ["/api/chart"]); +}); + +test("western and qizheng stay unavailable without failing the Indian tabs", async () => { + const { calls, postEngine } = engine(); + const result = await assembleChartView({ + userId: "user-1", + profile, + postEngine, + asOf: "2026-09-15", + layers: CHART_VIEW_LAYERS, + }); + assert.equal(result.httpStatus, 200); + const body = chartViewOkSchema.parse(result.body); + assert.equal(body.billed, false); + assert.ok(body.vedic.vargas.some((item) => item.id === "D1")); assert.ok(body.vedic.planets.length >= 9); assert.equal(body.dasha.vimshottari.method, "Vimshottari"); assert.equal(body.dasha.chara.title, "Chara Dasha(kn_rao 变体)"); assert.equal(body.western.status, "unavailable"); assert.equal(body.qizheng.status, "unavailable"); assert.ok(body.vedic.vargas.some((item) => item.id === "D9")); + // 原值: 无 layers 时也会打 /api/western 与 /api/qizheng + // 新值: 只有请求对应 layer 才打 + // 原因: BUG-717 开页不得占满重计算配额;西洋/七政改按需 assert.equal(calls.includes("/api/western"), true); assert.equal(calls.includes("/api/qizheng"), true); assert.doesNotMatch(calls.join(" "), /billing|mastra|consult/); @@ -124,6 +179,7 @@ test("a real western engine packet lights the tropical tab without mixing sidere profile, postEngine, asOf: "2026-09-15", + layers: ["western"], }); const body = chartViewOkSchema.parse(result.body); assert.equal(body.western.status, "ok"); @@ -148,6 +204,7 @@ test("qizheng ketuMode follows the engine field, not a request echo", async () = profile, postEngine, asOf: "2026-09-15", + layers: ["qizheng"], }); const body = chartViewOkSchema.parse(result.body); assert.equal(body.qizheng.status, "ok"); @@ -155,6 +212,63 @@ test("qizheng ketuMode follows the engine field, not a request echo", async () = assert.equal(body.qizheng.ketuMode, "apogee"); }); +test("busy vs other natal engine failures split copy and each leave a log line", async () => { + const stubs: Array<{ status: EngineCallResult["status"]; httpStatus?: number; errorName?: string }> = [ + { status: "busy", httpStatus: 429 }, + { status: "http_error", httpStatus: 500 }, + { status: "timeout", errorName: "TimeoutError" }, + { status: "bad_payload", httpStatus: 200 }, + ]; + for (const stub of stubs) { + const { result, warnings } = await captureWarnings(() => assembleChartView({ + userId: "user-1", + profile, + asOf: "2026-09-15", + postEngine: async (path) => ({ + status: stub.status === "ok" ? "http_error" : stub.status, + path, + elapsedMs: 22, + httpStatus: stub.httpStatus, + errorName: stub.errorName, + }), + })); + assert.equal(result.body.status, "chart_unavailable"); + assert.equal(warnings.length, 1); + const line = JSON.stringify(warnings[0]); + assert.match(line, new RegExp(stub.status === "ok" ? "http_error" : stub.status)); + assert.doesNotMatch(line, /示例|1990|39\.9042/); + if (stub.status === "busy") { + assert.equal(result.body.message, CHART_VIEW_COPY.busy); + } else { + assert.equal(result.body.message, CHART_VIEW_COPY.unavailable); + assert.doesNotMatch(result.body.message, /过一会儿再打开/); + } + } +}); + +test("mapper failures log the error name and do not swallow with an empty catch", async () => { + const { result, warnings } = await captureWarnings(() => assembleChartView({ + userId: "user-1", + profile, + asOf: "2026-09-15", + postEngine: async (path) => { + if (path !== "/api/chart") return { status: "http_error", path, elapsedMs: 1, httpStatus: 500 }; + return { + status: "ok", + payload: { + success: true, + planets: { Sun: { sign: "Aries", lon: 10, degree_in_sign: 10 } }, + ascendant: {}, + }, + }; + }, + })); + assert.equal(result.body.status, "chart_unavailable"); + assert.equal(result.body.message, CHART_VIEW_COPY.unavailable); + assert.match(JSON.stringify(warnings), /chart_view_assemble_failed/); + assert.doesNotMatch(loadSource, /catch\s*\{\s*\r?\n\s*return \{/); +}); + test("the golden chart-view envelope stays inside the page contract", () => { chartViewResponseSchema.parse({ status: "birth_profile_incomplete", @@ -170,3 +284,11 @@ test("page.tsx does not grow to host the chart page", () => { assert.ok((pageSource.match(/\n/g) ?? []).length <= 1951); assert.doesNotMatch(pageSource, /chart-page|ChartPageView|\/api\/chart-view/); }); + +test("the chart route renders a shell and does not block the document on engine calls", () => { + assert.doesNotMatch(chartPageSource, /force-dynamic/); + assert.doesNotMatch(chartPageSource, /loadChartView/); + assert.match(chartPageSource, /ChartPageRoute/); + assert.match(routeSource, /parseChartViewLayers/); + assert.match(routeSource, /searchParams\.get\("layers"\)/); +});