mirror of
https://github.com/NoFxAiOS/nofx.git
synced 2025-12-06 13:54:41 +08:00
fix: resolve multiple bugs preventing trader creation (#1138)
* fix: resolve multiple bugs preventing trader creation Bug fixes: 1. Fix time.Time scanning error - SQLite stores datetime as TEXT, now parsing manually 2. Fix foreign key mismatch - traders table referenced exchanges(id) but exchanges uses composite primary key (id, user_id) 3. Add missing backtestManager field to Server struct 4. Add missing Shutdown method to Server struct 5. Fix NewFuturesTrader call - pass userId parameter 6. Fix UpdateExchange call - pass all required parameters 7. Add migrateTradersTable() to fix existing databases These issues prevented creating new traders with 500 errors. * fix(api): fix balance extraction field name mismatch Binance API returns 'availableBalance' (camelCase) but code was looking for 'available_balance' (snake_case). Now supports both formats. Also added 'totalWalletBalance' as fallback for total balance extraction. * fix(frontend): add missing ConfirmDialogProvider to App The delete trader button required ConfirmDialogProvider to be wrapped around the App component for the confirmation dialog to work. --------- Co-authored-by: NOFX Trader <nofx@local.dev>
This commit is contained in:
515
api/server.go
515
api/server.go
@@ -5,7 +5,6 @@ import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log"
|
||||
"math"
|
||||
"net"
|
||||
"net/http"
|
||||
"nofx/auth"
|
||||
@@ -13,7 +12,6 @@ import (
|
||||
"nofx/config"
|
||||
"nofx/crypto"
|
||||
"nofx/decision"
|
||||
"nofx/hook"
|
||||
"nofx/manager"
|
||||
"nofx/trader"
|
||||
"strconv"
|
||||
@@ -27,22 +25,15 @@ import (
|
||||
// Server HTTP API服务器
|
||||
type Server struct {
|
||||
router *gin.Engine
|
||||
httpServer *http.Server
|
||||
traderManager *manager.TraderManager
|
||||
database *config.Database
|
||||
cryptoHandler *CryptoHandler
|
||||
backtestManager *backtest.Manager
|
||||
httpServer *http.Server
|
||||
port int
|
||||
}
|
||||
|
||||
// NewServer 创建API服务器
|
||||
func NewServer(
|
||||
traderManager *manager.TraderManager,
|
||||
database *config.Database,
|
||||
cryptoService *crypto.CryptoService,
|
||||
backtestManager *backtest.Manager,
|
||||
port int,
|
||||
) *Server {
|
||||
func NewServer(traderManager *manager.TraderManager, database *config.Database, cryptoService *crypto.CryptoService, backtestManager *backtest.Manager, port int) *Server {
|
||||
// 设置为Release模式(减少日志输出)
|
||||
gin.SetMode(gin.ReleaseMode)
|
||||
|
||||
@@ -62,9 +53,6 @@ func NewServer(
|
||||
backtestManager: backtestManager,
|
||||
port: port,
|
||||
}
|
||||
if s.backtestManager != nil {
|
||||
s.backtestManager.SetAIResolver(s.hydrateBacktestAIConfig)
|
||||
}
|
||||
|
||||
// 设置路由
|
||||
s.setupRoutes()
|
||||
@@ -130,11 +118,6 @@ func (s *Server) setupRoutes() {
|
||||
// 需要认证的路由
|
||||
protected := api.Group("/", s.authMiddleware())
|
||||
{
|
||||
if s.backtestManager != nil {
|
||||
backtestGroup := protected.Group("/backtest")
|
||||
s.registerBacktestRoutes(backtestGroup)
|
||||
}
|
||||
|
||||
// 注销(加入黑名单)
|
||||
protected.POST("/logout", s.handleLogout)
|
||||
|
||||
@@ -150,6 +133,7 @@ func (s *Server) setupRoutes() {
|
||||
protected.POST("/traders/:id/start", s.handleStartTrader)
|
||||
protected.POST("/traders/:id/stop", s.handleStopTrader)
|
||||
protected.PUT("/traders/:id/prompt", s.handleUpdateTraderPrompt)
|
||||
protected.POST("/traders/:id/sync-balance", s.handleSyncBalance)
|
||||
|
||||
// AI模型配置
|
||||
protected.GET("/models", s.handleGetModelConfigs)
|
||||
@@ -171,7 +155,6 @@ func (s *Server) setupRoutes() {
|
||||
protected.GET("/decisions/latest", s.handleLatestDecisions)
|
||||
protected.GET("/statistics", s.handleStatistics)
|
||||
protected.GET("/performance", s.handlePerformance)
|
||||
protected.GET("/competition/full", s.handleCompetition)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -215,34 +198,16 @@ func (s *Server) handleGetSystemConfig(c *gin.Context) {
|
||||
betaModeStr, _ := s.database.GetSystemConfig("beta_mode")
|
||||
betaMode := betaModeStr == "true"
|
||||
|
||||
regEnabledStr, err := s.database.GetSystemConfig("registration_enabled")
|
||||
registrationEnabled := true
|
||||
if err == nil {
|
||||
registrationEnabled = strings.ToLower(regEnabledStr) != "false"
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"beta_mode": betaMode,
|
||||
"default_coins": defaultCoins,
|
||||
"btc_eth_leverage": btcEthLeverage,
|
||||
"altcoin_leverage": altcoinLeverage,
|
||||
"registration_enabled": registrationEnabled,
|
||||
"beta_mode": betaMode,
|
||||
"default_coins": defaultCoins,
|
||||
"btc_eth_leverage": btcEthLeverage,
|
||||
"altcoin_leverage": altcoinLeverage,
|
||||
})
|
||||
}
|
||||
|
||||
// handleGetServerIP 获取服务器IP地址(用于白名单配置)
|
||||
func (s *Server) handleGetServerIP(c *gin.Context) {
|
||||
|
||||
// 首先尝试从Hook获取用户专用IP
|
||||
userIP := hook.HookExec[hook.IpResult](hook.GETIP, c.GetString("user_id"))
|
||||
if userIP != nil && userIP.Error() == nil {
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"public_ip": userIP.GetResult(),
|
||||
"message": "请将此IP地址添加到白名单中",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// 尝试通过第三方API获取公网IP
|
||||
publicIP := getPublicIPFromAPI()
|
||||
|
||||
@@ -431,8 +396,8 @@ type SafeModelConfig struct {
|
||||
Name string `json:"name"`
|
||||
Provider string `json:"provider"`
|
||||
Enabled bool `json:"enabled"`
|
||||
CustomAPIURL string `json:"customApiUrl"` // 自定义API URL(通常不敏感)
|
||||
CustomModelName string `json:"customModelName"` // 自定义模型名(不敏感)
|
||||
CustomAPIURL string `json:"customApiUrl"` // 自定义API URL(通常不敏感)
|
||||
CustomModelName string `json:"customModelName"` // 自定义模型名(不敏感)
|
||||
}
|
||||
|
||||
type ExchangeConfig struct {
|
||||
@@ -453,8 +418,8 @@ type SafeExchangeConfig struct {
|
||||
Enabled bool `json:"enabled"`
|
||||
Testnet bool `json:"testnet,omitempty"`
|
||||
HyperliquidWalletAddr string `json:"hyperliquidWalletAddr"` // Hyperliquid钱包地址(不敏感)
|
||||
AsterUser string `json:"asterUser"` // Aster用户名(不敏感)
|
||||
AsterSigner string `json:"asterSigner"` // Aster签名者(不敏感)
|
||||
AsterUser string `json:"asterUser"` // Aster用户名(不敏感)
|
||||
AsterSigner string `json:"asterSigner"` // Aster签名者(不敏感)
|
||||
}
|
||||
|
||||
type UpdateModelConfigRequest struct {
|
||||
@@ -512,9 +477,8 @@ func (s *Server) handleCreateTrader(c *gin.Context) {
|
||||
}
|
||||
}
|
||||
|
||||
// 生成交易员ID (使用 UUID 确保唯一性,解决 Issue #893)
|
||||
// 保留前缀以便调试和日志追踪
|
||||
traderID := fmt.Sprintf("%s_%s_%s", req.ExchangeID, req.AIModelID, uuid.New().String())
|
||||
// 生成交易员ID
|
||||
traderID := fmt.Sprintf("%s_%s_%d", req.ExchangeID, req.AIModelID, time.Now().Unix())
|
||||
|
||||
// 设置默认值
|
||||
isCrossMargin := true // 默认为全仓模式
|
||||
@@ -610,42 +574,32 @@ func (s *Server) handleCreateTrader(c *gin.Context) {
|
||||
if balanceErr != nil {
|
||||
log.Printf("⚠️ 查询交易所余额失败,使用用户输入的初始资金: %v", balanceErr)
|
||||
} else {
|
||||
// 🔧 计算Total Equity = Wallet Balance + Unrealized Profit
|
||||
// 这是账户的真实净值,用作Initial Balance的基准
|
||||
var totalWalletBalance float64
|
||||
var totalUnrealizedProfit float64
|
||||
|
||||
// 提取钱包余额
|
||||
if wb, ok := balanceInfo["totalWalletBalance"].(float64); ok {
|
||||
totalWalletBalance = wb
|
||||
} else if wb, ok := balanceInfo["wallet_balance"].(float64); ok {
|
||||
totalWalletBalance = wb
|
||||
} else if wb, ok := balanceInfo["balance"].(float64); ok {
|
||||
totalWalletBalance = wb
|
||||
}
|
||||
|
||||
// 提取未实现盈亏
|
||||
if up, ok := balanceInfo["totalUnrealizedProfit"].(float64); ok {
|
||||
totalUnrealizedProfit = up
|
||||
} else if up, ok := balanceInfo["unrealized_profit"].(float64); ok {
|
||||
totalUnrealizedProfit = up
|
||||
}
|
||||
|
||||
// 计算总净值
|
||||
totalEquity := totalWalletBalance + totalUnrealizedProfit
|
||||
|
||||
if totalEquity > 0 {
|
||||
actualBalance = totalEquity
|
||||
log.Printf("✅ 查询到交易所实际净值: %.2f USDT (钱包: %.2f + 未实现: %.2f, 用户输入: %.2f)",
|
||||
actualBalance, totalWalletBalance, totalUnrealizedProfit, req.InitialBalance)
|
||||
// 提取可用余额 - 支持多种字段名格式
|
||||
if availableBalance, ok := balanceInfo["availableBalance"].(float64); ok && availableBalance > 0 {
|
||||
// Binance 格式: availableBalance (camelCase)
|
||||
actualBalance = availableBalance
|
||||
log.Printf("✓ 查询到交易所实际余额: %.2f USDT (用户输入: %.2f USDT)", actualBalance, req.InitialBalance)
|
||||
} else if availableBalance, ok := balanceInfo["available_balance"].(float64); ok && availableBalance > 0 {
|
||||
// 其他格式: available_balance (snake_case)
|
||||
actualBalance = availableBalance
|
||||
log.Printf("✓ 查询到交易所实际余额: %.2f USDT (用户输入: %.2f USDT)", actualBalance, req.InitialBalance)
|
||||
} else if totalBalance, ok := balanceInfo["totalWalletBalance"].(float64); ok && totalBalance > 0 {
|
||||
// Binance 格式: totalWalletBalance (camelCase)
|
||||
actualBalance = totalBalance
|
||||
log.Printf("✓ 查询到交易所总余额: %.2f USDT (用户输入: %.2f USDT)", actualBalance, req.InitialBalance)
|
||||
} else if totalBalance, ok := balanceInfo["balance"].(float64); ok && totalBalance > 0 {
|
||||
// 其他格式: balance
|
||||
actualBalance = totalBalance
|
||||
log.Printf("✓ 查询到交易所实际余额: %.2f USDT (用户输入: %.2f USDT)", actualBalance, req.InitialBalance)
|
||||
} else {
|
||||
log.Printf("⚠️ 无法从余额信息中计算净值,使用用户输入的初始资金")
|
||||
log.Printf("⚠️ 无法从余额信息中提取可用余额,balanceInfo=%v,使用用户输入的初始资金", balanceInfo)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 创建交易员配置(数据库实体)
|
||||
log.Printf("🔧 DEBUG: 开始创建交易员配置, ID=%s, Name=%s, AIModel=%s, Exchange=%s", traderID, req.Name, req.AIModelID, req.ExchangeID)
|
||||
trader := &config.TraderRecord{
|
||||
ID: traderID,
|
||||
UserID: userID,
|
||||
@@ -667,18 +621,23 @@ func (s *Server) handleCreateTrader(c *gin.Context) {
|
||||
}
|
||||
|
||||
// 保存到数据库
|
||||
log.Printf("🔧 DEBUG: 准备调用 CreateTrader")
|
||||
err = s.database.CreateTrader(trader)
|
||||
if err != nil {
|
||||
log.Printf("❌ 创建交易员失败: %v", err)
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": fmt.Sprintf("创建交易员失败: %v", err)})
|
||||
return
|
||||
}
|
||||
log.Printf("🔧 DEBUG: CreateTrader 成功")
|
||||
|
||||
// 立即将新交易员加载到TraderManager中
|
||||
err = s.traderManager.LoadTraderByID(s.database, userID, traderID)
|
||||
log.Printf("🔧 DEBUG: 准备调用 LoadUserTraders")
|
||||
err = s.traderManager.LoadUserTraders(s.database, userID)
|
||||
if err != nil {
|
||||
log.Printf("⚠️ 加载交易员到内存失败: %v", err)
|
||||
log.Printf("⚠️ 加载用户交易员到内存失败: %v", err)
|
||||
// 这里不返回错误,因为交易员已经成功创建到数据库
|
||||
}
|
||||
log.Printf("🔧 DEBUG: LoadUserTraders 完成")
|
||||
|
||||
log.Printf("✓ 创建交易员成功: %s (模型: %s, 交易所: %s)", req.Name, req.AIModelID, req.ExchangeID)
|
||||
|
||||
@@ -692,18 +651,17 @@ func (s *Server) handleCreateTrader(c *gin.Context) {
|
||||
|
||||
// UpdateTraderRequest 更新交易员请求
|
||||
type UpdateTraderRequest struct {
|
||||
Name string `json:"name" binding:"required"`
|
||||
AIModelID string `json:"ai_model_id" binding:"required"`
|
||||
ExchangeID string `json:"exchange_id" binding:"required"`
|
||||
InitialBalance float64 `json:"initial_balance"`
|
||||
ScanIntervalMinutes int `json:"scan_interval_minutes"`
|
||||
BTCETHLeverage int `json:"btc_eth_leverage"`
|
||||
AltcoinLeverage int `json:"altcoin_leverage"`
|
||||
TradingSymbols string `json:"trading_symbols"`
|
||||
CustomPrompt string `json:"custom_prompt"`
|
||||
OverrideBasePrompt bool `json:"override_base_prompt"`
|
||||
SystemPromptTemplate string `json:"system_prompt_template"`
|
||||
IsCrossMargin *bool `json:"is_cross_margin"`
|
||||
Name string `json:"name" binding:"required"`
|
||||
AIModelID string `json:"ai_model_id" binding:"required"`
|
||||
ExchangeID string `json:"exchange_id" binding:"required"`
|
||||
InitialBalance float64 `json:"initial_balance"`
|
||||
ScanIntervalMinutes int `json:"scan_interval_minutes"`
|
||||
BTCETHLeverage int `json:"btc_eth_leverage"`
|
||||
AltcoinLeverage int `json:"altcoin_leverage"`
|
||||
TradingSymbols string `json:"trading_symbols"`
|
||||
CustomPrompt string `json:"custom_prompt"`
|
||||
OverrideBasePrompt bool `json:"override_base_prompt"`
|
||||
IsCrossMargin *bool `json:"is_cross_margin"`
|
||||
}
|
||||
|
||||
// handleUpdateTrader 更新交易员配置
|
||||
@@ -761,12 +719,6 @@ func (s *Server) handleUpdateTrader(c *gin.Context) {
|
||||
scanIntervalMinutes = 3
|
||||
}
|
||||
|
||||
// 设置提示词模板,允许更新
|
||||
systemPromptTemplate := req.SystemPromptTemplate
|
||||
if systemPromptTemplate == "" {
|
||||
systemPromptTemplate = existingTrader.SystemPromptTemplate // 如果请求中没有提供,保持原值
|
||||
}
|
||||
|
||||
// 更新交易员配置
|
||||
trader := &config.TraderRecord{
|
||||
ID: traderID,
|
||||
@@ -780,7 +732,7 @@ func (s *Server) handleUpdateTrader(c *gin.Context) {
|
||||
TradingSymbols: req.TradingSymbols,
|
||||
CustomPrompt: req.CustomPrompt,
|
||||
OverrideBasePrompt: req.OverrideBasePrompt,
|
||||
SystemPromptTemplate: systemPromptTemplate,
|
||||
SystemPromptTemplate: existingTrader.SystemPromptTemplate, // 保持原值
|
||||
IsCrossMargin: isCrossMargin,
|
||||
ScanIntervalMinutes: scanIntervalMinutes,
|
||||
IsRunning: existingTrader.IsRunning, // 保持原值
|
||||
@@ -793,25 +745,10 @@ func (s *Server) handleUpdateTrader(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
// 如果请求中包含initial_balance且与现有值不同,单独更新它
|
||||
// UpdateTrader不会更新initial_balance,需要使用专门的方法
|
||||
if req.InitialBalance > 0 && math.Abs(req.InitialBalance-existingTrader.InitialBalance) > 0.1 {
|
||||
err = s.database.UpdateTraderInitialBalance(userID, traderID, req.InitialBalance)
|
||||
if err != nil {
|
||||
log.Printf("⚠️ 更新初始余额失败: %v", err)
|
||||
// 不返回错误,因为主要配置已更新成功
|
||||
} else {
|
||||
log.Printf("✓ 初始余额已更新: %.2f -> %.2f", existingTrader.InitialBalance, req.InitialBalance)
|
||||
}
|
||||
}
|
||||
|
||||
// 🔄 从内存中移除旧的trader实例,以便重新加载最新配置
|
||||
s.traderManager.RemoveTrader(traderID)
|
||||
|
||||
// 重新加载交易员到内存
|
||||
err = s.traderManager.LoadTraderByID(s.database, userID, traderID)
|
||||
err = s.traderManager.LoadUserTraders(s.database, userID)
|
||||
if err != nil {
|
||||
log.Printf("⚠️ 重新加载交易员到内存失败: %v", err)
|
||||
log.Printf("⚠️ 重新加载用户交易员到内存失败: %v", err)
|
||||
}
|
||||
|
||||
log.Printf("✓ 更新交易员成功: %s (模型: %s, 交易所: %s)", req.Name, req.AIModelID, req.ExchangeID)
|
||||
@@ -855,15 +792,12 @@ func (s *Server) handleStartTrader(c *gin.Context) {
|
||||
traderID := c.Param("id")
|
||||
|
||||
// 校验交易员是否属于当前用户
|
||||
traderRecord, _, _, err := s.database.GetTraderConfig(userID, traderID)
|
||||
_, _, _, err := s.database.GetTraderConfig(userID, traderID)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "交易员不存在或无访问权限"})
|
||||
return
|
||||
}
|
||||
|
||||
// 获取模板名称
|
||||
templateName := traderRecord.SystemPromptTemplate
|
||||
|
||||
trader, err := s.traderManager.GetTrader(traderID)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "交易员不存在"})
|
||||
@@ -877,9 +811,6 @@ func (s *Server) handleStartTrader(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
// 重新加载系统提示词模板(确保使用最新的硬盘文件)
|
||||
s.reloadPromptTemplatesWithLog(templateName)
|
||||
|
||||
// 启动交易员
|
||||
go func() {
|
||||
log.Printf("▶️ 启动交易员 %s (%s)", traderID, trader.GetName())
|
||||
@@ -969,6 +900,113 @@ func (s *Server) handleUpdateTraderPrompt(c *gin.Context) {
|
||||
c.JSON(http.StatusOK, gin.H{"message": "自定义prompt已更新"})
|
||||
}
|
||||
|
||||
// handleSyncBalance 同步交易所余额到initial_balance(选项B:手动同步 + 选项C:智能检测)
|
||||
func (s *Server) handleSyncBalance(c *gin.Context) {
|
||||
userID := c.GetString("user_id")
|
||||
traderID := c.Param("id")
|
||||
|
||||
log.Printf("🔄 用户 %s 请求同步交易员 %s 的余额", userID, traderID)
|
||||
|
||||
// 从数据库获取交易员配置(包含交易所信息)
|
||||
traderConfig, _, exchangeCfg, err := s.database.GetTraderConfig(userID, traderID)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "交易员不存在"})
|
||||
return
|
||||
}
|
||||
|
||||
if exchangeCfg == nil || !exchangeCfg.Enabled {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "交易所未配置或未启用"})
|
||||
return
|
||||
}
|
||||
|
||||
// 创建临时 trader 查询余额
|
||||
var tempTrader trader.Trader
|
||||
var createErr error
|
||||
|
||||
switch traderConfig.ExchangeID {
|
||||
case "binance":
|
||||
tempTrader = trader.NewFuturesTrader(exchangeCfg.APIKey, exchangeCfg.SecretKey, userID)
|
||||
case "hyperliquid":
|
||||
tempTrader, createErr = trader.NewHyperliquidTrader(
|
||||
exchangeCfg.APIKey,
|
||||
exchangeCfg.HyperliquidWalletAddr,
|
||||
exchangeCfg.Testnet,
|
||||
)
|
||||
case "aster":
|
||||
tempTrader, createErr = trader.NewAsterTrader(
|
||||
exchangeCfg.AsterUser,
|
||||
exchangeCfg.AsterSigner,
|
||||
exchangeCfg.AsterPrivateKey,
|
||||
)
|
||||
default:
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "不支持的交易所类型"})
|
||||
return
|
||||
}
|
||||
|
||||
if createErr != nil {
|
||||
log.Printf("⚠️ 创建临时 trader 失败: %v", createErr)
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": fmt.Sprintf("连接交易所失败: %v", createErr)})
|
||||
return
|
||||
}
|
||||
|
||||
// 查询实际余额
|
||||
balanceInfo, balanceErr := tempTrader.GetBalance()
|
||||
if balanceErr != nil {
|
||||
log.Printf("⚠️ 查询交易所余额失败: %v", balanceErr)
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": fmt.Sprintf("查询余额失败: %v", balanceErr)})
|
||||
return
|
||||
}
|
||||
|
||||
// 提取可用余额
|
||||
var actualBalance float64
|
||||
if availableBalance, ok := balanceInfo["available_balance"].(float64); ok && availableBalance > 0 {
|
||||
actualBalance = availableBalance
|
||||
} else if availableBalance, ok := balanceInfo["availableBalance"].(float64); ok && availableBalance > 0 {
|
||||
actualBalance = availableBalance
|
||||
} else if totalBalance, ok := balanceInfo["balance"].(float64); ok && totalBalance > 0 {
|
||||
actualBalance = totalBalance
|
||||
} else {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "无法获取可用余额"})
|
||||
return
|
||||
}
|
||||
|
||||
oldBalance := traderConfig.InitialBalance
|
||||
|
||||
// ✅ 选项C:智能检测余额变化
|
||||
changePercent := ((actualBalance - oldBalance) / oldBalance) * 100
|
||||
changeType := "增加"
|
||||
if changePercent < 0 {
|
||||
changeType = "减少"
|
||||
}
|
||||
|
||||
log.Printf("✓ 查询到交易所实际余额: %.2f USDT (当前配置: %.2f USDT, 变化: %.2f%%)",
|
||||
actualBalance, oldBalance, changePercent)
|
||||
|
||||
// 更新数据库中的 initial_balance
|
||||
err = s.database.UpdateTraderInitialBalance(userID, traderID, actualBalance)
|
||||
if err != nil {
|
||||
log.Printf("❌ 更新initial_balance失败: %v", err)
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "更新余额失败"})
|
||||
return
|
||||
}
|
||||
|
||||
// 重新加载交易员到内存
|
||||
err = s.traderManager.LoadUserTraders(s.database, userID)
|
||||
if err != nil {
|
||||
log.Printf("⚠️ 重新加载用户交易员到内存失败: %v", err)
|
||||
}
|
||||
|
||||
log.Printf("✅ 已同步余额: %.2f → %.2f USDT (%s %.2f%%)", oldBalance, actualBalance, changeType, changePercent)
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"message": "余额同步成功",
|
||||
"old_balance": oldBalance,
|
||||
"new_balance": actualBalance,
|
||||
"change_percent": changePercent,
|
||||
"change_type": changeType,
|
||||
})
|
||||
}
|
||||
|
||||
// handleGetModelConfigs 获取AI模型配置
|
||||
func (s *Server) handleGetModelConfigs(c *gin.Context) {
|
||||
userID := c.GetString("user_id")
|
||||
@@ -1060,7 +1098,7 @@ func (s *Server) handleUpdateModelConfigs(c *gin.Context) {
|
||||
// 这里不返回错误,因为模型配置已经成功更新到数据库
|
||||
}
|
||||
|
||||
log.Printf("✓ AI模型配置已更新: %+v", SanitizeModelConfigForLog(req.Models))
|
||||
log.Printf("✓ AI模型配置已更新: %+v", req.Models)
|
||||
c.JSON(http.StatusOK, gin.H{"message": "模型配置已更新"})
|
||||
}
|
||||
|
||||
@@ -1075,6 +1113,23 @@ func (s *Server) handleGetExchangeConfigs(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
log.Printf("✅ 找到 %d 个交易所配置", len(exchanges))
|
||||
|
||||
// 调试:输出配置详情(脱敏)
|
||||
for _, ex := range exchanges {
|
||||
apiKeyMasked := ""
|
||||
if len(ex.APIKey) > 8 {
|
||||
apiKeyMasked = ex.APIKey[:8] + "..."
|
||||
}
|
||||
secretKeyMasked := ""
|
||||
if len(ex.SecretKey) > 8 {
|
||||
secretKeyMasked = ex.SecretKey[:8] + "..."
|
||||
}
|
||||
log.Printf(" └─ 交易所: %s, APIKey: %s, SecretKey: %s", ex.ID, apiKeyMasked, secretKeyMasked)
|
||||
}
|
||||
|
||||
// 打印完整JSON响应用于调试
|
||||
jsonData, _ := json.Marshal(exchanges)
|
||||
log.Printf("📤 完整JSON响应: %s", string(jsonData))
|
||||
|
||||
// 转换为安全的响应结构,移除敏感信息
|
||||
safeExchanges := make([]SafeExchangeConfig, len(exchanges))
|
||||
@@ -1157,7 +1212,7 @@ func (s *Server) handleUpdateExchangeConfigs(c *gin.Context) {
|
||||
// 这里不返回错误,因为交易所配置已经成功更新到数据库
|
||||
}
|
||||
|
||||
log.Printf("✓ 交易所配置已更新: %+v", SanitizeExchangeConfigForLog(req.Exchanges))
|
||||
log.Printf("✓ 交易所配置已更新: %+v", req.Exchanges)
|
||||
c.JSON(http.StatusOK, gin.H{"message": "交易所配置已更新"})
|
||||
}
|
||||
|
||||
@@ -1226,13 +1281,12 @@ func (s *Server) handleTraderList(c *gin.Context) {
|
||||
// 返回完整的 AIModelID(如 "admin_deepseek"),不要截断
|
||||
// 前端需要完整 ID 来验证模型是否存在(与 handleGetTraderConfig 保持一致)
|
||||
result = append(result, map[string]interface{}{
|
||||
"trader_id": trader.ID,
|
||||
"trader_name": trader.Name,
|
||||
"ai_model": trader.AIModelID, // 使用完整 ID
|
||||
"exchange_id": trader.ExchangeID,
|
||||
"is_running": isRunning,
|
||||
"initial_balance": trader.InitialBalance,
|
||||
"system_prompt_template": trader.SystemPromptTemplate,
|
||||
"trader_id": trader.ID,
|
||||
"trader_name": trader.Name,
|
||||
"ai_model": trader.AIModelID, // 使用完整 ID
|
||||
"exchange_id": trader.ExchangeID,
|
||||
"is_running": isRunning,
|
||||
"initial_balance": trader.InitialBalance,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1268,22 +1322,21 @@ func (s *Server) handleGetTraderConfig(c *gin.Context) {
|
||||
aiModelID := traderConfig.AIModelID
|
||||
|
||||
result := map[string]interface{}{
|
||||
"trader_id": traderConfig.ID,
|
||||
"trader_name": traderConfig.Name,
|
||||
"ai_model": aiModelID,
|
||||
"exchange_id": traderConfig.ExchangeID,
|
||||
"initial_balance": traderConfig.InitialBalance,
|
||||
"scan_interval_minutes": traderConfig.ScanIntervalMinutes,
|
||||
"btc_eth_leverage": traderConfig.BTCETHLeverage,
|
||||
"altcoin_leverage": traderConfig.AltcoinLeverage,
|
||||
"trading_symbols": traderConfig.TradingSymbols,
|
||||
"custom_prompt": traderConfig.CustomPrompt,
|
||||
"override_base_prompt": traderConfig.OverrideBasePrompt,
|
||||
"system_prompt_template": traderConfig.SystemPromptTemplate,
|
||||
"is_cross_margin": traderConfig.IsCrossMargin,
|
||||
"use_coin_pool": traderConfig.UseCoinPool,
|
||||
"use_oi_top": traderConfig.UseOITop,
|
||||
"is_running": isRunning,
|
||||
"trader_id": traderConfig.ID,
|
||||
"trader_name": traderConfig.Name,
|
||||
"ai_model": aiModelID,
|
||||
"exchange_id": traderConfig.ExchangeID,
|
||||
"initial_balance": traderConfig.InitialBalance,
|
||||
"scan_interval_minutes": traderConfig.ScanIntervalMinutes,
|
||||
"btc_eth_leverage": traderConfig.BTCETHLeverage,
|
||||
"altcoin_leverage": traderConfig.AltcoinLeverage,
|
||||
"trading_symbols": traderConfig.TradingSymbols,
|
||||
"custom_prompt": traderConfig.CustomPrompt,
|
||||
"override_base_prompt": traderConfig.OverrideBasePrompt,
|
||||
"is_cross_margin": traderConfig.IsCrossMargin,
|
||||
"use_coin_pool": traderConfig.UseCoinPool,
|
||||
"use_oi_top": traderConfig.UseOITop,
|
||||
"is_running": isRunning,
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, result)
|
||||
@@ -1405,15 +1458,7 @@ func (s *Server) handleLatestDecisions(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
// 从 query 参数读取 limit,默认 5,最大 50
|
||||
limit := 5
|
||||
if limitStr := c.Query("limit"); limitStr != "" {
|
||||
if l, err := strconv.Atoi(limitStr); err == nil && l > 0 && l <= 50 {
|
||||
limit = l
|
||||
}
|
||||
}
|
||||
|
||||
records, err := trader.GetDecisionLogger().GetLatestRecords(limit)
|
||||
records, err := trader.GetDecisionLogger().GetLatestRecords(5)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{
|
||||
"error": fmt.Sprintf("获取决策日志失败: %v", err),
|
||||
@@ -1512,16 +1557,22 @@ func (s *Server) handleEquityHistory(c *gin.Context) {
|
||||
CycleNumber int `json:"cycle_number"`
|
||||
}
|
||||
|
||||
// 从AutoTrader获取当前初始余额(用作旧数据的fallback)
|
||||
base := 0.0
|
||||
// 从AutoTrader获取初始余额(用于计算盈亏百分比)
|
||||
initialBalance := 0.0
|
||||
if status := trader.GetStatus(); status != nil {
|
||||
if ib, ok := status["initial_balance"].(float64); ok && ib > 0 {
|
||||
base = ib
|
||||
initialBalance = ib
|
||||
}
|
||||
}
|
||||
|
||||
// 如果无法从status获取,且有历史记录,则从第一条记录获取
|
||||
if initialBalance == 0 && len(records) > 0 {
|
||||
// 第一条记录的equity作为初始余额
|
||||
initialBalance = records[0].AccountState.TotalBalance
|
||||
}
|
||||
|
||||
// 如果还是无法获取,返回错误
|
||||
if base == 0 {
|
||||
if initialBalance == 0 {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{
|
||||
"error": "无法获取初始余额",
|
||||
})
|
||||
@@ -1531,24 +1582,14 @@ func (s *Server) handleEquityHistory(c *gin.Context) {
|
||||
var history []EquityPoint
|
||||
for _, record := range records {
|
||||
// TotalBalance字段实际存储的是TotalEquity
|
||||
// totalEquity := record.AccountState.TotalBalance
|
||||
totalEquity := record.AccountState.TotalBalance
|
||||
// TotalUnrealizedProfit字段实际存储的是TotalPnL(相对初始余额)
|
||||
// totalPnL := record.AccountState.TotalUnrealizedProfit
|
||||
walletBalance := record.AccountState.TotalBalance
|
||||
unrealizedPnL := record.AccountState.TotalUnrealizedProfit
|
||||
totalEquity := walletBalance + unrealizedPnL
|
||||
totalPnL := record.AccountState.TotalUnrealizedProfit
|
||||
|
||||
// 🔄 使用历史记录中保存的initial_balance(如果有)
|
||||
// 这样可以保持历史PNL%的准确性,即使用户后来更新了initial_balance
|
||||
if record.AccountState.InitialBalance > 0 {
|
||||
base = record.AccountState.InitialBalance
|
||||
}
|
||||
|
||||
totalPnL := totalEquity - base
|
||||
// 计算盈亏百分比
|
||||
totalPnLPct := 0.0
|
||||
if base > 0 {
|
||||
totalPnLPct = (totalPnL / base) * 100
|
||||
if initialBalance > 0 {
|
||||
totalPnLPct = (totalPnL / initialBalance) * 100
|
||||
}
|
||||
|
||||
history = append(history, EquityPoint{
|
||||
@@ -1635,6 +1676,7 @@ func (s *Server) authMiddleware() gin.HandlerFunc {
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// handleLogout 将当前token加入黑名单
|
||||
func (s *Server) handleLogout(c *gin.Context) {
|
||||
authHeader := c.GetHeader("Authorization")
|
||||
@@ -1665,14 +1707,6 @@ func (s *Server) handleLogout(c *gin.Context) {
|
||||
|
||||
// handleRegister 处理用户注册请求
|
||||
func (s *Server) handleRegister(c *gin.Context) {
|
||||
regEnabled := true
|
||||
if regStr, err := s.database.GetSystemConfig("registration_enabled"); err == nil {
|
||||
regEnabled = strings.ToLower(regStr) != "false"
|
||||
}
|
||||
if !regEnabled {
|
||||
c.JSON(http.StatusForbidden, gin.H{"error": "注册已关闭"})
|
||||
return
|
||||
}
|
||||
|
||||
var req struct {
|
||||
Email string `json:"email" binding:"required,email"`
|
||||
@@ -1707,21 +1741,8 @@ func (s *Server) handleRegister(c *gin.Context) {
|
||||
}
|
||||
|
||||
// 检查邮箱是否已存在
|
||||
existingUser, err := s.database.GetUserByEmail(req.Email)
|
||||
_, err := s.database.GetUserByEmail(req.Email)
|
||||
if err == nil {
|
||||
// 如果用户未完成OTP验证,允许重新获取OTP(支持中断后恢复注册)
|
||||
if !existingUser.OTPVerified {
|
||||
qrCodeURL := auth.GetOTPQRCodeURL(existingUser.OTPSecret, req.Email)
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"user_id": existingUser.ID,
|
||||
"email": req.Email,
|
||||
"otp_secret": existingUser.OTPSecret,
|
||||
"qr_code_url": qrCodeURL,
|
||||
"message": "检测到未完成的注册,请继续完成OTP设置",
|
||||
})
|
||||
return
|
||||
}
|
||||
// 用户已完成验证,拒绝重复注册
|
||||
c.JSON(http.StatusConflict, gin.H{"error": "邮箱已被注册"})
|
||||
return
|
||||
}
|
||||
@@ -2014,63 +2035,44 @@ func (s *Server) Start() error {
|
||||
addr := fmt.Sprintf(":%d", s.port)
|
||||
log.Printf("🌐 API服务器启动在 http://localhost%s", addr)
|
||||
log.Printf("📊 API文档:")
|
||||
log.Printf(" • GET /api/health - 健康检查")
|
||||
log.Printf(" • 公共竞赛/排行榜相关接口")
|
||||
log.Printf(" - GET /api/traders - 公开的AI交易员排行榜(无需认证)")
|
||||
log.Printf(" - GET /api/competition - 公开竞赛数据(无需认证)")
|
||||
log.Printf(" - GET /api/top-traders - 前5名交易员(无需认证)")
|
||||
log.Printf(" - GET /api/equity-history - 指定trader收益率历史(无需认证)")
|
||||
log.Printf(" - POST /api/equity-history-batch - 批量获取收益率历史(无需认证)")
|
||||
log.Printf(" - GET /api/traders/:id/public-config - 公开交易员配置(无需认证)")
|
||||
log.Printf(" • Backtest")
|
||||
log.Printf(" - GET /api/backtest/runs - 回测运行列表")
|
||||
log.Printf(" - POST /api/backtest/start - 启动新的回测")
|
||||
log.Printf(" - POST /api/backtest/pause - 暂停指定回测")
|
||||
log.Printf(" - POST /api/backtest/resume - 恢复指定回测")
|
||||
log.Printf(" - POST /api/backtest/stop - 停止指定回测")
|
||||
log.Printf(" - GET /api/backtest/status - 查询回测状态")
|
||||
log.Printf(" - GET /api/backtest/equity - 回测净值曲线")
|
||||
log.Printf(" - GET /api/backtest/trades - 回测交易记录")
|
||||
log.Printf(" - GET /api/backtest/metrics - 回测统计指标")
|
||||
log.Printf(" - GET /api/backtest/trace - 回测AI Trace")
|
||||
log.Printf(" - GET /api/backtest/export - 导出回测数据ZIP")
|
||||
log.Printf(" • Trader / 配置(需认证)")
|
||||
log.Printf(" - POST /api/traders - 创建AI交易员")
|
||||
log.Printf(" - DELETE /api/traders/:id - 删除AI交易员")
|
||||
log.Printf(" - POST /api/traders/:id/start - 启动AI交易员")
|
||||
log.Printf(" - POST /api/traders/:id/stop - 停止AI交易员")
|
||||
log.Printf(" - GET /api/models - 获取AI模型配置")
|
||||
log.Printf(" - PUT /api/models - 更新AI模型配置")
|
||||
log.Printf(" - GET /api/exchanges - 获取交易所配置")
|
||||
log.Printf(" - PUT /api/exchanges - 更新交易所配置")
|
||||
log.Printf(" - GET /api/status?trader_id=xxx - 指定trader的系统状态")
|
||||
log.Printf(" - GET /api/account?trader_id=xxx - 指定trader的账户信息")
|
||||
log.Printf(" - GET /api/positions?trader_id=xxx - 指定trader的持仓列表")
|
||||
log.Printf(" - GET /api/decisions?trader_id=xxx - 指定trader的决策日志")
|
||||
log.Printf(" - GET /api/decisions/latest?trader_id=xxx - 指定trader的最新决策")
|
||||
log.Printf(" - GET /api/statistics?trader_id=xxx - 指定trader的统计信息")
|
||||
log.Printf(" - GET /api/performance?trader_id=xxx - AI学习表现分析")
|
||||
log.Printf(" • GET /api/health - 健康检查")
|
||||
log.Printf(" • GET /api/traders - 公开的AI交易员排行榜前50名(无需认证)")
|
||||
log.Printf(" • GET /api/competition - 公开的竞赛数据(无需认证)")
|
||||
log.Printf(" • GET /api/top-traders - 前5名交易员数据(无需认证,表现对比用)")
|
||||
log.Printf(" • GET /api/equity-history?trader_id=xxx - 公开的收益率历史数据(无需认证,竞赛用)")
|
||||
log.Printf(" • GET /api/equity-history-batch?trader_ids=a,b,c - 批量获取历史数据(无需认证,表现对比优化)")
|
||||
log.Printf(" • GET /api/traders/:id/public-config - 公开的交易员配置(无需认证,不含敏感信息)")
|
||||
log.Printf(" • POST /api/traders - 创建新的AI交易员")
|
||||
log.Printf(" • DELETE /api/traders/:id - 删除AI交易员")
|
||||
log.Printf(" • POST /api/traders/:id/start - 启动AI交易员")
|
||||
log.Printf(" • POST /api/traders/:id/stop - 停止AI交易员")
|
||||
log.Printf(" • GET /api/models - 获取AI模型配置")
|
||||
log.Printf(" • PUT /api/models - 更新AI模型配置")
|
||||
log.Printf(" • GET /api/exchanges - 获取交易所配置")
|
||||
log.Printf(" • PUT /api/exchanges - 更新交易所配置")
|
||||
log.Printf(" • GET /api/status?trader_id=xxx - 指定trader的系统状态")
|
||||
log.Printf(" • GET /api/account?trader_id=xxx - 指定trader的账户信息")
|
||||
log.Printf(" • GET /api/positions?trader_id=xxx - 指定trader的持仓列表")
|
||||
log.Printf(" • GET /api/decisions?trader_id=xxx - 指定trader的决策日志")
|
||||
log.Printf(" • GET /api/decisions/latest?trader_id=xxx - 指定trader的最新决策")
|
||||
log.Printf(" • GET /api/statistics?trader_id=xxx - 指定trader的统计信息")
|
||||
log.Printf(" • GET /api/performance?trader_id=xxx - 指定trader的AI学习表现分析")
|
||||
log.Println()
|
||||
|
||||
// 创建 http.Server 以支持 graceful shutdown
|
||||
s.httpServer = &http.Server{
|
||||
Addr: addr,
|
||||
Handler: s.router,
|
||||
}
|
||||
|
||||
return s.httpServer.ListenAndServe()
|
||||
}
|
||||
|
||||
// Shutdown 优雅关闭 API 服务器
|
||||
// Shutdown 优雅关闭服务器
|
||||
func (s *Server) Shutdown() error {
|
||||
if s.httpServer == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
// 设置 5 秒超时
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
|
||||
return s.httpServer.Shutdown(ctx)
|
||||
}
|
||||
|
||||
@@ -2138,17 +2140,16 @@ func (s *Server) handlePublicTraderList(c *gin.Context) {
|
||||
result := make([]map[string]interface{}, 0, len(traders))
|
||||
for _, trader := range traders {
|
||||
result = append(result, map[string]interface{}{
|
||||
"trader_id": trader["trader_id"],
|
||||
"trader_name": trader["trader_name"],
|
||||
"ai_model": trader["ai_model"],
|
||||
"exchange": trader["exchange"],
|
||||
"is_running": trader["is_running"],
|
||||
"total_equity": trader["total_equity"],
|
||||
"total_pnl": trader["total_pnl"],
|
||||
"total_pnl_pct": trader["total_pnl_pct"],
|
||||
"position_count": trader["position_count"],
|
||||
"margin_used_pct": trader["margin_used_pct"],
|
||||
"system_prompt_template": trader["system_prompt_template"],
|
||||
"trader_id": trader["trader_id"],
|
||||
"trader_name": trader["trader_name"],
|
||||
"ai_model": trader["ai_model"],
|
||||
"exchange": trader["exchange"],
|
||||
"is_running": trader["is_running"],
|
||||
"total_equity": trader["total_equity"],
|
||||
"total_pnl": trader["total_pnl"],
|
||||
"total_pnl_pct": trader["total_pnl_pct"],
|
||||
"position_count": trader["position_count"],
|
||||
"margin_used_pct": trader["margin_used_pct"],
|
||||
})
|
||||
}
|
||||
|
||||
@@ -2316,17 +2317,3 @@ func (s *Server) handleGetPublicTraderConfig(c *gin.Context) {
|
||||
|
||||
c.JSON(http.StatusOK, result)
|
||||
}
|
||||
|
||||
// reloadPromptTemplatesWithLog 重新加载提示词模板并记录日志
|
||||
func (s *Server) reloadPromptTemplatesWithLog(templateName string) {
|
||||
if err := decision.ReloadPromptTemplates(); err != nil {
|
||||
log.Printf("⚠️ 重新加载提示词模板失败: %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
if templateName == "" {
|
||||
log.Printf("✓ 已重新加载系统提示词模板 [当前使用: default (未指定,使用默认)]")
|
||||
} else {
|
||||
log.Printf("✓ 已重新加载系统提示词模板 [当前使用: %s]", templateName)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -186,9 +186,7 @@ func (d *Database) createTables() error {
|
||||
use_oi_top BOOLEAN DEFAULT 0,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE,
|
||||
FOREIGN KEY (ai_model_id) REFERENCES ai_models(id),
|
||||
FOREIGN KEY (exchange_id) REFERENCES exchanges(id)
|
||||
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
|
||||
)`,
|
||||
|
||||
// 用户表
|
||||
@@ -390,6 +388,12 @@ func (d *Database) createTables() error {
|
||||
log.Printf("⚠️ 迁移exchanges表失败: %v", err)
|
||||
}
|
||||
|
||||
// 修复traders表的外键约束问题
|
||||
err = d.migrateTradersTable()
|
||||
if err != nil {
|
||||
log.Printf("⚠️ 迁移traders表失败: %v", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -613,6 +617,91 @@ func (d *Database) migrateExchangesTable() error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// migrateTradersTable 迁移traders表,移除外键约束
|
||||
func (d *Database) migrateTradersTable() error {
|
||||
// 检查traders表是否存在外键约束(通过尝试创建一个测试记录来判断)
|
||||
// 如果表已经没有外键约束,则跳过迁移
|
||||
var tableSQL string
|
||||
err := d.db.QueryRow(`SELECT sql FROM sqlite_master WHERE type='table' AND name='traders'`).Scan(&tableSQL)
|
||||
if err != nil {
|
||||
// 表不存在,无需迁移
|
||||
return nil
|
||||
}
|
||||
|
||||
// 检查是否包含 FOREIGN KEY (exchange_id) 或 FOREIGN KEY (ai_model_id)
|
||||
if !strings.Contains(tableSQL, "FOREIGN KEY (exchange_id)") && !strings.Contains(tableSQL, "FOREIGN KEY (ai_model_id)") {
|
||||
// 已经没有这些外键约束,无需迁移
|
||||
return nil
|
||||
}
|
||||
|
||||
log.Printf("🔄 开始迁移traders表,移除外键约束...")
|
||||
|
||||
// 创建新的traders表,不包含exchange_id和ai_model_id的外键约束
|
||||
_, err = d.db.Exec(`
|
||||
CREATE TABLE traders_new (
|
||||
id TEXT PRIMARY KEY,
|
||||
user_id TEXT NOT NULL DEFAULT 'default',
|
||||
name TEXT NOT NULL,
|
||||
ai_model_id TEXT NOT NULL,
|
||||
exchange_id TEXT NOT NULL,
|
||||
initial_balance REAL NOT NULL,
|
||||
scan_interval_minutes INTEGER DEFAULT 3,
|
||||
is_running BOOLEAN DEFAULT 0,
|
||||
btc_eth_leverage INTEGER DEFAULT 5,
|
||||
altcoin_leverage INTEGER DEFAULT 5,
|
||||
trading_symbols TEXT DEFAULT '',
|
||||
use_coin_pool BOOLEAN DEFAULT 0,
|
||||
use_oi_top BOOLEAN DEFAULT 0,
|
||||
custom_prompt TEXT DEFAULT '',
|
||||
override_base_prompt BOOLEAN DEFAULT 0,
|
||||
system_prompt_template TEXT DEFAULT 'default',
|
||||
is_cross_margin BOOLEAN DEFAULT 1,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
|
||||
)
|
||||
`)
|
||||
if err != nil {
|
||||
return fmt.Errorf("创建新traders表失败: %w", err)
|
||||
}
|
||||
|
||||
// 复制数据到新表
|
||||
_, err = d.db.Exec(`
|
||||
INSERT INTO traders_new (id, user_id, name, ai_model_id, exchange_id, initial_balance,
|
||||
scan_interval_minutes, is_running, btc_eth_leverage, altcoin_leverage, trading_symbols,
|
||||
use_coin_pool, use_oi_top, custom_prompt, override_base_prompt, system_prompt_template,
|
||||
is_cross_margin, created_at, updated_at)
|
||||
SELECT id, user_id, name, ai_model_id, exchange_id, initial_balance,
|
||||
scan_interval_minutes, is_running,
|
||||
COALESCE(btc_eth_leverage, 5), COALESCE(altcoin_leverage, 5),
|
||||
COALESCE(trading_symbols, ''), COALESCE(use_coin_pool, 0), COALESCE(use_oi_top, 0),
|
||||
COALESCE(custom_prompt, ''), COALESCE(override_base_prompt, 0),
|
||||
COALESCE(system_prompt_template, 'default'), COALESCE(is_cross_margin, 1),
|
||||
created_at, updated_at
|
||||
FROM traders
|
||||
`)
|
||||
if err != nil {
|
||||
// 如果复制失败,删除新表
|
||||
d.db.Exec(`DROP TABLE traders_new`)
|
||||
return fmt.Errorf("复制traders数据失败: %w", err)
|
||||
}
|
||||
|
||||
// 删除旧表
|
||||
_, err = d.db.Exec(`DROP TABLE traders`)
|
||||
if err != nil {
|
||||
return fmt.Errorf("删除旧traders表失败: %w", err)
|
||||
}
|
||||
|
||||
// 重命名新表
|
||||
_, err = d.db.Exec(`ALTER TABLE traders_new RENAME TO traders`)
|
||||
if err != nil {
|
||||
return fmt.Errorf("重命名traders表失败: %w", err)
|
||||
}
|
||||
|
||||
log.Printf("✅ traders表迁移完成,已移除外键约束")
|
||||
return nil
|
||||
}
|
||||
|
||||
// User 用户配置
|
||||
type User struct {
|
||||
ID string `json:"id"`
|
||||
@@ -744,32 +833,38 @@ func (d *Database) EnsureAdminUser() error {
|
||||
// GetUserByEmail 通过邮箱获取用户
|
||||
func (d *Database) GetUserByEmail(email string) (*User, error) {
|
||||
var user User
|
||||
var createdAt, updatedAt string
|
||||
err := d.db.QueryRow(`
|
||||
SELECT id, email, password_hash, otp_secret, otp_verified, created_at, updated_at
|
||||
FROM users WHERE email = ?
|
||||
`, email).Scan(
|
||||
&user.ID, &user.Email, &user.PasswordHash, &user.OTPSecret,
|
||||
&user.OTPVerified, &user.CreatedAt, &user.UpdatedAt,
|
||||
&user.OTPVerified, &createdAt, &updatedAt,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
user.CreatedAt, _ = time.Parse("2006-01-02 15:04:05", createdAt)
|
||||
user.UpdatedAt, _ = time.Parse("2006-01-02 15:04:05", updatedAt)
|
||||
return &user, nil
|
||||
}
|
||||
|
||||
// GetUserByID 通过ID获取用户
|
||||
func (d *Database) GetUserByID(userID string) (*User, error) {
|
||||
var user User
|
||||
var createdAt, updatedAt string
|
||||
err := d.db.QueryRow(`
|
||||
SELECT id, email, password_hash, otp_secret, otp_verified, created_at, updated_at
|
||||
FROM users WHERE id = ?
|
||||
`, userID).Scan(
|
||||
&user.ID, &user.Email, &user.PasswordHash, &user.OTPSecret,
|
||||
&user.OTPVerified, &user.CreatedAt, &user.UpdatedAt,
|
||||
&user.OTPVerified, &createdAt, &updatedAt,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
user.CreatedAt, _ = time.Parse("2006-01-02 15:04:05", createdAt)
|
||||
user.UpdatedAt, _ = time.Parse("2006-01-02 15:04:05", updatedAt)
|
||||
return &user, nil
|
||||
}
|
||||
|
||||
@@ -826,14 +921,18 @@ func (d *Database) GetAIModels(userID string) ([]*AIModelConfig, error) {
|
||||
models := make([]*AIModelConfig, 0)
|
||||
for rows.Next() {
|
||||
var model AIModelConfig
|
||||
var createdAt, updatedAt string
|
||||
err := rows.Scan(
|
||||
&model.ID, &model.UserID, &model.Name, &model.Provider,
|
||||
&model.Enabled, &model.APIKey, &model.CustomAPIURL, &model.CustomModelName,
|
||||
&model.CreatedAt, &model.UpdatedAt,
|
||||
&createdAt, &updatedAt,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// 解析时间字符串
|
||||
model.CreatedAt, _ = time.Parse("2006-01-02 15:04:05", createdAt)
|
||||
model.UpdatedAt, _ = time.Parse("2006-01-02 15:04:05", updatedAt)
|
||||
// 解密API Key
|
||||
model.APIKey = d.decryptSensitiveData(model.APIKey)
|
||||
models = append(models, &model)
|
||||
@@ -861,6 +960,7 @@ func (d *Database) GetAIModel(userID, modelID string) (*AIModelConfig, error) {
|
||||
|
||||
for _, uid := range candidates {
|
||||
var model AIModelConfig
|
||||
var createdAt, updatedAt string
|
||||
err := d.db.QueryRow(`
|
||||
SELECT id, user_id, name, provider, enabled, api_key,
|
||||
COALESCE(custom_api_url, ''), COALESCE(custom_model_name, ''), created_at, updated_at
|
||||
@@ -876,10 +976,13 @@ func (d *Database) GetAIModel(userID, modelID string) (*AIModelConfig, error) {
|
||||
&model.APIKey,
|
||||
&model.CustomAPIURL,
|
||||
&model.CustomModelName,
|
||||
&model.CreatedAt,
|
||||
&model.UpdatedAt,
|
||||
&createdAt,
|
||||
&updatedAt,
|
||||
)
|
||||
if err == nil {
|
||||
// 解析时间字符串
|
||||
model.CreatedAt, _ = time.Parse("2006-01-02 15:04:05", createdAt)
|
||||
model.UpdatedAt, _ = time.Parse("2006-01-02 15:04:05", updatedAt)
|
||||
// 解密API Key(与 GetAIModels 行为保持一致)
|
||||
model.APIKey = d.decryptSensitiveData(model.APIKey)
|
||||
return &model, nil
|
||||
@@ -912,6 +1015,7 @@ func (d *Database) GetDefaultAIModel(userID string) (*AIModelConfig, error) {
|
||||
|
||||
func (d *Database) firstEnabledAIModel(userID string) (*AIModelConfig, error) {
|
||||
var model AIModelConfig
|
||||
var createdAt, updatedAt string
|
||||
err := d.db.QueryRow(`
|
||||
SELECT id, user_id, name, provider, enabled, api_key,
|
||||
COALESCE(custom_api_url, ''), COALESCE(custom_model_name, ''), created_at, updated_at
|
||||
@@ -928,12 +1032,15 @@ func (d *Database) firstEnabledAIModel(userID string) (*AIModelConfig, error) {
|
||||
&model.APIKey,
|
||||
&model.CustomAPIURL,
|
||||
&model.CustomModelName,
|
||||
&model.CreatedAt,
|
||||
&model.UpdatedAt,
|
||||
&createdAt,
|
||||
&updatedAt,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// 解析时间字符串
|
||||
model.CreatedAt, _ = time.Parse("2006-01-02 15:04:05", createdAt)
|
||||
model.UpdatedAt, _ = time.Parse("2006-01-02 15:04:05", updatedAt)
|
||||
// 解密API Key,避免上层拿到加密串导致下游认证失败
|
||||
model.APIKey = d.decryptSensitiveData(model.APIKey)
|
||||
return &model, nil
|
||||
@@ -1033,6 +1140,7 @@ func (d *Database) GetExchanges(userID string) ([]*ExchangeConfig, error) {
|
||||
COALESCE(aster_private_key, '') as aster_private_key,
|
||||
COALESCE(lighter_wallet_addr, '') as lighter_wallet_addr,
|
||||
COALESCE(lighter_private_key, '') as lighter_private_key,
|
||||
COALESCE(lighter_api_key_private_key, '') as lighter_api_key_private_key,
|
||||
created_at, updated_at
|
||||
FROM exchanges WHERE user_id = ? ORDER BY id
|
||||
`, userID)
|
||||
@@ -1045,23 +1153,30 @@ func (d *Database) GetExchanges(userID string) ([]*ExchangeConfig, error) {
|
||||
exchanges := make([]*ExchangeConfig, 0)
|
||||
for rows.Next() {
|
||||
var exchange ExchangeConfig
|
||||
var createdAt, updatedAt string
|
||||
err := rows.Scan(
|
||||
&exchange.ID, &exchange.UserID, &exchange.Name, &exchange.Type,
|
||||
&exchange.Enabled, &exchange.APIKey, &exchange.SecretKey, &exchange.Testnet,
|
||||
&exchange.HyperliquidWalletAddr, &exchange.AsterUser,
|
||||
&exchange.AsterSigner, &exchange.AsterPrivateKey,
|
||||
&exchange.LighterWalletAddr, &exchange.LighterPrivateKey,
|
||||
&exchange.CreatedAt, &exchange.UpdatedAt,
|
||||
&exchange.LighterAPIKeyPrivateKey,
|
||||
&createdAt, &updatedAt,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// 解析时间字符串
|
||||
exchange.CreatedAt, _ = time.Parse("2006-01-02 15:04:05", createdAt)
|
||||
exchange.UpdatedAt, _ = time.Parse("2006-01-02 15:04:05", updatedAt)
|
||||
|
||||
// 解密敏感字段
|
||||
exchange.APIKey = d.decryptSensitiveData(exchange.APIKey)
|
||||
exchange.SecretKey = d.decryptSensitiveData(exchange.SecretKey)
|
||||
exchange.AsterPrivateKey = d.decryptSensitiveData(exchange.AsterPrivateKey)
|
||||
exchange.LighterPrivateKey = d.decryptSensitiveData(exchange.LighterPrivateKey)
|
||||
exchange.LighterAPIKeyPrivateKey = d.decryptSensitiveData(exchange.LighterAPIKeyPrivateKey)
|
||||
|
||||
exchanges = append(exchanges, &exchange)
|
||||
}
|
||||
@@ -1243,6 +1358,7 @@ func (d *Database) GetTraders(userID string) ([]*TraderRecord, error) {
|
||||
var traders []*TraderRecord
|
||||
for rows.Next() {
|
||||
var trader TraderRecord
|
||||
var createdAt, updatedAt string
|
||||
err := rows.Scan(
|
||||
&trader.ID, &trader.UserID, &trader.Name, &trader.AIModelID, &trader.ExchangeID,
|
||||
&trader.InitialBalance, &trader.ScanIntervalMinutes, &trader.IsRunning,
|
||||
@@ -1250,11 +1366,14 @@ func (d *Database) GetTraders(userID string) ([]*TraderRecord, error) {
|
||||
&trader.UseCoinPool, &trader.UseOITop,
|
||||
&trader.CustomPrompt, &trader.OverrideBasePrompt, &trader.SystemPromptTemplate,
|
||||
&trader.IsCrossMargin,
|
||||
&trader.CreatedAt, &trader.UpdatedAt,
|
||||
&createdAt, &updatedAt,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// 解析时间字符串
|
||||
trader.CreatedAt, _ = time.Parse("2006-01-02 15:04:05", createdAt)
|
||||
trader.UpdatedAt, _ = time.Parse("2006-01-02 15:04:05", updatedAt)
|
||||
traders = append(traders, &trader)
|
||||
}
|
||||
|
||||
@@ -1307,6 +1426,9 @@ func (d *Database) GetTraderConfig(userID, traderID string) (*TraderRecord, *AIM
|
||||
var trader TraderRecord
|
||||
var aiModel AIModelConfig
|
||||
var exchange ExchangeConfig
|
||||
var traderCreatedAt, traderUpdatedAt string
|
||||
var aiModelCreatedAt, aiModelUpdatedAt string
|
||||
var exchangeCreatedAt, exchangeUpdatedAt string
|
||||
|
||||
err := d.db.QueryRow(`
|
||||
SELECT
|
||||
@@ -1332,6 +1454,7 @@ func (d *Database) GetTraderConfig(userID, traderID string) (*TraderRecord, *AIM
|
||||
COALESCE(e.aster_private_key, '') as aster_private_key,
|
||||
COALESCE(e.lighter_wallet_addr, '') as lighter_wallet_addr,
|
||||
COALESCE(e.lighter_private_key, '') as lighter_private_key,
|
||||
COALESCE(e.lighter_api_key_private_key, '') as lighter_api_key_private_key,
|
||||
e.created_at, e.updated_at
|
||||
FROM traders t
|
||||
JOIN ai_models a ON t.ai_model_id = a.id AND t.user_id = a.user_id
|
||||
@@ -1344,27 +1467,36 @@ func (d *Database) GetTraderConfig(userID, traderID string) (*TraderRecord, *AIM
|
||||
&trader.UseCoinPool, &trader.UseOITop,
|
||||
&trader.CustomPrompt, &trader.OverrideBasePrompt, &trader.SystemPromptTemplate,
|
||||
&trader.IsCrossMargin,
|
||||
&trader.CreatedAt, &trader.UpdatedAt,
|
||||
&traderCreatedAt, &traderUpdatedAt,
|
||||
&aiModel.ID, &aiModel.UserID, &aiModel.Name, &aiModel.Provider, &aiModel.Enabled, &aiModel.APIKey,
|
||||
&aiModel.CustomAPIURL, &aiModel.CustomModelName,
|
||||
&aiModel.CreatedAt, &aiModel.UpdatedAt,
|
||||
&aiModelCreatedAt, &aiModelUpdatedAt,
|
||||
&exchange.ID, &exchange.UserID, &exchange.Name, &exchange.Type, &exchange.Enabled,
|
||||
&exchange.APIKey, &exchange.SecretKey, &exchange.Testnet,
|
||||
&exchange.HyperliquidWalletAddr, &exchange.AsterUser, &exchange.AsterSigner, &exchange.AsterPrivateKey,
|
||||
&exchange.LighterWalletAddr, &exchange.LighterPrivateKey,
|
||||
&exchange.CreatedAt, &exchange.UpdatedAt,
|
||||
&exchange.LighterWalletAddr, &exchange.LighterPrivateKey, &exchange.LighterAPIKeyPrivateKey,
|
||||
&exchangeCreatedAt, &exchangeUpdatedAt,
|
||||
)
|
||||
|
||||
if err != nil {
|
||||
return nil, nil, nil, err
|
||||
}
|
||||
|
||||
// 解析时间字符串
|
||||
trader.CreatedAt, _ = time.Parse("2006-01-02 15:04:05", traderCreatedAt)
|
||||
trader.UpdatedAt, _ = time.Parse("2006-01-02 15:04:05", traderUpdatedAt)
|
||||
aiModel.CreatedAt, _ = time.Parse("2006-01-02 15:04:05", aiModelCreatedAt)
|
||||
aiModel.UpdatedAt, _ = time.Parse("2006-01-02 15:04:05", aiModelUpdatedAt)
|
||||
exchange.CreatedAt, _ = time.Parse("2006-01-02 15:04:05", exchangeCreatedAt)
|
||||
exchange.UpdatedAt, _ = time.Parse("2006-01-02 15:04:05", exchangeUpdatedAt)
|
||||
|
||||
// 解密敏感数据
|
||||
aiModel.APIKey = d.decryptSensitiveData(aiModel.APIKey)
|
||||
exchange.APIKey = d.decryptSensitiveData(exchange.APIKey)
|
||||
exchange.SecretKey = d.decryptSensitiveData(exchange.SecretKey)
|
||||
exchange.AsterPrivateKey = d.decryptSensitiveData(exchange.AsterPrivateKey)
|
||||
exchange.LighterPrivateKey = d.decryptSensitiveData(exchange.LighterPrivateKey)
|
||||
exchange.LighterAPIKeyPrivateKey = d.decryptSensitiveData(exchange.LighterAPIKeyPrivateKey)
|
||||
|
||||
return &trader, &aiModel, &exchange, nil
|
||||
}
|
||||
@@ -1396,16 +1528,19 @@ func (d *Database) CreateUserSignalSource(userID, coinPoolURL, oiTopURL string)
|
||||
// GetUserSignalSource 获取用户信号源配置
|
||||
func (d *Database) GetUserSignalSource(userID string) (*UserSignalSource, error) {
|
||||
var source UserSignalSource
|
||||
var createdAt, updatedAt string
|
||||
err := d.db.QueryRow(`
|
||||
SELECT id, user_id, coin_pool_url, oi_top_url, created_at, updated_at
|
||||
FROM user_signal_sources WHERE user_id = ?
|
||||
`, userID).Scan(
|
||||
&source.ID, &source.UserID, &source.CoinPoolURL, &source.OITopURL,
|
||||
&source.CreatedAt, &source.UpdatedAt,
|
||||
&createdAt, &updatedAt,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
source.CreatedAt, _ = time.Parse("2006-01-02 15:04:05", createdAt)
|
||||
source.UpdatedAt, _ = time.Parse("2006-01-02 15:04:05", updatedAt)
|
||||
return &source, nil
|
||||
}
|
||||
|
||||
|
||||
@@ -13,6 +13,7 @@ import HeaderBar from './components/HeaderBar'
|
||||
import AILearning from './components/AILearning'
|
||||
import { LanguageProvider, useLanguage } from './contexts/LanguageContext'
|
||||
import { AuthProvider, useAuth } from './contexts/AuthContext'
|
||||
import { ConfirmDialogProvider } from './components/ConfirmDialog'
|
||||
import { t, type Language } from './i18n/translations'
|
||||
import { useSystemConfig } from './hooks/useSystemConfig'
|
||||
import { DecisionCard } from './components/DecisionCard'
|
||||
@@ -1062,7 +1063,9 @@ export default function AppWithProviders() {
|
||||
return (
|
||||
<LanguageProvider>
|
||||
<AuthProvider>
|
||||
<App />
|
||||
<ConfirmDialogProvider>
|
||||
<App />
|
||||
</ConfirmDialogProvider>
|
||||
</AuthProvider>
|
||||
</LanguageProvider>
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user