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
+3
View File
@@ -78,3 +78,6 @@ order_expiration_time=10
order_notice_max_retry=0 order_notice_max_retry=0
#强制汇率(设置此参数后每笔交易将按照此汇率计算,例如:6.4) #强制汇率(设置此参数后每笔交易将按照此汇率计算,例如:6.4)
forced_usdt_rate= forced_usdt_rate=
# 汇率接口url
api_rate_url=
tron_grid_api_key=
+65 -10
View File
@@ -2,22 +2,28 @@ package config
import ( import (
"fmt" "fmt"
"github.com/spf13/viper" "log"
"net/url" "net/url"
"os" "os"
"time" "time"
"github.com/assimon/luuu/util/http_client"
"github.com/spf13/viper"
"github.com/tidwall/gjson"
) )
var ( var (
AppDebug bool AppDebug bool
MysqlDns string MysqlDns string
RuntimePath string RuntimePath string
LogSavePath string LogSavePath string
StaticPath string StaticPath string
TgBotToken string TgBotToken string
TgProxy string TgProxy string
TgManage int64 TgManage int64
UsdtRate float64 UsdtRate float64
RateApiUrl string
TRON_GRID_API_KEY string
) )
func Init() { func Init() {
@@ -52,6 +58,9 @@ func Init() {
TgBotToken = viper.GetString("tg_bot_token") TgBotToken = viper.GetString("tg_bot_token")
TgProxy = viper.GetString("tg_proxy") TgProxy = viper.GetString("tg_proxy")
TgManage = viper.GetInt64("tg_manage") TgManage = viper.GetInt64("tg_manage")
GetRateApiUrl()
TRON_GRID_API_KEY = viper.GetString("tron_grid_api_key")
} }
func GetAppVersion() string { func GetAppVersion() string {
@@ -74,6 +83,52 @@ func GetApiAuthToken() string {
return viper.GetString("api_auth_token") return viper.GetString("api_auth_token")
} }
func GetRateApiUrl() string {
url := viper.GetString("api_rate_url")
urlFromEnv := os.Getenv("API_RATE_URL")
if url == "" && urlFromEnv != "" {
url = urlFromEnv
}
if url == "" {
log.Println("api_rate_url is empty")
}
RateApiUrl = url
return url
}
func GetRateForCoin(coin string, base string) float64 {
client := http_client.GetHttpClient()
baseUrl := RateApiUrl
if baseUrl == "" {
log.Printf("rate api url is empty")
return 0.0
}
if baseUrl[len(baseUrl)-1] != '/' {
baseUrl += "/"
}
url := baseUrl + fmt.Sprintf("%s.json", base)
fmt.Println("call rate api url:", url)
resp, err := client.R().Get(url)
if err != nil {
log.Printf("call rate api error: %s", err.Error())
return 0.0
}
targetRate := 0.0
gjson.GetBytes(resp.Body(), base).ForEach(func(key, value gjson.Result) bool {
if key.String() == coin {
targetRate = value.Float()
return false
}
return true
})
return targetRate
}
func GetUsdtRate() float64 { func GetUsdtRate() float64 {
forcedUsdtRate := viper.GetFloat64("forced_usdt_rate") forcedUsdtRate := viper.GetFloat64("forced_usdt_rate")
if forcedUsdtRate > 0 { if forcedUsdtRate > 0 {
+30
View File
@@ -0,0 +1,30 @@
package crypto
import (
"crypto/sha256"
"github.com/shengdoushi/base58"
)
const AddressLength = 20
const PrefixMainnet = 0x41
// Encode returns the Base58 encoding of input using the Bitcoin alphabet.
func Encode(input []byte) string {
return base58.Encode(input, base58.BitcoinAlphabet)
}
// EncodeCheck returns the Base58Check encoding of input (Base58 with a 4-byte SHA-256d checksum).
func EncodeCheck(input []byte) string {
h256h0 := sha256.New()
h256h0.Write(input)
h0 := h256h0.Sum(nil)
h256h1 := sha256.New()
h256h1.Write(h0)
h1 := h256h1.Sum(nil)
inputCheck := append(append([]byte(nil), input...), h1[:4]...)
return Encode(inputCheck)
}
+4
View File
@@ -17,9 +17,11 @@ require (
github.com/natefinch/lumberjack v2.0.0+incompatible github.com/natefinch/lumberjack v2.0.0+incompatible
github.com/robfig/cron/v3 v3.0.1 github.com/robfig/cron/v3 v3.0.1
github.com/satori/go.uuid v1.2.0 github.com/satori/go.uuid v1.2.0
github.com/shengdoushi/base58 v1.0.0
github.com/shopspring/decimal v1.3.1 github.com/shopspring/decimal v1.3.1
github.com/spf13/cobra v1.2.1 github.com/spf13/cobra v1.2.1
github.com/spf13/viper v1.9.0 github.com/spf13/viper v1.9.0
github.com/tidwall/gjson v1.18.0
go.uber.org/zap v1.17.0 go.uber.org/zap v1.17.0
gopkg.in/telebot.v3 v3.0.0 gopkg.in/telebot.v3 v3.0.0
gorm.io/driver/mysql v1.5.1 gorm.io/driver/mysql v1.5.1
@@ -62,6 +64,8 @@ require (
github.com/spf13/jwalterweatherman v1.1.0 // indirect github.com/spf13/jwalterweatherman v1.1.0 // indirect
github.com/spf13/pflag v1.0.5 // indirect github.com/spf13/pflag v1.0.5 // indirect
github.com/subosito/gotenv v1.2.0 // indirect github.com/subosito/gotenv v1.2.0 // indirect
github.com/tidwall/match v1.2.0 // indirect
github.com/tidwall/pretty v1.2.1 // indirect
github.com/valyala/bytebufferpool v1.0.0 // indirect github.com/valyala/bytebufferpool v1.0.0 // indirect
github.com/valyala/fasttemplate v1.2.1 // indirect github.com/valyala/fasttemplate v1.2.1 // indirect
github.com/xo/terminfo v0.0.0-20210125001918-ca9a967f8778 // indirect github.com/xo/terminfo v0.0.0-20210125001918-ca9a967f8778 // indirect
+10
View File
@@ -380,6 +380,8 @@ github.com/sagikazarmark/crypt v0.1.0/go.mod h1:B/mN0msZuINBtQ1zZLEQcegFJJf9vnYI
github.com/satori/go.uuid v1.2.0 h1:0uYX9dsZ2yD7q2RtLRtPSdGDWzjeM3TbMJP9utgA0ww= github.com/satori/go.uuid v1.2.0 h1:0uYX9dsZ2yD7q2RtLRtPSdGDWzjeM3TbMJP9utgA0ww=
github.com/satori/go.uuid v1.2.0/go.mod h1:dA0hQrYB0VpLJoorglMZABFdXlWrHn1NEOzdhQKdks0= github.com/satori/go.uuid v1.2.0/go.mod h1:dA0hQrYB0VpLJoorglMZABFdXlWrHn1NEOzdhQKdks0=
github.com/sean-/seed v0.0.0-20170313163322-e2103e2c3529/go.mod h1:DxrIzT+xaE7yg65j358z/aeFdxmN0P9QXhEzd20vsDc= github.com/sean-/seed v0.0.0-20170313163322-e2103e2c3529/go.mod h1:DxrIzT+xaE7yg65j358z/aeFdxmN0P9QXhEzd20vsDc=
github.com/shengdoushi/base58 v1.0.0 h1:tGe4o6TmdXFJWoI31VoSWvuaKxf0Px3gqa3sUWhAxBs=
github.com/shengdoushi/base58 v1.0.0/go.mod h1:m5uIILfzcKMw6238iWAhP4l3s5+uXyF3+bJKUNhAL9I=
github.com/shopspring/decimal v1.3.1 h1:2Usl1nmF/WZucqkFZhnfFYxxxu8LG21F6nPQBE5gKV8= github.com/shopspring/decimal v1.3.1 h1:2Usl1nmF/WZucqkFZhnfFYxxxu8LG21F6nPQBE5gKV8=
github.com/shopspring/decimal v1.3.1/go.mod h1:DKyhrW/HYNuLGql+MJL6WCR6knT2jwCFRcu2hWCYk4o= github.com/shopspring/decimal v1.3.1/go.mod h1:DKyhrW/HYNuLGql+MJL6WCR6knT2jwCFRcu2hWCYk4o=
github.com/shurcooL/sanitized_anchor_name v1.0.0/go.mod h1:1NzhyTcUVG4SuEtjjoZeVRXNmyL/1OwPU0+IJeTBvfc= github.com/shurcooL/sanitized_anchor_name v1.0.0/go.mod h1:1NzhyTcUVG4SuEtjjoZeVRXNmyL/1OwPU0+IJeTBvfc=
@@ -413,6 +415,14 @@ github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
github.com/subosito/gotenv v1.2.0 h1:Slr1R9HxAlEKefgq5jn9U+DnETlIUa6HfgEzj0g5d7s= github.com/subosito/gotenv v1.2.0 h1:Slr1R9HxAlEKefgq5jn9U+DnETlIUa6HfgEzj0g5d7s=
github.com/subosito/gotenv v1.2.0/go.mod h1:N0PQaV/YGNqwC0u51sEeR/aUtSLEXKX9iv69rRypqCw= github.com/subosito/gotenv v1.2.0/go.mod h1:N0PQaV/YGNqwC0u51sEeR/aUtSLEXKX9iv69rRypqCw=
github.com/tidwall/gjson v1.18.0 h1:FIDeeyB800efLX89e5a8Y0BNH+LOngJyGrIWxG2FKQY=
github.com/tidwall/gjson v1.18.0/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk=
github.com/tidwall/match v1.1.1/go.mod h1:eRSPERbgtNPcGhD8UCthc6PmLEQXEWd3PRB5JTxsfmM=
github.com/tidwall/match v1.2.0 h1:0pt8FlkOwjN2fPt4bIl4BoNxb98gGHN2ObFEDkrfZnM=
github.com/tidwall/match v1.2.0/go.mod h1:eRSPERbgtNPcGhD8UCthc6PmLEQXEWd3PRB5JTxsfmM=
github.com/tidwall/pretty v1.2.0/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU=
github.com/tidwall/pretty v1.2.1 h1:qjsOFOWWQl+N3RsoF5/ssm1pHmJJwhjlSbZ51I6wMl4=
github.com/tidwall/pretty v1.2.1/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU=
github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw= github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw=
github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc= github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc=
github.com/valyala/fasttemplate v1.0.1/go.mod h1:UQGH1tvbgY+Nz5t2n7tXsz52dQxojPUpymEIMZ47gx8= github.com/valyala/fasttemplate v1.0.1/go.mod h1:UQGH1tvbgY+Nz5t2n7tXsz52dQxojPUpymEIMZ47gx8=
+5 -1
View File
@@ -8,5 +8,9 @@ import (
) )
func openDB(dsn string, cfg *gorm.Config) (*gorm.DB, error) { 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) { 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 ( import (
"context" "context"
"fmt" "fmt"
"strings"
"time"
"github.com/assimon/luuu/model/dao" "github.com/assimon/luuu/model/dao"
"github.com/assimon/luuu/model/mdb" "github.com/assimon/luuu/model/mdb"
"github.com/assimon/luuu/model/request" "github.com/assimon/luuu/model/request"
"github.com/assimon/luuu/util/log"
"github.com/go-redis/redis/v8" "github.com/go-redis/redis/v8"
"gorm.io/gorm" "gorm.io/gorm"
"time"
) )
var ( var (
CacheWalletAddressWithAmountToTradeIdKey = "wallet:%s_%v" // 钱包_待支付金额 : 交易号 CacheWalletAddressWithAmountToTradeIdKey = "wallet:%s_%s_%v" // 钱包_币种_待支付金额 : 交易号
) )
// GetOrderInfoByOrderId 通过客户订单号查询订单 // GetOrderInfoByOrderId 通过客户订单号查询订单
@@ -78,10 +81,10 @@ func UpdateOrderIsExpirationById(id uint64) error {
return err return err
} }
// GetTradeIdByWalletAddressAndAmount 通过钱包地址支付金额获取交易号 // GetTradeIdByWalletAddressAndAmountAndToken 通过钱包地址、币种、支付金额获取交易号
func GetTradeIdByWalletAddressAndAmount(token string, amount float64) (string, error) { func GetTradeIdByWalletAddressAndAmountAndToken(address string, token string, amount float64) (string, error) {
ctx := context.Background() 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() result, err := dao.Rdb.Get(ctx, cacheKey).Result()
if err == redis.Nil { if err == redis.Nil {
return "", nil return "", nil
@@ -93,17 +96,18 @@ func GetTradeIdByWalletAddressAndAmount(token string, amount float64) (string, e
} }
// LockTransaction 锁定交易 // 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() 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() err := dao.Rdb.Set(ctx, cacheKey, tradeId, expirationTime).Err()
return err return err
} }
// UnLockTransaction 解锁交易 // UnLockTransaction 解锁交易
func UnLockTransaction(token string, amount float64) error { func UnLockTransaction(address string, token string, amount float64) error {
ctx := context.Background() 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() err := dao.Rdb.Del(ctx, cacheKey).Err()
return err return err
} }
+7 -7
View File
@@ -7,8 +7,8 @@ import (
) )
// AddWalletAddress 创建钱包 // AddWalletAddress 创建钱包
func AddWalletAddress(token string) (*mdb.WalletAddress, error) { func AddWalletAddress(address string) (*mdb.WalletAddress, error) {
exist, err := GetWalletAddressByToken(token) exist, err := GetWalletAddressByToken(address)
if err != nil { if err != nil {
return nil, err return nil, err
} }
@@ -16,17 +16,17 @@ func AddWalletAddress(token string) (*mdb.WalletAddress, error) {
return nil, constant.WalletAddressAlreadyExists return nil, constant.WalletAddressAlreadyExists
} }
walletAddress := &mdb.WalletAddress{ walletAddress := &mdb.WalletAddress{
Token: token, Address: address,
Status: mdb.TokenStatusEnable, Status: mdb.TokenStatusEnable,
} }
err = dao.Mdb.Create(walletAddress).Error err = dao.Mdb.Create(walletAddress).Error
return walletAddress, err return walletAddress, err
} }
// GetWalletAddressByToken 通过钱包地址获取token // GetWalletAddressByToken 通过钱包地址获取address
func GetWalletAddressByToken(token string) (*mdb.WalletAddress, error) { func GetWalletAddressByToken(address string) (*mdb.WalletAddress, error) {
walletAddress := new(mdb.WalletAddress) 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 return walletAddress, err
} }
+3 -1
View File
@@ -13,8 +13,10 @@ type Orders struct {
OrderId string `gorm:"column:order_id" json:"order_id"` // 客户交易id 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 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位小数 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位小数 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:已过期 Status int `gorm:"column:status;default:1" json:"status"` // 1:等待支付,2:支付成功,3:已过期
NotifyUrl string `gorm:"column:notify_url" json:"notify_url"` // 异步回调地址 NotifyUrl string `gorm:"column:notify_url" json:"notify_url"` // 异步回调地址
RedirectUrl string `gorm:"column:redirect_url" json:"redirect_url"` // 同步回调地址 RedirectUrl string `gorm:"column:redirect_url" json:"redirect_url"` // 同步回调地址
+2 -2
View File
@@ -7,8 +7,8 @@ const (
// WalletAddress 钱包表 // WalletAddress 钱包表
type WalletAddress struct { type WalletAddress struct {
Token string `gorm:"index:wallet_address_token_index;column:token" json:"token"` // 钱包token Address string `gorm:"index:wallet_address_index;column:address" json:"address"` // 钱包地址
Status int64 `gorm:"column:status;default:1" json:"status"` // 1:启用 2:禁用 Status int64 `gorm:"column:status;default:1" json:"status"` // 1:启用 2:禁用
BaseModel BaseModel
} }
+6
View File
@@ -5,6 +5,8 @@ import "github.com/gookit/validate"
// CreateTransactionRequest 创建交易请求 // CreateTransactionRequest 创建交易请求
type CreateTransactionRequest struct { type CreateTransactionRequest struct {
OrderId string `json:"order_id" validate:"required|maxLen:32"` 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"` Amount float64 `json:"amount" validate:"required|isFloat|gt:0.01"`
NotifyUrl string `json:"notify_url" validate:"required"` NotifyUrl string `json:"notify_url" validate:"required"`
Signature string `json:"signature" validate:"required"` Signature string `json:"signature" validate:"required"`
@@ -14,6 +16,8 @@ type CreateTransactionRequest struct {
func (r CreateTransactionRequest) Translates() map[string]string { func (r CreateTransactionRequest) Translates() map[string]string {
return validate.MS{ return validate.MS{
"OrderId": "订单号", "OrderId": "订单号",
"Currency": "货币",
"Token": "币种",
"Amount": "支付金额", "Amount": "支付金额",
"NotifyUrl": "异步回调网址", "NotifyUrl": "异步回调网址",
"Signature": "签名", "Signature": "签名",
@@ -22,6 +26,8 @@ func (r CreateTransactionRequest) Translates() map[string]string {
// OrderProcessingRequest 订单处理 // OrderProcessingRequest 订单处理
type OrderProcessingRequest struct { type OrderProcessingRequest struct {
ReceiveAddress string
Currency string
Token string Token string
Amount float64 Amount float64
TradeId string TradeId string
+5 -2
View File
@@ -5,8 +5,10 @@ type CreateTransactionResponse struct {
TradeId string `json:"trade_id"` // epusdt订单号 TradeId string `json:"trade_id"` // epusdt订单号
OrderId string `json:"order_id"` // 客户交易id OrderId string `json:"order_id"` // 客户交易id
Amount float64 `json:"amount"` // 订单金额,保留4位小数 Amount float64 `json:"amount"` // 订单金额,保留4位小数
Currency string `json:"currency"` // 订单货币类型 CNY USD......
ActualAmount float64 `json:"actual_amount"` // 订单实际需要支付的金额,保留4位小数 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"` // 过期时间 时间戳 ExpirationTime int64 `json:"expiration_time"` // 过期时间 时间戳
PaymentUrl string `json:"payment_url"` // 收银台地址 PaymentUrl string `json:"payment_url"` // 收银台地址
} }
@@ -17,7 +19,8 @@ type OrderNotifyResponse struct {
OrderId string `json:"order_id"` // 客户交易id OrderId string `json:"order_id"` // 客户交易id
Amount float64 `json:"amount"` // 订单金额,保留4位小数 Amount float64 `json:"amount"` // 订单金额,保留4位小数
ActualAmount float64 `json:"actual_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 BlockTransactionId string `json:"block_transaction_id"` // 区块id
Signature string `json:"signature"` // 签名 Signature string `json:"signature"` // 签名
Status int `json:"status"` // 1:等待支付,2:支付成功,3:已过期 Status int `json:"status"` // 1:等待支付,2:支付成功,3:已过期
+2 -1
View File
@@ -3,7 +3,8 @@ package response
type CheckoutCounterResponse struct { type CheckoutCounterResponse struct {
TradeId string `json:"trade_id"` // epusdt订单号 TradeId string `json:"trade_id"` // epusdt订单号
ActualAmount float64 `json:"actual_amount"` // 订单实际需要支付的金额,保留4位小数 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"` // 过期时间 时间戳 ExpirationTime int64 `json:"expiration_time"` // 过期时间 时间戳
RedirectUrl string `json:"redirect_url"` RedirectUrl string `json:"redirect_url"`
} }
+34 -25
View File
@@ -3,6 +3,7 @@ package service
import ( import (
"fmt" "fmt"
"math/rand" "math/rand"
"strings"
"sync" "sync"
"time" "time"
@@ -35,10 +36,14 @@ func CreateTransaction(req *request.CreateTransactionRequest) (*response.CreateT
gCreateTransactionLock.Lock() gCreateTransactionLock.Lock()
defer gCreateTransactionLock.Unlock() defer gCreateTransactionLock.Unlock()
payAmount := math.MustParsePrecFloat64(req.Amount, 2) 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) decimalPayAmount := decimal.NewFromFloat(payAmount)
decimalRate := decimal.NewFromFloat(config.GetUsdtRate()) decimalRate := decimal.NewFromFloat(coinRateBaseCurrency)
decimalUsdt := decimalPayAmount.Div(decimalRate) decimalUsdt := decimalPayAmount.Mul(decimalRate)
// cny 是否可以满足最低支付金额 // cny 是否可以满足最低支付金额
if decimalPayAmount.Cmp(decimal.NewFromFloat(CnyMinimumPaymentAmount)) == -1 { if decimalPayAmount.Cmp(decimal.NewFromFloat(CnyMinimumPaymentAmount)) == -1 {
return nil, constant.PayAmountErr return nil, constant.PayAmountErr
@@ -64,23 +69,25 @@ func CreateTransaction(req *request.CreateTransactionRequest) (*response.CreateT
return nil, constant.NotAvailableWalletAddress return nil, constant.NotAvailableWalletAddress
} }
amount := math.MustParsePrecFloat64(decimalUsdt.InexactFloat64(), 2) amount := math.MustParsePrecFloat64(decimalUsdt.InexactFloat64(), 2)
availableToken, availableAmount, err := CalculateAvailableWalletAndAmount(amount, walletAddress) availableAddress, availableAmount, err := CalculateAvailableWalletAndAmount(amount, req.Token, walletAddress)
if err != nil { if err != nil {
return nil, err return nil, err
} }
if availableToken == "" { if availableAddress == "" {
return nil, constant.NotAvailableAmountErr return nil, constant.NotAvailableAmountErr
} }
tx := dao.Mdb.Begin() tx := dao.Mdb.Begin()
order := &mdb.Orders{ order := &mdb.Orders{
TradeId: GenerateCode(), TradeId: GenerateCode(),
OrderId: req.OrderId, OrderId: req.OrderId,
Amount: req.Amount, Amount: req.Amount,
ActualAmount: availableAmount, Currency: req.Currency,
Token: availableToken, ActualAmount: availableAmount,
Status: mdb.StatusWaitPay, ReceiveAddress: availableAddress,
NotifyUrl: req.NotifyUrl, Token: req.Token,
RedirectUrl: req.RedirectUrl, Status: mdb.StatusWaitPay,
NotifyUrl: req.NotifyUrl,
RedirectUrl: req.RedirectUrl,
} }
err = data.CreateOrderWithTransaction(tx, order) err = data.CreateOrderWithTransaction(tx, order)
if err != nil { if err != nil {
@@ -88,7 +95,7 @@ func CreateTransaction(req *request.CreateTransactionRequest) (*response.CreateT
return nil, err 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 { if err != nil {
tx.Rollback() tx.Rollback()
return nil, err return nil, err
@@ -104,7 +111,9 @@ func CreateTransaction(req *request.CreateTransactionRequest) (*response.CreateT
TradeId: order.TradeId, TradeId: order.TradeId,
OrderId: order.OrderId, OrderId: order.OrderId,
Amount: order.Amount, Amount: order.Amount,
Currency: order.Currency,
ActualAmount: order.ActualAmount, ActualAmount: order.ActualAmount,
ReceiveAddress: order.ReceiveAddress,
Token: order.Token, Token: order.Token,
ExpirationTime: ExpirationTime, ExpirationTime: ExpirationTime,
PaymentUrl: fmt.Sprintf("%s/pay/checkout-counter/%s", config.GetAppUri(), order.TradeId), PaymentUrl: fmt.Sprintf("%s/pay/checkout-counter/%s", config.GetAppUri(), order.TradeId),
@@ -130,7 +139,7 @@ func OrderProcessing(req *request.OrderProcessingRequest) error {
return err return err
} }
// 解锁交易 // 解锁交易
err = data.UnLockTransaction(req.Token, req.Amount) err = data.UnLockTransaction(req.ReceiveAddress, req.Token, req.Amount)
if err != nil { if err != nil {
tx.Rollback() tx.Rollback()
return err return err
@@ -140,40 +149,40 @@ func OrderProcessing(req *request.OrderProcessingRequest) error {
} }
// CalculateAvailableWalletAndAmount 计算可用钱包地址和金额 // CalculateAvailableWalletAndAmount 计算可用钱包地址和金额
func CalculateAvailableWalletAndAmount(amount float64, walletAddress []mdb.WalletAddress) (string, float64, error) { func CalculateAvailableWalletAndAmount(amount float64, token string, walletAddress []mdb.WalletAddress) (string, float64, error) {
availableToken := "" availableAddress := ""
availableAmount := amount availableAmount := amount
calculateAvailableWalletFunc := func(amount float64) (string, error) { calculateAvailableWalletFunc := func(amount float64) (string, error) {
availableWallet := "" availableWallet := ""
for _, address := range walletAddress { for _, addr := range walletAddress {
token := address.Token walletAddr := addr.Address
result, err := data.GetTradeIdByWalletAddressAndAmount(token, amount) result, err := data.GetTradeIdByWalletAddressAndAmountAndToken(walletAddr, token, amount)
if err != nil { if err != nil {
return "", err return "", err
} }
if result == "" { if result == "" {
availableWallet = token availableWallet = walletAddr
break break
} }
} }
return availableWallet, nil return availableWallet, nil
} }
for i := 0; i < IncrementalMaximumNumber; i++ { for i := 0; i < IncrementalMaximumNumber; i++ {
token, err := calculateAvailableWalletFunc(availableAmount) wallet, err := calculateAvailableWalletFunc(availableAmount)
if err != nil { if err != nil {
return "", 0, err return "", 0, err
} }
// 拿不到可用钱包就累加金额 // 拿不到可用钱包就累加金额
if token == "" { if wallet == "" {
decimalOldAmount := decimal.NewFromFloat(availableAmount) decimalOldAmount := decimal.NewFromFloat(availableAmount)
decimalIncr := decimal.NewFromFloat(UsdtAmountPerIncrement) decimalIncr := decimal.NewFromFloat(UsdtAmountPerIncrement)
availableAmount = decimalOldAmount.Add(decimalIncr).InexactFloat64() availableAmount = decimalOldAmount.Add(decimalIncr).InexactFloat64()
continue continue
} }
availableToken = token availableAddress = wallet
break break
} }
return availableToken, availableAmount, nil return availableAddress, availableAmount, nil
} }
// GenerateCode 订单号生成 // GenerateCode 订单号生成
+1
View File
@@ -21,6 +21,7 @@ func GetCheckoutCounterByTradeId(tradeId string) (*response.CheckoutCounterRespo
resp := &response.CheckoutCounterResponse{ resp := &response.CheckoutCounterResponse{
TradeId: orderInfo.TradeId, TradeId: orderInfo.TradeId,
ActualAmount: orderInfo.ActualAmount, ActualAmount: orderInfo.ActualAmount,
ReceiveAddress: orderInfo.ReceiveAddress,
Token: orderInfo.Token, Token: orderInfo.Token,
ExpirationTime: orderInfo.CreatedAt.AddMinutes(config.GetOrderExpirationTime()).TimestampMilli(), ExpirationTime: orderInfo.CreatedAt.AddMinutes(config.GetOrderExpirationTime()).TimestampMilli(),
RedirectUrl: orderInfo.RedirectUrl, RedirectUrl: orderInfo.RedirectUrl,
+274 -88
View File
@@ -1,161 +1,347 @@
package service package service
import ( import (
"encoding/hex"
"fmt" "fmt"
"net/http" "net/http"
"strings"
"sync" "sync"
"github.com/assimon/luuu/config" tron "github.com/assimon/luuu/crypto"
"github.com/spf13/viper"
"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/data"
"github.com/assimon/luuu/model/request" "github.com/assimon/luuu/model/request"
"github.com/assimon/luuu/mq" "github.com/assimon/luuu/mq"
"github.com/assimon/luuu/mq/handle" "github.com/assimon/luuu/mq/handle"
"github.com/assimon/luuu/telegram" "github.com/assimon/luuu/telegram"
"github.com/assimon/luuu/util/http_client" "github.com/assimon/luuu/util/http_client"
"github.com/assimon/luuu/util/json"
"github.com/assimon/luuu/util/log" "github.com/assimon/luuu/util/log"
"github.com/assimon/luuu/util/math"
"github.com/dromara/carbon/v2" "github.com/dromara/carbon/v2"
"github.com/gookit/goutil/stdutil" "github.com/gookit/goutil/stdutil"
"github.com/hibiken/asynq"
"github.com/shopspring/decimal"
) )
const UsdtTrc20ApiUri = "https://apilist.tronscanapi.com/api/transfer/trc20" const TRC20_USDT_ID = "TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6t"
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"`
}
// Trc20CallBack trc20回调 // Trc20CallBack trc20回调
func Trc20CallBack(token string, wg *sync.WaitGroup) { func Trc20CallBack(address string, wg *sync.WaitGroup) {
defer wg.Done() defer wg.Done()
defer func() { defer func() {
if err := recover(); err != nil { if err := recover(); err != nil {
log.Sugar.Error(err) 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() client := http_client.GetHttpClient()
startTime := carbon.Now().AddHours(-24).TimestampMilli() startTime := carbon.Now().AddHours(-24).TimestampMilli()
endTime := carbon.Now().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{ resp, err := client.R().SetQueryParams(map[string]string{
"sort": "-timestamp", "order_by": "block_timestamp,desc",
"limit": "50", "limit": "100",
"start": "0", // "only_confirmed": "true",
"direction": "2", "only_to": "true",
"db_version": "1", "min_timestamp": stdutil.ToString(startTime),
"trc20Id": "TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6t", "max_timestamp": stdutil.ToString(endTime),
"address": token, }).SetHeader("TRON-PRO-API-KEY", config.TRON_GRID_API_KEY).Get(url)
"start_timestamp": stdutil.ToString(startTime),
"end_timestamp": stdutil.ToString(endTime),
}).Get(UsdtTrc20ApiUri)
if err != nil { if err != nil {
panic(err) panic(err)
} }
if resp.StatusCode() != http.StatusOK { 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) log.Sugar.Debugf("Raw request URL: %s", resp.Request.URL)
if err != nil {
panic(err) 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 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 continue
} }
decimalQuant, err := decimal.NewFromString(transfer.Amount)
if err != nil { contractRet := transfer.Get("ret.0.contractRet").String()
panic(err) 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() toAddressHex := transfer.Get("raw_data.contract.0.parameter.value.to_address").String()
tradeId, err := data.GetTradeIdByWalletAddressAndAmount(token, amount) 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 { if err != nil {
panic(err) panic(err)
} }
if tradeId == "" { if tradeId == "" {
log.Sugar.Infof("[TRX][%s] 第%d条: Redis未匹配到订单, 金额=%.2f, 跳过", address, i, amount)
continue continue
} }
log.Sugar.Infof("[TRX][%s] 第%d条: Redis匹配到订单! tradeId=%s, 金额=%.2f", address, i, tradeId, amount)
order, err := data.GetOrderInfoByTradeId(tradeId) order, err := data.GetOrderInfoByTradeId(tradeId)
if err != nil { if err != nil {
panic(err) 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() 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") 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{ req := &request.OrderProcessingRequest{
Token: token, ReceiveAddress: address,
Token: "TRX",
TradeId: tradeId, TradeId: tradeId,
Amount: amount, Amount: amount,
BlockTransactionId: transfer.Hash, BlockTransactionId: transferHash,
} }
err = OrderProcessing(req) err = OrderProcessing(req)
if err != nil { if err != nil {
log.Sugar.Errorf("[TRX][%s] OrderProcessing失败: tradeId=%s, err=%v", address, tradeId, err)
panic(err) panic(err)
} }
// 回调队列 log.Sugar.Infof("[TRX][%s] OrderProcessing成功: tradeId=%s", address, tradeId)
orderCallbackQueue, _ := handle.NewOrderCallbackQueue(order) orderCallbackQueue, _ := handle.NewOrderCallbackQueue(order)
orderNoticeMaxRetry := viper.GetInt("order_notice_max_retry") orderNoticeMaxRetry := viper.GetInt("order_notice_max_retry")
mq.MClient.Enqueue(orderCallbackQueue, asynq.MaxRetry(orderNoticeMaxRetry), mq.MClient.Enqueue(orderCallbackQueue, asynq.MaxRetry(orderNoticeMaxRetry),
asynq.Retention(config.GetOrderExpirationTimeDuration()), asynq.Retention(config.GetOrderExpirationTimeDuration()),
) )
// mq.MClient.Enqueue(orderCallbackQueue, asynq.MaxRetry(5)) log.Sugar.Infof("[TRX][%s] 回调队列已入队: tradeId=%s", address, tradeId)
// 发送机器人消息
msgTpl := ` msgTpl := `
<b>📢📢有新的交易支付成功!</b> 🎉 <b>收款成功通知</b>
<pre>交易号:%s</pre>
<pre>订单号:%s</pre> 💰 <b>金额信息</b>
<pre>请求支付金额:%f cny</pre> ├ 订单金额:<code>%.2f %s</code>
<pre>实际支付金额:%f usdt</pre> └ 实际到账:<code>%.2f %s</code>
<pre>钱包地址:%s</pre>
<pre>订单创建时间:%s</pre> 📋 <b>订单信息</b>
<pre>支付成功时间:%s</pre> ├ 交易号:<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) telegram.SendToBot(msg)
} }
} }
+1
View File
@@ -47,6 +47,7 @@ func OrderCallbackHandle(ctx context.Context, t *asynq.Task) error {
OrderId: order.OrderId, OrderId: order.OrderId,
Amount: order.Amount, Amount: order.Amount,
ActualAmount: order.ActualAmount, ActualAmount: order.ActualAmount,
ReceiveAddress: order.ReceiveAddress,
Token: order.Token, Token: order.Token,
BlockTransactionId: order.BlockTransactionId, BlockTransactionId: order.BlockTransactionId,
Status: mdb.StatusPaySuccess, Status: mdb.StatusPaySuccess,
+1 -1
View File
@@ -31,7 +31,7 @@ func OrderExpirationHandle(ctx context.Context, t *asynq.Task) error {
if err != nil { if err != nil {
return err return err
} }
err = data.UnLockTransaction(orderInfo.Token, orderInfo.ActualAmount) err = data.UnLockTransaction(orderInfo.ReceiveAddress, orderInfo.Token, orderInfo.ActualAmount)
if err != nil { if err != nil {
return err return err
} }
+12 -4
View File
@@ -1,12 +1,20 @@
package task package task
import "github.com/robfig/cron/v3" import (
"github.com/assimon/luuu/util/log"
"github.com/robfig/cron/v3"
)
func Start() { func Start() {
log.Sugar.Info("[task] Starting task scheduler...")
c := cron.New() c := cron.New()
// 汇率监听
c.AddJob("@every 60s", UsdtRateJob{})
// trc20钱包监听 // trc20钱包监听
c.AddJob("@every 5s", ListenTrc20Job{}) _, err := c.AddJob("@every 5s", ListenTrc20Job{})
if err != nil {
log.Sugar.Errorf("[task] Failed to add ListenTrc20Job: %v", err)
return
}
log.Sugar.Info("[task] ListenTrc20Job scheduled successfully (@every 5s)")
c.Start() c.Start()
log.Sugar.Info("[task] Task scheduler started")
} }
+10 -3
View File
@@ -1,10 +1,11 @@
package task package task
import ( import (
"sync"
"github.com/assimon/luuu/model/data" "github.com/assimon/luuu/model/data"
"github.com/assimon/luuu/model/service" "github.com/assimon/luuu/model/service"
"github.com/assimon/luuu/util/log" "github.com/assimon/luuu/util/log"
"sync"
) )
type ListenTrc20Job struct { type ListenTrc20Job struct {
@@ -15,18 +16,24 @@ var gListenTrc20JobLock sync.Mutex
func (r ListenTrc20Job) Run() { func (r ListenTrc20Job) Run() {
gListenTrc20JobLock.Lock() gListenTrc20JobLock.Lock()
defer gListenTrc20JobLock.Unlock() defer gListenTrc20JobLock.Unlock()
log.Sugar.Debug("[ListenTrc20Job] Job triggered")
walletAddress, err := data.GetAvailableWalletAddress() walletAddress, err := data.GetAvailableWalletAddress()
if err != nil { if err != nil {
log.Sugar.Error(err) log.Sugar.Errorf("[ListenTrc20Job] Failed to get wallet addresses: %v", err)
return return
} }
if len(walletAddress) <= 0 { if len(walletAddress) <= 0 {
log.Sugar.Debug("[ListenTrc20Job] No available wallet addresses")
return return
} }
log.Sugar.Infof("[ListenTrc20Job] Found %d wallet addresses to monitor", len(walletAddress))
var wg sync.WaitGroup var wg sync.WaitGroup
for _, address := range walletAddress { for _, address := range walletAddress {
log.Sugar.Infof("[ListenTrc20Job] Listening to address: %s", address.Address)
wg.Add(1) wg.Add(1)
go service.Trc20CallBack(address.Token, &wg) go service.Trc20CallBack(address.Address, &wg)
} }
wg.Wait() wg.Wait()
log.Sugar.Debug("[ListenTrc20Job] Job completed")
} }
-62
View File
@@ -1,62 +0,0 @@
package task
import (
"github.com/assimon/luuu/config"
"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"
"time"
)
const UsdtRateApiUri = "https://api.coinmarketcap.com/data-api/v3/cryptocurrency/detail/chart"
type UsdtRateJob struct {
}
type UsdtRateResp struct {
Data Data `json:"data"`
Status Status `json:"status"`
}
type Status struct {
Timestamp time.Time `json:"timestamp"`
ErrorCode string `json:"error_code"`
ErrorMessage string `json:"error_message"`
Elapsed string `json:"elapsed"`
CreditCount int `json:"credit_count"`
}
type Data struct {
Points map[string]Points `json:"points"`
}
type Points struct {
V []float64 `json:"v"`
C []float64 `json:"c"`
}
func (r UsdtRateJob) Run() {
client := http_client.GetHttpClient()
resp, err := client.R().SetQueryString("id=825&range=1H&convertId=2787").SetHeader("Accept", "application/json").Get(UsdtRateApiUri)
if err != nil {
log.Sugar.Error("usdt rate get err:", err.Error())
return
}
var usdtResp UsdtRateResp
err = json.Cjson.Unmarshal(resp.Body(), &usdtResp)
if err != nil {
log.Sugar.Error("Unmarshal usdt resp err:", err.Error())
return
}
if usdtResp.Status.ErrorCode != "0" {
log.Sugar.Error("usdt resp err:", usdtResp.Status.ErrorMessage)
return
}
for _, points := range usdtResp.Data.Points {
if len(points.C) > 0 && points.C[0] > 0 {
config.UsdtRate = math.MustParsePrecFloat64(points.C[0], 2)
return
}
}
}
+3 -3
View File
@@ -45,8 +45,8 @@ func WalletList(c tb.Context) error {
} }
var temp []tb.InlineButton var temp []tb.InlineButton
btnInfo := tb.InlineButton{ btnInfo := tb.InlineButton{
Unique: wallet.Token, Unique: wallet.Address,
Text: fmt.Sprintf("%s[%s]", wallet.Token, status), Text: fmt.Sprintf("%s[%s]", wallet.Address, status),
Data: strutil.MustString(wallet.ID), Data: strutil.MustString(wallet.ID),
} }
bots.Handle(&btnInfo, WalletInfo) bots.Handle(&btnInfo, WalletInfo)
@@ -93,7 +93,7 @@ func WalletInfo(c tb.Context) error {
bots.Handle(&disableBtn, DisableWallet) bots.Handle(&disableBtn, DisableWallet)
bots.Handle(&delBtn, DelWallet) bots.Handle(&delBtn, DelWallet)
bots.Handle(&backBtn, WalletList) bots.Handle(&backBtn, WalletList)
return c.EditOrReply(tokenInfo.Token, &tb.ReplyMarkup{InlineKeyboard: [][]tb.InlineButton{ return c.EditOrReply(tokenInfo.Address, &tb.ReplyMarkup{InlineKeyboard: [][]tb.InlineButton{
{ {
enableBtn, enableBtn,
disableBtn, disableBtn,
+8 -1
View File
@@ -46,14 +46,21 @@ func RegisterHandle() {
// SendToBot 主动发送消息机器人消息 // SendToBot 主动发送消息机器人消息
func SendToBot(msg string) { func SendToBot(msg string) {
go func() { go func() {
if bots == nil {
log.Sugar.Error("[Telegram] bots实例为nil,无法发送消息")
return
}
user := tb.User{ user := tb.User{
ID: config.TgManage, ID: config.TgManage,
} }
log.Sugar.Infof("[Telegram] 正在发送消息给用户ID=%d", config.TgManage)
_, err := bots.Send(&user, msg, &tb.SendOptions{ _, err := bots.Send(&user, msg, &tb.SendOptions{
ParseMode: tb.ModeHTML, ParseMode: tb.ModeHTML,
}) })
if err != nil { if err != nil {
log.Sugar.Error(err) log.Sugar.Errorf("[Telegram] 发送消息失败: userID=%d, err=%v", config.TgManage, err)
} else {
log.Sugar.Infof("[Telegram] 发送消息成功: userID=%d", config.TgManage)
} }
}() }()
} }