Persist synastry report history in Supabase

This commit is contained in:
732642856
2026-07-17 19:29:35 +08:00
parent aeb6852aa7
commit efaa8fa9b6
4 changed files with 217 additions and 3 deletions
@@ -0,0 +1,72 @@
import { NextResponse } from "next/server";
import { isSupabaseConfigurationError } from "@/lib/supabase/config";
import { createServerSupabaseClient } from "@/lib/supabase/server";
type SynastryReportPayload = {
id?: string;
partnerName?: string;
report?: unknown;
};
function errorMessage(error: unknown, fallback: string) {
return error instanceof Error ? error.message : fallback;
}
export async function GET() {
try {
const supabase = await createServerSupabaseClient();
const { data: { user } } = await supabase.auth.getUser();
if (!user) return NextResponse.json({ error: "请先登录" }, { status: 401 });
const { data, error } = await supabase
.from("synastry_reports")
.select("id, partner_name, report, created_at")
.eq("user_id", user.id)
.order("created_at", { ascending: false })
.limit(10);
if (error) throw error;
return NextResponse.json({ reports: data ?? [] });
} catch (error) {
if (isSupabaseConfigurationError(error)) {
return NextResponse.json({ error: "Supabase 尚未配置", code: "SUPABASE_NOT_CONFIGURED" }, { status: 503 });
}
return NextResponse.json({ error: errorMessage(error, "合盘历史暂时不可用") }, { status: 500 });
}
}
export async function POST(request: Request) {
try {
const body = await request.json().catch(() => null) as SynastryReportPayload | null;
if (!body?.report || typeof body.report !== "object" || Array.isArray(body.report)) {
return NextResponse.json({ error: "合盘报告格式不正确" }, { status: 400 });
}
const supabase = await createServerSupabaseClient();
const { data: { user } } = await supabase.auth.getUser();
if (!user) return NextResponse.json({ error: "请先登录" }, { status: 401 });
const partnerName = body.partnerName?.trim() || "对方";
const record = {
...(body.id ? { id: body.id } : {}),
user_id: user.id,
partner_name: partnerName,
report: body.report,
created_at: new Date().toISOString(),
};
const { data, error } = await supabase
.from("synastry_reports")
.insert(record)
.select("id, partner_name, report, created_at")
.single();
if (error) throw error;
return NextResponse.json({ report: data });
} catch (error) {
if (isSupabaseConfigurationError(error)) {
return NextResponse.json({ error: "Supabase 尚未配置", code: "SUPABASE_NOT_CONFIGURED" }, { status: 503 });
}
return NextResponse.json({ error: errorMessage(error, "合盘历史保存失败") }, { status: 500 });
}
}
+61 -3
View File
@@ -58,6 +58,12 @@ type SynastryReportCard = {
nextEvidence?: string[];
createdAt: number;
};
type SynastryReportApiRecord = {
id: string;
partner_name?: string;
report?: SynastryReportCard;
created_at?: string;
};
type ChatSession = { id: string; title: string; theme: Theme; modelId: string; messages: Message[]; updatedAt: number };
type RequestError = { sessionId: string; message: string };
type StreamingReply = { sessionId: string; text: string };
@@ -229,6 +235,38 @@ function readSynastryHistory(accountId: string): SynastryReportCard[] {
}
}
function writeSynastryHistory(accountId: string, history: SynastryReportCard[]) {
localStorage.setItem(synastryHistoryStorageKey(accountId), JSON.stringify(history.slice(0, 10)));
}
function normalizeSynastryReportApiRecord(record: SynastryReportApiRecord): SynastryReportCard | null {
if (!record.report || typeof record.report !== "object") return null;
return {
...record.report,
id: record.id,
partnerName: record.partner_name || record.report.partnerName || "对方",
createdAt: Date.parse(record.created_at || "") || record.report.createdAt || timestamp(),
};
}
async function fetchCloudSynastryHistory() {
const response = await fetch("/api/synastry-reports", { cache: "no-store" });
if (!response.ok) throw new Error("cloud_synastry_history_unavailable");
const payload = await response.json().catch(() => null) as { reports?: SynastryReportApiRecord[] } | null;
return (payload?.reports || []).map(normalizeSynastryReportApiRecord).filter(Boolean) as SynastryReportCard[];
}
async function saveCloudSynastryReport(report: SynastryReportCard) {
const response = await fetch("/api/synastry-reports", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ partnerName: report.partnerName, report }),
});
if (!response.ok) throw new Error("cloud_synastry_report_save_failed");
const payload = await response.json().catch(() => null) as { report?: SynastryReportApiRecord } | null;
return payload?.report ? normalizeSynastryReportApiRecord(payload.report) || report : report;
}
function normalizeChartLibraryApiRecord(record: ChartLibraryApiRecord): ChartLibraryRecord {
return {
id: record.role === "self" ? "self" : record.id,
@@ -636,6 +674,18 @@ export default function Home() {
.catch(() => {
// Cloud chart library is best-effort; local library remains usable.
});
void fetchCloudSynastryHistory()
.then((cloudHistory) => {
setSynastryHistory((current) => {
const byId = new Map([...current, ...cloudHistory].map((record) => [record.id, record] as const));
const next = [...byId.values()].sort((a, b) => b.createdAt - a.createdAt).slice(0, 10);
writeSynastryHistory(accountId, next);
return next;
});
})
.catch(() => {
// Cloud synastry history is best-effort; local history remains usable.
});
}, [accountId, profile]);
useEffect(() => {
@@ -1303,11 +1353,19 @@ export default function Home() {
nextEvidence: payload.relationshipReport?.nextEvidence,
createdAt: Date.now(),
};
setSynastryReportCard(reportCard);
let savedReportCard = reportCard;
if (accountId) {
try {
savedReportCard = await saveCloudSynastryReport(reportCard);
} catch {
// Local history remains the fallback when cloud persistence is unavailable.
}
}
setSynastryReportCard(savedReportCard);
if (accountId) {
setSynastryHistory((current) => {
const next = [reportCard, ...current].slice(0, 10);
localStorage.setItem(synastryHistoryStorageKey(accountId), JSON.stringify(next));
const next = [savedReportCard, ...current.filter((item) => item.id !== savedReportCard.id)].slice(0, 10);
writeSynastryHistory(accountId, next);
return next;
});
}
@@ -0,0 +1,36 @@
begin;
create table if not exists public.synastry_reports (
id uuid primary key default gen_random_uuid(),
user_id uuid not null references auth.users(id) on delete cascade,
partner_name text not null default '对方',
report jsonb not null check (jsonb_typeof(report) = 'object'),
created_at timestamptz not null default now()
);
create index if not exists synastry_reports_user_created_at_idx
on public.synastry_reports (user_id, created_at desc);
alter table public.synastry_reports enable row level security;
drop policy if exists synastry_reports_select_own on public.synastry_reports;
create policy synastry_reports_select_own
on public.synastry_reports for select to authenticated
using ((select auth.uid()) = user_id);
drop policy if exists synastry_reports_insert_own on public.synastry_reports;
create policy synastry_reports_insert_own
on public.synastry_reports for insert to authenticated
with check ((select auth.uid()) = user_id);
drop policy if exists synastry_reports_delete_own on public.synastry_reports;
create policy synastry_reports_delete_own
on public.synastry_reports for delete to authenticated
using ((select auth.uid()) = user_id);
revoke all on table public.synastry_reports from anon, authenticated, service_role;
grant select on table public.synastry_reports to authenticated;
grant insert (id, user_id, partner_name, report, created_at) on table public.synastry_reports to authenticated;
grant delete on table public.synastry_reports to authenticated;
commit;
+48
View File
@@ -30,10 +30,18 @@ CHART_PROFILE_MIGRATION = (
/ "migrations"
/ "20260717010000_chart_profiles.sql"
)
SYNASTRY_REPORT_MIGRATION = (
Path(__file__).resolve().parents[1]
/ "frontend"
/ "supabase"
/ "migrations"
/ "20260717020000_synastry_reports.sql"
)
PAGE = Path(__file__).resolve().parents[1] / "frontend" / "src" / "app" / "page.tsx"
CHART_PROFILE_ROUTE = Path(__file__).resolve().parents[1] / "frontend" / "src" / "app" / "api" / "chart-profiles" / "route.ts"
CHART_PROFILE_DELETE_ROUTE = Path(__file__).resolve().parents[1] / "frontend" / "src" / "app" / "api" / "chart-profiles" / "[id]" / "route.ts"
SYNASTRY_ROUTE = Path(__file__).resolve().parents[1] / "frontend" / "src" / "app" / "api" / "synastry" / "route.ts"
SYNASTRY_REPORT_ROUTE = Path(__file__).resolve().parents[1] / "frontend" / "src" / "app" / "api" / "synastry-reports" / "route.ts"
def _sql() -> str:
@@ -183,6 +191,46 @@ def test_synastry_route_orchestrates_python_chart_and_ashtakoot() -> None:
assert token in route
def test_synastry_reports_are_cloud_persisted_per_user() -> None:
sql = re.sub(r"\s+", " ", SYNASTRY_REPORT_MIGRATION.read_text(encoding="utf-8").lower()).strip()
route = SYNASTRY_REPORT_ROUTE.read_text(encoding="utf-8")
page = PAGE.read_text(encoding="utf-8")
for token in (
"create table if not exists public.synastry_reports",
"user_id uuid not null references auth.users(id) on delete cascade",
"partner_name text not null default '对方'",
"report jsonb not null check (jsonb_typeof(report) = 'object')",
"alter table public.synastry_reports enable row level security",
"create policy synastry_reports_select_own on public.synastry_reports for select to authenticated using ((select auth.uid()) = user_id)",
"create policy synastry_reports_insert_own on public.synastry_reports for insert to authenticated with check ((select auth.uid()) = user_id)",
"create policy synastry_reports_delete_own on public.synastry_reports for delete to authenticated using ((select auth.uid()) = user_id)",
"grant select on table public.synastry_reports to authenticated",
"grant insert (id, user_id, partner_name, report, created_at) on table public.synastry_reports to authenticated",
):
assert token in sql
for token in (
'.from("synastry_reports")',
'.select("id, partner_name, report, created_at")',
".eq(\"user_id\", user.id)",
".limit(10)",
"partner_name: partnerName",
"report: body.report",
):
assert token in route
for token in (
"fetchCloudSynastryHistory",
"saveCloudSynastryReport",
'fetch("/api/synastry-reports"',
"writeSynastryHistory(accountId, next)",
"cloud_synastry_history_unavailable",
"cloud_synastry_report_save_failed",
):
assert token in page
def test_consultation_credit_lifecycle_is_idempotent_and_server_only() -> None:
sql = re.sub(r"\s+", " ", CONSULTATION_MIGRATION.read_text(encoding="utf-8").lower()).strip()