CASE STUDY · 实战篇

🧩 我的网站没有后端:拿 mingyuyang.com 讲透 Supabase

Supabase by example — my own site, real code, real probes
mingyuyang.com 上线至今,我没写过一行自建后端接口、没管过一个密码、没搭过一台数据库服务器——因为这些全是 Supabase 在干。这篇文档把我仓库里的真实代码逐段拆开,讲清 Supabase 每个组件到底替我干了什么活。所有代码都能在仓库里找到,所有探针命令都真实跑过。
📅 2026-07-19 💻 代码全部来自我的仓库 🧪 探针输出真实可复现 📖 理论篇:Supabase 面试备考指南
🎬

我的网站没有后端

No backend, on purpose

先交代这个站是什么、我在里面写了什么、没写什么——「没写的部分」就是 Supabase 的作用。

mingyuyang.com 是我的个人资料站:AI 学习笔记、公众号文章、每日简报,访客可以浏览搜索,我登录后能管理内容,登录用户还能给每篇文章打「待读/已读」标记。技术栈是 TanStack Start(React 19)跑在 Cloudflare Workers 上——但这只是「壳」,负责渲染页面。真正的后端三件事:存数据、认身份、管权限,我一行都没写。

0 行
我自己写的后端接口代码
78 行
resources 表当前数据量(2026-07-19 实测)
8 条
RLS policy 撑起全站权限(两张业务表 + 存储)

换句话说:如果把 Supabase 从我的站里抽走,剩下的只是一个漂亮但空心的前端。这正是讲解它的最好角度——不讲概念,讲「它替我干了哪些我本来要自己干的活」

💡 本篇是《Supabase 面试备考指南》的实战篇:那篇讲通用原理和面试题,这篇把每个概念钉到我自己仓库的具体文件上。两篇对照读,「会用」和「能讲」就都有了。
🗺️

全景对号入座

The big picture

一张图看清我的站和 Supabase 的分工——后面每一节展开图里的一块。

我的网站前端(浏览器 / Cloudflare Workers 壳) supabase-js + sb_publishable_ key(公开,内联在构建产物里) 本机 sync 脚本 service_role key(仓库外,绕过 RLS) 带 JWT 特权路径 🔐 GoTrue 认证 Google 登录 · 签发 JWT · §5 🔌 PostgREST 自动 API 表结构直接变 REST 端点 · §4 🗂 Storage 文件 封面与附件上传 · §10 🐘 Postgres 数据库 —— RLS 在这一层把关(§6–§8) resources 表 首页每张卡片一行(78 行) 人人可读 · owner/admin 可写 reading_status 表 待读/已读标记 RLS 限本人,匿名读 = [] auth.users · storage.objects 用户表(Supabase 管) 文件元数据也是表
mingyuyang.com 与 Supabase 的分工全景:前端拿公开钥匙走正门,本机脚本拿特权钥匙走侧门,所有门都通向同一个 Postgres

记住这张图的三个要点,后面全是展开:① 所有数据(包括用户、文件清单)都是 Postgres 里的表;② 前端不经过任何自建服务器,直接和 Supabase 的三个服务对话;③ 权限不写在代码里,写在数据库的 RLS policy 里。

🐘

数据库:resources 表就是整个网站

Postgres

Supabase 的第一重身份:一个我不用运维的完整 PostgreSQL。

我的站首页每张资料卡、搜索出的每条结果、文章页的每个标题,都是 resources 表里的一行。这张表长这样(摘自我仓库的 supabase/schema.sql,原文照录):

CREATE TABLE IF NOT EXISTS public.resources (
  id UUID NOT NULL DEFAULT gen_random_uuid() PRIMARY KEY,
  slug TEXT UNIQUE,
  type TEXT NOT NULL CHECK (type IN ('article','video','link','file','note')),
  title TEXT,
  summary TEXT,
  content TEXT,
  url TEXT,
  cover_url TEXT,
  category TEXT,
  subcategory TEXT,
  tags TEXT[] NOT NULL DEFAULT '{}',
  owner_id UUID REFERENCES auth.users(id) ON DELETE SET NULL,
  ...
);

来源:my-shared-memories 仓库 supabase/schema.sql(节选,省略部分列)

几个值得停下来看的细节:

这张表现在有 78 行(下文探针实测),来源有三路,也顺便展示了「往 Supabase 写数据」的三种姿势:

来源写入方式用的钥匙
静态学习文档部署时 sync 脚本扫 public/ 目录,REST upsert(只新增不覆盖)service_role(本机)
微信公众号文章导入脚本抓取后写库service_role(本机)
后台手动添加/修改/admin 页面里登录操作,走 supabase-jspublishable + 我的 JWT(admin 角色)
🔗 类比
把传统建站比作开餐厅:你得自己租厨房(数据库服务器)、雇厨师(后端服务)、定规矩(权限代码)。Supabase 相当于一个「共享中央厨房」:厨房、灶台、冷库全是现成的,我只需要决定菜单(表结构)和谁能进哪个储藏间(RLS)。
类比帮助理解分工,不对应具体技术细节。
🔌

自动 API:我从没写过接口,但接口一直都在

PostgREST

「建了表就有 API」听起来像魔法——看我站里的真实调用就祛魅了。

首页加载资料列表的代码,在 src/lib/resources.ts 里,原文如下:

// 首页拉资料列表(节选自 src/lib/resources.ts)
let q = supabase
  .from("resources")
  .select("*")
  .order("published_at", { ascending: false });
if (opts?.type)  q = q.eq("type", opts.type);
if (opts?.limit) q = q.limit(opts.limit);
const { data, error } = await q;

这段代码在访客的浏览器里执行。它没有请求我的任何服务器——supabase-js 把这个链式调用翻译成一个 HTTP 请求,直接打到 PostgREST 自动生成的端点上。翻译出来大概是:

GET https://<项目ref>.supabase.co/rest/v1/resources
    ?select=*&order=published_at.desc&type=eq.article
    apikey: sb_publishable_...

为什么这个端点存在?因为 PostgREST 读了我的表结构:resources 表存在,于是 /rest/v1/resources 就存在;表里有 type 列,于是 ?type=eq.article 这个过滤器就能用。我加一列,API 立刻多一个可查询字段;我删一列,对应查询立刻 400。schema 即接口,没有中间商。

🔧 我平时的内容运维,其实是在「裸用」这套 API

给已入库的文章改标题、加标签时,我不开后台页面,直接在终端 PATCH REST 端点(这是我真实干过的事:给 W4 六篇文章补「CS146S Week4」标签,6 条 PATCH 全部成功):

# 真实运维操作:直接 PATCH 一行数据(service_role key 存在仓库外)
curl -X PATCH "https://<项目ref>.supabase.co/rest/v1/resources?slug=eq.ai-notes-xxx" \
  -H "apikey: $SERVICE_KEY" -H "Authorization: Bearer $SERVICE_KEY" \
  -H "Content-Type: application/json" \
  -d '{"tags": ["CS146S Week4", "AI", "Claude Code"]}'
📌 一个我踩过才记住的细节:PATCH 会触发表上的 updated_at 自动更新触发器,于是那六篇文章页面上都显示了「更新于 7月7日」——数据库触发器不管你从哪个入口写,一视同仁。这个「副作用」反过来证明了:REST API、后台页面、脚本写入,底下是同一张表、同一套触发器、同一套规则
🔐

登录:六行代码的 Google 登录

GoTrue & JWT

我的站有完整的 Google 账号登录,而我写的相关代码,全文如下。

// src/routes/auth.tsx —— Google 登录的全部业务代码,原文照录
const signInGoogle = async () => {
  const { error } = await supabase.auth.signInWithOAuth({
    provider: "google",
    options: { redirectTo: `${window.location.origin}/admin` },
  });
  if (error) toast.error(error.message || "Google 登录失败");
};

这六行背后,GoTrue(Supabase 的认证服务)替我做了整条链:跳转 Google 授权页 → 接住回调 → 和 Google 换取用户信息 → 在 auth.users 表建立/匹配用户 → 签发一张 JWT 交还给浏览器 → 之后 supabase-js 自动在每个请求上带着它、并在过期前自动续期。我没有存过密码,没有写过 session 管理,没有碰过 OAuth 的 token 交换细节。

JWT 是什么
一段签过名的「身份证明」,里面写着:sub(我在 auth.users 里的用户 ID)、role(数据库角色,登录后是 authenticated)、exp(过期时间)等。任何 Supabase 服务拿到它验个签名就知道「这是谁、有什么身份」,不用回数据库查。
为什么关键
它是下一节 RLS 的输入:policy 里的 auth.uid() 读的就是这张 JWT 里的 sub登录系统和数据库权限系统就是在这里接上头的——这是 Supabase 安全模型最精妙的一环。
动手验证
浏览器登录我的站后,开发者工具里找到 access token 粘到 jwt.io,就能看到上述字段。我复习时做过一遍,面试可以当经验讲。

顺带一提:我的站还有「管理员」概念——admin 能在后台管理所有资料。这不是写在 JWT 里的,而是一张 user_roles 表 + 一个 has_role() 数据库函数,RLS policy 里直接查(下一节能看到它出场)。角色数据放数据库而不是放 token,好处是改角色立刻生效,不用等 token 刷新。

🌐

RLS 标本一:公开内容表怎么写策略

resources 的 policy

我的站有两张业务表,恰好是 RLS 的两种典型配方。先看「公开读、受控写」的这张。

resources 表的诉求很朴素:谁都能看,但不能乱写。它的 policy 原文(schema.sql + 后来的 admin 补丁):

-- 读:对所有人放行(所以我的资料库不登录也能浏览)
CREATE POLICY "Resources are readable by everyone"
  ON public.resources FOR SELECT USING (true);

-- 写:本人或管理员(20260702-admin-manage-resources.sql 补丁原文)
CREATE POLICY "Owners or admins update resources"
  ON public.resources FOR UPDATE TO authenticated
  USING      (auth.uid() = owner_id OR public.has_role(auth.uid(), 'admin'))
  WITH CHECK (auth.uid() = owner_id OR public.has_role(auth.uid(), 'admin'));

这里有一个我真实踩过的坑,比任何教程都说明问题:

⚠️ 踩坑实录:「谁都删不掉」的行。最初的 UPDATE/DELETE policy 只放行 auth.uid() = owner_id(本人)。但脚本导入的文章 owner_id 是空的——于是这些行连我这个管理员登录后都删不掉:policy 条件 auth.uid() = NULL 永远不为真,数据库默默过滤,后台删除按钮点了没反应、还不报错。修复就是上面的补丁:条件里加 OR has_role(auth.uid(), 'admin')。这个坑教会我两件事:① RLS 拒绝的方式是「装作没这行」而不是报错,排查时要想到它;② NULL 参与比较永远不为真——这正是理论篇里 auth.uid() 陷阱的现实版本。

还有一个细节:policy 拒绝写入时,REST 返回的可能是「成功,影响 0 行」。所以我后台的删除代码专门核对返回行数来防「RLS 假成功」——这也是实战里才会长出来的防御。

🛡️

RLS 标本二:用户私有表的完整配方

reading_status ★

「待读/已读」功能是我 2026 年 7 月自己上线的,它的 SQL 是一份可以背下来的 RLS 全套模板。

📎 需先理解 上一节的 JWT:下面所有 auth.uid() 读的都是 JWT 里的用户 ID

需求:登录用户可以给任何文章打「待读」或「已读」标记,标记只有自己能看见、能修改。表结构 + 全部四条 policy,原文照录自我仓库的 supabase/patches/2026-07-13-reading-status.sql:

CREATE TABLE IF NOT EXISTS public.reading_status (
  id uuid NOT NULL DEFAULT gen_random_uuid() PRIMARY KEY,
  user_id uuid NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE,
  resource_id uuid NOT NULL REFERENCES public.resources(id) ON DELETE CASCADE,
  status text NOT NULL CHECK (status IN ('read', 'to_read')),
  updated_at timestamptz NOT NULL DEFAULT now(),
  UNIQUE (user_id, resource_id)
);

ALTER TABLE public.reading_status ENABLE ROW LEVEL SECURITY;

-- ① 看:只能看自己的行
CREATE POLICY "Users can view their own reading status" ON public.reading_status
  FOR SELECT TO authenticated USING (auth.uid() = user_id);

-- ② 增:只能以自己的身份写入
CREATE POLICY "Users can add their own reading status" ON public.reading_status
  FOR INSERT TO authenticated WITH CHECK (auth.uid() = user_id);

-- ③ 改:旧行得是自己的(USING),改完还得是自己的(WITH CHECK)
CREATE POLICY "Users can update their own reading status" ON public.reading_status
  FOR UPDATE TO authenticated
  USING (auth.uid() = user_id) WITH CHECK (auth.uid() = user_id);

-- ④ 删:只能删自己的行
CREATE POLICY "Users can remove their own reading status" ON public.reading_status
  FOR DELETE TO authenticated USING (auth.uid() = user_id);

CREATE INDEX IF NOT EXISTS idx_reading_status_user
  ON public.reading_status(user_id, status, updated_at DESC);

逐条拆开,这份 SQL 里藏着理论篇讲过的四个知识点的实物:

而前端那边,「看不见别人的标记」这件事零代码:组件里就是普通的 select,返回的天然只有自己的行。权限逻辑从 UI 层彻底消失了——这就是「安全下沉到数据库」的直观体感。

🧪

实证:两条匿名探针

Prove it

口说无凭。下面两条命令我在写这篇文档时(2026-07-19)真实跑过,输出原样贴上。

用的就是前端公开的那把 sb_publishable_ key,不带任何登录态,模拟「任何一个陌生人」的访问:

# 探针 1:匿名读公开表 resources —— 应该有数据
curl -s "https://<项目ref>.supabase.co/rest/v1/resources?select=title&order=published_at.desc&limit=3" \
  -H "apikey: $PUBLISHABLE_KEY"
[{"title":"Supabase 面试备考指南:架构 · RLS · Auth · 一周学习路径"},
 {"title":"多模型路由 Model Routing:把每个请求送给「最便宜的够用模型」"},
 {"title":"MCP Apps:让 MCP 服务器「长出界面」的交互 UI 扩展"}]

# 顺带看总量(响应头 content-range)
content-range: 0-77/78        # 78 行

# 探针 2:匿名读私有表 reading_status —— 应该什么都看不到
curl -s "https://<项目ref>.supabase.co/rest/v1/reading_status?select=*" \
  -H "apikey: $PUBLISHABLE_KEY"
[]

注意第二条:不是 401、不是 403,是空数组。请求是合法的、表是存在的、查询执行了——只是 RLS 把每一行都过滤掉了,匿名用户「看不见任何行」。同一把钥匙、同一套 API,一张表全量放行、一张表滴水不漏,差别只在 policy。这两条命令就是「安全写在数据库层」的最短证明,面试时可以直接口述这个实验。

🎯 面试加分句:「我上线阅读状态功能那天,验收清单里专门有一条:用匿名身份直接 curl REST 端点,确认返回空数组。前端看不到入口不等于安全,绕过前端直接打 API 还拿不到数据才算安全。」——这句话能把你和只会点后台的人区分开。
🗝️

两把钥匙:我的部署流程就是教科书

Key management

「key 能不能公开」不取决于藏得多深,取决于它对应什么数据库角色。我的站两把钥匙待遇天差地别。

「什么应用在访问我的项目?」 API Key 回答的问题 sb_publishable_...(公开钥匙) 写在 .env,构建时内联进前端产物 全世界可见 —— 故意的 只能以 anon / authenticated 角色行动 每一行数据都有 RLS 兜底 service_role(特权钥匙) 存在仓库外 ~/.ms-supabase-admin chmod 600 · 永不进 git · 不打包 绕过一切 RLS —— 整库钥匙 只有本机 sync/运维脚本能碰
同一个项目的两把钥匙:一把印在传单上到处发,一把锁在保险柜里——因为它们对应的数据库角色权限天差地别

为什么 publishable key 敢公开?因为公开它等于什么都没泄露:拿到它的人只能以 anon 角色发请求,而 anon 能看什么、不能看什么,上一节的探针已经演示过了——全由 RLS 决定。反过来,service_role key 绕过所有 RLS,所以我的仓库规范里它一条红线写死:永不进 .env、永不进 git、永不打包,只存在部署机的一个 600 权限文件里。

🥚 一个藏在我仓库里的彩蛋

我的站用的是 2025 年后的新版 key 体系(sb_publishable_ 前缀)。新 key 有个官方文档里写明的坑:它是不透明字符串、不是 JWT,不能放在 Authorization: Bearer 头里。而我仓库的 src/integrations/supabase/client.ts 里恰好有这么一段(原文照录):

// src/integrations/supabase/client.ts(节选)
function isNewSupabaseApiKey(value: string): boolean {
  return value.startsWith('sb_publishable_') || value.startsWith('sb_secret_');
}
// New Supabase API keys are opaque strings, not bearer JWTs.
if (isNewSupabaseApiKey(supabaseKey) && headers.get('Authorization') === `Bearer ${supabaseKey}`) {
  headers.delete('Authorization');   // 把误放进 Bearer 头的新 key 摘出去
}
headers.set('apikey', supabaseKey);

理论篇里那句「新 key 不能走 Bearer 头」的迁移细节,在我自己的代码库里就有现成实现。读文档时觉得琐碎的知识点,原来早就在生产代码里替我干活了——这种「原来如此」的时刻,就是拿自己项目复习的最大红利。

🗂️

Storage:管文件和管数据是同一套思路

storage.objects

封面图和附件传到哪去了?权限怎么管?答案还是「表 + policy」。

我的站有一个叫 resources 的存储桶(bucket),后台上传的封面图、文件附件都进这里。文件本体存在对象存储里,但每个文件的元数据是 storage.objects 表里的一行——所以文件权限就是往这张表上写 RLS policy,和管业务表一模一样(schema.sql 原文):

-- 谁都能看这个桶里的文件(封面图要公开展示)
CREATE POLICY "Public can read resources bucket"
  ON storage.objects FOR SELECT USING (bucket_id = 'resources');

-- 登录用户只能上传属于自己的文件
CREATE POLICY "Authenticated users can upload to resources"
  ON storage.objects FOR INSERT TO authenticated
  WITH CHECK (bucket_id = 'resources' AND owner = auth.uid());

对比一下 Firebase:它的文件权限要用单独的 Security Rules 语言再学一遍。这里我把管 reading_status 的那套思路原封不动搬过来就行——少学一门方言,这是「一切皆 Postgres」设计的又一次分红。顺便:这个桶不是自动出现的,是我在 schema.sql 里用 INSERT INTO storage.buckets 幂等建的——桶本身也是表里的一行,贯彻到底。

🧘

我没用的两块,和为什么没用

What I skipped

讲清「为什么不用」和讲清「为什么用」同样重要——这是选型能力的体现。

📡 Realtime —— 没用

我的站是内容站:文章更新频率以「天」计,访客没有「盯着别人实时改动」的场景。为一个不存在的需求维持 WebSocket 长连接,纯属浪费。

什么时候会用:如果以后做「多人同时给文章写批注、互相实时可见」,那就是 Postgres Changes(或 Broadcast)的活。

λ Edge Functions —— 没用

我的服务端逻辑(SSR 渲染、AI 自动分类调 Claude API)跑在 Cloudflare Workers 上——站本身部署在那里,逻辑放同处更顺。同类能力二选一即可。

什么时候会用:如果没有 Workers 这层壳,「收 Stripe webhook」「服务端调 LLM」这类活就该放 Edge Functions。

这件事本身就是 Supabase 的一个设计原则的体现:官方架构文档说每个组件「可作为独立工具运行」。我实际取用的是数据库 + 自动 API + 认证 + 存储四件,Realtime 和 Edge Functions 一根线都没连——平台没有强迫我全家桶,这才是「组件化」的诚意。

🚄

一次点击的完整旅程

End to end

把前面所有零件串起来:我在文章页点一下「已读」,这 200 毫秒里发生了什么。

① 点「已读」 supabase-js 发 upsert 自动带上我的 JWT ② 网关验 key sb_publishable_ 合法 路由给 PostgREST ③ PostgREST 验 JWT 签名有效,role claim → 切到 authenticated ④ RLS 逐条把关 WITH CHECK:user_id = auth.uid() ✓ 写入 徽章 变色 全程没有经过我写的任何服务器代码;UNIQUE 约束保证重复点击是更新而非重复插入
「标记已读」的 200 毫秒:五步里每一步都是前面章节讲过的零件在协作
  1. 浏览器:supabase-js 发出 upsert组件调用 hook,对 reading_status 表 upsert 一行 { resource_id, status: 'read' };SDK 自动附上我的 JWT 和公开 key。注意请求体里不用传 user_id——身份由 JWT 说了算,前端连伪造的机会都没有。
  2. 网关:验的是「什么应用」publishable key 合法 → 放行并路由。这一层不关心我是谁,只关心请求来自我的项目。
  3. PostgREST:验的是「哪个人」JWT 验签通过,读出 role: authenticatedsub(我的用户 ID),以 authenticated 角色执行 SQL。
  4. Postgres:RLS 最后把关INSERT 撞上 UNIQUE 约束转为 UPDATE;policy ③ 的 USING 确认旧行是我的、WITH CHECK 确认新行还是我的。任何一步不满足,这行就像不存在。
  5. 浏览器:UI 更新返回成功,徽章变色。整条链路里,权限判断只存在于第 4 步的两行 SQL 里。
🎤

把这些讲给面试官听

The 2-minute story

素材都有了,最后组装成一段两分钟的项目陈述。斜体处换成你面试当下的自然表达。

「我的个人网站用 Supabase 做全部后端,前端是 TanStack Start 部署在 Cloudflare Workers。选它的核心原因是 database-centric 的设计:内容表、用户表、文件元数据都是同一个 Postgres 里的表,权限全部用 RLS 收在数据库层——我一行后端接口代码都没写,PostgREST 把表结构直接映射成 REST API。

权限上我写过两种典型配方:公开内容表是『人人可读、owner 或 admin 可写』,管理员走 user_roles 表加 has_role 函数;用户私有的阅读状态表是四条 policy 全套,USING 管旧行、WITH CHECK 管新行,策略列建了索引、显式限定 TO authenticated。上线验收时我专门用匿名身份 curl 过 REST 端点,确认私有表返回空数组——绕过前端直接打 API 拿不到数据,才算安全。

密钥管理是两把钥匙:publishable key 内联在前端产物里公开无妨,因为 RLS 兜底;service_role key 绕过 RLS,只存在部署机的 600 权限文件里,永不进 git。Realtime 和 Edge Functions 我评估过但没用——站里没有实时需求,服务端逻辑已经在 Workers 上,没必要为用而用。」

配合追问储备:RLS 假成功(影响 0 行)踩坑、NULL 比较陷阱、updated_at 触发器副作用、新版 key 不走 Bearer 头

这段话的结构是刻意的:选型理由 → 具体实现(带术语)→ 安全验证(带实验)→ 边界判断(不用什么)。四层递进,每一层都能接住追问——追问落点就是本文档的对应章节。

💡 最后提醒:面试官对「真实踩过的坑」的兴趣远大于「背下来的特性」。本文档里有四个真坑(admin 删不掉空 owner 的行、RLS 假成功、PATCH 触发 updated_at、新 key 的 Bearer 头),每个都值得练成 30 秒小故事。

🎓 收尾

Supabase 的作用,用我的站一句话总结:它让「一个人 + 一个前端仓库」等于「一支配齐了 DBA、后端和安全工程师的团队」——数据库它托管,API 它生成,身份它认证,权限我只写 policy。

理论篇(概念、面试题、一周计划):Supabase 面试备考指南