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:
Jesse_Chen
2026-10-19 09:15:55 +08:00
co-authored by Claude Opus 5.5
169 changed files with 89684 additions and 782 deletions
+1 -1
View File
@@ -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);
+6 -1
View File
@@ -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)";
+2 -1
View File
@@ -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);
+289
View File
@@ -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,
};
}
+8 -1
View File
@@ -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"]) {
+2 -2
View File
@@ -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;