飲料訂購 APP — Go 後端專案架構與運作邏輯整理
這篇文章紀錄目前飲料訂購 App 的 Go 後端架構,以及 App 送出訂單後,後端如何處理請求、驗證資料、計算金額,最後寫入 MySQL。
飲料訂購 APP — Go 後端專案架構與運作邏輯整理
這篇文章紀錄目前飲料訂購 App 的 Go 後端架構,以及 App 送出訂單後,後端如何處理請求、驗證資料、計算金額,最後寫入 MySQL。
這個專案主要目標是提供一組飲料訂購 API,讓前端 App 可以完成會員註冊登入、查詢飲料、建立訂單、查詢訂單與更新訂單狀態。
一、專案整體架構
目前 Go 後端專案大致分成以下幾個部分:
go-drinkproject
├── cmd
│ └── server
│ └── main.go
├── internal
│ ├── auth
│ │ ├── password.go
│ │ └── token.go
│ ├── handlers
│ │ ├── auth_handler.go
│ │ ├── drink_handler.go
│ │ ├── order_handler.go
│ │ └── response.go
│ ├── models
│ │ └── models.go
│ ├── router
│ │ └── router.go
│ └── store
│ ├── store.go
│ ├── memory_store.go
│ └── mysql_store.go
├── schema.sql
├── go.mod
└── README.md
整個後端想成幾層:
cmd/server :程 式進入點,啟動 HTTP server
router : 設定 API 路由
handlers : 接收 HTTP request、解析 JSON、回傳 response
models:定義資料格式,例如 Drink、Order、User
store:資料存取層,負責操作 MySQL 或記憶體資料
auth:密碼加密與 JWT token 相關邏輯
schema.sql:MySQL 資料表結構
這樣拆分的好處是,每一層的責任清楚。
Handler 不需要知道 SQL 怎麼寫,只要呼叫 store
store 不需要知道 HTTP 狀態碼,只負責資料操作。
二、Server 啟動流程
後端入口在 cmd/server/main.go。
程式啟動後,會先檢查環境變數 MYSQL_DSN。
如果有設定 MYSQL_DSN,代表要連接 MySQL:
mysqlStore, err := store.NewMySQLStore(store.NormalizeDSN(dsn))
如果沒有設定,後端會使用 MemoryStore:
return store.NewMemoryStore()
也就是說,目前專案支援兩種資料來源:
MemoryStore : 資料存在記憶體,server 重開後資料會消失,適合測試
MySQLStore : 資料存在 MySQL,server 重開後資料仍會保留,適合正式使用
這個設計透過 Store interface 完成。
Handler 不需要知道背後是 MySQL 還是記憶體,只要呼叫共同定義的方法
例如:
CreateOrder(req models.CreateOrderRequest)
ListOrders()
GetOrder(id int)
UpdateOrderStatus(id int, status models.OrderStatus)
這是目前架構中很重要的一個設計:用 interface 隔離資料來源。
三、Router 如何分配 API
API 路由定義在 internal/router/router.go。
目前主要路由有:
GET /health
POST /api/auth/register
POST /api/auth/login
GET /api/me
GET /api/drinks
GET /api/drinks/{id}
POST /api/orders
GET /api/orders
GET /api/orders/{id}
PATCH /api/orders/{id}/status
Router 的工作是把不同 URL 分配給不同 handler。
例如:
mux.HandleFunc("/api/orders", orderHandler.Orders)
mux.HandleFunc("/api/orders/", orderHandler.OrderByID)
當 App 呼叫 POST /api/orders 時,請求會進入 order_handler.go 裡的 Orders 方法,接著再依照 HTTP method 判斷要建立訂單或查詢訂單。
四、Models 定義資料格式
internal/models/models.go 定義了後端主要使用的資料格式。
例如飲料:
type Drink struct {
ID int `json:"id"`
Name string `json:"name"`
Description string `json:"description"`
Price int `json:"price"`
ImageURL string `json:"image_url"`
Category string `json:"category"`
IsAvailable bool `json:"is_available"`
}
訂單:
type Order struct {
ID int `json:"id"`
CustomerName string `json:"customer_name"`
Phone string `json:"phone"`
Items []OrderItem `json:"items"`
TotalPrice int `json:"total_price"`
Status OrderStatus `json:"status"`
CreatedAt time.Time `json:"created_at"`
}
建立訂單時,前端送來的格式是:
type CreateOrderRequest struct {
CustomerName string `json:"customer_name"`
Phone string `json:"phone"`
Items []CreateOrderItemRequest `json:"items"`
}
其中每個品項只需要送:
type CreateOrderItemRequest struct {
DrinkID int `json:"drink_id"`
Quantity int `json:"quantity"`
Sweetness string `json:"sweetness"`
IceLevel string `json:"ice_level"`
}
這裡有一個重要設計:前端不用送飲料名稱、單價或總價。 這些資料都由後端根據 MySQL 裡的飲料資料計算。
五、建立訂單的完整流程
當 App 送出一筆飲料訂單時,流程如下:
App
↓
POST /api/orders
↓
router.go
↓
order_handler.go
↓
validateCreateOrderRequest
↓
store.CreateOrder
↓
MySQL transaction
↓
查 users
↓
查 drinks
↓
計算 subtotal / total_price
↓
寫入 orders
↓
寫入 order_items
↓
commit
↓
查回完整訂單
↓
回傳 JSON 給 App
1. App 送出訂單
前端會送出類似這樣的 JSON:
{
"customer_name": "Andy",
"phone": "0912345678",
"items": [
{
"drink_id": 1,
"quantity": 2,
"sweetness": "半糖",
"ice_level": "少冰"
}
]
}
2. Handler 解析與驗證資料
order_handler.go 會先把 request body 解析成 CreateOrderRequest。
接著驗證:
customer_name不能是空字串phone不能是空字串items不能是空陣列drink_id必須大於 0quantity必須大於 0
如果驗證失敗,後端會直接回傳 400 Bad Request。
3. Store 開始建立訂單
驗證通過後,handler 會呼叫:
h.store.CreateOrder(req)
如果目前使用 MySQL,實際會進入 mysql_store.go 的 CreateOrder。
六、MySQL 寫入邏輯
建立訂單時,後端會使用 transaction。
原因是建立訂單不是只寫一張表,而是至少會寫入:
ordersorder_items
如果其中一個步驟失敗,整筆訂單就不應該成立。 所以後端使用 transaction 確保資料一致性。
tx, err := s.db.Begin()
defer tx.Rollback()
如果所有步驟成功,最後才會:
tx.Commit()
七、會使用到哪些資料表
目前 MySQL schema 中和訂單有關的資料表主要有四張:
users : 會員資料
drinks : 飲料商品資料
orders : 訂單主檔
order_items : 訂單明細
建立訂單時,真正新增資料的是:
orders : 一張訂單的主資料
order_items : 訂單中的每個飲料品項
另外會讀取:
users : 用 phone 找會員 ID
drinks : 用 drink_id 找飲料名稱、價格、是否可販售
八、orders 表的角色
orders 是訂單主檔。
它記錄一張訂單的總覽資料,例如:
CREATE TABLE IF NOT EXISTS orders (
id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
order_no VARCHAR(32) NOT NULL UNIQUE,
customer_name VARCHAR(80) NOT NULL,
phone VARCHAR(30) NOT NULL,
total_price INT UNSIGNED NOT NULL DEFAULT 0,
status ENUM('pending', 'making', 'finished', 'canceled') NOT NULL DEFAULT 'pending',
note VARCHAR(500) NOT NULL DEFAULT '',
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
user_id BIGINT NOT NULL,
INDEX idx_orders_user_id (user_id)
);
當一張訂單建立時,後端會寫入:
order_nocustomer_namephonetotal_pricestatususer_id
其中 status 預設是:
pending
代表訂單已建立,但還沒有開始製作。
九、order_items 表的角色
order_items 是訂單明細。
一張訂單可以有多個品項,所以需要獨立一張表保存。
CREATE TABLE IF NOT EXISTS order_items (
id INT AUTO_INCREMENT PRIMARY KEY,
order_id BIGINT UNSIGNED NOT NULL,
drink_id INT NOT NULL,
drink_name VARCHAR(100) NOT NULL,
quantity INT NOT NULL,
sweetness VARCHAR(50) NOT NULL DEFAULT '',
ice_level VARCHAR(50) NOT NULL DEFAULT '',
unit_price INT NOT NULL,
subtotal INT NOT NULL,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
);
假設一張訂單裡有:
- 珍珠奶茶 2 杯
- 檸檬紅茶 1 杯
那麼資料會長這樣:
orders : 1 筆
order_items : 2 筆
這樣設計可以讓一張訂單支援多種飲料,也方便未來做訂單明細查詢、統計熱銷品項、計算營收。
十、為什麼後端要自己計算價格
這是目前專案中很重要的安全設計。
前端 App 只送:
{
"drink_id": 1,
"quantity": 2
}
原因是前端資料不能完全信任。 使用者有可能攔截 request,自己把價格改成 1 元。
所以後端會用 drink_id 查 MySQL 的 drinks 表:
drink, err := getDrinkTx(tx, item.DrinkID)
然後使用資料庫中的價格計算:
subtotal := drink.Price * item.Quantity
totalPrice += subtotal
這樣可以確保最後寫入 orders.total_price 和 order_items.subtotal 的金額是後端計算出來的,而不是前端隨便傳來的。
十一、為什麼 order_items 要保存 drink_name 和 unit_price
雖然 order_items 已經有 drink_id,但它仍然保存:
drink_nameunit_price
這樣做是為了保留「下單當下」的商品資訊。
假設今天珍珠奶茶是 60 元,使用者已經下單
明天店家把珍珠奶茶改成 65 元,如果舊訂單只靠 drink_id 去查 drinks.price,舊訂單金額就可能被新價格影響。
所以訂單明細會保存當下的名稱與單價 這樣即使未來飲料改名、漲價或下架,舊訂單仍然能正確顯示當時的資料。
十二、訂單狀態流程
目前訂單狀態定義在 models.go:
const (
OrderStatusPending OrderStatus = "pending"
OrderStatusMaking OrderStatus = "making"
OrderStatusFinished OrderStatus = "finished"
OrderStatusCanceled OrderStatus = "canceled"
)
狀態含義大致是:
pending : 訂單剛建立,尚未開始製作
making : 店家正在製作
finished : 訂單完成
canceled : 訂單取消
更新訂單狀態的 API 是:
PATCH /api/orders/{id}/status
例如:
{
"status": "making"
}
後端會檢查狀態是否合法,只有上述四種狀態可以使用。
十三、查詢訂單時的流程
查詢訂單列表時,後端會先查 orders:
SELECT id, customer_name, phone, total_price, status, created_at
FROM orders
ORDER BY id DESC
接著每一張訂單再查自己的 order_items:
SELECT drink_id, drink_name, quantity, sweetness, ice_level, unit_price, subtotal
FROM order_items
WHERE order_id = ?
ORDER BY id
最後組成完整的 JSON 回傳給 App。
這樣 App 拿到的資料就不是只有訂單主檔,而是包含完整品項:
{
"id": 1,
"customer_name": "Andy",
"phone": "0912345678",
"items": [
{
"drink_id": 1,
"drink_name": "珍珠奶茶",
"quantity": 2,
"sweetness": "半糖",
"ice_level": "少冰",
"unit_price": 60,
"subtotal": 120
}
],
"total_price": 120,
"status": "pending",
"created_at": "..."
}
十四、目前架構的優點
目前這個 Go 後端架構有幾個優點:
- 分層清楚 Router、Handler、Model、Store 各自負責不同事情,程式比較好維護。
- Store interface 彈性高 可以用 MySQL,也可以用 MemoryStore 測試。Handler 不需要改。
- 訂單資料設計合理
orders存主檔,order_items存明細,符合一般訂單系統設計。 - 價格由後端計算 避免前端竄改價格,安全性較好。
- 使用 transaction 建立訂單時避免只寫入一半資料。
- 保留下單當下資料
order_items保存drink_name和unit_price,避免未來飲料資料變動影響舊訂單。
메타데이터
- post_id
- 17eefcd8ea9a
- slug
- 飲料訂購-app-go-後端專案架構與運作邏輯整理-17eefcd8ea9a
- url
- https://medium.com/@croc0909/%E9%A3%B2%E6%96%99%E8%A8%82%E8%B3%BC-app-go-%E5%BE%8C%E7%AB%AF%E5%B0%88%E6%A1%88%E6%9E%B6%E6%A7%8B%E8%88%87%E9%81%8B%E4%BD%9C%E9%82%8F%E8%BC%AF%E6%95%B4%E7%90%86-17eefcd8ea9a
- canonical_url
- https://medium.com/@croc0909/%E9%A3%B2%E6%96%99%E8%A8%82%E8%B3%BC-app-go-%E5%BE%8C%E7%AB%AF%E5%B0%88%E6%A1%88%E6%9E%B6%E6%A7%8B%E8%88%87%E9%81%8B%E4%BD%9C%E9%82%8F%E8%BC%AF%E6%95%B4%E7%90%86-17eefcd8ea9a
- author_url
- https://medium.com/@croc0909
- status
- ok
- fetched_at
- 2026-06-20 20:29:01