feat: add transaction
This commit is contained in:
parent
47fb3139c2
commit
eacf85a532
|
@ -12,11 +12,11 @@ CREATE TABLE `transaction` (
|
||||||
`balance` DECIMAL(30, 18) NOT NULL COMMENT '交易完成後的錢包餘額',
|
`balance` DECIMAL(30, 18) NOT NULL COMMENT '交易完成後的錢包餘額',
|
||||||
`before_balance` DECIMAL(30, 18) NOT NULL COMMENT '交易前的錢包餘額(方便審計與對帳)',
|
`before_balance` DECIMAL(30, 18) NOT NULL COMMENT '交易前的錢包餘額(方便審計與對帳)',
|
||||||
`status` TINYINT NOT NULL DEFAULT 1 COMMENT '狀態(1: 有效、0: 無效/已取消)',
|
`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 '到期時間(適用於凍結或延後入帳等場景)',
|
`due_time` BIGINT NOT NULL DEFAULT 0 COMMENT '到期時間(適用於凍結或延後入帳等場景)',
|
||||||
PRIMARY KEY (`id`),
|
PRIMARY KEY (`id`),
|
||||||
UNIQUE KEY `uq_transaction_id` (`transaction_id`),
|
UNIQUE KEY `uq_transaction_id` (`transaction_id`),
|
||||||
KEY `idx_uid` (`uid`),
|
KEY `idx_uid` (`uid`),
|
||||||
KEY `idx_order_id` (`order_id`),
|
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='交易紀錄表';
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='交易紀錄表';
|
|
@ -21,7 +21,7 @@ type Transaction struct {
|
||||||
PostTransferBalance decimal.Decimal `gorm:"column:post_transfer_balance"` // 交易完成後的錢包餘額
|
PostTransferBalance decimal.Decimal `gorm:"column:post_transfer_balance"` // 交易完成後的錢包餘額
|
||||||
BeforeBalance decimal.Decimal `gorm:"column:before_balance"` // 交易前的錢包餘額(方便審計與對帳)
|
BeforeBalance decimal.Decimal `gorm:"column:before_balance"` // 交易前的錢包餘額(方便審計與對帳)
|
||||||
Status wallet.Enable `gorm:"column:status"` // 狀態(1: 有效、0: 無效/已取消)
|
Status wallet.Enable `gorm:"column:status"` // 狀態(1: 有效、0: 無效/已取消)
|
||||||
CreateAt int64 `gorm:"column:create_time;autoCreateTime"` // 建立時間(Unix 秒數)
|
CreateAt int64 `gorm:"column:create_at;autoCreateTime"` // 建立時間(Unix 秒數)
|
||||||
DueTime int64 `gorm:"column:due_time"` // 到期時間(適用於凍結或延後入帳等場景)
|
DueTime int64 `gorm:"column:due_time"` // 到期時間(適用於凍結或延後入帳等場景)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -86,4 +86,10 @@ type UserWalletService interface {
|
||||||
PersistOrderBalances(ctx context.Context) error
|
PersistOrderBalances(ctx context.Context) error
|
||||||
// HasAvailableBalance 確認此使用者此資產是否已有可用餘額錢包
|
// HasAvailableBalance 確認此使用者此資產是否已有可用餘額錢包
|
||||||
HasAvailableBalance(ctx context.Context) (bool, error)
|
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()
|
||||||
}
|
}
|
||||||
|
|
|
@ -5,3 +5,9 @@ type BusinessName string
|
||||||
func (b BusinessName) ToInt8() int8 {
|
func (b BusinessName) ToInt8() int8 {
|
||||||
return int8(0)
|
return int8(0)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const (
|
||||||
|
WalletNonStatus = iota
|
||||||
|
// WalletUnconfirmedSettleStatus 執行過限制餘額結算
|
||||||
|
WalletUnconfirmedSettleStatus
|
||||||
|
)
|
||||||
|
|
|
@ -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
|
||||||
|
}
|
|
@ -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)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
|
@ -136,6 +136,7 @@ func (s *WalletService) IncreaseBalance(kind wallet.Types, orderID string, amoun
|
||||||
Balance: w.Balance,
|
Balance: w.Balance,
|
||||||
})
|
})
|
||||||
s.localBalances[kind] = w
|
s.localBalances[kind] = w
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -210,6 +211,38 @@ func (s *WalletService) HasAvailableBalance(ctx context.Context) (bool, error) {
|
||||||
return exists, nil
|
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 轉為自訂錯誤
|
// translateNotFound 將 GORM 的 RecordNotFound 轉為自訂錯誤
|
||||||
func translateNotFound(err error) error {
|
func translateNotFound(err error) error {
|
||||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||||
|
|
|
@ -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/repository"
|
||||||
"code.30cm.net/digimon/app-cloudep-wallet-service/pkg/domain/wallet"
|
"code.30cm.net/digimon/app-cloudep-wallet-service/pkg/domain/wallet"
|
||||||
"context"
|
"context"
|
||||||
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"github.com/shopspring/decimal"
|
"github.com/shopspring/decimal"
|
||||||
"github.com/stretchr/testify/assert"
|
"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))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
@ -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
|
||||||
|
}
|
|
@ -41,14 +41,33 @@ func (use *WalletUseCase) Withdraw(ctx context.Context, tx usecase.WalletTransfe
|
||||||
return use.ProcessTransaction(ctx, tx, userWalletFlow{
|
return use.ProcessTransaction(ctx, tx, userWalletFlow{
|
||||||
UID: tx.FromUID,
|
UID: tx.FromUID,
|
||||||
Asset: tx.Asset,
|
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 {
|
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 {
|
func (use *WalletUseCase) DepositUnconfirmed(ctx context.Context, tx usecase.WalletTransferRequest) error {
|
||||||
|
|
|
@ -62,7 +62,7 @@ func (use *WalletUseCase) withLockAvailable() walletActionOption {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// subAvailable 減少用戶可用餘額
|
// withSubAvailable 減少用戶可用餘額
|
||||||
func (use *WalletUseCase) withSubAvailable() walletActionOption {
|
func (use *WalletUseCase) withSubAvailable() walletActionOption {
|
||||||
return func(_ context.Context, tx *usecase.WalletTransferRequest, w repository.UserWalletService) error {
|
return func(_ context.Context, tx *usecase.WalletTransferRequest, w repository.UserWalletService) error {
|
||||||
if err := w.DecreaseBalance(wallet.TypeAvailable, tx.ReferenceOrderID, tx.Amount); err != nil {
|
if err := w.DecreaseBalance(wallet.TypeAvailable, tx.ReferenceOrderID, tx.Amount); err != nil {
|
||||||
|
@ -77,3 +77,58 @@ func (use *WalletUseCase) withSubAvailable() walletActionOption {
|
||||||
return nil
|
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
|
||||||
|
// }
|
||||||
|
//}
|
||||||
|
|
Loading…
Reference in New Issue