第 6 章:下單(市價買 / 賣)⭐ 本專案核心

第 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
}
  • UpsertINSERT ... 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:插入後一次拿回整列(含資料庫產生的 idcreated_at),用 sqlx 的 StructScandb tag 塞進 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 同時用到了 accountRepoholdingRepoorderRepotransactionRepoquoteService——它是把前幾章的零件組合起來的地方。


✅ 如何執行 / 驗證這一章成功

啟動並登入、確認餘額充足(沿用 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 分離」的實際做法。