Merge branch 'beta' of github.com:NoFxAiOS/nofx into beta

# Conflicts:
#	.gitignore
This commit is contained in:
Icy
2025-11-12 23:43:21 +08:00
52 changed files with 3686 additions and 1182 deletions

View File

@@ -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

54
.github/workflows/test.yml vendored Normal file
View File

@@ -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

10
.gitignore vendored
View File

@@ -34,6 +34,16 @@ config.db*
nofx.db
configbak.json
# 生产配置
nginx/
certs/
beta_codes.txt
# 密钥文件
keys/
*.key
*.pem
# 决策日志
decision_logs/
coin_pool_cache/

View File

@@ -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

View File

@@ -13,7 +13,7 @@ NOFX 项目的所有重要更改都将记录在此文件中。
### 新增
- 多语言文档系统(英文/中文/俄语/乌克兰语)
- 完整的快速开始指南Docker、PM2、自定义 API
- 完整的快速开始指南Docker、自定义 API
- 架构文档,包含系统设计细节
- 用户指南,包含 FAQ 和故障排除
- 社区文档,包含悬赏计划

153
Makefile Normal file
View File

@@ -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"

View File

@@ -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デプロイガイドトラブルシューティング高度な設定について**

View File

@@ -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:**

View File

@@ -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
}
}
}

1046
config/database_pg.go Normal file

File diff suppressed because it is too large Load Diff

179
db/init.sql Normal file
View File

@@ -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);

View File

@@ -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

View File

@@ -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)
---

View File

@@ -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 |

View File

@@ -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 的 2FAGoogle Authenticator
- Bcrypt 密码哈希
- 管理员模式(简化的单用户模式)
---

View File

@@ -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)
---

View File

@@ -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可选
---

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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;"
```
---

View File

@@ -152,18 +152,17 @@ docker compose up -d
## 数据与隐私
### 我的数据存储在哪里?
所有数据都**本地存储**在您的机器上,使用 SQLite 数据库
- `config.db` - 交易员配置
- `trading.db` - 交易历史
所有数据都**本地存储**在 PostgreSQLDocker 卷 `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;"
```
---

View File

@@ -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, устранению неполадок и расширенной конфигурации:**

View File

@@ -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, усунення несправностей та розширеної конфігурації:**

View File

@@ -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部署教程故障排查和高级配置**

38
go.sum
View File

@@ -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=

View File

@@ -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 != "" {

View File

@@ -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
}
]
};

258
pm2.sh
View File

@@ -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

685
proxy/README.md Normal file
View File

@@ -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模式默认180030分钟 |
| `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个代理IPTTL=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)
}
```
### 示例2OI数据获取代理池模式 + 黑名单)
```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)
}
}
```
### 示例3Bright 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
```
### 问题3Bright 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

View File

@@ -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
}

42
proxy/fixed_provider.go Normal file
View File

@@ -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
}

10
proxy/provider.go Normal file
View File

@@ -0,0 +1,10 @@
package proxy
// IPProvider IP提供者接口
type IPProvider interface {
// GetIPList 获取IP列表
GetIPList() ([]ProxyIP, error)
// RefreshIPList 刷新IP列表可选实现
RefreshIPList() ([]ProxyIP, error)
}

47
proxy/proxy_client.go Normal file
View File

@@ -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)
}

346
proxy/proxy_manager.go Normal file
View File

@@ -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)
}

19
proxy/single_provider.go Normal file
View File

@@ -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()
}

40
proxy/types.go Normal file
View File

@@ -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刷新次数
}

View File

@@ -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
fi

View File

@@ -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注意: 请妥善保管私钥文件,不要提交到版本控制系统中!")
}

87
scripts/import_beta_codes.sh Executable file
View File

@@ -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 "✅ 导入完成(重复的已跳过)"

160
scripts/import_default_patch.sh Executable file
View File

@@ -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 "🎉 操作完成"

View File

@@ -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()
}

87
scripts/view_pg_data.sh Executable file
View File

@@ -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;"

View File

@@ -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;
"

View File

@@ -269,14 +269,20 @@ export function ComparisonChart({ traders }: ComparisonChartProps) {
<div
style={{
position: 'absolute',
top: '20px',
right: '20px',
fontSize: '24px',
top: '50%',
left: '50%',
transform: 'translate(-50%, -50%)',
width: '80%',
fontSize: 'min(20vw, 160px)',
fontWeight: 'bold',
color: 'rgba(240, 185, 11, 0.15)',
color: 'rgba(240, 185, 11, 0.12)',
zIndex: 10,
pointerEvents: 'none',
fontFamily: 'monospace',
textAlign: 'center',
letterSpacing: '0.4rem',
lineHeight: 1,
userSelect: 'none',
}}
>
NOFX

View File

@@ -301,14 +301,20 @@ export function EquityChart({ traderId }: EquityChartProps) {
<div
style={{
position: 'absolute',
top: '15px',
right: '15px',
fontSize: '20px',
top: '50%',
left: '50%',
transform: 'translate(-50%, -50%)',
width: '80%',
fontSize: 'min(20vw, 160px)',
fontWeight: 'bold',
color: 'rgba(240, 185, 11, 0.15)',
color: 'rgba(240, 185, 11, 0.12)',
zIndex: 10,
pointerEvents: 'none',
fontFamily: 'monospace',
textAlign: 'center',
letterSpacing: '0.4rem',
lineHeight: 1,
userSelect: 'none',
}}
>
NOFX

View File

@@ -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),

View File

@@ -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}
</div>
</div>
{!isAdminMode && onLogout && (
{onLogout && (
<button
onClick={() => {
onLogout()
@@ -763,7 +761,7 @@ export default function HeaderBar({
</div>
</div>
</div>
{!isAdminMode && onLogout && (
{onLogout && (
<button
onClick={() => {
onLogout()

View File

@@ -1,5 +1,10 @@
export interface SystemConfig {
beta_mode: boolean
default_coins?: string[]
btc_eth_leverage?: number
altcoin_leverage?: number
rsa_public_key?: string
rsa_key_id?: string
}
let configPromise: Promise<SystemConfig> | null = null

View File

@@ -108,18 +108,16 @@ export interface AIModel {
export interface Exchange {
id: string
user_id: string
name: string
type: 'cex' | 'dex'
enabled: boolean
apiKey?: string
secretKey?: string
testnet?: boolean
// Hyperliquid 特定字段
hyperliquidWalletAddr?: string
// Aster 特定字段
asterUser?: string
asterSigner?: string
asterPrivateKey?: string
hyperliquidWalletAddr?: string // 钱包地址,非敏感信息
asterUser?: string // Aster用户名非敏感信息
deleted: boolean
created_at: string
updated_at: string
}
export interface CreateTraderRequest {