From eacf85a5326dd197e7103ae59259b1575e443ebf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E6=80=A7=E9=A9=8A?= Date: Mon, 21 Apr 2025 15:46:43 +0800 Subject: [PATCH] feat: add transaction --- .../202504160353001_transaction.up.sql | 4 +- pkg/domain/entity/transaction.go | 30 +- pkg/domain/repository/wallet.go | 6 + pkg/domain/wallet/business_name.go | 6 + pkg/repository/transaction.go | 160 +++++ pkg/repository/transaction_test.go | 639 ++++++++++++++++++ pkg/repository/user_wallet.go | 33 + pkg/repository/user_wallet_test.go | 237 +++++++ pkg/repository/wallet_transaction.go | 68 ++ pkg/usecase/wallet.go | 25 +- pkg/usecase/wallet_tx_option.go | 57 +- 11 files changed, 1244 insertions(+), 21 deletions(-) create mode 100644 pkg/repository/transaction.go create mode 100644 pkg/repository/transaction_test.go create mode 100644 pkg/repository/wallet_transaction.go diff --git a/generate/database/202504160353001_transaction.up.sql b/generate/database/202504160353001_transaction.up.sql index 14e55cc..11e2700 100755 --- a/generate/database/202504160353001_transaction.up.sql +++ b/generate/database/202504160353001_transaction.up.sql @@ -12,11 +12,11 @@ CREATE TABLE `transaction` ( `balance` DECIMAL(30, 18) NOT NULL COMMENT '交易完成後的錢包餘額', `before_balance` DECIMAL(30, 18) NOT NULL COMMENT '交易前的錢包餘額(方便審計與對帳)', `status` TINYINT NOT NULL DEFAULT 1 COMMENT '狀態(1: 有效、0: 無效/已取消)', - `create_time` BIGINT NOT NULL DEFAULT 0 COMMENT '建立時間(Unix 秒數)', + `create_at` BIGINT NOT NULL DEFAULT 0 COMMENT '建立時間(Unix 秒數)', `due_time` BIGINT NOT NULL DEFAULT 0 COMMENT '到期時間(適用於凍結或延後入帳等場景)', PRIMARY KEY (`id`), UNIQUE KEY `uq_transaction_id` (`transaction_id`), KEY `idx_uid` (`uid`), KEY `idx_order_id` (`order_id`), - KEY `idx_create_time` (`create_time`) + KEY `idx_create_at` (`create_at`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='交易紀錄表'; \ No newline at end of file diff --git a/pkg/domain/entity/transaction.go b/pkg/domain/entity/transaction.go index 0fbee9a..1d3d565 100644 --- a/pkg/domain/entity/transaction.go +++ b/pkg/domain/entity/transaction.go @@ -8,21 +8,21 @@ import ( // Transaction 代表一筆錢包交易紀錄(例如充值、扣款、轉帳等) // 此表記錄所有交易的詳細資訊,包括金額、對象、餘額狀態與交易型態等 type Transaction struct { - ID int64 `gorm:"column:id"` // 交易主鍵 ID,自動遞增 - OrderID string `gorm:"column:order_id"` // 關聯的訂單 ID,可為空(若不是由訂單觸發) - TransactionID string `gorm:"column:transaction_id"` // 此筆交易的唯一識別碼(系統內部使用,可為 UUID) - Brand string `gorm:"column:brand"` // 所屬品牌(支援多品牌場景) - UID string `gorm:"column:uid"` // 交易發起者的 UID - ToUID string `gorm:"column:to_uid"` // 交易對象的 UID(如為轉帳場景) - TxType wallet.TxType `gorm:"column:type"` // 交易類型(如轉帳、入金、出金等,自定義列舉) - BusinessType int8 `gorm:"column:business_type"` // 業務類型(如合約、模擬、一般用途等,數字代碼) - Asset string `gorm:"column:asset"` // 幣種(如 BTC、ETH、USD、TWD 等) - Amount decimal.Decimal `gorm:"column:amount"` // 本次變動金額(正數為增加,負數為扣減) - PostTransferBalance decimal.Decimal `gorm:"column:post_transfer_balance"` // 交易完成後的錢包餘額 - BeforeBalance decimal.Decimal `gorm:"column:before_balance"` // 交易前的錢包餘額(方便審計與對帳) - Status wallet.Enable `gorm:"column:status"` // 狀態(1: 有效、0: 無效/已取消) - CreateAt int64 `gorm:"column:create_time;autoCreateTime"` // 建立時間(Unix 秒數) - DueTime int64 `gorm:"column:due_time"` // 到期時間(適用於凍結或延後入帳等場景) + ID int64 `gorm:"column:id"` // 交易主鍵 ID,自動遞增 + OrderID string `gorm:"column:order_id"` // 關聯的訂單 ID,可為空(若不是由訂單觸發) + TransactionID string `gorm:"column:transaction_id"` // 此筆交易的唯一識別碼(系統內部使用,可為 UUID) + Brand string `gorm:"column:brand"` // 所屬品牌(支援多品牌場景) + UID string `gorm:"column:uid"` // 交易發起者的 UID + ToUID string `gorm:"column:to_uid"` // 交易對象的 UID(如為轉帳場景) + TxType wallet.TxType `gorm:"column:type"` // 交易類型(如轉帳、入金、出金等,自定義列舉) + BusinessType int8 `gorm:"column:business_type"` // 業務類型(如合約、模擬、一般用途等,數字代碼) + Asset string `gorm:"column:asset"` // 幣種(如 BTC、ETH、USD、TWD 等) + Amount decimal.Decimal `gorm:"column:amount"` // 本次變動金額(正數為增加,負數為扣減) + PostTransferBalance decimal.Decimal `gorm:"column:post_transfer_balance"` // 交易完成後的錢包餘額 + BeforeBalance decimal.Decimal `gorm:"column:before_balance"` // 交易前的錢包餘額(方便審計與對帳) + Status wallet.Enable `gorm:"column:status"` // 狀態(1: 有效、0: 無效/已取消) + CreateAt int64 `gorm:"column:create_at;autoCreateTime"` // 建立時間(Unix 秒數) + DueTime int64 `gorm:"column:due_time"` // 到期時間(適用於凍結或延後入帳等場景) } // TableName 指定 GORM 對應的資料表名稱 diff --git a/pkg/domain/repository/wallet.go b/pkg/domain/repository/wallet.go index 86e8de6..d642c80 100644 --- a/pkg/domain/repository/wallet.go +++ b/pkg/domain/repository/wallet.go @@ -86,4 +86,10 @@ type UserWalletService interface { PersistOrderBalances(ctx context.Context) error // HasAvailableBalance 確認此使用者此資產是否已有可用餘額錢包 HasAvailableBalance(ctx context.Context) (bool, error) + // GetOrderBalance 查詢某筆交易(訂單),詳情寫入本地暫存 + GetOrderBalance(ctx context.Context, txID int64) (entity.Transaction, error) + // GetOrderBalanceForUpdate 查詢某筆交易(訂單),詳情寫入本地暫存 (FOR UPDATE) + GetOrderBalanceForUpdate(ctx context.Context, txID int64) (entity.Transaction, error) + // ClearCache 清空本地所有暫存 + ClearCache() } diff --git a/pkg/domain/wallet/business_name.go b/pkg/domain/wallet/business_name.go index 408f798..ad6c7c1 100644 --- a/pkg/domain/wallet/business_name.go +++ b/pkg/domain/wallet/business_name.go @@ -5,3 +5,9 @@ type BusinessName string func (b BusinessName) ToInt8() int8 { return int8(0) } + +const ( + WalletNonStatus = iota + // WalletUnconfirmedSettleStatus 執行過限制餘額結算 + WalletUnconfirmedSettleStatus +) diff --git a/pkg/repository/transaction.go b/pkg/repository/transaction.go new file mode 100644 index 0000000..f7e346b --- /dev/null +++ b/pkg/repository/transaction.go @@ -0,0 +1,160 @@ +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" + "errors" + "gorm.io/gorm" + "time" +) + +type TransactionRepositoryParam struct { + DB *gorm.DB `name:"dbM"` +} + +type TransactionRepository struct { + TransactionRepositoryParam +} + +func MustTransactionRepository(param TransactionRepositoryParam) repository.TransactionRepository { + return &TransactionRepository{ + param, + } +} + +func (repo *TransactionRepository) FindByOrderID(ctx context.Context, orderID string) (entity.Transaction, error) { + var result entity.Transaction + + err := repo.DB.WithContext(ctx).Where("order_id = ?", orderID).Take(&result).Error + if err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return entity.Transaction{}, repository.ErrRecordNotFound + } + } + + return result, nil +} + +func (repo *TransactionRepository) Insert(ctx context.Context, tx *entity.Transaction) error { + return repo.DB.Create(tx).Error +} + +func (repo *TransactionRepository) BatchInsert(ctx context.Context, txs []*entity.Transaction) error { + return repo.DB.Create(txs).Error +} + +func (repo *TransactionRepository) List(ctx context.Context, query repository.TransactionQuery) ([]entity.Transaction, int64, error) { + sql := repo.DB.WithContext(ctx) + + if len(query.BusinessType) != 0 { + sql = sql.Where("business_type IN ?", query.BusinessType) + } + + if query.UID != nil { + sql = sql.Where("uid = ?", query.UID) + } + + if query.OrderID != nil { + sql = sql.Where("order_id = ?", query.OrderID) + } + + if query.Assets != nil { + sql = sql.Where("asset = ?", query.Assets) + } + + if len(query.TxTypes) > 0 { + sql = sql.Where("type IN ?", query.TxTypes) + } + + //sql = sql.Where("status = ?", 0) + if query.StartTime != nil { + if query.EndTime != nil { + sql = sql.Where("create_at BETWEEN ? AND ?", query.StartTime, query.EndTime) + } + } + + var transactions []entity.Transaction + var count int64 + + if err := sql.Model(&entity.Transaction{}).Count(&count).Error; err != nil { + return []entity.Transaction{}, 0, err + } + + if count == 0 { + return []entity.Transaction{}, 0, repository.ErrRecordNotFound + } + + if query.PageIndex == 0 { + query.PageIndex = 1 + } + + if query.PageSize == 0 { + query.PageSize = 20 + } + + err := sql.Offset(int((query.PageIndex - 1) * query.PageSize)). + Limit(int(query.PageSize)). + Order("create_at desc"). + Find(&transactions).Error + + if err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return []entity.Transaction{}, 0, repository.ErrRecordNotFound + } + + return []entity.Transaction{}, 0, err + } + + return transactions, count, nil +} + +func (repo *TransactionRepository) FindByDueTimeRange(ctx context.Context, start time.Time, txType []wallet.TxType) ([]entity.Transaction, error) { + var data []entity.Transaction + err := repo.DB.WithContext(ctx). + Where("type IN ?", txType). + Where("status = ?", wallet.WalletNonStatus). + Where("due_time <= ?", start.UTC().Unix()). + Where("due_time != ?", 0). + Find(&data).Error + + if err != nil { + return nil, err + } + + return data, nil +} + +func (repo *TransactionRepository) UpdateStatusByID(ctx context.Context, id int64, status int) error { + err := repo.DB.WithContext(ctx). + Model(&entity.Transaction{}). + Where("id = ?", id). + UpdateColumn("status", status).Error + if err != nil { + return err + } + + return nil +} + +func (repo *TransactionRepository) ListWalletTransactions(ctx context.Context, uid string, orderIDs []string, walletType wallet.Types) ([]entity.WalletTransaction, error) { + sql := repo.DB.WithContext(ctx) + + if uid != "" { + sql = sql.Where("uid = ?", uid) + } + + sql = sql.Where("order_id IN ?", orderIDs) + + if walletType > 0 { + sql = sql.Where("wallet_type", walletType) + } + + result := make([]entity.WalletTransaction, len(orderIDs)) + if err := sql.Order("create_at desc").Find(&result).Error; err != nil { + return []entity.WalletTransaction{}, err + } + + return result, nil +} diff --git a/pkg/repository/transaction_test.go b/pkg/repository/transaction_test.go new file mode 100644 index 0000000..e4c656c --- /dev/null +++ b/pkg/repository/transaction_test.go @@ -0,0 +1,639 @@ +package repository + +import ( + "code.30cm.net/digimon/app-cloudep-wallet-service/internal/config" + "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" + "code.30cm.net/digimon/app-cloudep-wallet-service/pkg/lib/sql_client" + "context" + "errors" + "fmt" + "github.com/shopspring/decimal" + "github.com/stretchr/testify/assert" + "google.golang.org/protobuf/proto" + "gorm.io/gorm" + "testing" + "time" +) + +func SetupTestTransactionRepository() (repository.TransactionRepository, *gorm.DB, func(), error) { + host, port, _, tearDown, err := startMySQLContainer() + if err != nil { + return nil, nil, nil, fmt.Errorf("failed to start MySQL container: %w", err) + } + + conf := config.Config{ + MySQL: struct { + UserName string + Password string + Host string + Port string + Database string + MaxIdleConns int + MaxOpenConns int + ConnMaxLifetime time.Duration + LogLevel string + }{ + UserName: MySQLUser, + Password: MySQLPassword, + Host: host, + Port: port, + Database: MySQLDatabase, + MaxIdleConns: 10, + MaxOpenConns: 100, + ConnMaxLifetime: 300, + LogLevel: "info", + }, + } + + db, err := sql_client.NewMySQLClient(conf) + if err != nil { + tearDown() + return nil, nil, nil, fmt.Errorf("failed to create db client: %w", err) + } + + repo := MustTransactionRepository(TransactionRepositoryParam{DB: db}) + return repo, db, tearDown, nil +} + +func createTransactionTable(t *testing.T, db *gorm.DB) { + sql := ` + CREATE TABLE IF NOT EXISTS transaction ( + id BIGINT AUTO_INCREMENT PRIMARY KEY, + order_id VARCHAR(64) NOT NULL, + 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(sql).Error) +} + +func createWalletTransactionTable(t *testing.T, db *gorm.DB) { + createTableSQL := ` + CREATE TABLE IF NOT EXISTS wallet_transaction ( + id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT COMMENT '主鍵 ID,自動遞增', + transaction_id BIGINT NOT NULL COMMENT '交易流水號(可對應某次業務操作,例如同一訂單的多筆變化)', + order_id VARCHAR(64) NOT NULL COMMENT '訂單編號(對應實際訂單或業務事件)', + brand VARCHAR(50) NOT NULL COMMENT '品牌(多租戶或多平台識別)', + uid VARCHAR(64) NOT NULL COMMENT '使用者 UID', + wallet_type TINYINT NOT NULL COMMENT '錢包類型(如主錢包、獎勵錢包、凍結錢包等)', + business_type TINYINT NOT NULL COMMENT '業務類型(如購物、退款、加值等)', + asset VARCHAR(32) NOT NULL COMMENT '資產代號(如 BTC、ETH、GEM_RED、USD 等)', + amount DECIMAL(30,18) NOT NULL COMMENT '變動金額(正數為收入,負數為支出)', + balance DECIMAL(30,18) NOT NULL COMMENT '當前錢包餘額(這筆交易後的餘額快照)', + create_at BIGINT NOT NULL DEFAULT 0 COMMENT '建立時間(UnixNano,紀錄交易發生時間)', + PRIMARY KEY (id), + KEY idx_uid (uid), + KEY idx_transaction_id (transaction_id), + KEY idx_order_id (order_id), + KEY idx_brand (brand), + KEY idx_wallet_type (wallet_type) + ) ENGINE=InnoDB + DEFAULT CHARSET=utf8mb4 + COLLATE=utf8mb4_unicode_ci + COMMENT='錢包資金異動紀錄(每一次交易行為的快照記錄)';` + + assert.NoError(t, db.Exec(createTableSQL).Error) +} + +func TestTransactionRepository_InsertAndBatchInsert(t *testing.T) { + // start container and connect + repo, db, tearDown, err := SetupTestTransactionRepository() + assert.NoError(t, err) + defer tearDown() + + // prepare table + createTransactionTable(t, db) + + now := time.Now().Unix() + template := entity.Transaction{ + OrderID: "o1", + TransactionID: "tx1", + Brand: "b1", + UID: "u1", + ToUID: "u2", + TxType: 1, + BusinessType: 2, + Asset: "BTC", + Amount: decimal.RequireFromString("100.5"), + BeforeBalance: decimal.RequireFromString("50.0"), + PostTransferBalance: decimal.RequireFromString("150.5"), + Status: 1, + CreateAt: now, + DueTime: now + 3600, + } + + tests := []struct { + name string + setup func(t *testing.T) + action func() error + validate func(t *testing.T) + }{ + { + name: "single insert", + setup: func(t *testing.T) { + // clean table + assert.NoError(t, db.Exec("DELETE FROM transaction").Error) + }, + action: func() error { + tx := template + return repo.Insert(context.Background(), &tx) + }, + validate: func(t *testing.T) { + var count int64 + assert.NoError(t, db.Raw("SELECT COUNT(*) FROM transaction").Scan(&count).Error) + assert.Equal(t, int64(1), count) + + var got entity.Transaction + err := db.Take(&got, "order_id = ?", template.OrderID).Error + assert.NoError(t, err) + }, + }, + { + name: "batch insert", + setup: func(t *testing.T) { + assert.NoError(t, db.Exec("DELETE FROM transaction").Error) + }, + action: func() error { + // clone two entries with different order IDs + tx1 := template + tx2 := template + tx1.OrderID = "o2" + tx2.OrderID = "o3" + return repo.BatchInsert(context.Background(), []*entity.Transaction{&tx1, &tx2}) + }, + validate: func(t *testing.T) { + var count int64 + assert.NoError(t, db.Raw("SELECT COUNT(*) FROM transaction").Scan(&count).Error) + assert.Equal(t, int64(2), count) + + var orders []string + rows, _ := db.Raw("SELECT order_id FROM transaction ORDER BY order_id").Rows() + defer rows.Close() + for rows.Next() { + var oid string + rows.Scan(&oid) + orders = append(orders, oid) + } + assert.Equal(t, []string{"o2", "o3"}, orders) + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + tt.setup(t) + err := tt.action() + assert.NoError(t, err) + tt.validate(t) + }) + } +} + +func TestTransactionRepository_FindByOrderID(t *testing.T) { + // start container and connect + repo, db, tearDown, err := SetupTestTransactionRepository() + assert.NoError(t, err) + defer tearDown() + + // prepare table + createTransactionTable(t, db) + + // 4) seed one row + now := time.Now().Unix() + res := 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 (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + "order-123", "tx-abc", "brandX", "user1", "user2", + 1, 2, "BTC", + "10.0", "5.0", "15.0", + 1, now, now+3600, + ) + assert.NoError(t, res.Error) + assert.Equal(t, int64(1), res.RowsAffected) + + type want struct { + tx entity.Transaction + wantErr bool + } + + tests := []struct { + name string + orderID string + want want + }{ + { + name: "found existing", + orderID: "order-123", + want: want{ + tx: entity.Transaction{ + ID: 1, + OrderID: "order-123", + TransactionID: "tx-abc", + Brand: "brandX", + UID: "user1", + ToUID: "user2", + TxType: wallet.TxType(1), + BusinessType: int8(2), + Asset: "BTC", + Amount: decimal.RequireFromString("10.0"), + BeforeBalance: decimal.RequireFromString("5.0"), + PostTransferBalance: decimal.RequireFromString("15.0"), + Status: wallet.Enable(1), + CreateAt: now, + DueTime: now + 3600, + }, + wantErr: false, + }, + }, + { + name: "not found", + orderID: "missing", + want: want{wantErr: true}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := repo.FindByOrderID(context.Background(), tt.orderID) + if tt.want.wantErr { + assert.Error(t, err) + assert.True(t, errors.Is(err, repository.ErrRecordNotFound)) + return + } + assert.NoError(t, err) + 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 TestTransactionRepository_List(t *testing.T) { + repo, db, tearDown, err := SetupTestTransactionRepository() + assert.NoError(t, err) + defer tearDown() + + createTransactionTable(t, db) + + now := time.Now().Unix() + rows := []entity.Transaction{ + {OrderID: "A", UID: "u1", TxType: wallet.Deposit, BusinessType: 1, Asset: "BTC", CreateAt: now - 30}, + {OrderID: "B", UID: "u2", TxType: wallet.Withdraw, BusinessType: 2, Asset: "ETH", CreateAt: now - 20}, + {OrderID: "C", UID: "u1", TxType: wallet.Deposit, BusinessType: 1, Asset: "BTC", CreateAt: now - 10}, + } + + assert.NoError(t, db.Create(&rows).Error) + + tests := []struct { + name string + query repository.TransactionQuery + wantCount int64 + wantIDs []int64 + wantErr error + }{ + { + name: "no filter returns all", + query: repository.TransactionQuery{ + PageIndex: 1, + PageSize: 50, + }, + wantCount: 3, + wantIDs: []int64{rows[2].ID, rows[1].ID, rows[0].ID}, + wantErr: nil, + }, + { + name: "filter by UID", + query: repository.TransactionQuery{ + PageIndex: 1, + PageSize: 50, + UID: proto.String("u1"), + }, + wantCount: 2, + wantIDs: []int64{rows[2].ID, rows[0].ID}, + }, + { + name: "filter by BusinessType", + query: repository.TransactionQuery{ + PageIndex: 1, + PageSize: 50, + BusinessType: []int8{2}, + }, + wantCount: 1, + wantIDs: []int64{rows[1].ID}, + }, + { + name: "filter by Asset + TxType", + query: repository.TransactionQuery{ + PageIndex: 1, + PageSize: 50, + Assets: proto.String("BTC"), + TxTypes: []wallet.TxType{wallet.Deposit}, + }, + wantCount: 2, + wantIDs: []int64{rows[2].ID, rows[0].ID}, + }, + { + name: "time range filter", + query: repository.TransactionQuery{ + StartTime: proto.Int64(now - 25), + EndTime: proto.Int64(now - 5), + }, + wantCount: 2, + wantIDs: []int64{rows[2].ID, rows[1].ID}, + }, + { + name: "paging page 1 size 1", + query: repository.TransactionQuery{ + PageIndex: 1, + PageSize: 1, + }, + wantCount: 3, + wantIDs: []int64{rows[2].ID}, + }, + { + name: "paging page 2 size 1", + query: repository.TransactionQuery{ + PageIndex: 2, + PageSize: 1, + }, + wantCount: 3, + wantIDs: []int64{rows[1].ID}, + }, + { + name: "no match returns ErrRecordNotFound", + query: repository.TransactionQuery{UID: proto.String("nonexist")}, + wantErr: repository.ErrRecordNotFound, + }, + } + + ctx := context.Background() + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, cnt, err := repo.List(ctx, repository.TransactionQuery{ + BusinessType: tt.query.BusinessType, + UID: tt.query.UID, + OrderID: tt.query.OrderID, + Assets: tt.query.Assets, + TxTypes: tt.query.TxTypes, + StartTime: tt.query.StartTime, + EndTime: tt.query.EndTime, + PageIndex: tt.query.PageIndex, + PageSize: tt.query.PageSize, + }) + if tt.wantErr != nil { + assert.ErrorIs(t, err, tt.wantErr) + return + } + assert.NoError(t, err) + assert.Equal(t, tt.wantCount, cnt) + + var ids []int64 + for _, tx := range got { + ids = append(ids, tx.ID) + } + assert.Equal(t, tt.wantIDs, ids) + }) + } +} + +func TestTransactionRepository_FindByDueTimeRange(t *testing.T) { + // start container and connect + repo, db, tearDown, err := SetupTestTransactionRepository() + assert.NoError(t, err) + defer tearDown() + + // prepare table + createTransactionTable(t, db) + + // seed rows + now := time.Now().Unix() + rows := []entity.Transaction{ + {TxType: wallet.Deposit, BusinessType: 0, DueTime: now - 10, Status: wallet.WalletNonStatus}, + {TxType: wallet.Deposit, BusinessType: 0, DueTime: now + 10, Status: wallet.WalletNonStatus}, + {TxType: wallet.Deposit, BusinessType: 0, DueTime: 0, Status: wallet.WalletNonStatus}, + {TxType: wallet.Deposit, BusinessType: 0, DueTime: now - 5, Status: wallet.Enable(1)}, // status != non + } + + assert.NoError(t, db.Create(&rows).Error) + + tests := []struct { + name string + cutoff time.Time + types []wallet.TxType + wantIDs []int64 + }{ + { + name: "due before now for type=1", + cutoff: time.Unix(now, 0), + types: []wallet.TxType{wallet.Deposit}, + wantIDs: []int64{rows[0].ID}, + }, + { + name: "include multiple types", + cutoff: time.Unix(now+20, 0), + types: []wallet.TxType{wallet.Deposit}, + wantIDs: []int64{rows[0].ID, rows[1].ID}, + }, + { + name: "zero due_time is skipped", + cutoff: time.Unix(now+100, 0), + types: []wallet.TxType{wallet.Deposit}, + wantIDs: []int64{rows[0].ID, rows[1].ID}, + }, + { + name: "no matches", + cutoff: time.Unix(now-100, 0), + types: []wallet.TxType{wallet.Deposit}, + wantIDs: nil, + }, + } + + ctx := context.Background() + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := repo.FindByDueTimeRange(ctx, tt.cutoff, tt.types) + assert.NoError(t, err) + var ids []int64 + for _, tx := range got { + ids = append(ids, tx.ID) + } + assert.Equal(t, tt.wantIDs, ids) + }) + } +} + +func TestTransactionRepository_UpdateStatusByID(t *testing.T) { + // start container and connect + repo, db, tearDown, err := SetupTestTransactionRepository() + assert.NoError(t, err) + defer tearDown() + + createTransactionTable(t, db) + + ctx := context.Background() + + tx := &entity.Transaction{ + OrderID: "ord-123", + TransactionID: "tx-abc", + Brand: "brand1", + UID: "user1", + ToUID: "user2", + TxType: wallet.TxType(1), + BusinessType: int8(2), + Asset: "BTC", + Amount: decimal.NewFromInt(100), + PostTransferBalance: decimal.NewFromInt(1000), + BeforeBalance: decimal.NewFromInt(900), + Status: wallet.Enable(0), + CreateAt: time.Now().Unix(), + DueTime: time.Now().Add(time.Hour).Unix(), + } + err = repo.Insert(ctx, tx) + assert.NoError(t, err) + existingID := tx.ID + + tests := []struct { + name string + id int64 + newStatus int + wantErr bool + wantRow bool + }{ + { + name: "update existing row", + id: existingID, + newStatus: 1, + wantErr: false, + wantRow: true, + }, + { + name: "non-existent id does not error", + id: existingID + 999, + newStatus: 5, + wantErr: false, + wantRow: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := repo.UpdateStatusByID(ctx, tt.id, tt.newStatus) + if tt.wantErr { + assert.Error(t, err) + return + } + assert.NoError(t, err) + + var got entity.Transaction + res := db.First(&got, tt.id) + if tt.wantRow { + // existing row should reflect the new status + assert.NoError(t, res.Error) + assert.Equal(t, tt.newStatus, int(got.Status)) + } else { + // non-existent id: no record found + assert.Error(t, res.Error) + assert.True(t, errors.Is(res.Error, gorm.ErrRecordNotFound)) + } + }) + } +} + +func TestTransactionRepository_ListWalletTransactions(t *testing.T) { + // start container and connect + repo, db, tearDown, err := SetupTestTransactionRepository() + assert.NoError(t, err) + defer tearDown() + + createWalletTransactionTable(t, db) + ctx := context.Background() + now := time.Now().UnixNano() + transactions := []entity.WalletTransaction{ + {OrderID: "o1", UID: "u1", WalletType: wallet.TypeAvailable, Asset: "BTC", Amount: decimal.NewFromInt(10), Balance: decimal.NewFromInt(110), CreateAt: now - 3}, + {OrderID: "o2", UID: "u1", WalletType: wallet.TypeFreeze, Asset: "ETH", Amount: decimal.NewFromInt(20), Balance: decimal.NewFromInt(220), CreateAt: now - 2}, + {OrderID: "o1", UID: "u2", WalletType: wallet.TypeAvailable, Asset: "BTC", Amount: decimal.NewFromInt(30), Balance: decimal.NewFromInt(330), CreateAt: now - 1}, + } + + assert.NoError(t, db.Create(&transactions).Error) + + tests := []struct { + name string + uid string + orderIDs []string + walletType wallet.Types + wantIDs []int64 + wantErr bool + }{ + { + name: "filter by uid=u1, both orders", + uid: "u1", + orderIDs: []string{"o1", "o2"}, + walletType: 0, + wantIDs: []int64{transactions[0].ID, transactions[1].ID}, + }, + { + name: "filter by uid=u1, walletType=1", + uid: "u1", + orderIDs: []string{"o1", "o2"}, + walletType: wallet.Types(1), + wantIDs: []int64{transactions[0].ID}, + }, + { + name: "filter by order=o1 only, any uid", + uid: "", + orderIDs: []string{"o1"}, + walletType: wallet.Types(1), + wantIDs: []int64{transactions[0].ID, transactions[2].ID}, + }, + { + name: "no match yields empty", + uid: "nope", + orderIDs: []string{"o1"}, + walletType: 0, + wantIDs: []int64{}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := repo.ListWalletTransactions(ctx, tt.uid, tt.orderIDs, tt.walletType) + if tt.wantErr { + assert.Error(t, err) + return + } + assert.NoError(t, err) + // 提取返回的 IDs 进行无序比较 + gotIDs := make([]int64, len(got)) + for i, w := range got { + gotIDs[i] = w.ID + } + assert.ElementsMatch(t, tt.wantIDs, gotIDs) + }) + } +} diff --git a/pkg/repository/user_wallet.go b/pkg/repository/user_wallet.go index 27002df..c3a86c1 100644 --- a/pkg/repository/user_wallet.go +++ b/pkg/repository/user_wallet.go @@ -136,6 +136,7 @@ func (s *WalletService) IncreaseBalance(kind wallet.Types, orderID string, amoun Balance: w.Balance, }) s.localBalances[kind] = w + return nil } @@ -210,6 +211,38 @@ func (s *WalletService) HasAvailableBalance(ctx context.Context) (bool, error) { return exists, nil } +func (s *WalletService) GetOrderBalance(ctx context.Context, txID int64) (entity.Transaction, error) { + var t entity.Transaction + err := s.db.WithContext(ctx). + Where("id = ?", txID). + Take(&t).Error + if err != nil { + return entity.Transaction{}, translateNotFound(err) + } + s.localOrderBalances[t.ID] = t.PostTransferBalance + + return t, nil +} + +func (s *WalletService) GetOrderBalanceForUpdate(ctx context.Context, txID int64) (entity.Transaction, error) { + var t entity.Transaction + err := s.db.WithContext(ctx). + Where("id = ?", txID). + Clauses(clause.Locking{Strength: "UPDATE"}). + Take(&t).Error + if err != nil { + return entity.Transaction{}, translateNotFound(err) + } + s.localOrderBalances[t.ID] = t.PostTransferBalance + return t, nil +} + +func (s *WalletService) ClearCache() { + s.localBalances = make(map[wallet.Types]entity.Wallet, len(wallet.AllTypes)) + s.localOrderBalances = make(map[int64]decimal.Decimal, len(wallet.AllTypes)) + s.transactions = nil +} + // translateNotFound 將 GORM 的 RecordNotFound 轉為自訂錯誤 func translateNotFound(err error) error { if errors.Is(err, gorm.ErrRecordNotFound) { diff --git a/pkg/repository/user_wallet_test.go b/pkg/repository/user_wallet_test.go index 0882b74..9d709bb 100644 --- a/pkg/repository/user_wallet_test.go +++ b/pkg/repository/user_wallet_test.go @@ -5,6 +5,7 @@ import ( "code.30cm.net/digimon/app-cloudep-wallet-service/pkg/domain/repository" "code.30cm.net/digimon/app-cloudep-wallet-service/pkg/domain/wallet" "context" + "errors" "fmt" "github.com/shopspring/decimal" "github.com/stretchr/testify/assert" @@ -1023,3 +1024,239 @@ func TestWalletService_GetBalancesForUpdate(t *testing.T) { }) } } + +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 + txID int64 + want want + }{ + { + name: "found existing transaction", + txID: 1, + 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", + txID: 999, + want: want{errIsNF: true}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := svc.GetOrderBalance(context.Background(), tt.txID) + 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 + txID int64 + want want + }{ + { + name: "found and locks", + txID: 1, + 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", + txID: 999, + want: want{errIsNF: true}, + }, + } + + for _, tt := range cases { + t.Run(tt.name, func(t *testing.T) { + got, err := svc.GetOrderBalanceForUpdate(context.Background(), tt.txID) + 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/wallet_transaction.go b/pkg/repository/wallet_transaction.go new file mode 100644 index 0000000..5d30878 --- /dev/null +++ b/pkg/repository/wallet_transaction.go @@ -0,0 +1,68 @@ +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" + "gorm.io/gorm" +) + +type WalletTransactionRepositoryParam struct { + DB *gorm.DB `name:"dbM"` +} + +type WalletTransactionRepository struct { + WalletTransactionRepositoryParam +} + +func MustWalletTransactionRepository(param WalletTransactionRepositoryParam) repository.WalletTransactionRepo { + return &WalletTransactionRepository{ + param, + } +} + +func (repo *WalletTransactionRepository) Create(ctx context.Context, db *gorm.DB, tx []entity.WalletTransaction) error { + err := db.WithContext(ctx).Create(tx).Error + if err != nil { + return err + } + + return nil +} + +func (repo *WalletTransactionRepository) HistoryBalance(ctx context.Context, req repository.HistoryReq) ([]entity.WalletTransaction, error) { + var data []entity.WalletTransaction + + err := repo.DB.WithContext(ctx).Raw( + `SELECT + MAX(t.id) as id + FROM ( + SELECT * FROM wallet_transaction + WHERE uid = ? AND wallet_type IN ? AND create_at <= ? + ) As t + GROUP BY t.crypto, t.wallet_type`, + req.UID, wallet.AllTypes, req.StartTime, + ).Find(&data).Error + + if err != nil { + return nil, err + } + + if len(data) == 0 { + return nil, repository.ErrRecordNotFound + } + + ids := make([]int64, 0, len(wallet.AllTypes)) + for _, v := range data { + ids = append(ids, v.ID) + } + + err = repo.DB.WithContext(ctx).Where(ids).Find(&data).Error + + if err != nil { + return nil, err + } + + return data, nil +} diff --git a/pkg/usecase/wallet.go b/pkg/usecase/wallet.go index 848d8ca..08bb57e 100644 --- a/pkg/usecase/wallet.go +++ b/pkg/usecase/wallet.go @@ -41,14 +41,33 @@ func (use *WalletUseCase) Withdraw(ctx context.Context, tx usecase.WalletTransfe return use.ProcessTransaction(ctx, tx, userWalletFlow{ UID: tx.FromUID, Asset: tx.Asset, - Actions: []walletActionOption{use.withLockAvailable(), use.withSubAvailable()}, //use.lockAvailable(), use.subAvailable() + Actions: []walletActionOption{use.withLockAvailable(), use.withSubAvailable()}, }) } +// Deposit 充值 +// 1. 新增一筆充值交易 +// 2. 錢包增加可用餘額 +// 3. 錢包變化新增一筆增加可用餘額資料 func (use *WalletUseCase) Deposit(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.Deposit + + if err := use.ProcessTransaction( + ctx, tx, userWalletFlow{ + UID: tx.FromUID, + Asset: tx.Asset, + Actions: []walletActionOption{use.withLockAvailable(), use.withAddAvailable()}, + }); err != nil { + return err + } + + return nil } func (use *WalletUseCase) DepositUnconfirmed(ctx context.Context, tx usecase.WalletTransferRequest) error { diff --git a/pkg/usecase/wallet_tx_option.go b/pkg/usecase/wallet_tx_option.go index 78ced43..209c89a 100644 --- a/pkg/usecase/wallet_tx_option.go +++ b/pkg/usecase/wallet_tx_option.go @@ -62,7 +62,7 @@ func (use *WalletUseCase) withLockAvailable() walletActionOption { } } -// subAvailable 減少用戶可用餘額 +// withSubAvailable 減少用戶可用餘額 func (use *WalletUseCase) withSubAvailable() walletActionOption { return func(_ context.Context, tx *usecase.WalletTransferRequest, w repository.UserWalletService) error { if err := w.DecreaseBalance(wallet.TypeAvailable, tx.ReferenceOrderID, tx.Amount); err != nil { @@ -77,3 +77,58 @@ func (use *WalletUseCase) withSubAvailable() walletActionOption { return nil } } + +// withAddAvailable 增加用戶可用餘額 +func (use *WalletUseCase) withAddAvailable() walletActionOption { + return func(_ context.Context, tx *usecase.WalletTransferRequest, w repository.UserWalletService) error { + if err := w.IncreaseBalance(wallet.TypeAvailable, tx.ReferenceOrderID, tx.Amount); err != nil { + return err + } + + return nil + } +} + +// withAddFreeze 增加用戶凍結餘額 +func (use *WalletUseCase) withAddFreeze() walletActionOption { + return func(_ context.Context, tx *usecase.WalletTransferRequest, w repository.UserWalletService) error { + if err := w.IncreaseBalance(wallet.TypeFreeze, tx.ReferenceOrderID, tx.Amount); err != nil { + return err + } + // 訂單可以做解凍,解凍最大上限金額來自當初凍結金額,所以在每一筆tx可以設定Balance + // 後續tx需要依據其他tx做交易時能有所依據 + tx.PostTransferBalance = tx.Amount + + return nil + } +} + +//// WithAppendFreeze 追加用戶原凍結餘額 +//func (use *WalletUseCase) withAppendFreeze() walletActionOption { +// return func(ctx context.Context, tx *usecase.WalletTransferRequest, w repository.UserWalletService) error { +// order, err := wallet.GetOrderBalance(ctx, tx.ReferenceOrderIDs) +// if err != nil { +// return use.translateError(err) +// } +// +// // 以id來做lock更可以確保只lock到該筆,而不會因為index關係lock到多筆導致死鎖 +// // 而且先不lock把資料先拉出來判斷餘額是否足夠,在不足夠時可以直接return而不用lock減少開銷 +// order, err = wallet.GetOrderBalanceXLock(ctx, order.ID) +// if err != nil { +// return use.translateError(err) +// } +// +// tx.Crypto = order.Crypto +// tx.UID = order.UID +// +// if err := wallet.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 +// } +//}