feat(report): birth-sky report cover bound to the report's birth fingerprint (F1–F3)

Product decision A (TASK-birth-sky-followup-20260928):
- every generation path writes subject.birthFingerprint (stored-row
  fingerprint, same function as the chart page's profileFingerprint);
  never into the evidence bundle the model reads
- GET /api/reports/:id compares on the server and returns only
  report.coverSubject (subject id or null); legacy reports never get one
- report page shows the night cover above the body, paper cover alone on
  the first printed A4 page; silent on any failure, no waiting state
- TS / JSON schema / Python contract accept the optional field
- BLOCKED T5 struck through; DESIGN §17, CHANGELOG, checklist, PROGRESS

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01N4f2nya58RoRu4yEmJgRGE
This commit is contained in:
Jesse_Chen
2026-09-28 21:47:44 +08:00
co-authored by Claude Opus 5.5
parent 14ef18ac24
commit a9a917d50c
27 changed files with 739 additions and 27 deletions
@@ -109,3 +109,38 @@ JS 的 +773 B 是首页为 T6 引入的 `useBirthSkyReveal`、`startBirthSkyReve
- 无 `.venv`:Python 用系统 `python3`。
- 无头 Chrome 的 `--screenshot` 在本机对这页时好时坏(挂起),海报改用 `canvas.toDataURL` + `--dump-dom` 取出,是导出 PNG 的真实像素。
- 未推送、未部署。
---
## 追加单(`TASK-birth-sky-followup-20260928`)
- 任务书:`docs/tasks/TASK-birth-sky-followup-20260928.md`(staging-docs)。产品授权直接执行(Claude 子代理)。
- 基线:开工 `git fetch origin --prune` 后 `origin/staging` 仍是 `d670521f`,无需 rebase;本分支在前三个提交(`d277cbd8`、`028aef27`、`14ef18ac`)之上再加两个提交。
- BUG 编号:开工时最大 BUG-1079;新功能,未开 BUG。
### F1 · 报告写入出生资料指纹
- 新模块 `frontend/src/lib/report-birth-binding.ts`:`subjectBirthFingerprint(row)` = `resolveServerOwnedChartBirth(row).fingerprint`。
- **与星盘页同一种算法、同一种输入**:星盘页的 `profileFingerprint` 来自 `prepareChartViewProfile`,它取的是**存储行**的指纹(`stored.fingerprint`,在内存补全时区之前)。报告两处加载点(`POST /api/reports` 与 worker 的 `loadReportWorkerProfile`)都先 `loadSubjectBirth` 取存储行、再补时区,所以在补全之前用 `stampReportBirthFingerprint(resolved.row, filled)` 把指纹挂在内存行的 `report_birth_fingerprint` 上(只在服务端内存里,不落库、不进引擎输入)。测试证明:缺时区的行,补全前后指纹不同,补全前的等于星盘页。
- 三条生成路径都写 `subject.birthFingerprint`:
- 路由内联生成(`resolveReportCreate` → `generatePersonalReport`);
- worker writer 路径(`generateWriterReport` → `generatePersonalReport`,单次调用与分章两种组装都在 guard 之前写入);
- worker 长报告路径(`generatePersonalReportLongform` → `buildLongformCoverDocument`)。
- 指纹**不进证据包**(bundle 会被发给模型),只在组装后的文档上写:`generatePersonalReport` 新增可选 `birthFingerprint`,在 `applyReportGuard` 之前写到候选文档上,随后照常走最终 server parse。
- Schema:TS `subjectSchema`(v1/v2 共用)、`contracts/personal-report/report-document.v1|v2.schema.json`、Python `scripts/personal_report_contract.py` 都加可选 `birthFingerprint`(`^[0-9a-f]{32}$`)。旧文档不含该字段照常解析;格式不对的拒绝。
- 测试:`frontend/tests/report-birth-binding.test.ts`(7 条:同算法 / 补时区前取;worker 加载器写入;三条路径接线;长报告写入与缺省;旧文档解析与坏格式;coverSubject 判定;GET 路由在服务端比对)、`personal-report-api.test.ts` 追加「inline generation writes subject.birthFingerprint」、`personal-report-generation-v2.test.ts` 追加「single-call and sectioned generation write subject.birthFingerprint only when given」(并断言分章调用发给模型的 bundle 不含指纹)、`tests/test_personal_report_contract.py` 追加 1 条。
### F2 · 服务端比对
- `reportView(row, job, failure, sections, coverSubject?)`:新增第 5 个可选参数,传入时输出 `coverSubject`。`resolveReportRead` 只在 `ready` 分支计算:读 `row.reportDocument.subject.birthFingerprint` → 没有就直接 `null`(不查库)→ 有则调新依赖 `loadCurrentBirthFingerprint(userId, subjectId)`(`subjectId` = `chart_profile_id ?? "self"`)→ 相等给 subject id,否则 `null`;加载器抛错(不是自己的人物、已删除、读不到)一律 `null`。
- `GET /api/reports/:id` 用 `loadSubjectBirth(supabase, userId, subjectId)`(与星盘页同一个人物加载器,按 `user_id` 限定)取存储行再算指纹。
- 响应里不含指纹:`reportDocument` 走 `projectOrdinaryReportDocument`,本来就不带 `subject`。
- 测试(`personal-report-api.test.ts` 追加 4 条):一致(本人 `self`、家人给人物 id,序列化响应不含指纹、出生日期、经纬度、时区、地名);不一致;缺字段(且不查库);非本人(加载器抛错 / 返回空)。
### F3 · 报告封面
- `classifyReportEnvelope` 在 ready 态读 `report.coverSubject`(非空字符串才带上);`PersonalReportPage` 在 `.personal-report-reader-body` 正文最上方渲染 `<ReportSkyCover subjectId=…/>`(`next/dynamic`,`ssr: false`)。
- `components/birth-sky/report-sky-cover.tsx` + `report-sky-cover.css`:挂载后(正文已上屏)才取数;`lib/birth-sky/report-cover.ts` `loadReportSkyCover` 取天空并用同一个 `renderBirthSkyPng` 画 `night`(屏幕)与 `paper`(打印)两张;任何失败返回 `null`,组件什么也不渲染。无按钮、无占位、无等待态。
- 打印:`.report-sky-cover-screen` 隐藏、`.report-sky-cover-print` 显示;`.report-sky-cover { break-after: page }`,图高 246mm 居中(A4 减报告 `@page` 边距后版心 186 × 270mm,3:4 在 186mm 宽时 248mm 高)。
- `BLOCKED.md` 的 T5 条目已划掉并注明「→ 已解除:产品 2026-09-28 选 A」。
- 测试:`frontend/tests/report-sky-cover.test.ts`(5 条:coverSubject 有 / 无;封面在正文之上且懒加载、无按钮无等待;night + paper 两张;取数失败 / 空 / 画图失败 / abort 都返回 null;打印 CSS 的 paper 与分页)。