第 6 章:下單(市價買 / 賣)⭐ 本專案核心
這一章在做什麼? 我們要實作市價單:以「當下報價」立刻成交。買單要檢查餘額夠不夠、賣單要檢查持股夠不夠;成交時要同時扣款(或入帳)、更新持股、寫入訂單、記一筆資金 ledger。
為什麼這是最關鍵的一章? 因為一筆下單牽涉好幾個資料表的寫入,這些寫入必須「要嘛全部成功、要嘛全部不留」。如果扣了錢卻沒加到股票,使用者的錢就憑空消失了。我們會用一筆資料庫交易(transaction)+ 行鎖(FOR UPDATE) 來保證這件事——這正是後端最能展現嚴謹度的地方。
步驟 1:先想清楚一筆「買單」要做哪些事
買 5 股 AAPL(假設現價每股 195.50)要連續做這些動作,而且一個都不能少、一個都不能錯:
1. 取得 AAPL 當下報價(決定成交價)→ 195.50/股
2. 算出總額 = 19550 分 × 5 = 97750 分
3. 鎖定帳戶,檢查餘額 ≥ 97750 分 ← 不夠就整筆失敗
4. 從餘額扣掉 97750 分
5. 更新持股:原本沒有就新增 5 股;已持有就加碼並重算平均成本
6. 寫入一筆 order 紀錄(成交價、數量、總額)
7. 寫入一筆 transaction(type=buy,金額 -97750,關聯這筆 order)
8. 全部成功 → Commit;中途任何一步失敗 → Rollback(當作沒發生)
賣單對稱:檢查持股夠不夠、入帳、減少持股、寫 order、寫 transaction。
這 8 步必須是「原子」的——這就是交易(transaction)存在的理由。
步驟 2:repository(持股、訂單,以及鎖定查詢)
2-1 AccountRepository 新增「鎖定查詢」
下單時要先鎖住帳戶這一列,避免同一使用者並發下單互相踩。在 internal/repository/account_repository.go 新增:
// GetByUserIDForUpdate 取得帳戶並「鎖定該列」(SELECT ... FOR UPDATE)
// 在交易中呼叫:其他想動到同一列的交易會被擋住,直到本交易結束。
func (r *AccountRepository) GetByUserIDForUpdate(q DBTX, userID int64) (*model.Account, error) {
var a model.Account
if err := q.Get(&a, `SELECT * FROM accounts WHERE user_id = $1 FOR UPDATE`, userID); err != nil {
return nil, err
}
return &a, nil
}
SELECT ... FOR UPDATE 是什麼? 它在交易中讀取某列的同時鎖住那一列。其他交易若也想 FOR UPDATE 同一列,會被迫等待,直到我們 Commit / Rollback。這叫悲觀鎖——我們先把門鎖上,確保「檢查餘額 → 扣款」這段期間,沒有別的下單能插隊改動餘額。
🔁 JS 對照 這是 SQL 層級的概念,跟語言無關。在 Node 用
pg你也是送出SELECT ... FOR UPDATE達到一樣效果。重點觀念:「先檢查、再修改」之間若不鎖住,並發時就可能兩筆都通過檢查而超支。
2-2 HoldingRepository(新檔)
建立 internal/repository/holding_repository.go:
// internal/repository/holding_repository.go
package repository
import "no-logic-trade/internal/model"
type HoldingRepository struct{}
func NewHoldingRepository() *HoldingRepository {
return &HoldingRepository{}
}
// GetForUpdate 取得並鎖定某帳戶某股票的持股;查無回 sql.ErrNoRows
func (r *HoldingRepository) GetForUpdate(q DBTX, accountID int64, symbol string) (*model.Holding, error) {
var h model.Holding
err := q.Get(&h,
`SELECT * FROM holdings WHERE account_id = $1 AND symbol = $2 FOR UPDATE`,
accountID, symbol)
if err != nil {
return nil, err
}
return &h, nil
}
// Upsert 新增或更新持股(買進後的新數量與新均價)
func (r *HoldingRepository) Upsert(q DBTX, accountID int64, symbol string, quantity, avgCost int64) error {
_, err := q.Exec(`
INSERT INTO holdings (account_id, symbol, quantity, avg_cost)
VALUES ($1, $2, $3, $4)
ON CONFLICT (account_id, symbol)
DO UPDATE SET quantity = $3, avg_cost = $4, updated_at = now()
`, accountID, symbol, quantity, avgCost)
return err
}
// UpdateQuantity 更新持股數量(賣出後仍有剩)
func (r *HoldingRepository) UpdateQuantity(q DBTX, accountID int64, symbol string, quantity int64) error {
_, err := q.Exec(
`UPDATE holdings SET quantity = $3, updated_at = now() WHERE account_id = $1 AND symbol = $2`,
accountID, symbol, quantity)
return err
}
// Delete 刪除持股(賣到 0 股)
func (r *HoldingRepository) Delete(q DBTX, accountID int64, symbol string) error {
_, err := q.Exec(`DELETE FROM holdings WHERE account_id = $1 AND symbol = $2`, accountID, symbol)
return err
}
// ListByAccount 列出某帳戶所有持股(第 7 章會用到)
func (r *HoldingRepository) ListByAccount(q DBTX, accountID int64) ([]model.Holding, error) {
var holdings []model.Holding
err := q.Select(&holdings,
`SELECT * FROM holdings WHERE account_id = $1 ORDER BY symbol`, accountID)
return holdings, err
}
Upsert用INSERT ... ON CONFLICT ... DO UPDATE:PostgreSQL 的「有就更新、沒有就新增」語法(靠第 2 章建的UNIQUE(account_id, symbol)判斷衝突)。買進時很好用。
2-3 OrderRepository(新檔)
建立 internal/repository/order_repository.go:
// internal/repository/order_repository.go
package repository
import "no-logic-trade/internal/model"
type OrderRepository struct{}
func NewOrderRepository() *OrderRepository {
return &OrderRepository{}
}
// Create 寫入一筆訂單,回傳寫入後的完整紀錄(含 id 與 created_at)
func (r *OrderRepository) Create(q DBTX, o *model.Order) (*model.Order, error) {
var created model.Order
err := q.QueryRowx(`
INSERT INTO orders (account_id, symbol, side, quantity, price, total_amount, status)
VALUES ($1, $2, $3, $4, $5, $6, $7)
RETURNING *
`, o.AccountID, o.Symbol, o.Side, o.Quantity, o.Price, o.TotalAmount, o.Status).StructScan(&created)
if err != nil {
return nil, err
}
return &created, nil
}
RETURNING *+StructScan:插入後一次拿回整列(含資料庫產生的id、created_at),用 sqlx 的StructScan依dbtag 塞進 struct。省得插入後再查一次。
步驟 3:service(下單的核心邏輯)
建立 internal/service/order_service.go。這是全專案最重要的一段,我們分段看,但它是一個完整檔案。
3-1 型別、錯誤、建構子
// internal/service/order_service.go
package service
import (
"database/sql"
"errors"
"fmt"
"strings"
"github.com/jmoiron/sqlx"
"no-logic-trade/internal/model"
"no-logic-trade/internal/repository"
)
var (
ErrInvalidSide = errors.New("side 必須是 buy 或 sell")
ErrInvalidQuantity = errors.New("數量必須是正整數")
ErrInsufficientFunds = errors.New("餘額不足")
ErrInsufficientHolding = errors.New("持股不足")
)
type OrderService struct {
db *sqlx.DB
accounts *repository.AccountRepository
holdings *repository.HoldingRepository
orders *repository.OrderRepository
txns *repository.TransactionRepository
quotes QuoteService // 用介面:成交價來自報價服務
}
func NewOrderService(
db *sqlx.DB,
accounts *repository.AccountRepository,
holdings *repository.HoldingRepository,
orders *repository.OrderRepository,
txns *repository.TransactionRepository,
quotes QuoteService,
) *OrderService {
return &OrderService{db: db, accounts: accounts, holdings: holdings, orders: orders, txns: txns, quotes: quotes}
}
3-2 PlaceOrder 主流程
接在同一個檔案後面:
// PlaceOrder 以市價下單(side: "buy"/"sell"),成功回傳成交的訂單
func (s *OrderService) PlaceOrder(userID int64, symbol, side string, quantity int64) (*model.Order, error) {
// --- 輸入正規化與驗證 ---
side = strings.ToLower(side)
if side != "buy" && side != "sell" {
return nil, ErrInvalidSide
}
if quantity <= 0 {
return nil, ErrInvalidQuantity
}
symbol = strings.ToUpper(symbol)
// --- 取得當下報價,決定成交價與總額 ---
quote, err := s.quotes.GetQuote(symbol)
if err != nil {
return nil, err // 查無代號 → ErrQuoteNotFound
}
price := quote.Price
totalAmount := price * quantity
// --- 開交易 ---
tx, err := s.db.Beginx()
if err != nil {
return nil, fmt.Errorf("開始交易失敗: %w", err)
}
defer tx.Rollback() // 沒 Commit 成功就會自動 rollback
// --- 鎖定帳戶:序列化同一使用者的並發下單 ---
account, err := s.accounts.GetByUserIDForUpdate(tx, userID)
if err != nil {
return nil, fmt.Errorf("查詢帳戶失敗: %w", err)
}
var newBalance int64
if side == "buy" {
// 1) 檢查餘額
if account.CashBalance < totalAmount {
return nil, ErrInsufficientFunds
}
// 2) 扣款
newBalance, err = s.accounts.AddBalance(tx, account.ID, -totalAmount)
if err != nil {
return nil, fmt.Errorf("扣款失敗: %w", err)
}
// 3) 更新持股(新增或加碼重算均價)
if err := s.applyBuy(tx, account.ID, symbol, quantity, price); err != nil {
return nil, err
}
} else { // sell
// 1) 鎖定並取得持股
holding, err := s.holdings.GetForUpdate(tx, account.ID, symbol)
if err != nil {
if errors.Is(err, sql.ErrNoRows) {
return nil, ErrInsufficientHolding // 根本沒持有
}
return nil, fmt.Errorf("查詢持股失敗: %w", err)
}
// 2) 檢查持股數量
if holding.Quantity < quantity {
return nil, ErrInsufficientHolding
}
// 3) 入帳
newBalance, err = s.accounts.AddBalance(tx, account.ID, totalAmount)
if err != nil {
return nil, fmt.Errorf("入帳失敗: %w", err)
}
// 4) 更新持股(賣完就刪、沒賣完就減)
remaining := holding.Quantity - quantity
if remaining == 0 {
if err := s.holdings.Delete(tx, account.ID, symbol); err != nil {
return nil, fmt.Errorf("刪除持股失敗: %w", err)
}
} else {
if err := s.holdings.UpdateQuantity(tx, account.ID, symbol, remaining); err != nil {
return nil, fmt.Errorf("更新持股失敗: %w", err)
}
}
}
// --- 寫入訂單 ---
created, err := s.orders.Create(tx, &model.Order{
AccountID: account.ID,
Symbol: symbol,
Side: side,
Quantity: quantity,
Price: price,
TotalAmount: totalAmount,
Status: "filled",
})
if err != nil {
return nil, fmt.Errorf("建立訂單失敗: %w", err)
}
// --- 寫入資金 ledger(買為負、賣為正,並關聯這筆 order)---
ledgerAmount := totalAmount
if side == "buy" {
ledgerAmount = -totalAmount
}
err = s.txns.Create(tx, account.ID, side, ledgerAmount, newBalance,
sql.NullInt64{Int64: created.ID, Valid: true})
if err != nil {
return nil, fmt.Errorf("寫入交易紀錄失敗: %w", err)
}
// --- 全部成功,提交 ---
if err := tx.Commit(); err != nil {
return nil, fmt.Errorf("提交交易失敗: %w", err)
}
return created, nil
}
// applyBuy 買進後更新持股:沒持有就新增,已持有就加碼並重算「加權平均成本」
func (s *OrderService) applyBuy(tx repository.DBTX, accountID int64, symbol string, quantity, price int64) error {
existing, err := s.holdings.GetForUpdate(tx, accountID, symbol)
if err != nil {
if errors.Is(err, sql.ErrNoRows) {
// 全新持股:均價就是這次買價
return s.holdings.Upsert(tx, accountID, symbol, quantity, price)
}
return fmt.Errorf("查詢持股失敗: %w", err)
}
// 已持有 → 加權平均:(原數量×原均價 + 本次數量×本次價) / 新數量
newQty := existing.Quantity + quantity
newAvg := (existing.Quantity*existing.AvgCost + quantity*price) / newQty
return s.holdings.Upsert(tx, accountID, symbol, newQty, newAvg)
}
把這段讀通,你就掌握了後端交易的精髓。幾個重點:
defer tx.Rollback()(第 3 章學的慣用法)在這裡價值最大:流程中有十幾個return(每個錯誤分支都會 return)。我們完全不用在每個 return 前手動 rollback——defer保證函式一結束就 rollback;只有最後Commit()成功的那條路徑,rollback 才變成無效操作。- 先鎖帳戶
GetByUserIDForUpdate:這把鎖序列化了同一使用者的所有下單。所以「檢查餘額 → 扣款」「檢查持股 → 賣出」這些「先檢查再修改」的動作都是安全的,不會被並發插隊。 - 買賣的對稱性:買→檢查餘額、扣款、加股;賣→檢查持股、入帳、減股。
- 加權平均成本:用整數運算
(原數量×原均價 + 本次數量×本次價) / 新數量。整數除法會無條件捨去小數(最多差 1 分),對教學專案可接受。 - 寫 order 要在寫 transaction 之前:因為 transaction 的
reference_id要關聯 order 的id。 - 全部用
tx:每個 repository 呼叫都傳tx(不是s.db),確保它們都在同一筆交易裡。這就是第 3 章DBTX介面的威力——同一個 repository 方法,平常傳db、交易時傳tx。
🔁 JS 對照 對應的 Node 寫法會是一個大
try { BEGIN; ...一堆 await; COMMIT } catch { ROLLBACK; throw } finally { release }。Go 用defer tx.Rollback()把「最容易忘記的 ROLLBACK」變成自動且絕不遺漏,程式反而更安全、更好讀。
步驟 4:handler(下單入口)
建立 internal/handler/order_handler.go:
// internal/handler/order_handler.go
package handler
import (
"errors"
"net/http"
"github.com/gin-gonic/gin"
"no-logic-trade/internal/model"
"no-logic-trade/internal/service"
)
type OrderHandler struct {
orders *service.OrderService
}
func NewOrderHandler(orders *service.OrderService) *OrderHandler {
return &OrderHandler{orders: orders}
}
type placeOrderRequest struct {
Symbol string `json:"symbol" binding:"required"`
Side string `json:"side" binding:"required"`
Quantity int64 `json:"quantity" binding:"required,gt=0"`
}
func orderResponse(o *model.Order) gin.H {
return gin.H{
"id": o.ID,
"symbol": o.Symbol,
"side": o.Side,
"quantity": o.Quantity,
"price_cents": o.Price,
"price": formatCents(o.Price),
"total_amount_cents": o.TotalAmount,
"total_amount": formatCents(o.TotalAmount),
"status": o.Status,
"created_at": o.CreatedAt,
}
}
// POST /api/orders
func (h *OrderHandler) Place(c *gin.Context) {
var req placeOrderRequest
if err := c.ShouldBindJSON(&req); err != nil {
respondError(c, http.StatusBadRequest, err.Error())
return
}
userID := c.GetInt64("userID")
order, err := h.orders.PlaceOrder(userID, req.Symbol, req.Side, req.Quantity)
if err != nil {
switch {
case errors.Is(err, service.ErrQuoteNotFound):
respondError(c, http.StatusBadRequest, "查無此股票代號")
case errors.Is(err, service.ErrInvalidSide), errors.Is(err, service.ErrInvalidQuantity):
respondError(c, http.StatusBadRequest, err.Error())
case errors.Is(err, service.ErrInsufficientFunds):
respondError(c, http.StatusUnprocessableEntity, "餘額不足")
case errors.Is(err, service.ErrInsufficientHolding):
respondError(c, http.StatusUnprocessableEntity, "持股不足")
default:
respondError(c, http.StatusInternalServerError, "下單失敗")
}
return
}
c.JSON(http.StatusCreated, gin.H{"order": orderResponse(order)})
}
重點:
switch { case ... }(沒有條件的 switch):等同於一串if/else if,但更整齊。我們用它把不同的業務錯誤對應到不同 HTTP 狀態碼。- 狀態碼語意:
- 輸入格式 / 代號錯 →
400 Bad Request。 - 餘額不足 / 持股不足 →
422 Unprocessable Entity(請求格式正確,但業務規則不允許)。 - 其他非預期錯 →
500。
- 輸入格式 / 代號錯 →
🔁 JS 對照 Go 的
switch { case cond1: ...; case cond2: ... }≈ JS 的switch (true) { case cond1: ... },或一串if/else if。用來把多種錯誤分流到不同回應,比一堆巢狀 if 好讀。
步驟 5:組裝路由
更新 setupRouter(下單需登入):
// cmd/api/main.go (只列變動處)
func setupRouter(db *sqlx.DB, cfg *config.Config) *gin.Engine {
// --- repository 層 ---
userRepo := repository.NewUserRepository()
accountRepo := repository.NewAccountRepository()
transactionRepo := repository.NewTransactionRepository()
holdingRepo := repository.NewHoldingRepository() // ★新增
orderRepo := repository.NewOrderRepository() // ★新增
// --- service 層 ---
authService := service.NewAuthService(db, userRepo, accountRepo, cfg.JWTSecret)
accountService := service.NewAccountService(db, accountRepo, transactionRepo)
quoteService := service.NewMemoryQuoteService()
quoteService.StartPriceSimulation(5 * time.Second)
orderService := service.NewOrderService( // ★新增
db, accountRepo, holdingRepo, orderRepo, transactionRepo, quoteService,
)
// --- handler 層 ---
authHandler := handler.NewAuthHandler(authService)
accountHandler := handler.NewAccountHandler(accountService)
quoteHandler := handler.NewQuoteHandler(quoteService)
orderHandler := handler.NewOrderHandler(orderService) // ★新增
// --- 路由 ---
r := gin.Default()
r.GET("/health", func(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{"status": "ok"})
})
api := r.Group("/api")
{
api.POST("/auth/register", authHandler.Register)
api.POST("/auth/login", authHandler.Login)
api.GET("/quotes", quoteHandler.List)
api.GET("/quotes/:symbol", quoteHandler.Get)
authed := api.Group("")
authed.Use(middleware.AuthRequired(authService))
{
authed.GET("/me", authHandler.Me)
authed.POST("/account/deposit", accountHandler.Deposit)
authed.GET("/account/balance", accountHandler.Balance)
authed.POST("/orders", orderHandler.Place) // ★新增
}
}
return r
}
注意 orderService 同時用到了 accountRepo、holdingRepo、orderRepo、transactionRepo 與 quoteService——它是把前幾章的零件組合起來的地方。
✅ 如何執行 / 驗證這一章成功
啟動並登入、確認餘額充足(沿用 alice,第 4 章已有 1500.50;不夠就再入金):
go run ./cmd/api
TOKEN=$(curl -s -X POST http://localhost:8080/api/auth/login \
-H "Content-Type: application/json" \
-d '{"email":"alice@example.com","password":"secret123"}' | sed 's/.*"token":"//;s/".*//')
# 保險起見再入金 10000 元
curl -s -X POST http://localhost:8080/api/account/deposit \
-H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
-d '{"amount":10000}' | grep -o '"cash_balance":"[^"]*"'
驗證 1:市價買進 5 股 AAPL
curl -X POST http://localhost:8080/api/orders \
-H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
-d '{"symbol":"AAPL","side":"buy","quantity":5}'
預期(201;price 為當下報價,total = price × 5):
{"order":{
"id":1,"symbol":"AAPL","side":"buy","quantity":5,
"price":"195.50","price_cents":19550,
"total_amount":"977.50","total_amount_cents":97750,
"status":"filled","created_at":"2026-..."
}}
驗證 2:餘額有扣、持股有增
curl -s http://localhost:8080/api/account/balance -H "Authorization: Bearer $TOKEN"
docker compose exec db psql -U nolologic -d nolologic \
-c "SELECT symbol, quantity, avg_cost FROM holdings;"
預期餘額比下單前少了約 977.50;持股出現:
symbol | quantity | avg_cost
--------+----------+----------
AAPL | 5 | 19550
驗證 3:市價賣出 2 股
curl -X POST http://localhost:8080/api/orders \
-H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
-d '{"symbol":"AAPL","side":"sell","quantity":2}'
之後再查持股,數量應變成 3:
docker compose exec db psql -U nolologic -d nolologic \
-c "SELECT symbol, quantity FROM holdings;"
symbol | quantity
--------+----------
AAPL | 3
驗證 4:餘額不足會被擋(422)
買一個天價數量:
curl -i -X POST http://localhost:8080/api/orders \
-H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
-d '{"symbol":"AAPL","side":"buy","quantity":999999}'
預期:
HTTP/1.1 422 Unprocessable Entity
...
{"error":"餘額不足"}
驗證 5:持股不足會被擋(422)
賣超過持有的數量:
curl -i -X POST http://localhost:8080/api/orders \
-H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
-d '{"symbol":"AAPL","side":"sell","quantity":999}'
預期 422、{"error":"持股不足"}。
驗證 6:原子性(最重要的驗證)
買賣各幾筆後,看資金 ledger,每一筆都該對應一筆 order,且 balance_after 連續正確:
docker compose exec db psql -U nolologic -d nolologic \
-c "SELECT type, amount, balance_after, reference_id FROM transactions ORDER BY id;"
預期看到 deposit 全部 reference_id 為空、buy(負)/sell(正)都帶著對應的 order id,且沒有任何「扣了款卻沒下單」的孤兒資料——因為它們都在同一筆交易裡,要嘛全有、要嘛全無。
六項都通過,代表市價買賣、餘額/持股檢查、加權均價、行鎖、以及交易原子性都正確。🎉 這是整個後端最硬的一塊。
這一章你完成了什麼
- 用一筆資料庫交易把「扣款 / 加股 / 寫單 / 記帳」綁成原子操作,確保不會出現「錢消失」的半套狀態。
- 用
SELECT ... FOR UPDATE行鎖序列化同一使用者的並發下單,讓「先檢查再修改」安全無虞。 - 實作買賣對稱邏輯、加權平均成本、
INSERT ... ON CONFLICT DO UPDATE(upsert)。 - 再次見證
defer tx.Rollback()慣用法在「多分支、多 return」時的價值。 - 用無條件
switch把多種業務錯誤分流到 400 / 422 / 500。
下一步 → 第 7 章:持倉與投資組合查詢 我們會把持股結合即時報價,算出每檔的市值、成本、未實現損益,以及整個投資組合的總市值與總損益——你會看到「跨 service 組合資料」與「回應 DTO 與 DB model 分離」的實際做法。