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
+125
View File
@@ -0,0 +1,125 @@
package data
import (
"strings"
"github.com/assimon/luuu/model/dao"
"github.com/assimon/luuu/model/mdb"
)
// ListEnabledChainTokens returns enabled tokens, optionally filtered by network.
// An empty network returns all enabled tokens across all networks.
func ListEnabledChainTokens(network string) ([]mdb.ChainToken, error) {
var rows []mdb.ChainToken
tx := dao.Mdb.Model(&mdb.ChainToken{}).Where("enabled = ?", true)
if n := strings.ToLower(strings.TrimSpace(network)); n != "" {
tx = tx.Where("network = ?", n)
}
err := tx.Order("network ASC, id ASC").Find(&rows).Error
return rows, err
}
// ListChainTokens returns rows optionally filtered by network.
func ListChainTokens(network string) ([]mdb.ChainToken, error) {
var rows []mdb.ChainToken
tx := dao.Mdb.Model(&mdb.ChainToken{})
if network != "" {
tx = tx.Where("network = ?", strings.ToLower(network))
}
err := tx.Order("id ASC").Find(&rows).Error
return rows, err
}
// ListEnabledChainTokensByNetwork returns the enabled tokens for a network.
// Scanners call this to learn which contract addresses to subscribe to.
func ListEnabledChainTokensByNetwork(network string) ([]mdb.ChainToken, error) {
var rows []mdb.ChainToken
err := dao.Mdb.Model(&mdb.ChainToken{}).
Where("network = ?", strings.ToLower(network)).
Where("enabled = ?", true).
Find(&rows).Error
return rows, err
}
// GetEnabledChainTokenByContract finds one enabled token by (network,
// contract_address). Used by EVM/TRON scanners to resolve the symbol
// and decimals of an incoming transfer. Returns (zero row, nil) if no
// match. The contract address match is case-insensitive.
func GetEnabledChainTokenByContract(network, contractAddress string) (*mdb.ChainToken, error) {
row := new(mdb.ChainToken)
addr := strings.TrimSpace(contractAddress)
if addr == "" {
return row, nil
}
err := dao.Mdb.Model(row).
Where("network = ?", strings.ToLower(strings.TrimSpace(network))).
Where("enabled = ?", true).
Where("LOWER(contract_address) = ?", strings.ToLower(addr)).
Limit(1).Find(row).Error
return row, err
}
// GetEnabledChainTokenBySymbol finds one enabled token by (network, symbol).
// Used by Solana native-SOL path where there's no contract address.
func GetEnabledChainTokenBySymbol(network, symbol string) (*mdb.ChainToken, error) {
row := new(mdb.ChainToken)
sym := strings.TrimSpace(symbol)
if sym == "" {
return row, nil
}
err := dao.Mdb.Model(row).
Where("network = ?", strings.ToLower(strings.TrimSpace(network))).
Where("enabled = ?", true).
Where("UPPER(symbol) = ?", strings.ToUpper(sym)).
Limit(1).Find(row).Error
return row, err
}
// GetChainTokenByID fetches by primary key.
func GetChainTokenByID(id uint64) (*mdb.ChainToken, error) {
row := new(mdb.ChainToken)
err := dao.Mdb.Model(row).Limit(1).Find(row, id).Error
return row, err
}
// CreateChainToken inserts a row.
// If a soft-deleted row with the same (network, symbol) exists it is
// restored with the incoming field values rather than creating a duplicate
// that would violate the unique index.
func CreateChainToken(row *mdb.ChainToken) error {
deleted := new(mdb.ChainToken)
err := dao.Mdb.Unscoped().
Where("network = ? AND symbol = ? AND deleted_at IS NOT NULL", row.Network, row.Symbol).
Limit(1).Find(deleted).Error
if err != nil {
return err
}
if deleted.ID > 0 {
err = dao.Mdb.Unscoped().Model(deleted).Updates(map[string]interface{}{
"deleted_at": nil,
"contract_address": row.ContractAddress,
"decimals": row.Decimals,
"enabled": row.Enabled,
"min_amount": row.MinAmount,
}).Error
if err != nil {
return err
}
row.ID = deleted.ID
return nil
}
return dao.Mdb.Create(row).Error
}
// UpdateChainTokenFields patches mutable columns.
func UpdateChainTokenFields(id uint64, fields map[string]interface{}) error {
if len(fields) == 0 {
return nil
}
return dao.Mdb.Model(&mdb.ChainToken{}).Where("id = ?", id).Updates(fields).Error
}
// DeleteChainTokenByID soft-deletes the row.
func DeleteChainTokenByID(id uint64) error {
return dao.Mdb.Where("id = ?", id).Delete(&mdb.ChainToken{}).Error
}