使用 Supabase 与 Next.js 构建实时 Slack 克隆:从数据库 Schema、RLS 权限到 Realtime 全栈实践
本指南以仓库中的 nextjs-slack-clone 示例 为讲解主体,剖析一个生产级实时聊天应用如何组合 Postgres 数据模型、行级安全(Row Level Security)、基于自定义 JWT Claim 的角色访问控制(RBAC)与 Supabase Realtime 订阅,帮助你理解并复刻从建库、授权、部署到本地联调的完整闭环。
示例概览:技术栈与功能
这是仓库 examples/slack-clone/nextjs-slack-clone 目录下的全栈示例,目标是以一个可运行的 Slack 克隆演示 Supabase 的三大核心能力:托管 Postgres、用户认证与实时数据同步。
示例的技术分层如下:
- 前端:Next.js(React 框架)负责页面渲染与路由;
- 客户端 SDK:Supabase.js(
@supabase/supabase-js)承担用户管理与实时数据监听; - 后端:由 supabase.com/dashboard 提供的托管 Postgres 数据库及其 RESTful API,供 Supabase.js 直接调用。
从 package.json 可以看到完整的依赖清单:核心运行时依赖只有 @supabase/supabase-js@^2、jwt-decode@^4、next 与 react/react-dom@^18.2,前端样式使用 Tailwind CSS 3 与 PostCSS/Autoprefixer 处理,整体依赖轻量、易于理解。
示例实现的功能点包括:邮箱注册登录、频道列表(channels)与消息流(messages)、消息与频道的实时增删同步、在线状态字段预留,以及 admin / moderator 两种管理角色的差异化删除权限。其目录结构如下:
nextjs-slack-clone/
├── components/ # Layout / Message / MessageInput / TrashIcon
├── lib/
│ ├── Store.js # supabase 客户端 + 数据读取、写入与实时订阅 Hook
│ └── UserContext.js # 用户上下文(由 _app.js 提供)
├── pages/
│ ├── _app.js # 会话恢复、JWT 解码与角色注入、登录重定向
│ ├── index.js
│ └── channels/[id].js # 频道消息页
├── public/slack-clone-demo.gif
├── supabase/
│ ├── config.toml # 本地开发配置(API / DB / Realtime / Auth / Hook)
│ ├── migrations/ # init.sql 与 auth-hook.sql
│ └── seed.sql # 角色权限与演示数据种子
├── full-schema.sql # 与 Dashboard 端 Quickstart 一致的单文件完整 Schema
└── .env(.production).example
下图为示例的交互效果演示:
数据模型:六张表与三类枚举
无论通过 Supabase Dashboard 运行 "Slack Clone" Quickstart,还是使用本地迁移文件建库,最终都会得到相同的数据结构。单文件版见 full-schema.sql,分步迁移版见 20240214102356_init.sql。
自定义枚举类型
create type public.app_permission as enum ('channels.delete', 'messages.delete');
create type public.app_role as enum ('admin', 'moderator');
create type public.user_status as enum ('ONLINE', 'OFFLINE');
业务表
| 表 | 关键字段 | 语义 |
|---|---|---|
users |
id uuid(引用 auth.users,主键)、username、status(默认 OFFLINE) |
用户资料,id 直接指向 Supabase Auth 的内部用户 |
channels |
slug text unique、created_by(引用 users) |
频道(Slack 中的 Channel) |
messages |
message、user_id、channel_id(on delete cascade) |
消息,频道删除时级联清理 |
user_roles |
user_id + role,联合唯一 |
用户与角色的多对多关联 |
role_permissions |
role + permission,联合唯一 |
角色与权限点的多对多映射 |
值得注意的是,channels 与 messages 的 inserted_at 字段使用 timezone('utc'::text, now()) 生成统一 UTC 时间戳,id 则采用 generated by default as identity,避免显式序列依赖。
Row Level Security:以 Postgres 为安全边界
启动 Supabase 项目时,平台会自动预置 auth schema 与一批辅助函数。用户登录后会获得一个包含 authenticated 角色与自身 UUID 的 JWT,这正是 RLS 策略进行精细化授权的依据。
表级开启与策略
Schema 对全部业务表执行了 enable row level security,并为每种操作定义了最小化授权策略。以 channels 为例:
create policy "Allow logged-in read access" on public.channels
for select using ( auth.role() = 'authenticated' );
create policy "Allow individual insert access" on public.channels
for insert with check ( auth.uid() = created_by );
create policy "Allow individual delete access" on public.channels
for delete using ( auth.uid() = created_by );
create policy "Allow authorized delete access" on public.channels
for delete using ( authorize('channels.delete') );
设计要点如下:
- 读:任何已登录用户(
auth.role() = 'authenticated')都能读取频道、消息与用户资料,这与 Slack 默认开放的工作区属性一致; - 写:插入消息/频道时要求
auth.uid()与记录所有者字段一致,杜绝伪造他人身份的数据写入; - 删:采用“双轨制”——普通用户只能删除自己创建的内容,同时叠加一条
authorize(...)授权策略,使拥有对应权限点的角色可以删除任意内容; user_roles表仅允许用户读取自己的记录,而完整的角色关系由后端的 auth hook 或security definer函数持有,避免客户端越权窥探权限映射。
authorize授权函数 删除策略中调用的public.authorize是一个security definer函数,它从当前访问令牌 JWT 中取出自定义 Claimuser_role,再与role_permissions表做匹配:若count > 0则放行,否则拒绝。关键实现见 full-schema.sql:select count(*) from public.role_permissions where role_permissions.permission = authorize.requested_permission and role_permissions.role = (auth.jwt() ->> 'user_role')::public.app_role into bind_permissions; return bind_permissions > 0;
为什么 RLS 是本示例的安全核心
客户端 SDK 默认使用 anon 公钥直连 PostgREST。anon 公钥允许“匿名访问”数据库,直到用户登录——登录后,请求会自动携带用户自己的登录令牌。真正阻止越权的,不是密钥本身,而是每一张表上的 RLS 策略。这就是本示例强调的“使用 Postgres 行级安全实现高级别授权”的含义:即使 anon key 暴露在浏览器中,未通过 RLS 策略的操作仍会被数据库拒绝。
安全提醒:
service_role密钥拥有完整数据访问权限,会绕过所有安全策略,必须严格保密,只能在服务端环境使用,绝不能出现在浏览器或客户端代码中。
RBAC:用 plus addressing + 自定义 JWT Claim 管理角色
角色规则的注册规则
示例约定通过邮箱的 plus 别名(subaddressing)自动分配角色:
// admin 用户
email+supaadmin@example.com
// moderator 用户
email+supamod@example.com
该逻辑由触发器 handle_new_user 实现。每当 auth.users 有新用户插入,on_auth_user_created 触发器(见 full-schema.sql)就会自动执行以下工作:
insert into public.users (id, username) values (new.id, new.email);
if position('+supaadmin@' in new.email) > 0 then
insert into public.user_roles (user_id, role) values (new.id, 'admin');
elsif position('+supamod@' in new.email) > 0 then
insert into public.user_roles (user_id, role) values (new.id, 'moderator');
end if;
即:为用户创建资料行,并根据邮箱中是否包含 +supaadmin@ / +supamod@ 关键字写入对应角色。
权限矩阵
权限点与角色的映射由 seed.sql 预置:
insert into public.role_permissions (role, permission)
values
('admin', 'channels.delete'),
('admin', 'messages.delete'),
('moderator', 'messages.delete');
由此得到完整的行为矩阵:
| 操作 | 普通用户 | moderator | admin |
|---|---|---|---|
| 删除自己的消息 | 允许(RLS) | 允许 | 允许 |
| 删除任意消息 | 禁止 | 允许(messages.delete) |
允许 |
| 删除任意频道 | 禁止 | 禁止 | 允许(channels.delete) |
注意:示例并不建议删除
public频道——前端在路由层面也会把当前频道被删除的用户引导回public(见下文源码分析)。
自定义 Access Token Hook:把角色写进 JWT
RLS 策略与 authorize 函数都依赖 JWT 中的 user_role Claim,那么该 Claim 从何而来?答案是 Auth Hook 自定义 Access Token Hook:Auth 服务在签发访问令牌前调用一个 Postgres 函数,把数据库里的角色注入令牌。
该函数定义在 20240214114147_auth-hook.sql:
create or replace function public.custom_access_token_hook(event jsonb)
returns jsonb
language plpgsql
stable
as $$
declare
claims jsonb;
user_role public.app_role;
begin
select role into user_role from public.user_roles
where user_id = (event->>'user_id')::uuid;
claims := event->'claims';
if user_role is not null then
claims := jsonb_set(claims, '{user_role}', to_jsonb(user_role));
else
claims := jsonb_set(claims, '{user_role}', 'null');
end if;
event := jsonb_set(event, '{claims}', claims);
return event;
end;
$$;
配套的权限收敛同样关键:
- 仅授予
supabase_auth_admin执行该函数与访问user_roles表的权限; - 从
authenticated、anon角色撤销执行与访问权限; - 新增一条针对
supabase_auth_admin的全表可读策略,保证 hook 运行时能查询到角色数据。
本地开发时,Auth Hook 的启用声明在 supabase/config.toml:
[auth.hook.custom_access_token]
enabled = true
uri = "pg-functions://postgres/public/custom_access_token_hook"
前端如何消费角色
客户端不会直接接触 user_roles 表,而是解码访问令牌获取角色。在 pages/_app.js 中,每次会话建立时都会用 jwt-decode 解码 access_token 并把 jwt.user_role 挂到当前用户对象上:
const jwt = jwtDecode(session.access_token)
currentUser.appRole = jwt.user_role
Message / MessageInput 等组件再依据 user.appRole 决定是否渲染删除按钮(moderator 可删消息,admin 额外可删频道),界面权限与数据库权限保持一致。删除按钮的 UI 控制与底层 deleteChannel / deleteMessage 调用位于 components 与 lib/Store.js。
Realtime:让数据变化实时流向 UI
逻辑层:把表加入实时发布
实时推送需要先把表加入 Postgres 的逻辑复制发布(publication)。Schema 的做法是先重置 supabase_realtime 发布再逐表加入:
begin;
drop publication if exists supabase_realtime;
create publication supabase_realtime;
commit;
alter publication supabase_realtime add table public.channels;
alter publication supabase_realtime add table public.messages;
alter publication supabase_realtime add table public.users;
为了让订阅方能收到“完整旧值”(例如删除事件的 payload 里能拿到被删记录的完整字段),三张表均被设置为 replica identity full:
alter table public.users replica identity full;
alter table public.channels replica identity full;
alter table public.messages replica identity full;
应用层:postgres_changes 订阅
前端数据层集中在 lib/Store.js。其核心是 useStore Hook,它在挂载时并行建立三条实时通道:
const messageListener = supabase
.channel('public:messages')
.on('postgres_changes', { event: 'INSERT', schema: 'public', table: 'messages' }, (payload) =>
handleNewMessage(payload.new)
)
.on('postgres_changes', { event: 'DELETE', schema: 'public', table: 'messages' }, (payload) =>
handleDeletedMessage(payload.old)
)
.subscribe()
订阅矩阵概括如下:
| 通道 | 事件 | 处理逻辑 |
|---|---|---|
public:messages |
INSERT | 追加消息;若作者不在本地 Map 中,先按 user_id 拉取作者再展示 |
public:messages |
DELETE | 从本地消息数组中过滤掉对应 id |
public:channels |
INSERT / DELETE | 向侧栏追加频道 / 移除频道 |
public:users |
*(任意事件) |
更新本地用户名映射 |
这些 Hook 收到变化后只修改本地 React 状态,最终通过返回值把作者信息映射到消息上:
messages: messages.map((x) => ({ ...x, author: users.get(x.user_id) })),
channels: channels.sort((a, b) => a.slug.localeCompare(b.slug)),
这一模式(单一客户端、多通道订阅、状态与数据库事件联动)是理解 Supabase Realtime 的最短路径:应用不需要自建 WebSocket 服务,只需写数据库,postgres_changes 会把变更推送给所有订阅者。
关键数据读写与路由编排
数据访问函数
lib/Store.js 以模块级函数暴露所有增删查操作:
fetchChannels:supabase.from('channels').select('*')拉取全部频道;fetchMessages(channelId):连表查询消息作者,并按时间升序排列:这里的supabase .from('messages') .select(`*, author:user_id(*)`) .eq('channel_id', channelId) .order('inserted_at', { ascending: true })author:user_id(*)是 PostgREST 的内嵌资源语法——把外键user_id关联的 users 行重命名为author,一次请求同时取回消息与其作者;addChannel(slug, user_id)/addMessage(...):执行带.select()的插入,便于拿到服务端返回的完整记录;deleteChannel/deleteMessage:.delete().match({ id })按主键删除,删除是否被放行由 RLS +authorize决定。
会话编排与会话守卫
pages/_app.js 是全局会话中枢:应用启动时调用 supabase.auth.getSession() 恢复会话,并注册 supabase.auth.onAuthStateChange 监听登录/登出事件;一旦检测到登录用户,立即把路由推进到频道页:
if (currentUser) {
router.push('/channels/[id]', '/channels/1')
}
登出则调用 supabase.auth.signOut() 后回到首页。用户与登录状态通过 UserContext(见 lib/UserContext.js)向下分发。
pages/channels/[id].js 承载聊天主界面,它把路由参数 channelId 传给 useStore,当消息列表变化时自动滚动到底部,并在当前频道被删除时把用户安全重定向回 public(/channels/1):
useEffect(() => {
if (!channels.some((channel) => channel.id === Number(channelId))) {
router.push('/channels/1')
}
}, [channels, channelId])
这一“兜底频道”设计保证了演示环境中的 public 频道始终可达,与该频道不建议删除的建议相呼应。
四种落地方式:从 Dashboard 到本地 CLI
方式一:Supabase Dashboard Quickstart(最快上手)
- 到 supabase.com/dashboard 注册并创建一个新项目,等待数据库启动;
- 数据库就绪后,在 SQL Editor 中运行 "Slack Clone" Quickstart(即 full-schema.sql 的内容);
- 进入项目设置(齿轮图标)的 API 选项卡,复制 API URL 与 anon 公钥,供下一步配置环境变量使用。
anon key 是客户端 API 密钥:它允许对数据库进行“匿名访问”,用户登录后请求自动切换为该用户的登录令牌,行级安全随即对数据生效。
方式二:一键部署到 Vercel
Vercel 部署流程会引导你创建 Supabase 账号与项目:安装 Supabase integration 后,全部相关环境变量会自动配置完毕,部署完成后项目即可直接使用。
部署前建议在 Vercel 项目设置中核对以下环境变量,它们决定了前端 SDK 的连接目标:
NEXT_PUBLIC_SUPABASE_URLNEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY
方式三:本地开发(连接本地 Supabase)
本地开发配置以 supabase/ 目录与 .env 文件为主:
- 复制环境变量模板:
模板内容见 .env.example,默认指向本地实例:cp .env.example .envNEXT_PUBLIC_SUPABASE_URL=http://127.0.0.1:54321/ NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY=<local-anon-key> NEXT_SITE_URL=http://localhost:3000 NEXT_REDIRECT_URLS=http://localhost:3000/ - 启动本地 Supabase 栈(会按 supabase/config.toml 启动 API、Postgres、Realtime、Auth 与 Inbucket 邮件测试服务器,并应用
supabase/migrations中的迁移与seed.sql种子数据); - 安装依赖并启动前端:
npm install npm run dev # 打开 http://localhost:3000
在本地栈中,config.toml 有几个值得注意的配置:API 端口 54321、数据库端口 54322、major_version = 15、[auth.hook.custom_access_token] 已启用,并且 [inbucket] 开启——注册确认邮件不会真实投递,而是显示在 Inbucket 的 Web 界面中,方便完成邮箱验证流程。
方式四:连接远程 Supabase 项目(生产)
若直接使用远程项目,可按以下流程把本地配置与远端同步:
- 在 Supabase Dashboard 创建或选择一个项目;
- 复制生产环境模板并填写真实值:
模板见 .env.production.example,其中cp .env.production.example .env.productionNEXT_SITE_URL/NEXT_REDIRECT_URLS需要替换为你真实的 Vercel 应用地址,Redirect 列表同时支持精确地址与通配子路径(如https://<app>.vercel.app/**); - 关联本地项目与远程项目:
SUPABASE_ENV=production npx supabase@latest link --project-ref <your-project-ref> - 推送配置(
auth、realtime、api等项目级设置):SUPABASE_ENV=production npx supabase@latest config push - 推送数据库 Schema(迁移文件 + 种子数据):
SUPABASE_ENV=production npx supabase@latest db push
方式五:Vercel Preview + Branching 的隔离环境
Supabase 与 Vercel 预览分支集成,可以为每个 Git 分支创建独立的 Supabase 项目,从而在安全隔离的环境中先行验证数据库迁移或服务配置,再合入生产。操作步骤:
- 确认 Vercel 项目已关联 Git 仓库;
- 在 Vercel 中为 Preview 环境配置两个变量:
NEXT_PUBLIC_SUPABASE_URLNEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY
- 新建分支并修改代码(例如调整 config.toml 中的
max_frequency邮件频率限制),推送到 Git 并开启 Pull Request,触发 Vercel + Supabase 集成; - 部署成功后,在 Preview 环境即可验证改动效果,确认无误后再合并到主分支。
前端鉴权链路速查
把 RLS、RBAC、Auth Hook 与前端解码串起来,即得到本示例完整的授权闭环:
用户注册(含 +supaadmin@ / +supamod@)
└─ auth.users 插入
└─ on_auth_user_created 触发器 → users 资料行 + user_roles 角色
└─ 登录签发令牌时,custom_access_token_hook 将 user_role 注入 JWT claims
└─ 客户端 jwtDecode 解出 user.appRole,控制删除按钮显隐
└─ 后端删除请求再次经过 RLS 策略 + authorize() 复核
即:界面权限只是体验层,真正的安全决策永远发生在数据库层——即使有人绕过 UI 直接调用 REST API,缺少 channels.delete / messages.delete 权限点的角色也会被 authorize() 函数拦截。
小结
nextjs-slack-clone 是学习 Supabase 生产模式的最小而完整的范本。它示范了四件可迁移到任意业务的关键技能:
- 用 Postgres 原生类型 + 外键 + 级联 设计聊天域数据模型;
- 用 RLS 策略 +
security definer授权函数 让客户端直连数据库依然安全; - 用 自定义 Access Token Hook 把 RBAC 角色注入 JWT,让权限在数据库、后端与前端三层统一;
- 用 Realtime Publication +
postgres_changes免自建消息服务实现多端实时同步。
若想继续深挖实现细节,推荐按顺序阅读以下仓库文件:迁移文件 init、Auth Hook 迁移、完整单文件 Schema、种子数据 与 前端实时数据层。
atomcodeClaude Code 的开源替代方案。连接任意大模型,编辑代码,运行命令,自动验证 — 全自动执行。用 Rust 构建,极致性能。 | An open-source alternative to Claude Code. Connect any LLM, edit code, run commands, and verify changes — autonomously. Built in Rust for speed. Get StartedRust0623
Hy4-previewHy4 preview 是由腾讯混元团队研发的新一代混合专家(MoE)旗舰模型。模型总参数量 770B,每个 token 激活 49B,主干共包含78层,第一层采用标准 FFN,其余 77 层均为 MoE 结构,每层包含 256 个路由专家与 1 个共享专家,每个 token 激活 top-8 路由专家及共享专家。主干之外原生内置 1 层 MTP(总参数量 10B,激活 0.7B)以支持投机解码。Python00
GLM-5.3GLM-5.3 与 GLM-5.2 使用相同的基座模型——所有提升均来自后训练。与 GLM-5.2 相比,它在复杂编程和长程任务上的表现显著提升。Jinja00
GLM-5.3-FlashGLM-5.3-Flash (320B-A18B),是GLM-5系列的首个原生多模态模型。320B总参数,能力超过GLM-5.2Jinja00
Spark-X2.5-4BSpark-X2.5-4B 旨在让强大的 AI 更实用、更高效、更易获得。在广泛日常任务中表现强劲,涵盖对话、写作、翻译、推理、编码、工具调用以及智能体工作流,并在同等规模的开源模型中取得领先成绩。Spark-X2.5 将面向效率的架构与最高 1M tokens 的原生上下文窗口相结合,并支持 200 多种语言。Python00
Spark-X2.5-1.7BSpark-X2.5-1.7B 旨在让强大的 AI 更加实用、高效且易于获取。这些模型在广泛的日常任务中表现出色,涵盖对话、写作、翻译、推理、编程、工具调用和智能体工作流,并在同等规模的开源模型中取得领先结果。Spark-X2.5 将面向效率的架构与最高 1M tokens 的原生上下文窗口相结合,并支持 200 多种语言。Python00
