Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017eEAG8HD3mm8gsKXgk8uU8
14 KiB
14 KiB
TASK · 星盘档案(多人物)P1:人物统一 + 标题人名切换 + 功能跟人走(2026-09-24)
原型(形态参考,以本文决策为准,原型里的侧栏顶部切换、关系、「全部 / 只看当前人」筛选、首页小字均已被产品否掉):https://claude.ai/artifact/8EFiEZBbD8JnToHfdwZej2
基线
origin/staging = b9756176。分支codex/people-archive-p1-20260924。- 串行(本单最后做):同日待领取单与本单大面积重叠——
TASK-home-slow-network-20260924(page.tsx)、TASK-chart-ephemeris-fixes-20260924(/api/ephemeris、星盘空状态?settings=chart)、TASK-report-reader-actions-20260924(报告列表)、TASK-chart-dasha-western-redesign-20260924(星盘页)。这四单全部合入后再开工,基线以开工时origin/staging为准。 - 动表:必须真跑
npm run test:db --prefix frontend(需 Docker);数据库迁移部署前要先执行 Migrate Staging Database。
事故实证(按 origin/staging e0db23f4 调查)
数据
- 户主资料在
public.profiles(带reported_birth_time不可改、active_birth_*只由校正 RPC 写入等触发器守卫)。 - 其他人在
public.chart_profiles(20260718100000_repair_missing_chart_profiles.sql):id, user_id, role('self'|'other'), profile jsonb。出生资料全在无校验的 jsonb 里,没有 reported / active 区分和守卫;/api/chart-profiles(GET / POST、[id]PUT / DELETE)只校验"是对象 + 属于本人";无数量上限。关系chartRelationship只在 JSON 里。 - 户主还有一份
chart_profiles role='self'镜像:use-profile-onboarding.tspersistProfile写入,但读取时被chartLibraryFromCloudOthers丢掉、由buildSelfChartRecord从profile重建——写而不读。
各功能对"他人"的支持
| 功能 | 现状 | 证据 |
|---|---|---|
| 普通对话 | ✅ 真按所选人排盘(BUG-997/1000) | consultation-subject-resolver.ts resolveConsultationSubject;/api/consult loadOwnedChartProfile;首问后 subject_locked |
| 生时校正 | ❌ 只读 / 只写 profiles |
rectification-agentic/v9/case-service.ts loadV9RectificationProfile;RPC accept_/confirm_agentic_rectification_candidate_for_case_v2 |
| 个人报告 | ❌ chartProfileId 收了、校验了、存了,不用于取资料 |
api/reports/route.ts、personal-report-route-core.ts;客户端 buildPersonalReportCreateRequest 不发 |
| 星盘页 | ❌ 只 profiles |
chart-view-service.ts loadChartView → prepareChartViewProfile → resolveServerOwnedChartBirth |
| 星历 | ❌ 只 profiles |
api/ephemeris/route.ts globalBirthProfileFromAccountRow |
| 今日星语 | ❌ 服务端丢弃请求体读 profiles |
api/daily-starlanguage/route.ts |
| 合盘 | ⚠️ 只能「我 × 他人」 | api/synastry/route.ts |
遗留「当前星盘 / 设为默认」造成的错位(Bug)
page.tsxactiveChartId(localStoragejyotisha_active_chart:${accountId},home-cloud-sync.tsactiveChartStorageKey)+ 一个 effect 把页面级profile换成别人的;makeDefaultChart同样setProfile(record.profile)。服务端从不读它,但它仍喂给:use-synastry.tsbuildSynastryQuestion(profile, …)(合盘问题里"我的资料"写成别人)、首页今日星语卡的可用判断、use-consultation-run.ts的咨询模式 / 完整度门、use-rectification-surface.tschartSnapshotForSession(activeChartId, …)(校正会话标签显示别人名字、实际算户主)、个人资料昵称。设置里「当前默认」徽章又写死在户主行。
根因
- 多人物只在对话一条链上打通(BUG-997/1000 明确"报告、每日星语、生时校正另立任务");其余功能都直接读
profiles。 - 他人资料没有与户主同级的服务端真值与守卫,不能直接接到报告 / 星盘这些"结论型"功能上。
- 「设为默认」是旧模型残留,只改客户端全局
profile,与服务端真值分叉。
决策记录(产品 2026-09-24)
- D1 每个星盘档案 = 一个人,户主本人也是其中一个,统一列表、统一操作;户主不可删。
- D2 最多 5 人(含本人):他人最多 4 个;数据库与接口双重限制,第 6 个时添加按钮禁用并提示「最多保存 5 个人的星盘」。
- D3 不要「关系」字段:档案只有称呼 + 出生资料(日期、时间与精度、地点 / 时区、岁差)。UI 删关系选择;存量 JSON 里的
chartRelationship忽略、不迁移。 - D4 切换入口 = 标题旁的人名:复用现有对话标题旁的人名组件(
ChatProfilePicker),扩成全局「当前人物」切换器SubjectSwitcher,同一组件放在:对话标题旁、星盘 / 星历 / 我的报告 / 星盘档案页标题旁。**侧栏顶部不放切换器。**切换后全局生效。- 在已发过消息的对话里点人名切到别人 = 切换当前人物 并新建一个空对话(首问锁定规则不变);空对话里点 = 直接改绑。
- D5 全功能跟随当前人物:星盘页、星历(本命叠加)、生成个人报告、今日星语 / 今日节奏、新建对话默认人物 = 当前人物(推翻 BUG-1000「新建对话固定本人」)。
- D6 历史只显示当前人物的:侧栏会话列表、报告列表都只列当前人物的记录;切人,列表跟着换。列表项不标人名,不做"全部"筛选。
- D7 计费:给任何人对话 / 报告 / 校正都按正常规则扣点,不区分本人与他人。
- D8 档案管理 = 侧栏独立页「星盘档案」
/people(与星盘 / 星历 / 我的报告并列,○ Static),列表 + 详情;详情里有「和 TA 对话 / 看星盘 / 生成报告」快捷入口和「和我合盘」(沿用现有合盘能力)。设置弹窗删除「星盘资料」面板;?settings=chart(若 chart-ephemeris-fixes T3 已加)改为跳/people。 - D9 删除一个人 = 连同他的对话与报告一起删除,删除前显示「将同时删除 N 个对话和 M 份报告,无法恢复。」;服务端事务内删除,删除后服务器不保留其出生资料。当前人物被删 → 回到本人。
- D10 首页开场不加任何"在看某某"提示,问候保持原样(今日星语内容随当前人物变)。
- D11 取消「设为默认」与
activeChartId:删除 localStorage 键与"替换页面profile"的 effect;当前人物只作为请求参数传给服务端,任何客户端代码不得再用他人资料覆盖页面级profile。 - D12 生时校正本单不跟人(P2):当前人物不是本人时,生时校正入口禁用,文案「生时校正暂时只支持本人」。任意两人合盘也在 P2。
- D13(架构,Claude 决定):P1 不把户主迁出
profiles——校正 RPC、resolveServerOwnedChartBirth、首页引导都依赖它,迁移风险与本单收益不成比例。产品层面"户主也是其中一个人"通过统一的服务端解析实现:新增唯一入口resolveSubjectBirth({ userId, subjectId }):self→ 现有profiles真值链;其他 →chart_profiles新增的类型化列(见 T1)。户主的chart_profiles role='self'镜像停止写入并在迁移里删除。
硬红线
- 服务端永不信任客户端出生资料;所有功能的出生资料只来自
resolveSubjectBirth,按user_id校验所有权,失败 fail-closed(不回落本人、不扣点),沿用consultation-subject-resolver的失败码。 - 他人类型化列具备与户主同级的守卫:
reported_*写入后不可改;active_*只能由(P2 的)校正流程写,P1 客户端与/api/chart-profiles不得写active_*/birth_time_status = accepted|confirmed。 - 解析函数只一处实现;星盘 / 星历 / 报告 / 今日星语 / 对话都调用它,不得各写一份读取。
- 缓存键(星盘页
secondary-page-data身份、服务端引擎缓存、今日星语指纹)必须包含subjectId,切人不得闪现上一个人的盘。 page.tsx不得净增行、Home()不新增 useState / useRef;jyotish_api_server.py不增长。/、/chart、/ephemeris、/people为○ Static;首屏 gzip ±2%。- 隐私:fixture / 测试 / 文档只用虚构人物;日志不记姓名、生日、坐标。
- 既有断言三栏;测试总数不低于基线;不改 workflow / DNS。
任务分解
T1 · 数据层(迁移 + 服务端)
- 迁移:
chart_profiles增类型化列(name, birth_date, reported_birth_time, birth_time_source, birth_time_period, declared_window_start/end, uncertainty_before/after_minutes, latitude, longitude, timezone_id, timezone_offset, birth_place_label/type/provider/provider_id, ayanamsa, active_birth_time, active_birth_date, active_birth_timezone_offset, birth_time_status)+ 守卫触发器(仿guard_birth_time_journey/zz_guard_adopted_birth_date);从现有profilejsonb 回填;删role='self'行;每用户role='other'行数 ≤ 4 的约束(触发器)。jsonb 列保留只读一版以便回滚,下一轮再删。 - 删除级联:
delete_chart_subject(id)RPC(事务内删chat_sessions where chart_profile_id = id、personal_reports where chart_profile_id = id、该档案),与计数查询chart_subject_usage(id)。 frontend/src/lib/subject-birth.ts(新)resolveSubjectBirth;/api/chart-profiles改读写类型化列并做字段校验(日期 / 时间 / 坐标 / 时区合法、长度上限),POST 超限返回 409subject_limit_reached。
验收:npm run test:db 新增用例——回填后类型化列与原 JSON 一致;第 5 个他人插入被拒;reported_birth_time 更新被拒;客户端写 active_* 被拒;删除 RPC 连带删会话与报告且只删本人的;跨用户读写被 RLS 拒绝。
T2 · 功能跟人走(服务端 + 请求参数)
/api/chart-view、/api/ephemeris、/api/reports(POST 用chartProfileId取资料)、/api/daily-starlanguage、/api/consult(改为经resolveSubjectBirth)都接受subjectId(缺省self),经resolveSubjectBirth取资料。- 列表:
GET /api/sessions?subject=、GET /api/reports?subject=服务端过滤(self匹配chart_profile_id is null or = 'self'的存量行)。 - 生时校正入口在非本人时禁用(D12)。
验收:路由测试——各接口 subjectId=他人 时引擎收到的是该人的出生资料(fixture 用虚构人物);他人不存在 / 不属于本人 → fail-closed 且不扣点;列表按人过滤正确;存量本人会话在 subject=self 下可见。
T3 · 当前人物与切换器(前端)
frontend/src/lib/current-subject.ts(新):当前人物 id(localStorage 按账户存,读到无效 id 回落self),订阅式 store,不进Home()状态。SubjectSwitcher(由ChatProfilePicker抽出):对话标题旁、星盘 / 星历 / 我的报告 / 星盘档案页标题旁;D4 的锁定对话行为;列表最多 5 人。- 删除
activeChartId、activeChartStorageKey、makeDefaultChart、替换profile的 effect、「当前默认」徽章;use-synastry.ts改为服务端取"我"的资料;use-rectification-surface.ts会话绑定固定self。 - 新建对话默认人物 = 当前人物(
NEW_CHAT_SUBJECT_ID改为读 store)。
验收:切人后星盘页 / 星历 / 报告列表 / 侧栏会话同时换人且不闪旧盘;锁定对话里切人 → 新空对话;grep -rn "activeChartId\|activeChartStorageKey\|makeDefaultChart" frontend/src = 0;合盘问题文本中"我"为本人。
T4 · 星盘档案页 /people(前端)
- 侧栏新增入口;列表(本人第一)+ 详情(称呼、出生时间两格:填报时间 / 排盘时间、地点与岁差、快捷入口、和我合盘、删除);添加 / 编辑表单复用
ChartProfileForm(删关系字段);第 5 人上限提示;删除确认显示计数(D9)。 - 设置弹窗删除「星盘资料」面板与相关导航;
?settings=chart→/people。
验收:/people ○ Static;添加第 5 个他人(总数第 6)被禁用;删除确认计数来自 chart_subject_usage;删除当前人物后回到本人;设置弹窗不再有星盘资料。
T5 · 记录
docs/BUG_HISTORY.md:BUG-1030 「设为默认」/activeChartId覆盖页面profile致合盘问题与校正会话标签错人(关联 BUG-466、997、1000)。其余为功能,进 CHANGELOG。CONTEXT.md术语:新增「人物 / 星盘档案 / 当前人物」,替换「当前星盘」。- DESIGN(星盘档案页、SubjectSwitcher、设置弹窗面板变更)、VOICE(「最多保存 5 个人的星盘」「生时校正暂时只支持本人」「将同时删除 N 个对话和 M 份报告,无法恢复。」)、CHANGELOG、PROGRESS、
docs/testing/people-archive-p1-20260924.md(加 4 个虚构人物 → 第 5 个被拦;切人后四个页面同步;锁定对话切人开新对话;删人连带删除;本人不可删;校正入口禁用;旧账号升级后资料不丢)。
让步顺序
- 类型化列回填遇到脏 JSON(缺字段)时,允许该行保持"资料不完整"状态并在档案页提示补全,不得猜值。
- 若
/api/consult改走resolveSubjectBirth牵动过大,允许 consult 暂保留现有 resolver,但其读他人资料必须改读类型化列。 - 列表服务端过滤若与
SessionListProvider分页冲突,允许按人物分开缓存键重新拉取。 - 不得让步:红线 1、2、3;D11(清除 activeChartId);D9 连带删除。
开工前置命令
git fetch origin --prune
git log --oneline origin/staging | head -20 # 确认四个前置单已合入
git worktree add -b codex/people-archive-p1-20260924 .worktrees/people-archive-p1-20260924 origin/staging
cd .worktrees/people-archive-p1-20260924/frontend
./node_modules/.bin/tsc --noEmit && npm run lint && npm test 2>&1 | tail -5
npm run test:db # 需 Docker;无 Docker 写 BLOCKED 并停在 T1 之前
BUG 编号
写单时最大 BUG-1020,1021–1028 已预留,1029 留给大运 / 西洋单备用;本单 BUG-1030,开工时核对。