1. 项目概述为什么一个LLM SaaS的起点必须是“稳”和“快”我做后端开发十年从Django到Flask再到现在的FastAPI踩过的坑比写过的代码还多。去年开始带团队做几个面向中小企业的AI工具核心诉求就两个字上线快、不出错。不是追求炫技而是客户今天提需求明天就要能用上基础功能。所以当我在Quivr这个24k Star的开源项目里看到它把Supabase Auth和文件上传这两块拆得清清楚楚、实打实跑在生产环境时第一反应不是“哇好酷”而是“这结构能直接抄作业”。这篇讲的就是我们团队基于Quivr提炼出的FastAPI LLM SaaS第一块地基——认证与文件上传。它不涉及大模型推理、向量检索这些高光环节恰恰相反它处理的是所有用户都会遇到的、最枯燥也最致命的环节怎么确认来的人真是他本人怎么把用户上传的PDF、CSV安全、可靠、可追溯地存进系统这两个问题没解决好后面再炫的RAG、再强的流式响应都是沙上筑塔。关键词里的“Towards AI - Medium”不是凑数的。这篇文章的原始出处恰恰说明了它的实战属性——它不是学院派的理论推演而是工程师在真实项目压力下从一个成熟开源项目里“扒”出来的、经过验证的最小可行路径。我们团队把它落地时把原方案里一些隐含的假设、环境依赖、权限陷阱都补全了比如Supabase Anon Key和Service Key到底该在哪儿用、JWT校验时为什么必须关掉verify_aud、文件上传失败时如何精准区分是网络超时还是存储桶权限错误。这些细节文档里不会写但线上报错时它们就是你凌晨三点爬起来查日志的全部理由。适合谁看如果你正打算用FastAPI搭一个带用户体系的AI应用哪怕只是个内部工具也强烈建议你从这里开始。它不教你如何调用OpenAI API但它会确保当用户点下“登录”按钮时你的后端不会因为一个JWT密钥配置错误而返回500它不讲Embedding模型怎么选但它能保证用户拖进来的100页PDF不会因为文件名里有个中文斜杠就卡死在上传环节。这才是工程师真正的基本功。2. 整体架构设计与核心思路拆解2.1 为什么放弃自建Auth选择Supabase坦白说我最初是抗拒的。作为一个写了八年用户系统的老后端看到“Auth即服务”这种说法第一反应是“又一个被过度简化的抽象”。但现实很快打了脸。我们第一个POC项目只为了实现邮箱密码登录、Google OAuth、密码重置这三个功能前后花了三周。不是逻辑复杂而是无数个“边缘case”邮箱大小写怎么处理重置链接有效期是15分钟还是24小时OAuth回调地址在本地开发、测试、生产三个环境怎么配置才不互相污染更别说后续要加短信验证码、MFA这些。Supabase Auth的价值不在于它多先进而在于它把所有这些“应该有但没人想写”的东西打包成一个Postgres表auth.users和一套稳定API。它不是黑盒你随时可以连进数据库看auth.users表里每条记录的raw_user_meta_data字段里面存着用户头像、姓名等扩展信息你也可以在Supabase Dashboard里直接编辑用户状态禁用某个测试账号。这种“透明的便利”是自研Auth永远无法提供的。提示Supabase Auth本质是Postgres pg_net 自定义函数。它的“开箱即用”建立在对Postgres深度定制的基础上。这意味着当你需要微调登录流程比如要求新用户必须绑定手机号你不是去改一个独立的Auth服务代码而是直接在Supabase里写一个Postgres函数然后在前端调用它。这种能力让Auth从一个“外部依赖”变成了你数据栈的一部分。2.2 为什么文件上传不走FastAPI直连Supabase Storage这是最容易被误解的一点。很多新手会想“既然用了FastAPI那所有请求都应该先过它再由它转发给Storage这样我能统一加日志、加鉴权、加限流。”想法很美好但实践下来全是坑。首先性能瓶颈。用户上传一个200MB的PDF如果让FastAPI进程全程扛着这个二进制流它会吃掉大量内存和CPU严重拖慢整个服务的响应速度。FastAPI的异步IO在这里帮不上忙因为文件读取本身是阻塞的除非你用aiofiles但那又引入了新的复杂度。其次可靠性风险。网络传输是不可靠的。如果用户上传到90%时断网FastAPI收到的是一段不完整的数据你得自己实现断点续传逻辑。而Supabase Storage底层是S3兼容对象存储天生支持分块上传Multipart Upload客户端可以直接和它对话失败了重传某一块就行完全绕过你的后端。最后也是最关键的——权限模型。Supabase Storage的权限控制是基于Bucket和文件路径的Row Level Security (RLS)。你可以精确设置规则“用户只能读写自己ID目录下的文件”。这个规则是在Storage服务层执行的不是在你的FastAPI代码里if user.id file_path.split(/)[0]。前者是原子性的、不可绕过的后者是一行可能被bug或疏忽跳过的代码。所以我们的设计是前端拿到一个临时上传凭证由FastAPI后端生成然后直接和Supabase Storage通信。FastAPI只做两件事1验证用户身份通过JWT2生成一个带签名的、有时效性的上传URL。这就像机场安检——FastAPI是那个查身份证的安检员它确认你是谁之后就给你一张登机牌上传凭证剩下的登机、找座位、起飞都由航空公司Supabase Storage负责。2.3 整体架构图解三层分离各司其职------------------ HTTPS --------------------- Redis Pub/Sub ------------------ | Frontend | ------------ | FastAPI Backend | ----------------- | Celery Worker | | (React/Vue/etc) | | (Auth, API Routes) | | (Long Tasks) | ------------------ -------------------- ------------------ | | HTTP (JWT Bearer) v --------------------- | Supabase DB | | - auth.users | | - public.files | | - storage.buckets | -------------------- | | Direct S3-compatible API v --------------------- | Supabase Storage | | (Files: PDF, CSV) | ---------------------这个图里最核心的箭头只有两条一条是前端到FastAPI的HTTPS请求带JWT另一条是FastAPI到Supabase DB的直接连接。Celery Worker和FastAPI之间没有HTTP调用而是通过Redis消息队列通信这保证了长任务如PDF解析不会阻塞API响应。而Supabase Storage它根本不在这个“请求链路”上它是一个独立的、高可用的对象存储服务前端和Worker都可以直接访问它。这种彻底的解耦是系统能横向扩展的基础。3. 核心细节解析与实操要点3.1 Supabase Auth集成JWT校验的魔鬼细节Supabase Auth生成的JWT和你用PyJWT手动生成的有一个关键区别它的audAudience字段默认是authenticated。这是一个安全设计意味着这个Token只被授权给Supabase自己的服务使用。但当你在FastAPI后端用jose.jwt.decode去校验它时如果不显式告诉库“别检查aud”就会抛出InvalidAudienceError。这就是为什么原文代码里有options{verify_aud: False}。这不是偷懒而是必须的妥协。因为你的FastAPI后端在这里扮演的角色是Supabase Auth服务的“信任代理”。Supabase已经用它的私钥签发了Token证明了用户身份你的后端只需要验证这个签名的有效性并提取其中的sub用户UUID和email就完成了身份核验。强制校验aud反而会破坏这个信任链。另一个常被忽略的点是密钥管理。Supabase提供两种密钥Anon Key和Service Key。很多人会混淆它们的用途Anon Key用于前端SDK权限极低只能做登录、注册等公开操作。它被硬编码在前端代码里所以必须是“匿名”的、无害的。Service Key用于后端服务拥有最高权限可以读写所有表。它绝对不能出现在前端在JWT校验中你必须使用Anon Key作为SECRET_KEY。因为JWT是Supabase Auth服务用它的私钥签发的而公钥验证时需要用Supabase提供的、与之配对的Anon Key它本质上是一个公钥的Base64编码。Service Key是用于后端调用Supabase Admin API的和JWT校验无关。如果你错误地把Service Key当成JWT密钥校验一定会失败而且你会花很长时间排查因为错误信息只会显示“Signature verification failed”。注意Supabase Dashboard里Anon Key和Service Key都叫“Project API Keys”但它们的权限天差地别。务必在.env文件里用清晰的变量名区分SUPABASE_ANON_KEY和SUPABASE_SERVICE_KEY。我们团队的规范是任何代码里出现os.getenv(SUPABASE_SERVICE_KEY)的地方都必须有# SECURITY: This key must never be exposed to frontend的注释。3.2 文件上传流程从前端凭证到后端落库整个流程分为四步每一步都有其不可替代的作用前端发起上传请求用户选择文件后前端向FastAPI的/upload/presign接口发送一个POST请求携带用户JWT。后端生成预签名URLFastAPI收到JWT验证通过后调用Supabase Storage的create_signed_upload_url方法注意不是upload传入文件名如user_id/filename.pdf和过期时间建议15分钟。Supabase返回一个带签名的、一次性的、仅限上传的URL。前端直传文件前端拿到这个URL用fetch或axios直接向它发送PUT请求把文件二进制流作为body。这个请求不带任何JWT因为签名已经包含了所有权限信息。后端记录元数据文件上传成功后Supabase Storage会触发一个Webhook或你可以在前端上传成功后再调用一个FastAPI的/upload/confirm接口后端此时才在public.files表里插入一条记录关联user_id、file_name、storage_path、size等信息。这个设计的精妙之处在于上传动作本身是无状态的。FastAPI不碰文件内容只负责“发通行证”和“记台账”。这让你可以轻松地为上传服务做水平扩展——部署10个FastAPI实例它们共享同一个Supabase项目互不影响。而文件存储的可靠性则100%交给了Supabase背后的对象存储通常是AWS S3或GCP Cloud Storage它们本身就是为海量、高并发文件上传而生的。3.3 安全边界RLS策略如何守护你的数据Supabase的Row Level Security (RLS) 是它的灵魂。没有它Supabase Auth就只是一个登录框。我们为文件表public.files写的RLS策略如下-- 策略名称: users_can_read_own_files -- 启用: true -- 对于 SELECT 操作 -- 策略表达式: auth.uid() user_id -- 策略名称: users_can_insert_own_files -- 启用: true -- 对于 INSERT 操作 -- 策略表达式: auth.uid() NEW.user_id -- 策略名称: users_can_update_own_files -- 启用: true -- 对于 UPDATE 操作 -- 策略表达式: auth.uid() OLD.user_id AND auth.uid() NEW.user_id这段SQL的意思是任何用户只能查询、插入、更新自己user_id字段匹配的记录。auth.uid()是Supabase提供的一个函数它会自动从当前请求的JWT中提取sub字段即用户UUID。这个函数在数据库层面执行意味着即使你的FastAPI后端代码里有个WHERE user_id hacker-id的bugRLS也会在SQL执行前就把这个恶意请求拦下来。实操心得RLS策略必须配合auth.users表的id字段使用。不要试图用email或其他字段做关联因为email可能重复虽然Supabase Auth默认不允许但自定义逻辑可能绕过而id是全局唯一的UUID是唯一可靠的锚点。我们曾经在一个项目里因为误用了email做RLS关联导致两个同名用户的数据互相可见修复时不得不导出所有数据用脚本重新生成UUID并更新所有外键耗时一整天。4. 实操过程与核心环节实现4.1 环境准备与依赖安装我们使用poetry进行依赖管理因为它能完美隔离不同项目的Python环境避免pip install带来的版本冲突。初始化项目# 创建项目目录 mkdir llm-saas-template cd llm-saas-template # 初始化poetry poetry init -n # 添加核心依赖 poetry add fastapi uvicorn python-jose[cryptography] python-dotenv supabase langchain # 添加开发依赖 poetry add pytest pytest-asyncio httpx black isort # 生成lock文件 poetry lock关键依赖说明python-jose[cryptography]: JWT编解码的核心库[cryptography]是可选依赖但必须加上否则HS256算法无法工作。supabase: Supabase官方Python SDK它封装了对Auth、Storage、DB的所有API调用。langchain: 虽然本Part不涉及LLM但它是后续Part处理PDF/CSV的基石提前装好避免版本不兼容。.env文件内容请务必将your-values替换为实际值# Supabase项目配置 SUPABASE_URLhttps://your-project-ref.supabase.co SUPABASE_ANON_KEYeyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9... SUPABASE_SERVICE_KEYeyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9... # JWT密钥必须与Supabase Anon Key一致 JWT_SECRET_KEYeyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9... # Supabase Storage配置 SUPABASE_BUCKETquivr-files # 开发模式开关 AUTHENTICATEtrue4.2 JWT认证模块完整实现auth/jwt_token_handler.py是整个认证体系的心脏。我们对原文代码做了大幅增强加入了详细的日志、更严格的异常处理和测试友好的结构import os import logging from datetime import datetime, timedelta from typing import Optional, Dict, Any from jose import jwt, JWTError from jose.exceptions import ExpiredSignatureError, JWTClaimsError from models import UserIdentity logger logging.getLogger(__name__) # 从环境变量读取密钥失败则抛出明确错误 SECRET_KEY os.environ.get(JWT_SECRET_KEY) if not SECRET_KEY: raise ValueError(JWT_SECRET_KEY environment variable is required but not set.) ALGORITHM HS256 # Token过期时间生产环境建议设为1小时 ACCESS_TOKEN_EXPIRE_MINUTES int(os.environ.get(ACCESS_TOKEN_EXPIRE_MINUTES, 60)) def create_access_token(data: Dict[str, Any], expires_delta: Optional[timedelta] None) - str: 创建JWT Access Token。 :param data: 要编码到token中的数据通常包含user_id和email :param expires_delta: 过期时间偏移量若为None则使用默认值 :return: 编码后的JWT字符串 to_encode data.copy() if expires_delta: expire datetime.utcnow() expires_delta else: expire datetime.utcnow() timedelta(minutesACCESS_TOKEN_EXPIRE_MINUTES) to_encode.update({exp: expire}) try: encoded_jwt jwt.encode(to_encode, SECRET_KEY, algorithmALGORITHM) logger.debug(fCreated JWT for user {data.get(sub, unknown)}) return encoded_jwt except Exception as e: logger.error(fFailed to create JWT: {e}) raise def decode_access_token(token: str) - Optional[UserIdentity]: 解码并验证JWT Access Token。 :param token: 待解码的JWT字符串 :return: UserIdentity对象如果解码失败则返回None try: # 关键关闭audience校验因为Supabase JWT的aud是authenticated payload jwt.decode( token, SECRET_KEY, algorithms[ALGORITHM], options{verify_aud: False} ) email: str payload.get(email) user_id: str payload.get(sub) # sub是Supabase Auth分配的UUID if not email or not user_id: logger.warning(JWT payload missing email or sub field) return None return UserIdentity(emailemail, iduser_id) except ExpiredSignatureError: logger.warning(JWT token has expired) return None except JWTClaimsError as e: logger.warning(fJWT claims error: {e}) return None except JWTError as e: logger.warning(fJWT decode error: {e}) return None except Exception as e: logger.error(fUnexpected error during JWT decode: {e}) return None def verify_token(token: str) - bool: 验证JWT token是否有效仅检查签名和过期。 :param token: 待验证的JWT字符串 :return: True if valid, False otherwise return decode_access_token(token) is not Noneauth/auth_bearer.py实现了FastAPI的依赖注入式认证import os from typing import Optional, Callable, Awaitable from fastapi import Depends, HTTPException, Request, status from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer from models import UserIdentity from auth.jwt_token_handler import decode_access_token, verify_token class AuthBearer(HTTPBearer): def __init__(self, auto_error: bool True): super().__init__(auto_errorauto_error) async def __call__(self, request: Request) - UserIdentity: credentials: Optional[HTTPAuthorizationCredentials] await super().__call__(request) self.check_scheme(credentials) token credentials.credentials # type: ignore return await self.authenticate(token) def check_scheme(self, credentials: Optional[HTTPAuthorizationCredentials]): if not credentials: raise HTTPException( status_codestatus.HTTP_403_FORBIDDEN, detailAuthentication credentials missing, ) if credentials.scheme ! Bearer: raise HTTPException( status_codestatus.HTTP_401_UNAUTHORIZED, detailToken must be Bearer, ) async def authenticate(self, token: str) - UserIdentity: # 开发模式开关方便本地调试 if os.environ.get(AUTHENTICATE, true).lower() false: return self.get_test_user() if not verify_token(token): raise HTTPException( status_codestatus.HTTP_401_UNAUTHORIZED, detailInvalid token or api key., headers{WWW-Authenticate: Bearer}, ) user decode_access_token(token) if not user: raise HTTPException( status_codestatus.HTTP_401_UNAUTHORIZED, detailCould not validate credentials, headers{WWW-Authenticate: Bearer}, ) return user def get_test_user(self) - UserIdentity: 返回一个测试用户仅用于开发环境 return UserIdentity( emaildevexample.com, id11111111-1111-1111-1111-111111111111 ) # 依赖注入供路由使用 get_current_user Depends(AuthBearer())4.3 文件上传路由与存储客户端repository/files/upload_file.py封装了与Supabase Storage的交互import logging import os from typing import Optional, BinaryIO from supabase.client import Client, create_client from dotenv import load_dotenv load_dotenv() logger logging.getLogger(__name__) def get_supabase_client() - Client: 创建Supabase客户端实例。 注意此客户端应使用Service Key因为它需要管理Storage。 url os.getenv(SUPABASE_URL) key os.getenv(SUPABASE_SERVICE_KEY) if not url or not key: raise ValueError(SUPABASE_URL and SUPABASE_SERVICE_KEY must be set in environment variables) return create_client(url, key) def upload_file_storage( file_content: bytes, file_identifier: str, bucket_name: Optional[str] None ) - str: 将文件内容上传到Supabase Storage。 :param file_content: 文件的二进制内容 :param file_identifier: 存储路径格式为 user_id/filename.pdf :param bucket_name: 存储桶名称默认从环境变量读取 :return: 文件在Storage中的完整路径 client get_supabase_client() bucket bucket_name or os.getenv(SUPABASE_BUCKET, quivr-files) try: # 创建存储桶如果不存在生产环境应提前在Dashboard创建 # client.storage.create_bucket(bucket, {public: False}) # 执行上传 response client.storage.from_(bucket).upload(file_identifier, file_content) logger.info(fFile uploaded successfully to {bucket}/{file_identifier}) return f{bucket}/{file_identifier} except Exception as e: logger.error(fFailed to upload file {file_identifier} to bucket {bucket}: {e}) raise def create_presigned_upload_url( file_identifier: str, expires_in: int 900 # 15 minutes ) - str: 为文件上传创建一个预签名URL。 :param file_identifier: 存储路径 :param expires_in: URL过期时间秒 :return: 预签名的上传URL client get_supabase_client() bucket os.getenv(SUPABASE_BUCKET, quivr-files) try: # Supabase Python SDK目前不直接支持create_signed_upload_url # 我们需要手动构造一个符合S3签名V4的URL但这很复杂。 # 更简单的方法使用Supabase的Admin API调用/storage/v1/object/sign/{bucket} # 但需要Service Key权限。 # 因此我们采用一个折中方案在Supabase Dashboard中启用Storage的Public权限 # 并在RLS策略中严格控制文件路径然后使用简单的URL。 # 生产环境强烈建议使用真正的预签名URL。 # 此处为演示返回一个占位符URL。 return fhttps://placeholder-upload-url/{file_identifier}?expires{expires_in} except Exception as e: logger.error(fFailed to create presigned URL for {file_identifier}: {e}) raiseapi/upload_router.py定义了FastAPI路由import logging import os from typing import Optional from uuid import UUID from fastapi import APIRouter, Depends, HTTPException, Query, Request, UploadFile, File, Form from auth import get_current_user from models import UserIdentity, UserUsage from repository.files.upload_file import upload_file_storage from repository.user_identity import get_user_identity logger logging.getLogger(__name__) upload_router APIRouter() upload_router.get(/upload/healthz, tags[Health]) async def healthz(): return {status: ok} upload_router.post(/upload, dependencies[Depends(get_current_user)], tags[Upload]) async def upload_file( request: Request, uploadFile: UploadFile File(...), chat_id: Optional[UUID] Query(None, descriptionThe ID of the chat), current_user: UserIdentity Depends(get_current_user), ): 处理用户文件上传。 注意此端点仅用于接收文件元数据和触发上传流程。 实际文件传输由前端直连Supabase Storage完成。 # 读取文件内容小文件适用大文件需流式处理 try: file_content await uploadFile.read() except Exception as e: logger.error(fFailed to read file {uploadFile.filename}: {e}) raise HTTPException(status_code400, detailFailed to read uploaded file) # 构建存储路径user_id/filename filename_with_user_id f{current_user.id}/{uploadFile.filename} try: # 调用存储客户端上传 storage_path upload_file_storage(file_content, filename_with_user_id) logger.info(fFile {uploadFile.filename} uploaded to {storage_path}) # TODO: 在public.files表中插入元数据记录 # insert_file_metadata(current_user.id, uploadFile.filename, storage_path, len(file_content)) return { message: File processing has started., file_id: str(current_user.id), filename: uploadFile.filename, storage_path: storage_path } except Exception as e: logger.error(fUpload failed for {uploadFile.filename}: {e}) # 精准捕获常见错误 error_msg str(e) if The resource already exists in error_msg: raise HTTPException( status_code409, detailfFile {uploadFile.filename} already exists in storage. ) elif Connection refused in error_msg or timeout in error_msg.lower(): raise HTTPException( status_code503, detailStorage service is temporarily unavailable. Please try again later. ) else: raise HTTPException( status_code500, detailFailed to upload file to storage. )4.4 数据库迁移与RLS策略部署Supabase的数据库迁移我们推荐使用supabaseCLI工具而不是手动在Dashboard里点。首先安装CLI# macOS brew tap supabase/tap brew install supabase # Windows (PowerShell) scoop bucket add supabase https://github.com/supabase/scoop-bucket.git scoop install supabase # 登录 supabase login # 关联你的项目 supabase link --project-ref your-project-ref创建迁移文件supabase/migrations/20231120120000_init.sql-- 创建files表 CREATE TABLE IF NOT EXISTS public.files ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), user_id UUID NOT NULL REFERENCES auth.users (id) ON DELETE CASCADE, file_name TEXT NOT NULL, storage_path TEXT NOT NULL, size_bytes BIGINT NOT NULL, mime_type TEXT, created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(), updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW() ); -- 启用RLS ALTER TABLE public.files ENABLE ROW LEVEL SECURITY; -- 创建RLS策略 CREATE POLICY users_can_read_own_files ON public.files FOR SELECT USING (auth.uid() user_id); CREATE POLICY users_can_insert_own_files ON public.files FOR INSERT WITH CHECK (auth.uid() user_id); CREATE POLICY users_can_update_own_files ON public.files FOR UPDATE USING (auth.uid() user_id) WITH CHECK (auth.uid() user_id); -- 创建索引以提高查询性能 CREATE INDEX idx_files_user_id ON public.files (user_id); CREATE INDEX idx_files_created_at ON public.files (created_at);应用迁移supabase db push实操心得RLS策略的USING和WITH CHECK子句初学者很容易混淆。简单记USING控制“能不能看到”WITH CHECK控制“能不能改”。对于INSERTWITH CHECK确保你插入的记录其user_id必须等于当前登录用户的ID对于UPDATEUSING确保你只能更新自己的记录WITH CHECK确保你更新后的记录user_id字段不能被改成别人的ID。我们曾在一个项目里漏写了WITH CHECK导致用户可以通过UPDATE files SET user_id other-user-id WHERE id my-file-id来窃取他人文件。5. 常见问题与排查技巧实录5.1 JWT校验失败401 Unauthorized 的七种可能当你的API返回401时不要急着改代码先按这个清单快速排查现象最可能原因排查命令/步骤解决方案Invalid token or api key.JWT密钥不匹配echo $JWT_SECRET_KEY和 Supabase Dashboard里的Anon Key对比确保.env中的JWT_SECRET_KEY和Supabase的Anon Key完全一致包括所有字符和空格Could not validate credentialsToken已过期在https://jwt.io/粘贴你的token看exp字段前端在登录后应将token存入localStorage并在每次请求时带上后端ACCESS_TOKEN_EXPIRE_MINUTES应设为合理值如60Signature verification failed使用了Service Key而非Anon Key检查auth/jwt_token_handler.py中SECRET_KEY的来源必须使用Supabase Dashboard里的Anon KeyService Key只用于supabase.client初始化JWT decode error: ...Token格式错误缺少Bearer前缀curl -H Authorization: Bearer token ...确保前端请求头是Authorization: Bearer token不是Authorization: tokenJWT payload missing email or subSupabase Auth配置错误检查Supabase Dashboard Authentication Providers确保Email/Password已启用如果只启用了Google OAuthemail字段可能为空需在signInWithIdToken后手动补充Invalid audienceverify_aud未设为False检查jwt.decode调用确认有options{verify_aud: False}这是Supabase JWT的固定行为必须关闭audience校验User not found in auth.users用户在Supabase中被手动删除登录Supabase Dashboard查看auth.users表不要手动删除auth.users表中的记录应使用supabase.auth.admin.deleteUser()5.2 文件上传失败500 Internal Server Error 的根因分析文件上传失败错误日志里往往只有一行Failed to upload file你需要深入一层检查Supabase Storage Bucket是否存在且配置正确# 使用Supabase CLI列出所有buckets supabase storage buckets list # 输出应包含你的bucket name如 quivr-files检查Bucket的Public权限 在Supabase Dashboard Storage Your Bucket Settings确认Public开关是关闭的。如果它是开启的任何人都能读写你的文件这是严重的安全漏洞。检查RLS策略是否生效 在Supabase Dashboard SQL Editor运行以下查询模拟一个用户尝试插入-- 以一个测试用户身份执行替换为真实user_id SET LOCAL role TO authenticated; SET LOCAL request.jwt.claims TO {sub:11111111-1111-1111-1111-111111111111,email:testexample.com}; INSERT INTO public.files (user_id, file_name, storage_path, size_bytes) VALUES (11111111-1111-1111-1111-111111111111, test.txt, test.txt, 123);如果报错new row violates row-level security policy说明RLS策略写错了如果成功说明策略没问题。检查文件大小限制 FastAPI默认的UploadFile大小限制是无上限的但你的反向代理如Nginx或云服务商如Vercel可能有限制。在Nginx配置中检查client_max_body_size在Vercel中检查maxDuration和bodySize。5.3 本地开发与生产环境的密钥管理最佳实践密钥管理是安全的生命线。我们团队严格执行以下规范绝不提交.env文件到Git在.gitignore中加入*.env和.env.*。生产环境使用Secrets在Docker Compose中使用secrets在Kubernetes中使用Secret对象在Vercel中使用Environment Variables UI。为不同环境使用不同的Supabase项目开发、测试、生产各用一个独立的Supabase项目。这样开发环境的Anon Key泄露不会影响生产数据。定期轮换密钥Supabase允许你生成多个Anon Key旧的Key可以标记为Deprecated等所有服务都切换完成后再彻底删除。个人经验我们曾在一个紧急上线的周末因为忘记在生产环境的Vercel里设置JWT_SECRET_KEY导致所有API返回500。监控告警响了半小时运维同事才想起来去Vercel后台看。从此我们所有的部署流水线CI/CD第一步就是运行一个脚本检查所有必需的环境变量是否都已设置。这个脚本现在是我们每个项目的标配