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
+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)