mirror of
https://github.com/GMWalletApp/epusdt.git
synced 2026-07-07 18:26:16 +00:00
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:
+199
-38
@@ -1,60 +1,221 @@
|
||||
package dao
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
"github.com/assimon/luuu/model/mdb"
|
||||
"github.com/gookit/color"
|
||||
"gorm.io/gorm/clause"
|
||||
)
|
||||
|
||||
var once sync.Once
|
||||
|
||||
// 自动建表
|
||||
// MdbTableInit performs AutoMigrate for all primary DB tables and seeds
|
||||
// the minimum static rows: chains, chain tokens, default settings, and
|
||||
// a Telegram notification channel migrated from legacy settings keys.
|
||||
// Called by dao.Init (via bootstrap.InitApp) after the DB connection is
|
||||
// established. The install wizard (see install/installer.go) writes the
|
||||
// .env file before bootstrap runs, so this function always finds the DB
|
||||
// available.
|
||||
func MdbTableInit() {
|
||||
once.Do(func() {
|
||||
if err := Mdb.AutoMigrate(&mdb.Orders{}); err != nil {
|
||||
color.Red.Printf("[store_db] AutoMigrate DB(Orders),err=%s\n", err)
|
||||
// panic(err)
|
||||
return
|
||||
migrations := []struct {
|
||||
name string
|
||||
model interface{}
|
||||
}{
|
||||
{"Orders", &mdb.Orders{}},
|
||||
{"WalletAddress", &mdb.WalletAddress{}},
|
||||
{"AdminUser", &mdb.AdminUser{}},
|
||||
{"ApiKey", &mdb.ApiKey{}},
|
||||
{"Setting", &mdb.Setting{}},
|
||||
{"NotificationChannel", &mdb.NotificationChannel{}},
|
||||
{"Chain", &mdb.Chain{}},
|
||||
{"ChainToken", &mdb.ChainToken{}},
|
||||
{"RpcNode", &mdb.RpcNode{}},
|
||||
}
|
||||
if err := Mdb.AutoMigrate(&mdb.WalletAddress{}); err != nil {
|
||||
color.Red.Printf("[store_db] AutoMigrate DB(Orders),err=%s\n", err)
|
||||
// panic(err)
|
||||
return
|
||||
}
|
||||
if err := Mdb.AutoMigrate(&mdb.SupportedAsset{}); err != nil {
|
||||
color.Red.Printf("[store_db] AutoMigrate DB(SupportedAsset),err=%s\n", err)
|
||||
return
|
||||
for _, m := range migrations {
|
||||
if err := Mdb.AutoMigrate(m.model); err != nil {
|
||||
color.Red.Printf("[store_db] AutoMigrate DB(%s),err=%s\n", m.name, err)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
seedSupportedAssets()
|
||||
seedChains()
|
||||
seedRpcNodes()
|
||||
seedChainTokens()
|
||||
seedDefaultSettings()
|
||||
seedTelegramChannelFromSettings()
|
||||
})
|
||||
}
|
||||
|
||||
func seedSupportedAssets() {
|
||||
var cnt int64
|
||||
if err := Mdb.Model(&mdb.SupportedAsset{}).Count(&cnt).Error; err != nil {
|
||||
color.Red.Printf("[store_db] Count SupportedAsset,err=%s\n", err)
|
||||
return
|
||||
// seedChains inserts the built-in networks as enabled rows. Uses
|
||||
// ON CONFLICT DO NOTHING so re-runs are no-ops and admin edits persist.
|
||||
func seedChains() {
|
||||
defaults := []mdb.Chain{
|
||||
{Network: mdb.NetworkTron, DisplayName: "TRON", Enabled: true, MinConfirmations: 1, ScanIntervalSec: 5},
|
||||
{Network: mdb.NetworkEthereum, DisplayName: "Ethereum", Enabled: true, MinConfirmations: 3, ScanIntervalSec: 5},
|
||||
{Network: mdb.NetworkSolana, DisplayName: "Solana", Enabled: true, MinConfirmations: 1, ScanIntervalSec: 5},
|
||||
{Network: mdb.NetworkBsc, DisplayName: "BSC", Enabled: true, MinConfirmations: 3, ScanIntervalSec: 5},
|
||||
{Network: mdb.NetworkPolygon, DisplayName: "Polygon", Enabled: true, MinConfirmations: 3, ScanIntervalSec: 5},
|
||||
{Network: mdb.NetworkPlasma, DisplayName: "Plasma", Enabled: true, MinConfirmations: 1, ScanIntervalSec: 5},
|
||||
}
|
||||
if cnt > 0 {
|
||||
return
|
||||
}
|
||||
defaults := []mdb.SupportedAsset{
|
||||
{Network: mdb.NetworkTron, Token: "TRX", Status: mdb.TokenStatusEnable},
|
||||
{Network: mdb.NetworkTron, Token: "USDT", Status: mdb.TokenStatusEnable},
|
||||
{Network: mdb.NetworkSolana, Token: "SOL", Status: mdb.TokenStatusEnable},
|
||||
{Network: mdb.NetworkSolana, Token: "USDT", Status: mdb.TokenStatusEnable},
|
||||
{Network: mdb.NetworkSolana, Token: "USDC", Status: mdb.TokenStatusEnable},
|
||||
{Network: mdb.NetworkEthereum, Token: "USDT", Status: mdb.TokenStatusEnable},
|
||||
{Network: mdb.NetworkEthereum, Token: "USDC", Status: mdb.TokenStatusEnable},
|
||||
{Network: mdb.NetworkBsc, Token: "USDT", Status: mdb.TokenStatusEnable},
|
||||
{Network: mdb.NetworkBsc, Token: "USDC", Status: mdb.TokenStatusEnable},
|
||||
{Network: mdb.NetworkPolygon, Token: "USDT", Status: mdb.TokenStatusEnable},
|
||||
{Network: mdb.NetworkPolygon, Token: "USDC", Status: mdb.TokenStatusEnable},
|
||||
{Network: mdb.NetworkPlasma, Token: "USDT", Status: mdb.TokenStatusEnable},
|
||||
}
|
||||
if err := Mdb.Create(&defaults).Error; err != nil {
|
||||
color.Red.Printf("[store_db] Seed SupportedAsset,err=%s\n", err)
|
||||
if err := Mdb.Clauses(clause.OnConflict{DoNothing: true}).Create(&defaults).Error; err != nil {
|
||||
color.Red.Printf("[store_db] seed chains err=%s\n", err)
|
||||
}
|
||||
}
|
||||
|
||||
// seedChainTokens inserts default token configurations for each chain.
|
||||
// ON CONFLICT DO NOTHING on the (network, symbol) unique index so
|
||||
// re-runs are no-ops and admin edits to contract_address / decimals /
|
||||
// min_amount persist across restarts.
|
||||
func seedChainTokens() {
|
||||
// Contract-address rows are matched by scanners via exact contract
|
||||
// lookup. Native-token rows (empty contract_address) are matched by
|
||||
// symbol — these act as admin toggles for the native payment path.
|
||||
defaults := []mdb.ChainToken{
|
||||
// TRON
|
||||
{Network: mdb.NetworkTron, Symbol: "USDT", ContractAddress: "TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6t", Decimals: 6, Enabled: true},
|
||||
{Network: mdb.NetworkTron, Symbol: "TRX", ContractAddress: "", Decimals: 6, Enabled: true},
|
||||
// Ethereum
|
||||
{Network: mdb.NetworkEthereum, Symbol: "USDT", ContractAddress: "0xdAC17F958D2ee523a2206206994597C13D831ec7", Decimals: 6, Enabled: true},
|
||||
{Network: mdb.NetworkEthereum, Symbol: "USDC", ContractAddress: "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", Decimals: 6, Enabled: true},
|
||||
// Solana
|
||||
{Network: mdb.NetworkSolana, Symbol: "USDT", ContractAddress: "Es9vMFrzaCERmJfrF4H2FYD4KCoNkY11McCe8BenwNYB", Decimals: 6, Enabled: true},
|
||||
{Network: mdb.NetworkSolana, Symbol: "USDC", ContractAddress: "EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v", Decimals: 6, Enabled: true},
|
||||
{Network: mdb.NetworkSolana, Symbol: "SOL", ContractAddress: "", Decimals: 9, Enabled: true},
|
||||
// BSC (BEP20 USDT/USDC are 18 decimals, unlike their Ethereum counterparts)
|
||||
{Network: mdb.NetworkBsc, Symbol: "USDT", ContractAddress: "0x55d398326f99059fF775485246999027B3197955", Decimals: 18, Enabled: true},
|
||||
{Network: mdb.NetworkBsc, Symbol: "USDC", ContractAddress: "0x8AC76a51cc950d9822D68b83fE1Ad97B32Cd580d", Decimals: 18, Enabled: true},
|
||||
// Polygon
|
||||
{Network: mdb.NetworkPolygon, Symbol: "USDT", ContractAddress: "0xc2132D05D31c914a87C6611C10748AEb04B58e8F", Decimals: 6, Enabled: true},
|
||||
{Network: mdb.NetworkPolygon, Symbol: "USDC", ContractAddress: "0x3c499c542cEF5E3811e1192ce70d8cC03d5c3359", Decimals: 6, Enabled: true},
|
||||
{Network: mdb.NetworkPolygon, Symbol: "USDC.e", ContractAddress: "0x2791Bca1f2de4661ED88A30C99A7a9449Aa84174", Decimals: 6, Enabled: true},
|
||||
// Plasma
|
||||
{Network: mdb.NetworkPlasma, Symbol: "USDT", ContractAddress: "0xB8CE59FC3717ada4C02eaDF9682A9e934F625ebb", Decimals: 6, Enabled: true},
|
||||
}
|
||||
if err := Mdb.Clauses(clause.OnConflict{DoNothing: true}).Create(&defaults).Error; err != nil {
|
||||
color.Red.Printf("[store_db] seed chain_tokens err=%s\n", err)
|
||||
}
|
||||
}
|
||||
|
||||
// seedRpcNodes inserts default RPC endpoints for each chain. Checks per
|
||||
// (network, url) so re-runs are idempotent and admin edits persist.
|
||||
func seedRpcNodes() {
|
||||
defaults := []mdb.RpcNode{
|
||||
{Network: mdb.NetworkTron, Url: "https://api.trongrid.io", Type: mdb.RpcNodeTypeHttp, Weight: 1, Enabled: true, Status: mdb.RpcNodeStatusUnknown},
|
||||
{Network: mdb.NetworkEthereum, Url: "wss://ethereum.publicnode.com", Type: mdb.RpcNodeTypeWs, Weight: 1, Enabled: true, Status: mdb.RpcNodeStatusUnknown},
|
||||
{Network: mdb.NetworkSolana, Url: "https://api.mainnet-beta.solana.com", Type: mdb.RpcNodeTypeHttp, Weight: 1, Enabled: true, Status: mdb.RpcNodeStatusUnknown},
|
||||
{Network: mdb.NetworkBsc, Url: "wss://bsc.drpc.org", Type: mdb.RpcNodeTypeWs, Weight: 1, Enabled: true, Status: mdb.RpcNodeStatusUnknown},
|
||||
{Network: mdb.NetworkPolygon, Url: "wss://polygon-bor-rpc.publicnode.com", Type: mdb.RpcNodeTypeWs, Weight: 1, Enabled: true, Status: mdb.RpcNodeStatusUnknown},
|
||||
{Network: mdb.NetworkPlasma, Url: "wss://rpc.plasma.to", Type: mdb.RpcNodeTypeWs, Weight: 1, Enabled: true, Status: mdb.RpcNodeStatusUnknown},
|
||||
}
|
||||
for _, d := range defaults {
|
||||
var count int64
|
||||
if err := Mdb.Model(&mdb.RpcNode{}).
|
||||
Where("network = ? AND url = ?", d.Network, d.Url).
|
||||
Count(&count).Error; err != nil {
|
||||
color.Red.Printf("[store_db] seed rpc_nodes check err=%s\n", err)
|
||||
continue
|
||||
}
|
||||
if count > 0 {
|
||||
continue
|
||||
}
|
||||
if err := Mdb.Create(&d).Error; err != nil {
|
||||
color.Red.Printf("[store_db] seed rpc_nodes err=%s\n", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// seedDefaultSettings inserts built-in default settings.
|
||||
// Uses ON CONFLICT DO NOTHING so admin edits persist across restarts.
|
||||
func seedDefaultSettings() {
|
||||
defaults := []mdb.Setting{
|
||||
{Group: mdb.SettingGroupEpay, Key: mdb.SettingKeyEpayDefaultToken, Value: "usdt", Type: mdb.SettingTypeString},
|
||||
{Group: mdb.SettingGroupEpay, Key: mdb.SettingKeyEpayDefaultCurrency, Value: "cny", Type: mdb.SettingTypeString},
|
||||
{Group: mdb.SettingGroupEpay, Key: mdb.SettingKeyEpayDefaultNetwork, Value: "tron", Type: mdb.SettingTypeString},
|
||||
}
|
||||
if err := Mdb.Clauses(clause.OnConflict{DoNothing: true}).Create(&defaults).Error; err != nil {
|
||||
color.Red.Printf("[store_db] seed default settings err=%s\n", err)
|
||||
}
|
||||
}
|
||||
|
||||
// seedTelegramChannelFromSettings auto-migrates the legacy settings-table
|
||||
// Telegram config into a notification_channels row so the notify dispatcher
|
||||
// can use it. Idempotent: no-op when any telegram channel already exists.
|
||||
//
|
||||
// Legacy keys read from settings:
|
||||
//
|
||||
// system.telegram_bot_token → TelegramConfig.BotToken
|
||||
// system.telegram_chat_id → TelegramConfig.ChatID
|
||||
// system.telegram_payment_notice_enabled → events.pay_success
|
||||
// system.telegram_abnormal_notice_enabled → events.order_expired
|
||||
func seedTelegramChannelFromSettings() {
|
||||
var count int64
|
||||
if err := Mdb.Model(&mdb.NotificationChannel{}).
|
||||
Where("type = ?", mdb.NotificationTypeTelegram).
|
||||
Count(&count).Error; err != nil {
|
||||
color.Red.Printf("[store_db] seed telegram channel check err=%s\n", err)
|
||||
return
|
||||
}
|
||||
if count > 0 {
|
||||
return
|
||||
}
|
||||
|
||||
keys := []string{
|
||||
"system.telegram_bot_token",
|
||||
"system.telegram_chat_id",
|
||||
"system.telegram_payment_notice_enabled",
|
||||
"system.telegram_abnormal_notice_enabled",
|
||||
}
|
||||
var rows []mdb.Setting
|
||||
if err := Mdb.Where("key IN ?", keys).Find(&rows).Error; err != nil {
|
||||
color.Red.Printf("[store_db] seed telegram channel: read settings err=%s\n", err)
|
||||
return
|
||||
}
|
||||
m := make(map[string]string, len(rows))
|
||||
for _, r := range rows {
|
||||
m[r.Key] = r.Value
|
||||
}
|
||||
|
||||
botToken := strings.TrimSpace(m["system.telegram_bot_token"])
|
||||
chatIDStr := strings.TrimSpace(m["system.telegram_chat_id"])
|
||||
if botToken == "" || chatIDStr == "" {
|
||||
return // No legacy telegram config to migrate.
|
||||
}
|
||||
|
||||
chatID, err := strconv.ParseInt(chatIDStr, 10, 64)
|
||||
if err != nil {
|
||||
color.Red.Printf("[store_db] seed telegram channel: invalid chat_id=%q\n", chatIDStr)
|
||||
return
|
||||
}
|
||||
|
||||
paymentEnabled, _ := strconv.ParseBool(m["system.telegram_payment_notice_enabled"])
|
||||
abnormalEnabled, _ := strconv.ParseBool(m["system.telegram_abnormal_notice_enabled"])
|
||||
|
||||
configJSON, _ := json.Marshal(map[string]interface{}{
|
||||
"bot_token": botToken,
|
||||
"chat_id": chatID,
|
||||
"proxy": "",
|
||||
})
|
||||
eventsJSON, _ := json.Marshal(map[string]bool{
|
||||
mdb.NotifyEventPaySuccess: paymentEnabled,
|
||||
mdb.NotifyEventOrderExpired: abnormalEnabled,
|
||||
mdb.NotifyEventDailyReport: false,
|
||||
})
|
||||
|
||||
ch := mdb.NotificationChannel{
|
||||
Type: mdb.NotificationTypeTelegram,
|
||||
Name: "Telegram",
|
||||
Config: string(configJSON),
|
||||
Events: string(eventsJSON),
|
||||
Enabled: true,
|
||||
}
|
||||
if err := Mdb.Create(&ch).Error; err != nil {
|
||||
color.Red.Printf("[store_db] seed telegram channel err=%s\n", err)
|
||||
} else {
|
||||
color.Green.Printf("[store_db] migrated legacy telegram settings to notification_channels (id=%d)\n", ch.ID)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user