diff --git a/pkg/domain/repository/user_wallet_manager.go b/pkg/domain/repository/user_wallet_manager.go new file mode 100644 index 0000000..41ae4a2 --- /dev/null +++ b/pkg/domain/repository/user_wallet_manager.go @@ -0,0 +1,48 @@ +package repository + +import ( + "code.30cm.net/digimon/app-cloudep-wallet-service/pkg/domain/entity" + "code.30cm.net/digimon/app-cloudep-wallet-service/pkg/domain/wallet" + "context" + "github.com/shopspring/decimal" +) + +// UserWalletManager 負責單一使用者對單一資產的錢包操作: +// · 建立初始錢包 +// · 查詢/鎖定餘額 +// · 本地暫存變動(錢包層面與訂單層面) +// · 最後一次性寫回資料庫 +type UserWalletManager interface { + // InitWallets 為新使用者建立所有類型錢包,並寫入資料庫與本地緩存 + InitWallets(ctx context.Context, brand string) ([]entity.Wallet, error) + // FetchAllWallets 查詢資料庫所有錢包類型餘額,不鎖表,並更新本地緩存 + FetchAllWallets(ctx context.Context) ([]entity.Wallet, error) + // FetchWalletsByTypes 查詢指定類型錢包餘額,不鎖表 + FetchWalletsByTypes(ctx context.Context, kinds []wallet.Types) ([]entity.Wallet, error) + // LockWalletsByTypes 查詢並鎖定指定類型錢包 (FOR UPDATE) + LockWalletsByTypes(ctx context.Context, kinds []wallet.Types) ([]entity.Wallet, error) + // GetCachedBalance 從本地緩存讀取指定類型錢包餘額 + GetCachedBalance(kind wallet.Types) decimal.Decimal + // CreditWallet 增加本地緩存錢包餘額,並記錄一筆 WalletTransaction + CreditWallet(kind wallet.Types, orderID string, amount decimal.Decimal) error + // DebitWallet 扣減本地緩存錢包餘額,並記錄一筆 WalletTransaction + DebitWallet(kind wallet.Types, orderID string, amount decimal.Decimal) error + // BuildWalletTransactions 填充所有暫存 WalletTransaction 的共用欄位,回傳可落庫之切片 + BuildWalletTransactions(txID int64, brand string, biz wallet.BusinessName) []entity.WalletTransaction + // CommitWalletBalances 將本地緩存的最終錢包餘額一次性寫回 wallet 表 + CommitWalletBalances(ctx context.Context) error + // FetchOrderTx 從 transaction 表讀取一筆訂單交易,並緩存其後餘額 + FetchOrderTx(ctx context.Context, orderID string) (entity.Transaction, error) + // LockOrderTx 同 FetchOrderTx 但加上 FOR UPDATE + LockOrderTx(ctx context.Context, orderID string) (entity.Transaction, error) + // CreditOrderBalance 增加本地緩存的訂單後餘額 + CreditOrderBalance(txID int64, amount decimal.Decimal) error + // DebitOrderBalance 扣減本地緩存的訂單後餘額 + DebitOrderBalance(txID int64, amount decimal.Decimal) error + // CommitOrderBalances 將本地暫存的訂單後餘額寫回 transaction.post_transfer_balance + CommitOrderBalances(ctx context.Context) error + // HasAvailableWallet 檢查是否已有「可用餘額」類型的錢包 + HasAvailableWallet(ctx context.Context) (bool, error) + // Reset 清空本地所有緩存 + Reset() +} diff --git a/pkg/domain/repository/wallet.go b/pkg/domain/repository/wallet.go index d642c80..0517051 100644 --- a/pkg/domain/repository/wallet.go +++ b/pkg/domain/repository/wallet.go @@ -87,9 +87,9 @@ type UserWalletService interface { // HasAvailableBalance 確認此使用者此資產是否已有可用餘額錢包 HasAvailableBalance(ctx context.Context) (bool, error) // GetOrderBalance 查詢某筆交易(訂單),詳情寫入本地暫存 - GetOrderBalance(ctx context.Context, txID int64) (entity.Transaction, error) + GetOrderBalance(ctx context.Context, orderID string) (entity.Transaction, error) // GetOrderBalanceForUpdate 查詢某筆交易(訂單),詳情寫入本地暫存 (FOR UPDATE) - GetOrderBalanceForUpdate(ctx context.Context, txID int64) (entity.Transaction, error) + GetOrderBalanceForUpdate(ctx context.Context, orderID string) (entity.Transaction, error) // ClearCache 清空本地所有暫存 ClearCache() } diff --git a/pkg/repository/user_wallet.go b/pkg/repository/user_wallet.go index c3a86c1..54e89e4 100644 --- a/pkg/repository/user_wallet.go +++ b/pkg/repository/user_wallet.go @@ -211,10 +211,10 @@ func (s *WalletService) HasAvailableBalance(ctx context.Context) (bool, error) { return exists, nil } -func (s *WalletService) GetOrderBalance(ctx context.Context, txID int64) (entity.Transaction, error) { +func (s *WalletService) GetOrderBalance(ctx context.Context, orderID string) (entity.Transaction, error) { var t entity.Transaction err := s.db.WithContext(ctx). - Where("id = ?", txID). + Where("order_id = ?", orderID). Take(&t).Error if err != nil { return entity.Transaction{}, translateNotFound(err) @@ -224,10 +224,10 @@ func (s *WalletService) GetOrderBalance(ctx context.Context, txID int64) (entity return t, nil } -func (s *WalletService) GetOrderBalanceForUpdate(ctx context.Context, txID int64) (entity.Transaction, error) { +func (s *WalletService) GetOrderBalanceForUpdate(ctx context.Context, orderID string) (entity.Transaction, error) { var t entity.Transaction err := s.db.WithContext(ctx). - Where("id = ?", txID). + Where("order_id = ?", orderID). Clauses(clause.Locking{Strength: "UPDATE"}). Take(&t).Error if err != nil { @@ -248,5 +248,6 @@ func translateNotFound(err error) error { if errors.Is(err, gorm.ErrRecordNotFound) { return repository.ErrRecordNotFound } + return err } diff --git a/pkg/repository/user_wallet_manager.go b/pkg/repository/user_wallet_manager.go new file mode 100644 index 0000000..5341528 --- /dev/null +++ b/pkg/repository/user_wallet_manager.go @@ -0,0 +1,264 @@ +package repository + +import ( + "code.30cm.net/digimon/app-cloudep-wallet-service/pkg/domain/entity" + "code.30cm.net/digimon/app-cloudep-wallet-service/pkg/domain/repository" + "code.30cm.net/digimon/app-cloudep-wallet-service/pkg/domain/wallet" + "context" + "database/sql" + "fmt" + "github.com/shopspring/decimal" + "gorm.io/gorm" + "gorm.io/gorm/clause" + "time" +) + +// userWalletManager 代表一個使用者在某資產上的錢包服務, +// 負責讀取/寫入資料庫並在記憶體暫存變動 +type userWalletManager struct { + db *gorm.DB + uid, asset string // 使用者識別碼, 資產代號 (如 BTC、ETH、TWD) + cacheWallets map[wallet.Types]entity.Wallet // 暫存各類型錢包 + cacheOrderBal map[int64]decimal.Decimal // 暫存訂單後餘額 + pendingTxs []entity.WalletTransaction // 暫存待落庫的錢包交易紀錄 +} + +// NewUserWalletManager 建立一個新的 UserWalletManager +func NewUserWalletManager(db *gorm.DB, uid, asset string) repository.UserWalletManager { + return &userWalletManager{ + db: db, + uid: uid, + asset: asset, + cacheWallets: make(map[wallet.Types]entity.Wallet, len(wallet.AllTypes)), + cacheOrderBal: make(map[int64]decimal.Decimal), + } +} + +// InitWallets 為新使用者建立所有類型錢包,並寫入資料庫與本地緩存 +func (repo *userWalletManager) InitWallets(ctx context.Context, brand string) ([]entity.Wallet, error) { + ws := make([]entity.Wallet, 0, len(wallet.AllTypes)) + for _, t := range wallet.AllTypes { + ws = append(ws, + entity.Wallet{ + Brand: brand, + UID: repo.uid, + Asset: repo.asset, + Balance: decimal.Zero, + Type: t, + }) + } + + if err := repo.db.WithContext(ctx).Create(&ws).Error; err != nil { + return nil, err + } + + for _, w := range ws { + repo.cacheWallets[w.Type] = w + } + + return ws, nil +} + +// FetchAllWallets 查詢資料庫所有錢包類型餘額,不鎖表,並更新本地緩存 +func (repo *userWalletManager) FetchAllWallets(ctx context.Context) ([]entity.Wallet, error) { + out := make([]entity.Wallet, 0, len(wallet.AllTypes)) + + if err := repo.db.WithContext(ctx). + Select("id, asset, balance, type"). + Where("uid = ? AND asset = ?", repo.uid, repo.asset). + Find(&out).Error; err != nil { + return nil, err + } + for _, w := range out { + repo.cacheWallets[w.Type] = w + } + + return out, nil +} + +// FetchWalletsByTypes 查詢指定類型錢包餘額,不鎖表 +func (repo *userWalletManager) FetchWalletsByTypes(ctx context.Context, kinds []wallet.Types) ([]entity.Wallet, error) { + out := make([]entity.Wallet, 0, len(wallet.AllTypes)) + if err := repo.db.WithContext(ctx). + Select("id, asset, balance, type"). + Where("uid = ? AND asset = ?", repo.uid, repo.asset). + Where("type IN ?", kinds). + Find(&out).Error; err != nil { + return nil, translateNotFound(err) + } + for _, w := range out { + repo.cacheWallets[w.Type] = w + } + + return out, nil +} + +// LockWalletsByTypes 查詢並鎖定指定類型錢包 (FOR UPDATE) +func (repo *userWalletManager) LockWalletsByTypes(ctx context.Context, kinds []wallet.Types) ([]entity.Wallet, error) { + out := make([]entity.Wallet, 0, len(wallet.AllTypes)) + + if err := repo.db.WithContext(ctx). + Where("uid = ? AND asset = ?", repo.uid, repo.asset). + Where("type IN ?", kinds). + Clauses(clause.Locking{Strength: "UPDATE"}). + Find(&out).Error; err != nil { + return nil, translateNotFound(err) + } + for _, w := range out { + repo.cacheWallets[w.Type] = w + } + + return out, nil +} + +// GetCachedBalance 從本地緩存讀取指定類型錢包餘額 +func (repo *userWalletManager) GetCachedBalance(kind wallet.Types) decimal.Decimal { + if w, ok := repo.cacheWallets[kind]; ok { + return w.Balance + } + + return decimal.Zero +} + +// CreditWallet 增加本地緩存錢包餘額,並記錄一筆 WalletTransaction +func (repo *userWalletManager) CreditWallet(kind wallet.Types, orderID string, amount decimal.Decimal) error { + w, ok := repo.cacheWallets[kind] + if !ok { + return repository.ErrRecordNotFound + } + + w.Balance = w.Balance.Add(amount) + if w.Balance.LessThan(decimal.Zero) { + return repository.ErrBalanceInsufficient + } + repo.cacheWallets[kind] = w + repo.pendingTxs = append(repo.pendingTxs, entity.WalletTransaction{ + OrderID: orderID, + UID: repo.uid, + WalletType: kind, + Asset: repo.asset, + Amount: amount, + Balance: w.Balance, + }) + + return nil +} + +// DebitWallet 扣減本地緩存錢包餘額,並記錄一筆 WalletTransaction +func (repo *userWalletManager) DebitWallet(kind wallet.Types, orderID string, amount decimal.Decimal) error { + return repo.CreditWallet(kind, orderID, amount.Neg()) +} + +// BuildWalletTransactions 填充所有暫存 WalletTransaction 的共用欄位,回傳可落庫之切片 +func (repo *userWalletManager) BuildWalletTransactions(txID int64, brand string, biz wallet.BusinessName) []entity.WalletTransaction { + for i := range repo.pendingTxs { + repo.pendingTxs[i].TransactionID = txID + repo.pendingTxs[i].Brand = brand + repo.pendingTxs[i].BusinessType = biz.ToInt8() + } + return repo.pendingTxs +} + +// CommitWalletBalances 將本地緩存的最終錢包餘額一次性寫回 wallet 表 +func (repo *userWalletManager) CommitWalletBalances(ctx context.Context) error { + return repo.db.Transaction(func(tx *gorm.DB) error { + for _, w := range repo.cacheWallets { + if err := tx.WithContext(ctx). + Model(&entity.Wallet{}). + Where("id = ?", w.ID). + UpdateColumns(map[string]interface{}{ + "balance": w.Balance, + "update_at": time.Now().Unix(), + }).Error; err != nil { + + return fmt.Errorf("更新錢包 %d 失敗: %w", w.ID, err) + } + } + + return nil + }, &sql.TxOptions{Isolation: sql.LevelReadCommitted}) +} + +// FetchOrderTx 從 transaction 表讀取一筆訂單交易,並緩存其後餘額 +func (repo *userWalletManager) FetchOrderTx(ctx context.Context, orderID string) (entity.Transaction, error) { + var t entity.Transaction + if err := repo.db.WithContext(ctx). + Where("order_id = ?", orderID). + Take(&t).Error; err != nil { + return t, translateNotFound(err) + } + repo.cacheOrderBal[t.ID] = t.PostTransferBalance + + return t, nil +} + +// LockOrderTx 同 FetchOrderTx 但加上 FOR UPDATE +func (repo *userWalletManager) LockOrderTx(ctx context.Context, orderID string) (entity.Transaction, error) { + var t entity.Transaction + + if err := repo.db.WithContext(ctx). + Where("order_id = ?", orderID). + Clauses(clause.Locking{Strength: "UPDATE"}). + Take(&t).Error; err != nil { + + return t, translateNotFound(err) + } + repo.cacheOrderBal[t.ID] = t.PostTransferBalance + + return t, nil +} + +// CreditOrderBalance 增加本地緩存的訂單後餘額 +func (repo *userWalletManager) CreditOrderBalance(txID int64, amount decimal.Decimal) error { + b, ok := repo.cacheOrderBal[txID] + if !ok { + return repository.ErrRecordNotFound + } + nb := b.Add(amount) + if nb.LessThan(decimal.Zero) { + return repository.ErrBalanceInsufficient + } + repo.cacheOrderBal[txID] = nb + + return nil +} + +// DebitOrderBalance 扣減本地緩存的訂單後餘額 +func (repo *userWalletManager) DebitOrderBalance(txID int64, amount decimal.Decimal) error { + return repo.CreditOrderBalance(txID, amount.Neg()) +} + +// CommitOrderBalances 將本地暫存的訂單後餘額寫回 transaction.post_transfer_balance +func (repo *userWalletManager) CommitOrderBalances(ctx context.Context) error { + return repo.db.Transaction(func(tx *gorm.DB) error { + for id, bal := range repo.cacheOrderBal { + if err := tx.WithContext(ctx). + Model(&entity.Transaction{}). + Where("id = ?", id). + UpdateColumn("post_transfer_balance", bal).Error; err != nil { + return fmt.Errorf("更新訂單 %d 後餘額失敗: %w", id, err) + } + } + return nil + }, &sql.TxOptions{Isolation: sql.LevelReadCommitted}) +} + +// HasAvailableWallet 檢查是否已有「可用餘額」類型的錢包 +func (repo *userWalletManager) HasAvailableWallet(ctx context.Context) (bool, error) { + var ok bool + + err := repo.db.WithContext(ctx). + Model(&entity.Wallet{}). + Select("1"). + Where("uid = ? AND asset = ? AND type = ?", repo.uid, repo.asset, wallet.TypeAvailable). + Limit(1). + Scan(&ok).Error + return ok, err +} + +// Reset 清空本地所有緩存 +func (repo *userWalletManager) Reset() { + repo.cacheWallets = make(map[wallet.Types]entity.Wallet, len(wallet.AllTypes)) + repo.cacheOrderBal = make(map[int64]decimal.Decimal) + repo.pendingTxs = nil +} diff --git a/pkg/repository/user_wallet_manager_test.go b/pkg/repository/user_wallet_manager_test.go new file mode 100644 index 0000000..4a47067 --- /dev/null +++ b/pkg/repository/user_wallet_manager_test.go @@ -0,0 +1,1231 @@ +package repository + +import ( + "code.30cm.net/digimon/app-cloudep-wallet-service/pkg/domain/entity" + "code.30cm.net/digimon/app-cloudep-wallet-service/pkg/domain/wallet" + "context" + "github.com/shopspring/decimal" + "github.com/stretchr/testify/assert" + "gorm.io/gorm" + "testing" +) + +func buildSchema(t *testing.T, db *gorm.DB) { + // 先手動建表 + createTable := ` + CREATE TABLE IF NOT EXISTS wallet ( + id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, + brand VARCHAR(50) NOT NULL, + uid VARCHAR(64) NOT NULL, + asset VARCHAR(32) NOT NULL, + balance DECIMAL(30,18) NOT NULL DEFAULT 0, + type TINYINT NOT NULL, + create_at INTEGER NOT NULL DEFAULT 0, + update_at INTEGER NOT NULL DEFAULT 0, + PRIMARY KEY (id), + UNIQUE KEY uq_brand_uid_asset_type (brand, uid, asset, type) + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;` + assert.NoError(t, db.Exec(createTable).Error) +} + +func TestUserWalletManager_InitWallets(t *testing.T) { + _, db, teardown, err := SetupTestWalletRepository() + assert.NoError(t, err) + defer teardown() + + buildSchema(t, db) + type args struct { + uid string + asset string + brand string + } + tests := []struct { + name string + args args + wantCount int + wantErr bool + validateDB bool + }{ + { + name: "正常初始化一次", + args: args{uid: "user1", asset: "BTC", brand: "brandA"}, + wantCount: len(wallet.AllTypes), + }, + { + name: "再次初始化同一 UID/asset/brand,應因 UNIQUE KEY 失敗", + args: args{uid: "user1", asset: "BTC", brand: "brandA"}, + wantErr: true, + validateDB: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + service := NewUserWalletManager(db, tt.args.uid, tt.args.asset) + ctx := context.Background() + + got, err := service.InitWallets(ctx, tt.args.brand) + if tt.wantErr { + assert.Error(t, err) + return + } + assert.NoError(t, err) + // 回傳 slice 長度應等於 AllTypes + assert.Len(t, got, tt.wantCount) + + if tt.validateDB { + // 再查一次 DB,確認確實寫入 + var dbRows []entity.Wallet + err := db.WithContext(ctx). + Where("uid = ? AND asset = ? AND brand = ?", tt.args.uid, tt.args.asset, tt.args.brand). + Find(&dbRows).Error + assert.NoError(t, err) + assert.Len(t, dbRows, tt.wantCount) + // 檢查每一筆都初始為零 + for _, w := range dbRows { + assert.Equal(t, decimal.Zero, w.Balance) + } + } + }) + } +} + +// +//func TestUserWalletManager_FetchAllWallets(t *testing.T) { +// _, db, teardown, err := SetupTestWalletRepository() +// assert.NoError(t, err) +// defer teardown() +// +// // 建表 +// createTable := ` +// CREATE TABLE IF NOT EXISTS wallet ( +// id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, +// brand VARCHAR(50) NOT NULL, +// uid VARCHAR(64) NOT NULL, +// asset VARCHAR(32) NOT NULL, +// balance DECIMAL(30,18) NOT NULL DEFAULT 0, +// type TINYINT NOT NULL, +// create_at INTEGER NOT NULL DEFAULT 0, +// update_at INTEGER NOT NULL DEFAULT 0, +// PRIMARY KEY (id), +// UNIQUE KEY uq_brand_uid_asset_type (brand, uid, asset, type) +// ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;` +// assert.NoError(t, db.Exec(createTable).Error) +// +// type seedRow struct { +// Brand string +// UID string +// Asset string +// Balance decimal.Decimal +// Type wallet.Types +// } +// +// type args struct { +// uid string +// asset string +// brand string +// } +// tests := []struct { +// name string +// args args +// seed []seedRow +// wantCount int +// }{ +// { +// name: "no data returns empty", +// args: args{"u1", "BTC", "b1"}, +// seed: nil, +// wantCount: 0, +// }, +// { +// name: "single user many types", +// args: args{"u1", "BTC", "b1"}, +// seed: []seedRow{ +// {"b1", "u1", "BTC", decimal.NewFromInt(10), wallet.AllTypes[0]}, +// {"b1", "u1", "BTC", decimal.NewFromInt(20), wallet.AllTypes[1]}, +// {"b1", "u1", "BTC", decimal.NewFromInt(30), wallet.AllTypes[2]}, +// }, +// wantCount: 3, +// }, +// { +// name: "mixed users and assets", +// args: args{"u2", "ETH", "b2"}, +// seed: []seedRow{ +// {"b1", "u1", "BTC", decimal.NewFromInt(10), wallet.AllTypes[0]}, +// {"b2", "u2", "ETH", decimal.NewFromInt(15), wallet.AllTypes[1]}, +// {"b2", "u2", "ETH", decimal.NewFromInt(25), wallet.AllTypes[2]}, +// }, +// wantCount: 2, +// }, +// } +// +// for _, tt := range tests { +// t.Run(tt.name, func(t *testing.T) { +// ctx := context.Background() +// // 清空表 +// assert.NoError(t, db.Exec(`DELETE FROM wallet`).Error) +// +// // 插入 seed +// for _, row := range tt.seed { +// err := db.WithContext(ctx).Exec( +// `INSERT INTO wallet (brand, uid, asset, balance, type, create_at, update_at) +// VALUES (?, ?, ?, ?, ?, ?, ?)`, +// row.Brand, row.UID, row.Asset, row.Balance, row.Type, +// time.Now().Unix(), time.Now().Unix(), +// ).Error +// assert.NoError(t, err) +// } +// +// // 建立 service +// svc := NewWalletService(db, tt.args.uid, tt.args.asset) +// +// // 呼叫 GetAllBalances +// got, err := svc.GetAllBalances(ctx) +// assert.NoError(t, err) +// assert.Len(t, got, tt.wantCount) +// +// // 檢查回傳資料與本地快取一致 +// ws := svc.(*WalletService) +// for _, w := range got { +// // 回傳的每筆都應該存在於 localBalances +// cached, ok := ws.localBalances[w.Type] +// assert.True(t, ok) +// assert.Equal(t, w.Balance, cached.Balance) +// assert.Equal(t, w.Asset, cached.Asset) +// assert.Equal(t, w.Type, cached.Type) +// } +// }) +// } +//} +// +//func TestUserWalletManager_GetBalancesForTypes(t *testing.T) { +// _, db, teardown, err := SetupTestWalletRepository() +// assert.NoError(t, err) +// defer teardown() +// +// // 建表 +// createTable := ` +// CREATE TABLE IF NOT EXISTS wallet ( +// id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, +// brand VARCHAR(50) NOT NULL, +// uid VARCHAR(64) NOT NULL, +// asset VARCHAR(32) NOT NULL, +// balance DECIMAL(30,18) NOT NULL DEFAULT 0, +// type TINYINT NOT NULL, +// create_at INTEGER NOT NULL DEFAULT 0, +// update_at INTEGER NOT NULL DEFAULT 0, +// PRIMARY KEY (id), +// UNIQUE KEY uq_brand_uid_asset_type (brand, uid, asset, type) +// ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;` +// assert.NoError(t, db.Exec(createTable).Error) +// +// type seedRow struct { +// Brand string +// UID string +// Asset string +// Balance decimal.Decimal +// Type wallet.Types +// } +// +// tests := []struct { +// name string +// uid string +// asset string +// brand string +// seed []seedRow +// kinds []wallet.Types +// wantCount int +// }{ +// { +// name: "no matching types returns empty", +// uid: "user1", asset: "BTC", brand: "b1", +// seed: []seedRow{ +// {"b1", "user1", "BTC", decimal.NewFromInt(5), wallet.AllTypes[0]}, +// }, +// kinds: []wallet.Types{wallet.AllTypes[1]}, +// wantCount: 0, +// }, +// { +// name: "single type match", +// uid: "user1", asset: "BTC", brand: "b1", +// seed: []seedRow{ +// {"b1", "user1", "BTC", decimal.NewFromInt(5), wallet.AllTypes[0]}, +// {"b1", "user1", "BTC", decimal.NewFromInt(7), wallet.AllTypes[1]}, +// }, +// kinds: []wallet.Types{wallet.AllTypes[1]}, +// wantCount: 1, +// }, +// { +// name: "multiple type matches", +// uid: "user2", asset: "ETH", brand: "b2", +// seed: []seedRow{ +// {"b2", "user2", "ETH", decimal.NewFromInt(3), wallet.AllTypes[0]}, +// {"b2", "user2", "ETH", decimal.NewFromInt(8), wallet.AllTypes[2]}, +// {"b2", "user2", "ETH", decimal.NewFromInt(10), wallet.AllTypes[1]}, +// }, +// kinds: []wallet.Types{wallet.AllTypes[0], wallet.AllTypes[2]}, +// wantCount: 2, +// }, +// } +// +// for _, tt := range tests { +// t.Run(tt.name, func(t *testing.T) { +// ctx := context.Background() +// // 清表 +// assert.NoError(t, db.Exec(`DELETE FROM wallet`).Error) +// +// // 插入種子資料 +// for _, r := range tt.seed { +// assert.NoError(t, db.Exec( +// `INSERT INTO wallet (brand, uid, asset, balance, type, create_at, update_at) +// VALUES (?, ?, ?, ?, ?, ?, ?)`, +// r.Brand, r.UID, r.Asset, r.Balance, r.Type, +// time.Now().Unix(), time.Now().Unix(), +// ).Error) +// } +// +// // 建立 service +// svc := NewWalletService(db, tt.uid, tt.asset) +// +// // 呼叫 GetBalancesForTypes +// got, err := svc.GetBalancesForTypes(ctx, tt.kinds) +// assert.NoError(t, err) +// assert.Len(t, got, tt.wantCount) +// +// // 檢查每筆結果皆正確緩存 +// ws := svc.(*WalletService) +// for _, w := range got { +// cached, ok := ws.localBalances[w.Type] +// assert.True(t, ok) +// assert.Equal(t, w.Balance, cached.Balance) +// assert.Equal(t, w.Asset, cached.Asset) +// } +// }) +// } +//} +// +//func TestForUpdateLockBehavior(t *testing.T) { +// // 建立測試環境 +// _, db, teardown, err := SetupTestWalletRepository() +// assert.NoError(t, err) +// defer teardown() +// +// // 建表 +// createTable := ` +// CREATE TABLE IF NOT EXISTS wallet ( +// id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, +// brand VARCHAR(50) NOT NULL, +// uid VARCHAR(64) NOT NULL, +// asset VARCHAR(32) NOT NULL, +// balance DECIMAL(30,18) NOT NULL DEFAULT 0, +// type TINYINT NOT NULL, +// create_at INTEGER NOT NULL DEFAULT 0, +// update_at INTEGER NOT NULL DEFAULT 0, +// PRIMARY KEY (id), +// UNIQUE KEY uq_brand_uid_asset_type (brand, uid, asset, type) +// ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;` +// assert.NoError(t, db.Exec(createTable).Error) +// +// // 種子資料:一筆 wallet +// ctx := context.Background() +// initial := entity.Wallet{ +// Brand: "b1", +// UID: "user1", +// Asset: "BTC", +// Balance: decimal.NewFromInt(100), +// Type: wallet.AllTypes[0], +// } +// err = db.WithContext(ctx).Create(&initial).Error +// assert.NoError(t, err) +// +// // 讀回自動產生的 ID +// var seeded entity.Wallet +// assert.NoError(t, db.Where("uid = ? AND asset = ?", initial.UID, initial.Asset). +// First(&seeded).Error) +// +// // 開啟第一個 transaction 並 SELECT … FOR UPDATE +// tx1 := db.Begin() +// var locked entity.Wallet +// err = tx1.Clauses(clause.Locking{Strength: "UPDATE"}). +// WithContext(ctx). +// Where("id = ?", seeded.ID). +// Take(&locked).Error +// assert.NoError(t, err) +// +// // 啟動 goroutine 嘗試在第二 transaction 更新該 row +// done := make(chan error, 1) +// go func() { +// tx2 := db.Begin() +// // 試圖更新,被 FOR UPDATE 鎖住前應該會 block +// err := tx2.WithContext(ctx). +// Model(&entity.Wallet{}). +// Where("id = ?", seeded.ID). +// Update("balance", seeded.Balance.Add(decimal.NewFromInt(50))).Error +// done <- err +// }() +// +// // 等 100ms 確認尚未完成 +// time.Sleep(100 * time.Millisecond) +// select { +// case err2 := <-done: +// t.Fatalf("expected update to be blocked, but completed with err=%v", err2) +// default: +// // 正在等待鎖 +// } +// +// // 釋放鎖 +// assert.NoError(t, tx1.Commit().Error) +// +// // 現在第二 transaction 應該很快完成 +// select { +// case err2 := <-done: +// assert.NoError(t, err2) +// case <-time.After(500 * time.Millisecond): +// t.Fatal("update did not complete after lock released") +// } +//} +// +//func TestWalletService_IncreaseBalance(t *testing.T) { +// uid := "user1" +// asset := "BTC" +// +// type testCase struct { +// name string +// kind wallet.Types +// initial *entity.Wallet // nil 表示不存在 +// orderID string +// amount decimal.Decimal +// wantErr error +// wantBalance decimal.Decimal +// wantTxCount int +// } +// tests := []testCase{ +// { +// name: "missing wallet type", +// kind: wallet.AllTypes[0], +// initial: nil, +// orderID: "ord1", +// amount: decimal.NewFromInt(10), +// wantErr: repository.ErrRecordNotFound, +// wantBalance: decimal.Zero, +// wantTxCount: 0, +// }, +// { +// name: "increase from zero", +// kind: wallet.AllTypes[1], +// initial: &entity.Wallet{Balance: decimal.Zero}, +// orderID: "ord2", +// amount: decimal.NewFromInt(15), +// wantErr: nil, +// wantBalance: decimal.NewFromInt(15), +// wantTxCount: 1, +// }, +// { +// name: "successful increment on non-zero", +// kind: wallet.AllTypes[2], +// initial: &entity.Wallet{Balance: decimal.NewFromInt(5)}, +// orderID: "ord3", +// amount: decimal.NewFromInt(7), +// wantErr: nil, +// wantBalance: decimal.NewFromInt(12), +// wantTxCount: 1, +// }, +// { +// name: "insufficient leads to error", +// kind: wallet.AllTypes[2], +// initial: &entity.Wallet{Balance: decimal.NewFromInt(3)}, +// orderID: "ord4", +// amount: decimal.NewFromInt(-5), +// wantErr: repository.ErrBalanceInsufficient, +// wantBalance: decimal.NewFromInt(3), +// wantTxCount: 0, +// }, +// } +// +// for _, tc := range tests { +// t.Run(tc.name, func(t *testing.T) { +// // 準備 WalletService +// svc := NewWalletService(nil, uid, asset).(*WalletService) +// // 初始化本地快取 +// if tc.initial != nil { +// svc.localBalances = map[wallet.Types]entity.Wallet{tc.kind: *tc.initial} +// } else { +// svc.localBalances = map[wallet.Types]entity.Wallet{} +// } +// // 清空交易紀錄 +// svc.transactions = nil +// +// // 執行 +// err := svc.IncreaseBalance(tc.kind, tc.orderID, tc.amount) +// +// // 驗證錯誤 +// if tc.wantErr != nil { +// assert.ErrorIs(t, err, tc.wantErr) +// } else { +// assert.NoError(t, err) +// } +// +// // 驗證快取餘額 +// gotBal := svc.CurrentBalance(tc.kind) +// assert.True(t, gotBal.Equal(tc.wantBalance), "balance = %s, want %s", gotBal, tc.wantBalance) +// +// // 驗證交易筆數 +// assert.Len(t, svc.transactions, tc.wantTxCount) +// if tc.wantTxCount > 0 { +// tx := svc.transactions[0] +// assert.Equal(t, tc.orderID, tx.OrderID) +// assert.Equal(t, uid, tx.UID) +// assert.Equal(t, asset, tx.Asset) +// assert.True(t, tx.Amount.Equal(tc.amount)) +// assert.True(t, tx.Balance.Equal(tc.wantBalance)) +// } +// }) +// } +//} +// +//func TestWalletService_PrepareTransactions(t *testing.T) { +// const ( +// txID = int64(42) +// orderID = "order-123" +// brand = "brandX" +// bizNameStr = "business-test" +// ) +// biz := wallet.BusinessName(bizNameStr) +// +// tests := []struct { +// name string +// initialTxs []entity.WalletTransaction +// wantCount int +// }{ +// { +// name: "no transactions returns empty slice", +// initialTxs: nil, +// wantCount: 0, +// }, +// { +// name: "single transaction is populated", +// initialTxs: []entity.WalletTransaction{ +// { +// OrderID: "placeholder", +// UID: "u1", +// WalletType: wallet.AllTypes[0], +// Asset: "BTC", +// Amount: decimal.NewFromInt(5), +// Balance: decimal.NewFromInt(10), +// }, +// }, +// wantCount: 1, +// }, +// { +// name: "multiple transactions are all populated", +// initialTxs: []entity.WalletTransaction{ +// {UID: "u1", WalletType: wallet.AllTypes[1], Asset: "ETH", Amount: decimal.NewFromInt(1), Balance: decimal.NewFromInt(1)}, +// {UID: "u2", WalletType: wallet.AllTypes[2], Asset: "TWD", Amount: decimal.NewFromInt(2), Balance: decimal.NewFromInt(2)}, +// {UID: "u3", WalletType: wallet.AllTypes[3%len(wallet.AllTypes)], Asset: "USD", Amount: decimal.NewFromInt(3), Balance: decimal.NewFromInt(3)}, +// }, +// wantCount: 3, +// }, +// } +// +// for _, tc := range tests { +// t.Run(tc.name, func(t *testing.T) { +// // 建立 WalletService 並注入初始交易 +// svc := &WalletService{ +// transactions: make([]entity.WalletTransaction, len(tc.initialTxs)), +// } +// copy(svc.transactions, tc.initialTxs) +// +// // 執行 PrepareTransactions +// result := svc.PrepareTransactions(txID, orderID, brand, biz) +// +// // 檢查回傳長度 +// assert.Len(t, result, tc.wantCount) +// assert.Len(t, svc.transactions, tc.wantCount) +// +// // 驗證每筆交易的共用欄位是否正確 +// for i := 0; i < tc.wantCount; i++ { +// tx := result[i] +// assert.Equal(t, txID, tx.TransactionID, "tx[%d].TransactionID", i) +// assert.Equal(t, orderID, tx.OrderID, "tx[%d].OrderID", i) +// assert.Equal(t, brand, tx.Brand, "tx[%d].Brand", i) +// assert.Equal(t, biz.ToInt8(), tx.BusinessType, "tx[%d].BusinessType", i) +// // 原有欄位保持不變 +// assert.Equal(t, tc.initialTxs[i].UID, tx.UID, "tx[%d].UID unchanged", i) +// assert.Equal(t, tc.initialTxs[i].WalletType, tx.WalletType, "tx[%d].WalletType unchanged", i) +// assert.Equal(t, tc.initialTxs[i].Asset, tx.Asset, "tx[%d].Asset unchanged", i) +// assert.True(t, tx.Amount.Equal(tc.initialTxs[i].Amount), "tx[%d].Amount unchanged", i) +// assert.True(t, tx.Balance.Equal(tc.initialTxs[i].Balance), "tx[%d].Balance unchanged", i) +// } +// }) +// } +//} +// +//func TestWalletService_PersistBalances(t *testing.T) { +// _, db, teardown, err := SetupTestWalletRepository() +// assert.NoError(t, err) +// defer teardown() +// +// // helper:删除表、重建表、插入两笔 seed 数据,返回这两笔带 ID 的 slice +// seedWallets := func() []entity.Wallet { +// // DROP + AutoMigrate +// assert.NoError(t, db.Migrator().DropTable(&entity.Wallet{})) +// assert.NoError(t, db.AutoMigrate(&entity.Wallet{})) +// +// base := []entity.Wallet{ +// {UID: "u1", Asset: "BTC", Brand: "b", Balance: decimal.NewFromInt(10), Type: wallet.AllTypes[0]}, +// {UID: "u1", Asset: "BTC", Brand: "b", Balance: decimal.NewFromInt(20), Type: wallet.AllTypes[1]}, +// } +// assert.NoError(t, db.Create(&base).Error) +// return base +// } +// +// type fields struct { +// updates map[wallet.Types]decimal.Decimal +// } +// type want struct { +// final []decimal.Decimal +// err bool +// } +// +// tests := []struct { +// name string +// fields fields +// want want +// }{ +// { +// name: "no local balances → no change", +// fields: fields{updates: map[wallet.Types]decimal.Decimal{}}, +// want: want{ +// final: []decimal.Decimal{decimal.NewFromInt(10), decimal.NewFromInt(20)}, +// err: false, +// }, +// }, +// { +// name: "update both balances", +// fields: fields{updates: map[wallet.Types]decimal.Decimal{ +// wallet.AllTypes[0]: decimal.NewFromInt(15), +// wallet.AllTypes[1]: decimal.NewFromInt(5), +// }}, +// want: want{ +// final: []decimal.Decimal{decimal.NewFromInt(15), decimal.NewFromInt(5)}, +// err: false, +// }, +// }, +// } +// +// for _, tc := range tests { +// t.Run(tc.name, func(t *testing.T) { +// // seed 并拿到带 ID 的记录 +// seed := seedWallets() +// +// // 构造 localBalances:只有 Type 在 updates 里的才覆盖 +// localMap := make(map[wallet.Types]entity.Wallet, len(tc.fields.updates)) +// for _, w := range seed { +// if nb, ok := tc.fields.updates[w.Type]; ok { +// localMap[w.Type] = entity.Wallet{ +// ID: w.ID, +// Balance: nb, +// } +// } +// } +// +// // 初始化 service 并注入 localBalances +// svc := NewWalletService(db, "u1", "BTC").(*WalletService) +// svc.localBalances = localMap +// +// // 执行 PersistBalances +// err := svc.PersistBalances(context.Background()) +// if tc.want.err { +// assert.Error(t, err) +// return +// } +// assert.NoError(t, err) +// +// // 重新查 DB,按 ID 顺序比较余额 +// var got []entity.Wallet +// assert.NoError(t, db. +// Where("uid = ?", "u1"). +// Order("id"). +// Find(&got).Error) +// +// assert.Len(t, got, len(seed)) +// for i, w := range got { +// assert.Truef(t, +// w.Balance.Equal(tc.want.final[i]), +// "第 %d 条记录 (id=%d) 余额 = %s, 期望 %s", +// i, w.ID, w.Balance, tc.want.final[i], +// ) +// } +// }) +// } +//} +// +//func TestWalletService_PersistOrderBalances(t *testing.T) { +// _, db, teardown, err := SetupTestWalletRepository() +// assert.NoError(t, err) +// defer teardown() +// +// // helper:重建 transaction 表並 seed 資料 +// seedTransactions := func() []entity.Transaction { +// // DROP + AutoMigrate +// _ = db.Migrator().DropTable(&entity.Transaction{}) +// assert.NoError(t, db.AutoMigrate(&entity.Transaction{})) +// +// // 插入兩筆 transaction +// base := []entity.Transaction{ +// {PostTransferBalance: decimal.NewFromInt(100)}, +// {PostTransferBalance: decimal.NewFromInt(200)}, +// } +// assert.NoError(t, db.Create(&base).Error) +// return base +// } +// +// type fields struct { +// updates map[int64]decimal.Decimal +// } +// type want struct { +// final []decimal.Decimal +// err bool +// } +// +// tests := []struct { +// name string +// fields fields +// want want +// }{ +// { +// name: "no local order balances → no change", +// fields: fields{updates: map[int64]decimal.Decimal{}}, +// want: want{ +// final: []decimal.Decimal{decimal.NewFromInt(100), decimal.NewFromInt(200)}, +// err: false, +// }, +// }, +// { +// name: "update first order balance only", +// fields: fields{updates: map[int64]decimal.Decimal{ +// 1: decimal.NewFromInt(150), +// }}, +// want: want{ +// final: []decimal.Decimal{decimal.NewFromInt(150), decimal.NewFromInt(200)}, +// err: false, +// }, +// }, +// { +// name: "update both order balances", +// fields: fields{updates: map[int64]decimal.Decimal{ +// 1: decimal.NewFromInt(110), +// 2: decimal.NewFromInt(220), +// }}, +// want: want{ +// final: []decimal.Decimal{decimal.NewFromInt(110), decimal.NewFromInt(220)}, +// err: false, +// }, +// }, +// } +// +// for _, tc := range tests { +// t.Run(tc.name, func(t *testing.T) { +// // seed 資料並拿回 slice(包含自動產生的 ID) +// seed := seedTransactions() +// +// // 建構 localOrderBalances:key 是 seed[i].ID +// localMap := make(map[int64]decimal.Decimal, len(tc.fields.updates)) +// for id, newBal := range tc.fields.updates { +// localMap[id] = newBal +// } +// +// // 初始化 service、注入 localOrderBalances +// svc := NewWalletService(db, "u1", "BTC").(*WalletService) +// svc.localOrderBalances = localMap +// +// // 執行 PersistOrderBalances +// err := svc.PersistOrderBalances(context.Background()) +// if tc.want.err { +// assert.Error(t, err) +// return +// } +// assert.NoError(t, err) +// +// // 依照 seed 的 ID 順序讀回資料庫 +// var got []entity.Transaction +// assert.NoError(t, db. +// Where("1 = 1"). +// Order("id"). +// Find(&got).Error) +// // 長度要和 seed 相同 +// assert.Len(t, got, len(seed)) +// // 比對每一筆 balance +// for i, tr := range got { +// assert.Truef(t, +// tr.PostTransferBalance.Equal(tc.want.final[i]), +// "第 %d 筆交易(id=%d) balance = %s, want %s", +// i, tr.ID, tr.PostTransferBalance, tc.want.final[i], +// ) +// } +// }) +// } +//} +// +//func TestWalletService_HasAvailableBalance(t *testing.T) { +// _, db, teardown, err := SetupTestWalletRepository() +// assert.NoError(t, err) +// defer teardown() +// +// // 建表 +// createTable := ` +// CREATE TABLE IF NOT EXISTS wallet ( +// id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, +// brand VARCHAR(50) NOT NULL, +// uid VARCHAR(64) NOT NULL, +// asset VARCHAR(32) NOT NULL, +// balance DECIMAL(30,18) NOT NULL DEFAULT 0, +// type TINYINT NOT NULL, +// create_at INTEGER NOT NULL DEFAULT 0, +// update_at INTEGER NOT NULL DEFAULT 0, +// PRIMARY KEY (id), +// UNIQUE KEY uq_brand_uid_asset_type (brand, uid, asset, type) +// ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;` +// assert.NoError(t, db.Exec(createTable).Error) +// +// type seedRow struct { +// Brand string +// UID string +// Asset string +// Type wallet.Types +// Balance decimal.Decimal +// } +// +// tests := []struct { +// name string +// uid string +// asset string +// seed []seedRow +// wantExist bool +// }{ +// { +// name: "無任何紀錄", +// uid: "user1", +// asset: "BTC", +// seed: nil, +// wantExist: false, +// }, +// { +// name: "只有其他類型錢包", +// uid: "user1", +// asset: "BTC", +// seed: []seedRow{ +// {"", "user1", "BTC", wallet.TypeFreeze, decimal.Zero}, +// {"", "user1", "BTC", wallet.TypeUnconfirmed, decimal.Zero}, +// }, +// wantExist: false, +// }, +// { +// name: "已有可用錢包", +// uid: "user1", +// asset: "BTC", +// seed: []seedRow{ +// {"", "user1", "BTC", wallet.TypeAvailable, decimal.NewFromInt(10)}, +// }, +// wantExist: true, +// }, +// { +// name: "不同 UID 不算", +// uid: "user2", +// asset: "BTC", +// seed: []seedRow{ +// {"", "user1", "BTC", wallet.TypeAvailable, decimal.NewFromInt(5)}, +// }, +// wantExist: false, +// }, +// { +// name: "不同 Asset 不算", +// uid: "user1", +// asset: "ETH", +// seed: []seedRow{ +// {"", "user1", "BTC", wallet.TypeAvailable, decimal.NewFromInt(5)}, +// }, +// wantExist: false, +// }, +// } +// +// for _, tc := range tests { +// t.Run(tc.name, func(t *testing.T) { +// // 每個子測試前都清空 wallet table +// assert.NoError(t, db.Exec("DELETE FROM wallet").Error) +// +// // seed 資料到 wallet +// for _, r := range tc.seed { +// w := entity.Wallet{ +// Brand: r.Brand, +// UID: r.UID, +// Asset: r.Asset, +// Type: r.Type, +// Balance: r.Balance, +// } +// assert.NoError(t, db.Create(&w).Error) +// } +// +// // 建 service +// svc := NewWalletService(db, tc.uid, tc.asset).(*WalletService) +// got, err := svc.HasAvailableBalance(context.Background()) +// assert.NoError(t, err) +// assert.Equal(t, tc.wantExist, got) +// }) +// } +//} +// +// +// +//func TestWalletService_GetBalancesForUpdate(t *testing.T) { +// db, tearDown := SetupTestDB(t) +// defer tearDown() +// +// type seedRow struct { +// Brand string +// UID string +// Asset string +// Type wallet.Types +// Balance decimal.Decimal +// } +// +// tests := []struct { +// name string +// uid string +// asset string +// seed []seedRow +// kinds []wallet.Types +// wantIDs []int64 +// expectErr bool +// }{ +// { +// name: "查詢空結果", +// uid: "u1", +// asset: "BTC", +// seed: nil, +// kinds: []wallet.Types{wallet.TypeAvailable}, +// wantIDs: nil, +// expectErr: false, +// }, +// { +// name: "單一類型查詢", +// uid: "u1", +// asset: "BTC", +// seed: []seedRow{ +// {"", "u1", "BTC", wallet.TypeAvailable, decimal.NewFromInt(5)}, +// {"", "u1", "BTC", wallet.TypeFreeze, decimal.NewFromInt(2)}, +// }, +// kinds: []wallet.Types{wallet.TypeFreeze}, +// wantIDs: []int64{2}, +// expectErr: false, +// }, +// { +// name: "多類型查詢", +// uid: "u1", +// asset: "BTC", +// seed: []seedRow{ +// {"", "u1", "BTC", wallet.TypeAvailable, decimal.NewFromInt(5)}, +// {"", "u1", "BTC", wallet.TypeFreeze, decimal.NewFromInt(2)}, +// {"", "u1", "BTC", wallet.TypeUnconfirmed, decimal.NewFromInt(3)}, +// }, +// kinds: []wallet.Types{wallet.TypeAvailable, wallet.TypeUnconfirmed}, +// wantIDs: []int64{3, 5}, +// expectErr: false, +// }, +// { +// name: "不同 UID 不列入", +// uid: "u2", +// asset: "BTC", +// seed: []seedRow{ +// {"", "u1", "BTC", wallet.TypeAvailable, decimal.NewFromInt(5)}, +// }, +// kinds: []wallet.Types{wallet.TypeAvailable}, +// wantIDs: nil, +// expectErr: false, +// }, +// { +// name: "不同 Asset 不列入", +// uid: "u1", +// asset: "ETH", +// seed: []seedRow{ +// {"", "u1", "BTC", wallet.TypeAvailable, decimal.NewFromInt(5)}, +// }, +// kinds: []wallet.Types{wallet.TypeAvailable}, +// wantIDs: nil, +// expectErr: false, +// }, +// } +// +// for _, tc := range tests { +// t.Run(tc.name, func(t *testing.T) { +// // 清空資料 +// assert.NoError(t, db.Exec("DELETE FROM wallet").Error) +// // Seed +// for _, r := range tc.seed { +// w := entity.Wallet{ +// Brand: r.Brand, +// UID: r.UID, +// Asset: r.Asset, +// Type: r.Type, +// Balance: r.Balance, +// } +// // Create will auto-assign incremental IDs +// assert.NoError(t, db.Create(&w).Error) +// } +// +// // 建 Service +// svc := NewWalletService(db, tc.uid, tc.asset).(*WalletService) +// got, err := svc.GetBalancesForUpdate(context.Background(), tc.kinds) +// if tc.expectErr { +// assert.Error(t, err) +// return +// } +// assert.NoError(t, err) +// +// // 取回 IDs 並排序 +// var gotIDs []int64 +// for _, w := range got { +// gotIDs = append(gotIDs, w.ID) +// // localBalances 應該被更新 +// assert.Equal(t, w, svc.localBalances[w.Type]) +// } +// assert.Equal(t, tc.wantIDs, gotIDs) +// }) +// } +//} +// +//func TestWalletService_GetOrderBalance(t *testing.T) { +// _, db, tearDown, err := SetupTestWalletRepository() +// assert.NoError(t, err) +// defer tearDown() +// +// // create transaction table +// create := ` +// CREATE TABLE IF NOT EXISTS transaction ( +// id BIGINT AUTO_INCREMENT PRIMARY KEY, +// order_id VARCHAR(64), +// transaction_id VARCHAR(64), +// brand VARCHAR(32), +// uid VARCHAR(64), +// to_uid VARCHAR(64), +// type TINYINT, +// business_type TINYINT, +// asset VARCHAR(32), +// amount DECIMAL(30,18), +// before_balance DECIMAL(30,18), +// post_transfer_balance DECIMAL(30,18), +// status TINYINT, +// create_at BIGINT, +// due_time BIGINT +// ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;` +// assert.NoError(t, db.Exec(create).Error) +// +// // seed a row +// now := time.Now().Unix() +// seed := ` +// INSERT INTO transaction +// (order_id, transaction_id, brand, uid, to_uid, +// type, business_type, asset, amount, +// before_balance, post_transfer_balance, +// status, create_at, due_time) +// VALUES +// (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)` +// _, err = db.Exec(seed, +// "order1", "tx-uuid-1", "brandA", "user1", "user2", +// 1, 2, "BTC", "42.5", +// "10.0", "52.5", +// 1, now, now+3600, +// ).RowsAffected, db.Error +// assert.NoError(t, err) +// +// svc := NewWalletService(db, "user1", "BTC") +// +// type want struct { +// tx entity.Transaction +// errIsNF bool +// } +// +// tests := []struct { +// name string +// orderID string +// want want +// }{ +// { +// name: "found existing transaction", +// orderID: "order1", +// want: want{ +// tx: entity.Transaction{ +// ID: 1, +// OrderID: "order1", +// TransactionID: "tx-uuid-1", +// Brand: "brandA", +// UID: "user1", +// ToUID: "user2", +// TxType: wallet.TxType(1), +// BusinessType: int8(2), +// Asset: "BTC", +// Amount: decimal.RequireFromString("42.5"), +// BeforeBalance: decimal.RequireFromString("10.0"), +// PostTransferBalance: decimal.RequireFromString("52.5"), +// Status: wallet.Enable(1), +// CreateAt: now, +// DueTime: now + 3600, +// }, +// errIsNF: false, +// }, +// }, +// { +// name: "not found returns ErrRecordNotFound", +// orderID: "order2", +// want: want{errIsNF: true}, +// }, +// } +// +// for _, tt := range tests { +// t.Run(tt.name, func(t *testing.T) { +// got, err := svc.GetOrderBalance(context.Background(), tt.orderID) +// if tt.want.errIsNF { +// assert.Error(t, err) +// assert.True(t, errors.Is(err, repository.ErrRecordNotFound)) +// } else { +// assert.NoError(t, err) +// // compare all fields except those auto‐set by GORM (e.g. pointers) +// assert.Equal(t, tt.want.tx.ID, got.ID) +// assert.Equal(t, tt.want.tx.OrderID, got.OrderID) +// assert.Equal(t, tt.want.tx.TransactionID, got.TransactionID) +// assert.Equal(t, tt.want.tx.Brand, got.Brand) +// assert.Equal(t, tt.want.tx.UID, got.UID) +// assert.Equal(t, tt.want.tx.ToUID, got.ToUID) +// assert.Equal(t, tt.want.tx.TxType, got.TxType) +// assert.Equal(t, tt.want.tx.BusinessType, got.BusinessType) +// assert.Equal(t, tt.want.tx.Asset, got.Asset) +// assert.True(t, tt.want.tx.Amount.Equal(got.Amount)) +// assert.True(t, tt.want.tx.BeforeBalance.Equal(got.BeforeBalance)) +// assert.True(t, tt.want.tx.PostTransferBalance.Equal(got.PostTransferBalance)) +// assert.Equal(t, tt.want.tx.Status, got.Status) +// assert.Equal(t, tt.want.tx.CreateAt, got.CreateAt) +// assert.Equal(t, tt.want.tx.DueTime, got.DueTime) +// } +// }) +// } +//} +// +//func TestWalletService_GetOrderBalanceForUpdate(t *testing.T) { +// // setup container + DB + repo +// _, db, tearDown, err := SetupTestWalletRepository() +// assert.NoError(t, err) +// defer tearDown() +// +// // create transaction table +// create := ` +// CREATE TABLE IF NOT EXISTS transaction ( +// id BIGINT AUTO_INCREMENT PRIMARY KEY, +// order_id VARCHAR(64), +// transaction_id VARCHAR(64), +// brand VARCHAR(32), +// uid VARCHAR(64), +// to_uid VARCHAR(64), +// type TINYINT, +// business_type TINYINT, +// asset VARCHAR(32), +// amount DECIMAL(30,18), +// before_balance DECIMAL(30,18), +// post_transfer_balance DECIMAL(30,18), +// status TINYINT, +// create_at BIGINT, +// due_time BIGINT +// ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;` +// assert.NoError(t, db.Exec(create).Error) +// +// // seed one row +// now := time.Now().Unix() +// _, err = db.Exec( +// `INSERT INTO transaction +// (order_id, transaction_id, brand, uid, to_uid, type, business_type, asset, +// amount, before_balance, post_transfer_balance, status, create_at, due_time) +// VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, +// "ordX", "tx-XYZ", "brandZ", "u1", "u2", +// 1, 2, "ETH", +// "100.0", "50.0", "150.0", +// 1, now, now+600, +// ).RowsAffected, db.Error +// assert.NoError(t, err) +// +// // service under test +// svc := NewWalletService(db, "u1", "ETH") +// +// type want struct { +// tx entity.Transaction +// errIsNF bool +// } +// +// cases := []struct { +// name string +// orderID string +// want want +// }{ +// { +// name: "found and locks", +// orderID: "ordX", +// want: want{ +// tx: entity.Transaction{ +// ID: 1, +// OrderID: "ordX", +// TransactionID: "tx-XYZ", +// Brand: "brandZ", +// UID: "u1", +// ToUID: "u2", +// TxType: wallet.TxType(1), +// BusinessType: int8(2), +// Asset: "ETH", +// Amount: decimal.RequireFromString("100.0"), +// BeforeBalance: decimal.RequireFromString("50.0"), +// PostTransferBalance: decimal.RequireFromString("150.0"), +// Status: wallet.Enable(1), +// CreateAt: now, +// DueTime: now + 600, +// }, +// errIsNF: false, +// }, +// }, +// { +// name: "missing row returns ErrRecordNotFound", +// orderID: "ordY", +// want: want{errIsNF: true}, +// }, +// } +// +// for _, tt := range cases { +// t.Run(tt.name, func(t *testing.T) { +// got, err := svc.GetOrderBalanceForUpdate(context.Background(), tt.orderID) +// if tt.want.errIsNF { +// assert.Error(t, err) +// return +// } +// assert.NoError(t, err) +// +// // verify all fields +// expected := tt.want.tx +// assert.Equal(t, expected.ID, got.ID) +// assert.Equal(t, expected.OrderID, got.OrderID) +// assert.Equal(t, expected.TransactionID, got.TransactionID) +// assert.Equal(t, expected.Brand, got.Brand) +// assert.Equal(t, expected.UID, got.UID) +// assert.Equal(t, expected.ToUID, got.ToUID) +// assert.Equal(t, expected.TxType, got.TxType) +// assert.Equal(t, expected.BusinessType, got.BusinessType) +// assert.Equal(t, expected.Asset, got.Asset) +// assert.True(t, expected.Amount.Equal(got.Amount)) +// assert.True(t, expected.BeforeBalance.Equal(got.BeforeBalance)) +// assert.True(t, expected.PostTransferBalance.Equal(got.PostTransferBalance)) +// assert.Equal(t, expected.Status, got.Status) +// assert.Equal(t, expected.CreateAt, got.CreateAt) +// assert.Equal(t, expected.DueTime, got.DueTime) +// +// // verify local cache was set +// cached, ok := svc.(*WalletService).localOrderBalances[got.ID] +// assert.True(t, ok, "expected localOrderBalances to contain key") +// assert.True(t, expected.PostTransferBalance.Equal(cached)) +// }) +// } +//} diff --git a/pkg/repository/user_wallet_test.go b/pkg/repository/user_wallet_test.go index 9d709bb..e1514be 100644 --- a/pkg/repository/user_wallet_test.go +++ b/pkg/repository/user_wallet_test.go @@ -1077,13 +1077,13 @@ func TestWalletService_GetOrderBalance(t *testing.T) { } tests := []struct { - name string - txID int64 - want want + name string + orderID string + want want }{ { - name: "found existing transaction", - txID: 1, + name: "found existing transaction", + orderID: "order1", want: want{ tx: entity.Transaction{ ID: 1, @@ -1106,15 +1106,15 @@ func TestWalletService_GetOrderBalance(t *testing.T) { }, }, { - name: "not found returns ErrRecordNotFound", - txID: 999, - want: want{errIsNF: true}, + name: "not found returns ErrRecordNotFound", + orderID: "order2", + want: want{errIsNF: true}, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - got, err := svc.GetOrderBalance(context.Background(), tt.txID) + got, err := svc.GetOrderBalance(context.Background(), tt.orderID) if tt.want.errIsNF { assert.Error(t, err) assert.True(t, errors.Is(err, repository.ErrRecordNotFound)) @@ -1191,13 +1191,13 @@ func TestWalletService_GetOrderBalanceForUpdate(t *testing.T) { } cases := []struct { - name string - txID int64 - want want + name string + orderID string + want want }{ { - name: "found and locks", - txID: 1, + name: "found and locks", + orderID: "ordX", want: want{ tx: entity.Transaction{ ID: 1, @@ -1220,15 +1220,15 @@ func TestWalletService_GetOrderBalanceForUpdate(t *testing.T) { }, }, { - name: "missing row returns ErrRecordNotFound", - txID: 999, - want: want{errIsNF: true}, + name: "missing row returns ErrRecordNotFound", + orderID: "ordY", + want: want{errIsNF: true}, }, } for _, tt := range cases { t.Run(tt.name, func(t *testing.T) { - got, err := svc.GetOrderBalanceForUpdate(context.Background(), tt.txID) + got, err := svc.GetOrderBalanceForUpdate(context.Background(), tt.orderID) if tt.want.errIsNF { assert.Error(t, err) return diff --git a/pkg/usecase/wallet.go b/pkg/usecase/wallet.go index 08bb57e..d84ef93 100644 --- a/pkg/usecase/wallet.go +++ b/pkg/usecase/wallet.go @@ -6,6 +6,9 @@ import ( "code.30cm.net/digimon/app-cloudep-wallet-service/pkg/domain/wallet" "code.30cm.net/digimon/library-go/errs" "context" + "errors" + "github.com/go-sql-driver/mysql" + "github.com/sirupsen/logrus" "sync" ) @@ -58,11 +61,57 @@ func (use *WalletUseCase) Deposit(ctx context.Context, tx usecase.WalletTransfer tx.TxType = wallet.Deposit + uidAsset := uidAssetKey{ + uid: tx.FromUID, + asset: tx.Asset, + } + + exists := use.checkWalletExistence(uidAsset) + + withLockAvailable := func(ctx context.Context, tx *usecase.WalletTransferRequest, w repository.UserWalletService) error { + if !exists { + checkWallet, err := w.HasAvailableBalance(ctx) + if err != nil { + return err + } + + // 錢包不存在要做新增 + if !checkWallet { + if _, err := w.InitializeWallets(ctx, tx.Brand); err != nil { + var mysqlErr *mysql.MySQLError + // 解析是否被其他 transaction insert 了,是的話嘗試取得 insert 後的鎖,不是的話需要直接回傳錯誤 + if errors.As(err, &mysqlErr) && mysqlErr.Number == 1062 { + logrus.WithFields(logrus.Fields{ + "err": err, + "uid": tx.FromUID, + }).Warn("Deposit.Create.Wallet") + } else { + return err + } + } else { + // 因為是透過 transaction 新增,所以不用上鎖 + return nil + } + } else { + exists = true + use.markWalletAsExisting(uidAsset) + } + } + + // 確認有 wallet 再 lock for update,避免 deadlock + _, err := w.GetBalancesForUpdate(ctx, []wallet.Types{wallet.TypeAvailable}) + if err != nil { + return err + } + + return nil + } + if err := use.ProcessTransaction( ctx, tx, userWalletFlow{ UID: tx.FromUID, Asset: tx.Asset, - Actions: []walletActionOption{use.withLockAvailable(), use.withAddAvailable()}, + Actions: []walletActionOption{withLockAvailable, use.withAddAvailable()}, }); err != nil { return err } @@ -70,19 +119,117 @@ func (use *WalletUseCase) Deposit(ctx context.Context, tx usecase.WalletTransfer return nil } +// DepositUnconfirmed 增加限制餘額 +// 1. 新增一筆充值限制交易 +// 2. 錢包新增限制餘額 +// 3. 錢包變化新增一筆增加限制餘額資料 func (use *WalletUseCase) DepositUnconfirmed(ctx context.Context, tx usecase.WalletTransferRequest) error { - //TODO implement me - panic("implement me") + // 確認錢包新增或減少的餘額是否正確 + if !tx.Amount.IsPositive() { + return errs.InvalidRange("failed to get correct amount") + } + + uidAsset := uidAssetKey{ + uid: tx.FromUID, + asset: tx.Asset, + } + + exists := use.checkWalletExistence(uidAsset) + tx.TxType = wallet.DepositUnconfirmed + + withLockUnconfirmed := func(ctx context.Context, tx *usecase.WalletTransferRequest, w repository.UserWalletService) error { + if !exists { + checkWallet, err := w.HasAvailableBalance(ctx) + if err != nil { + return err + } + + // 錢包不存在要做新增 + if !checkWallet { + if _, err := w.InitializeWallets(ctx, tx.Brand); err != nil { + var mysqlErr *mysql.MySQLError + // 解析是否被其他 transaction insert 了,是的話嘗試取得 insert 後的鎖,不是的話需要直接回傳錯誤 + if errors.As(err, &mysqlErr) && mysqlErr.Number == 1062 { + logrus.WithFields(logrus.Fields{ + "err": err, + "uid": tx.FromUID, + }).Warn("Deposit.Create.Wallet") + } else { + return err + } + } else { + // 因為是透過 transaction 新增,所以不用上鎖 + return nil + } + } else { + exists = true + use.markWalletAsExisting(uidAsset) + } + } + + // 確認有 wallet 再 lock for update,避免 deadlock + _, err := w.GetBalancesForUpdate(ctx, []wallet.Types{wallet.TypeUnconfirmed}) + if err != nil { + return err + } + + return nil + } + + if err := use.ProcessTransaction( + ctx, tx, userWalletFlow{ + UID: tx.FromUID, + Asset: tx.Asset, + Actions: []walletActionOption{withLockUnconfirmed, use.withAddUnconfirmed()}, + }); err != nil { + return err + } + + return nil } +// Freeze 凍結 +// 1. 新增一筆凍結交易 +// 2. 錢包減少可用餘額 +// 3. 錢包增加凍結餘額 +// 4. 錢包變化新增一筆減少可用餘額資料 +// 5. 錢包變化新增一筆增加凍結餘額資料 +// 6. 訂單錢包新增一筆資料,餘額是凍結金額 func (use *WalletUseCase) Freeze(ctx context.Context, tx usecase.WalletTransferRequest) error { - //TODO implement me - panic("implement me") + // 確認錢包新增或減少的餘額是否正確 + if !tx.Amount.IsPositive() { + return errs.InvalidRange("failed to get correct amount") + } + + tx.TxType = wallet.DepositUnconfirmed + + return use.ProcessTransaction(ctx, tx, userWalletFlow{ + UID: tx.FromUID, + Asset: tx.Asset, + Actions: []walletActionOption{use.withLockAvailableAndFreeze(), use.withSubAvailable(), use.withAddFreeze()}, + }) } +// AppendFreeze 追加凍結金額 +// 1. 新增一筆凍結交易 +// 2. 錢包減少可用餘額 +// 3. 錢包增加凍結餘額 +// 4. 錢包變化新增一筆減少可用餘額資料 +// 5. 錢包變化新增一筆增加凍結餘額資料 +// 6. 原凍結金額上追加凍結金額 func (use *WalletUseCase) AppendFreeze(ctx context.Context, tx usecase.WalletTransferRequest) error { - //TODO implement me - panic("implement me") + // 確認錢包新增或減少的餘額是否正確 + if !tx.Amount.IsPositive() { + return errs.InvalidRange("failed to get correct amount") + } + + tx.TxType = wallet.DepositUnconfirmed + + return use.ProcessTransaction(ctx, tx, userWalletFlow{ + UID: tx.FromUID, + Asset: tx.Asset, + Actions: []walletActionOption{use.withLockAvailableAndFreeze(), use.withSubAvailable(), use.withAddFreeze()}, + }) } func (use *WalletUseCase) UnFreeze(ctx context.Context, tx usecase.WalletTransferRequest) error { diff --git a/pkg/usecase/wallet_tx_option.go b/pkg/usecase/wallet_tx_option.go index 209c89a..db638a4 100644 --- a/pkg/usecase/wallet_tx_option.go +++ b/pkg/usecase/wallet_tx_option.go @@ -103,6 +103,123 @@ func (use *WalletUseCase) withAddFreeze() walletActionOption { } } +// withAddUnconfirmed 增加用戶限制餘額 +func (use *WalletUseCase) withAddUnconfirmed() walletActionOption { + return func(_ context.Context, tx *usecase.WalletTransferRequest, w repository.UserWalletService) error { + err := w.IncreaseBalance(wallet.TypeUnconfirmed, tx.ReferenceOrderID, tx.Amount) + if err != nil { + return err + } + + return nil + } +} + +// withLockAvailableAndFreeze 用戶可用與凍結餘額 +func (use *WalletUseCase) withLockAvailableAndFreeze() walletActionOption { + return func(ctx context.Context, tx *usecase.WalletTransferRequest, w repository.UserWalletService) error { + uidAsset := uidAssetKey{ + uid: tx.FromUID, + asset: tx.Asset, + } + + if !use.checkWalletExistence(uidAsset) { + // 找不到錢包存不存在 + wStatus, err := w.HasAvailableBalance(ctx) + if err != nil { + return fmt.Errorf("failed to check wallet: %w", err) + } + // 錢包不存在要做新增 + if !wStatus { + //// 是合約模擬交易或帳變且錢包不存在才建立錢包 + //if !(tx.Business == wa.ContractSimulationBusinessTypeBusinessName || tx.BusinessType == domain.DistributionBusinessTypeBusinessName) { + // // 新增錢包有命中 UK 不需要額外上鎖 + // return use.translateError(err) + //} + + if _, err := w.InitializeWallets(ctx, tx.Brand); err != nil { + return err + } + + return nil + } + + use.markWalletAsExisting(uidAsset) + } + + _, err := w.GetBalancesForUpdate(ctx, []wallet.Types{wallet.TypeAvailable, wallet.TypeFreeze}) + if err != nil { + return err + } + + return nil + } +} + +// appendFreeze 追加用戶原凍結餘額 +func (use *WalletUseCase) withAppendFreeze() walletActionOption { + return func(ctx context.Context, tx *usecase.WalletTransferRequest, w repository.UserWalletService) error { + order, err := w.GetOrderBalance(ctx, tx.ReferenceOrderID) + if err != nil { + return err + } + + // 以id來做lock更可以確保只lock到該筆,而不會因為index關係lock到多筆導致死鎖 + // 而且先不lock把資料先拉出來判斷餘額是否足夠,在不足夠時可以直接return而不用lock減少開銷 + order, err = w.GetOrderBalanceForUpdate(ctx, tx.ReferenceOrderID) + if err != nil { + return err + } + + tx.Asset = order.Asset + tx.FromUID = order.UID + + //w.IncreaseBalance() + //if err := w.AddOrder(order.ID, tx.Amount); err != nil { + // return use.translateError(err) + //} + // + //if err := wallet.AddFreeze(tx.BusinessType, tx.Amount); err != nil { + // return use.translateError(err) + //} + + return nil + } +} + +//// withSubFreeze 減少用戶凍結餘額 +//func (use *WalletUseCase) withSubFreeze() walletActionOption { +// return func(_ context.Context, tx *usecase.Transaction, wallet repository.UserWallet) error { +// if err := wallet.SubFreeze(tx.BusinessType, tx.Amount); err != nil { +// if errors.Is(err, repository.ErrBalanceInsufficient) { +// return usecase.BalanceInsufficientError{ +// Amount: tx.Amount.Neg(), +// Balance: wallet.LocalBalance(domain.WalletFreezeType.ToBusiness(tx.BusinessType)), +// } +// } +// +// return use.translateError(err) +// } +// +// return nil +// } +//} +// +//// addFreeze 增加用戶凍結餘額 +//func (use *walletUseCase) addFreeze() walletActionOption { +// return func(_ context.Context, tx *usecase.Transaction, wallet repository.UserWallet) error { +// if err := wallet.AddFreeze(tx.BusinessType, tx.Amount); err != nil { +// return use.translateError(err) +// } +// +// // 訂單可以做解凍,解凍最大上限金額來自當初凍結金額,所以在每一筆tx可以設定Balance +// // 後續tx需要依據其他tx做交易時能有所依據 +// tx.Balance = tx.Amount +// +// return nil +// } +//} + //// WithAppendFreeze 追加用戶原凍結餘額 //func (use *WalletUseCase) withAppendFreeze() walletActionOption { // return func(ctx context.Context, tx *usecase.WalletTransferRequest, w repository.UserWalletService) error { diff --git a/pkg/usecase/wallet_tx_processer.go b/pkg/usecase/wallet_tx_processer.go index 33ff987..90a02ac 100644 --- a/pkg/usecase/wallet_tx_processer.go +++ b/pkg/usecase/wallet_tx_processer.go @@ -39,10 +39,10 @@ func (use *WalletUseCase) ProcessTransaction( // flows 會按照順序做.順序是重要的 for _, flow := range flows { - // 1️⃣ 建立針對該使用者+資產的 UserWalletService + // 1 建立針對該使用者+資產的 UserWalletService wSvc := repo.NewWalletService(db, flow.UID, flow.Asset) - // 2️⃣ 依序執行所有定義好的錢包操作 + // 2 依序執行所有定義好的錢包操作 for _, action := range flow.Actions { if err := action(ctx, &req, wSvc); err != nil { return err @@ -52,7 +52,7 @@ func (use *WalletUseCase) ProcessTransaction( wallets = append(wallets, wSvc) } - // 3️⃣ 準備寫入 Transaction 主檔 + // 3 準備寫入 Transaction 主檔 txRecord := &entity.Transaction{ OrderID: req.ReferenceOrderID, TransactionID: uuid.New().String(), @@ -68,14 +68,14 @@ func (use *WalletUseCase) ProcessTransaction( DueTime: 0, } - // 4️⃣ TODO 計算 DueTime (T+N 結算時間) + // 4 TODO 計算 DueTime (T+N 結算時間) - // 5️⃣ 寫入 Transaction 主檔 + // 5 寫入 Transaction 主檔 if err := use.TransactionRepo.Insert(ctx, txRecord); err != nil { return fmt.Errorf("TransactionRepo.Insert 失敗: %w", err) } - // 6️⃣ 聚合所有 wallet 內的交易歷程 + // 6 聚合所有 wallet 內的交易歷程 var walletTxs []entity.WalletTransaction for _, w := range wallets { walletTxs = append( @@ -89,12 +89,12 @@ func (use *WalletUseCase) ProcessTransaction( ) } - // 7️⃣ 批次寫入所有 WalletTransaction + // 7 批次寫入所有 WalletTransaction if err := use.WalletTransactionRepo.Create(ctx, db, walletTxs); err != nil { return fmt.Errorf("WalletTransactionRepository.Create 失敗: %w", err) } - // 8️⃣ 最後才真正把錢包的餘額更新到資料庫(同一事務) + // 8 最後才真正把錢包的餘額更新到資料庫(同一事務) for _, wSvc := range wallets { if err := wSvc.PersistBalances(ctx); err != nil { return err