From b8e6ee9112bcaedb546579ff521b61043e275d54 Mon Sep 17 00:00:00 2001 From: 732642856 <732642856@qq.com> Date: Fri, 17 Jul 2026 16:42:36 +0800 Subject: [PATCH] Sync chart library profiles to Supabase --- .../src/app/api/chart-profiles/[id]/route.ts | 35 +++++++ frontend/src/app/api/chart-profiles/route.ts | 95 +++++++++++++++++++ frontend/src/app/page.tsx | 74 ++++++++++++++- .../20260717010000_chart_profiles.sql | 56 +++++++++++ tests/test_supabase_user_data_contract.py | 60 +++++++++++- 5 files changed, 315 insertions(+), 5 deletions(-) create mode 100644 frontend/src/app/api/chart-profiles/[id]/route.ts create mode 100644 frontend/src/app/api/chart-profiles/route.ts create mode 100644 frontend/supabase/migrations/20260717010000_chart_profiles.sql diff --git a/frontend/src/app/api/chart-profiles/[id]/route.ts b/frontend/src/app/api/chart-profiles/[id]/route.ts new file mode 100644 index 00000000..bc49ae14 --- /dev/null +++ b/frontend/src/app/api/chart-profiles/[id]/route.ts @@ -0,0 +1,35 @@ +import { NextResponse } from "next/server"; +import { createServerSupabaseClient } from "@/lib/supabase/server"; +import { isSupabaseConfigurationError } from "@/lib/supabase/config"; + +type RouteContext = { + params: Promise<{ id: string }>; +}; + +function errorMessage(error: unknown, fallback: string) { + return error instanceof Error ? error.message : fallback; +} + +export async function DELETE(_request: Request, context: RouteContext) { + try { + const { id } = await context.params; + const supabase = await createServerSupabaseClient(); + const { data: { user } } = await supabase.auth.getUser(); + if (!user) return NextResponse.json({ error: "请先登录" }, { status: 401 }); + + const { error } = await supabase + .from("chart_profiles") + .delete() + .eq("id", id) + .eq("user_id", user.id) + .eq("role", "other"); + + if (error) throw error; + return NextResponse.json({ ok: true }); + } 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/api/chart-profiles/route.ts b/frontend/src/app/api/chart-profiles/route.ts new file mode 100644 index 00000000..e537af1d --- /dev/null +++ b/frontend/src/app/api/chart-profiles/route.ts @@ -0,0 +1,95 @@ +import { NextResponse } from "next/server"; +import { createServerSupabaseClient } from "@/lib/supabase/server"; +import { isSupabaseConfigurationError } from "@/lib/supabase/config"; + +type ChartProfilePayload = { + id?: string; + role?: "self" | "other"; + profile?: 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("chart_profiles") + .select("id, role, profile, updated_at") + .eq("user_id", user.id) + .order("updated_at", { ascending: false }); + + if (error) throw error; + return NextResponse.json({ profiles: 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 supabase = await createServerSupabaseClient(); + const { data: { user } } = await supabase.auth.getUser(); + if (!user) return NextResponse.json({ error: "请先登录" }, { status: 401 }); + + const body = await request.json().catch(() => null) as ChartProfilePayload | null; + if (!body?.profile || typeof body.profile !== "object") { + return NextResponse.json({ error: "星盘资料格式不正确" }, { status: 400 }); + } + const role = body.role === "self" ? "self" : "other"; + const updatedAt = new Date().toISOString(); + let data; + let error; + if (role === "self") { + const existing = await supabase + .from("chart_profiles") + .select("id") + .eq("user_id", user.id) + .eq("role", "self") + .maybeSingle(); + if (existing.error) throw existing.error; + const query = existing.data?.id + ? supabase + .from("chart_profiles") + .update({ profile: body.profile, updated_at: updatedAt }) + .eq("id", existing.data.id) + .select("id, role, profile, updated_at") + .single() + : supabase + .from("chart_profiles") + .insert({ user_id: user.id, role, profile: body.profile, updated_at: updatedAt }) + .select("id, role, profile, updated_at") + .single(); + ({ data, error } = await query); + } else { + const record = { + ...(body.id ? { id: body.id } : {}), + user_id: user.id, + role, + profile: body.profile, + updated_at: updatedAt, + }; + ({ data, error } = await supabase + .from("chart_profiles") + .upsert(record, { onConflict: "id" }) + .select("id, role, profile, updated_at") + .single()); + } + + if (error) throw error; + return NextResponse.json({ profile: 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 7f72d2a5..44c730bd 100644 --- a/frontend/src/app/page.tsx +++ b/frontend/src/app/page.tsx @@ -39,6 +39,12 @@ type ChartLibraryRecord = { profile: Profile; updatedAt: number; }; +type ChartLibraryApiRecord = { + id: string; + role: "self" | "other"; + profile: Profile; + updated_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 }; @@ -199,6 +205,42 @@ function readChartLibrary(accountId: string): ChartLibraryRecord[] { } } +function normalizeChartLibraryApiRecord(record: ChartLibraryApiRecord): ChartLibraryRecord { + return { + id: record.role === "self" ? "self" : record.id, + role: record.role, + profile: record.profile, + updatedAt: Date.parse(record.updated_at || "") || timestamp(), + }; +} + +async function fetchCloudChartLibrary() { + const response = await fetch("/api/chart-profiles", { cache: "no-store" }); + if (!response.ok) throw new Error("cloud_chart_library_unavailable"); + const payload = await response.json().catch(() => null) as { profiles?: ChartLibraryApiRecord[] } | null; + return (payload?.profiles || []).map(normalizeChartLibraryApiRecord); +} + +async function saveCloudChartProfile(record: ChartLibraryRecord) { + const response = await fetch("/api/chart-profiles", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + id: record.role === "self" ? undefined : record.id, + role: record.role, + profile: record.profile, + }), + }); + if (!response.ok) throw new Error("cloud_chart_profile_save_failed"); + const payload = await response.json().catch(() => null) as { profile?: ChartLibraryApiRecord } | null; + return payload?.profile ? normalizeChartLibraryApiRecord(payload.profile) : record; +} + +async function deleteCloudChartProfile(recordId: string) { + const response = await fetch(`/api/chart-profiles/${encodeURIComponent(recordId)}`, { method: "DELETE" }); + if (!response.ok) throw new Error("cloud_chart_profile_delete_failed"); +} + function profilePlaceLabel(profile: Profile) { return selectedBirthPlace(profile)?.label || "地点未完整"; } @@ -518,6 +560,7 @@ export default function Home() { const modelSyncFailures = useRef(new Set()); const modelSelectionVersions = useRef(new Map()); const activeSessionIdRef = useRef(""); + const chartLibraryLoadedAccount = useRef(""); const uiPreview = useRef(false); const uiPreviewMode = useRef(null); @@ -535,9 +578,27 @@ export default function Home() { useEffect(() => { if (!accountId) { setChartLibrary([]); + chartLibraryLoadedAccount.current = ""; return; } + if (chartLibraryLoadedAccount.current === accountId) return; + chartLibraryLoadedAccount.current = accountId; setChartLibrary(upsertSelfChart(readChartLibrary(accountId), profile)); + void fetchCloudChartLibrary() + .then((cloudLibrary) => { + setChartLibrary((current) => { + const otherById = new Map([ + ...current.filter((record) => record.role === "other").map((record) => [record.id, record] as const), + ...cloudLibrary.filter((record) => record.role === "other").map((record) => [record.id, record] as const), + ]); + const next = upsertSelfChart([...otherById.values()], profile); + localStorage.setItem(chartLibraryStorageKey(accountId), JSON.stringify(next)); + return next; + }); + }) + .catch(() => { + // Cloud chart library is best-effort; local library remains usable. + }); }, [accountId, profile]); useEffect(() => { @@ -988,9 +1049,10 @@ export default function Home() { .maybeSingle(); if (error) throw error; if (!data) throw new Error("账户档案不存在,请重新登录后再试。"); + await saveCloudChartProfile({ ...buildSelfChartRecord(nextProfile), updatedAt: timestamp() }).catch(() => null); } - function saveOtherChart(event: FormEvent) { + async function saveOtherChart(event: FormEvent) { event.preventDefault(); const nextProfile = { ...otherProfileDraft, name: otherProfileDraft.name.trim() }; if (missingProfileStep(nextProfile)) { @@ -998,12 +1060,17 @@ export default function Home() { return; } if (!accountId) return; - const record: ChartLibraryRecord = { + let record: ChartLibraryRecord = { id: globalThis.crypto.randomUUID(), role: "other", profile: nextProfile, updatedAt: timestamp(), }; + try { + record = await saveCloudChartProfile(record); + } catch { + // Keep local chart library usable when cloud sync is unavailable. + } setChartLibrary((current) => { const next = [...upsertSelfChart(current, profile), record]; localStorage.setItem(chartLibraryStorageKey(accountId), JSON.stringify(next)); @@ -1016,6 +1083,9 @@ export default function Home() { function deleteOtherChart(recordId: string) { if (!accountId) return; + void deleteCloudChartProfile(recordId).catch(() => { + // Local deletion should not be blocked by temporary cloud sync failures. + }); setChartLibrary((current) => { const next = current.filter((record) => record.id !== recordId || record.role === "self"); localStorage.setItem(chartLibraryStorageKey(accountId), JSON.stringify(next)); diff --git a/frontend/supabase/migrations/20260717010000_chart_profiles.sql b/frontend/supabase/migrations/20260717010000_chart_profiles.sql new file mode 100644 index 00000000..ec55d33c --- /dev/null +++ b/frontend/supabase/migrations/20260717010000_chart_profiles.sql @@ -0,0 +1,56 @@ +begin; + +create table if not exists public.chart_profiles ( + id uuid primary key default gen_random_uuid(), + user_id uuid not null references auth.users(id) on delete cascade, + role text not null default 'other' check (role in ('self', 'other')), + profile jsonb not null check (jsonb_typeof(profile) = 'object'), + created_at timestamptz not null default now(), + updated_at timestamptz not null default now() +); + +create index if not exists chart_profiles_user_updated_at_idx + on public.chart_profiles (user_id, updated_at desc); + +create unique index if not exists chart_profiles_one_self_per_user_idx + on public.chart_profiles (user_id) + where role = 'self'; + +alter table public.chart_profiles enable row level security; + +drop policy if exists chart_profiles_select_own on public.chart_profiles; +create policy chart_profiles_select_own + on public.chart_profiles + for select + to authenticated + using ((select auth.uid()) = user_id); + +drop policy if exists chart_profiles_insert_own on public.chart_profiles; +create policy chart_profiles_insert_own + on public.chart_profiles + for insert + to authenticated + with check ((select auth.uid()) = user_id); + +drop policy if exists chart_profiles_update_own on public.chart_profiles; +create policy chart_profiles_update_own + on public.chart_profiles + for update + to authenticated + using ((select auth.uid()) = user_id) + with check ((select auth.uid()) = user_id); + +drop policy if exists chart_profiles_delete_own on public.chart_profiles; +create policy chart_profiles_delete_own + on public.chart_profiles + for delete + to authenticated + using ((select auth.uid()) = user_id); + +revoke all on table public.chart_profiles from anon, authenticated, service_role; +grant select on table public.chart_profiles to authenticated; +grant insert (id, user_id, role, profile, updated_at) on table public.chart_profiles to authenticated; +grant update (role, profile, updated_at) on table public.chart_profiles to authenticated; +grant delete on table public.chart_profiles to authenticated; + +commit; diff --git a/tests/test_supabase_user_data_contract.py b/tests/test_supabase_user_data_contract.py index 472b8ccf..ffc0b79c 100644 --- a/tests/test_supabase_user_data_contract.py +++ b/tests/test_supabase_user_data_contract.py @@ -23,7 +23,16 @@ CONSULTATION_MIGRATION = ( / "migrations" / "20260717000000_consultation_request_lifecycle.sql" ) +CHART_PROFILE_MIGRATION = ( + Path(__file__).resolve().parents[1] + / "frontend" + / "supabase" + / "migrations" + / "20260717010000_chart_profiles.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" def _sql() -> str: @@ -92,9 +101,54 @@ def test_chat_page_uses_authenticated_cloud_persistence() -> None: assert 'await persistSession(interruptedSession)' in source assert 'await persistence' in source assert 'disabled={Boolean(pendingSessionId) || cancellationPending}' in source - assert "localStorage" not in source - assert "ayanam-profile" not in source - assert "ayanam-sessions" not in source + assert 'localStorage.setItem(chartLibraryStorageKey(accountId)' in source + assert 'localStorage.setItem("chat_sessions"' not in source + + +def test_chart_profile_library_has_cloud_table_api_and_local_fallback() -> None: + sql = re.sub(r"\s+", " ", CHART_PROFILE_MIGRATION.read_text(encoding="utf-8").lower()).strip() + route = CHART_PROFILE_ROUTE.read_text(encoding="utf-8") + delete_route = CHART_PROFILE_DELETE_ROUTE.read_text(encoding="utf-8") + page = PAGE.read_text(encoding="utf-8") + + for token in ( + "create table if not exists public.chart_profiles", + "user_id uuid not null references auth.users(id) on delete cascade", + "role text not null default 'other' check (role in ('self', 'other'))", + "profile jsonb not null check (jsonb_typeof(profile) = 'object')", + "create unique index if not exists chart_profiles_one_self_per_user_idx", + "alter table public.chart_profiles enable row level security", + "create policy chart_profiles_select_own", + "create policy chart_profiles_insert_own", + "create policy chart_profiles_update_own", + "create policy chart_profiles_delete_own", + "grant delete on table public.chart_profiles to authenticated", + ): + assert token in sql + + for token in ( + 'from("chart_profiles")', + 'eq("user_id", user.id)', + 'eq("role", "self")', + 'insert({ user_id: user.id, role, profile: body.profile', + 'upsert(record, { onConflict: "id" })', + ): + assert token in route + assert 'eq("role", "other")' in delete_route + + for token in ( + "fetchCloudChartLibrary", + "saveCloudChartProfile", + "deleteCloudChartProfile", + "chartLibraryStorageKey", + "Cloud chart library is best-effort", + "星盘库", + "添加其他星盘", + "设为默认", + ): + assert token in page + assert "ayanam-profile" not in page + assert "ayanam-sessions" not in page def test_consultation_credit_lifecycle_is_idempotent_and_server_only() -> None: