K
Kouwua Studio
首頁 / 實驗室筆記 / 刷題神器的資料層設計:題庫保護與使用者進度的兩套策略…
科技

刷題神器的資料層設計:題庫保護與使用者進度的兩套策略

題庫要防止整批被爬走,使用者進度要能跨裝置同步,買斷紀錄要在付款後可靠地寫入——這三件事用了三種不同的策略,背後的設計思路值得說清楚。

2026年6月1日
SupabasePostgreSQL資安架構設計Next.js

在開發刷題神器時,資料層要解決三個問題:

  1. 題庫保護:640 道整理好的題目,不能讓人直接爬走
  2. 進度同步:登入後要能跨裝置繼續刷題
  3. 買斷紀錄:付款成功後,要確保「無廣告」狀態可靠地被記下來

這三件事用了三種不同的策略。

題庫:不放進 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 紀錄

這樣不管使用者關掉瀏覽器、換裝置,只要金流那邊確認付款成功,後端都能正確記下是誰買了哪個題庫。

← 回實驗室筆記