diff --git a/frontend/src/app/api/synastry-reports/route.ts b/frontend/src/app/api/synastry-reports/route.ts new file mode 100644 index 00000000..e51a2b81 --- /dev/null +++ b/frontend/src/app/api/synastry-reports/route.ts @@ -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 }); + } +} diff --git a/frontend/src/app/page.tsx b/frontend/src/app/page.tsx index ed7c546c..323fb786 100644 --- a/frontend/src/app/page.tsx +++ b/frontend/src/app/page.tsx @@ -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; }); } diff --git a/frontend/supabase/migrations/20260717020000_synastry_reports.sql b/frontend/supabase/migrations/20260717020000_synastry_reports.sql new file mode 100644 index 00000000..f9f14d8b --- /dev/null +++ b/frontend/supabase/migrations/20260717020000_synastry_reports.sql @@ -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; diff --git a/tests/test_supabase_user_data_contract.py b/tests/test_supabase_user_data_contract.py index 97c6e842..27d96533 100644 --- a/tests/test_supabase_user_data_contract.py +++ b/tests/test_supabase_user_data_contract.py @@ -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()