Merge branch 'dev' into dev-runtime

This commit is contained in:
alphago9
2026-03-30 20:14:29 +08:00
27 changed files with 532 additions and 318 deletions
+2
View File
@@ -57,3 +57,5 @@ api_auth_token=
order_expiration_time=10 order_expiration_time=10
order_notice_max_retry=0 order_notice_max_retry=0
forced_usdt_rate= forced_usdt_rate=
api_rate_url=
tron_grid_api_key=
+91 -24
View File
@@ -2,27 +2,33 @@ package config
import ( import (
"fmt" "fmt"
"log"
"net/url" "net/url"
"os" "os"
"path/filepath" "path/filepath"
"strings"
"time" "time"
"github.com/assimon/luuu/util/http_client"
"github.com/spf13/viper" "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
BuildVersion = "0.0.0-dev" RateApiUrl string
BuildCommit = "none" TRON_GRID_API_KEY string
BuildDate = "unknown" BuildVersion = "0.0.0-dev"
BuildCommit = "none"
BuildDate = "unknown"
) )
func Init() { func Init() {
@@ -38,27 +44,21 @@ func Init() {
} }
AppDebug = viper.GetBool("app_debug") AppDebug = viper.GetBool("app_debug")
StaticPath = viper.GetString("static_path") StaticPath = viper.GetString("static_path")
RuntimePath = fmt.Sprintf( RuntimePath = fmt.Sprintf("%s%s", gwd, viper.GetString("runtime_root_path"))
"%s%s", LogSavePath = fmt.Sprintf("%s%s", RuntimePath, viper.GetString("log_save_path"))
gwd,
viper.GetString("runtime_root_path"))
LogSavePath = fmt.Sprintf(
"%s%s",
RuntimePath,
viper.GetString("log_save_path"))
mustMkdir(RuntimePath) mustMkdir(RuntimePath)
mustMkdir(LogSavePath) mustMkdir(LogSavePath)
MysqlDns = fmt.Sprintf("%s:%s@tcp(%s)/%s?charset=utf8mb4&parseTime=True&loc=Local", MysqlDns = fmt.Sprintf("%s:%s@tcp(%s)/%s?charset=utf8mb4&parseTime=True&loc=Local",
url.QueryEscape(viper.GetString("mysql_user")), url.QueryEscape(viper.GetString("mysql_user")),
url.QueryEscape(viper.GetString("mysql_passwd")), url.QueryEscape(viper.GetString("mysql_passwd")),
fmt.Sprintf( fmt.Sprintf("%s:%s", viper.GetString("mysql_host"), viper.GetString("mysql_port")),
"%s:%s",
viper.GetString("mysql_host"),
viper.GetString("mysql_port")),
viper.GetString("mysql_database")) viper.GetString("mysql_database"))
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")
RateApiUrl = GetRateApiUrl()
TRON_GRID_API_KEY = viper.GetString("tron_grid_api_key")
} }
func mustMkdir(path string) { func mustMkdir(path string) {
@@ -95,6 +95,73 @@ func GetApiAuthToken() string {
return viper.GetString("api_auth_token") return viper.GetString("api_auth_token")
} }
func GetRateApiUrl() string {
rateURL := viper.GetString("api_rate_url")
if rateURL == "" {
rateURL = os.Getenv("API_RATE_URL")
}
if rateURL == "" {
log.Println("api_rate_url is empty")
}
RateApiUrl = rateURL
return rateURL
}
func GetRateForCoin(coin string, base string) float64 {
coin = strings.ToLower(strings.TrimSpace(coin))
base = strings.ToLower(strings.TrimSpace(base))
if coin == "" || base == "" {
return 0
}
if coin == base {
return 1
}
if coin == "usdt" {
switch base {
case "usd":
return 1
case "cny":
usdtRate := GetUsdtRate()
if usdtRate > 0 {
return 1 / usdtRate
}
}
}
baseURL := RateApiUrl
if baseURL == "" {
baseURL = GetRateApiUrl()
}
if baseURL == "" {
log.Printf("rate api url is empty")
return 0.0
}
if baseURL[len(baseURL)-1] != '/' {
baseURL += "/"
}
client := http_client.GetHttpClient()
resp, err := client.R().Get(baseURL + fmt.Sprintf("%s.json", base))
if err != nil {
log.Printf("call rate api error: %s", err.Error())
return 0.0
}
if resp.StatusCode() < 200 || resp.StatusCode() >= 300 {
log.Printf("call rate api unexpected status: %s", resp.Status())
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
@@ -15,9 +15,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
@@ -58,6 +60,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
@@ -360,6 +360,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=
@@ -393,6 +395,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
} }
+3
View File
@@ -56,6 +56,9 @@ func RuntimeInit() error {
color.Red.Printf("[runtime_db] sqlite connDB err:%s", err.Error()) color.Red.Printf("[runtime_db] sqlite connDB err:%s", err.Error())
return err return err
} }
if err = RuntimeDB.Exec("DROP INDEX IF EXISTS transaction_lock_token_amount_uindex").Error; err != nil {
return err
}
if err = RuntimeDB.AutoMigrate(&mdb.TransactionLock{}); err != nil { if err = RuntimeDB.AutoMigrate(&mdb.TransactionLock{}); err != nil {
color.Red.Printf("[runtime_db] sqlite migrate DB(TransactionLock),err=%s\n", err) color.Red.Printf("[runtime_db] sqlite migrate DB(TransactionLock),err=%s\n", err)
return err return err
+29 -16
View File
@@ -2,6 +2,7 @@ package data
import ( import (
"errors" "errors"
"strings"
"time" "time"
"github.com/assimon/luuu/model/dao" "github.com/assimon/luuu/model/dao"
@@ -19,6 +20,10 @@ func normalizeLockAmount(amount float64) (int64, string) {
return value.Shift(2).IntPart(), value.StringFixed(2) return value.Shift(2).IntPart(), value.StringFixed(2)
} }
func normalizeLockToken(token string) string {
return strings.ToUpper(strings.TrimSpace(token))
}
// GetOrderInfoByOrderId fetches an order by merchant order id. // GetOrderInfoByOrderId fetches an order by merchant order id.
func GetOrderInfoByOrderId(orderId string) (*mdb.Orders, error) { func GetOrderInfoByOrderId(orderId string) (*mdb.Orders, error) {
order := new(mdb.Orders) order := new(mdb.Orders)
@@ -39,9 +44,9 @@ func CreateOrderWithTransaction(tx *gorm.DB, order *mdb.Orders) error {
} }
// GetOrderByBlockIdWithTransaction fetches an order by blockchain tx id. // GetOrderByBlockIdWithTransaction fetches an order by blockchain tx id.
func GetOrderByBlockIdWithTransaction(tx *gorm.DB, blockId string) (*mdb.Orders, error) { func GetOrderByBlockIdWithTransaction(tx *gorm.DB, blockID string) (*mdb.Orders, error) {
order := new(mdb.Orders) order := new(mdb.Orders)
err := tx.Model(order).Limit(1).Find(order, "block_transaction_id = ?", blockId).Error err := tx.Model(order).Limit(1).Find(order, "block_transaction_id = ?", blockID).Error
return order, err return order, err
} }
@@ -94,12 +99,13 @@ func UpdateOrderIsExpirationById(id uint64, expirationCutoff time.Time) (bool, e
return result.RowsAffected > 0, result.Error return result.RowsAffected > 0, result.Error
} }
// GetTradeIdByWalletAddressAndAmount resolves the reserved trade id by token and amount. // GetTradeIdByWalletAddressAndAmountAndToken resolves the reserved trade id by address, token and amount.
func GetTradeIdByWalletAddressAndAmount(token string, amount float64) (string, error) { func GetTradeIdByWalletAddressAndAmountAndToken(address string, token string, amount float64) (string, error) {
scaledAmount, _ := normalizeLockAmount(amount) scaledAmount, _ := normalizeLockAmount(amount)
var lock mdb.TransactionLock var lock mdb.TransactionLock
err := dao.RuntimeDB.Model(&mdb.TransactionLock{}). err := dao.RuntimeDB.Model(&mdb.TransactionLock{}).
Where("token = ?", token). Where("address = ?", address).
Where("token = ?", normalizeLockToken(token)).
Where("amount_scaled = ?", scaledAmount). Where("amount_scaled = ?", scaledAmount).
Where("expires_at > ?", time.Now()). Where("expires_at > ?", time.Now()).
Limit(1). Limit(1).
@@ -113,26 +119,29 @@ func GetTradeIdByWalletAddressAndAmount(token string, amount float64) (string, e
return lock.TradeId, nil return lock.TradeId, nil
} }
// LockTransaction reserves a token+amount pair in sqlite until expiration. // LockTransaction reserves an address+token+amount pair in sqlite until expiration.
func LockTransaction(token, tradeId string, amount float64, expirationTime time.Duration) error { func LockTransaction(address, token, tradeID string, amount float64, expirationTime time.Duration) error {
scaledAmount, amountText := normalizeLockAmount(amount) scaledAmount, amountText := normalizeLockAmount(amount)
normalizedToken := normalizeLockToken(token)
now := time.Now() now := time.Now()
lock := &mdb.TransactionLock{ lock := &mdb.TransactionLock{
Token: token, Address: address,
Token: normalizedToken,
AmountScaled: scaledAmount, AmountScaled: scaledAmount,
AmountText: amountText, AmountText: amountText,
TradeId: tradeId, TradeId: tradeID,
ExpiresAt: now.Add(expirationTime), ExpiresAt: now.Add(expirationTime),
} }
return dao.RuntimeDB.Transaction(func(tx *gorm.DB) error { return dao.RuntimeDB.Transaction(func(tx *gorm.DB) error {
if err := tx.Where("token = ?", token). if err := tx.Where("address = ?", address).
Where("token = ?", normalizedToken).
Where("amount_scaled = ?", scaledAmount). Where("amount_scaled = ?", scaledAmount).
Where("expires_at <= ?", now). Where("expires_at <= ?", now).
Delete(&mdb.TransactionLock{}).Error; err != nil { Delete(&mdb.TransactionLock{}).Error; err != nil {
return err return err
} }
if err := tx.Where("trade_id = ?", tradeId).Delete(&mdb.TransactionLock{}).Error; err != nil { if err := tx.Where("trade_id = ?", tradeID).Delete(&mdb.TransactionLock{}).Error; err != nil {
return err return err
} }
@@ -147,14 +156,18 @@ func LockTransaction(token, tradeId string, amount float64, expirationTime time.
}) })
} }
// UnLockTransaction releases the reservation for token+amount. // UnLockTransaction releases the reservation for address+token+amount.
func UnLockTransaction(token string, amount float64) error { func UnLockTransaction(address string, token string, amount float64) error {
scaledAmount, _ := normalizeLockAmount(amount) scaledAmount, _ := normalizeLockAmount(amount)
return dao.RuntimeDB.Where("token = ?", token).Where("amount_scaled = ?", scaledAmount).Delete(&mdb.TransactionLock{}).Error return dao.RuntimeDB.
Where("address = ?", address).
Where("token = ?", normalizeLockToken(token)).
Where("amount_scaled = ?", scaledAmount).
Delete(&mdb.TransactionLock{}).Error
} }
func UnLockTransactionByTradeId(tradeId string) error { func UnLockTransactionByTradeId(tradeID string) error {
return dao.RuntimeDB.Where("trade_id = ?", tradeId).Delete(&mdb.TransactionLock{}).Error return dao.RuntimeDB.Where("trade_id = ?", tradeID).Delete(&mdb.TransactionLock{}).Error
} }
func CleanupExpiredTransactionLocks() error { func CleanupExpiredTransactionLocks() error {
+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
} }
+2
View File
@@ -13,7 +13,9 @@ type Orders struct {
OrderId string `gorm:"column:order_id;uniqueIndex:orders_order_id_uindex" json:"order_id"` OrderId string `gorm:"column:order_id;uniqueIndex:orders_order_id_uindex" json:"order_id"`
BlockTransactionId string `gorm:"index:orders_block_transaction_id_index;column:block_transaction_id" json:"block_transaction_id"` BlockTransactionId string `gorm:"index:orders_block_transaction_id_index;column:block_transaction_id" json:"block_transaction_id"`
Amount float64 `gorm:"column:amount" json:"amount"` Amount float64 `gorm:"column:amount" json:"amount"`
Currency string `gorm:"column:currency" json:"currency"`
ActualAmount float64 `gorm:"column:actual_amount" json:"actual_amount"` ActualAmount float64 `gorm:"column:actual_amount" json:"actual_amount"`
ReceiveAddress string `gorm:"column:receive_address" json:"receive_address"`
Token string `gorm:"column:token" json:"token"` Token string `gorm:"column:token" json:"token"`
Status int `gorm:"column:status;default:1" json:"status"` Status int `gorm:"column:status;default:1" json:"status"`
NotifyUrl string `gorm:"column:notify_url" json:"notify_url"` NotifyUrl string `gorm:"column:notify_url" json:"notify_url"`
+3 -2
View File
@@ -4,8 +4,9 @@ import "time"
type TransactionLock struct { type TransactionLock struct {
ID uint64 `gorm:"column:id;primaryKey;autoIncrement" json:"id"` ID uint64 `gorm:"column:id;primaryKey;autoIncrement" json:"id"`
Token string `gorm:"column:token;uniqueIndex:transaction_lock_token_amount_uindex,priority:1" json:"token"` Address string `gorm:"column:address;uniqueIndex:transaction_lock_address_token_amount_uindex,priority:1" json:"address"`
AmountScaled int64 `gorm:"column:amount_scaled;uniqueIndex:transaction_lock_token_amount_uindex,priority:2" json:"amount_scaled"` Token string `gorm:"column:token;uniqueIndex:transaction_lock_address_token_amount_uindex,priority:2" json:"token"`
AmountScaled int64 `gorm:"column:amount_scaled;uniqueIndex:transaction_lock_address_token_amount_uindex,priority:3" json:"amount_scaled"`
AmountText string `gorm:"column:amount_text" json:"amount_text"` AmountText string `gorm:"column:amount_text" json:"amount_text"`
TradeId string `gorm:"column:trade_id;index:transaction_lock_trade_id_index" json:"trade_id"` TradeId string `gorm:"column:trade_id;index:transaction_lock_trade_id_index" json:"trade_id"`
ExpiresAt time.Time `gorm:"column:expires_at;index:transaction_lock_expires_at_index" json:"expires_at"` ExpiresAt time.Time `gorm:"column:expires_at;index:transaction_lock_expires_at_index" json:"expires_at"`
+2 -2
View File
@@ -6,8 +6,8 @@ const (
) )
type WalletAddress struct { type WalletAddress struct {
Token string `gorm:"column:token;uniqueIndex:wallet_address_token_uindex" json:"token"` Address string `gorm:"column:address;uniqueIndex:wallet_address_address_uindex" json:"address"`
Status int64 `gorm:"column:status;default:1" json:"status"` Status int64 `gorm:"column:status;default:1" json:"status"`
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"`
} }
+38 -27
View File
@@ -4,6 +4,7 @@ import (
"errors" "errors"
"fmt" "fmt"
"math/rand" "math/rand"
"strings"
"sync" "sync"
"time" "time"
@@ -35,14 +36,20 @@ func CreateTransaction(req *request.CreateTransactionRequest) (*response.CreateT
gCreateTransactionLock.Lock() gCreateTransactionLock.Lock()
defer gCreateTransactionLock.Unlock() defer gCreateTransactionLock.Unlock()
token := strings.ToUpper(strings.TrimSpace(req.Token))
currency := strings.ToUpper(strings.TrimSpace(req.Currency))
payAmount := math.MustParsePrecFloat64(req.Amount, 2) payAmount := math.MustParsePrecFloat64(req.Amount, 2)
rate := config.GetRateForCoin(strings.ToLower(token), strings.ToLower(currency))
if rate <= 0 {
return nil, constant.RateAmountErr
}
decimalPayAmount := decimal.NewFromFloat(payAmount) decimalPayAmount := decimal.NewFromFloat(payAmount)
decimalRate := decimal.NewFromFloat(config.GetUsdtRate()) decimalTokenAmount := decimalPayAmount.Mul(decimal.NewFromFloat(rate))
decimalUsdt := decimalPayAmount.Div(decimalRate)
if decimalPayAmount.Cmp(decimal.NewFromFloat(CnyMinimumPaymentAmount)) == -1 { if decimalPayAmount.Cmp(decimal.NewFromFloat(CnyMinimumPaymentAmount)) == -1 {
return nil, constant.PayAmountErr return nil, constant.PayAmountErr
} }
if decimalUsdt.Cmp(decimal.NewFromFloat(UsdtMinimumPaymentAmount)) == -1 { if decimalTokenAmount.Cmp(decimal.NewFromFloat(UsdtMinimumPaymentAmount)) == -1 {
return nil, constant.PayAmountErr return nil, constant.PayAmountErr
} }
@@ -62,35 +69,37 @@ func CreateTransaction(req *request.CreateTransactionRequest) (*response.CreateT
return nil, constant.NotAvailableWalletAddress return nil, constant.NotAvailableWalletAddress
} }
tradeId := GenerateCode() tradeID := GenerateCode()
amount := math.MustParsePrecFloat64(decimalUsdt.InexactFloat64(), 2) amount := math.MustParsePrecFloat64(decimalTokenAmount.InexactFloat64(), 2)
availableToken, availableAmount, err := ReserveAvailableWalletAndAmount(tradeId, amount, walletAddress) availableAddress, availableAmount, err := ReserveAvailableWalletAndAmount(tradeID, token, amount, 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: tradeId, TradeId: tradeID,
OrderId: req.OrderId, OrderId: req.OrderId,
Amount: req.Amount, Amount: req.Amount,
ActualAmount: availableAmount, Currency: currency,
Token: availableToken, ActualAmount: availableAmount,
Status: mdb.StatusWaitPay, ReceiveAddress: availableAddress,
NotifyUrl: req.NotifyUrl, Token: token,
RedirectUrl: req.RedirectUrl, Status: mdb.StatusWaitPay,
NotifyUrl: req.NotifyUrl,
RedirectUrl: req.RedirectUrl,
} }
if err = data.CreateOrderWithTransaction(tx, order); err != nil { if err = data.CreateOrderWithTransaction(tx, order); err != nil {
tx.Rollback() tx.Rollback()
_ = data.UnLockTransaction(availableToken, availableAmount) _ = data.UnLockTransactionByTradeId(tradeID)
return nil, err return nil, err
} }
if err = tx.Commit().Error; err != nil { if err = tx.Commit().Error; err != nil {
tx.Rollback() tx.Rollback()
_ = data.UnLockTransaction(availableToken, availableAmount) _ = data.UnLockTransactionByTradeId(tradeID)
return nil, err return nil, err
} }
@@ -99,7 +108,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),
@@ -137,22 +148,22 @@ func OrderProcessing(req *request.OrderProcessingRequest) error {
return err return err
} }
if err = data.UnLockTransaction(req.Token, req.Amount); err != nil { if err = data.UnLockTransaction(req.ReceiveAddress, req.Token, req.Amount); err != nil {
log.Sugar.Warnf("[order] unlock transaction after pay success failed, trade_id=%s, err=%v", req.TradeId, err) log.Sugar.Warnf("[order] unlock transaction after pay success failed, trade_id=%s, err=%v", req.TradeId, err)
} }
return nil return nil
} }
// ReserveAvailableWalletAndAmount finds and locks a token+amount pair. // ReserveAvailableWalletAndAmount finds and locks an address+token+amount pair.
func ReserveAvailableWalletAndAmount(tradeId string, amount float64, walletAddress []mdb.WalletAddress) (string, float64, error) { func ReserveAvailableWalletAndAmount(tradeID string, token string, amount float64, walletAddress []mdb.WalletAddress) (string, float64, error) {
availableToken := "" availableAddress := ""
availableAmount := amount availableAmount := amount
tryLockWalletFunc := func(targetAmount float64) (string, error) { tryLockWalletFunc := func(targetAmount float64) (string, error) {
for _, address := range walletAddress { for _, address := range walletAddress {
err := data.LockTransaction(address.Token, tradeId, targetAmount, config.GetOrderExpirationTimeDuration()) err := data.LockTransaction(address.Address, token, tradeID, targetAmount, config.GetOrderExpirationTimeDuration())
if err == nil { if err == nil {
return address.Token, nil return address.Address, nil
} }
if errors.Is(err, data.ErrTransactionLocked) { if errors.Is(err, data.ErrTransactionLocked) {
continue continue
@@ -163,20 +174,20 @@ func ReserveAvailableWalletAndAmount(tradeId string, amount float64, walletAddre
} }
for i := 0; i < IncrementalMaximumNumber; i++ { for i := 0; i < IncrementalMaximumNumber; i++ {
token, err := tryLockWalletFunc(availableAmount) address, err := tryLockWalletFunc(availableAmount)
if err != nil { if err != nil {
return "", 0, err return "", 0, err
} }
if token == "" { if address == "" {
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 = address
break break
} }
return availableToken, availableAmount, nil return availableAddress, availableAmount, nil
} }
// GenerateCode creates a unique trade id. // GenerateCode creates a unique trade id.
+34 -47
View File
@@ -13,6 +13,16 @@ import (
"github.com/assimon/luuu/util/constant" "github.com/assimon/luuu/util/constant"
) )
func newCreateTransactionRequest(orderID string, amount float64) *request.CreateTransactionRequest {
return &request.CreateTransactionRequest{
OrderId: orderID,
Currency: "CNY",
Token: "USDT",
Amount: amount,
NotifyUrl: "https://merchant.example/callback",
}
}
func TestCreateTransactionAssignsIncrementedAmountsAndLocks(t *testing.T) { func TestCreateTransactionAssignsIncrementedAmountsAndLocks(t *testing.T) {
cleanup := testutil.SetupTestDatabases(t) cleanup := testutil.SetupTestDatabases(t)
defer cleanup() defer cleanup()
@@ -21,22 +31,11 @@ func TestCreateTransactionAssignsIncrementedAmountsAndLocks(t *testing.T) {
t.Fatalf("add wallet: %v", err) t.Fatalf("add wallet: %v", err)
} }
req1 := &request.CreateTransactionRequest{ resp1, err := CreateTransaction(newCreateTransactionRequest("order_1", 1))
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 { if err != nil {
t.Fatalf("create first transaction: %v", err) t.Fatalf("create first transaction: %v", err)
} }
resp2, err := CreateTransaction(req2) resp2, err := CreateTransaction(newCreateTransactionRequest("order_2", 1))
if err != nil { if err != nil {
t.Fatalf("create second transaction: %v", err) t.Fatalf("create second transaction: %v", err)
} }
@@ -47,11 +46,14 @@ func TestCreateTransactionAssignsIncrementedAmountsAndLocks(t *testing.T) {
if got := fmt.Sprintf("%.2f", resp2.ActualAmount); got != "1.01" { if got := fmt.Sprintf("%.2f", resp2.ActualAmount); got != "1.01" {
t.Fatalf("second actual amount = %s, want 1.01", got) t.Fatalf("second actual amount = %s, want 1.01", got)
} }
if resp1.Token != "wallet_1" || resp2.Token != "wallet_1" { if resp1.ReceiveAddress != "wallet_1" || resp2.ReceiveAddress != "wallet_1" {
t.Fatalf("unexpected wallet tokens: %s, %s", resp1.Token, resp2.Token) t.Fatalf("unexpected receive addresses: %s, %s", resp1.ReceiveAddress, resp2.ReceiveAddress)
}
if resp1.Token != "USDT" || resp2.Token != "USDT" {
t.Fatalf("unexpected tokens: %s, %s", resp1.Token, resp2.Token)
} }
tradeID1, err := data.GetTradeIdByWalletAddressAndAmount(resp1.Token, resp1.ActualAmount) tradeID1, err := data.GetTradeIdByWalletAddressAndAmountAndToken(resp1.ReceiveAddress, resp1.Token, resp1.ActualAmount)
if err != nil { if err != nil {
t.Fatalf("get first runtime lock: %v", err) t.Fatalf("get first runtime lock: %v", err)
} }
@@ -59,7 +61,7 @@ func TestCreateTransactionAssignsIncrementedAmountsAndLocks(t *testing.T) {
t.Fatalf("first runtime lock = %s, want %s", tradeID1, resp1.TradeId) t.Fatalf("first runtime lock = %s, want %s", tradeID1, resp1.TradeId)
} }
tradeID2, err := data.GetTradeIdByWalletAddressAndAmount(resp2.Token, resp2.ActualAmount) tradeID2, err := data.GetTradeIdByWalletAddressAndAmountAndToken(resp2.ReceiveAddress, resp2.Token, resp2.ActualAmount)
if err != nil { if err != nil {
t.Fatalf("get second runtime lock: %v", err) t.Fatalf("get second runtime lock: %v", err)
} }
@@ -76,16 +78,13 @@ func TestOrderProcessingMarksPaidAndReleasesLock(t *testing.T) {
t.Fatalf("add wallet: %v", err) t.Fatalf("add wallet: %v", err)
} }
resp, err := CreateTransaction(&request.CreateTransactionRequest{ resp, err := CreateTransaction(newCreateTransactionRequest("order_1", 1))
OrderId: "order_1",
Amount: 1,
NotifyUrl: "https://merchant.example/callback",
})
if err != nil { if err != nil {
t.Fatalf("create transaction: %v", err) t.Fatalf("create transaction: %v", err)
} }
err = OrderProcessing(&request.OrderProcessingRequest{ err = OrderProcessing(&request.OrderProcessingRequest{
ReceiveAddress: resp.ReceiveAddress,
Token: resp.Token, Token: resp.Token,
TradeId: resp.TradeId, TradeId: resp.TradeId,
Amount: resp.ActualAmount, Amount: resp.ActualAmount,
@@ -109,7 +108,7 @@ func TestOrderProcessingMarksPaidAndReleasesLock(t *testing.T) {
t.Fatalf("block transaction id = %s, want block_1", order.BlockTransactionId) t.Fatalf("block transaction id = %s, want block_1", order.BlockTransactionId)
} }
tradeID, err := data.GetTradeIdByWalletAddressAndAmount(resp.Token, resp.ActualAmount) tradeID, err := data.GetTradeIdByWalletAddressAndAmountAndToken(resp.ReceiveAddress, resp.Token, resp.ActualAmount)
if err != nil { if err != nil {
t.Fatalf("get runtime lock after processing: %v", err) t.Fatalf("get runtime lock after processing: %v", err)
} }
@@ -126,16 +125,13 @@ func TestOrderProcessingRejectsDuplicateBlockForSameOrder(t *testing.T) {
t.Fatalf("add wallet: %v", err) t.Fatalf("add wallet: %v", err)
} }
resp, err := CreateTransaction(&request.CreateTransactionRequest{ resp, err := CreateTransaction(newCreateTransactionRequest("order_1", 1))
OrderId: "order_1",
Amount: 1,
NotifyUrl: "https://merchant.example/callback",
})
if err != nil { if err != nil {
t.Fatalf("create transaction: %v", err) t.Fatalf("create transaction: %v", err)
} }
req := &request.OrderProcessingRequest{ req := &request.OrderProcessingRequest{
ReceiveAddress: resp.ReceiveAddress,
Token: resp.Token, Token: resp.Token,
TradeId: resp.TradeId, TradeId: resp.TradeId,
Amount: resp.ActualAmount, Amount: resp.ActualAmount,
@@ -170,11 +166,7 @@ func TestOrderProcessingDoesNotReviveExpiredOrder(t *testing.T) {
t.Fatalf("add wallet: %v", err) t.Fatalf("add wallet: %v", err)
} }
resp, err := CreateTransaction(&request.CreateTransactionRequest{ resp, err := CreateTransaction(newCreateTransactionRequest("order_1", 1))
OrderId: "order_1",
Amount: 1,
NotifyUrl: "https://merchant.example/callback",
})
if err != nil { if err != nil {
t.Fatalf("create transaction: %v", err) t.Fatalf("create transaction: %v", err)
} }
@@ -186,6 +178,7 @@ func TestOrderProcessingDoesNotReviveExpiredOrder(t *testing.T) {
} }
err = OrderProcessing(&request.OrderProcessingRequest{ err = OrderProcessing(&request.OrderProcessingRequest{
ReceiveAddress: resp.ReceiveAddress,
Token: resp.Token, Token: resp.Token,
TradeId: resp.TradeId, TradeId: resp.TradeId,
Amount: resp.ActualAmount, Amount: resp.ActualAmount,
@@ -218,19 +211,11 @@ func TestOrderProcessingOnlyOneOrderClaimsABlockTransaction(t *testing.T) {
t.Fatalf("add wallet: %v", err) t.Fatalf("add wallet: %v", err)
} }
resp1, err := CreateTransaction(&request.CreateTransactionRequest{ resp1, err := CreateTransaction(newCreateTransactionRequest("order_1", 1))
OrderId: "order_1",
Amount: 1,
NotifyUrl: "https://merchant.example/callback",
})
if err != nil { if err != nil {
t.Fatalf("create first transaction: %v", err) t.Fatalf("create first transaction: %v", err)
} }
resp2, err := CreateTransaction(&request.CreateTransactionRequest{ resp2, err := CreateTransaction(newCreateTransactionRequest("order_2", 2))
OrderId: "order_2",
Amount: 2,
NotifyUrl: "https://merchant.example/callback",
})
if err != nil { if err != nil {
t.Fatalf("create second transaction: %v", err) t.Fatalf("create second transaction: %v", err)
} }
@@ -239,24 +224,26 @@ func TestOrderProcessingOnlyOneOrderClaimsABlockTransaction(t *testing.T) {
errs := make(chan error, 2) errs := make(chan error, 2)
var wg sync.WaitGroup var wg sync.WaitGroup
for _, tc := range []struct { for _, tc := range []struct {
address string
token string token string
tradeID string tradeID string
amount float64 amount float64
}{ }{
{token: resp1.Token, tradeID: resp1.TradeId, amount: resp1.ActualAmount}, {address: resp1.ReceiveAddress, token: resp1.Token, tradeID: resp1.TradeId, amount: resp1.ActualAmount},
{token: resp2.Token, tradeID: resp2.TradeId, amount: resp2.ActualAmount}, {address: resp2.ReceiveAddress, token: resp2.Token, tradeID: resp2.TradeId, amount: resp2.ActualAmount},
} { } {
wg.Add(1) wg.Add(1)
go func(token, tradeID string, amount float64) { go func(address, token, tradeID string, amount float64) {
defer wg.Done() defer wg.Done()
<-start <-start
errs <- OrderProcessing(&request.OrderProcessingRequest{ errs <- OrderProcessing(&request.OrderProcessingRequest{
ReceiveAddress: address,
Token: token, Token: token,
TradeId: tradeID, TradeId: tradeID,
Amount: amount, Amount: amount,
BlockTransactionId: "shared_block", BlockTransactionId: "shared_block",
}) })
}(tc.token, tc.tradeID, tc.amount) }(tc.address, tc.token, tc.tradeID, tc.amount)
} }
close(start) close(start)
+4 -2
View File
@@ -9,18 +9,20 @@ import (
"github.com/assimon/luuu/model/response" "github.com/assimon/luuu/model/response"
) )
// GetCheckoutCounterByTradeId 获取收银台详情,通过订单 // GetCheckoutCounterByTradeId returns checkout info for a pending order.
func GetCheckoutCounterByTradeId(tradeId string) (*response.CheckoutCounterResponse, error) { func GetCheckoutCounterByTradeId(tradeId string) (*response.CheckoutCounterResponse, error) {
orderInfo, err := data.GetOrderInfoByTradeId(tradeId) orderInfo, err := data.GetOrderInfoByTradeId(tradeId)
if err != nil { if err != nil {
return nil, err return nil, err
} }
if orderInfo.ID <= 0 || orderInfo.Status != mdb.StatusWaitPay { if orderInfo.ID <= 0 || orderInfo.Status != mdb.StatusWaitPay {
return nil, errors.New("不存在待支付订单或已过期!") return nil, errors.New("pending order does not exist or has expired")
} }
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,
+187 -92
View File
@@ -1,67 +1,32 @@
package service package service
import ( import (
"encoding/hex"
"errors" "errors"
"fmt" "fmt"
"net/http" "net/http"
"strings"
"sync" "sync"
tron "github.com/assimon/luuu/crypto"
"github.com/assimon/luuu/config"
"github.com/assimon/luuu/model/data" "github.com/assimon/luuu/model/data"
"github.com/assimon/luuu/model/mdb"
"github.com/assimon/luuu/model/request" "github.com/assimon/luuu/model/request"
"github.com/assimon/luuu/telegram" "github.com/assimon/luuu/telegram"
"github.com/assimon/luuu/util/constant" "github.com/assimon/luuu/util/constant"
"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/shopspring/decimal" "github.com/shopspring/decimal"
"github.com/tidwall/gjson"
) )
const UsdtTrc20ApiUri = "https://apilist.tronscanapi.com/api/transfer/trc20" const TRC20_USDT_ID = "TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6t"
type UsdtTrc20Resp struct { func Trc20CallBack(address string, wg *sync.WaitGroup) {
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 polls transfers for one wallet and matches them to active orders.
func Trc20CallBack(token string, wg *sync.WaitGroup) {
defer wg.Done() defer wg.Done()
defer func() { defer func() {
if err := recover(); err != nil { if err := recover(); err != nil {
@@ -69,88 +34,218 @@ func Trc20CallBack(token string, wg *sync.WaitGroup) {
} }
}() }()
var innerWg sync.WaitGroup
innerWg.Add(2)
go checkTrxTransfers(address, &innerWg)
go checkTrc20Transfers(address, &innerWg)
innerWg.Wait()
}
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)
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_to": "true",
"direction": "2", "min_timestamp": stdutil.ToString(startTime),
"db_version": "1", "max_timestamp": stdutil.ToString(endTime),
"trc20Id": "TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6t", }).SetHeader("TRON-PRO-API-KEY", config.TRON_GRID_API_KEY).Get(url)
"address": token,
"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 success := gjson.GetBytes(resp.Body(), "success").Bool()
if err = json.Cjson.Unmarshal(resp.Body(), &trc20Resp); err != nil { if !success {
panic(err) panic("TRX API response indicates failure")
}
if trc20Resp.PageSize <= 0 {
return
} }
for _, transfer := range trc20Resp.Data { for i, transfer := range gjson.GetBytes(resp.Body(), "data").Array() {
if transfer.To != token || transfer.ContractRet != "SUCCESS" { if transfer.Get("raw_data.contract.0.type").String() != "TransferContract" {
continue
}
if transfer.Get("ret.0.contractRet").String() != "SUCCESS" {
continue continue
} }
decimalQuant, err := decimal.NewFromString(transfer.Amount) toAddressHex := transfer.Get("raw_data.contract.0.parameter.value.to_address").String()
toBytes, err := hex.DecodeString(toAddressHex)
if err != nil { if err != nil {
panic(err) log.Sugar.Errorf("[TRX][%s] decode address failed on tx #%d: %v", address, i, err)
continue
} }
decimalDivisor := decimal.NewFromFloat(1000000) if tron.EncodeCheck(toBytes) != address {
amount := decimalQuant.Div(decimalDivisor).InexactFloat64()
tradeId, err := data.GetTradeIdByWalletAddressAndAmount(token, amount)
if err != nil {
panic(err)
}
if tradeId == "" {
continue continue
} }
order, err := data.GetOrderInfoByTradeId(tradeId) rawAmount := transfer.Get("raw_data.contract.0.parameter.value.amount").String()
decimalQuant, err := decimal.NewFromString(rawAmount)
if err != nil {
log.Sugar.Errorf("[TRX][%s] parse amount failed on tx #%d: %v", address, i, err)
continue
}
amount := math.MustParsePrecFloat64(decimalQuant.Div(decimal.NewFromInt(1000000)).InexactFloat64(), 2)
if amount <= 0 {
continue
}
txID := transfer.Get("txID").String()
tradeID, err := data.GetTradeIdByWalletAddressAndAmountAndToken(address, "TRX", amount)
if err != nil { if err != nil {
panic(err) panic(err)
} }
if tradeID == "" {
continue
}
order, err := data.GetOrderInfoByTradeId(tradeID)
if err != nil {
panic(err)
}
blockTimestamp := transfer.Get("block_timestamp").Int()
createTime := order.CreatedAt.TimestampMilli() createTime := order.CreatedAt.TimestampMilli()
if transfer.BlockTimestamp < createTime { if blockTimestamp < createTime {
panic("orders cannot actually be matched") log.Sugar.Warnf("[TRX][%s] skip tx %s because block time %d is before order create time %d", address, txID, blockTimestamp, createTime)
continue
} }
req := &request.OrderProcessingRequest{ req := &request.OrderProcessingRequest{
Token: token, ReceiveAddress: address,
TradeId: tradeId, Token: "TRX",
TradeId: tradeID,
Amount: amount, Amount: amount,
BlockTransactionId: transfer.Hash, BlockTransactionId: txID,
} }
if err = OrderProcessing(req); err != nil { err = OrderProcessing(req)
if err != nil {
if errors.Is(err, constant.OrderBlockAlreadyProcess) || errors.Is(err, constant.OrderStatusConflict) { 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) log.Sugar.Infof("[TRX][%s] skip resolved transfer trade_id=%s hash=%s err=%v", address, tradeID, txID, err)
continue continue
} }
panic(err) panic(err)
} }
msgTpl := ` sendPaymentNotification(order)
<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)
} }
} }
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)
resp, err := client.R().SetQueryParams(map[string]string{
"order_by": "block_timestamp,desc",
"limit": "100",
"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()))
}
success := gjson.GetBytes(resp.Body(), "success").Bool()
if !success {
panic("TRC20 API response indicates failure")
}
for i, transfer := range gjson.GetBytes(resp.Body(), "data").Array() {
if transfer.Get("token_info.address").String() != TRC20_USDT_ID {
continue
}
if transfer.Get("to").String() != address {
continue
}
valueStr := transfer.Get("value").String()
decimalQuant, err := decimal.NewFromString(valueStr)
if err != nil {
log.Sugar.Errorf("[TRC20][%s] parse value failed on tx #%d: %v", address, i, err)
continue
}
tokenDecimals := transfer.Get("token_info.decimals").Int()
amount := math.MustParsePrecFloat64(decimalQuant.Div(decimal.New(1, int32(tokenDecimals))).InexactFloat64(), 2)
if amount <= 0 {
continue
}
txID := transfer.Get("transaction_id").String()
tradeID, err := data.GetTradeIdByWalletAddressAndAmountAndToken(address, "USDT", amount)
if err != nil {
panic(err)
}
if tradeID == "" {
continue
}
order, err := data.GetOrderInfoByTradeId(tradeID)
if err != nil {
panic(err)
}
blockTimestamp := transfer.Get("block_timestamp").Int()
createTime := order.CreatedAt.TimestampMilli()
if blockTimestamp < createTime {
log.Sugar.Warnf("[TRC20][%s] skip tx %s because block time %d is before order create time %d", address, txID, blockTimestamp, createTime)
continue
}
req := &request.OrderProcessingRequest{
ReceiveAddress: address,
Token: "USDT",
TradeId: tradeID,
Amount: amount,
BlockTransactionId: txID,
}
err = OrderProcessing(req)
if err != nil {
if errors.Is(err, constant.OrderBlockAlreadyProcess) || errors.Is(err, constant.OrderStatusConflict) {
log.Sugar.Infof("[TRC20][%s] skip resolved transfer trade_id=%s hash=%s err=%v", address, tradeID, txID, err)
continue
}
panic(err)
}
sendPaymentNotification(order)
}
}
func sendPaymentNotification(order *mdb.Orders) {
msg := fmt.Sprintf(
"Payment received\nTrade ID: %s\nOrder ID: %s\nOrder Amount: %.2f %s\nReceived Amount: %.2f %s\nAddress: %s\nCreated At: %s\nPaid At: %s",
order.TradeId,
order.OrderId,
order.Amount,
strings.ToUpper(order.Currency),
order.ActualAmount,
strings.ToUpper(order.Token),
order.ReceiveAddress,
order.CreatedAt.ToDateTimeString(),
carbon.Now().ToDateTimeString(),
)
telegram.SendToBot(msg)
}
+2 -1
View File
@@ -75,7 +75,7 @@ func processExpiredOrders() {
if !expired { if !expired {
continue continue
} }
if err = data.UnLockTransaction(order.Token, order.ActualAmount); err != nil { if err = data.UnLockTransaction(order.ReceiveAddress, order.Token, order.ActualAmount); err != nil {
log.Sugar.Warnf("[mq] release expired transaction lock failed, trade_id=%s, err=%v", order.TradeId, err) log.Sugar.Warnf("[mq] release expired transaction lock failed, trade_id=%s, err=%v", order.TradeId, err)
} }
} }
@@ -147,6 +147,7 @@ func sendOrderCallback(order *mdb.Orders) 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,
+28 -20
View File
@@ -20,13 +20,15 @@ func TestProcessExpiredOrdersExpiresWaitingOrdersAndReleasesLocks(t *testing.T)
defer cleanup() defer cleanup()
order := &mdb.Orders{ order := &mdb.Orders{
TradeId: "trade_expired", TradeId: "trade_expired",
OrderId: "order_expired", OrderId: "order_expired",
Amount: 1, Amount: 1,
ActualAmount: 1, Currency: "CNY",
Token: "wallet_1", ActualAmount: 1,
Status: mdb.StatusWaitPay, ReceiveAddress: "wallet_1",
NotifyUrl: "https://merchant.example/callback", Token: "USDT",
Status: mdb.StatusWaitPay,
NotifyUrl: "https://merchant.example/callback",
} }
if err := dao.Mdb.Create(order).Error; err != nil { if err := dao.Mdb.Create(order).Error; err != nil {
t.Fatalf("create expired order: %v", err) t.Fatalf("create expired order: %v", err)
@@ -34,23 +36,25 @@ func TestProcessExpiredOrdersExpiresWaitingOrdersAndReleasesLocks(t *testing.T)
if err := dao.Mdb.Model(order).UpdateColumn("created_at", time.Now().Add(-20*time.Minute)).Error; err != nil { if err := dao.Mdb.Model(order).UpdateColumn("created_at", time.Now().Add(-20*time.Minute)).Error; err != nil {
t.Fatalf("age expired order: %v", err) t.Fatalf("age expired order: %v", err)
} }
if err := data.LockTransaction(order.Token, order.TradeId, order.ActualAmount, time.Hour); err != nil { if err := data.LockTransaction(order.ReceiveAddress, order.Token, order.TradeId, order.ActualAmount, time.Hour); err != nil {
t.Fatalf("lock expired order: %v", err) t.Fatalf("lock expired order: %v", err)
} }
recentOrder := &mdb.Orders{ recentOrder := &mdb.Orders{
TradeId: "trade_recent", TradeId: "trade_recent",
OrderId: "order_recent", OrderId: "order_recent",
Amount: 1, Amount: 1,
ActualAmount: 1.01, Currency: "CNY",
Token: "wallet_1", ActualAmount: 1.01,
Status: mdb.StatusWaitPay, ReceiveAddress: "wallet_1",
NotifyUrl: "https://merchant.example/callback", Token: "USDT",
Status: mdb.StatusWaitPay,
NotifyUrl: "https://merchant.example/callback",
} }
if err := dao.Mdb.Create(recentOrder).Error; err != nil { if err := dao.Mdb.Create(recentOrder).Error; err != nil {
t.Fatalf("create recent order: %v", err) t.Fatalf("create recent order: %v", err)
} }
if err := data.LockTransaction(recentOrder.Token, recentOrder.TradeId, recentOrder.ActualAmount, time.Hour); err != nil { if err := data.LockTransaction(recentOrder.ReceiveAddress, recentOrder.Token, recentOrder.TradeId, recentOrder.ActualAmount, time.Hour); err != nil {
t.Fatalf("lock recent order: %v", err) t.Fatalf("lock recent order: %v", err)
} }
@@ -63,7 +67,7 @@ func TestProcessExpiredOrdersExpiresWaitingOrdersAndReleasesLocks(t *testing.T)
if expired.Status != mdb.StatusExpired { if expired.Status != mdb.StatusExpired {
t.Fatalf("expired order status = %d, want %d", expired.Status, mdb.StatusExpired) t.Fatalf("expired order status = %d, want %d", expired.Status, mdb.StatusExpired)
} }
lockTradeID, err := data.GetTradeIdByWalletAddressAndAmount(order.Token, order.ActualAmount) lockTradeID, err := data.GetTradeIdByWalletAddressAndAmountAndToken(order.ReceiveAddress, order.Token, order.ActualAmount)
if err != nil { if err != nil {
t.Fatalf("expired order lock lookup: %v", err) t.Fatalf("expired order lock lookup: %v", err)
} }
@@ -78,7 +82,7 @@ func TestProcessExpiredOrdersExpiresWaitingOrdersAndReleasesLocks(t *testing.T)
if recent.Status != mdb.StatusWaitPay { if recent.Status != mdb.StatusWaitPay {
t.Fatalf("recent order status = %d, want %d", recent.Status, mdb.StatusWaitPay) t.Fatalf("recent order status = %d, want %d", recent.Status, mdb.StatusWaitPay)
} }
lockTradeID, err = data.GetTradeIdByWalletAddressAndAmount(recentOrder.Token, recentOrder.ActualAmount) lockTradeID, err = data.GetTradeIdByWalletAddressAndAmountAndToken(recentOrder.ReceiveAddress, recentOrder.Token, recentOrder.ActualAmount)
if err != nil { if err != nil {
t.Fatalf("recent order lock lookup: %v", err) t.Fatalf("recent order lock lookup: %v", err)
} }
@@ -95,8 +99,10 @@ func TestProcessExpiredOrdersKeepsPaidOrdersPaid(t *testing.T) {
TradeId: "trade_paid", TradeId: "trade_paid",
OrderId: "order_paid", OrderId: "order_paid",
Amount: 1, Amount: 1,
Currency: "CNY",
ActualAmount: 1, ActualAmount: 1,
Token: "wallet_1", ReceiveAddress: "wallet_1",
Token: "USDT",
Status: mdb.StatusPaySuccess, Status: mdb.StatusPaySuccess,
NotifyUrl: "https://merchant.example/callback", NotifyUrl: "https://merchant.example/callback",
BlockTransactionId: "block_paid", BlockTransactionId: "block_paid",
@@ -141,8 +147,10 @@ func TestDispatchPendingCallbacksHonorsBackoffAndPersistsSuccess(t *testing.T) {
TradeId: "trade_callback", TradeId: "trade_callback",
OrderId: "order_callback", OrderId: "order_callback",
Amount: 1, Amount: 1,
Currency: "CNY",
ActualAmount: 1, ActualAmount: 1,
Token: "wallet_1", ReceiveAddress: "wallet_1",
Token: "USDT",
Status: mdb.StatusPaySuccess, Status: mdb.StatusPaySuccess,
NotifyUrl: server.URL, NotifyUrl: server.URL,
BlockTransactionId: "block_callback", BlockTransactionId: "block_callback",
+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)
} }
}() }()
} }