Day 17 做好回報功能之後,每一則回報都會透過 Telegram 通知我。
一開始很方便,手機一響就知道有人回報。但後續如果回報一多,問題就來了:哪些已經修正?哪些還沒看?哪些是重複的?Telegram 只會一直響,卻沒有地方能記錄處理進度。想知道一筆回報處理到哪裡,只能自己另外記,或是直接進資料庫修改。
漂流的時候,Pi 每天都在看海平面,留意遠方有沒有船、天氣會不會變。網站也需要一個這樣的瞭望台:一眼就能看見所有回報,並更新處理進度。所以今天要來做管理員後台。
今天用到的觀念,大部分前幾天都出現過:Day 16 的繞過畫面送出請求、Day 17 的不信任輸入、Day 18 的身分、Day 19 的 RLS、Day 20 的渲染。
不同的是,Day 19 的規則是「每個人只能碰自己的」;今天第一次出現「有一種人,可以看見所有人送來的資料」。這種權限要怎麼給、給到哪裡為止,是今天的新問題。
這也是這個系列想做的事:比起照著步驟做出某個功能,更想把網站背後的底層邏輯拆清楚。功能會一直變,但這些觀念會不斷被組合使用。
我先請 Codex 列出回報管理後台可以有哪些功能。它列得很完整:指派處理人員、多人協作、內部留言、通知中心、權限分級……
但目前只有我一個管理員。我需要的是一個能追蹤回報的地方,不是一套企業級的客服工單系統。所以目前先只做:
| 功能 | 說明 |
|---|---|
| 回報列表 | 時間、專輯、版本、回報內容、目前狀態 |
| 狀態 | 未處理 → 處理中 → 已修正/不處理 |
| 備註 | 寫給自己看的處理紀錄 |
| 結案資訊 | 最後的處理者、結案時間 |
有趣的是,Codex 盤點資料表時發現 Day 17 建立回報表時,就已經設計好這四種狀態,連「已修正和不處理一定要有結案時間」的規則都在。這次只需要再加上備註和處理者兩個欄位。
要注意的是,這只記錄每筆回報目前的狀態和最後的結案資訊,不是完整的操作歷史。如果一筆回報被改了好幾次狀態,中間的過程不會留下來。我的想法是目前只有一位管理員,這樣暫時就夠了;等真的需要多人協作,或是發現過程是有必要記錄下來的話,可以再考慮另外建立一張異動紀錄表。
/admin 就安全了嗎?最直覺的做法,是新增一個 /admin 頁面,然後不要把連結放在導覽列上。但 Day 16 我們就看過:網址藏起來,不代表別人猜不到;就算猜不到,只要知道 Data API 網址和公開金鑰,也可以繞過網站直接發請求。
所以後台需要三道門:
| 做什麼 | 擋住誰 | |
|---|---|---|
| 第一道:路由 | 不是管理員,打開 /admin 就顯示 404 |
一般使用者誤闖(這是呈現方式,不是保護) |
| 第二道:伺服器 | 每次讀取或修改回報時,在伺服器確認登入和管理員身分 | 直接打開後台網址、偽造表單請求的人 |
| 第三道:資料庫 | 就算有人不經過網站,資料庫也只讓管理員讀回報、只讓管理員用指定的方式修改 | 繞過網站、直接打 API 的人 |

【圖1|三道門防禦】
第一道主要是呈現方式;第二道在應用層確認這個請求有沒有資格執行;第三道則是資料層最後的防線。即使前面的程式寫錯,資料庫仍然不應該放行。這也是 Day 18 那句話:前端擋不住,後端才是真的門;而資料庫,是最後一道門。
每一道門都假設前一道可能失守,這種做法叫做縱深防禦(Defense in Depth)。
為什麼顯示 404,而不是「沒有權限」?
「沒有權限」等於告訴對方:這裡有東西,只是你進不來。
404 則是選擇不公開後台的存在,對一般使用者來說,後台本來就不需要出現。但這只是呈現方式,不是額外的保護。真正擋住資料的,還是後面兩道門。
網站要怎麼知道誰是管理員?常見的做法有三種:
| 做法 | 怎麼判斷 | 優點 | 限制 |
|---|---|---|---|
| 環境變數寫死 | 伺服器比對 ADMIN_EMAIL |
最簡單 | 資料庫看不到環境變數,只能靠程式擋;換人要重新部署 |
| 管理員資料表 | 建一張 admins 表,資料庫檢查目前使用者在不在裡面 |
規則寫在資料庫,繞過網站也擋得住;改了立刻生效 | 管理員表本身也要上鎖 |
| 寫進登入通行證 | 在使用者的 app_metadata 標上管理員角色 |
不用每次查表 | 改了角色,要等通行證更新才生效 |
這次選擇管理員資料表。規則寫在資料庫裡,而且新增或移除管理員,下一次請求就生效。
user_metadata不能拿來判斷權限Supabase 的使用者有兩種額外資料:
user_metadata和app_metadata。user_metadata是使用者自己就改得到的,例如暱稱、大頭貼。如果用它來判斷誰是管理員,任何人都能把自己升級成管理員。審 AI 寫的權限規則時,這是值得特別看一眼的地方。
管理員表建議不要讓網站上的任何人新增,最好連管理員自己也不行。否則只要找到一個漏洞,就能把自己加進去。那第一個管理員怎麼來?答案是:不經過網站。由我在 Supabase 後台的 SQL 編輯器(SQL Editor)手動加入自己。
insert into public.admins (user_id)
values ('我的使用者 id');
網站上,沒有任何一個按鈕可以讓人變成管理員。
如果不只一種管理員呢?
admins表只回答一個問題:你是不是管理員。如果未來有不同分工,例如有人只能處理回報、有人可以修改專輯資料,就需要把「角色」和「權限」分開設計,這叫做角色權限控管(RBAC,Role-Based Access Control)。專案中目前只有一位管理員,一張表就夠了。等真的出現第二種分工,再升級也不遲。
Day 19 的規則是「每個人只能碰自己的收藏」。今天的規則是:
| 身分 | 讀取回報 | 修改回報 | 刪除回報 |
|---|---|---|---|
| 訪客、一般使用者 | 不行 | 不行 | 不行 |
| 管理員 | 可以讀全部 | 只能改狀態和備註 | 暫定不行 |
先寫一個判斷函式,讀取規則和修改函式,都用它來判斷是不是管理員:
create function public.is_admin()
returns boolean
language sql
stable
security definer
set search_path = ''
as $$
select exists (
select 1 from public.admins
where user_id = (select auth.uid())
);
$$;
create policy "Admins can read data reports"
on public.data_reports for select to authenticated
using ((select public.is_admin()));
security definer 的意思是:這個函式用建立者的權限去查 admins 表。之所以需要這樣,是因為一般使用者本來就沒有權限直接讀 admins 表,但 RLS 又需要知道「你是不是管理員」。
RLS 決定管理員能讀哪幾筆回報。但回報裡有一個欄位,是 Day 17 用來限流的訪客指紋,後台用不到,也不該讓管理員看到。RLS 是以「列」為單位,管不到同一筆資料裡的哪幾個欄位。
這時候要用另一道鎖:欄位層級的權限。
-- 只能讀這些欄位(不包含訪客指紋)
grant select (id, album_id, version_id, message, status,
created_at, resolved_at, admin_note, handled_by)
on public.data_reports to authenticated;
| 鎖 | 管什麼 |
|---|---|
| RLS | 能碰哪幾列(哪幾筆回報) |
| 欄位權限 | 能碰哪幾欄(回報裡的哪些欄位) |
管理員要能改狀態和備註;處理者和結案時間,我希望由系統自動記錄,不讓人隨意填。(也對處理者比較省事)
如果只在 Server Action 做限制,會有一個缺口:管理員手上有自己的通行證,只要資料表開放他修改這些欄位,他就能不經過 Server Action,直接呼叫 Data API 填入任意值。
所以這次我選擇讓管理員沒有資料表的修改權限,只能透過一個資料庫函式修改。這個函式只接受三個值:哪一筆、新狀態、備註;處理者和結案時間,由資料庫自動填入。
| 限制放在哪裡 | 能擋住什麼 |
|---|---|
| 前端、Server Action | 一般操作、偽造的表單請求 |
| 資料庫函式 | 不走網站流程、直接打 Data API 的人 |
這不是唯一的做法。如果處理者可以讓人自己填,只用欄位權限就夠了;如果是傳統的網站架構,資料庫本來就不對外開放,由後端程式把關也很常見。選哪一種,要看使用者能不能直接碰到資料庫,以及哪些欄位不能讓人亂填。
資料表上鎖了,函式呢?
PostgreSQL 函式本身也有 EXECUTE 權限,而且新函式通常會帶有對
PUBLIC的執行權限,所以不能只鎖資料表,還要另外確認誰能呼叫這個函式。
所以這個函式只開放給登入的使用者,函式裡再檢查一次is_admin()。因為登入的使用者,不等於管理員。
刪除則是一條規則都沒寫、一個權限都沒給,這樣下來,一般網站角色不管從網站還是 Data API,都沒有刪除回報的能力。
在這樣的實作下,管理員並不是擁有一切權限的人,而是被明確授予特定操作能力的人。只給完成工作所需的權限,這叫做最小權限原則(Principle of Least Privilege),管理員也一樣適用。
Day 17 送出回報時,我們選擇由伺服器先驗證內容、限流,再用 secret key 寫入。這是當時的設計選擇:訪客沒有登入,由伺服器統一把關。
那後台讀回報,也用 secret key 不是比較簡單嗎?
| 用管理員自己的通行證 | 用 secret key | |
|---|---|---|
| RLS | 每次都會檢查 | 直接略過 |
| 程式寫錯時 | 資料庫還會擋 | 可能把全部回報交給錯的人 |
| 資料庫知道是誰操作 | 知道 | 只知道是「伺服器」 |
這次選擇用管理員自己的通行證。多一層保護,就算後台程式哪裡寫錯了,資料庫也不會把回報交給不是管理員的人。
secret key 繼續只用在原本的地方:接收訪客回報、更新 Telegram 通知狀態。
Day 20 學了幾種渲染方式,今天直接拿來用:
| 部分 | 在哪裡做 | 原因 |
|---|---|---|
| 確認管理員身分 | 伺服器 | 不能交給瀏覽器判斷 |
| 取得回報列表 | 伺服器 | 確認身分後才拿資料 |
| 依狀態篩選 | 瀏覽器 | 回報不多,用 Day 20 的方法 A 就好 |
| 修改狀態和備註 | 伺服器(Server Action)呼叫資料庫函式 | 處理者和結案時間由資料庫填入,不相信任何傳進來的值 |
這次選擇在伺服器確認管理員身分、取得回報。後台內容包含受保護資料,不適合做成可公開共用的快取內容。
後台也有自己的版面(app/admin/layout.tsx),負責共同介面和初步的身分檢查,不會影響首頁、專輯頁這些公開頁面。
不過,layout 不能是唯一的檢查。Next.js 在站內換頁時,可能會重複使用同一個 layout,不一定每次都重新執行。所以真正讀取回報的頁面、修改狀態的 Server Action 都會各自確認身分,最後資料庫還會再檢查一次。不能因為某個人曾經進入後台,就認定他之後的每一個請求都有管理權限。
等回報變多,再照 Day 20 的判斷流程,改成分頁加上資料庫查詢。
回報內容,要當成不可信任的資料
回報是訪客寫的,裡面可能放了任何東西,包括 HTML 或程式碼。React 預設會把文字當成純文字顯示,但只要使用了
dangerouslySetInnerHTML這類寫法,就可能讓別人的程式在管理員的瀏覽器裡執行。Day 17 驗證「送進來的資料能不能信」,今天則是「顯示出來的資料能不能信」。
經過與 AI 的多輪溝通與實作,成功實作了管理員後台(目前管理員後台只先處理回報問題,新增/編輯專輯資料之後再加)

【圖2|以管理員身份登入時,頂部 Nav 自動顯示回報管理入口】

【圖3|可成功進入管理員頁面】

【圖4|非管理員無法進入管理員頁面】
Day 19 時,我們防止 A 看到 B 的收藏;今天,實作完後則是要確認一般使用者沒有辦法進入管理員頁面。
所以驗收要分四種身分(未登入訪客、已登入一般使用者、已登入管理員、被移除的管理員),用各自真正的登入狀態測試。在 Supabase 的 SQL 編輯器裡測不準,因為那裡的身分會直接略過 RLS。
| 身分 | 測試 | 預期結果 |
|---|---|---|
| 訪客 | 用 Day 17 的回報表單送出 | 照樣可以送出 |
| 訪客 | 打開 /admin |
404 |
| 一般使用者 | 打開 /admin |
404 |
| 一般使用者 | 繞過網站,直接向 Supabase 讀回報 | 拿不到任何資料 |
| 一般使用者 | 直接修改回報狀態 | 沒有任何一筆被修改 |
| 一般使用者 | 把自己加進 admins |
被拒絕 |
| 一般使用者 | 直接呼叫修改函式 | 被拒絕,回報不變 |
| 管理員 | 打開 /admin |
看到回報列表 |
| 管理員 | 修改狀態和備註,重新整理 | 修改保留下來 |
| 管理員 | 嘗試修改回報的原始內容 | 被拒絕 |
| 管理員 | 繞過 Server Action,直接修改處理者或結案時間 | 被拒絕 |
| 管理員 | 嘗試刪除回報 | 被拒絕 |
| 管理員 | 在 SQL 編輯器把自己從 admins 移除,再重新整理 |
立刻變成 404 |
| 被移除的管理員 | 用原本還沒過期的通行證呼叫修改函式 | 被拒絕,不用等通行證過期 |
| 管理員 | 登出後重新整理 /admin |
404 |
其中最重要的是「一般使用者直接向 Supabase 讀回報」和「直接呼叫修改函式」這兩列:前兩道門擋住的是畫面,這兩列驗證的才是資料。 最後一列則證明第三節說的「改了立刻生效」,不是說說而已。
目前的回報是匿名的,要不要改成登入才能回報?
| 做法 | 優點 | 代價 |
|---|---|---|
| 完全匿名(現在) | 門檻最低,看到錯就能回報 | 無法通知回報者處理結果;要另外設計防濫用措施,目前靠限流 |
| 一定要登入 | 能依帳號限流,也能有「我的回報」 | 門檻變高,只是想回報一個錯字,卻得先登入 |
| 匿名為主,登入者可選擇關聯帳號 | 保留低門檻,也能讓登入者追蹤進度 | 資料設計和隱私處理比較多 |
如果未來要做第三種,可以在回報表加一個可以空白的 user_id,由伺服器根據登入狀態填入,而不是相信前端傳來的 id;登入者也可以透過 RLS,只看到自己的回報。
不過,在決定要不要多存一個欄位之前,可以先問幾個問題:
我們真的需要知道是誰回報的嗎?還是只需要讓回報者知道,這件事處理到哪裡了?
這兩件事不一定要綁在一起。例如送出回報後給一組追蹤碼,回報者不用登入,也能查詢處理進度。不過追蹤碼必須夠難猜,不能用流水號,查詢結果也不能透露回報者的其他資訊。
資料要不要多存一個欄位,不只是工程問題,也是產品和隱私的決定。這次先維持匿名,等真的被濫用,或真的需要通知回報者時,再回來決定。
可以呼應這個系列文中很常提到的一件事:一個功能的實作方法絕對不唯一,取決於你想要做到什麼程度、需要有哪些附加功能。我通常會在與 AI 溝通的過程中去說明我想要的一些功能細節,如此一來答案會比較容易在較少次與 AI 的來回對話中取得。
如果之後想讓管理員也能修改專輯資料,就可以不用從零開始:要開放哪些欄位?用欄位權限,還是資料庫函式?改錯了能不能復原?前面學過的觀念,就是思考這些問題的起點。再搭配 AI 進行規劃,可以讓開發變得更加順暢。
| 產品 | 一般使用者 | 管理者 |
|---|---|---|
| 電商網站 | 只看到自己的訂單和出貨進度 | 看到所有訂單、更新出貨狀態、上架商品 |
| 客服系統 | 只看到自己的工單 | 看到所有工單、可以改狀態 |
| 線上課程 | 只看到自己的作業成績 | 老師看到全班的作業 |
| 社群平台 | 只能檢舉內容 | 版主看到所有檢舉、決定處理方式 |
| 協作文件 | 只能看或留言 | 擁有者決定誰能看、誰能編輯 |
有些產品可能還會有一個特點:管理者可能不只一種,以電商為例:
| 角色 | 可以做的 | 通常不需要的 |
|---|---|---|
| 客服 | 查訂單、更新出貨狀態 | 修改商品價格 |
| 商品管理 | 上架商品、修改價格和庫存 | 看到客人的地址、電話 |
| 財務 | 看營收報表、處理退款 | 修改商品內容 |
這就是第三節提到的角色權限控管(RBAC),也是最小權限原則的實際樣子:每種角色只拿完成工作需要的權限。
今天,網站多了一個能看見全部回報的地方。後台重要的不只是畫面,而是權限的控管:路由讓一般使用者不會誤闖,伺服器確認身分,資料庫做最後的把關。權限不只是讓該進來的人進來,更要讓不該進來的人拿不到資料。
回頭看,今天新學到的其實只有幾件事,其他都是前幾天觀念的組合:
| 新學到的 | 一句話 |
|---|---|
| 欄位權限 | RLS 管列,欄位權限管欄 |
| 資料庫函式 | 修改只走一扇門,函式也要管誰能呼叫 |
| 最小權限 | 管理員也只拿需要的權限 |
| 縱深防禦 | 每一道門,都假設前一道會失守 |
不過,後台現在只靠一組帳號保護。如果管理員的帳號被盜,所有回報都看得到。這件事,我們留到後續與資安相關的文章再來處理。
明天,我們回到使用者這一邊。逛了很多專輯之後,想回頭找剛剛看過的那一張,卻怎麼也想不起名字。網站能不能幫我記住,我剛剛去過哪裡?
而「記住」這件事,其實網站一直都在做,只是我們沒注意到。
我們明天見。