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 <cursoragent@cursor.com>
This commit is contained in:
jesse-ux
2026-10-18 19:14:31 +08:00
co-authored by Grok (agent loop) Cursor
parent 52160a9cef
commit cf5a62c684
10 changed files with 206 additions and 18 deletions
+1 -1
View File
@@ -3,7 +3,7 @@
## 2026-10-18 — 婚恋合盘改为上游七项口径,本单只做计算和存储
- 合盘计算改为上游模块:七项、满分 35,两个方向取平均,不按性别比较。火星冲克和婚姻宫压力分开保存。证据包接口会先排两人的完整星盘。
- 同一对人、出生资料和规则版本都没变时,服务端直接读已保存的结果,不扣点。页面和入口留到下一单。
- 同一对人、出生资料和规则版本都没变时,服务端直接读已保存的两个配对结果,不扣点,也不把整份证据包存进数据库。页面和入口留到下一单。
- 旧的 `synastry` 命令和模块已删除。Skill 版本不变(BUG-1370、BUG-1371、BUG-1372、BUG-1374)。
## 2026-10-18 — 星盘档案最多保存 20 个人
+2 -1
View File
@@ -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`。
@@ -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 / 快速门 | 未跑 | 留给验收方。 |
## 偏离
- 不做页面、不扣点,与第六版一致。
+2 -2
View File
@@ -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`;动表 |
@@ -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;本单没有任何页面,原型给的是后续单的样子)
## 现状实证(按符号定位)
@@ -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);
+55 -7
View File
@@ -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<string, unknown>;
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<typeof canonicalPair>,
): Promise<MarriageMatchPacket | null> {
): Promise<MarriageMatchStructured | null> {
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,
};
}
+28 -2
View File
@@ -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}'
+90 -4
View File
@@ -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<string, unknown>;
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<string, unknown>;
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);
@@ -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\)/);
});