feat: refactor payment system with multi-currency support and TRX native transfer detection

- Add multi-currency exchange rate API integration (GetRateForCoin)
 - Implement native TRX transfer detection alongside TRC20 USDT transfers
 - Replace deprecated Tronscan API with Trongrid API for better reliability
 - Add TRON Grid API key configuration support
 - Refactor order service to support multiple tokens and currencies
 - Update wallet address locking/unlocking to track token type
 - Add base58 and gjson dependencies for crypto operations
 - Optimize SQLite connection with WAL mode and busy timeout
 - Remove deprecated USDT rate job (replaced by dynamic rate API)
 - Update order data model to include currency and receive address fields
 - Enhance TRC20 callback with improved error handling and logging
This commit is contained in:
line-6000
2026-03-30 16:19:10 +08:00
parent d6e7927605
commit 938396d82b
24 changed files with 505 additions and 222 deletions
+5 -1
View File
@@ -8,5 +8,9 @@ import (
)
func openDB(dsn string, cfg *gorm.Config) (*gorm.DB, error) {
return gorm.Open(sqlite.Open(dsn), cfg)
db, err := gorm.Open(sqlite.Open(dsn+"?_journal_mode=WAL&_busy_timeout=5000"), cfg)
if err != nil {
return nil, err
}
return db, nil
}
+5 -1
View File
@@ -8,5 +8,9 @@ import (
)
func openDB(dsn string, cfg *gorm.Config) (*gorm.DB, error) {
return gorm.Open(sqlite.Open(dsn), cfg)
db, err := gorm.Open(sqlite.Open(dsn+"?_journal_mode=WAL&_busy_timeout=5000"), cfg)
if err != nil {
return nil, err
}
return db, nil
}
+13 -9
View File
@@ -3,16 +3,19 @@ package data
import (
"context"
"fmt"
"strings"
"time"
"github.com/assimon/luuu/model/dao"
"github.com/assimon/luuu/model/mdb"
"github.com/assimon/luuu/model/request"
"github.com/assimon/luuu/util/log"
"github.com/go-redis/redis/v8"
"gorm.io/gorm"
"time"
)
var (
CacheWalletAddressWithAmountToTradeIdKey = "wallet:%s_%v" // 钱包_待支付金额 : 交易号
CacheWalletAddressWithAmountToTradeIdKey = "wallet:%s_%s_%v" // 钱包_币种_待支付金额 : 交易号
)
// GetOrderInfoByOrderId 通过客户订单号查询订单
@@ -78,10 +81,10 @@ func UpdateOrderIsExpirationById(id uint64) error {
return err
}
// GetTradeIdByWalletAddressAndAmount 通过钱包地址支付金额获取交易号
func GetTradeIdByWalletAddressAndAmount(token string, amount float64) (string, error) {
// GetTradeIdByWalletAddressAndAmountAndToken 通过钱包地址、币种、支付金额获取交易号
func GetTradeIdByWalletAddressAndAmountAndToken(address string, token string, amount float64) (string, error) {
ctx := context.Background()
cacheKey := fmt.Sprintf(CacheWalletAddressWithAmountToTradeIdKey, token, amount)
cacheKey := fmt.Sprintf(CacheWalletAddressWithAmountToTradeIdKey, address, strings.ToUpper(token), amount)
result, err := dao.Rdb.Get(ctx, cacheKey).Result()
if err == redis.Nil {
return "", nil
@@ -93,17 +96,18 @@ func GetTradeIdByWalletAddressAndAmount(token string, amount float64) (string, e
}
// LockTransaction 锁定交易
func LockTransaction(token, tradeId string, amount float64, expirationTime time.Duration) error {
func LockTransaction(address, token, tradeId string, amount float64, expirationTime time.Duration) error {
ctx := context.Background()
cacheKey := fmt.Sprintf(CacheWalletAddressWithAmountToTradeIdKey, token, amount)
cacheKey := fmt.Sprintf(CacheWalletAddressWithAmountToTradeIdKey, address, strings.ToUpper(token), amount)
log.Sugar.Infof("LockTransaction: cacheKey=%s, tradeId=%s, expirationTime=%v", cacheKey, tradeId, expirationTime)
err := dao.Rdb.Set(ctx, cacheKey, tradeId, expirationTime).Err()
return err
}
// UnLockTransaction 解锁交易
func UnLockTransaction(token string, amount float64) error {
func UnLockTransaction(address string, token string, amount float64) error {
ctx := context.Background()
cacheKey := fmt.Sprintf(CacheWalletAddressWithAmountToTradeIdKey, token, amount)
cacheKey := fmt.Sprintf(CacheWalletAddressWithAmountToTradeIdKey, address, strings.ToUpper(token), amount)
err := dao.Rdb.Del(ctx, cacheKey).Err()
return err
}
+7 -7
View File
@@ -7,8 +7,8 @@ import (
)
// AddWalletAddress 创建钱包
func AddWalletAddress(token string) (*mdb.WalletAddress, error) {
exist, err := GetWalletAddressByToken(token)
func AddWalletAddress(address string) (*mdb.WalletAddress, error) {
exist, err := GetWalletAddressByToken(address)
if err != nil {
return nil, err
}
@@ -16,17 +16,17 @@ func AddWalletAddress(token string) (*mdb.WalletAddress, error) {
return nil, constant.WalletAddressAlreadyExists
}
walletAddress := &mdb.WalletAddress{
Token: token,
Status: mdb.TokenStatusEnable,
Address: address,
Status: mdb.TokenStatusEnable,
}
err = dao.Mdb.Create(walletAddress).Error
return walletAddress, err
}
// GetWalletAddressByToken 通过钱包地址获取token
func GetWalletAddressByToken(token string) (*mdb.WalletAddress, error) {
// GetWalletAddressByToken 通过钱包地址获取address
func GetWalletAddressByToken(address string) (*mdb.WalletAddress, error) {
walletAddress := new(mdb.WalletAddress)
err := dao.Mdb.Model(walletAddress).Limit(1).Find(walletAddress, "token = ?", token).Error
err := dao.Mdb.Model(walletAddress).Limit(1).Find(walletAddress, "address = ?", address).Error
return walletAddress, err
}
+3 -1
View File
@@ -13,8 +13,10 @@ type Orders struct {
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位小数
Currency string `gorm:"column:currency" json:"currency"` // 订单货币类型 CNY USD......
ActualAmount float64 `gorm:"column:actual_amount" json:"actual_amount"` // 订单实际需要支付的金额,保留4位小数
Token string `gorm:"column:token" json:"token"` // 所属钱包地址
ReceiveAddress string `gorm:"column:receive_address" json:"receive_address"` // 所属钱包地址
Token string `gorm:"column:token" json:"token"` // 所属币种 TRX USDT......
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"` // 同步回调地址
+2 -2
View File
@@ -7,8 +7,8 @@ const (
// 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:禁用
Address string `gorm:"index:wallet_address_index;column:address" json:"address"` // 钱包地址
Status int64 `gorm:"column:status;default:1" json:"status"` // 1:启用 2:禁用
BaseModel
}
+6
View File
@@ -5,6 +5,8 @@ import "github.com/gookit/validate"
// CreateTransactionRequest 创建交易请求
type CreateTransactionRequest struct {
OrderId string `json:"order_id" validate:"required|maxLen:32"`
Currency string `json:"currency" validate:"required"`
Token string `json:"token" validate:"required"`
Amount float64 `json:"amount" validate:"required|isFloat|gt:0.01"`
NotifyUrl string `json:"notify_url" validate:"required"`
Signature string `json:"signature" validate:"required"`
@@ -14,6 +16,8 @@ type CreateTransactionRequest struct {
func (r CreateTransactionRequest) Translates() map[string]string {
return validate.MS{
"OrderId": "订单号",
"Currency": "货币",
"Token": "币种",
"Amount": "支付金额",
"NotifyUrl": "异步回调网址",
"Signature": "签名",
@@ -22,6 +26,8 @@ func (r CreateTransactionRequest) Translates() map[string]string {
// OrderProcessingRequest 订单处理
type OrderProcessingRequest struct {
ReceiveAddress string
Currency string
Token string
Amount float64
TradeId string
+5 -2
View File
@@ -5,8 +5,10 @@ type CreateTransactionResponse struct {
TradeId string `json:"trade_id"` // epusdt订单号
OrderId string `json:"order_id"` // 客户交易id
Amount float64 `json:"amount"` // 订单金额,保留4位小数
Currency string `json:"currency"` // 订单货币类型 CNY USD......
ActualAmount float64 `json:"actual_amount"` // 订单实际需要支付的金额,保留4位小数
Token string `json:"token"` // 收款钱包地址
ReceiveAddress string `json:"receive_address"` // 收款钱包地址
Token string `json:"token"` // 所属币种 TRX USDT......
ExpirationTime int64 `json:"expiration_time"` // 过期时间 时间戳
PaymentUrl string `json:"payment_url"` // 收银台地址
}
@@ -17,7 +19,8 @@ type OrderNotifyResponse struct {
OrderId string `json:"order_id"` // 客户交易id
Amount float64 `json:"amount"` // 订单金额,保留4位小数
ActualAmount float64 `json:"actual_amount"` // 订单实际需要支付的金额,保留4位小数
Token string `json:"token"` // 收款钱包地址
ReceiveAddress string `json:"receive_address"` // 收款钱包地址
Token string `json:"token"` // 所属币种 TRX USDT......
BlockTransactionId string `json:"block_transaction_id"` // 区块id
Signature string `json:"signature"` // 签名
Status int `json:"status"` // 1:等待支付,2:支付成功,3:已过期
+2 -1
View File
@@ -3,7 +3,8 @@ package response
type CheckoutCounterResponse struct {
TradeId string `json:"trade_id"` // epusdt订单号
ActualAmount float64 `json:"actual_amount"` // 订单实际需要支付的金额,保留4位小数
Token string `json:"token"` // 收款钱包地址
ReceiveAddress string `json:"receive_address"` // 收款钱包地址
Token string `json:"token"` // 所属币种 TRX USDT......
ExpirationTime int64 `json:"expiration_time"` // 过期时间 时间戳
RedirectUrl string `json:"redirect_url"`
}
+34 -25
View File
@@ -3,6 +3,7 @@ package service
import (
"fmt"
"math/rand"
"strings"
"sync"
"time"
@@ -35,10 +36,14 @@ func CreateTransaction(req *request.CreateTransactionRequest) (*response.CreateT
gCreateTransactionLock.Lock()
defer gCreateTransactionLock.Unlock()
payAmount := math.MustParsePrecFloat64(req.Amount, 2)
// 按照汇率转化USDT
// 按照汇率转化接收货币
// coin
coin := strings.ToLower(req.Token)
currency := strings.ToLower(req.Currency)
coinRateBaseCurrency := config.GetRateForCoin(coin, currency)
decimalPayAmount := decimal.NewFromFloat(payAmount)
decimalRate := decimal.NewFromFloat(config.GetUsdtRate())
decimalUsdt := decimalPayAmount.Div(decimalRate)
decimalRate := decimal.NewFromFloat(coinRateBaseCurrency)
decimalUsdt := decimalPayAmount.Mul(decimalRate)
// cny 是否可以满足最低支付金额
if decimalPayAmount.Cmp(decimal.NewFromFloat(CnyMinimumPaymentAmount)) == -1 {
return nil, constant.PayAmountErr
@@ -64,23 +69,25 @@ func CreateTransaction(req *request.CreateTransactionRequest) (*response.CreateT
return nil, constant.NotAvailableWalletAddress
}
amount := math.MustParsePrecFloat64(decimalUsdt.InexactFloat64(), 2)
availableToken, availableAmount, err := CalculateAvailableWalletAndAmount(amount, walletAddress)
availableAddress, availableAmount, err := CalculateAvailableWalletAndAmount(amount, req.Token, walletAddress)
if err != nil {
return nil, err
}
if availableToken == "" {
if availableAddress == "" {
return nil, constant.NotAvailableAmountErr
}
tx := dao.Mdb.Begin()
order := &mdb.Orders{
TradeId: GenerateCode(),
OrderId: req.OrderId,
Amount: req.Amount,
ActualAmount: availableAmount,
Token: availableToken,
Status: mdb.StatusWaitPay,
NotifyUrl: req.NotifyUrl,
RedirectUrl: req.RedirectUrl,
TradeId: GenerateCode(),
OrderId: req.OrderId,
Amount: req.Amount,
Currency: req.Currency,
ActualAmount: availableAmount,
ReceiveAddress: availableAddress,
Token: req.Token,
Status: mdb.StatusWaitPay,
NotifyUrl: req.NotifyUrl,
RedirectUrl: req.RedirectUrl,
}
err = data.CreateOrderWithTransaction(tx, order)
if err != nil {
@@ -88,7 +95,7 @@ func CreateTransaction(req *request.CreateTransactionRequest) (*response.CreateT
return nil, err
}
// 锁定支付池
err = data.LockTransaction(availableToken, order.TradeId, availableAmount, config.GetOrderExpirationTimeDuration())
err = data.LockTransaction(availableAddress, req.Token, order.TradeId, availableAmount, config.GetOrderExpirationTimeDuration())
if err != nil {
tx.Rollback()
return nil, err
@@ -104,7 +111,9 @@ func CreateTransaction(req *request.CreateTransactionRequest) (*response.CreateT
TradeId: order.TradeId,
OrderId: order.OrderId,
Amount: order.Amount,
Currency: order.Currency,
ActualAmount: order.ActualAmount,
ReceiveAddress: order.ReceiveAddress,
Token: order.Token,
ExpirationTime: ExpirationTime,
PaymentUrl: fmt.Sprintf("%s/pay/checkout-counter/%s", config.GetAppUri(), order.TradeId),
@@ -130,7 +139,7 @@ func OrderProcessing(req *request.OrderProcessingRequest) error {
return err
}
// 解锁交易
err = data.UnLockTransaction(req.Token, req.Amount)
err = data.UnLockTransaction(req.ReceiveAddress, req.Token, req.Amount)
if err != nil {
tx.Rollback()
return err
@@ -140,40 +149,40 @@ func OrderProcessing(req *request.OrderProcessingRequest) error {
}
// CalculateAvailableWalletAndAmount 计算可用钱包地址和金额
func CalculateAvailableWalletAndAmount(amount float64, walletAddress []mdb.WalletAddress) (string, float64, error) {
availableToken := ""
func CalculateAvailableWalletAndAmount(amount float64, token string, walletAddress []mdb.WalletAddress) (string, float64, error) {
availableAddress := ""
availableAmount := amount
calculateAvailableWalletFunc := func(amount float64) (string, error) {
availableWallet := ""
for _, address := range walletAddress {
token := address.Token
result, err := data.GetTradeIdByWalletAddressAndAmount(token, amount)
for _, addr := range walletAddress {
walletAddr := addr.Address
result, err := data.GetTradeIdByWalletAddressAndAmountAndToken(walletAddr, token, amount)
if err != nil {
return "", err
}
if result == "" {
availableWallet = token
availableWallet = walletAddr
break
}
}
return availableWallet, nil
}
for i := 0; i < IncrementalMaximumNumber; i++ {
token, err := calculateAvailableWalletFunc(availableAmount)
wallet, err := calculateAvailableWalletFunc(availableAmount)
if err != nil {
return "", 0, err
}
// 拿不到可用钱包就累加金额
if token == "" {
if wallet == "" {
decimalOldAmount := decimal.NewFromFloat(availableAmount)
decimalIncr := decimal.NewFromFloat(UsdtAmountPerIncrement)
availableAmount = decimalOldAmount.Add(decimalIncr).InexactFloat64()
continue
}
availableToken = token
availableAddress = wallet
break
}
return availableToken, availableAmount, nil
return availableAddress, availableAmount, nil
}
// GenerateCode 订单号生成
+1
View File
@@ -21,6 +21,7 @@ func GetCheckoutCounterByTradeId(tradeId string) (*response.CheckoutCounterRespo
resp := &response.CheckoutCounterResponse{
TradeId: orderInfo.TradeId,
ActualAmount: orderInfo.ActualAmount,
ReceiveAddress: orderInfo.ReceiveAddress,
Token: orderInfo.Token,
ExpirationTime: orderInfo.CreatedAt.AddMinutes(config.GetOrderExpirationTime()).TimestampMilli(),
RedirectUrl: orderInfo.RedirectUrl,
+274 -88
View File
@@ -1,161 +1,347 @@
package service
import (
"encoding/hex"
"fmt"
"net/http"
"strings"
"sync"
"github.com/assimon/luuu/config"
"github.com/spf13/viper"
tron "github.com/assimon/luuu/crypto"
"github.com/hibiken/asynq"
"github.com/shopspring/decimal"
"github.com/spf13/viper"
"github.com/tidwall/gjson"
"github.com/assimon/luuu/config"
"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/http_client"
"github.com/assimon/luuu/util/json"
"github.com/assimon/luuu/util/log"
"github.com/assimon/luuu/util/math"
"github.com/dromara/carbon/v2"
"github.com/gookit/goutil/stdutil"
"github.com/hibiken/asynq"
"github.com/shopspring/decimal"
)
const UsdtTrc20ApiUri = "https://apilist.tronscanapi.com/api/transfer/trc20"
type UsdtTrc20Resp struct {
PageSize int `json:"page_size"`
Code int `json:"code"`
Data []Data `json:"data"`
}
type TokenInfo struct {
TokenID string `json:"tokenId"`
TokenAbbr string `json:"tokenAbbr"`
TokenName string `json:"tokenName"`
TokenDecimal int `json:"tokenDecimal"`
TokenCanShow int `json:"tokenCanShow"`
TokenType string `json:"tokenType"`
TokenLogo string `json:"tokenLogo"`
TokenLevel string `json:"tokenLevel"`
IssuerAddr string `json:"issuerAddr"`
Vip bool `json:"vip"`
}
type Data struct {
Amount string `json:"amount"`
ApprovalAmount string `json:"approval_amount"`
BlockTimestamp int64 `json:"block_timestamp"`
Block int `json:"block"`
From string `json:"from"`
To string `json:"to"`
Hash string `json:"hash"`
Confirmed int `json:"confirmed"`
ContractType string `json:"contract_type"`
ContracTType int `json:"contractType"`
Revert int `json:"revert"`
ContractRet string `json:"contract_ret"`
EventType string `json:"event_type"`
IssueAddress string `json:"issue_address"`
Decimals int `json:"decimals"`
TokenName string `json:"token_name"`
ID string `json:"id"`
Direction int `json:"direction"`
}
const TRC20_USDT_ID = "TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6t"
// Trc20CallBack trc20回调
func Trc20CallBack(token string, wg *sync.WaitGroup) {
func Trc20CallBack(address string, wg *sync.WaitGroup) {
defer wg.Done()
defer func() {
if err := recover(); err != nil {
log.Sugar.Error(err)
}
}()
var innerWg sync.WaitGroup
innerWg.Add(2)
go checkTrxTransfers(address, &innerWg)
go checkTrc20Transfers(address, &innerWg)
innerWg.Wait()
}
// checkTrxTransfers 查询 TRX 原生转账
func checkTrxTransfers(address string, wg *sync.WaitGroup) {
defer wg.Done()
defer func() {
if err := recover(); err != nil {
log.Sugar.Errorf("[TRX][%s] panic recovered: %v", address, err)
}
}()
client := http_client.GetHttpClient()
startTime := carbon.Now().AddHours(-24).TimestampMilli()
endTime := carbon.Now().TimestampMilli()
url := fmt.Sprintf("https://api.trongrid.io/v1/accounts/%s/transactions", address)
log.Sugar.Debugf("checkTrxTransfers URL: %s, from %d to %d", url, startTime, endTime)
resp, err := client.R().SetQueryParams(map[string]string{
"sort": "-timestamp",
"limit": "50",
"start": "0",
"direction": "2",
"db_version": "1",
"trc20Id": "TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6t",
"address": token,
"start_timestamp": stdutil.ToString(startTime),
"end_timestamp": stdutil.ToString(endTime),
}).Get(UsdtTrc20ApiUri)
"order_by": "block_timestamp,desc",
"limit": "100",
// "only_confirmed": "true",
"only_to": "true",
"min_timestamp": stdutil.ToString(startTime),
"max_timestamp": stdutil.ToString(endTime),
}).SetHeader("TRON-PRO-API-KEY", config.TRON_GRID_API_KEY).Get(url)
if err != nil {
panic(err)
}
if resp.StatusCode() != http.StatusOK {
panic(err)
panic(fmt.Sprintf("TRX API returned status %d", resp.StatusCode()))
}
var trc20Resp UsdtTrc20Resp
err = json.Cjson.Unmarshal(resp.Body(), &trc20Resp)
if err != nil {
panic(err)
log.Sugar.Debugf("Raw request URL: %s", resp.Request.URL)
success := gjson.GetBytes(resp.Body(), "success").Bool()
if !success {
panic("TRX API response indicates failure")
}
if trc20Resp.PageSize <= 0 {
dataArray := gjson.GetBytes(resp.Body(), "data").Array()
log.Sugar.Infof("[TRX][%s] API返回 %d 条交易记录", address, len(dataArray))
if len(dataArray) == 0 {
log.Sugar.Infof("[TRX][%s] 没有找到任何交易记录,跳过", address)
return
}
for _, transfer := range trc20Resp.Data {
if transfer.To != token || transfer.ContractRet != "SUCCESS" {
for i, transfer := range dataArray {
transferType := transfer.Get("raw_data.contract.0.type").String()
if transferType != "TransferContract" {
log.Sugar.Debugf("[TRX][%s] 第%d条: 类型=%s, 非TransferContract, 跳过", address, i, transferType)
continue
}
decimalQuant, err := decimal.NewFromString(transfer.Amount)
if err != nil {
panic(err)
contractRet := transfer.Get("ret.0.contractRet").String()
if contractRet != "SUCCESS" {
log.Sugar.Infof("[TRX][%s] 第%d条: contractRet=%s, 非SUCCESS, 跳过", address, i, contractRet)
continue
}
decimalDivisor := decimal.NewFromFloat(1000000)
amount := decimalQuant.Div(decimalDivisor).InexactFloat64()
tradeId, err := data.GetTradeIdByWalletAddressAndAmount(token, amount)
toAddressHex := transfer.Get("raw_data.contract.0.parameter.value.to_address").String()
toBytes, err := hex.DecodeString(toAddressHex)
if err != nil {
log.Sugar.Errorf("[TRX][%s] 第%d条: 解码地址失败: %v", address, i, err)
continue
}
toAddress := tron.EncodeCheck(toBytes)
if toAddress != address {
log.Sugar.Debugf("[TRX][%s] 第%d条: 目标地址=%s, 不匹配, 跳过", address, i, toAddress)
continue
}
rawAmount := transfer.Get("raw_data.contract.0.parameter.value.amount").String()
decimalQuant, err := decimal.NewFromString(rawAmount)
if err != nil {
log.Sugar.Errorf("[TRX][%s] 第%d条: 解析金额失败: %v", address, i, err)
continue
}
divisor := decimal.NewFromInt(1000000)
amount := math.MustParsePrecFloat64(decimalQuant.Div(divisor).InexactFloat64(), 2)
txID := transfer.Get("txID").String()
log.Sugar.Infof("[TRX][%s] 第%d条: txID=%s, rawAmount=%s, 解析金额=%.2f", address, i, txID, rawAmount, amount)
if amount <= 0 {
log.Sugar.Infof("[TRX][%s] 第%d条: 金额<=0, 跳过", address, i)
continue
}
cacheKey := fmt.Sprintf("wallet:%s_%s_%v", address, "TRX", amount)
log.Sugar.Infof("[TRX][%s] 第%d条: 查询Redis匹配, cacheKey=%s", address, i, cacheKey)
tradeId, err := data.GetTradeIdByWalletAddressAndAmountAndToken(address, "TRX", amount)
if err != nil {
panic(err)
}
if tradeId == "" {
log.Sugar.Infof("[TRX][%s] 第%d条: Redis未匹配到订单, 金额=%.2f, 跳过", address, i, amount)
continue
}
log.Sugar.Infof("[TRX][%s] 第%d条: Redis匹配到订单! tradeId=%s, 金额=%.2f", address, i, tradeId, amount)
order, err := data.GetOrderInfoByTradeId(tradeId)
if err != nil {
panic(err)
}
// 区块的确认时间必须在订单创建时间之后
log.Sugar.Infof("[TRX][%s] 查到订单: tradeId=%s, orderId=%s, status=%d, amount=%.2f, actualAmount=%.2f", address, order.TradeId, order.OrderId, order.Status, order.Amount, order.ActualAmount)
createTime := order.CreatedAt.TimestampMilli()
if transfer.BlockTimestamp < createTime {
blockTimestamp := transfer.Get("block_timestamp").Int()
log.Sugar.Infof("[TRX][%s] 时间校验: blockTimestamp=%d, orderCreateTime=%d", address, blockTimestamp, createTime)
if blockTimestamp < createTime {
log.Sugar.Errorf("[TRX][%s] 区块时间早于订单创建时间,无法匹配! blockTimestamp=%d < createTime=%d", address, blockTimestamp, createTime)
panic("Orders cannot actually be matched")
}
// 到这一步就完全算是支付成功了
transferHash := transfer.Get("txID").String()
log.Sugar.Infof("[TRX][%s] 开始处理订单: tradeId=%s, hash=%s, amount=%.2f", address, tradeId, transferHash, amount)
req := &request.OrderProcessingRequest{
Token: token,
ReceiveAddress: address,
Token: "TRX",
TradeId: tradeId,
Amount: amount,
BlockTransactionId: transfer.Hash,
BlockTransactionId: transferHash,
}
err = OrderProcessing(req)
if err != nil {
log.Sugar.Errorf("[TRX][%s] OrderProcessing失败: tradeId=%s, err=%v", address, tradeId, err)
panic(err)
}
// 回调队列
log.Sugar.Infof("[TRX][%s] OrderProcessing成功: tradeId=%s", address, tradeId)
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))
// 发送机器人消息
log.Sugar.Infof("[TRX][%s] 回调队列已入队: tradeId=%s", address, tradeId)
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>
💰 <b>金额信息</b>
├ 订单金额:<code>%.2f %s</code>
└ 实际到账:<code>%.2f %s</code>
📋 <b>订单信息</b>
├ 交易号:<code>%s</code>
├ 订单号:<code>%s</code>
└ 钱包地址:<code>%s</code>
⏰ <b>时间信息</b>
├ 创建时间:%s
└ 支付时间:%s
`
msg := fmt.Sprintf(msgTpl, order.TradeId, order.OrderId, order.Amount, order.ActualAmount, order.Token, order.CreatedAt.ToDateTimeString(), carbon.Now().ToDateTimeString())
msg := fmt.Sprintf(msgTpl, order.Amount, strings.ToUpper(order.Currency), order.ActualAmount, strings.ToUpper(order.Token), order.TradeId, order.OrderId, order.ReceiveAddress, order.CreatedAt.ToDateTimeString(), carbon.Now().ToDateTimeString())
log.Sugar.Infof("[TRX][%s] 准备发送Telegram通知: tradeId=%s, orderId=%s", address, tradeId, order.OrderId)
telegram.SendToBot(msg)
}
}
// checkTrc20Transfers 查询 TRC20 (USDT) 转账
func checkTrc20Transfers(address string, wg *sync.WaitGroup) {
defer wg.Done()
defer func() {
if err := recover(); err != nil {
log.Sugar.Errorf("[TRC20][%s] panic recovered: %v", address, err)
}
}()
client := http_client.GetHttpClient()
startTime := carbon.Now().AddHours(-24).TimestampMilli()
endTime := carbon.Now().TimestampMilli()
url := fmt.Sprintf("https://api.trongrid.io/v1/accounts/%s/transactions/trc20", address)
log.Sugar.Debugf("checkTrc20Transfers URL: %s, from %d to %d", url, startTime, endTime)
resp, err := client.R().SetQueryParams(map[string]string{
"order_by": "block_timestamp,desc",
"limit": "100",
// "only_confirmed": "true",
"only_to": "true",
"min_timestamp": stdutil.ToString(startTime),
"max_timestamp": stdutil.ToString(endTime),
}).SetHeader("TRON-PRO-API-KEY", config.TRON_GRID_API_KEY).Get(url)
if err != nil {
panic(err)
}
if resp.StatusCode() != http.StatusOK {
panic(fmt.Sprintf("TRC20 API returned status %d", resp.StatusCode()))
}
log.Sugar.Debugf("Raw request URL: %s", resp.Request.URL)
success := gjson.GetBytes(resp.Body(), "success").Bool()
if !success {
panic("TRC20 API response indicates failure")
}
dataArray := gjson.GetBytes(resp.Body(), "data").Array()
log.Sugar.Infof("[TRC20][%s] API返回 %d 条交易记录", address, len(dataArray))
if len(dataArray) == 0 {
log.Sugar.Infof("[TRC20][%s] 没有找到任何交易记录,跳过", address)
return
}
for i, transfer := range dataArray {
// 只处理 USDT
tokenAddress := transfer.Get("token_info.address").String()
if tokenAddress != TRC20_USDT_ID {
log.Sugar.Debugf("[TRC20][%s] 第%d条: tokenAddress=%s, 非USDT, 跳过", address, i, tokenAddress)
continue
}
to := transfer.Get("to").String()
if to != address {
log.Sugar.Debugf("[TRC20][%s] 第%d条: to=%s, 不匹配, 跳过", address, i, to)
continue
}
// 解析金额: value / 10^decimals
valueStr := transfer.Get("value").String()
decimalQuant, err := decimal.NewFromString(valueStr)
if err != nil {
log.Sugar.Errorf("[TRC20][%s] 第%d条: 解析value失败: %v", address, i, err)
continue
}
tokenDecimals := transfer.Get("token_info.decimals").Int()
divisor := decimal.New(1, int32(tokenDecimals)) // 10^decimals
amount := math.MustParsePrecFloat64(decimalQuant.Div(divisor).InexactFloat64(), 2)
txID := transfer.Get("transaction_id").String()
log.Sugar.Infof("[TRC20][%s] 第%d条: txID=%s, value=%s, decimals=%d, 解析金额=%.2f", address, i, txID, valueStr, tokenDecimals, amount)
if amount <= 0 {
log.Sugar.Infof("[TRC20][%s] 第%d条: 金额<=0, 跳过", address, i)
continue
}
cacheKey := fmt.Sprintf("wallet:%s_%s_%v", address, "USDT", amount)
log.Sugar.Infof("[TRC20][%s] 第%d条: 查询Redis匹配, cacheKey=%s", address, i, cacheKey)
tradeId, err := data.GetTradeIdByWalletAddressAndAmountAndToken(address, "USDT", amount)
if err != nil {
panic(err)
}
if tradeId == "" {
log.Sugar.Infof("[TRC20][%s] 第%d条: Redis未匹配到订单, 金额=%.2f, 跳过", address, i, amount)
continue
}
log.Sugar.Infof("[TRC20][%s] 第%d条: Redis匹配到订单! tradeId=%s, 金额=%.2f", address, i, tradeId, amount)
order, err := data.GetOrderInfoByTradeId(tradeId)
if err != nil {
panic(err)
}
log.Sugar.Infof("[TRC20][%s] 查到订单: tradeId=%s, orderId=%s, status=%d, amount=%.2f, actualAmount=%.2f", address, order.TradeId, order.OrderId, order.Status, order.Amount, order.ActualAmount)
createTime := order.CreatedAt.TimestampMilli()
blockTimestamp := transfer.Get("block_timestamp").Int()
log.Sugar.Infof("[TRC20][%s] 时间校验: blockTimestamp=%d, orderCreateTime=%d", address, blockTimestamp, createTime)
if blockTimestamp < createTime {
log.Sugar.Errorf("[TRC20][%s] 区块时间早于订单创建时间,无法匹配! blockTimestamp=%d < createTime=%d", address, blockTimestamp, createTime)
panic("Orders cannot actually be matched")
}
transferHash := transfer.Get("transaction_id").String()
log.Sugar.Infof("[TRC20][%s] 开始处理订单: tradeId=%s, hash=%s, amount=%.2f", address, tradeId, transferHash, amount)
req := &request.OrderProcessingRequest{
ReceiveAddress: address,
Token: "USDT",
TradeId: tradeId,
Amount: amount,
BlockTransactionId: transferHash,
}
err = OrderProcessing(req)
if err != nil {
log.Sugar.Errorf("[TRC20][%s] OrderProcessing失败: tradeId=%s, err=%v", address, tradeId, err)
panic(err)
}
log.Sugar.Infof("[TRC20][%s] OrderProcessing成功: tradeId=%s", address, tradeId)
orderCallbackQueue, _ := handle.NewOrderCallbackQueue(order)
orderNoticeMaxRetry := viper.GetInt("order_notice_max_retry")
mq.MClient.Enqueue(orderCallbackQueue, asynq.MaxRetry(orderNoticeMaxRetry),
asynq.Retention(config.GetOrderExpirationTimeDuration()),
)
log.Sugar.Infof("[TRC20][%s] 回调队列已入队: tradeId=%s", address, tradeId)
msgTpl := `
🎉 <b>收款成功通知</b>
💰 <b>金额信息</b>
├ 订单金额:<code>%.2f %s</code>
└ 实际到账:<code>%.2f %s</code>
📋 <b>订单信息</b>
├ 交易号:<code>%s</code>
├ 订单号:<code>%s</code>
└ 钱包地址:<code>%s</code>
⏰ <b>时间信息</b>
├ 创建时间:%s
└ 支付时间:%s
`
msg := fmt.Sprintf(msgTpl, order.Amount, strings.ToUpper(order.Currency), order.ActualAmount, strings.ToUpper(order.Token), order.TradeId, order.OrderId, order.ReceiveAddress, order.CreatedAt.ToDateTimeString(), carbon.Now().ToDateTimeString())
log.Sugar.Infof("[TRC20][%s] 准备发送Telegram通知: tradeId=%s, orderId=%s", address, tradeId, order.OrderId)
telegram.SendToBot(msg)
}
}