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:
@@ -0,0 +1,21 @@
|
||||
package mdb
|
||||
|
||||
import "github.com/dromara/carbon/v2"
|
||||
|
||||
const (
|
||||
AdminUserStatusEnable = 1
|
||||
AdminUserStatusDisable = 2
|
||||
)
|
||||
|
||||
type AdminUser struct {
|
||||
Username string `gorm:"column:username;uniqueIndex:admin_users_username_uindex;size:64" json:"username" example:"admin"`
|
||||
PasswordHash string `gorm:"column:password_hash;size:255" json:"-"`
|
||||
// 状态 1=启用 2=禁用
|
||||
Status int `gorm:"column:status;default:1" json:"status" enums:"1,2" example:"1"`
|
||||
LastLoginAt carbon.Time `gorm:"column:last_login_at" json:"last_login_at" example:"2026-04-16 12:00:00"`
|
||||
BaseModel
|
||||
}
|
||||
|
||||
func (a *AdminUser) TableName() string {
|
||||
return "admin_users"
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
package mdb
|
||||
|
||||
import "github.com/dromara/carbon/v2"
|
||||
|
||||
const (
|
||||
ApiKeyStatusEnable = 1
|
||||
ApiKeyStatusDisable = 2
|
||||
)
|
||||
|
||||
// ApiKey stores a universal merchant credential (PID + secret) that is
|
||||
// valid for both EPAY and GMPAY payment flows. Identification at
|
||||
// request time is always by PID; the gateway flow is chosen by the
|
||||
// route (not by any property of the row).
|
||||
type ApiKey struct {
|
||||
Name string `gorm:"column:name;size:128" json:"name" example:"My API Key"`
|
||||
Pid string `gorm:"column:pid;size:128;uniqueIndex:api_keys_pid_uindex" json:"pid" example:"1000"`
|
||||
SecretKey string `gorm:"column:secret_key;size:255" json:"-"`
|
||||
IpWhitelist string `gorm:"column:ip_whitelist;type:text" json:"ip_whitelist" example:"192.168.1.0/24,10.0.0.1"`
|
||||
NotifyUrl string `gorm:"column:notify_url;size:512" json:"notify_url" example:"https://example.com/notify"`
|
||||
// 状态 1=启用 2=禁用
|
||||
Status int `gorm:"column:status;default:1" json:"status" enums:"1,2" example:"1"`
|
||||
CallCount int64 `gorm:"column:call_count;default:0" json:"call_count" example:"342"`
|
||||
LastUsedAt carbon.Time `gorm:"column:last_used_at" json:"last_used_at" example:"2026-04-16 12:00:00"`
|
||||
BaseModel
|
||||
}
|
||||
|
||||
func (a *ApiKey) TableName() string {
|
||||
return "api_keys"
|
||||
}
|
||||
@@ -6,8 +6,8 @@ import (
|
||||
)
|
||||
|
||||
type BaseModel struct {
|
||||
ID uint64 `gorm:"column:id;primary_key;autoIncrement" json:"id"`
|
||||
CreatedAt carbon.Time `gorm:"column:created_at" json:"created_at"`
|
||||
UpdatedAt carbon.Time `gorm:"column:updated_at" json:"updated_at"`
|
||||
ID uint64 `gorm:"column:id;primary_key;autoIncrement" json:"id" example:"1"`
|
||||
CreatedAt carbon.Time `gorm:"column:created_at" json:"created_at" example:"2026-04-16 12:00:00"`
|
||||
UpdatedAt carbon.Time `gorm:"column:updated_at" json:"updated_at" example:"2026-04-16 12:00:00"`
|
||||
DeletedAt gorm.DeletedAt `gorm:"index" json:"deleted_at"`
|
||||
}
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
package mdb
|
||||
|
||||
// Chain represents an enabled blockchain network.
|
||||
// Scanner tasks read this table on each polling tick to decide whether to
|
||||
// process the network. When Enabled=false the scanner skips the chain
|
||||
// entirely without restarting.
|
||||
type Chain struct {
|
||||
Network string `gorm:"column:network;uniqueIndex:chains_network_uindex;size:32" json:"network" example:"tron"`
|
||||
DisplayName string `gorm:"column:display_name;size:64" json:"display_name" example:"Tron"`
|
||||
Enabled bool `gorm:"column:enabled;default:true" json:"enabled" example:"true"`
|
||||
MinConfirmations int `gorm:"column:min_confirmations;default:1" json:"min_confirmations" example:"20"`
|
||||
ScanIntervalSec int `gorm:"column:scan_interval_sec;default:5" json:"scan_interval_sec" example:"5"`
|
||||
Extra string `gorm:"column:extra;type:text" json:"extra" example:"{}"`
|
||||
BaseModel
|
||||
}
|
||||
|
||||
func (c *Chain) TableName() string {
|
||||
return "chains"
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
package mdb
|
||||
|
||||
const (
|
||||
ChainTokenStatusEnable = 1
|
||||
ChainTokenStatusDisable = 2
|
||||
)
|
||||
|
||||
// ChainToken describes a token to watch on a given chain. Replaces the
|
||||
// hardcoded USDT/USDC contract addresses in task/listen_eth.go. Scanners
|
||||
// load this table on startup (and refresh periodically) to learn which
|
||||
// contracts to subscribe to.
|
||||
type ChainToken struct {
|
||||
Network string `gorm:"column:network;size:32;uniqueIndex:chain_tokens_network_symbol_uindex,priority:1" json:"network" example:"tron"`
|
||||
Symbol string `gorm:"column:symbol;size:32;uniqueIndex:chain_tokens_network_symbol_uindex,priority:2" json:"symbol" example:"USDT"`
|
||||
ContractAddress string `gorm:"column:contract_address;size:128" json:"contract_address" example:"TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6t"`
|
||||
Decimals int `gorm:"column:decimals;default:6" json:"decimals" example:"6"`
|
||||
Enabled bool `gorm:"column:enabled;default:true" json:"enabled" example:"true"`
|
||||
MinAmount float64 `gorm:"column:min_amount;default:0" json:"min_amount" example:"1.0"`
|
||||
BaseModel
|
||||
}
|
||||
|
||||
func (c *ChainToken) TableName() string {
|
||||
return "chain_tokens"
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
package mdb
|
||||
|
||||
const (
|
||||
NotificationTypeTelegram = "telegram"
|
||||
NotificationTypeWebhook = "webhook"
|
||||
NotificationTypeEmail = "email"
|
||||
)
|
||||
|
||||
const (
|
||||
NotificationStatusEnable = 1
|
||||
NotificationStatusDisable = 2
|
||||
)
|
||||
|
||||
// Notification events (JSON keys in Events column).
|
||||
const (
|
||||
NotifyEventPaySuccess = "pay_success"
|
||||
NotifyEventOrderExpired = "order_expired"
|
||||
NotifyEventDailyReport = "daily_report"
|
||||
)
|
||||
|
||||
// NotificationChannel represents one push target instance.
|
||||
// Config/Events are JSON strings consumed by the notify dispatcher.
|
||||
// For telegram, Config = {"bot_token":"...","chat_id":"...","proxy":"..."}.
|
||||
// For webhook, Config = {"url":"...","headers":{...},"secret":"..."}.
|
||||
type NotificationChannel struct {
|
||||
Type string `gorm:"column:type;size:32;index:notification_channels_type_enabled_index,priority:1" json:"type" enums:"telegram,webhook,email" example:"telegram"`
|
||||
Name string `gorm:"column:name;size:128" json:"name" example:"主通知群"`
|
||||
Config string `gorm:"column:config;type:text" json:"config" example:"{\"bot_token\":\"123:ABC\",\"chat_id\":\"456\"}"`
|
||||
Events string `gorm:"column:events;type:text" json:"events" example:"{\"pay_success\":true,\"order_expired\":true}"`
|
||||
Enabled bool `gorm:"column:enabled;default:true;index:notification_channels_type_enabled_index,priority:2" json:"enabled" example:"true"`
|
||||
BaseModel
|
||||
}
|
||||
|
||||
func (n *NotificationChannel) TableName() string {
|
||||
return "notification_channels"
|
||||
}
|
||||
+23
-17
@@ -13,24 +13,30 @@ const (
|
||||
)
|
||||
|
||||
type Orders struct {
|
||||
TradeId string `gorm:"column:trade_id;uniqueIndex:orders_trade_id_uindex" json:"trade_id"`
|
||||
OrderId string `gorm:"column:order_id;uniqueIndex:orders_order_id_uindex" json:"order_id"` // the order id is generated by client, and will notify client when order is paid
|
||||
TradeId string `gorm:"column:trade_id;uniqueIndex:orders_trade_id_uindex" json:"trade_id" example:"T2026041612345678"`
|
||||
OrderId string `gorm:"column:order_id;uniqueIndex:orders_order_id_uindex" json:"order_id" example:"ORD20260416001"`
|
||||
ParentTradeId string `gorm:"column:parent_trade_id;index:idx_orders_parent_trade_id;default:''" json:"parent_trade_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"`
|
||||
Currency string `gorm:"column:currency" json:"currency"`
|
||||
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"`
|
||||
Network string `gorm:"column:network" json:"network"`
|
||||
Status int `gorm:"column:status;default:1" json:"status"`
|
||||
NotifyUrl string `gorm:"column:notify_url" json:"notify_url"`
|
||||
RedirectUrl string `gorm:"column:redirect_url" json:"redirect_url"`
|
||||
Name string `gorm:"column:name" json:"name"`
|
||||
CallbackNum int `gorm:"column:callback_num;default:0" json:"callback_num"`
|
||||
CallBackConfirm int `gorm:"column:callback_confirm;default:2" json:"callback_confirm"`
|
||||
IsSelected bool `gorm:"column:is_selected;default:false" json:"is_selected"`
|
||||
PaymentType string `gorm:"column:payment_type" json:"payment_type"`
|
||||
BlockTransactionId string `gorm:"index:orders_block_transaction_id_index;column:block_transaction_id" json:"block_transaction_id" example:"0xabc123..."`
|
||||
Amount float64 `gorm:"column:amount" json:"amount" example:"100.0000"`
|
||||
Currency string `gorm:"column:currency" json:"currency" example:"CNY"`
|
||||
ActualAmount float64 `gorm:"column:actual_amount" json:"actual_amount" example:"14.2857"`
|
||||
ReceiveAddress string `gorm:"column:receive_address" json:"receive_address" example:"TTestTronAddress001"`
|
||||
Token string `gorm:"column:token" json:"token" example:"USDT"`
|
||||
Network string `gorm:"column:network" json:"network" example:"tron"`
|
||||
// 订单状态 1=等待支付 2=支付成功 3=已过期
|
||||
Status int `gorm:"column:status;default:1" json:"status" enums:"1,2,3" example:"1"`
|
||||
NotifyUrl string `gorm:"column:notify_url" json:"notify_url" example:"https://example.com/notify"`
|
||||
RedirectUrl string `gorm:"column:redirect_url" json:"redirect_url" example:"https://example.com/success"`
|
||||
Name string `gorm:"column:name" json:"name" example:"VIP月卡"`
|
||||
CallbackNum int `gorm:"column:callback_num;default:0" json:"callback_num" example:"0"`
|
||||
// 回调确认状态 1=回调成功 2=未回调/回调失败
|
||||
CallBackConfirm int `gorm:"column:callback_confirm;default:2" json:"callback_confirm" enums:"1,2" example:"2"`
|
||||
IsSelected bool `gorm:"column:is_selected;default:false" json:"is_selected" example:"false"`
|
||||
PaymentType string `gorm:"column:payment_type" json:"payment_type" example:"Epay"`
|
||||
ApiKeyID uint64 `gorm:"column:api_key_id;default:0;index:orders_api_key_id_index" json:"api_key_id" example:"1"`
|
||||
// PayBySubId holds the primary-key ID of the sub-order that settled this parent order.
|
||||
// Zero when the parent order was paid directly (no sub-order involved).
|
||||
PayBySubId uint64 `gorm:"column:pay_by_sub_id;default:0" json:"pay_by_sub_id" example:"0"`
|
||||
BaseModel
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
package mdb
|
||||
|
||||
import "github.com/dromara/carbon/v2"
|
||||
|
||||
const (
|
||||
RpcNodeTypeHttp = "http"
|
||||
RpcNodeTypeWs = "ws"
|
||||
)
|
||||
|
||||
const (
|
||||
RpcNodeStatusUnknown = "unknown"
|
||||
RpcNodeStatusOk = "ok"
|
||||
RpcNodeStatusDown = "down"
|
||||
)
|
||||
|
||||
// RpcNode records one RPC endpoint for a network. Clients select a node
|
||||
// by weight among rows where Enabled=true AND Status=ok. A periodic
|
||||
// task/rpc_health_job writes Status/LastLatencyMs/LastCheckedAt.
|
||||
type RpcNode struct {
|
||||
Network string `gorm:"column:network;size:32;index:rpc_nodes_network_status_index,priority:1" json:"network" example:"tron"`
|
||||
Url string `gorm:"column:url;size:512" json:"url" example:"https://api.trongrid.io"`
|
||||
// 连接类型 http=HTTP请求 ws=WebSocket长连接
|
||||
Type string `gorm:"column:type;size:16" json:"type" enums:"http,ws" example:"http"`
|
||||
Weight int `gorm:"column:weight;default:1" json:"weight" example:"1"`
|
||||
ApiKey string `gorm:"column:api_key;size:255" json:"api_key" example:"your-api-key"`
|
||||
Enabled bool `gorm:"column:enabled;default:true" json:"enabled" example:"true"`
|
||||
// 健康状态 unknown=未知 ok=正常 down=异常
|
||||
Status string `gorm:"column:status;size:16;default:unknown;index:rpc_nodes_network_status_index,priority:2" json:"status" enums:"unknown,ok,down" example:"ok"`
|
||||
LastLatencyMs int `gorm:"column:last_latency_ms;default:-1" json:"last_latency_ms" example:"120"`
|
||||
LastCheckedAt carbon.Time `gorm:"column:last_checked_at" json:"last_checked_at" example:"2026-04-16 12:00:00"`
|
||||
BaseModel
|
||||
}
|
||||
|
||||
func (r *RpcNode) TableName() string {
|
||||
return "rpc_nodes"
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
package mdb
|
||||
|
||||
// Setting stores non-credential runtime configuration as key/value pairs.
|
||||
// Groups: brand, rate, system, epay. Merchant credentials (pid + secret_key)
|
||||
// live in the api_keys table; notification configs live in NotificationChannel.
|
||||
// Default rows for the epay group are seeded on first startup (see
|
||||
// model/dao/mdb_table_init.go seedDefaultSettings). All other groups start
|
||||
// empty and fall back to hardcoded defaults until an admin sets them.
|
||||
// Only exception: system.jwt_secret is auto-generated on first startup.
|
||||
const (
|
||||
SettingGroupBrand = "brand"
|
||||
SettingGroupRate = "rate"
|
||||
SettingGroupSystem = "system"
|
||||
SettingGroupEpay = "epay"
|
||||
)
|
||||
|
||||
const (
|
||||
SettingTypeString = "string"
|
||||
SettingTypeInt = "int"
|
||||
SettingTypeBool = "bool"
|
||||
SettingTypeJSON = "json"
|
||||
)
|
||||
|
||||
const (
|
||||
SettingKeyJwtSecret = "system.jwt_secret"
|
||||
SettingKeyOrderExpiration = "system.order_expiration_time"
|
||||
SettingKeyBrandSiteName = "brand.site_name"
|
||||
SettingKeyBrandLogoUrl = "brand.logo_url"
|
||||
SettingKeyBrandPageTitle = "brand.page_title"
|
||||
SettingKeyBrandPaySuccess = "brand.pay_success_text"
|
||||
SettingKeyBrandSupportUrl = "brand.support_url"
|
||||
SettingKeyRateForcedUsdt = "rate.forced_usdt_rate"
|
||||
SettingKeyRateAdjustPercent = "rate.adjust_percent"
|
||||
SettingKeyRateOkxC2cEnabled = "rate.okx_c2c_enabled"
|
||||
SettingKeyRateApiUrl = "rate.api_url"
|
||||
|
||||
// EPAY route defaults — can be overridden via admin settings.
|
||||
SettingKeyEpayDefaultToken = "epay.default_token"
|
||||
SettingKeyEpayDefaultCurrency = "epay.default_currency"
|
||||
SettingKeyEpayDefaultNetwork = "epay.default_network"
|
||||
)
|
||||
|
||||
type Setting struct {
|
||||
Group string `gorm:"column:group;size:32;index:settings_group_index" json:"group" enums:"brand,rate,system" example:"rate"`
|
||||
Key string `gorm:"column:key;uniqueIndex:settings_key_uindex;size:128" json:"key" example:"rate.forced_usdt_rate"`
|
||||
Value string `gorm:"column:value;type:text" json:"value" example:"7.2"`
|
||||
Type string `gorm:"column:type;size:16;default:string" json:"type" enums:"string,int,bool,json" example:"string"`
|
||||
Description string `gorm:"column:description;size:255" json:"description" example:"强制USDT汇率"`
|
||||
BaseModel
|
||||
}
|
||||
|
||||
func (s *Setting) TableName() string {
|
||||
return "settings"
|
||||
}
|
||||
@@ -1,12 +0,0 @@
|
||||
package mdb
|
||||
|
||||
type SupportedAsset struct {
|
||||
Network string `gorm:"column:network;uniqueIndex:supported_asset_network_token_uindex,priority:1" json:"network"`
|
||||
Token string `gorm:"column:token;uniqueIndex:supported_asset_network_token_uindex,priority:2" json:"token"`
|
||||
Status int64 `gorm:"column:status;default:1" json:"status"`
|
||||
BaseModel
|
||||
}
|
||||
|
||||
func (s *SupportedAsset) TableName() string {
|
||||
return "supported_asset"
|
||||
}
|
||||
@@ -14,10 +14,18 @@ const (
|
||||
NetworkPlasma = "plasma"
|
||||
)
|
||||
|
||||
const (
|
||||
WalletSourceManual = "manual"
|
||||
WalletSourceImport = "import"
|
||||
)
|
||||
|
||||
type WalletAddress struct {
|
||||
Network string `gorm:"column:network;uniqueIndex:wallet_address_network_address_uindex" json:"network"`
|
||||
Address string `gorm:"column:address;uniqueIndex:wallet_address_network_address_uindex" json:"address"`
|
||||
Status int64 `gorm:"column:status;default:1" json:"status"`
|
||||
Network string `gorm:"column:network;uniqueIndex:wallet_address_network_address_uindex" json:"network" example:"tron"`
|
||||
Address string `gorm:"column:address;uniqueIndex:wallet_address_network_address_uindex" json:"address" example:"TTestTronAddress001"`
|
||||
// 状态 1=启用 2=禁用
|
||||
Status int64 `gorm:"column:status;default:1" json:"status" enums:"1,2" example:"1"`
|
||||
Remark string `gorm:"column:remark;size:255" json:"remark" example:"主钱包"`
|
||||
Source string `gorm:"column:source;size:16;default:manual" json:"source" enums:"manual,import" example:"manual"`
|
||||
BaseModel
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user