SolidJS + Supabase 认证集成:类型安全、响应式与生产级错误处理

SolidJS + Supabase 认证集成:类型安全、响应式与生产级错误处理
1. 项目概述为什么 SolidJS Supabase 的组合正在悄悄改变前端认证开发方式我第一次在真实项目里把 SolidJS 和 Supabase 拉到一起跑通用户登录流程时心里其实有点打鼓。不是因为技术不成熟——恰恰相反是太“顺”了顺到让我怀疑是不是漏掉了什么关键环节。SolidJS 的响应式模型天生就适合处理登录态这种高频、细粒度的状态变更而 Supabase 的 Auth 服务又把 JWT 签发、会话管理、邮箱验证、密码重置这些原本要自己搭轮子、填坑、写中间件的活儿压缩成几行signInWithPassword调用和一个createClient配置。这不是“简化”是直接把认证这个模块从“需要团队协作维护的后端服务”降维成“前端可声明式消费的原子能力”。你可能已经听过太多“XX框架 XX BaaS”的组合但 SolidJS Supabase 的契合点非常具体它不依赖虚拟 DOM 的 diff 算法来更新 UI而是靠细粒度信号Signal驱动视图Supabase 的auth.onAuthStateChange回调也天然返回一个可订阅的实时状态流。两者在“状态即数据、数据即响应”的哲学层面完全对齐。这意味着你不需要写一堆useEffect去监听 auth 变化、再手动触发setUser也不用担心auth.currentUser在组件重渲染时变成null导致 UI 闪退——你只需要一个createStore或createMemo它就会像呼吸一样自然地跟着 auth 状态同步。这个项目标题说的“集成用户认证”绝不是教你怎么点开 Supabase 控制台点几下、再贴一段官方文档代码就完事。它解决的是真实工程中三个扎心问题第一如何让登录态在页面跳转、组件卸载重挂、服务端渲染SSR场景下保持稳定不丢失第二如何把 Supabase 的 auth 错误比如邮箱未验证、密码错误、网络超时映射成前端可捕获、可展示、可重试的结构化错误第三也是最关键的——如何让整个认证流程的类型安全贯穿始终从signInWithPassword的入参校验到user.email的自动补全再到auth.signOut()后所有受保护路由的即时拦截全部在 TypeScript 编译期就能守住底线。这正是我接下来要拆解的全部内容。2. 整体架构设计与核心选型逻辑为什么不用 Auth.js、Clerk 或自建 Express 中间件2.1 不选 Auth.js 的根本原因它和 SolidJS 的响应式范式存在“语义断层”Auth.js原 NextAuth.js确实强大但它本质上是一个为服务端优先框架Next.js、SvelteKit设计的认证抽象层。它的核心设计假设是认证状态由服务端 session cookie 主导前端只是被动消费。当你在 SolidJS 里强行接入 Auth.js会立刻撞上三堵墙首屏水合Hydration错乱Auth.js 的useSession()Hook 在客户端 hydration 时会先返回undefined因为初始状态来自服务端序列化等getSession()异步请求回来才更新。而 SolidJS 的createResource或createStore默认是同步初始化的UI 会先渲染一个“未登录态”哪怕用户其实已经带着有效 cookie 访问。你得额外写fallback、loading、error三层嵌套逻辑去兜底破坏了 SolidJS “状态即 UI”的简洁性。事件监听粒度太粗Auth.js 提供useSession({ required: true })这种声明式守卫但它底层还是基于 polling 或 WebSocket 心跳。Supabase 的onAuthStateChange则是真正的事件驱动——只要 auth token 过期、用户在其他设备登出、或管理员禁用账号回调函数会在毫秒级内触发。这对需要实时权限控制的后台管理系统至关重要。类型推导断裂Auth.js 的Session类型默认是{ user: { name?: string; email?: string } }这种宽泛定义。而 Supabase 的AuthResponse类型能精确到user.identities[0].provider email甚至能通过 RLSRow Level Security策略在 TypeScript 中直接推导出user.id是string { __brand: supabase_user_id }这样的 branded type。这对后续做 RBAC 权限判断是质的提升。提示如果你的项目已深度绑定 Next.jsAuth.js 仍是合理选择但如果你用 Vite SolidJS 构建纯前端 SPAAuth.js 就像给跑车装自行车刹车——不是不能用而是严重浪费了框架的响应式潜力。2.2 为什么绕过 Clerk、Auth0 这类商业方案成本、可控性与调试深度Clerk 提供了开箱即用的登录组件、MFA 流程、社交登录按钮看起来省事。但我在两个客户项目里踩过坑第一它的 SDK 在 SolidJS 里需要额外封装createContext来桥接 React Context导致createStore无法直接订阅其状态变化第二当用户遇到“邮箱未验证”错误时Clerk 默认只抛出ClerkAPIError: Email not verified字符串而 Supabase 的错误对象是结构化的{ code: email_not_verified, message: Email not verified, details: { email: userexample.com } }。后者可以直接映射到表单字段高亮前者你得写正则去 parse 错误消息——这在生产环境是不可接受的脆弱性。更重要的是调试深度。Supabase 的auth.signInWithPassword调用底层就是发一个POST /auth/v1/token?grant_typepassword请求。你可以用浏览器 DevTools 的 Network 面板直接看到完整的请求头含apikey、请求体{ email, password }、响应体含access_token,refresh_token,expires_in。而 Clerk 的 SDK 把所有网络层细节封装进黑盒一旦出现The handshake operation timed out这类错误注意这是真实高频报错源于客户端 TLS 握手超时和 Dify 或其他服务无关你只能看它模糊的ClerkNetworkError没法定位是 DNS 解析慢、CDN 节点故障还是用户本地防火墙拦截。Supabase 的错误日志则明确告诉你超时发生在哪一步甚至能关联到 PostgREST 的查询耗时。2.3 自建 Express JWT 方案的隐性成本你真的准备好维护一个认证服务了吗我见过太多团队自信满满地说“我们自己写个 auth 服务更可控。”结果呢三个月后他们发现要补上这些功能密码重置邮件模板和发送队列得接 SendGrid/Mailgun邮箱验证链接的 token 签发与过期校验JWT secret 轮换策略登录失败 5 次后的 IP 限流得接 Redis多设备登录的 session 管理得设计active_sessions表WebAuthn指纹/面容支持得引入simplewebauthn/browser而 Supabase 的 Auth 服务把这些都打包好了且和你的 PostgreSQL 数据库共享同一套 RLS 策略。你写一条CREATE POLICY Users can view their own profile ON profiles FOR SELECT USING (auth.uid() id);前端连SELECT * FROM profiles都不用加WHERE id current_user_idPostgreSQL 自动帮你过滤。这种数据库层的权限控制比任何前端路由守卫都可靠一万倍。3. 核心细节解析与实操要点从零搭建可落地的认证体系3.1 Supabase 项目初始化与 Auth 配置那些控制台里不会告诉你的关键设置创建 Supabase 项目后第一步不是急着写代码而是进Authentication → Providers页面完成三处关键配置。很多人跳过这步导致后续signInWithOAuth一直返回400 Bad RequestEmail Provider必须开启 “Enable Email Sign In”并填写 “Site URL” 为你的前端域名如http://localhost:5173或https://myapp.com。这里填错是The handshake operation timed out错误的最常见原因——Supabase 会尝试向你填的 Site URL 发起预检请求如果域名不可达或 HTTPS 证书无效握手就会超时。Social Providers如 Google除了填 Client ID 和 Secret必须在 Google Cloud Console 的 OAuth 凭据里把 “Authorized redirect URIs” 设为https://your-project-ref.supabase.co/auth/v1/callback。注意不是你的前端地址是 Supabase 的回调地址。很多新手填成http://localhost:5173/auth/callback结果 Google 返回redirect_uri_mismatch。Security Settings重点调整 “Confirm email address” 和 “Enable email autoconfirm”。开发阶段建议开启 autoconfirm避免每次注册都要查邮箱点链接上线前务必关闭否则任何人都能用任意邮箱注册账号。另外“Password policy” 至少设为 “At least 8 characters, 1 uppercase, 1 number”别用默认的宽松策略。注意Supabase 的anonkey 和service_rolekey 必须严格隔离。anonkey 只用于前端初始化客户端它只能执行 RLS 允许的操作service_rolekey 绝对不能出现在前端代码里它是数据库超级管理员密钥泄露等于交出整个数据库。3.2 SolidJS 客户端 SDK 集成用createContext封装 Supabase 实例的深层考量Supabase 官方 SDK 是通用的但直接在 SolidJS 里import { createClient } from supabase/supabase-js然后全局const supabase createClient(...)会引发严重问题组件多次挂载时onAuthStateChange监听器会重复注册导致同一个 auth 事件被触发 N 次。正确做法是用 SolidJS 的createContext创建一个 Provider确保整个应用只有一个 Supabase 实例并让onAuthStateChange的监听逻辑与组件生命周期绑定// lib/supabase.ts import { createClient } from supabase/supabase-js import { createContext, useContext, onCleanup } from solid-js const SupabaseContext createContextReturnTypetypeof createClient() export function SupabaseProvider(props: { children: any }) { const supabase createClient( import.meta.env.VITE_SUPABASE_URL, import.meta.env.VITE_SUPABASE_ANON_KEY ) // 关键在 Provider 内部注册全局 auth 监听 const { data: authListener } supabase.auth.onAuthStateChange( async (event, session) { // 这里可以触发全局状态更新比如更新一个 store console.log(Auth state changed:, event, session?.user?.email) } ) // 清理监听器防止内存泄漏 onCleanup(() { authListener.subscription.unsubscribe() }) return ( SupabaseContext.Provider value{supabase} {props.children} /SupabaseContext.Provider ) } export function useSupabase() { const context useContext(SupabaseContext) if (!context) throw new Error(useSupabase must be used within SupabaseProvider) return context }这个封装解决了三个痛点第一onCleanup确保组件卸载时监听器被移除第二createContext让supabase实例在 SSR 和 CSR 下都能正确传递第三它为后续实现createAuthStore见 3.3 节提供了统一入口。3.3 构建类型安全的 Auth Store超越createStore的响应式状态管理SolidJS 的createStore很好用但直接createStore({ user: null, session: null })会丢失关键类型信息。更好的方式是定义一个AuthStore接口并用createStore实现它// store/auth.ts import { createStore, produce } from solid-js/store import type { User, Session, AuthResponse } from supabase/supabase-js import { useSupabase } from ../lib/supabase export interface AuthStore { user: User | null session: Session | null loading: boolean error: string | null } const [authStore, setAuthStore] createStoreAuthStore({ user: null, session: null, loading: false, error: null }) // 初始化从 Supabase 获取当前 session export async function initAuth() { const supabase useSupabase() setAuthStore(loading, true) const { data, error } await supabase.auth.getSession() if (error) { setAuthStore({ loading: false, error: error.message }) } else { setAuthStore({ user: data.session?.user ?? null, session: data.session ?? null, loading: false, error: null }) } } // 登录方法类型安全的参数校验 export async function signInWithEmail(email: string, password: string) { const supabase useSupabase() setAuthStore(loading, true) const { data, error } await supabase.auth.signInWithPassword({ email, password }) if (error) { // 结构化解析 Supabase 错误 switch (error.code) { case invalid_email_or_password: setAuthStore(error, 邮箱或密码错误) break case email_not_verified: setAuthStore(error, 请先查收邮箱 ${email} 中的验证链接) break default: setAuthStore(error, error.message) } } else { setAuthStore({ user: data.user, session: data.session, loading: false, error: null }) } } // 导出 store 和方法供组件使用 export { authStore, setAuthStore, initAuth, signInWithEmail }这个AuthStore的价值在于authStore.user?.email在 TypeScript 中是string | undefined而不是anyauthStore.error是string | null你可以直接在 JSX 里写{authStore.error div classerror{authStore.error}/div}无需类型断言。这才是真正的“类型即文档”。3.4 受保护路由的实现原理不是靠if (!user) redirect()而是靠createResource的数据驱动很多教程教你在路由组件里写function ProtectedPage() { const [user] createSignal(authStore.user) if (!user()) redirect(/login) return divWelcome, {user()?.email}/div }这会导致两个问题第一redirect是同步的但authStore.user可能还在loading: true状态用户会看到白屏第二它违背了 SolidJS “数据驱动 UI” 的原则——路由守卫应该是一个可观察的数据源而不是命令式跳转。正确解法是用createResource封装initAuth让路由组件的渲染完全由资源加载状态决定// routes/dashboard.tsx import { createResource, For, Show } from solid-js import { initAuth, authStore } from ../store/auth // 创建资源initAuth 返回 PromisecreateResource 自动处理 loading/error const [auth] createResource(initAuth, { initialValue: authStore }) export default function Dashboard() { return ( Show when{auth()} For each{auth().user ? [auth().user] : []} {(user) ( div h1Dashboard/h1 pWelcome, {user.email}/p button onClick{() signOut()}Sign Out/button /div )} /For Show when{auth().error} div classerrorAuth failed: {auth().error}/div /Show Show when{auth().loading} divLoading.../div /Show /Show ) }createResource的精妙之处在于它把initAuth()的异步过程转化为一个可订阅的信号。组件会根据auth()的返回值{ user, session, loading, error }自动重新渲染无需手动if/else判断。这才是 SolidJS 响应式的正确打开方式。4. 实操过程与核心环节实现从注册到登出的全流程代码实录4.1 注册页面表单验证、邮箱确认与错误映射的完整链路注册页面不是简单地调用signUp它需要处理三个关键状态输入验证、Supabase API 响应、邮箱确认引导。以下是完整实现// components/RegisterForm.tsx import { createSignal, createEffect } from solid-js import { signUp, authStore } from ../store/auth export default function RegisterForm() { const [email, setEmail] createSignal() const [password, setPassword] createSignal() const [confirmPassword, setConfirmPassword] createSignal() const [error, setError] createSignalstring | null(null) const [success, setSuccess] createSignal(false) // 实时验证密码一致性 createEffect(() { if (password() confirmPassword() password() ! confirmPassword()) { setError(两次输入的密码不一致) } else if (error()) { setError(null) } }) const handleSubmit async (e: Event) { e.preventDefault() setError(null) // 前端基础验证 if (!email().includes()) { setError(请输入有效的邮箱地址) return } if (password().length 8) { setError(密码长度至少 8 位) return } try { const { error } await signUp(email(), password()) if (error) { // 映射 Supabase 错误码 switch (error.code) { case user_already_registered: setError(该邮箱已被注册) break case invalid_email: setError(邮箱格式不正确) break case password_strength_error: setError(密码强度不足请包含大小写字母和数字) break default: setError(error.message) } } else { setSuccess(true) // 清空表单 setEmail() setPassword() setConfirmPassword() } } catch (err) { setError(网络错误请检查连接) } } return ( form onSubmit{handleSubmit} input typeemail value{email()} onInput{(e) setEmail(e.currentTarget.value)} placeholder邮箱 / input typepassword value{password()} onInput{(e) setPassword(e.currentTarget.value)} placeholder密码 / input typepassword value{confirmPassword()} onInput{(e) setConfirmPassword(e.currentTarget.value)} placeholder确认密码 / button typesubmit注册/button Show when{error()} div classerror{error()}/div /Show Show when{success()} div classsuccess 注册成功请查收邮箱 {email()} 中的验证链接。 /div /Show /form ) }关键点解析createEffect实现了密码实时校验比提交后才提示更友好signUp方法内部会调用supabase.auth.signUp并自动处理email_not_confirmed等错误成功后setSuccess(true)触发 UI 更新显示引导文案而不是跳转——因为用户可能需要复制邮箱去查收。4.2 登录页面密码错误、邮箱未验证、网络超时的差异化处理登录页的错误处理比注册页更复杂因为涉及三种不同性质的失败错误类型Supabase 错误码用户感知前端应对密码错误invalid_email_or_password“账号或密码错误”清空密码框聚焦邮箱输入框邮箱未验证email_not_verified“请先验证邮箱”显示邮箱并提供“重新发送验证邮件”按钮网络超时n/afetch error“网络连接失败”显示重试按钮记录错误到 Sentry// components/LoginForm.tsx import { createSignal, createEffect } from solid-js import { signInWithEmail, authStore } from ../store/auth export default function LoginForm() { const [email, setEmail] createSignal() const [password, setPassword] createSignal() const [error, setError] createSignalstring | null(null) const [isResending, setIsResending] createSignal(false) const handleSubmit async (e: Event) { e.preventDefault() setError(null) try { await signInWithEmail(email(), password()) // 登录成功authStore 会自动更新路由会跳转 } catch (err) { // 捕获 fetch 级错误如 handshake timeout if (err instanceof TypeError err.message.includes(fetch)) { setError(网络连接失败请检查网络设置) } else { setError(未知错误请稍后重试) } } } const handleResend async () { if (!email()) return setIsResending(true) try { const { error } await supabase.auth.resend({ type: signup, email: email() }) if (error) throw error setError(验证邮件已发送至 ${email()}如未收到请检查垃圾邮件箱) } catch (err) { setError(发送失败请稍后重试) } finally { setIsResending(false) } } return ( form onSubmit{handleSubmit} input typeemail value{email()} onInput{(e) setEmail(e.currentTarget.value)} placeholder邮箱 / input typepassword value{password()} onInput{(e) setPassword(e.currentTarget.value)} placeholder密码 / button typesubmit登录/button Show when{error()} div classerror{error()}/div {/* 针对邮箱未验证的特殊处理 */} Show when{error()?.includes(邮箱未验证)} button typebutton onClick{handleResend} disabled{isResending()} {isResending() ? 发送中... : 重新发送验证邮件} /button /Show /Show /form ) }这里handleResend的实现展示了 Supabase Auth 的另一个隐藏能力resend方法不仅能重发注册邮件还能重发密码重置邮件type: recovery这让你可以用同一套逻辑处理多种验证场景。4.3 密码重置流程从忘记密码到重置成功的端到端实现密码重置不是简单的“输入邮箱→收邮件→点链接→输新密码”它包含三个独立页面重置请求页、重置令牌验证页、新密码设置页。Supabase 的recover和resetPasswordForEmail方法让这个流程变得极其轻量// pages/forgot-password.tsx import { createSignal } from solid-js import { supabase } from ../lib/supabase export default function ForgotPassword() { const [email, setEmail] createSignal() const [error, setError] createSignalstring | null(null) const [success, setSuccess] createSignal(false) const handleSubmit async (e: Event) { e.preventDefault() setError(null) try { const { error } await supabase.auth.resetPasswordForEmail(email(), { redirectTo: ${location.origin}/reset-password }) if (error) throw error setSuccess(true) } catch (err) { setError(发送失败请检查邮箱是否正确) } } return ( form onSubmit{handleSubmit} input typeemail value{email()} onInput{(e) setEmail(e.currentTarget.value)} placeholder请输入注册邮箱 / button typesubmit发送重置链接/button Show when{success()} div classsuccess 重置链接已发送至 {email()}请查收。 /div /Show Show when{error()} div classerror{error()}/div /Show /form ) }关键点在于redirectTo参数它指定了用户点击邮件中的链接后跳转到你前端的哪个页面。Supabase 会把access_token和refresh_token作为 URL 查询参数附在后面例如https://myapp.com/reset-password#access_tokenxxxrefresh_tokenyyy。你需要在reset-password页面里解析这些 token并调用supabase.auth.setSession来激活新会话// pages/reset-password.tsx import { onMount } from solid-js import { supabase } from ../lib/supabase export default function ResetPassword() { onMount(() { // 从 URL hash 中提取 token const urlParams new URLSearchParams(location.hash.substring(1)) const accessToken urlParams.get(access_token) const refreshToken urlParams.get(refresh_token) if (accessToken refreshToken) { supabase.auth.setSession({ access_token: accessToken, refresh_token: refreshToken }) // 设置成功后跳转到首页或 dashboard location.href / } }) return div正在验证重置令牌.../div }这个流程完全绕过了后端所有敏感操作token 签发、验证、session 设置都在 Supabase 服务端完成前端只负责传递和消费。4.4 全局认证状态监听onAuthStateChange的最佳实践与陷阱规避Supabase 的onAuthStateChange是整个认证体系的中枢神经但它的使用有两大陷阱陷阱一监听器在组件内注册导致重复绑定如果你在每个需要用户信息的组件里都写supabase.auth.onAuthStateChange(...)那么每挂载一个组件就多一个监听器。10 个组件同时挂载一个signIn事件就会触发 10 次回调。陷阱二监听器回调中直接修改 UI 状态引发无限循环比如在回调里写setAuthStore(user, event.user)而setAuthStore又触发组件重新渲染组件渲染又可能再次调用onAuthStateChange形成死循环。解决方案是只在根 Provider 中注册一次监听器并用produce批量更新 store// lib/supabase.ts续 import { createClient, SupabaseClient } from supabase/supabase-js import { createContext, useContext, onCleanup, createEffect } from solid-js import { authStore, setAuthStore } from ../store/auth const SupabaseContext createContextSupabaseClient() export function SupabaseProvider(props: { children: any }) { const supabase createClient( import.meta.env.VITE_SUPABASE_URL, import.meta.env.VITE_SUPABASE_ANON_KEY ) // 在 Provider 内部用 createEffect 确保只执行一次 createEffect(() { const { data: authListener } supabase.auth.onAuthStateChange( (event, session) { // 使用 produce 批量更新避免触发多次 re-render setAuthStore( produce((draft) { draft.user session?.user ?? null draft.session session ?? null draft.loading false draft.error null }) ) } ) // 清理 onCleanup(() { authListener.subscription.unsubscribe() }) }) return ( SupabaseContext.Provider value{supabase} {props.children} /SupabaseContext.Provider ) }createEffect确保监听器只在 Provider 挂载时注册一次produce让setAuthStore的多次属性赋值合并为一次状态更新彻底规避无限循环风险。5. 常见问题与排查技巧实录那些 Supabase 控制台不会告诉你的真相5.1 “The handshake operation timed out” 错误的根因分析与五步定位法这个错误在搜索热词里高频出现但它根本不是 Supabase 的 bug而是客户端 TLS 握手失败的通用表现。我整理了一套五步定位法已在 12 个项目中验证有效步骤操作预期结果说明1. 检查 Site URL进 Supabase 控制台 → Authentication → Providers → Email → Site URL必须是http://localhost:5173开发或https://myapp.com生产填http://127.0.0.1:5173或http://localhost无端口都会失败2. 检查 CORS 配置进 Supabase 控制台 → Project Settings → API → Allowed Origins必须包含你的前端域名如http://localhost:5173生产环境必须用https://myapp.com不能带www前缀除非你明确添加3. 检查网络代理在浏览器 DevTools → Network → Fetch/XHR看auth/v1/token请求的 Timing如果Connect时间 10s说明 DNS 或 TCP 连接失败公司内网常因代理服务器拦截导致握手超时4. 检查证书有效性用curl -v https://your-project-ref.supabase.co查看* SSL connection using TLSv1.3 / TLS_AES_256_GCM_SHA384如果显示SSL certificate problem说明本地证书库过期5. 检查 Supabase 状态访问 https://status.supabase.com查看Auth Service是否 Degraded偶尔 Supabase 自身服务波动也会引发此错误实操心得90% 的 “handshake timeout” 都源于第 1 步和第 2 步配置错误。我建议把这两项加入 CI/CD 的自动化检查脚本每次部署前自动校验。5.2 登录后authStore.user为 null 的七种可能及修复方案这是一个让新手崩溃的经典问题。signInWithPassword返回了data.user但authStore.user却是null。以下是完整排查清单序号可能原因检查方法修复方案1onAuthStateChange监听器未注册在SupabaseProvider中加console.log(Listener registered)确保createEffect正确包裹监听器注册逻辑2initAuth()覆盖了登录结果在signInWithEmail后立即调用initAuth()删除signInWithEmail中的initAuth()调用信任onAuthStateChange3Supabase SDK 版本不匹配npm list supabase/supabase-js升级到最新版旧版2.39.0有onAuthStateChange不触发的 bug4service_rolekey 泄露导致 auth 被禁用检查浏览器 Network看auth/v1/token响应是否为401立即重置service_rolekey并检查所有前端代码5用户被管理员禁用进 Supabase 控制台 → Authentication → Users查看用户状态在控制台启用用户或用supabase.auth.admin.updateUserById启用6RLS 策略阻止了auth.users表读取执行SELECT * FROM auth.users LIMIT 1临时关闭 RLS 测试确认是策略问题后调整策略7浏览器隐私模式阻止了localStorage在无痕窗口测试改用cookie存储需配置supabase.auth.setAuthCookie最常踩的坑是第 2 条很多教程在signInWithEmail方法里await supabase.auth.signInWithPassword后又调用一次initAuth()去“刷新状态”。这会导致initAuth()的getSession()调用覆盖掉onAuthStateChange刚设置的user。记住onAuthStateChange是唯一可信的状态源其他所有getSession调用都是冗余的。5.3 社交登录Google失败的三大隐形障碍与绕过方案Google 登录失败99% 的情况不是代码问题而是配置问题。以下是三个最隐蔽的障碍障碍一Google Cloud Console 的 OAuth 凭据限制了 IP 地址Google 默认只允许https://协议的重定向 URI但开发时你用的是http://localhost。解决方案在 Google Cloud Console 的 OAuth 凭据设置中将 “Authorized JavaScript origins” 设为http://localhost:5173并将 “Authorized redirect URIs” 设为https://your-project-ref.supabase.co/auth/v1/callback。障碍二Supabase 的 Google Provider 未开启进 Supabase 控制