【Supabase 教學】#05 整合實戰:打造完整的會員資料系統

測驗:Supabase 整合實戰 – 打造完整的會員資料系統

共 5 題,點選答案後會立即顯示結果

1. 為什麼需要建立獨立的 profiles 表,而不是直接使用 auth.users 表?

  • A. auth.users 表的效能較差,無法處理大量查詢
  • B. auth.users 無法透過 API 直接存取,且欄位固定無法自訂
  • C. auth.users 表只能存放密碼,不能存放其他資料
  • D. Supabase 規定每個專案必須建立 profiles 表

2. 在 profiles 表的主鍵設計中,on delete cascade 的作用是什麼?

  • A. 當 profiles 記錄被刪除時,同步刪除 auth.users 記錄
  • B. 防止任何人刪除 profiles 或 auth.users 的記錄
  • C. 當 auth.users 記錄被刪除時,自動刪除對應的 profiles 記錄
  • D. 允許使用者同時刪除多筆 profiles 記錄

3. 在 Database Trigger 中使用 security definer 的目的是什麼?

  • A. 讓 function 以建立者的權限執行,可寫入 public schema
  • B. 加強安全性,防止未授權的使用者觸發 trigger
  • C. 限制只有管理員才能修改 trigger function
  • D. 自動加密 trigger 處理的所有資料

4. 在 RLS Policy 中,usingwith check 的差異是什麼?

  • A. using 用於 SELECT,with check 用於 INSERT
  • B. using 驗證使用者身份,with check 驗證資料格式
  • C. 兩者功能相同,只是語法不同
  • D. using 決定哪些記錄可被選中,with check 確保更新後的資料符合條件

5. 使用 Supabase Client 的 upsert 方法有什麼作用?

  • A. 強制更新記錄,如果記錄不存在會拋出錯誤
  • B. 如果記錄存在就更新,不存在就新增
  • C. 同時執行多筆更新操作的批次處理
  • D. 在更新前先備份原有資料

前言

經過前四篇的學習,你已經掌握了 Supabase 的核心功能:資料庫操作、身份驗證、以及 Row Level Security。現在是時候把這些知識整合起來,打造一個完整的會員資料系統了。

這篇文章將帶你從需求分析開始,一步步建立 profiles 表、設定自動化的 Database Trigger、配置 RLS Policy,最後整合前端程式碼,完成一個可以實際運作的會員系統。

學習目標

讀完本篇後,你將能夠:

  • 整合 Auth + Database + RLS 建立完整功能
  • 設計 profiles 表與 auth.users 的關聯
  • 使用 Database Trigger 自動建立使用者資料
  • 實作會員資料的顯示與更新功能

專案需求分析

在開始寫程式碼之前,讓我們先思考一個會員系統需要什麼。

為什麼需要 profiles 表?

Supabase 的 auth.users 表是由系統管理的,存放在 auth schema 中,有幾個限制:

  1. 無法直接透過 API 存取auth.users 不會被 PostgREST 暴露出來
  2. 欄位固定:你無法隨意新增自訂欄位
  3. 由 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
    |
    vprofiles 表新增對應記錄
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 有錯誤,會導致整個註冊流程失敗。建議:

  1. 在部署前徹底測試 trigger function
  2. 使用 try-catch 包裝邏輯(雖然 PL/pgSQL 語法不同)
  3. 確保 security definer 設定正確

RLS 導致查詢沒有結果

確認:

  1. Policy 是否正確設定 to authenticated
  2. 使用者是否已登入
  3. 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)

重點回顧

  1. profiles 表設計:使用 UUID 作為主鍵,透過外鍵關聯到 auth.users,設定 on delete cascade
  2. Database Trigger:使用 security definer 讓 function 有權限寫入 public schema,在使用者註冊時自動建立 profile
  3. RLS Policy:使用 auth.uid() = id 確保使用者只能存取自己的資料
  4. 前端整合:使用 getUser() 取得當前使用者,使用 upsert 更新 profile
  5. 進階功能: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 整合實戰 – 打造完整的會員資料系統

測驗目標:驗證你是否能在實際情境中應用所學。
共 5 題,包含情境題與錯誤診斷題。

1. 你正在建立一個會員系統,需要讓使用者能夠更新自己的 profile,但不能修改其他人的資料。你應該如何設計 RLS Policy? 情境題

  • A. 只設定 using (auth.uid() = id)
  • B. 只設定 with check (auth.uid() = id)
  • C. 同時設定 using (auth.uid() = id)with check (auth.uid() = id)
  • D. 設定 using (true) 搭配前端驗證

2. 團隊成員在測試註冊功能時,發現新使用者註冊後 profiles 表沒有自動建立記錄。以下是 trigger function 的程式碼:錯誤診斷

create function public.handle_new_user() returns trigger language plpgsql as $$ begin insert into public.profiles (id, full_name) values ( new.id, new.raw_user_meta_data ->> ‘full_name’ ); return new; end; $$;

最可能的問題是什麼?

  • A. new.id 語法錯誤,應該用 NEW.id
  • B. 缺少 security definer,supabase_auth_admin 沒有權限寫入 public schema
  • C. ->> 運算子無法用於 JSONB 欄位
  • D. 沒有設定 return new,導致 trigger 失敗

3. 你需要在使用者註冊時將他們輸入的暱稱存入 profiles 表。註冊表單要如何傳遞這個資料? 情境題

  • A. 註冊成功後,另外呼叫 API 將資料存入 profiles
  • B. 直接在前端修改 auth.users 表的欄位
  • C. 將暱稱放在 email 欄位的註解中
  • D. 使用 signUpoptions.data 傳入,讓 trigger 從 raw_user_meta_data 讀取

4. 使用者回報說他可以讀取自己的 profile,但無法更新。你檢查了 RLS Policy 如下:錯誤診斷

— SELECT Policy create policy “Users can view own profile” on public.profiles for select to authenticated using (auth.uid() = id); — UPDATE Policy create policy “Users can update own profile” on public.profiles for update to anon using (auth.uid() = id) with check (auth.uid() = id);

問題出在哪裡?

  • A. UPDATE Policy 的 to anon 應該改為 to authenticated
  • B. UPDATE Policy 不應該同時有 usingwith check
  • C. SELECT Policy 沒有設定 with check
  • D. 兩個 Policy 的名稱不能有空格

5. 你發現有惡意使用者透過 API 嘗試把自己的 profile id 改成別人的 id,想要冒充其他使用者。如何防止這種攻擊? 情境題

  • A. 使用 RLS Policy 的 using 子句限制存取
  • B. 在前端驗證使用者輸入的 id
  • C. 建立 BEFORE UPDATE trigger,檢查 new.id != old.id 時拋出錯誤
  • D. 將 id 欄位設為 nullable,阻止修改

發佈留言

發佈留言必須填寫的電子郵件地址不會公開。 必填欄位標示為 *