第 4 章:帳戶與資金(入金、餘額查詢)

第 4 章:帳戶與資金(入金、餘額查詢)

這一章在做什麼? 我們替登入後的使用者做兩支 API:入金(增加現金餘額)與查餘額。同時建立一張 transactions(資金異動 ledger)表,把每一筆現金進出都記下來,方便日後對帳與查詢交易紀錄。

為什麼這章很重要? 因為它定下「金額一律用整數的『分』來存與算」這個會貫穿後面所有章節(下單、持倉、損益)的關鍵決定。第一次踩到浮點數算錢的雷,通常都很痛。


步驟 1:先搞懂「金額為什麼不能用浮點數」

電腦的浮點數(float)是二進位近似值,很多十進位小數它存不準。最經典的例子,在幾乎所有語言都成立:

0.1 + 0.2 = 0.30000000000000004   ← 不等於 0.3!

算錢時這種誤差會累積,最後對帳對不起來。業界標準做法:用整數的最小貨幣單位(分 / cents)來存與算。 $1000 就存成 100000(分),$19.99 存成 1999(分)。要顯示給人看時,再除以 100 格式化成 1000.00

我們第 2 章定義 schema 時,cash_balancepriceavg_cost 全都用 BIGINT、單位是分,對應 Go 的 int64——就是為了這個。

🔁 JS 對照 JS 的 number 也是浮點數,一樣有這個問題:

0.1 + 0.2 // 0.30000000000000004
(19.99 * 100) // 1998.9999999999998  ← 注意!

所以 Node 後端算錢同樣會用「整數分」或 BigInt 或 decimal 套件。Go 用 int64 存分,乾淨又快。

💡 那輸入怎麼辦? 使用者還是習慣輸入「元」(例如 1000)。我們的做法是:只在「接收輸入」這個邊界短暫用 float,立刻用 math.Round 轉成整數分;之後所有運算永遠只碰整數,不再碰 float。 步驟 6 會看到。


步驟 2:新增 transactions 資金異動表(migration)

我們要把每一筆現金進出都記成一筆 ledger。建立 migrations/005_create_transactions.sql

-- migrations/005_create_transactions.sql
CREATE TABLE transactions (
    id            BIGSERIAL PRIMARY KEY,
    account_id    BIGINT NOT NULL REFERENCES accounts(id) ON DELETE CASCADE,
    type          TEXT NOT NULL CHECK (type IN ('deposit', 'buy', 'sell')),
    amount        BIGINT NOT NULL,   -- 現金變動量(分):正=流入、負=流出
    balance_after BIGINT NOT NULL,   -- 這筆異動之後的餘額(分),方便對帳
    reference_id  BIGINT,            -- 關聯的 order id(買賣時填),入金為 NULL
    created_at    TIMESTAMPTZ NOT NULL DEFAULT now()
);

CREATE INDEX idx_transactions_account ON transactions (account_id, created_at DESC);
  • type:用 CHECK 限定只能是 deposit / buy / sell。本章只會寫入 deposit,第 6 章下單會寫入 buy / sell
  • amount有正負號。入金、賣出是流入(正),買進是流出(負)。
  • balance_after:記下「異動後餘額」,這樣查 ledger 時不必重算就能看到每一步的餘額。
  • reference_id:可為 NULL。買賣時填對應的 order.id,入金時沒有對應訂單就留 NULL
  • 索引 (account_id, created_at DESC):第 8 章「依帳戶查交易紀錄、依時間排序」會用到。

還記得第 2 章的 migrator 嗎?它會在下次啟動時自動套用這個新檔(因為 schema_migrations 裡還沒有 005)。這就是 migration 的價值——schema 隨功能一個檔一個檔長出來,每個環境跑同一批檔就同步。


步驟 3:新增 Transaction 模型

internal/model/model.go 最上方的 import 加入 database/sql,並在檔案最後新增 Transaction struct:

// internal/model/model.go (節錄:import 區與新增的 struct)
import (
	"database/sql"
	"time"
)

// ... User / Account / Holding / Order 維持不變 ...

// Transaction 對應 transactions 表(資金異動紀錄)
type Transaction struct {
	ID           int64         `db:"id" json:"id"`
	AccountID    int64         `db:"account_id" json:"account_id"`
	Type         string        `db:"type" json:"type"`
	Amount       int64         `db:"amount" json:"amount"`
	BalanceAfter int64         `db:"balance_after" json:"balance_after"`
	ReferenceID  sql.NullInt64 `db:"reference_id" json:"reference_id"`
	CreatedAt    time.Time     `db:"created_at" json:"created_at"`
}
  • sql.NullInt64reference_id 在資料庫可以是 NULL。Go 的 int64 不能是 nil,所以用標準庫的 sql.NullInt64——它是一個 struct,內含 Int64 int64Valid bool 兩個欄位(Valid=false 代表 NULL)。
  • 本章只是寫入 transaction,還不會把它序列化成 JSON 回傳,所以先不管 sql.NullInt64 轉成 JSON 會長怎樣(它預設長相有點醜)。第 8 章查交易紀錄時,我們會用一個乾淨的 DTO 來呈現。

🔁 JS 對照 JS 沒有「型別不能為 null」的問題(什麼都能是 null)。Go 因為 int64 是值型別、不能 nil,所以可空的數字欄位要用 sql.NullInt64 這種包裝型別來表達「有值 / 沒值」。你可以把它想成 { value: number, present: boolean }


步驟 4:repository(加錢 + 記帳)

4-1 AccountRepository 新增「原子加減餘額」

internal/repository/account_repository.go 新增一個方法:

// AddBalance 以原子方式增減餘額(delta 可正可負),回傳異動後的新餘額
func (r *AccountRepository) AddBalance(q DBTX, accountID, delta int64) (int64, error) {
	var newBalance int64
	err := q.QueryRowx(
		`UPDATE accounts
		    SET cash_balance = cash_balance + $1,
		        updated_at   = now()
		  WHERE id = $2
		RETURNING cash_balance`,
		delta, accountID,
	).Scan(&newBalance)
	if err != nil {
		return 0, err
	}
	return newBalance, nil
}

為什麼用 cash_balance = cash_balance + $1 而不是先讀出來、在 Go 裡加完再寫回? 因為「先讀再寫」在多個請求同時進來時會互相覆蓋(race condition):兩個入金都讀到 1000、各自算出 1500、各自寫回 1500——其中一筆就憑空消失了。把加法交給資料庫在一個 UPDATE 裡完成,就是原子操作,不會有這個問題。RETURNING cash_balance 順便把新餘額拿回來。

🔁 JS 對照 一樣的道理:在 Node 你也該寫 UPDATE ... SET balance = balance + $1,而不是 SELECT 出來在 JS 加完再 UPDATE。並發下「read-modify-write」會出事,這跟語言無關。

4-2 TransactionRepository(新檔)

建立 internal/repository/transaction_repository.go

// internal/repository/transaction_repository.go
package repository

import "database/sql"

type TransactionRepository struct{}

func NewTransactionRepository() *TransactionRepository {
	return &TransactionRepository{}
}

// Create 寫入一筆資金異動紀錄
func (r *TransactionRepository) Create(
	q DBTX,
	accountID int64,
	txType string,
	amount int64,
	balanceAfter int64,
	referenceID sql.NullInt64,
) error {
	_, err := q.Exec(
		`INSERT INTO transactions (account_id, type, amount, balance_after, reference_id)
		 VALUES ($1, $2, $3, $4, $5)`,
		accountID, txType, amount, balanceAfter, referenceID,
	)
	return err
}
  • 入金時 referenceID 傳一個「無效(NULL)」的 sql.NullInt64{};第 6 章買賣時才會帶上 order id。
  • 回傳只有 error(不需回傳值),因為我們不需要新 id。

步驟 5:service(入金的業務邏輯)

建立 internal/service/account_service.go

// internal/service/account_service.go
package service

import (
	"database/sql"
	"errors"
	"fmt"

	"github.com/jmoiron/sqlx"

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

var ErrInvalidAmount = errors.New("金額必須大於 0")

type AccountService struct {
	db           *sqlx.DB
	accounts     *repository.AccountRepository
	transactions *repository.TransactionRepository
}

func NewAccountService(
	db *sqlx.DB,
	accounts *repository.AccountRepository,
	transactions *repository.TransactionRepository,
) *AccountService {
	return &AccountService{db: db, accounts: accounts, transactions: transactions}
}

// Deposit 入金(amountCents 為整數分),回傳更新後的帳戶
func (s *AccountService) Deposit(userID, amountCents int64) (*model.Account, error) {
	if amountCents <= 0 {
		return nil, ErrInvalidAmount
	}

	// 先找出這個 user 的帳戶
	account, err := s.accounts.GetByUserID(s.db, userID)
	if err != nil {
		return nil, fmt.Errorf("查詢帳戶失敗: %w", err)
	}

	// 開交易:加錢 + 記一筆 ledger,要嘛都成功,要嘛都不留
	tx, err := s.db.Beginx()
	if err != nil {
		return nil, fmt.Errorf("開始交易失敗: %w", err)
	}
	defer tx.Rollback()

	newBalance, err := s.accounts.AddBalance(tx, account.ID, amountCents)
	if err != nil {
		return nil, fmt.Errorf("更新餘額失敗: %w", err)
	}

	// 入金沒有對應訂單,reference_id 留 NULL
	if err := s.transactions.Create(tx, account.ID, "deposit", amountCents, newBalance, sql.NullInt64{}); err != nil {
		return nil, fmt.Errorf("寫入交易紀錄失敗: %w", err)
	}

	if err := tx.Commit(); err != nil {
		return nil, fmt.Errorf("提交交易失敗: %w", err)
	}

	account.CashBalance = newBalance
	return account, nil
}

// GetAccount 取得某 user 的帳戶
func (s *AccountService) GetAccount(userID int64) (*model.Account, error) {
	return s.accounts.GetByUserID(s.db, userID)
}

重點:

  • 入金是「兩個寫入要同時成功」:更新餘額 + 寫 ledger。所以包在一筆交易裡,沿用第 3 章學到的 defer tx.Rollback() + Commit 慣用法。
  • 業務驗證(金額 > 0)放在 service:即使 handler 漏擋,這裡也擋得住(防禦性)。
  • sql.NullInt64{}(零值)的 Validfalse,寫進 DB 就是 NULL

步驟 6:handler(接 HTTP + 邊界轉換 + 金額格式化)

6-1 共用:金額格式化

internal/handler/handler.go 加上 fmt import 與 formatCents

// internal/handler/handler.go
package handler

import (
	"fmt"

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

func respondError(c *gin.Context, status int, message string) {
	c.JSON(status, gin.H{"error": message})
}

// formatCents 把「整數分」格式化成「元.分」字串,例如 100000 → "1000.00"
func formatCents(cents int64) string {
	sign := ""
	if cents < 0 {
		sign = "-"
		cents = -cents
	}
	return fmt.Sprintf("%s%d.%02d", sign, cents/100, cents%100)
}
  • cents/100 取「元」、cents%100 取「分」。%02d 確保分一定是兩位數(例如 5 分顯示成 05)。

6-2 AccountHandler(新檔)

建立 internal/handler/account_handler.go

// internal/handler/account_handler.go
package handler

import (
	"errors"
	"math"
	"net/http"

	"github.com/gin-gonic/gin"

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

type AccountHandler struct {
	account *service.AccountService
}

func NewAccountHandler(account *service.AccountService) *AccountHandler {
	return &AccountHandler{account: account}
}

// 入金請求:amount 以「元」為單位(可含小數)
type depositRequest struct {
	Amount float64 `json:"amount" binding:"required,gt=0"`
}

// balanceResponse 統一餘額的回應格式(同時給整數分與好讀的字串)
func balanceResponse(a *model.Account) gin.H {
	return gin.H{
		"cash_balance_cents": a.CashBalance,        // 真實值:整數分
		"cash_balance":       formatCents(a.CashBalance), // 好讀:例如 "1000.00"
		"currency":           "USD",
	}
}

// POST /api/account/deposit
func (h *AccountHandler) Deposit(c *gin.Context) {
	var req depositRequest
	if err := c.ShouldBindJSON(&req); err != nil {
		respondError(c, http.StatusBadRequest, err.Error())
		return
	}

	userID := c.GetInt64("userID")

	// 邊界轉換:元(float) → 分(int64),用 Round 避免浮點誤差
	amountCents := int64(math.Round(req.Amount * 100))

	account, err := h.account.Deposit(userID, amountCents)
	if err != nil {
		if errors.Is(err, service.ErrInvalidAmount) {
			respondError(c, http.StatusBadRequest, "金額必須大於 0")
			return
		}
		respondError(c, http.StatusInternalServerError, "入金失敗")
		return
	}

	c.JSON(http.StatusOK, balanceResponse(account))
}

// GET /api/account/balance
func (h *AccountHandler) Balance(c *gin.Context) {
	userID := c.GetInt64("userID")

	account, err := h.account.GetAccount(userID)
	if err != nil {
		respondError(c, http.StatusInternalServerError, "查詢餘額失敗")
		return
	}

	c.JSON(http.StatusOK, balanceResponse(account))
}

重點:

  • amountCents := int64(math.Round(req.Amount * 100)):就是步驟 1 說的「在邊界轉換」。math.Round1998.9999... 這種浮點誤差修正回 1999。轉完之後,後面 service / repository / DB 全程都用整數分。
  • balanceResponse 是個小 DTO:同時回傳 cash_balance_cents(程式好用的真實值)與 cash_balance(人好讀的字串)。前端要算就用 cents,要顯示就用字串。
  • userID 來自第 3 章的中介層(c.Set("userID", ...)),所以這兩支 API 必須掛在受保護群組底下。

🔁 JS 對照

const amountCents = Math.round(req.body.amount * 100); // 邊界轉換
// ...service...
res.json({
  cash_balance_cents: account.cashBalance,
  cash_balance: (account.cashBalance / 100).toFixed(2),
  currency: "USD",
});

一模一樣的策略:邊界 Math.round 轉分、輸出時 toFixed(2) 格式化。


步驟 7:組裝路由

main() 不變,只更新 setupRouter(新增的行有標註):

// cmd/api/main.go (只列 setupRouter)
func setupRouter(db *sqlx.DB, cfg *config.Config) *gin.Engine {
	// --- repository 層 ---
	userRepo := repository.NewUserRepository()
	accountRepo := repository.NewAccountRepository()
	transactionRepo := repository.NewTransactionRepository() // ★新增

	// --- service 層 ---
	authService := service.NewAuthService(db, userRepo, accountRepo, cfg.JWTSecret)
	accountService := service.NewAccountService(db, accountRepo, transactionRepo) // ★新增

	// --- handler 層 ---
	authHandler := handler.NewAuthHandler(authService)
	accountHandler := handler.NewAccountHandler(accountService) // ★新增

	// --- 路由 ---
	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)

		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)  // ★新增
		}
	}

	return r
}

看出規律了嗎?每加一個功能就是:多 new 一組 repo/service/handler、多掛幾條路由。 後面每一章都是這個節奏。


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

啟動(會自動套用 005 migration):

go run ./cmd/api

啟動 log 應出現一行:

✅ 已套用 migration: 005_create_transactions.sql

先登入拿 token(沿用第 3 章的 alice):

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

這行用 sed 把回應裡的 token 取出來存進 $TOKEN,省得手動複製。

驗證 1:查初始餘額(應為 0)

curl http://localhost:8080/api/account/balance \
  -H "Authorization: Bearer $TOKEN"

預期:

{"cash_balance":"0.00","cash_balance_cents":0,"currency":"USD"}

驗證 2:入金 1000 元

curl -X POST http://localhost:8080/api/account/deposit \
  -H "Authorization: Bearer $TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"amount":1000}'

預期:

{"cash_balance":"1000.00","cash_balance_cents":100000,"currency":"USD"}

驗證 3:再入金 500.50,確認累加正確

curl -X POST http://localhost:8080/api/account/deposit \
  -H "Authorization: Bearer $TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"amount":500.50}'

預期(1000 + 500.50 = 1500.50):

{"cash_balance":"1500.50","cash_balance_cents":150050,"currency":"USD"}

驗證 4:入金 0 或負數會被擋(400)

curl -X POST http://localhost:8080/api/account/deposit \
  -H "Authorization: Bearer $TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"amount":-100}'

預期(被 binding:"gt=0" 擋下):

{"error":"Key: 'depositRequest.Amount' Error:Field validation for 'Amount' failed on the 'gt' tag"}

驗證 5:資金異動 ledger 有被記下來

docker compose exec db psql -U nolologic -d nolologic \
  -c "SELECT type, amount, balance_after, reference_id FROM transactions ORDER BY id;"

預期看到兩筆入金(reference_id 為空,代表 NULL):

  type   | amount | balance_after | reference_id
---------+--------+---------------+--------------
 deposit | 100000 |        100000 |
 deposit |  50050 |        150050 |

五項都通過,代表入金、餘額查詢、整數分策略、資金 ledger 都正確運作了。🎉


這一章你完成了什麼

  • 確立「金額用整數分(int64)儲存與運算、只在邊界用 math.Round 轉換」的策略。
  • 用新的 migration 長出 transactions 表,再次體會 migration 的增量本質。
  • 學到可空欄位用 sql.NullInt64、以及為什麼餘額要用 SET cash_balance = cash_balance + $1 的原子更新避免並發覆蓋。
  • 用交易同時「加錢 + 記帳」,並用 DTO(balanceResponse)同時回傳真實值與好讀字串。

下一步 → 第 5 章:股票報價(記憶體假資料) 我們會用記憶體假資料模擬即時報價,並藉這個機會深入 Go 的 interface:定義一個 QuoteService 介面 + 記憶體實作,將來要換成真資料源也不必動到 handler。也會碰到 map 的並發安全(sync.RWMutex)。