目标:实现一个文章发布 Web 应用(Demo),包含用户注册、编辑、发布文章的功能,同时有广场(展示所有已发布文章、所有用户可以查看)、并使用管理员进行维护,技术架构是 Supabase + Vue,Vue 部署在哪里还未计划,以此为契机学习了解 Supabase 服务
本篇学习和记录了如下内容
- 体验 Supabase Console 建表、执行 SQL
- 创建 RLS 行级安全策略
- 了解不同的 API Key 类型、创建 API Key、Curl 命令调用接口
- 了解 Supabase 的 Users 和业务表的 Users 表关联方式(通过触发器)
- 模拟用户注册、登陆、创建文章、查看广场文章的接口使用场景
- 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,可以看到已经成功创建两张表
但是上方都有 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 提供邮件邀请和手动创建的方式
可以手动创建用户
也可以点击 “Send invitation” 发送邀请到邮箱。
获取免费的 SMPT Server(可选)
最开始我以为 Supabase 发送邮件需要自己提供服务,就在网上找了一个 Brevo 这个服务,有免费额度,注册过程很丝滑,记录如下,这步可以跳过,直接使用 Supabase 的邮件服务就好,也不用设置。
服务地址:Free SMTP Server | Deliver to the Inbox Every Time
免费套餐每天 300 封,测试使用足够了
注册后点击右上角的组织,选择 “SMTP & API”
可以看到 SMTP 服务的服务器地址、用户名密码
还需要添加 Sender,否则发不出邮件
这里我起的应用名字是 “Reader Bot”,邮箱就是我的 Gmail 邮箱,需要真实的邮箱地址,稍后会发送验证码验证。
点击添加
提示我们的免费邮箱可能大概率进入收件人的垃圾邮箱,建议绑定域名,因为是测试,当然是 Anyway
验证完成后,可以看到新的 Sender 添加成功
此时 Brevo 提供的邮箱服务已可用
在 Supabase 配置自己的 Email SMPT 服务(可选)
如果你申请了邮箱,可以在 Authentication 模块进行配置
填入 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 表中发送邮件邀请用户,就可以收到邮件
内容如下
其中的邮件内容模板在 “Emails - Templates” 内进行配置,访问的链接在 “URL Configuration - Site URL” 进行配置,暂时先不用修改
初识 API & Keys
在 Project Settings 的 API Keys 页面可以创建项目 Key
创建后有一个 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
翻到 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 可以看到已经多了一条记录
然后我发现 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
注册、登陆不再重复粘贴代码,控制台已经可以看到用户
解决 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级(下起了小雨)" }'
解决了
查看 “广场” 文章
以用户身份分页请求 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
已更新,预期应该是可以查询到了,但是依然返回 []
,到 Supabase 翻翻,发现还是 RLS 策略缺失的问题;
Supabase SQL 执行页面有个功能,可以选择作为哪个 ROLE 执行
默认使用 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 可以配置函数,以下的限制借助边缘函数实现
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 开发前端页面,也会调研部署在哪个免费服务比较好。