刷題神器的資料層設計:題庫保護與使用者進度的兩套策略
題庫要防止整批被爬走,使用者進度要能跨裝置同步,買斷紀錄要在付款後可靠地寫入——這三件事用了三種不同的策略,背後的設計思路值得說清楚。
在開發刷題神器時,資料層要解決三個問題:
- 題庫保護:640 道整理好的題目,不能讓人直接爬走
- 進度同步:登入後要能跨裝置繼續刷題
- 買斷紀錄:付款成功後,要確保「無廣告」狀態可靠地被記下來
這三件事用了三種不同的策略。
題庫:不放進 Supabase,用 API Route 保護
最直覺的想法是把題庫放進 Supabase,用 RLS 控制誰能看幾題。但我們最後選擇了另一條路:題庫以 JSON 格式存放在伺服器端,不在 public/ 資料夾。
data/
└── ipas-ise-middle/
├── questions.json ← 640 題,不在 public/,無法直接下載
└── meta.json
題庫只能透過 Next.js API Route 取得:
GET /api/decks/{slug}
這個 API Route 在伺服器端執行,可以檢查登入狀態、計算試用天數、決定回傳多少題——完全在我們的掌控下。即使有人拿到 Supabase 的 anon key,也找不到題庫,因為題庫根本不在 Supabase 裡。
使用者進度:Supabase RLS 保護個人資料
使用者的刷題進度(哪些題答錯、哪些已熟練)存在 Supabase 的 user_progress 表。這裡才是 RLS 發揮作用的地方:
-- 每個人只能讀寫自己的進度
create policy "Users can read own progress"
on public.user_progress for select
using (auth.uid() = user_id);
create policy "Users can insert own progress"
on public.user_progress for insert
with check (auth.uid() = user_id);
create policy "Users can update own progress"
on public.user_progress for update
using (auth.uid() = user_id);
auth.uid() 是 Supabase JWT token 裡的用戶 ID,每次資料庫查詢都會自動帶入。你的進度只有你能讀到,不需要在應用程式層特別過濾。
沉沒成本設計
進入試用期結束的「user_free」狀態後,有一個刻意的非對稱設計:
- 答題仍然持續寫入雲端(你繼續累積錯題)
- 但讀取被鎖定(看不到累積的錯題分析)
使用者答錯 → POST /api/progress → 伺服器不檢查試用 → 寫入 user_progress
↓
使用者想看弱點分析 → GET /api/progress → 伺服器算 access → locked: true
↓
「升級解鎖 N 道弱點題目」
使用者知道自己的弱點資料在那裡,只是看不到——這比直接擋住功能更能引發行動。
買斷紀錄:service_role 寫入,RLS 保護讀取
付款走的是藍新金流(NewebPay),付款完成後藍新會用 POST 回呼(webhook)通知我們的伺服器。這裡有一個設計問題:webhook 是後端對後端,不是使用者的瀏覽器發出的請求,所以 Supabase 拿不到使用者的 JWT token。
解法:webhook 使用 service_role key 寫入,這個 key 可以繞過 RLS:
-- user_purchases:authenticated 只能讀,不能寫
-- 寫入由 service_role(webhook)執行
create policy "Users can read own purchases"
on public.user_purchases for select
using (auth.uid() = user_id);
grant select on public.user_purchases to authenticated;
grant select, insert, delete on public.user_purchases to service_role;
使用者的瀏覽器只能讀自己的購買紀錄,不能新增或修改。買斷紀錄只有金流 webhook 確認付款後才會出現。
service_role key 是高權限的,我們確保它只存在 Vercel 的環境變數裡,絕對不出現在前端程式碼。
買斷流程的「訂單橋接」
Webhook 的一個挑戰是:藍新回呼時,你只知道訂單編號,不知道是哪個使用者付的款。
解法是在發起付款前,先把「使用者 ID + 題庫 slug」存進一張暫存表 pending_checkouts,以訂單編號為 key:
使用者按「升級」
→ 後端生成 20 字元訂單號 → 寫入 pending_checkouts { ref, user_id, slug }
→ form POST 到藍新金流
↓
藍新完成付款 → webhook POST 到 /api/webhooks/newebpay
→ 驗簽解密 → 用訂單號查 pending_checkouts → 找到 user_id
→ 寫入 user_purchases { user_id, slug, feature: 'no-ads' }
→ 刪除 pending_checkouts 紀錄
這樣不管使用者關掉瀏覽器、換裝置,只要金流那邊確認付款成功,後端都能正確記下是誰買了哪個題庫。