diff --git a/.env.example b/.env.example index cd64fe4e..da0512fa 100644 --- a/.env.example +++ b/.env.example @@ -1,6 +1,21 @@ # NOFX Environment Variables Template # Copy this file to .env and modify the values as needed +# PostgreSQL数据库配置 +POSTGRES_HOST=postgres +POSTGRES_PORT=5432 +POSTGRES_DB=nofx +POSTGRES_USER=nofx +POSTGRES_PASSWORD=nofx123456 + +# Redis配置 +REDIS_HOST=redis +REDIS_PORT=6379 +REDIS_PASSWORD=redis123456 + +# 数据加密密钥 +DATA_ENCRYPTION_KEY=my_secret_encryption_key + # Ports Configuration # Backend API server port (internal: 8080, external: configurable) NOFX_BACKEND_PORT=8080 diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml new file mode 100644 index 00000000..31b11c12 --- /dev/null +++ b/.github/workflows/test.yml @@ -0,0 +1,54 @@ +name: Test + +on: + push: + branches: [main, dev] + pull_request: + branches: [main, dev] + +jobs: + backend-tests: + name: Backend Tests + runs-on: ubuntu-latest + continue-on-error: true # Don't block PRs + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Set up Go + uses: actions/setup-go@v5 + with: + go-version: '1.23' + + - name: Download dependencies + run: go mod download + + - name: Run tests + run: go test -v ./... + + - name: Generate coverage + run: go test -coverprofile=coverage.out ./... + continue-on-error: true + + frontend-tests: + name: Frontend Tests + runs-on: ubuntu-latest + continue-on-error: true # Don't block PRs + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Set up Node.js + uses: actions/setup-node@v4 + with: + node-version: '20' + cache: 'npm' + cache-dependency-path: web/package-lock.json + + - name: Install dependencies + run: cd web && npm ci + + - name: Run tests + run: cd web && npm run test diff --git a/.gitignore b/.gitignore index 275a54a6..1b24334e 100644 --- a/.gitignore +++ b/.gitignore @@ -34,6 +34,16 @@ config.db* nofx.db configbak.json +# 生产配置 +nginx/ +certs/ +beta_codes.txt + +# 密钥文件 +keys/ +*.key +*.pem + # 决策日志 decision_logs/ coin_pool_cache/ diff --git a/CHANGELOG.md b/CHANGELOG.md index f520303d..063b7b4b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,7 +13,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added - Documentation system with multi-language support (EN/CN/RU/UK) -- Complete getting-started guides (Docker, PM2, Custom API) +- Complete getting-started guides (Docker, Custom API) - Architecture documentation with system design details - User guides with FAQ and troubleshooting - Community documentation with bounty programs diff --git a/CHANGELOG.zh-CN.md b/CHANGELOG.zh-CN.md index 0bfdab3d..92c7840c 100644 --- a/CHANGELOG.zh-CN.md +++ b/CHANGELOG.zh-CN.md @@ -13,7 +13,7 @@ NOFX 项目的所有重要更改都将记录在此文件中。 ### 新增 - 多语言文档系统(英文/中文/俄语/乌克兰语) -- 完整的快速开始指南(Docker、PM2、自定义 API) +- 完整的快速开始指南(Docker、自定义 API) - 架构文档,包含系统设计细节 - 用户指南,包含 FAQ 和故障排除 - 社区文档,包含悬赏计划 diff --git a/Makefile b/Makefile new file mode 100644 index 00000000..4225c0f4 --- /dev/null +++ b/Makefile @@ -0,0 +1,153 @@ +# NOFX Makefile for testing and development + +.PHONY: help test test-backend test-frontend test-coverage clean + +# Default target +help: + @echo "NOFX Testing & Development Commands" + @echo "" + @echo "Testing:" + @echo " make test - Run all tests (backend + frontend)" + @echo " make test-backend - Run backend tests only" + @echo " make test-frontend - Run frontend tests only" + @echo " make test-coverage - Generate backend coverage report" + @echo "" + @echo "Build:" + @echo " make build - Build backend binary" + @echo " make build-frontend - Build frontend" + @echo "" + @echo "Clean:" + @echo " make clean - Clean build artifacts and test cache" + +# ============================================================================= +# Testing +# ============================================================================= + +# Run all tests +test: + @echo "🧪 Running backend tests..." + go test -v ./... + @echo "" + @echo "🧪 Running frontend tests..." + cd web && npm run test + @echo "✅ All tests completed" + +# Backend tests only +test-backend: + @echo "🧪 Running backend tests..." + go test -v ./... + +# Frontend tests only +test-frontend: + @echo "🧪 Running frontend tests..." + cd web && npm run test + +# Coverage report +test-coverage: + @echo "📊 Generating coverage..." + go test -coverprofile=coverage.out ./... + go tool cover -html=coverage.out -o coverage.html + @echo "✅ Backend coverage: coverage.html" + +# ============================================================================= +# Build +# ============================================================================= + +# Build backend binary +build: + @echo "🔨 Building backend..." + go build -o nofx + @echo "✅ Backend built: ./nofx" + +# Build frontend +build-frontend: + @echo "🔨 Building frontend..." + cd web && npm run build + @echo "✅ Frontend built: ./web/dist" + +# ============================================================================= +# Development +# ============================================================================= + +# Run backend in development mode +run: + @echo "🚀 Starting backend..." + go run main.go + +# Run frontend in development mode +run-frontend: + @echo "🚀 Starting frontend dev server..." + cd web && npm run dev + +# Format Go code +fmt: + @echo "🎨 Formatting Go code..." + go fmt ./... + @echo "✅ Code formatted" + +# Lint Go code (requires golangci-lint) +lint: + @echo "🔍 Linting Go code..." + golangci-lint run + @echo "✅ Linting completed" + +# ============================================================================= +# Clean +# ============================================================================= + +clean: + @echo "🧹 Cleaning..." + rm -f nofx + rm -f coverage.out coverage.html + rm -rf web/dist + go clean -testcache + @echo "✅ Cleaned" + +# ============================================================================= +# Docker +# ============================================================================= + +# Build Docker images +docker-build: + @echo "🐳 Building Docker images..." + docker compose build + @echo "✅ Docker images built" + +# Run Docker containers +docker-up: + @echo "🐳 Starting Docker containers..." + docker compose up -d + @echo "✅ Docker containers started" + +# Stop Docker containers +docker-down: + @echo "🐳 Stopping Docker containers..." + docker compose down + @echo "✅ Docker containers stopped" + +# View Docker logs +docker-logs: + docker compose logs -f + +# ============================================================================= +# Dependencies +# ============================================================================= + +# Download Go dependencies +deps: + @echo "📦 Downloading Go dependencies..." + go mod download + @echo "✅ Dependencies downloaded" + +# Update Go dependencies +deps-update: + @echo "📦 Updating Go dependencies..." + go get -u ./... + go mod tidy + @echo "✅ Dependencies updated" + +# Install frontend dependencies +deps-frontend: + @echo "📦 Installing frontend dependencies..." + cd web && npm install + @echo "✅ Frontend dependencies installed" diff --git a/README.ja.md b/README.ja.md index de215593..db4e81a0 100644 --- a/README.ja.md +++ b/README.ja.md @@ -293,8 +293,8 @@ nano config.json # または任意のエディタを使用 ```bash # オプション1:便利スクリプトを使用(推奨) -chmod +x start.sh -./start.sh start --build +chmod +x scripts/start.sh +./scripts/start.sh start --build > #### Docker Composeバージョンに関する注意 > @@ -315,10 +315,10 @@ docker compose up -d --build #### システム管理 ```bash -./start.sh logs # ログを表示 -./start.sh status # ステータスを確認 -./start.sh stop # サービスを停止 -./start.sh restart # サービスを再起動 +./scripts/start.sh logs # ログを表示 +./scripts/start.sh status # ステータスを確認 +./scripts/start.sh stop # サービスを停止 +./scripts/start.sh restart # サービスを再起動 ``` **📖 詳細なDockerデプロイガイド、トラブルシューティング、高度な設定について:** diff --git a/README.md b/README.md index 1420d2e5..ad86dfa6 100644 --- a/README.md +++ b/README.md @@ -337,8 +337,8 @@ nano config.json # or use any editor #### Step 2: One-Click Start ```bash # Option 1: Use convenience script (Recommended) -chmod +x start.sh -./start.sh start --build +chmod +x scripts/start.sh +./scripts/start.sh start --build > #### Docker Compose Version Notes > @@ -363,10 +363,10 @@ Open your browser and visit: **http://localhost:3000** #### Manage Your System ```bash -./start.sh logs # View logs -./start.sh status # Check status -./start.sh stop # Stop services -./start.sh restart # Restart services +./scripts/start.sh logs # View logs +./scripts/start.sh status # Check status +./scripts/start.sh stop # Stop services +./scripts/start.sh restart # Restart services ``` **📖 For detailed Docker deployment guide, troubleshooting, and advanced configuration:** diff --git a/config.json.example b/config.json.example index 1c7406d0..ccdccca1 100644 --- a/config.json.example +++ b/config.json.example @@ -22,5 +22,20 @@ "jwt_secret": "Qk0kAa+d0iIEzXVHXbNbm+UaN3RNabmWtH8rDWZ5OPf+4GX8pBflAHodfpbipVMyrw1fsDanHsNBjhgbDeK9Jg==", "log": { "level": "info" + }, + "proxy": { + "enabled": false, + "mode": "single", + "timeout": 30, + "proxy_url": "http://127.0.0.1:7890", + "proxy_list": [], + "brightdata_endpoint": "", + "brightdata_token": "", + "brightdata_zone": "", + "proxy_host": "", + "proxy_user": "", + "proxy_password": "", + "refresh_interval": 0, + "blacklist_ttl": 5 } -} \ No newline at end of file +} diff --git a/config/database_pg.go b/config/database_pg.go new file mode 100644 index 00000000..1acee98f --- /dev/null +++ b/config/database_pg.go @@ -0,0 +1,1046 @@ +package config + +import ( + "database/sql" + "encoding/json" + "fmt" + "log" + "nofx/crypto" + "nofx/market" + "os" + "slices" + "strings" + "time" + + _ "github.com/lib/pq" +) + +// PostgreSQLDatabase PostgreSQL数据库配置 +type PostgreSQLDatabase struct { + db *sql.DB + cryptoService *crypto.CryptoService +} + +// NewPostgreSQLDatabase 创建PostgreSQL数据库连接 +func NewPostgreSQLDatabase() (*PostgreSQLDatabase, error) { + // 从环境变量获取数据库连接信息 + host := getEnv("POSTGRES_HOST", "localhost") + port := getEnv("POSTGRES_PORT", "5432") + dbname := getEnv("POSTGRES_DB", "nofx") + user := getEnv("POSTGRES_USER", "nofx") + password := getEnv("POSTGRES_PASSWORD", "nofx123456") + + // 构建连接字符串 + dsn := fmt.Sprintf("host=%s port=%s user=%s password=%s dbname=%s sslmode=disable", + host, port, user, password, dbname) + + log.Printf("📋 连接PostgreSQL数据库: %s:%s/%s", host, port, dbname) + + db, err := sql.Open("postgres", dsn) + if err != nil { + return nil, fmt.Errorf("打开PostgreSQL数据库失败: %w", err) + } + + // 测试连接 + if err := db.Ping(); err != nil { + return nil, fmt.Errorf("连接PostgreSQL数据库失败: %w", err) + } + + // 设置连接池参数 + db.SetMaxOpenConns(25) + db.SetMaxIdleConns(5) + db.SetConnMaxLifetime(time.Hour) + + database := &PostgreSQLDatabase{db: db} + log.Printf("✅ PostgreSQL数据库连接成功") + + // 初始化默认数据 + if err := database.initDefaultData(); err != nil { + return nil, fmt.Errorf("初始化默认数据失败: %w", err) + } + + return database, nil +} + +func (d *PostgreSQLDatabase) SetCryptoService(cs *crypto.CryptoService) { + d.cryptoService = cs +} + +func (d *PostgreSQLDatabase) encryptValue(value string, aadParts ...string) (string, error) { + if value == "" { + return "", nil + } + if d.cryptoService == nil { + return "", fmt.Errorf("crypto service not initialized") + } + if !d.cryptoService.HasDataKey() { + return "", fmt.Errorf("data encryption key not configured") + } + if d.cryptoService.IsEncryptedStorageValue(value) { + return value, nil + } + return d.cryptoService.EncryptForStorage(value, aadParts...) +} + +func (d *PostgreSQLDatabase) decryptValue(value string, aadParts ...string) (string, error) { + if value == "" { + return "", nil + } + if d.cryptoService == nil { + return "", fmt.Errorf("crypto service not initialized") + } + if !d.cryptoService.HasDataKey() { + return "", fmt.Errorf("data encryption key not configured") + } + if !d.cryptoService.IsEncryptedStorageValue(value) { + return "", fmt.Errorf("value is not encrypted") + } + return d.cryptoService.DecryptFromStorage(value, aadParts...) +} + +// getEnv 获取环境变量,如果不存在返回默认值 +func getEnv(key, defaultValue string) string { + if value := os.Getenv(key); value != "" { + return value + } + return defaultValue +} + +// CreateUser 创建用户 +func (d *PostgreSQLDatabase) CreateUser(user *User) error { + _, err := d.db.Exec(` + INSERT INTO users (id, email, password_hash, otp_secret, otp_verified) + VALUES ($1, $2, $3, $4, $5) + `, user.ID, user.Email, user.PasswordHash, user.OTPSecret, user.OTPVerified) + return err +} + +// GetUserByEmail 通过邮箱获取用户 +func (d *PostgreSQLDatabase) GetUserByEmail(email string) (*User, error) { + var user User + err := d.db.QueryRow(` + SELECT id, email, password_hash, otp_secret, otp_verified, created_at, updated_at + FROM users WHERE email = $1 + `, email).Scan( + &user.ID, &user.Email, &user.PasswordHash, &user.OTPSecret, + &user.OTPVerified, &user.CreatedAt, &user.UpdatedAt, + ) + if err != nil { + return nil, err + } + return &user, nil +} + +// GetUserByID 通过ID获取用户 +func (d *PostgreSQLDatabase) GetUserByID(userID string) (*User, error) { + var user User + err := d.db.QueryRow(` + SELECT id, email, password_hash, otp_secret, otp_verified, created_at, updated_at + FROM users WHERE id = $1 + `, userID).Scan( + &user.ID, &user.Email, &user.PasswordHash, &user.OTPSecret, + &user.OTPVerified, &user.CreatedAt, &user.UpdatedAt, + ) + if err != nil { + return nil, err + } + return &user, nil +} + +// GetAllUsers 获取所有用户ID列表 +func (d *PostgreSQLDatabase) GetAllUsers() ([]string, error) { + rows, err := d.db.Query(`SELECT id FROM users ORDER BY id`) + if err != nil { + return nil, err + } + defer rows.Close() + + var userIDs []string + for rows.Next() { + var userID string + if err := rows.Scan(&userID); err != nil { + return nil, err + } + userIDs = append(userIDs, userID) + } + return userIDs, nil +} + +// UpdateUserOTPVerified 更新用户OTP验证状态 +func (d *PostgreSQLDatabase) UpdateUserOTPVerified(userID string, verified bool) error { + _, err := d.db.Exec(`UPDATE users SET otp_verified = $1 WHERE id = $2`, verified, userID) + return err +} + +// GetAIModels 获取用户的AI模型配置 +func (d *PostgreSQLDatabase) GetAIModels(userID string) ([]*AIModelConfig, error) { + rows, err := d.db.Query(` + SELECT id, user_id, name, provider, enabled, api_key, + COALESCE(custom_api_url, '') as custom_api_url, + COALESCE(custom_model_name, '') as custom_model_name, + COALESCE(deleted, FALSE) as deleted, + created_at, updated_at + FROM ai_models WHERE user_id = $1 AND COALESCE(deleted, FALSE) = FALSE ORDER BY id + `, userID) + if err != nil { + return nil, err + } + defer rows.Close() + + // 初始化为空切片而不是nil,确保JSON序列化为[]而不是null + models := make([]*AIModelConfig, 0) + for rows.Next() { + var model AIModelConfig + var deleted bool // 临时变量,用于读取 deleted 字段但不保存到结构体 + err := rows.Scan( + &model.ID, &model.UserID, &model.Name, &model.Provider, + &model.Enabled, &model.APIKey, &model.CustomAPIURL, &model.CustomModelName, + &deleted, &model.CreatedAt, &model.UpdatedAt, + ) + if err != nil { + return nil, err + } + + if model.APIKey != "" { + decrypted, err := d.decryptValue(model.APIKey, model.UserID, model.ID, "api_key") + if err != nil { + return nil, err + } + model.APIKey = decrypted + } + + models = append(models, &model) + } + + return models, nil +} + +// UpdateAIModel 更新AI模型配置,如果不存在则创建用户特定配置 +func (d *PostgreSQLDatabase) UpdateAIModel(userID, id string, enabled bool, apiKey, customAPIURL, customModelName string) error { + log.Printf("🔧 UpdateAIModel: userID=%s, id=%s, enabled=%v", userID, id, enabled) + + // 检查是否为删除操作(API Key 为空且 enabled 为 false 表示删除) + isDelete := !enabled && apiKey == "" && customAPIURL == "" && customModelName == "" + + if isDelete { + // 执行软删除:标记为已删除并清空敏感数据 + // 先尝试精确匹配 ID + var existingID string + err := d.db.QueryRow(` + SELECT id FROM ai_models WHERE user_id = $1 AND id = $2 LIMIT 1 + `, userID, id).Scan(&existingID) + + if err == nil { + // 找到了现有配置(精确匹配 ID),标记为删除并清空敏感数据 + _, err = d.db.Exec(` + UPDATE ai_models SET enabled = FALSE, deleted = TRUE, api_key = '', custom_api_url = '', custom_model_name = '', updated_at = CURRENT_TIMESTAMP + WHERE id = $1 AND user_id = $2 + `, existingID, userID) + if err != nil { + log.Printf("❌ UpdateAIModel: 标记删除失败: %v", err) + return err + } + log.Printf("🗑️ UpdateAIModel: 已标记删除用户 %s 的模型配置 %s", userID, existingID) + return nil + } + + // ID 不存在,尝试兼容旧逻辑:将 id 作为 provider 查找 + provider := id + err = d.db.QueryRow(` + SELECT id FROM ai_models WHERE user_id = $1 AND provider = $2 LIMIT 1 + `, userID, provider).Scan(&existingID) + + if err == nil { + // 找到了现有配置(通过 provider 匹配),标记为删除并清空敏感数据 + _, err = d.db.Exec(` + UPDATE ai_models SET enabled = FALSE, deleted = TRUE, api_key = '', custom_api_url = '', custom_model_name = '', updated_at = CURRENT_TIMESTAMP + WHERE id = $1 AND user_id = $2 + `, existingID, userID) + if err != nil { + log.Printf("❌ UpdateAIModel: 标记删除失败: %v", err) + return err + } + log.Printf("🗑️ UpdateAIModel: 已标记删除用户 %s 的模型配置 %s (通过provider匹配)", userID, existingID) + return nil + } + + // 没有找到配置,返回成功(幂等性) + log.Printf("ℹ️ UpdateAIModel: 模型配置不存在,跳过删除: %s", id) + return nil + } + + // 启用模型的情况:先尝试精确匹配 ID(新版逻辑,支持多个相同 provider 的模型) + var existingID string + err := d.db.QueryRow(` + SELECT id FROM ai_models WHERE user_id = $1 AND id = $2 LIMIT 1 + `, userID, id).Scan(&existingID) + + if err == nil { + apiKeyEnc, err := d.encryptValue(apiKey, userID, existingID, "api_key") + if err != nil { + return err + } + // 找到了现有配置(精确匹配 ID),更新它 + _, err = d.db.Exec(` + UPDATE ai_models SET enabled = $1, api_key = $2, custom_api_url = $3, custom_model_name = $4, deleted = FALSE, updated_at = CURRENT_TIMESTAMP + WHERE id = $5 AND user_id = $6 + `, enabled, apiKeyEnc, customAPIURL, customModelName, existingID, userID) + return err + } + if err != sql.ErrNoRows { + return err + } + + // ID 不存在,尝试兼容旧逻辑:将 id 作为 provider 查找 + provider := id + err = d.db.QueryRow(` + SELECT id FROM ai_models WHERE user_id = $1 AND provider = $2 LIMIT 1 + `, userID, provider).Scan(&existingID) + + if err == nil { + apiKeyEnc, err := d.encryptValue(apiKey, userID, existingID, "api_key") + if err != nil { + return err + } + // 找到了现有配置(通过 provider 匹配,兼容旧版),更新它 + log.Printf("⚠️ 使用旧版 provider 匹配更新模型: %s -> %s", provider, existingID) + _, err = d.db.Exec(` + UPDATE ai_models SET enabled = $1, api_key = $2, custom_api_url = $3, custom_model_name = $4, deleted = FALSE, updated_at = CURRENT_TIMESTAMP + WHERE id = $5 AND user_id = $6 + `, enabled, apiKeyEnc, customAPIURL, customModelName, existingID, userID) + return err + } + if err != sql.ErrNoRows { + return err + } + + // 没有找到任何现有配置,创建新的 + // 推断 provider(从 id 中提取,或者直接使用 id) + if provider == id && (provider == "deepseek" || provider == "qwen") { + // id 本身就是 provider + provider = id + } else { + // 从 id 中提取 provider(假设格式是 userID_provider 或 timestamp_userID_provider) + parts := strings.Split(id, "_") + if len(parts) >= 2 { + provider = parts[len(parts)-1] // 取最后一部分作为 provider + } else { + provider = id + } + } + + // 获取模型的基本信息 + var name string + err = d.db.QueryRow(` + SELECT name FROM ai_models WHERE provider = $1 LIMIT 1 + `, provider).Scan(&name) + if err != nil { + // 如果找不到基本信息,使用默认值 + if provider == "deepseek" { + name = "DeepSeek AI" + } else if provider == "qwen" { + name = "Qwen AI" + } else { + name = provider + " AI" + } + } + + // 如果传入的 ID 已经是完整格式(如 "admin_deepseek_custom1"),直接使用 + // 否则生成新的 ID + newModelID := id + if id == provider { + // id 就是 provider,生成新的用户特定 ID + newModelID = fmt.Sprintf("%s_%s", userID, provider) + } + + apiKeyEnc, err := d.encryptValue(apiKey, userID, newModelID, "api_key") + if err != nil { + return err + } + + log.Printf("✓ 创建新的 AI 模型配置: ID=%s, Provider=%s, Name=%s", newModelID, provider, name) + _, err = d.db.Exec(` + INSERT INTO ai_models (id, user_id, name, provider, enabled, api_key, custom_api_url, custom_model_name, created_at, updated_at) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP) + `, newModelID, userID, name, provider, enabled, apiKeyEnc, customAPIURL, customModelName) + + return err +} + +// GetExchanges 获取用户的交易所配置 +func (d *PostgreSQLDatabase) GetExchanges(userID string) ([]*ExchangeConfig, error) { + rows, err := d.db.Query(` + SELECT id, user_id, name, type, enabled, api_key, secret_key, testnet, + COALESCE(hyperliquid_wallet_addr, '') AS hyperliquid_wallet_addr, + COALESCE(aster_user, '') AS aster_user, + COALESCE(aster_signer, '') AS aster_signer, + COALESCE(aster_private_key, '') AS aster_private_key, + COALESCE(dex_wallet_private_key, '') AS dex_wallet_private_key, + COALESCE(deleted, FALSE) AS deleted, + created_at, updated_at + FROM exchanges + WHERE user_id = $1 AND COALESCE(deleted, FALSE) = FALSE + ORDER BY id + `, userID) + if err != nil { + return nil, err + } + defer rows.Close() + + // 初始化为空切片而不是nil,确保JSON序列化为[]而不是null + exchanges := make([]*ExchangeConfig, 0) + for rows.Next() { + var exchange ExchangeConfig + 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.DEXWalletPrivateKey, + &exchange.Deleted, + &exchange.CreatedAt, &exchange.UpdatedAt, + ) + if err != nil { + return nil, err + } + + if decrypted, err := d.decryptValue(exchange.APIKey, exchange.UserID, exchange.ID, "api_key"); err == nil { + exchange.APIKey = decrypted + } else { + return nil, err + } + if decrypted, err := d.decryptValue(exchange.SecretKey, exchange.UserID, exchange.ID, "secret_key"); err == nil { + exchange.SecretKey = decrypted + } else { + return nil, err + } + if decrypted, err := d.decryptValue(exchange.HyperliquidWalletAddr, exchange.UserID, exchange.ID, "hyperliquid_wallet_addr"); err == nil { + exchange.HyperliquidWalletAddr = decrypted + } else { + return nil, err + } + if decrypted, err := d.decryptValue(exchange.AsterUser, exchange.UserID, exchange.ID, "aster_user"); err == nil { + exchange.AsterUser = decrypted + } else { + return nil, err + } + if decrypted, err := d.decryptValue(exchange.AsterSigner, exchange.UserID, exchange.ID, "aster_signer"); err == nil { + exchange.AsterSigner = decrypted + } else { + return nil, err + } + if decrypted, err := d.decryptValue(exchange.AsterPrivateKey, exchange.UserID, exchange.ID, "aster_private_key"); err == nil { + exchange.AsterPrivateKey = decrypted + } else { + return nil, err + } + if decrypted, err := d.decryptValue(exchange.DEXWalletPrivateKey, exchange.UserID, exchange.ID, "dex_wallet_private_key"); err == nil { + exchange.DEXWalletPrivateKey = decrypted + } else { + return nil, err + } + + exchanges = append(exchanges, &exchange) + } + + return exchanges, nil +} + +// UpdateExchange 更新交易所配置,如果不存在则创建用户特定配置 +func (d *PostgreSQLDatabase) UpdateExchange(userID, id string, enabled bool, apiKey, secretKey string, testnet bool, hyperliquidWalletAddr, asterUser, asterSigner, asterPrivateKey string) error { + log.Printf("🔧 UpdateExchange: userID=%s, id=%s, enabled=%v", userID, id, enabled) + + // 如果请求禁用该交易所,执行软删除 + if !enabled { + _, err := d.db.Exec(` + UPDATE exchanges + SET enabled = FALSE, + deleted = TRUE, + api_key = '', + secret_key = '', + testnet = FALSE, + hyperliquid_wallet_addr = '', + aster_user = '', + aster_signer = '', + aster_private_key = '', + updated_at = CURRENT_TIMESTAMP + WHERE id = $1 AND user_id = $2 + `, id, userID) + if err != nil { + log.Printf("❌ UpdateExchange: 标记删除失败: %v", err) + return err + } + log.Printf("🗑️ UpdateExchange: 已标记删除用户 %s 的交易所配置 %s", userID, id) + return nil + } + + apiKeyEnc, err := d.encryptValue(apiKey, userID, id, "api_key") + if err != nil { + return fmt.Errorf("encrypt api_key failed: %w", err) + } + secretKeyEnc, err := d.encryptValue(secretKey, userID, id, "secret_key") + if err != nil { + return fmt.Errorf("encrypt secret_key failed: %w", err) + } + hyperAddrEnc, err := d.encryptValue(hyperliquidWalletAddr, userID, id, "hyperliquid_wallet_addr") + if err != nil { + return fmt.Errorf("encrypt hyperliquid_wallet_addr failed: %w", err) + } + asterUserEnc, err := d.encryptValue(asterUser, userID, id, "aster_user") + if err != nil { + return fmt.Errorf("encrypt aster_user failed: %w", err) + } + asterSignerEnc, err := d.encryptValue(asterSigner, userID, id, "aster_signer") + if err != nil { + return fmt.Errorf("encrypt aster_signer failed: %w", err) + } + asterPrivateKeyEnc, err := d.encryptValue(asterPrivateKey, userID, id, "aster_private_key") + if err != nil { + return fmt.Errorf("encrypt aster_private_key failed: %w", err) + } + + // 首先尝试更新现有的用户配置 + result, err := d.db.Exec(` + UPDATE exchanges SET enabled = $1, api_key = $2, secret_key = $3, testnet = $4, + hyperliquid_wallet_addr = $5, aster_user = $6, aster_signer = $7, aster_private_key = $8, + deleted = FALSE, updated_at = CURRENT_TIMESTAMP + WHERE id = $9 AND user_id = $10 + `, enabled, apiKeyEnc, secretKeyEnc, testnet, hyperAddrEnc, asterUserEnc, asterSignerEnc, asterPrivateKeyEnc, id, userID) + if err != nil { + log.Printf("❌ UpdateExchange: 更新失败: %v", err) + return err + } + + // 检查是否有行被更新 + rowsAffected, err := result.RowsAffected() + if err != nil { + log.Printf("❌ UpdateExchange: 获取影响行数失败: %v", err) + return err + } + + log.Printf("📊 UpdateExchange: 影响行数 = %d", rowsAffected) + + // 如果没有行被更新,说明用户没有这个交易所的配置,需要创建 + if rowsAffected == 0 { + log.Printf("💡 UpdateExchange: 没有现有记录,创建新记录") + + // 根据交易所ID确定基本信息 + var name, typ string + if id == "binance" { + name = "Binance Futures" + typ = "cex" + } else if id == "hyperliquid" { + name = "Hyperliquid" + typ = "dex" + } else if id == "aster" { + name = "Aster DEX" + typ = "dex" + } else { + name = id + " Exchange" + typ = "cex" + } + + log.Printf("🆕 UpdateExchange: 创建新记录 ID=%s, name=%s, type=%s", id, name, typ) + + // 创建用户特定的配置,使用原始的交易所ID + _, err = d.db.Exec(` + INSERT INTO exchanges (id, user_id, name, type, enabled, api_key, secret_key, testnet, + hyperliquid_wallet_addr, aster_user, aster_signer, aster_private_key, + deleted, created_at, updated_at) + VALUES ($1, $2, $3, $4, TRUE, $5, $6, $7, $8, $9, $10, $11, FALSE, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP) + `, id, userID, name, typ, apiKeyEnc, secretKeyEnc, testnet, hyperAddrEnc, asterUserEnc, asterSignerEnc, asterPrivateKeyEnc) + + if err != nil { + log.Printf("❌ UpdateExchange: 创建记录失败: %v", err) + } else { + log.Printf("✅ UpdateExchange: 创建记录成功") + } + return err + } + + log.Printf("✅ UpdateExchange: 更新现有记录成功") + return nil +} + +// CreateAIModel 创建AI模型配置 +func (d *PostgreSQLDatabase) CreateAIModel(userID, id, name, provider string, enabled bool, apiKey, customAPIURL string) error { + apiKeyEnc, err := d.encryptValue(apiKey, userID, id, "api_key") + if err != nil { + return err + } + + _, err = d.db.Exec(` + INSERT INTO ai_models (id, user_id, name, provider, enabled, api_key, custom_api_url) + VALUES ($1, $2, $3, $4, $5, $6, $7) + ON CONFLICT (id) DO NOTHING + `, id, userID, name, provider, enabled, apiKeyEnc, customAPIURL) + return err +} + +// CreateExchange 创建交易所配置 +func (d *PostgreSQLDatabase) CreateExchange(userID, id, name, typ string, enabled bool, apiKey, secretKey string, testnet bool, hyperliquidWalletAddr, asterUser, asterSigner, asterPrivateKey string) error { + apiKeyEnc, err := d.encryptValue(apiKey, userID, id, "api_key") + if err != nil { + return fmt.Errorf("encrypt api_key failed: %w", err) + } + secretKeyEnc, err := d.encryptValue(secretKey, userID, id, "secret_key") + if err != nil { + return fmt.Errorf("encrypt secret_key failed: %w", err) + } + hyperAddrEnc, err := d.encryptValue(hyperliquidWalletAddr, userID, id, "hyperliquid_wallet_addr") + if err != nil { + return fmt.Errorf("encrypt hyperliquid_wallet_addr failed: %w", err) + } + asterUserEnc, err := d.encryptValue(asterUser, userID, id, "aster_user") + if err != nil { + return fmt.Errorf("encrypt aster_user failed: %w", err) + } + asterSignerEnc, err := d.encryptValue(asterSigner, userID, id, "aster_signer") + if err != nil { + return fmt.Errorf("encrypt aster_signer failed: %w", err) + } + asterPrivateKeyEnc, err := d.encryptValue(asterPrivateKey, userID, id, "aster_private_key") + if err != nil { + return fmt.Errorf("encrypt aster_private_key failed: %w", err) + } + + _, err = d.db.Exec(` + INSERT INTO exchanges (id, user_id, name, type, enabled, api_key, secret_key, testnet, hyperliquid_wallet_addr, aster_user, aster_signer, aster_private_key) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12) + ON CONFLICT (id, user_id) DO NOTHING + `, id, userID, name, typ, enabled, apiKeyEnc, secretKeyEnc, testnet, hyperAddrEnc, asterUserEnc, asterSignerEnc, asterPrivateKeyEnc) + return err +} + +// CreateTrader 创建交易员 +func (d *PostgreSQLDatabase) CreateTrader(trader *TraderRecord) error { + _, err := d.db.Exec(` + INSERT INTO traders (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) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17) + `, trader.ID, trader.UserID, trader.Name, trader.AIModelID, trader.ExchangeID, trader.InitialBalance, trader.ScanIntervalMinutes, trader.IsRunning, trader.BTCETHLeverage, trader.AltcoinLeverage, trader.TradingSymbols, trader.UseCoinPool, trader.UseOITop, trader.CustomPrompt, trader.OverrideBasePrompt, trader.SystemPromptTemplate, trader.IsCrossMargin) + return err +} + +// GetTraders 获取用户的交易员 +func (d *PostgreSQLDatabase) GetTraders(userID string) ([]*TraderRecord, error) { + rows, err := d.db.Query(` + SELECT id, user_id, name, ai_model_id, exchange_id, initial_balance, scan_interval_minutes, is_running, + COALESCE(btc_eth_leverage, 5) as btc_eth_leverage, COALESCE(altcoin_leverage, 5) as altcoin_leverage, + COALESCE(trading_symbols, '') as trading_symbols, + COALESCE(use_coin_pool, false) as use_coin_pool, COALESCE(use_oi_top, false) as use_oi_top, + COALESCE(custom_prompt, '') as custom_prompt, COALESCE(override_base_prompt, false) as override_base_prompt, + COALESCE(system_prompt_template, 'default') as system_prompt_template, + COALESCE(is_cross_margin, true) as is_cross_margin, created_at, updated_at + FROM traders WHERE user_id = $1 ORDER BY created_at DESC + `, userID) + if err != nil { + return nil, err + } + defer rows.Close() + + var traders []*TraderRecord + for rows.Next() { + var trader TraderRecord + err := rows.Scan( + &trader.ID, &trader.UserID, &trader.Name, &trader.AIModelID, &trader.ExchangeID, + &trader.InitialBalance, &trader.ScanIntervalMinutes, &trader.IsRunning, + &trader.BTCETHLeverage, &trader.AltcoinLeverage, &trader.TradingSymbols, + &trader.UseCoinPool, &trader.UseOITop, + &trader.CustomPrompt, &trader.OverrideBasePrompt, &trader.SystemPromptTemplate, + &trader.IsCrossMargin, + &trader.CreatedAt, &trader.UpdatedAt, + ) + if err != nil { + return nil, err + } + traders = append(traders, &trader) + } + + return traders, nil +} + +// UpdateTraderStatus 更新交易员状态 +func (d *PostgreSQLDatabase) UpdateTraderStatus(userID, id string, isRunning bool) error { + _, err := d.db.Exec(`UPDATE traders SET is_running = $1 WHERE id = $2 AND user_id = $3`, isRunning, id, userID) + return err +} + +// UpdateTrader 更新交易员配置 +func (d *PostgreSQLDatabase) UpdateTrader(trader *TraderRecord) error { + _, err := d.db.Exec(` + UPDATE traders SET + name = $1, ai_model_id = $2, exchange_id = $3, initial_balance = $4, + scan_interval_minutes = $5, btc_eth_leverage = $6, altcoin_leverage = $7, + trading_symbols = $8, custom_prompt = $9, override_base_prompt = $10, + system_prompt_template = $11, is_cross_margin = $12, updated_at = CURRENT_TIMESTAMP + WHERE id = $13 AND user_id = $14 + `, trader.Name, trader.AIModelID, trader.ExchangeID, trader.InitialBalance, + trader.ScanIntervalMinutes, trader.BTCETHLeverage, trader.AltcoinLeverage, + trader.TradingSymbols, trader.CustomPrompt, trader.OverrideBasePrompt, + trader.SystemPromptTemplate, trader.IsCrossMargin, trader.ID, trader.UserID) + return err +} + +// UpdateTraderCustomPrompt 更新交易员自定义Prompt +func (d *PostgreSQLDatabase) UpdateTraderCustomPrompt(userID, id string, customPrompt string, overrideBase bool) error { + _, err := d.db.Exec(`UPDATE traders SET custom_prompt = $1, override_base_prompt = $2 WHERE id = $3 AND user_id = $4`, customPrompt, overrideBase, id, userID) + return err +} + +// UpdateTraderInitialBalance 更新交易员初始余额(用于自动同步交易所实际余额) +func (d *PostgreSQLDatabase) UpdateTraderInitialBalance(userID, id string, newBalance float64) error { + _, err := d.db.Exec(`UPDATE traders SET initial_balance = $1, updated_at = CURRENT_TIMESTAMP WHERE id = $2 AND user_id = $3`, newBalance, id, userID) + return err +} + +// DeleteTrader 删除交易员 +func (d *PostgreSQLDatabase) DeleteTrader(userID, id string) error { + _, err := d.db.Exec(`DELETE FROM traders WHERE id = $1 AND user_id = $2`, id, userID) + return err +} + +// GetTraderConfig 获取交易员完整配置(包含AI模型和交易所信息) +func (d *PostgreSQLDatabase) GetTraderConfig(userID, traderID string) (*TraderRecord, *AIModelConfig, *ExchangeConfig, error) { + var trader TraderRecord + var aiModel AIModelConfig + var exchange ExchangeConfig + + err := d.db.QueryRow(` + SELECT + t.id, t.user_id, t.name, t.ai_model_id, t.exchange_id, t.initial_balance, t.scan_interval_minutes, t.is_running, t.created_at, t.updated_at, + a.id, a.user_id, a.name, a.provider, a.enabled, a.api_key, a.created_at, a.updated_at, + e.id, e.user_id, e.name, e.type, e.enabled, e.api_key, e.secret_key, e.testnet, + COALESCE(e.hyperliquid_wallet_addr, '') as hyperliquid_wallet_addr, + COALESCE(e.aster_user, '') as aster_user, + COALESCE(e.aster_signer, '') as aster_signer, + COALESCE(e.aster_private_key, '') as aster_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 + JOIN exchanges e ON t.exchange_id = e.id AND t.user_id = e.user_id + WHERE t.id = $1 AND t.user_id = $2 + `, traderID, userID).Scan( + &trader.ID, &trader.UserID, &trader.Name, &trader.AIModelID, &trader.ExchangeID, + &trader.InitialBalance, &trader.ScanIntervalMinutes, &trader.IsRunning, + &trader.CreatedAt, &trader.UpdatedAt, + &aiModel.ID, &aiModel.UserID, &aiModel.Name, &aiModel.Provider, &aiModel.Enabled, &aiModel.APIKey, + &aiModel.CreatedAt, &aiModel.UpdatedAt, + &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.CreatedAt, &exchange.UpdatedAt, + ) + + if err != nil { + return nil, nil, nil, err + } + + if aiModel.APIKey != "" { + decrypted, err := d.decryptValue(aiModel.APIKey, aiModel.UserID, aiModel.ID, "api_key") + if err != nil { + return nil, nil, nil, err + } + aiModel.APIKey = decrypted + } + + if exchange.APIKey != "" { + decrypted, err := d.decryptValue(exchange.APIKey, exchange.UserID, exchange.ID, "api_key") + if err != nil { + return nil, nil, nil, err + } + exchange.APIKey = decrypted + } + if exchange.SecretKey != "" { + decrypted, err := d.decryptValue(exchange.SecretKey, exchange.UserID, exchange.ID, "secret_key") + if err != nil { + return nil, nil, nil, err + } + exchange.SecretKey = decrypted + } + if exchange.HyperliquidWalletAddr != "" { + decrypted, err := d.decryptValue(exchange.HyperliquidWalletAddr, exchange.UserID, exchange.ID, "hyperliquid_wallet_addr") + if err != nil { + return nil, nil, nil, err + } + exchange.HyperliquidWalletAddr = decrypted + } + if exchange.AsterUser != "" { + decrypted, err := d.decryptValue(exchange.AsterUser, exchange.UserID, exchange.ID, "aster_user") + if err != nil { + return nil, nil, nil, err + } + exchange.AsterUser = decrypted + } + if exchange.AsterSigner != "" { + decrypted, err := d.decryptValue(exchange.AsterSigner, exchange.UserID, exchange.ID, "aster_signer") + if err != nil { + return nil, nil, nil, err + } + exchange.AsterSigner = decrypted + } + if exchange.AsterPrivateKey != "" { + decrypted, err := d.decryptValue(exchange.AsterPrivateKey, exchange.UserID, exchange.ID, "aster_private_key") + if err != nil { + return nil, nil, nil, err + } + exchange.AsterPrivateKey = decrypted + } + + return &trader, &aiModel, &exchange, nil +} + +// GetSystemConfig 获取系统配置 +func (d *PostgreSQLDatabase) GetSystemConfig(key string) (string, error) { + var value string + err := d.db.QueryRow(`SELECT value FROM system_config WHERE key = $1`, key).Scan(&value) + return value, err +} + +// SetSystemConfig 设置系统配置 +func (d *PostgreSQLDatabase) SetSystemConfig(key, value string) error { + _, err := d.db.Exec(` + INSERT INTO system_config (key, value) VALUES ($1, $2) + ON CONFLICT (key) DO UPDATE SET value = $2, updated_at = CURRENT_TIMESTAMP + `, key, value) + return err +} + +// CreateUserSignalSource 创建用户信号源配置 +func (d *PostgreSQLDatabase) CreateUserSignalSource(userID, coinPoolURL, oiTopURL string) error { + _, err := d.db.Exec(` + INSERT INTO user_signal_sources (user_id, coin_pool_url, oi_top_url, updated_at) + VALUES ($1, $2, $3, CURRENT_TIMESTAMP) + ON CONFLICT (user_id) DO UPDATE SET + coin_pool_url = $2, oi_top_url = $3, updated_at = CURRENT_TIMESTAMP + `, userID, coinPoolURL, oiTopURL) + return err +} + +// GetUserSignalSource 获取用户信号源配置 +func (d *PostgreSQLDatabase) GetUserSignalSource(userID string) (*UserSignalSource, error) { + var source UserSignalSource + 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 = $1 + `, userID).Scan( + &source.ID, &source.UserID, &source.CoinPoolURL, &source.OITopURL, + &source.CreatedAt, &source.UpdatedAt, + ) + if err != nil { + return nil, err + } + return &source, nil +} + +// UpdateUserSignalSource 更新用户信号源配置 +func (d *PostgreSQLDatabase) UpdateUserSignalSource(userID, coinPoolURL, oiTopURL string) error { + _, err := d.db.Exec(` + UPDATE user_signal_sources SET coin_pool_url = $1, oi_top_url = $2, updated_at = CURRENT_TIMESTAMP + WHERE user_id = $3 + `, coinPoolURL, oiTopURL, userID) + return err +} + +// GetCustomCoins 获取所有交易员自定义币种 +func (d *PostgreSQLDatabase) GetCustomCoins() []string { + var symbol string + var symbols []string + + err := d.db.QueryRow(` + SELECT STRING_AGG(custom_coins, ',') as symbol + FROM traders WHERE custom_coins != '' + `).Scan(&symbol) + + // 检测用户是否未配置币种 - 兼容性 + if err != nil || symbol == "" { + symbolJSON, _ := d.GetSystemConfig("default_coins") + if err := json.Unmarshal([]byte(symbolJSON), &symbols); err != nil { + log.Printf("⚠️ 解析default_coins配置失败: %v,使用硬编码默认值", err) + symbols = []string{"BTCUSDT", "ETHUSDT", "SOLUSDT", "BNBUSDT"} + } + } + + // filter Symbol + for _, s := range strings.Split(symbol, ",") { + if s == "" { + continue + } + coin := market.Normalize(s) + if !slices.Contains(symbols, coin) { + symbols = append(symbols, coin) + } + } + return symbols +} + +// LoadBetaCodesFromFile 从文件加载内测码到数据库 +func (d *PostgreSQLDatabase) LoadBetaCodesFromFile(filePath string) error { + // 读取文件内容 + content, err := os.ReadFile(filePath) + if err != nil { + return fmt.Errorf("读取内测码文件失败: %w", err) + } + + // 按行分割内测码 + lines := strings.Split(string(content), "\n") + var codes []string + for _, line := range lines { + code := strings.TrimSpace(line) + if code != "" && !strings.HasPrefix(code, "#") { + codes = append(codes, code) + } + } + + // 批量插入内测码 + tx, err := d.db.Begin() + if err != nil { + return fmt.Errorf("开始事务失败: %w", err) + } + defer tx.Rollback() + + stmt, err := tx.Prepare(`INSERT INTO beta_codes (code) VALUES ($1) ON CONFLICT (code) DO NOTHING`) + if err != nil { + return fmt.Errorf("准备语句失败: %w", err) + } + defer stmt.Close() + + insertedCount := 0 + for _, code := range codes { + result, err := stmt.Exec(code) + if err != nil { + log.Printf("插入内测码 %s 失败: %v", code, err) + continue + } + + if rowsAffected, _ := result.RowsAffected(); rowsAffected > 0 { + insertedCount++ + } + } + + if err := tx.Commit(); err != nil { + return fmt.Errorf("提交事务失败: %w", err) + } + + log.Printf("✅ 成功加载 %d 个内测码到数据库 (总计 %d 个)", insertedCount, len(codes)) + return nil +} + +// ValidateBetaCode 验证内测码是否有效且未使用 +func (d *PostgreSQLDatabase) ValidateBetaCode(code string) (bool, error) { + var used bool + err := d.db.QueryRow(`SELECT used FROM beta_codes WHERE code = $1`, code).Scan(&used) + if err != nil { + if err == sql.ErrNoRows { + return false, nil // 内测码不存在 + } + return false, err + } + return !used, nil // 内测码存在且未使用 +} + +// UseBetaCode 使用内测码(标记为已使用) +func (d *PostgreSQLDatabase) UseBetaCode(code, userEmail string) error { + result, err := d.db.Exec(` + UPDATE beta_codes SET used = true, used_by = $1, used_at = CURRENT_TIMESTAMP + WHERE code = $2 AND used = false + `, userEmail, code) + if err != nil { + return err + } + + rowsAffected, err := result.RowsAffected() + if err != nil { + return err + } + + if rowsAffected == 0 { + return fmt.Errorf("内测码无效或已被使用") + } + + return nil +} + +// GetBetaCodeStats 获取内测码统计信息 +func (d *PostgreSQLDatabase) GetBetaCodeStats() (total, used int, err error) { + err = d.db.QueryRow(`SELECT COUNT(*) FROM beta_codes`).Scan(&total) + if err != nil { + return 0, 0, err + } + + err = d.db.QueryRow(`SELECT COUNT(*) FROM beta_codes WHERE used = true`).Scan(&used) + if err != nil { + return 0, 0, err + } + + return total, used, nil +} + +// initDefaultData 初始化默认数据(AI模型和交易所) +func (d *PostgreSQLDatabase) initDefaultData() error { + // 确保traders表存在custom_coins列,防止旧环境缺少字段 + if _, err := d.db.Exec(`ALTER TABLE traders ADD COLUMN IF NOT EXISTS custom_coins TEXT DEFAULT ''`); err != nil { + return fmt.Errorf("添加custom_coins列失败: %w", err) + } + + // 确保exchanges表存在deleted列 + if _, err := d.db.Exec(`ALTER TABLE exchanges ADD COLUMN IF NOT EXISTS deleted BOOLEAN DEFAULT FALSE`); err != nil { + return fmt.Errorf("添加deleted列失败: %w", err) + } + + // 首先创建default用户(如果不存在) + _, err := d.db.Exec(` + INSERT INTO users (id, email, password_hash, otp_secret, otp_verified) + VALUES ('default', 'default@localhost', '', '', true) + ON CONFLICT (id) DO NOTHING + `) + if err != nil { + return fmt.Errorf("创建default用户失败: %w", err) + } + + // 初始化AI模型(使用default用户) + aiModels := []struct { + id, name, provider string + }{ + {"deepseek", "DeepSeek", "deepseek"}, + {"qwen", "Qwen", "qwen"}, + } + + for _, model := range aiModels { + _, err := d.db.Exec(` + INSERT INTO ai_models (id, user_id, name, provider, enabled, api_key, custom_api_url, custom_model_name, created_at, updated_at) + VALUES ($1, 'default', $2, $3, false, '', '', '', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP) + ON CONFLICT (id) DO NOTHING + `, model.id, model.name, model.provider) + if err != nil { + return fmt.Errorf("初始化AI模型失败: %w", err) + } + } + + // 初始化交易所(使用default用户) + exchanges := []struct { + id, name, typ string + }{ + {"binance", "Binance Futures", "binance"}, + {"hyperliquid", "Hyperliquid", "hyperliquid"}, + {"aster", "Aster DEX", "aster"}, + } + + for _, exchange := range exchanges { + _, err := d.db.Exec(` + INSERT INTO exchanges (id, user_id, name, type, enabled, api_key, secret_key, testnet, + hyperliquid_wallet_addr, aster_user, aster_signer, aster_private_key, created_at, updated_at) + VALUES ($1, 'default', $2, $3, false, '', '', false, '', '', '', '', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP) + ON CONFLICT (id, user_id) DO NOTHING + `, exchange.id, exchange.name, exchange.typ) + if err != nil { + return fmt.Errorf("初始化交易所失败: %w", err) + } + } + + return nil +} + +// Close 关闭数据库连接 +func (d *PostgreSQLDatabase) Close() error { + return d.db.Close() +} diff --git a/db/init.sql b/db/init.sql new file mode 100644 index 00000000..c89fa78a --- /dev/null +++ b/db/init.sql @@ -0,0 +1,179 @@ +-- PostgreSQL初始化脚本 +-- AI交易系统数据库迁移 + +-- 用户表 +CREATE TABLE IF NOT EXISTS users ( + id TEXT PRIMARY KEY, + email TEXT UNIQUE NOT NULL, + password_hash TEXT NOT NULL, + otp_secret TEXT, + otp_verified BOOLEAN DEFAULT FALSE, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP +); + +-- AI模型配置表 +CREATE TABLE IF NOT EXISTS ai_models ( + id TEXT PRIMARY KEY, + user_id TEXT NOT NULL DEFAULT 'default', + name TEXT NOT NULL, + provider TEXT NOT NULL, + enabled BOOLEAN DEFAULT FALSE, + api_key TEXT DEFAULT '', + custom_api_url TEXT DEFAULT '', + custom_model_name TEXT DEFAULT '', + deleted BOOLEAN DEFAULT FALSE, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE +); + +-- 交易所配置表 +CREATE TABLE IF NOT EXISTS exchanges ( + id TEXT NOT NULL, + user_id TEXT NOT NULL DEFAULT 'default', + name TEXT NOT NULL, + type TEXT NOT NULL, -- 'cex' or 'dex' + enabled BOOLEAN DEFAULT FALSE, + api_key TEXT DEFAULT '', + secret_key TEXT DEFAULT '', + testnet BOOLEAN DEFAULT FALSE, + -- Hyperliquid 特定字段 + hyperliquid_wallet_addr TEXT DEFAULT '', + -- Aster 特定字段 + aster_user TEXT DEFAULT '', + aster_signer TEXT DEFAULT '', + aster_private_key TEXT DEFAULT '', + deleted BOOLEAN DEFAULT FALSE, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (id, user_id), + FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE +); + +-- 用户信号源配置表 +CREATE TABLE IF NOT EXISTS user_signal_sources ( + id SERIAL PRIMARY KEY, + user_id TEXT NOT NULL, + coin_pool_url TEXT DEFAULT '', + oi_top_url TEXT DEFAULT '', + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE, + UNIQUE(user_id) +); + +-- 交易员配置表 +CREATE TABLE IF NOT EXISTS traders ( + 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 FALSE, + btc_eth_leverage INTEGER DEFAULT 5, + altcoin_leverage INTEGER DEFAULT 5, + trading_symbols TEXT DEFAULT '', + use_coin_pool BOOLEAN DEFAULT FALSE, + use_oi_top BOOLEAN DEFAULT FALSE, + custom_prompt TEXT DEFAULT '', + override_base_prompt BOOLEAN DEFAULT FALSE, + system_prompt_template TEXT DEFAULT 'default', + is_cross_margin BOOLEAN DEFAULT TRUE, + custom_coins TEXT DEFAULT '', + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP 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, user_id) REFERENCES exchanges(id, user_id) +); + +-- 系统配置表 +CREATE TABLE IF NOT EXISTS system_config ( + key TEXT PRIMARY KEY, + value TEXT NOT NULL, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP +); + +-- 内测码表 +CREATE TABLE IF NOT EXISTS beta_codes ( + code TEXT PRIMARY KEY, + used BOOLEAN DEFAULT FALSE, + used_by TEXT DEFAULT '', + used_at TIMESTAMP DEFAULT NULL, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP +); + +-- 自动更新 updated_at 函数 +CREATE OR REPLACE FUNCTION update_updated_at_column() +RETURNS TRIGGER AS $$ +BEGIN + NEW.updated_at = CURRENT_TIMESTAMP; + RETURN NEW; +END; +$$ language 'plpgsql'; + +-- 创建触发器:自动更新 updated_at +CREATE TRIGGER update_users_updated_at BEFORE UPDATE ON users + FOR EACH ROW EXECUTE FUNCTION update_updated_at_column(); + +CREATE TRIGGER update_ai_models_updated_at BEFORE UPDATE ON ai_models + FOR EACH ROW EXECUTE FUNCTION update_updated_at_column(); + +CREATE TRIGGER update_exchanges_updated_at BEFORE UPDATE ON exchanges + FOR EACH ROW EXECUTE FUNCTION update_updated_at_column(); + +CREATE TRIGGER update_traders_updated_at BEFORE UPDATE ON traders + FOR EACH ROW EXECUTE FUNCTION update_updated_at_column(); + +CREATE TRIGGER update_user_signal_sources_updated_at BEFORE UPDATE ON user_signal_sources + FOR EACH ROW EXECUTE FUNCTION update_updated_at_column(); + +CREATE TRIGGER update_system_config_updated_at BEFORE UPDATE ON system_config + FOR EACH ROW EXECUTE FUNCTION update_updated_at_column(); + +-- 插入默认数据 + +-- 创建default用户(如果不存在) +INSERT INTO users (id, email, password_hash, otp_secret, otp_verified) VALUES +('default', 'default@localhost', '', '', TRUE) +ON CONFLICT (id) DO NOTHING; + +-- 初始化AI模型(使用default用户) +INSERT INTO ai_models (id, user_id, name, provider, enabled) VALUES +('deepseek', 'default', 'DeepSeek', 'deepseek', FALSE), +('qwen', 'default', 'Qwen', 'qwen', FALSE) +ON CONFLICT (id) DO NOTHING; + +-- 初始化交易所(使用default用户) +INSERT INTO exchanges (id, user_id, name, type, enabled) VALUES +('binance', 'default', 'Binance Futures', 'binance', FALSE), +('hyperliquid', 'default', 'Hyperliquid', 'hyperliquid', FALSE), +('aster', 'default', 'Aster DEX', 'aster', FALSE) +ON CONFLICT (id, user_id) DO NOTHING; + +-- 初始化系统配置 +INSERT INTO system_config (key, value) VALUES +('beta_mode', 'false'), +('api_server_port', '8080'), +('use_default_coins', 'true'), +('default_coins', '["BTCUSDT","ETHUSDT","SOLUSDT","BNBUSDT","XRPUSDT","DOGEUSDT","ADAUSDT","HYPEUSDT"]'), +('max_daily_loss', '10.0'), +('max_drawdown', '20.0'), +('stop_trading_minutes', '60'), +('btc_eth_leverage', '5'), +('altcoin_leverage', '5'), +('jwt_secret', '') +ON CONFLICT (key) DO NOTHING; + +-- 数据库迁移:添加 deleted 字段到现有 ai_models 表 +ALTER TABLE ai_models ADD COLUMN IF NOT EXISTS deleted BOOLEAN DEFAULT FALSE; + +-- 创建索引 +CREATE INDEX IF NOT EXISTS idx_ai_models_user_id ON ai_models(user_id); +CREATE INDEX IF NOT EXISTS idx_exchanges_user_id ON exchanges(user_id); +CREATE INDEX IF NOT EXISTS idx_traders_user_id ON traders(user_id); +CREATE INDEX IF NOT EXISTS idx_traders_running ON traders(is_running); +CREATE INDEX IF NOT EXISTS idx_beta_codes_used ON beta_codes(used); diff --git a/docs/MIGRATION_GUIDE.md b/docs/MIGRATION_GUIDE.md index 1f4dad42..46bf6b48 100644 --- a/docs/MIGRATION_GUIDE.md +++ b/docs/MIGRATION_GUIDE.md @@ -9,7 +9,6 @@ NOFX documentation has been reorganized into a structured `docs/` directory for ### Deployment Guides - `DOCKER_DEPLOY.en.md` → `docs/getting-started/docker-deploy.en.md` - `DOCKER_DEPLOY.md` → `docs/getting-started/docker-deploy.zh-CN.md` -- `PM2_DEPLOYMENT.md` → `docs/getting-started/pm2-deploy.md` - `CUSTOM_API.md` → `docs/getting-started/custom-api.md` ### Community Docs @@ -42,7 +41,6 @@ nofx/ ├── README.uk.md ├── DOCKER_DEPLOY.md ├── DOCKER_DEPLOY.en.md -├── PM2_DEPLOYMENT.md ├── CUSTOM_API.md ├── HOW_TO_POST_BOUNTY.md ├── INTEGRATION_BOUNTY_HYPERLIQUID.md @@ -101,7 +99,6 @@ Files GitHub needs to see: 1. **`getting-started/`** - Deployment and setup - Docker deployment (EN/中文) - - PM2 deployment - Custom API configuration 2. **`guides/`** - Usage guides and tutorials diff --git a/docs/README.md b/docs/README.md index 72ea76b2..10fbe917 100644 --- a/docs/README.md +++ b/docs/README.md @@ -17,15 +17,12 @@ Welcome to the NOFX documentation! This page helps you find the right documentat | [Getting Started Index (中文)](getting-started/README.zh-CN.md) | 所有部署选项 | All deployment options | | [Docker Deployment (EN)](getting-started/docker-deploy.en.md) | Deploy with Docker (recommended) | Docker 部署(推荐) | | [Docker Deployment (中文)](getting-started/docker-deploy.zh-CN.md) | Docker 部署指南(中文) | Docker deployment guide | -| [PM2 Deployment (EN)](getting-started/pm2-deploy.en.md) | Deploy with PM2 process manager | PM2 进程管理器部署 | -| [PM2 Deployment (中文)](getting-started/pm2-deploy.md) | PM2 部署指南(中文) | PM2 deployment guide | | [Custom API (EN)](getting-started/custom-api.en.md) | Connect custom AI API providers | 连接自定义 AI API | | [Custom API (中文)](getting-started/custom-api.md) | 连接自定义 AI API 提供商 | Custom AI provider guide | **Quick Links:** - 📖 See all options → [Getting Started](getting-started/README.md) / [快速开始](getting-started/README.zh-CN.md) - 🐳 Want easiest setup? → [Docker (EN)](getting-started/docker-deploy.en.md) / [Docker (中文)](getting-started/docker-deploy.zh-CN.md) -- 🔧 Advanced user? → [PM2 (EN)](getting-started/pm2-deploy.en.md) / [PM2 (中文)](getting-started/pm2-deploy.md) - 🤖 Custom AI model? → [Custom API (EN)](getting-started/custom-api.en.md) / [自定义 API](getting-started/custom-api.md) --- diff --git a/docs/architecture/README.md b/docs/architecture/README.md index fb233a31..e5147770 100644 --- a/docs/architecture/README.md +++ b/docs/architecture/README.md @@ -93,7 +93,7 @@ nofx/ | `github.com/gin-gonic/gin` | HTTP API framework | v1.9+ | | `github.com/adshao/go-binance/v2` | Binance API client | v2.4+ | | `github.com/markcheno/go-talib` | Technical indicators (TA-Lib) | Latest | -| `github.com/mattn/go-sqlite3` | SQLite database driver | v1.14+ | +| `github.com/lib/pq` | PostgreSQL database driver | v1.10+ | | `github.com/golang-jwt/jwt/v5` | JWT authentication | v5.0+ | | `github.com/pquerna/otp` | 2FA/TOTP support | v1.4+ | | `golang.org/x/crypto` | Password hashing (bcrypt) | Latest | diff --git a/docs/architecture/README.zh-CN.md b/docs/architecture/README.zh-CN.md index 4acc0f90..0ce428fc 100644 --- a/docs/architecture/README.zh-CN.md +++ b/docs/architecture/README.zh-CN.md @@ -93,7 +93,7 @@ nofx/ | `github.com/gin-gonic/gin` | HTTP API 框架 | v1.9+ | | `github.com/adshao/go-binance/v2` | Binance API 客户端 | v2.4+ | | `github.com/markcheno/go-talib` | 技术指标(TA-Lib) | 最新 | -| `github.com/mattn/go-sqlite3` | SQLite 数据库驱动 | v1.14+ | +| `github.com/lib/pq` | PostgreSQL 数据库驱动 | v1.10+ | | `github.com/golang-jwt/jwt/v5` | JWT 认证 | v5.0+ | | `github.com/pquerna/otp` | 2FA/TOTP 支持 | v1.4+ | | `golang.org/x/crypto` | 密码哈希(bcrypt) | 最新 | @@ -282,7 +282,6 @@ GET /api/decisions/latest # 最近决策 - 基于 JWT token 的认证 - 使用 TOTP 的 2FA(Google Authenticator) - Bcrypt 密码哈希 -- 管理员模式(简化的单用户模式) --- diff --git a/docs/getting-started/README.md b/docs/getting-started/README.md index 41339cd7..70156f2d 100644 --- a/docs/getting-started/README.md +++ b/docs/getting-started/README.md @@ -24,32 +24,11 @@ Choose the method that best fits your needs: **Quick Start:** ```bash cp config.json.example config.json -./start.sh start --build +./scripts/start.sh start --build ``` --- -### 🔧 PM2 Deployment - -**Best for:** Advanced users, development, custom setups - -- **English:** [pm2-deploy.en.md](pm2-deploy.en.md) -- **中文:** [pm2-deploy.md](pm2-deploy.md) - -**Pros:** -- ✅ Direct process control -- ✅ Better for development -- ✅ Lower resource usage -- ✅ More flexible - -**Quick Start:** -```bash -go build -o nofx -cd web && npm install && npm run build -pm2 start ecosystem.config.js -``` - ---- ## 🤖 AI Configuration @@ -77,7 +56,6 @@ Before starting, ensure you have: - ✅ Go 1.21+ - ✅ Node.js 18+ - ✅ TA-Lib library -- ✅ PM2 (optional) --- diff --git a/docs/getting-started/README.zh-CN.md b/docs/getting-started/README.zh-CN.md index 836619f6..2e333e5c 100644 --- a/docs/getting-started/README.zh-CN.md +++ b/docs/getting-started/README.zh-CN.md @@ -22,32 +22,11 @@ **快速开始:** ```bash cp config.json.example config.json -./start.sh start --build +./scripts/start.sh start --build ``` --- -### 🔧 PM2 部署 - -**适合:** 进阶用户、开发环境、自定义设置 - -- **中文文档:** [pm2-deploy.md](pm2-deploy.md) -- **English:** [pm2-deploy.en.md](pm2-deploy.en.md) - -**优势:** -- ✅ 直接进程控制 -- ✅ 更适合开发 -- ✅ 资源占用更低 -- ✅ 更灵活 - -**快速开始:** -```bash -go build -o nofx -cd web && npm install && npm run build -pm2 start ecosystem.config.js -``` - ---- ## 🤖 AI 配置 @@ -75,7 +54,6 @@ pm2 start ecosystem.config.js - ✅ Go 1.21+ - ✅ Node.js 18+ - ✅ TA-Lib 库 -- ✅ PM2(可选) --- diff --git a/docs/getting-started/pm2-deploy.en.md b/docs/getting-started/pm2-deploy.en.md deleted file mode 100644 index 428bef88..00000000 --- a/docs/getting-started/pm2-deploy.en.md +++ /dev/null @@ -1,303 +0,0 @@ -# NoFX Trading Bot - PM2 Deployment Guide - -Complete guide for local development and production deployment using PM2. - -## 🚀 Quick Start - -### 1. Install PM2 - -```bash -npm install -g pm2 -``` - -### 2. One-Command Launch - -```bash -./pm2.sh start -``` - -That's it! Frontend and backend will start automatically. - ---- - -## 📋 All Commands - -### Service Management - -```bash -# Start services -./pm2.sh start - -# Stop services -./pm2.sh stop - -# Restart services -./pm2.sh restart - -# View status -./pm2.sh status - -# Delete services -./pm2.sh delete -``` - -### Log Viewing - -```bash -# View all logs (live) -./pm2.sh logs - -# Backend logs only -./pm2.sh logs backend - -# Frontend logs only -./pm2.sh logs frontend -``` - -### Build & Compile - -```bash -# Compile backend -./pm2.sh build - -# Recompile backend and restart -./pm2.sh rebuild -``` - -### Monitoring - -```bash -# Open PM2 monitoring dashboard (real-time CPU/Memory) -./pm2.sh monitor -``` - ---- - -## 📊 Access URLs - -After successful startup: - -- **Frontend Web Interface**: http://localhost:3000 -- **Backend API**: http://localhost:8080 -- **Health Check**: http://localhost:8080/api/health - ---- - -## 🔧 Configuration Files - -### pm2.config.js - -PM2 configuration file, defines frontend and backend startup parameters: - -```javascript -const path = require('path'); - -module.exports = { - apps: [ - { - name: 'nofx-backend', - script: './nofx', // Go binary - cwd: __dirname, // Dynamically get current directory - autorestart: true, - max_memory_restart: '500M' - }, - { - name: 'nofx-frontend', - script: 'npm', - args: 'run dev', // Vite dev server - cwd: path.join(__dirname, 'web'), // Dynamically join path - autorestart: true, - max_memory_restart: '300M' - } - ] -}; -``` - -**After modifying configuration, restart is required:** -```bash -./pm2.sh restart -``` - ---- - -## 📝 Log File Locations - -- **Backend Logs**: `./logs/backend-error.log` and `./logs/backend-out.log` -- **Frontend Logs**: `./web/logs/frontend-error.log` and `./web/logs/frontend-out.log` - ---- - -## 🔄 Startup on Boot - -Set PM2 to start on boot: - -```bash -# 1. Start services -./pm2.sh start - -# 2. Save current process list -pm2 save - -# 3. Generate startup script -pm2 startup - -# 4. Follow the instructions to execute command (requires sudo) -``` - -**Disable startup on boot:** -```bash -pm2 unstartup -``` - ---- - -## 🛠️ Common Operations - -### Restart After Code Changes - -**Backend changes:** -```bash -./pm2.sh rebuild # Auto compile and restart -``` - -**Frontend changes:** -```bash -./pm2.sh restart # Vite will auto hot-reload, no restart needed -``` - -### View Real-time Resource Usage - -```bash -./pm2.sh monitor -``` - -### View Detailed Information - -```bash -pm2 info nofx-backend # Backend details -pm2 info nofx-frontend # Frontend details -``` - -### Clear Logs - -```bash -pm2 flush -``` - ---- - -## 🐛 Troubleshooting - -### Service Startup Failed - -```bash -# 1. View detailed errors -./pm2.sh logs - -# 2. Check port usage -lsof -i :8080 # Backend port -lsof -i :3000 # Frontend port - -# 3. Manual compile test -go build -o nofx -./nofx -``` - -### Backend Won't Start - -```bash -# ~~Check if config.json exists~~ -# ~~ls -l config.json~~ - -# Check if database file exists -ls -l trading.db - -# Check permissions -chmod +x nofx - -# Run manually to see errors -./nofx -``` - -### Frontend Not Accessible - -```bash -# Check node_modules -cd web && npm install - -# Manual start test -npm run dev -``` - ---- - -## 🎯 Production Environment Recommendations - -### 1. Use Production Mode - -Modify `pm2.config.js`: - -```javascript -{ - name: 'nofx-frontend', - script: 'npm', - args: 'run preview', // Change to preview (requires npm run build first) - env: { - NODE_ENV: 'production' - } -} -``` - -### 2. Increase Instances (Load Balancing) - -```javascript -{ - name: 'nofx-backend', - script: './nofx', - instances: 2, // Start 2 instances - exec_mode: 'cluster' -} -``` - -### 3. Auto Restart Strategy - -```javascript -{ - autorestart: true, - max_restarts: 10, - min_uptime: '10s', - max_memory_restart: '500M' -} -``` - ---- - -## 📦 Comparison with Docker Deployment - -| Feature | PM2 Deployment | Docker Deployment | -|---------|---------------|-------------------| -| Startup Speed | ⚡ Fast | 🐌 Slower | -| Resource Usage | 💚 Low | 🟡 Medium | -| Isolation | 🟡 Medium | 💚 High | -| Use Case | Dev/Single-machine | Production/Cluster | -| Configuration Complexity | 💚 Simple | 🟡 Medium | - -**Recommendations:** -- **Development Environment**: Use `./pm2.sh` -- **Production Environment**: Use `./start.sh` (Docker) - ---- - -## 🆘 Getting Help - -```bash -./pm2.sh help -``` - -Or check PM2 official documentation: https://pm2.keymetrics.io/ - ---- - -## 📄 License - -MIT diff --git a/docs/getting-started/pm2-deploy.md b/docs/getting-started/pm2-deploy.md deleted file mode 100644 index 3668a29e..00000000 --- a/docs/getting-started/pm2-deploy.md +++ /dev/null @@ -1,303 +0,0 @@ -# NoFX Trading Bot - PM2 部署指南 - -使用 PM2 进行本地开发和生产部署的完整指南。 - -## 🚀 快速开始 - -### 1. 安装 PM2 - -```bash -npm install -g pm2 -``` - -### 2. 一键启动 - -```bash -./pm2.sh start -``` - -就这么简单!前后端将自动启动。 - ---- - -## 📋 所有命令 - -### 服务管理 - -```bash -# 启动服务 -./pm2.sh start - -# 停止服务 -./pm2.sh stop - -# 重启服务 -./pm2.sh restart - -# 查看状态 -./pm2.sh status - -# 删除服务 -./pm2.sh delete -``` - -### 日志查看 - -```bash -# 查看所有日志(实时) -./pm2.sh logs - -# 只看后端日志 -./pm2.sh logs backend - -# 只看前端日志 -./pm2.sh logs frontend -``` - -### 构建与编译 - -```bash -# 编译后端 -./pm2.sh build - -# 重新编译后端并重启 -./pm2.sh rebuild -``` - -### 监控 - -```bash -# 打开 PM2 监控面板(实时CPU/内存) -./pm2.sh monitor -``` - ---- - -## 📊 访问地址 - -启动成功后: - -- **前端 Web 界面**: http://localhost:3000 -- **后端 API**: http://localhost:8080 -- **健康检查**: http://localhost:8080/api/health - ---- - -## 🔧 配置文件 - -### pm2.config.js - -PM2 配置文件,定义了前后端的启动参数: - -```javascript -const path = require('path'); - -module.exports = { - apps: [ - { - name: 'nofx-backend', - script: './nofx', // Go 二进制文件 - cwd: __dirname, // 动态获取当前目录 - autorestart: true, - max_memory_restart: '500M' - }, - { - name: 'nofx-frontend', - script: 'npm', - args: 'run dev', // Vite 开发服务器 - cwd: path.join(__dirname, 'web'), // 动态拼接路径 - autorestart: true, - max_memory_restart: '300M' - } - ] -}; -``` - -**修改配置后需要重启:** -```bash -./pm2.sh restart -``` - ---- - -## 📝 日志文件位置 - -- **后端日志**: `./logs/backend-error.log` 和 `./logs/backend-out.log` -- **前端日志**: `./web/logs/frontend-error.log` 和 `./web/logs/frontend-out.log` - ---- - -## 🔄 开机自启动 - -设置 PM2 开机自启动: - -```bash -# 1. 启动服务 -./pm2.sh start - -# 2. 保存当前进程列表 -pm2 save - -# 3. 生成启动脚本 -pm2 startup - -# 4. 按照提示执行命令(需要 sudo) -``` - -**取消开机自启动:** -```bash -pm2 unstartup -``` - ---- - -## 🛠️ 常见操作 - -### 修改代码后重启 - -**后端修改:** -```bash -./pm2.sh rebuild # 自动编译并重启 -``` - -**前端修改:** -```bash -./pm2.sh restart # Vite 会自动热重载,无需重启 -``` - -### 查看实时资源占用 - -```bash -./pm2.sh monitor -``` - -### 查看详细信息 - -```bash -pm2 info nofx-backend # 后端详情 -pm2 info nofx-frontend # 前端详情 -``` - -### 清空日志 - -```bash -pm2 flush -``` - ---- - -## 🐛 故障排查 - -### 服务启动失败 - -```bash -# 1. 查看详细错误 -./pm2.sh logs - -# 2. 检查端口占用 -lsof -i :8080 # 后端端口 -lsof -i :3000 # 前端端口 - -# 3. 手动编译测试 -go build -o nofx -./nofx -``` - -### 后端无法启动 - -```bash -# ~~检查 config.json 是否存在~~ -# ~~ls -l config.json~~ - -# 检查数据库文件是否存在 -ls -l trading.db - -# 检查权限 -chmod +x nofx - -# 手动运行看报错 -./nofx -``` - -### 前端无法访问 - -```bash -# 检查 node_modules -cd web && npm install - -# 手动启动测试 -npm run dev -``` - ---- - -## 🎯 生产环境建议 - -### 1. 使用生产模式 - -修改 `pm2.config.js`: - -```javascript -{ - name: 'nofx-frontend', - script: 'npm', - args: 'run preview', // 改为 preview(需先 npm run build) - env: { - NODE_ENV: 'production' - } -} -``` - -### 2. 增加实例数(负载均衡) - -```javascript -{ - name: 'nofx-backend', - script: './nofx', - instances: 2, // 启动 2 个实例 - exec_mode: 'cluster' -} -``` - -### 3. 自动重启策略 - -```javascript -{ - autorestart: true, - max_restarts: 10, - min_uptime: '10s', - max_memory_restart: '500M' -} -``` - ---- - -## 📦 与 Docker 部署的对比 - -| 特性 | PM2 部署 | Docker 部署 | -|------|---------|------------| -| 启动速度 | ⚡ 快 | 🐌 较慢 | -| 资源占用 | 💚 低 | 🟡 中等 | -| 隔离性 | 🟡 中等 | 💚 高 | -| 适用场景 | 开发/单机 | 生产/集群 | -| 配置复杂度 | 💚 简单 | 🟡 中等 | - -**建议:** -- **开发环境**: 使用 `./pm2.sh` -- **生产环境**: 使用 `./start.sh` (Docker) - ---- - -## 🆘 获取帮助 - -```bash -./pm2.sh help -``` - -或查看 PM2 官方文档:https://pm2.keymetrics.io/ - ---- - -## 📄 License - -MIT diff --git a/docs/guides/TROUBLESHOOTING.md b/docs/guides/TROUBLESHOOTING.md index e2ccf179..0094ca6a 100644 --- a/docs/guides/TROUBLESHOOTING.md +++ b/docs/guides/TROUBLESHOOTING.md @@ -403,22 +403,24 @@ docker compose up -d #### ❌ Trader Configuration Not Saving **Check:** -1. **Permissions:** +1. **PostgreSQL container health** ```bash - ls -l config.db trading.db - # Should be writable by current user + docker compose ps postgres + docker compose exec postgres pg_isready -U nofx -d nofx ``` -2. **Disk Space:** +2. **Inspect data directly** + ```bash + ./scripts/view_pg_data.sh # quick overview + docker compose exec postgres \ + psql -U nofx -d nofx -c "SELECT COUNT(*) FROM traders;" + ``` + +3. **Disk space** ```bash df -h # Ensure disk not full ``` -3. **Database Integrity:** - ```bash - sqlite3 config.db "PRAGMA integrity_check;" - ``` - --- ## 📊 How to Capture Logs @@ -437,15 +439,9 @@ docker compose logs -f backend docker compose logs backend --tail=500 > backend_logs.txt ``` -**Manual/PM2:** +**Manual binary:** ```bash -# Terminal where you ran ./nofx shows logs - -# PM2: -pm2 logs nofx --lines 100 - -# Save to file -pm2 logs nofx --lines 500 > backend_logs.txt +# If running without Docker, the terminal running ./nofx prints logs ``` --- @@ -532,13 +528,16 @@ docker compose restart frontend ```bash # Check traders in database -sqlite3 config.db "SELECT id, name, ai_model_id, exchange_id, is_running FROM traders;" +docker compose exec postgres \ + psql -U nofx -d nofx -c "SELECT id, name, ai_model_id, exchange_id, is_running FROM traders;" # Check AI models -sqlite3 config.db "SELECT id, name, model_type, enabled FROM ai_models;" +docker compose exec postgres \ + psql -U nofx -d nofx -c "SELECT id, name, provider, enabled FROM ai_models;" # Check system config -sqlite3 config.db "SELECT key, value FROM system_config;" +docker compose exec postgres \ + psql -U nofx -d nofx -c "SELECT key, value FROM system_config;" ``` --- @@ -572,12 +571,12 @@ If you've tried all the above and still have problems: # Stop everything docker compose down -# Backup databases (just in case) -cp config.db config.db.backup -cp trading.db trading.db.backup +# Optional: back up PostgreSQL data +docker compose exec postgres \ + pg_dump -U nofx -d nofx > backup_nofx.sql -# Remove databases (fresh start) -rm config.db trading.db +# Remove all persisted volumes (fresh start) +docker compose down -v # Restart docker compose up -d --build diff --git a/docs/guides/TROUBLESHOOTING.zh-CN.md b/docs/guides/TROUBLESHOOTING.zh-CN.md index b070c8a0..ca140d96 100644 --- a/docs/guides/TROUBLESHOOTING.zh-CN.md +++ b/docs/guides/TROUBLESHOOTING.zh-CN.md @@ -403,22 +403,24 @@ docker compose up -d #### ❌ 交易员配置无法保存 **检查:** -1. **权限:** +1. **PostgreSQL 容器状态** ```bash - ls -l config.db trading.db - # 应该对当前用户可写 + docker compose ps postgres + docker compose exec postgres pg_isready -U nofx -d nofx ``` -2. **磁盘空间:** +2. **直接检查数据库数据** + ```bash + ./scripts/view_pg_data.sh # 快速总览 + docker compose exec postgres \ + psql -U nofx -d nofx -c "SELECT COUNT(*) FROM traders;" + ``` + +3. **磁盘空间** ```bash df -h # 确保磁盘未满 ``` -3. **数据库完整性:** - ```bash - sqlite3 config.db "PRAGMA integrity_check;" - ``` - --- ## 📊 如何捕获日志 @@ -437,15 +439,9 @@ docker compose logs -f backend docker compose logs backend --tail=500 > backend_logs.txt ``` -**手动/PM2:** +**手动运行:** ```bash -# 运行 ./nofx 的终端会显示日志 - -# PM2: -pm2 logs nofx --lines 100 - -# 保存到文件 -pm2 logs nofx --lines 500 > backend_logs.txt +# 如果不是通过 Docker,而是手动运行 ./nofx,可直接在终端查看日志 ``` --- @@ -532,13 +528,16 @@ docker compose restart frontend ```bash # 检查数据库中的交易员 -sqlite3 config.db "SELECT id, name, ai_model_id, exchange_id, is_running FROM traders;" +docker compose exec postgres \ + psql -U nofx -d nofx -c "SELECT id, name, ai_model_id, exchange_id, is_running FROM traders;" # 检查 AI 模型 -sqlite3 config.db "SELECT id, name, model_type, enabled FROM ai_models;" +docker compose exec postgres \ + psql -U nofx -d nofx -c "SELECT id, name, provider, enabled FROM ai_models;" # 检查系统配置 -sqlite3 config.db "SELECT key, value FROM system_config;" +docker compose exec postgres \ + psql -U nofx -d nofx -c "SELECT key, value FROM system_config;" ``` --- @@ -572,12 +571,12 @@ sqlite3 config.db "SELECT key, value FROM system_config;" # 停止所有服务 docker compose down -# 备份数据库(以防万一) -cp config.db config.db.backup -cp trading.db trading.db.backup +# 可选:备份 PostgreSQL 数据 +docker compose exec postgres \ + pg_dump -U nofx -d nofx > backup_nofx.sql -# 删除数据库(全新开始) -rm config.db trading.db +# 删除所有持久化卷(全新开始) +docker compose down -v # 重启 docker compose up -d --build diff --git a/docs/guides/faq.en.md b/docs/guides/faq.en.md index abe3dd5c..644de323 100644 --- a/docs/guides/faq.en.md +++ b/docs/guides/faq.en.md @@ -152,18 +152,17 @@ Yes, to some extent. NOFX provides historical performance feedback in each decis ## Data & Privacy ### Where is my data stored? -All data is stored **locally** on your machine in SQLite databases: -- `config.db` - Trader configurations -- `trading.db` - Trade history +All data is stored **locally** in PostgreSQL (Docker volume `postgres_data`) plus: - `decision_logs/` - AI decision records ### Is my API key secure? API keys are stored in local databases. Never share your databases or `.env` files. We recommend using API keys with IP whitelist restrictions. ### Can I export my trading history? -Yes! Trading data is in SQLite format. You can query it directly: +Yes! Use `pg_dump` or `psql` to export data: ```bash -sqlite3 trading.db "SELECT * FROM trades;" +docker compose exec postgres \ + psql -U nofx -d nofx -c "SELECT * FROM trades;" ``` --- diff --git a/docs/guides/faq.zh-CN.md b/docs/guides/faq.zh-CN.md index 2c74ca89..b33a4282 100644 --- a/docs/guides/faq.zh-CN.md +++ b/docs/guides/faq.zh-CN.md @@ -152,18 +152,17 @@ docker compose up -d ## 数据与隐私 ### 我的数据存储在哪里? -所有数据都**本地存储**在您的机器上,使用 SQLite 数据库: -- `config.db` - 交易员配置 -- `trading.db` - 交易历史 +所有数据都**本地存储**在 PostgreSQL(Docker 卷 `postgres_data`)中,另有: - `decision_logs/` - AI 决策记录 ### API 密钥安全吗? API 密钥存储在本地数据库中。永远不要分享您的数据库或 `.env` 文件。我们建议使用带 IP 白名单限制的 API 密钥。 ### 可以导出交易历史吗? -可以!交易数据是 SQLite 格式。您可以直接查询: +可以!使用 `pg_dump` 或 `psql` 导出数据: ```bash -sqlite3 trading.db "SELECT * FROM trades;" +docker compose exec postgres \ + psql -U nofx -d nofx -c "SELECT * FROM trades;" ``` --- diff --git a/docs/i18n/ru/README.md b/docs/i18n/ru/README.md index eaa9bb9c..f18c6155 100644 --- a/docs/i18n/ru/README.md +++ b/docs/i18n/ru/README.md @@ -297,8 +297,8 @@ nano config.json # или используйте любой редактор #### Шаг 2: Запуск в один клик ```bash # Вариант 1: Используйте удобный скрипт (Рекомендуется) -chmod +x start.sh -./start.sh start --build +chmod +x scripts/start.sh +./scripts/start.sh start --build # Вариант 2: Используйте docker compose напрямую # Этот проект использует синтаксис Docker Compose V2 (с пробелами) @@ -313,10 +313,10 @@ docker compose up -d --build #### Управление вашей системой ```bash -./start.sh logs # Просмотреть логи -./start.sh status # Проверить статус -./start.sh stop # Остановить сервисы -./start.sh restart # Перезапустить сервисы +./scripts/start.sh logs # Просмотреть логи +./scripts/start.sh status # Проверить статус +./scripts/start.sh stop # Остановить сервисы +./scripts/start.sh restart # Перезапустить сервисы ``` **📖 Подробное руководство по развертыванию Docker, устранению неполадок и расширенной конфигурации:** diff --git a/docs/i18n/uk/README.md b/docs/i18n/uk/README.md index 38c40fdd..f8dc8c4d 100644 --- a/docs/i18n/uk/README.md +++ b/docs/i18n/uk/README.md @@ -300,8 +300,8 @@ nano config.json # або використайте будь-який редак #### Крок 2: Запуск в один клік ```bash # Варіант 1: Використайте зручний скрипт (Рекомендується) -chmod +x start.sh -./start.sh start --build +chmod +x scripts/start.sh +./scripts/start.sh start --build # Варіант 2: Використайте docker compose безпосередньо # Цей проект використовує синтаксис Docker Compose V2 (з пробілами) @@ -316,10 +316,10 @@ docker compose up -d --build #### Керування вашою системою ```bash -./start.sh logs # Переглянути логи -./start.sh status # Перевірити статус -./start.sh stop # Зупинити сервіси -./start.sh restart # Перезапустити сервіси +./scripts/start.sh logs # Переглянути логи +./scripts/start.sh status # Перевірити статус +./scripts/start.sh stop # Зупинити сервіси +./scripts/start.sh restart # Перезапустити сервіси ``` **📖 Детальний посібник з розгортання Docker, усунення несправностей та розширеної конфігурації:** diff --git a/docs/i18n/zh-CN/README.md b/docs/i18n/zh-CN/README.md index bb23e4ef..f6915861 100644 --- a/docs/i18n/zh-CN/README.md +++ b/docs/i18n/zh-CN/README.md @@ -296,8 +296,8 @@ nano config.json # 或使用其他编辑器 #### 步骤2:一键启动 ```bash # 方式1:使用便捷脚本(推荐) -chmod +x start.sh -./start.sh start --build +chmod +x scripts/start.sh +./scripts/start.sh start --build # 方式2:直接使用docker compose @@ -312,10 +312,10 @@ docker compose up -d --build #### 管理你的系统 ```bash -./start.sh logs # 查看日志 -./start.sh status # 检查状态 -./start.sh stop # 停止服务 -./start.sh restart # 重启服务 +./scripts/start.sh logs # 查看日志 +./scripts/start.sh status # 检查状态 +./scripts/start.sh stop # 停止服务 +./scripts/start.sh restart # 重启服务 ``` **📖 详细的Docker部署教程、故障排查和高级配置:** diff --git a/go.sum b/go.sum index d394df3e..172d9e05 100644 --- a/go.sum +++ b/go.sum @@ -34,8 +34,6 @@ github.com/decred/dcrd/crypto/blake256 v1.1.0 h1:zPMNGQCm0g4QTY27fOCorQW7EryeQ/U github.com/decred/dcrd/crypto/blake256 v1.1.0/go.mod h1:2OfgNZ5wDpcsFmHmCK5gZTPcCXqlm2ArzUIkw9czNJo= github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.0 h1:NMZiJj8QnKe1LgsbDayM4UoHwbvwDRwnI3hwNaAHRnc= github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.0/go.mod h1:ZXNYxsqcloTdSy/rNShjYzMhyjf0LaoftYK0p+A3h40= -github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= -github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= github.com/elastic/go-sysinfo v1.15.4 h1:A3zQcunCxik14MgXu39cXFXcIw2sFXZ0zL886eyiv1Q= github.com/elastic/go-sysinfo v1.15.4/go.mod h1:ZBVXmqS368dOn/jvijV/zHLfakWTYHBZPk3G244lHrU= github.com/elastic/go-windows v1.0.2 h1:yoLLsAsV5cfg9FLhZ9EXZ2n2sQFKeDYrHenkcivY4vI= @@ -86,8 +84,6 @@ github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= github.com/google/gofuzz v1.2.0 h1:xRy4A+RhZaiKjJ1bPfwQ8sedCA+YS2YcCHW6ec7JMi0= github.com/google/gofuzz v1.2.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= -github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17kjQEVQ1XRhq2/JR1M3sGqeJoxs= -github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/gopherjs/gopherjs v0.0.0-20181017120253-0766667cb4d1/go.mod h1:wJfORRmW1u3UXTncJ5qlYoELFm8eSnnEO6hX4iZ3EWY= @@ -117,6 +113,8 @@ github.com/leanovate/gopter v0.2.11 h1:vRjThO1EKPb/1NsDXuDrzldR28RLkBflWYcU9CvzW github.com/leanovate/gopter v0.2.11/go.mod h1:aK3tzZP/C+p1m3SPRE4SYZFGP7jjkuSI4f7Xvpt0S9c= github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ= github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI= +github.com/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw= +github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o= github.com/mailru/easyjson v0.9.1 h1:LbtsOm5WAswyWbvTEOqhypdPeZzHavpZx96/n553mR8= github.com/mailru/easyjson v0.9.1/go.mod h1:1+xMtQp2MRNVL/V1bOzuP3aP8VNwRW55fQUto+XFtTU= github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg= @@ -136,8 +134,6 @@ github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421 h1:ZqeYNhU3OH github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M= github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= -github.com/ncruces/go-strftime v0.1.9 h1:bY0MQC28UADQmHmaF5dgpLmImcShSi2kHU9XLdhx/f4= -github.com/ncruces/go-strftime v0.1.9/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls= github.com/olekukonko/tablewriter v0.0.5 h1:P2Ga83D34wi1o9J6Wh1mRuqd4mF/x/lgBS7N7AbDhec= github.com/olekukonko/tablewriter v0.0.5/go.mod h1:hPp6KlRPjbx+hW8ykQs1w3UBbZlj6HuIJcUGPhkA7kY= github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4= @@ -154,8 +150,6 @@ github.com/quic-go/qpack v0.5.1 h1:giqksBPnT/HDtZ6VhtFKgoLOWmlyo9Ei6u9PqzIMbhI= github.com/quic-go/qpack v0.5.1/go.mod h1:+PC4XFrEskIVkcLzpEkbLqq1uCoxPhQuvK5rH1ZgaEg= github.com/quic-go/quic-go v0.54.0 h1:6s1YB9QotYI6Ospeiguknbp2Znb/jZYjZLRXn9kMQBg= github.com/quic-go/quic-go v0.54.0/go.mod h1:e68ZEaCdyviluZmy44P6Iey98v/Wfz6HCjQEm+l8zTY= -github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE= -github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= @@ -218,8 +212,6 @@ golang.org/x/arch v0.20.0/go.mod h1:bdwinDaKcfZUGpH09BB7ZmOfhalA8lQdzl62l8gGWsk= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= golang.org/x/crypto v0.42.0 h1:chiH31gIWm57EkTXpwnqf8qeuMUi0yekh6mT2AvFlqI= golang.org/x/crypto v0.42.0/go.mod h1:4+rDnOTJhQCx2q7/j6rAN5XDw8kPjeaXEUR2eL94ix8= -golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b h1:M2rDM6z3Fhozi9O7NWsxAkg/yqS/lQJ6PmkyIV3YP+o= -golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b/go.mod h1:3//PLf8L/X+8b4vuAfHzxeRUl04Adcb341+IGKfnqS8= golang.org/x/mod v0.27.0 h1:kb+q2PyFnEADO2IEF935ehFUXlWiNjJWtRNgBLSfbxQ= golang.org/x/mod v0.27.0/go.mod h1:rWI627Fq0DEoudcK+MBkNkCe0EetEaDSwJJkCcjpazc= golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= @@ -255,29 +247,3 @@ gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= howett.net/plist v1.0.1 h1:37GdZ8tP09Q35o9ych3ehygcsL+HqKSwzctveSlarvM= howett.net/plist v1.0.1/go.mod h1:lqaXoTrLY4hg8tnEzNru53gicrbv7rrk+2xJA/7hw9g= -modernc.org/cc/v4 v4.26.5 h1:xM3bX7Mve6G8K8b+T11ReenJOT+BmVqQj0FY5T4+5Y4= -modernc.org/cc/v4 v4.26.5/go.mod h1:uVtb5OGqUKpoLWhqwNQo/8LwvoiEBLvZXIQ/SmO6mL0= -modernc.org/ccgo/v4 v4.28.1 h1:wPKYn5EC/mYTqBO373jKjvX2n+3+aK7+sICCv4Fjy1A= -modernc.org/ccgo/v4 v4.28.1/go.mod h1:uD+4RnfrVgE6ec9NGguUNdhqzNIeeomeXf6CL0GTE5Q= -modernc.org/fileutil v1.3.40 h1:ZGMswMNc9JOCrcrakF1HrvmergNLAmxOPjizirpfqBA= -modernc.org/fileutil v1.3.40/go.mod h1:HxmghZSZVAz/LXcMNwZPA/DRrQZEVP9VX0V4LQGQFOc= -modernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI= -modernc.org/gc/v2 v2.6.5/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito= -modernc.org/goabi0 v0.2.0 h1:HvEowk7LxcPd0eq6mVOAEMai46V+i7Jrj13t4AzuNks= -modernc.org/goabi0 v0.2.0/go.mod h1:CEFRnnJhKvWT1c1JTI3Avm+tgOWbkOu5oPA8eH8LnMI= -modernc.org/libc v1.66.10 h1:yZkb3YeLx4oynyR+iUsXsybsX4Ubx7MQlSYEw4yj59A= -modernc.org/libc v1.66.10/go.mod h1:8vGSEwvoUoltr4dlywvHqjtAqHBaw0j1jI7iFBTAr2I= -modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU= -modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg= -modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI= -modernc.org/memory v1.11.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw= -modernc.org/opt v0.1.4 h1:2kNGMRiUjrp4LcaPuLY2PzUfqM/w9N23quVwhKt5Qm8= -modernc.org/opt v0.1.4/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns= -modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w= -modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE= -modernc.org/sqlite v1.40.0 h1:bNWEDlYhNPAUdUdBzjAvn8icAs/2gaKlj4vM+tQ6KdQ= -modernc.org/sqlite v1.40.0/go.mod h1:9fjQZ0mB1LLP0GYrp39oOJXx/I2sxEnZtzCmEQIKvGE= -modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0= -modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A= -modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y= -modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM= diff --git a/manager/trader_manager.go b/manager/trader_manager.go index 9e1e9fb7..38ea96c6 100644 --- a/manager/trader_manager.go +++ b/manager/trader_manager.go @@ -39,7 +39,7 @@ func NewTraderManager() *TraderManager { } // LoadTradersFromDatabase 从数据库加载所有交易员到内存 -func (tm *TraderManager) LoadTradersFromDatabase(database *config.Database) error { +func (tm *TraderManager) LoadTradersFromDatabase(database config.DatabaseInterface) error { tm.mu.Lock() defer tm.mu.Unlock() @@ -182,7 +182,7 @@ func (tm *TraderManager) LoadTradersFromDatabase(database *config.Database) erro } // addTraderFromConfig 内部方法:从配置添加交易员(不加锁,因为调用方已加锁) -func (tm *TraderManager) addTraderFromDB(traderCfg *config.TraderRecord, aiModelCfg *config.AIModelConfig, exchangeCfg *config.ExchangeConfig, coinPoolURL, oiTopURL string, maxDailyLoss, maxDrawdown float64, stopTradingMinutes int, defaultCoins []string, database *config.Database, userID string) error { +func (tm *TraderManager) addTraderFromDB(traderCfg *config.TraderRecord, aiModelCfg *config.AIModelConfig, exchangeCfg *config.ExchangeConfig, coinPoolURL, oiTopURL string, maxDailyLoss, maxDrawdown float64, stopTradingMinutes int, defaultCoins []string, database config.DatabaseInterface, userID string) error { if _, exists := tm.traders[traderCfg.ID]; exists { return fmt.Errorf("trader ID '%s' 已存在", traderCfg.ID) } @@ -286,7 +286,7 @@ func (tm *TraderManager) addTraderFromDB(traderCfg *config.TraderRecord, aiModel // AddTrader 从数据库配置添加trader (移除旧版兼容性) // AddTraderFromDB 从数据库配置添加trader -func (tm *TraderManager) AddTraderFromDB(traderCfg *config.TraderRecord, aiModelCfg *config.AIModelConfig, exchangeCfg *config.ExchangeConfig, coinPoolURL, oiTopURL string, maxDailyLoss, maxDrawdown float64, stopTradingMinutes int, defaultCoins []string, database *config.Database, userID string) error { +func (tm *TraderManager) AddTraderFromDB(traderCfg *config.TraderRecord, aiModelCfg *config.AIModelConfig, exchangeCfg *config.ExchangeConfig, coinPoolURL, oiTopURL string, maxDailyLoss, maxDrawdown float64, stopTradingMinutes int, defaultCoins []string, database config.DatabaseInterface, userID string) error { tm.mu.Lock() defer tm.mu.Unlock() @@ -709,7 +709,7 @@ func containsUserPrefix(traderID string) bool { } // LoadUserTraders 为特定用户加载交易员到内存 -func (tm *TraderManager) LoadUserTraders(database *config.Database, userID string) error { +func (tm *TraderManager) LoadUserTraders(database config.DatabaseInterface, userID string) error { tm.mu.Lock() defer tm.mu.Unlock() @@ -995,7 +995,7 @@ func (tm *TraderManager) LoadTraderByID(database *config.Database, userID, trade } // loadSingleTrader 加载单个交易员(从现有代码提取的公共逻辑) -func (tm *TraderManager) loadSingleTrader(traderCfg *config.TraderRecord, aiModelCfg *config.AIModelConfig, exchangeCfg *config.ExchangeConfig, coinPoolURL, oiTopURL string, maxDailyLoss, maxDrawdown float64, stopTradingMinutes int, defaultCoins []string, database *config.Database, userID string) error { +func (tm *TraderManager) loadSingleTrader(traderCfg *config.TraderRecord, aiModelCfg *config.AIModelConfig, exchangeCfg *config.ExchangeConfig, coinPoolURL, oiTopURL string, maxDailyLoss, maxDrawdown float64, stopTradingMinutes int, defaultCoins []string, database config.DatabaseInterface, userID string) error { // 处理交易币种列表 var tradingCoins []string if traderCfg.TradingSymbols != "" { diff --git a/pm2.config.js b/pm2.config.js deleted file mode 100644 index 2f166388..00000000 --- a/pm2.config.js +++ /dev/null @@ -1,41 +0,0 @@ -const path = require('path'); - -module.exports = { - apps: [ - { - name: 'nofx-backend', - script: './nofx', - cwd: __dirname, // 使用当前目录(配置文件所在目录) - interpreter: 'none', // 不使用解释器,直接执行二进制文件 - instances: 1, - autorestart: true, - watch: false, - max_memory_restart: '500M', - env: { - NODE_ENV: 'production' - }, - error_file: './logs/backend-error.log', - out_file: './logs/backend-out.log', - log_date_format: 'YYYY-MM-DD HH:mm:ss Z', - merge_logs: true - }, - { - name: 'nofx-frontend', - script: 'npm', - args: 'run dev', - cwd: path.join(__dirname, 'web'), // 动态拼接 web 目录 - instances: 1, - autorestart: true, - watch: false, - max_memory_restart: '300M', - env: { - NODE_ENV: 'development', - PORT: 3000 - }, - error_file: './logs/frontend-error.log', - out_file: './logs/frontend-out.log', - log_date_format: 'YYYY-MM-DD HH:mm:ss Z', - merge_logs: true - } - ] -}; diff --git a/pm2.sh b/pm2.sh deleted file mode 100755 index b55c8412..00000000 --- a/pm2.sh +++ /dev/null @@ -1,258 +0,0 @@ -#!/bin/bash - -# NoFX Trading Bot - PM2 管理脚本 -# 用法: ./pm2.sh [start|stop|restart|status|logs|build] - -set -e - -# 自动获取脚本所在目录(支持符号链接) -PROJECT_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" -cd "$PROJECT_ROOT" - -# 颜色输出 -RED='\033[0;31m' -GREEN='\033[0;32m' -YELLOW='\033[1;33m' -BLUE='\033[0;34m' -PURPLE='\033[0;35m' -CYAN='\033[0;36m' -NC='\033[0m' # No Color - -# 函数:打印带颜色的消息 -print_info() { - echo -e "${BLUE}ℹ️ $1${NC}" -} - -print_success() { - echo -e "${GREEN}✅ $1${NC}" -} - -print_warning() { - echo -e "${YELLOW}⚠️ $1${NC}" -} - -print_error() { - echo -e "${RED}❌ $1${NC}" -} - -print_header() { - echo -e "${PURPLE}═══════════════════════════════════════${NC}" - echo -e "${PURPLE} 🤖 NoFX Trading Bot - PM2 Manager${NC}" - echo -e "${PURPLE}═══════════════════════════════════════${NC}" - echo "" -} - -# 函数:检查 PM2 是否安装 -check_pm2() { - if ! command -v pm2 &> /dev/null; then - print_error "PM2 未安装,请先安装: npm install -g pm2" - exit 1 - fi -} - -# 函数:确保日志目录存在 -ensure_log_dirs() { - mkdir -p "$PROJECT_ROOT/logs" - mkdir -p "$PROJECT_ROOT/web/logs" - print_info "日志目录已创建" -} - -# 函数:编译后端 -build_backend() { - print_info "正在编译后端..." - go build -o nofx - if [ $? -eq 0 ]; then - print_success "后端编译完成" - else - print_error "后端编译失败" - exit 1 - fi -} - -# 函数:构建前端(生产环境) -build_frontend() { - print_info "正在构建前端..." - cd web - npm run build - if [ $? -eq 0 ]; then - print_success "前端构建完成" - cd .. - else - print_error "前端构建失败" - exit 1 - fi -} - -# 函数:启动服务 -start_services() { - print_header - ensure_log_dirs - - # 检查后端二进制文件是否存在 - if [ ! -f "./nofx" ]; then - print_warning "后端二进制文件不存在,开始编译..." - build_backend - fi - - print_info "正在启动服务..." - pm2 start pm2.config.js - - sleep 2 - pm2 status - - echo "" - print_success "服务启动完成!" - echo "" - echo -e "${CYAN}📊 访问地址:${NC}" - echo -e " ${GREEN}前端:${NC} http://localhost:3000" - echo -e " ${GREEN}后端 API:${NC} http://localhost:8080" - echo "" - echo -e "${CYAN}📝 查看日志:${NC}" - echo -e " ${GREEN}实时日志:${NC} ./pm2.sh logs" - echo -e " ${GREEN}后端日志:${NC} ./pm2.sh logs backend" - echo -e " ${GREEN}前端日志:${NC} ./pm2.sh logs frontend" - echo "" -} - -# 函数:停止服务 -stop_services() { - print_header - print_info "正在停止服务..." - pm2 stop pm2.config.js - print_success "服务已停止" -} - -# 函数:重启服务 -restart_services() { - print_header - print_info "正在重启服务..." - pm2 restart pm2.config.js - sleep 2 - pm2 status - print_success "服务已重启" -} - -# 函数:删除服务 -delete_services() { - print_header - print_warning "正在删除 PM2 服务..." - pm2 delete pm2.config.js || true - print_success "PM2 服务已删除" -} - -# 函数:查看状态 -show_status() { - print_header - pm2 status - echo "" - print_info "详细信息:" - pm2 info nofx-backend - echo "" - pm2 info nofx-frontend -} - -# 函数:查看日志 -show_logs() { - if [ -z "$2" ]; then - # 显示所有日志 - pm2 logs - elif [ "$2" = "backend" ]; then - pm2 logs nofx-backend - elif [ "$2" = "frontend" ]; then - pm2 logs nofx-frontend - else - print_error "未知的日志类型: $2" - print_info "用法: ./pm2.sh logs [backend|frontend]" - exit 1 - fi -} - -# 函数:监控 -show_monitor() { - print_header - print_info "启动 PM2 监控面板..." - pm2 monit -} - -# 函数:重新编译并重启 -rebuild_and_restart() { - print_header - print_info "正在重新编译后端..." - build_backend - - print_info "正在重启后端服务..." - pm2 restart nofx-backend - - sleep 2 - pm2 status - print_success "后端已重新编译并重启" -} - -# 函数:显示帮助 -show_help() { - print_header - echo -e "${CYAN}使用方法:${NC}" - echo " ./pm2.sh [command]" - echo "" - echo -e "${CYAN}可用命令:${NC}" - echo -e " ${GREEN}start${NC} - 启动前后端服务" - echo -e " ${GREEN}stop${NC} - 停止所有服务" - echo -e " ${GREEN}restart${NC} - 重启所有服务" - echo -e " ${GREEN}status${NC} - 查看服务状态" - echo -e " ${GREEN}logs${NC} - 查看所有日志 (Ctrl+C 退出)" - echo -e " ${GREEN}logs backend${NC} - 查看后端日志" - echo -e " ${GREEN}logs frontend${NC} - 查看前端日志" - echo -e " ${GREEN}monitor${NC} - 打开 PM2 监控面板" - echo -e " ${GREEN}build${NC} - 编译后端" - echo -e " ${GREEN}rebuild${NC} - 重新编译后端并重启" - echo -e " ${GREEN}delete${NC} - 删除 PM2 服务" - echo -e " ${GREEN}help${NC} - 显示此帮助信息" - echo "" - echo -e "${CYAN}示例:${NC}" - echo " ./pm2.sh start # 启动服务" - echo " ./pm2.sh logs backend # 查看后端日志" - echo " ./pm2.sh rebuild # 重新编译后端并重启" - echo "" -} - -# 主逻辑 -check_pm2 - -case "${1:-help}" in - start) - start_services - ;; - stop) - stop_services - ;; - restart) - restart_services - ;; - status) - show_status - ;; - logs) - show_logs "$@" - ;; - monitor|mon) - show_monitor - ;; - build) - build_backend - ;; - rebuild) - rebuild_and_restart - ;; - delete|remove) - delete_services - ;; - help|--help|-h) - show_help - ;; - *) - print_error "未知命令: $1" - echo "" - show_help - exit 1 - ;; -esac diff --git a/proxy/README.md b/proxy/README.md new file mode 100644 index 00000000..f48a35d4 --- /dev/null +++ b/proxy/README.md @@ -0,0 +1,685 @@ +# HTTP 代理模块 + +## 概述 + +这是一个高度解耦的HTTP代理管理模块,专为解决高频API请求被限流/封禁问题而设计。支持单代理、代理池和动态IP获取三种模式,提供线程安全的IP轮换和智能黑名单管理机制。 + +## 功能特性 + +- ✅ **三种工作模式**:单代理、固定代理池、Bright Data API动态获取 +- ✅ **线程安全**:所有操作使用读写锁保护,支持并发访问 +- ✅ **智能黑名单**:失败的代理IP手动加入黑名单,TTL机制自动恢复 +- ✅ **自动刷新**:支持定时刷新代理IP列表(默认30分钟) +- ✅ **随机轮换**:从可用IP池中随机选择,避免单点压力 +- ✅ **防越界保护**:多层数组边界检查,确保运行时安全 +- ✅ **可选启用**:未配置或禁用时自动使用直连,不影响独立客户 + +## 架构设计 + +``` +proxy/ +├── README.md # 本文档 +├── types.go # 核心数据结构定义 +├── provider.go # IP提供者接口定义 +├── single_provider.go # 单代理实现 +├── fixed_provider.go # 固定代理池实现 +├── brightdata_provider.go # Bright Data API实现 +└── proxy_manager.go # 代理管理器(核心逻辑) +``` + +### 设计原则 + +1. **接口抽象**:通过 `IPProvider` 接口实现不同代理源的统一管理 +2. **策略模式**:三种Provider实现可灵活切换 +3. **单例模式**:全局ProxyManager确保资源统一管理 +4. **防御性编程**:多层边界检查,优雅处理异常情况 + +## 配置说明 + +在 `config.json` 中添加 `proxy` 配置段: + +```json +{ + "proxy": { + "enabled": true, + "mode": "single", + "timeout": 30, + "proxy_url": "http://127.0.0.1:7890", + "proxy_list": [], + "brightdata_endpoint": "", + "brightdata_token": "", + "brightdata_zone": "", + "proxy_host": "", + "proxy_user": "", + "proxy_password": "", + "refresh_interval": 1800, + "blacklist_ttl": 5 + } +} +``` + +### 配置字段详解 + +| 字段 | 类型 | 必填 | 说明 | +|------|------|------|------| +| `enabled` | bool | 是 | 是否启用代理(false时使用直连) | +| `mode` | string | 是 | 代理模式:`single`/`pool`/`brightdata` | +| `timeout` | int | 否 | HTTP请求超时时间(秒),默认30 | +| `proxy_url` | string | single模式必填 | 单个代理地址,如 `http://127.0.0.1:7890` | +| `proxy_list` | []string | pool模式必填 | 代理列表,支持 `http://`、`https://`、`socks5://` | +| `brightdata_endpoint` | string | brightdata模式必填 | Bright Data API端点 | +| `brightdata_token` | string | brightdata模式可选 | Bright Data访问令牌 | +| `brightdata_zone` | string | brightdata模式可选 | Bright Data区域参数 | +| `proxy_host` | string | 否 | 代理主机(用于认证代理) | +| `proxy_user` | string | 否 | 代理用户名模板,支持 `%s` 占位符替换IP | +| `proxy_password` | string | 否 | 代理密码 | +| `refresh_interval` | int | 否 | IP列表刷新间隔(秒),brightdata模式默认1800(30分钟) | +| `blacklist_ttl` | int | 否 | 黑名单IP的TTL(刷新次数),默认5 | + +## 使用方法 + +### 1. 初始化代理管理器 + +在 `main.go` 或初始化代码中: + +```go +import ( + "nofx/proxy" + "time" +) + +// 方式1:使用配置结构体初始化 +proxyConfig := &proxy.Config{ + Enabled: true, + Mode: "single", + Timeout: 30 * time.Second, + ProxyURL: "http://127.0.0.1:7890", + BlacklistTTL: 5, +} + +err := proxy.InitGlobalProxyManager(proxyConfig) +if err != nil { + log.Fatalf("初始化代理管理器失败: %v", err) +} +``` + +### 2. 获取代理HTTP客户端 + +在需要发送HTTP请求的地方: + +```go +// 获取代理客户端(包含ProxyID用于黑名单管理) +proxyClient, err := proxy.GetProxyHTTPClient() +if err != nil { + log.Printf("获取代理客户端失败: %v", err) + return +} + +// 使用代理客户端发送请求 +resp, err := proxyClient.Client.Get("https://api.example.com/data") +if err != nil { + // 请求失败,将此代理加入黑名单 + proxy.AddBlacklist(proxyClient.ProxyID) + log.Printf("请求失败,代理IP %s 已加入黑名单", proxyClient.IP) + return +} +defer resp.Body.Close() + +// 处理响应... +``` + +### 3. 黑名单管理 + +```go +// 添加失败的代理到黑名单 +proxy.AddBlacklist(proxyClient.ProxyID) + +// 获取黑名单状态 +total, blacklisted, available := proxy.GetGlobalProxyManager().GetBlacklistStatus() +log.Printf("代理状态: 总计%d个,黑名单%d个,可用%d个", total, blacklisted, available) +``` + +### 4. 手动刷新IP列表 + +```go +err := proxy.RefreshIPList() +if err != nil { + log.Printf("刷新IP列表失败: %v", err) +} +``` + +### 5. 检查代理是否启用 + +```go +if proxy.IsEnabled() { + log.Println("代理已启用") +} else { + log.Println("代理未启用,使用直连") +} +``` + +## 三种模式详解 + +### Mode 1: Single(单代理模式) + +适用场景:本地代理工具(如Clash、V2Ray)或单个固定代理服务器 + +```json +{ + "proxy": { + "enabled": true, + "mode": "single", + "proxy_url": "http://127.0.0.1:7890" + } +} +``` + +特点: +- 简单直接,适合本地开发和测试 +- 所有请求通过同一个代理 +- 不需要刷新和轮换 + +### Mode 2: Pool(代理池模式) + +适用场景:拥有多个固定代理服务器,需要轮换使用 + +```json +{ + "proxy": { + "enabled": true, + "mode": "pool", + "proxy_list": [ + "http://proxy1.example.com:8080", + "http://user:pass@proxy2.example.com:8080", + "socks5://proxy3.example.com:1080" + ], + "blacklist_ttl": 5 + } +} +``` + +特点: +- 支持多协议:HTTP、HTTPS、SOCKS5 +- 随机选择代理,分散请求压力 +- 失败的代理自动加入黑名单 +- 黑名单IP经过TTL次刷新后自动恢复 + +### Mode 3: BrightData(动态IP模式) + +适用场景:使用Bright Data等提供API的动态代理服务 + +```json +{ + "proxy": { + "enabled": true, + "mode": "brightdata", + "brightdata_endpoint": "https://api.brightdata.com/zones/get_ips", + "brightdata_token": "your_api_token", + "brightdata_zone": "residential", + "proxy_host": "brd.superproxy.io:22225", + "proxy_user": "brd-customer-xxx-zone-residential-ip-%s", + "proxy_password": "your_password", + "refresh_interval": 1800, + "blacklist_ttl": 5 + } +} +``` + +特点: +- 从API动态获取可用IP列表 +- 自动定时刷新(默认30分钟) +- 支持用户名模板(`%s` 替换为IP地址) +- 黑名单TTL机制避免频繁切换 + +**用户名模板说明**: +``` +proxy_user: "brd-customer-xxx-zone-residential-ip-%s" + ↑ + 自动替换为IP地址 +``` + +## 核心API + +### 全局函数 + +```go +// 初始化全局代理管理器(只执行一次) +func InitGlobalProxyManager(config *Config) error + +// 获取全局代理管理器实例 +func GetGlobalProxyManager() *ProxyManager + +// 获取代理HTTP客户端(包含ProxyID和IP信息) +func GetProxyHTTPClient() (*ProxyClient, error) + +// 将代理IP添加到黑名单 +func AddBlacklist(proxyID int) + +// 刷新IP列表 +func RefreshIPList() error + +// 检查代理是否启用 +func IsEnabled() bool +``` + +### ProxyManager 方法 + +```go +// 获取代理客户端 +func (m *ProxyManager) GetProxyClient() (*ProxyClient, error) + +// 刷新IP列表 +func (m *ProxyManager) RefreshIPList() error + +// 添加到黑名单 +func (m *ProxyManager) AddBlacklist(proxyID int) + +// 获取黑名单状态 +func (m *ProxyManager) GetBlacklistStatus() (total, blacklisted, available int) + +// 启动自动刷新 +func (m *ProxyManager) StartAutoRefresh() + +// 停止自动刷新 +func (m *ProxyManager) StopAutoRefresh() +``` + +## 黑名单机制 + +### 工作原理 + +1. **添加黑名单**:当代理请求失败时,调用 `AddBlacklist(proxyID)` 将该IP加入黑名单 +2. **TTL倒计时**:每次刷新IP列表时,黑名单中的IP的TTL减1 +3. **自动恢复**:当TTL归零时,IP自动从黑名单移除,重新可用 + +### 线程安全保证 + +```go +// 添加黑名单使用写锁 +func (m *ProxyManager) AddBlacklist(proxyID int) { + m.mutex.Lock() + defer m.mutex.Unlock() + + // 防越界检查 + if proxyID < 0 || proxyID >= len(m.ipList) { + log.Printf("⚠️ 无效的 ProxyID: %d", proxyID) + return + } + + ip := m.ipList[proxyID].IP + m.blacklist[proxyID] = ip + m.ipBlacklist[ip] = m.config.BlacklistTTL +} + +// 获取代理使用读锁(支持并发) +func (m *ProxyManager) getRandomProxy() (int, *ProxyIP, error) { + m.mutex.RLock() + defer m.mutex.RUnlock() + // ... 读取操作 +} +``` + +### 示例流程 + +``` +初始状态:5个代理IP,TTL=3 +IP列表: [IP1, IP2, IP3, IP4, IP5] +黑名单: {} + +第1次失败:IP2请求失败 +IP列表: [IP1, IP2, IP3, IP4, IP5] +黑名单: {IP2: TTL=3} + +第1次刷新:TTL-1 +黑名单: {IP2: TTL=2} + +第2次刷新:TTL-1 +黑名单: {IP2: TTL=1} + +第3次刷新:TTL-1 +黑名单: {IP2: TTL=0} → 从黑名单移除 + +第3次刷新后: +IP列表: [IP1, IP2, IP3, IP4, IP5] +黑名单: {} ← IP2已恢复可用 +``` + +## 完整使用示例 + +### 示例1:币安API请求(单代理模式) + +```go +package main + +import ( + "log" + "nofx/proxy" + "time" +) + +func main() { + // 初始化代理 + err := proxy.InitGlobalProxyManager(&proxy.Config{ + Enabled: true, + Mode: "single", + ProxyURL: "http://127.0.0.1:7890", + Timeout: 30 * time.Second, + }) + if err != nil { + log.Fatalf("初始化代理失败: %v", err) + } + + // 获取币安数据 + proxyClient, err := proxy.GetProxyHTTPClient() + if err != nil { + log.Fatalf("获取代理客户端失败: %v", err) + } + + resp, err := proxyClient.Client.Get("https://fapi.binance.com/fapi/v1/ticker/24hr") + if err != nil { + log.Printf("请求失败: %v", err) + return + } + defer resp.Body.Close() + + log.Printf("请求成功,使用代理: %s", proxyClient.IP) +} +``` + +### 示例2:OI数据获取(代理池模式 + 黑名单) + +```go +package main + +import ( + "fmt" + "io" + "log" + "nofx/proxy" + "time" +) + +func fetchOIData(symbol string) error { + proxyClient, err := proxy.GetProxyHTTPClient() + if err != nil { + return fmt.Errorf("获取代理失败: %w", err) + } + + url := fmt.Sprintf("https://fapi.binance.com/futures/data/openInterestHist?symbol=%s&period=5m&limit=1", symbol) + resp, err := proxyClient.Client.Get(url) + if err != nil { + // 请求失败,加入黑名单 + proxy.AddBlacklist(proxyClient.ProxyID) + return fmt.Errorf("请求失败 (代理: %s): %w", proxyClient.IP, err) + } + defer resp.Body.Close() + + if resp.StatusCode != 200 { + // 状态码异常,加入黑名单 + proxy.AddBlacklist(proxyClient.ProxyID) + return fmt.Errorf("状态码异常: %d (代理: %s)", resp.StatusCode, proxyClient.IP) + } + + body, _ := io.ReadAll(resp.Body) + log.Printf("✓ 获取 %s OI数据成功 (代理: %s): %s", symbol, proxyClient.IP, string(body)) + return nil +} + +func main() { + // 初始化代理池 + err := proxy.InitGlobalProxyManager(&proxy.Config{ + Enabled: true, + Mode: "pool", + ProxyList: []string{ + "http://proxy1.example.com:8080", + "http://proxy2.example.com:8080", + "http://proxy3.example.com:8080", + }, + Timeout: 30 * time.Second, + BlacklistTTL: 5, + }) + if err != nil { + log.Fatalf("初始化代理失败: %v", err) + } + + // 循环获取数据 + symbols := []string{"BTCUSDT", "ETHUSDT", "SOLUSDT"} + for { + for _, symbol := range symbols { + if err := fetchOIData(symbol); err != nil { + log.Printf("⚠️ %v", err) + } + time.Sleep(1 * time.Second) + } + time.Sleep(10 * time.Second) + } +} +``` + +### 示例3:Bright Data动态IP + +```go +package main + +import ( + "log" + "nofx/proxy" + "time" +) + +func main() { + // 初始化Bright Data代理 + err := proxy.InitGlobalProxyManager(&proxy.Config{ + Enabled: true, + Mode: "brightdata", + BrightDataEndpoint: "https://api.brightdata.com/zones/get_ips", + BrightDataToken: "your_token", + BrightDataZone: "residential", + ProxyHost: "brd.superproxy.io:22225", + ProxyUser: "brd-customer-xxx-zone-residential-ip-%s", + ProxyPassword: "your_password", + RefreshInterval: 30 * time.Minute, + Timeout: 30 * time.Second, + BlacklistTTL: 5, + }) + if err != nil { + log.Fatalf("初始化代理失败: %v", err) + } + + // 代理会自动每30分钟刷新IP列表 + log.Println("✓ Bright Data代理已启动,自动刷新已开启") + + // 获取并使用代理 + for i := 0; i < 10; i++ { + proxyClient, err := proxy.GetProxyHTTPClient() + if err != nil { + log.Printf("获取代理失败: %v", err) + continue + } + + resp, err := proxyClient.Client.Get("https://api.ipify.org?format=json") + if err != nil { + proxy.AddBlacklist(proxyClient.ProxyID) + log.Printf("请求失败,代理已加入黑名单: %s", proxyClient.IP) + continue + } + resp.Body.Close() + + log.Printf("✓ 请求成功 (代理ID: %d, IP: %s)", proxyClient.ProxyID, proxyClient.IP) + time.Sleep(2 * time.Second) + } +} +``` + +## 注意事项 + +### 1. 模块解耦性 + +- ✅ 代理模块完全独立,不依赖其他业务模块 +- ✅ 禁用代理时自动使用直连,对业务代码透明 +- ✅ 适合多租户/多客户环境,可按需启用 + +### 2. 线程安全 + +- ✅ 所有公开方法都是线程安全的 +- ✅ 支持高并发场景下的代理获取和黑名单操作 +- ✅ 读写锁优化性能:读操作可并发,写操作独占 + +### 3. 错误处理 + +```go +proxyClient, err := proxy.GetProxyHTTPClient() +if err != nil { + // 可能的错误: + // - 代理IP列表为空 + // - 所有代理都在黑名单中 + // - 代理URL解析失败 + log.Printf("获取代理失败: %v", err) + + // 建议:降级为直连或重试 + return +} +``` + +### 4. 性能优化建议 + +- 对于高频请求,复用 `http.Client` 而不是每次创建新的 +- 合理设置 `refresh_interval` 避免频繁刷新 +- `blacklist_ttl` 建议设置为 3-10,平衡恢复速度和稳定性 + +### 5. 安全建议 + +- 生产环境中代理密钥应使用环境变量或密钥管理服务 +- 避免在日志中打印完整的代理URL(包含密码) +- TLS验证默认开启,如需跳过请谨慎评估风险 + +### 6. 调试技巧 + +```go +// 获取当前代理状态 +total, blacklisted, available := proxy.GetGlobalProxyManager().GetBlacklistStatus() +log.Printf("代理池状态: 总计=%d, 黑名单=%d, 可用=%d", total, blacklisted, available) + +// 检查是否启用 +if !proxy.IsEnabled() { + log.Println("代理未启用,请检查配置") +} +``` + +## 故障排查 + +### 问题1:获取代理失败 - "代理IP列表为空" + +**原因**: +- `single` 模式:未配置 `proxy_url` +- `pool` 模式:`proxy_list` 为空 +- `brightdata` 模式:API返回空列表或请求失败 + +**解决方案**: +```bash +# 检查配置文件 +cat config.json | grep -A 15 "proxy" + +# 检查日志,查看初始化信息 +# 应该看到类似:🌐 HTTP 代理已启用 (xxx模式) +``` + +### 问题2:所有代理都在黑名单中 + +**原因**:请求持续失败,所有IP被加入黑名单 + +**解决方案**: +```go +// 方案1:手动刷新IP列表(会触发TTL倒计时) +proxy.RefreshIPList() + +// 方案2:降低blacklist_ttl,加快恢复速度 +// config.json: "blacklist_ttl": 2 (默认5) + +// 方案3:检查代理本身是否可用 +// 使用curl测试代理: +// curl -x http://proxy_url https://api.binance.com/api/v3/ping +``` + +### 问题3:Bright Data模式无法获取IP + +**原因**: +- API端点配置错误 +- Token无效或过期 +- Zone参数不正确 + +**解决方案**: +```bash +# 手动测试API +curl -H "Authorization: Bearer YOUR_TOKEN" \ + "https://api.brightdata.com/zones/get_ips?zone=residential" + +# 检查返回格式是否符合: +# {"ips": [{"ip": "1.2.3.4", ...}, ...]} +``` + +### 问题4:代理连接超时 + +**原因**:代理服务器响应慢或网络不稳定 + +**解决方案**: +```json +{ + "proxy": { + "timeout": 60 // 增加超时时间(秒) + } +} +``` + +## 扩展开发 + +### 添加新的Provider + +实现 `IPProvider` 接口即可: + +```go +// custom_provider.go +package proxy + +type CustomProvider struct { + // 自定义字段 +} + +func NewCustomProvider(config string) *CustomProvider { + return &CustomProvider{} +} + +func (p *CustomProvider) GetIPList() ([]ProxyIP, error) { + // 实现获取IP列表的逻辑 + return []ProxyIP{}, nil +} + +func (p *CustomProvider) RefreshIPList() ([]ProxyIP, error) { + // 实现刷新IP列表的逻辑 + return p.GetIPList() +} +``` + +然后在 `proxy_manager.go` 的 `NewProxyManager` 中添加新模式: + +```go +case "custom": + m.provider = NewCustomProvider(config.CustomEndpoint) + log.Printf("🌐 HTTP 代理已启用 (自定义模式)") +``` + +## 更新日志 + +### v1.0.0 (当前版本) +- ✅ 支持三种代理模式:single、pool、brightdata +- ✅ 线程安全的IP轮换和黑名单管理 +- ✅ 自动刷新机制(30分钟默认) +- ✅ TTL黑名单自动恢复 +- ✅ 防越界保护 +- ✅ ProxyID追踪机制 + + +## 技术支持 + +如有问题或建议,请联系项目维护者 @hzb1115 +。 diff --git a/proxy/brightdata_provider.go b/proxy/brightdata_provider.go new file mode 100644 index 00000000..e8febd55 --- /dev/null +++ b/proxy/brightdata_provider.go @@ -0,0 +1,105 @@ +package proxy + +import ( + "encoding/json" + "fmt" + "io/ioutil" + "net/http" + "time" +) + +// BrightDataProvider Bright Data动态获取IP提供者 +type BrightDataProvider struct { + endpoint string + token string + zone string + client *http.Client +} + +// NewBrightDataProvider 创建Bright Data IP提供者 +func NewBrightDataProvider(endpoint, token, zone string) *BrightDataProvider { + return &BrightDataProvider{ + endpoint: endpoint, + token: token, + zone: zone, + client: &http.Client{ + Timeout: 30 * time.Second, + }, + } +} + +// BrightDataIPList Bright Data API返回的IP列表结构 +type BrightDataIPList struct { + IPs []struct { + IP string `json:"ip"` + Maxmind string `json:"maxmind"` + Ext map[string]interface{} `json:"ext"` + } `json:"ips"` +} + +func (p *BrightDataProvider) GetIPList() ([]ProxyIP, error) { + return p.fetchIPList() +} + +func (p *BrightDataProvider) RefreshIPList() ([]ProxyIP, error) { + return p.fetchIPList() +} + +func (p *BrightDataProvider) fetchIPList() ([]ProxyIP, error) { + // 构建请求URL + url := p.endpoint + if p.zone != "" { + url = fmt.Sprintf("%s?zone=%s", p.endpoint, p.zone) + } + + // 创建HTTP请求 + req, err := http.NewRequest("GET", url, nil) + if err != nil { + return nil, fmt.Errorf("创建HTTP请求失败: %w", err) + } + + // 设置授权头 + if p.token != "" { + req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", p.token)) + } + + // 发送请求 + resp, err := p.client.Do(req) + if err != nil { + return nil, fmt.Errorf("发送HTTP请求失败: %w", err) + } + defer resp.Body.Close() + + // 读取响应体 + body, err := ioutil.ReadAll(resp.Body) + if err != nil { + return nil, fmt.Errorf("读取HTTP响应失败: %w", err) + } + + // 检查状态码 + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("API返回错误状态码 %d: %s", resp.StatusCode, string(body)) + } + + // 解析JSON数据(支持Bright Data格式) + var ipList BrightDataIPList + if err := json.Unmarshal(body, &ipList); err != nil { + return nil, fmt.Errorf("解析JSON数据失败: %w", err) + } + + // 转换为ProxyIP列表 + result := make([]ProxyIP, 0, len(ipList.IPs)) + for _, ip := range ipList.IPs { + result = append(result, ProxyIP{ + IP: ip.IP, + Protocol: "http", + Ext: ip.Ext, + }) + } + + if len(result) == 0 { + return nil, fmt.Errorf("API返回的IP列表为空") + } + + return result, nil +} diff --git a/proxy/fixed_provider.go b/proxy/fixed_provider.go new file mode 100644 index 00000000..267b047e --- /dev/null +++ b/proxy/fixed_provider.go @@ -0,0 +1,42 @@ +package proxy + +import "strings" + +// FixedIPProvider 固定IP列表提供者 +type FixedIPProvider struct { + ips []ProxyIP +} + +// NewFixedIPProvider 创建固定IP列表提供者 +func NewFixedIPProvider(proxyURLs []string) *FixedIPProvider { + ips := make([]ProxyIP, 0, len(proxyURLs)) + for _, proxyURL := range proxyURLs { + // 简单解析代理URL + // 格式: http://ip:port 或 socks5://user:pass@ip:port + protocol := "http" + if strings.HasPrefix(proxyURL, "socks5://") { + protocol = "socks5" + proxyURL = strings.TrimPrefix(proxyURL, "socks5://") + } else if strings.HasPrefix(proxyURL, "http://") { + proxyURL = strings.TrimPrefix(proxyURL, "http://") + } else if strings.HasPrefix(proxyURL, "https://") { + protocol = "https" + proxyURL = strings.TrimPrefix(proxyURL, "https://") + } + + ips = append(ips, ProxyIP{ + IP: proxyURL, + Protocol: protocol, + }) + } + + return &FixedIPProvider{ips: ips} +} + +func (p *FixedIPProvider) GetIPList() ([]ProxyIP, error) { + return p.ips, nil +} + +func (p *FixedIPProvider) RefreshIPList() ([]ProxyIP, error) { + return p.ips, nil +} diff --git a/proxy/provider.go b/proxy/provider.go new file mode 100644 index 00000000..b4d6e06d --- /dev/null +++ b/proxy/provider.go @@ -0,0 +1,10 @@ +package proxy + +// IPProvider IP提供者接口 +type IPProvider interface { + // GetIPList 获取IP列表 + GetIPList() ([]ProxyIP, error) + + // RefreshIPList 刷新IP列表(可选实现) + RefreshIPList() ([]ProxyIP, error) +} diff --git a/proxy/proxy_client.go b/proxy/proxy_client.go new file mode 100644 index 00000000..cda50b00 --- /dev/null +++ b/proxy/proxy_client.go @@ -0,0 +1,47 @@ +package proxy + +import ( + "log" + "net/http" + "time" +) + +// --- 便捷函数(直接使用全局管理器) --- + +// GetProxyHTTPClient 获取代理 HTTP 客户端(返回 ProxyClient,包含 ProxyID) +func GetProxyHTTPClient() (*ProxyClient, error) { + return GetGlobalProxyManager().GetProxyClient() +} + +// NewHTTPClient 创建一个新的HTTP客户端(使用全局代理配置) +// 注意:不返回 ProxyID,如需 ProxyID 请使用 GetProxyHTTPClient() +func NewHTTPClient() *http.Client { + client, err := GetGlobalProxyManager().GetProxyClient() + if err != nil { + log.Printf("⚠️ 获取代理客户端失败,使用直连: %v", err) + return &http.Client{Timeout: 30 * time.Second} + } + return client.Client +} + +// NewHTTPClientWithTimeout 创建一个新的HTTP客户端并指定超时时间 +// 注意:不返回 ProxyID,如需 ProxyID 请使用 GetProxyHTTPClient() +func NewHTTPClientWithTimeout(timeout time.Duration) *http.Client { + client, err := GetGlobalProxyManager().GetProxyClient() + if err != nil { + log.Printf("⚠️ 获取代理客户端失败,使用直连: %v", err) + return &http.Client{Timeout: timeout} + } + client.Client.Timeout = timeout + return client.Client +} + +// GetTransport 获取HTTP Transport +func GetTransport() *http.Transport { + client, err := GetGlobalProxyManager().GetProxyClient() + if err != nil { + log.Printf("⚠️ 获取代理客户端失败,使用直连: %v", err) + return &http.Transport{} + } + return client.Client.Transport.(*http.Transport) +} \ No newline at end of file diff --git a/proxy/proxy_manager.go b/proxy/proxy_manager.go new file mode 100644 index 00000000..aaca00e4 --- /dev/null +++ b/proxy/proxy_manager.go @@ -0,0 +1,346 @@ +package proxy + +import ( + "crypto/tls" + "fmt" + "log" + "math/rand" + "net/http" + "net/url" + "sync" + "time" +) + +// ProxyManager 代理管理器 +type ProxyManager struct { + config *Config + provider IPProvider + + // IP池管理 + ipList []ProxyIP + blacklist map[int]string // ProxyID -> IP + ipBlacklist map[string]int // IP -> 剩余TTL + mutex sync.RWMutex // 读写锁,保证线程安全 + + // 刷新控制 + stopRefresh chan struct{} +} + +var ( + globalProxyManager *ProxyManager + once sync.Once +) + +// InitGlobalProxyManager 初始化全局代理管理器 +func InitGlobalProxyManager(config *Config) error { + var err error + once.Do(func() { + globalProxyManager, err = NewProxyManager(config) + if err == nil && config.Enabled && config.RefreshInterval > 0 { + globalProxyManager.StartAutoRefresh() + } + }) + return err +} + +// GetGlobalProxyManager 获取全局代理管理器 +func GetGlobalProxyManager() *ProxyManager { + if globalProxyManager == nil { + // 如果未初始化,使用默认配置(禁用代理) + _ = InitGlobalProxyManager(&Config{Enabled: false}) + } + return globalProxyManager +} + +// NewProxyManager 创建代理管理器 +func NewProxyManager(config *Config) (*ProxyManager, error) { + if config == nil { + config = &Config{Enabled: false} + } + + // 设置默认值 + if config.Timeout == 0 { + config.Timeout = 30 * time.Second + } + if config.BlacklistTTL == 0 { + config.BlacklistTTL = 5 // 默认 TTL 为 5 次刷新 + } + if config.RefreshInterval == 0 && config.Mode == "brightdata" { + config.RefreshInterval = 30 * time.Minute // 默认 30 分钟刷新一次 + } + + m := &ProxyManager{ + config: config, + blacklist: make(map[int]string), + ipBlacklist: make(map[string]int), + stopRefresh: make(chan struct{}), + } + + // 如果未启用代理,直接返回 + if !config.Enabled { + log.Printf("🌐 HTTP 代理未启用,使用直连") + return m, nil + } + + // 根据模式选择IP提供者 + switch config.Mode { + case "single": + // 单个代理模式 + if config.ProxyURL == "" { + return nil, fmt.Errorf("single模式下必须配置proxy_url") + } + m.provider = NewSingleProxyProvider(config.ProxyURL) + log.Printf("🌐 HTTP 代理已启用 (单代理模式): %s", config.ProxyURL) + + case "pool": + // 代理池模式(固定列表) + if len(config.ProxyList) == 0 { + return nil, fmt.Errorf("pool模式下必须配置proxy_list") + } + m.provider = NewFixedIPProvider(config.ProxyList) + log.Printf("🌐 HTTP 代理已启用 (代理池模式): %d个代理", len(config.ProxyList)) + + case "brightdata": + // Bright Data动态获取模式 + if config.BrightDataEndpoint == "" { + return nil, fmt.Errorf("brightdata模式下必须配置brightdata_endpoint") + } + m.provider = NewBrightDataProvider(config.BrightDataEndpoint, config.BrightDataToken, config.BrightDataZone) + log.Printf("🌐 HTTP 代理已启用 (Bright Data模式): %s", config.BrightDataEndpoint) + + default: + // 默认使用single模式 + if config.ProxyURL == "" { + return nil, fmt.Errorf("未知的proxy模式: %s", config.Mode) + } + m.provider = NewSingleProxyProvider(config.ProxyURL) + log.Printf("🌐 HTTP 代理已启用 (默认模式): %s", config.ProxyURL) + } + + // 初始化IP列表 + if err := m.RefreshIPList(); err != nil { + return nil, fmt.Errorf("初始化IP列表失败: %w", err) + } + + return m, nil +} + +// RefreshIPList 刷新IP列表(线程安全) +func (m *ProxyManager) RefreshIPList() error { + if m.provider == nil { + return nil + } + + ips, err := m.provider.RefreshIPList() + if err != nil { + return err + } + + m.mutex.Lock() + defer m.mutex.Unlock() + + // 清理黑名单,TTL倒计时 + validIPs := make([]ProxyIP, 0, len(ips)) + newBlacklist := make(map[int]string) + + for _, ip := range ips { + if ttl, inBlacklist := m.ipBlacklist[ip.IP]; inBlacklist { + // TTL 倒计时 + m.ipBlacklist[ip.IP] = ttl - 1 + if ttl > 0 { + // 仍在黑名单中,跳过 + continue + } + // TTL 归零,从黑名单移除 + delete(m.ipBlacklist, ip.IP) + log.Printf("✓ 代理IP已从黑名单恢复: %s", ip.IP) + } + validIPs = append(validIPs, ip) + } + + m.ipList = validIPs + m.blacklist = newBlacklist + + log.Printf("✓ 刷新代理IP列表: 总计%d个,黑名单%d个,可用%d个", + len(ips), len(m.ipBlacklist), len(validIPs)) + + return nil +} + +// StartAutoRefresh 启动自动刷新 +func (m *ProxyManager) StartAutoRefresh() { + if m.config.RefreshInterval <= 0 { + return + } + + go func() { + ticker := time.NewTicker(m.config.RefreshInterval) + defer ticker.Stop() + + for { + select { + case <-ticker.C: + if err := m.RefreshIPList(); err != nil { + log.Printf("⚠️ 自动刷新IP列表失败: %v", err) + } + case <-m.stopRefresh: + return + } + } + }() + + log.Printf("✓ 已启动代理IP自动刷新 (间隔: %v)", m.config.RefreshInterval) +} + +// StopAutoRefresh 停止自动刷新 +func (m *ProxyManager) StopAutoRefresh() { + close(m.stopRefresh) +} + +// getRandomProxy 随机获取一个可用代理(线程安全 - 读锁,确保不越界) +func (m *ProxyManager) getRandomProxy() (int, *ProxyIP, error) { + m.mutex.RLock() + defer m.mutex.RUnlock() + + if len(m.ipList) == 0 { + return -1, nil, fmt.Errorf("代理IP列表为空") + } + + // 找到所有未被黑名单的索引 + availableIndices := make([]int, 0, len(m.ipList)) + for i := range m.ipList { + if _, inBlacklist := m.blacklist[i]; !inBlacklist { + availableIndices = append(availableIndices, i) + } + } + + if len(availableIndices) == 0 { + return -1, nil, fmt.Errorf("所有代理IP都在黑名单中") + } + + // 随机选择一个(确保不越界) + randomIdx := availableIndices[rand.Intn(len(availableIndices))] + + // 二次检查,确保索引有效(防御性编程) + if randomIdx < 0 || randomIdx >= len(m.ipList) { + return -1, nil, fmt.Errorf("代理索引越界: %d (总数: %d)", randomIdx, len(m.ipList)) + } + + return randomIdx, &m.ipList[randomIdx], nil +} + +// buildProxyURL 构建代理URL +func (m *ProxyManager) buildProxyURL(ip *ProxyIP) string { + if m.config.ProxyHost != "" && m.config.ProxyUser != "" { + // 使用配置的代理主机和认证信息 + user := m.config.ProxyUser + if m.config.ProxyUser != "" && ip.IP != "" { + // 支持%s占位符替换IP + user = fmt.Sprintf(m.config.ProxyUser, ip.IP) + } + + protocol := ip.Protocol + if protocol == "" { + protocol = "http" + } + + if m.config.ProxyPassword != "" { + return fmt.Sprintf("%s://%s:%s@%s", protocol, user, m.config.ProxyPassword, m.config.ProxyHost) + } + return fmt.Sprintf("%s://%s@%s", protocol, user, m.config.ProxyHost) + } + + // 直接使用IP信息 + return ip.IP +} + +// GetProxyClient 获取代理客户端(线程安全) +func (m *ProxyManager) GetProxyClient() (*ProxyClient, error) { + if !m.config.Enabled { + // 未启用代理,返回普通HTTP客户端 + return &ProxyClient{ + ProxyID: -1, // -1 表示未使用代理 + IP: "direct", + Client: &http.Client{ + Timeout: m.config.Timeout, + }, + }, nil + } + + // 获取随机代理(使用读锁,确保不越界) + proxyID, proxyIP, err := m.getRandomProxy() + if err != nil { + return nil, err + } + + // 构建代理URL + proxyURLStr := m.buildProxyURL(proxyIP) + proxyURL, err := url.Parse(proxyURLStr) + if err != nil { + return nil, fmt.Errorf("解析代理URL失败: %w", err) + } + + // 创建Transport + transport := &http.Transport{ + Proxy: http.ProxyURL(proxyURL), + TLSClientConfig: &tls.Config{ + InsecureSkipVerify: false, + }, + MaxIdleConns: 100, + MaxIdleConnsPerHost: 10, + IdleConnTimeout: 90 * time.Second, + } + + return &ProxyClient{ + ProxyID: proxyID, + IP: proxyIP.IP, + Client: &http.Client{ + Transport: transport, + Timeout: m.config.Timeout, + }, + }, nil +} + +// AddBlacklist 将代理IP添加到黑名单(线程安全 - 写锁) +func (m *ProxyManager) AddBlacklist(proxyID int) { + m.mutex.Lock() + defer m.mutex.Unlock() + + // 检查 proxyID 有效性,防止越界 + if proxyID < 0 || proxyID >= len(m.ipList) { + log.Printf("⚠️ 无效的 ProxyID: %d (有效范围: 0-%d)", proxyID, len(m.ipList)-1) + return + } + + ip := m.ipList[proxyID].IP + m.blacklist[proxyID] = ip + m.ipBlacklist[ip] = m.config.BlacklistTTL + + log.Printf("⚠️ 代理IP已加入黑名单: %s (ProxyID: %d, TTL: %d)", ip, proxyID, m.config.BlacklistTTL) +} + +// GetBlacklistStatus 获取黑名单状态(线程安全 - 读锁) +func (m *ProxyManager) GetBlacklistStatus() (total int, blacklisted int, available int) { + m.mutex.RLock() + defer m.mutex.RUnlock() + + total = len(m.ipList) + blacklisted = len(m.ipBlacklist) + available = total - len(m.blacklist) + return +} + +// IsEnabled 检查代理是否启用 +func IsEnabled() bool { + return GetGlobalProxyManager().config.Enabled +} + +// RefreshIPList 刷新全局代理IP列表 +func RefreshIPList() error { + return GetGlobalProxyManager().RefreshIPList() +} + +// AddBlacklist 将代理IP添加到全局黑名单 +func AddBlacklist(proxyID int) { + GetGlobalProxyManager().AddBlacklist(proxyID) +} diff --git a/proxy/single_provider.go b/proxy/single_provider.go new file mode 100644 index 00000000..bbea9fce --- /dev/null +++ b/proxy/single_provider.go @@ -0,0 +1,19 @@ +package proxy + +// SingleProxyProvider 单个代理提供者(不使用IP池) +type SingleProxyProvider struct { + proxyURL string +} + +// NewSingleProxyProvider 创建单个代理提供者 +func NewSingleProxyProvider(proxyURL string) *SingleProxyProvider { + return &SingleProxyProvider{proxyURL: proxyURL} +} + +func (p *SingleProxyProvider) GetIPList() ([]ProxyIP, error) { + return []ProxyIP{{IP: p.proxyURL}}, nil +} + +func (p *SingleProxyProvider) RefreshIPList() ([]ProxyIP, error) { + return p.GetIPList() +} diff --git a/proxy/types.go b/proxy/types.go new file mode 100644 index 00000000..89678c86 --- /dev/null +++ b/proxy/types.go @@ -0,0 +1,40 @@ +package proxy + +import ( + "net/http" + "time" +) + +// ProxyIP 代理IP信息 +type ProxyIP struct { + IP string `json:"ip"` // IP地址 + Port string `json:"port"` // 端口(可选) + Username string `json:"username"` // 用户名(可选) + Password string `json:"password"` // 密码(可选) + Protocol string `json:"protocol"` // 协议: http, https, socks5 + Ext map[string]interface{} `json:"ext"` // 扩展信息 +} + +// ProxyClient 代理客户端 +type ProxyClient struct { + ProxyID int // IP池中的代理ID(索引) + IP string // 使用的IP地址 + *http.Client // HTTP客户端 +} + +// Config 代理配置 +type Config struct { + Enabled bool // 是否启用代理 + Mode string // 模式: "single", "pool", "brightdata" + Timeout time.Duration // 超时时间 + ProxyURL string // 单个代理地址 (single模式) + ProxyList []string // 代理列表 (pool模式) + BrightDataEndpoint string // Bright Data接口地址 (brightdata模式) + BrightDataToken string // Bright Data访问令牌 (brightdata模式) + BrightDataZone string // Bright Data区域 (brightdata模式) + ProxyHost string // 代理主机 + ProxyUser string // 代理用户名模板(支持%s占位符) + ProxyPassword string // 代理密码 + RefreshInterval time.Duration // IP列表刷新间隔 + BlacklistTTL int // 黑名单IP的TTL(刷新次数) +} diff --git a/generate_beta_code.sh b/scripts/generate_beta_code.sh similarity index 96% rename from generate_beta_code.sh rename to scripts/generate_beta_code.sh index cee2ca3a..228d256f 100755 --- a/generate_beta_code.sh +++ b/scripts/generate_beta_code.sh @@ -1,5 +1,12 @@ #!/bin/bash +# Fail fast and normalize working directory +set -e + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +ROOT_DIR="$(cd "$SCRIPT_DIR/.." && pwd)" +cd "$ROOT_DIR" + # 内测码生成脚本 # 生成6位不重复的内测码并写入 beta_codes.txt @@ -218,4 +225,4 @@ if [ ! -s "$BETA_CODES_FILE" ] || [ $(wc -l < "$BETA_CODES_FILE") -eq ${#new_cod echo "- 长度: $CODE_LENGTH 位" echo "- 字符集: 数字 2-9, 小写字母 a-z (排除 0,1,i,l,o 避免混淆)" echo "- 每个内测码唯一且不重复" -fi \ No newline at end of file +fi diff --git a/scripts/generate_rsa_keys/main.go b/scripts/generate_rsa_keys/main.go new file mode 100644 index 00000000..c3f642e2 --- /dev/null +++ b/scripts/generate_rsa_keys/main.go @@ -0,0 +1,76 @@ +package main + +import ( + "crypto/rand" + "crypto/rsa" + "crypto/x509" + "encoding/pem" + "fmt" + "io/ioutil" + "os" + "path/filepath" +) + +func main() { + keysDir := "keys" + if err := os.MkdirAll(keysDir, 0700); err != nil { + fmt.Printf("创建keys目录失败: %v\n", err) + return + } + + privateKeyPath := filepath.Join(keysDir, "rsa_private.key") + publicKeyPath := filepath.Join(keysDir, "rsa_private.key.pub") + + if _, err := os.Stat(privateKeyPath); err == nil { + fmt.Println("RSA密钥对已存在:") + fmt.Printf(" 私钥: %s\n", privateKeyPath) + fmt.Printf(" 公钥: %s\n", publicKeyPath) + + publicKeyPEM, err := ioutil.ReadFile(publicKeyPath) + if err == nil { + fmt.Println("\n公钥内容:") + fmt.Println(string(publicKeyPEM)) + } + return + } + + fmt.Println("生成新的RSA密钥对...") + privateKey, err := rsa.GenerateKey(rand.Reader, 2048) + if err != nil { + fmt.Printf("生成RSA密钥失败: %v\n", err) + return + } + + privateKeyPEM := pem.EncodeToMemory(&pem.Block{ + Type: "RSA PRIVATE KEY", + Bytes: x509.MarshalPKCS1PrivateKey(privateKey), + }) + + if err := ioutil.WriteFile(privateKeyPath, privateKeyPEM, 0600); err != nil { + fmt.Printf("保存私钥失败: %v\n", err) + return + } + + publicKeyDER, err := x509.MarshalPKIXPublicKey(&privateKey.PublicKey) + if err != nil { + fmt.Printf("编码公钥失败: %v\n", err) + return + } + + publicKeyPEM := pem.EncodeToMemory(&pem.Block{ + Type: "PUBLIC KEY", + Bytes: publicKeyDER, + }) + + if err := ioutil.WriteFile(publicKeyPath, publicKeyPEM, 0644); err != nil { + fmt.Printf("保存公钥失败: %v\n", err) + return + } + + fmt.Println("✓ RSA密钥对生成成功!") + fmt.Printf(" 私钥: %s\n", privateKeyPath) + fmt.Printf(" 公钥: %s\n", publicKeyPath) + fmt.Println("\n公钥内容(可用于前端配置):") + fmt.Println(string(publicKeyPEM)) + fmt.Println("\n注意: 请妥善保管私钥文件,不要提交到版本控制系统中!") +} diff --git a/scripts/import_beta_codes.sh b/scripts/import_beta_codes.sh new file mode 100755 index 00000000..dab961ae --- /dev/null +++ b/scripts/import_beta_codes.sh @@ -0,0 +1,87 @@ +#!/bin/bash + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +ROOT_DIR="$(cd "$SCRIPT_DIR/.." && pwd)" +cd "$ROOT_DIR" + +echo "🎟️ 导入 beta_codes.txt 到 PostgreSQL" + +if [ ! -f "beta_codes.txt" ]; then + echo "❌ 找不到 beta_codes.txt 文件" + exit 1 +fi + +if command -v "docker-compose" &> /dev/null; then + DOCKER_CMD="docker-compose" +elif command -v "docker" &> /dev/null && docker compose version &> /dev/null; then + DOCKER_CMD="docker compose" +else + echo "❌ 错误:找不到 docker-compose 或 docker compose 命令" + exit 1 +fi + +ENV_FILE=".env" +if [ -f "$ENV_FILE" ]; then + echo "📁 加载 .env 配置..." + set -a + # shellcheck disable=SC1090 + source "$ENV_FILE" + set +a +else + echo "⚠️ 未找到 .env 文件,使用默认数据库配置" +fi + +POSTGRES_HOST=${POSTGRES_HOST:-postgres} +POSTGRES_PORT=${POSTGRES_PORT:-5432} +POSTGRES_DB=${POSTGRES_DB:-nofx} +POSTGRES_USER=${POSTGRES_USER:-nofx} +POSTGRES_PASSWORD=${POSTGRES_PASSWORD:-} +POSTGRES_SERVICE=${POSTGRES_SERVICE:-postgres} +POSTGRES_CONTAINER_NAME=${POSTGRES_CONTAINER_NAME:-nofx-postgres} + +POSTGRES_CONTAINER=$($DOCKER_CMD ps -q "$POSTGRES_SERVICE" 2>/dev/null || true) +if [ -z "$POSTGRES_CONTAINER" ]; then + POSTGRES_CONTAINER=$(docker ps -q --filter "name=$POSTGRES_CONTAINER_NAME" | head -n 1) +fi + +if [ -z "$POSTGRES_CONTAINER" ]; then + echo "❌ 找不到 PostgreSQL 容器 (${POSTGRES_SERVICE}/${POSTGRES_CONTAINER_NAME})" + echo "💡 请确认数据库服务已启动" + exit 1 +fi + +PG_ENV_ARGS=() +if [ -n "$POSTGRES_PASSWORD" ]; then + PG_ENV_ARGS=(--env "PGPASSWORD=$POSTGRES_PASSWORD") +fi + +SQL_PAYLOAD=$(python3 - <<'PY' +from pathlib import Path + +codes = [] +for line in Path('beta_codes.txt').read_text(encoding='utf-8').splitlines(): + code = line.strip() + if code and not code.startswith('#'): + codes.append(f"('{code}')") + +if codes: + values = ",\n".join(codes) + print(f"INSERT INTO beta_codes (code) VALUES\n{values}\nON CONFLICT (code) DO NOTHING;") +PY +) + +if [ -z "$SQL_PAYLOAD" ]; then + echo "⚠️ beta_codes.txt 中没有有效的内测码,已跳过导入" + exit 0 +fi + +TOTAL_CODES=$(grep -vc '^\s*$' beta_codes.txt || true) +echo "📊 检测到 $TOTAL_CODES 条内测码记录" + +echo "🔄 导入到数据库..." +printf '%s\n' "$SQL_PAYLOAD" | docker exec -i "${PG_ENV_ARGS[@]}" "$POSTGRES_CONTAINER" \ + psql -v ON_ERROR_STOP=1 --pset pager=off -U "$POSTGRES_USER" -d "$POSTGRES_DB" + +echo "✅ 导入完成(重复的已跳过)" diff --git a/scripts/import_default_patch.sh b/scripts/import_default_patch.sh new file mode 100755 index 00000000..ef971edd --- /dev/null +++ b/scripts/import_default_patch.sh @@ -0,0 +1,160 @@ +#!/bin/bash + +set -euo pipefail + +echo "🔧 同步默认用户与基础配置" +echo "===============================" + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +ROOT_DIR="$(cd "$SCRIPT_DIR/.." && pwd)" +cd "$ROOT_DIR" + +# 检测 Docker Compose 命令 +if command -v docker-compose &> /dev/null; then + DOCKER_COMPOSE_CMD="docker-compose" +elif docker compose version &> /dev/null; then + DOCKER_COMPOSE_CMD="docker compose" +else + echo "❌ 无法找到 docker-compose 或 docker compose 命令" + exit 1 +fi + +echo "📋 使用命令: $DOCKER_COMPOSE_CMD" + +# 加载 .env 配置 +ENV_FILE=".env" +if [ -f "$ENV_FILE" ]; then + echo "📁 加载 .env ..." + set -a + # shellcheck disable=SC1090 + source "$ENV_FILE" + set +a +else + echo "⚠️ 未找到 .env,使用默认数据库配置" +fi + +POSTGRES_HOST=${POSTGRES_HOST:-postgres} +POSTGRES_PORT=${POSTGRES_PORT:-5432} +POSTGRES_DB=${POSTGRES_DB:-nofx} +POSTGRES_USER=${POSTGRES_USER:-nofx} +POSTGRES_PASSWORD=${POSTGRES_PASSWORD:-} +POSTGRES_SERVICE=${POSTGRES_SERVICE:-postgres} +POSTGRES_CONTAINER_NAME=${POSTGRES_CONTAINER_NAME:-nofx-postgres} + +# 查找 PostgreSQL 容器 +POSTGRES_CONTAINER=$($DOCKER_COMPOSE_CMD ps -q "$POSTGRES_SERVICE" 2>/dev/null || true) +if [ -z "$POSTGRES_CONTAINER" ]; then + POSTGRES_CONTAINER=$(docker ps -q --filter "name=$POSTGRES_CONTAINER_NAME" | head -n 1) +fi + +if [ -z "$POSTGRES_CONTAINER" ]; then + echo "❌ 未找到 PostgreSQL 容器 (${POSTGRES_SERVICE}/${POSTGRES_CONTAINER_NAME})" + echo "💡 请先启动数据库容器: $DOCKER_COMPOSE_CMD up -d postgres" + exit 1 +fi + +PG_ENV_ARGS=() +if [ -n "$POSTGRES_PASSWORD" ]; then + PG_ENV_ARGS=(-e "PGPASSWORD=$POSTGRES_PASSWORD") +fi + +echo "🔌 检查数据库连接..." +if ! docker exec "${PG_ENV_ARGS[@]}" "$POSTGRES_CONTAINER" pg_isready -U "$POSTGRES_USER" -d "$POSTGRES_DB" > /dev/null 2>&1; then + echo "❌ 无法连接到 PostgreSQL,请确认容器和凭据" + exit 1 +fi + +echo +read -p "确认写入默认账号和基础配置? (y/N): " confirm +if [[ $confirm != [yY] ]]; then + echo "ℹ️ 已取消操作" + exit 0 +fi + +echo "🚀 执行初始化 SQL..." +if docker exec -i "${PG_ENV_ARGS[@]}" "$POSTGRES_CONTAINER" \ + psql -v ON_ERROR_STOP=1 -U "$POSTGRES_USER" -d "$POSTGRES_DB" <<'SQL' +-- 确保 traders 表存在 custom_coins 字段 +DO $$ +BEGIN + IF NOT EXISTS ( + SELECT 1 FROM information_schema.columns + WHERE table_name = 'traders' AND column_name = 'custom_coins' + ) THEN + ALTER TABLE traders ADD COLUMN custom_coins TEXT DEFAULT ''; + END IF; +END +$$; + +-- 创建 default 用户 +INSERT INTO users (id, email, password_hash, otp_secret, otp_verified, created_at, updated_at) +VALUES ('default', 'default@localhost', '', '', true, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP) +ON CONFLICT (id) DO UPDATE + SET email = EXCLUDED.email, + updated_at = CURRENT_TIMESTAMP; + +-- 默认 AI 模型配置 +INSERT INTO ai_models (id, user_id, name, provider, enabled, api_key, custom_api_url, custom_model_name, created_at, updated_at) VALUES +('deepseek', 'default', 'DeepSeek', 'deepseek', false, '', '', '', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP), +('qwen', 'default', 'Qwen', 'qwen', false, '', '', '', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP) +ON CONFLICT (id) DO UPDATE + SET user_id = EXCLUDED.user_id, + name = EXCLUDED.name, + provider = EXCLUDED.provider, + enabled = EXCLUDED.enabled, + api_key = EXCLUDED.api_key, + custom_api_url = EXCLUDED.custom_api_url, + custom_model_name = EXCLUDED.custom_model_name, + updated_at = CURRENT_TIMESTAMP; + +-- 默认交易所配置 +INSERT INTO exchanges (id, user_id, name, type, enabled, api_key, secret_key, testnet, + hyperliquid_wallet_addr, aster_user, aster_signer, aster_private_key, + created_at, updated_at) VALUES +('binance', 'default', 'Binance Futures', 'binance', false, '', '', false, '', '', '', '', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP), +('hyperliquid', 'default', 'Hyperliquid', 'hyperliquid', false, '', '', false, '', '', '', '', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP), +('aster', 'default', 'Aster DEX', 'aster', false, '', '', false, '', '', '', '', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP) +ON CONFLICT (id, user_id) DO UPDATE + SET name = EXCLUDED.name, + type = EXCLUDED.type, + enabled = EXCLUDED.enabled, + api_key = EXCLUDED.api_key, + secret_key = EXCLUDED.secret_key, + testnet = EXCLUDED.testnet, + hyperliquid_wallet_addr = EXCLUDED.hyperliquid_wallet_addr, + aster_user = EXCLUDED.aster_user, + aster_signer = EXCLUDED.aster_signer, + aster_private_key = EXCLUDED.aster_private_key, + updated_at = CURRENT_TIMESTAMP; + +-- 默认系统配置(不存在时写入) +INSERT INTO system_config (key, value) VALUES +('beta_mode', 'false'), +('api_server_port', '8080'), +('use_default_coins', 'true'), +('default_coins', '["BTCUSDT","ETHUSDT","SOLUSDT","BNBUSDT","XRPUSDT","DOGEUSDT","ADAUSDT","HYPEUSDT"]'), +('max_daily_loss', '10.0'), +('max_drawdown', '20.0'), +('stop_trading_minutes', '60'), +('btc_eth_leverage', '5'), +('altcoin_leverage', '5'), +('jwt_secret', '') +ON CONFLICT (key) DO NOTHING; + +-- 输出校验信息 +SELECT 'default_user' AS item, COUNT(*) AS count FROM users WHERE id = 'default' +UNION ALL +SELECT 'default_ai_models', COUNT(*) FROM ai_models WHERE user_id = 'default' +UNION ALL +SELECT 'default_exchanges', COUNT(*) FROM exchanges WHERE user_id = 'default'; +SQL +then + echo + echo "✅ 默认数据写入完成" +else + echo + echo "❌ 数据写入失败" + exit 1 +fi + +echo "🎉 操作完成" diff --git a/scripts/migrate_sensitive_data/main.go b/scripts/migrate_sensitive_data/main.go new file mode 100644 index 00000000..f5db9a3b --- /dev/null +++ b/scripts/migrate_sensitive_data/main.go @@ -0,0 +1,367 @@ +package main + +import ( + "bufio" + "database/sql" + "flag" + "fmt" + "log" + "nofx/crypto" + "os" + "strings" + "time" + + _ "github.com/lib/pq" +) + +func main() { + privateKeyPath := flag.String("key", "keys/rsa_private.key", "RSA 私钥路径") + dryRun := flag.Bool("dry-run", false, "仅检查需要迁移的数据,不写入数据库") + flag.Parse() + + // 尝试加载 .env 文件(从项目根目录运行时) + envPaths := []string{ + ".env", // 项目根目录 + } + envLoaded := false + for _, envPath := range envPaths { + if err := loadEnvFile(envPath); err == nil { + log.Printf("成功加载 .env 文件: %s", envPath) + envLoaded = true + break + } + } + if !envLoaded { + log.Printf("警告: 未找到 .env 文件,请确保在项目根目录存在 .env 文件") + log.Printf("尝试的路径: %v", envPaths) + } + + // 确保环境变量已设置 + if os.Getenv("DATA_ENCRYPTION_KEY") == "" { + log.Fatalf("迁移失败: DATA_ENCRYPTION_KEY 环境变量未设置") + } + + if err := run(*privateKeyPath, *dryRun); err != nil { + log.Fatalf("迁移失败: %v", err) + } +} + +func run(privateKeyPath string, dryRun bool) error { + log.SetFlags(0) + + // 尝试多个可能的私钥路径(从项目根目录运行时) + keyPaths := []string{ + privateKeyPath, // 用户指定的路径 + "keys/rsa_private.key", // 项目根目录的 keys 文件夹 + } + + var finalKeyPath string + for _, path := range keyPaths { + if _, err := os.Stat(path); err == nil { + finalKeyPath = path + log.Printf("找到私钥文件: %s", path) + break + } + } + + if finalKeyPath == "" { + finalKeyPath = privateKeyPath // 使用默认路径,让 crypto 服务生成新密钥 + log.Printf("警告: 私钥文件不存在,将使用路径: %s, 系统将尝试生成新密钥", finalKeyPath) + } + + cryptoService, err := crypto.NewCryptoService(finalKeyPath) + if err != nil { + return fmt.Errorf("初始化加密服务失败: %w", err) + } + + db, err := openPostgres() + if err != nil { + return fmt.Errorf("连接数据库失败: %w", err) + } + defer db.Close() + + log.Printf("开始迁移 AI 模型密钥 (dry-run=%v)", dryRun) + if err := migrateAIModels(db, cryptoService, dryRun); err != nil { + return fmt.Errorf("迁移 AI 模型失败: %w", err) + } + + log.Printf("开始迁移交易所密钥 (dry-run=%v)", dryRun) + if err := migrateExchanges(db, cryptoService, dryRun); err != nil { + return fmt.Errorf("迁移交易所失败: %w", err) + } + + log.Printf("✓ 敏感数据迁移完成") + return nil +} + +func openPostgres() (*sql.DB, error) { + host := getEnv("POSTGRES_HOST", "localhost") + // 如果是 Docker 服务名,替换为 localhost + if host == "postgres" { + host = "localhost" + } + port := getEnv("POSTGRES_PORT", "5432") + dbname := getEnv("POSTGRES_DB", "nofx") + user := getEnv("POSTGRES_USER", "nofx") + password := getEnv("POSTGRES_PASSWORD", "nofx123456") + + dsn := fmt.Sprintf("host=%s port=%s user=%s password=%s dbname=%s sslmode=disable", + host, port, user, password, dbname) + + db, err := sql.Open("postgres", dsn) + if err != nil { + return nil, err + } + + db.SetMaxOpenConns(5) + db.SetMaxIdleConns(2) + db.SetConnMaxLifetime(5 * time.Minute) + + if err := db.Ping(); err != nil { + db.Close() + return nil, err + } + + return db, nil +} + +func migrateAIModels(db *sql.DB, cryptoService *crypto.CryptoService, dryRun bool) error { + type record struct { + ID string + UserID string + APIKey string + } + + rows, err := db.Query(` + SELECT id, user_id, COALESCE(api_key, '') + FROM ai_models + WHERE COALESCE(deleted, FALSE) = FALSE + `) + if err != nil { + return err + } + defer rows.Close() + + var records []record + for rows.Next() { + var r record + if err := rows.Scan(&r.ID, &r.UserID, &r.APIKey); err != nil { + return err + } + records = append(records, r) + } + if err := rows.Err(); err != nil { + return err + } + + var updated int + for _, r := range records { + if r.APIKey == "" || cryptoService.IsEncryptedStorageValue(r.APIKey) { + continue + } + + encrypted, err := cryptoService.EncryptForStorage(r.APIKey, r.UserID, r.ID, "api_key") + if err != nil { + return fmt.Errorf("加密 AI 模型 %s (%s) 失败: %w", r.ID, r.UserID, err) + } + + updated++ + if dryRun { + log.Printf("[DRY-RUN] AI 模型 %s (%s) 将被加密", r.ID, r.UserID) + continue + } + + if _, err := db.Exec(` + UPDATE ai_models + SET api_key = $1, updated_at = CURRENT_TIMESTAMP + WHERE id = $2 AND user_id = $3 + `, encrypted, r.ID, r.UserID); err != nil { + return fmt.Errorf("更新 AI 模型 %s (%s) 失败: %w", r.ID, r.UserID, err) + } + } + + log.Printf("AI 模型处理完成,需更新 %d 条记录", updated) + return nil +} + +func migrateExchanges(db *sql.DB, cryptoService *crypto.CryptoService, dryRun bool) error { + type record struct { + ID string + UserID string + APIKey string + SecretKey string + HyperliquidWallet string + AsterUser string + AsterSigner string + AsterPrivateKey string + } + + rows, err := db.Query(` + SELECT id, user_id, + COALESCE(api_key, '') AS api_key, + COALESCE(secret_key, '') AS secret_key, + COALESCE(hyperliquid_wallet_addr, '') AS hyperliquid_wallet_addr, + COALESCE(aster_user, '') AS aster_user, + COALESCE(aster_signer, '') AS aster_signer, + COALESCE(aster_private_key, '') AS aster_private_key + FROM exchanges + WHERE COALESCE(deleted, FALSE) = FALSE + `) + if err != nil { + return err + } + defer rows.Close() + + var records []record + for rows.Next() { + var r record + if err := rows.Scan( + &r.ID, &r.UserID, + &r.APIKey, &r.SecretKey, + &r.HyperliquidWallet, + &r.AsterUser, &r.AsterSigner, &r.AsterPrivateKey, + ); err != nil { + return err + } + records = append(records, r) + } + if err := rows.Err(); err != nil { + return err + } + + var updated int + for _, r := range records { + newAPIKey := r.APIKey + newSecretKey := r.SecretKey + newHyper := r.HyperliquidWallet + newAsterUser := r.AsterUser + newAsterSigner := r.AsterSigner + newAsterPrivate := r.AsterPrivateKey + + changed := false + + if r.APIKey != "" && !cryptoService.IsEncryptedStorageValue(r.APIKey) { + enc, err := cryptoService.EncryptForStorage(r.APIKey, r.UserID, r.ID, "api_key") + if err != nil { + return fmt.Errorf("加密交易所 API Key 失败: %s (%s): %w", r.ID, r.UserID, err) + } + newAPIKey = enc + changed = true + } + if r.SecretKey != "" && !cryptoService.IsEncryptedStorageValue(r.SecretKey) { + enc, err := cryptoService.EncryptForStorage(r.SecretKey, r.UserID, r.ID, "secret_key") + if err != nil { + return fmt.Errorf("加密交易所 Secret Key 失败: %s (%s): %w", r.ID, r.UserID, err) + } + newSecretKey = enc + changed = true + } + if r.HyperliquidWallet != "" && !cryptoService.IsEncryptedStorageValue(r.HyperliquidWallet) { + enc, err := cryptoService.EncryptForStorage(r.HyperliquidWallet, r.UserID, r.ID, "hyperliquid_wallet_addr") + if err != nil { + return fmt.Errorf("加密 Hyperliquid 地址失败: %s (%s): %w", r.ID, r.UserID, err) + } + newHyper = enc + changed = true + } + if r.AsterUser != "" && !cryptoService.IsEncryptedStorageValue(r.AsterUser) { + enc, err := cryptoService.EncryptForStorage(r.AsterUser, r.UserID, r.ID, "aster_user") + if err != nil { + return fmt.Errorf("加密 Aster 用户失败: %s (%s): %w", r.ID, r.UserID, err) + } + newAsterUser = enc + changed = true + } + if r.AsterSigner != "" && !cryptoService.IsEncryptedStorageValue(r.AsterSigner) { + enc, err := cryptoService.EncryptForStorage(r.AsterSigner, r.UserID, r.ID, "aster_signer") + if err != nil { + return fmt.Errorf("加密 Aster Signer 失败: %s (%s): %w", r.ID, r.UserID, err) + } + newAsterSigner = enc + changed = true + } + if r.AsterPrivateKey != "" && !cryptoService.IsEncryptedStorageValue(r.AsterPrivateKey) { + enc, err := cryptoService.EncryptForStorage(r.AsterPrivateKey, r.UserID, r.ID, "aster_private_key") + if err != nil { + return fmt.Errorf("加密 Aster 私钥失败: %s (%s): %w", r.ID, r.UserID, err) + } + newAsterPrivate = enc + changed = true + } + + if !changed { + continue + } + + updated++ + if dryRun { + log.Printf("[DRY-RUN] 交易所 %s (%s) 将被加密", r.ID, r.UserID) + continue + } + + if _, err := db.Exec(` + UPDATE exchanges + SET api_key = $1, + secret_key = $2, + hyperliquid_wallet_addr = $3, + aster_user = $4, + aster_signer = $5, + aster_private_key = $6, + updated_at = CURRENT_TIMESTAMP + WHERE id = $7 AND user_id = $8 + `, newAPIKey, newSecretKey, newHyper, newAsterUser, newAsterSigner, newAsterPrivate, r.ID, r.UserID); err != nil { + return fmt.Errorf("更新交易所 %s (%s) 失败: %w", r.ID, r.UserID, err) + } + } + + log.Printf("交易所处理完成,需更新 %d 条记录", updated) + return nil +} + +func getEnv(key, fallback string) string { + if val := os.Getenv(key); val != "" { + return val + } + return fallback +} + +func loadEnvFile(filename string) error { + // 检查文件是否存在 + if _, err := os.Stat(filename); os.IsNotExist(err) { + return fmt.Errorf("文件不存在: %s", filename) + } + + // 打开文件 + file, err := os.Open(filename) + if err != nil { + return fmt.Errorf("无法打开文件: %w", err) + } + defer file.Close() + + // 逐行读取 + scanner := bufio.NewScanner(file) + for scanner.Scan() { + line := strings.TrimSpace(scanner.Text()) + + // 跳过空行和注释行 + if line == "" || strings.HasPrefix(line, "#") { + continue + } + + // 解析 KEY=VALUE 格式 + parts := strings.SplitN(line, "=", 2) + if len(parts) != 2 { + continue + } + + key := strings.TrimSpace(parts[0]) + value := strings.TrimSpace(parts[1]) + + // 只有当环境变量不存在时才设置 + if os.Getenv(key) == "" { + os.Setenv(key, value) + } + } + + return scanner.Err() +} diff --git a/start.sh b/scripts/start.sh similarity index 100% rename from start.sh rename to scripts/start.sh diff --git a/scripts/view_pg_data.sh b/scripts/view_pg_data.sh new file mode 100755 index 00000000..d6c1f06d --- /dev/null +++ b/scripts/view_pg_data.sh @@ -0,0 +1,87 @@ +#!/bin/bash + +set -euo pipefail + +# 保证从仓库根目录运行 +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +ROOT_DIR="$(cd "$SCRIPT_DIR/.." && pwd)" +cd "$ROOT_DIR" + +# PostgreSQL数据查看工具 +echo "🔍 PostgreSQL 数据查看工具" +echo "==========================" + +# 检测Docker Compose命令 +DOCKER_COMPOSE_CMD="" +if command -v "docker-compose" &> /dev/null; then + DOCKER_COMPOSE_CMD="docker-compose" +elif command -v "docker" &> /dev/null && docker compose version &> /dev/null; then + DOCKER_COMPOSE_CMD="docker compose" +else + echo "❌ 错误:找不到 docker-compose 或 docker compose 命令" + exit 1 +fi + +# 加载数据库配置 +ENV_FILE=".env" +if [ -f "$ENV_FILE" ]; then + echo "📁 加载 .env 配置..." + set -a + # shellcheck disable=SC1090 + source "$ENV_FILE" + set +a +else + echo "⚠️ 未找到 .env 文件,使用默认数据库配置" +fi + +POSTGRES_HOST=${POSTGRES_HOST:-postgres} +POSTGRES_PORT=${POSTGRES_PORT:-5432} +POSTGRES_DB=${POSTGRES_DB:-nofx} +POSTGRES_USER=${POSTGRES_USER:-nofx} +POSTGRES_PASSWORD=${POSTGRES_PASSWORD:-} +POSTGRES_SERVICE=${POSTGRES_SERVICE:-postgres} +POSTGRES_CONTAINER_NAME=${POSTGRES_CONTAINER_NAME:-nofx-postgres} + +# 获取 PostgreSQL 容器 ID +POSTGRES_CONTAINER=$($DOCKER_COMPOSE_CMD ps -q "$POSTGRES_SERVICE" 2>/dev/null || true) +if [ -z "$POSTGRES_CONTAINER" ]; then + POSTGRES_CONTAINER=$(docker ps -q --filter "name=$POSTGRES_CONTAINER_NAME" | head -n 1) +fi + +if [ -z "$POSTGRES_CONTAINER" ]; then + echo "❌ 找不到 PostgreSQL 容器 (${POSTGRES_SERVICE}/${POSTGRES_CONTAINER_NAME})" + echo "💡 请确认数据库服务已启动" + exit 1 +fi + +PG_ENV_ARGS=() +if [ -n "$POSTGRES_PASSWORD" ]; then + PG_ENV_ARGS=(--env "PGPASSWORD=$POSTGRES_PASSWORD") +fi + +run_psql() { + local sql="$1" + docker exec -i "${PG_ENV_ARGS[@]}" "$POSTGRES_CONTAINER" \ + psql -v ON_ERROR_STOP=1 --pset pager=off -U "$POSTGRES_USER" -d "$POSTGRES_DB" -c "$sql" +} + +echo "📋 数据库容器: $POSTGRES_CONTAINER" +echo "📋 连接参数: $POSTGRES_HOST:${POSTGRES_PORT}/$POSTGRES_DB (user: $POSTGRES_USER)" + +echo "📊 数据库概览:" +run_psql "SELECT relname AS \"表名\", n_live_tup AS \"记录数\" FROM pg_stat_user_tables WHERE n_live_tup > 0 ORDER BY relname;" + +echo -e "\n🤖 AI模型配置:" +run_psql "SELECT id, name, provider, enabled, CASE WHEN api_key != '' THEN '已配置' ELSE '未配置' END AS api_key_status FROM ai_models ORDER BY id;" + +echo -e "\n🏢 交易所配置:" +run_psql "SELECT id, name, type, enabled, CASE WHEN api_key != '' THEN '已配置' ELSE '未配置' END AS api_key_status FROM exchanges ORDER BY id;" + +echo -e "\n⚙️ 关键系统配置:" +run_psql "SELECT key, CASE WHEN LENGTH(value) > 50 THEN LEFT(value, 50) || '...' ELSE value END AS value FROM system_config WHERE key IN ('beta_mode', 'api_server_port', 'default_coins', 'jwt_secret') ORDER BY key;" + +echo -e "\n🎟️ 内测码统计:" +run_psql "SELECT CASE WHEN used THEN '已使用' ELSE '未使用' END AS status, COUNT(*) AS count FROM beta_codes GROUP BY used ORDER BY used;" + +echo -e "\n👥 用户信息:" +run_psql "SELECT id, email, otp_verified, created_at FROM users ORDER BY created_at;" diff --git a/view_pg_data.sh b/view_pg_data.sh deleted file mode 100755 index 59a7aef5..00000000 --- a/view_pg_data.sh +++ /dev/null @@ -1,65 +0,0 @@ -#!/bin/bash - -# PostgreSQL数据查看工具 -echo "🔍 PostgreSQL 数据查看工具" -echo "==========================" - -# 检测Docker Compose命令 -DOCKER_COMPOSE_CMD="" -if command -v "docker-compose" &> /dev/null; then - DOCKER_COMPOSE_CMD="docker-compose" -elif command -v "docker" &> /dev/null && docker compose version &> /dev/null; then - DOCKER_COMPOSE_CMD="docker compose" -else - echo "❌ 错误:找不到 docker-compose 或 docker compose 命令" - exit 1 -fi - -echo "📊 数据库概览:" -$DOCKER_COMPOSE_CMD exec postgres psql -U nofx -d nofx --pset pager=off -c " -SELECT relname as \"表名\", n_live_tup as \"记录数\" -FROM pg_stat_user_tables -WHERE n_live_tup > 0 -ORDER BY relname; -" - -echo -e "\n🤖 AI模型配置:" -$DOCKER_COMPOSE_CMD exec postgres psql -U nofx -d nofx --pset pager=off -c " -SELECT id, name, provider, enabled, - CASE WHEN api_key != '' THEN '已配置' ELSE '未配置' END as api_key_status -FROM ai_models ORDER BY id; -" - -echo -e "\n🏢 交易所配置:" -$DOCKER_COMPOSE_CMD exec postgres psql -U nofx -d nofx --pset pager=off -c " -SELECT id, name, type, enabled, - CASE WHEN api_key != '' THEN '已配置' ELSE '未配置' END as api_key_status -FROM exchanges ORDER BY id; -" - -echo -e "\n⚙️ 关键系统配置:" -$DOCKER_COMPOSE_CMD exec postgres psql -U nofx -d nofx --pset pager=off -c " -SELECT key, - CASE - WHEN LENGTH(value) > 50 THEN LEFT(value, 50) || '...' - ELSE value - END as value -FROM system_config -WHERE key IN ('admin_mode', 'beta_mode', 'api_server_port', 'default_coins', 'jwt_secret') -ORDER BY key; -" - -echo -e "\n🎟️ 内测码统计:" -$DOCKER_COMPOSE_CMD exec postgres psql -U nofx -d nofx --pset pager=off -c " -SELECT - CASE WHEN used THEN '已使用' ELSE '未使用' END as status, - COUNT(*) as count -FROM beta_codes -GROUP BY used -ORDER BY used; -" - -echo -e "\n👥 用户信息:" -$DOCKER_COMPOSE_CMD exec postgres psql -U nofx -d nofx --pset pager=off -c " -SELECT id, email, otp_verified, created_at FROM users ORDER BY created_at; -" \ No newline at end of file diff --git a/web/src/components/ComparisonChart.tsx b/web/src/components/ComparisonChart.tsx index dc81c9cf..9789517d 100644 --- a/web/src/components/ComparisonChart.tsx +++ b/web/src/components/ComparisonChart.tsx @@ -269,14 +269,20 @@ export function ComparisonChart({ traders }: ComparisonChartProps) {
NOFX diff --git a/web/src/components/EquityChart.tsx b/web/src/components/EquityChart.tsx index f8beb1d5..e433ade0 100644 --- a/web/src/components/EquityChart.tsx +++ b/web/src/components/EquityChart.tsx @@ -301,14 +301,20 @@ export function EquityChart({ traderId }: EquityChartProps) {
NOFX diff --git a/web/src/components/landing/AboutSection.tsx b/web/src/components/landing/AboutSection.tsx index 9ec4df0a..97b0c21f 100644 --- a/web/src/components/landing/AboutSection.tsx +++ b/web/src/components/landing/AboutSection.tsx @@ -104,8 +104,8 @@ export default function AboutSection({ language }: AboutSectionProps) { lines={[ '$ git clone https://github.com/tinkle-community/nofx.git', '$ cd nofx', - '$ chmod +x start.sh', - '$ ./start.sh start --build', + '$ chmod +x scripts/start.sh', + '$ ./scripts/start.sh start --build', t('startupMessages1', language), t('startupMessages2', language), t('startupMessages3', language), diff --git a/web/src/components/landing/HeaderBar.tsx b/web/src/components/landing/HeaderBar.tsx index 37f2ae8b..665b0fd3 100644 --- a/web/src/components/landing/HeaderBar.tsx +++ b/web/src/components/landing/HeaderBar.tsx @@ -12,7 +12,6 @@ interface HeaderBarProps { onLanguageChange?: (lang: Language) => void user?: { email: string } | null onLogout?: () => void - isAdminMode?: boolean onPageChange?: (page: string) => void } @@ -24,7 +23,6 @@ export default function HeaderBar({ onLanguageChange, user, onLogout, - isAdminMode = false, onPageChange, }: HeaderBarProps) { const [mobileMenuOpen, setMobileMenuOpen] = useState(false) @@ -363,7 +361,7 @@ export default function HeaderBar({ {user.email}
- {!isAdminMode && onLogout && ( + {onLogout && (