From cf5a62c684b0523dbc33406bbdc5067eb513bb29 Mon Sep 17 00:00:00 2001 From: jesse-ux Date: Sun, 18 Oct 2026 19:14:31 +0800 Subject: [PATCH] fix(synastry): store only the two marriage-match profiles The saved row was the whole dossier twice. Keep the default and Raman match results, drop a concurrent duplicate insert back to the first row, and hide the new rows from the old history list. Checked service tests 9/9, tsc 0, lint 0 errors, and test:db 73 pass / 13 fail matching the Windows known list. Co-Authored-By: Grok (agent loop) Co-authored-by: Cursor --- CHANGELOG.md | 2 +- docs/BUG_HISTORY.md | 3 +- .../PROGRESS-marriage-match-p1-20261017.md | 16 ++++ docs/tasks/README.md | 4 +- docs/tasks/TASK-marriage-match-p1-20261017.md | 2 +- .../src/app/api/synastry-reports/route.ts | 1 + frontend/src/lib/marriage-match-service.ts | 62 ++++++++++-- .../tests/database-marriage-match.test.ts | 30 +++++- frontend/tests/marriage-match-service.test.ts | 94 ++++++++++++++++++- .../tests/synastry-reports-history.test.ts | 10 ++ 10 files changed, 206 insertions(+), 18 deletions(-) create mode 100644 frontend/tests/synastry-reports-history.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 4c54a15f..8709402c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,7 +3,7 @@ ## 2026-10-18 — 婚恋合盘改为上游七项口径,本单只做计算和存储 - 合盘计算改为上游模块:七项、满分 35,两个方向取平均,不按性别比较。火星冲克和婚姻宫压力分开保存。证据包接口会先排两人的完整星盘。 -- 同一对人、出生资料和规则版本都没变时,服务端直接读已保存的结果,不扣点。页面和入口留到下一单。 +- 同一对人、出生资料和规则版本都没变时,服务端直接读已保存的两个配对结果,不扣点,也不把整份证据包存进数据库。页面和入口留到下一单。 - 旧的 `synastry` 命令和模块已删除。Skill 版本不变(BUG-1370、BUG-1371、BUG-1372、BUG-1374)。 ## 2026-10-18 — 星盘档案最多保存 20 个人 diff --git a/docs/BUG_HISTORY.md b/docs/BUG_HISTORY.md index f4c6e4d3..25031789 100644 --- a/docs/BUG_HISTORY.md +++ b/docs/BUG_HISTORY.md @@ -36500,7 +36500,8 @@ - 根因:旧接口只收两个月亮度数,并写死男方 / 女方。 - 修复:改用上游合盘模块。配对层对两个方向取平均,不读性别。 - 验证:上游产品测试与本地对调测试。公开名人证据包可以进入解读。 -- 防复发:对调后配对分、火星冲克和婚姻宫压力一致的测试。 +- 复查(2026-10-18 打回):第一次实现把整份证据包写入 `report` 和 `structured_result`。修复后这两列只存 `{ rules_version, default, raman }`,`default` 是默认 profile 的配对结果,`raman` 是 Raman profile。命中不再返回证据包。同时点两次时,第二次读第一次存下的行。 +- 防复发:对调后配对分、火星冲克和婚姻宫压力一致的测试。存档单测要求结构结果不含证据包,且小于 64 KB。 - 相关记录:BUG-1371。 - 复发自:无。 - 修复版本:分支 `codex/marriage-match-p1-20261017`。 diff --git a/docs/tasks/PROGRESS-marriage-match-p1-20261017.md b/docs/tasks/PROGRESS-marriage-match-p1-20261017.md index 90e07e0f..aeaa430d 100644 --- a/docs/tasks/PROGRESS-marriage-match-p1-20261017.md +++ b/docs/tasks/PROGRESS-marriage-match-p1-20261017.md @@ -41,6 +41,22 @@ | 数据库 | `npm run test:db` | 86 项:73 通过,13 失败,0 取消。13 条失败名称与 `tools/agent-loop/windows-known-failures.txt` 逐条相同。婚恋合盘这一项通过。 | | 全量 pytest / 快速门 | 未跑 | Windows 执行约定由验收方跑。 | +## 第 1 次打回(F1 / F2) + +结构结果的键是 `rules_version`、`default`、`raman`。`default` 取证据包里的 `marriage_match`,`raman` 取 Raman profile。`report` 列存同一份结构结果,不存证据包。现算返回 `{ cached: false, structured, dossier }`;命中返回 `{ cached: true, structured }`,不带证据包。后续单要证据包时自己调证据包接口。旧历史列表只取 `rules_version is null` 的行。同一对人同时写入时,第二次遇到唯一冲突就重读第一次的行。 + +大小用 `frontend/tests/fixtures/marriage-match-snapshot.json` 当两套 profile 的金样,存进去的 JSON 小于 64 KB。证据包里的盘面快照不入库。 + +## 修复后的检查 + +| 检查 | 命令 | 结果 | +| --- | --- | --- | +| 服务端函数与旧历史过滤 | `node node_modules/tsx/dist/cli.mjs --test tests/marriage-match-service.test.ts tests/synastry-reports-history.test.ts` | 9 通过,0 失败,0 取消。 | +| 类型检查 | `tsc --noEmit` | 0 错。 | +| lint | `npm run lint` | 0 error,123 warning(原有)。 | +| 数据库 | `npm run test:db` | 86 项:73 通过,13 失败,0 取消。13 条与 Windows 已知失败清单逐条相同。`database-marriage-match.test.ts` 通过。 | +| 全量 pytest / 快速门 | 未跑 | 留给验收方。 | + ## 偏离 - 不做页面、不扣点,与第六版一致。 diff --git a/docs/tasks/README.md b/docs/tasks/README.md index 1940fc6f..9a246406 100644 --- a/docs/tasks/README.md +++ b/docs/tasks/README.md @@ -501,8 +501,8 @@ | `TASK-data-layer-types-20261013.md` | `PROGRESS-data-layer-types-20261013.md` | 数据访问层改用自己的 DataClient 类型第一阶段(BUG-1351;任务书预留的 1343 已被更大编号越过) | 待验收 | `codex/data-layer-types-20261013` | | `TASK-contracts-helpers-dedupe-20261013.md` | — | 前后端契约与小工具去重第一阶段 | 待领取(排队:frontend-dead-code、consult-route-split) | `codex/contracts-helpers-dedupe-20261013` | | `TASK-people-archive-limit-20-20261017.md` | `PROGRESS-people-archive-limit-20-20261017.md` | 星盘档案上限 5→20 人(触发器新迁移 + 常量 + 文案;婚恋合盘前置单) | 待验收 | `codex/people-archive-limit-20-20261017` | -| `TASK-marriage-match-p1-20261017.md` | #79 | 婚恋合盘引擎(搬上游 bde5d615:marriage_match + 合盘证据包;35 分七项、不分男女;第六版不扣点、不做页面)(BUG-1370 起) | 执行中(第 1 次打回修复) | `codex/marriage-match-p1-20261017`;动表 | -| `TASK-marriage-match-p1-fix-20261018.md` | #79 | 只存两个 profile 的配对结果;旧历史列表不读新行;并发重复写入改为重读 | 执行中 | `codex/marriage-match-p1-20261017` | +| `TASK-marriage-match-p1-20261017.md` | #79 | 婚恋合盘引擎(搬上游 bde5d615:marriage_match + 合盘证据包;35 分七项、不分男女;第六版不扣点、不做页面)(BUG-1370 起) | 待验收 | `codex/marriage-match-p1-20261017`;动表 | +| `TASK-marriage-match-p1-fix-20261018.md` | #79 | 只存两个 profile 的配对结果;旧历史列表不读新行;并发重复写入改为重读 | 待验收 | `codex/marriage-match-p1-20261017` | | `TASK-full-reading-module-parity-20261018.md` | #81 | full-reading 补齐上游 5 个模块(西占层文件没搬致永远 blocked、Navamsha / Niryaana 用我们自己的实现填键、Lagna Vargottamamsa / Pushkara / Punarphoo)(BUG-1375 起) | 待执行(可与 #78 并行) | `codex/full-reading-module-parity-20261018` | | `TASK-synastry-full-report-20261018.md` | #82 | 婚恋合盘:选人页 + 完整合盘报告(关系画像九节:结论+折叠的分析逻辑+关系时间线+技术依据摘要;英文按需;计费键 synastry.full) | 排队(等 #78、#79、#81) | `codex/synastry-full-report-20261018`;动表 | | `TASK-synastry-report-chat-20261018.md` | #83 | 从合盘报告某一节进入对话问依据(绑定报告与节、对话里可用术语、只依据已存计算结果并逐条核对、计费照普通对话) | 排队(等 #82) | `codex/synastry-report-chat-20261018`;动表 | diff --git a/docs/tasks/TASK-marriage-match-p1-20261017.md b/docs/tasks/TASK-marriage-match-p1-20261017.md index 9b21de0f..00f8fe60 100644 --- a/docs/tasks/TASK-marriage-match-p1-20261017.md +++ b/docs/tasks/TASK-marriage-match-p1-20261017.md @@ -5,7 +5,7 @@ - `origin/staging` = `f95eb670b`(开工前 `git fetch origin --prune`,以最新 `origin/staging` 为准) - 分支:`codex/marriage-match-p1-20261017`;工作树:`.worktrees/marriage-match-p1-20261017` - **前置**:~~档案上限单先合入~~ 第六版起本单不碰前端页面(D16),**不再依赖 #78,可以直接开工**。 -- **后续单**:`TASK-synastry-full-report-20261018.md`(#82,完整合盘报告)在本单与 #78、#81 合入后开工,复用本单的引擎、证据包接口、存储与服务端函数;选人页和所有入口改动都在那一单。 +- **后续单**:`TASK-synastry-full-report-20261018.md`(#82,完整合盘报告)在本单与 #78、#81 合入后开工,复用本单的引擎、证据包接口、存储与服务端函数;选人页和所有入口改动都在那一单。证据包不入库。本单只存两个 profile 的配对结果(`structured_result.default` / `raman`)。#82 要证据包时自己调证据包接口;已保存命中时服务端函数不返回证据包。 - 原型:https://claude.ai/artifact/Tq6vhXFMnGitDZhJdamaHe (v8;本单没有任何页面,原型给的是后续单的样子) ## 现状实证(按符号定位) diff --git a/frontend/src/app/api/synastry-reports/route.ts b/frontend/src/app/api/synastry-reports/route.ts index bf13c7cd..3a6b8de7 100644 --- a/frontend/src/app/api/synastry-reports/route.ts +++ b/frontend/src/app/api/synastry-reports/route.ts @@ -29,6 +29,7 @@ export async function GET() { .from("synastry_reports") .select("id, partner_name, report, created_at") .eq("user_id", user.id) + .is("rules_version", null) .order("created_at", { ascending: false }) .limit(10); diff --git a/frontend/src/lib/marriage-match-service.ts b/frontend/src/lib/marriage-match-service.ts index 08642ad8..52694463 100644 --- a/frontend/src/lib/marriage-match-service.ts +++ b/frontend/src/lib/marriage-match-service.ts @@ -44,10 +44,18 @@ export type MarriageMatchPacket = { raman_marriage_match: unknown; }; +/** Stored pair result. `default` is dossier.marriage_match; `raman` is the Raman profile. */ +export type MarriageMatchStructured = { + rules_version: string; + default: unknown; + raman: unknown; +}; + export type MarriageMatchRead = { rulesVersion: string; cached: boolean; - result: MarriageMatchPacket; + structured: MarriageMatchStructured; + dossier?: unknown; }; type BirthBody = { @@ -145,11 +153,41 @@ function isPacket(value: unknown): value is MarriageMatchPacket { return typeof record.rules_version === "string" && "dossier" in record && "raman_marriage_match" in record; } +function isStructured(value: unknown): value is MarriageMatchStructured { + if (!value || typeof value !== "object" || Array.isArray(value)) return false; + const record = value as Record; + return typeof record.rules_version === "string" + && "default" in record + && "raman" in record + && !("dossier" in record) + && !("chart_snapshots" in record); +} + +function structuredFromPacket(packet: MarriageMatchPacket): MarriageMatchStructured { + const dossier = packet.dossier; + if (!dossier || typeof dossier !== "object" || Array.isArray(dossier)) { + throw new MarriageMatchServiceError("engine_failed"); + } + const marriageMatch = (dossier as { marriage_match?: unknown }).marriage_match; + if (marriageMatch == null || packet.raman_marriage_match == null) { + throw new MarriageMatchServiceError("engine_failed"); + } + return { + rules_version: packet.rules_version, + default: marriageMatch, + raman: packet.raman_marriage_match, + }; +} + +function isUniqueViolation(error: unknown): boolean { + return Boolean(error && typeof error === "object" && (error as { code?: unknown }).code === "23505"); +} + async function findSaved( db: MarriageMatchDb, userId: string, pair: ReturnType, -): Promise { +): Promise { const { data, error } = await db .from("synastry_reports") .select("structured_result") @@ -163,7 +201,7 @@ async function findSaved( if (error) throw new MarriageMatchServiceError("store_failed"); if (!data || typeof data !== "object") return null; const stored = (data as { structured_result?: unknown }).structured_result; - return isPacket(stored) ? stored : null; + return isStructured(stored) ? stored : null; } export async function readOrComputeMarriageMatch(input: { @@ -208,7 +246,7 @@ async function readResolvedPair(input: { ); const saved = await findSaved(input.supabase, input.userId, pair); if (saved) { - return { rulesVersion: MARRIAGE_MATCH_RULES_VERSION, cached: true, result: saved }; + return { rulesVersion: MARRIAGE_MATCH_RULES_VERSION, cached: true, structured: saved }; } let computed: unknown; @@ -224,18 +262,28 @@ async function readResolvedPair(input: { if (!isPacket(computed) || computed.rules_version !== MARRIAGE_MATCH_RULES_VERSION) { throw new MarriageMatchServiceError("engine_failed"); } + const structured = structuredFromPacket(computed); const inserted = await input.supabase.from("synastry_reports").insert({ user_id: input.userId, partner_name: "婚恋合盘", - report: computed, + report: structured, subject_low: pair.subjectLow, subject_high: pair.subjectHigh, fingerprint_low: pair.fingerprintLow, fingerprint_high: pair.fingerprintHigh, rules_version: MARRIAGE_MATCH_RULES_VERSION, - structured_result: computed, + structured_result: structured, }); + if (isUniqueViolation(inserted.error)) { + const raced = await findSaved(input.supabase, input.userId, pair); + if (raced) return { rulesVersion: MARRIAGE_MATCH_RULES_VERSION, cached: true, structured: raced }; + } if (inserted.error) throw new MarriageMatchServiceError("store_failed"); - return { rulesVersion: MARRIAGE_MATCH_RULES_VERSION, cached: false, result: computed }; + return { + rulesVersion: MARRIAGE_MATCH_RULES_VERSION, + cached: false, + structured, + dossier: computed.dossier, + }; } diff --git a/frontend/tests/database-marriage-match.test.ts b/frontend/tests/database-marriage-match.test.ts index 3c80faea..20772f07 100644 --- a/frontend/tests/database-marriage-match.test.ts +++ b/frontend/tests/database-marriage-match.test.ts @@ -43,12 +43,38 @@ test("marriage match rows are reused by fingerprint and removed with the person" subject_low, subject_high, fingerprint_low, fingerprint_high, rules_version, structured_result ) values ( - '${userId}', '婚恋合盘', '{"rules_version":"${rulesVersion}","dossier":{},"raman_marriage_match":{}}'::jsonb, + '${userId}', '婚恋合盘', '{"rules_version":"${rulesVersion}","default":{},"raman":{}}'::jsonb, '${personId}', 'self', '${fingerprint}', '${otherFingerprint}', - '${rulesVersion}', '{"rules_version":"${rulesVersion}","dossier":{},"raman_marriage_match":{}}'::jsonb + '${rulesVersion}', '{"rules_version":"${rulesVersion}","default":{},"raman":{}}'::jsonb ); `); + const shape = lastLine(fixture.psql(` + select (octet_length(structured_result::text) < 65536) + and (structured_result ? 'default') + and (structured_result ? 'raman') + and not (structured_result ? 'dossier') + and not (structured_result ? 'chart_snapshots') + from public.synastry_reports + where user_id = '${userId}'; + `)); + assert.equal(shape, "t"); + + assert.throws(() => fixture.psql(` + insert into public.synastry_reports ( + user_id, partner_name, report, + subject_low, subject_high, fingerprint_low, fingerprint_high, + rules_version, structured_result + ) values ( + '${userId}', '婚恋合盘', '{"rules_version":"${rulesVersion}","default":{},"raman":{}}'::jsonb, + '${personId}', 'self', '${fingerprint}', '${otherFingerprint}', + '${rulesVersion}', '{"rules_version":"${rulesVersion}","default":{},"raman":{}}'::jsonb + ); + `), (error: unknown) => { + const stderr = error && typeof error === "object" && "stderr" in error ? String(error.stderr) : ""; + return /duplicate key|23505/.test(`${stderr}\n${String(error)}`); + }); + const again = lastLine(fixture.psql(` select count(*) from public.synastry_reports where user_id = '${userId}' diff --git a/frontend/tests/marriage-match-service.test.ts b/frontend/tests/marriage-match-service.test.ts index 6355fba0..72facd12 100644 --- a/frontend/tests/marriage-match-service.test.ts +++ b/frontend/tests/marriage-match-service.test.ts @@ -42,10 +42,17 @@ const otherRow = { ayanamsa: "raman", }; +const defaultMatch = { product: "marriage_match_product_v1", total_score: 19 }; +const ramanMatch = { product: "marriage_match_product_v1", profile: "raman_muhurtha_v1" }; const packet: MarriageMatchPacket = { rules_version: MARRIAGE_MATCH_RULES_VERSION, - dossier: { schema: "fixture" }, - raman_marriage_match: { product: "marriage_match_product_v1" }, + dossier: { schema: "fixture", marriage_match: defaultMatch, chart_snapshots: { person_a: {} } }, + raman_marriage_match: ramanMatch, +}; +const structured = { + rules_version: MARRIAGE_MATCH_RULES_VERSION, + default: defaultMatch, + raman: ramanMatch, }; function createDb(other = otherRow) { @@ -96,7 +103,7 @@ test("a saved pair is returned without calling the engine", async () => { fingerprint_low: marriageMatchFingerprint(otherRow), fingerprint_high: marriageMatchFingerprint(selfRow), rules_version: MARRIAGE_MATCH_RULES_VERSION, - structured_result: packet, + structured_result: structured, }); let calls = 0; const read = await readOrComputeMarriageMatch({ @@ -111,7 +118,8 @@ test("a saved pair is returned without calling the engine", async () => { }); assert.equal(calls, 0); assert.equal(read.cached, true); - assert.equal(read.result, packet); + assert.deepEqual(read.structured, structured); + assert.equal("dossier" in read, false); }); test("the same person twice is rejected", async () => { @@ -157,10 +165,17 @@ test("swapped ids hit the same stored row", async () => { const second = await readOrComputeMarriageMatch({ ...input, personAId: otherId, personBId: "self" }); assert.equal(calls, 1); assert.equal(first.cached, false); + assert.equal("dossier" in first, true); assert.equal(second.cached, true); + assert.equal("dossier" in second, false); assert.equal(saved.length, 1); assert.equal(saved[0]?.subject_low, otherId); assert.equal(saved[0]?.subject_high, "self"); + const stored = saved[0]?.structured_result as Record; + assert.equal("dossier" in stored, false); + assert.equal("chart_snapshots" in stored, false); + assert.deepEqual(stored, structured); + assert.deepEqual(saved[0]?.report, structured); }); test("a changed birth fingerprint is not the stored row", async () => { @@ -168,6 +183,77 @@ test("a changed birth fingerprint is not the stored row", async () => { assert.notEqual(marriageMatchFingerprint(otherRow), marriageMatchFingerprint(changed)); }); +test("a unique conflict reads the row the other request stored", async () => { + const { db } = createDb(); + let selects = 0; + const racing: MarriageMatchDb = { + from(table) { + if (table !== "synastry_reports") return db.from(table); + return { + select() { + const query = { + eq() { + return query; + }, + async maybeSingle() { + selects += 1; + if (selects === 1) return { data: null, error: null }; + return { data: { structured_result: structured }, error: null }; + }, + }; + return query; + }, + async insert() { + return { error: { code: "23505", message: "duplicate key" } }; + }, + }; + }, + }; + const read = await readOrComputeMarriageMatch({ + supabase: racing, + userId, + personAId: "self", + personBId: otherId, + postDossier: async () => packet, + }); + assert.equal(read.cached, true); + assert.deepEqual(read.structured, structured); + assert.equal("dossier" in read, false); + assert.equal(selects, 2); +}); + +test("a large dossier is not what gets stored", async () => { + const golden = JSON.parse(readFileSync(new URL("./fixtures/marriage-match-snapshot.json", import.meta.url), "utf8")) as unknown; + const dossier = { + marriage_match: golden, + chart_snapshots: { person_a: { padding: "x".repeat(400_000) } }, + }; + const { db, saved } = createDb(); + const read = await readOrComputeMarriageMatch({ + supabase: db, + userId, + personAId: "self", + personBId: otherId, + postDossier: async () => ({ + rules_version: MARRIAGE_MATCH_RULES_VERSION, + dossier, + raman_marriage_match: golden, + }), + }); + const stored = saved[0]?.structured_result as Record; + assert.equal(read.cached, false); + assert.equal(read.dossier, dossier); + assert.deepEqual(stored, { + rules_version: MARRIAGE_MATCH_RULES_VERSION, + default: golden, + raman: golden, + }); + assert.equal("dossier" in stored, false); + assert.equal("chart_snapshots" in stored, false); + assert.equal(saved[0]?.report, stored); + assert.ok(Buffer.byteLength(JSON.stringify(stored)) < 64 * 1024); +}); + test("the service does not charge usage", () => { const source = readFileSync(new URL("../src/lib/marriage-match-service.ts", import.meta.url), "utf8"); assert.equal(source.includes("authorizeUsage"), false); diff --git a/frontend/tests/synastry-reports-history.test.ts b/frontend/tests/synastry-reports-history.test.ts new file mode 100644 index 00000000..ec520c3e --- /dev/null +++ b/frontend/tests/synastry-reports-history.test.ts @@ -0,0 +1,10 @@ +import assert from "node:assert/strict"; +import { readFileSync } from "node:fs"; +import test from "node:test"; + +test("old synastry history lists only rows that have no rules version", () => { + const source = readFileSync(new URL("../src/app/api/synastry-reports/route.ts", import.meta.url), "utf8"); + const get = source.slice(source.indexOf("export async function GET"), source.indexOf("export async function POST")); + assert.match(get, /\.select\("id, partner_name, report, created_at"\)/); + assert.match(get, /\.is\("rules_version", null\)/); +});