From 4d801e53c34c7422f7a40ea576b8a00f0c5bfd89 Mon Sep 17 00:00:00 2001 From: jesse-ux Date: Fri, 25 Sep 2026 20:41:16 +0800 Subject: [PATCH] fix: integrate people archive, report reader and western chart corrections Validate on Linux Node 22 and PostgreSQL 17: 3894 frontend tests and 64 database tests pass, with no removed test names or new failures. Preserve static Home, bounded gzip, assertion-change records and manual acceptance gaps. Co-Authored-By: Claude Code --- BLOCKED.md | 35 + CHANGELOG.md | 24 +- docs/BUG_HISTORY.md | 77 +- docs/research/pre_work_error_ledger.md | 17 + .../PROGRESS-chart-western-fix-20260925.md | 128 + ...PROGRESS-people-archive-p1-fix-20260925.md | 115 + ...ROGRESS-report-reader-main-fix-20260925.md | 74 + ...OGRESS-three-fixes-integration-20260925.md | 163 + docs/tasks/README.md | 8 +- docs/testing/chart-dasha-western-20260924.md | 13 + docs/testing/people-archive-p1-20260924.md | 26 + docs/testing/report-reader-main-20260924.md | 11 +- .../three-fixes-integration-20260925.md | 83 + frontend/DESIGN.md | 18 +- frontend/src/app/(app)/page.tsx | 10 +- .../src/app/api/chart-profiles/[id]/route.ts | 17 +- frontend/src/app/api/chart-profiles/route.ts | 10 +- frontend/src/app/api/reports/route.ts | 20 +- .../chart-page/western-wheel-layout.ts | 114 +- .../chart-page/western-wheel-svg.tsx | 84 +- .../components/current-subject-switcher.tsx | 25 +- .../components/daily-starlanguage-binder.tsx | 19 +- .../components/ephemeris/ephemeris-page.tsx | 56 +- frontend/src/components/people-home-links.tsx | 6 +- .../src/components/people/people-page.tsx | 37 +- .../personal-report-center.tsx | 18 +- frontend/src/hooks/use-chart-page.ts | 14 +- frontend/src/hooks/use-session-management.ts | 52 +- frontend/src/hooks/use-synastry.ts | 2 + frontend/src/lib/current-subject.ts | 145 +- .../src/lib/db/local-postgres-client-core.ts | 6 + frontend/src/lib/home-bootstrap-run.ts | 11 +- .../src/lib/personal-report-worker-core.ts | 4 +- .../src/lib/personal-report-worker-subject.ts | 40 + frontend/src/lib/personal-report-worker.ts | 45 +- frontend/src/lib/report-public-projection.ts | 53 +- frontend/src/lib/report-subject-request.ts | 18 + frontend/src/lib/secondary-page-data.ts | 20 +- frontend/src/lib/session-list-context.tsx | 71 +- frontend/src/lib/subject-birth.ts | 5 + frontend/src/lib/subject-session-merge.ts | 16 + ...25030000_chart_subject_trust_and_limit.sql | 42 + .../20260925040000_deleted_report_release.sql | 64 + frontend/tests/adopted-birth-date.test.ts | 5 +- .../tests/chart-library-other-profile.test.ts | 53 +- frontend/tests/chart-page-hook.test.ts | 8 +- frontend/tests/chart-page-view.test.tsx | 2 +- frontend/tests/chat-session-url.test.ts | 6 +- .../tests/composer-isolation-contract.test.ts | 15 +- frontend/tests/daily-starlanguage.test.ts | 12 +- frontend/tests/database-chart-subject.test.ts | 115 + .../tests/ephemeris-page-lifecycle.test.tsx | 48 +- .../report-reader-main-fictional.json | 6 +- .../fixtures/western-1990-01-01-beijing.json | 495 +- .../western-fictional-scan-20260925.json | 31830 ++++++++++++++++ frontend/tests/home-bootstrap-run.test.ts | 47 + frontend/tests/home-surface.ts | 2 +- frontend/tests/local-postgres-or.test.ts | 10 + .../tests/people-archive-regressions.test.ts | 129 + .../people-pagination-lifecycle.test.tsx | 35 + .../personal-report-density-retry.test.ts | 5 +- frontend/tests/personal-report-worker.test.ts | 98 +- frontend/tests/report-export-blocks.test.ts | 5 +- .../tests/report-polling-contract.test.ts | 4 +- .../tests/report-public-projection.test.ts | 49 +- frontend/tests/report-row-delete.test.tsx | 2 + .../tests/session-list-lifecycle.test.tsx | 107 +- frontend/tests/session-list-provider.test.ts | 10 +- frontend/tests/settings-mvp-contract.test.ts | 12 +- frontend/tests/western-wheel-layout.test.ts | 185 +- scripts/annual_tajika_pack.py | 16 +- scripts/jyotish_engine.py | 2 +- scripts/pl9_reader_export.py | 62 +- tests/test_annual_tajika_pack.py | 4 +- tests/test_birth_time_journey_contract.py | 2 +- tests/test_report_reader_main.py | 74 + tests/test_session_management_entrypoints.py | 2 +- tests/test_supabase_user_data_contract.py | 12 +- 78 files changed, 34844 insertions(+), 441 deletions(-) create mode 100644 docs/tasks/PROGRESS-chart-western-fix-20260925.md create mode 100644 docs/tasks/PROGRESS-people-archive-p1-fix-20260925.md create mode 100644 docs/tasks/PROGRESS-report-reader-main-fix-20260925.md create mode 100644 docs/tasks/PROGRESS-three-fixes-integration-20260925.md create mode 100644 docs/testing/three-fixes-integration-20260925.md create mode 100644 frontend/src/lib/personal-report-worker-subject.ts create mode 100644 frontend/src/lib/report-subject-request.ts create mode 100644 frontend/src/lib/subject-session-merge.ts create mode 100644 frontend/supabase/migrations/20260925030000_chart_subject_trust_and_limit.sql create mode 100644 frontend/supabase/migrations/20260925040000_deleted_report_release.sql create mode 100644 frontend/tests/fixtures/western-fictional-scan-20260925.json create mode 100644 frontend/tests/people-archive-regressions.test.ts create mode 100644 frontend/tests/people-pagination-lifecycle.test.tsx diff --git a/BLOCKED.md b/BLOCKED.md index 35415b3d..f3f34152 100644 --- a/BLOCKED.md +++ b/BLOCKED.md @@ -1,5 +1,40 @@ # BLOCKED +## 三份修复单 final-r4b:自动化缺口解除与剩余边界(2026-09-25) + +- ~~最终组合 Linux Node22 全量、真实 DB、名称对账、Static/gzip 尚未完成。~~ **已解除这一部分**:final-r4b 前端3894/3894、PG17标准DB64/64;五个exit均0,旧名称无消失、新增失败0;lint0error/127warning,首页Static;三种gzip均在±2%内。下方各单“待最终全量”是历史快照,以整合PROGRESS补记为准。 +- 首次final-r4纯archive没有`.git`,`staging database environment file is ignored`调用git check-ignore失败128;仅在隔离验证快照补临时Git/index后完整重跑,未改测试断言或源树历史。旧失败日志保留。 +- ~~Python补验误在Node镜像调用Python3.13 venv得到exit127(解释器目标不存在),没有开始测试。~~ 匹配Python3.13镜像+Node22后final-r4b补验:定向49通过、CI quick同基线参数948通过/1跳过、隐私70通过、两扫描各3311文件0finding,五个exit均0;未借用旧r2成绩。 +- 交付预检远端verified,但focused两项失败:历史`.workbuddy`镜像缺失、fragment candidate_count=4小于workspace_residue_count=45。两种均属台账已有机器目录假设问题;不制造目录、不清他人残留、不弱化测试,预检整体仍fail。Linux业务全量与此分开登记。 +- 受控登录、整页移动端、真实Tab/读屏、线上迁移/health仍待验;旧`chart-library-panel.tsx`删除权限缺口不变,文件保留。未凭自动化将BUG-1030/1031/1032/1033标resolved。 + +## TASK-people-archive-p1-fix:最终组合与受控登录验收待完成(2026-09-25) + +- 旧死文件清理未交付:主整合工具明确拒绝删除预存`frontend/src/components/chart-library-panel.tsx`。整合树保留该文件;本人物执行树原删除不撤销,但不能据此称整合版已删除。合同已迁到活跃PeoplePage,旧文件清理待明确权限,不改用其他工具/代理代删,不清空或替换以绕过拒绝。 +- 最终冻结点Windows Node22.23.2永久回归54/54,tsc0,lint 0 error/127既有warning;独立5个前端探针通过,真实Postgres退款r4六场景通过。这些范围可能重叠,不能相加冒充全量或登录浏览器验收。 +- 最终组合Linux Node22全量、标准DB套件、测试名称逐条基线对照、build Static/gzip仍由协调方执行。基线3863/3863 fail0 skip0、DB61/61;中间快照有失败,不是交付结果,也不得用旧Node20失败豁免。新增失败0、旧测试名称无消失之前不得推送。 +- 未使用受控登录账号,不读取或借用凭据;实际给他人生成并打开报告、刷新他人对话、切换人物慢网时序、删除确认与退款账单、星历首次失败重试均见`docs/testing/people-archive-p1-20260924.md`,全部真人条目仍待验。 +- 尚未提交/推送/部署,迁移上线与health SHA未核对,BUG-1030/1031/1032保持investigating;禁止以定向通过声称生产问题resolved。 + +## TASK-chart-western-fix:组件证据与整页验收边界(2026-09-25) + +- 没有本单受控登录账号,未读取或借用凭据。真实字体 Chrome 独立组件扫描不是登录页点选/取消、真实 Tab、读屏、亮暗色及整页横滚验收,步骤见 `docs/testing/chart-dasha-western-20260924.md`。 +- 首轮 64 真实盘的字符误点和 2 盘宫号互碰已由执行方修正;组件复核证据与最终组合全量分开登记,不提前声称部署通过。 +- 最终 Linux 全量、名称逐项对照、build Static/gzip 尚待整合验收;预检历史 `.workbuddy` 缺口沿用下面记录,不豁免新增业务失败。 + +## 三份修复单:受控浏览器及报告执行树副作用边界(2026-09-25) + +- 报告修复已独立只读审查:Windows Node 22 五文件 51/51,Python 年度两文件 21/21;执行树 UTF-8 quick 948/1 skip。最终组合 Linux 全量、名称逐项对照和构建尚待完成,不能以定向结果替代。 +- 无受控登录账号,未读取或借用凭据;新报告生成/下载、英文聊天导出及真机验收待执行,见 `docs/testing/report-reader-main-20260924.md` 与整合清单。未提交、未推送、未部署。 +- 报告执行树 quick 把 oracle pending_packets 中 Einstein、historical_dst_london、Marilyn Monroe、Steve Jobs、synthetic_extreme_latitude 对应的五个 `external_template_*_varshaphala_*.json` 文件写成 CRLF。执行方和独立审查均核验 JSON 相同、统一换行后逐字相同;恢复操作被拒,保留原树脏态,不绕过或转交代执行。整合白名单明确排除这五个文件;清理须另行获批。 +- 真实报告 golden 某次只读重算逐字相同,其他重算在未改动 Shadbala 个别值有 0.01 漂移,年运与章节无差。不把单次相等说成跨次确定性,不修改算法/golden/容差消漂移。 + +## 三份修复单:开工预检历史环境缺口(2026-09-25) + +- 固定基线 `48fe4daf5`,Python 3.11.7 的 pre_work_check focused 23 通过 / 1 失败:`test_preflight_fragment_scan_reports_authority_layers_and_risk_buckets` 要求不存在的历史 `.workbuddy/skills/jyotish-vedic-astrology` 镜像路径。远端 verified,适配器/碎片步骤成功,预检整体仍 fail。 +- 不制造历史镜像,不修改环境断言迎合测试。该失败与台账历史项同类,不能据此宣称本轮业务测试已通过。 +- 本轮实测 Linux Docker 可用,Node 22 全量基线与隔离真实数据库测试另行进行;状态与证据见 `docs/tasks/PROGRESS-three-fixes-integration-20260925.md`。 + ## TASK-report-reader-actions:删除权限与依赖验收阻塞(2026-09-24) - 删除权限 blocked:`frontend/src/components/personal-report/raw-appendix-download.tsx`、`frontend/src/app/api/reports/[reportId]/raw-appendix/route.ts`、`frontend/src/lib/personal-report-raw-appendix.ts` 的 rm 已被拒。三个文件原样保留,不删除、清空、替换或转交他人删除;相关 raw-appendix 与 density-retry 测试保留。阅读页入口已撤下不等于服务端通道已退役,T2 与全仓零命中红线尚未完全满足,等待用户批准。 diff --git a/CHANGELOG.md b/CHANGELOG.md index 26d2a32e..e9c277ad 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,27 @@ # 印度占星 Skill 更新日志 +## 2026-09-25 — 星盘档案错人、历史与删除退点修复(待验收) + +- 给他人生成报告时,后台也按报告保存的人物取服务端资料,不再用户主资料代算;本人报告保留自己的校正候选范围。人物不存在或不属于账户时失败,不回落本人。 +- 本人聊天历史修复自托管数据库查询500;切换人物、整页跳转和刷新先确认账户与人物,已打开的消息不因目录刷新被清空,迟到分页不覆盖新人物列表。 +- 删除人物时,在同一事务退回尚未交付报告的预留点数;已完成或已交付报告不退款,已释放预留不重复退。查询不到删除用量时禁用确认,不显示虚假的零条记录。 +- 星语使用当前人物的资料与缓存指纹;合盘里的「我」保持本人且不改变全局当前人物;星历暖进复用同账户同人物缓存,首次失败可重试。编辑表单不再写「添加一个人」。 +- 旧他人档案从JSON抄来的未验证「已校正」状态降回填报状态,不改本人资料。仍是本人加最多四个他人、无关系字段。 +- Skill版本不bump。本地与独立定向通过不等于全量或上线;最终组合Linux Node22、受控登录和部署待验收。 + +## 2026-09-25 — 西洋盘轴点避让与窄屏点击修复(待验收) + +- 行星标签避开固定的 ASC、MC、星座名和宫号;不改真实黄经与宫头,相位线仍常显。 +- 375px 页面里的实际盘宽为 343px,字号约 12.53px,透明点击圆直径约 44.85px;引导线不拦截其他行星点击。 +- 512 张固定种子几何盘与 64 张真实引擎虚构盘覆盖所有标签避让。拥挤宫号保持真实宫中角度,仅径向挪动;行星文字在全部命中圆之上,每个字符点选自身。 +- Skill 版本不 bump。Chrome 组件扫描不替代登录页面、集成全量与部署验收。 + +## 2026-09-25 — 修复读者版年运空值与十二宫力量表(待验收) + +- 年主一致时不再因附属字段缺失误报冲突;年主、Muntha、年度上升和未来返照时刻读取各自实际结果。真实不一致仍分别展示。 +- 十二宫力量表不再被 Score 表头误删;清除内部表时保留章节标题。聊天导出不再误删普通英文句或安全图盘。 +- 五要素字段改为中文,缺少姓名时不显示空姓名行。旧报告不重算,Skill 版本不 bump;未部署,受控浏览器验收待补。 + ## 2026-09-25 — 星盘档案可以保存最多五个人 - 侧栏新增「星盘档案」。每个人只有称呼和出生资料,没有关系。本人不可删除,其他人最多四个。 @@ -18,7 +40,7 @@ ## 2026-09-25 — 首页内部重构,无用户可见变化 - 首页启动流程、咨询恢复轮询、错误屏和引导屏从首页组件拆到独立模块。落点、20 秒错误屏、局部重试、pending 优先级、恢复轮询和文案都不变。 -- `?settings=chart` 仍打开星盘资料。 +- 当时 `?settings=chart` 打开设置里的星盘资料;随后星盘档案上线后,该旧链接已转到 `/people`,不再打开已撤下的设置分区。 - Skill 版本不 bump。浏览器和 `next build` 未验收。 ## 2026-09-25 — 新报告正文改为读者版 diff --git a/docs/BUG_HISTORY.md b/docs/BUG_HISTORY.md index faa0057c..3b98406d 100644 --- a/docs/BUG_HISTORY.md +++ b/docs/BUG_HISTORY.md @@ -13694,8 +13694,8 @@ - 用户现象:没有出生资料时只看到说明,不能从这一页去填。 - 触发条件:`birth_profile_incomplete`,以及资料查询失败、已采用时间缺时区、时区解析失败。 - 根因:失败页只有一段文字。`parseSettingsQuery` 以前只认 `?settings=billing`。 -- 修复:没有资料时加标题「还没有星盘资料」、原句和主按钮「去填写出生资料」,链到 `/?settings=chart`。另外三种资料失败保留各自的句子,并给同一按钮。首页据此打开设置里的星盘资料。`?settings=billing` 仍只开账户与点数。解析只在 `settings-url.ts`。 -- 验证:四种资料失败都渲染该按钮;引擎忙不渲染。`?settings=chart` 解析为星盘资料,billing 的既有断言没改。 +- 修复(历史):最初加标题「还没有星盘资料」、原句和主按钮「去填写出生资料」,链到 `/?settings=chart`,当时打开设置里的星盘资料。另外三种资料失败给同一按钮。2026-09-25 星盘档案上线后,当前入口改到 `/people`,旧 `?settings=chart` 也转到 `/people`;设置里的星盘资料分区已撤下。`?settings=billing` 仍只开账户与点数。 +- 验证(历史与现状):四种资料失败都渲染该按钮;引擎忙不渲染。原回归锁 `?settings=chart` 的星盘资料解析;现行合同锁 `/people` 跳转,不再将旧解析称为当前行为。billing 的既有断言没改。 - 防复发:查询失败不得再写成资料不完整。这四类以外的失败不得带上「去填写出生资料」。 - 相关记录:BUG-996、BUG-715 - 复发自:无 @@ -13703,7 +13703,8 @@ ## BUG-1026 | 报告正文仍是审计版,投影把内部句截成半句 -- 状态:investigating(读者版切换与泄漏清理已验收:虚构生日读者版 D5 词 0 命中;投影仍误删 Bhava Bala 章节,见读者版修复单;已部署 `fabe0114`) +- 状态:investigating(读者版切换已部署 `fabe0114`;09-25 修复单本地恢复 Bhava Bala 整表、标题与聊天英文,定向通过;组合 Linux 全量、部署与受控浏览器验收待协调方闭环) +- 09-25 修复单补充:`tableIsInternal` 把通用 Score 当内部键,`adjacentInternalTableIndexes` 越过说明段继续删除标题;此前只锁章节关键词和泄漏零命中,没有检查真实十二宫表完整性。现报告表允许 Score / Weight(明确内部键仍拦截),删内部表不连标题;PL9 新规则只用于报告正文,聊天及安全图盘不受影响。真实引擎重算的虚构 reader-main golden 新增整段 Bhava Bala 原样保留断言,且原 `ordinaryOutputLeaks=[]` 不变。前端相关三文件 40/40;旧 density golden 逐行差异只有新增标题/空行,两个冻结 SHA 的原值/新值/原因见 `PROGRESS-report-reader-main-fix-20260925.md`。本轮未提交、未推送,不提前 resolved。 - 首次发现 / 最近更新:2026-09-24 / 2026-09-25 - 影响面:新个人长报告正文、普通投影、旧报告下载后的可见正文。已生成的 Markdown 不回填。 - 用户现象:正文出现「结论等级规则」、英文 boundary、「producer failed」和异常原文、`full-report://`、页码和「…所有行保持」这种半句,以及删表后留下的说明段。 @@ -13734,7 +13735,8 @@ ## BUG-1028 | 太阳返照漏了出生上升,年主恒为火星 -- 状态:investigating(引擎侧 `birth_asc_sign_idx` 已补、Muntha 随年变化;但 Year Lord 冲突判定在真实数据上仍误报,读者版年运章 Varshesha / Muntha 全为「-」,用户可见结果未修;修复单 `TASK-report-reader-main-fix-20260925.md`) +- 状态:investigating(引擎侧出生上升已补;09-25 修复单本地消除附属键缺失造成的假冲突,读者可见年主/Muntha/未来返照时刻已补真实来源;组合 Linux 全量、部署与受控浏览器验收待闭环) +- 09-25 修复单补充:上轮用户可见结果未修:真实两侧年主相同,但 Tajika 没有 `year_lord_sign`,被当成冲突;读者表又从年主对象读取 Muntha、选取依据,未来年白名单漏 `solar_return`。旧测试只查表头、或使用键集相同的合成字典,没守住可见单元格。现 D1 仅比较年主行星,D2 分别绑定真实字段、补保留未来返照结果;真实冲突仍同时显示两侧。参考提示按缺失/冲突/可读状态生成,不再恒说年主分歧。上游 `23b9609e` 的 `calc_panchadhikari_year_lord` 闭包依赖进程内 PyJHora,并会回退已 blocked 的代理算法,故按 D2 让步不引入、不解除边界。真实完整引擎三年测试与故障注入回归保留两侧真实值,不把本地一致性冒充外部验证。修复文件、测试及三栏见 `PROGRESS-report-reader-main-fix-20260925.md`;本轮未提交、未推送。 - 首次发现 / 最近更新:2026-09-24 / 2026-09-25 - 影响面:Muntha、Year Lord、年度章里的冲突句。 - 用户现象:Muntha 为 blocked;Year Lord 恒为火星 / 白羊座;年度句出现「当前优先候选指向conflict between vs Capricorn」。 @@ -13765,16 +13767,63 @@ ## BUG-1030 | 「设为默认」把页面资料换成别人,合盘问题和校正标签因此错人 -- 状态:investigating(Claude 2026-09-25 验收 `fabe0114`:activeChartId 已清除,但同单出现两个 P0——报告 worker 仍按户主资料生成、本人会话列表在自托管 Postgres 上 500——及切人时序 P1;修复单 `TASK-people-archive-p1-fix-20260925.md`) +- 状态:investigating(本地永久回归及独立探针通过;最终组合Linux、受控登录与部署待验收) - 首次发现:2026-09-24 - 最近更新:2026-09-25 -- 影响面:首页 `profile`、合盘问题里的「我」、生时校正会话标签、设置里的「当前默认」 -- 用户现象:把别人设为当前星盘后,合盘问题把别人写成「我」;校正会话标签显示别人的名字,计算仍用户主。 -- 触发条件:`activeChartId` 指向另一份星盘,或点「设为默认」。服务端并不读这个本地键。 -- 根因:BUG-466 留下每日星语缓存和 `activeChartId` 不动。BUG-997 / BUG-1000 只把普通对话接到服务端人物,并明确报告、每日星语、生时校正另立任务。页面级 `profile` 仍会被别人的资料覆盖,而星盘、报告和校正继续读 `profiles`。 -- 修复:删除 `activeChartId`、`makeDefaultChart` 和替换页面 `profile` 的 effect。当前人物只作为请求参数。星盘、星历、报告、今日星语和普通对话都经 `resolveSubjectBirth` 取服务端资料;失败不回落本人、不扣点。生时校正在当前人物不是本人时禁用。户主仍在 `profiles`。 -- 验证:`frontend/src` 不再出现这三个符号。`tests/subject-birth.test.ts` 锁住他人资料与 fail-closed。`tests/database-chart-subject.test.ts` 锁住回填、上限、不可改的填报时间和连带删除。没有登录浏览器,未声称在浏览器里加过人物。 -- 防复发:任何客户端代码不得再用他人资料覆盖页面级 `profile`。出生资料只从 `resolveSubjectBirth` 读取。不得恢复「设为默认」或把 `activeChartId` 写回 localStorage。 -- 相关记录:BUG-466、BUG-997、BUG-1000 +- 影响面:首页 `profile`、合盘问题里的「我」、生时校正会话标签、设置里的「当前默认」、星盘/星历/报告/今日星语。 +- 用户现象:把别人设为当前星盘后,合盘问题把别人写成「我」;校正会话标签显示别人的名字,计算仍用户主;切人或刷新时页面还可能先显示本人资料。 +- 触发条件:旧 `activeChartId`/默认资料入口、当前人物切换期间的未就绪请求、目录或会话响应晚到。 +- 根因:BUG-466 留下每日星语缓存和 `activeChartId` 不动。BUG-997 / BUG-1000 只把普通对话接到服务端人物,并明确报告、每日星语、生时校正另立任务。页面级 `profile` 仍会被别人的资料覆盖,而星盘、报告和校正继续读 `profiles`;新目录/Provider 时序还可能复用旧 ready 或把可恢复目录失败误判为未登录。 +- 修复:删除 `activeChartId`、`makeDefaultChart` 和替换页面 `profile` 的 effect。当前人物只作为请求参数;星盘、星历、报告、今日星语和普通对话都经 `resolveSubjectBirth` 取服务端资料,失败不回落本人、不扣点。账户/目录绑定 ready 前不发默认本人首屏请求,当前 generation gate、消息 metadata merge 和 scope 代数拦截迟到响应;生时校正在当前人物不是本人时禁用。户主仍在 `profiles`。 +- 验证:永久行为测试覆盖 `?new=1` 当前人物、A→B 分页晚响应、Provider reload 等待、catalog503不登出、跨账户缓存;独立5项前端探针通过。没有登录浏览器,未声称真实页面验收。 +- 防复发:任何客户端代码不得再用他人资料覆盖页面级 `profile`。出生资料只从 `resolveSubjectBirth` 读取。不得恢复「设为默认」或把 `activeChartId` 写回 localStorage;ready闸门、metadata合并和异步scope代数必须有行为回归,不只源码字符串。 +- 相关记录:BUG-466、BUG-997、BUG-1000、BUG-1031、BUG-1032、BUG-1024 - 复发自:无。BUG-1000 当时规定新对话固定本人,本单按产品决定推翻那一条,不把页面资料再换成人。 -- 修复版本:本分支,未推 staging,未部署。 +- 修复版本:人物冻结工作树,未推 staging,未部署。 + +## BUG-1031 | 他人报告后台取户主资料,删除级联后未交付预留可能漏退 + +- 状态:investigating(本地永久回归及独立真实DB退款矩阵通过;最终组合Linux、受控登录与部署待验收) +- 首次发现 / 最近更新:2026-09-25 / 2026-09-25 +- 影响面:报告排队worker、服务端人物解析、本人校正metadata、删除人物RPC与报告删除触发器。 +- 用户现象:选择他人生成的报告仍使用户主资料并扣点;删除排队/运行中人物时,报告与job已被级联删除,预留点数可能无后续worker可释放。 +- 触发条件:报告POST进入排队成功路径;或未交付报告的所属人物被真实删除。本人报告另存在select投影未带校正case metadata而丢失候选范围的修复期回归。 +- 根因:路由虽解析正确人物,worker仍只按job.userId直读profiles;旧测试只锁路由失败路径。单靠worker发现人物缺失再退款覆盖不了删除RPC先级联清除job的真实路径;mock整行profile也掩盖了self metadata漏列。 +- 修复:worker按报告持久化chart_profile_id经loadSubjectBirth/resolveSubjectBirth取权威资料;他人不读profiles,本人单独补读校正case metadata、候选窗仅本人。缺失/外属人物失败不回落本人并沿既有退款路径。报告subjectId别名与chartProfileId先规范化、冲突拒绝,未知字段仍拒绝。 +- 真实删除修复:新增迁移在报告删除事务内复用release_usage释放未交付预留;ready已交付与completed已结算不退,already-released幂等,其他退款失败回滚删除。删除RPC按job→report锁序与worker一致,避免完成/删除双连接死锁。 +- 权限边界:新delete_chart_subject为SECURITY DEFINER,不是依赖调用者RLS。函数显式校验auth.uid非空,锁定同owner且role=other的档案,对session/report删除与job锁均owner过滤;本人/跨用户不可删。触发器函数撤销public/anon/authenticated直接执行权限,RPC撤销public/anon、只授authenticated;两函数search_path为空,不开放通用退款入口。 +- 验证:永久worker成功路径注入fake engine、真实select投影,并覆盖缺失/外属失败;新增永久DB用例走真实authenticated删除,不只mock人物null。独立退款r4六场景通过:queued、running、completed、already-released、ready待结算、running完成与删除双连接并发。整体永久定向54/54属于Windows Node22局部,不是最终Linux全量。 +- 防复发:报告路由成功不得替代worker真实路径;本人metadata按select列裁剪测试;删除退款必须经真实PG事务与并发,不以worker失败mock替代。不得扩大SECURITY DEFINER授权或删掉owner/role验证。 +- 相关记录:BUG-1030、BUG-997、BUG-1000;任务TASK-people-archive-p1-fix-20260925。 +- 复发自:BUG-1030同轮多人物接入覆盖遗漏;不是已完成报告重算/退款需求。 +- 修复版本:人物冻结工作树,未推 staging,未部署。 + +## BUG-1032 | 默认本人历史的OR is.null在自托管Postgres上500 + +- 状态:investigating(算子与真实路径测试已补;最终组合Linux/DB、受控登录与部署待验收) +- 首次发现 / 最近更新:2026-09-25 / 2026-09-25 +- 影响面:LocalPostgres兼容层parsePostgrestOr、GET /api/sessions默认subject=self、侧栏本人历史。 +- 用户现象:本人侧栏显示「聊天记录暂时无法读取」,存量历史消失;他人会话筛选不走此OR分支。 +- 触发条件:本人过滤`chart_profile_id.is.null`与`chart_profile_id.eq.self`通过`.or()`提交给自托管客户端。 +- 根因:OR解析器只认比较算子,不支持is,抛unsupported or filter后被路由转为500。BUG-990修复的是not(...,is,null),OR分支未共用完整能力;mock query builder不编译真实SQL,旧测试漏掉此差异。 +- 修复:OR支持is.null/is.true/is.false,复用一元IS编译;SQL使用IS NULL/IS TRUE/IS FALSE,不写=null,也不把null/布尔字面量当字符串。本人路由仍查存量null/self,不改筛选语义。 +- 验证:local-postgres-or永久测试补算子行为,database-chart-subject新增真实sessions GET+Postgres,核对self返回null/self且不返回他人。Linux中间标准DB63/63是中间快照,不能冒充最终组合验收;最终DB与全量由协调方重跑。 +- 防复发:沿用BUG-990要求,兼容层新增算子必须真实Postgres覆盖,不能只mock链式调用或源码正则;OR与not一元IS语义均锁回归。 +- 相关记录:BUG-990、BUG-926、BUG-1030。 +- 复发自:BUG-990,同一兼容层的另一个is入口未覆盖;旧not-is测试仍保留,不把复发写成无关新问题。 +- 修复版本:人物冻结工作树,未推 staging,未部署。 + +## BUG-1033 | 西洋盘轴点不参与避让,移动端命中圆遮住相邻文字 + +- 状态:investigating(本地定向及真实字体组件验证通过;集成全量、受控登录与部署待验收) +- 首次发现 / 最近更新:2026-09-25 / 2026-09-25 +- 影响面:`western-wheel-layout.ts`、`western-wheel-svg.tsx`,不涉及引擎和接口。 +- 用户现象:拥挤盘行星压住 ASC/MC;窄屏文字和点击区过小。扩大命中圆后,一颗星可见文字的末尾可能选中邻星;窄宫宫号也会相碰。 +- 触发条件:轴点附近星群、移动端343px实际盘宽、真实非等宫头,后绘制透明命中圆覆盖前一标签末尾。 +- 根因:旧布局只松弛行星,不含固定轴点;17单位字号和r22命中圆未扣页面内边距;字框将本地符号字体当成1em,`middle`基线不居中;扩大圆时仅保护标签中心,未覆盖每字符点击归属。宫号固定同半径不足以容纳窄宫。 +- 修复:固定ASC/MC、星座名和宫号作为障碍,行星两环近邻搜索;19单位字号、r34命中圆、central基线与2.2em符号字框;先画所有命中按钮,再画可见文字,保持每星唯一Tab/读屏按钮;装饰线不接收指针。宫号保留真实宫中角度,仅拥挤时径向换层。真实原始引擎响应存为golden,映射留在测试内。 +- 验证:Node22本地相关28项通过;固定种子512盘、真实引擎64盘所有标签两两检查无碰撞;Chrome实际字体129盘所有字框碰撞0,逐圆中心及逐字符hit-test错误0,实测字号12.53px、命中直径44.85px。最初真实字体碰撞与独立复核字符点击失败均留账于PROGRESS,未删失败样本或放宽1%阈值。 +- 防复发:原三项测试及11°间距/宫中角度/骨架断言不降级;扫描锁真实字号、完整标签数、所有标签对、命中分层顺序及无重复键盘按钮;浏览器逐字符检查不能以圆心距离替代。 +- 相关记录:BUG-906、BUG-1016、BUG-1017;BUG-1025旧入口文档本轮按历史与现状分开更正。 +- 复发自:西洋盘改版的布局覆盖不足。旧单盘golden缺少轴点附近拥挤情形,估算字框未覆盖真实字体。 +- 修复版本:`codex/chart-western-fix-20260925`工作树,未提交、未推送、未部署。 diff --git a/docs/research/pre_work_error_ledger.md b/docs/research/pre_work_error_ledger.md index d49a48cf..97df88f9 100644 --- a/docs/research/pre_work_error_ledger.md +++ b/docs/research/pre_work_error_ledger.md @@ -2,6 +2,23 @@ Purpose: read this file before substantial project work. It exists to stop repeat mistakes caused by multiple Codex windows, WorkBuddy mirrors, local drafts, backup folders, and partial cloud-git visibility. +## 2026-09-25 · 三份修复单最终验证环境保真 + +- Windows普通文件形式的migration symlink导致duplicate filename;被错误设为可执行的Skill文件改变hash framing。改用保留Git mode/symlink的archive,不能改迁移duplicate detector或registry hash迎合污染环境。 +- 纯archive没有Git metadata,`staging database environment file is ignored`调用`git check-ignore`得到128。隔离快照内补临时Git/index后以新label `final-r4b`重跑,前端3894/3894、PG17标准DB64/64、tsc/lint/build全部exit0;旧失败日志不覆盖,临时index不等于源树提交。 +- Node镜像的Python3.11不能执行另一Python3.13镜像创建的venv(python链接指向缺失`/usr/local/bin/python`);首次补验exit127并未启动测试。使用匹配Python3.13镜像、共享本轮已授权验证卷并将独立Node22工具加入PATH,不能把镜像不匹配当业务失败或伪称已跑。 +- 日志wrapper可能末步成功但前步失败,交付必须逐一读`.exit`、summary、compare及三种gzip,不只看wrapper状态。匹配Python3.13+Node22后定向49/49、CI quick948/1skip、隐私70/70和双扫描0finding通过,逐步exit均0。细节见整合PROGRESS final-r4b节。 +- 交付预检远端verified,focused再现两项机器目录假设失败:历史镜像缺失、candidate_count=4小于workspace_residue_count=45;后者与09-24已有同类记录一致。保留原日志,不伪造镜像、不清其它会话目录,不把预检整体写成pass。 + +## 2026-09-25 · 三份修复单的独立开工基线 + +- fetch 成功,固定 `origin/staging=48fe4daf519dab2a5c6bf4b01dd513067de1647b`;与任务书 `fabe0114` 仅文档差异,代码相同。主工作区原有文件不动,基线及三个实现均在独立工作树。 +- Python 3.11.7 运行预检:远端 verified,适配器与碎片步骤成功;focused 23 通过 / 1 失败,仍为 `test_preflight_fragment_scan_reports_authority_layers_and_risk_buckets` 要求历史 `.workbuddy` 路径。预检整体 fail,不制造镜像或弱化断言。 +- 实测 Windows Node 22.23.2、Linux Docker 引擎可用。本轮准备独立 Linux Node 22 与隔离 Postgres 做全量基线/最终比较,不拿 Windows 定向或旧门禁绿色代替本轮 Linux 全量。任何既有断言改动逐条列「原值 / 新值 / 原因」,包括补前轮欠账;新增失败非零禁止推送。 +- 独立 Linux Node22.23.2 基线已完成:npm test 3863/3863、真实DB61/61、tsc0、lint0error/127warning、build0首页Static。零失败基线不得套用旧Node20失败豁免;最终组合尚需同环境全套复跑。 +- Python 工具层两次环境修复均保留失败原日志:Windows quick 最初子进程默认GBK解码导致28失败,加 `PYTHONUTF8=1 PYTHONIOENCODING=utf-8` 后948/1skip通过;Linux Python3.13容器最初缺Node导致七政接口测试失败,补同一专用Node22运行时后948/1skip通过。未改产品/断言/依赖锁消红。Git Bash调用Linux容器路径需 `MSYS_NO_PATHCONV=1`;临时索引输入需 `git update-index --add -z --stdin`,不造仓库历史。 +- 详细证据与尚未完成的验收见 `docs/tasks/PROGRESS-three-fixes-integration-20260925.md`。 + ## 2026-09-25 · 读者版同步预检与上游 tip - 工作树 `codex/report-reader-main-20260924`,基线 `514951d7`。Python 3.11.7(Anaconda)跑通 `pre_work_check.py`:远端 verified,碎片扫描与适配器诊断成功。focused tests 22 通过 / 1 失败,仍是 `.workbuddy/skills/jyotish-vedic-astrology` 镜像路径断言。没有制造镜像目录,预检整体 fail,不记为通过。 diff --git a/docs/tasks/PROGRESS-chart-western-fix-20260925.md b/docs/tasks/PROGRESS-chart-western-fix-20260925.md new file mode 100644 index 00000000..0cb1ccad --- /dev/null +++ b/docs/tasks/PROGRESS-chart-western-fix-20260925.md @@ -0,0 +1,128 @@ +# PROGRESS · 西洋盘避让与移动端点击修复(2026-09-25) + +## 最终整合复验补记 + +组合快照 `final-r4b` 已在 Linux Node22.23.2 / PostgreSQL17 验证:前端3894/3894、DB64/64,fail/skip/cancel/todo均0;tsc/lint/build各exit0,lint0error/127既有warning,首页Static。顶层及嵌套旧测试名无删除、新增失败0;完整首屏/入口增量/专属JS gzip分别+0.1612%/−0.4536%/−0.7984%。完整日志与快照hash见 `PROGRESS-three-fixes-integration-20260925.md` final-r4b节。 + +下文执行期状态保留作历史。组件几何/字符命中证据不冒充受控登录、整页移动端、真实Tab/读屏或部署验收;这些缺口保留,BUG-1033不提前resolved。 + +## 状态与范围 + +本地实现和组件级验证完成,集成全量/独立复核/登录整页/部署待验收;未提交、未推送。BUG-1033由协调方分配(people预留1031/1032)。 + +- 工作树:`.worktrees/chart-western-fix-20260925`;分支:`codex/chart-western-fix-20260925`。 +- 实际开工基线:`48fe4daf519dab2a5c6bf4b01dd513067de1647b`,不是任务书早先的`fabe0114`。 +- 规格校正:任务书所写`frontend/src/lib/western-wheel-layout.ts`不存在;实际文件是`frontend/src/components/chart-page/western-wheel-layout.ts`,协调方已确认。 +- 只改Western布局/SVG、测试/golden及文档。无引擎/API/people/报告业务修改,无依赖升级、workflow/main/DNS/stash操作。 +- 用户硬门槛:**必须用Node22跑全量测试,并和开工时的基线逐条对比,新增失败为0才允许推送;改任何既有断言必须原值/新值/原因三栏。** 本单不以定向通过代替此门槛。 + +## 决策与实现 + +### D1 固定锚点,行星让位 + +保留ASC/MC的r162和+9°位置,共用同一坐标函数用于绘制与障碍盒。星座名和宫号亦作固定障碍。行星先保持既有11°松弛,再按距离代价搜索两层半径188/136及邻近角度,要求文字盒不交、完整在viewBox内、显示角仍至少11°、圆心距离大于命中半径+1。保留真实黄经用于刻度与引导线,未更改任何引擎数值。 + +独立复核发现真实窄宫的宫号互碰,因此最终不再豁免house-house:宫号保留真实宫中角度,默认r84,拥挤才径向尝试108/60/132/36。原golden仍全部r84,其既有半径断言未改。所有真实扫描样本保留,所有标签对均检查。 + +### D2 扫描而非单一幸运样本 + +- 固定种子20260925、LCG的512张几何压力盘:ASC附近、MC附近、全周均匀随机三类,均匀宫头仅用于几何压力测试,不冒充引擎合同。 +- 64张真实Swiss引擎虚构盘:保留原始响应及真实非均匀宫头,纬度±55°,无样本过滤。 +- `MAX_OVERLAPPING_CHART_RATE=0.01`,并对固定回归集合额外断言实际重叠盘数为0。 +- 渲染后读取实际inline字号,检查完整37个标签(11行星+26其他)、全部标签盒、边界;骨架和相位常显合同保留。 + +### D3 按实际盘宽与真实字体验收 + +| 指标 | 原值 | 最终值 | +| --- | --- | --- | +| 375px页面盘宽 | 忽略左右页边距 | 375−16×2=343px | +| 字号 | 17×343/520=11.21px | 19×343/520=12.53px | +| 透明圆直径 | 44×343/520=29.02px | 68×343/520=44.85px | +| 基线/符号字框 | middle / 约1em估算 | central / 符号2.2em,普通1.4em | +| 点击归属 | 同组圆/线按行星顺序覆盖 | 所有按钮圆先画,所有文字后画;逐字符归属自身 | + +真实本地Noto Symbols的文本盒约2.07em高,因此不能只按font-size估高。装饰引导线和刻度`pointerEvents=none`。可见文字层`pointerEvents=bounding-box`、点击同一回调,`aria-hidden`且无tabIndex;原透明圆组保留唯一键盘按钮/aria-pressed/标签,未产生重复Tab项。 + +### D4 原始golden及陈旧文档 + +原Western fixture从视图键子集改为完整`build_tropical_natal_from_request`返回对象,测试内mapper转换。另存64份完整原始响应。仅JSON格式化及LF换行,无键值改造。BUG-1025与首页拆分CHANGELOG按历史行为→当前`/people`跳转更正,不抹掉历史。 + +## 失败探索与复核账本 + +1. 初版估算几何扫描过,但Chrome65盘发现462对字框相交、7个中心命中错误。真实字体高度和middle基线导致漏算,后画装饰线也拦点击。修正字框/central/装饰线后65盘均0。 +2. 高字框暴露512样本中的7个行星-宫号冲突;把实际宫号盒加入障碍后消除,不缩字。 +3. 曾试`asc + offset*30 + skew*sin(offset*pi/6)`任意非均匀宫头,发现大量宫号互碰;这种生成器不是物理盘,未列正式引擎集。没有据此把全部验证换成均匀宫头:另补64张真实引擎完整响应。 +4. 真实64盘初次Chrome有5对宫号互碰(2盘)。最初新测试临时跳过house-house;独立复核指出任务书要求任意标签,最终撤销例外并实现仅径向宫号避让。同样64盘保留,最终宫号碰撞0。 +5. 独立复核指出只保护中心仍不够:真实扫描索引8/23/33/53/60可见文字末字符被邻星r34圆吞掉。改为先所有命中圆、后所有文字;逐字符`getExtentOfChar`中心`elementFromPoint`验证纳入可复跑脚本,最终0错误。没有把r34退回22来消红。 +6. `git diff --check`首次报生成JSON的CRLF空白,统一为LF后复核;不改变raw数据。 + +## 既有断言三栏 + +原有3个Western测试名称、断言表达式和chart-page-view原21项均未删除/替换。即便仅间接改变夹具或盒语义,也按三栏留账: + +| 原值 | 新值 | 原因 | +| --- | --- | --- | +| fixture为手工视图数组/键,测试直接读 | 完整引擎raw对象,测试内mapper构造视图 | AGENTS §7.4真实合同;11行星/12宫头真实值保留 | +| `westernLabelBox`默认font17、高约1em | font19、符号2.2em/普通1.4em | 修正真实字体与343px缩放;盒更大是收紧检查,不是降容差 | +| 单盘两两不交、11°、宫中角度/r84、骨架3圈12线无文字 | 原断言原样保留;追加512压力盘与64真实盘全标签扫描 | 提升覆盖,不删除幸运单盘合同 | +| 本轮新真实扫描草稿暂排除house-house | 最终取消排除,检查全部标签对,阈值≤1%且固定集必须0 | 独立复核纠正范围;宫号径向避让而非豁免失败 | +| 本轮新点击测试最初仅检查圆心 | 追加全部命中圆早于文字、唯一按钮数、装饰线不拦事件;Chrome逐字符hit-test | 独立审查发现可见字末尾被邻圆截获 | + +## 本地验证 + +运行时:Windows、Node v22.23.2、Python3.11.7;依赖复用已有node_modules目录junction,未安装或升级。Linux全量由协调方独立执行。 + +| 检查 | 结果 | 边界 | +| --- | --- | --- | +| `npx tsx --test tests/western-wheel-layout.test.ts tests/chart-page-view.test.tsx` | 28/28,fail0 skip0 | 原24项保留,新增4项 | +| `npx tsc --noEmit` | exit0 | 本地 | +| `npm run lint` | 0 error / 127 warning | 既有无关警告,不顺手修 | +| 512压力盘 | 全标签碰撞0 | 固定种子几何,不是引擎响应 | +| 64真实引擎盘 | 全标签碰撞0 | 不过滤样本,不排除house-house | +| Chrome129盘实际字体 | 碰撞0、宫号碰撞0、圆心与逐字符命中错误0 | 65真实含原golden +64压力;343px盘宽 | +| Chrome实测 | 最小字12.53269px、命中直径44.85382px | 独立组件,不是登录页 | +| LinuxNode22开工基线(协调方回报) | 3863/3863,fail0 skip0;DB61/61,tsc0 | 最终集成仍须名称逐条比较,不能用旧Node20失败豁免 | +| Linux最终全量/build/Static/gzip | 待协调验收 | 未声称通过;不得推送 | +| 登录页面/键盘/点选取消/部署health | 待验 | 见BLOCKED与真人清单 | + +额外`pre_work_check.py --remote-timeout 8 --command-timeout 45`结果fail:`test_preflight_fragment_scan_reports_authority_layers_and_risk_buckets`在`tests/test_preflight_fragment_scan.py:64`要求`.workbuddy/skills/jyotish-vedic-astrology`镜像命中,但独立worktree无该镜像;Python运行、外部适配、远端可见性和其余扫描项通过。未修改Python生产文件或该断言;此失败不豁免前端门禁。 + +## 复跑原始响应 + +仓库根目录运行;输入均为明确虚构的测试数据,不来自真实用户: + +```python +import json, random, sys +from pathlib import Path +sys.path.insert(0, 'scripts') +from western_chart_engine import build_tropical_natal_from_request +out = Path('frontend/tests/fixtures') +one = build_tropical_natal_from_request(dict( + year=1990, month=1, day=1, hour=12, minute=0, + lat=39.9042, lon=116.4074, tz=8, house_system='P')) +r = random.Random(20260925) +scan = [build_tropical_natal_from_request(dict( + year=1970+i%45, month=1+i%12, day=1+i%27, + hour=i%24, minute=(i*13)%60, + lat=r.uniform(-55,55), lon=r.uniform(-180,180), tz=0, + house_system='P')) for i in range(64)] +for name, value in [('western-1990-01-01-beijing.json', one), + ('western-fictional-scan-20260925.json', scan)]: + (out/name).write_bytes((json.dumps(value, ensure_ascii=False, indent=2)+'\n').encode()) +``` + +## 复跑浏览器证据 + +`artifacts/western-browser-check.tsx`读取实际组件、globals.css和本地Noto字体,生成HTML,不接网络或用户数据。脚本及`artifacts/western-browser-result.json`作为复跑证据;生成HTML、Chrome profile、stderr和其他审查临时文件不纳入交付清单。 + +```bash +cd frontend +NODE_PATH="$PWD/node_modules" npx tsx ../artifacts/western-browser-check.tsx +# Chrome路径按本机安装调整,使用新建的专用临时profile,不借用登录profile +chrome --headless --no-first-run --disable-gpu \ + --user-data-dir=/tmp/western-check-profile --window-size=375,850 \ + --virtual-time-budget=10000 --dump-dom \ + file:///absolute/worktree/artifacts/western-browser-check.html +``` + +等待字体就绪后`#result`必须charts129、collisions[]、houseOnlyCollisions[]、hits[]。页面交互、选择/取消及键盘需另按`docs/testing/chart-dasha-western-20260924.md`在受控登录环境执行。 diff --git a/docs/tasks/PROGRESS-people-archive-p1-fix-20260925.md b/docs/tasks/PROGRESS-people-archive-p1-fix-20260925.md new file mode 100644 index 00000000..a5fdaa21 --- /dev/null +++ b/docs/tasks/PROGRESS-people-archive-p1-fix-20260925.md @@ -0,0 +1,115 @@ +# PROGRESS · 人物档案 P0/P1 修复(2026-09-25) + +## 最终整合复验补记 + +Linux Node22.23.2 / PostgreSQL17 的 `final-r4b`:全量 **3894/3894**、DB **64/64**,fail/skip/cancel/todo 均0;tsc/lint/build各exit0,lint0error/127既有warning,首页Static。相对开工基线3863/61,顶层及嵌套名称多重集合均无删除、新增失败0。三种首页gzip变化为完整资源+0.1612%、入口增量−0.4536%、专属JS−0.7984%。日志、快照hash、四项修复三栏及验证环境失败保留在 `PROGRESS-three-fixes-integration-20260925.md` final-r4b节。 + +以下执行期记录保留作历史;旧面板在整合树保留,不能将本单执行树的删除写成已交付。Python/隐私及最终边界核查另行补记;受控真人、迁移上线和health仍待验,未据自动化绿提前resolved。 + +## 基线与门禁 + +- 开发树:`codex/people-archive-p1-fix-20260925`,实际开工基线 `48fe4daf519dab2a5c6bf4b01dd513067de1647b`(任务书之后的新 staging)。不改其它分支、workflow、DNS;未提交、未推送。 +- **必须用 Node 22 跑全量测试,并和开工时的基线逐条对比,新增失败为 0 才能推;改任何断言都要写三栏。** +- 协调方 Linux Node22 基线:前端 3863/3863、fail0、skip0;数据库 61/61;tsc exit0。旧 Node20 的失败不作为本轮豁免。 +- 当前仅本地定向证据:Node v22.23.2,首轮组合 70/70,provider/report/cache 39/39;tsc exit0;前端修改区域 lint 0 error(既有 warning 未顺手修改);根 Python 三个相关合同 21/21。新增星历用例、Linux全量与DB快照审查仍进行中,完成后由协调方补确数。 + +## 实现清单 + +| 任务 | 实现 | 验收状态 | +|---|---|---| +| T1 worker | 持久化 chartProfileId 经 loadSubjectBirth;他人从 typed columns 读取;候选范围仅本人;人物缺失/外属终止且走既有退款 | fake-engine ready 与删除/外属失败退点定向通过;真实模型/线上待验 | +| T2 OR is | 复用 unary IS 编译器,支持 null/true/false,不当作字符串或 =null | 单测通过;真实 sessions GET + PG 在新增 DB 用例中 | +| T3 时序 | 同步 last-account 预读仅作暂存选择;账户/目录校验 ready 闸门挡 chart/report/ephemeris/newChat;provider先账户再目录再session | 跨账户/目录事件单测通过,受控浏览器待验 | +| T4 消息 | metadata merge 保留 hydrated 消息、深链及分页外行;人物不变不广播subject;视图按当前人物过滤,跨账户清除旧数据 | 纯函数与provider合同通过,生命周期独立审查中 | +| T5 纠偏 | 新迁移降级旧JSON accepted/confirmed、清active tuple;不改旧迁移、不改profiles;目录失效刷新;未知用量拒绝删除;daily按所选资料;合盘新对话显式self且不改全局;星历暖缓存/首败重试 | 定向通过;迁移与真机待独立验 | +| T6 清理 | 有界 ids 预检替代count/head;同账户事务锁确保4人上限;无效UUID 404;report双字段冲突400并规范化持久化;删除死面板、合同改锁/people;编辑标题纠正 | DB并发用例新增,所有既有测试名保留 | +| T7 记录 | BUG1031/1032,关联1030/990/1024;本记录、设计、changelog、真人清单 | BUG状态保持 investigating,未部署 | + +## 数据边界与设计说明 + +- 检查迁移:`personal_reports.chart_profile_id` 为 nullable uuid,无指向 chart_profiles 的 FK;不存在 ON DELETE SET NULL 把他人改成本人的窗口。**独立真实DB进一步确认:删除RPC级联删报告/job后,worker不会再看到缺失人物,单靠worker失败退款不足。** 新 `20260925040000_deleted_report_release.sql` 在删除事务内以受限definer触发器复用`release_usage`,未交付预留退款;ready不退,已completed不退,已released幂等。删除RPC显式校验auth.uid/所有权并按job→report锁序避免running worker完成时死锁。未改旧迁移、未开放通用退款权限。 +- 新纠偏迁移在独占表锁事务中临时停用 birth guard,清理完立即恢复。旧JSON保留作兼容,不再赋予校正权威。profiles不写入。 +- 人数上限使用 owner 派生的事务 advisory lock 后再独立 count,两个连接争抢第四/第五个名额;预检只改善错误反馈,DB为最终权威。 +- Provider内部保留同账户会话,以免深链/消息丢失;可见侧栏按当前人物过滤,不展示对方列表。不同账户不能合并。 +- Home未新增useState/useRef。Part B为纯编码豁免。Skill版本不bump。 + +## fabe0114 追溯三栏(完整覆盖11个文件、12个修改位置) + +| 文件/断言 | 原值 | 新值 | 原因 | +|---|---|---|---| +| chart-page-view / provider URL | sessions仅limit | limit+subject | 历史按当前人物;本轮进一步用bind后捕获subjectId | +| chart-view-route / birth reader | ACCOUNT_BIRTH_SELECT直读 | loadSubjectBirth且拒clientBirth/body.profile | 唯一服务端人物解析入口 | +| consultation-entrypoint / disabled | 产品/加载/变更3条件 | 加 !rectificationForSelf | 校正仅支持本人 | +| database-admin-account-reset / fixture | role=self | role=other | 本人镜像停写,仍覆盖重置他人档案 | +| home-bootstrap-reveal / guards源 | 只page | page+binder,数量仍2 | 今日星语移到独立组件,并未减少等待约束 | +| personal-report-api / birth | from(profiles) | loadSubjectBirth | 报告取当前人物资料 | +| personal-report-api / GET签名 | GET() | GET(request: Request) | 支持subject查询参数 | +| report-row-delete / fetch匹配 | url==/api/reports | startsWith(/api/reports) | 允许subject查询,旧响应复活测试仍保留 | +| secondary-page-entry / state初始化 | viewFromSnapshot(peekChartPage()) | viewMatchingSubject(subjectId) | 防暖缓存显示错人 | +| server-owned-birth-profile / synastry | select ACCOUNT_BIRTH_SELECT | loadSubjectBirth | 本人与对方共用权威解析器 | +| session-list-provider / URL | sessions仅limit | limit+subject | 当时调用readCurrentSubjectId;本轮进一步bind后捕获subjectId | +| settings-mvp-contract /菜单 | span星盘资料 | /people星盘档案并禁止旧span | 独立档案页替代已退役对话框 | + +## 本轮既有断言三栏 + +| 范围 | 原值 | 新值 | 原因 | +|---|---|---|---| +| adopted-birth-date | 死面板 record.profile有效日期 | /people selected.profile有效日期 | 保留日期正确性,转锁实际入口 | +| chart-library-other-profile:保存资格 | nextProfile | draft+保存中守卫 | 新人物页独立保存不依赖户主校正 | +| 同文件:保存/删除失败与成功刷新 | 旧helper调用、云成功文案、deleteOtherChart catch | 人物页POST/PUT/DELETE、失败早返、invalidate+load | 不再测试不可达代码;失败不得本地伪成功 | +| 同文件:添加后合盘 | 选关系提示/用于合盘按钮 | 显式和我合盘、保存不触发合盘 | 保留显式操作语义;无人物关系字段 | +| 同文件:pending | 死按钮disabled/spinner | hook重入守卫、finally复位和既有通知 | 真实入口仍不可重入 | +| 同文件:列表 | ChartLibraryView/list返回片段/添加其他人 | editing=false、列表无form、条件ChartProfileForm、添加一个人 | 名称不删除,锁替代UI | +| 同文件:本人删除/合盘 | selfView无删除、死面板历史 | 本人不能删、self新对话、绑定删除确认id | 真实详情操作边界 | +| settings-mvp-contract:编辑 | 旧本人saveProfile+otherSave | 账户readProfile+other独立saveOther | 移除死入口,不伪造当前不存在的本人编辑 | +| chart-page-view + session-list-provider URL | 调用时readCurrentSubjectId | bind后捕获subjectId | 账户与目录闸门先于请求 | +| session-list-provider 401 | 并行 OR状态检查 | 先account401,再sessions401 | 防跨账户预读先发请求;仍各自清理 | +| home-surface、3个Python surface集合 | chart-library-panel路径 | people/people-page路径 | 扩散半径同时检索tests/与frontend/;保留测试名 | +| test_supabase_user_data_contract | 死样式synastry-report-card/history、旧添加/用于合盘 | people-detail/pendingDelete、添加一个人/和我合盘 | 旧无入口面板退役,重定向合同不删除测试 | +| ephemeris生命周期、report-row-delete fixture | 默认无需绑定 | 显式虚构account已bind | 新ready门要求,原行为断言不弱化 | +| personal-report-worker harness | loadProfile只收userId | 收userId+subjectId,可注入真实resolver | 既有断言不变,扩展P0成功/失败路径 | + +## 最终对比方法(待协调方冻结后执行) + +```sh +node -v +npm test > final-tests.log 2>&1 +npm run test:db > final-db.log 2>&1 +./node_modules/.bin/tsc --noEmit +npm run lint +# 基线和最终日志分别提取完整TAP名称,使用集合差:基线名称 - 最终名称必须空; +# not ok 名单逐条比较:当前Node22基线空,因此最终必须空。 +``` + +必须记录实际Linux日志路径、失败名称差集、消失名称差集、build Static及gzip;当前不宣称这几项完成。没有受控账号,不借用凭据,真人清单待产品负责人执行。 + + +## 独立审查闭环与冻结(2026-09-25) + +- 修复self worker metadata投影遗漏:唯一出生解析不变,仅self额外读取`rectification_case_id`;他人不读profiles,候选窗仍self专用。永久worker测试按真实select投影,防mock整行掩盖漏列。 +- report subjectId别名与chartProfileId同传先判冲突,移除别名再入原strict schema,不放宽schema。永久测试覆盖别名单传、两者同值成功、冲突拒绝和未知字段仍拒绝。 +- 标准`?new=1`两个bootstrap新建分支改用已绑定当前人物;合盘特殊self独立,不改变全局人物。永久bootstrap行为测试覆盖。 +- A人物分页晚回不得覆盖B cursor,scope+generation同时守卫提交/错误/finally;永久React hook测试重放原竞态。 +- provider用稳定初始ready避免重复bootstrap,但提供`waitUntilReady`等待当前reload代;Home调用当前gate。非401目录失败作为可恢复错误,不假登出。永久provider+bootstrap行为测试分别覆盖reload等待和目录503。 +- reports/ephemeris共享cache按account+subject隔离,过期异步结果拒写;页面同scope拒晚响应。常规登出是硬导航,跨账号函数级探针不夸大为普通用户路径P0。 +- `people-refund-r4`独立真实PG矩阵六场景通过:queued、running、completed、already-released、ready待结算、running完成与删除双连接并发。原失败探针/修复后探针均保留在协调方忽略artifacts,不提交。 +- 本地Node22.23.2最终生产冻结点:tsc exit0;完整lint **0 error / 127 warnings**(未顺手修无关warning);54/54 review定向、fail0;先前辅助5文件51项定向通过。日志本机临时目录`people-tsc8.txt`、`people-lint-final3.txt`、`people-review-regressions6.txt`。不同组合可能重叠,不把它们相加伪称全量。 +- Linux中间r1标准DB63/63、全量3868中35fail;后续定向已修各失败组,但**中间快照不是最终验收**。冻结已通知协调方,下一次直接整合最终Linux全量和永久DB测试。未经最终fail0/名称多重集合差为空不得推。 +- 新永久DB退款用例覆盖真实authenticated删除,不再只mock人物null;ready fixture遵循report_document/完成时间/hash CHECK。双连接独立验证已过,最终标准suite待协调方。 + +### 独立审查后既有测试三栏补充 + +| 文件/范围 | 原值 | 新值 | 原因 | +|---|---|---|---| +| chart-page-hook fixture | cache reset后直接挂载 | reset后绑定明确虚构账户/空others | 新目录ready闸门;原17个超时、401、429、重试和缓存断言不改 | +| report-polling-contract / loadReports取消防线 | `assert.match(centerSource, /if \(cancelled\.current\) return;/)` | `assert.equal((centerSource.match(/if \(cancelled\.current \|\| requestedSubject !== readCurrentSubjectScope\(\)\) return;/g) ?? []).length, 2)` | 成功和异常两分支均在取消或账户/人物scope变化时拒绝提交;检查两处防线,未放宽取消/轮询频率合同。原台账误写ready依赖迁移,现按HEAD实际diff更正 | +| composer-isolation 两否定断言取样 | homeSurface混入独立人物表单draft | Home+聊天相关hooks集合 | 独立编辑draft不是Home根聊天draft;无根useState/value断言不变 | +| daily-starlanguage effect切片 | 旧guard/旧deps锚点 | useEffect起点/含subjectReady的新deps | 新ready/profile guard与cleanup;1次重试、清timer、cache指纹断言保留并增强当前人物来源 | +| session-list-lifecycle fetch fixture | 只account/sessions,无目录隔离 | chart-profiles合法响应,挂载/卸载reset目录 | 真走新加载链,不预设ready;StrictMode/401/不稳定callbacks收敛断言不改 | +| session-list-provider bootstrap合同 | `await deps.sessionListReady` | `await (deps.waitForSessionList?.() ?? deps.sessionListReady)`且Home传函数 | 初始promise已resolve不能代表人物切换reload就绪;永久行为测试同时锁住 | + +新测试编写时的修正不伪装旧基线变更:首次新bootstrap成功测试误以为hydrated=true,按既有正常两阶段揭幕模型改锁phase=prepare+正确account;新pagination探针去掉any并指定hook类型;新增DB ready fixture补齐既有CHECK,均非放宽既有断言。 + +### 交付文件边界 + +交付包含本树tracked修改/删除、3个新增lib(worker-subject、report-subject-request、subject-session-merge)、两份新迁移、两份新people测试、进度及委托补齐docs。所有`artifacts/*`复现脚本/日志和`frontend/node_modules`junction不进提交。无commit/push、无真实账号凭据、无生产变更。 diff --git a/docs/tasks/PROGRESS-report-reader-main-fix-20260925.md b/docs/tasks/PROGRESS-report-reader-main-fix-20260925.md new file mode 100644 index 00000000..906c4277 --- /dev/null +++ b/docs/tasks/PROGRESS-report-reader-main-fix-20260925.md @@ -0,0 +1,74 @@ +# PROGRESS · 读者版年运与投影修复(2026-09-25) + +## 最终整合复验补记 + +组合快照 `final-r4b` 已在 Linux Node22.23.2 / PostgreSQL17 验证:前端3894/3894、DB64/64,fail/skip/cancel/todo均0;tsc/lint/build各exit0,lint0error/127既有warning,首页Static。相对开工基线无旧测试名消失、无新增失败;完整首屏/入口增量/专属JS gzip分别+0.1612%/−0.4536%/−0.7984%。完整证据与环境失败历史见 `PROGRESS-three-fixes-integration-20260925.md` final-r4b节。 + +下文为本单执行期记录;Python/隐私最终快照补验另记,受控登录与部署缺口不由自动化替代,BUG状态不提前升级。 + +## 状态与基线 + +本地实现与定向回归已完成,等待协调方对组合代码独立审查、Linux Node 22 全量及构建验收。未提交、未推送、未部署;BUG-1026 / 1028 不提前写 resolved。 + +- 任务:`TASK-report-reader-main-fix-20260925.md`,本树 `codex/report-reader-main-fix-20260925`。 +- 开工远端基线:`48fe4daf519dab2a5c6bf4b01dd513067de1647b`;任务所列 `fabe0114` 之后为任务文档更新。 +- 已检索并完整核对 BUG-1026 / 1028 及关联 BUG-999 / 1027;旧防复发测试存在,但只锁表头、泄漏词或同键合成字典,未检查实际可见单元格和 Bhava Bala 整表。 +- 已读 AGENTS、frontend/AGENTS、DESIGN、VOICE、CONTEXT 与预检台账。开工预检 `python scripts/pre_work_check.py --remote-timeout 8 --command-timeout 45`:remote verified,fragment scan / adapter diagnostics 已执行;focused 因既有 `.workbuddy` mirror 路径断言失败,未伪造环境。 +- 用户硬门槛:必须 Node 22 + Linux 全量,按名称逐项对照,新增失败为 0 才能推;每个既有断言改动记录原值 / 新值 / 原因。本执行方没有推送权限。 + +## T1–T3 实现 + +1. `annual_tajika_pack._annual_comparable_value`:年主仅比较实际行星,Tajika 缺少 `year_lord_sign` 不再导致假冲突。代码注明 BUG-1028 与上游差异。真实不同年主仍 conflict;参考版提示也不再无条件宣称分歧,且保留外部验证未完成说明。 +2. `pl9_reader_export`:年主、Muntha、年度上升各从独立字段取值;真实冲突以「太阳返照:…;Tajika:…(两者不同)」并列,不强行取一侧。选取依据仅解释现有本地 Muntha 守护星口径,不冒充 Panchadhikari。`_annual_series_surface` 补保留 `solar_return`,未来年返照时刻从对应年度真实数据读取。 +3. `report-public-projection`:共享安全规则与 PL9 报告专用规则分离;聊天导出、安全 chart fence / SVG 不使用新长英文段规则。明确内部字段、密钥、危险 URL、脚本仍拦截。Score / Weight 不单独让报告领域表消失,provider / attempt / job / model 仍拦截。删内部表只连紧邻说明段,不连标题。 +4. 五要素只显示中文「综合性质 / 吉性要素数量 / 要素总数」,删除原始评分键;缺姓名不输出空姓名行。 +5. 未改 API 主文件、Ayanamsa、宫制、大运、依赖、迁移、workflow、人物档案或西洋盘代码;未改 Skill 包,版本不 bump。 + +## 上游 Panchadhikari 同步决定(D2 允许让步) + +查证上游提交 `23b9609e330dbee70a4725bdc222b125d9b0e39f` 的 `scripts/tajika.py::calc_panchadhikari_year_lord` 及其力量计算闭包:会在产品进程导入 `jhora`,外部不可用时退到本仓已 blocked 的 Panchavargiya native_proxy;且其 Muntha 从年度上升推算,与本轮恢复本命上升的既有口径不一致。完整同步会跨越许可证隔离与算法治理边界,不能为了补表格解除 blocked。 + +因此按 D2 使用本地既有真实字段,不引入此选择器、不改算法、不宣称 oracle / 多引擎精度闭环。查证源码只留本地 `artifacts/report-reader-fix/upstream-tajika-inspect.py`,不并入运行包。 + +## Golden 与测试证据 + +Golden 使用原有明确虚构测试输入,通过 `cmd_full_reading → build_professional_report_reference_packet → _pl9_export_markdown_for_edition(reader_main)` 真实重算,1832 行;provenance 增加生成链和任务名。未手写领域响应。 + +末尾重算脚本再次覆盖 golden 被工具拒绝,未换工具绕过,也未转交覆盖。改用**只读**完整引擎重算对照,确认最终代码输出与先前已成功重算的 golden **逐字相等**,故不需要第二次覆盖。证据 `fixture-final-verification.log`。这仅证明**该次重算逐字相等,不承诺全引擎跨次字节确定性**。协调方独立多次重跑观察到 Shadbala Mercury / Venus 的 Drik / Total 有 0.01 漂移,年运与章节无差;保留本次成功记录及独立限制,不改算法、golden 或容差消除漂移。本单字段回归以真实年运来源和明确单元格断言为准,不能由一次全报告相等推断所有引擎输出确定性。 + +新增 Python 4 项测试覆盖:真实不同键字典一致、三年非空且变化、未来真实返照时刻、真实两侧值冲突故障注入、缺名与五要素清理。前端新增2项并增强 golden:Bhava Bala 标题+十二行表原样存在、后续表不丢标题、聊天英文保留、安全规则不退步、年运可见格不为空。没有删除既有测试。 + +| 验证 | 结果 | 证据(本地 artifacts/report-reader-fix/) | +| --- | --- | --- | +| Python 开工两文件基线 | 17 通过 | report-baseline-tests.log | +| Python 修改后两文件 | 21 通过 | python-targeted.log | +| Python 最终扩展7文件(含API增长、隐私、parity) | 124 通过,0 失败 | python-expanded-final.log | +| 前端三文件首次 | 38 通过 / 2 个旧 SHA 不符;逐行检查后按 D3 修正 | frontend-targeted.log、projection-diff.txt | +| 前端三文件最终,Node 22.23.2 | 40 通过 / 0 失败 / 0 跳过 | frontend-targeted-final.log | +| 最终 tsc --noEmit | exit 0,0 错 | tsc-final.log | +| 最终 npm run lint | exit 0,0 error / 127 warning | lint-final.log | +| Python quick,UTF-8,skip-frontend-runtime | 首跑与最终重跑均 exit 0;最终 948 passed / 1 skipped | python-quick.log、python-quick-final.log | +| 最终真实引擎与 golden 对照 | 1832 行逐字相等,未覆盖 | fixture-final-verification.log | + +环境:Windows、Node 22.23.2 / npm 10.8.1、Python 3.11.7。quick 使用 `PYTHONUTF8=1 PYTHONIOENCODING=utf-8`,与协调方 UTF-8 基线同环境;前端单独跑,不把 Windows 定向当 Linux 全量。 + +协调方通报的 **Linux Node 22 基线**:3863/3863、fail0、skip0、DB61/61、tsc0、lint0error/127warning、build0 首页 Static;证据主仓 `artifacts/three-fixes-baseline-20260925-u2mvj0n4/linux-logs/`。最终组合必须 fail0、原测试名称无消失、新增失败0,构建/gzip由协调方独立验收,不能引用旧 Node20 失败豁免。 + +## 所有既有断言改动三栏 + +| 断言位置 | 原值 | 新值 | 原因 | +| --- | --- | --- | --- | +| test_annual_tajika_pack:executive_summary.status | in {blocked, parameter_sensitive} | == partial_verified | 两侧真实相同年主不再误报冲突;只是本地可读状态,外部 replay 边界未升级 | +| test_annual_tajika_pack:unified.status | in {blocked, parameter_sensitive} | == partial_verified | 同上,统一报告摘要同步真实本地状态 | +| report-public-projection:KP 原始表标题 | doesNotMatch | match ### KP 原始表 | D3 只删内部表及说明段,不删标题;防后续正常表归错章节 | +| personal-report-density-retry:投影 SHA | f3aeb8f4b09ba0a78af4ab5e7aee4db32d7499b8e6b1488f2925b35fd91750b9 | 3d16948c52d97adbdcfee45441336f8cd1ae242e3c0c99f766cb8aa0bc964067 | 固定旧 density golden 未改,旧/新投影逐行差异仅增加标题与空行,符合 D3;不是为消红放宽泄漏 | +| report-export-blocks:LF 下载 SHA | b7f6947f6ae6f82e4a972be3c1f2bc50260ed0c5f029847264f40a5ee6ba7b88 | 4295165c4ed6e13b03e8c3c25fd54ad3ef5a2ca1732e9f0783d0ada0c2bbfef2 | 同一投影恢复标题后普通下载同步;22 个安全 SVG、去围栏、逐字节组合、泄漏零命中断言未弱化 | + +只有上述5处改变既有预期。新增断言不替代原有泄漏、安全或图盘断言;参考版年运提示回归也使用真实完整引擎结果。 + +## T4 记录与未完成验收 + +- 沿用 BUG-1026 / 1028 补根因、旧测试为何未拦住、修复与回归;更新 testing、CHANGELOG、DESIGN、任务索引、BLOCKED。 +- 无受控登录,未读取或借用凭据。浏览器清单未执行,不写已验收;无提交/推送/部署/health 声明。 +- quick 在 Windows 把5个既有 pending_packets 文件改为 CRLF;逐字核验确为纯换行后,恢复操作被工具拒绝覆盖。未绕过或转交代执行,保留本树脏态;这5个文件不在交付白名单,不修改 oracle 内容。整体 `git diff --check` 会因此报 CRLF whitespace;仅交付范围 `scripts frontend tests docs CHANGELOG.md BLOCKED.md` 的 diff --check 为0。清理需用户另行批准,见 BLOCKED。 +- 本单是纯代码维护,不输出运势解读;MEVG/三引擎精度未在本轮关闭,不能把自动化绿当作领域真值验证。 diff --git a/docs/tasks/PROGRESS-three-fixes-integration-20260925.md b/docs/tasks/PROGRESS-three-fixes-integration-20260925.md new file mode 100644 index 00000000..220b9bad --- /dev/null +++ b/docs/tasks/PROGRESS-three-fixes-integration-20260925.md @@ -0,0 +1,163 @@ +# 三份修复单整合与独立验收 · 2026-09-25 + +## 授权与基线 + +- 产品要求同步代码并执行人物档案、报告读者版、西洋盘三份修复单;人物档案最优先,另两单在不交叉代码文件的前提下并行。 +- 产品追加硬门槛:**必须用 Node 22 跑全量测试,并和开工时的基线逐条对比,新增失败为 0 才能推;修改任何既有测试断言均记录「原值 / 新值 / 原因」三栏。** 三份任务书进一步要求 Linux 环境。 +- 已成功 fetch;本轮固定开工基线为 `48fe4daf519dab2a5c6bf4b01dd513067de1647b`(`origin/staging`)。任务书引用的旧版本为 `fabe0114`,验证代理核对二者仅 5 份文档差异,产品代码一致。2026-09-25 03:33 UTC 公共 staging health 只读核对 HTTP 200,web/api SHA 均为 `fabe0114dd324cf56e4690d1be7c16d42916af07`;这是开工部署基线,不是本轮修复部署证据。 +- 主检出有既存未跟踪文件,本轮不切分支、不覆盖、不顺带提交。三个执行树、一个基线树与一个整合树均独立创建。 +- 不提升 main、不改 workflow、不动 DNS、不进行生产发布;执行代理不自行提交或推送。 + +## 执行与整合顺序 + +| 顺序 | 执行工作树 | 范围 | 状态 | +| --- | --- | --- | --- | +| 1 | `.worktrees/people-archive-p1-fix-20260925` | TASK-people-archive-p1-fix-20260925 全部 T1–T7 | 执行中 | +| 2,可并行 | `.worktrees/report-reader-main-fix-20260925` | TASK-report-reader-main-fix-20260925 | 本地实现、独立定向验收完成,白名单已整合 | +| 3,可并行 | `.worktrees/chart-western-fix-20260925` | TASK-chart-western-fix-20260925 | 字符误点/宫号修正后独立复验通过,白名单已整合 | +| 基线 | `.worktrees/three-fixes-baseline-20260925` | Linux / Node 22 全量、DB、构建基线 | 已完成,零失败 | +| 最终整合 | `.worktrees/three-fixes-integration-20260925` | 顺序合入三单,独立审查和全量复验 | 报告与西洋已合入;人物修复审查发现及中间全量失败中 | + +产品代码按各任务书边界隔离。BUG_HISTORY、CHANGELOG、DESIGN、任务索引等共用文档在整合树串行处理,不覆盖其它轮次记录。新 Bug 号人物档案使用 BUG-1031 / BUG-1032,西洋盘如需新记录使用 BUG-1033;报告沿用 BUG-1026 / BUG-1028。 + +## 开工预检 + +主会话已在独立基线运行: + +```text +python scripts/pre_work_check.py --remote-timeout 8 --command-timeout 45 +``` + +| 项目 | 实测结果 | +| --- | --- | +| Windows Node | 22.23.2 | +| Windows Python | 3.11.7 | +| Docker 引擎 | Linux,可访问;不得借用其它会话资源 | +| 远端可见性 | verified | +| 适配器、碎片扫描步骤 | 成功;不代表三引擎数值交叉验收 | +| focused tests | 23 通过、1 失败,预检整体 fail | + +失败为 `tests/test_preflight_fragment_scan.py::test_preflight_fragment_scan_reports_authority_layers_and_risk_buckets`,断言要求机器存在历史 `.workbuddy/skills/jyotish-vedic-astrology` 镜像路径。与错误台账已有环境问题同类;不伪造镜像目录、不改断言消红。这不是本轮业务修复通过的证据。 + +## 已完成的开工测试基线 + +验证代理独立 Linux / Node 22.23.2 环境实跑(Docker 29.8.1、Compose 5.5.1、Postgres 17),原始证据位于主仓忽略目录 `artifacts/three-fixes-baseline-20260925-u2mvj0n4/linux-logs/`(不是提交内容);主会话已读取对应 README 核验环境与口径。 + +| 检查 | 基线结果 | +| --- | --- | +| npm test | 3863 通过 / 0 失败 / 0 跳过;顶层 TAP 名称多重集合 3833(另有嵌套用例) | +| npm run test:db | 61 通过 / 0 失败 / 0 跳过 | +| tsc --noEmit | exit 0 | +| lint | exit 0,0 error / 127 既有 warning | +| build | exit 0,首页 `○ Static` | +| 首页专属 JS gzip(level 9) | 4 块,130258 B;最终必须单独比较,避免共享资源分母掩盖增长 | +| 首页相对父 layout 增量 JS gzip | 8 块,229275 B(其中 4 块 99017 B 同时被其它页面共享) | +| 首页完整直链 JS/CSS gzip(level 9) | 32 份资源,合计 682982 B(JS 641409 B / CSS 41573 B);其余父 layout/runtime/CSS 共 24 资源 453707 B。三种分组均保留逐资源 SHA256 与清单,最终同口径比较;不同于旧任务书 130933 B 口径,不混比 | +| Windows Python quick 首轮 | 28 失败 / 920 通过 / 1 跳过,subprocess 默认 GBK 解码 UTF-8 报错;原日志保留 | +| Windows Python quick UTF-8 复跑 | `PYTHONUTF8=1 PYTHONIOENCODING=utf-8 python scripts/run_quality_gate.py --profile quick --skip-frontend-runtime`,948 通过 / 1 跳过,exit 0;前端由独立 Linux 全量验证,不冒称此命令包含前端 | + +Python 基线日志在整合树 `artifacts/three-fixes-verification/python-quick-baseline.log` 与 `python-quick-baseline-utf8.log`。没有修改产品代码来解决编码问题。 + +**本轮 Linux 前端/DB 基线为零失败,最终版本必须零失败;不得引用前轮 Node 20 的 56/61 项失败作为豁免。** + +## 分单独立审查与早期反馈 + +- 报告:独立只读 Windows Node 22.23.2 五文件 51/51,Python 年度两文件 21/21;D1–D5 已测范围无确认新增产品缺陷,5 处既有断言三栏齐全,22 SVG/泄漏零命中未弱化。白名单已合入整合树,5 个 oracle 纯换行副作用排除。真实引擎跨次 Shadbala 0.01 漂移限制见分单 PROGRESS,不宣称全报告确定性。 +- 西洋:首轮独立 Chrome 扫描发现 64 真实盘中 5 个可见字符中心会命中另一行星;仅圆心点击为零错误不足以验收。另有 2/64 盘出现宫号间重叠,不接受排除宫号后称全部标签达标。修正绘制层与宫号径向位置后,独立原脚本 v2 复验 64 真实盘 / 2368 标签(含宫号)碰撞 0、2865 字符误点 0、704 圆心误点 0,原 5 例全部恢复;最小字号 12.5327px、命中直径 44.8538px。键盘处理器 Enter/Space 22 次正确、Escape 11 次忽略,样例相位选择前后均 2,骨架 text/button/aspect 均 0。主会话已读原始 JSON;产品/测试/raw fixture 白名单已整合。上述是组件/处理器验证,不冒称登录整页、真实 Tab 或读屏验收。 +- 人物:本地定向 70/70 为执行方证据。独立后端六文件 39/39,但实际投影重现本人 `rectification_case_id` 遗漏导致旧候选范围丢失;实际 strict schema 重现有效 `subjectId` 别名残留导致 400,均退回执行方修。独立前端真实 bootstrap 注入重现旧 `?new=1` 入口仍硬编码本人;暖星历缓存账号边界仍在核查。标准用例通过不等于这些遗漏不存在。 +- 人物早期 Linux 固定快照 `people-db-r1` 标准真实 DB 63/63、fail0/skip0(基线61);纠偏/并发及 GET self 路径已实跑。该中间快照全量 3868 项 / 3833 通过 / 35 失败,tsc 有 1 处测试 mock 返回类型错误,lint 0 error。主会话已提取 35 个失败名;包括旧生命周期 harness 未 bind、退役入口源码合同、Home 聚合 surface 扩大等,执行方须逐项修正并记三栏,不归为环境豁免。当前不得推送。 +- **独立真实 DB 已确认删除人物退款遗漏(P0)**:fixture 余额 10,经 `authorize_usage(report.full,2)` 后 8,report generating/job queued;实际 authenticated owner `delete_chart_subject` 成功后 subject/report/job 均删除,余额仍 8、reservation 仍 reserved、refund 交易 0。worker 已无 job 可处理,不能用模拟 loadSubjectBirth=null 的测试证明此路径已修。日志主仓 `artifacts/three-fixes-baseline-20260925-u2mvj0n4/people-db-r1-refund-probe.log` 已由主会话读取,退回执行方补兼容新迁移与真实回归;阻止最终交付。 +- 前端额外真实 React hook 探针确认旧人物 A 分页请求晚到会覆盖 B 的 nextCursor,导致 B 后续历史无法加载;真实 Provider + bootstrap 探针还重现切人 reload 期间复用已完成 ready、以及 account200/catalog503 被当作 account:null,两种情况均误跳登录。执行方修复中,原探针复验前不标通过。上述中间快照不是最终组合,业务修正及测试 harness 补齐后仍须完整复跑。 +- 退款独立复验:r2 的删除 trigger 已能退款,r3 为修锁序误引用不存在 `j.report_id`,真实 DB 当场拦截,失败原日志保留;改用实际 `(user_id, request_id)` 后 r4 六场景全过:queued/running 删除 8→10 且只退一次、already released 幂等、completed 不退、ready 未完成计费窗口保留收费且后续 complete_usage 成功、两连接 worker 锁 job→delete 等待→complete_job 提交→删除无死锁。主会话已读 `people-refund-r4-matrix.log`;日志内标签误沿用 r3,实际 cwd/归档为 r4,快照 SHA256 `d9cd1f8631c67b5f5679ebf85db7914d11b534de144c610e7b2f06183c125337`。不替代最终标准 DB 全套。 +- 人物第二轮独立探针:本人 caseId 的真实列投影、两种有效别名 strict schema 均已转绿;前端保留原失败脚本,另建 recheck,确认 `?new=1` 正确人物、旧分页不覆盖 B 游标、账户+self缓存隔离、provider reload 等待后正常启动、catalog503可恢复而非登出。Windows 六文件 39 项中 38 通过、1 处旧源码合同仍要求旧 ready API,已交执行方迁移并记三栏;不据此提前声明全量绿。 +- 主会话在报告/西洋整合树额外运行两个报告 Python 文件,21 项全部通过、exit 0,日志 `artifacts/three-fixes-verification/report-integrated-python-targeted.log`;三份隐私扫描测试 70 项通过、exit 0,日志 `report-western-privacy-targeted.log`。仍不替代最终 Linux quick,后续人物与文档整合后需再核验。 +- Linux Python 3.13 CI-style quick 首轮缺 Node 导致 1 失败;补齐独立容器 Node 22 环境后 948 passed / 1 skipped、exit 0。首轮日志保留,未改业务或断言。最终整合仍同环境重跑。 + +## 整合冻结进展与权限边界 + +- 人物生产源码及两份 SQL 已冻结,21 份修改源码和 5 份新增源码/迁移已按非删除白名单合入;逐文件 LF 规范化哈希一致,清单 `artifacts/three-fixes-verification/people-product-integration-whitelist.json`。人物永久测试最后补齐真实 provider→bootstrap 串联与独立 ephemeris 暖缓存跨账户场景,测试冻结后再整合运行完整门禁。 +- 后端独立审查收束:限定范围无尚未修复的确认缺陷。040000 迁移 SHA256 `f783bacf370826d0d0bf35e68b6515edb081057a8fcb1d981fd3d1080218e67b` 与 r4 实测一致。删除 RPC 是 SECURITY DEFINER,以显式 auth.uid/owner/role 校验及 owner 过滤保障权限,不应表述为依赖调用者 RLS;受限退款触发器不开放通用退款权限。两连接结果只证明已测试调度,不声称穷尽并发。 +- **权限阻塞:** 整合工具明确拒绝删除既存 `frontend/src/components/chart-library-panel.tsx`。未通过其它工具/代理绕过,整合树保留该旧文件;执行树已做的删除不等于整合交付已删除。其余非删除修复继续整合。若要清除此死文件,需用户明确许可;未经许可不以打包排除等方式变相删除。 + +## 最终交付门槛(历史清单;最新结果见 final-r4b 补记) + +1. 独立 Linux / Node 22 开工全量原始日志与失败清单保留;最终版本同环境全量执行,逐项核对失败标识与原因,新增失败 0,测试总数不下降。 +2. 真实 PostgreSQL 执行 `npm run test:db`,覆盖本人历史 `null/self` 与他人隔离、迁移纠偏及并发人数限制;不得以 mock 替代。 +3. P0 报告 worker 成功路径测试验证实际 engine 入参属于目标人物;人物删除走失败退点,不回落本人。 +4. tsc 无错误,lint 0 error;完整 next build 的 `/` 保持 Static,首屏 gzip 与同环境基线相比 ±2%。 +5. Python 定向与 quick 真跑,未通过与环境缺口逐条列出;不改引擎口径与主服务增长合同。 +6. 旧断言三栏覆盖本轮所有修改,并补齐人物档案前轮遗漏;fixture 来自真实引擎。 +7. 对照三份任务书逐项独立复核。无受控登录态的浏览器验收保留为环境缺口并给出 testing 清单,不冒充通过。 + +## 测试断言变更 + +各单变更三栏分别归入各自 PROGRESS,整合阶段另做最终 diff 对账。 + +- 报告:5 处既有断言变更已由独立审查逐项核对,泄漏、安全、22 SVG 等无关约束没有弱化。 +- 西洋:原 24 项名称保留,新增 4 项;raw fixture 与更保守字框语义变化有三栏,新扫描曾排除宫号的草稿已纠正,保留失败探索记录。主会话独立真实引擎重算 65 份 raw 响应,JSON 全等,未覆盖 fixture;证据 `artifacts/three-fixes-verification/western-raw-independent-replay.json`。 +- 人物前轮补账:独立 `git show fabe0114` 对账确认 11 个测试文件 / 12 个位置完整覆盖(9 处 assert 预期、1 处 guards 取样源、1 处 DB fixture、1 处 fetch mock),无遗漏;两条新增保护断言也归入相关行。已将“本轮 bind 后捕获”的描述与前轮历史区分,未倒写历史。 +- 人物本轮最终测试快照:20 个既有前端/ Python 合同文件修改、2 个新增永久行为测试已合入;补齐 ephemeris A/self→B/self 暖缓存隔离,以及真实 Provider generation reload→Home bootstrap 串联(含 `?new=1` 当前人物断言)。测试文件与人物源码均已 `diff --check`,白名单 `people-tests-integration-whitelist.json`。尚未跑整合 Linux 门禁。 +- 人物文档已按段合入:BUG-1030 保持 investigating 并补齐 ready/scope 证据边界;新增 BUG-1031(worker错人/self metadata/删除预留退款)与 BUG-1032(OR is复发BUG-990);最终组合仍未提交、推送或部署。 + +## final-r4b:最终 Linux 前端、数据库与构建复验 + +本节取代上文中间快照的“最终待验”状态,但不解除受控登录、部署或删除权限缺口。开工基线 SHA 不变;2026-09-25 交付前再次 fetch,远端 staging 仍为 `48fe4daf519dab2a5c6bf4b01dd513067de1647b`。 + +- 验证环境:Linux、Node v22.23.2、专属 DinD 的真实 PostgreSQL 17;使用原 lockfile `npm ci`,不升级依赖。 +- 快照:`final-integration-r4-clean.tar`,SHA256 `438181e66b2368a6eefef2f47b7eb827ca5fe440b2e11c8d57d18d0b65077471`。保留 Git symlink / mode、旧 `chart-library-panel.tsx`;不包含本轮过程 artifacts、node_modules、.next 或未授权副作用文件。 +- 原始日志在授权隔离容器 `/work/logs/final-r4b-{test,db,tsc,lint,build}.{log,exit}`;摘要 `final-r4b-summary.json`,逐项对账 `final-r4b-versus-baseline.json`,资产分组由 `analyze-home-assets.py` 生成。旧 `final`(r2)及 `final-r4` 失败日志原样保留,不覆盖。 +- wrapper exit 不代表全部通过;已逐一读取五个 `.exit` 并核对 summary / comparison。 + +| 检查 | 开工基线 | final-r4b | 结论 | +| --- | ---: | ---: | --- | +| npm test | 3863/3863 | 3894/3894 | exit 0,失败/skip/cancel/todo 均 0 | +| 前端顶层名称多重集合 | 3833 | 3864 | 无删除,新增 31 | +| 前端含嵌套名称多重集合 | 3863 | 3894 | 无删除,新增 31 | +| test:db | 61/61 | 64/64 | exit 0,失败/skip/cancel/todo 均 0 | +| DB 顶层/含嵌套名称 | 49/61 | 52/64 | 无删除,新增 3 | +| 新增失败/诊断变化 | 0 | 0 | 两套比较均为空 | +| tsc / lint / build | 0/0/0 | 0/0/0 | 全部 exit 0 | +| lint error / warning | 0/127 | 0/127 | 无新增,不顺修 warning | +| 首页 `/` | Static | Static | build 明确 `○ /` | +| 完整首屏 JS+CSS gzip | 682982 B | 684083 B | +0.1612% | +| 首页相对父 layout 增量 JS | 229275 B | 228235 B | −0.4536% | +| 首页专属 JS gzip | 130258 B | 129218 B | −0.7984% | + +补充分组:JS 641409→642510 B(+0.1717%);CSS 41573 B 不变;增量共享 JS 99017 B 不变;父 layout/runtime/CSS 453707→455848 B(+0.4719%)。全部口径在 ±2% 内,不用总资源分母掩盖首页专属增长。 + +### r2 四个新增失败的修复与断言台账 + +| 范围 | 原值 | 新值 | 原因 | +| --- | --- | --- | --- | +| chat-session-url / static Home 源码锚点 | `await deps.sessionListReady;` | `await (deps.waitForSessionList?.() ?? deps.sessionListReady);` | 等待当前 Provider generation;仍检查预览不写 URL、首页不 fetchSessions、客户端读 query,不放宽 Static 合同 | +| report-row-delete / 挂载 fixture | 没有显式 account scope | 绑定明确虚构账户与空人物目录 | 真实目录 ready 闸门要求;tombstone、旧 GET 不复活、旧 finally 不清新 flight 断言不变 | +| session-list-lifecycle / Storage fixture | Node 无 localStorage | 隔离 Map-backed Storage,关闭及异常路径恢复原 descriptor | 浏览器依赖只补测试环境;StrictMode/current-ready/无误跳登录断言不变 | +| secondary-page-data / invalidate 行为 | epoch 改变一律抛 scope_changed | scope 改变才拒绝;同 scope invalidate 允许旧调用返回但禁止写缓存 | epoch 与身份不同义;生产实现修复,不改既有竞态断言来消红 | + +四个原失败名已在 final-r4b 全量中通过:`home stays a client-read query on a static route`、`report cache invalidation rejects stale GET writes and stale finally keeps newer flight`、`mounted report center removes deleted row and an older GET cannot resurrect it`、`Home connects to the real provider current generation during person-switch reload`。 + +### 验证环境失败与处理 + +- Windows materialized symlink 导致 migration duplicate、Linux 污染 mode 导致 Skill hash mismatch,不修改迁移扫描器或 registry hash;保真 archive 内 symlink 正确,包 hash 为原登记值 `750a0d58c6731c0dbdeb5c785cea1bf8a3f73aaf94a560a86397805b7037724c`。 +- 首次 `final-r4` 的 test / DB 各 exit 1,唯一失败名 `staging database environment file is ignored`:archive 无 `.git`,`git check-ignore` 返回 128。仅在隔离验证目录建立临时 Git/index 后用新标签 final-r4b 重跑;源 worktree 未提交,测试未改。 +- Python 补验首次误在 Node 镜像调用 Python3.13 venv,解释器目标不存在,exit127;测试未开始,不记为业务失败或通过。需在匹配 Python 镜像并带 Node22 的环境另跑。 + +### final-r4b Python、隐私与交付边界复核 + +在匹配Python3.13 Bookworm镜像复用本轮验证卷、venv及Node22工具,逐一读取以下log/exit(均在`/work/logs/final-r4b-*`): + +| 项目 | 结果 | exit | +| --- | --- | ---: | +| Python六文件定向(年度、读者版、API增长、人物/会话合同) | 49 passed | 0 | +| CI-style quick:`--profile quick --skip-yoga-logic --skip-frontend-runtime` | 948 passed / 1 skipped,与同参数基线一致 | 0 | +| 三份隐私测试 | 70 passed | 0 | +| public privacy scan | 3311文件,0 finding,pass | 0 | +| commercial privacy scan | 3311文件,0 finding,pass | 0 | + +quick明确使用与Linux基线相同的CI参数;不声称运行Yoga逻辑全套或在此命令重复运行前端。前端由本节独立Node22全量覆盖。首次exit127及旧快照证据保留,不混用。 + +只读独立审计确认:78个候选文件(65 tracked修改+13新增),无依赖/锁文件/workflow变更,旧迁移未改,仅两份指定新迁移;`chart-library-panel.tsx`与HEAD相同。Home useState 33→33、useRef 37→37,文件1347→1349行;API主文件原样(215方法/10931行),无新增伪造点。既有断言三栏无剩余确认遗漏;report-polling原台账误述已按实际两处分支scope守卫更正。64个非文档候选文件与已测archive双侧LF规范化后全部一致,测试/生产源码没有在验收后再改。 + +交付前预检再次运行:远端verified,但focused仍失败。除历史镜像路径断言外,碎片计数断言为`4 >= 0 + 45`不成立(当前工作区残留增加,台账已有同类);不制造镜像、不清理其它工作区、不改断言,预检整体不写通过。日志`artifacts/three-fixes-validation-r4/delivery-pre-work-check.log`。这不改变Linux产品全量新增失败0的实测,也不是领域/oracle全绿声明。 + +最终暂存边界复核发现两个新测试文件含CRLF(此前未跟踪,普通diff --check未覆盖):`people-archive-regressions.test.ts`39行、`people-pagination-lifecycle.test.tsx`35行。仅将CRLF规范化为LF,逐行文本/断言完全不变;Linux Node22重跑两文件10/10、fail/skip/cancel/todo0、exit0,证据`/work/logs/delivery-lf-targeted.{log,exit}`。未修改生产代码,不改测试预期。最终暂存集的public/commercial隐私扫描均3357文件、0finding、exit0(与Linux快照扫描范围不同,分别记数);日志在`artifacts/three-fixes-validation-r4/delivery-*-privacy.json`。提交/推送状态以实际命令和远端SHA核对为准。无受控账号的整页移动端、真实Tab/读屏与staging health不据此声称通过;BUG-1030/1031/1032/1033保持原状态。 diff --git a/docs/tasks/README.md b/docs/tasks/README.md index f7682f58..f0aaf8d1 100644 --- a/docs/tasks/README.md +++ b/docs/tasks/README.md @@ -11,6 +11,8 @@ - [侧栏导航注册循环修复与验收](PROGRESS-sidebar-navigation-loop-20260919.md) — 2026-09-19,修复/回归已完成,完整验收与 staging 交付状态见记录。 - [门禁隐私扫描 `.venv` 与头像合同](PROGRESS-privacy-venv-20260920.md) — 2026-09-20,直接执行:删跟踪的 `.venv` 符号链接、对齐 Beam 文案合同(BUG-974/975)。 +- [三份修复单整合与独立验收](PROGRESS-three-fixes-integration-20260925.md) — 2026-09-25,人物档案优先,报告与西洋盘并行;Node 22 / Linux 全量对照与断言三栏为交付硬门槛。 + ## 状态板 这一节是所有任务的总板。任务书推上来时加一行,状态变化时改这一行,不另开文件。 @@ -117,7 +119,7 @@ | 任务书 | 进度 | 主题 | 状态 | 落点 | | --- | --- | --- | --- | --- | -| `TASK-people-archive-p1-fix-20260925.md` | — | **P0 修复(staging 实况,修好前不得提升 main)**:报告 worker 仍按 `job.userId` 读户主 `profiles`(给别人生成报告用户主资料且扣点);`SELF_SESSION_FILTER` 的 `is.null` 自托管适配器不支持 → 本人会话列表 500(BUG-990 同类复发);当前人物整页加载后先是本人、会话订阅替换清空已打开对话;另 7 项 P2(回填把 jsonb 里未经校正的「已校正」抄进类型化列等)。必须 Node 22 全量 + test:db | 待领取(**最优先**) | — | +| `TASK-people-archive-p1-fix-20260925.md` | `PROGRESS-people-archive-p1-fix-20260925.md` | **P0修复**:worker按持久化人物解析、真实删除预留退款、本人历史OR is、切人/消息时序及P2/P3;BUG-1031/1032,关联1030/990/1024。永久定向54/54、独立前端5探针与退款r4六场景通过;必须最终Node22 Linux全量+DB逐条基线对照 | 本地冻结;最终组合/真人/部署待验收,未推送 | — | | `TASK-home-page-split-20260925.md` | `PROGRESS-home-page-split-20260925.md` | **首页 page.tsx 拆分(只搬不改)**:启动流程约 285 行抽成 lib 纯函数 `runHomeBootstrap`、恢复轮询约 100 行抽出、错误 / 引导屏抽组件,目标 ≤1300 行并下调增长合同基线;两个按源码切片的测试改为直接调用函数;还原 824647f2 的 import 挤行。排在 chart-ephemeris 之后、people-archive P1 之前 | 已验收(行为逐行一致;1417 行达让步线、合同基线已下调;流程违规:未跑全量致门禁红 3 次) | `da35b3e4`(已部署) | | `TASK-staging-gate-fix-20260924.md` | — | **修复单(阻塞 staging 部署)**:慢网首页 `0e4dd0f4` 起 5 条源码合同测试未跟上(硬跳转计数、重试 reload 正则、两处按已删语句切片)→ 门禁红,`0e4dd0f4..f125fae0` 全部未部署;另 page.tsx +9 行、订阅支付不刷新余额、原始附录三文件残留。只修测试与收尾,不改已验收行为;失败名单须与 `edc9c22c` 逐条一致 | 已验收(Claude 09-25:5 条转绿、失败名单与 edc9c22c 逐条一致 56/56、门禁 run 2877 绿;import 挤行凑数为 P3,由 home-page-split D5 还原;原始附录三文件仍未删) | `651c75e7`/`7a001456`/`824647f2`;deploy run 2879 因 ACR 网络失败,待同 SHA 重跑 | | `TASK-people-archive-p1-20260924.md` | `PROGRESS-people-archive-p1-20260924.md` | **星盘档案(多人物)P1**:每个档案 = 一个人(含本人),最多 5 人、无关系字段;标题旁人名 = 全局切换器(对话 / 星盘 / 星历 / 报告 / 档案页同一组件),星盘、星历、报告、今日星语、新建对话都跟当前人物走(推翻 BUG-1000 新对话固定本人);历史只显示当前人物;档案管理改侧栏 `/people` 页、设置删星盘资料;删人连带删对话与报告;取消「设为默认」/`activeChartId`(BUG-1030 错人)。他人资料改类型化列 + 与户主同级守卫,统一 `resolveSubjectBirth`;户主暂留 `profiles`。生时校正跟人与任意两人合盘留 P2。**同日四单全部合入后开工**,需 Docker 跑 test:db | 验收未通过(Claude 09-25:两个 P0 实况——给他人报告按户主资料生成且扣点、本人会话列表自托管 Postgres 500;切人时序 P1×2;修复单 people-archive-p1-fix) | `ab6c55f8`/`45842361`/`fabe0114`(已部署) | @@ -175,7 +177,7 @@ | 任务书 | 进度 | 主题 | 状态 | 落点 | | --- | --- | --- | --- | --- | -| `TASK-report-reader-main-fix-20260925.md` | — | **读者版修复**:year_lord 比较键不齐致真实数据恒判冲突,读者版年运 Varshesha/Muntha 每年都是「-」(用户可见年运仍不可用);投影把 `Score` 表头当内部词、连标题删掉 Bhava Bala;新泄漏规则误作用于聊天导出 | 待领取 | — | +| `TASK-report-reader-main-fix-20260925.md` | `PROGRESS-report-reader-main-fix-20260925.md` | **读者版修复**:真实年主比较、分字段绑定与未来返照;保留 Bhava Bala 和标题,隔离报告/聊天规则 | 本地独立定向通过,组合 Linux 全量待验 | 已整合至 `codex/three-fixes-integration-20260925`(未提交、未推送) | | `TASK-report-reader-main-20260924.md` | `PROGRESS-report-reader-main-20260924.md` | **报告正文换上游读者版 reader_main + KP / Muntha 计算缺陷 + 投影截断(BUG-1026/1027/1028)**:正文一直是上游审计版(读者版 09-09 才出、从未同步),本仓又自加「结论等级规则」/英文段/异常原文;投影黑名单不认 PL9 词汇且截断半句、删表留说明;KP args `vars()` 拷贝为空恒失败;Muntha 漏合 `birth_asc_sign_idx` → Year Lord 恒火星。产品拍板接入读者版、旧报告不回填。 | 部分通过(Claude 09-25:读者版切换、泄漏 0 命中、KP 已修;年运 Year Lord/Muntha 全为「-」P1、投影误删 Bhava Bala P2 → 修复单) | `3c2f7bd5`(已部署) | | `TASK-report-reader-actions-20260924.md` | — | **报告操作收敛**:列表只留「查看报告」;阅读页删「下载原始附录」「打印」,导出改图标按钮;导出对话框 ≥860 居中、窄屏仍贴底(修订 chapter-export D9/D10)。产品变更,不开 BUG 号 | 基本通过(Claude 09-24:入口收敛、图标、≥860 居中均符合;原始附录三文件因执行方权限未删、PROGRESS/清单未写 → 并入修复单) | `f125fae0`(未部署) | | `TASK-report-chapter-export-fix-20260924.md` | `PROGRESS-report-chapter-export-20260924.md` | **CRLF图盘JSON残留(BUG-1020)**:旧剥离器只匹配LF;建议D5对错误围栏残留作有限例外,D13不构成该授权。 | 代码已验收(Claude 09-24),待部署(被同批门禁红阻塞) | `fa8fef55`/`133aca49`(未部署) | @@ -204,7 +206,7 @@ | 任务书 | 进度 | 主题 | 状态 | 落点 | | --- | --- | --- | --- | --- | -| `TASK-chart-western-fix-20260925.md` | — | **西洋盘 ASC/MC 重叠**:轴点标签不参与避让,3000 张随机盘约 34% 与行星重叠;避让测试改扫描;点击区 ≥44px;fixture 改存原始响应;BUG-1025/CHANGELOG 陈旧行 | 待领取 | — | +| `TASK-chart-western-fix-20260925.md` | `PROGRESS-chart-western-fix-20260925.md` | **西洋盘 ASC/MC 重叠**:固定锚点避让、真实引擎64盘与固定种子512盘扫描、343px盘宽命中≥44px;raw golden;文档陈旧行更正 | 本地独立组件复核通过,组合全量与登录整页待验 | 已整合至 `codex/three-fixes-integration-20260925`(未提交、未推送) | | `TASK-chart-dasha-western-redesign-20260924.md` | `PROGRESS-chart-dasha-western-redesign-20260924.md` | **大运 / 西洋盘改版**(原型 https://claude.ai/artifact/DYovEPMqD1xwNgna5xVTre ,产品 09-24 全部同意,相位线常显):大运「现在」卡 + 时间条 + 默认折叠(原 81/144 行平铺);西洋盘 520 三圈、行星避让 + 引线、宫号居宫中、ASC/MC 盘内、行星位置表、点选过滤相位。排在 chart-ephemeris-fixes T1 之后 | 已验收,1 P1(ASC/MC 标签不参与避让,约 34% 盘重叠)→ chart-western-fix | `5a2d9da9`(已部署) | | `TASK-chart-ephemeris-fixes-20260924.md` | `PROGRESS-chart-ephemeris-fixes-20260924.md` | **Chara 0 年大运崩溃 + 星历切日期旧数据 + 星盘无资料空状态(BUG-1023/1024/1025)**:mapper 0/0 → `RangeError`(约 7.8% 星盘,产品定照实显示 0 年、不改引擎);BUG-966 删掉重置致日期与数据分叉(回归);无资料只有一句话,加「去填写出生资料」+ `?settings=chart` 深链(T3 排在 home-slow-network 之后)。大运 / 西洋版式重做不在本单 | 已验收(Claude 09-25;星历暖进重复拉取与首屏失败无重试来自 people-archive 提交,并入其修复单) | `19ea0f06`(已部署) | | `TASK-chart-page-skeleton-wait-20260924.md` | `PROGRESS-chart-page-skeleton-wait-20260924.md` | **星盘页骨架盘等待态 + 三个「永远还没拿到」死角**:「这一张盘还没拿到。」只代表 `view === null`,是唯一等待态,失败各有句子;但客户端无超时(A)、401 跳转前(B)、分盘层失败 / 429 后 `ChartVedicTab` 只看 `!selected` 不看 pending(C)三条路都让这句永远挂着。**产品 09-24 拍板推翻 09-18 方案一(BUG-966)**:星盘页盘位用骨架盘 + 呼吸动效(复用 `VedicChartSvg` 空模型,不复制几何;`prefers-reduced-motion` 静止),范围只限星盘页会出盘轮的位置,首页 / 侧栏 / 星历 / 报告的「不放骨架」合同不动;`AGENTS.md` §6 与 `DESIGN.md` §9 / §15 追加例外。三死角出口:客户端 25s 超时 → `timedOut` 句、401 先写失败体、按层失败态 `layerFailures`(429 独立档,BUG-715/723)。BUG-1016 / 1017 | 已验收(Claude 09-24:红线全绿、+46 测试、失败名单同基线、四路由标记与 gzip 同基线;BUG-1016/1017 resolved) | `1420471a`(staging 已部署);真机清单欠 | diff --git a/docs/testing/chart-dasha-western-20260924.md b/docs/testing/chart-dasha-western-20260924.md index 8192dd26..dc1f5e05 100644 --- a/docs/testing/chart-dasha-western-20260924.md +++ b/docs/testing/chart-dasha-western-20260924.md @@ -11,4 +11,17 @@ | 0 年大运 | 用虚构 1985-08-03 03:59 北京打开大运 | 射手那段是灰色「0 年」,不能展开,没有子运。时间条上没有这段。别的当前段默认展开 | 待验 | | 暗色 | 系统或页面切到暗色,看西洋盘星座环 | 火土风水四色还能分清,星座名在色块上读得清。相位的绿和红不是亮色模式下的那两个深色 | 待验 | +## 2026-09-25 西洋修复补验 + +独立 Chrome 组件证据:129 盘(65 份真实引擎盘含原 golden、64 张几何压力盘),实际盘宽 343px、本地 Noto Symbols 字体。最终全部标签碰撞 0(包含宫号互碰;初次5对/2盘经径向避让修复);按脚本逐圆及逐字符检查,命中错误 0。字号约 12.53px、命中圆直径约 44.85px。可复跑生成器:`artifacts/western-browser-check.tsx`。这是静态组件字框和命中检查,不是登录页面交互通过。 + +| 项目 | 操作 | 通过标准 | 结果 | +| --- | --- | --- | --- | +| ASC/MC 拥挤 | 用真实引擎虚构扫描样本,检查上升和中天附近行星 | 行星字框不压 ASC、MC、其他行星、星座名或宫号;真实位置刻度仍在 | 自动组件通过;登录页待验 | +| 375px 实际尺寸 | 保留页面左右各16px,测盘宽与透明命中圆 | 343px盘宽时字号≥12px、命中直径≥44px,无整页横滚 | 尺寸组件通过;整页待验 | +| 拥挤盘点选 | 逐个点行星标签中心,并再点取消 | 每次选择自身,无后画圆/引导线吞掉中心;相位线常显、相位表同步筛选 | 中心hit-test通过;React交互待验 | +| 键盘 | Tab逐个聚焦行星,Enter/空格选择,再次取消 | 可聚焦、选择自身、不滚动抢焦点 | 待验 | +| 真实窄宫 | 使用完整raw宫头,不改成均匀宫头 | 宫号拥挤只径向移动、宫中角度不变;所有标签两两不重叠 | 自动组件通过;整页待验 | +| 骨架与暗色 | 慢速加载西洋层,分别亮暗色观察 | 骨架仍仅三圈12辐条无文字;标签不裁切、无新配色 | 自动骨架通过;整页待验 | + 部署后还要核对 staging `/api/health` 的 `deployment.gitCommit`。本清单未走完之前,不能写成浏览器已验收。 diff --git a/docs/testing/people-archive-p1-20260924.md b/docs/testing/people-archive-p1-20260924.md index fddc6faa..d82595ee 100644 --- a/docs/testing/people-archive-p1-20260924.md +++ b/docs/testing/people-archive-p1-20260924.md @@ -9,3 +9,29 @@ 5. 把当前人物换成别人。生时校正入口应禁用,文案是「生时校正暂时只支持本人」。 6. 用一个升级前就有他人星盘的账号打开档案。原有称呼和出生资料还在;缺字段的行应提示补全,不要被猜成一个时间。 7. 档案详情「和 TA 对话」应打开一段空对话。「和我合盘」生成的问题里,「我」是本人,对方是选中的那个人。 + +## 2026-09-25 P0/P1修复补验(全部真人待验) + +本地Node22永久回归54/54、tsc0、lint0error/127warning;协调方独立5个前端探针及真实Postgres退款r4六场景通过。这不是登录页面证据。最终组合Linux全量/DB与部署仍待验;下表不得由定向日志直接勾成通过。仅使用受控账号和明确虚构资料,不借用账号或凭据,不把出生资料、报告正文、Cookie、账单标识写入日志。 + +| 项目 | 操作 | 通过标准 | 结果 | +| --- | --- | --- | --- | +| 后台报告人物 | 选择虚构朋友生成报告,等后台完成后打开 | 页面出生资料属于该朋友,不含户主资料/候选窗;本人报告仍保留本人校正上下文 | 待验 | +| 本人历史 | 打开本人侧栏历史,包含升级前存量会话 | null/self会话均可见,他人会话不混入,无500 | 待验 | +| TA对话深链 | 人物列表慢速返回时,从档案点「和TA对话」 | 新对话标题旁是TA名字;新会话绑定TA,不先绑本人 | 待验 | +| 他人对话刷新 | 打开有消息的他人对话,整页刷新 | 仍是TA,历史消息不清空;第一页外/深链会话仍在 | 待验 | +| 普通新建 | 选他人后打开标准新建链接,再发送虚构测试句 | 等人物绑定后新建,普通会话用当前人物,不默认本人 | 待验 | +| 切人/分页竞态 | A历史翻页未回来时切B,再让A旧请求返回 | B列表/游标不被A覆盖;当前消息保留;目录同人刷新不闪空 | 待验 | +| 目录失败恢复 | 受控模拟人物目录503,随后恢复 | 可恢复错误,不假登出;未就绪前不先请求本人的盘/报告 | 待验 | +| 跨账户缓存 | 受控退出后换另一个测试账号 | 星盘/星历/报告无前账号内容,缓存按账户和人物隔离 | 待验 | +| 删除用量失败 | 受控模拟用量查询失败后点删除 | 提示暂时查不到数量、确认禁用,不显示0个对话/0份报告 | 待验 | +| 真删除退款 | 删除仍有排队/运行中未交付报告的虚构人物 | 档案及关联数据删除,预留点数在同一事务释放;不因job已删漏退 | 待验 | +| 不重复/不误退款 | 分别删除已释放、已结算、已ready待结算报告的虚构人物 | 已释放不重复退;已结算和已交付不退;失败不能只删数据不退预留 | 待验 | +| 删除与完成并发 | 受控后台完成与删除同时发生 | 不死锁,不重复结算/退款,不遗留孤立预留 | 待验 | +| 人物目录变更 | 新增、改名、编辑、删除后打开标题切换器 | 立即反映服务端结果,被删者不可再选;编辑标题不是添加 | 待验 | +| 星语与资料更新 | 户主无分钟、他人完整;再修改他人资料 | 他人星语可加载;资料变化后指纹更新,不复用旧当天结果 | 待验 | +| 合盘上下文 | 从他人档案点「和我合盘」 | 我为本人;该对话绑定本人,不改全局人物,题中对方正确 | 待验 | +| 星历暖进/首败 | 同人物暖返回;冷进首请求模拟429/坏包再恢复 | 暖进无额外重复日期请求;首败可重试,不永久停在等待句 | 待验 | +| 回填纠偏/上限 | 受控旧档案升级、两窗口同时抢最后名额 | 未验证JSON状态不冒充已校正;本人资料不变;最多四个他人 | 待验 | + +发布验收另核对最终组合Linux Node22全量fail0、基线测试名称无消失、标准DB、build Static/gzip和staging health部署SHA。未完成前不写已部署或resolved。 diff --git a/docs/testing/report-reader-main-20260924.md b/docs/testing/report-reader-main-20260924.md index f926ed14..9486a3e2 100644 --- a/docs/testing/report-reader-main-20260924.md +++ b/docs/testing/report-reader-main-20260924.md @@ -7,4 +7,13 @@ 3. 看两个不同年份的年度章。Muntha 星座应不同;Year Lord 至少有两年不一样,不应每年都是火星 / 白羊座,也不应出现「conflict between vs」。 4. KP 节应存在,不应是 `SimpleNamespace` 缺少 `year` 的失败句。 5. 下载普通 Markdown。不应留下「所有行保持」这种半句,也不应留下孤立的「- `」。图盘仍是安全 SVG,不是 JSON 围栏。 -6. 打开一份这次改动之前生成的旧报告。正文应保持原样,只是下载投影比以前少一些内部词。不要求它变成读者版。 +6. 打开一份这次改动之前生成的旧报告。已存 Markdown 不重算;投影后的内部表不显示,但标题保留。不要求旧内容变成读者版。 + +## 09-25 修复单补验(未执行受控浏览器验收) + +7. 用仓内明确虚构的 reader-main 测试资料新生成报告,核对三个年度:Varshesha 与 Muntha sign 均不是「-」,至少两年不同;年度上升非空;未来两年的 Varshapravesha 为各自实际返照时刻,不沿用本年日期。 +8. 若真实两种口径给出不同年主,页面应显示「太阳返照:…;Tajika:…(两者不同)」,不得隐藏其中一个或说已经统一。两边一致时不应仍写「带着分歧标签阅读」。 +9. 核对「Bhava Bala 十二宫力量」标题与十二行表都在;导出普通 Markdown 仍保留整表。内部表前只有说明段可随表移除,标题与同节后续正常表不得消失。 +10. 无姓名的虚构资料不应显示「姓名:-」;五要素表不应露出 overall_score、吉性_count、total_elements。 +11. 导出包含普通英文长句及 producer / blocked 普通词义的测试聊天:句子不应因报告专用过滤而消失。安全 SVG / 图盘仍在;脚本、危险链接、密钥及内部字段仍不得进入导出。 +12. 对照网页与下载:D5 审计词、原始异常仍为零;上述恢复表格和标题不代表放开内部字段,也不代表外部引擎精度已经闭环。 diff --git a/docs/testing/three-fixes-integration-20260925.md b/docs/testing/three-fixes-integration-20260925.md new file mode 100644 index 00000000..29fce9f7 --- /dev/null +++ b/docs/testing/three-fixes-integration-20260925.md @@ -0,0 +1,83 @@ +# 三份修复单整合验收 · 2026-09-25 + +这是整合版本的验收入口,不替代各单清单。所有项目初始均为未验证;自动化通过不等于受控账号真机通过。 + +## final-r4b 已核实自动化结果 + +- [x] Linux Node22.23.2 全量3894/3894,真实PostgreSQL17标准DB64/64;两套fail/skip/cancel/todo均0,各exit0。 +- [x] 顶层与含嵌套名称多重集合对账:相对基线无删除、新增失败0。 +- [x] tsc/lint/build各exit0;lint0error/127既有warning,首页`○ Static`。 +- [x] 三种gzip口径在±2%内:完整首屏+0.1612%、入口增量−0.4536%、首页专属JS−0.7984%。 +- [x] final-r4b Python六文件49通过;与基线同参数CI quick 948通过/1跳过;隐私测试70通过、public/commercial扫描各3311文件0finding;五个exit均0。 +- [x] 独立审计全部既有断言三栏;Home状态/ref不增长,API主文件不改,旧面板保留,依赖/workflow不改。最新文档隐私扫描另见交付补记。 + +quick参数为`--profile quick --skip-yoga-logic --skip-frontend-runtime`;前端另跑上述Linux全量,不把该命令写成未跳过任何阶段。 + +完整数字、原始日志、快照SHA256与r2/r4失败历史见整合PROGRESS的final-r4b节。以下真人与部署项目仍未验证,不能勾选为通过。 + +## 自动化证据矩阵 + +最终数字、日志路径、基线/最终 SHA 记入 `docs/tasks/PROGRESS-three-fixes-integration-20260925.md`。 + +| 项目 | 必须证明的事实 | 证据类型 | +| --- | --- | --- | +| 全量回归 | Node 22 + Linux,失败逐项及原因与开工基线对比,新增失败 0;测试名称无无故消失、总数不下降 | 基线与最终完整日志及比较产物 | +| 类型与 lint | tsc 0 错,lint 0 error,不顺带清 warning | 同环境命令日志 | +| 构建 | 完整 next build,首页 `/` 为 Static,gzip 相比同环境基线 ±2% | 构建日志、资产测量明细 | +| 报告错人 | worker 真正传入引擎的是目标人物;不夹带户主校正范围;删除目标后失败且退点 | worker 成功/失败真实路径注入 fake engine | +| 本人历史 | 自托管真实 Postgres 下 `subject=self` 包含 null/self,不混入其他人物;is.null/true/false 正确编译 | 标准 test:db 实跑 | +| 迁移纠偏 | 不可信旧 accepted/confirmed 降级,active 字段清空;本人不变、幂等、旧部署兼容 | 真实迁移回放测试 | +| 人物状态 | 本地同步预读但未绑定前不发错人请求;账户切换与列表失败不串人;相同人物重绑不清消息 | 生命周期回归及受控真机 | +| 今日星语 | 当前人物的完整度、时区和资料指纹决定可用性与缓存 | 定向回归 | +| 删除/合盘/星历 | 用量未知不得删除;合盘对话绑定本人;暖进不重算、失败可重试 | 定向回归与真机 | +| 年运 | 真实引擎两侧一致不误判冲突;至少两年 Varshesha/Muntha 有值且变化 | 真实引擎重算 golden 与 Python/前端测试 | +| 投影 | 保留 Bhava Bala 标题及十二宫表,报告泄漏清单仍空;不改聊天导出规则 | golden 投影及导出回归 | +| 西洋盘 | 固定种子扫描 ≥50 盘、含 ASC/MC 标签碰撞率 ≤1%;实际宽度下命中区 ≥44px | 布局扫描及真实浏览器几何核验 | +| 断言对账 | 所有改动的既有断言均列原值/新值/原因,补前轮遗漏;不借修复削弱不相关测试 | 各单 PROGRESS 三栏 + 最终 diff 逐项审查 | +| Python | 相关定向与 quick 真跑,API 主文件不增长、算法口径不变 | 命令日志与 diff | + +## 受控账号真人验收 + +只使用产品负责人明确提供的受控测试账号,录入明确虚构资料。不得借用现有浏览器登录态或读取他人凭据;截图/日志分享前去除账号信息与资料细节。 + +### 人物档案(优先) + +- [ ] 本人和另一个人物使用明显不同的虚构生日、地点;给另一个人物生成报告,打开后核对属于目标人物,而不是本人。失败不重复扣点。 +- [ ] 报告已扣点且仍排队时删除目标人物,核查实际删除接口后的余额与账本:不生成本人报告、未交付任务的费用退还且不重复退款;不能仅模拟 worker 查询人物为空。 +- [ ] 本人的既有校正候选范围仍传入报告生成;给他人生成时不夹带本人的范围。 +- [ ] 报告请求只传 `subjectId`、只传 `chartProfileId`、两者相同都能按正确人物受理;两者冲突返回 400,不放宽其它未知字段的严格校验。 +- [ ] 默认本人侧栏的存量对话可见;切换到另一人物只显示该人物的对话。 +- [ ] 从人物列表点击「和 TA 对话」,新对话标题旁直接是 TA;在慢网下不先发本人的请求。次级页标准「新建」旧 `?new=1` 入口也绑定当前人物,而不只检查 `?newChat=1`。 +- [ ] A 人物正在加载下一页历史时切到 B,随后 A 请求晚返回,B 的历史与分页游标不被覆盖。 +- [ ] 次级页切人物、目录/历史仍在请求时返回 Home,不误跳登录;账户正常但人物目录接口 503 时显示可恢复失败,不冒充未登录、不自动改用本人。 +- [ ] 在他人的对话中刷新,消息仍在、人物仍正确;深链打开分页之外的旧会话,人物列表晚返回不会清空消息。 +- [ ] 星盘、星历、报告页整页进入时不会先显示本人的内容再换人。 +- [ ] 添加、改名、删除人物后,顶栏列表及时更新,已删除人物不再可选。 +- [ ] 删除前显示服务端实际数量;模拟用量接口失败时不得显示假 0,且不可确认删除。 +- [ ] 本人无精确分钟而另一人物资料完整时,后者今日星语可用;修改后者资料后不沿用旧指纹卡片。 +- [ ] 「和我合盘」的新对话绑定本人、问题明确写对方,不把“我”解释成对方。 +- [ ] 星历暖返回无多余重复计算;首次请求失败后有可执行的重试。 +- [ ] 编辑表单标题是编辑而非添加;达到人数上限后无法增加第六个人。 + +### 报告读者版 + +- [ ] 新生成的虚构资料报告中,年运章至少两年的 Varshesha 与 Muntha 有值且不同;真正冲突明确呈现,缺资料不编造。 +- [ ] 「Bhava Bala 十二宫力量」标题和完整表格保留,后续表格归属正确。 +- [ ] 五要素不显示内部原始键;姓名缺失时不显示「姓名:-」。 +- [ ] 聊天导出的正常英文内容保留;报告中仍无内部账本/泄漏词。 + +### 西洋盘 + +- [ ] 375px 与桌面宽度下,ASC/MC 与行星标签不遮挡,字号可读;临近上升的密集盘也检查。 +- [ ] 每颗行星均能独立点中;逐一点击符号、数字、度数符号、逆行标记,透明点击圈不得吞掉相邻行星任何可见字符的点击。核查真实浏览器 `getExtentOfChar()` 字符中心,不以点击圈中心测试代替。 +- [ ] 标签扫描包含宫号之间以及宫号与其它标签的碰撞,不能排除宫号后报告零重叠;径向避让不改变引擎宫头与宫位。 +- [ ] Tab 顺序、Enter/Space 选择与焦点可见性正确;文字覆盖层不增加重复焦点或重复无障碍标签。 +- [ ] 相位线常显未退化,选择行星后的主要相位表正确;骨架仍无文字。 + +## 若获得 staging 推送授权后的部署核验 + +- [ ] 推送前最终版本已完成 Linux 全量对照,新增失败为 0,所有变更断言有三栏。 +- [ ] 核对 Gitea 远端 staging SHA,不用 GitHub 镜像或本地提交替代。 +- [ ] Gitea 该 SHA 的门禁、迁移与部署有成功证据。 +- [ ] 公共 health 的 `deployment.gitCommit` 对上最新含门禁路径变更的提交;`/login` 正常、匿名 `/api/account` 为 401;内部 health 与 swisseph 按部署口径核验。 +- [ ] 不提升 main、不发布生产。权限不足或缺登录态的检查写为环境缺口,不写通过。 diff --git a/frontend/DESIGN.md b/frontend/DESIGN.md index a9de6784..ee0d30ab 100644 --- a/frontend/DESIGN.md +++ b/frontend/DESIGN.md @@ -2,6 +2,12 @@ This file adapts the full visual analysis in `CLAUDE_DESIGN.md` to the shipped Jyotisha application. `CLAUDE_DESIGN.md` remains the upstream reference; this file is the implementation contract. +## 读者版年运与正文投影(2026-09-25) + +年主、Muntha 星座、年度上升与返照时刻分别来自对应年度的真实计算字段,不从年主对象猜补。两种口径不一致时并列注明,不合成单一结论;本地结果不等于完成外部验证。姓名缺失不显示空行,五要素字段使用中文列名。 + +Bhava Bala 保留完整十二宫力量表;Score / Weight 等领域表头不单独触发整表删除。内部表连同紧邻说明段移除,标题留下,避免后续正常表归错章节。PL9 审计词和长英文段过滤只用于报告正文,聊天导出与安全图盘沿用既有安全规则。不增加入口、等待动画、布局或颜色。 + ## 报告核对层与独立附录(2026-09-23) 个人报告保留原 Markdown 阅读、目录隔离与 memo 边界;文后独立渲染服务端分盘和事实表。分盘复用 VedicChartSvg 与 chart-grid/chart-card,不使用 float 或负 margin。事实表默认折叠、可键盘展开,横向溢出仅在表格区域滚动,缺数据不补写。Sade Sati 明确缺三轮日期。09-24 reader-actions D2 撤下操作区「下载原始附录」入口;相关组件、路由和 helper 因删除权限阻塞暂留源码,不当作完成清理。不接普通下载 fallback、不增加 spinner。旧缓存不升级、不重算;没有结构化层就不显示占位盘。文案遵守 VOICE:不把可计算写成已验证。 @@ -888,6 +894,16 @@ Three product rules for assistant markdown in `.message-markdown` (BUG-962 / 963 盘内 SVG 文字用 CSS 提到 16px viewBox 单位,使约 318px 盘宽下实际像素不低于 12px。Tab、分盘 chip 均 44px。 +### 星盘档案与当前人物(2026-09-25修复) + +标题旁的人物切换仍是当前阅读/对话上下文,不替换户主资料,也不是「设为默认」。上次选择可同步预读,但账户与服务端目录绑定完成前,星盘、星历、报告和新建对话不发默认本人的首屏请求;目录暂时失败应可恢复,不假登出,不先闪别人的内容。档案增删改后切换器重新取目录;同一人物不重复广播切换。会话列表更新合并metadata,保留已加载消息、深链与分页外对话;可见列表仅属于当前人物,换账户不保留旧数据。 + +档案数量仍本人加最多四个他人,无关系字段,本人不可删除。表单添加/编辑标题必须与操作一致。删除确认只显示服务端成功取得的对话/报告数;查询失败时显示「暂时查不到这个人的对话数量,稍后再试」,禁用确认,不能显示0冒充已知。服务端成功后才移除档案并刷新目录;删除当前人物才回到本人。删除未交付报告释放预留点数,已交付/已结算不退,不用客户端猜测退款结果。 + +「和TA对话」新会话绑定选中的人物;「和我合盘」中的我保持本人,新会话显式绑定本人但不改变全局选择。普通新建深链用已绑定的当前人物。今日星语的可用性与缓存指纹取当前人物,不能由户主是否有分钟压掉他人的卡片。星历暖进只复用同账户/同人物缓存,首次失败仍显示可重试的静态错误句,不新增spinner、骨架或「正在加载」。跨scope迟到响应不得覆盖当前视图或分页游标。 + +上述为实现约束;登录页面、窄屏和慢网交互仍按真人清单待验,不以组件探针代替。 + ### 行星表 三张 `.chart-page-planet-table`(印度盘宿度、西洋相位、七政十一曜)**每一张**都包在 `.chart-page-planet-table-wrap` 里:`min-width: 0` + `overflow-x: auto`。375px 下表格在自己的盒子里横滚,页面不横滚。`min-width: 0` 和 overflow 一样是必需的——grid item 默认按内容定宽,少了它表格会把页面撑宽而不是滚动。印度盘那张从四列扩到八列后 `min-width` 由 `26rem` 提到 `34rem`。 @@ -946,7 +962,7 @@ Three product rules for assistant markdown in `.message-markdown` (BUG-962 / 963 星座名用 `--color-ink`。这四个填色不是通用文字底,三级字和强调色不铺在上面。相位:合 / 六分 / 三分用 `--color-success`,刑 / 冲用 `--color-danger`。组件里不写死色值。 -行星标的是符号加星座内整度,逆行再加 ℞。字号 17(viewBox 单位),在 375px 宽的盘上仍不低于 12px。相邻显示角小于 11° 时沿黄经推开。推开后同一半径的字框仍会叠,就改用第二层半径(188 与 136),不再把字号降到 12 以下。真实位置在星座环内缘留刻度,并用细线连到显示位置。宫位号放在宫头到下一宫头的中点,不压在宫头线上。1 / 4 / 7 / 10 宫头加粗。ASC、MC 写在盘内;不再画盘外的「升」。`view.western` 没有 MC 字段,MC 用第 10 宫头。 +行星标的是符号加星座内整度,逆行再加 ℞。字号 19(viewBox 单位),375px 页面扣掉两侧各 16px 后,343px 实际盘宽下约 12.53px;不能用屏幕宽代替盘宽验收。标签以 `central` 基线居中,符号字框按本地 Noto Symbols 的高 em 框保守留到 2.2em,普通文字为 1.4em。相邻显示角至少 11°,在两层半径(188 与 136)内找最近空位;ASC / MC 是固定锚点,星座名与宫位号也是障碍,行星让位而不缩字。真实位置在星座环内缘留刻度,并用细线连到显示位置。引导线和刻度不接收指针事件。透明命中圆 r=34,在 343px 盘宽下直径约 44.85px;任一圆不得吞掉其他行星的中心;全部命中圆先画,全部可见行星文字后画,文字点选直接归属自身,透明圆保持唯一键盘按钮,文字层对读屏隐藏。真实浏览器需用 `getExtentOfChar` + `elementFromPoint` 逐字符验证,不只检查标签中心。宫位号保持宫头到下一宫头的中点角度,默认半径84;字框拥挤时只径向换层(108、60、132、36),不改变真实宫头或文字大小。1 / 4 / 7 / 10 宫头加粗。ASC、MC 写在盘内;不再画盘外的「升」。`view.western` 没有 MC 字段,MC 用第 10 宫头。 相位线一直画着。点行星或行星位置表的一行,只留下该星和它的相位,其余变淡;再点取消。相位表同步只列该星。右栏顺序:要点 chips(上升、太阳、月亮、元素最多、星群)→ 行星位置表 → 主要相位表 → 口径说明。星群是同一星座里至少 3 颗行星,由现有 `planets` 数出来,不是新字段。表用 `.chart-page-western-table`,不继承 `min-width: 34rem`,外包 `.chart-page-western-table-wrap`(`min-width: 0`、`overflow-x: auto`)。断点仍是 1024。 diff --git a/frontend/src/app/(app)/page.tsx b/frontend/src/app/(app)/page.tsx index ebb8e328..b92c33be 100644 --- a/frontend/src/app/(app)/page.tsx +++ b/frontend/src/app/(app)/page.tsx @@ -42,7 +42,7 @@ import { SidebarTrigger } from "@/components/ui/sidebar"; import { useHomeShellRegistration } from "@/hooks/use-home-shell-registration"; import { useSessionList } from "@/hooks/use-session-list"; import { composerDraftSnapshot, setComposerDraft } from "@/lib/composer-draft"; -import { bindCurrentSubjectAccount } from "@/lib/current-subject"; +import { bindCurrentSubjectAccount, useCurrentSubjectProfile } from "@/lib/current-subject"; import { DailyStarlanguageBinder } from "@/components/daily-starlanguage-binder"; import { NewChatDeepLink, SynastryDeepLink } from "@/components/people-home-links"; import { StarterEntries, StarterGreeting } from "@/components/starter-home"; @@ -185,6 +185,7 @@ export default function Home() { account, setAccount, ready: sessionListReady, + waitUntilReady: waitForSessionList, settled: sessionListSettled, boot: sessionListBoot, reload: reloadSessionList, @@ -407,7 +408,7 @@ export default function Home() { }); const { synastryPanel, clearSynastryHistory, applyCloudSynastryHistory } = useSynastry({ - accountId, profile, chooseSuggestedQuestion, closeAccountDialog, + accountId, profile, chooseSuggestedQuestion, closeAccountDialog, startNewChat, }); const { @@ -540,7 +541,6 @@ export default function Home() { queueMicrotask(() => { setChartLibrary([]); clearSynastryHistory(); - bindCurrentSubjectAccount("", null); }); return; } @@ -604,7 +604,8 @@ export default function Home() { }, [account, accountId, applyCloudSynastryHistory, beginChatProfileReload, clearSynastryHistory, markChatProfilesFailed, markChatProfilesReady, profile]); const profileComplete = isProfileComplete(profile); - const birthTimeRoute = resolveBirthTimeConsultationRoute(profile, birthTimeConsultationConsent, activeSessionId); + const dailyProfile = useCurrentSubjectProfile(profile); + const birthTimeRoute = resolveBirthTimeConsultationRoute(dailyProfile ?? { ...profile, time: "", reportedTime: "", birthTimeStatus: "reported", birthTimeSource: "unknown" }, birthTimeConsultationConsent, activeSessionId); const natalMinuteAvailable = birthTimeRoute.kind === "consult" && (birthTimeRoute.mode === "verified_chart" || birthTimeRoute.mode === "unverified_birth_time"); @@ -763,6 +764,7 @@ export default function Home() { const controller = new AbortController(); void runHomeBootstrap({ sessionListReady, + waitForSessionList, sessionListBoot, uiPreview, uiPreviewMode, diff --git a/frontend/src/app/api/chart-profiles/[id]/route.ts b/frontend/src/app/api/chart-profiles/[id]/route.ts index df17f08e..1ecd4299 100644 --- a/frontend/src/app/api/chart-profiles/[id]/route.ts +++ b/frontend/src/app/api/chart-profiles/[id]/route.ts @@ -1,6 +1,6 @@ import { NextResponse } from "next/server"; import { libraryProfileFromSubjectRow, parseChartSubjectWrite } from "@/lib/chart-subject-write"; -import { CHART_SUBJECT_API_SELECT } from "@/lib/subject-birth"; +import { CHART_SUBJECT_API_SELECT, isChartSubjectId } from "@/lib/subject-birth"; import { createServerSupabaseClient } from "@/lib/supabase/server"; import { isSupabaseConfigurationError } from "@/lib/supabase/config"; @@ -9,7 +9,8 @@ type RouteContext = { }; function errorMessage(error: unknown, fallback: string) { - return error instanceof Error ? error.message : fallback; + return error && typeof error === "object" && "message" in error && typeof error.message === "string" + ? error.message : fallback; } function databaseFailure(error: unknown): NextResponse | null { @@ -41,6 +42,7 @@ export async function GET(_request: Request, context: RouteContext) { const supabase = await createServerSupabaseClient(); const { data: { user } } = await supabase.auth.getUser(); if (!user) return NextResponse.json({ error: "请先登录" }, { status: 401 }); + if (!isChartSubjectId(id)) return NextResponse.json({ error: "星盘不存在" }, { status: 404 }); const { data, error } = await supabase .from("chart_profiles") .select(CHART_SUBJECT_API_SELECT) @@ -53,12 +55,13 @@ export async function GET(_request: Request, context: RouteContext) { const usage = await supabase.rpc("chart_subject_usage", { p_id: id }); if (usage.error) throw usage.error; const row = Array.isArray(usage.data) ? usage.data[0] as { session_count?: number; report_count?: number } : null; + if (!row || !Number.isInteger(row.session_count) || Number(row.session_count) < 0 + || !Number.isInteger(row.report_count) || Number(row.report_count) < 0) { + return NextResponse.json({ error: "暂时查不到这个人的对话数量,稍后再试" }, { status: 503 }); + } return NextResponse.json({ profile: toClientProfile(data as Record), - usage: { - sessions: row?.session_count ?? 0, - reports: row?.report_count ?? 0, - }, + usage: { sessions: row.session_count, reports: row.report_count }, }); } catch (error) { if (isSupabaseConfigurationError(error)) { @@ -79,6 +82,7 @@ export async function DELETE(_request: Request, context: RouteContext) { const supabase = await createServerSupabaseClient(); const { data: { user } } = await supabase.auth.getUser(); if (!user) return NextResponse.json({ error: "请先登录" }, { status: 401 }); + if (!isChartSubjectId(id)) return NextResponse.json({ error: "星盘不存在" }, { status: 404 }); const { error } = await supabase.rpc("delete_chart_subject", { p_id: id }); if (error) { const mapped = databaseFailure(error); @@ -102,6 +106,7 @@ export async function PUT(request: Request, context: RouteContext) { const supabase = await createServerSupabaseClient(); const { data: { user } } = await supabase.auth.getUser(); if (!user) return NextResponse.json({ error: "请先登录" }, { status: 401 }); + if (!isChartSubjectId(id)) return NextResponse.json({ error: "星盘不存在" }, { status: 404 }); const body = await request.json().catch(() => null) as { profile?: unknown } | null; const parsed = parseChartSubjectWrite(body?.profile); diff --git a/frontend/src/app/api/chart-profiles/route.ts b/frontend/src/app/api/chart-profiles/route.ts index d44b9a60..70bbe0c0 100644 --- a/frontend/src/app/api/chart-profiles/route.ts +++ b/frontend/src/app/api/chart-profiles/route.ts @@ -11,7 +11,8 @@ import { isSupabaseConfigurationError } from "@/lib/supabase/config"; const SUBJECT_LIMIT_MESSAGE = "最多保存 5 个人的星盘"; function errorMessage(error: unknown, fallback: string) { - return error instanceof Error ? error.message : fallback; + return error && typeof error === "object" && "message" in error && typeof error.message === "string" + ? error.message : fallback; } function databaseFailure(error: unknown): NextResponse | null { @@ -83,11 +84,12 @@ export async function POST(request: Request) { const existing = await supabase .from("chart_profiles") - .select("id", { count: "exact", head: true }) + .select("id") .eq("user_id", user.id) - .eq("role", "other"); + .eq("role", "other") + .limit(MAX_SAVED_SUBJECTS - 1); if (existing.error) throw existing.error; - if (subjectLimitReached(existing.count ?? 0)) { + if (subjectLimitReached(Array.isArray(existing.data) ? existing.data.length : 0)) { return NextResponse.json({ error: SUBJECT_LIMIT_MESSAGE, code: "subject_limit_reached" }, { status: 409 }); } diff --git a/frontend/src/app/api/reports/route.ts b/frontend/src/app/api/reports/route.ts index bc0d673c..348da4d7 100644 --- a/frontend/src/app/api/reports/route.ts +++ b/frontend/src/app/api/reports/route.ts @@ -1,3 +1,4 @@ +import { normalizeReportSubjectRequest } from "@/lib/report-subject-request"; import { NextResponse } from "next/server"; import { LONGFORM_SNAPSHOT_SECTION_ID } from "@/lib/personal-report-longform-snapshot"; import { resolveMissingBirthTimezoneOffset } from "@/lib/birth-profile-timezone"; @@ -37,6 +38,7 @@ import { createServerSupabaseClient } from "@/lib/supabase/server"; import { ConsultationSubjectError, loadSubjectBirth, + isChartSubjectId, subjectIdFromQuery, } from "@/lib/subject-birth"; import { consultationSubjectFailureResponse } from "@/lib/consultation-subject-resolver"; @@ -104,6 +106,9 @@ export async function GET(request: Request) { return NextResponse.json({ error: "请先登录" }, { status: 401 }); } const subjectId = subjectIdFromQuery(new URL(request.url).searchParams.get("subject")); + if (subjectId !== "self" && !isChartSubjectId(subjectId)) { + return NextResponse.json({ error: "星盘不存在", code: "subject_not_found" }, { status: 404 }); + } let listQuery = supabase .from("personal_reports") .select(REPORT_LIST_COLUMNS) @@ -180,14 +185,12 @@ export async function POST(request: Request) { const persistence: PersonalReportService = createSupabasePersonalReportService(admin); const adminDataClient = createPersonalReportDataClient(admin); - const rawBody = await request.json().catch(() => null); - const requestedSubject = subjectIdFromQuery( - rawBody && typeof rawBody === "object" && "chartProfileId" in rawBody - ? (rawBody as { chartProfileId?: unknown }).chartProfileId - : rawBody && typeof rawBody === "object" && "subjectId" in rawBody - ? (rawBody as { subjectId?: unknown }).subjectId - : null, - ); + const normalized = normalizeReportSubjectRequest(await request.json().catch(() => null)); + if (normalized.mismatch) { + return NextResponse.json({ error: "报告人物不一致", code: "subject_mismatch" }, { status: 400 }); + } + const requestedSubject = normalized.subjectId; + const rawBody = normalized.body; let profile: unknown = null; if (userId) { try { @@ -220,6 +223,7 @@ export async function POST(request: Request) { rawBody, profile, loadCandidateRange: async () => { + if (requestedSubject !== "self") return null; const profileRow = profile && typeof profile === "object" ? profile as Record : {}; const caseId = typeof profileRow.rectification_case_id === "string" ? profileRow.rectification_case_id diff --git a/frontend/src/components/chart-page/western-wheel-layout.ts b/frontend/src/components/chart-page/western-wheel-layout.ts index f82365d8..83afeaab 100644 --- a/frontend/src/components/chart-page/western-wheel-layout.ts +++ b/frontend/src/components/chart-page/western-wheel-layout.ts @@ -13,7 +13,9 @@ export const WESTERN_R_HOUSE = 112; export const WESTERN_R_NUMBER = 84; export const WESTERN_R_ASPECT = 62; export const WESTERN_R_ANGLE = 162; -export const WESTERN_FONT_SIZE = 17; +// The 375px page leaves 343px for the wheel after its two 16px gutters. +export const WESTERN_FONT_SIZE = 19; +export const WESTERN_HIT_RADIUS = 34; export const WESTERN_MIN_SEPARATION_DEG = 11; const TEXT_PRESENTATION = "\uFE0E"; @@ -103,7 +105,8 @@ export function westernLabelBox( fontSize = WESTERN_FONT_SIZE, ): WesternLabelBox { const width = westernTextWidth(text, fontSize); - const height = fontSize; + // Vendored Noto Symbols has a taller em box than Latin/CJK fallback text. + const height = fontSize * (/[☉☽☿♀♂♃♄♅♆♇☊℞]/u.test(text) ? 2.2 : 1.4); return { left: x - width / 2, top: y - height / 2, @@ -183,14 +186,48 @@ function planetPoint(displayLongitude: number, ascendantLongitude: number, tier: return westernPolar(radius, westernScreenAngle(displayLongitude, ascendantLongitude)); } -/** - * Push planet captions apart along the ecliptic until adjacent display angles - * are at least 11°. A second radius is used only when the primary radius would - * still overlap; the font size stays at 17. +export function layoutWesternHouseLabels( + houses: readonly { house: number; longitude: number }[], + ascendantLongitude: number, +) { + const occupied: WesternLabelBox[] = []; + return houses.map((house, index) => { + const next = houses[(index + 1) % houses.length] ?? house; + const longitude = houseMidLongitude(house.longitude, next.longitude); + const angle = westernScreenAngle(longitude, ascendantLongitude); + let point = westernPolar(WESTERN_R_NUMBER, angle); + // Keep the true cusp midpoint angle; only crowded numbers move radially. + for (const radius of [WESTERN_R_NUMBER, 108, 60, 132, 36]) { + const candidate = westernPolar(radius, angle); + const box = westernLabelBox(candidate.x, candidate.y, String(house.house)); + if (occupied.some((other) => boxesOverlap(box, other))) continue; + point = candidate; + break; + } + occupied.push(westernLabelBox(point.x, point.y, String(house.house))); + return { house: house.house, longitude, ...point }; + }); +} + +export function westernAngleLabels(ascendantLongitude: number, mcLongitude?: number) { + return [ + { caption: "ASC", longitude: ascendantLongitude }, + ...(mcLongitude === undefined ? [] : [{ caption: "MC", longitude: mcLongitude }]), + ].map((angle) => ({ + ...angle, + ...westernPolar(WESTERN_R_ANGLE, westernScreenAngle(angle.longitude, ascendantLongitude) + 9), + })); +} + +/** Keep the axes fixed and place each caption in the nearest free two-ring slot. + * The search checks text boxes as well as hit-circle centres, rather than + * assuming alternating radii resolves every collision after angular relaxation. */ export function layoutWesternLabels( planets: readonly WesternLayoutBody[], ascendantLongitude = 0, + mcLongitude?: number, + fixedBoxes: readonly WesternLabelBox[] = [], ): WesternLayoutResult[] { if (planets.length === 0) return []; const indexed = planets.map((planet, index) => ({ @@ -200,44 +237,47 @@ export function layoutWesternLabels( })); indexed.sort((a, b) => a.longitude - b.longitude || a.planet.id.localeCompare(b.planet.id)); const display = separateSorted(indexed.map((item) => item.longitude)); - const byDisplay = indexed - .map((_, index) => index) - .sort((a, b) => display[a]! - display[b]! || indexed[a]!.planet.id.localeCompare(indexed[b]!.planet.id)); - const overlapCount = (tiers: readonly (0 | 1)[]) => { - const boxes = indexed.map((item, index) => { - const point = planetPoint(display[index]!, ascendantLongitude, tiers[index]!); - return westernLabelBox(point.x, point.y, item.planet.caption); - }); - let count = 0; - for (let left = 0; left < boxes.length; left += 1) { - for (let right = left + 1; right < boxes.length; right += 1) { - if (boxesOverlap(boxes[left]!, boxes[right]!)) count += 1; - } - } - return count; - }; - const alternate = (phase: 0 | 1) => { - const tiers = new Array<0 | 1>(indexed.length).fill(0); - byDisplay.forEach((sortedIndex, order) => { - tiers[sortedIndex] = ((order + phase) % 2) as 0 | 1; - }); - return tiers; - }; - const flat = new Array<0 | 1>(indexed.length).fill(0); - let tiers = flat; - if (overlapCount(flat) > 0) { - const primary = alternate(0); - const secondary = alternate(1); - tiers = overlapCount(secondary) < overlapCount(primary) ? secondary : primary; - } + const occupied = [ + ...fixedBoxes, + ...westernAngleLabels(ascendantLongitude, mcLongitude).map((angle) => + westernLabelBox(angle.x, angle.y, angle.caption)), + ]; + const centres: Array<{ x: number; y: number; longitude: number }> = []; const results: WesternLayoutResult[] = new Array(planets.length); + const candidates = [0, 1].flatMap((tier) => + Array.from({ length: 361 }, (_, step) => { + const offset = Math.ceil(step / 2) * (step % 2 === 0 ? -1 : 1); + return { tier: tier as 0 | 1, offset, cost: Math.abs(offset) + tier * 8 }; + }), + ).sort((a, b) => a.cost - b.cost || a.tier - b.tier || a.offset - b.offset); indexed.forEach((item, index) => { + // Preserve every body even if a future caller exceeds the eleven-body wheel. results[item.index] = { id: item.planet.id, longitude: item.longitude, displayLongitude: display[index]!, - tier: tiers[index]!, + tier: 0, }; + for (const candidate of candidates) { + const longitude = normalizeDegrees(display[index]! + candidate.offset); + const point = planetPoint(longitude, ascendantLongitude, candidate.tier); + const box = westernLabelBox(point.x, point.y, item.planet.caption); + if (!westernLabelInsideViewBox(box) || occupied.some((other) => boxesOverlap(box, other))) continue; + if (centres.some((other) => { + const delta = Math.abs(longitude - other.longitude); + return Math.min(delta, 360 - delta) < WESTERN_MIN_SEPARATION_DEG - 1e-6 + || Math.hypot(point.x - other.x, point.y - other.y) <= WESTERN_HIT_RADIUS + 1; + })) continue; + results[item.index] = { + id: item.planet.id, + longitude: item.longitude, + displayLongitude: longitude, + tier: candidate.tier, + }; + occupied.push(box); + centres.push({ ...point, longitude }); + break; + } }); return results; } diff --git a/frontend/src/components/chart-page/western-wheel-svg.tsx b/frontend/src/components/chart-page/western-wheel-svg.tsx index 66358e76..18fe102e 100644 --- a/frontend/src/components/chart-page/western-wheel-svg.tsx +++ b/frontend/src/components/chart-page/western-wheel-svg.tsx @@ -3,10 +3,10 @@ import { SIGN_ORDER, signZh, type SignName } from "@/lib/chart-view-labels"; import { WESTERN_CX, WESTERN_CY, - WESTERN_R_ANGLE, + WESTERN_FONT_SIZE, + WESTERN_HIT_RADIUS, WESTERN_R_ASPECT, WESTERN_R_HOUSE, - WESTERN_R_NUMBER, WESTERN_R_OUTER, WESTERN_R_PLANET, WESTERN_R_PLANET_INNER, @@ -14,6 +14,9 @@ import { WESTERN_SIZE, houseMidLongitude, layoutWesternLabels, + layoutWesternHouseLabels, + westernAngleLabels, + westernLabelBox, westernPlanetCaption, westernPolar, westernRingSlice, @@ -82,6 +85,16 @@ export function WesternWheelSvg({ }) { const western = skeleton ? EMPTY_WESTERN_WHEEL : suppliedWestern; const asc = western.ascendantLongitude; + const tenth = western.houses.find((house) => house.house === 10); + const angles = westernAngleLabels(asc, tenth?.longitude); + const houseLabels = layoutWesternHouseLabels(western.houses, asc); + const fixedBoxes = [ + ...SIGN_ORDER.map((sign, index) => { + const point = westernPolar((WESTERN_R_ZODIAC + WESTERN_R_OUTER) / 2, westernScreenAngle(index * 30 + 15, asc)); + return westernLabelBox(point.x, point.y, signZh(sign)); + }), + ...houseLabels.map((label) => westernLabelBox(label.x, label.y, String(label.house))), + ]; const placements = layoutWesternLabels( western.planets.map((planet) => ({ id: planet.id, @@ -89,11 +102,12 @@ export function WesternWheelSvg({ caption: westernPlanetCaption(planet), })), asc, + tenth?.longitude, + fixedBoxes, ); const placementById = new Map(placements.map((item) => [item.id, item])); const longitudeByLabel = new Map(western.planets.map((planet) => [planet.label, planet.longitude])); longitudeByLabel.set("上升", asc); - const tenth = western.houses.find((house) => house.house === 10); if (tenth) longitudeByLabel.set("中天", tenth.longitude); return ( @@ -131,7 +145,7 @@ export function WesternWheelSvg({ {!skeleton && ( - + {signZh(sign)} )} @@ -144,7 +158,7 @@ export function WesternWheelSvg({ const inner = westernPolar(WESTERN_R_ASPECT, angle); const outer = westernPolar(WESTERN_R_ZODIAC, angle); const mid = houseMidLongitude(house.longitude, next.longitude); - const label = westernPolar(WESTERN_R_NUMBER, westernScreenAngle(mid, asc)); + const label = houseLabels[index]!; const axis = house.house === 1 || house.house === 4 || house.house === 7 || house.house === 10; return ( @@ -159,7 +173,7 @@ export function WesternWheelSvg({ x={label.x} y={label.y} textAnchor="middle" - dominantBaseline="middle" + dominantBaseline="central" style={{ fontSize: WESTERN_FONT_SIZE }} className="chart-page-western-house-label" data-house={house.house} data-longitude={fmt(mid)} @@ -197,7 +211,6 @@ export function WesternWheelSvg({ const truth = westernPolar(WESTERN_R_ZODIAC - 8, westernScreenAngle(placement.longitude, asc)); const leaderEnd = pointToward(truth, display, 14); const selected = selectedLabel === planet.label; - const caption = westernPlanetCaption(planet); return ( - - - {caption} + + + ); + })} + {!skeleton && western.planets.map((planet) => { + const placement = placementById.get(planet.id); + if (!placement) return null; + const radius = placement.tier === 0 ? WESTERN_R_PLANET : WESTERN_R_PLANET_INNER; + const display = westernPolar(radius, westernScreenAngle(placement.displayLongitude, asc)); + const selected = selectedLabel === planet.label; + return ( + ); })} - {!skeleton && ( + {!skeleton && angles.map((angle) => ( - ASC + {angle.caption} - )} - {!skeleton && tenth && ( - - MC - - )} + ))} ); } diff --git a/frontend/src/components/current-subject-switcher.tsx b/frontend/src/components/current-subject-switcher.tsx index 90e4791f..512b6a3c 100644 --- a/frontend/src/components/current-subject-switcher.tsx +++ b/frontend/src/components/current-subject-switcher.tsx @@ -3,7 +3,7 @@ import { useEffect, useState } from "react"; import { SubjectSwitcher } from "@/components/subject-switcher"; -import { loadSubjectCatalog, setCurrentSubject, useCurrentSubject } from "@/lib/current-subject"; +import { loadSubjectCatalog, setCurrentSubject, subscribeSubjectCatalog, useCurrentSubject } from "@/lib/current-subject"; import type { ChatProfileCatalogStatus } from "@/lib/chat-profile-picker-model"; import type { ChartLibraryRecord } from "@/lib/home-types"; @@ -24,17 +24,22 @@ export function CurrentSubjectSwitcher() { useEffect(() => { let cancelled = false; - void loadSubjectCatalog() - .then((next) => { - if (cancelled) return; - setLibrary(next); - setStatus("ready"); - }) - .catch(() => { - if (!cancelled) setStatus("failed"); - }); + const refresh = () => { + void loadSubjectCatalog() + .then((next) => { + if (cancelled) return; + setLibrary(next); + setStatus("ready"); + }) + .catch(() => { + if (!cancelled) setStatus("failed"); + }); + }; + refresh(); + const unsubscribe = subscribeSubjectCatalog(refresh); return () => { cancelled = true; + unsubscribe(); }; }, []); diff --git a/frontend/src/components/daily-starlanguage-binder.tsx b/frontend/src/components/daily-starlanguage-binder.tsx index 136c4d0d..868103e4 100644 --- a/frontend/src/components/daily-starlanguage-binder.tsx +++ b/frontend/src/components/daily-starlanguage-binder.tsx @@ -2,7 +2,9 @@ import { useEffect } from "react"; -import { useCurrentSubject } from "@/lib/current-subject"; +import { useCurrentSubject, useCurrentSubjectProfile, useCurrentSubjectReady } from "@/lib/current-subject"; +import { resolveBirthTimeConsultationRoute } from "@/lib/birth-time-consultation-consent"; +import { missingOtherProfileStep } from "@/lib/home-profile"; import { calendarDateInTimeZone, dailyStarlanguageProfileKey } from "@/lib/daily-starlanguage"; import type { BootstrapPhase } from "@/lib/home-bootstrap"; import { @@ -24,11 +26,20 @@ export function DailyStarlanguageBinder(props: { natalMinuteAvailable: boolean; setDailyStarlanguage: (next: DailyStarlanguageState) => void; }) { - const { accountId, bootstrapPhase, profile, profileComplete, natalMinuteAvailable, setDailyStarlanguage } = props; + const { accountId, bootstrapPhase, setDailyStarlanguage } = props; const subjectId = useCurrentSubject(); + const subjectReady = useCurrentSubjectReady(); + const profile = useCurrentSubjectProfile(props.profile); + const profileComplete = Boolean(profile && missingOtherProfileStep(profile) === null); + const route = profile ? resolveBirthTimeConsultationRoute(profile, {}, "") : null; + const natalMinuteAvailable = route?.kind === "consult" && Boolean(route.time); useEffect(() => { - if (bootstrapPhase === "account" || !accountId || !profileComplete || !natalMinuteAvailable) return; + if (bootstrapPhase === "account" || !accountId || !subjectReady || !profile || !profileComplete || !natalMinuteAvailable) { + let cancelled = false; + queueMicrotask(() => { if (!cancelled) setDailyStarlanguage({ kind: "unavailable" }); }); + return () => { cancelled = true; }; + } const today = calendarDateInTimeZone(new Date(), profile.timezoneId); const fingerprint = `${subjectId}:${dailyStarlanguageProfileKey(profile)}`; const stored = readStoredDailyStarlanguage(accountId); @@ -69,7 +80,7 @@ export function DailyStarlanguageBinder(props: { controller.abort(); if (retryTimer !== undefined) clearTimeout(retryTimer); }; - }, [accountId, bootstrapPhase, natalMinuteAvailable, profile, profileComplete, setDailyStarlanguage, subjectId]); + }, [accountId, bootstrapPhase, natalMinuteAvailable, profile, profileComplete, setDailyStarlanguage, subjectId, subjectReady]); return null; } diff --git a/frontend/src/components/ephemeris/ephemeris-page.tsx b/frontend/src/components/ephemeris/ephemeris-page.tsx index 540d4164..abda5eb9 100644 --- a/frontend/src/components/ephemeris/ephemeris-page.tsx +++ b/frontend/src/components/ephemeris/ephemeris-page.tsx @@ -10,7 +10,7 @@ import { Calendar } from "@/components/ui/calendar"; import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"; import { SecondaryPageShell } from "@/components/secondary-page-shell"; import { setComposerDraft } from "@/lib/composer-draft"; -import { readCurrentSubjectId, useCurrentSubject } from "@/lib/current-subject"; +import { readCurrentSubjectId, readCurrentSubjectScope, useCurrentSubjectScope, useCurrentSubject, useCurrentSubjectReady } from "@/lib/current-subject"; import { parseEphemerisOkResponse, type EphemerisOkResponse } from "@/lib/ephemeris-contract"; import { EPHEMERIS_WAITING_COPY, @@ -49,6 +49,10 @@ function snapshotToState(): PageState { export function EphemerisPage() { const subjectId = useCurrentSubject(); + const scope = useCurrentSubjectScope(); + const subjectReady = useCurrentSubjectReady(); + const [initialAttempt, setInitialAttempt] = useState(0); + const cacheSubject = useRef(scope); const cachedFirst = peekEphemerisPage(); const initialDate = cachedFirst?.kind === "ready" ? cachedFirst.payload.date : null; const [date, setDate] = useState(initialDate); @@ -56,10 +60,10 @@ export function EphemerisPage() { const [knownToday, setKnownToday] = useState( cachedFirst?.kind === "ready" ? cachedFirst.payload.today : null, ); - const [seenSubject, setSeenSubject] = useState(subjectId); + const [seenSubject, setSeenSubject] = useState(scope); const [state, setState] = useState(snapshotToState); - if (seenSubject !== subjectId) { - setSeenSubject(subjectId); + if (seenSubject !== scope) { + setSeenSubject(scope); setDate(null); setKnownToday(null); setState({ phase: "pending" }); @@ -70,19 +74,20 @@ export function EphemerisPage() { )); const load = useCallback(async (nextDate: string, signal: AbortSignal) => { - const response = await fetch(`/api/ephemeris?date=${encodeURIComponent(nextDate)}&subject=${encodeURIComponent(readCurrentSubjectId())}`, { + const requestedSubject = subjectId; + const response = await fetch(`/api/ephemeris?date=${encodeURIComponent(nextDate)}&subject=${encodeURIComponent(requestedSubject)}`, { credentials: "same-origin", headers: { Accept: "application/json" }, signal, }); - if (signal.aborted || selectedDateRef.current !== nextDate) return; + if (signal.aborted || selectedDateRef.current !== nextDate || readCurrentSubjectScope() !== scope) return; if (response.status === 401) { writeEphemerisPage({ kind: "unauthorized" }); setState({ phase: "unauthorized" }); return; } const json: unknown = await response.json().catch(() => null); - if (signal.aborted || selectedDateRef.current !== nextDate) return; + if (signal.aborted || selectedDateRef.current !== nextDate || readCurrentSubjectScope() !== scope) return; const payload = parseEphemerisOkResponse(json); if (payload?.subjectId && payload.subjectId !== readCurrentSubjectId()) return; if (!payload || payload.date !== nextDate) { @@ -95,15 +100,21 @@ export function EphemerisPage() { selectedDateRef.current = payload.date; setDate(payload.date); setState({ phase: "ready", payload }); - }, []); + }, [subjectId, scope]); useEffect(() => { + if (!subjectReady) return; let cancelled = false; - cache.current.clear(); - invalidateEphemerisPage(); - void refreshEphemerisPage() + if (cacheSubject.current !== scope) { + cacheSubject.current = scope; + cache.current.clear(); + selectedDateRef.current = null; + invalidateEphemerisPage(); + } + const warm = peekEphemerisPage(); + void (warm ? Promise.resolve(warm) : refreshEphemerisPage()) .then((snapshot) => { - if (cancelled) return; + if (cancelled || readCurrentSubjectScope() !== scope) return; if (snapshot.kind === "unauthorized") { setState({ phase: "unauthorized" }); return; @@ -117,25 +128,26 @@ export function EphemerisPage() { setState({ phase: "ready", payload: snapshot.payload }); }) .catch((error: unknown) => { - if (cancelled) return; + if (cancelled || scope !== readCurrentSubjectScope()) return; console.warn("ephemeris_page_fetch_failed", error); + setState({ phase: "failed" }); }); return () => { cancelled = true; }; - }, [subjectId]); + }, [scope, subjectReady, initialAttempt]); useEffect(() => { - if (!date || cache.current.has(date)) return; + if (!subjectReady || !date || cache.current.has(date)) return; const controller = new AbortController(); const requested = date; void load(requested, controller.signal).catch((error: unknown) => { - if (controller.signal.aborted || selectedDateRef.current !== requested) return; + if (controller.signal.aborted || readCurrentSubjectScope() !== scope || selectedDateRef.current !== requested) return; console.warn("ephemeris_page_fetch_failed", error); setState({ phase: "failed" }); }); return () => controller.abort(); - }, [date, attempt, load]); + }, [date, attempt, load, subjectReady, scope]); if (state.phase === "unauthorized") { return ( @@ -152,7 +164,15 @@ export function EphemerisPage() { const readyPayload = state.phase === "ready" && date && state.payload.date === date ? state.payload : null; - if (!date) { + if (!date || !subjectReady) { + if (state.phase === "failed") return ( + +
+

{EPHEMERIS_COPY.dateFailed}

+ +
+
+ ); return ; } const activeDate = date; diff --git a/frontend/src/components/people-home-links.tsx b/frontend/src/components/people-home-links.tsx index 838fb6bf..b2864f52 100644 --- a/frontend/src/components/people-home-links.tsx +++ b/frontend/src/components/people-home-links.tsx @@ -2,6 +2,7 @@ import { useEffect, useRef } from "react"; +import { useCurrentSubjectReady } from "@/lib/current-subject"; import type { ChartLibraryRecord, Profile, SynastryRelationshipType } from "@/lib/home-types"; function stripQuery(key: string) { @@ -17,13 +18,14 @@ export function NewChatDeepLink(props: { startNewChat: () => Promise; }) { const started = useRef(false); + const subjectReady = useCurrentSubjectReady(); useEffect(() => { - if (!props.ready || started.current) return; + if (!props.ready || !subjectReady || started.current) return; if (!new URLSearchParams(window.location.search).has("newChat")) return; started.current = true; stripQuery("newChat"); void props.startNewChat(); - }, [props.ready, props.startNewChat]); + }, [props.ready, props.startNewChat, subjectReady]); return null; } diff --git a/frontend/src/components/people/people-page.tsx b/frontend/src/components/people/people-page.tsx index fb4b4543..be030674 100644 --- a/frontend/src/components/people/people-page.tsx +++ b/frontend/src/components/people/people-page.tsx @@ -2,9 +2,10 @@ import { useEffect, useState, type FormEvent } from "react"; +import { effectiveBirthDate } from "@/lib/effective-birth-date"; import { ChartProfileForm } from "@/components/chart-profile-form"; import { SecondaryPageShell } from "@/components/secondary-page-shell"; -import { setCurrentSubject, useCurrentSubject } from "@/lib/current-subject"; +import { bindCurrentSubjectAccount, invalidateSubjectCatalog, setCurrentSubject, useCurrentSubject } from "@/lib/current-subject"; import { fetchAccount } from "@/lib/home-cloud-sync"; import { missingOtherProfileStep, @@ -22,7 +23,8 @@ type PersonRow = { readonly incomplete: boolean; }; -type Usage = { sessions: number; reports: number }; +type Usage = { id: string; sessions: number; reports: number }; +const DELETE_USAGE_UNAVAILABLE = "暂时查不到这个人的对话数量,稍后再试"; const LIMIT_COPY = "最多保存 5 个人的星盘"; @@ -56,6 +58,7 @@ export function PeoplePage() { const profile = readProfile(row.profile); return [{ id: row.id, role: "other" as const, profile, incomplete: incompleteProfile(profile) }]; }); + bindCurrentSubjectAccount(account.user.id, others.map((person) => person.id)); setPeople([ { id: "self", role: "self", profile: self, incomplete: incompleteProfile(self) }, ...others, @@ -92,7 +95,7 @@ export function PeoplePage() { setSaving(true); setNotice(""); try { - const editingOther = selected?.role === "other" ? selected.id : ""; + const editingOther = selectedId && selected?.role === "other" ? selected.id : ""; const response = await fetch(editingOther ? `/api/chart-profiles/${editingOther}` : "/api/chart-profiles", { method: editingOther ? "PUT" : "POST", headers: { "content-type": "application/json" }, @@ -105,8 +108,11 @@ export function PeoplePage() { } setEditing(false); setNotice(""); + invalidateSubjectCatalog(); await load(); if (payload?.profile?.id) setSelectedId(payload.profile.id); + } catch { + setNotice("保存失败,请重试"); } finally { setSaving(false); } @@ -114,13 +120,22 @@ export function PeoplePage() { async function askDelete() { if (!selected || selected.role !== "other") return; - const response = await fetch(`/api/chart-profiles/${selected.id}`, { cache: "no-store" }); - const payload = await response.json().catch(() => null) as { usage?: Usage } | null; - setPendingDelete(response.ok && payload?.usage ? payload.usage : { sessions: 0, reports: 0 }); + setPendingDelete(null); + setNotice(""); + try { + const response = await fetch(`/api/chart-profiles/${selected.id}`, { cache: "no-store" }); + const payload = await response.json().catch(() => null) as { usage?: Omit } | null; + const usage = payload?.usage; + if (!response.ok || !usage || !Number.isInteger(usage.sessions) || usage.sessions < 0 + || !Number.isInteger(usage.reports) || usage.reports < 0) throw new Error("usage_unavailable"); + setPendingDelete({ id: selected.id, ...usage }); + } catch { + setNotice(DELETE_USAGE_UNAVAILABLE); + } } async function confirmDelete() { - if (!selected || selected.role !== "other") return; + if (!selected || selected.role !== "other" || pendingDelete?.id !== selected.id) return; const response = await fetch(`/api/chart-profiles/${selected.id}`, { method: "DELETE" }); if (!response.ok) { setNotice("删除失败,请重试"); @@ -129,6 +144,7 @@ export function PeoplePage() { if (subjectId === selected.id) setCurrentSubject("self"); setPendingDelete(null); setSelectedId("self"); + invalidateSubjectCatalog(); await load(); } @@ -172,7 +188,7 @@ export function PeoplePage() {
{editing ? (

{selected.profile.name.trim() || (selected.role === "self" ? "我" : "未命名资料")}

{selected.incomplete ?

这份资料还不完整,补全后才能排盘。

: null} +

出生日期:{effectiveBirthDate(selected.profile) || "出生日期待补全"}

填报时间:{selected.profile.reportedTime || "还没填"}

排盘时间:{profileBirthTimeLabel(selected.profile)}

地点:{profilePlaceLabel(selected.profile)}

@@ -197,7 +214,7 @@ export function PeoplePage() { {selected.role === "other" ? ( - + ) : null} {selected.role === "other" ? ( @@ -209,7 +226,7 @@ export function PeoplePage() { {selected.role === "self" ?

本人不能删除。

: ( )} - {pendingDelete && selected.role === "other" ? ( + {pendingDelete?.id === selected.id && selected.role === "other" ? (

将同时删除 {pendingDelete.sessions} 个对话和 {pendingDelete.reports} 份报告,无法恢复。

diff --git a/frontend/src/components/personal-report/personal-report-center.tsx b/frontend/src/components/personal-report/personal-report-center.tsx index b15f09de..d27e7b5a 100644 --- a/frontend/src/components/personal-report/personal-report-center.tsx +++ b/frontend/src/components/personal-report/personal-report-center.tsx @@ -6,7 +6,7 @@ import { InlineSpinner } from "@/components/inline-spinner"; import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { SecondaryPageShell } from "@/components/secondary-page-shell"; -import { useCurrentSubject } from "@/lib/current-subject"; +import { readCurrentSubjectScope, useCurrentSubjectScope, useCurrentSubjectReady } from "@/lib/current-subject"; import { GeneratePersonalReportButton } from "./generate-personal-report-button"; import { Button } from "@/components/ui/button"; import { useVisibilityAwarePoll } from "@/hooks/use-visibility-aware-poll"; @@ -65,7 +65,8 @@ export function rowMeta(report: Pick & { } export function PersonalReportCenter() { - const subjectId = useCurrentSubject(); + const subjectId = useCurrentSubjectScope(); + const subjectReady = useCurrentSubjectReady(); const [seenSubject, setSeenSubject] = useState(subjectId); const [state, setState] = useState(() => snapshotToState(peekReportsPage())); const deletedIds = useRef(new Set()); @@ -88,24 +89,27 @@ export function PersonalReportCenter() { }, []); const load = useCallback(async (showLoading = false) => { + if (!subjectReady) return; + const requestedSubject = subjectId; if (showLoading) { setState((current) => ({ phase: "loading", reports: current.reports })); } try { const snapshot = await refreshReportsPage(); - if (cancelled.current) return; + if (cancelled.current || requestedSubject !== readCurrentSubjectScope()) return; applySnapshot(snapshot); } catch { - if (cancelled.current) return; + if (cancelled.current || requestedSubject !== readCurrentSubjectScope()) return; const cached = peekReportsPage(); const reports = cached && "reports" in cached ? cached.reports : []; const fallback: ReportsPageSnapshot = { kind: "error", reports }; writeReportsPage(fallback); applySnapshot(fallback); } - }, [applySnapshot]); + }, [applySnapshot, subjectId, subjectReady]); useEffect(() => { + if (!subjectReady) return; cancelled.current = false; invalidateReportsPage(); const timer = window.setTimeout(() => void load(true), 0); @@ -113,7 +117,7 @@ export function PersonalReportCenter() { window.clearTimeout(timer); cancelled.current = true; }; - }, [load, subjectId]); + }, [load, subjectId, subjectReady]); const hasGenerating = state.reports.some((report) => report.status === "generating"); const refresh = useCallback(() => void load(), [load]); @@ -147,7 +151,7 @@ export function PersonalReportCenter() { ); } - if (state.phase === "loading" && state.reports.length === 0) { + if (!subjectReady || (state.phase === "loading" && state.reports.length === 0)) { return ; } diff --git a/frontend/src/hooks/use-chart-page.ts b/frontend/src/hooks/use-chart-page.ts index f2806296..8933f000 100644 --- a/frontend/src/hooks/use-chart-page.ts +++ b/frontend/src/hooks/use-chart-page.ts @@ -4,7 +4,7 @@ import { useCallback, useEffect, useRef, useState } from "react"; import { flushSync } from "react-dom"; import { fetchChartView } from "@/lib/chart-view-client"; -import { useCurrentSubject } from "@/lib/current-subject"; +import { readCurrentSubjectId, useCurrentSubject, useCurrentSubjectReady } from "@/lib/current-subject"; import type { ChartViewFailureStatus, ChartViewOk, ChartViewResponse } from "@/lib/chart-view-contract"; import type { ChartViewLayer } from "@/lib/chart-view-engine"; import { chartClientFailureStatus, chartViewFailureResponse } from "@/lib/chart-view-failure"; @@ -54,6 +54,7 @@ function mergeLayer(current: ChartViewOk, incoming: ChartViewOk, layer: ChartVie export function useChartPage() { const subjectId = useCurrentSubject(); + const subjectReady = useCurrentSubjectReady(); const [seenSubject, setSeenSubject] = useState(subjectId); const [view, setView] = useState(() => viewMatchingSubject(subjectId)); const [pendingLayers, setPendingLayers] = useState>(new Set()); @@ -69,6 +70,7 @@ export function useChartPage() { } useEffect(() => { + if (!subjectReady) return; mounted.current = true; loadedLayers.current.clear(); inflightLayers.current.clear(); @@ -101,12 +103,12 @@ export function useChartPage() { cancelled = true; mounted.current = false; }; - }, [subjectId]); + }, [subjectId, subjectReady]); const requestLayer = useCallback((layer: ChartViewLayer) => { - if (loadedLayers.current.has(layer) || inflightLayers.current.has(layer)) return; + if (!subjectReady || loadedLayers.current.has(layer) || inflightLayers.current.has(layer)) return; const generation = beginChartCacheAccountRead(); - const isCurrent = () => mounted.current && accountChartPinStillCurrent(generation); + const isCurrent = () => mounted.current && readCurrentSubjectId() === subjectId && accountChartPinStillCurrent(generation); const fail = (status: ChartViewFailureStatus) => { setLayerFailures((current) => new Map(current).set(layer, status)); }; @@ -162,7 +164,7 @@ export function useChartPage() { return next; }); }); - }, [subjectId]); + }, [subjectId, subjectReady]); - return { view, pendingLayers, layerFailures, requestLayer }; + return { view: subjectReady ? view : null, pendingLayers, layerFailures, requestLayer }; } diff --git a/frontend/src/hooks/use-session-management.ts b/frontend/src/hooks/use-session-management.ts index d56cc573..4bfcfe69 100644 --- a/frontend/src/hooks/use-session-management.ts +++ b/frontend/src/hooks/use-session-management.ts @@ -12,7 +12,8 @@ import { writeSessionUrl, } from "@/lib/chat-session-url"; import { sessionMatchesSubject } from "@/lib/chat-profile-picker-model"; -import { readCurrentSubjectId, subscribeCurrentSubject } from "@/lib/current-subject"; +import { currentSubjectCatalogReady, readCurrentSubjectId, readCurrentSubjectScope, subscribeCurrentSubject, subscribeSubjectCatalog } from "@/lib/current-subject"; +import { mergeSubjectSessionRows } from "@/lib/subject-session-merge"; import { consultationReportMarkdown } from "@/lib/consultation-report-export"; import { fallbackSessionId } from "@/lib/rectification-session-composer-guard"; import { sortSessions } from "@/lib/session-groups"; @@ -129,23 +130,34 @@ export function useSessionManagement(params: SessionManagementParams) { // Owned here since 2026-09-16 (state lowering batch 2). Home reads back only // what its own JSX renders; the rest never leaves this hook. const [sessionsCursor, setSessionsCursor] = useState(null); + const pageGeneration = useRef(0); + const loadMoreInFlight = useRef(false); + useEffect(() => { + let scope = readCurrentSubjectScope(); + const invalidatePage = () => { + const next = readCurrentSubjectScope(); + if (next === scope) return; + scope = next; + pageGeneration.current += 1; + loadMoreInFlight.current = false; + setSessionsCursor(null); + }; + const offSubject = subscribeCurrentSubject(invalidatePage); + const offCatalog = subscribeSubjectCatalog(invalidatePage); + return () => { pageGeneration.current += 1; offSubject(); offCatalog(); }; + }, []); useEffect(() => { if (!account?.user.id) return; let cancelled = false; + let requestGeneration = 0; const pull = () => { + if (!currentSubjectCatalogReady()) return; const subject = readCurrentSubjectId(); + const generation = ++requestGeneration; void fetchSessions(undefined, { subject }).then((page) => { - if (cancelled) return; + if (cancelled || generation !== requestGeneration || subject !== readCurrentSubjectId()) return; const incoming = readSessions(page.sessions, modelCatalog).sessions; - setSessions((current) => { - const pending = current.filter((session) => ( - session.messages.length === 0 - && session.sessionType === "consultation" - && sessionMatchesSubject(session, subject) - && !incoming.some((item) => item.id === session.id) - )); - return [...pending, ...incoming]; - }); + setSessions((current) => mergeSubjectSessionRows(current, incoming)); setSessionsCursor(page.nextCursor); }).catch(() => undefined); }; @@ -160,14 +172,14 @@ export function useSessionManagement(params: SessionManagementParams) { const [sessionDetailLoadingId, setSessionDetailLoadingId] = useState(null); const [sessionFullPrompt, setSessionFullPrompt] = useState<{ question: string; theme: Theme } | null>(null); sessionsRef.current = sessions; - const loadMoreInFlight = useRef(false); const pendingCreateById = useRef(new Map()); const cloudCreatedIds = useRef(new Set()); for (const session of sessions) { if (!isUnsavedEmptyConsultation(session)) cloudCreatedIds.current.add(session.id); } - const visibleSessions = sortSessions(sessions.filter(isListedSidebarSession)); + const visibleSessions = sortSessions(sessions.filter((session) => isListedSidebarSession(session) + && sessionMatchesSubject(session, readCurrentSubjectId()))); function updateSession(sessionId: string, change: (session: ChatSession) => ChatSession) { setSessions((current) => current.map((session) => (session.id === sessionId ? change(session) : session))); @@ -321,11 +333,11 @@ export function useSessionManagement(params: SessionManagementParams) { } } - async function startNewChat(options?: { continuedFromSessionId?: string }): Promise { + async function startNewChat(options?: { continuedFromSessionId?: string; subjectId?: "self" }): Promise { if (!account || !modelCatalog || creatingSession) return null; const nextSession = { ...createSession(modelCatalog.defaultModelId), - ...chartSnapshotForSession(NEW_CHAT_SUBJECT_ID(), chartLibrary, profile), + ...chartSnapshotForSession(options?.subjectId ?? NEW_CHAT_SUBJECT_ID(), chartLibrary, profile), }; for (const session of sessions) { if (isUnsavedEmptyConsultation(session)) pendingCreateById.current.delete(session.id); @@ -393,21 +405,25 @@ export function useSessionManagement(params: SessionManagementParams) { } async function loadMoreSessions() { - if (!beginSessionPageLoad(loadMoreInFlight, sessionsCursor)) return; + if (!currentSubjectCatalogReady() || !beginSessionPageLoad(loadMoreInFlight, sessionsCursor)) return; + const scope = readCurrentSubjectScope(); + const generation = pageGeneration.current; + const stillCurrent = () => scope === readCurrentSubjectScope() && generation === pageGeneration.current; try { const page = await fetchSessions(undefined, { before: sessionsCursor, subject: readCurrentSubjectId(), }); + if (!stillCurrent()) return; const incoming = readSessions(page.sessions, modelCatalog).sessions; for (const session of incoming) cloudCreatedIds.current.add(session.id); setSessions((current) => mergeSessionPage(current, incoming)); setSessionsCursor(page.nextCursor); } catch (caught) { - if (caught instanceof LoginRedirectError) return; + if (!stillCurrent() || caught instanceof LoginRedirectError) return; setComposerNotice(caught instanceof Error ? caught.message : "暂时无法读取聊天记录"); } finally { - loadMoreInFlight.current = false; + if (stillCurrent()) loadMoreInFlight.current = false; } } diff --git a/frontend/src/hooks/use-synastry.ts b/frontend/src/hooks/use-synastry.ts index 0a22ad7d..00c569e0 100644 --- a/frontend/src/hooks/use-synastry.ts +++ b/frontend/src/hooks/use-synastry.ts @@ -19,6 +19,7 @@ export type SynastryParams = { profile: Profile; chooseSuggestedQuestion: (question: string, theme: Theme) => void; closeAccountDialog: () => void; + startNewChat: (options: { subjectId: "self" }) => Promise; }; export type SynastryPanel = { @@ -55,6 +56,7 @@ export function useSynastry(params: SynastryParams) { async function draftSynastryQuestionFromChart(record: ChartLibraryRecord, relationshipType: SynastryRelationshipType) { if (record.role !== "other") return; if (synastryPendingId) return; + if (!await params.startNewChat({ subjectId: "self" })) return; const baseQuestion = buildSynastryQuestion(profile, record.profile, relationshipType); setSynastryPendingId(record.id); setComposerNotice("正在计算基础合盘证据,请稍候。"); diff --git a/frontend/src/lib/current-subject.ts b/frontend/src/lib/current-subject.ts index 5a51b05d..219da3d7 100644 --- a/frontend/src/lib/current-subject.ts +++ b/frontend/src/lib/current-subject.ts @@ -1,109 +1,170 @@ "use client"; -import { useSyncExternalStore } from "react"; +import { useEffect, useSyncExternalStore } from "react"; import { fetchAccount, fetchCloudChartLibrary } from "@/lib/home-cloud-sync"; import { readProfile, upsertSelfChart } from "@/lib/home-profile"; -import type { ChartLibraryRecord } from "@/lib/home-types"; +import type { ChartLibraryRecord, Profile } from "@/lib/home-types"; const SELF_SUBJECT_ID = "self"; - -let accountId = ""; -let subjectId = SELF_SUBJECT_ID; -let catalogReady = false; -const listeners = new Set<() => void>(); +export const LAST_SUBJECT_ACCOUNT_KEY = "jyotisha_current_subject_account"; export function currentSubjectStorageKey(id: string): string { return `jyotisha_current_subject:${id}`; } +function storedValue(key: string): string { + try { + return typeof localStorage === "undefined" ? "" : localStorage.getItem(key) || ""; + } catch { + return ""; + } +} + +function storeValue(key: string, value: string): void { + try { + if (typeof localStorage !== "undefined") localStorage.setItem(key, value); + } catch { /* The in-memory selection still works when storage is unavailable. */ } +} + +let accountId = storedValue(LAST_SUBJECT_ACCOUNT_KEY); +let subjectId = (accountId && storedValue(currentSubjectStorageKey(accountId))) || SELF_SUBJECT_ID; +let catalogReady = false; +let catalog: ChartLibraryRecord[] | null = null; +const listeners = new Set<() => void>(); +const catalogListeners = new Set<() => void>(); + export function readCurrentSubjectId(): string { return subjectId; } +export function readCurrentSubjectAccountId(): string { + return accountId; +} + +export function peekSubjectCatalog(): readonly ChartLibraryRecord[] | null { + return catalogReady ? catalog : null; +} + +export function readCurrentSubjectScope(): string { + return JSON.stringify([accountId, subjectId]); +} + +export function useCurrentSubjectScope(): string { + return useSyncExternalStore((listener) => { + const offSubject = subscribeCurrentSubject(listener); + const offCatalog = subscribeSubjectCatalog(listener); + return () => { offSubject(); offCatalog(); }; + }, readCurrentSubjectScope, () => ""); +} + export function currentSubjectCatalogReady(): boolean { return catalogReady; } export function subscribeCurrentSubject(listener: () => void): () => void { listeners.add(listener); - return () => { - listeners.delete(listener); - }; + return () => { listeners.delete(listener); }; +} + +export function subscribeSubjectCatalog(listener: () => void): () => void { + catalogListeners.add(listener); + return () => { catalogListeners.delete(listener); }; } function emit(): void { for (const listener of listeners) listener(); } +function emitCatalog(): void { + for (const listener of catalogListeners) listener(); +} + export function setCurrentSubject(next: string): void { const id = next.trim() || SELF_SUBJECT_ID; if (id === subjectId) return; subjectId = id; - if (accountId && typeof localStorage !== "undefined") { - localStorage.setItem(currentSubjectStorageKey(accountId), id); - } + if (accountId) storeValue(currentSubjectStorageKey(accountId), id); emit(); } -/** - * `knownOtherIds === null` means the archive has not loaded yet, so a stored - * id is kept. Once ids are known, an id that is not in the archive falls - * back to self. - */ +/** A null list preserves the stored choice, but cannot open the request gate. */ export function bindCurrentSubjectAccount(nextAccountId: string, knownOtherIds: readonly string[] | null): void { + const previousReady = catalogReady; + const previousAccount = accountId; accountId = nextAccountId; - if (!nextAccountId || typeof localStorage === "undefined") { - catalogReady = knownOtherIds !== null; - if (subjectId !== SELF_SUBJECT_ID) { - subjectId = SELF_SUBJECT_ID; - emit(); - } - return; - } - localStorage.removeItem(`jyotisha_active_chart:${nextAccountId}`); - const stored = localStorage.getItem(currentSubjectStorageKey(nextAccountId)) || SELF_SUBJECT_ID; + const stored = nextAccountId ? storedValue(currentSubjectStorageKey(nextAccountId)) || SELF_SUBJECT_ID : SELF_SUBJECT_ID; const valid = stored === SELF_SUBJECT_ID || knownOtherIds === null || knownOtherIds.includes(stored); const next = valid ? stored : SELF_SUBJECT_ID; - if (!valid) localStorage.setItem(currentSubjectStorageKey(nextAccountId), SELF_SUBJECT_ID); - catalogReady = knownOtherIds !== null; - if (next !== subjectId || knownOtherIds !== null) { + if (nextAccountId) { + storeValue(LAST_SUBJECT_ACCOUNT_KEY, nextAccountId); + if (!valid) storeValue(currentSubjectStorageKey(nextAccountId), SELF_SUBJECT_ID); + try { localStorage.removeItem(`jyotisha_active_chart:${nextAccountId}`); } catch { /* Optional storage. */ } + } + catalogReady = Boolean(nextAccountId) && knownOtherIds !== null; + if (next !== subjectId) { subjectId = next; emit(); } + if (previousReady !== catalogReady || previousAccount !== accountId) emitCatalog(); } export function useCurrentSubject(): string { return useSyncExternalStore(subscribeCurrentSubject, readCurrentSubjectId, () => SELF_SUBJECT_ID); } +export function useCurrentSubjectReady(): boolean { + const ready = useSyncExternalStore(subscribeSubjectCatalog, currentSubjectCatalogReady, () => false); + useEffect(() => { + if (currentSubjectCatalogReady()) return; + void loadSubjectCatalog().catch(() => undefined); + }, []); + return ready; +} + +export function useCurrentSubjectProfile(self: Profile): Profile | null { + const id = useCurrentSubject(); + const rows = useSyncExternalStore(subscribeSubjectCatalog, () => catalog, () => null); + return id === SELF_SUBJECT_ID ? self : rows?.find((row) => row.id === id)?.profile ?? null; +} + let catalogRequest: Promise | null = null; let catalogAccountId = ""; +let catalogGeneration = 0; export function resetSubjectCatalogForTests(): void { + catalogGeneration += 1; catalogRequest = null; catalogAccountId = ""; accountId = ""; subjectId = SELF_SUBJECT_ID; catalogReady = false; + catalog = null; +} + +export function invalidateSubjectCatalog(): void { + catalogGeneration += 1; + catalogRequest = null; + catalogAccountId = ""; + catalogReady = false; + catalog = null; + emitCatalog(); } export function loadSubjectCatalog(force = false): Promise { - if (!force && catalogRequest && catalogAccountId) return catalogRequest; - catalogRequest = Promise.all([ - fetchAccount(), - fetchCloudChartLibrary(), - ]).then(([account, others]) => { + if (!force && catalogRequest) return catalogRequest; + const generation = ++catalogGeneration; + catalogRequest = Promise.all([fetchAccount(), fetchCloudChartLibrary()]).then(([account, others]) => { + if (generation !== catalogGeneration) return loadSubjectCatalog(); const self = readProfile(account.profile); - const library = upsertSelfChart(others.filter((record) => record.role === "other"), self); + const library = upsertSelfChart(others.filter((row) => row.role === "other"), self); catalogAccountId = account.user.id; - bindCurrentSubjectAccount( - account.user.id, - library.filter((record) => record.role === "other").map((record) => record.id), - ); + catalog = library; + bindCurrentSubjectAccount(catalogAccountId, library.filter((row) => row.role === "other").map((row) => row.id)); + emitCatalog(); return library; }).catch((error: unknown) => { - catalogRequest = null; + if (generation === catalogGeneration) catalogRequest = null; throw error; }); return catalogRequest; diff --git a/frontend/src/lib/db/local-postgres-client-core.ts b/frontend/src/lib/db/local-postgres-client-core.ts index cb2b6c38..9dd83eef 100644 --- a/frontend/src/lib/db/local-postgres-client-core.ts +++ b/frontend/src/lib/db/local-postgres-client-core.ts @@ -26,6 +26,7 @@ type Filter = export type PostgrestOrNode = | Readonly<{ kind: "cmp"; column: string; op: CmpOp; value: unknown }> + | Readonly<{ kind: "is"; column: string; value: null | boolean }> | Readonly<{ kind: "and"; nodes: readonly PostgrestOrNode[] }>; type Mutation = @@ -114,6 +115,10 @@ function parsePostgrestTerm(term: string): PostgrestOrNode { if (andMatch?.[1] != null) { return { kind: "and", nodes: splitTopLevel(andMatch[1]).map(parsePostgrestTerm) }; } + const isMatch = /^([a-z_][a-z0-9_]*)\.is\.(null|true|false)$/.exec(term); + if (isMatch) { + return { kind: "is", column: isMatch[1], value: isMatch[2] === "null" ? null : isMatch[2] === "true" }; + } const cmp = /^([a-z_][a-z0-9_]*)\.(eq|neq|lt|gt|lte|gte)\.([\s\S]*)$/.exec(term); if (!cmp) throw new Error(`unsupported or filter: ${term}`); return { @@ -136,6 +141,7 @@ function compilePostgrestNode( if (node.kind === "and") { return `(${node.nodes.map((child) => compilePostgrestNode(child, parameters, types)).join(" and ")})`; } + if (node.kind === "is") return compileUnaryIsClause(node.column, node.value, false); identifier(node.column); parameters.push(databaseValue(types.get(node.column), node.value)); return `${identifier(node.column)} ${SQL_CMP[node.op]} $${parameters.length}`; diff --git a/frontend/src/lib/home-bootstrap-run.ts b/frontend/src/lib/home-bootstrap-run.ts index e2ddb3a0..075e6244 100644 --- a/frontend/src/lib/home-bootstrap-run.ts +++ b/frontend/src/lib/home-bootstrap-run.ts @@ -43,6 +43,7 @@ import { resolveLookupBootstrap, } from "@/lib/home-cloud-sync"; import { chartSnapshotForSession, missingProfileStep, readProfile } from "@/lib/home-profile"; +import { peekSubjectCatalog, readCurrentSubjectId } from "@/lib/current-subject"; import { emptyProfile, pendingConsultationStorageKey, @@ -104,6 +105,7 @@ export type HomeBootstrapIo = { export type HomeBootstrapDeps = { sessionListReady: Promise; + waitForSessionList?: () => Promise; sessionListBoot: () => SessionListBoot | null; uiPreview: { current: boolean }; uiPreviewMode: { current: string | null }; @@ -267,13 +269,14 @@ export async function runAccountPhase( if (caught instanceof Error && caught.name === "AbortError") throw caught; return null; }); - await deps.sessionListReady; + await (deps.waitForSessionList?.() ?? deps.sessionListReady); if (signal.aborted) return { kind: "stop" }; const listBoot = deps.sessionListBoot(); - if (listBoot?.signedOut || !listBoot?.account) { + if (listBoot?.signedOut) { io.redirectToLogin(); return { kind: "stop" }; } + if (!listBoot?.account || listBoot.error) throw new Error("session_list_unavailable"); const nextAccount = listBoot.account; const modelCatalogResult = await modelCatalogPromise; if (modelCatalogResult.error) throw modelCatalogResult.error; @@ -285,7 +288,7 @@ export async function runAccountPhase( nextSessions = [createSession( nextModelCatalog?.defaultModelId ?? "", "consultation", - chartSnapshotForSession("self", [], nextProfile), + chartSnapshotForSession(readCurrentSubjectId(), [...(peekSubjectCatalog() ?? [])], nextProfile), )]; } nextSessions = applyLegacySessionControls(nextAccount.user.id, nextSessions); @@ -360,7 +363,7 @@ export async function resolveLanding( const homeSession = createSession( ready.nextModelCatalog?.defaultModelId ?? "", "consultation", - chartSnapshotForSession("self", [], ready.nextProfile), + chartSnapshotForSession(readCurrentSubjectId(), [...(peekSubjectCatalog() ?? [])], ready.nextProfile), ); nextSessions = [homeSession, ...nextSessions]; landingSessionId = homeSession.id; diff --git a/frontend/src/lib/personal-report-worker-core.ts b/frontend/src/lib/personal-report-worker-core.ts index b3dab678..650f5128 100644 --- a/frontend/src/lib/personal-report-worker-core.ts +++ b/frontend/src/lib/personal-report-worker-core.ts @@ -76,7 +76,7 @@ export type PersonalReportWorkerDeps = Readonly<{ workerId: string; jobs: PersonalReportWorkerJobPort; reports: PersonalReportWorkerReportPort; - loadProfile: (userId: string) => Promise; + loadProfile: (userId: string, subjectId: string) => Promise; generate: ( context: PersonalReportWorkerGenerationContext, ) => Promise; @@ -313,7 +313,7 @@ export function createPersonalReportWorker(deps: PersonalReportWorkerDeps) { leaseToken: job.leaseToken, ...PERSONAL_REPORT_WORKER_PROGRESS.loadingContext, }); - const profile = await deps.loadProfile(job.userId); + const profile = await deps.loadProfile(job.userId, report.chartProfileId ?? "self"); if (!profile) { throw new PersonalReportWorkerError("profile_incomplete", false); } diff --git a/frontend/src/lib/personal-report-worker-subject.ts b/frontend/src/lib/personal-report-worker-subject.ts new file mode 100644 index 00000000..063486d1 --- /dev/null +++ b/frontend/src/lib/personal-report-worker-subject.ts @@ -0,0 +1,40 @@ +import { resolveMissingBirthTimezoneOffset } from "./birth-profile-timezone.ts"; +import { resolveReportBirthClock } from "./personal-report-route-core.ts"; +import { PersonalReportWorkerError } from "./personal-report-worker-core.ts"; +import { ConsultationSubjectError, loadSubjectBirth } from "./subject-birth.ts"; + +export async function loadReportWorkerProfile(client: unknown, userId: string, subjectId: string): Promise { + try { + const resolved = await loadSubjectBirth(client, userId, subjectId); + if (!resolved.chartable) throw new PersonalReportWorkerError("profile_incomplete", false); + let row = resolved.row; + if (resolved.role === "self") { + // Case linkage is worker metadata, not a second source of birth data. + const metadataClient = client as { from: (table: string) => { + select: (columns: string) => { eq: (column: string, value: string) => { + maybeSingle: () => PromiseLike<{ data: { rectification_case_id?: unknown } | null; error: unknown }>; + } }; + } }; + const { data, error } = await metadataClient.from("profiles") + .select("rectification_case_id").eq("id", userId).maybeSingle(); + if (error) throw new PersonalReportWorkerError("profile_incomplete", true); + row = { ...row, rectification_case_id: data?.rectification_case_id ?? null }; + } + return await resolveMissingBirthTimezoneOffset(row, { useActiveDate: true }); + } catch (error) { + if (error instanceof ConsultationSubjectError) { + // Missing/deleted/foreign subjects are terminal and release the existing + // reservation. A temporary database outage may retry, never as self. + throw new PersonalReportWorkerError("profile_incomplete", error.code === "subject_unavailable"); + } + throw error; + } +} + +export function reportWorkerUsesCandidateRange( + report: { chartProfileId: string | null }, + profile: Record, +): boolean { + return (!report.chartProfileId || report.chartProfileId === "self") + && resolveReportBirthClock(profile)?.status !== "confirmed"; +} diff --git a/frontend/src/lib/personal-report-worker.ts b/frontend/src/lib/personal-report-worker.ts index 440c238c..42f77e40 100644 --- a/frontend/src/lib/personal-report-worker.ts +++ b/frontend/src/lib/personal-report-worker.ts @@ -1,7 +1,6 @@ import type { ReportCandidateClockRange } from "./report-candidate-range"; import "server-only"; import { effectiveBirthDate } from "./effective-birth-date"; -import { resolveMissingBirthTimezoneOffset } from "./birth-profile-timezone"; import { runConsultationWorkflow } from "@/mastra"; import { createPersonalReportAgent } from "@/mastra/personal-report"; @@ -36,28 +35,7 @@ import { completeUsage, releaseUsage } from "@/lib/consultation-billing"; import type { ConsultationInput } from "@/mastra/consultation-workflow"; import { resolveAyanamsa } from "@/lib/ayanamsa"; -const PROFILE_COLUMNS = [ - "name", - "birth_date", - "active_birth_date", - "active_birth_timezone_offset", - "active_birth_provenance", - "timezone_id", - "reported_birth_time", - "active_birth_time", - "birth_time_source", - "birth_time_status", - "rectification_case_id", - "declared_window_start", - "declared_window_end", - "uncertainty_before_minutes", - "uncertainty_after_minutes", - "latitude", - "longitude", - "timezone_offset", - "birth_place_label", - "ayanamsa", -].join(","); +import { loadReportWorkerProfile, reportWorkerUsesCandidateRange } from "@/lib/personal-report-worker-subject"; type JsonRecord = Record; type WorkerGlobal = typeof globalThis & { @@ -280,15 +258,6 @@ async function generateProductionReport( function createProductionWorker(workerId: string) { const admin = createAdminSupabaseClient(); - const backend = admin as unknown as { - from(table: string): { - select(columns: string): { - eq(column: string, value: unknown): { - maybeSingle(): PromiseLike<{ data: unknown; error: { message: string } | null }>; - }; - }; - }; - }; return createPersonalReportWorker({ workerId, leaseSeconds: 600, @@ -315,18 +284,10 @@ function createProductionWorker(workerId: string) { release: async ({ userId, requestId, reason }) => (await releaseUsage(admin, userId, requestId, reason)).success, reserve: async () => { throw new Error("report worker does not reserve usage"); }, }, - loadProfile: async (userId) => { - const { data, error } = await backend - .from("profiles") - .select(PROFILE_COLUMNS) - .eq("id", userId) - .maybeSingle(); - if (error) throw new Error("personal report profile load failed"); - return data ? await resolveMissingBirthTimezoneOffset(data, { useActiveDate: true }) : null; - }, + loadProfile: (userId, subjectId) => loadReportWorkerProfile(admin, userId, subjectId), generate: async (context) => { const profile = record(context.profile) ?? {}; - if (resolveReportBirthClock(profile)?.status === "confirmed") { + if (!reportWorkerUsesCandidateRange(context.report, profile)) { return generateProductionReport(context, null, admin as never); } const range = await loadReportCandidateRange(admin, { diff --git a/frontend/src/lib/report-public-projection.ts b/frontend/src/lib/report-public-projection.ts index fe0887a3..243312b3 100644 --- a/frontend/src/lib/report-public-projection.ts +++ b/frontend/src/lib/report-public-projection.ts @@ -150,7 +150,7 @@ const DROP_TAG = new Set(["img", "embed", "script", "style", "iframe", "object"] const PUBLIC_PROTOCOLS = new Set(["http:", "https:", "mailto:"]); -export const ORDINARY_OUTPUT_LEAK_PATTERNS: readonly RegExp[] = [ +const SHARED_OUTPUT_LEAK_PATTERNS: readonly RegExp[] = [ /\btechnique_truth\b/i, /\bworkflow_route\b/i, /\bworkflow_status\b/i, @@ -184,6 +184,11 @@ export const ORDINARY_OUTPUT_LEAK_PATTERNS: readonly RegExp[] = [ /<\s*object\b/i, /<\s*embed\b/i, /<\s*img\b/i, +]; + +/** PL9 audit vocabulary applies to report prose, not chat or chart payloads. */ +export const ORDINARY_OUTPUT_LEAK_PATTERNS: readonly RegExp[] = [ + ...SHARED_OUTPUT_LEAK_PATTERNS, /\bblocked\b/i, /\bexecuted\b/i, /\bpartial_verified\b/i, @@ -242,7 +247,7 @@ type Block = { }; export function projectChatExportMarkdown(input: ChatExportInput): string { - const analyzed = analyzeOrdinaryReportMarkdown(input.prose.trim()); + const analyzed = analyzeOrdinaryReportMarkdown(input.prose.trim(), "chat_export"); const prose = analyzed.prose.trim() || "暂无回答。"; const limitations = orderedLimitations([ ...analyzed.limitations, @@ -333,27 +338,31 @@ function limitationsFromSignals(input: ChatExportInput): string[] { return notes; } -function adjacentInternalTableIndexes(blocks: Block[]): Set { +type ProjectionScope = "report" | "chat_export"; + +function outputLeaks(text: string, scope: ProjectionScope): boolean { + const patterns = scope === "report" ? ORDINARY_OUTPUT_LEAK_PATTERNS : SHARED_OUTPUT_LEAK_PATTERNS; + return patterns.some((pattern) => pattern.test(text)); +} + +function adjacentInternalTableIndexes(blocks: Block[], scope: ProjectionScope): Set { const drop = new Set(); blocks.forEach((block, index) => { - if (block.type !== "table" || !tableIsInternal(block.text)) return; + if (scope !== "report" || block.type !== "table" || !tableIsInternal(block.text, scope)) return; drop.add(index); let cursor = index - 1; while (cursor >= 0 && blocks[cursor]?.type === "blank") cursor -= 1; if (cursor >= 0 && (blocks[cursor]?.type === "paragraph" || blocks[cursor]?.type === "list")) { drop.add(cursor); - cursor -= 1; - while (cursor >= 0 && blocks[cursor]?.type === "blank") cursor -= 1; } - if (cursor >= 0 && blocks[cursor]?.type === "heading") drop.add(cursor); }); return drop; } -function analyzeOrdinaryReportMarkdown(markdown: string): Analysis { +function analyzeOrdinaryReportMarkdown(markdown: string, scope: ProjectionScope = "report"): Analysis { if (!markdown) return { prose: markdown, limitations: [], changed: false }; const blocks = parseBlocks(markdown); - const dropWithTable = adjacentInternalTableIndexes(blocks); + const dropWithTable = adjacentInternalTableIndexes(blocks, scope); const limitations = new Set(); const kept: string[] = []; let changed = false; @@ -412,13 +421,13 @@ function analyzeOrdinaryReportMarkdown(markdown: string): Analysis { continue; } - if (block.type === "table" && tableIsInternal(block.text)) { + if (block.type === "table" && tableIsInternal(block.text, scope)) { changed = true; collectLimitations(block.raw, limitations); continue; } - const projected = projectPreservedBlock(block.raw); + const projected = projectPreservedBlock(block.raw, scope); if (projected.changed) changed = true; for (const note of projected.limitations) limitations.add(note); if (projected.text) kept.push(projected.text); @@ -429,13 +438,13 @@ function analyzeOrdinaryReportMarkdown(markdown: string): Analysis { return { prose, limitations: orderedLimitations(limitations), changed }; } -function projectPreservedBlock(raw: string): { text: string; changed: boolean; limitations: string[] } { +function projectPreservedBlock(raw: string, scope: ProjectionScope): { text: string; changed: boolean; limitations: string[] } { const limitations = new Set(); const lines = splitLines(raw); const kept: string[] = []; let changed = false; for (const line of lines) { - const projected = projectProseLine(line.text); + const projected = projectProseLine(line.text, scope); for (const note of projected.limitations) limitations.add(note); if (!projected.keep) { changed = true; @@ -452,14 +461,14 @@ function projectPreservedBlock(raw: string): { text: string; changed: boolean; l return { text: kept.join(""), changed, limitations: [...limitations] }; } -function projectProseLine(line: string): { keep: boolean; text: string; limitations: string[] } { +function projectProseLine(line: string, scope: ProjectionScope = "report"): { keep: boolean; text: string; limitations: string[] } { if (line.trim() === "") return { keep: true, text: line, limitations: [] }; const field = FIELD_LINE.exec(line); if (field && isInternalKey(field[1])) { return { keep: false, text: "", limitations: notesForField(field[1], field[2] ?? "") }; } const cleaned = projectInline(line); - if (!cleaned.trim() || containsInternalToken(cleaned) || ordinaryOutputLeaks(cleaned).length > 0) { + if (!cleaned.trim() || containsInternalToken(cleaned) || outputLeaks(cleaned, scope)) { return { keep: false, text: "", limitations: notesFromText(line) }; } return { keep: true, text: cleaned, limitations: [] }; @@ -573,15 +582,19 @@ function containsInternalToken(text: string): boolean { function fenceIsPublic(raw: string): boolean { if (containsInternalToken(raw)) return false; - if (ordinaryOutputLeaks(raw).length > 0) return false; + if (outputLeaks(raw, "chat_export")) return false; if (/<\s*(?:script|iframe|object|embed|img)\b/i.test(raw)) return false; return true; } -function tableIsInternal(text: string): boolean { - if (containsInternalToken(text) || ordinaryOutputLeaks(text).length > 0) return true; +function tableIsInternal(text: string, scope: ProjectionScope): boolean { + if (containsInternalToken(text) || outputLeaks(text, scope)) return true; const cells = text.split("|").map((cell) => cell.trim()).filter(Boolean); - return cells.some((cell) => isInternalKey(cell) || /^(?:score|weight|provider|attempt|job|model)$/i.test(cell)); + // A domain table can legitimately have Score / Weight columns (Bhava Bala). + // Explicit internal keys still reject it; generic English labels do not. + return cells.some((cell) => scope === "chat_export" + ? isInternalKey(cell) || /^(?:score|weight|provider|attempt|job|model)$/i.test(cell) + : (isInternalKey(cell) && !/^(?:score|weight)$/i.test(cell)) || /^(?:provider|attempt|job|model)$/i.test(cell)); } function isPublicUrl(raw: string): boolean { @@ -687,7 +700,7 @@ function isAllowlistedSvg(raw: string): boolean { if (!tags.every((tag) => SVG_TAGS.has(tag))) return false; if (/\bon[a-z]+\s*=/i.test(raw)) return false; if (/\b(?:javascript|vbscript|data|file):/i.test(raw)) return false; - return ordinaryOutputLeaks(raw).length === 0; + return !outputLeaks(raw, "chat_export"); } function readTag(input: string, start: number): { name: string; end: number; selfClosing: boolean } | null { diff --git a/frontend/src/lib/report-subject-request.ts b/frontend/src/lib/report-subject-request.ts new file mode 100644 index 00000000..bd0e8fd7 --- /dev/null +++ b/frontend/src/lib/report-subject-request.ts @@ -0,0 +1,18 @@ +import { subjectIdFromQuery } from "./subject-birth.ts"; + +export function normalizeReportSubjectRequest(rawBody: unknown): { + subjectId: string; + body: unknown; + mismatch: boolean; +} { + if (!rawBody || typeof rawBody !== "object" || Array.isArray(rawBody)) { + return { subjectId: "self", body: rawBody, mismatch: false }; + } + const body = { ...rawBody } as Record; + const mismatch = "subjectId" in body && "chartProfileId" in body + && subjectIdFromQuery(body.subjectId) !== subjectIdFromQuery(body.chartProfileId); + const subjectId = subjectIdFromQuery("chartProfileId" in body ? body.chartProfileId : body.subjectId); + delete body.subjectId; + body.chartProfileId = subjectId === "self" ? null : subjectId; + return { subjectId, body, mismatch }; +} diff --git a/frontend/src/lib/secondary-page-data.ts b/frontend/src/lib/secondary-page-data.ts index cbc060ff..d8fcea8b 100644 --- a/frontend/src/lib/secondary-page-data.ts +++ b/frontend/src/lib/secondary-page-data.ts @@ -1,5 +1,5 @@ import { fetchChartView } from "@/lib/chart-view-client"; -import { readCurrentSubjectId } from "@/lib/current-subject"; +import { currentSubjectCatalogReady, readCurrentSubjectAccountId, readCurrentSubjectId } from "@/lib/current-subject"; import type { ChartViewResponse } from "@/lib/chart-view-contract"; import { chartClientFailureStatus, chartViewFailureResponse } from "@/lib/chart-view-failure"; import { CHART_VIEW_CLIENT_TIMEOUT_MS, CHART_VIEW_COPY } from "@/lib/chart-view-labels"; @@ -47,12 +47,26 @@ function createMemoryCache(load: () => Promise): MemoryCache { let value: T | null = null; let inflight: Promise | null = null; let epoch = 0; + let scope = ""; + const currentScope = () => JSON.stringify([readCurrentSubjectAccountId(), readCurrentSubjectId()]); + const syncScope = () => { + const next = currentScope(); + if (scope === next) return; + scope = next; + epoch += 1; + value = null; + inflight = null; + }; const refresh = (): Promise => { + syncScope(); if (inflight) return inflight; const requestEpoch = epoch; + const requestScope = scope; const request = load() .then((next) => { + syncScope(); + if (scope !== requestScope) throw new Error("secondary_page_scope_changed"); if (epoch === requestEpoch) value = next; return next; }) @@ -64,8 +78,9 @@ function createMemoryCache(load: () => Promise): MemoryCache { }; return { - peek: () => value, + peek: () => { syncScope(); return value; }, write: (next) => { + syncScope(); value = next; return next; }, @@ -375,6 +390,7 @@ export function refreshReportsPage(): Promise { } export function prefetchSecondaryPage(href: string): void { + if (!currentSubjectCatalogReady()) return; if (href === "/chart" || href.startsWith("/chart?")) void refreshChartPage(); else if (href === "/ephemeris" || href.startsWith("/ephemeris?")) ephemerisCache.prefetch(); else if (href === "/reports" || href.startsWith("/reports?")) reportsCache.prefetch(); diff --git a/frontend/src/lib/session-list-context.tsx b/frontend/src/lib/session-list-context.tsx index b63c7d46..7f9eb2ec 100644 --- a/frontend/src/lib/session-list-context.tsx +++ b/frontend/src/lib/session-list-context.tsx @@ -15,12 +15,14 @@ import { import type { AppSidebarControls, SidebarAccount } from "@/components/app-sidebar"; import type { SidebarSession } from "@/components/sidebar-session-row"; -import { readCurrentSubjectId, subscribeCurrentSubject } from "@/lib/current-subject"; +import { bindCurrentSubjectAccount, invalidateSubjectCatalog, loadSubjectCatalog, readCurrentSubjectAccountId, readCurrentSubjectId, subscribeCurrentSubject, useCurrentSubject } from "@/lib/current-subject"; +import { sessionMatchesSubject } from "@/lib/chat-profile-picker-model"; import { readSessions } from "@/lib/home-cloud-sync"; import type { Account, ChatSession } from "@/lib/home-types"; import { SESSION_PAGE_SIZE } from "@/lib/session-cursor"; import { isListedSidebarSession } from "@/lib/session-list-filter"; import { toSidebarSessionRow } from "@/lib/session-sidebar-row"; +import { mergeSubjectSessionRows } from "@/lib/subject-session-merge"; export type SessionListBoot = { readonly sessions: ChatSession[]; @@ -28,6 +30,8 @@ export type SessionListBoot = { readonly cursor: string | null; readonly account: Account | null; readonly signedOut: boolean; + readonly subjectId?: string; + readonly error?: string; }; export type ShellRegistration = { @@ -53,6 +57,7 @@ export type SessionListContextValue = { signedOut: boolean; settled: boolean; ready: Promise; + waitUntilReady: () => Promise; boot: () => SessionListBoot | null; reload: () => void; registerShellControls: (registration: ShellRegistration | null) => void; @@ -88,25 +93,35 @@ function toSidebarAccount(account: Account | null): SidebarAccount | null { } function sessionsToSidebarRows(sessions: readonly ChatSession[]): SidebarSession[] { - return sessions.filter(isListedSidebarSession).map((session) => toSidebarSessionRow(session)); + return sessions.filter((session) => isListedSidebarSession(session) + && sessionMatchesSubject(session, readCurrentSubjectId())).map((session) => toSidebarSessionRow(session)); } async function loadSessionList(signal: AbortSignal): Promise { - const [sessionResponse, accountResponse] = await Promise.all([ - fetch(`/api/sessions?limit=${SESSION_PAGE_SIZE}&subject=${encodeURIComponent(readCurrentSubjectId())}`, { signal, cache: "no-store" }), - fetch("/api/account", { signal, cache: "no-store" }), - ]); - if (sessionResponse.status === 401 || accountResponse.status === 401) { + const accountResponse = await fetch("/api/account", { signal, cache: "no-store" }); + if (accountResponse.status === 401) { + invalidateSubjectCatalog(); + bindCurrentSubjectAccount("", null); + return { sessions: [], rawRows: [], cursor: null, account: null, signedOut: true }; + } + if (!accountResponse.ok) throw new Error("account_unavailable"); + const accountPayload = await accountResponse.json() as Account; + if (readCurrentSubjectAccountId() !== accountPayload.user.id) { + invalidateSubjectCatalog(); + bindCurrentSubjectAccount(accountPayload.user.id, null); + } + await loadSubjectCatalog(); + signal.throwIfAborted(); + const subjectId = readCurrentSubjectId(); + const sessionResponse = await fetch(`/api/sessions?limit=${SESSION_PAGE_SIZE}&subject=${encodeURIComponent(subjectId)}`, { signal, cache: "no-store" }); + if (sessionResponse.status === 401) { return { sessions: [], rawRows: [], cursor: null, account: null, signedOut: true }; } const sessionPayload = await sessionResponse.json().catch(() => null) as { sessions?: unknown; nextCursor?: unknown; } | null; - const accountPayload = await accountResponse.json().catch(() => null); - if (!sessionResponse.ok || !accountResponse.ok) { - return { sessions: [], rawRows: [], cursor: null, account: null, signedOut: false }; - } + if (!sessionResponse.ok) throw new Error("sessions_unavailable"); const rawRows = Array.isArray(sessionPayload?.sessions) ? sessionPayload.sessions : []; const parsed = readSessions(rawRows, null); const cursor = typeof sessionPayload?.nextCursor === "string" ? sessionPayload.nextCursor : null; @@ -114,8 +129,9 @@ async function loadSessionList(signal: AbortSignal): Promise { sessions: parsed.sessions, rawRows, cursor, - account: accountPayload as Account, + account: accountPayload, signedOut: false, + subjectId, }; } @@ -129,23 +145,29 @@ export function SessionListProvider({ children }: { children: ReactNode }) { const bootRef = useRef(null); const controllerRef = useRef(null); const reloadRef = useRef<() => void>(() => undefined); - const [readyPack, setReadyPack] = useState(createReadyGate); + const [readyPack] = useState(createReadyGate); + const currentReady = useRef(readyPack); + useCurrentSubject(); useEffect(() => { let disposed = false; + let displayedAccount = ""; const load = (nextReadyPack: ReturnType) => { controllerRef.current?.abort(); const controller = new AbortController(); controllerRef.current = controller; setSettled(false); setSignedOut(false); - setSessions([]); bootRef.current = null; void loadSessionList(controller.signal) .then((boot) => { if (disposed || controller.signal.aborted || controllerRef.current !== controller) return; + const requestedSubject = boot.subjectId ?? readCurrentSubjectId(); + if (requestedSubject !== readCurrentSubjectId()) return; bootRef.current = boot; - setSessions(boot.sessions); + const sameAccount = Boolean(boot.account) && displayedAccount === boot.account?.user.id; + setSessions((current) => sameAccount ? mergeSubjectSessionRows(current, boot.sessions) : boot.sessions); + displayedAccount = boot.account?.user.id ?? ""; setSessionsCursor(boot.cursor); setAccount(boot.account); setSignedOut(boot.signedOut); @@ -154,15 +176,17 @@ export function SessionListProvider({ children }: { children: ReactNode }) { }) .catch(() => { if (disposed || controller.signal.aborted || controllerRef.current !== controller) return; - bootRef.current = { sessions: [], rawRows: [], cursor: null, account: null, signedOut: false }; + bootRef.current = { sessions: [], rawRows: [], cursor: null, account: null, signedOut: false, error: "session_list_unavailable" }; setSettled(true); nextReadyPack.resolve(); }); }; reloadRef.current = () => { - const nextReadyPack = createReadyGate(); - setReadyPack(nextReadyPack); - load(nextReadyPack); + const previous = currentReady.current; + const next = createReadyGate(); + currentReady.current = next; + previous.resolve(); + load(next); }; load(readyPack); const unsubscribe = subscribeCurrentSubject(() => { @@ -185,6 +209,13 @@ export function SessionListProvider({ children }: { children: ReactNode }) { const boot = useCallback(() => bootRef.current, []); + const waitUntilReady = useCallback(async () => { + for (;;) { + const gate = currentReady.current; + await gate.promise; + if (gate === currentReady.current) return; + } + }, []); const ready = readyPack.promise; const value = useMemo(() => ({ sessions, @@ -196,6 +227,7 @@ export function SessionListProvider({ children }: { children: ReactNode }) { signedOut, settled, ready, + waitUntilReady, boot, reload, registerShellControls, @@ -209,6 +241,7 @@ export function SessionListProvider({ children }: { children: ReactNode }) { settled, signedOut, ready, + waitUntilReady, ]); return ( diff --git a/frontend/src/lib/subject-birth.ts b/frontend/src/lib/subject-birth.ts index a6df474d..f07ce145 100644 --- a/frontend/src/lib/subject-birth.ts +++ b/frontend/src/lib/subject-birth.ts @@ -62,6 +62,10 @@ type SubjectLoader = { }; }; +export function isChartSubjectId(value: string): boolean { + return /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(value); +} + export function subjectIdFromQuery(value: unknown): string { if (typeof value !== "string") return SELF_SUBJECT_ID; const trimmed = value.trim(); @@ -218,6 +222,7 @@ export async function loadSubjectBirth( return data; }, loadOtherRow: async (chartProfileId) => { + if (!isChartSubjectId(chartProfileId)) throw new ConsultationSubjectError("subject_not_found"); const { data, error } = await client .from("chart_profiles") .select(CHART_SUBJECT_SELECT) diff --git a/frontend/src/lib/subject-session-merge.ts b/frontend/src/lib/subject-session-merge.ts new file mode 100644 index 00000000..9eaff9ba --- /dev/null +++ b/frontend/src/lib/subject-session-merge.ts @@ -0,0 +1,16 @@ +import type { ChatSession } from "./home-types"; + +/** A list row is metadata, never authority to erase an opened transcript. */ +export function mergeSubjectSessionRows( + current: readonly ChatSession[], + incoming: readonly ChatSession[], +): ChatSession[] { + const rows = new Map(current.map((session) => [session.id, session])); + for (const row of incoming) { + const existing = rows.get(row.id); + rows.set(row.id, existing?.messagesHydrated + ? { ...existing, ...row, messages: existing.messages, messagesHydrated: true } + : row); + } + return [...rows.values()]; +} diff --git a/frontend/supabase/migrations/20260925030000_chart_subject_trust_and_limit.sql b/frontend/supabase/migrations/20260925030000_chart_subject_trust_and_limit.sql new file mode 100644 index 00000000..6b8bb601 --- /dev/null +++ b/frontend/supabase/migrations/20260925030000_chart_subject_trust_and_limit.sql @@ -0,0 +1,42 @@ +-- Legacy JSON is not evidence of an accepted/confirmed rectification. +-- This is a corrective migration, not a rewrite of the deployed backfill. +begin; +lock table public.chart_profiles in access exclusive mode; +alter table public.chart_profiles disable trigger chart_profiles_guard_birth; +update public.chart_profiles +set birth_time_status = 'reported', + active_birth_time = null, + active_birth_date = null, + active_birth_timezone_offset = null +where role = 'other' + and (birth_time_status in ('accepted', 'confirmed') + or active_birth_time is not null + or active_birth_date is not null + or active_birth_timezone_offset is not null); +alter table public.chart_profiles enable trigger chart_profiles_guard_birth; + +create or replace function public.chart_profiles_guard_limit() +returns trigger +language plpgsql +set search_path = '' +as $$ +declare + others integer; +begin + if new.role is distinct from 'other' then + raise exception 'chart_subject_self_mirror_retired'; + end if; + if tg_op = 'INSERT' or new.user_id is distinct from old.user_id then + -- The lock and count are separate statements: after a competing insert + -- commits, READ COMMITTED sees its row before deciding the limit. + perform pg_catalog.pg_advisory_xact_lock(pg_catalog.hashtextextended(new.user_id::text, 73031)); + select count(*) into others from public.chart_profiles + where user_id = new.user_id and role = 'other'; + if others >= 4 then + raise exception 'subject_limit_reached'; + end if; + end if; + return new; +end; +$$; +commit; diff --git a/frontend/supabase/migrations/20260925040000_deleted_report_release.sql b/frontend/supabase/migrations/20260925040000_deleted_report_release.sql new file mode 100644 index 00000000..ea0e85a8 --- /dev/null +++ b/frontend/supabase/migrations/20260925040000_deleted_report_release.sql @@ -0,0 +1,64 @@ +begin; + +-- A subject deletion cascades through reports/jobs before a worker can observe +-- the missing subject. Release the reservation in that same transaction. +create or replace function public.release_deleted_report_reservation() +returns trigger +language plpgsql +security definer +set search_path = '' +as $$ +declare + released record; +begin + -- A ready document has already been delivered, even if the worker is between + -- its durable ready write and billing completion. Do not refund that window. + if old.status = 'ready' then return old; end if; + -- Account removal may already have cascaded away the wallet itself. + if not exists (select 1 from public.profiles where id = old.user_id) then return old; end if; + select * into released from public.release_usage(old.user_id, old.request_id::text, 'report_deleted'); + -- Completed usage remains charged. Any other failure must roll back deletion + -- rather than orphan a paid reservation with no durable job left to refund it. + if not released.success and released.error_code is distinct from 'request_completed' then + raise exception 'report_reservation_release_failed'; + end if; + return old; +end; +$$; + +revoke all on function public.release_deleted_report_reservation() from public, anon, authenticated; +create trigger personal_reports_release_before_delete +before delete on public.personal_reports +for each row execute function public.release_deleted_report_reservation(); + +-- Match the worker's job -> report lock order before the cascading delete. +-- Definer is needed only for job locks; the caller identity/ownership check is +-- explicit and no arbitrary refund operation is exposed to authenticated. +create or replace function public.delete_chart_subject(p_id uuid) +returns void +language plpgsql +security definer +set search_path = '' +as $$ +declare + owner uuid := (select auth.uid()); +begin + if owner is null then raise exception 'chart_subject_unauthenticated'; end if; + perform 1 from public.chart_profiles + where id = p_id and user_id = owner and role = 'other' for update; + if not found then raise exception 'chart_subject_not_found'; end if; + perform 1 from public.personal_report_jobs j + join public.personal_reports r on r.user_id = j.user_id and r.request_id = j.request_id + where r.user_id = owner and r.chart_profile_id = p_id + order by j.id for update of j; + perform 1 from public.personal_reports + where user_id = owner and chart_profile_id = p_id order by id for update; + delete from public.chat_sessions where user_id = owner and chart_profile_id = p_id::text; + delete from public.personal_reports where user_id = owner and chart_profile_id = p_id; + delete from public.chart_profiles where id = p_id and user_id = owner and role = 'other'; +end; +$$; +revoke all on function public.delete_chart_subject(uuid) from public, anon; +grant execute on function public.delete_chart_subject(uuid) to authenticated; + +commit; diff --git a/frontend/tests/adopted-birth-date.test.ts b/frontend/tests/adopted-birth-date.test.ts index 4f4d0df5..14dfd3ab 100644 --- a/frontend/tests/adopted-birth-date.test.ts +++ b/frontend/tests/adopted-birth-date.test.ts @@ -33,8 +33,9 @@ test("chart library summary uses effective date while its editable profile keeps assert.equal(effectiveBirthDate(editable), "2000-06-14"); assert.equal(editable.date, "2000-06-15"); assert.equal(effectiveBirthDate({ ...editable, birthTimeStatus: "reported" }), "2000-06-15"); - const component = readFileSync(new URL("../src/components/chart-library-panel.tsx", import.meta.url), "utf8"); - assert.match(component, /effectiveBirthDate\(record\.profile\) \|\| "出生日期待补全"/); + // 原值 / 新值 / 原因:旧面板 record.profile / 人物页 selected.profile / 退役入口,保留有效日期合同。 + const component = readFileSync(new URL("../src/components/people/people-page.tsx", import.meta.url), "utf8"); + assert.match(component, /effectiveBirthDate\(selected\.profile\) \|\| "出生日期待补全"/); }); test("adopted chart reuses the persisted candidate calculation offset, not new DST rules", async () => { diff --git a/frontend/tests/chart-library-other-profile.test.ts b/frontend/tests/chart-library-other-profile.test.ts index da18aed3..8af09300 100644 --- a/frontend/tests/chart-library-other-profile.test.ts +++ b/frontend/tests/chart-library-other-profile.test.ts @@ -6,7 +6,8 @@ const route = readFileSync(new URL("../src/app/api/chart-profiles/route.ts", imp test("other chart saves do not require the owner's rectification state", () => { assert.match(source, /function missingOtherProfileStep\(profile: Profile\)/); - assert.match(source, /if \(missingOtherProfileStep\(nextProfile\)\)/); + // 原值 / 新值 / 原因:nextProfile 校验 / 人物页 draft 校验 / 替代保存入口仍不依赖本人校正。 + assert.match(source, /if \(missingOtherProfileStep\(draft\) \|\| saving\) return;/); assert.doesNotMatch(source, /saveOtherChart[\s\S]{0,500}missingProfileStep\(nextProfile\)/); }); @@ -16,20 +17,22 @@ test("other chart save fails closed when cloud sync fails", () => { // `localStorage.setItem(chartLibraryStorageKey(accountId), JSON.stringify(next))`, // delete copy "已从本地星盘库删除;云端同步失败". Cloud write failure is now failure. assert.doesNotMatch(source, /let cloudSaved = false/); - assert.match(source, /await saveCloudChartProfile\(record\)/); - assert.match(source, /async function saveOtherChart[\s\S]{0,1600}catch \{[\s\S]{0,180}保存失败,请重试/); + // 原值 / 新值 / 原因:旧 helper 保存 / 人物页 POST/PUT 与 catch / 死面板退役,云失败仍不得伪成功。 + assert.match(source, /method: editingOther \? "PUT" : "POST"/); + assert.match(source, /async function saveOther\([\s\S]{0,1700}catch \{[\s\S]{0,180}保存失败,请重试/); assert.doesNotMatch(source, /localStorage\.setItem\(chartLibraryStorageKey/); assert.doesNotMatch(source, /已保存到本地星盘库/); - assert.match(source, /已保存到云端星盘库/); - assert.match(source, /async function deleteOtherChart[\s\S]{0,500}await deleteCloudChartProfile\(recordId\)/); - assert.match(source, /async function deleteOtherChart[\s\S]{0,700}catch \{[\s\S]{0,160}删除失败,请重试[\s\S]{0,80}return;/); + assert.match(source, /invalidateSubjectCatalog\(\);\s*await load\(\)/); + assert.match(source, /async function confirmDelete[\s\S]{0,500}method: "DELETE"/); + assert.match(source, /async function confirmDelete[\s\S]{0,700}if \(!response\.ok\) \{[\s\S]{0,160}删除失败,请重试[\s\S]{0,80}return;/); assert.doesNotMatch(source, /已从本地星盘库删除/); }); test("adding another chart waits for the user to choose a relationship type", () => { assert.doesNotMatch(source, /async function saveOtherChart[\s\S]{0,1400}await draftSynastryQuestionFromChart\(/); - assert.match(source, /请选择关系类型后点击“用于合盘”。/); - assert.match(source, /用于合盘/); + // 原值 / 新值 / 原因:保存提示让用户再选关系 / 显式合盘按钮 / 人物页保存不自动启动合盘。 + assert.match(source, /和我合盘/); + assert.doesNotMatch(source, /async function saveOther\([\s\S]{0,1700}draftSynastryQuestionFromChart\(/); assert.match(source, /\/api\/synastry/); }); @@ -61,8 +64,9 @@ test("synastry selection exposes a pending state while the evidence packet is co assert.match(source, /const \[synastryPendingId, setSynastryPendingId\] = useState\(null\);/); assert.match(source, /setSynastryPendingId\(record\.id\);/); assert.match(source, /finally \{\s*setSynastryPendingId\(null\);\s*\}/); - assert.match(source, /disabled=\{synastryPendingId !== null\}/); - assert.match(source, /synastryPendingId === selectedOther\.id \? "正在计算合盘\.\.\." : "用于合盘"/); + // 原值 / 新值 / 原因:死面板 disabled/按钮 spinner / hook 重入门与既有通知 / 锁实际合盘请求入口。 + assert.match(source, /if \(synastryPendingId\) return;/); + assert.match(source, /正在计算基础合盘证据,请稍候。/); }); test("relationship intent selects domain-specific evidence instead of treating every pairing as romance", () => { @@ -112,28 +116,27 @@ test("current chart selection is local and does not overwrite the owner's profil }); test("the chart library starts as a list and only renders a form after a view change", () => { - const charts = readFileSync(new URL("../src/components/chart-library-panel.tsx", import.meta.url), "utf8"); - const lastReturn = charts.lastIndexOf("return ("); - const list = charts.slice(lastReturn); - assert.match(charts, /useState\(CHART_LIBRARY_LIST_VIEW\)/); + // 原值 / 新值 / 原因:旧 ChartLibraryView 列表合同 / 人物页 editing 门控 / 删除死面板,保留列表先行合同。 + const charts = readFileSync(new URL("../src/components/people/people-page.tsx", import.meta.url), "utf8"); + const list = charts.slice(charts.indexOf('className="people-archive-list"'), charts.indexOf('className="people-archive-detail"')); + assert.match(charts, /\[editing, setEditing\] = useState\(false\)/); assert.doesNotMatch(list, /ChartProfileForm|
{ - const charts = readFileSync(new URL("../src/components/chart-library-panel.tsx", import.meta.url), "utf8"); - const selfView = charts.slice(charts.indexOf('if (view.kind === "self")'), charts.indexOf('if (view.kind === "add"')); - assert.doesNotMatch(selfView, /删除/); + // 原值 / 新值 / 原因:旧 selfView/合盘历史面板 / 人物页本人不可删与本人合盘绑定 / 替代入口覆盖,不保留死组件。 + const charts = readFileSync(new URL("../src/components/people/people-page.tsx", import.meta.url), "utf8"); + assert.match(charts, /selected\.role === "self" \?

本人不能删除/); // 原值: the detail offered 设为默认. // 新值: that action is gone. // 原因: D11 removes "set as default". assert.doesNotMatch(charts, /设为默认/); - assert.match(charts, /用于合盘/); - assert.match(charts, /synastryHistoryForPartner/); - assert.match(charts, /partnerHistory\.length > 0 &&/); + assert.match(charts, /和我合盘/); + assert.match(source, /startNewChat\(\{ subjectId: "self" \}\)/); + assert.match(charts, /pendingDelete\?\.id === selected\.id && selected\.role === "other"/); assert.match(source, /partnerChartId: record\.id/); }); diff --git a/frontend/tests/chart-page-hook.test.ts b/frontend/tests/chart-page-hook.test.ts index f068a4b3..e9475420 100644 --- a/frontend/tests/chart-page-hook.test.ts +++ b/frontend/tests/chart-page-hook.test.ts @@ -3,6 +3,7 @@ import { readFileSync } from "node:fs"; import React, { useLayoutEffect } from "react"; import test from "node:test"; import { useChartPage } from "../src/hooks/use-chart-page.ts"; +import { bindCurrentSubjectAccount, resetSubjectCatalogForTests } from "../src/lib/current-subject.ts"; import { assembleChartView } from "../src/lib/chart-view-load.ts"; import type { ChartViewLayer, EngineCallResult } from "../src/lib/chart-view-engine.ts"; import type { ChartViewOk } from "../src/lib/chart-view-contract.ts"; @@ -24,6 +25,10 @@ function response(result: Awaited>) { return new Respo function deferred() { let resolve!: (value: T) => void; let reject!: (reason: unknown) => void; const promise = new Promise((a, b) => { resolve = a; reject = b; }); return { promise, resolve, reject }; } async function harness(run: (h: ReturnType, current: () => ReturnType, assigned: string[]) => Promise) { resetSecondaryPageDataForTests(); + // 原值:未绑定账户,直接挂载;新值:隔离目录状态并绑定虚构账户。 + // 原因:D3 请求必须等待目录校验完成;本组仍验证校验后的超时/401/429/并发,不绕过产品闸门。 + resetSubjectCatalogForTests(); + bindCurrentSubjectAccount("synthetic", []); const originalFetch = globalThis.fetch; const originalTimeout = AbortSignal.timeout; const h = createClientLifecycleHarness(); @@ -34,7 +39,8 @@ async function harness(run: (h: ReturnType, const mount = h.render.bind(h); h.render = () => mount(React.createElement(Probe)); try { await run(h, () => value, assigned); assert.deepEqual(h.errors, []); } - finally { await h.close(); globalThis.fetch = originalFetch; AbortSignal.timeout = originalTimeout; resetSecondaryPageDataForTests(); } + // 原值:只清图盘缓存;新值:卸载后同时清目录状态;原因:不让上一例账户 ready 泄漏到下一例。 + finally { await h.close(); globalThis.fetch = originalFetch; AbortSignal.timeout = originalTimeout; resetSecondaryPageDataForTests(); resetSubjectCatalogForTests(); } } test("chart first request times out with client_timeout and timedOut copy", async () => harness(async (h, state) => { diff --git a/frontend/tests/chart-page-view.test.tsx b/frontend/tests/chart-page-view.test.tsx index 1d384c36..3b2f23a3 100644 --- a/frontend/tests/chart-page-view.test.tsx +++ b/frontend/tests/chart-page-view.test.tsx @@ -442,6 +442,6 @@ test("the (app) layout mounts one sidebar for home and the four secondary routes assert.equal(layout.match(/ { const pageFile = readFileSync(new URL("../src/app/(app)/page.tsx", import.meta.url), "utf8"); // 原值: 从 page.tsx 切 `if (previewMode)` 到 `await sessionListReady`。 // 新值: 预览分支在 home-bootstrap-run.ts;首页把 sessionListReady / sessionListBoot 交给它。 - // 原因: 预览仍不写会话 URL,`/` 仍是静态路由上的客户端读,不在首页里 fetchSessions。 + // 原因: 预览仍不写会话 URL,`/` 仍是静态路由上的客户端读,不在首页里 fetchSessions;等待必须跟随当前 Provider generation。 const preview = sourceBetween( bootstrapRun, "if (previewMode) {", - "await deps.sessionListReady;", + "await (deps.waitForSessionList?.() ?? deps.sessionListReady);", ); assert.doesNotMatch(preview, /writeSessionUrl|persistLoginSessionReturn|readLoginSessionReturn/); - assert.match(bootstrapRun, /await deps\.sessionListReady;/); + assert.match(bootstrapRun, /await \(deps\.waitForSessionList\?\.\(\) \?\? deps\.sessionListReady\);/); assert.match(bootstrapRun, /deps\.sessionListBoot\(\)/); assert.match(pageFile, /sessionListReady,/); assert.match(pageFile, /sessionListBoot,/); diff --git a/frontend/tests/composer-isolation-contract.test.ts b/frontend/tests/composer-isolation-contract.test.ts index c04cebea..c6d663bc 100644 --- a/frontend/tests/composer-isolation-contract.test.ts +++ b/frontend/tests/composer-isolation-contract.test.ts @@ -5,6 +5,17 @@ import test from "node:test"; import { homeSurface as pageSource } from "./home-surface.ts"; const composerSource = readFileSync(new URL("../src/components/chat-composer.tsx", import.meta.url), "utf8"); const draftStoreSource = readFileSync(new URL("../src/lib/composer-draft.ts", import.meta.url), "utf8"); +// 原值:homeSurface 连同独立 /people 表单一起检查聊天根草稿。 +// 新值:Home 及其业务 hooks;原隔离断言不变。 +// 原因:人物编辑表单的局部 draft 不属于聊天根状态,不能把它误判为击键重渲染 Home。 +const chatOwnerSource = [ + "../src/app/(app)/page.tsx", + "../src/hooks/use-session-management.ts", + "../src/hooks/use-consultation-run.ts", + "../src/hooks/use-profile-onboarding.ts", + "../src/hooks/use-rectification-surface.ts", + "../src/hooks/use-synastry.ts", +].map((path) => readFileSync(new URL(path, import.meta.url), "utf8")).join("\n"); function sourceBetween(source: string, startMarker: string, endMarker: string) { const start = source.indexOf(startMarker); @@ -16,7 +27,7 @@ function sourceBetween(source: string, startMarker: string, endMarker: string) { test("keystrokes never re-render the chat page root", () => { // Given: the composer text used to live in page-level React state. - assert.doesNotMatch(pageSource, /const \[draft, setDraft\] = useState/); + assert.doesNotMatch(chatOwnerSource, /const \[draft, setDraft\] = useState/); assert.doesNotMatch(pageSource, /const \[draftTheme, setDraftTheme\] = useState/); assert.doesNotMatch(pageSource, /const \[draftEntrypoint, setDraftEntrypoint\] = useState/); @@ -38,7 +49,7 @@ test("the textarea lives in an isolated composer that owns the draft subscriptio const composerElement = sourceBetween(pageSource, ""); assert.doesNotMatch(composerElement, /\bdraft=/); assert.doesNotMatch(pageSource, /