第 8 章:交易紀錄查詢

第 8 章:交易紀錄查詢

這一章在做什麼? 我們要提供兩支查詢:/orders 列出歷史成交、/transactions 列出資金異動(ledger)。兩者都支援分頁limit / offset)與篩選(訂單可依 symbol / side、資金可依 type)。

這章的學習重點? 三件實務常見的事:(1)從 query string 安全地解析與驗證參數(含預設值與上限);(2)依「有沒有帶某個篩選」動態組裝 SQL 條件,同時仍用佔位符防注入;(3)把第 4 章那個帶 sql.NullInt64、長相有點醜的 transactions,用乾淨的 DTO 呈現成 order_id: number | null


步驟 1:repository — 動態條件 + 分頁 + 總數

我們要的查詢長這樣:「某帳戶的訂單,(可選)只看某 symbol、(可選)只看 buy 或 sell,依時間新到舊,取第 offset 筆起的 limit 筆,並回報符合條件的總數。」

1-1 OrderRepository 新增 ListByAccount

更新 internal/repository/order_repository.go(注意 import 多了 fmtstrings):

// internal/repository/order_repository.go
package repository

import (
	"fmt"
	"strings"

	"no-logic-trade/internal/model"
)

// ... OrderRepository struct / New / Create 維持不變 ...

// OrderFilter 查詢訂單的條件(空字串 = 不套用該篩選)
type OrderFilter struct {
	AccountID int64
	Symbol    string
	Side      string
	Limit     int
	Offset    int
}

// ListByAccount 依條件查訂單,回傳該頁資料與符合條件的總筆數
func (r *OrderRepository) ListByAccount(q DBTX, f OrderFilter) ([]model.Order, int, error) {
	// 動態組 WHERE:先放固定條件,再依有帶的篩選逐一追加
	where := []string{"account_id = $1"}
	args := []interface{}{f.AccountID}
	idx := 2

	if f.Symbol != "" {
		where = append(where, fmt.Sprintf("symbol = $%d", idx))
		args = append(args, f.Symbol)
		idx++
	}
	if f.Side != "" {
		where = append(where, fmt.Sprintf("side = $%d", idx))
		args = append(args, f.Side)
		idx++
	}
	whereClause := strings.Join(where, " AND ")

	// 1) 先算符合條件的總數(用目前的 args,不含 limit/offset)
	var total int
	if err := q.Get(&total, "SELECT COUNT(*) FROM orders WHERE "+whereClause, args...); err != nil {
		return nil, 0, err
	}

	// 2) 再查這一頁(把 limit/offset 接在 args 後面)
	listSQL := fmt.Sprintf(
		"SELECT * FROM orders WHERE %s ORDER BY created_at DESC, id DESC LIMIT $%d OFFSET $%d",
		whereClause, idx, idx+1,
	)
	args = append(args, f.Limit, f.Offset)

	var orders []model.Order
	if err := q.Select(&orders, listSQL, args...); err != nil {
		return nil, 0, err
	}
	return orders, total, nil
}

逐段解釋:

  • 動態 WHERE:用一個 where []string 收集條件、一個 args []interface{} 收集對應的值,兩者一起長大。有帶 symbol 才追加 symbol = $2,沒帶就跳過。
  • idx 追蹤佔位符編號:每加一個條件,$idx 就往上數。最後 limit / offset$idx / $idx+1
  • 值永遠走 args、用佔位符:我們只動態拼接「欄位名與佔位符」這種固定字串,使用者輸入的「值」一律透過 args 交給資料庫——所以即使 symbol 帶惡意字串也不會 SQL injection。
  • 回傳 (資料, 總數, error):總數讓前端能算「共幾頁」。Count 與 List 共用同一個 whereClause,條件保證一致。
  • ORDER BY created_at DESC, id DESC:新到舊;加 id DESC 當第二排序鍵,確保同秒的紀錄順序也穩定。

🔁 JS 對照 在 Node(node-postgres / Knex)你也是這樣:用陣列收集 WHERE 片段與 params,最後 query(text, params)關鍵原則跨語言一致:拼接的只能是「結構」(欄位、佔位符),「值」永遠走參數陣列。 千萬別 `WHERE symbol = '${symbol}'` ——那就是 SQL injection 的破口。

1-2 TransactionRepository 新增 ListByAccount

更新 internal/repository/transaction_repository.go(import 多了 fmtstrings):

// internal/repository/transaction_repository.go
package repository

import (
	"database/sql"
	"fmt"
	"strings"

	"no-logic-trade/internal/model"
)

// ... TransactionRepository struct / New / Create 維持不變 ...

type TransactionFilter struct {
	AccountID int64
	Type      string // 可選:deposit / buy / sell
	Limit     int
	Offset    int
}

func (r *TransactionRepository) ListByAccount(q DBTX, f TransactionFilter) ([]model.Transaction, int, error) {
	where := []string{"account_id = $1"}
	args := []interface{}{f.AccountID}
	idx := 2

	if f.Type != "" {
		where = append(where, fmt.Sprintf("type = $%d", idx))
		args = append(args, f.Type)
		idx++
	}
	whereClause := strings.Join(where, " AND ")

	var total int
	if err := q.Get(&total, "SELECT COUNT(*) FROM transactions WHERE "+whereClause, args...); err != nil {
		return nil, 0, err
	}

	listSQL := fmt.Sprintf(
		"SELECT * FROM transactions WHERE %s ORDER BY created_at DESC, id DESC LIMIT $%d OFFSET $%d",
		whereClause, idx, idx+1,
	)
	args = append(args, f.Limit, f.Offset)

	var txns []model.Transaction
	if err := q.Select(&txns, listSQL, args...); err != nil {
		return nil, 0, err
	}
	return txns, total, nil
}

結構跟訂單那支一模一樣,只是篩選欄位換成 type

注意:TransactionReferenceIDsql.NullInt64q.Select 把整列掃進 struct 時,DB 的 NULL 會讓 ReferenceID.Valid = false,有值則 Valid = trueInt64 為該值——這就是 sql.NullInt64 的用途(第 4 章)。


步驟 2:service — 兩支查詢方法

不必新增 service,直接在既有的兩個 service 加方法。

internal/service/order_service.go 新增(檔案已 import stringsrepository):

// ListOrders 查某使用者的成交紀錄
func (s *OrderService) ListOrders(userID int64, symbol, side string, limit, offset int) ([]model.Order, int, error) {
	account, err := s.accounts.GetByUserID(s.db, userID)
	if err != nil {
		return nil, 0, err
	}
	return s.orders.ListByAccount(s.db, repository.OrderFilter{
		AccountID: account.ID,
		Symbol:    strings.ToUpper(symbol), // "" 仍是 "",代表不篩
		Side:      strings.ToLower(side),
		Limit:     limit,
		Offset:    offset,
	})
}

internal/service/account_service.go 新增(記得 import 補上 strings):

// ListTransactions 查某使用者的資金異動紀錄
func (s *AccountService) ListTransactions(userID int64, txType string, limit, offset int) ([]model.Transaction, int, error) {
	account, err := s.accounts.GetByUserID(s.db, userID)
	if err != nil {
		return nil, 0, err
	}
	return s.transactions.ListByAccount(s.db, repository.TransactionFilter{
		AccountID: account.ID,
		Type:      strings.ToLower(txType),
		Limit:     limit,
		Offset:    offset,
	})
}

兩支都是「先用 userID 找到帳戶、再帶條件查」。


步驟 3:handler — 解析 query string + 乾淨 DTO

3-1 共用:分頁參數解析

internal/handler/handler.go 補上分頁解析工具(import 加 strconv):

// internal/handler/handler.go
package handler

import (
	"fmt"
	"strconv"

	"github.com/gin-gonic/gin"
)

// ... respondError / formatCents 維持不變 ...

// parsePagination 從 query string 解析 limit / offset,套用預設值與上下限
func parsePagination(c *gin.Context) (limit, offset int) {
	limit, offset = 20, 0 // 預設:一頁 20 筆、從第 0 筆起

	if n, err := strconv.Atoi(c.Query("limit")); err == nil {
		limit = n
	}
	if n, err := strconv.Atoi(c.Query("offset")); err == nil {
		offset = n
	}

	// 夾在合理範圍:limit 1~100、offset 不為負
	if limit < 1 {
		limit = 1
	}
	if limit > 100 {
		limit = 100
	}
	if offset < 0 {
		offset = 0
	}
	return
}
  • c.Query("limit"):取 query string 參數(沒帶就是空字串)。strconv.Atoi 把字串轉成 int,轉失敗就維持預設值。
  • 夾範圍很重要:別讓人傳 limit=999999 把整張表撈出來拖垮 DB;也擋掉負數。

🔁 JS 對照 c.Query("limit")req.query.limitstrconv.AtoiparseInt。「給預設值 + 夾上下限」的防呆,跟你在 Express 寫 Math.min(100, Math.max(1, parseInt(req.query.limit) || 20)) 是同一回事。

3-2 訂單列表(GET /orders)

internal/handler/order_handler.go 新增 List

// GET /api/orders?symbol=&side=&limit=&offset=
func (h *OrderHandler) List(c *gin.Context) {
	userID := c.GetInt64("userID")
	symbol := c.Query("symbol")
	side := c.Query("side")
	limit, offset := parsePagination(c)

	orders, total, err := h.orders.ListOrders(userID, symbol, side, limit, offset)
	if err != nil {
		respondError(c, http.StatusInternalServerError, "查詢訂單失敗")
		return
	}

	items := make([]gin.H, 0, len(orders))
	for _, o := range orders {
		items = append(items, orderResponse(&o)) // 重用第 6 章的 orderResponse
	}

	c.JSON(http.StatusOK, gin.H{
		"orders":     items,
		"pagination": gin.H{"total": total, "limit": limit, "offset": offset},
	})
}
  • 重用第 6 章寫好的 orderResponse,不必再寫一次格式化。
  • for _, o := range orders { ... &o ... }:Go 1.22 起,迴圈變數每輪都是新的,所以這裡取 &o 是安全的(在更早的 Go 版本這樣寫會踩到「所有指標都指到同一個變數」的雷)。

3-3 資金異動列表(GET /transactions)

internal/handler/account_handler.go 新增「乾淨 DTO」與 ListTransactions

// transactionResponse 把帶 sql.NullInt64 的紀錄,轉成乾淨 JSON:
// reference_id 有值 → order_id 為數字;無值 → order_id 為 null
func transactionResponse(t *model.Transaction) gin.H {
	var orderID interface{} // 預設 nil → JSON null
	if t.ReferenceID.Valid {
		orderID = t.ReferenceID.Int64
	}
	return gin.H{
		"id":            t.ID,
		"type":          t.Type,
		"amount":        formatCents(t.Amount),
		"amount_cents":  t.Amount,
		"balance_after": formatCents(t.BalanceAfter),
		"order_id":      orderID,
		"created_at":    t.CreatedAt,
	}
}

// GET /api/transactions?type=&limit=&offset=
func (h *AccountHandler) ListTransactions(c *gin.Context) {
	userID := c.GetInt64("userID")
	txType := c.Query("type")
	limit, offset := parsePagination(c)

	txns, total, err := h.account.ListTransactions(userID, txType, limit, offset)
	if err != nil {
		respondError(c, http.StatusInternalServerError, "查詢交易紀錄失敗")
		return
	}

	items := make([]gin.H, 0, len(txns))
	for _, t := range txns {
		items = append(items, transactionResponse(&t))
	}

	c.JSON(http.StatusOK, gin.H{
		"transactions": items,
		"pagination":   gin.H{"total": total, "limit": limit, "offset": offset},
	})
}
  • transactionResponse 就是「乾淨 DTO」的價值所在sql.NullInt64 直接序列化成 JSON 會變成 {"Int64":3,"Valid":true} 這種醜東西。我們用一個 interface{}(預設 nil),有值才填數字——前端拿到的就是清爽的 "order_id": 3"order_id": null
  • var orderID interface{}:Go 的 interface{}(空介面)可裝任何值,零值是 nil。塞進 JSON 時 nilnull

🔁 JS 對照 interface{} ≈ TypeScript 的 any / unknown,零值 nilnull。這段等於你在 Node 手寫的 order_id: row.reference_id ?? null——把資料庫的 nullable 欄位,整理成前端好用的形狀。


步驟 4:組裝路由

這次不需要新的 service / handler,只在 setupRouter 的受保護群組多掛兩條路由:

// cmd/api/main.go (authed 群組內新增兩行)
		authed.GET("/orders", orderHandler.List)                 // ★新增
		authed.GET("/transactions", accountHandler.ListTransactions) // ★新增

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

啟動、登入(沿用 alice,前面已有幾筆買賣與入金):

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/".*//')

驗證 1:列出成交紀錄(新到舊)

curl "http://localhost:8080/api/orders" -H "Authorization: Bearer $TOKEN"

預期(前面做過 買AAPL / 賣AAPL / 買TSLA 共 3 筆,最新的在最前):

{
  "orders":[
    {"id":3,"symbol":"TSLA","side":"buy","quantity":2, ...},
    {"id":2,"symbol":"AAPL","side":"sell","quantity":2, ...},
    {"id":1,"symbol":"AAPL","side":"buy","quantity":5, ...}
  ],
  "pagination":{"total":3,"limit":20,"offset":0}
}

驗證 2:依 symbol 篩選

curl "http://localhost:8080/api/orders?symbol=AAPL" -H "Authorization: Bearer $TOKEN"

預期只剩 AAPL 的兩筆,pagination.total 為 2。

驗證 3:依 side 篩選 + 分頁

curl "http://localhost:8080/api/orders?side=buy&limit=1&offset=0" -H "Authorization: Bearer $TOKEN"

預期:orders 只回 1 筆(最新的買單 TSLA),但 pagination.total 為 2(buy 共 2 筆)——證明分頁與總數都正確。

驗證 4:資金異動(乾淨的 order_id)

curl "http://localhost:8080/api/transactions" -H "Authorization: Bearer $TOKEN"

預期能看到 depositorder_idnullbuy/sellorder_id 為對應訂單編號:

{
  "transactions":[
    {"id":5,"type":"buy","amount":"-500.06","balance_after":"...","order_id":3, ...},
    {"id":4,"type":"sell","amount":"391.00","balance_after":"...","order_id":2, ...},
    {"id":3,"type":"buy","amount":"-977.50","balance_after":"...","order_id":1, ...},
    {"id":2,"type":"deposit","amount":"10000.00","balance_after":"...","order_id":null, ...},
    {"id":1,"type":"deposit","amount":"1000.00","balance_after":"...","order_id":null, ...}
  ],
  "pagination":{"total":5,"limit":20,"offset":0}
}

驗證 5:依 type 篩選

curl "http://localhost:8080/api/transactions?type=deposit" -H "Authorization: Bearer $TOKEN"

預期只剩入金紀錄。

五項都通過,代表動態篩選、分頁與總數、query string 解析防呆、以及 nullable 欄位的乾淨 DTO 都正確。🎉


這一章你完成了什麼

  • 寫出可重用的「動態 WHERE + 分頁 + 總數」查詢模式,並牢記「拼接結構、值走參數」的防注入原則。
  • 從 query string 安全解析 limit / offset,套用預設值與上下限。
  • interface{}nilnull)把 sql.NullInt64 整理成乾淨的 order_id,再次實踐 DTO 與 DB model 分離。
  • 重用既有的 orderResponse 與 service,體會前面分層設計帶來的複用性。

下一步 → 第 9 章:把 Go app 也容器化,完成一鍵啟動 🎉 到了部署階段,我們才把 Go app 裝進容器:寫 multi-stage build 的 Dockerfile、把 app 與 db 串成完整的 docker-compose、處理「容器間用服務名 db 連線」與環境變數注入,做出 docker compose up --build 就能一鍵啟動的完整專案。