返回博客列表
技术文章Supabase微信小程序数据建模云函数

数据建模与 Supabase Functions 全解

2025年05月🇨🇳 中文

在掌握了认证与 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;