diff --git a/internal/domain/errors.go b/internal/domain/errors.go index 050f912..5d7ba75 100644 --- a/internal/domain/errors.go +++ b/internal/domain/errors.go @@ -23,6 +23,12 @@ const ( TimeoutOrderErrorCode ) +// Error Code 統一這邊改 +const ( + _ ErrorCode = 20 + iota + CreateWalletErrorCode +) + const ( _ ErrorCode = 10 + iota DataNotFoundErrorCode diff --git a/internal/domain/repository/user_wallet.go b/internal/domain/repository/user_wallet.go new file mode 100644 index 0000000..3032ee1 --- /dev/null +++ b/internal/domain/repository/user_wallet.go @@ -0,0 +1,4 @@ +package repository + +// UserWalletOperator 針對使用者的錢包基本操作接口 +type UserWalletOperator interface{} diff --git a/internal/domain/repository/wallet.go b/internal/domain/repository/wallet.go new file mode 100644 index 0000000..3153d53 --- /dev/null +++ b/internal/domain/repository/wallet.go @@ -0,0 +1,30 @@ +package repository + +import ( + "app-cloudep-trade-service/internal/domain" + "app-cloudep-trade-service/internal/model" + "context" + + "github.com/zeromicro/go-zero/core/stores/sqlx" +) + +// WalletRepository 錢包基礎操作(可能有平台,使用者等多元的錢包) +type WalletRepository interface { + // Create 建立錢包組合(某人的可用,凍結,限制三種) + Create(ctx context.Context, uid, currency, brand string) ([]*model.Wallet, error) + // Balances 取得某個人的餘額 + Balances(ctx context.Context, req BalanceReq) ([]model.Wallet, error) + // GetTxDatabaseConn 取得 sql 要做 tx 的連線 + GetTxDatabaseConn() sqlx.SqlConn + // GetUserWalletOperator 取得使用者錢包操作看使否需要使用 transaction + GetUserWalletOperator(uid, currency string, opts ...Option) UserWalletOperator +} + +// BalanceReq 取得全部的,因為一個人錢包種類的不會太多,故全撈 +type BalanceReq struct { + UID []string + Currency []string + Kind []domain.WalletType +} + +type Option func() sqlx.SqlConn diff --git a/internal/domain/usecase/wallet.go b/internal/domain/usecase/wallet.go index c97721b..b5a1b37 100644 --- a/internal/domain/usecase/wallet.go +++ b/internal/domain/usecase/wallet.go @@ -1,4 +1,81 @@ package usecase -type WalletUseCase interface { +import ( + "app-cloudep-trade-service/internal/domain" + "context" + + "github.com/shopspring/decimal" +) + +// WalletQueryUseCase 定義所有查詢行為(餘額查詢、檢查、歷史記錄)的行為 +type WalletQueryUseCase interface { + // Balance 用戶餘額 + Balance(ctx context.Context, req BalanceReq) ([]Balance, error) + // CheckBalance 根據tx檢查用戶餘額是否足夠 + CheckBalance(ctx context.Context, tx Transaction) error + // HistoryBalance 歷史餘額變化 + HistoryBalance(ctx context.Context, req BalanceReq) ([]Balance, error) +} + +// WalletOperationUseCase 定義所有錢包操作(提款、充值、凍結等)的行為 +type WalletOperationUseCase interface { + // Withdraw 提款 + Withdraw(ctx context.Context, tx Transaction) error + // Deposit 充值 + Deposit(ctx context.Context, tx Transaction) error + // DepositUnconfirmed 增加限制餘額 + DepositUnconfirmed(ctx context.Context, tx Transaction) error + // Freeze 凍結 + Freeze(ctx context.Context, tx Transaction) error + // AppendFreeze 追加凍結金額 + AppendFreeze(ctx context.Context, tx Transaction) error + // UnFreeze 解凍 + UnFreeze(ctx context.Context, tx Transaction) error + // RollbackFreeze Rollback 凍結,不可指定金額(剩餘order凍結金額) + RollbackFreeze(ctx context.Context, tx Transaction) error + // RollbackFreezeAddAvailable Rollback剩餘凍結,可指定金額(剩餘order凍結金額) + RollbackFreezeAddAvailable(ctx context.Context, tx Transaction) error + // CancelFreeze 取消凍結,可指定金額 + CancelFreeze(ctx context.Context, tx Transaction) error + // Unconfirmed 增加限制 + Unconfirmed(ctx context.Context, tx Transaction) error +} + +// WalletUseCase 基礎操作類別 +type WalletUseCase interface { + WalletOperationUseCase + WalletQueryUseCase +} + +// Transaction 交易 +type Transaction struct { + OrderID string // 交易訂單 + UID string // 交易發起人 + ToUID string // 交易接收人 + Currency string // 幣別 + Amount decimal.Decimal // 交易金額 + BeforeBalance decimal.Decimal // 交易前餘額 + Type domain.TxType // 交易種類 + BusinessType domain.BusinessName // 商業種類 + Brand string // 轉帳平台 + From domain.WalletType // 從哪種錢包類型 + To domain.WalletType // 到哪種錢包類型 +} + +type BalanceReq struct { + UID string // 用戶 UID + Currency string // 幣值 + BeforeHour int // 在某個時段之前 +} + +type Balance struct { + Currency string `json:"currency"` + Available decimal.Decimal `json:"available"` + Unavailable UnavailableBalance `json:"unavailable"` + UpdateTime int64 `json:"update_time"` +} + +type UnavailableBalance struct { + Freeze decimal.Decimal `json:"freeze"` + Unconfirmed decimal.Decimal `json:"unconfirmed"` } diff --git a/internal/domain/wallet_business_name.go b/internal/domain/wallet_business_name.go new file mode 100644 index 0000000..ab401e0 --- /dev/null +++ b/internal/domain/wallet_business_name.go @@ -0,0 +1,52 @@ +package domain + +// ===================交易商業邏輯種類=================== + +type BusinessName string + +const ( + // NoBusinessName 非商業邏輯 + NoBusinessName BusinessName = "" + // OrderBusinessTypeBusinessName order業務邏輯 + OrderBusinessTypeBusinessName BusinessName = "order" + // SystemTransferBusinessTypeBusinessName 系統劃轉 + SystemTransferBusinessTypeBusinessName BusinessName = "system_transfer" +) + +const ( + // NoBusinessType 非商業邏輯 + NoBusinessType int8 = iota + // OrderBusinessTypeBusinessType order 業務邏輯 + OrderBusinessTypeBusinessType + // SystemTransferBusinessTypeBusinessType 系統劃轉 + SystemTransferBusinessTypeBusinessType +) + +// 定義兩個map用於名稱和類型的相互映射 +var nameToBusinessType = map[BusinessName]int8{ + OrderBusinessTypeBusinessName: OrderBusinessTypeBusinessType, + SystemTransferBusinessTypeBusinessName: SystemTransferBusinessTypeBusinessType, +} + +var businessTypeToName = map[int8]BusinessName{ + OrderBusinessTypeBusinessType: OrderBusinessTypeBusinessName, + SystemTransferBusinessTypeBusinessType: SystemTransferBusinessTypeBusinessName, +} + +// ToINT8 converts BusinessName to its corresponding int8 type +func (b BusinessName) ToINT8() int8 { + if val, ok := nameToBusinessType[b]; ok { + return val + } + + return NoBusinessType +} + +// BusinessTypeToString converts an int8 type to its corresponding BusinessName +func BusinessTypeToString(b int8) BusinessName { + if val, ok := businessTypeToName[b]; ok { + return val + } + + return NoBusinessName +} diff --git a/internal/domain/wallet_tx.go b/internal/domain/wallet_tx.go new file mode 100644 index 0000000..a3a85ed --- /dev/null +++ b/internal/domain/wallet_tx.go @@ -0,0 +1,48 @@ +package domain + +// ===================交易種類=================== + +type TxType int64 + +// 交易類型 +const ( + // TxDepositType 充值(增加可用餘額) + TxDepositType TxType = iota + 1 + + // TxWithdrawType 提現(減少可用餘額) + TxWithdrawType + + // TxFreezeType 凍結(減少可用餘額,加在凍結餘額) + TxFreezeType + + // TxUnFreezeType 解凍(減少凍結餘額) + TxUnFreezeType + + // TxRollbackFreezeType rollback凍結(減少凍結餘額,加回可用餘額,不可指定金額) + TxRollbackFreezeType + + // TxUnconfirmedType 限制(減少凍結餘額,加別人限制餘額) + TxUnconfirmedType + + // TxCancelFreezeType 取消凍結(減少凍結餘額,加回可用餘額,,可指定金額) + TxCancelFreezeType + + // TxDepositUnconfirmedType 充值(增加限制餘額) + TxDepositUnconfirmedType + + // TxAppendFreezeType 追加凍結(減少可用餘額,加在凍結餘額) + TxAppendFreezeType + + // TxRollbackFreezeAddAvailableType rollback凍結(rollback凍結餘額,指定金額加回可用餘額) + TxRollbackFreezeAddAvailableType + + // TxDistributionType 平台分發 + TxDistributionType + + // TxSystemTransfer 系統劃轉 + TxSystemTransfer +) + +func (t TxType) ToInt() int64 { + return int64(t) +} diff --git a/internal/domain/wallet_type.go b/internal/domain/wallet_type.go new file mode 100644 index 0000000..78ed2f8 --- /dev/null +++ b/internal/domain/wallet_type.go @@ -0,0 +1,54 @@ +package domain + +// ===================錢包金額種類=================== + +type WalletType int64 + +// 錢包種類 +const ( + // WalletAvailableType 錢包可動用的金額 + WalletAvailableType WalletType = iota + 1 + + // WalletFreezeType 交易過程,錢包被凍結的金額 + WalletFreezeType + + // WalletUnconfirmedType 已提交的交易但還未確認完成交易的金額 + WalletUnconfirmedType + + // WalletContractAvailableType 合約/交易可用餘額 + WalletContractAvailableType + + // WalletContractFreezeType 合約/交易凍結餘額 + WalletContractFreezeType +) + +var walletTypeToName = map[WalletType]string{ + WalletAvailableType: "available", + WalletFreezeType: "freeze", + WalletUnconfirmedType: "unconfirmed", + WalletContractAvailableType: "contract_available", + WalletContractFreezeType: "contract_freeze", +} + +var nameToWalletType = map[string]WalletType{ + "available": WalletAvailableType, + "freeze": WalletFreezeType, + "unconfirmed": WalletUnconfirmedType, + "contract_available": WalletContractAvailableType, + "contract_freeze": WalletContractFreezeType, +} + +// Name returns the name associated with the WalletType. +func (w WalletType) Name() string { + return walletTypeToName[w] +} + +// GetWalletTypeByName returns the WalletType associated with the given name. +func GetWalletTypeByName(name string) WalletType { + return nameToWalletType[name] +} + +var AllWalletType = []WalletType{ + WalletAvailableType, WalletFreezeType, WalletUnconfirmedType, + WalletContractAvailableType, WalletContractFreezeType, +} diff --git a/internal/model/wallet_model.go b/internal/model/wallet_model.go index f4349db..16d462a 100755 --- a/internal/model/wallet_model.go +++ b/internal/model/wallet_model.go @@ -1,6 +1,18 @@ package model -import "github.com/zeromicro/go-zero/core/stores/sqlx" +import ( + "app-cloudep-trade-service/internal/domain" + + "context" + "database/sql" + "errors" + "fmt" + "strings" + + "github.com/zeromicro/go-zero/core/stores/sqlc" + + "github.com/zeromicro/go-zero/core/stores/sqlx" +) var _ WalletModel = (*customWalletModel)(nil) @@ -9,11 +21,19 @@ type ( // and implement the added methods in customWalletModel. WalletModel interface { walletModel + InsertMany(ctx context.Context, wallets []*Wallet) (sql.Result, error) + Balances(ctx context.Context, req BalanceReq) ([]Wallet, error) } customWalletModel struct { *defaultWalletModel } + + BalanceReq struct { + UID []string + Currency []string + Kind []domain.WalletType + } ) // NewWalletModel returns a model for the database table. @@ -22,3 +42,75 @@ func NewWalletModel(conn sqlx.SqlConn) WalletModel { defaultWalletModel: newWalletModel(conn), } } + +func (m *customWalletModel) InsertMany(ctx context.Context, wallets []*Wallet) (sql.Result, error) { + if len(wallets) == 0 { + return nil, fmt.Errorf("no data to insert") + } + + // 構建多條記錄的佔位符,例如: (?, ?, ?, ?, ?, ?, ?), (?, ?, ?, ?, ?, ?, ?), ... + valueStrings := make([]string, 0, len(wallets)) + valueArgs := make([]interface{}, 0, len(wallets)*7) // 每條記錄有7個值 + + for _, wallet := range wallets { + valueStrings = append(valueStrings, "(?, ?, ?, ?, ?, ?, ?)") + valueArgs = append(valueArgs, wallet.Uid, wallet.Brand, wallet.Currency, wallet.Balance, wallet.WalletType, wallet.CreatedAt, wallet.UpdatedAt) + } + + // 構建批量插入的 SQL 語句 + query := fmt.Sprintf("insert into %s (%s) values %s", m.table, walletRowsExpectAutoSet, strings.Join(valueStrings, ",")) + + // 使用單一連線執行批量插入 + return m.conn.ExecCtx(ctx, query, valueArgs...) +} + +func (m *customWalletModel) Balances(ctx context.Context, req BalanceReq) ([]Wallet, error) { + var data []Wallet + query := fmt.Sprintf("select 'id', 'uid', 'currency', 'balance', 'type', 'update_time' from %s", m.table) + var conditions []string + var args []any + + // 根據條件動態拼接 WHERE 子句 + if len(req.UID) > 0 { + placeholders := strings.Repeat("?,", len(req.UID)) + placeholders = placeholders[:len(placeholders)-1] // 移除最後一個逗號 + conditions = append(conditions, fmt.Sprintf("uid IN (%s)", placeholders)) + args = append(args, convertSliceToInterface(req.UID)...) + } + if len(req.Currency) > 0 { + placeholders := strings.Repeat("?,", len(req.Currency)) + placeholders = placeholders[:len(placeholders)-1] + conditions = append(conditions, fmt.Sprintf("currency IN (%s)", placeholders)) + args = append(args, convertSliceToInterface(req.Currency)...) + } + if len(req.Kind) > 0 { + placeholders := strings.Repeat("?,", len(req.Kind)) + placeholders = placeholders[:len(placeholders)-1] + conditions = append(conditions, fmt.Sprintf("type IN (%s)", placeholders)) + args = append(args, convertSliceToInterface(req.Kind)...) + } + + // 如果有條件,則拼接 WHERE 子句 + if len(conditions) > 0 { + query += " WHERE " + strings.Join(conditions, " AND ") + } + + // 執行查詢 + err := m.conn.QueryRowCtx(ctx, &data, query, args...) + switch { + case err == nil: + return data, nil + case errors.Is(err, sqlc.ErrNotFound): + return nil, ErrNotFound + default: + return nil, err + } +} + +func convertSliceToInterface[T any](slice []T) []any { + interfaces := make([]any, 0, len(slice)) + for i, v := range slice { + interfaces[i] = v + } + return interfaces +} diff --git a/internal/model/wallet_model_gen.go b/internal/model/wallet_model_gen.go index c6358c9..50eb821 100755 --- a/internal/model/wallet_model_gen.go +++ b/internal/model/wallet_model_gen.go @@ -3,11 +3,14 @@ package model import ( + "app-cloudep-trade-service/internal/domain" "context" "database/sql" "fmt" "strings" + "github.com/shopspring/decimal" + "github.com/zeromicro/go-zero/core/stores/builder" "github.com/zeromicro/go-zero/core/stores/sqlc" "github.com/zeromicro/go-zero/core/stores/sqlx" @@ -36,14 +39,14 @@ type ( } Wallet struct { - Id int64 `db:"id"` // 錢包流水號 - Uid string `db:"uid"` // 用戶ID - Brand string `db:"brand"` // 品牌名稱 - Currency string `db:"currency"` // 幣別(或平台點數) - Balance float64 `db:"balance"` // 錢包餘額 - WalletType int64 `db:"wallet_type"` // 錢包種類: 1=可用, 2=凍結, 3=限制(僅出金) - CreatedAt int64 `db:"created_at"` // 創建時間 - UpdatedAt int64 `db:"updated_at"` // 更新時間 + Id int64 `db:"id"` // 錢包流水號 + Uid string `db:"uid"` // 用戶ID + Brand string `db:"brand"` // 品牌名稱 + Currency string `db:"currency"` // 幣別(或平台點數) + Balance decimal.Decimal `db:"balance"` // 錢包餘額 + WalletType domain.WalletType `db:"wallet_type"` // 錢包種類: 1=可用, 2=凍結, 3=限制(僅出金) + CreatedAt int64 `db:"created_at"` // 創建時間 + UpdatedAt int64 `db:"updated_at"` // 更新時間 } ) diff --git a/internal/repository/user_wallet.go b/internal/repository/user_wallet.go new file mode 100644 index 0000000..9fc118a --- /dev/null +++ b/internal/repository/user_wallet.go @@ -0,0 +1,41 @@ +package repository + +import ( + "app-cloudep-trade-service/internal/domain" + "app-cloudep-trade-service/internal/domain/repository" + "app-cloudep-trade-service/internal/model" + + "github.com/zeromicro/go-zero/core/stores/sqlx" + + "github.com/shopspring/decimal" +) + +// 用戶某個幣種餘額 +type userLocalWallet struct { + wm model.WalletModel + txConn sqlx.SqlConn + + uid string + crypto string + + // local wallet 相關計算的餘額存在這裡 + walletBalance map[domain.WalletType]model.Wallet + + // local order wallet 相關計算的餘額存在這裡 + localOrderBalance map[int64]decimal.Decimal + + // local wallet 內所有餘額變化紀錄 + transactions []model.WalletJournal +} + +func NewUserWalletOperator(uid, crypto string, wm model.WalletModel, txConn sqlx.SqlConn) repository.UserWalletOperator { + return &userLocalWallet{ + wm: wm, + txConn: txConn, + uid: uid, + crypto: crypto, + + walletBalance: make(map[domain.WalletType]model.Wallet, len(domain.AllWalletType)), + localOrderBalance: make(map[int64]decimal.Decimal, len(domain.AllWalletType)), + } +} diff --git a/internal/repository/wallet.go b/internal/repository/wallet.go new file mode 100644 index 0000000..7f06ece --- /dev/null +++ b/internal/repository/wallet.go @@ -0,0 +1,106 @@ +package repository + +import ( + "app-cloudep-trade-service/internal/domain" + "app-cloudep-trade-service/internal/domain/repository" + "app-cloudep-trade-service/internal/model" + "context" + "fmt" + "time" + + "github.com/shopspring/decimal" + "github.com/zeromicro/go-zero/core/logx" + "github.com/zeromicro/go-zero/core/stores/sqlx" +) + +type WalletRepositoryParam struct { + WalletModel model.WalletModel + TxConn sqlx.SqlConn +} + +type WalletRepository struct { + WalletRepositoryParam +} + +// GetUserWalletOperator 取得本地操作使用者錢包的操作運算元 +func (repo *WalletRepository) GetUserWalletOperator(uid, currency string, opts ...repository.Option) repository.UserWalletOperator { + db := repo.TxConn + + // 看是否有最新的DB 連線要傳入,做 tx + for _, fn := range opts { + db = fn() + } + + return NewUserWalletOperator(uid, currency, repo.WalletModel, db) +} + +// Create 創建錢包,如果有相同的就跳過不建立 +func (repo *WalletRepository) Create(ctx context.Context, uid, currency, brand string) ([]*model.Wallet, error) { + wallets := make([]*model.Wallet, 0, len(domain.AllWalletType)) + // 建立個人所有種類的錢包 + now := time.Now().UTC().UnixNano() + for _, item := range domain.AllWalletType { + balance := decimal.Zero + wallets = append(wallets, &model.Wallet{ + Brand: brand, + Currency: currency, + Uid: uid, + Balance: balance, + WalletType: item, + CreatedAt: now, + UpdatedAt: now, + }) + } + + _, err := repo.WalletModel.InsertMany(ctx, wallets) + if err != nil { + // 錯誤代碼 06-021-20 + e := domain.CommentErrorL( + domain.CreateWalletErrorCode, + logx.WithContext(ctx), + []logx.LogField{ + {Key: "param", Value: fmt.Sprintf("uid: %s, currency:%s, brand:%s", uid, currency, brand)}, + {Key: "func", Value: "WalletModel.InsertMany"}, + {Key: "err", Value: err}, + }, + "failed to insert wallet into mysql:").Wrap(err) + + return nil, e + } + + return wallets, nil +} + +func (repo *WalletRepository) Balances(ctx context.Context, req repository.BalanceReq) ([]model.Wallet, error) { + data, err := repo.WalletModel.Balances(ctx, model.BalanceReq{ + UID: req.UID, + Currency: req.Currency, + }) + + if err != nil { + // 錯誤代碼 06-021-20 + e := domain.CommentErrorL( + domain.CreateWalletErrorCode, + logx.WithContext(ctx), + []logx.LogField{ + {Key: "param", Value: req}, + {Key: "func", Value: "WalletModel.Balances"}, + {Key: "err", Value: err}, + }, + "failed to find balance into mongo:").Wrap(err) + + return []model.Wallet{}, e + } + + return data, nil +} + +func (repo *WalletRepository) GetTxDatabaseConn() sqlx.SqlConn { + return repo.TxConn +} + +func NewWalletRepository(param WalletRepositoryParam) repository.WalletRepository { + return &WalletRepository{ + WalletRepositoryParam: param, + } +} diff --git a/internal/svc/inject_mysql.go b/internal/svc/inject_mysql.go index 37c626f..4ca7f71 100644 --- a/internal/svc/inject_mysql.go +++ b/internal/svc/inject_mysql.go @@ -2,12 +2,10 @@ package svc import ( "app-cloudep-trade-service/internal/config" - model "app-cloudep-trade-service/internal/model" "database/sql" "fmt" "github.com/zeromicro/go-zero/core/logx" - "github.com/zeromicro/go-zero/core/stores/sqlx" ) func mustDSN(c config.Config) string { @@ -38,11 +36,3 @@ func newDatabase(c config.Config) (*sql.DB, error) { return db, nil } - -// MustWalletModel 連線 wallet 時 -func MustWalletModel(db *sql.DB) model.WalletModel { - // 創建並返回 *sqlx.SqlConn - sqlConn := sqlx.NewSqlConnFromDB(db) - - return model.NewWalletModel(sqlConn) -} diff --git a/internal/svc/service_context.go b/internal/svc/service_context.go index ee1e24e..ddc1c95 100644 --- a/internal/svc/service_context.go +++ b/internal/svc/service_context.go @@ -3,9 +3,10 @@ package svc import ( "app-cloudep-trade-service/internal/config" duc "app-cloudep-trade-service/internal/domain/usecase" - "app-cloudep-trade-service/internal/model" "app-cloudep-trade-service/internal/usecase" + "github.com/zeromicro/go-zero/core/stores/sqlx" + ers "code.30cm.net/digimon/library-go/errs" "code.30cm.net/digimon/library-go/errs/code" vi "code.30cm.net/digimon/library-go/validator" @@ -16,7 +17,7 @@ type ServiceContext struct { Validate vi.Validate OrderUseCase duc.OrderUseCase - WalletModel model.WalletModel + SQLConn sqlx.SqlConn } func NewServiceContext(c config.Config) *ServiceContext { @@ -32,6 +33,8 @@ func NewServiceContext(c config.Config) *ServiceContext { if err != nil { panic("failed to connect to wallet") } + // 創建 SQL 連線並返回 + sqlConn := sqlx.NewSqlConnFromDB(mysql) return &ServiceContext{ Config: c, @@ -39,8 +42,7 @@ func NewServiceContext(c config.Config) *ServiceContext { WithDecimalGt(), WithDecimalGte(), ), - + SQLConn: sqlConn, OrderUseCase: orderUseCase, - WalletModel: MustWalletModel(mysql), } }