mirror of
https://github.com/GMWalletApp/epusdt.git
synced 2026-07-07 10:16:15 +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,93 @@
|
||||
package data
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"encoding/hex"
|
||||
"strings"
|
||||
|
||||
"github.com/assimon/luuu/model/dao"
|
||||
"github.com/assimon/luuu/model/mdb"
|
||||
"github.com/dromara/carbon/v2"
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
)
|
||||
|
||||
const defaultAdminUsername = "admin"
|
||||
|
||||
// EnsureDefaultAdmin seeds an initial admin account when no admin user
|
||||
// exists. The password is randomly generated and returned so the caller
|
||||
// can print it to the console. Idempotent — subsequent calls return
|
||||
// ("", false, nil).
|
||||
func EnsureDefaultAdmin() (password string, created bool, err error) {
|
||||
var count int64
|
||||
if err := dao.Mdb.Model(&mdb.AdminUser{}).Count(&count).Error; err != nil {
|
||||
return "", false, err
|
||||
}
|
||||
if count > 0 {
|
||||
return "", false, nil
|
||||
}
|
||||
password = randomAdminPassword()
|
||||
hash, err := HashPassword(password)
|
||||
if err != nil {
|
||||
return "", false, err
|
||||
}
|
||||
user := &mdb.AdminUser{
|
||||
Username: defaultAdminUsername,
|
||||
PasswordHash: hash,
|
||||
Status: mdb.AdminUserStatusEnable,
|
||||
}
|
||||
return password, true, dao.Mdb.Create(user).Error
|
||||
}
|
||||
|
||||
func randomAdminPassword() string {
|
||||
b := make([]byte, 16)
|
||||
_, _ = rand.Read(b)
|
||||
return hex.EncodeToString(b)
|
||||
}
|
||||
|
||||
// HashPassword bcrypts a plaintext password.
|
||||
func HashPassword(plain string) (string, error) {
|
||||
b, err := bcrypt.GenerateFromPassword([]byte(plain), bcrypt.DefaultCost)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return string(b), nil
|
||||
}
|
||||
|
||||
// VerifyPassword compares a plaintext password against a bcrypt hash.
|
||||
func VerifyPassword(hash, plain string) bool {
|
||||
return bcrypt.CompareHashAndPassword([]byte(hash), []byte(plain)) == nil
|
||||
}
|
||||
|
||||
// GetAdminUserByUsername returns the row for a username (case-insensitive).
|
||||
func GetAdminUserByUsername(username string) (*mdb.AdminUser, error) {
|
||||
u := new(mdb.AdminUser)
|
||||
err := dao.Mdb.Model(u).
|
||||
Where("username = ?", strings.ToLower(strings.TrimSpace(username))).
|
||||
Limit(1).Find(u).Error
|
||||
return u, err
|
||||
}
|
||||
|
||||
// GetAdminUserByID returns the row for an ID.
|
||||
func GetAdminUserByID(id uint64) (*mdb.AdminUser, error) {
|
||||
u := new(mdb.AdminUser)
|
||||
err := dao.Mdb.Model(u).Limit(1).Find(u, id).Error
|
||||
return u, err
|
||||
}
|
||||
|
||||
// UpdateAdminUserPassword rehashes and persists a new password.
|
||||
func UpdateAdminUserPassword(id uint64, newPlain string) error {
|
||||
hash, err := HashPassword(newPlain)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return dao.Mdb.Model(&mdb.AdminUser{}).
|
||||
Where("id = ?", id).
|
||||
Update("password_hash", hash).Error
|
||||
}
|
||||
|
||||
// TouchAdminUserLastLogin stamps last_login_at to now.
|
||||
func TouchAdminUserLastLogin(id uint64) error {
|
||||
return dao.Mdb.Model(&mdb.AdminUser{}).
|
||||
Where("id = ?", id).
|
||||
Update("last_login_at", carbon.Now().StdTime()).Error
|
||||
}
|
||||
@@ -0,0 +1,137 @@
|
||||
package data
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"encoding/hex"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/assimon/luuu/model/dao"
|
||||
"github.com/assimon/luuu/model/mdb"
|
||||
"github.com/dromara/carbon/v2"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// GetEnabledApiKey looks up an enabled merchant credential row by PID.
|
||||
// A single row is valid for both EPAY and GMPAY flows; the route
|
||||
// determines the flow. Returns (row, nil) on success; (zero row, nil)
|
||||
// when no match; error only on true DB failure.
|
||||
func GetEnabledApiKey(pid string) (*mdb.ApiKey, error) {
|
||||
row := new(mdb.ApiKey)
|
||||
err := dao.Mdb.Model(row).
|
||||
Where("pid = ?", strings.TrimSpace(pid)).
|
||||
Where("status = ?", mdb.ApiKeyStatusEnable).
|
||||
Limit(1).Find(row).Error
|
||||
return row, err
|
||||
}
|
||||
|
||||
// GetApiKeyByID fetches a row by primary key (including disabled).
|
||||
func GetApiKeyByID(id uint64) (*mdb.ApiKey, error) {
|
||||
row := new(mdb.ApiKey)
|
||||
err := dao.Mdb.Model(row).Limit(1).Find(row, id).Error
|
||||
return row, err
|
||||
}
|
||||
|
||||
// ListApiKeys returns all rows ordered by ID descending.
|
||||
func ListApiKeys() ([]mdb.ApiKey, error) {
|
||||
var rows []mdb.ApiKey
|
||||
err := dao.Mdb.Model(&mdb.ApiKey{}).Order("id DESC").Find(&rows).Error
|
||||
return rows, err
|
||||
}
|
||||
|
||||
// CreateApiKey persists a new row.
|
||||
func CreateApiKey(row *mdb.ApiKey) error {
|
||||
return dao.Mdb.Create(row).Error
|
||||
}
|
||||
|
||||
// UpdateApiKeyFields updates a whitelist of mutable fields on a row.
|
||||
func UpdateApiKeyFields(id uint64, fields map[string]interface{}) error {
|
||||
if len(fields) == 0 {
|
||||
return nil
|
||||
}
|
||||
return dao.Mdb.Model(&mdb.ApiKey{}).Where("id = ?", id).Updates(fields).Error
|
||||
}
|
||||
|
||||
// DeleteApiKeyByID removes (soft-deletes) a row.
|
||||
func DeleteApiKeyByID(id uint64) error {
|
||||
return dao.Mdb.Where("id = ?", id).Delete(&mdb.ApiKey{}).Error
|
||||
}
|
||||
|
||||
// TouchApiKeyUsage increments call_count and stamps last_used_at.
|
||||
// Called from the signature middleware after a successful verify.
|
||||
func TouchApiKeyUsage(id uint64) error {
|
||||
return dao.Mdb.Model(&mdb.ApiKey{}).
|
||||
Where("id = ?", id).
|
||||
Updates(map[string]interface{}{
|
||||
"call_count": gorm.Expr("call_count + 1"),
|
||||
"last_used_at": carbon.Now().StdTime(),
|
||||
}).Error
|
||||
}
|
||||
|
||||
// basePid is the starting PID value for api_keys.
|
||||
const basePid = 1000
|
||||
|
||||
// NextPid returns the next available numeric PID for api_keys. Scans
|
||||
// ALL rows (including soft-deleted) to avoid reusing a PID whose row
|
||||
// is still in the table with a non-null deleted_at — the unique index
|
||||
// on pid doesn't know about soft deletes, so reuse would trigger a
|
||||
// DB-level unique violation on Create.
|
||||
func NextPid() (int, error) {
|
||||
var rows []mdb.ApiKey
|
||||
err := dao.Mdb.Unscoped().Model(&mdb.ApiKey{}).Select("pid").Find(&rows).Error
|
||||
if err != nil {
|
||||
return basePid, err
|
||||
}
|
||||
maxPid := basePid - 1
|
||||
for _, r := range rows {
|
||||
if pid, err := strconv.Atoi(strings.TrimSpace(r.Pid)); err == nil && pid > maxPid {
|
||||
maxPid = pid
|
||||
}
|
||||
}
|
||||
return maxPid + 1, nil
|
||||
}
|
||||
|
||||
// SeededApiKey carries the details of a newly created default key so
|
||||
// the caller can print them to the console (secret is never surfaced
|
||||
// again via the admin API — it has to be viewed via /api-keys/:id/secret
|
||||
// after login).
|
||||
type SeededApiKey struct {
|
||||
Pid string
|
||||
Name string
|
||||
SecretKey string
|
||||
}
|
||||
|
||||
// EnsureDefaultApiKey seeds ONE universal API key on a fresh install.
|
||||
// The seeded key is valid for all three gateway flows — the admin can
|
||||
// create additional keys later, each also universal. Idempotent: if
|
||||
// any api_keys row already exists, returns nil without seeding.
|
||||
func EnsureDefaultApiKey() (*SeededApiKey, error) {
|
||||
var count int64
|
||||
if err := dao.Mdb.Model(&mdb.ApiKey{}).Count(&count).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if count > 0 {
|
||||
return nil, nil
|
||||
}
|
||||
secret := generateHex(32)
|
||||
row := &mdb.ApiKey{
|
||||
Name: "default",
|
||||
Pid: strconv.Itoa(basePid),
|
||||
SecretKey: secret,
|
||||
Status: mdb.ApiKeyStatusEnable,
|
||||
}
|
||||
if err := dao.Mdb.Create(row).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &SeededApiKey{
|
||||
Pid: row.Pid,
|
||||
Name: row.Name,
|
||||
SecretKey: secret,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func generateHex(n int) string {
|
||||
b := make([]byte, n)
|
||||
_, _ = rand.Read(b)
|
||||
return hex.EncodeToString(b)
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
package data
|
||||
|
||||
import (
|
||||
"strings"
|
||||
|
||||
"github.com/assimon/luuu/model/dao"
|
||||
"github.com/assimon/luuu/model/mdb"
|
||||
)
|
||||
|
||||
// ListChains returns all chain rows ordered by id.
|
||||
func ListChains() ([]mdb.Chain, error) {
|
||||
var rows []mdb.Chain
|
||||
err := dao.Mdb.Model(&mdb.Chain{}).Order("id ASC").Find(&rows).Error
|
||||
return rows, err
|
||||
}
|
||||
|
||||
// ListEnabledChains returns only rows with Enabled=true.
|
||||
func ListEnabledChains() ([]mdb.Chain, error) {
|
||||
var rows []mdb.Chain
|
||||
err := dao.Mdb.Model(&mdb.Chain{}).
|
||||
Where("enabled = ?", true).
|
||||
Order("id ASC").Find(&rows).Error
|
||||
return rows, err
|
||||
}
|
||||
|
||||
// GetChainByNetwork fetches one row by network key.
|
||||
func GetChainByNetwork(network string) (*mdb.Chain, error) {
|
||||
row := new(mdb.Chain)
|
||||
err := dao.Mdb.Model(row).
|
||||
Where("network = ?", strings.ToLower(strings.TrimSpace(network))).
|
||||
Limit(1).Find(row).Error
|
||||
return row, err
|
||||
}
|
||||
|
||||
// IsChainEnabled returns whether the row exists and Enabled=true. A
|
||||
// missing row is treated as disabled — scanners must be able to
|
||||
// short-circuit even before the admin creates the chain entry.
|
||||
func IsChainEnabled(network string) bool {
|
||||
row, err := GetChainByNetwork(network)
|
||||
if err != nil || row.ID == 0 {
|
||||
return false
|
||||
}
|
||||
return row.Enabled
|
||||
}
|
||||
|
||||
// UpdateChainFields patches mutable columns.
|
||||
func UpdateChainFields(network string, fields map[string]interface{}) error {
|
||||
if len(fields) == 0 {
|
||||
return nil
|
||||
}
|
||||
return dao.Mdb.Model(&mdb.Chain{}).
|
||||
Where("network = ?", strings.ToLower(strings.TrimSpace(network))).
|
||||
Updates(fields).Error
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
package data
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"strings"
|
||||
|
||||
"github.com/assimon/luuu/model/dao"
|
||||
"github.com/assimon/luuu/model/mdb"
|
||||
)
|
||||
|
||||
// ListNotificationChannels returns all rows, optionally filtered by type.
|
||||
func ListNotificationChannels(channelType string) ([]mdb.NotificationChannel, error) {
|
||||
var rows []mdb.NotificationChannel
|
||||
tx := dao.Mdb.Model(&mdb.NotificationChannel{})
|
||||
if channelType != "" {
|
||||
tx = tx.Where("type = ?", strings.ToLower(channelType))
|
||||
}
|
||||
err := tx.Order("id DESC").Find(&rows).Error
|
||||
return rows, err
|
||||
}
|
||||
|
||||
// ListEnabledChannelsByEvent returns enabled rows whose Events JSON has
|
||||
// the named event key set to true. Done in Go (rather than JSON_EXTRACT)
|
||||
// because the runtime DB is DB-agnostic (MySQL/Postgres/SQLite).
|
||||
func ListEnabledChannelsByEvent(event string) ([]mdb.NotificationChannel, error) {
|
||||
var rows []mdb.NotificationChannel
|
||||
if err := dao.Mdb.Model(&mdb.NotificationChannel{}).
|
||||
Where("enabled = ?", true).
|
||||
Find(&rows).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out := rows[:0]
|
||||
for _, r := range rows {
|
||||
events := map[string]bool{}
|
||||
if r.Events != "" {
|
||||
_ = json.Unmarshal([]byte(r.Events), &events)
|
||||
}
|
||||
if events[event] {
|
||||
out = append(out, r)
|
||||
}
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// GetFirstEnabledTelegramChannel returns the oldest enabled telegram
|
||||
// row, or nil ID if none. Telegram bot startup uses this as the primary
|
||||
// instance backing /start and command handlers.
|
||||
func GetFirstEnabledTelegramChannel() (*mdb.NotificationChannel, error) {
|
||||
row := new(mdb.NotificationChannel)
|
||||
err := dao.Mdb.Model(row).
|
||||
Where("type = ?", mdb.NotificationTypeTelegram).
|
||||
Where("enabled = ?", true).
|
||||
Order("id ASC").
|
||||
Limit(1).Find(row).Error
|
||||
return row, err
|
||||
}
|
||||
|
||||
// GetNotificationChannelByID fetches by PK.
|
||||
func GetNotificationChannelByID(id uint64) (*mdb.NotificationChannel, error) {
|
||||
row := new(mdb.NotificationChannel)
|
||||
err := dao.Mdb.Model(row).Limit(1).Find(row, id).Error
|
||||
return row, err
|
||||
}
|
||||
|
||||
// CreateNotificationChannel inserts a new row.
|
||||
func CreateNotificationChannel(row *mdb.NotificationChannel) error {
|
||||
return dao.Mdb.Create(row).Error
|
||||
}
|
||||
|
||||
// UpdateNotificationChannelFields patches mutable columns.
|
||||
func UpdateNotificationChannelFields(id uint64, fields map[string]interface{}) error {
|
||||
if len(fields) == 0 {
|
||||
return nil
|
||||
}
|
||||
return dao.Mdb.Model(&mdb.NotificationChannel{}).Where("id = ?", id).Updates(fields).Error
|
||||
}
|
||||
|
||||
// DeleteNotificationChannelByID soft-deletes a row.
|
||||
func DeleteNotificationChannelByID(id uint64) error {
|
||||
return dao.Mdb.Where("id = ?", id).Delete(&mdb.NotificationChannel{}).Error
|
||||
}
|
||||
@@ -0,0 +1,151 @@
|
||||
package data
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/assimon/luuu/model/dao"
|
||||
"github.com/assimon/luuu/model/mdb"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// OrderListFilter bundles every filter supported by the admin orders
|
||||
// list page. Zero values are ignored so callers can pass only what the
|
||||
// user actually filtered on.
|
||||
type OrderListFilter struct {
|
||||
Status int
|
||||
Network string
|
||||
Token string
|
||||
Address string
|
||||
Keyword string // matches trade_id / order_id / block_transaction_id
|
||||
StartAt *time.Time
|
||||
EndAt *time.Time
|
||||
Page int
|
||||
PageSize int
|
||||
// ParentOnly restricts the result to top-level orders only
|
||||
// (parent_trade_id = ''). Sub-orders are excluded from the listing.
|
||||
ParentOnly bool
|
||||
}
|
||||
|
||||
// ListOrders returns a paginated order slice plus the total count under
|
||||
// the same filter (for the UI pagination bar).
|
||||
func ListOrders(f OrderListFilter) ([]mdb.Orders, int64, error) {
|
||||
tx := buildOrderListQuery(f)
|
||||
var total int64
|
||||
if err := tx.Count(&total).Error; err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
page := f.Page
|
||||
if page < 1 {
|
||||
page = 1
|
||||
}
|
||||
size := f.PageSize
|
||||
if size < 1 {
|
||||
size = 20
|
||||
}
|
||||
if size > 200 {
|
||||
size = 200
|
||||
}
|
||||
var rows []mdb.Orders
|
||||
err := tx.Order("id DESC").
|
||||
Offset((page - 1) * size).Limit(size).
|
||||
Find(&rows).Error
|
||||
return rows, total, err
|
||||
}
|
||||
|
||||
func buildOrderListQuery(f OrderListFilter) *gorm.DB {
|
||||
tx := dao.Mdb.Model(&mdb.Orders{})
|
||||
if f.ParentOnly {
|
||||
tx = tx.Where("parent_trade_id = ?", "")
|
||||
}
|
||||
if f.Status > 0 {
|
||||
tx = tx.Where("status = ?", f.Status)
|
||||
}
|
||||
if f.Network != "" {
|
||||
tx = tx.Where("network = ?", strings.ToLower(f.Network))
|
||||
}
|
||||
if f.Token != "" {
|
||||
tx = tx.Where("token = ?", strings.ToUpper(f.Token))
|
||||
}
|
||||
if f.Address != "" {
|
||||
tx = tx.Where("receive_address = ?", f.Address)
|
||||
}
|
||||
if f.Keyword != "" {
|
||||
kw := "%" + strings.TrimSpace(f.Keyword) + "%"
|
||||
tx = tx.Where("trade_id LIKE ? OR order_id LIKE ? OR block_transaction_id LIKE ?", kw, kw, kw)
|
||||
}
|
||||
if f.StartAt != nil {
|
||||
tx = tx.Where("created_at >= ?", *f.StartAt)
|
||||
}
|
||||
if f.EndAt != nil {
|
||||
tx = tx.Where("created_at <= ?", *f.EndAt)
|
||||
}
|
||||
return tx
|
||||
}
|
||||
|
||||
// CountOrdersByStatus returns how many orders exist in each status.
|
||||
// Used by the dashboard overview card.
|
||||
func CountOrdersByStatus() (map[int]int64, error) {
|
||||
type row struct {
|
||||
Status int
|
||||
Total int64
|
||||
}
|
||||
var rows []row
|
||||
err := dao.Mdb.Model(&mdb.Orders{}).
|
||||
Select("status, COUNT(*) AS total").
|
||||
Group("status").
|
||||
Scan(&rows).Error
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out := map[int]int64{}
|
||||
for _, r := range rows {
|
||||
out[r.Status] = r.Total
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// CloseOrderManually transitions a pending order to expired. Only
|
||||
// touches rows currently in StatusWaitPay so idempotent / safe.
|
||||
func CloseOrderManually(tradeID string) (bool, error) {
|
||||
result := dao.Mdb.Model(&mdb.Orders{}).
|
||||
Where("trade_id = ?", tradeID).
|
||||
Where("status = ?", mdb.StatusWaitPay).
|
||||
Update("status", mdb.StatusExpired)
|
||||
return result.RowsAffected > 0, result.Error
|
||||
}
|
||||
|
||||
// ReopenOrderCallback flips callback_confirm back to NO so the mq
|
||||
// worker picks it up on the next tick. Used by "resend callback".
|
||||
func ReopenOrderCallback(tradeID string) (bool, error) {
|
||||
result := dao.Mdb.Model(&mdb.Orders{}).
|
||||
Where("trade_id = ?", tradeID).
|
||||
Where("status = ?", mdb.StatusPaySuccess).
|
||||
Updates(map[string]interface{}{
|
||||
"callback_confirm": mdb.CallBackConfirmNo,
|
||||
"callback_num": 0,
|
||||
})
|
||||
return result.RowsAffected > 0, result.Error
|
||||
}
|
||||
|
||||
// CountOrdersByAddress returns order counts grouped by receive_address.
|
||||
// The admin wallet list annotates each wallet row with this number.
|
||||
func CountOrdersByAddress() (map[string]int64, error) {
|
||||
type row struct {
|
||||
Address string `gorm:"column:receive_address"`
|
||||
Total int64
|
||||
}
|
||||
var rows []row
|
||||
err := dao.Mdb.Model(&mdb.Orders{}).
|
||||
Select("receive_address, COUNT(*) AS total").
|
||||
Group("receive_address").
|
||||
Scan(&rows).Error
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out := map[string]int64{}
|
||||
for _, r := range rows {
|
||||
out[r.Address] = r.Total
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
@@ -28,10 +28,31 @@ func normalizeLockAmount(amount float64) (int64, string) {
|
||||
return value.Shift(2).IntPart(), value.StringFixed(2)
|
||||
}
|
||||
|
||||
func normalizeLockNetwork(network string) string {
|
||||
return strings.ToLower(strings.TrimSpace(network))
|
||||
}
|
||||
|
||||
func normalizeLockAddress(network, address string) string {
|
||||
address = strings.TrimSpace(address)
|
||||
if isEVMNetwork(normalizeLockNetwork(network)) {
|
||||
return strings.ToLower(address)
|
||||
}
|
||||
return address
|
||||
}
|
||||
|
||||
func normalizeLockToken(token string) string {
|
||||
return strings.ToUpper(strings.TrimSpace(token))
|
||||
}
|
||||
|
||||
func applyLockAddressFilter(tx *gorm.DB, network, address string) *gorm.DB {
|
||||
network = normalizeLockNetwork(network)
|
||||
address = normalizeLockAddress(network, address)
|
||||
if isEVMNetwork(network) {
|
||||
return tx.Where("lower(address) = ?", address)
|
||||
}
|
||||
return tx.Where("address = ?", address)
|
||||
}
|
||||
|
||||
// GetOrderInfoByOrderId fetches an order by merchant order id.
|
||||
func GetOrderInfoByOrderId(orderId string) (*mdb.Orders, error) {
|
||||
order := new(mdb.Orders)
|
||||
@@ -142,22 +163,38 @@ func GetSiblingSubOrders(parentTradeId string, excludeTradeId string) ([]mdb.Ord
|
||||
return orders, err
|
||||
}
|
||||
|
||||
// MarkParentOrderSuccess updates the parent order with the sub-order's payment details.
|
||||
// Token and network are NOT overwritten — the parent keeps its original values.
|
||||
// MarkParentOrderSuccess marks the parent order as paid and records which sub-order
|
||||
// settled it. Only the status, callback_confirm, and pay_by_sub_id fields are updated;
|
||||
// the parent's own block_transaction_id, actual_amount, and receive_address are
|
||||
// intentionally left unchanged because the parent was not directly paid.
|
||||
func MarkParentOrderSuccess(parentTradeId string, sub *mdb.Orders) (bool, error) {
|
||||
result := dao.Mdb.Model(&mdb.Orders{}).
|
||||
return MarkParentOrderSuccessWithTransaction(dao.Mdb, parentTradeId, sub)
|
||||
}
|
||||
|
||||
// MarkParentOrderSuccessWithTransaction is the transactional variant of
|
||||
// MarkParentOrderSuccess.
|
||||
func MarkParentOrderSuccessWithTransaction(tx *gorm.DB, parentTradeId string, sub *mdb.Orders) (bool, error) {
|
||||
result := tx.Model(&mdb.Orders{}).
|
||||
Where("trade_id = ?", parentTradeId).
|
||||
Where("status = ?", mdb.StatusWaitPay).
|
||||
Updates(map[string]interface{}{
|
||||
"status": mdb.StatusPaySuccess,
|
||||
"block_transaction_id": sub.BlockTransactionId,
|
||||
"callback_confirm": mdb.CallBackConfirmNo,
|
||||
"actual_amount": sub.ActualAmount,
|
||||
"receive_address": sub.ReceiveAddress,
|
||||
"status": mdb.StatusPaySuccess,
|
||||
"callback_confirm": mdb.CallBackConfirmNo,
|
||||
"pay_by_sub_id": sub.ID,
|
||||
})
|
||||
return result.RowsAffected > 0, result.Error
|
||||
}
|
||||
|
||||
// ExpireSiblingSubOrdersWithTransaction expires all waiting sibling
|
||||
// sub-orders under the same parent in one statement.
|
||||
func ExpireSiblingSubOrdersWithTransaction(tx *gorm.DB, parentTradeId string, excludeTradeId string) error {
|
||||
return tx.Model(&mdb.Orders{}).
|
||||
Where("parent_trade_id = ?", parentTradeId).
|
||||
Where("trade_id != ?", excludeTradeId).
|
||||
Where("status = ?", mdb.StatusWaitPay).
|
||||
Update("status", mdb.StatusExpired).Error
|
||||
}
|
||||
|
||||
// MarkOrderSelected sets is_selected=true for the given trade_id.
|
||||
func MarkOrderSelected(tradeId string) error {
|
||||
return dao.Mdb.Model(&mdb.Orders{}).
|
||||
@@ -191,6 +228,32 @@ func GetActiveSubOrders(parentTradeId string) ([]mdb.Orders, error) {
|
||||
return orders, err
|
||||
}
|
||||
|
||||
// GetAllSubOrders returns all sub-orders (any status) under a parent order,
|
||||
// ordered by creation time ascending.
|
||||
func GetAllSubOrders(parentTradeId string) ([]mdb.Orders, error) {
|
||||
var orders []mdb.Orders
|
||||
err := dao.Mdb.Model(&mdb.Orders{}).
|
||||
Where("parent_trade_id = ?", parentTradeId).
|
||||
Order("created_at ASC").
|
||||
Find(&orders).Error
|
||||
return orders, err
|
||||
}
|
||||
|
||||
// GetSubOrdersByParentTradeIds batch-fetches all sub-orders (any status)
|
||||
// whose parent_trade_id is in the given set. Ordered by parent_trade_id
|
||||
// then created_at ASC so callers can build a grouped map in one pass.
|
||||
func GetSubOrdersByParentTradeIds(parentTradeIds []string) ([]mdb.Orders, error) {
|
||||
if len(parentTradeIds) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
var orders []mdb.Orders
|
||||
err := dao.Mdb.Model(&mdb.Orders{}).
|
||||
Where("parent_trade_id IN ?", parentTradeIds).
|
||||
Order("parent_trade_id ASC, created_at ASC").
|
||||
Find(&orders).Error
|
||||
return orders, err
|
||||
}
|
||||
|
||||
// ExpireOrderByTradeId marks a single order as expired if still waiting.
|
||||
func ExpireOrderByTradeId(tradeId string) error {
|
||||
return dao.Mdb.Model(&mdb.Orders{}).
|
||||
@@ -201,16 +264,17 @@ func ExpireOrderByTradeId(tradeId string) error {
|
||||
|
||||
// GetTradeIdByWalletAddressAndAmountAndToken resolves the reserved trade id by network, address, token and amount.
|
||||
func GetTradeIdByWalletAddressAndAmountAndToken(network string, address string, token string, amount float64) (string, error) {
|
||||
network = normalizeLockNetwork(network)
|
||||
address = normalizeLockAddress(network, address)
|
||||
scaledAmount, _ := normalizeLockAmount(amount)
|
||||
var lock mdb.TransactionLock
|
||||
err := dao.RuntimeDB.Model(&mdb.TransactionLock{}).
|
||||
query := dao.RuntimeDB.Model(&mdb.TransactionLock{}).
|
||||
Where("network = ?", network).
|
||||
Where("address = ?", address).
|
||||
Where("token = ?", normalizeLockToken(token)).
|
||||
Where("amount_scaled = ?", scaledAmount).
|
||||
Where("expires_at > ?", time.Now()).
|
||||
Limit(1).
|
||||
Find(&lock).Error
|
||||
Where("expires_at > ?", time.Now())
|
||||
query = applyLockAddressFilter(query, network, address)
|
||||
err := query.Limit(1).Find(&lock).Error
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
@@ -222,6 +286,8 @@ func GetTradeIdByWalletAddressAndAmountAndToken(network string, address string,
|
||||
|
||||
// LockTransaction reserves a network+address+token+amount pair in sqlite until expiration.
|
||||
func LockTransaction(network, address, token, tradeID string, amount float64, expirationTime time.Duration) error {
|
||||
network = normalizeLockNetwork(network)
|
||||
address = normalizeLockAddress(network, address)
|
||||
scaledAmount, amountText := normalizeLockAmount(amount)
|
||||
normalizedToken := normalizeLockToken(token)
|
||||
now := time.Now()
|
||||
@@ -236,12 +302,12 @@ func LockTransaction(network, address, token, tradeID string, amount float64, ex
|
||||
}
|
||||
|
||||
return dao.RuntimeDB.Transaction(func(tx *gorm.DB) error {
|
||||
if err := tx.Where("network = ?", network).
|
||||
Where("address = ?", address).
|
||||
expiredQuery := tx.Where("network = ?", network).
|
||||
Where("token = ?", normalizedToken).
|
||||
Where("amount_scaled = ?", scaledAmount).
|
||||
Where("expires_at <= ?", now).
|
||||
Delete(&mdb.TransactionLock{}).Error; err != nil {
|
||||
Where("expires_at <= ?", now)
|
||||
expiredQuery = applyLockAddressFilter(expiredQuery, network, address)
|
||||
if err := expiredQuery.Delete(&mdb.TransactionLock{}).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
if err := tx.Where("trade_id = ?", tradeID).Delete(&mdb.TransactionLock{}).Error; err != nil {
|
||||
@@ -261,13 +327,15 @@ func LockTransaction(network, address, token, tradeID string, amount float64, ex
|
||||
|
||||
// UnLockTransaction releases the reservation for network+address+token+amount.
|
||||
func UnLockTransaction(network string, address string, token string, amount float64) error {
|
||||
network = normalizeLockNetwork(network)
|
||||
address = normalizeLockAddress(network, address)
|
||||
scaledAmount, _ := normalizeLockAmount(amount)
|
||||
return dao.RuntimeDB.
|
||||
query := dao.RuntimeDB.
|
||||
Where("network = ?", network).
|
||||
Where("address = ?", address).
|
||||
Where("token = ?", normalizeLockToken(token)).
|
||||
Where("amount_scaled = ?", scaledAmount).
|
||||
Delete(&mdb.TransactionLock{}).Error
|
||||
Where("amount_scaled = ?", scaledAmount)
|
||||
query = applyLockAddressFilter(query, network, address)
|
||||
return query.Delete(&mdb.TransactionLock{}).Error
|
||||
}
|
||||
|
||||
func UnLockTransactionByTradeId(tradeID string) error {
|
||||
|
||||
@@ -0,0 +1,319 @@
|
||||
package data
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"github.com/assimon/luuu/model/dao"
|
||||
"github.com/assimon/luuu/model/mdb"
|
||||
)
|
||||
|
||||
// DailyStat is one bucket of day-level aggregation used by the
|
||||
// dashboard trend charts.
|
||||
// Field "Day" holds "YYYY-MM-DD" for daily buckets and
|
||||
// "YYYY-MM-DD HH:00" for hourly buckets.
|
||||
type DailyStat struct {
|
||||
Day string `json:"day"` // "YYYY-MM-DD" (daily) or "YYYY-MM-DD HH:00" (hourly)
|
||||
OrderCount int64 `json:"order_count"`
|
||||
SuccessCount int64 `json:"success_count"`
|
||||
TotalAmount float64 `json:"total_amount"` // sum of amount (fiat)
|
||||
ActualAmount float64 `json:"actual_amount"` // sum of actual_amount (token)
|
||||
}
|
||||
|
||||
// AddressDailyStat buckets actual_amount by (day, receive_address) for
|
||||
// the stacked asset-trend chart.
|
||||
// Field "Day" holds "YYYY-MM-DD" (daily) or "YYYY-MM-DD HH:00" (hourly).
|
||||
type AddressDailyStat struct {
|
||||
Day string `json:"day"`
|
||||
Address string `json:"address"`
|
||||
ActualAmount float64 `json:"actual_amount"`
|
||||
}
|
||||
|
||||
// DailyOrderStats returns per-day counts/amounts within [start, end],
|
||||
// zero-filling any days in the range that have no orders.
|
||||
// Only orders in StatusPaySuccess contribute to Amount sums — that's
|
||||
// what the user means by "流水". Pending orders count toward OrderCount
|
||||
// to keep成交率 computable.
|
||||
func DailyOrderStats(start, end time.Time) ([]DailyStat, error) {
|
||||
var rows []DailyStat
|
||||
err := dao.Mdb.Model(&mdb.Orders{}).
|
||||
Select(`DATE(created_at) AS day,
|
||||
COUNT(*) AS order_count,
|
||||
SUM(CASE WHEN status = ? THEN 1 ELSE 0 END) AS success_count,
|
||||
SUM(CASE WHEN status = ? THEN amount ELSE 0 END) AS total_amount,
|
||||
SUM(CASE WHEN status = ? THEN actual_amount ELSE 0 END) AS actual_amount`,
|
||||
mdb.StatusPaySuccess, mdb.StatusPaySuccess, mdb.StatusPaySuccess).
|
||||
Where("created_at >= ?", start).
|
||||
Where("created_at <= ?", end).
|
||||
Group("DATE(created_at)").
|
||||
Order("day ASC").
|
||||
Scan(&rows).Error
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return fillDailyStats(start, end, rows), nil
|
||||
}
|
||||
|
||||
// HourlyOrderStats returns per-hour counts/amounts within [start, end],
|
||||
// zero-filling any hours that have no orders.
|
||||
func HourlyOrderStats(start, end time.Time) ([]DailyStat, error) {
|
||||
var rows []DailyStat
|
||||
err := dao.Mdb.Model(&mdb.Orders{}).
|
||||
Select(`strftime('%Y-%m-%d %H:00', created_at) AS day,
|
||||
COUNT(*) AS order_count,
|
||||
SUM(CASE WHEN status = ? THEN 1 ELSE 0 END) AS success_count,
|
||||
SUM(CASE WHEN status = ? THEN amount ELSE 0 END) AS total_amount,
|
||||
SUM(CASE WHEN status = ? THEN actual_amount ELSE 0 END) AS actual_amount`,
|
||||
mdb.StatusPaySuccess, mdb.StatusPaySuccess, mdb.StatusPaySuccess).
|
||||
Where("created_at >= ?", start).
|
||||
Where("created_at <= ?", end).
|
||||
Group("strftime('%Y-%m-%d %H:00', created_at)").
|
||||
Order("day ASC").
|
||||
Scan(&rows).Error
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return fillHourlyStats(start, end, rows), nil
|
||||
}
|
||||
|
||||
// fillDailyStats generates a zero-filled slice covering every calendar
|
||||
// day in [start, end]. Days present in rows overwrite the zero entries.
|
||||
func fillDailyStats(start, end time.Time, rows []DailyStat) []DailyStat {
|
||||
byDay := make(map[string]DailyStat, len(rows))
|
||||
for _, r := range rows {
|
||||
byDay[r.Day] = r
|
||||
}
|
||||
var out []DailyStat
|
||||
cur := time.Date(start.Year(), start.Month(), start.Day(), 0, 0, 0, 0, start.Location())
|
||||
last := time.Date(end.Year(), end.Month(), end.Day(), 0, 0, 0, 0, end.Location())
|
||||
for !cur.After(last) {
|
||||
key := cur.Format("2006-01-02")
|
||||
if r, ok := byDay[key]; ok {
|
||||
out = append(out, r)
|
||||
} else {
|
||||
out = append(out, DailyStat{Day: key})
|
||||
}
|
||||
cur = cur.AddDate(0, 0, 1)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// fillHourlyStats generates a zero-filled slice covering every whole
|
||||
// hour in [start, end]. Hours present in rows overwrite the zero entries.
|
||||
func fillHourlyStats(start, end time.Time, rows []DailyStat) []DailyStat {
|
||||
byHour := make(map[string]DailyStat, len(rows))
|
||||
for _, r := range rows {
|
||||
byHour[r.Day] = r
|
||||
}
|
||||
var out []DailyStat
|
||||
cur := time.Date(start.Year(), start.Month(), start.Day(), start.Hour(), 0, 0, 0, start.Location())
|
||||
endHour := time.Date(end.Year(), end.Month(), end.Day(), end.Hour(), 0, 0, 0, end.Location())
|
||||
for !cur.After(endHour) {
|
||||
key := cur.Format("2006-01-02 15:00")
|
||||
if r, ok := byHour[key]; ok {
|
||||
out = append(out, r)
|
||||
} else {
|
||||
out = append(out, DailyStat{Day: key})
|
||||
}
|
||||
cur = cur.Add(time.Hour)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// DailyAssetByAddress groups paid actual_amount by (day, receive_address)
|
||||
// for the per-address stacked chart, zero-filling missing days.
|
||||
func DailyAssetByAddress(start, end time.Time) ([]AddressDailyStat, error) {
|
||||
var rows []AddressDailyStat
|
||||
err := dao.Mdb.Model(&mdb.Orders{}).
|
||||
Select("DATE(created_at) AS day, receive_address AS address, SUM(actual_amount) AS actual_amount").
|
||||
Where("status = ?", mdb.StatusPaySuccess).
|
||||
Where("created_at >= ?", start).
|
||||
Where("created_at <= ?", end).
|
||||
Group("DATE(created_at), receive_address").
|
||||
Order("day ASC").
|
||||
Scan(&rows).Error
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return fillAddressDailyStats(start, end, rows), nil
|
||||
}
|
||||
|
||||
// HourlyAssetByAddress groups paid actual_amount by (hour, receive_address)
|
||||
// for the per-address stacked chart, zero-filling missing hours.
|
||||
func HourlyAssetByAddress(start, end time.Time) ([]AddressDailyStat, error) {
|
||||
var rows []AddressDailyStat
|
||||
err := dao.Mdb.Model(&mdb.Orders{}).
|
||||
Select("strftime('%Y-%m-%d %H:00', created_at) AS day, receive_address AS address, SUM(actual_amount) AS actual_amount").
|
||||
Where("status = ?", mdb.StatusPaySuccess).
|
||||
Where("created_at >= ?", start).
|
||||
Where("created_at <= ?", end).
|
||||
Group("strftime('%Y-%m-%d %H:00', created_at), receive_address").
|
||||
Order("day ASC").
|
||||
Scan(&rows).Error
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return fillAddressHourlyStats(start, end, rows), nil
|
||||
}
|
||||
|
||||
// fillAddressDailyStats zero-fills missing day×address combinations.
|
||||
// All addresses that appear in rows are represented for every day in range.
|
||||
func fillAddressDailyStats(start, end time.Time, rows []AddressDailyStat) []AddressDailyStat {
|
||||
return fillAddressStats(start, end, rows, false)
|
||||
}
|
||||
|
||||
// fillAddressHourlyStats zero-fills missing hour×address combinations.
|
||||
func fillAddressHourlyStats(start, end time.Time, rows []AddressDailyStat) []AddressDailyStat {
|
||||
return fillAddressStats(start, end, rows, true)
|
||||
}
|
||||
|
||||
func fillAddressStats(start, end time.Time, rows []AddressDailyStat, hourly bool) []AddressDailyStat {
|
||||
type key struct{ day, addr string }
|
||||
byKey := make(map[key]float64, len(rows))
|
||||
addrSet := make(map[string]struct{}, 4)
|
||||
for _, r := range rows {
|
||||
byKey[key{r.Day, r.Address}] = r.ActualAmount
|
||||
addrSet[r.Address] = struct{}{}
|
||||
}
|
||||
if len(addrSet) == 0 {
|
||||
return rows
|
||||
}
|
||||
var periods []string
|
||||
if hourly {
|
||||
cur := time.Date(start.Year(), start.Month(), start.Day(), start.Hour(), 0, 0, 0, start.Location())
|
||||
endH := time.Date(end.Year(), end.Month(), end.Day(), end.Hour(), 0, 0, 0, end.Location())
|
||||
for !cur.After(endH) {
|
||||
periods = append(periods, cur.Format("2006-01-02 15:00"))
|
||||
cur = cur.Add(time.Hour)
|
||||
}
|
||||
} else {
|
||||
cur := time.Date(start.Year(), start.Month(), start.Day(), 0, 0, 0, 0, start.Location())
|
||||
last := time.Date(end.Year(), end.Month(), end.Day(), 0, 0, 0, 0, end.Location())
|
||||
for !cur.After(last) {
|
||||
periods = append(periods, cur.Format("2006-01-02"))
|
||||
cur = cur.AddDate(0, 0, 1)
|
||||
}
|
||||
}
|
||||
var out []AddressDailyStat
|
||||
for _, day := range periods {
|
||||
for addr := range addrSet {
|
||||
out = append(out, AddressDailyStat{
|
||||
Day: day,
|
||||
Address: addr,
|
||||
ActualAmount: byKey[key{day, addr}],
|
||||
})
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// SumPaidActualAmount returns the total paid actual_amount across all
|
||||
// time. Used by the overview "total asset" card.
|
||||
func SumPaidActualAmount() (float64, error) {
|
||||
var sum float64
|
||||
err := dao.Mdb.Model(&mdb.Orders{}).
|
||||
Select("COALESCE(SUM(actual_amount), 0)").
|
||||
Where("status = ?", mdb.StatusPaySuccess).
|
||||
Scan(&sum).Error
|
||||
return sum, err
|
||||
}
|
||||
|
||||
// PaidStatsInRange returns (order_count, success_count, actual_sum) for
|
||||
// a given time range. Caller chooses the range (today / last 7 days).
|
||||
func PaidStatsInRange(start, end time.Time) (int64, int64, float64, error) {
|
||||
type row struct {
|
||||
OrderCount int64
|
||||
SuccessCount int64
|
||||
ActualSum float64
|
||||
}
|
||||
var r row
|
||||
err := dao.Mdb.Model(&mdb.Orders{}).
|
||||
Select(`COUNT(*) AS order_count,
|
||||
SUM(CASE WHEN status = ? THEN 1 ELSE 0 END) AS success_count,
|
||||
SUM(CASE WHEN status = ? THEN actual_amount ELSE 0 END) AS actual_sum`,
|
||||
mdb.StatusPaySuccess, mdb.StatusPaySuccess).
|
||||
Where("created_at >= ?", start).
|
||||
Where("created_at <= ?", end).
|
||||
Scan(&r).Error
|
||||
return r.OrderCount, r.SuccessCount, r.ActualSum, err
|
||||
}
|
||||
|
||||
// ActiveAddressCountInRange returns the number of distinct wallet addresses
|
||||
// that appear in at least one order within [start, end].
|
||||
func ActiveAddressCountInRange(start, end time.Time) (int64, error) {
|
||||
var count int64
|
||||
err := dao.Mdb.Model(&mdb.Orders{}).
|
||||
Where("created_at >= ?", start).
|
||||
Where("created_at <= ?", end).
|
||||
Distinct("receive_address").
|
||||
Count(&count).Error
|
||||
return count, err
|
||||
}
|
||||
|
||||
// AveragePaymentDurationSeconds returns the mean elapsed time between
|
||||
// created_at and updated_at for orders that reached StatusPaySuccess
|
||||
// in the given range. Zero-safe when no rows match.
|
||||
func AveragePaymentDurationSeconds(start, end time.Time) (float64, error) {
|
||||
type row struct {
|
||||
Avg float64
|
||||
}
|
||||
var r row
|
||||
// Using TIMESTAMPDIFF would be MySQL-specific. Fetch the raw
|
||||
// columns and compute in Go — trend windows are small enough.
|
||||
type pair struct {
|
||||
CreatedAt time.Time
|
||||
UpdatedAt time.Time
|
||||
}
|
||||
var pairs []pair
|
||||
err := dao.Mdb.Model(&mdb.Orders{}).
|
||||
Select("created_at, updated_at").
|
||||
Where("status = ?", mdb.StatusPaySuccess).
|
||||
Where("created_at >= ?", start).
|
||||
Where("created_at <= ?", end).
|
||||
Scan(&pairs).Error
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
if len(pairs) == 0 {
|
||||
return 0, nil
|
||||
}
|
||||
total := 0.0
|
||||
for _, p := range pairs {
|
||||
total += p.UpdatedAt.Sub(p.CreatedAt).Seconds()
|
||||
}
|
||||
r.Avg = total / float64(len(pairs))
|
||||
return r.Avg, nil
|
||||
}
|
||||
|
||||
// CountExpiredInRange returns the number of StatusExpired orders in range.
|
||||
func CountExpiredInRange(start, end time.Time) (int64, error) {
|
||||
var n int64
|
||||
err := dao.Mdb.Model(&mdb.Orders{}).
|
||||
Where("status = ?", mdb.StatusExpired).
|
||||
Where("created_at >= ?", start).
|
||||
Where("created_at <= ?", end).
|
||||
Count(&n).Error
|
||||
return n, err
|
||||
}
|
||||
|
||||
// RecentOrders returns the latest N orders regardless of status.
|
||||
func RecentOrders(limit int) ([]mdb.Orders, error) {
|
||||
if limit < 1 {
|
||||
limit = 20
|
||||
}
|
||||
if limit > 100 {
|
||||
limit = 100
|
||||
}
|
||||
var rows []mdb.Orders
|
||||
err := dao.Mdb.Model(&mdb.Orders{}).
|
||||
Order("id DESC").
|
||||
Limit(limit).
|
||||
Find(&rows).Error
|
||||
return rows, err
|
||||
}
|
||||
|
||||
// CountEnabledChains counts chains where Enabled=true.
|
||||
func CountEnabledChains() (int64, error) {
|
||||
var n int64
|
||||
err := dao.Mdb.Model(&mdb.Chain{}).Where("enabled = ?", true).Count(&n).Error
|
||||
return n, err
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
package data
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/assimon/luuu/internal/testutil"
|
||||
"github.com/assimon/luuu/model/mdb"
|
||||
)
|
||||
|
||||
func TestEvmTransactionLockAddressIsCaseInsensitive(t *testing.T) {
|
||||
cleanup := testutil.SetupTestDatabases(t)
|
||||
defer cleanup()
|
||||
|
||||
tradeID := "trade-evm-case"
|
||||
address := "0xA1B2c3D4e5F60718293aBcDeF001122334455667"
|
||||
|
||||
if err := LockTransaction("Ethereum", address, "usdt", tradeID, 1.23, time.Hour); err != nil {
|
||||
t.Fatalf("lock transaction: %v", err)
|
||||
}
|
||||
|
||||
gotTradeID, err := GetTradeIdByWalletAddressAndAmountAndToken(mdb.NetworkEthereum, strings.ToLower(address), "USDT", 1.23)
|
||||
if err != nil {
|
||||
t.Fatalf("lookup transaction lock: %v", err)
|
||||
}
|
||||
if gotTradeID != tradeID {
|
||||
t.Fatalf("trade id = %q, want %q", gotTradeID, tradeID)
|
||||
}
|
||||
|
||||
if err := UnLockTransaction(mdb.NetworkEthereum, strings.ToUpper(address), "USDT", 1.23); err != nil {
|
||||
t.Fatalf("unlock transaction: %v", err)
|
||||
}
|
||||
|
||||
gotTradeID, err = GetTradeIdByWalletAddressAndAmountAndToken(mdb.NetworkEthereum, address, "USDT", 1.23)
|
||||
if err != nil {
|
||||
t.Fatalf("lookup after unlock: %v", err)
|
||||
}
|
||||
if gotTradeID != "" {
|
||||
t.Fatalf("expected lock to be released, got trade id %q", gotTradeID)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNonEvmTransactionLockAddressRemainsCaseSensitive(t *testing.T) {
|
||||
cleanup := testutil.SetupTestDatabases(t)
|
||||
defer cleanup()
|
||||
|
||||
tradeID := "trade-tron-case"
|
||||
address := "TCaseSensitiveAddress001"
|
||||
|
||||
if err := LockTransaction(mdb.NetworkTron, address, "USDT", tradeID, 1.00, time.Hour); err != nil {
|
||||
t.Fatalf("lock transaction: %v", err)
|
||||
}
|
||||
|
||||
gotTradeID, err := GetTradeIdByWalletAddressAndAmountAndToken(mdb.NetworkTron, strings.ToLower(address), "USDT", 1.00)
|
||||
if err != nil {
|
||||
t.Fatalf("lookup transaction lock: %v", err)
|
||||
}
|
||||
if gotTradeID != "" {
|
||||
t.Fatalf("tron address lookup should remain case-sensitive, got trade id %q", gotTradeID)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
package data
|
||||
|
||||
import (
|
||||
"math/rand"
|
||||
"strings"
|
||||
|
||||
"github.com/assimon/luuu/model/dao"
|
||||
"github.com/assimon/luuu/model/mdb"
|
||||
"github.com/dromara/carbon/v2"
|
||||
)
|
||||
|
||||
// ListRpcNodes returns rows optionally filtered by network.
|
||||
func ListRpcNodes(network string) ([]mdb.RpcNode, error) {
|
||||
var rows []mdb.RpcNode
|
||||
tx := dao.Mdb.Model(&mdb.RpcNode{})
|
||||
if network != "" {
|
||||
tx = tx.Where("network = ?", strings.ToLower(network))
|
||||
}
|
||||
err := tx.Order("id ASC").Find(&rows).Error
|
||||
return rows, err
|
||||
}
|
||||
|
||||
// GetRpcNodeByID fetches one row.
|
||||
func GetRpcNodeByID(id uint64) (*mdb.RpcNode, error) {
|
||||
row := new(mdb.RpcNode)
|
||||
err := dao.Mdb.Model(row).Limit(1).Find(row, id).Error
|
||||
return row, err
|
||||
}
|
||||
|
||||
// CreateRpcNode inserts a row.
|
||||
func CreateRpcNode(row *mdb.RpcNode) error {
|
||||
return dao.Mdb.Create(row).Error
|
||||
}
|
||||
|
||||
// UpdateRpcNodeFields patches mutable columns.
|
||||
func UpdateRpcNodeFields(id uint64, fields map[string]interface{}) error {
|
||||
if len(fields) == 0 {
|
||||
return nil
|
||||
}
|
||||
return dao.Mdb.Model(&mdb.RpcNode{}).Where("id = ?", id).Updates(fields).Error
|
||||
}
|
||||
|
||||
// DeleteRpcNodeByID soft-deletes the row.
|
||||
func DeleteRpcNodeByID(id uint64) error {
|
||||
return dao.Mdb.Where("id = ?", id).Delete(&mdb.RpcNode{}).Error
|
||||
}
|
||||
|
||||
// SelectRpcNode picks a healthy RPC endpoint for a (network, type) pair.
|
||||
// Strategy: weighted random among rows where enabled=true AND status=ok.
|
||||
// Falls back to any enabled row when none are healthy yet (bootstrap case
|
||||
// where the health check hasn't run). Returns nil if nothing is defined.
|
||||
func SelectRpcNode(network, nodeType string) (*mdb.RpcNode, error) {
|
||||
var rows []mdb.RpcNode
|
||||
err := dao.Mdb.Model(&mdb.RpcNode{}).
|
||||
Where("network = ?", strings.ToLower(network)).
|
||||
Where("type = ?", strings.ToLower(nodeType)).
|
||||
Where("enabled = ?", true).
|
||||
Find(&rows).Error
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(rows) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
healthy := rows[:0]
|
||||
for _, r := range rows {
|
||||
if r.Status == mdb.RpcNodeStatusOk {
|
||||
healthy = append(healthy, r)
|
||||
}
|
||||
}
|
||||
candidates := healthy
|
||||
if len(candidates) == 0 {
|
||||
candidates = rows // bootstrap: no health check has run yet
|
||||
}
|
||||
return pickWeighted(candidates), nil
|
||||
}
|
||||
|
||||
// pickWeighted chooses one row using the Weight column. Weights < 1 are
|
||||
// coerced to 1 so an admin-zeroed row still has a chance (use Enabled=false
|
||||
// to truly disable).
|
||||
func pickWeighted(rows []mdb.RpcNode) *mdb.RpcNode {
|
||||
total := 0
|
||||
for i := range rows {
|
||||
w := rows[i].Weight
|
||||
if w < 1 {
|
||||
w = 1
|
||||
}
|
||||
total += w
|
||||
}
|
||||
if total <= 0 {
|
||||
return &rows[0]
|
||||
}
|
||||
pick := rand.Intn(total)
|
||||
acc := 0
|
||||
for i := range rows {
|
||||
w := rows[i].Weight
|
||||
if w < 1 {
|
||||
w = 1
|
||||
}
|
||||
acc += w
|
||||
if pick < acc {
|
||||
return &rows[i]
|
||||
}
|
||||
}
|
||||
return &rows[len(rows)-1]
|
||||
}
|
||||
|
||||
// UpdateRpcNodeHealth stamps status/latency + last_checked_at.
|
||||
func UpdateRpcNodeHealth(id uint64, status string, latencyMs int) error {
|
||||
return dao.Mdb.Model(&mdb.RpcNode{}).
|
||||
Where("id = ?", id).
|
||||
Updates(map[string]interface{}{
|
||||
"status": status,
|
||||
"last_latency_ms": latencyMs,
|
||||
"last_checked_at": carbon.Now().StdTime(),
|
||||
}).Error
|
||||
}
|
||||
@@ -0,0 +1,166 @@
|
||||
package data
|
||||
|
||||
import (
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
"github.com/assimon/luuu/model/dao"
|
||||
"github.com/assimon/luuu/model/mdb"
|
||||
"gorm.io/gorm/clause"
|
||||
)
|
||||
|
||||
// Simple in-process cache. Settings are read frequently (on every order
|
||||
// create, every scanner tick, etc.); the DB round-trip isn't free. Cache
|
||||
// is invalidated explicitly via SetString/Delete and expires on process
|
||||
// restart — that's enough for admin-edit semantics without TTL complexity.
|
||||
var (
|
||||
settingsCache = map[string]string{}
|
||||
settingsCacheMu sync.RWMutex
|
||||
settingsLoadMu sync.Mutex // serializes the lazy bootstrap load
|
||||
settingsLoaded bool
|
||||
)
|
||||
|
||||
func loadAllSettings() error {
|
||||
var rows []mdb.Setting
|
||||
if err := dao.Mdb.Find(&rows).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
settingsCacheMu.Lock()
|
||||
defer settingsCacheMu.Unlock()
|
||||
settingsCache = make(map[string]string, len(rows))
|
||||
for _, r := range rows {
|
||||
settingsCache[r.Key] = r.Value
|
||||
}
|
||||
settingsLoaded = true
|
||||
return nil
|
||||
}
|
||||
|
||||
// ensureLoaded performs a thread-safe lazy bootstrap of the cache. Uses
|
||||
// a dedicated mutex + double-checked flag so two concurrent callers
|
||||
// don't both hit the DB. Can't use sync.Once because ReloadSettings
|
||||
// needs to be able to rebuild after a failed initial load.
|
||||
func ensureLoaded() {
|
||||
settingsCacheMu.RLock()
|
||||
loaded := settingsLoaded
|
||||
settingsCacheMu.RUnlock()
|
||||
if loaded {
|
||||
return
|
||||
}
|
||||
settingsLoadMu.Lock()
|
||||
defer settingsLoadMu.Unlock()
|
||||
// Recheck under the load mutex — another goroutine may have loaded
|
||||
// while we were blocked.
|
||||
settingsCacheMu.RLock()
|
||||
loaded = settingsLoaded
|
||||
settingsCacheMu.RUnlock()
|
||||
if loaded {
|
||||
return
|
||||
}
|
||||
_ = loadAllSettings()
|
||||
}
|
||||
|
||||
// ReloadSettings forces a refresh from DB. Call after external writes.
|
||||
func ReloadSettings() error {
|
||||
settingsLoadMu.Lock()
|
||||
defer settingsLoadMu.Unlock()
|
||||
return loadAllSettings()
|
||||
}
|
||||
|
||||
// GetSettingString returns the string value for key, or fallback if unset.
|
||||
func GetSettingString(key, fallback string) string {
|
||||
ensureLoaded()
|
||||
settingsCacheMu.RLock()
|
||||
defer settingsCacheMu.RUnlock()
|
||||
v, ok := settingsCache[key]
|
||||
if !ok || strings.TrimSpace(v) == "" {
|
||||
return fallback
|
||||
}
|
||||
return v
|
||||
}
|
||||
|
||||
// GetSettingInt returns the int value for key, or fallback on miss/parse fail.
|
||||
func GetSettingInt(key string, fallback int) int {
|
||||
v := GetSettingString(key, "")
|
||||
if v == "" {
|
||||
return fallback
|
||||
}
|
||||
n, err := strconv.Atoi(v)
|
||||
if err != nil {
|
||||
return fallback
|
||||
}
|
||||
return n
|
||||
}
|
||||
|
||||
// GetSettingFloat returns the float value for key, or fallback on miss/parse fail.
|
||||
func GetSettingFloat(key string, fallback float64) float64 {
|
||||
v := GetSettingString(key, "")
|
||||
if v == "" {
|
||||
return fallback
|
||||
}
|
||||
f, err := strconv.ParseFloat(v, 64)
|
||||
if err != nil {
|
||||
return fallback
|
||||
}
|
||||
return f
|
||||
}
|
||||
|
||||
// GetSettingBool returns the bool value for key, or fallback on miss/parse fail.
|
||||
func GetSettingBool(key string, fallback bool) bool {
|
||||
v := GetSettingString(key, "")
|
||||
if v == "" {
|
||||
return fallback
|
||||
}
|
||||
b, err := strconv.ParseBool(v)
|
||||
if err != nil {
|
||||
return fallback
|
||||
}
|
||||
return b
|
||||
}
|
||||
|
||||
// SetSetting upserts a setting row and refreshes the cache entry.
|
||||
func SetSetting(group, key, value, valueType string) error {
|
||||
if valueType == "" {
|
||||
valueType = mdb.SettingTypeString
|
||||
}
|
||||
row := mdb.Setting{Group: group, Key: key, Value: value, Type: valueType}
|
||||
err := dao.Mdb.Clauses(clause.OnConflict{
|
||||
Columns: []clause.Column{{Name: "key"}},
|
||||
DoUpdates: clause.AssignmentColumns([]string{"group", "value", "type", "updated_at"}),
|
||||
}).Create(&row).Error
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
settingsCacheMu.Lock()
|
||||
settingsCache[key] = value
|
||||
settingsCacheMu.Unlock()
|
||||
return nil
|
||||
}
|
||||
|
||||
// DeleteSetting removes a setting row and drops the cache entry.
|
||||
func DeleteSetting(key string) error {
|
||||
if err := dao.Mdb.Where("key = ?", key).Delete(&mdb.Setting{}).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
settingsCacheMu.Lock()
|
||||
delete(settingsCache, key)
|
||||
settingsCacheMu.Unlock()
|
||||
return nil
|
||||
}
|
||||
|
||||
// sensitiveSettingKeys lists keys that must never be returned to API callers.
|
||||
var sensitiveSettingKeys = []string{
|
||||
mdb.SettingKeyJwtSecret,
|
||||
}
|
||||
|
||||
// ListSettingsByGroup returns all rows for a given group (empty group = all),
|
||||
// excluding any keys in sensitiveSettingKeys.
|
||||
func ListSettingsByGroup(group string) ([]mdb.Setting, error) {
|
||||
var rows []mdb.Setting
|
||||
tx := dao.Mdb.Model(&mdb.Setting{}).Not("`key`", sensitiveSettingKeys)
|
||||
if group != "" {
|
||||
tx = tx.Where("`group` = ?", group)
|
||||
}
|
||||
err := tx.Order("`key` ASC").Find(&rows).Error
|
||||
return rows, err
|
||||
}
|
||||
@@ -1,123 +0,0 @@
|
||||
package data
|
||||
|
||||
import (
|
||||
"strings"
|
||||
|
||||
"github.com/assimon/luuu/model/dao"
|
||||
"github.com/assimon/luuu/model/mdb"
|
||||
"github.com/assimon/luuu/util/constant"
|
||||
)
|
||||
|
||||
func normalizeNetworkToken(network, token string) (string, string) {
|
||||
return strings.ToLower(strings.TrimSpace(network)), strings.ToUpper(strings.TrimSpace(token))
|
||||
}
|
||||
|
||||
func AddSupportedAsset(network, token string, status int64) (*mdb.SupportedAsset, error) {
|
||||
network, token = normalizeNetworkToken(network, token)
|
||||
exist, err := GetSupportedAssetByNetworkAndToken(network, token)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if exist.ID > 0 {
|
||||
return nil, constant.SupportedAssetAlreadyExists
|
||||
}
|
||||
|
||||
deleted, err := getSupportedAssetByNetworkAndTokenUnscoped(network, token)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if deleted.ID > 0 && deleted.DeletedAt.Valid {
|
||||
deleted.Status = status
|
||||
deleted.DeletedAt.Valid = false
|
||||
if err := dao.Mdb.Unscoped().Save(deleted).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return deleted, nil
|
||||
}
|
||||
|
||||
asset := &mdb.SupportedAsset{
|
||||
Network: network,
|
||||
Token: token,
|
||||
Status: status,
|
||||
}
|
||||
err = dao.Mdb.Create(asset).Error
|
||||
return asset, err
|
||||
}
|
||||
|
||||
func getSupportedAssetByNetworkAndTokenUnscoped(network, token string) (*mdb.SupportedAsset, error) {
|
||||
network, token = normalizeNetworkToken(network, token)
|
||||
asset := new(mdb.SupportedAsset)
|
||||
err := dao.Mdb.Unscoped().Model(asset).
|
||||
Where("network = ?", network).
|
||||
Where("token = ?", token).
|
||||
Limit(1).Find(asset).Error
|
||||
return asset, err
|
||||
}
|
||||
|
||||
func GetSupportedAssetByNetworkAndToken(network, token string) (*mdb.SupportedAsset, error) {
|
||||
network, token = normalizeNetworkToken(network, token)
|
||||
asset := new(mdb.SupportedAsset)
|
||||
err := dao.Mdb.Model(asset).
|
||||
Where("network = ?", network).
|
||||
Where("token = ?", token).
|
||||
Limit(1).Find(asset).Error
|
||||
return asset, err
|
||||
}
|
||||
|
||||
func GetSupportedAssetByID(id uint64) (*mdb.SupportedAsset, error) {
|
||||
asset := new(mdb.SupportedAsset)
|
||||
err := dao.Mdb.Model(asset).Limit(1).Find(asset, id).Error
|
||||
return asset, err
|
||||
}
|
||||
|
||||
func UpdateSupportedAsset(id uint64, network, token string, status int64) (*mdb.SupportedAsset, error) {
|
||||
network, token = normalizeNetworkToken(network, token)
|
||||
|
||||
asset, err := GetSupportedAssetByID(id)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if asset.ID <= 0 {
|
||||
return nil, constant.SupportedAssetNotFound
|
||||
}
|
||||
|
||||
exist, err := GetSupportedAssetByNetworkAndToken(network, token)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if exist.ID > 0 && exist.ID != id {
|
||||
return nil, constant.SupportedAssetAlreadyExists
|
||||
}
|
||||
|
||||
asset.Network = network
|
||||
asset.Token = token
|
||||
asset.Status = status
|
||||
if err := dao.Mdb.Save(asset).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return asset, nil
|
||||
}
|
||||
|
||||
func DeleteSupportedAssetByID(id uint64) error {
|
||||
return dao.Mdb.Where("id = ?", id).Delete(&mdb.SupportedAsset{}).Error
|
||||
}
|
||||
|
||||
func ListSupportedAssets(network string) ([]mdb.SupportedAsset, error) {
|
||||
network = strings.ToLower(strings.TrimSpace(network))
|
||||
var list []mdb.SupportedAsset
|
||||
tx := dao.Mdb.Model(&mdb.SupportedAsset{})
|
||||
if network != "" {
|
||||
tx = tx.Where("network = ?", network)
|
||||
}
|
||||
err := tx.Order("network asc, token asc").Find(&list).Error
|
||||
return list, err
|
||||
}
|
||||
|
||||
func ListEnabledSupportedAssets() ([]mdb.SupportedAsset, error) {
|
||||
var list []mdb.SupportedAsset
|
||||
err := dao.Mdb.Model(&mdb.SupportedAsset{}).
|
||||
Where("status = ?", mdb.TokenStatusEnable).
|
||||
Order("network asc, token asc").
|
||||
Find(&list).Error
|
||||
return list, err
|
||||
}
|
||||
@@ -22,15 +22,22 @@ func isEVMNetwork(network string) bool {
|
||||
return false
|
||||
}
|
||||
|
||||
func normalizeWalletNetwork(network string) string {
|
||||
return strings.ToLower(strings.TrimSpace(network))
|
||||
}
|
||||
|
||||
func normalizeWalletAddressByNetwork(network, address string) string {
|
||||
address = strings.TrimSpace(address)
|
||||
if isEVMNetwork(normalizeWalletNetwork(network)) {
|
||||
return strings.ToLower(address)
|
||||
}
|
||||
return address
|
||||
}
|
||||
|
||||
// AddWalletAddressWithNetwork 创建指定网络的钱包地址
|
||||
func AddWalletAddressWithNetwork(network, address string) (*mdb.WalletAddress, error) {
|
||||
network = strings.ToLower(strings.TrimSpace(network))
|
||||
address = strings.TrimSpace(address)
|
||||
|
||||
// evm 网络地址统一小写,tron 和 solana 保持原样
|
||||
if isEVMNetwork(network) {
|
||||
address = strings.ToLower(address)
|
||||
}
|
||||
network = normalizeWalletNetwork(network)
|
||||
address = normalizeWalletAddressByNetwork(network, address)
|
||||
|
||||
exist, err := GetWalletAddressByNetworkAndAddress(network, address)
|
||||
if err != nil {
|
||||
@@ -39,6 +46,23 @@ func AddWalletAddressWithNetwork(network, address string) (*mdb.WalletAddress, e
|
||||
if exist.ID > 0 {
|
||||
return nil, constant.WalletAddressAlreadyExists
|
||||
}
|
||||
|
||||
// Check for a soft-deleted record with the same (network, address) and restore it.
|
||||
deleted := new(mdb.WalletAddress)
|
||||
err = dao.Mdb.Unscoped().
|
||||
Where("network = ? AND address = ? AND deleted_at IS NOT NULL", network, address).
|
||||
Limit(1).Find(deleted).Error
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if deleted.ID > 0 {
|
||||
err = dao.Mdb.Unscoped().Model(deleted).Updates(map[string]interface{}{
|
||||
"deleted_at": nil,
|
||||
"status": mdb.TokenStatusEnable,
|
||||
}).Error
|
||||
return deleted, err
|
||||
}
|
||||
|
||||
walletAddress := &mdb.WalletAddress{
|
||||
Network: network,
|
||||
Address: address,
|
||||
@@ -50,6 +74,8 @@ func AddWalletAddressWithNetwork(network, address string) (*mdb.WalletAddress, e
|
||||
|
||||
// GetWalletAddressByNetworkAndAddress 通过网络和地址查询
|
||||
func GetWalletAddressByNetworkAndAddress(network, address string) (*mdb.WalletAddress, error) {
|
||||
network = normalizeWalletNetwork(network)
|
||||
address = normalizeWalletAddressByNetwork(network, address)
|
||||
walletAddress := new(mdb.WalletAddress)
|
||||
err := dao.Mdb.Model(walletAddress).
|
||||
Where("network = ?", network).
|
||||
@@ -87,11 +113,20 @@ func GetAvailableWalletAddress() ([]mdb.WalletAddress, error) {
|
||||
|
||||
// GetAvailableWalletAddressByNetwork 获得指定网络的所有可用钱包地址
|
||||
func GetAvailableWalletAddressByNetwork(network string) ([]mdb.WalletAddress, error) {
|
||||
network = normalizeWalletNetwork(network)
|
||||
var list []mdb.WalletAddress
|
||||
err := dao.Mdb.Model(list).
|
||||
Where("status = ?", mdb.TokenStatusEnable).
|
||||
Where("network = ?", network).
|
||||
Find(&list).Error
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if isEVMNetwork(network) {
|
||||
for i := range list {
|
||||
list[i].Address = strings.ToLower(strings.TrimSpace(list[i].Address))
|
||||
}
|
||||
}
|
||||
return list, err
|
||||
}
|
||||
|
||||
@@ -104,8 +139,17 @@ func GetAllWalletAddress() ([]mdb.WalletAddress, error) {
|
||||
|
||||
// GetAllWalletAddressByNetwork 获得指定网络的所有钱包地址
|
||||
func GetAllWalletAddressByNetwork(network string) ([]mdb.WalletAddress, error) {
|
||||
network = normalizeWalletNetwork(network)
|
||||
var list []mdb.WalletAddress
|
||||
err := dao.Mdb.Model(list).Where("network = ?", network).Find(&list).Error
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if isEVMNetwork(network) {
|
||||
for i := range list {
|
||||
list[i].Address = strings.ToLower(strings.TrimSpace(list[i].Address))
|
||||
}
|
||||
}
|
||||
return list, err
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,83 @@
|
||||
package data
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/assimon/luuu/internal/testutil"
|
||||
"github.com/assimon/luuu/model/dao"
|
||||
"github.com/assimon/luuu/model/mdb"
|
||||
)
|
||||
|
||||
func TestAddWalletAddressWithNetworkNormalizesEvmAddressToLowercase(t *testing.T) {
|
||||
cleanup := testutil.SetupTestDatabases(t)
|
||||
defer cleanup()
|
||||
|
||||
input := "0xA1B2c3D4e5F60718293aBcDeF001122334455667"
|
||||
row, err := AddWalletAddressWithNetwork(mdb.NetworkEthereum, input)
|
||||
if err != nil {
|
||||
t.Fatalf("add wallet: %v", err)
|
||||
}
|
||||
if row.Address != strings.ToLower(input) {
|
||||
t.Fatalf("wallet address = %q, want %q", row.Address, strings.ToLower(input))
|
||||
}
|
||||
|
||||
loaded, err := GetWalletAddressByNetworkAndAddress(mdb.NetworkEthereum, strings.ToUpper(input))
|
||||
if err != nil {
|
||||
t.Fatalf("load wallet by mixed-case address: %v", err)
|
||||
}
|
||||
if loaded.ID == 0 {
|
||||
t.Fatal("expected to find wallet by mixed-case query")
|
||||
}
|
||||
if loaded.Address != strings.ToLower(input) {
|
||||
t.Fatalf("stored wallet address = %q, want lowercase", loaded.Address)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetAvailableWalletAddressByNetworkReturnsLowercaseForEvm(t *testing.T) {
|
||||
cleanup := testutil.SetupTestDatabases(t)
|
||||
defer cleanup()
|
||||
|
||||
mixed := "0xA1B2c3D4e5F60718293aBcDeF001122334455667"
|
||||
if err := dao.Mdb.Create(&mdb.WalletAddress{
|
||||
Network: mdb.NetworkEthereum,
|
||||
Address: mixed,
|
||||
Status: mdb.TokenStatusEnable,
|
||||
}).Error; err != nil {
|
||||
t.Fatalf("seed mixed-case wallet: %v", err)
|
||||
}
|
||||
|
||||
rows, err := GetAvailableWalletAddressByNetwork("Ethereum")
|
||||
if err != nil {
|
||||
t.Fatalf("list wallets: %v", err)
|
||||
}
|
||||
if len(rows) != 1 {
|
||||
t.Fatalf("wallet count = %d, want 1", len(rows))
|
||||
}
|
||||
if rows[0].Address != strings.ToLower(mixed) {
|
||||
t.Fatalf("listed wallet address = %q, want %q", rows[0].Address, strings.ToLower(mixed))
|
||||
}
|
||||
}
|
||||
|
||||
func TestAddWalletAddressWithNetworkKeepsOriginalCaseForNonEvm(t *testing.T) {
|
||||
cleanup := testutil.SetupTestDatabases(t)
|
||||
defer cleanup()
|
||||
|
||||
tronAddress := "TCaseSensitiveTronAddress001"
|
||||
tronRow, err := AddWalletAddressWithNetwork(mdb.NetworkTron, tronAddress)
|
||||
if err != nil {
|
||||
t.Fatalf("add tron wallet: %v", err)
|
||||
}
|
||||
if tronRow.Address != tronAddress {
|
||||
t.Fatalf("tron wallet address = %q, want %q", tronRow.Address, tronAddress)
|
||||
}
|
||||
|
||||
solAddress := "SoLAnACaseSensitiveAddress111111111111111111"
|
||||
solRow, err := AddWalletAddressWithNetwork(mdb.NetworkSolana, solAddress)
|
||||
if err != nil {
|
||||
t.Fatalf("add solana wallet: %v", err)
|
||||
}
|
||||
if solRow.Address != solAddress {
|
||||
t.Fatalf("solana wallet address = %q, want %q", solRow.Address, solAddress)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user