跳至主要內容

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)資料,不含任何真實使用者資料。

首頁:「今天吃什麼?交給命運決定」主視覺、8 個美食分類、今日推薦店家與人氣必吃
首頁 — 頂端的展示模式橫幅由 IS_DEMO 控制;這一整頁的分類、店家與菜色都由前端種子檔供應,沒有任何後端在跑
店家頁:封面、評分、介紹、地址營業時間電話、標籤、完整菜單與食客評論
店家頁 — 介紹、營業資訊與菜單同頁呈現;評論區在 demo 模式讀種子,live 模式則回傳空陣列,因為後端沒有任何路由指向 CommentController
探索頁套用「燒肉」分類篩選:搜尋列、分類 chip、價格區間,以及菜色 3/店家 1 的計數
探索頁套用分類篩選 — 關鍵字、分類、價格上下限對應後端 /search/ 的查詢參數;上方「菜色 3 / 店家 1」是同一組條件下的即時計數
隨機挑選頁在轉盤停下後的結果卡:「就決定是你了!」與被抽中的店家資訊
「幫我決定」轉完後的結果 — 挑選就是從候選池均勻取一筆,沒有個人化也沒有加權;偵測到 prefers-reduced-motion 時會跳過 1.6 秒的轉盤動畫直接給結果
口袋名單頁:6 筆已收藏菜色的卡片網格,頁首導覽列帶收藏數量徽章
口袋名單 — 收藏只寫在瀏覽器的 localStorage(fs_favorites);後端雖然有 collect.create / read / delete 三條權限路由,前端至今一次都沒呼叫過
390px 手機版探索頁:分類 chip 換行、價格區間獨立一列、雙欄卡片與底部固定四格導覽列
手機版探索頁(390px)— 分類 chip 換行、價格區間獨立成列,並改用 md:hidden 的底部固定導覽;與桌機共用同一組頁面元件,只靠斷點切換

09 / ARCHITECTURE DIAGRAMS

以下架構圖皆由專案原始碼推導繪製,每個節點都可回溯到實際檔案。圖較寬,可左右捲動,或點擊開啟原尺寸 SVG。

POST /login 與 GET /collect/ 的時序圖,涵蓋帳號鎖檢查、JWT 簽發與逐路由權限檢查
登入與授權時序 — 左半是 login:先查 locks、再 attempt(),失敗遞增計數、成功清空;右半是受保護路由通過 check.permission:collect.read 的全程。圖上刻意留著一個事實:中介層解過的 token,controller 又獨立解了第二次。
請求管線與完整路由表:公開路由、buyer 與 seller 兩組受權限保護的路由,以及 401/403 的分岔
請求管線與路由表 — 25 條路由中 13 條公開、12 條掛 check.permission:<action>;buyer / seller 的分組不是我畫上去的,兩個角色名與權限切分直接取自 RoleSeeder,每個權限字串都能在 PermissionsSeeder 找到對應列。
locks 資料表的帳號鎖定狀態機:未建列、未鎖定、已鎖定三個狀態與其轉移條件
帳號鎖定狀態機 — 兩個容易被說錯的細節都畫在圖上:checklock() 跑在 attempt() 之前,計數必須已經是 5,所以鎖其實在第 6 次嘗試才跳;而離開鎖定的唯一出口是用兩題安全問題重設密碼——沒有管理員解鎖、沒有 TTL、沒有排程任務。
探索、隨機挑選與人氣統計的資料路徑,含買家端公開路由與賣家端受權限路由
探索與挑選路徑 — 這張圖最有價值的是右下角的註記:所謂「推薦」就是 inRandomOrder()->limit(4),沒有使用者歷史、沒有加權、沒有 ML、沒有快取;而 look / collect 累積出來的人氣資料只回流到兩條賣家路由,從來沒有進入買家的挑選路徑。
14 張資料表的 ERD:帳號(private/member/store)、RBAC 四表、商品與店家、locks、look 與 collect
資料模型(14 張表,全部來自 5 個 migration)— 除了 private / member / store 三分的帳號結構與 roles×permissions 的 RBAC 四表,圖上也標出幾件實情:member.safe_ans1/2 是明文存放、user_roles 沒有唯一約束而中介層只讀第一列、comment 表有欄位也有 controller,卻沒有任何路由指得到它。

NEXT

多租戶 SaaS 人資系統