ARCHITECTURE v2 · 實際實作

系統架構說明 v2 — 從理想到現實

校園餐廳 POS 系統 · Modular Monolith + Cloud + Cache · 2026-06-08
⚠ v1 vs v2 的根本差異: v1 提出 Microservice(5 個獨立 JAR、5 個 DB、各自部署) 的理想架構; 但受限於實際 2 GB RAM 的硬體配額,5 個 JVM 同時跑會直接 OOM。 v2 改為 Modular Monolith(1 JAR、1 JVM、5 個內部模組), 保留模組邊界 + Caffeine 本地 Cache + Stripe 雲端金流, 達成「能跑、跑得起、未來能拆」。

一、為什麼不能像 v1 那樣做?

1.1 硬體限制(這才是現實)

整個系統部署在 2 GB RAM 的 EC2 主機,記憶體預算如下:

restaurant-app 700m
N
OS / 緩衝 1.25 GB

圖示:2 GB 記憶體分配(應用程式 · Nginx · OS 緩衝)— PostgreSQL 已遷移至 Neon Cloud,釋出 400m

1.2 如果硬要跑 5 個微服務會怎樣?

項目v1 微服務需求v2 Modular Monolith 實況
JVM 數量 5 個 1 個
JVM 基本開銷 5 × 約 150–200m = 750m–1 GB 純啟動 1 × 約 150–200m = 200m 純啟動
實際可用堆積 (Heap) 每服務僅剩 ~50m → 隨即 OOM 單一應用享有 -Xmx480m,舒適運作
Redis(v1 計畫的 cache) 另需 100–200m ❌ 不裝。改用 Caffeine in-JVM Cache(0 額外 RAM)
5 個獨立 DB PostgreSQL 多實例 × 5 ≈ 2 GB 單一 restaurant_db,模組以表前綴隔離
結論 2 GB 主機跑不起,需 8 GB+ 才舒適 2 GB 剛好夠用,仍保留模組邊界

二、v2 實際架構

FRONTEND
Alice 靜態 HTML + TailwindCSS(由 Nginx 直接託管)
NGINX 1.29(Port 80 · 交通警察)
路徑剝離 · CORS · 靜態檔 · proxy_pass → restaurant-app:8080
SINGLE JAR — Spring Boot 3.4.5 / Java 21 / Port 8080
restaurant-app(單一 JVM,5 個內部模組)
login
BCrypt + JWT
wallet
Stripe 儲值
menu
菜單 + 庫存
order
訂單 + 訂金
pickup
取餐 + WebSocket
⚡ Caffeine In-JVM Cache — 6 個快取池(menus / dishes / drinks · today / by-id)
DATA + CLOUD
☁ Neon Serverless PostgreSQL — 單一資料庫 restaurant_db(模組以表前綴隔離)
Production: Neon Cloud  |  Local Dev: Docker postgres:18
☁ Stripe Cloud API(金流、無需自建)

三、四大關鍵取捨

3.1 微服務 → Modular Monolith

❌ v1:5 JAR 微服務

  • 5 個獨立進程,2 GB 跑不動
  • 跨服務需 Saga Pattern 處理一致性
  • 5 套 CI/CD pipeline 開發成本高
  • 本案 MVP 階段不需要極致彈性

✅ v2:1 JAR Modular Monolith

  • 單一 JVM,700m RAM 內舒適運作
  • 單一 DB transaction,自然一致
  • 1 套 CI/CD,部署簡單
  • 模組邊界仍嚴格:未來資金到位可拆成微服務

關鍵設計:模組邊界仍守住。透過下列規範,未來要把單一模組「搬出去」成獨立微服務時,重構成本極低:

3.2 Redis → Caffeine(本地 In-JVM Cache)

❌ Redis

  • 另需 100–200m RAM
  • 需另開連線、處理序列化
  • 網路 round-trip 反而慢
  • 多一個服務要監控、要備份

✅ Caffeine(在 JVM 內)

  • 0 額外 RAM(用 -Xmx480m 內的空間)
  • 純 Java 物件,無序列化成本
  • 命中延遲 < 1ms
  • Spring Cache 抽象,未來換 Redis 改設定即可

Caffeine 快取池清單(6 個):

快取名稱TTLmax size用途
menus-today3 min10今日菜單 — 高頻讀取(午餐尖峰每秒上百次)
dishes-today3 min10今日主菜清單
drinks-today3 min10今日飲品清單
menu-by-id10 min200單一菜單細節(瀏覽商品頁)
dish-by-id10 min200單一主菜細節
drink-by-id10 min200單一飲品細節
失效規則(Eviction):所有寫入方法(create / update / delete / updateBalance / uploadImage)都帶 @Caching(evict = {...}) 自動清除受影響快取;每日午夜 DailyStockResetScheduler 重置庫存時,6 個快取一次清空。

3.3 自建金流 → Stripe Cloud

❌ 自建金流 / Linepay 整合

  • 需取得 PCI-DSS 合規
  • 需處理信用卡資料儲存風險
  • 整合工時長(2–3 週)
  • 失敗風險高、退款流程複雜

✅ Stripe Cloud API

  • 合規完全外包給 Stripe
  • 3–5 天即可整合完成
  • 支援 TWD 零小數位
  • Test card 可全流程驗證
  • 本機 0 額外 RAM(呼叫雲端 API)

Stripe Top-up 流程(Idempotent — 同一 session 重呼 verify 安全):

1. 前端 POST /wallets/{walletId}/topup/create-session  {amount}
2. 後端建 StripeTopupRecord (PENDING)、回傳 sessionUrl
3. 使用者導至 Stripe Checkout
4. 付款後 Stripe 導回 success-url?session_id=xxx
5. 前端 GET /wallets/{walletId}/topup/verify?sessionId=xxx
6. 後端與 Stripe API 對帳、入賬 wallet、標記 SUCCEEDED
7. 回 {status, walletBalance, message}

3.4 Server-side Cart → Frontend localStorage Cart(購物車設計)

⚠ Demo 方案:Server-side Cart(DB 持久化)

  • 需建立 carts + cart_items 兩張新表
  • CartService 必須 Feign 呼叫 menu module 取得真實價格(Demo 原碼硬碼 $40,無法用)
  • deposit 計算有 bug:finalTotal = baseTotal + depositAmt(等於多收 $50)
  • 認證改用 X-User-Id header,與現行 Spring Security HTTP Basic 不符
  • 優點:換裝置 / 重開瀏覽器後仍可恢復購物車內容

✅ 現行方案:Frontend localStorage Cart

  • 購物車存於瀏覽器 localStorage0 個新 DB 表
  • 價格在結帳時由 menu API 即時取得,不硬碼
  • depositAmt 正確語意:totalAmt 的先付部分,非額外加項
  • 結帳時直接送 POST /orders,流程最短
  • 校園 POS 單一餐期、當場結帳,無需跨裝置恢復
決策根據: 校園餐廳的使用場景是「坐下 → 點餐 → 當場結帳」,不存在「隔天繼續結帳」或「換手機繼續」的需求。 Server-side Cart 的唯一優勢(跨裝置 / 跨 session 恢復)在本場景中完全用不到, 反而帶來 2 張新表、Feign 跨模組取價、以及 Demo 原碼中兩個需修正的 bug(價格硬碼 + deposit 邏輯錯誤)。 現行 Frontend Cart 符合「夠用就好、不過度設計」的 MVP 原則。

四、雲端(Cloud)與本地(On-Premise)分工

v2 的核心策略是:能交給雲端的事,就交給雲端做。本機只跑最關鍵的業務邏輯與資料庫,省下記憶體給 JVM。

功能放哪理由
資料庫☁ Neon Serverless釋出 400m RAM;Zeabur 以環境變數注入連線字串;本機仍用 Docker postgres
金流付款☁ Stripe Cloud合規、安全、可靠性 99.99%,免維運
菜單圖片儲存☁ Cloudflare R2容器重啟圖片不滅失;免費方案足夠 MVP;image_url 直接存 public URL
使用者認證本地 JWT + BCrypt無第三方依賴,控管最大化
取餐通知本地 WebSocket低延遲、無需經外部服務
排程任務本地 Spring Scheduler每日庫存重置,量小無需 cloud job runner
反向代理本地 Nginx 1.29路徑剝離、CORS、靜態檔;輕量 50m RAM
監控 / 日誌(未來)☁ CloudWatch / Sentry不佔本機 RAM、可長期保存

五、實際記憶體預算(2 GB EC2)

元件RAM備註
☁ Neon Serverless PostgreSQL0 MB已遷移至 Neon Cloud,不佔 Zeabur 記憶體
restaurant-app (Spring Boot)700 MBJVM -Xmx480m -Xms128m;含 Caffeine cache
Nginx 1.29~50 MB反向代理 + 靜態檔
OS / Buffer / Cache~1.25 GBLinux 檔案快取、SSH、緩衝空間(較前增加 400m)
合計~2 GB餘裕比原本多 400m,穩定性大幅提升

六、保留的微服務基因

雖然這次跑 1 個 JAR,但模組設計仍按微服務的標準,未來只要記憶體升級或業務分流即可逐一搬出:

微服務原則v2 對應作法
單一職責 (SRP)每個模組獨立 package:com.restaurant.login / wallet / menu / order / pickup
無共享資料庫單一 DB,但無跨模組 JOIN,未來拆分時各模組可帶走自己的表
服務間通訊OpenFeign HTTP 呼叫,未來改 URL 即可指向獨立服務
故障隔離GlobalExceptionHandler 統一處理;單模組異常不影響其他模組
獨立部署暫時共部署;模組內 controller 已具獨立 API 命名空間

七、未來升級路徑(Phase 5+)

當以下任一條件成立時,再啟動「模組搬離」工程:

搬出順序候選模組理由
第 1 順位wallet金流敏感、可獨立銷售予福利社 / 影印站;首先解耦最有商業價值
第 2 順位menu讀多寫少,可獨立 scale;圖片可同步搬至 CDN
第 3 順位pickupWebSocket 連線管理特殊,獨立後可改用 AWS API Gateway WebSocket
留在主體login / order業務核心,最後再考慮拆分

同時將 Caffeine cache 替換為 Redis,並改用 SQS / EventBridge 做服務間非同步通訊。屆時架構即升級為「真正的微服務」。

八、圖片儲存問題與 Cloudflare R2 解決方案

問題根源

原始設計將上傳圖片存放於容器本地檔案系統(/app/uploads/menu/), 並由 Spring Boot WebConfig 透過 /uploads/** 路徑對外提供服務。 在本機 Docker Compose 環境中,uploads_data named volume 可使圖片在容器重啟後保留。

然而在 Zeabur 雲端平台部署後,每次 git push 觸發重新部署時容器會被替換, 本地檔案系統全數清空。資料庫中的 image_url 欄位仍保留舊路徑(如 /uploads/dish_af962598-....jpg),但實際檔案已不存在, 瀏覽器請求時收到 HTTP 404,前端所有圖片空白。

情境圖片是否存活原因
本機 Docker Compose✅ 存活uploads_data named volume 持久化
Zeabur 重新部署後❌ 消失容器替換,無持久 volume,檔案清零

解決方案:Cloudflare R2 外部儲存

放棄在容器內存圖,改用 Cloudflare R2 Object Storage 作為圖片倉庫。 R2 免費方案(10 GB 儲存 / 月 100 萬次請求)對校園餐廳規模完全足夠。

步驟做法
1. 建立 R2 Bucket Cloudflare Dashboard → R2 → 建立 bucket(例:canteendb
2. 開啟公開存取 Bucket → Settings → Public Access → Allow Access,取得 https://pub-xxx.r2.dev base URL
3. 上傳圖片 手動上傳 dish.jpgdrink.jpg 至 R2 bucket
4. 更新資料庫 在 PostgreSQL 執行:
UPDATE dishes SET image_url = 'https://pub-xxx.r2.dev/dish.jpg';
UPDATE drinks SET image_url = 'https://pub-xxx.r2.dev/drink.jpg';
5. 清除 Caffeine Cache 重啟 Zeabur 服務,或等待 3 分鐘讓 dishes-today/drinks-today cache TTL 到期

此方案的 image_url 欄位直接儲存完整的 HTTPS public URL(如 https://pub-e47315ff76f145eeae9e5a2f8486bea8.r2.dev/dish.jpg), 瀏覽器直接向 Cloudflare CDN 拉取圖片,Spring Boot 完全不參與圖片傳輸。 每次部署後圖片依然存活,無需重新上傳。

架構決策:圖片不屬於「業務邏輯」,不應由應用伺服器負責儲存與傳輸。 外包給 Cloudflare R2 既節省容器 RAM,又獲得 CDN 加速與跨部署持久性。 這是「Right-sized Architecture」的體現:用最低成本解決最實際的問題。

九、AI 智能客服聊天機器人

v2 新增一個嵌入式 AI 聊天助理,讓顧客無需登入任何第三方平台即可查詢取餐狀況或諮詢問題。

架構設計

顧客瀏覽器(index.html 右下角 💬 浮動按鈕)
  ↓ POST /api/chat { messages: [...] }
Spring Boot — ChatController(com.restaurant.chat)
  ↓ HTTPS + API Key(環境變數 GEMINI_API_KEY)
Google Gemini API(gemini-flash-latest)
  ↓ 回傳 { reply: "..." }
顧客瀏覽器 顯示回覆

取餐碼查詢(本地處理,不經 AI)

當訊息符合取餐碼格式(如 0289-018),前端直接呼叫 GET /api/pickups/status?code=XXXX,即時顯示狀態,無需呼叫 AI API,節省 token 用量。

快捷指令(前端本地處理)

指令處理方式說明
📋 訂單狀況本地引導輸入取餐碼
📞 聯絡本地顯示聯絡資訊
❓ 幫助本地顯示使用說明
一般問題Gemini AI呼叫 /api/chat 由 AI 回覆

安全設計

API Key 不暴露於前端 — GEMINI_API_KEY 存放於 Zeabur 環境變數,Spring Boot 在伺服器端呼叫 Gemini API,瀏覽器只與自己的後端通訊(/api/chat),永不直接接觸 Google API。

十、結語:務實大於理想

v2 的核心信念: 「Right-sized Architecture」 — 在 2 GB 主機跑得起來的能跑、有資金升級時能搬離。 我們不為了「看起來像微服務」而把 5 個 JAR 硬塞進 2 GB; 我們選擇 Modular Monolith + Caffeine Cache + Stripe Cloud, 用最少資源達成最多功能,並把未來升級路徑事先鋪好。

這份架構說明 v2 並非「降級」,而是「合身」。v1 的願景仍在;v2 是它的第一個可上線版本