在掌握了认证与 RLS 之后,真正决定项目能不能持续迭代的,往往是数据模型与“业务逻辑应该放在哪”的边界。
这一篇我会用一个虚构的业务场景贯穿(ProjectX 灵感协作小程序),不是为了讲故事,而是为了让你能把建模、触发器、Database Functions、Edge Functions 放到同一个上下文里对照。你完全可以把它当成一套可迁移的思路:遇到“多表一致性”“需要事务”“需要调用外部服务”时,应该把逻辑落在哪一层。
夯实地基:核心表的数据建模
数据模型是后端的“地基”。在 Supabase 中,业务表通常建立在 public schema 下。下面以核心的 ideas(灵感卡片)表为例。
1. 为什么坚决使用 UUID 作为主键?
很多新手喜欢用自增整数(SERIAL)作为主键 ID(比如 id: 1001)。这种选择在公开产品中有两个实际风险:
- 暴露商业机密:对手只需每天注册一个账号,看看卡片 ID 增长了多少,就能精准推算你的日活跃度。
- 遭受枚举攻击:黑客写个脚本,遍历 ID 从 1 到 10000 就能轻松爬取你全站的内容。
更稳的做法是使用 UUID:在建表时指定 id UUID DEFAULT gen_random_uuid() PRIMARY KEY。它能避免可枚举 ID,同时在后续做数据合并或多环境迁移时也更省心。
2. 用户关联与自动清理(级联删除)
每一张灵感卡片必须绑定创作者。我们的外键应该直接关联到 Supabase 底层负责身份管理的 auth.users 表:
author_id UUID NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE
这里的关键点是 ON DELETE CASCADE:当用户注销导致 auth.users 的记录被删除时,数据库会自动级联删除该用户发布的卡片,避免出现“用户删了但内容还在”的悬挂数据。是否要级联删除取决于你的业务(有些产品会选择软删除或匿名化),但需要在建模阶段就做出明确选择。
3. 状态控制:枚举还是 CHECK?
灵感卡片的状态通常有:草稿(draft)、已发布(published)、已归档(archived)。
- 如果你确信状态极少变更:使用 PostgreSQL 原生枚举(
CREATE TYPE idea_status AS ENUM ('draft', 'published'))。好处是强类型,但致命缺点是枚举值一旦创建,无法删除,也很难修改顺序。 - 对于敏捷开发的业务(推荐):使用
TEXT结合CHECK约束。如果你要加一个“审核中”状态,只需status TEXT DEFAULT 'draft' CHECK (status IN ('draft', 'published', 'archived'))ALTER TABLE更新约束即可。
自动化状态追踪:使用 Trigger (触发器)
灵感内容在被反复修改时,我们希望前端能精确显示“最近更新时间”。如果我们依赖前端在 UPDATE 请求里传入当前时间,那不仅繁琐,而且容易被恶意篡改。
这就是**数据库触发器(Trigger)**登场的完美时机。触发器能监听表的数据变更,并在最底层自动执行动作。
首先,我们定义一个可复用的函数,它的唯一作用就是把 updated_at 刷新为当前时间:
CREATE OR REPLACE FUNCTION update_updated_at()
RETURNS TRIGGER AS $$
BEGIN
NEW.updated_at = NOW(); -- NEW 代表即将被写入的那行新数据
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
然后,我们将这个触发器挂载到 ideas 表上:
CREATE TRIGGER set_ideas_updated_at
BEFORE UPDATE ON ideas
FOR EACH ROW
EXECUTE FUNCTION update_updated_at();
现在,无论用户怎么修改卡片内容,哪怕是在 Supabase Dashboard 里手动改一行数据,updated_at 都会精准无误地更新,完全防篡改。
什么是 Supabase Functions?
当应用从“简单的 CRUD”进化到“复杂的业务结算”时,纯前端的逻辑已经捉襟见肘了。试想一下:发布一个灵感不仅要存入 ideas 表,还要写入 tags 表,还要给用户增加活跃积分。如果前端网断了,导致前两步成功、加积分失败怎么办?
我们需要服务端的算力。在 Supabase 生态中,服务端函数(Functions)分为两大互补的阵营:
一个简单的经验法则:需要强一致事务的逻辑优先放 Database Function;需要调用外部服务、需要隐藏密钥的逻辑放 Edge Function。
收敛复杂事务:Database Functions 实战
让我们解决刚才提到的“发卡片+打标签+加积分”难题。如果在应用层执行 3 次 API 请求,这就是典型的非原子操作风险。
我们将这三步封装进一个原生的 Database Function。它直接在数据库内存中执行,没有往返网络延迟,最重要的是它天然包裹在一个事务(Transaction)里,要么全成功,要么全回滚。
CREATE OR REPLACE FUNCTION publish_idea(
p_title TEXT,
p_content TEXT,
p_tags TEXT[]
)
RETURNS UUID
LANGUAGE plpgsql
SECURITY INVOKER
AS $$
DECLARE
v_idea_id UUID;
v_user_id UUID := auth.uid();
BEGIN
INSERT INTO ideas (title, content, author_id)
VALUES (p_title, p_content, v_user_id)
RETURNING id INTO v_idea_id;
INSERT INTO idea_tags (idea_id, tag_name)
SELECT v_idea_id, unnest(p_tags);
UPDATE profiles SET points = points + 5 WHERE id = v_user_id;
RETURN v_idea_id;
END;
$$;
在小程序端,通过 RPC(远程过程调用)调用一次即可:
const { data: ideaId, error } = await supabase.rpc('publish_idea', {
p_title: '...',
p_content: '...',
p_tags: ['...'],
});
跨越数据库边界:Edge Functions 进阶
当业务需要调用微信支付、内容审核、短信等外部服务时,有一个硬约束:密钥只能放在服务端。小程序端只负责发起请求,签名与下单必须由服务端完成。
Edge Functions 适合承载这类逻辑:它是一个独立的 HTTP 入口,天然适合放置密钥与调用第三方 API。
import { serve } from "https://deno.land/[email protected]/http/server.ts"
import WechatPay from "npm:wechatpay-node-v3"
serve(async (req) => {
const privateKey = Deno.env.get("WECHAT_PRIVATE_KEY");
const { ideaId, amount } = await req.json();
return new Response(JSON.stringify(paySignData), {
headers: { "Content-Type": "application/json" }
})
})
如果 Edge Function 需要使用 service_role(例如写入订单状态),建议把范围收得尽量小:只做必要的服务端动作,并在函数内部校验调用者身份与参数合法性,不要把它当成绕过 RLS 的通用通道。
本篇最小 SQL 资产块(可复制)
下面是一套最小可跑的 SQL(表 + 触发器 + RPC + 权限),你可以把它拆成 migration 文件使用。字段与业务只是示例,重点是结构与边界。
create table if not exists public.profiles (
id uuid primary key references auth.users(id) on delete cascade,
points integer not null default 0,
created_at timestamptz not null default now()
);
create table if not exists public.ideas (
id uuid primary key default gen_random_uuid(),
author_id uuid not null references auth.users(id) on delete cascade,
title text not null,
content text not null,
status text not null default 'draft' check (status in ('draft', 'published', 'archived')),
created_at timestamptz not null default now(),
updated_at timestamptz not null default now()
);
create table if not exists public.idea_tags (
idea_id uuid not null references public.ideas(id) on delete cascade,
tag_name text not null,
primary key (idea_id, tag_name)
);
create or replace function public.update_updated_at()
returns trigger language plpgsql as $$
begin
new.updated_at = now();
return new;
end;
$$;
drop trigger if exists set_ideas_updated_at on public.ideas;
create trigger set_ideas_updated_at
before update on public.ideas
for each row execute function public.update_updated_at();
alter table public.ideas enable row level security;
alter table public.idea_tags enable row level security;
create policy "ideas: read own"
on public.ideas for select
to authenticated
using (author_id = auth.uid());
create policy "ideas: insert own"
on public.ideas for insert
to authenticated
with check (author_id = auth.uid());
create policy "idea_tags: insert via own idea"
on public.idea_tags for insert
to authenticated
with check (
exists (
select 1 from public.ideas
where ideas.id = idea_tags.idea_id
and ideas.author_id = auth.uid()
)
);
create or replace function public.publish_idea(p_title text, p_content text, p_tags text[])
returns uuid
language plpgsql
security invoker
as $$
declare
v_idea_id uuid;
v_user_id uuid := auth.uid();
begin
insert into public.ideas (title, content, author_id, status)
values (p_title, p_content, v_user_id, 'published')
returning id into v_idea_id;
insert into public.idea_tags (idea_id, tag_name)
select v_idea_id, unnest(p_tags);
update public.profiles set points = points + 5 where id = v_user_id;
return v_idea_id;
end;
$$;
grant execute on function public.publish_idea(text, text, text[]) to authenticated;