第七章:PostgreSQL 極簡資料庫設計 - 權限與訂單的核心樞紐
在開發初期的系統架構設計時,很多新手工程師(甚至是有些資歷的後端工程師)在設計電商或課程資料庫時,很容易陷入「過度設計 (Over-engineering)」的陷阱。
他們會打開關聯圖軟體,畫出錯綜複雜的架構:
建立一個 Users 表,再建立一個 Courses 表,接著為了記錄買了什麼,建立一個 Orders 表,然後還需要 Order_Items 表來記錄明細,最後再加一個 Transactions 表來對帳金流... 弄出一堆複雜的 Foreign Keys (外鍵) 與 JOIN 查詢。
但對於一個「純知識付費、賣虛擬商品、沒有庫存問題、沒有物流運送」的 SaaS 平台來說,這種龐大的傳統電商設計不僅開發起來極度痛苦、日後維護成本極高,而且在 99% 的場景下毫無必要。
在 Vibe Tutor 的系統架構中,我們秉持「Minimal Viable Product (MVP,最小可行性產品)」的精神。我們把整個核心的商業權限邏輯,極度壓縮,最後只需要一張表。
🧊 The One Table 哲學:vt_purchases
在 Supabase 的 PostgreSQL 資料庫中,我們建立了一張名為 vt_purchases (購買紀錄) 的資料表。
這張表的欄位非常精簡,但它完美支撐了我們全站所有的「權限解鎖判斷」與「後台銷售統計」。
欄位設計深度解析
| 欄位名稱 | 資料型別 | 說明與商業價值 |
| :--- | :--- | :--- |
| id | uuid (Primary Key) | 每筆購買紀錄的唯一識別碼,確保絕對不重複。 |
| created_at | timestamp with time zone | 購買完成的當下時間,預設為 now()。用來畫每月的營收走勢圖。 |
| user_id | uuid (Foreign Key) | 買家的 ID。這個欄位會安全地關聯到 Supabase 內建的高資安 auth.users 認證表。 |
| item_id | text | (核心靈魂) 購買的商品代號,例如 "coding-101"。程式靠這組字串來決定給什麼權限。 |
| item_type | text | 商品的類型。例如 course (單堂課程)、bundle (大禮包)。方便未來擴充販售實體商品。 |
| price | integer | 實際成交金額。這很重要,因為有些客戶可能是用早鳥優惠價買的,我們必須記錄當下的真實付款金額,以便財務對帳。 |
💡 核心靈魂:item_id 權限判斷的藝術
我們將所有的「權限鎖定邏輯」都壓縮到了 item_id 這個純文字字串中。這讓我們省去了建立龐大「課程表」與「訂單明細表」關聯的麻煩。
回顧我們在結帳時,是怎麼設定 item_id 的?
- 買 99 元入門課:寫入
item_id = "coding-101"。這代表該用戶解鎖了這堂課的文字內容。 - 買 1999 元進階地圖課:寫入
item_id = "car-camping-map"。同樣解鎖該課程的文字內容。 - 買 3999 元含原始碼方案:寫入
item_id = "car-camping-map-source"。在前端程式碼中,我們判斷如果擁有帶有-source結尾的 ID,就同時解鎖文字,並顯示該專案的 ZIP 下載按鈕。 - 買 9999 元終極 VIP 通行證:寫入
item_id = "vip-all-access"。當程式看到這個 ID 時,猶如看到古代的「免死金牌」,直接return true,全站所有課程與下載區全面無條件放行!
這種扁平化的設計,讓資料庫的查詢速度快如閃電,程式碼也極度乾淨:
// 🚀 一次性查出使用者擁有哪些東西,不需要 JOIN 任何複雜的表!
const { data: purchases } = await supabase
.from("vt_purchases")
.select("item_id")
.eq("user_id", user.id);
// 提取成字串陣列:['coding-101', 'car-camping-map']
const purchasedItemIds = purchases.map(p => p.item_id);
// 判斷是否可以看某堂課
const canAccess = purchasedItemIds.includes(currentCourseId) || purchasedItemIds.includes("vip-all-access");
🛡️ RLS (Row Level Security) 資料列級別安全性防禦
使用 Supabase 這種 Backend-as-a-Service (BaaS) 雖然開發神速,但也衍生出一個新手常犯的致命資安問題: 「如果 Supabase 的 API Key 是公開在前端的,那懂點程式的駭客,不就可以自己在瀏覽器 Console 裡面打 API 寫入資料庫,給自己塞幾百堂免錢的課?」
為了解決這個可怕的問題,PostgreSQL 內建了業界最強的 RLS (Row Level Security) 機制。
對於 vt_purchases 這張關乎公司營收的命脈表,我們設定了極其嚴苛的 RLS 政策 (Policies):
-
Select (讀取) 政策:
-- SQL 政策規則 (auth.uid() = user_id)商業防護意義:任何人都只能讀取
user_id等於自己登入 UID 的資料。你絕對看不到別人的購買紀錄,徹底防止競爭對手爬取你的客戶名單與銷量,符合嚴格的個資法規範。 -
Insert/Update/Delete (寫入與修改) 政策: 全部關閉 (禁止)! 這代表:前端的 JavaScript 程式碼,無論駭客怎麼竄改,都「完全沒有」寫入與修改這張表的權限。
❓ 那正常的訂單要怎麼寫入?
既然前端不能寫入資料,那我們怎麼在使用者付完錢後,把紀錄存進資料庫? 答案是透過後端伺服器的 Service Role Key (無敵超級鑰匙)。
我們在伺服器端(Next.js 的 /api Route Handlers 裡),使用的是具有最高系統權限的 Supabase Client,並攜帶存在 .env 裡的 SERVICE_ROLE_KEY。這把鑰匙能無視所有的 RLS 政策,直接對資料庫進行強勢寫入。
當綠界科技 (ECPay) 的伺服器呼叫我們的背景 Webhook (通知付款成功) 時,這件事是發生在「伺服器與伺服器之間」的安全通道。我們的後端 API 驗證無誤後,就會拔出這把超級鑰匙,將資料安全地寫入 vt_purchases 表中。
這種「前端只能查自己的資料,後端才能寫入核心資料」的設計,構成了知識付費平台堅若磐石的最後一道防線!
✅ 本章小結
最好的架構,往往是最簡單的架構。透過 One Table 哲學與強大的 RLS 防禦,我們打造了一個高擴充、高資安、零維護成本的資料庫層。 準備好迎接大魔王了嗎?下一章,我們就要來面對台灣電商最棘手、也最能讓你賺錢的關卡:綠界科技 ECPay 金流 API 串接與驗證!