merge origin/staging into narrower delivery (只碰文档;显示漏段 BUG-1375 顺延为 BUG-1392, issue #77)
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LutgzyJqNhPpZy1nFsJtN3
This commit is contained in:
@@ -9,7 +9,7 @@ import { scheduleConsultPacketWarm } from "@/lib/consult-packet-warm";
|
||||
import { createServerSupabaseClient } from "@/lib/supabase/server";
|
||||
import { isSupabaseConfigurationError } from "@/lib/supabase/config";
|
||||
|
||||
const SUBJECT_LIMIT_MESSAGE = "最多保存 5 个人的星盘";
|
||||
const SUBJECT_LIMIT_MESSAGE = `最多保存 ${MAX_SAVED_SUBJECTS} 个人的星盘`;
|
||||
|
||||
function errorMessage(error: unknown, fallback: string) {
|
||||
return error && typeof error === "object" && "message" in error && typeof error.message === "string"
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -978,7 +978,7 @@ section[popover]:has(> [data-sonner-toaster]), section[popover][aria-label^="页
|
||||
.chat-profile-picker-status p { margin: 0; }
|
||||
.chat-profile-picker-waiting { margin-top: var(--space-2); }
|
||||
.chat-profile-picker-retry { min-height: 44px; margin-top: var(--space-2); padding: 0 var(--space-3); border: 0; border-radius: var(--radius-md); background: transparent; color: var(--color-action); cursor: pointer; font: inherit; font-size: var(--type-caption); }
|
||||
.chat-profile-picker-options { display: grid; gap: var(--space-1); margin: var(--space-2) 0 0; }
|
||||
.chat-profile-picker-options { display: grid; gap: var(--space-1); max-height: min(50vh, 320px); margin: var(--space-2) 0 0; overflow-x: hidden; overflow-y: auto; overscroll-behavior: contain; -webkit-overflow-scrolling: touch; }
|
||||
.chat-profile-picker-option { width: 100%; min-height: 44px; display: grid; grid-template-columns: minmax(0, 1fr) 20px; align-items: center; gap: var(--space-3); padding: var(--space-2) var(--space-3); border: 1px solid transparent; border-radius: var(--radius-md); background: transparent; color: var(--color-ink-secondary); cursor: pointer; text-align: left; font: inherit; transition: border-color 120ms ease-out, background-color 120ms ease-out, color 120ms ease-out; }
|
||||
.chat-profile-picker-option[aria-disabled="true"] { cursor: default; color: var(--color-ink-tertiary); }
|
||||
.chat-profile-picker-option[data-selected] { border-color: var(--color-border); background: var(--color-selected); color: var(--color-ink); }
|
||||
@@ -6266,9 +6266,14 @@ input:not([type="radio"]):not([type="checkbox"]):not([class^="ant-"]):not([class
|
||||
.people-archive-rows {
|
||||
display: grid;
|
||||
gap: var(--space-2);
|
||||
max-height: min(70vh, 640px);
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
overflow-x: hidden;
|
||||
overflow-y: auto;
|
||||
overscroll-behavior: contain;
|
||||
list-style: none;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
}
|
||||
.people-archive-row {
|
||||
display: grid;
|
||||
|
||||
@@ -44,7 +44,7 @@ const DELETE_USAGE_UNAVAILABLE = "暂时查不到这个人的对话数量,稍
|
||||
/** `?person=new` marks the add form; a reload or Forward onto it shows the list. */
|
||||
const NEW_PERSON_PARAM = "new";
|
||||
|
||||
const LIMIT_COPY = "最多保存 5 个人的星盘";
|
||||
const LIMIT_COPY = `最多保存 ${MAX_SAVED_SUBJECTS} 个人的星盘`;
|
||||
|
||||
/** D1: below this width the page is list → detail; at and above it, two columns. */
|
||||
const PEOPLE_TWO_COLUMN_QUERY = "(min-width: 1024px)";
|
||||
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
} from "@/lib/chat-profile-picker-model";
|
||||
import { OrbitWaiting } from "@/components/orbit-waiting";
|
||||
import type { ChartLibraryRecord } from "@/lib/home-types";
|
||||
import { MAX_SAVED_SUBJECTS } from "@/lib/subject-birth";
|
||||
|
||||
export type SubjectSwitcherProps = {
|
||||
readonly library: readonly ChartLibraryRecord[];
|
||||
@@ -39,7 +40,7 @@ export function SubjectSwitcher({
|
||||
const [pendingId, setPendingId] = useState<string | null>(null);
|
||||
const triggerRef = useRef<HTMLButtonElement | null>(null);
|
||||
const listId = useId();
|
||||
const rows = chatProfilePickerRows({ status, library, selectedId }).slice(0, 5);
|
||||
const rows = chatProfilePickerRows({ status, library, selectedId }).slice(0, MAX_SAVED_SUBJECTS);
|
||||
|
||||
function closePicker() {
|
||||
setOpen(false);
|
||||
|
||||
@@ -0,0 +1,289 @@
|
||||
/**
|
||||
* Server-side marriage-match lookup. No route and no credit charge.
|
||||
* The same unordered pair, birth fingerprints, and rules version reuse the
|
||||
* stored engine packet.
|
||||
*/
|
||||
|
||||
import { createHash } from "node:crypto";
|
||||
|
||||
import { globalBirthProfileFromAccountRow } from "./server-owned-birth-profile.ts";
|
||||
import {
|
||||
ConsultationSubjectError,
|
||||
loadSubjectBirth,
|
||||
subjectIdFromQuery,
|
||||
type ResolvedSubjectBirth,
|
||||
} from "./subject-birth.ts";
|
||||
|
||||
export const MARRIAGE_MATCH_RULES_VERSION = "bde5d615:marriage_match_product_v1";
|
||||
|
||||
export class MarriageMatchServiceError extends Error {
|
||||
readonly code: "same_person" | "subject_incomplete" | "engine_failed" | "store_failed";
|
||||
|
||||
constructor(code: "same_person" | "subject_incomplete" | "engine_failed" | "store_failed") {
|
||||
super(code);
|
||||
this.name = "MarriageMatchServiceError";
|
||||
this.code = code;
|
||||
}
|
||||
}
|
||||
|
||||
type EqQuery = {
|
||||
eq: (column: string, value: string) => EqQuery;
|
||||
maybeSingle: () => Promise<{ data: unknown; error: unknown }>;
|
||||
};
|
||||
|
||||
export type MarriageMatchDb = {
|
||||
from: (table: string) => {
|
||||
select: (columns: string) => EqQuery;
|
||||
insert: (row: Record<string, unknown>) => Promise<{ error: unknown }>;
|
||||
};
|
||||
};
|
||||
|
||||
export type MarriageMatchPacket = {
|
||||
rules_version: string;
|
||||
dossier: unknown;
|
||||
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;
|
||||
structured: MarriageMatchStructured;
|
||||
dossier?: unknown;
|
||||
};
|
||||
|
||||
type BirthBody = {
|
||||
year: number;
|
||||
month: number;
|
||||
day: number;
|
||||
hour: number;
|
||||
minute: number;
|
||||
second: number;
|
||||
lat: number;
|
||||
lon: number;
|
||||
tz: number;
|
||||
ayanamsa: string;
|
||||
node_mode: "mean";
|
||||
today: string;
|
||||
};
|
||||
|
||||
function engineBirth(row: Record<string, unknown>, today: string): BirthBody {
|
||||
const profile = globalBirthProfileFromAccountRow(row);
|
||||
const dateMatch = /^(\d{4})-(\d{2})-(\d{2})$/.exec(profile.date ?? "");
|
||||
const timeMatch = /^(\d{2}):(\d{2})/.exec(profile.time ?? "");
|
||||
const latitude = profile.latitude;
|
||||
const longitude = profile.longitude;
|
||||
const timezoneOffset = profile.timezoneOffset;
|
||||
const ayanamsa = profile.ayanamsa;
|
||||
if (
|
||||
!dateMatch
|
||||
|| !timeMatch
|
||||
|| latitude == null
|
||||
|| longitude == null
|
||||
|| timezoneOffset == null
|
||||
|| !ayanamsa
|
||||
) {
|
||||
throw new MarriageMatchServiceError("subject_incomplete");
|
||||
}
|
||||
return {
|
||||
year: Number(dateMatch[1]),
|
||||
month: Number(dateMatch[2]),
|
||||
day: Number(dateMatch[3]),
|
||||
hour: Number(timeMatch[1]),
|
||||
minute: Number(timeMatch[2]),
|
||||
second: 0,
|
||||
lat: latitude,
|
||||
lon: longitude,
|
||||
tz: timezoneOffset,
|
||||
ayanamsa,
|
||||
node_mode: "mean",
|
||||
today,
|
||||
};
|
||||
}
|
||||
|
||||
export function marriageMatchFingerprint(row: Record<string, unknown>): string {
|
||||
const birth = engineBirth(row, "1970-01-01");
|
||||
const stable = {
|
||||
year: birth.year,
|
||||
month: birth.month,
|
||||
day: birth.day,
|
||||
hour: birth.hour,
|
||||
minute: birth.minute,
|
||||
second: birth.second,
|
||||
lat: birth.lat,
|
||||
lon: birth.lon,
|
||||
tz: birth.tz,
|
||||
ayanamsa: birth.ayanamsa,
|
||||
node_mode: birth.node_mode,
|
||||
};
|
||||
return createHash("sha256").update(JSON.stringify(stable)).digest("hex");
|
||||
}
|
||||
|
||||
function canonicalPair(
|
||||
leftId: string,
|
||||
rightId: string,
|
||||
leftFingerprint: string,
|
||||
rightFingerprint: string,
|
||||
) {
|
||||
if (leftId <= rightId) {
|
||||
return {
|
||||
subjectLow: leftId,
|
||||
subjectHigh: rightId,
|
||||
fingerprintLow: leftFingerprint,
|
||||
fingerprintHigh: rightFingerprint,
|
||||
};
|
||||
}
|
||||
return {
|
||||
subjectLow: rightId,
|
||||
subjectHigh: leftId,
|
||||
fingerprintLow: rightFingerprint,
|
||||
fingerprintHigh: leftFingerprint,
|
||||
};
|
||||
}
|
||||
|
||||
function isPacket(value: unknown): value is MarriageMatchPacket {
|
||||
if (!value || typeof value !== "object" || Array.isArray(value)) return false;
|
||||
const record = value as Record<string, unknown>;
|
||||
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<MarriageMatchStructured | null> {
|
||||
const { data, error } = await db
|
||||
.from("synastry_reports")
|
||||
.select("structured_result")
|
||||
.eq("user_id", userId)
|
||||
.eq("subject_low", pair.subjectLow)
|
||||
.eq("subject_high", pair.subjectHigh)
|
||||
.eq("fingerprint_low", pair.fingerprintLow)
|
||||
.eq("fingerprint_high", pair.fingerprintHigh)
|
||||
.eq("rules_version", MARRIAGE_MATCH_RULES_VERSION)
|
||||
.maybeSingle();
|
||||
if (error) throw new MarriageMatchServiceError("store_failed");
|
||||
if (!data || typeof data !== "object") return null;
|
||||
const stored = (data as { structured_result?: unknown }).structured_result;
|
||||
return isStructured(stored) ? stored : null;
|
||||
}
|
||||
|
||||
export async function readOrComputeMarriageMatch(input: {
|
||||
supabase: MarriageMatchDb;
|
||||
userId: string;
|
||||
personAId: string;
|
||||
personBId: string;
|
||||
postDossier: (body: { person_a: BirthBody; person_b: BirthBody }) => Promise<unknown>;
|
||||
today?: string;
|
||||
}): Promise<MarriageMatchRead> {
|
||||
const personAId = subjectIdFromQuery(input.personAId);
|
||||
const personBId = subjectIdFromQuery(input.personBId);
|
||||
if (personAId === personBId) throw new MarriageMatchServiceError("same_person");
|
||||
|
||||
const [personA, personB] = await Promise.all([
|
||||
loadSubjectBirth(input.supabase, input.userId, personAId),
|
||||
loadSubjectBirth(input.supabase, input.userId, personBId),
|
||||
]);
|
||||
return readResolvedPair({
|
||||
supabase: input.supabase,
|
||||
userId: input.userId,
|
||||
personA,
|
||||
personB,
|
||||
postDossier: input.postDossier,
|
||||
today: input.today ?? new Date().toISOString().slice(0, 10),
|
||||
});
|
||||
}
|
||||
|
||||
async function readResolvedPair(input: {
|
||||
supabase: MarriageMatchDb;
|
||||
userId: string;
|
||||
personA: ResolvedSubjectBirth;
|
||||
personB: ResolvedSubjectBirth;
|
||||
postDossier: (body: { person_a: BirthBody; person_b: BirthBody }) => Promise<unknown>;
|
||||
today: string;
|
||||
}): Promise<MarriageMatchRead> {
|
||||
const pair = canonicalPair(
|
||||
input.personA.subjectId,
|
||||
input.personB.subjectId,
|
||||
marriageMatchFingerprint(input.personA.row),
|
||||
marriageMatchFingerprint(input.personB.row),
|
||||
);
|
||||
const saved = await findSaved(input.supabase, input.userId, pair);
|
||||
if (saved) {
|
||||
return { rulesVersion: MARRIAGE_MATCH_RULES_VERSION, cached: true, structured: saved };
|
||||
}
|
||||
|
||||
let computed: unknown;
|
||||
try {
|
||||
computed = await input.postDossier({
|
||||
person_a: engineBirth(input.personA.row, input.today),
|
||||
person_b: engineBirth(input.personB.row, input.today),
|
||||
});
|
||||
} catch (error) {
|
||||
if (error instanceof MarriageMatchServiceError || error instanceof ConsultationSubjectError) throw error;
|
||||
throw new MarriageMatchServiceError("engine_failed");
|
||||
}
|
||||
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: 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: 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,
|
||||
structured,
|
||||
dossier: computed.dossier,
|
||||
};
|
||||
}
|
||||
@@ -169,7 +169,14 @@ export function assembleReportFactTables(packet: unknown, language: ReportLangua
|
||||
} else if (id === "avasthas") {
|
||||
for (const [name, value] of entries(raw)) {
|
||||
if (!planet(name) || isBlocked(value)) continue;
|
||||
add(table, `${sourcePath}.${name}`, [L.planet(name, record(value).planet_cn), ...["Bala", "Jagrat", "Deeptadi", "Lajjitadi", "Shayanadi"].map(kind => L.text(at(value, `avasthas.${kind}.state`)))]);
|
||||
const cells = ["Bala", "Jagrat", "Deeptadi", "Lajjitadi", "Shayanadi"].map(kind => {
|
||||
const item = record(at(value, `avasthas.${kind}`));
|
||||
if (kind === "Deeptadi" && Array.isArray(item.applicable_states) && item.applicable_states.length) {
|
||||
return item.applicable_states.join(en ? ", " : "、");
|
||||
}
|
||||
return L.text(item.state);
|
||||
});
|
||||
add(table, `${sourcePath}.${name}`, [L.planet(name, record(value).planet_cn), ...cells]);
|
||||
}
|
||||
} else if (id === "special_points") {
|
||||
for (const kind of ["upagrahas.raw", "special_lagnas", "sensitive_points"]) {
|
||||
|
||||
@@ -41,8 +41,8 @@ export const CHART_SUBJECT_SELECT = "id,user_id,role,name,birth_date,reported_bi
|
||||
|
||||
export const CHART_SUBJECT_API_SELECT = "id,user_id,role,name,birth_date,reported_birth_time,birth_time_source,birth_time_period,declared_window_start,declared_window_end,uncertainty_before_minutes,uncertainty_after_minutes,latitude,longitude,timezone_id,timezone_offset,birth_place_label,birth_place_type,birth_place_provider,birth_place_provider_id,ayanamsa,active_birth_time,active_birth_date,active_birth_timezone_offset,birth_time_status,gender,profile,updated_at";
|
||||
|
||||
export const MAX_OTHER_SUBJECTS = 4;
|
||||
export const MAX_SAVED_SUBJECTS = 5;
|
||||
export const MAX_OTHER_SUBJECTS = 19;
|
||||
export const MAX_SAVED_SUBJECTS = 20;
|
||||
|
||||
export type ResolvedSubjectBirth = Readonly<{
|
||||
subjectId: string;
|
||||
|
||||
Reference in New Issue
Block a user