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:
co-authored by
Grok (agent loop)
Cursor
parent
52160a9cef
commit
cf5a62c684
+1
-1
@@ -3,7 +3,7 @@
|
||||
## 2026-10-18 — 婚恋合盘改为上游七项口径,本单只做计算和存储
|
||||
|
||||
- 合盘计算改为上游模块:七项、满分 35,两个方向取平均,不按性别比较。火星冲克和婚姻宫压力分开保存。证据包接口会先排两人的完整星盘。
|
||||
- 同一对人、出生资料和规则版本都没变时,服务端直接读已保存的结果,不扣点。页面和入口留到下一单。
|
||||
- 同一对人、出生资料和规则版本都没变时,服务端直接读已保存的两个配对结果,不扣点,也不把整份证据包存进数据库。页面和入口留到下一单。
|
||||
- 旧的 `synastry` 命令和模块已删除。Skill 版本不变(BUG-1370、BUG-1371、BUG-1372、BUG-1374)。
|
||||
|
||||
## 2026-10-18 — 星盘档案最多保存 20 个人
|
||||
|
||||
+2
-1
@@ -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 / 快速门 | 未跑 | 留给验收方。 |
|
||||
|
||||
## 偏离
|
||||
|
||||
- 不做页面、不扣点,与第六版一致。
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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}'
|
||||
|
||||
@@ -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\)/);
|
||||
});
|
||||
Reference in New Issue
Block a user