fix(synastry): let the same pair start another report
A finished match used to reopen the old report. Starting again now writes a new one and charges again; a report still being written is reused. Co-Authored-By: Grok (agent loop) Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
co-authored by
Grok (agent loop)
Cursor
parent
dd9efb3447
commit
57ee79cc5f
@@ -1,5 +1,9 @@
|
||||
# 印度占星 Skill 更新日志
|
||||
|
||||
## 2026-10-21 — 同一对人可以再生成一份合盘
|
||||
|
||||
- 已经写好的合盘还在。再点「开始合盘」会新写一份,并再扣一次点数。正在写的那份仍打开原来的等待页,不重复扣。选人页在点数说明下写出已经合过几次、再生成会另扣多少点。Skill 版本不变(BUG-1408)。
|
||||
|
||||
## 2026-10-21 — 合盘两人顺序跟选人页一致
|
||||
|
||||
- 先选的人写在前面。等待页、报告标题、报告中心卡片和追问对话标题用同一个顺序。同一对已经有报告时,仍打开原来那份。Skill 版本不变(BUG-1407)。
|
||||
|
||||
+17
-1
@@ -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`。
|
||||
|
||||
@@ -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。随后取消排队任务,指纹变化的另一份仍能写入 |
|
||||
|
||||
@@ -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 随后。
|
||||
|
||||
## 开工前置命令
|
||||
|
||||
|
||||
+1
-1
@@ -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,在途)
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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"));
|
||||
|
||||
@@ -28,6 +28,8 @@ export function SynastryPicker() {
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [creditCost, setCreditCost] = useState<number | null>(null);
|
||||
const [pricingNote, setPricingNote] = useState("");
|
||||
const [readyCount, setReadyCount] = useState(0);
|
||||
const [pairGenerating, setPairGenerating] = useState(false);
|
||||
const [earlier, setEarlier] = useState<Earlier[]>([]);
|
||||
const [access, setAccess] = useState<"pending" | "open" | "closed" | "unreachable">("pending");
|
||||
const pickRef = useRef<HTMLDivElement>(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() {
|
||||
</div>
|
||||
<div className="synastry-go">
|
||||
<button type="button" className="button-primary" onClick={() => void createReport()} disabled={busy || !ready}>开始合盘</button>
|
||||
{creditCost !== null ? <p className="synastry-cost">生成一份完整合盘报告,用掉 {creditCost} 点。大约要几分钟。</p> : pricingNote ? <p className="synastry-cost">{pricingNote}</p> : null}
|
||||
<SynastryGoNotes creditCost={creditCost} pricingNote={pricingNote} readyCount={readyCount} generating={pairGenerating} />
|
||||
{message ? <p className="synastry-cost" role="status">{message}</p> : null}
|
||||
</div>
|
||||
{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 ? <p className="synastry-cost">生成一份完整合盘报告,用掉 {props.creditCost} 点。大约要几分钟。</p> : props.pricingNote ? <p className="synastry-cost">{props.pricingNote}</p> : null}
|
||||
{again ? <p className="synastry-cost">{again}</p> : null}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function personRow(id: string, role: "self" | "other", profile: Profile): Person {
|
||||
const gender = parseProfileGender(profile.gender);
|
||||
const date = effectiveBirthDate(profile);
|
||||
|
||||
@@ -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<typeof pairOf>;
|
||||
};
|
||||
|
||||
async function openSynastryPair(input: Readonly<{
|
||||
userId: string | null;
|
||||
body: unknown;
|
||||
supabase: unknown;
|
||||
admin: Parameters<typeof createSupabasePersonalReportService>[0];
|
||||
}>): Promise<SynastryCreateResponse> {
|
||||
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<string, unknown> : {};
|
||||
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<string, unknown>;
|
||||
let rowB: Record<string, unknown>;
|
||||
@@ -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<typeof createSupabasePersonalReportService>[0];
|
||||
subjectA: unknown;
|
||||
subjectB: unknown;
|
||||
}>): Promise<SynastryCreateResponse> {
|
||||
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<typeof createSupabasePersonalReportService>[0];
|
||||
}>): Promise<SynastryCreateResponse> {
|
||||
const body = input.body && typeof input.body === "object" ? input.body as Record<string, unknown> : {};
|
||||
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<string, unknown>): 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 } };
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
@@ -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 {
|
||||
|
||||
@@ -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(`
|
||||
|
||||
@@ -31,7 +31,7 @@ function runRoute(script: string): { status: number; body: Record<string, unknow
|
||||
return JSON.parse(line) as { status: number; body: Record<string, unknown>; 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);
|
||||
|
||||
@@ -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");
|
||||
|
||||
Reference in New Issue
Block a user