16 / WORK / CASE STUDY
耐人尋味 FoodSelector 美食選擇器
Lumen(PHP)JWT API 加 React + Vite 前端的美食探索站:先把課堂後端的權限繞過、失效的帳號鎖與壞掉的路由修好,再補上一個不需要 PHP 與資料庫就能完整操作的前端。
- TYPE
- FULL STACK
- SCOPE
- 課堂後端 → 全端重構 · 獨立開發
- STACK
- PHP · LUMEN · JWT · MYSQL · REACT · VITE · TAILWIND
- LINKS
01 / PROBLEM
「等一下」「隨便」「都可以」是聚餐時最沒效率的三句話。這個專案把餐廳資料拉到菜色層級(單品、價格、分類、人氣),讓人可以搜尋、篩選,再用一顆「幫我決定」的按鈕直接抽一家或一道。後端是 2024 年的課堂專案——一套 Lumen(PHP)的 JWT API;2026 年這一輪的工作是把它重新整理成能拿出來看的作品:先修掉後端確實壞掉的部分,再補上原本完全不存在的前端。
先把話說清楚:這個「幫我決定」不是推薦系統。整個挑選邏輯是一個 22 行的 RandomController,兩個方法各自 inRandomOrder()->limit(4)——翻成 SQL 就是 ORDER BY RAND() LIMIT 4。沒有個人化、沒有加權、沒有 ML。有趣的工程不在那裡,而在 JWT 認證與帳號鎖、逐路由的 RBAC,以及讓同一份前端能同時對種子資料與真實 API 運作的 adapter。
02 / CONSTRAINTS
- 後端是既有的課堂程式碼:這一輪只修「確定壞掉」的地方(權限繞過、失效的鎖、註解掉的路由、seeder 建不出角色),不重寫架構,也不追加新功能。
- 本機沒有 PHP 與 Composer,後端從頭到尾沒有啟動過。所有後端結論都追溯到原始碼行號;新寫的 PHPUnit 路由測試是「寫好了」而不是「跑過了」,這點如實記錄,不冒充驗證結果。
- 前端要能靜態部署(GitHub Actions → GitHub Pages)並在完全沒有後端的情況下完整操作,所以資料層必須可切換,而不是硬接 API 網址。
03 / ARCHITECTURE
後端:PHP 8.1 / Lumen 10 + Eloquent + tymon/jwt-auth 2,MySQL。routes/web.php 註冊 25 條路由——13 條公開(登入、忘記密碼、分類、店家、商品、搜尋、兩條隨機、照片),12 條掛 check.permission:<action>。RBAC 用 roles / permissions / role_permissions / user_roles 四張表建模,12 個權限字串切成 buyer(收藏三動作)與 seller(店家資訊與商品 CRUD 九動作)。資料層是 5 個 migration 建出的 14 張表。AuthMiddleware 同時是全域中介層(沒帶權限參數就直接放行)與 check.permission 別名的實作。
前端:React 18 + Vite 6 + Tailwind v4,自製的 shadcn 風格元件,24 個 .js/.jsx 檔約 2,300 行、7 條路由(首頁、探索、店家、隨機、口袋名單、登入、404)。核心是一層 data adapter:12 個匯出函式,依 VITE_API_MODE 走 demo(讀本地種子)或 live(fetch Lumen API),兩條分支回傳完全相同的資料形狀。種子檔提供 8 個分類、8 家店、26 道菜、6 則評論;口袋名單以 localStorage 保存。測試是 39 個 Vitest 案例(實跑通過),CI 有兩條 GitHub Actions:測試+建置,以及 demo 模式的 Pages 部署。
04 / RESPONSIBILITIES
整個 repo 的 38 個 commit 都出自我(三組 git 身分同屬一人):2024 年 6 月的 20 個 commit 是課堂期的 Lumen 後端;2026 年 7 月與 8 月的 18 個 commit 是這一輪重構——前端從零建置、後端的權限與資料層修復、前後端測試、CI/Pages 工作流與 README。
05 / CHALLENGES → SOLUTIONS
CHALLENGE
權限中介層有一個靜默的繞過:hasPermission() 在 token 無效時 return response()->json([...], 401),而呼叫端只把它當布林判斷——JsonResponse 物件恆為真,於是「沒帶 token」和「帶壞 token」都會直接通過 12 條受保護路由。
SOLUTION
把認證與授權拆開:先 verifyToken() 取回 private id,失敗直接 401;拿到 id 才進 hasPermission(),它現在只回傳純布林,失敗回 403。並補上路由層的 PHPUnit 測試,逐條斷言那 12 條路由掛著正確的 check.permission:<action>、公開路由則不得帶任何權限中介層——測試已寫好,但因為本機沒有 PHP 而尚未實跑。
CHALLENGE
帳號鎖定看起來做完了,實際上從未生效過:locks.status 是字串欄位,程式卻用 === 1 這個型別嚴格的整數比較,永遠為假;而且 add() 建立新列時根本沒寫入 status。
SOLUTION
改成與 '1' 字串比較,並讓 add() 明確寫入 '0'。修好之後,把真實行為畫成狀態機而不是寫成宣傳詞:因為 checklock() 跑在 attempt() 之前,計數必須已達 5,鎖是在第 6 次嘗試才跳;離開鎖定的唯一路徑是答對兩題安全問題重設密碼,沒有管理員解鎖、沒有 TTL、也沒有排程任務。
CHALLENGE
RBAC 有四張表、有中介層、有權限字串,卻不可能通過:RoleSeeder 其實是 PermissionsSeeder 的複製品,一個角色都沒有建立,也沒有寫入 role_permissions 與 user_roles——所以每一條受權限保護的路由都必然 403。
SOLUTION
重寫 RoleSeeder:建立 buyer / seller 兩個角色,依 action_name 查出 permission id 對映 role_permissions,再指派 user_roles,並補上買家的 member 列;DatabaseSeeder 改成外鍵安全的順序。另外補回一個從來沒有 migration 建立過的 collect 表——收藏功能所依賴的表其實不存在。這些修復目前只有原始碼佐證,沒有實跑遷移與 seeder 的紀錄。
CHALLENGE
要在沒有 PHP、沒有 MySQL 的環境裡把整個產品操作一遍——不論是 GitHub Pages 上的展示,還是自己開發前端時。
SOLUTION
所有資料存取收斂成一層 adapter(12 個匯出函式),以 VITE_API_MODE 切換 demo / live,兩條分支回傳同樣的形狀;demo 分支連後端的怪癖都照抄(/product/info/{id}/ 回傳單元素陣列),日後切回 live 不必動元件。39 個 Vitest 案例大多是在守這層。但 parity 並不完美,而且是已知的:demo 分支會過濾 products.status === 1(種子 26 道菜、上架 25 道,所以畫面顯示「菜色 25」),真實後端的 /search/ 與兩條隨機路由都沒有這個條件;前端向 adapter 要 8 家店 / 12 道菜當候選池,live 模式卻永遠只會拿到後端固定的 4 筆。這些落差列在已知問題,而不是假裝不存在;而且不只這兩處——demo 的搜尋還會比對描述欄,後端則只比對名稱。
06 / SYSTEM FACTS
25
後端路由(12 條需權限)
14
資料表(5 個 migration)
39
前端 Vitest 案例
22
行:整個隨機挑選器
07 / LESSONS
「有寫」不等於「有生效」。權限檢查和帳號鎖在原本的程式碼裡都存在、讀起來也合理,但一個把 JsonResponse 當布林、一個用 === 1 去比字串欄位——兩個安全機制都是靜默失效,沒有任何錯誤訊息。這一輪讓它們現形的不是重讀程式碼,是把路由表寫成斷言、把鎖的生命週期畫成狀態機。
不要把隨機說成推薦。這個產品的賣點是「幫我決定」,實作卻只是 ORDER BY RAND() LIMIT 4。更值得注意的是,系統其實已經在累積瀏覽(look)與收藏(collect)資料,只是那些數字只回流到賣家端的兩條路由,從沒進入買家的挑選路徑——要升級成真正的推薦,缺的不是資料,是把既有的訊號接進去。
讓 demo 與 live 共用同一組函式簽名,是這輪投資報酬率最高的決定——前端因此能離線開發、靜態部署、也能隨時切回真後端。但兩條分支的行為會慢慢分岔(status 過濾、候選池大小),而且沒有任何測試在守這件事。下一次會先寫一組跨模式的契約測試,再開始長功能。
08 / SCREENS
以下畫面皆為示範(mock)資料,不含任何真實使用者資料。






09 / ARCHITECTURE DIAGRAMS
以下架構圖皆由專案原始碼推導繪製,每個節點都可回溯到實際檔案。圖較寬,可左右捲動,或點擊開啟原尺寸 SVG。
NEXT
多租戶 SaaS 人資系統