diff --git a/CHANGELOG.md b/CHANGELOG.md index 8ef4973b..d3307d9a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,9 @@ # 印度占星 Skill 更新日志 +## 2026-10-21 — 同一对人可以再生成一份合盘 + +- 已经写好的合盘还在。再点「开始合盘」会新写一份,并再扣一次点数。正在写的那份仍打开原来的等待页,不重复扣。选人页在点数说明下写出已经合过几次、再生成会另扣多少点。Skill 版本不变(BUG-1408)。 + ## 2026-10-21 — 合盘两人顺序跟选人页一致 - 先选的人写在前面。等待页、报告标题、报告中心卡片和追问对话标题用同一个顺序。同一对已经有报告时,仍打开原来那份。Skill 版本不变(BUG-1407)。 diff --git a/docs/BUG_HISTORY.md b/docs/BUG_HISTORY.md index bb5908da..b58528ed 100644 --- a/docs/BUG_HISTORY.md +++ b/docs/BUG_HISTORY.md @@ -37071,7 +37071,7 @@ - 最近更新:2026-10-21 - 影响面:合盘等待页、报告标题、报告中心卡片、追问对话标题 - 用户现象:选人页先选甲、再选乙。等待页是甲 × 乙。写完之后,报告标题、报告中心卡片和追问对话标题变成乙 × 甲。 -- 触发条件:先选的那个人,编号排在后选的那个人后面。同一对已经有一份报告时,仍打开原来那份,顺序是当时保存的顺序。 +- 触发条件:先选的那个人,编号排在后选的那个人后面。已经写好的报告保留当时的顺序。再生成一份时按这次的选人顺序(BUG-1408)。 - 根因:去重用的一对编号按编号从小到大存放。生成时把较小的编号当成甲、较大的编号当成乙。等待页读的是选人页当场记下的顺序,所以只有等待页是对的。 - 修复:创建时另存选人顺序(先选的人、后选的人)。生成按这个顺序加载两个人,报告标题、卡片摘要和追问标题都用这个顺序。去重用的编号排序不变。没有这份顺序的旧报告仍按原来的编号顺序。等待页本来就是选人顺序,没有改。 - 验证:`frontend/tests/synastry-display-order.test.ts`:先选的人编号更大时,标题和追问标题仍是甲 × 乙;等待页句子是甲在前。`frontend/tests/database-synastry-full-create.test.ts`:请求先是本人、后是另一人时,存下的顺序是本人在前,而去重编号仍是另一人在前。 @@ -37079,3 +37079,19 @@ - 相关记录:issue #85。 - 复发自:无。 - 修复版本:分支 `codex/synastry-staging-fix-20261021`。 + +## BUG-1408 | 同一对人不能再生成一份合盘 + +- 状态:resolved(待 staging 真机复测) +- 首次发现:2026-10-21(产品负责人) +- 最近更新:2026-10-21 +- 影响面:婚恋合盘的「开始合盘」、点数、之前的合盘 +- 用户现象:同一对人已经有一份写好的合盘,再点「开始合盘」会打开上一份,不能新写。 +- 触发条件:两人、出生资料和规则版本都没变,上一份状态是 ready。 +- 根因:创建时把 ready 和 generating 都当成可复用,并且数据库不允许同一对有第二份 ready。这是 #82 决策 E3。产品在 K6 推翻了复用这一部分。 +- 修复:只有 generating 才打开原来那份、不扣点。ready 时新建一份并再扣 `synastry.full`,旧报告保留。去掉只允许一份 ready 的索引。引擎结果仍落在原来的 `synastry_reports` 一行上,报告正文按新的一份重写。选人页在点数说明下写出已经合过几次、再生成会另扣多少点;正在生成时不写这句。 +- 验证:`frontend/tests/database-synastry-full-create.test.ts`:generating 时第二次仍是原 id、点数不变;写成 ready 后再建得到新 id、点数再减。`frontend/tests/database-synastry-full-report.test.ts`:同一对可以留下两份 ready。`frontend/tests/synastry-full-report.test.ts`:已有两份且不在生成中时,渲染出「这两人已经合过 2 次,再生成会另扣 19 点」。 +- 防复发:上述三条。不要把 ready 再当成复用,也不要把那条唯一索引加回来。 +- 相关记录:issue #85。推翻 #82 决策 E3 的复用部分。BUG-1407。 +- 复发自:无。 +- 修复版本:分支 `codex/synastry-staging-fix-20261021`。 diff --git a/docs/tasks/PROGRESS-synastry-staging-fix-20261021.md b/docs/tasks/PROGRESS-synastry-staging-fix-20261021.md index afb065d3..1fa37233 100644 --- a/docs/tasks/PROGRESS-synastry-staging-fix-20261021.md +++ b/docs/tasks/PROGRESS-synastry-staging-fix-20261021.md @@ -86,3 +86,27 @@ 全量 Python 快速门没有跑:这个工作树没有项目虚拟环境。质检定向测试用系统 Python 跑过。 未推 staging,未改 `.gitea/`。真机未做,上面的真机步骤都不得写成已通过。 + +## T11 允许重新生成(BUG-1408) + +- 已有 `ready` 报告时,「开始合盘」新建一份并再扣 `synastry.full`。旧报告留在「之前的合盘」。已有 `generating` 时仍打开那份等待页,不扣点。 +- 去掉 `personal_reports_synastry_pair_ready_uidx`,同一对可以有多份写好的报告。`synastry_reports` 的一对一行没动,引擎结果继续复用,正文按新报告重写。 +- 选人页在点数说明下,两人已有写好的合盘且没有正在生成时,显示「这两人已经合过 N 次,再生成会另扣 X 点」。N、X 来自这对人的 ready 份数和当前点数。 +- 真机(执行方未做,不得写成已通过):同一对人再点「开始合盘」,应进入一份新的等待页,点数再减一次。上一份仍在「之前的合盘」里。按钮下能看到已经合过几次。 + +## 断言变更(T11) + +| 测试 | 原值 | 新值 | 原因 | +| --- | --- | --- | --- | +| `synastry-full-create-route.test.ts` 同一对复用 | 已有 ready 时 status 200、`reused`、不扣点 | 只有 generating 复用,status 202、不扣点 | K6,BUG-1408。ready 不再复用 | +| `database-synastry-full-report.test.ts` 第二份 ready | 插入被 `personal_reports_synastry_pair_ready_uidx` 拒绝,count 为 1 | 第二份 ready 可以留下,count 为 2 | 旧报告要保留,不能再靠这个唯一索引挡住 | + +## 这一轮检查(T11) + +| 检查 | 结果 | +| --- | --- | +| `tsc --noEmit` | 0 错 | +| `npm run lint` | 0 error,122 warning,与基线相同 | +| 选人页渲染、创建路由 | 通过,0 cancelled。复用断言从 ready 的 200 改为 generating 的 202 | +| 合盘创建真库 | 通过。generating 时第二次仍是原 id,点数停在 81;写成 ready 后再建得到新 id,点数到 62。ready 份数是 1,生成中为真 | +| 同一对两份 ready | 通过。第二份 ready 留下,count 为 2。随后取消排队任务,指纹变化的另一份仍能写入 | diff --git a/docs/tasks/TASK-synastry-staging-fix-20261021.md b/docs/tasks/TASK-synastry-staging-fix-20261021.md index 125fe106..461110ed 100644 --- a/docs/tasks/TASK-synastry-staging-fix-20261021.md +++ b/docs/tasks/TASK-synastry-staging-fix-20261021.md @@ -20,6 +20,7 @@ | 8 | 合盘报告页在电脑上「不能下滑」:鼠标在正文两侧空白处滚轮不动 | `app/globals.css` 的 `.synastry-report` 自己是滚动容器(`width: min(720px, 100%)` + `overflow: auto`),两侧空白属于 `.chat-panel`(`overflow: clip`),滚轮落在空白处无处可滚。个人报告是整宽的 `.personal-report-reader` 滚动、内层居中。来自 `67c1e6881` 重排 | | 9 | 第三节正文开头重复了标题(「沟通与理解 沟通与理解 沟通的天气…」) | 模型写稿时把节标题写进正文,保存与质检都没拦 | | 10 | 等待页写「岳辰 × 林瑶」,报告标题写「林瑶 × 岳辰」,同一份报告两人顺序不一致 | 未定位:等待页用选人页传入的顺序,报告用 `synastry-full-run.ts` 存的 `names`;执行方开工时先定位 | +| 11 | 同一对人已经有一份合盘报告,再点「开始合盘」直接跳回上一份,没法再生成新的 | 设计如此:`synastry-full-create.ts` 的 `createSynastryFullReport` 按两人 id + 出生资料指纹查已有报告,`ready` / `generating` 命中即返回 `reused: true`(#82 决策 E3「再次打开读已有报告,不扣点」)。产品现推翻 | ## 决策记录(产品负责人 2026-10-21) @@ -29,6 +30,7 @@ | K2 | 第 2、3 条按 #82 原定:时间线每段显示起止年月;英文报告同样有「分析逻辑」和时间线依据(英文内容来自英文那一次生成,不翻译中文) | | K4 | 第 7 条(10-21 产品确认):合盘报告有自己的地址 `/synastry/:reportId`,阅读组件仍复用同一个;侧栏点亮「婚恋合盘」,标签标题「婚恋合盘 · Jyotisha」。旧地址 `/reports/:id` 打开合盘报告时自动转到新地址(`replace`,不留历史),已分享的旧链接继续可用 | | K5 | 第 8~10 条(10-21 产品确认)并入本单:整块区域可滚、正文居中;正文开头重复的节标题去掉并让质检拦住;两人顺序一律按选人页选择的顺序(甲在前) | +| K6 | 第 11 条(10-21 产品选「允许重新生成」):**推翻 #82 决策 E3 的复用部分。** 同一对人已有 `ready` 报告时,「开始合盘」照常新生成一份、照常扣点(`synastry.full`);旧报告保留,在「之前的合盘」里并列。同一对人已有 `generating` 报告时仍跳到那份等待页,不重复扣点。为防误扣,选好的两人已有报告时,按钮下点数说明后加一句「这两人已经合过 N 次,再生成会另扣 X 点」(N、X 读实际数据)。每日上限照旧 | | K3 | 定价 `synastry.full` 19 点维持(已在 staging 生效) | ## 硬红线 @@ -52,6 +54,7 @@ | T8 | **最先做**:合盘报告由外层整宽容器滚动(与 `.personal-report-reader` 同法),`.synastry-report` 只负责居中排版,不再自带 `height:100%`/`overflow`;手机与打印不退化 | 样式/渲染测试断言滚动容器是整宽外层;PROGRESS 写明桌面滚轮在两侧空白处可滚的人工步骤 | | T9 | 保存前去掉每节正文开头与节标题相同的文字(中英文都做);质检规则增加「正文以节标题开头」判不合格并触发重写 | 单测:正文以「沟通与理解」开头时保存结果不含重复;质检对该样例报错 | | T10 | 定位第 10 条根因;等待页、报告标题、报告中心卡片、对话标题四处两人顺序一致,按选人页顺序 | 单测:甲乙顺序贯穿创建→存储→阅读页标题 | +| T11 | K6:`createSynastryFullReport` 只在已有 `generating` 时复用,`ready` 不再复用;选人页按钮下提示已合次数与将扣点数;`#82` 任务书 T1 的 test:db 断言「同一对人同指纹第二次找到已有报告」按三栏改为「第二次新建一份、扣点;生成中时复用」;引擎计算结果(`synastry_reports`)可以继续复用,只是报告正文重写 | test:db 真库:ready 后再建得到新 id、点数再减;generating 时再建返回原 id、点数不变;渲染测试:已有报告时显示提示句 | | T6 | 选人页:开关读取失败显示「合盘服务暂时连不上,请稍后再试」;开关结果回来之前不渲染选人区(只渲染标题和副标题);删掉 BUG_HISTORY 里重复的 BUG-1397 一段;CHANGELOG、PROGRESS(含真机复测步骤:选两人生成一份、等待页、报告揭幕、切英文、从末尾进对话问一节) | 渲染测试;文档齐 | ## 已完成(Claude 直接执行,产品负责人 10-21 授权「P0 你改吧,其余交给执行方」) @@ -60,7 +63,7 @@ T1、T2 已由 Claude 在 `codex/synastry-gte-fix-20261021` 完成并推 staging ## 让步顺序 -T1、T2 最先做,单独一个提交先推 staging(门禁绿、部署后告知产品负责人可以复测生成);T8 先单独推一次,T3~T7、T9、T10 随后。 +T1、T2 最先做,单独一个提交先推 staging(门禁绿、部署后告知产品负责人可以复测生成);T8 先单独推一次,T7、T9、T10、T11 随后。 ## 开工前置命令 diff --git a/frontend/DESIGN.md b/frontend/DESIGN.md index 371eb229..df8a39a1 100644 --- a/frontend/DESIGN.md +++ b/frontend/DESIGN.md @@ -2,7 +2,7 @@ ## 婚恋合盘报告(2026-10-19) -选人页标题下是副标题「看这段感情往后的走向,和两人之间的助力与考验。」两个人并排,中间一个 ×;窄屏改成上下排,× 不显示。各自是自定义列表,最多二十人,列表可滚动。资料不全的人灰掉,不能选;已经在另一边的人不能再选。列表最底一行是「+ 新增星盘」,点开一张卡片:称呼、性别(男 / 女)、出生日期、出生时间、出生地,右下角「取消」和「保存并选中」。满二十人时这一行变灰,并提示去星盘档案删人。「开始合盘」居中,点数说明在按钮正下方。之前的合盘只列人名和日期。生成中是轨道和四步(算两张完整命盘、整理两人之间的证据、写报告、检查报告),句子是「正在写 甲 × 乙 的合盘报告…」。报告页回到「‹ 婚恋合盘」,标题下是日期和「已保存,再次打开不扣点」,语言是「中文 / English」。九节编号,分析逻辑默认收起。英文报告同样收起分析逻辑(Analysis,依据行是 Chart 和 So),时间线每段标题写出起止年月;第一段从本月起时,起点写「现在」或 Now。英文准备时同一套轨道,写「正在写英文版…」,揭幕后整份替换。报告末尾「对哪一段想再问清楚?」,下面是八枚胶囊(`.synastry-report-ask`),不含共同练习。点一枚只新建对话并预填问题,不自动发送。对话里人物选择器锁住,显示「甲 × 乙」。合盘删除后输入框占位是「这份合盘已删除」,发送停用。报告在整宽的外层滚动,两侧空白也能滚;中间一栏只负责居中排版。合盘报告单独走 `/synastry/报告`。开始合盘、之前的合盘和报告中心的合盘卡片都进这个地址,侧栏仍亮「婚恋合盘」。旧的 `/reports/报告` 会换成新地址,并保留语言。 +选人页标题下是副标题「看这段感情往后的走向,和两人之间的助力与考验。」两个人并排,中间一个 ×;窄屏改成上下排,× 不显示。各自是自定义列表,最多二十人,列表可滚动。资料不全的人灰掉,不能选;已经在另一边的人不能再选。列表最底一行是「+ 新增星盘」,点开一张卡片:称呼、性别(男 / 女)、出生日期、出生时间、出生地,右下角「取消」和「保存并选中」。满二十人时这一行变灰,并提示去星盘档案删人。「开始合盘」居中,点数说明在按钮正下方。这两人已经有写好的合盘、且没有一份正在生成时,点数说明下面再写已经合过几次、再生成会另扣多少点。之前的合盘只列人名和日期。生成中是轨道和四步(算两张完整命盘、整理两人之间的证据、写报告、检查报告),句子是「正在写 甲 × 乙 的合盘报告…」。报告页回到「‹ 婚恋合盘」,标题下是日期和「已保存,再次打开不扣点」,语言是「中文 / English」。九节编号,分析逻辑默认收起。英文报告同样收起分析逻辑(Analysis,依据行是 Chart 和 So),时间线每段标题写出起止年月;第一段从本月起时,起点写「现在」或 Now。英文准备时同一套轨道,写「正在写英文版…」,揭幕后整份替换。报告末尾「对哪一段想再问清楚?」,下面是八枚胶囊(`.synastry-report-ask`),不含共同练习。点一枚只新建对话并预填问题,不自动发送。对话里人物选择器锁住,显示「甲 × 乙」。合盘删除后输入框占位是「这份合盘已删除」,发送停用。报告在整宽的外层滚动,两侧空白也能滚;中间一栏只负责居中排版。合盘报告单独走 `/synastry/报告`。开始合盘、之前的合盘和报告中心的合盘卡片都进这个地址,侧栏仍亮「婚恋合盘」。旧的 `/reports/报告` 会换成新地址,并保留语言。 ## 生时校正从对话进入、做完回到原对话(2026-10-07,BUG-1247~1251,在途) diff --git a/frontend/docs/VOICE.md b/frontend/docs/VOICE.md index be7f7c22..c0eac57b 100644 --- a/frontend/docs/VOICE.md +++ b/frontend/docs/VOICE.md @@ -34,7 +34,7 @@ Jyotisha 的可见文案是产品的一部分。正确性红线(真实性、 - 吵完之后,怎样递出台阶才不会变成认输? - 过日子的安排里,哪一件需要现在就说清楚? -报告末尾「对哪一段想再问清楚?」列出前八节(含关系时间线,不含共同练习)。点进去是一条新对话,输入框预先写好这句,用户自己发送。中文预填是「合盘报告里「节名」这一段,在我们两人的盘里依据是什么?」英文报告用英文问同一件事。对话里可以说星曜、宫位、九分盘、大运,但只能引用这份报告已经写下的计算。材料里没有的,说「这份合盘没算到」。对不上的盘面,说「这一点在这份合盘里没有算到,我不能确定」。报告删了之后,输入框写「这份合盘已删除」,不能再发送。英文还没写好时只说「英文版即将提供」。选人页副标题是「看这段感情往后的走向,和两人之间的助力与考验。」按钮是「开始合盘」。点数说明是「生成一份完整合盘报告,用掉 N 点。大约要几分钟。」,N 从配置读。没有配置时说「计费配置不可用」。下拉占位是「选择一个人」。资料不齐写「资料未齐」,已经选在另一边写「已在另一边」。档案满了写「档案已满(最多 20 人)」,旁边是「去星盘档案删一个」。新增星盘卡片用「称呼」「性别」「出生日期」「出生时间」「出生地」,按钮是「取消」和「保存并选中」。产品关着时说「合盘服务暂未开放」。开关读不到时说「合盘服务暂时连不上,请稍后再试」。开关结果回来之前,选人页只显示标题和副标题。英文次数用尽时说「英文版暂时无法再试」。生成中说「正在写 甲 × 乙 的合盘报告…」,四步是「算两张完整命盘」「整理两人之间的证据」「写报告」「检查报告」,下面是「大约要几分钟。可以先离开,写好后在「之前的合盘」和「报告」里都能找到。」英文等待说「正在写英文版…」和「第一次切到英文时才写,不扣点,大约一两分钟。」报告标题下是「已保存,再次打开不扣点」。 +报告末尾「对哪一段想再问清楚?」列出前八节(含关系时间线,不含共同练习)。点进去是一条新对话,输入框预先写好这句,用户自己发送。中文预填是「合盘报告里「节名」这一段,在我们两人的盘里依据是什么?」英文报告用英文问同一件事。对话里可以说星曜、宫位、九分盘、大运,但只能引用这份报告已经写下的计算。材料里没有的,说「这份合盘没算到」。对不上的盘面,说「这一点在这份合盘里没有算到,我不能确定」。报告删了之后,输入框写「这份合盘已删除」,不能再发送。英文还没写好时只说「英文版即将提供」。选人页副标题是「看这段感情往后的走向,和两人之间的助力与考验。」按钮是「开始合盘」。点数说明是「生成一份完整合盘报告,用掉 N 点。大约要几分钟。」,N 从配置读。这两人已经有写好的合盘、且没有一份正在生成时,点数说明下面加「这两人已经合过 N 次,再生成会另扣 X 点」,N、X 都读实际数字。正在生成时不写这句。没有配置时说「计费配置不可用」。下拉占位是「选择一个人」。资料不齐写「资料未齐」,已经选在另一边写「已在另一边」。档案满了写「档案已满(最多 20 人)」,旁边是「去星盘档案删一个」。新增星盘卡片用「称呼」「性别」「出生日期」「出生时间」「出生地」,按钮是「取消」和「保存并选中」。产品关着时说「合盘服务暂未开放」。开关读不到时说「合盘服务暂时连不上,请稍后再试」。开关结果回来之前,选人页只显示标题和副标题。英文次数用尽时说「英文版暂时无法再试」。生成中说「正在写 甲 × 乙 的合盘报告…」,四步是「算两张完整命盘」「整理两人之间的证据」「写报告」「检查报告」,下面是「大约要几分钟。可以先离开,写好后在「之前的合盘」和「报告」里都能找到。」英文等待说「正在写英文版…」和「第一次切到英文时才写,不扣点,大约一两分钟。」报告标题下是「已保存,再次打开不扣点」。 ## 生时校正从对话进入(2026-10-07,BUG-1247~1251;2026-10-12,BUG-1306) diff --git a/frontend/src/app/api/reports/route.ts b/frontend/src/app/api/reports/route.ts index 8c924f4c..6f8cd712 100644 --- a/frontend/src/app/api/reports/route.ts +++ b/frontend/src/app/api/reports/route.ts @@ -46,7 +46,7 @@ import { } from "@/lib/subject-birth"; import { consultationSubjectFailureResponse } from "@/lib/consultation-subject-resolver"; import { clampReportLimit, nextReportCursor, parseReportCursor, reportCursorFilter } from "@/lib/report-cursor"; -import { createSynastryFullReport } from "@/lib/synastry-full-create"; +import { createSynastryFullReport, readSynastryPairState } from "@/lib/synastry-full-create"; export const runtime = "nodejs"; export const maxDuration = 120; @@ -111,6 +111,16 @@ export async function GET(request: Request) { return NextResponse.json({ error: "请先登录" }, { status: 401 }); } const url = new URL(request.url); + if (url.searchParams.get("reportType") === "synastry_full" && url.searchParams.get("pair") === "1") { + const state = await readSynastryPairState({ + userId: user.id, + supabase, + admin: createAdminSupabaseClient(), + subjectA: url.searchParams.get("subjectA"), + subjectB: url.searchParams.get("subjectB"), + }); + return toNextResponse(state); + } // Keyset pages of 10 (BUG-1124). No `limit` is the first page, so an old // client that never sends one gets the newest 10 (was a fixed 20). const limit = clampReportLimit(url.searchParams.get("limit")); diff --git a/frontend/src/components/synastry/synastry-picker.tsx b/frontend/src/components/synastry/synastry-picker.tsx index a03cc0da..de948705 100644 --- a/frontend/src/components/synastry/synastry-picker.tsx +++ b/frontend/src/components/synastry/synastry-picker.tsx @@ -28,6 +28,8 @@ export function SynastryPicker() { const [busy, setBusy] = useState(false); const [creditCost, setCreditCost] = useState(null); const [pricingNote, setPricingNote] = useState(""); + const [readyCount, setReadyCount] = useState(0); + const [pairGenerating, setPairGenerating] = useState(false); const [earlier, setEarlier] = useState([]); const [access, setAccess] = useState<"pending" | "open" | "closed" | "unreachable">("pending"); const pickRef = useRef(null); @@ -93,6 +95,41 @@ export function SynastryPicker() { return () => window.clearTimeout(timer); }, []); + useEffect(() => { + let cancelled = false; + const timer = window.setTimeout(() => { + if (access !== "open" || !left || !right || left === right) { + setReadyCount(0); + setPairGenerating(false); + return; + } + setReadyCount(0); + setPairGenerating(false); + const params = new URLSearchParams({ + reportType: "synastry_full", + pair: "1", + subjectA: left, + subjectB: right, + }); + void fetch(`/api/reports?${params}`, { cache: "no-store" }) + .then((response) => response.ok ? response.json() : null) + .then((payload: { readyCount?: unknown; generating?: unknown } | null) => { + if (cancelled || !payload) return; + setReadyCount(typeof payload.readyCount === "number" ? payload.readyCount : 0); + setPairGenerating(payload.generating === true); + }) + .catch(() => { + if (cancelled) return; + setReadyCount(0); + setPairGenerating(false); + }); + }, 0); + return () => { + cancelled = true; + window.clearTimeout(timer); + }; + }, [access, left, right]); + useEffect(() => { if (!open && !adding) return; function onKey(event: KeyboardEvent) { @@ -215,7 +252,7 @@ export function SynastryPicker() {
- {creditCost !== null ?

生成一份完整合盘报告,用掉 {creditCost} 点。大约要几分钟。

: pricingNote ?

{pricingNote}

: null} + {message ?

{message}

: null}
{earlier.length > 0 ? ( @@ -250,6 +287,26 @@ export function SynastryPicker() { ); } +export function synastryAgainNote(readyCount: number, creditCost: number | null, generating: boolean): string | null { + if (generating || creditCost === null || readyCount < 1) return null; + return `这两人已经合过 ${readyCount} 次,再生成会另扣 ${creditCost} 点`; +} + +export function SynastryGoNotes(props: { + creditCost: number | null; + pricingNote: string; + readyCount: number; + generating: boolean; +}) { + const again = synastryAgainNote(props.readyCount, props.creditCost, props.generating); + return ( + <> + {props.creditCost !== null ?

生成一份完整合盘报告,用掉 {props.creditCost} 点。大约要几分钟。

: props.pricingNote ?

{props.pricingNote}

: null} + {again ?

{again}

: null} + + ); +} + function personRow(id: string, role: "self" | "other", profile: Profile): Person { const gender = parseProfileGender(profile.gender); const date = effectiveBirthDate(profile); diff --git a/frontend/src/lib/synastry-full-create.ts b/frontend/src/lib/synastry-full-create.ts index 28ecfdf8..8e4599cf 100644 --- a/frontend/src/lib/synastry-full-create.ts +++ b/frontend/src/lib/synastry-full-create.ts @@ -53,21 +53,27 @@ function selectReports(admin: unknown, columns: string): Query { return (admin as { from(table: string): { select(columns: string): Query } }).from("personal_reports").select(columns); } -export async function createSynastryFullReport(input: Readonly<{ +type OpenedPair = { + userId: string; + subjectA: string; + subjectB: string; + pair: ReturnType; +}; + +async function openSynastryPair(input: Readonly<{ userId: string | null; - body: unknown; supabase: unknown; - admin: Parameters[0]; -}>): Promise { - if (!input.userId) return { status: 401, body: { error: "请先登录" } }; + subjectA: unknown; + subjectB: unknown; +}>): Promise<{ response: SynastryCreateResponse } | OpenedPair> { + if (!input.userId) return { response: { status: 401, body: { error: "请先登录" } } }; if (!await isProductEnabled("compatibility") || !await isProductEnabled("report_center")) { - return { status: 503, body: { error: "合盘服务暂未开放", code: "compatibility_product_disabled" } }; + return { response: { status: 503, body: { error: "合盘服务暂未开放", code: "compatibility_product_disabled" } } }; } - const body = input.body && typeof input.body === "object" ? input.body as Record : {}; - const subjectA = subjectId(body.subjectA ?? "self"); - const subjectB = subjectId(body.subjectB ?? body.with); - if (!subjectA || !subjectB) return { status: 400, body: { error: "请选择两个人", code: "subject_required" } }; - if (subjectA === subjectB) return { status: 400, body: { error: "请选择两个不同的人", code: "same_subject" } }; + const subjectA = subjectId(input.subjectA ?? "self"); + const subjectB = subjectId(input.subjectB); + if (!subjectA || !subjectB) return { response: { status: 400, body: { error: "请选择两个人", code: "subject_required" } } }; + if (subjectA === subjectB) return { response: { status: 400, body: { error: "请选择两个不同的人", code: "same_subject" } } }; let rowA: Record; let rowB: Record; @@ -77,44 +83,90 @@ export async function createSynastryFullReport(input: Readonly<{ loadSubjectBirth(input.supabase, input.userId, subjectB), ]); if (!personA.chartable || !personB.chartable) { - return { status: 422, body: { error: "请先完善出生资料", code: REPORT_STABLE_CODES.profileIncomplete } }; + return { response: { status: 422, body: { error: "请先完善出生资料", code: REPORT_STABLE_CODES.profileIncomplete } } }; } rowA = personA.row; rowB = personB.row; } catch (error) { if (error instanceof ConsultationSubjectError) { const failure = consultationSubjectFailureResponse(error); - return { status: failure.status, body: failure.body }; + return { response: { status: failure.status, body: failure.body } }; } throw error; } + return { + userId: input.userId, + subjectA, + subjectB, + pair: pairOf(subjectA, subjectB, marriageMatchFingerprint(rowA), marriageMatchFingerprint(rowB)), + }; +} - const pair = pairOf(subjectA, subjectB, marriageMatchFingerprint(rowA), marriageMatchFingerprint(rowB)); - const requestFingerprint = createHash("sha256").update(JSON.stringify({ - ...pair, - rules: MARRIAGE_MATCH_RULES_VERSION, - })).digest("hex"); - const existing = await selectReports(input.admin, "id,request_id,status,created_at,completed_at") - .eq("user_id", input.userId) +function pairQuery(admin: unknown, pair: OpenedPair["pair"], userId: string) { + return selectReports(admin, "id,request_id,status,created_at,completed_at") + .eq("user_id", userId) .eq("report_type", "synastry_full") .eq("subject_low", pair.low) .eq("subject_high", pair.high) .eq("fingerprint_low", pair.fingerprintLow) .eq("fingerprint_high", pair.fingerprintHigh) - .eq("synastry_rules_version", MARRIAGE_MATCH_RULES_VERSION) - .in("status", ["ready", "generating"]) + .eq("synastry_rules_version", MARRIAGE_MATCH_RULES_VERSION); +} + +export async function readSynastryPairState(input: Readonly<{ + userId: string | null; + supabase: unknown; + admin: Parameters[0]; + subjectA: unknown; + subjectB: unknown; +}>): Promise { + const opened = await openSynastryPair(input); + if ("response" in opened) return opened.response; + const found = await pairQuery(input.admin, opened.pair, opened.userId).in("status", ["ready", "generating"]); + if (found.error) throw new Error(found.error.message ?? "synastry pair lookup failed"); + const rows = Array.isArray(found.data) ? found.data as Row[] : []; + return { + status: 200, + body: { + readyCount: rows.filter((row) => row.status === "ready").length, + generating: rows.some((row) => row.status === "generating"), + }, + }; +} + +export async function createSynastryFullReport(input: Readonly<{ + userId: string | null; + body: unknown; + supabase: unknown; + admin: Parameters[0]; +}>): Promise { + const body = input.body && typeof input.body === "object" ? input.body as Record : {}; + const opened = await openSynastryPair({ + userId: input.userId, + supabase: input.supabase, + subjectA: body.subjectA ?? "self", + subjectB: body.subjectB ?? body.with, + }); + if ("response" in opened) return opened.response; + const { userId, subjectA, subjectB, pair } = opened; + const requestFingerprint = createHash("sha256").update(JSON.stringify({ + ...pair, + rules: MARRIAGE_MATCH_RULES_VERSION, + })).digest("hex"); + const existing = await pairQuery(input.admin, pair, userId) + .eq("status", "generating") .limit(1) .maybeSingle(); if (existing.error) throw new Error(existing.error.message ?? "synastry lookup failed"); - if (existing.data?.status === "ready" || existing.data?.status === "generating") { + if (existing.data?.status === "generating") { return { - status: existing.data.status === "ready" ? 200 : 202, - body: { report: reportView(stubRecord(input.userId, existing.data)), reused: true }, + status: 202, + body: { report: reportView(stubRecord(userId, existing.data)), reused: true }, }; } const inFlight = await selectReports(input.admin, "id") - .eq("user_id", input.userId) + .eq("user_id", userId) .eq("status", "generating") .limit(1) .maybeSingle(); @@ -124,7 +176,7 @@ export async function createSynastryFullReport(input: Readonly<{ } const todayStart = new Date(); todayStart.setHours(0, 0, 0, 0); - const today = await selectReports(input.admin, "created_at").eq("user_id", input.userId).gte("created_at", todayStart.toISOString()); + const today = await selectReports(input.admin, "created_at").eq("user_id", userId).gte("created_at", todayStart.toISOString()); if (today.error) throw new Error(today.error.message ?? "synastry daily lookup failed"); if ((today.data ?? []).length >= readPersonalReportDailyLimit(process.env)) { return { status: 429, body: { error: "今日报告生成次数已达上限", code: REPORT_STABLE_CODES.rateLimited } }; @@ -133,7 +185,7 @@ export async function createSynastryFullReport(input: Readonly<{ const catalog = await loadLanguageModelCatalog(); const model = catalog.models.find((entry) => entry.id === catalog.defaultModelId) ?? null; if (!model) return { status: 502, body: { error: "报告模型暂不可用", code: REPORT_STABLE_CODES.modelUnavailable } }; - const limited = consumeUserRateLimitSet(["reportGenerateHour"], input.userId); + const limited = consumeUserRateLimitSet(["reportGenerateHour"], userId); if (!limited.ok) { return { status: 429, @@ -147,7 +199,7 @@ export async function createSynastryFullReport(input: Readonly<{ try { const pricing = await resolveFeaturePricing(input.admin as never, "synastry.full", model.id); const authorization = await authorizeUsage(input.admin as never, { - userId: input.userId, + userId, requestId, featureKey: "synastry.full", requestedModelId: model.id, @@ -181,7 +233,7 @@ export async function createSynastryFullReport(input: Readonly<{ const release = async (reason: string) => { if (!reserved) return; reserved = false; - try { await releaseUsage(input.admin as never, input.userId as string, requestId, reason); } catch { /* the RPC is idempotent */ } + try { await releaseUsage(input.admin as never, userId, requestId, reason); } catch { /* the RPC is idempotent */ } }; const persistence = createSupabasePersonalReportService(input.admin); @@ -191,7 +243,7 @@ export async function createSynastryFullReport(input: Readonly<{ let created; try { created = await persistence.createGenerating({ - userId: input.userId, + userId, requestId, requestFingerprint, reportType: "synastry_full", @@ -221,7 +273,7 @@ export async function createSynastryFullReport(input: Readonly<{ insert(row: Record): Promise<{ error: { message?: string } | null }>; }; }).from("personal_report_sections").insert({ - user_id: input.userId, + user_id: userId, request_id: requestId, section_id: SYNASTRY_ORDER_SECTION, status: "ready", @@ -230,19 +282,19 @@ export async function createSynastryFullReport(input: Readonly<{ max_attempts: 1, }); if (orderSaved.error) { - await persistence.markFailed(input.userId, created.record.id, REPORT_STABLE_CODES.generationFailed); + await persistence.markFailed(userId, created.record.id, REPORT_STABLE_CODES.generationFailed); await release(REPORT_STABLE_CODES.generationFailed); return { status: 503, body: { error: "报告任务暂时无法入队", code: REPORT_STABLE_CODES.generationFailed } }; } try { const enqueued = await createSupabasePersonalReportJobService(input.admin as never).enqueue({ - userId: input.userId, + userId, requestId, requestFingerprint, }); return { status: 202, body: { report: reportView(created.record), jobId: enqueued.job.id } }; } catch { - await persistence.markFailed(input.userId, created.record.id, REPORT_STABLE_CODES.generationFailed); + await persistence.markFailed(userId, created.record.id, REPORT_STABLE_CODES.generationFailed); await release(REPORT_STABLE_CODES.generationFailed); return { status: 503, body: { error: "报告任务暂时无法入队", code: REPORT_STABLE_CODES.generationFailed } }; } diff --git a/frontend/supabase/migrations/20261021120000_synastry_ready_reports_allow_repeat.sql b/frontend/supabase/migrations/20261021120000_synastry_ready_reports_allow_repeat.sql new file mode 100644 index 00000000..58772853 --- /dev/null +++ b/frontend/supabase/migrations/20261021120000_synastry_ready_reports_allow_repeat.sql @@ -0,0 +1,4 @@ +-- A ready synastry report used to be unique per pair, so a second reading +-- could not be saved. Product decision K6 keeps the old report and allows +-- another one. Engine rows in synastry_reports stay unique and are reused. +drop index if exists public.personal_reports_synastry_pair_ready_uidx; diff --git a/frontend/tests/database-synastry-full-create.test.ts b/frontend/tests/database-synastry-full-create.test.ts index 5ac41858..dc570742 100644 --- a/frontend/tests/database-synastry-full-create.test.ts +++ b/frontend/tests/database-synastry-full-create.test.ts @@ -105,7 +105,7 @@ test("createSynastryFullReport reserves synastry.full and enqueues a job on the }); catalog.invalidateLanguageModelCatalog(); - const { createSynastryFullReport } = await import("../src/lib/synastry-full-create.ts"); + const { createSynastryFullReport, readSynastryPairState } = await import("../src/lib/synastry-full-create.ts"); const user = createLocalPostgresDataClient(appUrl, { id: userId, email: `${userId}@example.invalid` }); const admin = createLocalPostgresDataClient(serviceUrl, null, "service_role"); const created = await createSynastryFullReport({ @@ -127,6 +127,61 @@ test("createSynastryFullReport reserves synastry.full and enqueues a job on the assert.equal(fixture.psql(`select payload->>'second' from public.personal_report_sections where user_id = '${userId}' and section_id = 'synastry_order'`), personId); assert.equal(fixture.psql(`select subject_low from public.personal_reports where id = '${report?.id}'`), personId); assert.equal(fixture.psql(`select subject_high from public.personal_reports where id = '${report?.id}'`), "self"); + + const again = await createSynastryFullReport({ + userId, + body: { reportType: "synastry_full", subjectA: "self", subjectB: personId }, + supabase: user, + admin, + }); + assert.equal(again.status, 202, JSON.stringify(again.body)); + assert.equal(again.body.reused, true); + assert.equal((again.body.report as { id?: string }).id, report?.id); + assert.equal(fixture.psql(`select credits from public.profiles where id = '${userId}'`), "81"); + + const hash = "d".repeat(64); + fixture.psql(` + update public.personal_reports + set status = 'ready', completed_at = now(), report_document = '{}'::jsonb, + calculation_hash = '${hash}', evidence_hash = '${hash}' + where id = '${report?.id}'; + update public.personal_report_jobs + set status = 'cancelled', finished_at = now() + where user_id = '${userId}' and status = 'queued'; + `); + const readyState = await readSynastryPairState({ + userId, + supabase: user, + admin, + subjectA: "self", + subjectB: personId, + }); + assert.equal(readyState.status, 200, JSON.stringify(readyState.body)); + assert.equal(readyState.body.readyCount, 1); + assert.equal(readyState.body.generating, false); + + const second = await createSynastryFullReport({ + userId, + body: { reportType: "synastry_full", subjectA: "self", subjectB: personId }, + supabase: user, + admin, + }); + assert.equal(second.status, 202, JSON.stringify(second.body)); + assert.equal(second.body.reused, undefined); + const secondId = (second.body.report as { id?: string }).id; + assert.equal(typeof secondId, "string"); + assert.notEqual(secondId, report?.id); + assert.equal(fixture.psql(`select status from public.personal_reports where id = '${secondId}'`), "generating"); + assert.equal(fixture.psql(`select credits from public.profiles where id = '${userId}'`), "62"); + const generatingState = await readSynastryPairState({ + userId, + supabase: user, + admin, + subjectA: "self", + subjectB: personId, + }); + assert.equal(generatingState.body.readyCount, 1); + assert.equal(generatingState.body.generating, true); catalog.setModelCatalogIoForTests(null); catalog.invalidateLanguageModelCatalog(); } finally { diff --git a/frontend/tests/database-synastry-full-report.test.ts b/frontend/tests/database-synastry-full-report.test.ts index cb1e875e..da165b0d 100644 --- a/frontend/tests/database-synastry-full-report.test.ts +++ b/frontend/tests/database-synastry-full-report.test.ts @@ -81,7 +81,10 @@ test("synastry reports reuse a pair, drop with the person, and settle the synast where id = '${reportId}'; ${closeJob} `); - assert.throws(() => fixture.psql(` + // 原值:第二份 ready 插入被 personal_reports_synastry_pair_ready_uidx 拒绝,count 为 1 + // 新值:同一对可以再有一份 ready,count 为 2 + // 原因:K6 推翻复用,旧报告保留(BUG-1408) + fixture.psql(` begin; ${insertReport(duplicateReportId, secondRequestId, fingerprint)} update public.personal_reports @@ -89,14 +92,12 @@ test("synastry reports reuse a pair, drop with the person, and settle the synast calculation_hash = '${skillHash}', evidence_hash = '${skillHash}' where id = '${duplicateReportId}'; commit; - `), (error: unknown) => { - const stderr = error && typeof error === "object" && "stderr" in error ? String((error as { stderr?: unknown }).stderr) : ""; - return /personal_reports_synastry_pair_ready_uidx/.test(`${stderr}\n${String(error)}`); - }); + `); assert.equal(lastLine(fixture.psql(` select count(*) from public.personal_reports where user_id = '${userId}' and fingerprint_low = '${fingerprint}' and status = 'ready'; - `)), "1"); + `)), "2"); + fixture.psql(closeJob); fixture.psql(insertReport(changedReportId, changedRequestId, changedFingerprint)); fixture.psql(` diff --git a/frontend/tests/synastry-full-create-route.test.ts b/frontend/tests/synastry-full-create-route.test.ts index d29bc1ef..07770c97 100644 --- a/frontend/tests/synastry-full-create-route.test.ts +++ b/frontend/tests/synastry-full-create-route.test.ts @@ -31,7 +31,7 @@ function runRoute(script: string): { status: number; body: Record; charged: boolean }; } -function script(input: { product: boolean; same: boolean; missingOther: boolean; reused: boolean }): string { +function script(input: { product: boolean; same: boolean; missingOther: boolean; existing: "none" | "generating" }): string { return String.raw` import { mock } from "node:test"; import { pathToFileURL } from "node:url"; @@ -60,7 +60,7 @@ function script(input: { product: boolean; same: boolean; missingOther: boolean; const q = { select() { return q; }, eq(column, value) { filters.push([column, value]); return q; }, - in() { return q; }, + in(column, values) { filters.push([column, values]); return q; }, gte() { return q; }, is() { return q; }, or() { return q; }, @@ -72,7 +72,11 @@ function script(input: { product: boolean; same: boolean; missingOther: boolean; return Promise.resolve({ data: ${input.missingOther ? "null" : "{ ...selfRow, id: otherId, user_id: 'user-1', role: 'other' }"}, error: null }); } if (table === "personal_reports") { - return Promise.resolve({ data: ${input.reused ? "{ id: 'ready-1', request_id: 'req-1', status: 'ready', created_at: '2026-10-19T00:00:00Z' }" : "null"}, error: null }); + const wantsGenerating = filters.some(([column, value]) => column === "status" && (value === "generating" || (Array.isArray(value) && value.includes("generating")))); + if (${input.existing === "generating" ? "true" : "false"} && wantsGenerating) { + return Promise.resolve({ data: { id: "gen-1", request_id: "req-1", status: "generating", created_at: "2026-10-19T00:00:00Z" }, error: null }); + } + return Promise.resolve({ data: null, error: null }); } return Promise.resolve({ data: null, error: null }); }, @@ -98,28 +102,31 @@ function script(input: { product: boolean; same: boolean; missingOther: boolean; } test("the same person is rejected before billing", () => { - const result = runRoute(script({ product: true, same: true, missingOther: false, reused: false })); + const result = runRoute(script({ product: true, same: true, missingOther: false, existing: "none" })); assert.equal(result.status, 400); assert.equal(result.body.code, "same_subject"); assert.equal(result.charged, false); }); test("someone else's id is rejected before billing", () => { - const result = runRoute(script({ product: true, same: false, missingOther: true, reused: false })); + const result = runRoute(script({ product: true, same: false, missingOther: true, existing: "none" })); assert.equal(result.status, 409); assert.equal(result.charged, false); assert.match(JSON.stringify(result.body), /不会扣点/); }); -test("an unchanged pair is reused without a new reservation", () => { - const result = runRoute(script({ product: true, same: false, missingOther: false, reused: true })); - assert.equal(result.status, 200); +test("a generating pair is reused without a new reservation", () => { + // 原值:已有 ready 报告时 status 200、reused、不扣点 + // 新值:只有 generating 复用并返回 202;ready 不再走这条 + // 原因:K6 允许同一对人重新生成(BUG-1408) + const result = runRoute(script({ product: true, same: false, missingOther: false, existing: "generating" })); + assert.equal(result.status, 202); assert.equal(result.body.reused, true); assert.equal(result.charged, false); }); test("a closed product switch rejects the report before billing", () => { - const result = runRoute(script({ product: false, same: false, missingOther: true, reused: false })); + const result = runRoute(script({ product: false, same: false, missingOther: true, existing: "none" })); assert.equal(result.status, 503); assert.equal(result.body.code, "compatibility_product_disabled"); assert.equal(result.charged, false); diff --git a/frontend/tests/synastry-full-report.test.ts b/frontend/tests/synastry-full-report.test.ts index ee3f943e..fd7f098a 100644 --- a/frontend/tests/synastry-full-report.test.ts +++ b/frontend/tests/synastry-full-report.test.ts @@ -6,8 +6,8 @@ import { fileURLToPath } from "node:url"; import { renderToStaticMarkup } from "react-dom/server"; import { createElement } from "react"; +import { SynastryGoNotes, SynastryPicker } from "../src/components/synastry/synastry-picker.tsx"; import { SynastryReportView } from "../src/components/synastry/synastry-report-view.tsx"; -import { SynastryPicker } from "../src/components/synastry/synastry-picker.tsx"; import { SidebarProvider } from "../src/components/ui/sidebar.tsx"; import { stripLeadingSectionTitle, writeSynastryLanguage } from "../src/lib/synastry-full-generate.ts"; import { EVIDENCE_LABELS, PRIVATE_KEY_NAMES, sectionEvidence, SYNASTRY_LENGTH, systemPrompt, userPrompt, SYNASTRY_SECTION_IDS, type SynastrySectionId } from "../src/lib/synastry-full-sections.ts"; @@ -172,6 +172,22 @@ test("the synastry page is a static shell and reads the flag in the browser", () assert.match(picker, /开始合盘/); assert.match(picker, /+ 新增星盘/); assert.match(picker, /生成一份完整合盘报告,用掉/); + const again = renderToStaticMarkup(createElement(SynastryGoNotes, { + creditCost: 19, + pricingNote: "", + readyCount: 2, + generating: false, + })); + assert.match(again, /这两人已经合过 2 次,再生成会另扣 19 点/); + assert.match(again, /生成一份完整合盘报告,用掉 19 点/); + const quiet = renderToStaticMarkup(createElement(SynastryGoNotes, { + creditCost: 19, + pricingNote: "", + readyCount: 2, + generating: true, + })); + assert.doesNotMatch(quiet, /再生成会另扣/); + assert.match(picker, /pair: "1"/); assert.doesNotMatch(picker, /男方|女方|合不合|会不会在一起/); const dialog = readFileSync(new URL("../src/components/synastry/synastry-add-dialog.tsx", import.meta.url), "utf8"); const wait = readFileSync(new URL("../src/components/synastry/synastry-wait.tsx", import.meta.url), "utf8");