測驗:Supabase 整合實戰 – 打造完整的會員資料系統
共 5 題,點選答案後會立即顯示結果
1. 為什麼需要建立獨立的 profiles 表,而不是直接使用 auth.users 表?
2. 在 profiles 表的主鍵設計中,on delete cascade 的作用是什麼?
3. 在 Database Trigger 中使用 security definer 的目的是什麼?
4. 在 RLS Policy 中,using 和 with check 的差異是什麼?
5. 使用 Supabase Client 的 upsert 方法有什麼作用?
前言
經過前四篇的學習,你已經掌握了 Supabase 的核心功能:資料庫操作、身份驗證、以及 Row Level Security。現在是時候把這些知識整合起來,打造一個完整的會員資料系統了。
這篇文章將帶你從需求分析開始,一步步建立 profiles 表、設定自動化的 Database Trigger、配置 RLS Policy,最後整合前端程式碼,完成一個可以實際運作的會員系統。
學習目標
讀完本篇後,你將能夠:
- 整合 Auth + Database + RLS 建立完整功能
- 設計 profiles 表與 auth.users 的關聯
- 使用 Database Trigger 自動建立使用者資料
- 實作會員資料的顯示與更新功能
專案需求分析
在開始寫程式碼之前,讓我們先思考一個會員系統需要什麼。
為什麼需要 profiles 表?
Supabase 的 auth.users 表是由系統管理的,存放在 auth schema 中,有幾個限制:
- 無法直接透過 API 存取:
auth.users不會被 PostgREST 暴露出來 - 欄位固定:你無法隨意新增自訂欄位
- 由 Supabase 管理:欄位結構可能會變動
因此,官方建議的做法是:在 public schema 建立一個 profiles 表,透過外鍵關聯到 auth.users。
會員系統的核心需求
我們的會員系統需要:
| 功能 | 說明 |
|---|---|
| 自動建立資料 | 使用者註冊時,自動在 profiles 建立一筆記錄 |
| 讀取個人資料 | 使用者可以查看自己的 profile |
| 更新個人資料 | 使用者可以修改自己的 username、website 等資訊 |
| 資料隔離 | 使用者只能存取自己的資料(RLS) |
資料表設計
profiles 表結構
這是我們的 profiles 表設計:
create table public.profiles (
id uuid not null references auth.users on delete cascade,
username text unique,
full_name text,
avatar_url text,
website text,
updated_at timestamp with time zone,
primary key (id)
);
Code language: JavaScript (javascript)讓我們逐行解讀這段 SQL:
主鍵設計
id uuid not null references auth.users on delete cascade,
Code language: JavaScript (javascript)id uuid:使用 UUID 作為主鍵,與auth.users的 id 一致references auth.users:建立外鍵關聯on delete cascade:當 auth.users 的記錄被刪除時,profiles 也會自動刪除
這是 Supabase 官方推薦的做法:只使用主鍵作為外鍵參考,因為主鍵保證不會改變。
其他欄位
username text unique, -- 使用者名稱,必須唯一
full_name text, -- 顯示名稱
avatar_url text, -- 頭像 URL
website text, -- 個人網站
updated_at timestamp with time zone, -- 最後更新時間
Code language: JavaScript (javascript)執行建表語句
在 Supabase Dashboard 的 SQL Editor 中執行:
-- 建立 profiles 表
create table public.profiles (
id uuid not null references auth.users on delete cascade,
username text unique,
full_name text,
avatar_url text,
website text,
updated_at timestamp with time zone,
primary key (id)
);
-- 啟用 RLS
alter table public.profiles enable row level security;
Code language: JavaScript (javascript)Database Trigger:自動建立 Profile
當使用者註冊時,我們希望自動在 profiles 表建立一筆記錄。這需要用到 Database Trigger。
理解 Trigger 的運作原理
使用者註冊
|
v
auth.users 新增一筆記錄
|
v
觸發 on_auth_user_created trigger
|
v
執行 handle_new_user() function
|
v
在 profiles 表新增對應記錄
Code language: JavaScript (javascript)建立 Trigger Function
create function public.handle_new_user()
returns trigger
language plpgsql
security definer set search_path = ''
as $$
begin
insert into public.profiles (id, full_name, avatar_url)
values (
new.id,
new.raw_user_meta_data ->> 'full_name',
new.raw_user_meta_data ->> 'avatar_url'
);
return new;
end;
$$;
Code language: JavaScript (javascript)這段程式碼有幾個關鍵點:
security definer 的作用
security definer set search_path = ''
Code language: JavaScript (javascript)security definer 是這段程式碼的關鍵。它讓 function 以建立者的權限(postgres)執行,而不是觸發者的權限。
為什麼需要這樣?因為管理 auth.users 的角色是 supabase_auth_admin,它沒有權限寫入 public schema。使用 security definer 可以繞過這個限制。
存取使用者 metadata
new.raw_user_meta_data ->> 'full_name'
Code language: JavaScript (javascript)new 代表剛插入的記錄(新的 auth.users 行)。raw_user_meta_data 是一個 JSONB 欄位,存放使用者註冊時提供的額外資料。
使用 ->> 運算子可以取出 JSON 中的值作為 text。
建立 Trigger
create trigger on_auth_user_created
after insert on auth.users
for each row execute procedure public.handle_new_user();
Code language: CSS (css)這個 trigger 會在每次 auth.users 有新記錄插入後執行 handle_new_user() function。
完整的 SQL 腳本
把上面的程式碼整合起來:
-- 1. 建立 profiles 表
create table public.profiles (
id uuid not null references auth.users on delete cascade,
username text unique,
full_name text,
avatar_url text,
website text,
updated_at timestamp with time zone,
primary key (id)
);
-- 2. 啟用 RLS
alter table public.profiles enable row level security;
-- 3. 建立 trigger function
create function public.handle_new_user()
returns trigger
language plpgsql
security definer set search_path = ''
as $$
begin
insert into public.profiles (id, full_name, avatar_url)
values (
new.id,
new.raw_user_meta_data ->> 'full_name',
new.raw_user_meta_data ->> 'avatar_url'
);
return new;
end;
$$;
-- 4. 建立 trigger
create trigger on_auth_user_created
after insert on auth.users
for each row execute procedure public.handle_new_user();
Code language: JavaScript (javascript)RLS Policy 設定
接下來要設定 Row Level Security,確保使用者只能存取自己的資料。
Policy 設計思路
| 操作 | 規則 |
|---|---|
| SELECT | 只能讀取自己的 profile |
| UPDATE | 只能更新自己的 profile |
| INSERT | 由 trigger 處理,一般使用者不需要 |
實作 RLS Policy
-- 使用者可以查看自己的 profile
create policy "Users can view own profile"
on public.profiles
for select
to authenticated
using (auth.uid() = id);
-- 使用者可以更新自己的 profile
create policy "Users can update own profile"
on public.profiles
for update
to authenticated
using (auth.uid() = id)
with check (auth.uid() = id);
Code language: PHP (php)解讀這些 Policy
SELECT Policy:
using (auth.uid() = id)
auth.uid() 會回傳當前登入使用者的 UUID。這個條件確保使用者只能讀取 id 等於自己 UUID 的記錄。
UPDATE Policy:
using (auth.uid() = id) -- 哪些記錄可以被選中更新
with check (auth.uid() = id) -- 更新後的資料必須符合的條件
Code language: JavaScript (javascript)using 決定哪些現有記錄可以被更新,with check 確保更新後的資料仍然符合條件(防止使用者把 id 改成別人的)。
重要提醒:RLS 的限制
RLS 是基於行(row)的安全機制,無法控制欄位(column)。
舉例來說,如果你給使用者更新自己 profile 的權限,他可以修改該行的任何欄位。如果你想保護某些欄位(例如不讓使用者自己改 id),需要使用 Trigger 來處理。
前端整合範例
現在讓我們用 JavaScript/React 來實作前端功能。
初始化 Supabase Client
import { createClient } from '@supabase/supabase-js'
const supabaseUrl = 'https://your-project.supabase.co'
const supabaseAnonKey = 'your-anon-key'
export const supabase = createClient(supabaseUrl, supabaseAnonKey)
Code language: JavaScript (javascript)註冊時帶入 metadata
註冊時可以帶入額外的 metadata,這些資料會存在 raw_user_meta_data,並被 trigger 讀取:
async function signUp(email, password, fullName) {
const { data, error } = await supabase.auth.signUp({
email: email,
password: password,
options: {
data: {
full_name: fullName,
avatar_url: null,
},
},
})
if (error) {
console.error('註冊失敗:', error.message)
return null
}
return data
}
Code language: JavaScript (javascript)取得個人資料
async function getProfile() {
// 先取得當前使用者
const { data: { user } } = await supabase.auth.getUser()
if (!user) {
console.error('使用者未登入')
return null
}
// 查詢 profiles 表
const { data, error } = await supabase
.from('profiles')
.select('username, full_name, avatar_url, website, updated_at')
.eq('id', user.id)
.single()
if (error) {
console.error('取得 profile 失敗:', error.message)
return null
}
return data
}
Code language: JavaScript (javascript)程式碼解讀
.select('username, full_name, avatar_url, website, updated_at')
Code language: JavaScript (javascript)指定要取得的欄位。
.eq('id', user.id)
Code language: JavaScript (javascript)篩選條件:id 等於當前使用者的 id。
.single()
Code language: CSS (css)預期只會回傳一筆記錄。如果回傳 0 筆或多筆,會拋出錯誤。
更新個人資料
async function updateProfile({ username, fullName, website, avatarUrl }) {
const { data: { user } } = await supabase.auth.getUser()
if (!user) {
console.error('使用者未登入')
return { error: '使用者未登入' }
}
const updates = {
id: user.id,
username: username,
full_name: fullName,
website: website,
avatar_url: avatarUrl,
updated_at: new Date().toISOString(),
}
const { data, error } = await supabase
.from('profiles')
.upsert(updates)
.select()
if (error) {
console.error('更新 profile 失敗:', error.message)
return { error: error.message }
}
return { data }
}
Code language: JavaScript (javascript)upsert 的作用
.upsert(updates)
Code language: CSS (css)upsert 是 “update or insert” 的縮寫。如果記錄存在就更新,不存在就新增。這是處理 profile 更新的常見模式。
React 元件範例
import { useState, useEffect } from 'react'
import { supabase } from './supabaseClient'
export default function Profile() {
const [loading, setLoading] = useState(true)
const [username, setUsername] = useState('')
const [fullName, setFullName] = useState('')
const [website, setWebsite] = useState('')
const [avatarUrl, setAvatarUrl] = useState('')
useEffect(() => {
loadProfile()
}, [])
async function loadProfile() {
setLoading(true)
const { data: { user } } = await supabase.auth.getUser()
if (user) {
const { data } = await supabase
.from('profiles')
.select('username, full_name, website, avatar_url')
.eq('id', user.id)
.single()
if (data) {
setUsername(data.username || '')
setFullName(data.full_name || '')
setWebsite(data.website || '')
setAvatarUrl(data.avatar_url || '')
}
}
setLoading(false)
}
async function handleSubmit(e) {
e.preventDefault()
setLoading(true)
const { data: { user } } = await supabase.auth.getUser()
const { error } = await supabase.from('profiles').upsert({
id: user.id,
username,
full_name: fullName,
website,
avatar_url: avatarUrl,
updated_at: new Date().toISOString(),
})
if (error) {
alert('更新失敗: ' + error.message)
} else {
alert('更新成功!')
}
setLoading(false)
}
return (
<form onSubmit={handleSubmit}>
<div>
<label>使用者名稱</label>
<input
type="text"
value={username}
onChange={(e) => setUsername(e.target.value)}
/>
</div>
<div>
<label>顯示名稱</label>
<input
type="text"
value={fullName}
onChange={(e) => setFullName(e.target.value)}
/>
</div>
<div>
<label>個人網站</label>
<input
type="url"
value={website}
onChange={(e) => setWebsite(e.target.value)}
/>
</div>
<button type="submit" disabled={loading}>
{loading ? '處理中...' : '儲存'}
</button>
</form>
)
}
Code language: JavaScript (javascript)進階延伸
Storage 頭像上傳
Supabase Storage 可以用來存放使用者頭像。基本流程:
async function uploadAvatar(file) {
const { data: { user } } = await supabase.auth.getUser()
// 產生唯一的檔案路徑
const filePath = `${user.id}/${Date.now()}-${file.name}`
// 上傳檔案
const { data, error } = await supabase.storage
.from('avatars')
.upload(filePath, file)
if (error) {
console.error('上傳失敗:', error.message)
return null
}
// 取得公開 URL
const { data: { publicUrl } } = supabase.storage
.from('avatars')
.getPublicUrl(filePath)
// 更新 profile 的 avatar_url
await supabase.from('profiles').upsert({
id: user.id,
avatar_url: publicUrl,
updated_at: new Date().toISOString(),
})
return publicUrl
}
Code language: JavaScript (javascript)記得在 Storage 設定對應的 Policy,讓使用者只能上傳到自己的資料夾。
Realtime 訂閱
如果你需要即時更新 profile 資料(例如多裝置同步),可以使用 Realtime:
// 訂閱 profiles 表的變更
const channel = supabase
.channel('profile-changes')
.on(
'postgres_changes',
{
event: '*',
schema: 'public',
table: 'profiles',
filter: `id=eq.${userId}`,
},
(payload) => {
console.log('Profile 更新了:', payload.new)
// 更新 UI
}
)
.subscribe()
// 取消訂閱
// channel.unsubscribe()
Code language: JavaScript (javascript)常見問題與解決方案
Trigger 失敗導致無法註冊
如果 trigger function 有錯誤,會導致整個註冊流程失敗。建議:
- 在部署前徹底測試 trigger function
- 使用
try-catch包裝邏輯(雖然 PL/pgSQL 語法不同) - 確保
security definer設定正確
RLS 導致查詢沒有結果
確認:
- Policy 是否正確設定
to authenticated - 使用者是否已登入
auth.uid()是否正確對應到 profile 的 id
欄位驗證
RLS 無法限制特定欄位的更新。如果需要防止使用者修改 id 或其他欄位,可以使用 trigger:
create function public.protect_profile_id()
returns trigger
language plpgsql
as $$
begin
if new.id != old.id then
raise exception 'Cannot change profile id';
end if;
return new;
end;
$$;
create trigger prevent_id_change
before update on public.profiles
for each row execute procedure public.protect_profile_id();
Code language: JavaScript (javascript)系統架構總覽
讓我們用一張圖來總結整個系統:
┌─────────────────┐
│ 前端應用程式 │
└────────┬────────┘
│
v
┌─────────────────┐
│ Supabase Client │
└────────┬────────┘
│
┌────────────────────┼────────────────────┐
│ │ │
v v v
┌──────────┐ ┌──────────┐ ┌──────────┐
│ Auth │ │ Database │ │ Storage │
│ (認證) │ │ (資料庫) │ │ (檔案) │
└────┬─────┘ └────┬─────┘ └──────────┘
│ │
│ ┌────────────────┴────────────────┐
│ │ │
v v v
┌──────────────┐ ┌─────────────┐
│ auth.users │ │ profiles │
│ (系統表) │───── Trigger ────>│ (自訂表) │
└──────────────┘ └─────────────┘
│
v
┌──────────┐
│ RLS │
│ Policies │
└──────────┘
Code language: CSS (css)重點回顧
- profiles 表設計:使用 UUID 作為主鍵,透過外鍵關聯到 auth.users,設定
on delete cascade - Database Trigger:使用
security definer讓 function 有權限寫入 public schema,在使用者註冊時自動建立 profile - RLS Policy:使用
auth.uid() = id確保使用者只能存取自己的資料 - 前端整合:使用
getUser()取得當前使用者,使用upsert更新 profile - 進階功能:Storage 頭像上傳、Realtime 即時訂閱
系列總結
恭喜你完成了整個 Supabase 教學系列!讓我們回顧一下學到的內容:
- #01:認識 Supabase 與專案設定
- #02:資料庫操作 CRUD
- #03:身份驗證 (Authentication)
- #04:Row Level Security
- #05:整合實戰 – 會員資料系統
現在你已經具備了使用 Supabase 開發完整應用程式的能力。接下來可以繼續探索:
- Supabase Edge Functions(Serverless Functions)
- Supabase Vector(AI/向量搜尋)
- 更複雜的 RLS 設計(多角色、組織架構)
祝你開發順利!
參考資源
- Supabase User Management Documentation
- Supabase Row Level Security
- Build a User Management App with React
進階測驗:Supabase 整合實戰 – 打造完整的會員資料系統
共 5 題,包含情境題與錯誤診斷題。
1. 你正在建立一個會員系統,需要讓使用者能夠更新自己的 profile,但不能修改其他人的資料。你應該如何設計 RLS Policy? 情境題
2. 團隊成員在測試註冊功能時,發現新使用者註冊後 profiles 表沒有自動建立記錄。以下是 trigger function 的程式碼:錯誤診斷
最可能的問題是什麼?
3. 你需要在使用者註冊時將他們輸入的暱稱存入 profiles 表。註冊表單要如何傳遞這個資料? 情境題
4. 使用者回報說他可以讀取自己的 profile,但無法更新。你檢查了 RLS Policy 如下:錯誤診斷
問題出在哪裡?