From f402e6f79c7c8c65de9137770b3041c6eb55da42 Mon Sep 17 00:00:00 2001 From: Jesse_Chen Date: Mon, 27 Jul 2026 15:47:18 +0800 Subject: [PATCH] fix: use same-origin data APIs on homepage --- docs/BUG_HISTORY.md | 22 ++-- frontend/src/app/api/account/route.ts | 5 +- frontend/src/app/page.tsx | 116 +++++++++++----------- frontend/tests/account-api.test.ts | 2 +- frontend/tests/chat-session-write.test.ts | 13 +++ 5 files changed, 86 insertions(+), 72 deletions(-) diff --git a/docs/BUG_HISTORY.md b/docs/BUG_HISTORY.md index f3bd05ba..8cb7500d 100644 --- a/docs/BUG_HISTORY.md +++ b/docs/BUG_HISTORY.md @@ -186,21 +186,21 @@ - 复发自:无 - 修复版本:待提交(本地可测) -## BUG-010 | 浏览器直连 Supabase 写会话泄露 `TypeError: Load failed` +## BUG-010 | 浏览器直连 Supabase 导致自托管 PostgreSQL staging 误报未配置 - 状态:resolved - 首次发现:2026-07-22 -- 最近更新:2026-07-22 -- 影响面:回答完成后的聊天记录持久化、移动 Safari 错误提示 -- 用户现象:回答已经生成,但页面反复显示“云端同步失败:TypeError: Load failed”,并要求复制保存后重试。 -- 触发条件:浏览器直接向 Supabase `chat_sessions` 发起跨域写入时发生传输失败。 -- 根因:会话读取和多数业务写入已经使用同源 Next.js API,但会话创建与更新仍由浏览器客户端直写 Supabase;异常原文又被拼进回答错误区域。 -- 修复:新增同源 `POST /api/sessions` 与 `PATCH /api/sessions/[id]`,服务端校验登录、所有权和写入负载;客户端对可重试失败短重试一次,并把最终失败降级为输入区状态提示,不再把浏览器异常原文渲染成回答错误。 -- 验证:`frontend/tests/chat-session-write.test.ts` 覆盖同源路由、所有者约束、短重试和 `Load failed` 脱敏;相关咨询与资料回归测试通过。 -- 防复发:会话写入契约禁止页面直接调用 `supabase.from("chat_sessions")`;网络异常必须映射为稳定用户文案。 +- 最近更新:2026-07-27 +- 影响面:聊天首页启动、会话模型选择、退出登录;自托管 PostgreSQL staging。 +- 用户现象:数据库和身份服务正常,首页却显示“Supabase 尚未配置”。 +- 触发条件:`AUTH_PROVIDER=self-hosted` 且不提供浏览器 Supabase 公钥。 +- 根因:页面启动、会话模型更新和退出登录仍直接创建浏览器 Supabase client;仅服务端 API 已切换到同源 PostgreSQL 路径。 +- 修复:`/api/account` 返回当前用户完整 profile;首页通过 `/api/account`、`/api/sessions` 和 `/api/sessions/:id` 读写,退出使用 self-hosted identity client。 +- 验证:`frontend/tests/chat-session-write.test.ts`、`frontend/tests/session-model-persistence.test.ts`、`frontend/tests/account-api.test.ts`、ESLint、`next build`。 +- 防复发:首页不得引用 `createBrowserSupabaseClient`;会话与 profile 只能经同源 API 访问。 - 相关记录:BUG-001、BUG-003 -- 复发自:无 -- 修复版本:待提交(本地可测) +- 复发自:BUG-010 +- 修复版本:待发布 staging ## BUG-011 | 对话消息暴露内部证据审计状态 diff --git a/frontend/src/app/api/account/route.ts b/frontend/src/app/api/account/route.ts index aa821bcd..552f3c49 100644 --- a/frontend/src/app/api/account/route.ts +++ b/frontend/src/app/api/account/route.ts @@ -78,14 +78,14 @@ export async function GET() { // an older case. A concurrently created case simply appears on refresh. let { data: profile, error: profileError } = await supabase .from("profiles") - .select("credits,active_birth_time,birth_time_status,birth_date,reported_birth_time,birth_time_source,birth_time_period,birth_time_clue,uncertainty_before_minutes,uncertainty_after_minutes,country_code,province_code,city_code,district_code,latitude,longitude,timezone_offset,birth_place_label,birth_place_type,birth_place_provider,birth_place_provider_id,timezone_id,timezone_source") + .select("credits,active_birth_time,birth_time_status,birth_date,reported_birth_time,birth_time_source,birth_time_period,birth_time_clue,uncertainty_before_minutes,uncertainty_after_minutes,country_code,province_code,city_code,district_code,latitude,longitude,timezone_offset,birth_place_label,birth_place_type,birth_place_provider,birth_place_provider_id,timezone_id,timezone_source,name,birth_time,rectification_case_id") .eq("id", userId) .single(); if (profileError && isMissingProfileColumn(profileError)) { const fallback = await supabase .from("profiles") - .select("credits,active_birth_time,birth_time_status,birth_date,reported_birth_time,birth_time_source,birth_time_period,birth_time_clue,uncertainty_before_minutes,uncertainty_after_minutes,country_code,province_code,city_code,district_code,latitude,longitude,timezone_offset") + .select("credits,active_birth_time,birth_time_status,birth_date,reported_birth_time,birth_time_source,birth_time_period,birth_time_clue,uncertainty_before_minutes,uncertainty_after_minutes,country_code,province_code,city_code,district_code,latitude,longitude,timezone_offset,name,birth_time,rectification_case_id") .eq("id", userId) .single(); profile = fallback.data ? { @@ -118,6 +118,7 @@ export async function GET() { hasConfirmedBirthTime: profile.birth_time_status === "confirmed" && typeof profile.active_birth_time === "string", rectificationCase, + profile, birthLocation: { label: profile.birth_place_label ?? null, placeType: profile.birth_place_type ?? null, diff --git a/frontend/src/app/page.tsx b/frontend/src/app/page.tsx index 0a92db6d..ac244f2f 100644 --- a/frontend/src/app/page.tsx +++ b/frontend/src/app/page.tsx @@ -94,7 +94,7 @@ import { resolveSessionModelId, type PublicLanguageModelCatalog, } from "@/lib/public-models"; -import { createBrowserSupabaseClient } from "@/lib/supabase/client"; +import { selfHostedOtpActions } from "@/modules/identity/client"; gsap.registerPlugin(useGSAP); @@ -192,6 +192,7 @@ type Account = { rectificationPriceCredits: number; hasConfirmedBirthTime: boolean; rectificationCase: AccountRectificationCaseState | null; + profile: unknown; }; type OnboardingStep = "name" | "birth" | "place" | "rectification"; type AccountDialog = "profile" | "redeem" | "logout"; @@ -885,6 +886,33 @@ async function fetchModelCatalog(signal?: AbortSignal) { return parsePublicModelCatalog(payload); } +async function fetchSessions(signal?: AbortSignal): Promise { + const response = await fetch("/api/sessions", { signal, cache: "no-store" }); + if (response.status === 401) { + window.location.assign("/login"); + throw new Error("请先登录"); + } + const payload = await response.json().catch(() => null); + if (!response.ok) throw new Error(payloadMessage(payload, "暂时无法读取聊天记录")); + return payload && typeof payload === "object" ? (payload as { sessions?: unknown }).sessions : null; +} + +async function patchSessionModel(sessionId: string, modelId: string, signal?: AbortSignal) { + const response = await fetch(`/api/sessions/${encodeURIComponent(sessionId)}`, { + method: "PATCH", + headers: { "content-type": "application/json" }, + credentials: "same-origin", + body: JSON.stringify({ model_id: modelId }), + signal, + }); + if (response.status === 401) { + window.location.assign("/login"); + throw new Error("请先登录"); + } + const payload = await response.json().catch(() => null); + if (!response.ok) throw new Error(payloadMessage(payload, "模型选择暂时无法同步到云端。")); +} + export default function Home() { const [profile, setProfile] = useState(emptyProfile); const [profileDraft, setProfileDraft] = useState(emptyProfile); @@ -1228,6 +1256,7 @@ export default function Home() { rectificationPriceCredits: 1, hasConfirmedBirthTime: previewProfile.birthTimeStatus === "confirmed", rectificationCase: null, + profile: previewProfile, }); setModelCatalog(previewModelCatalog); setProfile(previewProfile); @@ -1250,16 +1279,7 @@ export default function Home() { return; } - const supabase = createBrowserSupabaseClient(); - const { data: authData, error: authError } = await supabase.auth.getSession(); - if (authError) throw authError; - if (controller.signal.aborted) return; - if (!authData.session) { - window.location.assign("/login"); - return; - } - - const [nextAccount, modelCatalogResult] = await Promise.all([ + const [nextAccount, modelCatalogResult, sessionsPayload] = await Promise.all([ fetchAccount(controller.signal), fetchModelCatalog(controller.signal) .then((catalog) => ({ catalog, unavailable: false })) @@ -1267,50 +1287,30 @@ export default function Home() { if (caught instanceof Error && caught.name === "AbortError") throw caught; return { catalog: null, unavailable: true }; }), + fetchSessions(controller.signal), ]); const nextModelCatalog = modelCatalogResult.catalog; - const [profileResult, sessionsResult] = await Promise.all([ - supabase - .from("profiles") - .select("name,birth_date,birth_time,reported_birth_time,active_birth_time,birth_time_source,birth_time_period,birth_time_clue,uncertainty_before_minutes,uncertainty_after_minutes,birth_time_status,rectification_case_id,country_code,province_code,city_code,district_code,birth_place_label,birth_place_type,birth_place_provider,birth_place_provider_id,latitude,longitude,timezone_id,timezone_offset,timezone_source") - .eq("id", nextAccount.user.id) - .abortSignal(controller.signal) - .maybeSingle(), - supabase - .from("chat_sessions") - .select("id,title,theme,model_id,messages,session_type,rectification_case_id,updated_at") - .abortSignal(controller.signal) - .order("updated_at", { ascending: false }), - ]); - - if (profileResult.error) throw profileResult.error; - if (sessionsResult.error) throw sessionsResult.error; - - const parsedSessions = readSessions(sessionsResult.data, nextModelCatalog); + const parsedSessions = readSessions(sessionsPayload, nextModelCatalog); let nextSessions = parsedSessions.sessions; if (nextSessions.length === 0) { if (controller.signal.aborted) return; const initialSession = createSession(nextModelCatalog?.defaultModelId ?? ""); - const { error } = await supabase - .from("chat_sessions") - .insert({ - id: initialSession.id, - user_id: nextAccount.user.id, + if (nextModelCatalog) { + await writeChatSession(initialSession.id, { title: initialSession.title, theme: initialSession.theme, - model_id: initialSession.modelId || null, + model_id: initialSession.modelId, messages: initialSession.messages, session_type: initialSession.sessionType, rectification_case_id: initialSession.rectificationCaseId, updated_at: new Date(initialSession.updatedAt).toISOString(), - }) - .abortSignal(controller.signal); - if (error) throw error; + }, "create"); + } nextSessions = [initialSession]; } if (controller.signal.aborted) return; - const nextProfile = readProfile(profileResult.data); + const nextProfile = readProfile(nextAccount.profile); setAccount(nextAccount); setModelCatalog(nextModelCatalog); setProfile(nextProfile); @@ -1327,14 +1327,14 @@ export default function Home() { setAccountError(""); if (nextModelCatalog && parsedSessions.fallbackSessionIds.length > 0) { - const { error } = await supabase - .from("chat_sessions") - .update({ model_id: nextModelCatalog.defaultModelId }) - .eq("user_id", nextAccount.user.id) - .in("id", parsedSessions.fallbackSessionIds) - .abortSignal(controller.signal); - if (error && !controller.signal.aborted) { - setComposerNotice("已在当前页面切换为默认模型,但云端同步失败;刷新后可能需要重新选择。"); + try { + await Promise.all(parsedSessions.fallbackSessionIds.map((sessionId) => + patchSessionModel(sessionId, nextModelCatalog.defaultModelId, controller.signal), + )); + } catch { + if (!controller.signal.aborted) { + setComposerNotice("已在当前页面切换为默认模型,但云端同步失败;刷新后可能需要重新选择。"); + } } } } catch (caught) { @@ -1636,18 +1636,19 @@ export default function Home() { try { await modelPersistence.current.enqueue(nextSession.id, () => persistSessionModelSelection( - async ({ values, sessionId, userId: ownerId }) => { + async ({ values, sessionId }) => { if (process.env.NODE_ENV === "development" && uiPreview.current) { return { found: true, error: null }; } - const { data, error } = await createBrowserSupabaseClient() - .from("chat_sessions") - .update(values) - .eq("id", sessionId) - .eq("user_id", ownerId) - .select("id") - .maybeSingle(); - return { found: Boolean(data), error: error?.message ?? null }; + try { + await patchSessionModel(sessionId, values.model_id); + return { found: true, error: null }; + } catch (error) { + return { + found: false, + error: error instanceof Error ? error.message : "模型选择暂时无法同步到云端。", + }; + } }, userId, nextSession.id, @@ -1989,8 +1990,7 @@ export default function Home() { setSigningOut(true); setAccountError(""); try { - const { error } = await createBrowserSupabaseClient().auth.signOut(); - if (error) throw error; + await selfHostedOtpActions.signOut(); window.location.assign("/login"); } catch (caught) { const message = caught instanceof Error ? caught.message : "退出失败"; diff --git a/frontend/tests/account-api.test.ts b/frontend/tests/account-api.test.ts index 794ae944..29fe15e1 100644 --- a/frontend/tests/account-api.test.ts +++ b/frontend/tests/account-api.test.ts @@ -50,7 +50,7 @@ test("account GET falls back when global birthplace columns are not migrated yet const getSource = source.slice(source.indexOf("export async function GET"), source.indexOf("export async function PATCH")); assert.match(getSource, /profileError && isMissingProfileColumn\(profileError\)/); - assert.match(getSource, /select\("credits,active_birth_time[^"]*timezone_offset"\)/); + assert.match(getSource, /select\("credits,active_birth_time[^"]*timezone_offset(?:,[^"]*)?"\)/); assert.match(getSource, /birth_place_label: undefined/); assert.match(getSource, /timezone_id: undefined/); }); diff --git a/frontend/tests/chat-session-write.test.ts b/frontend/tests/chat-session-write.test.ts index af4ffd33..2ac9af3a 100644 --- a/frontend/tests/chat-session-write.test.ts +++ b/frontend/tests/chat-session-write.test.ts @@ -63,3 +63,16 @@ test("session API owns create and update while answer UI keeps sync failures out assert.match(itemRoute, /export async function PATCH/); assert.match(itemRoute, /\.eq\("user_id", user\.id\)/); }); + + +test("homepage bootstrap reads profiles and sessions through same-origin APIs", () => { + const page = readFileSync(new URL("../src/app/page.tsx", import.meta.url), "utf8"); + const accountRoute = readFileSync(new URL("../src/app/api/account/route.ts", import.meta.url), "utf8"); + + assert.doesNotMatch(page, /createBrowserSupabaseClient/); + assert.match(page, /fetch\("\/api\/account"/); + assert.match(page, /fetch\("\/api\/sessions"/); + assert.match(page, /writeChatSession\(initialSession\.id,[\s\S]*?"create"\)/); + assert.match(page, /fetch\(`\/api\/sessions\/\$\{encodeURIComponent\(sessionId\)\}`/); + assert.match(accountRoute, /rectificationCase,\s*profile,/); +});