第 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_balance、price、avg_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.NullInt64:reference_id在資料庫可以是NULL。Go 的int64不能是nil,所以用標準庫的sql.NullInt64——它是一個 struct,內含Int64 int64與Valid 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{}(零值)的Valid是false,寫進 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.Round把1998.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)。