Casha | 2. RBAC, Menu & Order api 等設計

Casha | 2. RBAC, Menu & Order api 等設計

Casha 的 admin-portal 提供了一套能配置餐廳權限與設定菜單的功能, 今天就來介紹一下 admin-portal 的幾個核心設計:

  • 管理平台, 餐廳, 分店的 RBAC 機制
  • 座位與菜單的設計

最後再花約一半的篇幅介紹 order api。

開始前提醒, 因為篇幅過長的原因, 若有興趣可以直接到我的 git doc 內觀看

RBAC

先看 ER Model:

Casha RBAC ER Model

以 ER Model 習慣的講法:

一個平台能有多個用戶, 每個用戶對應一組帳號
每個帳號可以綁定一個或多個角色
每個角色可以綁定多個權限
每個權限會對應到一組 api 在 crud 上的操作, 並對應用戶在後台的功能清單

在 cahsa admin-portal 之中, 依照使用者的身分, 分為三個預設角色:

  1. 平台管理員
  2. 餐廳管理員
  3. 分店管理員

這三個預設的角色具備全部該範圍下的全部權限, 當平台管理員收到註冊請求後創立餐廳管理員, 該帳號即綁定餐廳全數權限, 同理, 餐廳在創建分店時, 亦生成預設的分店管理員帳號, 後續再由這些管理員創建同犯完角色時, 去綁定他們設定的角色與權限。

Note

1. Spring Security

角色所綁定的權限(Perminssion)會對應一支 api, 故在開發新功能的時候, 會需要將設定的 Perminssion Code 寫入 Controller 的 @PreAuthorize 內。

1
2
3
4
5
6
@PreAuthorize("hasAuthority('PLATFORM_FUNCTION_MGMT')")
@PostMapping("/findAll")
public ResponseEntity<ApiResponse<FindAllPermissionRs>> findAllPermissions() {
FindAllPermissionRs rs = findAllPermissionUseCase.findAll();
return ResponseEntity.ok(ApiResponse.success(rs));
}

2. User Session

在整個 admin portal 中, 只有少部分 api 如登入, 註冊等不需要身分驗證, 而身分驗證成功後, 會在 redis 內創建 user session, 後續的 api 則在 gateway 驗證 jwt 成功後, 將 userAccount 放入 Header 後往 Bff 發送, 並在 Bff 層從 redis 內解析, 故在做相關的查詢的時候, 請求不需要特別帶 branch 或 restaurant Id。

1
2
3
4
5
6
7
8
9
@PreAuthorize("hasAuthority('BRANCH_ITEM_MGMT')")
@PostMapping("/findPage")
public ResponseEntity<ApiResponse<FindItemsPageRs>> findPage(
@RequestHeader("X-User-Account") String userAccount,
@RequestBody FindItemsPageRq rq
) {
FindItemsPageRs rs = useCase.findPage(userAccount, rq);
return ResponseEntity.ok(ApiResponse.success(rs));
}

3. 身分驗證流程

前面提到的 gateway 和 redis 等後踢前的效果, 核心是由 Auth-Service 頒發 jwt, 後續都由 gateway 進行驗證, 不用每次都再轉發到 Auth-Service 內, 但 gateway 和 auth-service 就要管理同一套 salt。

Admin Portal 驗證流程


座位與菜單

菜單的 ER-Model

1. 座位狀態

不得不說, 在思考設計的時候才發現分店管理座位狀態的設計是 F&B 系統非常核心的一塊。

一般常見的小型店面, 只要不須帶位的, 通常都會將 qrcode 貼在桌上, 所以點餐流程只需要集中處裡訂單即可。而需要帶位的店面則在 pos 上還需要仰賴座位狀態進行帶位, 常見的業務方法會即時生成一個共用的 qrcode 共同管理訂單狀態。

因此在設計後端座位管理的時候就要考量到, 這一套服務是要提供給上述哪一種需求的客戶, 又或是提供狀態選項, 平台全包。很不巧的我選擇了後者, 發現越做越複雜, 目前程式是依照 branch.visit_mode 來管理是否有追桌的行為, , 現在只先實作 DISABLED 不追桌, 若未來有時間可以再 refactor 與完善一下。

用戶掃碼

下面這張圖是其中一間分店第二桌的 QR Code, 在完成座位設定後生成, 其 url 為

https://casha-order.williamrightone.com/m/o8qzmm4u2lurqfy2

在 url 中後面是該座位的 token, 經解密後可以取得 branch & table Id, 在解析完成後攜帶分店資訊返回前端, 再轉導到菜單進行菜單查詢。

Table2

2. 菜單設計

Casha 的菜單設計為 一個時間區間下所設定的單品與分類組合, 單品與分類為多對一, 相對直覺, 而菜單在建立後還需要配置各個版本, 給定菜單啟用的時間, 以及該時端要配置的分類與單品。

關於菜單的快取設計

Casha Menu 快取

Menu 是一個快取優先讀取 (Cache First) 的設計, 當第一個人訪問該 QR Code 並獲取菜單後, 菜單的內容與訊息便會儲存在 Redis Cache 內, 並且設定 180 ± (180 * 20%) 秒的 TTL (Time To Live), 來分散尖峰時刻 Cache 同時過期導致大量用戶請求湧入造成快取雪崩的風險; 而後面的用戶則會快取命中 (Cache Hit), 直接取用快取資料。

快取未命中 (Cache Miss) 時, 處理競爭鎖與重建快取 (Lock Competition & Cache Rebuilding), 生成一個全域唯一的 lockToken(UUID),用於安全地釋放鎖, 並利用 Lua SET lockKey lockToken NX EX LOCK_EXPIRE_SECONDS 指令(經由 setIfAbsent 方法)原子性地嘗試獲取鎖, 同時給鎖設定一個較短的過期時間(3秒),防止持有鎖的執行緒崩潰導致鎖永遠無法釋放(死鎖)。

  • 若成功獲取鎖, 先處理雙重檢查 (Double-Check), 避免成功獲取鎖的極短時間內,已經有另一個執行緒完成了快取重建並釋放了鎖;接著通過 Feign Client 呼叫 store-service 的 /resolveActive API 來獲取最新的菜單資料(也是整個流程中最耗時、成本最高的操作), 成功後將 Rs 轉換與寫入快取, 設置 TTL 抖動並返回結果。
  • 若未能獲取鎖, 意味著已經有另一個執行緒正在重建快取, 退避等待 80ms 後再次嘗試讀取, 若等待後,快取依然為空(例如:持有鎖的執行緒執行非常慢,或在寫入快取前失敗了),則執行降級策略不再等待,而是直接降級呼叫下游服務。

最後當前成功獲取了鎖的執行緒,必須在 finally 程式碼塊中執行鎖釋放邏輯。

詳細內容可以到 git doc 內觀看


3. order api

點餐的流程起自 用戶掃碼到顯示訂單完成, 前置作業要完成菜單的配置。上述提到用戶在掃碼後獲取餐廳與分店等訊息, 並取得菜單資訊,下一步便能在菜單上點餐並前往購物車結帳。

目前的設計沒有真正串接金流, 而是在前端自行發送一個 mock 的付款按鈕, 所以核心付款的 api 會是:

  1. /cis/order/create: 創建訂單, 支付前準備, 同時會最終確認存貨是否充足
  2. /cis/payments/mock-callback: 模擬支付方付款成功時的 callbacl method

由於篇幅限制, 下面會稍微簡化, 若想看更詳細的流程歡迎到 git doc 內觀看

1. order/create

這個 API 的核心目的是:處理使用者在點選「下單」或「結帳」按鈕後、實際發起支付前的所有準備工作。它是一個集「存貨驗證」、「最終計價」、「庫存預留」和「訂單創建」於一體的原子性操作。

該 api 在 BFF 層僅做轉發, 在 Biz 主要職責可分解為:

  1. 最終報價與鎖庫: 由 order-service 呼叫下游 store-service 進行最終的存貨檢查與計價,並預留庫存。
  2. 冪等性保證:防止因客戶端重試(如網路抖動、用戶雙擊)而導致重複下單。
  3. 創建訂單:將訂單資訊(含快照)持久化到資料庫,狀態為「已下單,未支付」(PLACED, UNPAID)。
  4. 綁定預留:將臨時的庫存預留令牌與剛創建的正式訂單綁定,為後續的支付成功/失敗處理做準備。
  5. 支付超時管理:發送一個延遲消息,設定支付時限(如15分鐘),超時後系統將自動取消訂單並釋放預留的庫存。

其最終目標是生成一個待支付的訂單,並確保在支付過程中,用戶所見的價格和庫存是確定的、被臨時鎖定的。

Order 流程

2. payments/mock-callback

理支付提供商發送的非同步支付結果通知,並根據結果更新訂單狀態、驅動後續業務流程(如庫存結算或釋放)。

它是一個冪等的操作,需要妥善處理以下情況:

  1. 支付成功:將訂單狀態更新為「已支付」,並觸發庫存預留轉為實際銷售的流程。
  2. 用戶取消:用戶在支付過程中主動取消,立即釋放預留的庫存。
  3. 支付失敗:支付因各種原因失敗,保持訂單為「未支付」狀態,等待超時流程來處理。
  4. 重複通知:支付提供商可能重複發送通知,API 必須能識別並正確處理已處理過的訂單。

全流程

最後來張超大的流程圖。

全流程, 超大

結語

花了滿長的篇幅介紹 order & payment 的機制, 現在 Jmeter 壓力測試業在如火如荼的進行中, 測試後除了記錄觀測平台外, 也能用測資來調整後續報表的設計。

預計下一篇會介紹 Jmeter 的實作和 Grafana/Prometheus 的設置, 並看看在當前的設計下, 我的服務表現如何 (汗…)。