feat: add admin panel with multi-chain support and notification system

- Add full admin REST API: auth, API keys, chains, chain tokens, wallets,
  orders, RPC nodes, settings, dashboard stats, notifications
- Add JWT-based admin authentication and API key auth middleware
- Add multi-chain listeners: BSC, ETH, Polygon, Plasma, EVM WebSocket
- Add RPC node health check job with automatic failover
- Add Telegram notification channel for order events
- Add installer for first-run database initialization
- Add new DB models: admin_user, api_key, chain, chain_token,
  rpc_node, settings, notification_channel
- Add order statistics aggregation (daily/monthly/by-chain)
- Serve built admin SPA from embedded www/ assets
- Add comprehensive test coverage for all new features
This commit is contained in:
line-6000
2026-04-22 02:28:31 +08:00
parent 097c716714
commit 6bb47d4b00
247 changed files with 9933 additions and 1279 deletions
+71 -24
View File
@@ -33,8 +33,28 @@ var (
gOrderProcessingLock sync.Mutex
)
// apiKeyID safely extracts the primary key from an ApiKey row.
// Returns 0 when apiKey is nil (middleware didn't run — shouldn't happen on authed routes).
func apiKeyID(apiKey *mdb.ApiKey) uint64 {
if apiKey == nil {
return 0
}
return apiKey.ID
}
func normalizeOrderAddressByNetwork(network, address string) string {
network = strings.ToLower(strings.TrimSpace(network))
address = strings.TrimSpace(address)
switch network {
case mdb.NetworkEthereum, mdb.NetworkBsc, mdb.NetworkPolygon, mdb.NetworkPlasma:
return strings.ToLower(address)
default:
return address
}
}
// CreateTransaction creates a new payment order.
func CreateTransaction(req *request.CreateTransactionRequest) (*response.CreateTransactionResponse, error) {
func CreateTransaction(req *request.CreateTransactionRequest, apiKey *mdb.ApiKey) (*response.CreateTransactionResponse, error) {
gCreateTransactionLock.Lock()
defer gCreateTransactionLock.Unlock()
@@ -64,6 +84,9 @@ func CreateTransaction(req *request.CreateTransactionRequest) (*response.CreateT
return nil, constant.OrderAlreadyExists
}
if !data.IsChainEnabled(network) {
return nil, constant.ChainNotEnabled
}
walletAddress, err := data.GetAvailableWalletAddressByNetwork(network)
if err != nil {
return nil, err
@@ -97,6 +120,7 @@ func CreateTransaction(req *request.CreateTransactionRequest) (*response.CreateT
RedirectUrl: req.RedirectUrl,
Name: req.Name,
PaymentType: req.PaymentType,
ApiKeyID: apiKeyID(apiKey),
}
if err = data.CreateOrderWithTransaction(tx, order); err != nil {
tx.Rollback()
@@ -161,7 +185,7 @@ func OrderProcessing(req *request.OrderProcessingRequest) error {
// Load order to check parent-child relationship
order, err := data.GetOrderInfoByTradeId(req.TradeId)
if err != nil {
return nil
return fmt.Errorf("load paid order failed, trade_id=%s: %w", req.TradeId, err)
}
// Parent order paid directly: expire all sub-orders and release their locks
@@ -169,7 +193,7 @@ func OrderProcessing(req *request.OrderProcessingRequest) error {
subs, subErr := data.GetActiveSubOrders(order.TradeId)
if subErr != nil {
log.Sugar.Errorf("[order] get sub-orders for parent failed, trade_id=%s, err=%v", order.TradeId, subErr)
return nil
return fmt.Errorf("load sub-orders failed, parent_trade_id=%s: %w", order.TradeId, subErr)
}
for _, sub := range subs {
if err = data.ExpireOrderByTradeId(sub.TradeId); err != nil {
@@ -182,22 +206,48 @@ func OrderProcessing(req *request.OrderProcessingRequest) error {
return nil
}
// Sub-order should not trigger its own callback (notify_url is empty).
// OrderSuccessWithTransaction unconditionally sets callback_confirm=No,
// reset it here to prevent the callback worker from retrying an empty URL.
if err = data.ResetCallbackConfirmOk(order.TradeId); err != nil {
log.Sugar.Warnf("[order] reset sub-order callback_confirm failed, trade_id=%s, err=%v", order.TradeId, err)
}
parent, err := data.GetOrderInfoByTradeId(order.ParentTradeId)
if err != nil {
log.Sugar.Errorf("[order] load parent order failed, parent_trade_id=%s, err=%v", order.ParentTradeId, err)
return nil
return fmt.Errorf("load parent order failed, parent_trade_id=%s: %w", order.ParentTradeId, err)
}
// Snapshot siblings for lock release after DB state transition commits.
siblings, err := data.GetSiblingSubOrders(parent.TradeId, order.TradeId)
if err != nil {
log.Sugar.Errorf("[order] get sibling sub-orders failed, parent_trade_id=%s, err=%v", parent.TradeId, err)
return fmt.Errorf("load sibling sub-orders failed, parent_trade_id=%s: %w", parent.TradeId, err)
}
finalizeTx := dao.Mdb.Begin()
// Mark parent as paid with sub-order's payment details
if _, err = data.MarkParentOrderSuccess(parent.TradeId, order); err != nil {
log.Sugar.Errorf("[order] mark parent success failed, parent_trade_id=%s, err=%v", parent.TradeId, err)
updatedParent, markErr := data.MarkParentOrderSuccessWithTransaction(finalizeTx, parent.TradeId, order)
if markErr != nil {
finalizeTx.Rollback()
log.Sugar.Errorf("[order] mark parent success failed, parent_trade_id=%s, err=%v", parent.TradeId, markErr)
return fmt.Errorf("mark parent success failed, parent_trade_id=%s: %w", parent.TradeId, markErr)
}
if !updatedParent {
finalizeTx.Rollback()
return fmt.Errorf("parent order not updated, trade_id=%s is not in wait-pay status", parent.TradeId)
}
if err = data.ExpireSiblingSubOrdersWithTransaction(finalizeTx, parent.TradeId, order.TradeId); err != nil {
finalizeTx.Rollback()
return fmt.Errorf("expire sibling sub-orders failed, parent_trade_id=%s: %w", parent.TradeId, err)
}
if err = finalizeTx.Commit().Error; err != nil {
finalizeTx.Rollback()
return fmt.Errorf("commit parent finalize tx failed, parent_trade_id=%s: %w", parent.TradeId, err)
}
// Sub-order should not trigger its own callback (notify_url is empty).
// OrderSuccessWithTransaction unconditionally sets callback_confirm=No,
// reset it only after the parent order is successfully finalized.
if err = data.ResetCallbackConfirmOk(order.TradeId); err != nil {
log.Sugar.Warnf("[order] reset sub-order callback_confirm failed, trade_id=%s, err=%v", order.TradeId, err)
}
// Release parent's own wallet lock
@@ -205,16 +255,8 @@ func OrderProcessing(req *request.OrderProcessingRequest) error {
log.Sugar.Warnf("[order] unlock parent transaction failed, parent_trade_id=%s, err=%v", parent.TradeId, err)
}
// Expire sibling sub-orders and release their locks
siblings, err := data.GetSiblingSubOrders(parent.TradeId, order.TradeId)
if err != nil {
log.Sugar.Errorf("[order] get sibling sub-orders failed, parent_trade_id=%s, err=%v", parent.TradeId, err)
return nil
}
// Release sibling locks after their status transitions commit.
for _, sib := range siblings {
if err = data.ExpireOrderByTradeId(sib.TradeId); err != nil {
log.Sugar.Warnf("[order] expire sibling failed, trade_id=%s, err=%v", sib.TradeId, err)
}
if err = data.UnLockTransaction(sib.Network, sib.ReceiveAddress, sib.Token, sib.ActualAmount); err != nil {
log.Sugar.Warnf("[order] unlock sibling transaction failed, trade_id=%s, err=%v", sib.TradeId, err)
}
@@ -230,9 +272,10 @@ func ReserveAvailableWalletAndAmount(tradeID string, network string, token strin
tryLockWalletFunc := func(targetAmount float64) (string, error) {
for _, address := range walletAddress {
err := data.LockTransaction(network, address.Address, token, tradeID, targetAmount, config.GetOrderExpirationTimeDuration())
normalizedAddress := normalizeOrderAddressByNetwork(network, address.Address)
err := data.LockTransaction(network, normalizedAddress, token, tradeID, targetAmount, config.GetOrderExpirationTimeDuration())
if err == nil {
return address.Address, nil
return normalizedAddress, nil
}
if errors.Is(err, data.ErrTransactionLocked) {
continue
@@ -344,6 +387,9 @@ func SwitchNetwork(req *request.SwitchNetworkRequest) (*response.CheckoutCounter
}
// 6. Find and lock wallet
if !data.IsChainEnabled(network) {
return nil, constant.ChainNotEnabled
}
walletAddress, err := data.GetAvailableWalletAddressByNetwork(network)
if err != nil {
return nil, err
@@ -381,6 +427,7 @@ func SwitchNetwork(req *request.SwitchNetworkRequest) (*response.CheckoutCounter
Name: parent.Name,
CallBackConfirm: mdb.CallBackConfirmOk, // don't trigger callback on sub-order
PaymentType: parent.PaymentType,
ApiKeyID: parent.ApiKeyID, // inherit from parent so resolveOrderApiKey never fails
}
if err = data.CreateOrderWithTransaction(tx, subOrder); err != nil {
tx.Rollback()
+333 -7
View File
@@ -2,6 +2,7 @@ package service
import (
"fmt"
"strings"
"sync"
"testing"
@@ -32,11 +33,11 @@ func TestCreateTransactionAssignsIncrementedAmountsAndLocks(t *testing.T) {
t.Fatalf("add wallet: %v", err)
}
resp1, err := CreateTransaction(newCreateTransactionRequest("order_1", 1))
resp1, err := CreateTransaction(newCreateTransactionRequest("order_1", 1), nil)
if err != nil {
t.Fatalf("create first transaction: %v", err)
}
resp2, err := CreateTransaction(newCreateTransactionRequest("order_2", 1))
resp2, err := CreateTransaction(newCreateTransactionRequest("order_2", 1), nil)
if err != nil {
t.Fatalf("create second transaction: %v", err)
}
@@ -71,6 +72,49 @@ func TestCreateTransactionAssignsIncrementedAmountsAndLocks(t *testing.T) {
}
}
func TestCreateTransactionNormalizesEvmReceiveAddressToLowercase(t *testing.T) {
cleanup := testutil.SetupTestDatabases(t)
defer cleanup()
mixedAddress := "0xA1B2c3D4e5F60718293aBcDeF001122334455667"
if err := dao.Mdb.Create(&mdb.WalletAddress{
Network: mdb.NetworkEthereum,
Address: mixedAddress,
Status: mdb.TokenStatusEnable,
}).Error; err != nil {
t.Fatalf("seed mixed-case wallet: %v", err)
}
req := newCreateTransactionRequest("order_evm_1", 1)
req.Network = mdb.NetworkEthereum
resp, err := CreateTransaction(req, nil)
if err != nil {
t.Fatalf("create transaction: %v", err)
}
expectedAddress := strings.ToLower(mixedAddress)
if resp.ReceiveAddress != expectedAddress {
t.Fatalf("receive address = %q, want %q", resp.ReceiveAddress, expectedAddress)
}
order, err := data.GetOrderInfoByTradeId(resp.TradeId)
if err != nil {
t.Fatalf("load order: %v", err)
}
if order.ReceiveAddress != expectedAddress {
t.Fatalf("stored order address = %q, want %q", order.ReceiveAddress, expectedAddress)
}
tradeID, err := data.GetTradeIdByWalletAddressAndAmountAndToken(mdb.NetworkEthereum, strings.ToUpper(mixedAddress), resp.Token, resp.ActualAmount)
if err != nil {
t.Fatalf("lookup runtime lock: %v", err)
}
if tradeID != resp.TradeId {
t.Fatalf("runtime lock trade_id = %q, want %q", tradeID, resp.TradeId)
}
}
func TestOrderProcessingMarksPaidAndReleasesLock(t *testing.T) {
cleanup := testutil.SetupTestDatabases(t)
defer cleanup()
@@ -79,7 +123,7 @@ func TestOrderProcessingMarksPaidAndReleasesLock(t *testing.T) {
t.Fatalf("add wallet: %v", err)
}
resp, err := CreateTransaction(newCreateTransactionRequest("order_1", 1))
resp, err := CreateTransaction(newCreateTransactionRequest("order_1", 1), nil)
if err != nil {
t.Fatalf("create transaction: %v", err)
}
@@ -127,7 +171,7 @@ func TestOrderProcessingRejectsDuplicateBlockForSameOrder(t *testing.T) {
t.Fatalf("add wallet: %v", err)
}
resp, err := CreateTransaction(newCreateTransactionRequest("order_1", 1))
resp, err := CreateTransaction(newCreateTransactionRequest("order_1", 1), nil)
if err != nil {
t.Fatalf("create transaction: %v", err)
}
@@ -169,7 +213,7 @@ func TestOrderProcessingDoesNotReviveExpiredOrder(t *testing.T) {
t.Fatalf("add wallet: %v", err)
}
resp, err := CreateTransaction(newCreateTransactionRequest("order_1", 1))
resp, err := CreateTransaction(newCreateTransactionRequest("order_1", 1), nil)
if err != nil {
t.Fatalf("create transaction: %v", err)
}
@@ -215,11 +259,11 @@ func TestOrderProcessingOnlyOneOrderClaimsABlockTransaction(t *testing.T) {
t.Fatalf("add wallet: %v", err)
}
resp1, err := CreateTransaction(newCreateTransactionRequest("order_1", 1))
resp1, err := CreateTransaction(newCreateTransactionRequest("order_1", 1), nil)
if err != nil {
t.Fatalf("create first transaction: %v", err)
}
resp2, err := CreateTransaction(newCreateTransactionRequest("order_2", 2))
resp2, err := CreateTransaction(newCreateTransactionRequest("order_2", 2), nil)
if err != nil {
t.Fatalf("create second transaction: %v", err)
}
@@ -303,3 +347,285 @@ func TestOrderProcessingOnlyOneOrderClaimsABlockTransaction(t *testing.T) {
t.Fatalf("paid=%d pending=%d, want 1 and 1", paidCount, pendingCount)
}
}
func TestOrderProcessingSubOrderReturnsErrorWhenParentNotWaitPay(t *testing.T) {
cleanup := testutil.SetupTestDatabases(t)
defer cleanup()
if _, err := data.AddWalletAddress("TTestTronAddress001"); err != nil {
t.Fatalf("add tron wallet: %v", err)
}
if _, err := data.AddWalletAddressWithNetwork(mdb.NetworkEthereum, "0xA1B2c3D4e5F60718293aBcDeF001122334455667"); err != nil {
t.Fatalf("add ethereum wallet: %v", err)
}
parentReq := newCreateTransactionRequest("order_parent_for_sub", 1)
parentReq.Network = mdb.NetworkTron
parentResp, err := CreateTransaction(parentReq, nil)
if err != nil {
t.Fatalf("create parent order: %v", err)
}
subResp, err := SwitchNetwork(&request.SwitchNetworkRequest{
TradeId: parentResp.TradeId,
Token: "usdt",
Network: mdb.NetworkEthereum,
})
if err != nil {
t.Fatalf("switch network create sub-order: %v", err)
}
if err := dao.Mdb.Model(&mdb.Orders{}).
Where("trade_id = ?", parentResp.TradeId).
Update("status", mdb.StatusExpired).Error; err != nil {
t.Fatalf("force parent expired: %v", err)
}
err = OrderProcessing(&request.OrderProcessingRequest{
ReceiveAddress: subResp.ReceiveAddress,
Token: strings.ToUpper(subResp.Token),
Network: strings.ToLower(subResp.Network),
TradeId: subResp.TradeId,
Amount: subResp.ActualAmount,
BlockTransactionId: "block_sub_parent_not_wait",
})
if err == nil {
t.Fatal("expected error when parent order is not wait-pay")
}
}
func TestOrderProcessingSubOrderReturnsErrorWhenParentMissing(t *testing.T) {
cleanup := testutil.SetupTestDatabases(t)
defer cleanup()
if _, err := data.AddWalletAddress("TTestTronAddress001"); err != nil {
t.Fatalf("add tron wallet: %v", err)
}
if _, err := data.AddWalletAddressWithNetwork(mdb.NetworkEthereum, "0xA1B2c3D4e5F60718293aBcDeF001122334455667"); err != nil {
t.Fatalf("add ethereum wallet: %v", err)
}
parentReq := newCreateTransactionRequest("order_parent_missing", 1)
parentReq.Network = mdb.NetworkTron
parentResp, err := CreateTransaction(parentReq, nil)
if err != nil {
t.Fatalf("create parent order: %v", err)
}
subResp, err := SwitchNetwork(&request.SwitchNetworkRequest{
TradeId: parentResp.TradeId,
Token: "usdt",
Network: mdb.NetworkEthereum,
})
if err != nil {
t.Fatalf("switch network create sub-order: %v", err)
}
if err := dao.Mdb.Where("trade_id = ?", parentResp.TradeId).Delete(&mdb.Orders{}).Error; err != nil {
t.Fatalf("delete parent order: %v", err)
}
err = OrderProcessing(&request.OrderProcessingRequest{
ReceiveAddress: subResp.ReceiveAddress,
Token: strings.ToUpper(subResp.Token),
Network: strings.ToLower(subResp.Network),
TradeId: subResp.TradeId,
Amount: subResp.ActualAmount,
BlockTransactionId: "block_sub_parent_missing",
})
if err == nil {
t.Fatal("expected error when parent order is missing")
}
}
// TestOrderProcessingSubOrderPaidParentKeepsOwnFields verifies the new behavior:
// when a sub-order is paid, the parent order is marked as paid but its own
// block_transaction_id, actual_amount, and receive_address are NOT overwritten.
// The sub-order's primary-key ID is recorded in the parent's pay_by_sub_id field.
// Also verifies: parent callback_confirm=No (callback queued), sub-order callback_confirm=Ok.
func TestOrderProcessingSubOrderPaidParentKeepsOwnFields(t *testing.T) {
cleanup := testutil.SetupTestDatabases(t)
defer cleanup()
if _, err := data.AddWalletAddress("TTestTronAddress001"); err != nil {
t.Fatalf("add tron wallet: %v", err)
}
if _, err := data.AddWalletAddressWithNetwork(mdb.NetworkEthereum, "0xA1B2c3D4e5F60718293aBcDeF001122334455667"); err != nil {
t.Fatalf("add ethereum wallet: %v", err)
}
parentReq := newCreateTransactionRequest("order_sub_pay_test", 1)
parentReq.Network = mdb.NetworkTron
parentResp, err := CreateTransaction(parentReq, nil)
if err != nil {
t.Fatalf("create parent order: %v", err)
}
// Snapshot the parent's original fields before any payment.
originalParent, err := data.GetOrderInfoByTradeId(parentResp.TradeId)
if err != nil {
t.Fatalf("load original parent: %v", err)
}
subResp, err := SwitchNetwork(&request.SwitchNetworkRequest{
TradeId: parentResp.TradeId,
Token: "usdt",
Network: mdb.NetworkEthereum,
})
if err != nil {
t.Fatalf("switch network create sub-order: %v", err)
}
// Load the sub-order to get its DB primary-key ID.
subOrder, err := data.GetOrderInfoByTradeId(subResp.TradeId)
if err != nil {
t.Fatalf("load sub-order: %v", err)
}
err = OrderProcessing(&request.OrderProcessingRequest{
ReceiveAddress: subResp.ReceiveAddress,
Token: strings.ToUpper(subResp.Token),
Network: strings.ToLower(subResp.Network),
TradeId: subResp.TradeId,
Amount: subResp.ActualAmount,
BlockTransactionId: "block_sub_paid",
})
if err != nil {
t.Fatalf("order processing sub-order: %v", err)
}
// Sub-order must be paid with the block hash and no pending callback.
sub, err := data.GetOrderInfoByTradeId(subResp.TradeId)
if err != nil {
t.Fatalf("reload sub-order: %v", err)
}
if sub.Status != mdb.StatusPaySuccess {
t.Fatalf("sub-order status = %d, want %d", sub.Status, mdb.StatusPaySuccess)
}
if sub.BlockTransactionId != "block_sub_paid" {
t.Fatalf("sub-order block_transaction_id = %q, want %q", sub.BlockTransactionId, "block_sub_paid")
}
if sub.CallBackConfirm != mdb.CallBackConfirmOk {
t.Fatalf("sub-order callback_confirm = %d, want %d (no callback for sub-order)", sub.CallBackConfirm, mdb.CallBackConfirmOk)
}
// Parent must be paid but its own fields must be unchanged.
parent, err := data.GetOrderInfoByTradeId(parentResp.TradeId)
if err != nil {
t.Fatalf("reload parent order: %v", err)
}
if parent.Status != mdb.StatusPaySuccess {
t.Fatalf("parent status = %d, want %d", parent.Status, mdb.StatusPaySuccess)
}
if parent.BlockTransactionId != "" {
t.Fatalf("parent block_transaction_id = %q, want empty (parent was not directly paid)", parent.BlockTransactionId)
}
if parent.ReceiveAddress != originalParent.ReceiveAddress {
t.Fatalf("parent receive_address changed: got %q, want %q", parent.ReceiveAddress, originalParent.ReceiveAddress)
}
if parent.ActualAmount != originalParent.ActualAmount {
t.Fatalf("parent actual_amount changed: got %v, want %v", parent.ActualAmount, originalParent.ActualAmount)
}
if parent.PayBySubId != subOrder.ID {
t.Fatalf("parent pay_by_sub_id = %d, want %d (sub-order ID)", parent.PayBySubId, subOrder.ID)
}
if parent.CallBackConfirm != mdb.CallBackConfirmNo {
t.Fatalf("parent callback_confirm = %d, want %d (callback must be queued)", parent.CallBackConfirm, mdb.CallBackConfirmNo)
}
}
// TestOrderProcessingSubOrderExpiresSiblingsAndReleasesLocks verifies that when one
// sub-order is paid all sibling sub-orders are expired and their runtime locks (as
// well as the parent's lock) are released.
func TestOrderProcessingSubOrderExpiresSiblingsAndReleasesLocks(t *testing.T) {
cleanup := testutil.SetupTestDatabases(t)
defer cleanup()
if _, err := data.AddWalletAddress("TTestTronAddress001"); err != nil {
t.Fatalf("add tron wallet: %v", err)
}
if _, err := data.AddWalletAddressWithNetwork(mdb.NetworkEthereum, "0xA1B2c3D4e5F60718293aBcDeF001122334455667"); err != nil {
t.Fatalf("add ethereum wallet: %v", err)
}
if _, err := data.AddWalletAddressWithNetwork(mdb.NetworkBsc, "0xBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB"); err != nil {
t.Fatalf("add bsc wallet: %v", err)
}
parentReq := newCreateTransactionRequest("order_sib_expiry_test", 1)
parentReq.Network = mdb.NetworkTron
parentResp, err := CreateTransaction(parentReq, nil)
if err != nil {
t.Fatalf("create parent order: %v", err)
}
// Create two sub-orders on different networks.
subEthResp, err := SwitchNetwork(&request.SwitchNetworkRequest{
TradeId: parentResp.TradeId,
Token: "usdt",
Network: mdb.NetworkEthereum,
})
if err != nil {
t.Fatalf("switch to ethereum sub-order: %v", err)
}
subBscResp, err := SwitchNetwork(&request.SwitchNetworkRequest{
TradeId: parentResp.TradeId,
Token: "usdt",
Network: mdb.NetworkBsc,
})
if err != nil {
t.Fatalf("switch to bsc sub-order: %v", err)
}
// Pay the Ethereum sub-order.
err = OrderProcessing(&request.OrderProcessingRequest{
ReceiveAddress: subEthResp.ReceiveAddress,
Token: strings.ToUpper(subEthResp.Token),
Network: strings.ToLower(subEthResp.Network),
TradeId: subEthResp.TradeId,
Amount: subEthResp.ActualAmount,
BlockTransactionId: "block_sib_eth",
})
if err != nil {
t.Fatalf("order processing eth sub-order: %v", err)
}
// BSC sibling must be expired.
subBsc, err := data.GetOrderInfoByTradeId(subBscResp.TradeId)
if err != nil {
t.Fatalf("reload bsc sub-order: %v", err)
}
if subBsc.Status != mdb.StatusExpired {
t.Fatalf("bsc sibling status = %d, want %d (expired)", subBsc.Status, mdb.StatusExpired)
}
// Parent runtime lock must be released.
parentLock, err := data.GetTradeIdByWalletAddressAndAmountAndToken(
mdb.NetworkTron, parentResp.ReceiveAddress, parentResp.Token, parentResp.ActualAmount)
if err != nil {
t.Fatalf("check parent runtime lock: %v", err)
}
if parentLock != "" {
t.Fatalf("parent runtime lock still held: trade_id=%s", parentLock)
}
// BSC sibling runtime lock must be released.
sibLock, err := data.GetTradeIdByWalletAddressAndAmountAndToken(
mdb.NetworkBsc, subBscResp.ReceiveAddress, subBscResp.Token, subBscResp.ActualAmount)
if err != nil {
t.Fatalf("check bsc sibling runtime lock: %v", err)
}
if sibLock != "" {
t.Fatalf("bsc sibling runtime lock still held: trade_id=%s", sibLock)
}
// Ethereum sub-order runtime lock must also be released.
ethLock, err := data.GetTradeIdByWalletAddressAndAmountAndToken(
mdb.NetworkEthereum, subEthResp.ReceiveAddress, subEthResp.Token, subEthResp.ActualAmount)
if err != nil {
t.Fatalf("check eth sub-order runtime lock: %v", err)
}
if ethLock != "" {
t.Fatalf("eth sub-order runtime lock still held: trade_id=%s", ethLock)
}
}
+67 -35
View File
@@ -45,6 +45,33 @@ func SolCallBack(address string, wg *sync.WaitGroup) {
}
}()
// Load enabled Solana tokens from chain_tokens. The contract_address
// column holds the mint for SPL tokens; a special entry with
// symbol=SOL and empty contract_address enables native SOL payments.
tokens, err := data.ListEnabledChainTokensByNetwork(mdb.NetworkSolana)
if err != nil {
log.Sugar.Errorf("[SOL][%s] load chain_tokens err=%v", address, err)
return
}
if len(tokens) == 0 {
log.Sugar.Debugf("[SOL][%s] no enabled chain_tokens, skipping", address)
return
}
mintTokens := make(map[string]*mdb.ChainToken, len(tokens))
var nativeSolToken *mdb.ChainToken
for i := range tokens {
sym := strings.ToUpper(strings.TrimSpace(tokens[i].Symbol))
mint := strings.TrimSpace(tokens[i].ContractAddress)
if sym == "SOL" && mint == "" {
nativeSolToken = &tokens[i]
continue
}
if mint == "" {
continue
}
mintTokens[mint] = &tokens[i]
}
// Clean up old entries from processed cache (older than 1 hour)
cleanupCutoff := time.Now().Add(-1 * time.Hour).Unix()
gProcessedSignatures.Range(func(key, value interface{}) bool {
@@ -56,17 +83,15 @@ func SolCallBack(address string, wg *sync.WaitGroup) {
limit := 1000
// 查询钱包地址 + USDT ATA + USDC ATA 三个地址的签名
// 查询钱包地址 + 每个已启用 SPL 代币 ATA 的签名
queryAddrs := []string{address}
if usdtAta, err := FindATAAddress(address, USDT_Mint); err == nil {
queryAddrs = append(queryAddrs, usdtAta)
} else {
log.Sugar.Errorf("[SOL][%s] failed to derive USDT ATA: %v", address, err)
}
if usdcAta, err := FindATAAddress(address, USDC_Mint); err == nil {
queryAddrs = append(queryAddrs, usdcAta)
} else {
log.Sugar.Errorf("[SOL][%s] failed to derive USDC ATA: %v", address, err)
for mint := range mintTokens {
ata, err := FindATAAddress(address, mint)
if err != nil {
log.Sugar.Errorf("[SOL][%s] failed to derive ATA for mint %s: %v", address, mint, err)
continue
}
queryAddrs = append(queryAddrs, ata)
}
// 拉取签名并去重
@@ -177,7 +202,7 @@ func SolCallBack(address string, wg *sync.WaitGroup) {
continue
}
token, amount := getTokenTypeAndAmount(transferInfo)
token, amount := resolveSolTokenAndAmount(transferInfo, mintTokens, nativeSolToken)
if token == "" || amount <= 0 {
continue
}
@@ -363,35 +388,42 @@ func isTransferToAddress(transfer *TransferInfo, targetAddress string) bool {
return MatchAtaAddress(targetAddress, transfer.Mint, transfer.Destination)
}
// getTokenTypeAndAmount 根据 mint 识别代币类型并计算可读金额
func getTokenTypeAndAmount(transfer *TransferInfo) (string, float64) {
// resolveSolTokenAndAmount identifies the token from a transfer using
// the admin-configured chain_tokens list. mintTokens maps SPL mint
// addresses to their ChainToken config; nativeSolToken is non-nil when a
// native SOL entry exists in chain_tokens. Returns ("", 0) for
// transfers whose mint is not configured or below min_amount.
func resolveSolTokenAndAmount(transfer *TransferInfo, mintTokens map[string]*mdb.ChainToken, nativeSolToken *mdb.ChainToken) (string, float64) {
mint := transfer.Mint
// Native SOL
if mint == "SOL" {
return "SOL", transfer.Amount
if nativeSolToken == nil {
return "", 0
}
amount := transfer.Amount
if nativeSolToken.MinAmount > 0 && amount < nativeSolToken.MinAmount {
return "", 0
}
return strings.ToUpper(strings.TrimSpace(nativeSolToken.Symbol)), amount
}
// SPL Tokens
switch mint {
case USDT_Mint:
decimals := USDT_Decimals
if transfer.Decimals != nil {
decimals = int(*transfer.Decimals)
}
return "USDT", ADJustAmount(transfer.RawAmount, decimals)
case USDC_Mint:
decimals := USDC_Decimals
if transfer.Decimals != nil {
decimals = int(*transfer.Decimals)
}
return "USDC", ADJustAmount(transfer.RawAmount, decimals)
default:
// Unsupported token
cfg, ok := mintTokens[mint]
if !ok || cfg == nil {
return "", 0
}
decimals := cfg.Decimals
if transfer.Decimals != nil {
decimals = int(*transfer.Decimals)
}
if decimals <= 0 {
decimals = 6
}
amount := ADJustAmount(transfer.RawAmount, decimals)
if cfg.MinAmount > 0 && amount < cfg.MinAmount {
return "", 0
}
return strings.ToUpper(strings.TrimSpace(cfg.Symbol)), amount
}
const (
@@ -437,7 +469,7 @@ func ADJustAmount(amount uint64, decimals int) float64 {
func MatchUsdtAtaAddress(address string, ataTo string) bool {
ata, err := FindATAAddress(address, USDT_Mint)
if err != nil {
fmt.Printf("FindATAAddress failed: %v\n", err)
log.Sugar.Errorf("FindATAAddress failed: %v", err)
return false
}
@@ -447,7 +479,7 @@ func MatchUsdtAtaAddress(address string, ataTo string) bool {
func MatchUsdcAtaAddress(address string, ataTo string) bool {
ata, err := FindATAAddress(address, USDC_Mint)
if err != nil {
fmt.Printf("FindATAAddress failed: %v\n", err)
log.Sugar.Errorf("FindATAAddress failed: %v", err)
return false
}
@@ -457,7 +489,7 @@ func MatchUsdcAtaAddress(address string, ataTo string) bool {
func MatchAtaAddress(address string, mint string, ataTo string) bool {
ata, err := FindATAAddress(address, mint)
if err != nil {
fmt.Printf("FindATAAddress failed: %v\n", err)
log.Sugar.Errorf("FindATAAddress failed: %v", err)
return false
}
+138 -72
View File
@@ -14,7 +14,7 @@ import (
"github.com/assimon/luuu/model/data"
"github.com/assimon/luuu/model/mdb"
"github.com/assimon/luuu/model/request"
"github.com/assimon/luuu/telegram"
"github.com/assimon/luuu/notify"
"github.com/assimon/luuu/util/constant"
"github.com/assimon/luuu/util/http_client"
"github.com/assimon/luuu/util/log"
@@ -26,7 +26,20 @@ import (
"github.com/tidwall/gjson"
)
const TRC20_USDT_ID = "TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6t"
// tronNodeDefault is the fallback when no rpc_nodes row exists for TRON.
const tronNodeDefault = "https://api.trongrid.io"
// resolveTronNode returns (baseURL, apiKey) for the TRON HTTP RPC node.
// It reads the first healthy (or any enabled) row from the rpc_nodes table;
// if none exists it falls back to the hard-coded default URL and the
// tron_grid_api_key value from .env (config.TRON_GRID_API_KEY).
func resolveTronNode() (string, string) {
node, err := data.SelectRpcNode(mdb.NetworkTron, mdb.RpcNodeTypeHttp)
if err == nil && node != nil && node.ID > 0 {
return strings.TrimRight(node.Url, "/"), node.ApiKey
}
return tronNodeDefault, config.TRON_GRID_API_KEY
}
func Trc20CallBack(address string, wg *sync.WaitGroup) {
defer wg.Done()
@@ -45,16 +58,29 @@ func Trc20CallBack(address string, wg *sync.WaitGroup) {
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)
}
}()
// Native TRX is gated by a chain_tokens row with empty
// contract_address and symbol=TRX. Admin can disable this row to
// stop scanning native transfers without touching the chain toggle.
trxCfg, err := data.GetEnabledChainTokenBySymbol(mdb.NetworkTron, "TRX")
if err != nil {
log.Sugar.Errorf("[TRX][%s] load chain_tokens err=%v", address, err)
return
}
if trxCfg == nil || trxCfg.ID == 0 {
log.Sugar.Debugf("[TRX][%s] native TRX disabled in chain_tokens, skipping", address)
return
}
trxDecimals := trxCfg.Decimals
if trxDecimals <= 0 {
trxDecimals = 6
}
client := http_client.GetHttpClient()
startTime := carbon.Now().AddHours(-24).TimestampMilli()
endTime := carbon.Now().TimestampMilli()
url := fmt.Sprintf("https://api.trongrid.io/v1/accounts/%s/transactions", address)
tronBaseURL, tronAPIKey := resolveTronNode()
url := fmt.Sprintf("%s/v1/accounts/%s/transactions", tronBaseURL, address)
resp, err := client.R().SetQueryParams(map[string]string{
"order_by": "block_timestamp,desc",
@@ -62,17 +88,20 @@ func checkTrxTransfers(address string, wg *sync.WaitGroup) {
"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)
}).SetHeader("TRON-PRO-API-KEY", tronAPIKey).Get(url)
if err != nil {
panic(err)
log.Sugar.Errorf("[TRX][%s] HTTP request failed: %v", address, err)
return
}
if resp.StatusCode() != http.StatusOK {
panic(fmt.Sprintf("TRX API returned status %d", resp.StatusCode()))
log.Sugar.Errorf("[TRX][%s] API returned status %d", address, resp.StatusCode())
return
}
success := gjson.GetBytes(resp.Body(), "success").Bool()
if !success {
panic("TRX API response indicates failure")
log.Sugar.Errorf("[TRX][%s] API response indicates failure", address)
return
}
transfers := gjson.GetBytes(resp.Body(), "data").Array()
@@ -106,15 +135,19 @@ func checkTrxTransfers(address string, wg *sync.WaitGroup) {
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)
amount := math.MustParsePrecFloat64(decimalQuant.Div(decimal.New(1, int32(trxDecimals))).InexactFloat64(), 2)
if trxCfg.MinAmount > 0 && amount < trxCfg.MinAmount {
continue
}
if amount <= 0 {
continue
}
txID := transfer.Get("txID").String()
tradeID, err := data.GetTradeIdByWalletAddressAndAmountAndToken(mdb.NetworkTron, address, "TRX", amount)
tradeID, err := data.GetTradeIdByWalletAddressAndAmountAndToken(mdb.NetworkTron, address, strings.ToUpper(strings.TrimSpace(trxCfg.Symbol)), amount)
if err != nil {
panic(err)
log.Sugar.Errorf("[TRX][%s] lookup trade_id failed hash=%s err=%v", address, txID, err)
continue
}
if tradeID == "" {
log.Sugar.Debugf("[TRX][%s] skip unmatched tx hash=%s amount=%.2f", address, txID, amount)
@@ -124,7 +157,8 @@ func checkTrxTransfers(address string, wg *sync.WaitGroup) {
order, err := data.GetOrderInfoByTradeId(tradeID)
if err != nil {
panic(err)
log.Sugar.Errorf("[TRX][%s] get order failed trade_id=%s err=%v", address, tradeID, err)
continue
}
blockTimestamp := transfer.Get("block_timestamp").Int()
createTime := order.CreatedAt.TimestampMilli()
@@ -135,7 +169,7 @@ func checkTrxTransfers(address string, wg *sync.WaitGroup) {
req := &request.OrderProcessingRequest{
ReceiveAddress: address,
Token: "TRX",
Token: strings.ToUpper(strings.TrimSpace(trxCfg.Symbol)),
Network: mdb.NetworkTron,
TradeId: tradeID,
Amount: amount,
@@ -147,7 +181,8 @@ func checkTrxTransfers(address string, wg *sync.WaitGroup) {
log.Sugar.Infof("[TRX][%s] skip resolved transfer trade_id=%s hash=%s err=%v", address, tradeID, txID, err)
continue
}
panic(err)
log.Sugar.Errorf("[TRX][%s] order processing failed trade_id=%s hash=%s err=%v", address, tradeID, txID, err)
continue
}
sendPaymentNotification(order)
@@ -157,16 +192,33 @@ func checkTrxTransfers(address string, wg *sync.WaitGroup) {
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)
// Build contract -> token map for the TRON network. If nothing is
// configured, skip — preserves the previous behavior of only watching
// admin-approved tokens.
tokens, err := data.ListEnabledChainTokensByNetwork(mdb.NetworkTron)
if err != nil {
log.Sugar.Errorf("[TRC20][%s] load chain_tokens err=%v", address, err)
return
}
if len(tokens) == 0 {
log.Sugar.Debugf("[TRC20][%s] no enabled chain_tokens, skipping", address)
return
}
contractTokens := make(map[string]*mdb.ChainToken, len(tokens))
for i := range tokens {
c := strings.TrimSpace(tokens[i].ContractAddress)
if c == "" {
continue
}
}()
contractTokens[c] = &tokens[i]
}
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)
tronBaseURL, tronAPIKey := resolveTronNode()
url := fmt.Sprintf("%s/v1/accounts/%s/transactions/trc20", tronBaseURL, address)
resp, err := client.R().SetQueryParams(map[string]string{
"order_by": "block_timestamp,desc",
@@ -174,17 +226,20 @@ func checkTrc20Transfers(address string, wg *sync.WaitGroup) {
"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)
}).SetHeader("TRON-PRO-API-KEY", tronAPIKey).Get(url)
if err != nil {
panic(err)
log.Sugar.Errorf("[TRC20][%s] HTTP request failed: %v", address, err)
return
}
if resp.StatusCode() != http.StatusOK {
panic(fmt.Sprintf("TRC20 API returned status %d", resp.StatusCode()))
log.Sugar.Errorf("[TRC20][%s] API returned status %d", address, resp.StatusCode())
return
}
success := gjson.GetBytes(resp.Body(), "success").Bool()
if !success {
panic("TRC20 API response indicates failure")
log.Sugar.Errorf("[TRC20][%s] API response indicates failure", address)
return
}
transfers := gjson.GetBytes(resp.Body(), "data").Array()
@@ -195,12 +250,15 @@ func checkTrc20Transfers(address string, wg *sync.WaitGroup) {
log.Sugar.Debugf("[TRC20][%s] fetched %d transfer records", address, len(transfers))
for i, transfer := range transfers {
if transfer.Get("token_info.address").String() != TRC20_USDT_ID {
contractAddr := transfer.Get("token_info.address").String()
cfg, ok := contractTokens[contractAddr]
if !ok {
continue
}
if transfer.Get("to").String() != address {
continue
}
tokenSym := strings.ToUpper(strings.TrimSpace(cfg.Symbol))
valueStr := transfer.Get("value").String()
decimalQuant, err := decimal.NewFromString(valueStr)
@@ -209,25 +267,33 @@ func checkTrc20Transfers(address string, wg *sync.WaitGroup) {
continue
}
tokenDecimals := transfer.Get("token_info.decimals").Int()
if tokenDecimals <= 0 {
tokenDecimals = int64(cfg.Decimals)
}
amount := math.MustParsePrecFloat64(decimalQuant.Div(decimal.New(1, int32(tokenDecimals))).InexactFloat64(), 2)
if cfg.MinAmount > 0 && amount < cfg.MinAmount {
continue
}
if amount <= 0 {
continue
}
txID := transfer.Get("transaction_id").String()
tradeID, err := data.GetTradeIdByWalletAddressAndAmountAndToken(mdb.NetworkTron, address, "USDT", amount)
tradeID, err := data.GetTradeIdByWalletAddressAndAmountAndToken(mdb.NetworkTron, address, tokenSym, amount)
if err != nil {
panic(err)
}
if tradeID == "" {
log.Sugar.Debugf("[TRC20][%s] skip unmatched tx hash=%s amount=%.2f", address, txID, amount)
log.Sugar.Errorf("[TRC20][%s] lookup trade_id failed hash=%s err=%v", address, txID, err)
continue
}
log.Sugar.Infof("[TRC20][%s] matched trade_id=%s hash=%s amount=%.2f", address, tradeID, txID, amount)
if tradeID == "" {
log.Sugar.Debugf("[TRC20][%s] skip unmatched %s tx hash=%s amount=%.2f", address, tokenSym, txID, amount)
continue
}
log.Sugar.Infof("[TRC20][%s] matched %s trade_id=%s hash=%s amount=%.2f", address, tokenSym, tradeID, txID, amount)
order, err := data.GetOrderInfoByTradeId(tradeID)
if err != nil {
panic(err)
log.Sugar.Errorf("[TRC20][%s] get order failed trade_id=%s err=%v", address, tradeID, err)
continue
}
blockTimestamp := transfer.Get("block_timestamp").Int()
createTime := order.CreatedAt.TimestampMilli()
@@ -238,7 +304,7 @@ func checkTrc20Transfers(address string, wg *sync.WaitGroup) {
req := &request.OrderProcessingRequest{
ReceiveAddress: address,
Token: "USDT",
Token: tokenSym,
Network: mdb.NetworkTron,
TradeId: tradeID,
Amount: amount,
@@ -250,7 +316,8 @@ func checkTrc20Transfers(address string, wg *sync.WaitGroup) {
log.Sugar.Infof("[TRC20][%s] skip resolved transfer trade_id=%s hash=%s err=%v", address, tradeID, txID, err)
continue
}
panic(err)
log.Sugar.Errorf("[TRC20][%s] order processing failed trade_id=%s hash=%s err=%v", address, tradeID, txID, err)
continue
}
sendPaymentNotification(order)
@@ -273,7 +340,9 @@ func evmChainLogLabel(chainNetwork string) string {
}
}
// TryProcessEvmERC20Transfer 处理各 EVM 链上 USDT/USDC(及 Polygon USDC.e)的 Transfer 入账(合约与 network 需一致)
// TryProcessEvmERC20Transfer 处理各 EVM 链上代币的 Transfer 入账
// 代币识别、符号和 decimals 全部从 chain_tokens 表动态查询 —
// 管理后台新增 token 即可立即生效,无需代码改动。
func TryProcessEvmERC20Transfer(chainNetwork string, contract common.Address, toAddr common.Address, rawValue *big.Int, txHash string, blockTsMs int64) {
defer func() {
if err := recover(); err != nil {
@@ -281,46 +350,38 @@ func TryProcessEvmERC20Transfer(chainNetwork string, contract common.Address, to
}
}()
var usdt, usdc common.Address
var polygonUsdcE common.Address
switch chainNetwork {
case mdb.NetworkEthereum:
usdt = common.HexToAddress("0xdAC17F958D2ee523a2206206994597C13D831ec7")
usdc = common.HexToAddress("0xA0b86991c6218b36c1d19d4a2e9eb0ce3606eb48")
case mdb.NetworkBsc:
usdt = common.HexToAddress("0x55d398326f99059fF775485246999027B3197955")
usdc = common.HexToAddress("0x8AC76a51cc950d9822D68b83fE1Ad97B32Cd580d")
case mdb.NetworkPolygon:
usdt = common.HexToAddress("0xc2132D05D31c914a87C6611C10748AEb04B58e8F")
usdc = common.HexToAddress("0x3c499c542cEF5E3811e1192ce70d8cC03d5c3359")
polygonUsdcE = common.HexToAddress("0x2791Bca1f2de4661ED88A30C99A7a9449Aa84174")
case mdb.NetworkPlasma:
// USDT0(官方),6 decimals;链上暂无与 ETH 同级的 Circle USDC 部署,仅匹配 USDT 订单
usdt = common.HexToAddress("0xB8CE59FC3717ada4C02eaDF9682A9e934F625ebb")
default:
return
}
var tokenSym string
switch {
case contract == usdt:
tokenSym = "USDT"
case contract == usdc || (polygonUsdcE != (common.Address{}) && contract == polygonUsdcE):
tokenSym = "USDC"
default:
net := evmChainLogLabel(chainNetwork)
log.Sugar.Warnf("[%s-WS] skip unsupported contract %s", net, contract.Hex())
return
}
net := evmChainLogLabel(chainNetwork)
token, err := data.GetEnabledChainTokenByContract(chainNetwork, contract.Hex())
if err != nil {
log.Sugar.Warnf("[%s-WS] chain_tokens lookup err=%v contract=%s", net, err, contract.Hex())
return
}
if token == nil || token.ID == 0 {
log.Sugar.Debugf("[%s-WS] skip unconfigured contract %s", net, contract.Hex())
return
}
tokenSym := strings.ToUpper(strings.TrimSpace(token.Symbol))
if tokenSym == "" {
log.Sugar.Warnf("[%s-WS] chain_token id=%d has empty symbol", net, token.ID)
return
}
decimals := token.Decimals
if decimals <= 0 {
decimals = 6
}
walletAddr := strings.ToLower(toAddr.Hex())
if rawValue == nil || rawValue.Sign() <= 0 {
log.Sugar.Infof("[%s-%s][%s] skip non-positive or nil amount", net, tokenSym, walletAddr)
return
}
divisor := decimal.New(1, int32(decimals))
decimalQuant := decimal.NewFromBigInt(rawValue, 0)
amount := math.MustParsePrecFloat64(decimalQuant.Div(decimal.NewFromInt(1_000_000)).InexactFloat64(), 2)
amount := math.MustParsePrecFloat64(decimalQuant.Div(divisor).InexactFloat64(), 2)
if token.MinAmount > 0 && amount < token.MinAmount {
log.Sugar.Debugf("[%s-%s][%s] skip amount %.2f below min_amount %.2f", net, tokenSym, walletAddr, amount, token.MinAmount)
return
}
if amount <= 0 {
log.Sugar.Warnf("[%s-%s][%s] skip non-positive amount %.2f", net, tokenSym, walletAddr, amount)
return
@@ -351,6 +412,11 @@ func TryProcessEvmERC20Transfer(chainNetwork string, contract common.Address, to
log.Sugar.Warnf("[%s-%s][%s] skip trade_id=%s token mismatch order=%s", net, tokenSym, walletAddr, tradeID, order.Token)
return
}
if blockTsMs > 0 && blockTsMs < order.CreatedAt.TimestampMilli() {
log.Sugar.Warnf("[%s-%s][%s] skip tx %s because block_time_ms=%d is before order created_ms=%d",
net, tokenSym, walletAddr, txHash, blockTsMs, order.CreatedAt.TimestampMilli())
return
}
req := &request.OrderProcessingRequest{
ReceiveAddress: walletAddr,
@@ -399,7 +465,7 @@ func sendPaymentNotification(order *mdb.Orders) {
order.CreatedAt.ToDateTimeString(),
carbon.Now().ToDateTimeString(),
)
telegram.SendToBot(msg)
notify.Dispatch(mdb.NotifyEventPaySuccess, msg)
}
func networkDisplay(n string) string {
+92
View File
@@ -0,0 +1,92 @@
package service
import (
"testing"
"github.com/assimon/luuu/config"
"github.com/assimon/luuu/internal/testutil"
"github.com/assimon/luuu/model/dao"
"github.com/assimon/luuu/model/mdb"
)
// TestResolveTronNode_NoRow verifies that resolveTronNode falls back to the
// hard-coded default when the rpc_nodes table has no TRON row.
func TestResolveTronNode_NoRow(t *testing.T) {
cleanup := testutil.SetupTestDatabases(t)
defer cleanup()
config.TRON_GRID_API_KEY = "fallback-key"
gotURL, gotKey := resolveTronNode()
if gotURL != tronNodeDefault {
t.Errorf("url = %q, want %q", gotURL, tronNodeDefault)
}
if gotKey != "fallback-key" {
t.Errorf("apiKey = %q, want \"fallback-key\"", gotKey)
}
}
// TestResolveTronNode_WithRow verifies that resolveTronNode uses the DB row
// when an enabled TRON HTTP node is present.
func TestResolveTronNode_WithRow(t *testing.T) {
cleanup := testutil.SetupTestDatabases(t)
defer cleanup()
config.TRON_GRID_API_KEY = "fallback-key"
// Insert an enabled TRON http node.
node := &mdb.RpcNode{
Network: mdb.NetworkTron,
Url: "https://custom-tron.example.com",
Type: mdb.RpcNodeTypeHttp,
ApiKey: "db-api-key",
Weight: 1,
Enabled: true,
Status: mdb.RpcNodeStatusOk,
}
if err := dao.Mdb.Create(node).Error; err != nil {
t.Fatalf("seed rpc_node: %v", err)
}
gotURL, gotKey := resolveTronNode()
if gotURL != "https://custom-tron.example.com" {
t.Errorf("url = %q, want https://custom-tron.example.com", gotURL)
}
if gotKey != "db-api-key" {
t.Errorf("apiKey = %q, want \"db-api-key\"", gotKey)
}
}
// TestResolveTronNode_DisabledRow verifies that a disabled row is ignored and
// the fallback is used.
func TestResolveTronNode_DisabledRow(t *testing.T) {
cleanup := testutil.SetupTestDatabases(t)
defer cleanup()
config.TRON_GRID_API_KEY = "fallback-key"
node := &mdb.RpcNode{
Network: mdb.NetworkTron,
Url: "https://disabled-tron.example.com",
Type: mdb.RpcNodeTypeHttp,
ApiKey: "disabled-key",
Weight: 1,
Enabled: true, // start enabled, then disable below
Status: mdb.RpcNodeStatusOk,
}
if err := dao.Mdb.Create(node).Error; err != nil {
t.Fatalf("seed rpc_node: %v", err)
}
// Explicitly disable — GORM does not save bool zero-value on Create.
if err := dao.Mdb.Model(node).Update("enabled", false).Error; err != nil {
t.Fatalf("disable rpc_node: %v", err)
}
gotURL, gotKey := resolveTronNode()
if gotURL != tronNodeDefault {
t.Errorf("url = %q, want %q (disabled row should be ignored)", gotURL, tronNodeDefault)
}
if gotKey != "fallback-key" {
t.Errorf("apiKey = %q, want \"fallback-key\"", gotKey)
}
}