{title}
diff --git a/frontend/src/lib/synastry-display-order.ts b/frontend/src/lib/synastry-display-order.ts
new file mode 100644
index 00000000..07b5b916
--- /dev/null
+++ b/frontend/src/lib/synastry-display-order.ts
@@ -0,0 +1,15 @@
+/** Selection order for a synastry report. The pair key stays sorted for dedup. */
+
+export const SYNASTRY_ORDER_SECTION = "synastry_order";
+
+export function synastryDisplaySubjectIds(low: string, high: string, order: unknown): [string, string] {
+ const row = order && typeof order === "object" && !Array.isArray(order)
+ ? order as Record
+ : null;
+ const first = typeof row?.first === "string" ? row.first : "";
+ const second = typeof row?.second === "string" ? row.second : "";
+ if (first !== second && (first === low || first === high) && (second === low || second === high)) {
+ return [first, second];
+ }
+ return [low, high];
+}
diff --git a/frontend/src/lib/synastry-full-create.ts b/frontend/src/lib/synastry-full-create.ts
index 9526927a..28ecfdf8 100644
--- a/frontend/src/lib/synastry-full-create.ts
+++ b/frontend/src/lib/synastry-full-create.ts
@@ -5,6 +5,7 @@ import type { DataQueryBuilder } from "@/lib/db/data-client";
import { consultationSubjectFailureResponse } from "@/lib/consultation-subject-resolver";
import { FeaturePricingError, resolveFeaturePricing } from "@/lib/feature-pricing";
import { MARRIAGE_MATCH_RULES_VERSION, marriageMatchFingerprint } from "@/lib/marriage-match-service";
+import { SYNASTRY_ORDER_SECTION } from "@/lib/synastry-display-order";
import { loadLanguageModelCatalog } from "@/lib/model-catalog";
import { REPORT_STABLE_CODES } from "@/lib/personal-report-codes";
import { readPersonalReportDailyLimit } from "@/lib/personal-report-entitlement";
@@ -215,6 +216,24 @@ export async function createSynastryFullReport(input: Readonly<{
await release(REPORT_STABLE_CODES.generationInProgress);
return { status: 409, body: { error: "已有报告正在生成中", code: REPORT_STABLE_CODES.generationInProgress } };
}
+ const orderSaved = await (input.admin as {
+ from(table: string): {
+ insert(row: Record): Promise<{ error: { message?: string } | null }>;
+ };
+ }).from("personal_report_sections").insert({
+ user_id: input.userId,
+ request_id: requestId,
+ section_id: SYNASTRY_ORDER_SECTION,
+ status: "ready",
+ payload: { first: subjectA, second: subjectB },
+ attempt_count: 1,
+ max_attempts: 1,
+ });
+ if (orderSaved.error) {
+ await persistence.markFailed(input.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,
diff --git a/frontend/src/lib/synastry-full-run.ts b/frontend/src/lib/synastry-full-run.ts
index 3262a35c..c047b8d2 100644
--- a/frontend/src/lib/synastry-full-run.ts
+++ b/frontend/src/lib/synastry-full-run.ts
@@ -14,6 +14,7 @@ import { PersonalReportWorkerError } from "@/lib/personal-report-worker-core";
import type { PersonalReportRecord } from "@/lib/personal-report-service-core";
import { loadSubjectBirth } from "@/lib/subject-birth";
import { marriageMatchBirthBody } from "@/lib/marriage-match-service";
+import { SYNASTRY_ORDER_SECTION, synastryDisplaySubjectIds } from "@/lib/synastry-display-order";
import { timelineForPair, writeSynastryLanguage, type SynastrySnapshot } from "@/lib/synastry-full-generate";
import type { TimingPeriod } from "@/lib/synastry-full-timeline";
@@ -160,9 +161,21 @@ export async function runSynastryFullReport(input: Readonly<{
}
await input.onProgress?.({ phase: "computing_charts", completed: 0, total: 9 });
const today = new Date().toISOString().slice(0, 10);
+ const orderRow = await input.admin.from("personal_report_sections")
+ .select("payload")
+ .eq("user_id", input.report.userId)
+ .eq("request_id", input.report.requestId)
+ .eq("section_id", SYNASTRY_ORDER_SECTION)
+ .maybeSingle();
+ const orderPayload = orderRow.error ? null : asRecord(orderRow.data)?.payload ?? null;
+ const [firstId, secondId] = synastryDisplaySubjectIds(
+ input.report.subjectLow,
+ input.report.subjectHigh,
+ orderPayload,
+ );
const [personA, personB] = await Promise.all([
- loadSubjectBirth(input.admin, input.report.userId, input.report.subjectLow),
- loadSubjectBirth(input.admin, input.report.userId, input.report.subjectHigh),
+ loadSubjectBirth(input.admin, input.report.userId, firstId),
+ loadSubjectBirth(input.admin, input.report.userId, secondId),
]);
const packet = asRecord(await postJson("/api/synastry_full_dossier", {
person_a: marriageMatchBirthBody(personA.row, today),
diff --git a/frontend/tests/database-synastry-full-create.test.ts b/frontend/tests/database-synastry-full-create.test.ts
index 73ae711a..5ac41858 100644
--- a/frontend/tests/database-synastry-full-create.test.ts
+++ b/frontend/tests/database-synastry-full-create.test.ts
@@ -123,6 +123,10 @@ test("createSynastryFullReport reserves synastry.full and enqueues a job on the
assert.equal(fixture.psql(`select status from public.personal_report_jobs where user_id = '${userId}'`), "queued");
assert.equal(fixture.psql(`select status from public.usage_reservations where user_id = '${userId}'`), "reserved");
assert.equal(fixture.psql(`select credits from public.profiles where id = '${userId}'`), "81");
+ assert.equal(fixture.psql(`select payload->>'first' from public.personal_report_sections where user_id = '${userId}' and section_id = 'synastry_order'`), "self");
+ 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");
catalog.setModelCatalogIoForTests(null);
catalog.invalidateLanguageModelCatalog();
} finally {
diff --git a/frontend/tests/synastry-display-order.test.ts b/frontend/tests/synastry-display-order.test.ts
new file mode 100644
index 00000000..3a246691
--- /dev/null
+++ b/frontend/tests/synastry-display-order.test.ts
@@ -0,0 +1,21 @@
+import assert from "node:assert/strict";
+import test from "node:test";
+import { readFileSync } from "node:fs";
+
+import { synastryDisplaySubjectIds } from "../src/lib/synastry-display-order.ts";
+import { synastryFollowupTitle } from "../src/lib/synastry-report-session.ts";
+
+test("picker order outranks the sorted pair key for the title and the chat", () => {
+ const ids = synastryDisplaySubjectIds("person-yi", "self", { first: "self", second: "person-yi" });
+ assert.deepEqual(ids, ["self", "person-yi"]);
+ const labels: Record = { self: "甲", "person-yi": "乙" };
+ const names = { a: labels[ids[0]] ?? "", b: labels[ids[1]] ?? "" };
+ assert.equal(`${names.a} × ${names.b} · 婚恋合盘`, "甲 × 乙 · 婚恋合盘");
+ assert.equal(synastryFollowupTitle(names), "甲 × 乙 · 合盘追问");
+ assert.deepEqual(synastryDisplaySubjectIds("person-yi", "self", null), ["person-yi", "self"]);
+ const wait = readFileSync(new URL("../src/components/synastry/synastry-wait.tsx", import.meta.url), "utf8");
+ const run = readFileSync(new URL("../src/lib/synastry-full-run.ts", import.meta.url), "utf8");
+ assert.match(wait, /\$\{a\} × \$\{b\}/);
+ assert.match(run, /synastryDisplaySubjectIds\(/);
+ assert.match(run, /cardSummary: `\$\{names\.a\} × \$\{names\.b\} · 婚恋合盘`/);
+});