feat: add wallet translation

This commit is contained in:
王性驊 2025-04-18 17:10:40 +08:00
parent 4f6262d489
commit 47fb3139c2
6 changed files with 1196 additions and 260 deletions

View File

@ -58,36 +58,32 @@ type BalanceQuery struct {
Kinds []wallet.Types // 錢包類型(如可用、凍結等)
}
// UserWalletService 專注於某位使用者在單一資產下的錢包操作邏輯
// UserWalletService 定義了一個「單一使用者、單一資產」的錢包操作合約
type UserWalletService interface {
// Init 初始化錢包(如建立可用、凍結、未確認等錢包)
Init(ctx context.Context, uid, asset, brand string) ([]entity.Wallet, error)
// All 查詢所有錢包餘額
All(ctx context.Context) ([]entity.Wallet, error)
// Get 查詢單一或多種類型的餘額
Get(ctx context.Context, kinds []wallet.Types) ([]entity.Wallet, error)
// GetWithLock 查詢鎖定後的錢包(交易使用)
GetWithLock(ctx context.Context, kinds []wallet.Types) ([]entity.Wallet, error)
// LocalBalance 查詢記憶中的快取值(非查資料庫)
LocalBalance(kind wallet.Types) decimal.Decimal
// LockByIDs 根據錢包 ID 鎖定(資料一致性用)
LockByIDs(ctx context.Context, ids []int64) ([]entity.Wallet, error)
// CheckReady 檢查錢包是否已經存在並準備好(可用餘額的錢包)
CheckReady(ctx context.Context) (bool, error)
// Add 加值與扣款邏輯(含業務類別)
Add(kind wallet.Types, orderID string, amount decimal.Decimal) error
Sub(kind wallet.Types, orderID string, amount decimal.Decimal) error
// AddTransaction 新增一筆交易紀錄(建立資料)
AddTransaction(txID int64, orderID string, brand string, business wallet.BusinessName, kind wallet.Types, amount decimal.Decimal)
Transactions(
// InitializeWallets 為新使用者初始化所有錢包類型並寫入資料庫
InitializeWallets(ctx context.Context, brand string) ([]entity.Wallet, error)
// GetAllBalances 查詢此使用者此資產下所有錢包類型的當前餘額
GetAllBalances(ctx context.Context) ([]entity.Wallet, error)
// GetBalancesForTypes 查詢指定錢包類型的一組餘額(不加鎖)
GetBalancesForTypes(ctx context.Context, kinds []wallet.Types) ([]entity.Wallet, error)
// GetBalancesForUpdate 查詢並鎖定指定錢包類型FOR UPDATE
GetBalancesForUpdate(ctx context.Context, kinds []wallet.Types) ([]entity.Wallet, error)
// CurrentBalance 從本地緩存取得某種錢包類型的餘額
CurrentBalance(kind wallet.Types) decimal.Decimal
// IncreaseBalance 增加指定錢包類型的餘額,並累積一筆交易紀錄
IncreaseBalance(kind wallet.Types, orderID string, amount decimal.Decimal) error
// DecreaseBalance 減少指定錢包類型的餘額(等同於 IncreaseBalance 的負數版本)
DecreaseBalance(kind wallet.Types, orderID string, amount decimal.Decimal) error
// PrepareTransactions 為所有暫存交易紀錄填上 TXID/OrderID/Brand/BusinessType並回傳可落庫的切片
PrepareTransactions(
txID int64,
orderID string,
brand string,
orderID, brand string,
businessType wallet.BusinessName,
) []entity.WalletTransaction
// Commit 提交所有操作(更新錢包與新增交易紀錄)
Commit(ctx context.Context) error
// CommitOrder 提交所有訂單
CommitOrder(ctx context.Context) error
// PersistBalances 將本地緩存中所有錢包最終餘額批次寫入資料庫
PersistBalances(ctx context.Context) error
// PersistOrderBalances 將本地緩存中所有訂單相關餘額批次寫入 transaction 表
PersistOrderBalances(ctx context.Context) error
// HasAvailableBalance 確認此使用者此資產是否已有可用餘額錢包
HasAvailableBalance(ctx context.Context) (bool, error)
}

View File

@ -14,152 +14,191 @@ import (
"time"
)
// 用戶某個幣種餘額
type userWallet struct {
// WalletService 代表一個使用者在某資產上的錢包服務,
// 負責讀取/寫入資料庫並在記憶體暫存變動
type WalletService struct {
db *gorm.DB
uid string
asset string
// local wallet 相關計算的餘額存在這裡
localWalletBalance map[wallet.Types]entity.Wallet
// local order wallet 相關計算的餘額存在這裡
localOrderBalance map[int64]decimal.Decimal
// local wallet 內所有餘額變化紀錄
transactions []entity.WalletTransaction
uid string // 使用者識別碼
asset string // 資產代號 (如 BTC、ETH、TWD)
localBalances map[wallet.Types]entity.Wallet // 暫存各類型錢包當前餘額
localOrderBalances map[int64]decimal.Decimal // 暫存各訂單變動後的餘額
transactions []entity.WalletTransaction // 暫存所有尚未落庫的錢包交易紀錄
}
func NewUserWallet(db *gorm.DB, uid, asset string) repository.UserWalletService {
return &userWallet{
// NewWalletService 建立一個 WalletService 實例
func NewWalletService(db *gorm.DB, uid, asset string) repository.UserWalletService {
return &WalletService{
db: db,
uid: uid,
asset: asset,
localWalletBalance: make(map[wallet.Types]entity.Wallet, len(wallet.AllTypes)),
localOrderBalance: make(map[int64]decimal.Decimal, len(wallet.AllTypes)),
localBalances: make(map[wallet.Types]entity.Wallet, len(wallet.AllTypes)),
localOrderBalances: make(map[int64]decimal.Decimal, len(wallet.AllTypes)),
}
}
func (repo *userWallet) Init(ctx context.Context, uid, asset, brand string) ([]entity.Wallet, error) {
wallets := make([]entity.Wallet, 0, len(wallet.AllTypes))
// InitializeWallets 啟動時為新使用者初始化所有類型錢包,並寫入資料庫
func (s *WalletService) InitializeWallets(ctx context.Context, brand string) ([]entity.Wallet, error) {
var wallets []entity.Wallet
for _, t := range wallet.AllTypes {
balance := decimal.Zero
wallets = append(wallets, entity.Wallet{
Brand: brand,
UID: uid,
Asset: asset,
Balance: balance,
UID: s.uid,
Asset: s.asset,
Balance: decimal.Zero,
Type: t,
})
}
if err := repo.db.WithContext(ctx).Create(&wallets).Error; err != nil {
if err := s.db.WithContext(ctx).Create(&wallets).Error; err != nil {
return nil, err
}
for _, v := range wallets {
repo.localWalletBalance[v.Type] = v
// 將初始化後的錢包資料寫入本地緩存
for _, w := range wallets {
s.localBalances[w.Type] = w
}
return wallets, nil
}
func (repo *userWallet) All(ctx context.Context) ([]entity.Wallet, error) {
// GetAllBalances 查詢該使用者某資產所有錢包類型當前餘額
func (s *WalletService) GetAllBalances(ctx context.Context) ([]entity.Wallet, error) {
var result []entity.Wallet
err := repo.buildCommonWhereSQL(repo.uid, repo.asset).
WithContext(ctx).
Select("id, crypto, balance, type").
err := s.db.WithContext(ctx).
Where("uid = ? AND asset = ?", s.uid, s.asset).
Select("id, asset, balance, type").
Find(&result).Error
if err != nil {
return []entity.Wallet{}, err
return nil, err
}
for _, v := range result {
repo.localWalletBalance[v.Type] = v
for _, w := range result {
s.localBalances[w.Type] = w
}
return result, nil
}
func (repo *userWallet) Get(ctx context.Context, kinds []wallet.Types) ([]entity.Wallet, error) {
var wallets []entity.Wallet
err := repo.buildCommonWhereSQL(repo.uid, repo.asset).
WithContext(ctx).
Model(&entity.Wallet{}).
Select("id, crypto, balance, type").
// GetBalancesForTypes 查詢指定類型的錢包餘額,不上鎖
func (s *WalletService) GetBalancesForTypes(ctx context.Context, kinds []wallet.Types) ([]entity.Wallet, error) {
var result []entity.Wallet
err := s.db.WithContext(ctx).
Where("uid = ? AND asset = ?", s.uid, s.asset).
Where("type IN ?", kinds).
Find(&wallets).Error
Select("id, asset, balance, type").
Find(&result).Error
if err != nil {
return []entity.Wallet{}, notFoundError(err)
return nil, translateNotFound(err)
}
for _, w := range result {
s.localBalances[w.Type] = w
}
return result, nil
}
for _, w := range wallets {
repo.localWalletBalance[w.Type] = w
}
return wallets, nil
}
func (repo *userWallet) GetWithLock(ctx context.Context, kinds []wallet.Types) ([]entity.Wallet, error) {
var wallets []entity.Wallet
err := repo.buildCommonWhereSQL(repo.uid, repo.asset).
WithContext(ctx).
Model(&entity.Wallet{}).
Select("id, crypto, balance, type").
// GetBalancesForUpdate 查詢並鎖定指定類型的錢包 (FOR UPDATE)
func (s *WalletService) GetBalancesForUpdate(ctx context.Context, kinds []wallet.Types) ([]entity.Wallet, error) {
var result []entity.Wallet
err := s.db.WithContext(ctx).
Where("uid = ? AND asset = ?", s.uid, s.asset).
Where("type IN ?", kinds).
Clauses(clause.Locking{Strength: "UPDATE"}).
Find(&wallets).Error
Select("id, asset, balance, type").
Find(&result).Error
if err != nil {
return []entity.Wallet{}, notFoundError(err)
return nil, translateNotFound(err)
}
for _, w := range result {
s.localBalances[w.Type] = w
}
return result, nil
}
for _, w := range wallets {
repo.localWalletBalance[w.Type] = w
// CurrentBalance 從緩存中取得某種類型錢包的當前餘額
func (s *WalletService) CurrentBalance(kind wallet.Types) decimal.Decimal {
if w, ok := s.localBalances[kind]; ok {
return w.Balance
}
return wallets, nil
}
func (repo *userWallet) LocalBalance(kind wallet.Types) decimal.Decimal {
w, ok := repo.localWalletBalance[kind]
if !ok {
return decimal.Zero
}
return w.Balance
// IncreaseBalance 在本地緩存新增餘額,並記錄一筆 WalletTransaction
func (s *WalletService) IncreaseBalance(kind wallet.Types, orderID string, amount decimal.Decimal) error {
w, ok := s.localBalances[kind]
if !ok {
return repository.ErrRecordNotFound
}
w.Balance = w.Balance.Add(amount)
if w.Balance.LessThan(decimal.Zero) {
return repository.ErrBalanceInsufficient
}
s.transactions = append(s.transactions, entity.WalletTransaction{
OrderID: orderID,
UID: s.uid,
WalletType: kind,
Asset: s.asset,
Amount: amount,
Balance: w.Balance,
})
s.localBalances[kind] = w
return nil
}
func (repo *userWallet) LockByIDs(ctx context.Context, ids []int64) ([]entity.Wallet, error) {
var wallets []entity.Wallet
// DecreaseBalance 本質上是 IncreaseBalance 的負數版本
func (s *WalletService) DecreaseBalance(kind wallet.Types, orderID string, amount decimal.Decimal) error {
return s.IncreaseBalance(kind, orderID, amount.Neg())
}
err := repo.db.WithContext(ctx).
// PrepareTransactions 為每筆暫存的 WalletTransaction 填入共用欄位 (txID, brand, businessType)
// 並回傳完整可落庫的切片
func (s *WalletService) PrepareTransactions(
txID int64,
orderID, brand string,
businessType wallet.BusinessName,
) []entity.WalletTransaction {
for i := range s.transactions {
s.transactions[i].TransactionID = txID
s.transactions[i].OrderID = orderID
s.transactions[i].Brand = brand
s.transactions[i].BusinessType = businessType.ToInt8()
}
return s.transactions
}
// PersistBalances 寫入本地緩存中所有錢包的最終餘額到資料庫
func (s *WalletService) PersistBalances(ctx context.Context) error {
return s.db.Transaction(func(tx *gorm.DB) error {
for _, w := range s.localBalances {
if err := tx.WithContext(ctx).
Model(&entity.Wallet{}).
Select("id, crypto, balance, type").
Where("id IN ?", ids).
Clauses(clause.Locking{Strength: "UPDATE"}).
Find(&wallets).Error
if err != nil {
return []entity.Wallet{}, notFoundError(err)
Where("id = ?", w.ID).
UpdateColumns(map[string]interface{}{
"balance": w.Balance,
"update_at": time.Now().Unix(),
}).Error; err != nil {
return fmt.Errorf("更新錢包餘額失敗 (id=%d): %w", w.ID, err)
}
}
return nil
}, &sql.TxOptions{Isolation: sql.LevelReadCommitted})
}
for _, w := range wallets {
repo.localWalletBalance[w.Type] = w
// PersistOrderBalances 寫入所有訂單錢包的最終餘額到 transaction 表
func (s *WalletService) PersistOrderBalances(ctx context.Context) error {
return s.db.Transaction(func(tx *gorm.DB) error {
for id, bal := range s.localOrderBalances {
if err := tx.WithContext(ctx).
Model(&entity.Transaction{}).
Where("id = ?", id).
Update("post_transfer_balance", bal).Error; err != nil {
return fmt.Errorf("更新訂單錢包餘額失敗 (id=%d): %w", id, err)
}
}
return nil
}, &sql.TxOptions{Isolation: sql.LevelReadCommitted})
}
return wallets, nil
}
func (repo *userWallet) CheckReady(ctx context.Context) (bool, error) {
func (s *WalletService) HasAvailableBalance(ctx context.Context) (bool, error) {
var exists bool
err := repo.buildCommonWhereSQL(repo.uid, repo.asset).WithContext(ctx).
err := s.db.WithContext(ctx).
Model(&entity.Wallet{}).
Select("1").
Where("uid = ? AND asset = ?", s.uid, s.asset).
Where("type = ?", wallet.TypeAvailable).
Limit(1).
Scan(&exists).Error
@ -171,134 +210,10 @@ func (repo *userWallet) CheckReady(ctx context.Context) (bool, error) {
return exists, nil
}
// Add 新增某種餘額餘額
// 使用前 localWalletBalance 必須有資料,所以必須執行過 GetWithLock / All 才會有資料
func (repo *userWallet) Add(kind wallet.Types, orderID string, amount decimal.Decimal) error {
w, ok := repo.localWalletBalance[kind]
if !ok {
return repository.ErrRecordNotFound
}
w.Balance = w.Balance.Add(amount)
if w.Balance.LessThan(decimal.Zero) {
return repository.ErrBalanceInsufficient
}
repo.transactions = append(repo.transactions, entity.WalletTransaction{
OrderID: orderID,
UID: repo.uid,
WalletType: kind,
Asset: repo.asset,
Amount: amount,
Balance: w.Balance,
})
repo.localWalletBalance[kind] = w
return nil
}
func (repo *userWallet) Sub(kind wallet.Types, orderID string, amount decimal.Decimal) error {
return repo.Add(kind, orderID, decimal.Zero.Sub(amount))
}
// Transactions 為本次整筆交易 (txID) 給所有暫存的 WalletTransaction 設置共用欄位,
// 並回傳整批交易紀錄以便後續寫入資料庫。
func (repo *userWallet) Transactions(
txID int64,
orderID string,
brand string,
businessType wallet.BusinessName,
) []entity.WalletTransaction {
for i := range repo.transactions {
repo.transactions[i].TransactionID = txID
repo.transactions[i].OrderID = orderID
repo.transactions[i].Brand = brand
repo.transactions[i].BusinessType = businessType.ToInt8()
}
return repo.transactions
}
func (repo *userWallet) Commit(ctx context.Context) error {
// 事務隔離等級設定
rc := &sql.TxOptions{
Isolation: sql.LevelReadCommitted,
ReadOnly: false,
}
err := repo.db.Transaction(func(tx *gorm.DB) error {
for _, w := range repo.localWalletBalance {
err := tx.WithContext(ctx).
Model(&entity.Wallet{}).
Where("id = ?", w.ID).
UpdateColumns(map[string]any{
"balance": w.Balance,
"update_time": time.Now().UTC().Unix(),
}).Error
if err != nil {
return fmt.Errorf("failed to update wallet id %d: %w", w.ID, err)
}
}
return nil // 所有更新成功才 return nil
}, rc)
if err != nil {
return fmt.Errorf("update uid: %s asset: %s error: %w", repo.uid, repo.asset, err)
}
return nil
}
func (repo *userWallet) GetTransactions() []entity.WalletTransaction {
return repo.transactions
}
func (repo *userWallet) CommitOrder(ctx context.Context) error {
rc := &sql.TxOptions{
Isolation: sql.LevelReadCommitted,
ReadOnly: false,
}
err := repo.db.Transaction(func(tx *gorm.DB) error {
for id, balance := range repo.localOrderBalance {
err := tx.WithContext(ctx).
Model(&entity.Transaction{}).
Where("id = ?", id).
Update("balance", balance).Error
if err != nil {
return fmt.Errorf("failed to update order balance, id=%d, err=%w", id, err)
}
}
return nil // 所有更新成功才 return nil
}, rc)
if err != nil {
return fmt.Errorf("update uid: %s asset: %s error: %w", repo.uid, repo.asset, err)
}
return nil
}
func (repo *userWallet) AddTransaction(txID int64, orderID string, brand string, business wallet.BusinessName, kind wallet.Types, amount decimal.Decimal) {
//TODO implement me
panic("implement me")
}
// =============================================================================
func (repo *userWallet) buildCommonWhereSQL(uid, asset string) *gorm.DB {
return repo.db.Where("uid = ?", uid).
Where("asset = ?", asset)
}
func notFoundError(err error) error {
// translateNotFound 將 GORM 的 RecordNotFound 轉為自訂錯誤
func translateNotFound(err error) error {
if errors.Is(err, gorm.ErrRecordNotFound) {
return repository.ErrRecordNotFound
}
return err
}

File diff suppressed because it is too large Load Diff

View File

@ -46,11 +46,11 @@ func (repo *WalletRepository) Transaction(fn func(db *gorm.DB) error) error {
}
func (repo *WalletRepository) Session(uid, asset string) repository.UserWalletService {
return NewUserWallet(repo.DB, uid, asset)
return NewWalletService(repo.DB, uid, asset)
}
func (repo *WalletRepository) SessionWithTx(db *gorm.DB, uid, asset string) repository.UserWalletService {
return NewUserWallet(db, uid, asset)
return NewWalletService(db, uid, asset)
}
func (repo *WalletRepository) InitWallets(ctx context.Context, param []repository.Wallet) error {

View File

@ -31,7 +31,7 @@ func (use *WalletUseCase) withLockAvailable() walletActionOption {
if !use.checkWalletExistence(uidAsset) {
// 找不到錢包存不存在
wStatus, err := w.CheckReady(ctx)
wStatus, err := w.HasAvailableBalance(ctx)
if err != nil {
return fmt.Errorf("failed to check wallet: %w", err)
}
@ -43,7 +43,7 @@ func (use *WalletUseCase) withLockAvailable() walletActionOption {
// return use.translateError(err)
//}
if _, err := w.Init(ctx, tx.FromUID, tx.Asset, tx.Brand); err != nil {
if _, err := w.InitializeWallets(ctx, tx.Brand); err != nil {
return err
}
@ -53,7 +53,7 @@ func (use *WalletUseCase) withLockAvailable() walletActionOption {
use.markWalletAsExisting(uidAsset)
}
_, err := w.GetWithLock(ctx, []wallet.Types{wallet.TypeAvailable})
_, err := w.GetBalancesForUpdate(ctx, []wallet.Types{wallet.TypeAvailable})
if err != nil {
return err
}
@ -65,7 +65,7 @@ func (use *WalletUseCase) withLockAvailable() walletActionOption {
// subAvailable 減少用戶可用餘額
func (use *WalletUseCase) withSubAvailable() walletActionOption {
return func(_ context.Context, tx *usecase.WalletTransferRequest, w repository.UserWalletService) error {
if err := w.Sub(wallet.TypeAvailable, tx.ReferenceOrderID, tx.Amount); err != nil {
if err := w.DecreaseBalance(wallet.TypeAvailable, tx.ReferenceOrderID, tx.Amount); err != nil {
if errors.Is(err, repository.ErrBalanceInsufficient) {
// todo 錯誤要看怎麼給(餘額不足)
return fmt.Errorf("balance insufficient")

View File

@ -40,7 +40,7 @@ func (use *WalletUseCase) ProcessTransaction(
// flows 會按照順序做.順序是重要的
for _, flow := range flows {
// 1⃣ 建立針對該使用者+資產的 UserWalletService
wSvc := repo.NewUserWallet(db, flow.UID, flow.Asset)
wSvc := repo.NewWalletService(db, flow.UID, flow.Asset)
// 2⃣ 依序執行所有定義好的錢包操作
for _, action := range flow.Actions {
@ -80,7 +80,7 @@ func (use *WalletUseCase) ProcessTransaction(
for _, w := range wallets {
walletTxs = append(
walletTxs,
w.Transactions(
w.PrepareTransactions(
txRecord.ID,
txRecord.OrderID,
req.Brand,
@ -96,10 +96,10 @@ func (use *WalletUseCase) ProcessTransaction(
// 8⃣ 最後才真正把錢包的餘額更新到資料庫(同一事務)
for _, wSvc := range wallets {
if err := wSvc.Commit(ctx); err != nil {
if err := wSvc.PersistBalances(ctx); err != nil {
return err
}
if err := wSvc.CommitOrder(ctx); err != nil {
if err := wSvc.PersistOrderBalances(ctx); err != nil {
return err
}
}