首页
/ 使用 Supabase 与 Next.js 构建实时 Slack 克隆:从数据库 Schema、RLS 权限到 Realtime 全栈实践

使用 Supabase 与 Next.js 构建实时 Slack 克隆:从数据库 Schema、RLS 权限到 Realtime 全栈实践

2026-09-06 18:56:51作者:冯爽妲Honey

本指南以仓库中的 nextjs-slack-clone 示例 为讲解主体,剖析一个生产级实时聊天应用如何组合 Postgres 数据模型、行级安全(Row Level Security)、基于自定义 JWT Claim 的角色访问控制(RBAC)与 Supabase Realtime 订阅,帮助你理解并复刻从建库、授权、部署到本地联调的完整闭环。

示例概览:技术栈与功能

这是仓库 examples/slack-clone/nextjs-slack-clone 目录下的全栈示例,目标是以一个可运行的 Slack 克隆演示 Supabase 的三大核心能力:托管 Postgres、用户认证与实时数据同步。

示例的技术分层如下:

  • 前端:Next.js(React 框架)负责页面渲染与路由;
  • 客户端 SDKSupabase.js@supabase/supabase-js)承担用户管理与实时数据监听;
  • 后端:由 supabase.com/dashboard 提供的托管 Postgres 数据库及其 RESTful API,供 Supabase.js 直接调用。

package.json 可以看到完整的依赖清单:核心运行时依赖只有 @supabase/supabase-js@^2jwt-decode@^4nextreact/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

下图为示例的交互效果演示:

Next.js Slack Clone 实时聊天示例运行效果

数据模型:六张表与三类枚举

无论通过 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,主键)、usernamestatus(默认 OFFLINE 用户资料,id 直接指向 Supabase Auth 的内部用户
channels slug text uniquecreated_by(引用 users 频道(Slack 中的 Channel)
messages messageuser_idchannel_idon delete cascade 消息,频道删除时级联清理
user_roles user_id + role,联合唯一 用户与角色的多对多关联
role_permissions role + permission,联合唯一 角色与权限点的多对多映射

值得注意的是,channelsmessagesinserted_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 中取出自定义 Claim user_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 表的权限;
  • authenticatedanon 角色撤销执行与访问权限;
  • 新增一条针对 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 调用位于 componentslib/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 以模块级函数暴露所有增删查操作:

  • fetchChannelssupabase.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(最快上手)

  1. supabase.com/dashboard 注册并创建一个新项目,等待数据库启动;
  2. 数据库就绪后,在 SQL Editor 中运行 "Slack Clone" Quickstart(即 full-schema.sql 的内容);
  3. 进入项目设置(齿轮图标)的 API 选项卡,复制 API URLanon 公钥,供下一步配置环境变量使用。

anon key 是客户端 API 密钥:它允许对数据库进行“匿名访问”,用户登录后请求自动切换为该用户的登录令牌,行级安全随即对数据生效。

方式二:一键部署到 Vercel

Vercel 部署流程会引导你创建 Supabase 账号与项目:安装 Supabase integration 后,全部相关环境变量会自动配置完毕,部署完成后项目即可直接使用。

部署前建议在 Vercel 项目设置中核对以下环境变量,它们决定了前端 SDK 的连接目标:

  • NEXT_PUBLIC_SUPABASE_URL
  • NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY

方式三:本地开发(连接本地 Supabase)

本地开发配置以 supabase/ 目录与 .env 文件为主:

  1. 复制环境变量模板:
    cp .env.example .env
    
    模板内容见 .env.example,默认指向本地实例:
    NEXT_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/
    
  2. 启动本地 Supabase 栈(会按 supabase/config.toml 启动 API、Postgres、Realtime、Auth 与 Inbucket 邮件测试服务器,并应用 supabase/migrations 中的迁移与 seed.sql 种子数据);
  3. 安装依赖并启动前端:
    npm install
    npm run dev   # 打开 http://localhost:3000
    

在本地栈中,config.toml 有几个值得注意的配置:API 端口 54321、数据库端口 54322major_version = 15[auth.hook.custom_access_token] 已启用,并且 [inbucket] 开启——注册确认邮件不会真实投递,而是显示在 Inbucket 的 Web 界面中,方便完成邮箱验证流程。

方式四:连接远程 Supabase 项目(生产)

若直接使用远程项目,可按以下流程把本地配置与远端同步:

  1. Supabase Dashboard 创建或选择一个项目;
  2. 复制生产环境模板并填写真实值:
    cp .env.production.example .env.production
    
    模板见 .env.production.example,其中 NEXT_SITE_URL / NEXT_REDIRECT_URLS 需要替换为你真实的 Vercel 应用地址,Redirect 列表同时支持精确地址与通配子路径(如 https://<app>.vercel.app/**);
  3. 关联本地项目与远程项目:
    SUPABASE_ENV=production npx supabase@latest link --project-ref <your-project-ref>
    
  4. 推送配置(authrealtimeapi 等项目级设置):
    SUPABASE_ENV=production npx supabase@latest config push
    
  5. 推送数据库 Schema(迁移文件 + 种子数据):
    SUPABASE_ENV=production npx supabase@latest db push
    

方式五:Vercel Preview + Branching 的隔离环境

Supabase 与 Vercel 预览分支集成,可以为每个 Git 分支创建独立的 Supabase 项目,从而在安全隔离的环境中先行验证数据库迁移或服务配置,再合入生产。操作步骤:

  1. 确认 Vercel 项目已关联 Git 仓库;
  2. 在 Vercel 中为 Preview 环境配置两个变量:
    • NEXT_PUBLIC_SUPABASE_URL
    • NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY
  3. 新建分支并修改代码(例如调整 config.toml 中的 max_frequency 邮件频率限制),推送到 Git 并开启 Pull Request,触发 Vercel + Supabase 集成;
  4. 部署成功后,在 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 生产模式的最小而完整的范本。它示范了四件可迁移到任意业务的关键技能:

  1. Postgres 原生类型 + 外键 + 级联 设计聊天域数据模型;
  2. RLS 策略 + security definer 授权函数 让客户端直连数据库依然安全;
  3. 自定义 Access Token Hook 把 RBAC 角色注入 JWT,让权限在数据库、后端与前端三层统一;
  4. Realtime Publication + postgres_changes 免自建消息服务实现多端实时同步。

若想继续深挖实现细节,推荐按顺序阅读以下仓库文件:迁移文件 initAuth Hook 迁移完整单文件 Schema种子数据前端实时数据层

登录后查看全文
热门项目推荐
相关项目推荐