refactor: replace redis runtime and queues with sqlite-backed scheduler

This commit is contained in:
alphago9
2026-03-29 17:26:36 +08:00
parent d6e7927605
commit fc8d58024e
22 changed files with 1193 additions and 446 deletions
+2 -2
View File
@@ -9,7 +9,7 @@ func Init() {
log.Fatalf("DBInit err: %v", err)
}
if err := RedisInit(); err != nil {
log.Fatalf("RedisInit err: %v", err)
if err := RuntimeInit(); err != nil {
log.Fatalf("RuntimeInit err: %v", err)
}
}
-46
View File
@@ -1,46 +0,0 @@
package dao
import (
"context"
"fmt"
"time"
"github.com/assimon/luuu/util/log"
"github.com/go-redis/redis/v8"
"github.com/gookit/color"
"github.com/spf13/viper"
)
var Rdb *redis.Client
func RedisInit() error {
options := redis.Options{
Addr: fmt.Sprintf(
"%s:%s",
viper.GetString("redis_host"),
viper.GetString("redis_port")), // Redis地址
DB: viper.GetInt("redis_db"), // Redis库
PoolSize: viper.GetInt("redis_pool_size"), // Redis连接池大小
MaxRetries: viper.GetInt("redis_max_retries"), // 最大重试次数
IdleTimeout: time.Second * time.Duration(viper.GetInt("redis_idle_timeout")), // 空闲链接超时时间
}
if viper.GetString("redis_passwd") != "" {
options.Password = viper.GetString("redis_passwd")
}
Rdb = redis.NewClient(&options)
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
pong, err := Rdb.Ping(ctx).Result()
if err == redis.Nil {
log.Sugar.Debug("[store_redis] Nil reply returned by Rdb when key does not exist.")
} else if err != nil {
color.Red.Printf("[store_redis] redis connRdb err,err=%s", err)
// panic(err)
// time.Sleep(10 * time.Second)
// RedisInit()
return err
} else {
log.Sugar.Debug("[store_redis] redis connRdb success,suc=%s", pong)
}
return nil
}
+66
View File
@@ -0,0 +1,66 @@
package dao
import (
"github.com/assimon/luuu/config"
"github.com/assimon/luuu/model/mdb"
"github.com/assimon/luuu/util/log"
"github.com/gookit/color"
"gorm.io/gorm"
"gorm.io/gorm/logger"
"gorm.io/gorm/schema"
)
var RuntimeDB *gorm.DB
func RuntimeInit() error {
var err error
runtimePath := config.GetRuntimeSqlitePath()
color.Green.Printf("[runtime_db] sqlite filename: %s\n", runtimePath)
RuntimeDB, err = openDB(runtimePath, &gorm.Config{
NamingStrategy: schema.NamingStrategy{
SingularTable: true,
},
Logger: logger.Default.LogMode(logger.Error),
})
if err != nil {
color.Red.Printf("[runtime_db] sqlite open DB,err=%s\n", err)
return err
}
sqlDB, err := RuntimeDB.DB()
if err != nil {
color.Red.Printf("[runtime_db] sqlite get DB,err=%s\n", err)
return err
}
concurrency := config.GetQueueConcurrency()
if concurrency < 2 {
concurrency = 2
}
if concurrency > 16 {
concurrency = 16
}
sqlDB.SetMaxOpenConns(concurrency)
sqlDB.SetMaxIdleConns(1)
if err = RuntimeDB.Exec("PRAGMA journal_mode=WAL;").Error; err != nil {
return err
}
if err = RuntimeDB.Exec("PRAGMA synchronous=NORMAL;").Error; err != nil {
return err
}
if err = RuntimeDB.Exec("PRAGMA busy_timeout=5000;").Error; err != nil {
return err
}
if err = sqlDB.Ping(); err != nil {
color.Red.Printf("[runtime_db] sqlite connDB err:%s", err.Error())
return err
}
if err = RuntimeDB.AutoMigrate(&mdb.TransactionLock{}); err != nil {
color.Red.Printf("[runtime_db] sqlite migrate DB(TransactionLock),err=%s\n", err)
return err
}
log.Sugar.Debug("[runtime_db] sqlite connDB success")
return nil
}
+107 -54
View File
@@ -1,109 +1,162 @@
package data
import (
"context"
"fmt"
"errors"
"time"
"github.com/assimon/luuu/model/dao"
"github.com/assimon/luuu/model/mdb"
"github.com/assimon/luuu/model/request"
"github.com/go-redis/redis/v8"
"github.com/shopspring/decimal"
"gorm.io/gorm"
"time"
"gorm.io/gorm/clause"
)
var (
CacheWalletAddressWithAmountToTradeIdKey = "wallet:%s_%v" // 钱包_待支付金额 : 交易号
)
var ErrTransactionLocked = errors.New("transaction amount is already locked")
// GetOrderInfoByOrderId 通过客户订单号查询订单
func normalizeLockAmount(amount float64) (int64, string) {
value := decimal.NewFromFloat(amount).Round(2)
return value.Shift(2).IntPart(), value.StringFixed(2)
}
// GetOrderInfoByOrderId fetches an order by merchant order id.
func GetOrderInfoByOrderId(orderId string) (*mdb.Orders, error) {
order := new(mdb.Orders)
err := dao.Mdb.Model(order).Limit(1).Find(order, "order_id = ?", orderId).Error
return order, err
}
// GetOrderInfoByTradeId 通过交易号查询订单
// GetOrderInfoByTradeId fetches an order by epusdt trade id.
func GetOrderInfoByTradeId(tradeId string) (*mdb.Orders, error) {
order := new(mdb.Orders)
err := dao.Mdb.Model(order).Limit(1).Find(order, "trade_id = ?", tradeId).Error
return order, err
}
// CreateOrderWithTransaction 事务创建订单
// CreateOrderWithTransaction creates an order in the active database transaction.
func CreateOrderWithTransaction(tx *gorm.DB, order *mdb.Orders) error {
err := tx.Model(order).Create(order).Error
return err
return tx.Model(order).Create(order).Error
}
// GetOrderByBlockIdWithTransaction 通过区块获取订单
// GetOrderByBlockIdWithTransaction fetches an order by blockchain tx id.
func GetOrderByBlockIdWithTransaction(tx *gorm.DB, blockId string) (*mdb.Orders, error) {
order := new(mdb.Orders)
err := tx.Model(order).Limit(1).Find(order, "block_transaction_id = ?", blockId).Error
return order, err
}
// OrderSuccessWithTransaction 事务支付成功
func OrderSuccessWithTransaction(tx *gorm.DB, req *request.OrderProcessingRequest) error {
err := tx.Model(&mdb.Orders{}).Where("trade_id = ?", req.TradeId).Updates(map[string]interface{}{
"block_transaction_id": req.BlockTransactionId,
"status": mdb.StatusPaySuccess,
"callback_confirm": mdb.CallBackConfirmNo,
}).Error
return err
// OrderSuccessWithTransaction marks an order as paid only if it is still waiting for payment.
func OrderSuccessWithTransaction(tx *gorm.DB, req *request.OrderProcessingRequest) (bool, error) {
result := tx.Model(&mdb.Orders{}).
Where("trade_id = ?", req.TradeId).
Where("status = ?", mdb.StatusWaitPay).
Updates(map[string]interface{}{
"block_transaction_id": req.BlockTransactionId,
"status": mdb.StatusPaySuccess,
"callback_confirm": mdb.CallBackConfirmNo,
})
return result.RowsAffected > 0, result.Error
}
// GetPendingCallbackOrders 查询出等待回调的订单
func GetPendingCallbackOrders() ([]mdb.Orders, error) {
// GetPendingCallbackOrders returns orders that still need callback delivery.
func GetPendingCallbackOrders(maxRetry int, limit int) ([]mdb.Orders, error) {
var orders []mdb.Orders
err := dao.Mdb.Model(orders).
Where("callback_num < ?", 5).
query := dao.Mdb.Model(&mdb.Orders{}).
Where("callback_num <= ?", maxRetry).
Where("callback_confirm = ?", mdb.CallBackConfirmNo).
Where("status = ?", mdb.StatusPaySuccess).
Find(&orders).Error
Order("updated_at asc")
if limit > 0 {
query = query.Limit(limit)
}
err := query.Find(&orders).Error
return orders, err
}
// SaveCallBackOrdersResp 保存订单回调结果
// SaveCallBackOrdersResp persists a callback attempt result.
func SaveCallBackOrdersResp(order *mdb.Orders) error {
err := dao.Mdb.Model(order).Where("id = ?", order.ID).Updates(map[string]interface{}{
"callback_num": gorm.Expr("callback_num + ?", 1),
"callback_confirm": order.CallBackConfirm,
}).Error
return err
return dao.Mdb.Model(order).
Where("id = ?", order.ID).
Where("callback_confirm = ?", mdb.CallBackConfirmNo).
Updates(map[string]interface{}{
"callback_num": gorm.Expr("callback_num + ?", 1),
"callback_confirm": order.CallBackConfirm,
}).Error
}
// UpdateOrderIsExpirationById 通过id设置订单过期
func UpdateOrderIsExpirationById(id uint64) error {
err := dao.Mdb.Model(mdb.Orders{}).Where("id = ?", id).Update("status", mdb.StatusExpired).Error
return err
// UpdateOrderIsExpirationById expires an order only if it is still pending and already timed out.
func UpdateOrderIsExpirationById(id uint64, expirationCutoff time.Time) (bool, error) {
result := dao.Mdb.Model(mdb.Orders{}).
Where("id = ?", id).
Where("status = ?", mdb.StatusWaitPay).
Where("created_at <= ?", expirationCutoff).
Update("status", mdb.StatusExpired)
return result.RowsAffected > 0, result.Error
}
// GetTradeIdByWalletAddressAndAmount 通过钱包地址,支付金额获取交易号
// GetTradeIdByWalletAddressAndAmount resolves the reserved trade id by token and amount.
func GetTradeIdByWalletAddressAndAmount(token string, amount float64) (string, error) {
ctx := context.Background()
cacheKey := fmt.Sprintf(CacheWalletAddressWithAmountToTradeIdKey, token, amount)
result, err := dao.Rdb.Get(ctx, cacheKey).Result()
if err == redis.Nil {
return "", nil
}
scaledAmount, _ := normalizeLockAmount(amount)
var lock mdb.TransactionLock
err := dao.RuntimeDB.Model(&mdb.TransactionLock{}).
Where("token = ?", token).
Where("amount_scaled = ?", scaledAmount).
Where("expires_at > ?", time.Now()).
Limit(1).
Find(&lock).Error
if err != nil {
return "", err
}
return result, nil
if lock.ID <= 0 {
return "", nil
}
return lock.TradeId, nil
}
// LockTransaction 锁定交易
// LockTransaction reserves a token+amount pair in sqlite until expiration.
func LockTransaction(token, tradeId string, amount float64, expirationTime time.Duration) error {
ctx := context.Background()
cacheKey := fmt.Sprintf(CacheWalletAddressWithAmountToTradeIdKey, token, amount)
err := dao.Rdb.Set(ctx, cacheKey, tradeId, expirationTime).Err()
return err
scaledAmount, amountText := normalizeLockAmount(amount)
now := time.Now()
lock := &mdb.TransactionLock{
Token: token,
AmountScaled: scaledAmount,
AmountText: amountText,
TradeId: tradeId,
ExpiresAt: now.Add(expirationTime),
}
return dao.RuntimeDB.Transaction(func(tx *gorm.DB) error {
if err := tx.Where("token = ?", token).
Where("amount_scaled = ?", scaledAmount).
Where("expires_at <= ?", now).
Delete(&mdb.TransactionLock{}).Error; err != nil {
return err
}
if err := tx.Where("trade_id = ?", tradeId).Delete(&mdb.TransactionLock{}).Error; err != nil {
return err
}
result := tx.Clauses(clause.OnConflict{DoNothing: true}).Create(lock)
if result.Error != nil {
return result.Error
}
if result.RowsAffected == 0 {
return ErrTransactionLocked
}
return nil
})
}
// UnLockTransaction 解锁交易
// UnLockTransaction releases the reservation for token+amount.
func UnLockTransaction(token string, amount float64) error {
ctx := context.Background()
cacheKey := fmt.Sprintf(CacheWalletAddressWithAmountToTradeIdKey, token, amount)
err := dao.Rdb.Del(ctx, cacheKey).Err()
return err
scaledAmount, _ := normalizeLockAmount(amount)
return dao.RuntimeDB.Where("token = ?", token).Where("amount_scaled = ?", scaledAmount).Delete(&mdb.TransactionLock{}).Error
}
func UnLockTransactionByTradeId(tradeId string) error {
return dao.RuntimeDB.Where("trade_id = ?", tradeId).Delete(&mdb.TransactionLock{}).Error
}
func CleanupExpiredTransactionLocks() error {
return dao.RuntimeDB.Where("expires_at <= ?", time.Now()).Delete(&mdb.TransactionLock{}).Error
}
+11 -12
View File
@@ -9,21 +9,20 @@ const (
)
type Orders struct {
TradeId string `gorm:"column:trade_id" json:"trade_id"` // epusdt订单号
OrderId string `gorm:"column:order_id" json:"order_id"` // 客户交易id
BlockTransactionId string `gorm:"index:orders_block_transaction_id_index;column:block_transaction_id" json:"block_transaction_id"` // 区块id
Amount float64 `gorm:"column:amount" json:"amount"` // 订单金额,保留4位小数
ActualAmount float64 `gorm:"column:actual_amount" json:"actual_amount"` // 订单实际需要支付的金额,保留4位小数
Token string `gorm:"column:token" json:"token"` // 所属钱包地址
Status int `gorm:"column:status;default:1" json:"status"` // 1:等待支付,2:支付成功,3:已过期
NotifyUrl string `gorm:"column:notify_url" json:"notify_url"` // 异步回调地址
RedirectUrl string `gorm:"column:redirect_url" json:"redirect_url"` // 同步回调地址
CallbackNum int `gorm:"column:callback_num;default:0" json:"callback_num"` // 回调次数
CallBackConfirm int `gorm:"column:callback_confirm;default:2" json:"callback_confirm"` // 回调是否已确认 1是 2否
TradeId string `gorm:"column:trade_id;uniqueIndex:orders_trade_id_uindex" json:"trade_id"`
OrderId string `gorm:"column:order_id;uniqueIndex:orders_order_id_uindex" json:"order_id"`
BlockTransactionId string `gorm:"index:orders_block_transaction_id_index;column:block_transaction_id" json:"block_transaction_id"`
Amount float64 `gorm:"column:amount" json:"amount"`
ActualAmount float64 `gorm:"column:actual_amount" json:"actual_amount"`
Token string `gorm:"column:token" json:"token"`
Status int `gorm:"column:status;default:1" json:"status"`
NotifyUrl string `gorm:"column:notify_url" json:"notify_url"`
RedirectUrl string `gorm:"column:redirect_url" json:"redirect_url"`
CallbackNum int `gorm:"column:callback_num;default:0" json:"callback_num"`
CallBackConfirm int `gorm:"column:callback_confirm;default:2" json:"callback_confirm"`
BaseModel
}
// TableName sets the insert table name for this struct type
func (o *Orders) TableName() string {
return "orders"
}
+18
View File
@@ -0,0 +1,18 @@
package mdb
import "time"
type TransactionLock struct {
ID uint64 `gorm:"column:id;primaryKey;autoIncrement" json:"id"`
Token string `gorm:"column:token;uniqueIndex:transaction_lock_token_amount_uindex,priority:1" json:"token"`
AmountScaled int64 `gorm:"column:amount_scaled;uniqueIndex:transaction_lock_token_amount_uindex,priority:2" json:"amount_scaled"`
AmountText string `gorm:"column:amount_text" json:"amount_text"`
TradeId string `gorm:"column:trade_id;index:transaction_lock_trade_id_index" json:"trade_id"`
ExpiresAt time.Time `gorm:"column:expires_at;index:transaction_lock_expires_at_index" json:"expires_at"`
CreatedAt time.Time `gorm:"column:created_at" json:"created_at"`
UpdatedAt time.Time `gorm:"column:updated_at" json:"updated_at"`
}
func (t *TransactionLock) TableName() string {
return "transaction_lock"
}
+2 -4
View File
@@ -5,14 +5,12 @@ const (
TokenStatusDisable = 2
)
// WalletAddress 钱包表
type WalletAddress struct {
Token string `gorm:"index:wallet_address_token_index;column:token" json:"token"` // 钱包token
Status int64 `gorm:"column:status;default:1" json:"status"` // 1:启用 2:禁用
Token string `gorm:"column:token;uniqueIndex:wallet_address_token_uindex" json:"token"`
Status int64 `gorm:"column:status;default:1" json:"status"`
BaseModel
}
// TableName sets the insert table name for this struct type
func (w *WalletAddress) TableName() string {
return "wallet_address"
}
+55 -53
View File
@@ -1,6 +1,7 @@
package service
import (
"errors"
"fmt"
"math/rand"
"sync"
@@ -12,42 +13,39 @@ import (
"github.com/assimon/luuu/model/mdb"
"github.com/assimon/luuu/model/request"
"github.com/assimon/luuu/model/response"
"github.com/assimon/luuu/mq"
"github.com/assimon/luuu/mq/handle"
"github.com/assimon/luuu/util/constant"
"github.com/assimon/luuu/util/log"
"github.com/assimon/luuu/util/math"
"github.com/dromara/carbon/v2"
"github.com/hibiken/asynq"
"github.com/shopspring/decimal"
)
const (
CnyMinimumPaymentAmount = 0.01 // cny最低支付金额
UsdtMinimumPaymentAmount = 0.01 // usdt最低支付金额
UsdtAmountPerIncrement = 0.01 // usdt每次递增金额
IncrementalMaximumNumber = 100 // 最大递增次数
CnyMinimumPaymentAmount = 0.01
UsdtMinimumPaymentAmount = 0.01
UsdtAmountPerIncrement = 0.01
IncrementalMaximumNumber = 100
)
var gCreateTransactionLock sync.Mutex
var gOrderProcessingLock sync.Mutex
// CreateTransaction 创建订单
// CreateTransaction creates a new payment order.
func CreateTransaction(req *request.CreateTransactionRequest) (*response.CreateTransactionResponse, error) {
gCreateTransactionLock.Lock()
defer gCreateTransactionLock.Unlock()
payAmount := math.MustParsePrecFloat64(req.Amount, 2)
// 按照汇率转化USDT
decimalPayAmount := decimal.NewFromFloat(payAmount)
decimalRate := decimal.NewFromFloat(config.GetUsdtRate())
decimalUsdt := decimalPayAmount.Div(decimalRate)
// cny 是否可以满足最低支付金额
if decimalPayAmount.Cmp(decimal.NewFromFloat(CnyMinimumPaymentAmount)) == -1 {
return nil, constant.PayAmountErr
}
// Usdt是否可以满足最低支付金额
if decimalUsdt.Cmp(decimal.NewFromFloat(UsdtMinimumPaymentAmount)) == -1 {
return nil, constant.PayAmountErr
}
// 已经存在了的交易
exist, err := data.GetOrderInfoByOrderId(req.OrderId)
if err != nil {
return nil, err
@@ -55,7 +53,7 @@ func CreateTransaction(req *request.CreateTransactionRequest) (*response.CreateT
if exist.ID > 0 {
return nil, constant.OrderAlreadyExists
}
// 有无可用钱包
walletAddress, err := data.GetAvailableWalletAddress()
if err != nil {
return nil, err
@@ -63,17 +61,20 @@ func CreateTransaction(req *request.CreateTransactionRequest) (*response.CreateT
if len(walletAddress) <= 0 {
return nil, constant.NotAvailableWalletAddress
}
tradeId := GenerateCode()
amount := math.MustParsePrecFloat64(decimalUsdt.InexactFloat64(), 2)
availableToken, availableAmount, err := CalculateAvailableWalletAndAmount(amount, walletAddress)
availableToken, availableAmount, err := ReserveAvailableWalletAndAmount(tradeId, amount, walletAddress)
if err != nil {
return nil, err
}
if availableToken == "" {
return nil, constant.NotAvailableAmountErr
}
tx := dao.Mdb.Begin()
order := &mdb.Orders{
TradeId: GenerateCode(),
TradeId: tradeId,
OrderId: req.OrderId,
Amount: req.Amount,
ActualAmount: availableAmount,
@@ -82,88 +83,90 @@ func CreateTransaction(req *request.CreateTransactionRequest) (*response.CreateT
NotifyUrl: req.NotifyUrl,
RedirectUrl: req.RedirectUrl,
}
err = data.CreateOrderWithTransaction(tx, order)
if err != nil {
if err = data.CreateOrderWithTransaction(tx, order); err != nil {
tx.Rollback()
_ = data.UnLockTransaction(availableToken, availableAmount)
return nil, err
}
// 锁定支付池
err = data.LockTransaction(availableToken, order.TradeId, availableAmount, config.GetOrderExpirationTimeDuration())
if err != nil {
if err = tx.Commit().Error; err != nil {
tx.Rollback()
_ = data.UnLockTransaction(availableToken, availableAmount)
return nil, err
}
tx.Commit()
// 超时过期消息队列
orderExpirationQueue, _ := handle.NewOrderExpirationQueue(order.TradeId)
mq.MClient.Enqueue(orderExpirationQueue, asynq.ProcessIn(config.GetOrderExpirationTimeDuration()),
asynq.Retention(config.GetOrderExpirationTimeDuration()),
)
ExpirationTime := carbon.Now().AddMinutes(config.GetOrderExpirationTime()).Timestamp()
expirationTime := carbon.Now().AddMinutes(config.GetOrderExpirationTime()).Timestamp()
resp := &response.CreateTransactionResponse{
TradeId: order.TradeId,
OrderId: order.OrderId,
Amount: order.Amount,
ActualAmount: order.ActualAmount,
Token: order.Token,
ExpirationTime: ExpirationTime,
ExpirationTime: expirationTime,
PaymentUrl: fmt.Sprintf("%s/pay/checkout-counter/%s", config.GetAppUri(), order.TradeId),
}
return resp, nil
}
// OrderProcessing 成功处理订单
// OrderProcessing marks an order as paid and releases its sqlite reservation.
func OrderProcessing(req *request.OrderProcessingRequest) error {
gOrderProcessingLock.Lock()
defer gOrderProcessingLock.Unlock()
tx := dao.Mdb.Begin()
exist, err := data.GetOrderByBlockIdWithTransaction(tx, req.BlockTransactionId)
if err != nil {
tx.Rollback()
return err
}
if exist.ID > 0 {
tx.Rollback()
return constant.OrderBlockAlreadyProcess
}
// 标记订单成功
err = data.OrderSuccessWithTransaction(tx, req)
updated, err := data.OrderSuccessWithTransaction(tx, req)
if err != nil {
tx.Rollback()
return err
}
// 解锁交易
err = data.UnLockTransaction(req.Token, req.Amount)
if err != nil {
if !updated {
tx.Rollback()
return constant.OrderStatusConflict
}
if err = tx.Commit().Error; err != nil {
tx.Rollback()
return err
}
tx.Commit()
if err = data.UnLockTransaction(req.Token, req.Amount); err != nil {
log.Sugar.Warnf("[order] unlock transaction after pay success failed, trade_id=%s, err=%v", req.TradeId, err)
}
return nil
}
// CalculateAvailableWalletAndAmount 计算可用钱包地址和金额
func CalculateAvailableWalletAndAmount(amount float64, walletAddress []mdb.WalletAddress) (string, float64, error) {
// ReserveAvailableWalletAndAmount finds and locks a token+amount pair.
func ReserveAvailableWalletAndAmount(tradeId string, amount float64, walletAddress []mdb.WalletAddress) (string, float64, error) {
availableToken := ""
availableAmount := amount
calculateAvailableWalletFunc := func(amount float64) (string, error) {
availableWallet := ""
tryLockWalletFunc := func(targetAmount float64) (string, error) {
for _, address := range walletAddress {
token := address.Token
result, err := data.GetTradeIdByWalletAddressAndAmount(token, amount)
if err != nil {
return "", err
err := data.LockTransaction(address.Token, tradeId, targetAmount, config.GetOrderExpirationTimeDuration())
if err == nil {
return address.Token, nil
}
if result == "" {
availableWallet = token
break
if errors.Is(err, data.ErrTransactionLocked) {
continue
}
return "", err
}
return availableWallet, nil
return "", nil
}
for i := 0; i < IncrementalMaximumNumber; i++ {
token, err := calculateAvailableWalletFunc(availableAmount)
token, err := tryLockWalletFunc(availableAmount)
if err != nil {
return "", 0, err
}
// 拿不到可用钱包就累加金额
if token == "" {
decimalOldAmount := decimal.NewFromFloat(availableAmount)
decimalIncr := decimal.NewFromFloat(UsdtAmountPerIncrement)
@@ -176,15 +179,14 @@ func CalculateAvailableWalletAndAmount(amount float64, walletAddress []mdb.Walle
return availableToken, availableAmount, nil
}
// GenerateCode 订单号生成
// GenerateCode creates a unique trade id.
func GenerateCode() string {
date := time.Now().Format("20060102")
r := rand.Intn(1000)
code := fmt.Sprintf("%s%d%03d", date, time.Now().UnixNano()/1e6, r)
return code
return fmt.Sprintf("%s%d%03d", date, time.Now().UnixNano()/1e6, r)
}
// GetOrderInfoByTradeId 通过交易号获取订单
// GetOrderInfoByTradeId returns a validated order.
func GetOrderInfoByTradeId(tradeId string) (*mdb.Orders, error) {
order, err := data.GetOrderInfoByTradeId(tradeId)
if err != nil {
+313
View File
@@ -0,0 +1,313 @@
package service
import (
"fmt"
"sync"
"testing"
"github.com/assimon/luuu/internal/testutil"
"github.com/assimon/luuu/model/dao"
"github.com/assimon/luuu/model/data"
"github.com/assimon/luuu/model/mdb"
"github.com/assimon/luuu/model/request"
"github.com/assimon/luuu/util/constant"
)
func TestCreateTransactionAssignsIncrementedAmountsAndLocks(t *testing.T) {
cleanup := testutil.SetupTestDatabases(t)
defer cleanup()
if _, err := data.AddWalletAddress("wallet_1"); err != nil {
t.Fatalf("add wallet: %v", err)
}
req1 := &request.CreateTransactionRequest{
OrderId: "order_1",
Amount: 1,
NotifyUrl: "https://merchant.example/callback",
}
req2 := &request.CreateTransactionRequest{
OrderId: "order_2",
Amount: 1,
NotifyUrl: "https://merchant.example/callback",
}
resp1, err := CreateTransaction(req1)
if err != nil {
t.Fatalf("create first transaction: %v", err)
}
resp2, err := CreateTransaction(req2)
if err != nil {
t.Fatalf("create second transaction: %v", err)
}
if got := fmt.Sprintf("%.2f", resp1.ActualAmount); got != "1.00" {
t.Fatalf("first actual amount = %s, want 1.00", got)
}
if got := fmt.Sprintf("%.2f", resp2.ActualAmount); got != "1.01" {
t.Fatalf("second actual amount = %s, want 1.01", got)
}
if resp1.Token != "wallet_1" || resp2.Token != "wallet_1" {
t.Fatalf("unexpected wallet tokens: %s, %s", resp1.Token, resp2.Token)
}
tradeID1, err := data.GetTradeIdByWalletAddressAndAmount(resp1.Token, resp1.ActualAmount)
if err != nil {
t.Fatalf("get first runtime lock: %v", err)
}
if tradeID1 != resp1.TradeId {
t.Fatalf("first runtime lock = %s, want %s", tradeID1, resp1.TradeId)
}
tradeID2, err := data.GetTradeIdByWalletAddressAndAmount(resp2.Token, resp2.ActualAmount)
if err != nil {
t.Fatalf("get second runtime lock: %v", err)
}
if tradeID2 != resp2.TradeId {
t.Fatalf("second runtime lock = %s, want %s", tradeID2, resp2.TradeId)
}
}
func TestOrderProcessingMarksPaidAndReleasesLock(t *testing.T) {
cleanup := testutil.SetupTestDatabases(t)
defer cleanup()
if _, err := data.AddWalletAddress("wallet_1"); err != nil {
t.Fatalf("add wallet: %v", err)
}
resp, err := CreateTransaction(&request.CreateTransactionRequest{
OrderId: "order_1",
Amount: 1,
NotifyUrl: "https://merchant.example/callback",
})
if err != nil {
t.Fatalf("create transaction: %v", err)
}
err = OrderProcessing(&request.OrderProcessingRequest{
Token: resp.Token,
TradeId: resp.TradeId,
Amount: resp.ActualAmount,
BlockTransactionId: "block_1",
})
if err != nil {
t.Fatalf("order processing: %v", err)
}
order, err := data.GetOrderInfoByTradeId(resp.TradeId)
if err != nil {
t.Fatalf("get order by trade id: %v", err)
}
if order.Status != mdb.StatusPaySuccess {
t.Fatalf("order status = %d, want %d", order.Status, mdb.StatusPaySuccess)
}
if order.CallBackConfirm != mdb.CallBackConfirmNo {
t.Fatalf("callback confirm = %d, want %d", order.CallBackConfirm, mdb.CallBackConfirmNo)
}
if order.BlockTransactionId != "block_1" {
t.Fatalf("block transaction id = %s, want block_1", order.BlockTransactionId)
}
tradeID, err := data.GetTradeIdByWalletAddressAndAmount(resp.Token, resp.ActualAmount)
if err != nil {
t.Fatalf("get runtime lock after processing: %v", err)
}
if tradeID != "" {
t.Fatalf("runtime lock still exists: %s", tradeID)
}
}
func TestOrderProcessingRejectsDuplicateBlockForSameOrder(t *testing.T) {
cleanup := testutil.SetupTestDatabases(t)
defer cleanup()
if _, err := data.AddWalletAddress("wallet_1"); err != nil {
t.Fatalf("add wallet: %v", err)
}
resp, err := CreateTransaction(&request.CreateTransactionRequest{
OrderId: "order_1",
Amount: 1,
NotifyUrl: "https://merchant.example/callback",
})
if err != nil {
t.Fatalf("create transaction: %v", err)
}
req := &request.OrderProcessingRequest{
Token: resp.Token,
TradeId: resp.TradeId,
Amount: resp.ActualAmount,
BlockTransactionId: "block_1",
}
if err = OrderProcessing(req); err != nil {
t.Fatalf("first order processing: %v", err)
}
err = OrderProcessing(req)
if err != constant.OrderBlockAlreadyProcess {
t.Fatalf("second order processing error = %v, want %v", err, constant.OrderBlockAlreadyProcess)
}
order, err := data.GetOrderInfoByTradeId(resp.TradeId)
if err != nil {
t.Fatalf("reload order after duplicate block: %v", err)
}
if order.Status != mdb.StatusPaySuccess {
t.Fatalf("order status after duplicate block = %d, want %d", order.Status, mdb.StatusPaySuccess)
}
if order.BlockTransactionId != "block_1" {
t.Fatalf("order block transaction id after duplicate block = %s, want block_1", order.BlockTransactionId)
}
}
func TestOrderProcessingDoesNotReviveExpiredOrder(t *testing.T) {
cleanup := testutil.SetupTestDatabases(t)
defer cleanup()
if _, err := data.AddWalletAddress("wallet_1"); err != nil {
t.Fatalf("add wallet: %v", err)
}
resp, err := CreateTransaction(&request.CreateTransactionRequest{
OrderId: "order_1",
Amount: 1,
NotifyUrl: "https://merchant.example/callback",
})
if err != nil {
t.Fatalf("create transaction: %v", err)
}
if err = dao.Mdb.Model(&mdb.Orders{}).
Where("trade_id = ?", resp.TradeId).
Update("status", mdb.StatusExpired).Error; err != nil {
t.Fatalf("force order expired: %v", err)
}
err = OrderProcessing(&request.OrderProcessingRequest{
Token: resp.Token,
TradeId: resp.TradeId,
Amount: resp.ActualAmount,
BlockTransactionId: "block_expired",
})
if err != constant.OrderStatusConflict {
t.Fatalf("order processing error = %v, want %v", err, constant.OrderStatusConflict)
}
order, err := data.GetOrderInfoByTradeId(resp.TradeId)
if err != nil {
t.Fatalf("reload expired order: %v", err)
}
if order.Status != mdb.StatusExpired {
t.Fatalf("expired order status = %d, want %d", order.Status, mdb.StatusExpired)
}
if order.BlockTransactionId != "" {
t.Fatalf("expired order block transaction id = %s, want empty", order.BlockTransactionId)
}
}
func TestOrderProcessingOnlyOneOrderClaimsABlockTransaction(t *testing.T) {
cleanup := testutil.SetupTestDatabases(t)
defer cleanup()
if _, err := data.AddWalletAddress("wallet_1"); err != nil {
t.Fatalf("add wallet: %v", err)
}
if _, err := data.AddWalletAddress("wallet_2"); err != nil {
t.Fatalf("add wallet: %v", err)
}
resp1, err := CreateTransaction(&request.CreateTransactionRequest{
OrderId: "order_1",
Amount: 1,
NotifyUrl: "https://merchant.example/callback",
})
if err != nil {
t.Fatalf("create first transaction: %v", err)
}
resp2, err := CreateTransaction(&request.CreateTransactionRequest{
OrderId: "order_2",
Amount: 2,
NotifyUrl: "https://merchant.example/callback",
})
if err != nil {
t.Fatalf("create second transaction: %v", err)
}
start := make(chan struct{})
errs := make(chan error, 2)
var wg sync.WaitGroup
for _, tc := range []struct {
token string
tradeID string
amount float64
}{
{token: resp1.Token, tradeID: resp1.TradeId, amount: resp1.ActualAmount},
{token: resp2.Token, tradeID: resp2.TradeId, amount: resp2.ActualAmount},
} {
wg.Add(1)
go func(token, tradeID string, amount float64) {
defer wg.Done()
<-start
errs <- OrderProcessing(&request.OrderProcessingRequest{
Token: token,
TradeId: tradeID,
Amount: amount,
BlockTransactionId: "shared_block",
})
}(tc.token, tc.tradeID, tc.amount)
}
close(start)
wg.Wait()
close(errs)
var successCount int
var duplicateCount int
for err := range errs {
switch err {
case nil:
successCount++
case constant.OrderBlockAlreadyProcess:
duplicateCount++
default:
t.Fatalf("unexpected order processing error: %v", err)
}
}
if successCount != 1 || duplicateCount != 1 {
t.Fatalf("success=%d duplicate=%d, want 1 and 1", successCount, duplicateCount)
}
orders := []struct {
tradeID string
}{
{tradeID: resp1.TradeId},
{tradeID: resp2.TradeId},
}
var paidCount int
var pendingCount int
for _, item := range orders {
order, err := data.GetOrderInfoByTradeId(item.tradeID)
if err != nil {
t.Fatalf("reload order %s: %v", item.tradeID, err)
}
switch order.Status {
case mdb.StatusPaySuccess:
paidCount++
if order.BlockTransactionId != "shared_block" {
t.Fatalf("paid order block transaction id = %s, want shared_block", order.BlockTransactionId)
}
case mdb.StatusWaitPay:
pendingCount++
if order.BlockTransactionId != "" {
t.Fatalf("pending order block transaction id = %s, want empty", order.BlockTransactionId)
}
default:
t.Fatalf("unexpected order status for %s: %d", item.tradeID, order.Status)
}
}
if paidCount != 1 || pendingCount != 1 {
t.Fatalf("paid=%d pending=%d, want 1 and 1", paidCount, pendingCount)
}
}
+25 -30
View File
@@ -1,24 +1,20 @@
package service
import (
"errors"
"fmt"
"net/http"
"sync"
"github.com/assimon/luuu/config"
"github.com/spf13/viper"
"github.com/assimon/luuu/model/data"
"github.com/assimon/luuu/model/request"
"github.com/assimon/luuu/mq"
"github.com/assimon/luuu/mq/handle"
"github.com/assimon/luuu/telegram"
"github.com/assimon/luuu/util/constant"
"github.com/assimon/luuu/util/http_client"
"github.com/assimon/luuu/util/json"
"github.com/assimon/luuu/util/log"
"github.com/dromara/carbon/v2"
"github.com/gookit/goutil/stdutil"
"github.com/hibiken/asynq"
"github.com/shopspring/decimal"
)
@@ -64,7 +60,7 @@ type Data struct {
Direction int `json:"direction"`
}
// Trc20CallBack trc20回调
// Trc20CallBack polls transfers for one wallet and matches them to active orders.
func Trc20CallBack(token string, wg *sync.WaitGroup) {
defer wg.Done()
defer func() {
@@ -72,6 +68,7 @@ func Trc20CallBack(token string, wg *sync.WaitGroup) {
log.Sugar.Error(err)
}
}()
client := http_client.GetHttpClient()
startTime := carbon.Now().AddHours(-24).TimestampMilli()
endTime := carbon.Now().TimestampMilli()
@@ -92,18 +89,20 @@ func Trc20CallBack(token string, wg *sync.WaitGroup) {
if resp.StatusCode() != http.StatusOK {
panic(err)
}
var trc20Resp UsdtTrc20Resp
err = json.Cjson.Unmarshal(resp.Body(), &trc20Resp)
if err != nil {
if err = json.Cjson.Unmarshal(resp.Body(), &trc20Resp); err != nil {
panic(err)
}
if trc20Resp.PageSize <= 0 {
return
}
for _, transfer := range trc20Resp.Data {
if transfer.To != token || transfer.ContractRet != "SUCCESS" {
continue
}
decimalQuant, err := decimal.NewFromString(transfer.Amount)
if err != nil {
panic(err)
@@ -117,43 +116,39 @@ func Trc20CallBack(token string, wg *sync.WaitGroup) {
if tradeId == "" {
continue
}
order, err := data.GetOrderInfoByTradeId(tradeId)
if err != nil {
panic(err)
}
// 区块的确认时间必须在订单创建时间之后
createTime := order.CreatedAt.TimestampMilli()
if transfer.BlockTimestamp < createTime {
panic("Orders cannot actually be matched")
panic("orders cannot actually be matched")
}
// 到这一步就完全算是支付成功了
req := &request.OrderProcessingRequest{
Token: token,
TradeId: tradeId,
Amount: amount,
BlockTransactionId: transfer.Hash,
}
err = OrderProcessing(req)
if err != nil {
if err = OrderProcessing(req); err != nil {
if errors.Is(err, constant.OrderBlockAlreadyProcess) || errors.Is(err, constant.OrderStatusConflict) {
log.Sugar.Infof("[task] skip already resolved transfer, trade_id=%s, block_transaction_id=%s, err=%v", tradeId, transfer.Hash, err)
continue
}
panic(err)
}
// 回调队列
orderCallbackQueue, _ := handle.NewOrderCallbackQueue(order)
orderNoticeMaxRetry := viper.GetInt("order_notice_max_retry")
mq.MClient.Enqueue(orderCallbackQueue, asynq.MaxRetry(orderNoticeMaxRetry),
asynq.Retention(config.GetOrderExpirationTimeDuration()),
)
// mq.MClient.Enqueue(orderCallbackQueue, asynq.MaxRetry(5))
// 发送机器人消息
msgTpl := `
<b>📢📢有新的交易支付成功!</b>
<pre>交易号:%s</pre>
<pre>订单号:%s</pre>
<pre>请求支付金额:%f cny</pre>
<pre>实际支付金额:%f usdt</pre>
<pre>钱包地址:%s</pre>
<pre>订单创建时间:%s</pre>
<pre>支付成功时间:%s</pre>
<b>馃摙馃摙鏈夋柊鐨勪氦鏄撴敮浠樻垚鍔燂紒</b>
<pre>浜ゆ槗鍙凤細%s</pre>
<pre>璁㈠崟鍙凤細%s</pre>
<pre>璇锋眰鏀粯閲戦锛?f cny</pre>
<pre>瀹為檯鏀粯閲戦锛?f usdt</pre>
<pre>閽卞寘鍦板潃锛?s</pre>
<pre>璁㈠崟鍒涘缓鏃堕棿锛?s</pre>
<pre>鏀粯鎴愬姛鏃堕棿锛?s</pre>
`
msg := fmt.Sprintf(msgTpl, order.TradeId, order.OrderId, order.Amount, order.ActualAmount, order.Token, order.CreatedAt.ToDateTimeString(), carbon.Now().ToDateTimeString())
telegram.SendToBot(msg)