Sync chart library profiles to Supabase

This commit is contained in:
732642856
2026-07-17 16:42:36 +08:00
parent e0138496be
commit b8e6ee9112
5 changed files with 315 additions and 5 deletions
@@ -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 });
}
}
@@ -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 });
}
}
+72 -2
View File
@@ -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<string>());
const modelSelectionVersions = useRef(new Map<string, number>());
const activeSessionIdRef = useRef("");
const chartLibraryLoadedAccount = useRef("");
const uiPreview = useRef(false);
const uiPreviewMode = useRef<string | null>(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<HTMLFormElement>) {
async function saveOtherChart(event: FormEvent<HTMLFormElement>) {
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));
@@ -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;
+57 -3
View File
@@ -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: