diff --git a/.github/workflows/apply-supabase-profile-migrations.yml b/.github/workflows/apply-supabase-profile-migrations.yml index b479a410..db86d9ea 100644 --- a/.github/workflows/apply-supabase-profile-migrations.yml +++ b/.github/workflows/apply-supabase-profile-migrations.yml @@ -47,6 +47,7 @@ jobs: frontend/supabase/migrations/20260718102000_recover_missing_profile_rows.sql \ frontend/supabase/migrations/20260718103000_profile_birth_time_declaration_grants.sql \ frontend/supabase/migrations/20260718104000_chart_profiles_upsert_id_grant.sql \ + frontend/supabase/migrations/20260721100000_chat_sessions_delete_grant.sql \ "$DEPLOY_USER@$DEPLOY_HOST:$DEPLOY_PATH/tmp/profile-migrations/" - name: Apply profile migrations using VPS database URL @@ -76,7 +77,8 @@ jobs: tmp/profile-migrations/20260718100000_repair_missing_chart_profiles.sql \ tmp/profile-migrations/20260718102000_recover_missing_profile_rows.sql \ tmp/profile-migrations/20260718103000_profile_birth_time_declaration_grants.sql \ - tmp/profile-migrations/20260718104000_chart_profiles_upsert_id_grant.sql + tmp/profile-migrations/20260718104000_chart_profiles_upsert_id_grant.sql \ + tmp/profile-migrations/20260721100000_chat_sessions_delete_grant.sql do echo "applying $(basename "$SQL_FILE")" cat "$SQL_FILE" | docker run --rm -i postgres:16-alpine \ diff --git a/frontend/src/app/api/sessions/[id]/route.ts b/frontend/src/app/api/sessions/[id]/route.ts new file mode 100644 index 00000000..5c0919f7 --- /dev/null +++ b/frontend/src/app/api/sessions/[id]/route.ts @@ -0,0 +1,28 @@ +import { NextResponse } from "next/server"; +import { createServerSupabaseClient } from "@/lib/supabase/server"; +import { isSupabaseConfigurationError } from "@/lib/supabase/config"; + +type RouteContext = { params: Promise<{ id: string }> }; + +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 { count, error } = await supabase + .from("chat_sessions") + .delete({ count: "exact" }) + .eq("id", id) + .eq("user_id", user.id); + if (error) throw error; + if (count !== 1) return NextResponse.json({ error: "聊天记录不存在或无权删除" }, { status: 404 }); + 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: error instanceof Error ? error.message : "删除聊天记录失败" }, { status: 500 }); + } +} diff --git a/frontend/src/app/page.tsx b/frontend/src/app/page.tsx index 81b80a66..d1d1d054 100644 --- a/frontend/src/app/page.tsx +++ b/frontend/src/app/page.tsx @@ -1253,9 +1253,9 @@ export default function Home() { setArchivedSessionIds((current) => current.filter((id) => id !== session.id)); if (activeSessionId === session.id) setActiveSessionId(nextSessions[0]?.id ?? ""); try { - const supabase = createBrowserSupabaseClient(); - const { error } = await supabase.from("chat_sessions").delete().eq("id", session.id).eq("user_id", account.user.id); - if (error) throw error; + const response = await fetch(`/api/sessions/${encodeURIComponent(session.id)}`, { method: "DELETE" }); + const payload = await response.json().catch(() => null) as { error?: string } | null; + if (!response.ok) throw new Error(payload?.error || "删除聊天记录失败"); } catch (caught) { setSessions(previousSessions); setComposerNotice(caught instanceof Error ? `删除失败:${caught.message}` : "删除失败"); diff --git a/frontend/supabase/migrations/20260721100000_chat_sessions_delete_grant.sql b/frontend/supabase/migrations/20260721100000_chat_sessions_delete_grant.sql new file mode 100644 index 00000000..bb7aad04 --- /dev/null +++ b/frontend/supabase/migrations/20260721100000_chat_sessions_delete_grant.sql @@ -0,0 +1,5 @@ +begin; + +grant delete on table public.chat_sessions to authenticated; + +commit; diff --git a/tests/test_session_management_entrypoints.py b/tests/test_session_management_entrypoints.py index a9a550c8..ce0346fd 100644 --- a/tests/test_session_management_entrypoints.py +++ b/tests/test_session_management_entrypoints.py @@ -3,10 +3,13 @@ from pathlib import Path PAGE = Path("frontend/src/app/page.tsx") STYLES = Path("frontend/src/app/globals.css") +SESSION_ROW = Path("frontend/src/components/sidebar-session-row.tsx") +SESSION_DELETE_ROUTE = Path("frontend/src/app/api/sessions/[id]/route.ts") +SESSION_DELETE_MIGRATION = Path("frontend/supabase/migrations/20260721100000_chat_sessions_delete_grant.sql") def test_chat_history_management_actions_are_exposed() -> None: - source = PAGE.read_text(encoding="utf-8") + STYLES.read_text(encoding="utf-8") + source = PAGE.read_text(encoding="utf-8") + STYLES.read_text(encoding="utf-8") + SESSION_ROW.read_text(encoding="utf-8") for expected in ( "renameSession", "deleteSession", @@ -28,5 +31,15 @@ def test_chat_history_management_actions_are_exposed() -> None: "恢复", "删除", "转发", + 'fetch(`/api/sessions/${encodeURIComponent(session.id)}`', ): assert expected in source + + +def test_chat_session_delete_is_server_controlled_and_granted() -> None: + route = SESSION_DELETE_ROUTE.read_text(encoding="utf-8") + migration = SESSION_DELETE_MIGRATION.read_text(encoding="utf-8") + assert 'from("chat_sessions")' in route + assert '.eq("user_id", user.id)' in route + assert 'count !== 1' in route + assert 'grant delete on table public.chat_sessions to authenticated' in migration.lower()