Supabase Flutter 用户管理系统实战:从魔法链接登录到资料存储的完整指南
2026/9/7 18:35:15 网站建设 项目流程

Supabase Flutter 用户管理系统实战:从魔法链接登录到资料存储的完整指南

【免费下载链接】supabaseThe Postgres development platform. Supabase gives you a dedicated Postgres database to build your web, mobile, and AI applications.项目地址: https://gitcode.com/GitHub_Trending/supa/supabase

本文以 Supabase 官方仓库中的 Flutter User Management 示例应用(examples/user-management/flutter-user-management)为核心,系统讲解如何用 Flutter 与 Supabase 从零构建一个具备邮箱魔法链接登录、个人资料读写与头像图片上传能力的完整应用。读完本文,你将掌握supabase_flutter客户端的初始化、Auth 会话监听与魔法链接鉴权、Postgres 数据库行级安全(RLS)策略设计,以及 Storage 上传与签名 URL 的实战用法,并能在 iOS、Android 与 Web 三端直接运行该示例。

一、示例应用定位:一套最小可运行的 Flutter + Supabase 全栈模板

该示例是 Supabase 官方仓库中user-management系列示例的 Flutter 实现,用于演示三条核心能力链路:

  • 使用Supabase Auth + 魔法链接(Magic Link)让用户通过邮箱免密登录;
  • 使用Supabase Database(Postgres)存储并读取用户资料;
  • 使用Supabase Storage存储用户头像图片文件。

与 Next.js、React、Swift、Vue 等同目录下的兄弟示例不同,flutter-user-management 的独特价值在于它演示了 Flutter 跨端(iOS / Android / Web)下处理 Supabase 的完整写法,尤其是 Web 端魔法链接回调与移动端 Deep Link 回调在代码中的差异处理,这些细节在纯 Web 示例中不会出现。

应用本身是一个标准的 Flutter Material 应用,代码结构清晰:

文件职责
lib/main.dart入口:加载.env、初始化Supabase、根据会话状态决定首页路由,并提供全局 SnackBar 扩展
lib/pages/splash_page.dart启动页:依据本地是否已有会话跳转登录页或账户页(README 主流程之外的辅助页)
lib/pages/login_page.dart登录页:输入邮箱发送魔法链接,并监听onAuthStateChange自动跳转
lib/pages/account_page.dart账户页:读取/更新profiles表,上传头像后回写avatar_url
lib/components/avatar.dart头像组件:调用系统相册选取图片、上传 Storage、生成签名 URL

二、快速启动:环境准备与三端运行命令

1. 获取 Supabase 项目凭据并配置.env

参照示例目录下的 .env.example,在flutter-user-management目录中复制出.env文件并填入自己的项目凭据:

SUPABASE_URL=https://mysupabasereference.supabase.co SUPABASE_PUBLISHABLE_KEY=my.supabase.publishable.key

两个字段的含义分别为:

  • SUPABASE_URL:你的 Supabase 项目地址,即 Project Settings → API 中的 Project URL,格式形如https://xxx.supabase.co
  • SUPABASE_PUBLISHABLE_KEY:对外公开的 API Key(即 anon/publishable key),用于浏览器与移动端等不可信环境。仓库在此处使用 publishable key 命名,对应新版本 Supabase 的密钥体系,旧项目也可沿用 anon key。

.env之所以必须以该文件名出现在仓库中,是因为 pubspec.yaml 的flutter.assets段已将.env声明为打包资源,flutter_dotenv才能在运行时通过dotenv.load()将其注入环境。

2. 初始化与三端运行

环境准备完成后,依次执行:

flutter pub get

然后按目标平台选择运行命令。iOS 与 Android 直接运行:

flutter run

若以 Web 方式运行,并在localhost:3000启动:

flutter run -d web-server --web-hostname localhost --web-port 3000

3. 源码中的初始化要点

在 lib/main.dart 的main()中,先加载环境变量,再初始化客户端:

Future<void> main() async { await dotenv.load(); await Supabase.initialize( url: dotenv.env['SUPABASE_URL']!, anonKey: dotenv.env['SUPABASE_ANON_KEY']!, ); runApp(const MyApp()); } final supabase = Supabase.instance.client;

需要注意两点:

  • 示例通过全局supabase变量暴露客户端(在main.dart顶部定义,供各页面直接导入使用),它等价于Supabase.instance.client的单例;从源码结构看,这是为示例保持代码简洁而采用的写法,大型项目中更推荐用依赖注入或单一封装模块管理该客户端。
  • Supabase.initialize内部会完成 WebSocket 与 Auth 的初始配置。main()之后,MaterialApphome属性根据本地是否已存在会话做首屏分流:supabase.auth.currentSession == null时进入LoginPage,否则直接进入AccountPage。由于supabase_flutter会持久化会话,用户重启 App 后仍可保持登录态。

三、数据库 Schema:一张表 + 三层安全策略

README 中给出了完整的初始化 SQL。仓库将该 SQL 固化在 supabase/migrations/20240404030631_init.sql,可作为本地开发迁移脚本直接使用。全部内容可拆解为四个部分逐一说明。

1.profiles用户资料表

-- Create a table for public "profiles" create table profiles ( id uuid references auth.users not null, updated_at timestamp with time zone, username text unique, avatar_url text, website text, primary key (id), unique(username), constraint username_length check (char_length(username) >= 3) );

设计要点:

  • id是主键,同时外键引用auth.users,即每条 profile 都唯一对应用户系统中的一个 Auth 用户;
  • username声明为unique,且通过username_length检查约束要求用户名至少 3 个字符,从数据层杜绝过短用户名;
  • avatar_url用于存放头像图片地址,website是示例性扩展字段,演示如何为个人资料增加信息项。

2. 开启行级安全(RLS)

alter table profiles enable row level security;

开启 RLS 后,若未创建策略,默认所有连接方对该表都没有任何访问权限。示例接下来的三条策略正是在此基础上逐步放开"公开读 + 本人写"的最小权限模型。

3. 三条访问策略

create policy "Public profiles are viewable by everyone." on profiles for select using ( true ); create policy "Users can insert their own profile." on profiles for insert with check ( (select auth.uid()) = id ); create policy "Users can update own profile." on profiles for update using ( (select auth.uid()) = id );
  • 第一条允许任何人(含匿名用户)读取所有公开资料,这是头像、主页等公开信息的常见需求;
  • 第二条限定只能为自己插入 profile,with check (auth.uid() = id)会在写入时校验行数据,防止用户伪造他人的id写入;
  • 第三条限定只能更新自己的资料,using (auth.uid() = id)过滤可被更新的行,防止越权篡改他人记录。

注意:migration 文件与 README 中该约束的写法略有差异——README 版本使用(select auth.uid()) = id的子查询形式,migration 中使用auth.uid() = id,两者语义等价,以 20240404030631_init.sql 的简洁写法为准即可。

4. 开启 Realtime 并准备 Storage Bucket

-- Set up Realtime! begin; drop publication if exists supabase_realtime; create publication supabase_realtime; commit; alter publication supabase_realtime add table profiles; -- Set up Storage! insert into storage.buckets (id, name) values ('avatars', 'avatars'); create policy "Avatar images are publicly accessible." on storage.objects for select using ( bucket_id = 'avatars' ); create policy "Anyone can upload an avatar." on storage.objects for insert with check ( bucket_id = 'avatars' );
  • Realtime 部分:先重置supabase_realtime发布(publication),再把profiles表加入其中。此后的数据变更即可通过 Postgres 逻辑复制推送给订阅了该表的 Realtime 客户端;
  • Storage 部分:向storage.buckets插入名为avatars的存储桶,并为该桶中的对象开放"公开可读、人人可上传"两条策略,对应头像这种本身就是要对外展示、且登录用户都能替换自己头像的场景。

四、登录流程:魔法链接 + 会话监听实现无密码鉴权

登录页的完整实现在 lib/pages/login_page.dart,核心是两个异步机制:发送魔法链接监听登录态变化自动跳转

1. 发送邮箱魔法链接

await supabase.auth.signInWithOtp( email: _emailController.text.trim(), emailRedirectTo: kIsWeb ? null : 'io.supabase.flutterquickstart://login-callback/', );

关键点在于emailRedirectTo的跨端差异:

  • Web端(kIsWeb == true)传入null,Supabase 会使用项目默认的 Site URL 作为回调地址;
  • iOS / Android端传入自定义 URL Schemeio.supabase.flutterquickstart://login-callback/,用户在邮箱点击链接后,系统通过 Deep Link 把认证结果交回 App,由supabase_flutter完成深层链接解析并换取会话。若你修改了 iOS 工程中的 bundle identifier 或 Android 中的包名,需同步更新此处的 scheme,并在两端原生工程(iOSInfo.plist/ Android Manifest 的 intent-filter)中注册对应 scheme。

发送成功后页面通过context.showSnackBar('Check your email for a login link!')提示用户查收邮件。

2. 监听认证状态实现自动跳转

_authStateSubscription = supabase.auth.onAuthStateChange.listen( (data) { if (_redirecting) return; final session = data.session; if (session != null) { _redirecting = true; Navigator.of(context).pushReplacement( MaterialPageRoute(builder: (context) => const AccountPage()), ); } }, onError: (error) { // AuthException 会携带 error.message 用于提示 }, );

这里的onAuthStateChange返回Stream<AuthState>,App 生命周期内任何认证事件(登录、登出、令牌刷新)都会触发回调。示例在initState中订阅、在dispose中调用_authStateSubscription.cancel()取消订阅,避免内存泄漏;_redirecting布尔值则防止回调被重复触发导致多次跳转。

由于_signIn会先做表单输入校验与 loading 状态切换,并用on AuthException/ 通用catch分级捕获错误,登录页对"邮箱格式错误""网络故障"等异常都能给出明确的 SnackBar 反馈。

五、资料读写:从profiles表查询与 upsert

进入账户页后,lib/pages/account_page.dart 在initState阶段调用_getProfile()拉取当前用户资料:

final userId = supabase.auth.currentSession!.user.id; final data = await supabase.from('profiles').select().eq('id', userId).single(); _usernameController.text = (data['username'] ?? '') as String; _websiteController.text = (data['website'] ?? '') as String; _avatarUrl = (data['avatar_url'] ?? '') as String;

这是典型的 Supabase Dart 查询链:from('profiles')指定表 →select()全列选取 →eq('id', userId)按当前用户主键过滤 →single()断言只返回一行。由于字段可能为空,代码用?? ''兜底回填到输入框,避免空值异常。

更新资料时使用upsert

final updates = { 'id': user!.id, 'username': userName, 'website': website, 'updated_at': DateTime.now().toIso8601String(), }; await supabase.from('profiles').upsert(updates);

upsert的行为是"存在则更新、不存在则插入",配合 RLS 中"只能插入/更新自己"的策略:若用户首次进入尚无 profile 行,这次写入会以auth.uid()为其新建记录;若已存在则整行更新。updated_at使用 Dart 侧生成的 ISO 8601 时间字符串写入,若希望由数据库自动维护该时间戳,可改为updated_at = now()的 RPC 或数据库触发器。

账户页底部提供Sign Out按钮,调用supabase.auth.signOut()清除本地会话,随后跳回LoginPage

六、头像上传:Storage 上传二进制 + 签名 URL 回写

头像能力封装在 lib/components/avatar.dart,它既是 UI 组件,也承载了"选图 → 上传 → 取 URL → 回写 profile"的完整业务逻辑。

1. 本地选图与压缩

final picker = ImagePicker(); final imageFile = await picker.pickImage( source: ImageSource.gallery, maxWidth: 300, maxHeight: 300, );

通过image_picker插件调起系统相册,maxWidth/maxHeight均限定为 300px,在客户端先完成尺寸约束,避免大图直接上传浪费带宽与存储。

2. 上传二进制文件到avatars

final bytes = await imageFile.readAsBytes(); final fileExt = imageFile.path.split('.').last; final fileName = '${DateTime.now().toIso8601String()}.$fileExt'; final filePath = fileName; await supabase.storage.from('avatars').uploadBinary( filePath, bytes, fileOptions: FileOptions(contentType: imageFile.mimeType), );

实现细节值得留意:

  • 文件名以DateTime.now().toIso8601String()时间戳加原始扩展名拼成,天然唯一,避免不同用户上传同名文件相互覆盖(示例把filePath设为仅文件名,即上传到桶的根目录);
  • 使用uploadBinary直接上传内存字节数组(而非upload的文件路径版本),配合FileOptions(contentType: ...)显式声明 MIME 类型,让浏览器/App 端能正确渲染图片。

3. 生成长期有效的签名 URL 并回写

final imageUrlResponse = await supabase.storage .from('avatars') .createSignedUrl(filePath, 60 * 60 * 24 * 365 * 10); widget.onUpload(imageUrlResponse);

示例并未走"公开桶直出 URL"的路线,而是为上传对象创建有效期为60 * 60 * 24 * 365 * 10秒(即 10 年)的签名 URL,再通过回调onUpload交给AccountPage,由_onUploadavatar_urlupsert 到profiles表。这正是 RLS 中存储桶 select 策略"公开可读"存在的原因之一——即便使用了带签名 URL,桶策略依然允许直接公开访问。

Avatar组件在imageUrl非空时用Image.network渲染头像,为空时展示灰色占位块与No Image提示,兼顾了首次登录尚无头像的场景。

七、从示例到项目:可以继续深入仓库的参考路径

如果需要把该示例扩展为真实的业务应用,或想对照其他技术栈的实现方式,可以在仓库中找到以下相互印证的资源:

  • 对照实现:同属user-management目录的 nextjs-user-management、swift-user-management、react-user-management 等示例使用相同的profiles表结构与策略思想,可横向对比不同语言客户端的 API 差异;
  • 客户端能力扩展:supabase_flutter还支持基于 PostgREST 的实时订阅、auth.signUp密码注册、auth.onAuthStateChange令牌自动刷新等能力,均可从当前示例的 Auth/Database/Storage 三条链路继续延伸;
  • 本地开发:示例自带的 supabase/config.toml 与迁移脚本可直接用于supabase start本地开发环境,从而在不连接云端项目的情况下先跑通全流程。

八、小结:示例应用的整体数据流

把上面的内容串起来,一个完整会话周期中的数据流为:

  1. 用户在LoginPage输入邮箱 → 发送魔法链接 → 邮件回调触发onAuthStateChange→ 自动跳转AccountPage
  2. AccountPage依据auth.uid()profiles表读取本人资料并填充表单;
  3. 用户修改资料点击 Update →upsert写回(受 RLS 的 insert/update 策略约束);
  4. 用户选择头像 → 客户端压缩后uploadBinaryavatars桶 → 生成长期签名 URL → 回写avatar_url,页面即时刷新头像;
  5. 点击 Sign Out →signOut()清除会话 → 返回登录页。

从 README 的快速启动指引,到 20240404030631_init.sql 的完整 Schema,再到 main.dart、login_page.dart、account_page.dart、avatar.dart 的逐层实现,整个示例只用了极少的代码就覆盖了 Auth、Database 与 Storage 三大 Supabase 核心能力。它既是学习supabase_flutter的最佳最小样例,也是一套可以快速改造成真实 Flutter 应用骨架的参考模板。

【免费下载链接】supabaseThe Postgres development platform. Supabase gives you a dedicated Postgres database to build your web, mobile, and AI applications.项目地址: https://gitcode.com/GitHub_Trending/supa/supabase

创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询