基于 Supabase 构建示例应用(上篇):数据库与接口

目标:实现一个文章发布 Web 应用(Demo),包含用户注册、编辑、发布文章的功能,同时有广场(展示所有已发布文章、所有用户可以查看)、并使用管理员进行维护,技术架构是 Supabase + Vue,Vue 部署在哪里还未计划,以此为契机学习了解 Supabase 服务

本篇学习和记录了如下内容

  1. 体验 Supabase Console 建表、执行 SQL
  2. 创建 RLS 行级安全策略
  3. 了解不同的 API Key 类型、创建 API Key、Curl 命令调用接口
  4. 了解 Supabase 的 Users 和业务表的 Users 表关联方式(通过触发器)
  5. 模拟用户注册、登陆、创建文章、查看广场文章的接口使用场景
  6. Edge Functions 配置一些封控策略(未测试)

设计表

用户表

CREATE TABLE users (
  id UUID PRIMARY KEY REFERENCES auth.users(id) ON DELETE CASCADE,
  username TEXT UNIQUE CHECK (char_length(username) >= 3),
  email TEXT UNIQUE,
  created_at TIMESTAMPTZ DEFAULT NOW(),
  updated_at TIMESTAMPTZ DEFAULT NOW()
);

users 表通过 id 字段扩展了 Supabase 内置的auth.users

文章表

CREATE TABLE posts (
  id SERIAL PRIMARY KEY,
  user_id UUID REFERENCES users(id) ON DELETE CASCADE,
  title VARCHAR(255) NOT NULL,
  content TEXT,
  published_at TIMESTAMPTZ,
  created_at TIMESTAMPTZ DEFAULT NOW(),
  updated_at TIMESTAMPTZ DEFAULT NOW()
);

users 表和 posts 表之间存在一对多的关系,即一个用户可以拥有多篇文章。

触发器

-- 首先创建一个函数来更新 updated_at 字段
CREATE OR REPLACE FUNCTION update_updated_at_column()
RETURNS TRIGGER AS $$
BEGIN
    NEW.updated_at = NOW();
    RETURN NEW;
END;
$$ language 'plpgsql';

-- 为 users 表创建触发器
CREATE TRIGGER update_users_updated_at 
    BEFORE UPDATE ON users 
    FOR EACH ROW 
    EXECUTE FUNCTION update_updated_at_column();

-- 为 posts 表创建触发器
CREATE TRIGGER update_posts_updated_at 
    BEFORE UPDATE ON posts 
    FOR EACH ROW 
    EXECUTE FUNCTION update_updated_at_column();

Supabase 建表

点击 SQL Editor 侧边栏,右侧输入框执行 SQL 语句,执行后来到 Table Editor,可以看到已经成功创建两张表

01.webp

但是上方都有 Unrestricted 标注,没有开启 RLS 策略,可以点击页面上的黄色 “RLS disabled” 按钮,或是运行以下 SQL 语句均可开启。

ALTER TABLE users ENABLE ROW LEVEL SECURITY;
ALTER TABLE posts ENABLE ROW LEVEL SECURITY;

启用行级策略后,我们可以设置一些策略,例如:

允许用户只能插入(INSERT)属于自己的文章

CREATE POLICY "用户只能发表自己的文章" 
ON posts 
FOR INSERT 
WITH CHECK (auth.uid() = user_id); 

这样,用户尝试插入文章时,Supabase 会检查要插入的 user_id 是否等于当前登录用户的 ID

备注:策略也可以在 Authentication 模块的 Policies 子模块中,点击表后进行可视化创建。

创建用户

Supabase 提供邮件邀请和手动创建的方式

02.webp

可以手动创建用户

也可以点击 “Send invitation” 发送邀请到邮箱。

获取免费的 SMPT Server(可选)

最开始我以为 Supabase 发送邮件需要自己提供服务,就在网上找了一个 Brevo 这个服务,有免费额度,注册过程很丝滑,记录如下,这步可以跳过,直接使用 Supabase 的邮件服务就好,也不用设置。

服务地址:Free SMTP Server | Deliver to the Inbox Every Time

免费套餐每天 300 封,测试使用足够了

04.webp

注册后点击右上角的组织,选择 “SMTP & API”

05.webp

可以看到 SMTP 服务的服务器地址、用户名密码

06.webp

还需要添加 Sender,否则发不出邮件

这里我起的应用名字是 “Reader Bot”,邮箱就是我的 Gmail 邮箱,需要真实的邮箱地址,稍后会发送验证码验证。

07.webp

点击添加

提示我们的免费邮箱可能大概率进入收件人的垃圾邮箱,建议绑定域名,因为是测试,当然是 Anyway

验证完成后,可以看到新的 Sender 添加成功

09.webp

此时 Brevo 提供的邮箱服务已可用

在 Supabase 配置自己的 Email SMPT 服务(可选)

如果你申请了邮箱,可以在 Authentication 模块进行配置

10.webp

填入 Brevo 获取到的邮箱服务信息

Sender email:k********0@gmail.com
Sender name:Reader Bot
Host:smtp-relay.brevo.com
Port number:587
Username:954923002@smtp-brevo.com
Password:(YOUR-SMTP key value)

此时,再回到 Authentication 下的 Users 表中发送邮件邀请用户,就可以收到邮件

11.webp

内容如下

其中的邮件内容模板在 “Emails - Templates” 内进行配置,访问的链接在 “URL Configuration - Site URL” 进行配置,暂时先不用修改

初识 API & Keys

在 Project Settings 的 API Keys 页面可以创建项目 Key

13.webp

创建后有一个 Publishable key 可以在浏览器使用,格外注意,需要搭配 RLS 策略使用,它是可以公开的

sb_publishable_JToCFTBwo4urIOPnE9cIFg_1Wo-6WYj

页面下方还可以看到一个名为 default 的 Server Key 用于服务端机器、或者 Function、Workers 等

点击页面左侧的 API Docs 来到 API 页面

这里有一个知识点:Supabase 通过原生集成的 PostgREST,为数据库提供开箱即用的 Auto API,使开发者无需部署后端即可安全地进行基础的 CRUD 操作。

API 文档很细致,API 分为 Client API 和 Server API,Clinet API 可以

通过 API 注册登陆读写表数据

以下命令的 apikey 就是刚生成的 Publishable key(测试时可以使用临时邮箱, e.g. TEMP MAIL

curl -X POST 'https://iurlblwfhmulfdysyqdz.supabase.co/auth/v1/signup' \
-H "apikey: sb_publishable_JToCFTBwo4urIOPnE9cIFg_1Wo-6WYj" \
-H "Content-Type: application/json" \
-d '{
  "email": "nasovec941@chaublog.com",
  "password": "123456"
}'

返回(已格式化)

{  
    "id": "b0fec5be-879d-4912-863e-b7495a8906b9",  
    "aud": "authenticated",  
    "role": "authenticated",  
    "email": "nasovec941@chaublog.com",  
    "phone": "",  
    "confirmation_sent_at": "2025-08-23T07:19:42.840305897Z",  
    "app_metadata": {  
        "provider": "email",  
        "providers": [  
            "email"  
        ]  
    },  
    "user_metadata": {  
        "email": "nasovec941@chaublog.com",  
        "email_verified": false,  
        "phone_verified": false,  
        "sub": "b0fec5be-879d-4912-863e-b7495a8906b9"  
    },  
    "identities": [  
        {  
            "identity_id": "c9527fc9-13c9-4c85-a2c1-5b776663f22e",  
            "id": "b0fec5be-879d-4912-863e-b7495a8906b9",  
            "user_id": "b0fec5be-879d-4912-863e-b7495a8906b9",  
            "identity_data": {  
                "email": "nasovec941@chaublog.com",  
                "email_verified": false,  
                "phone_verified": false,  
                "sub": "b0fec5be-879d-4912-863e-b7495a8906b9"  
            },  
            "provider": "email",  
            "last_sign_in_at": "2025-08-23T07:19:42.817329844Z",  
            "created_at": "2025-08-23T07:19:42.81738Z",  
            "updated_at": "2025-08-23T07:19:42.81738Z",  
            "email": "nasovec941@chaublog.com"  
        }  
    ],  
    "created_at": "2025-08-23T07:19:42.772681Z",  
    "updated_at": "2025-08-23T07:19:44.000369Z",  
    "is_anonymous": false  
}

会收到 Supabase 注册邮件,目前地址会跳转到 localhost:3000 地址,我们的前端 Demo 还没开发部署(但是需要点击一下确认链接进行 Supabase 的用户激活)

此处仅体验使用 Publishable key 调用 Supabase 后端 API,注册用户后登陆:

curl -X POST 'https://iurlblwfhmulfdysyqdz.supabase.co/auth/v1/token?grant_type=password' \
-H "apikey: sb_publishable_JToCFTBwo4urIOPnE9cIFg_1Wo-6WYj" \
-H "Content-Type: application/json" \
-d '{
  "email": "nasovec941@chaublog.com",
  "password": "123456"
}'

返回

{  
    "access_token": "eyJhbGaciOiJ...NULE72KZGcdu6-LfeUdSbp8",  
    "token_type": "bearer",  
    "expires_in": 3600,  
    "expires_at": 1755937882,  
    "refresh_token": "u4jw6puieja6",  
    "user": {  
        "id": "b0fec5be-879d-4912-863e-b7495a8906b9",  
        "aud": "authenticated",  
        "role": "authenticated",  
        "email": "nasovec941@chaublog.com",  
        "email_confirmed_at": "2025-08-23T07:21:22.503059Z",  
        "phone": "",  
        "confirmation_sent_at": "2025-08-23T07:19:42.840305Z",  
        "confirmed_at": "2025-08-23T07:21:22.503059Z",  
        "last_sign_in_at": "2025-08-23T07:31:22.263515322Z",  
        "app_metadata": {  
            "provider": "email",  
            "providers": [  
                "email"  
            ]  
        },  
        "user_metadata": {  
            "email": "nasovec941@chaublog.com",  
            "email_verified": true,  
            "phone_verified": false,  
            "sub": "b0fec5be-879d-4912-863e-b7495a8906b9"  
        },  
        "identities": [  
            {  
                "identity_id": "c9527fc9-13c9-4c85-a2c1-5b776663f22e",  
                "id": "b0fec5be-879d-4912-863e-b7495a8906b9",  
                "user_id": "b0fec5be-879d-4912-863e-b7495a8906b9",  
                "identity_data": {  
                    "email": "nasovec941@chaublog.com",  
                    "email_verified": true,  
                    "phone_verified": false,  
                    "sub": "b0fec5be-879d-4912-863e-b7495a8906b9"  
                },  
                "provider": "email",  
                "last_sign_in_at": "2025-08-23T07:19:42.817329Z",  
                "created_at": "2025-08-23T07:19:42.81738Z",  
                "updated_at": "2025-08-23T07:19:42.81738Z",  
                "email": "nasovec941@chaublog.com"  
            }  
        ],  
        "created_at": "2025-08-23T07:19:42.772681Z",  
        "updated_at": "2025-08-23T07:31:22.270377Z",  
        "is_anonymous": false  
    }  
}

以上都是 GETTING STARTED 的内容,接下来可以看下业务表相关的 API

14.webp

翻到 Insert 语句,“创建一篇文章”

curl -X POST 'https://iurlblwfhmulfdysyqdz.supabase.co/rest/v1/posts' \
-H "apikey: sb_publishable_JToCFTBwo4urIOPnE9cIFg_1Wo-6WYj" \
-H "Authorization: Bearer eyJhbGaciOiJ...NULE72KZGcdu6-LfeUdSbp8" \
-H "Content-Type: application/json" \
-H "Prefer: return=minimal" \
-d '{ "title": "你好,世界!", "content": "北京今日天气:22-29度 多云 西北风3级" }'

报错

{"code":"42501","details":null,"hint":null,"message":"new row violates row-level security policy for table \"posts\""}%

RLS 行级策略不允许,执行以下策略:

-- 用户只能插入自己的帖子
CREATE POLICY "允许认证用户插入帖子"
ON posts
FOR INSERT
TO authenticated
WITH CHECK (true);

-- 用户只能更新自己的帖子
CREATE POLICY "用户只能更新自己的帖子"
ON posts
AS PERMISSIVE
FOR UPDATE
TO authenticated
USING (auth.uid() = user_id)
WITH CHECK (auth.uid() = user_id);

再次调用,没有返回任何内容,查看 Table Editor 可以看到已经多了一条记录

15.webp

然后我发现 users 表也没有记录,即 Supabase 的 auth.users 和我的 public.users 表没有关联,同时 posts 表的 user_id 也是空;

分别解决这两个问题,对于 users 未同步,可以创建一个触发器

-- 创建函数
CREATE OR REPLACE FUNCTION public.handle_new_user()
RETURNS TRIGGER AS $$
BEGIN
  INSERT INTO public.users (id, email, username)
  VALUES (
    NEW.id,
    NEW.email,
    COALESCE(NEW.raw_user_meta_data->>'username', SPLIT_PART(NEW.email, '@', 1))
  );
  RETURN NEW;
END;
$$ LANGUAGE plpgsql SECURITY DEFINER;

-- 创建触发器
CREATE OR REPLACE TRIGGER on_auth_user_created
  AFTER INSERT ON auth.users
  FOR EACH ROW EXECUTE FUNCTION public.handle_new_user();

好了,再注册个用户试试!

用户:oyentreng@deepyinc.com 密码:123456

注册、登陆不再重复粘贴代码,控制台已经可以看到用户

16.webp

解决 posts 的 user_id 为空的问题可以创建如下触发器

-- 启用 uuid 扩展(如果尚未启用)
CREATE EXTENSION IF NOT EXISTS "uuid-ossp";

-- 创建触发器函数
CREATE OR REPLACE FUNCTION public.set_post_user_id()
RETURNS TRIGGER AS $$
BEGIN
  -- 从 JWT 中获取用户 ID 并设置
  NEW.user_id = auth.uid();
  RETURN NEW;
END;
$$ LANGUAGE plpgsql SECURITY DEFINER;

-- 创建触发器
CREATE OR REPLACE TRIGGER set_post_user_id_trigger
  BEFORE INSERT ON posts
  FOR EACH ROW
  EXECUTE FUNCTION public.set_post_user_id();

使用新的用户 Token 创建文章

curl -X POST 'https://iurlblwfhmulfdysyqdz.supabase.co/rest/v1/posts' \
-H "apikey: sb_publishable_JToCFTBwo4urIOPnE9cIFg_1Wo-6WYj" \
-H "Authorization: Bearer eyJhbGaciOiJ...YNGEpJM2MkFp0pj4Hj45RY-h9L06M4" \
-H "Content-Type: application/json" \
-H "Prefer: return=minimal" \
-d '{ "title": "你好,世界!", "content": "北京今日天气:22-29度 多云 西北风3级(下起了小雨)" }'

解决了

17.webp

查看 “广场” 文章

以用户身份分页请求 10 篇文章

curl 'https://iurlblwfhmulfdysyqdz.supabase.co/rest/v1/posts?published_at=not.is.null&select=*' \
-H "apikey: sb_publishable_JToCFTBwo4urIOPnE9cIFg_1Wo-6WYj" \
-H "Authorization: Bearer eyJhbGaciOiJ...YNGEpJM2MkFp0pj4Hj45RY-h9L06M4" \
-H "Range: 0-9"

查询出来 [] 空数组,因为文章最开始我们定义了一个 “只能查询(SELECT)到已发布的文章” 的策略

现在正好试试 Server Key 的管理员 Key 的威力,批量更新 published_at 字段为当前时间(Server Key 要藏好)

curl -X PATCH 'https://iurlblwfhmulfdysyqdz.supabase.co/rest/v1/posts?id=in.(3,4,5)' \
-H "apikey: sb_secret_Xr48DdK*************pmR1XA_xLeM45LH" \
-H "Content-Type: application/json" \
-H "Prefer: return=minimal" \
-d '{ "published_at": "now()" }'

apikey 设置为 sb_secret_xxxx 管理员密钥,无需 Authorization Header

18.webp

已更新,预期应该是可以查询到了,但是依然返回 [],到 Supabase 翻翻,发现还是 RLS 策略缺失的问题;

Supabase SQL 执行页面有个功能,可以选择作为哪个 ROLE 执行

19.webp

默认使用 postgres,也可以切换为匿名角色或是 authenticated role,选择后可以选择特定的用户,到表的 RLS policies 页面,添加策略

using 条件就是发布时间不为空,允许了匿名和已登陆用户查看;

[  
    {  
        "id": 3,  
        "user_id": null,  
        "title": "你好,世界!",  
        "content": "北京今日天气:22-29度 多云 西北风3级",  
        "published_at": "2025-08-23T08:38:44.481162+00:00",  
        "created_at": "2025-08-23T07:58:08.479927+00:00",  
        "updated_at": "2025-08-23T08:38:44.481162+00:00"  
    },  
    {  
        "id": 4,  
        "user_id": null,  
        "title": "你好,世界!",  
        "content": "北京今日天气:22-29度 多云 西北风3级(下起了小雨)",  
        "published_at": "2025-08-23T08:38:44.481162+00:00",  
        "created_at": "2025-08-23T08:18:26.565641+00:00",  
        "updated_at": "2025-08-23T08:38:44.481162+00:00"  
    },  
    {  
        "id": 5,  
        "user_id": "badab300-959f-42f7-ae75-99d74f937804",  
        "title": "你好,世界!",  
        "content": "北京今日天气:22-29度 多云 西北风3级(下起了小雨)",  
        "published_at": "2025-08-23T08:38:44.481162+00:00",  
        "created_at": "2025-08-23T08:25:13.986663+00:00",  
        "updated_at": "2025-08-23T08:38:44.481162+00:00"  
    }  
]

“广场” 功能没问题,通过接口查询到了所有已发布的文章;

最后增加些安全策略

RLS 策略:用户每天最多发表 10 篇文章

CREATE POLICY "用户每天只能插入10篇文章"
ON posts 
FOR INSERT 
TO authenticated
WITH CHECK (
  auth.uid() = user_id AND
  -- 可以添加其他限制条件,比如每天最多10篇
  (SELECT COUNT(*) FROM posts 
   WHERE user_id = auth.uid() 
   AND created_at > NOW() - INTERVAL '1 day') < 10
);

在 Edge Functions 可以配置函数,以下的限制借助边缘函数实现

21.webp

rate-limiter(30 请求每分钟)

import "jsr:@supabase/functions-js/edge-runtime.d.ts";

// 内存存储速率限制
const rateLimitMap = new Map<string, { count: number; resetTime: number }>();

Deno.serve(async (req: Request) => {
  // 获取客户端IP
  const clientIP = req.headers.get('x-forwarded-for') || 'unknown';

  // 速率限制检查
  const now = Date.now();
  const limitData = rateLimitMap.get(clientIP) || { count: 0, resetTime: now + 60000 };

  // 重置计数器(每分钟)
  if (now > limitData.resetTime) {
    limitData.count = 0;
    limitData.resetTime = now + 60000;
  }

  // 检查限制(每分钟30次)
  if (limitData.count >= 30) {
    return new Response(
      JSON.stringify({ error: 'Rate limit exceeded' }),
      { status: 429 }
    );
  }

  // 增加计数
  limitData.count++;
  rateLimitMap.set(clientIP, limitData);

  // 返回成功响应
  return new Response(
    JSON.stringify({ 
      success: true,
      method: req.method,
      remaining: 30 - limitData.count 
    }),
    { headers: { 'Content-Type': 'application/json' } }
  );
});

post-content-size(限制 content 字段 10kb 大小)

import "jsr:@supabase/functions-js/edge-runtime.d.ts";

Deno.serve(async (req: Request) => {
  try {
    // 读取请求内容
    const content = await req.text();

    // 计算内容大小(字节数)
    const contentSize = new TextEncoder().encode(content).length;

    // 检查大小限制(10KB = 10240字节)
    if (contentSize > 10240) {
      return new Response(
        JSON.stringify({ 
          error: 'Content too large',
          max_size: '10KB',
          actual_size: `${(contentSize / 1024).toFixed(2)}KB`
        }),
        { status: 413 }
      );
    }

    // 内容大小合格
    return new Response(
      JSON.stringify({ 
        success: true,
        size: `${contentSize} bytes`,
        size_kb: `${(contentSize / 1024).toFixed(2)}KB`
      }),
      { headers: { 'Content-Type': 'application/json' } }
    );

  } catch (error) {
    return new Response(
      JSON.stringify({ error: 'Invalid request' }),
      { status: 400 }
    );
  }
});

reg-user-limiter(每天限制最多 100 人注册)

import "jsr:@supabase/functions-js/edge-runtime.d.ts";
import { createClient } from "jsr:@supabase/supabase-js@2";

const supabase = createClient(
  Deno.env.get("SUPABASE_URL")!,
  Deno.env.get("SUPABASE_SERVICE_ROLE_KEY")!
);

Deno.serve(async (req: Request) => {
  try {
    // 查询今日注册数量
    const today = new Date().toISOString().split('T')[0];
    const { count, error } = await supabase
      .from('auth.users')
      .select('*', { count: 'exact', head: true })
      .gte('created_at', `${today}T00:00:00`)
      .lte('created_at', `${today}T23:59:59`);

    if (error) throw error;

    // 检查是否超过限制
    if (count >= 100) {
      return new Response(
        JSON.stringify({ 
          error: 'Daily registration limit reached',
          limit: 100,
          today_count: count
        }),
        { status: 429 }
      );
    }

    // 允许注册
    return new Response(
      JSON.stringify({ 
        allowed: true,
        remaining: 100 - count,
        today_count: count
      }),
      { headers: { 'Content-Type': 'application/json' } }
    );

  } catch (error) {
    return new Response(
      JSON.stringify({ error: 'Internal server error' }),
      { status: 500 }
    );
  }
});

AI 提供的 Function 函数,我配置上,但没测试是否能正常工作 🤷

不知不觉记录了不少内容,后端目前先了解这些,已覆盖 Demo 所需功能,Web 前端的开发放到下篇文章进行记录。

下篇会根据 Supabase 的文档示例,使用 Vue 开发前端页面,也会调研部署在哪个免费服务比较好。