mirror of
https://github.com/NoFxAiOS/nofx.git
synced 2026-07-04 11:30:58 +08:00
Merge branch 'beta' of github.com:NoFxAiOS/nofx into beta
# Conflicts: # .gitignore
This commit is contained in:
15
.env.example
15
.env.example
@@ -1,6 +1,21 @@
|
||||
# NOFX Environment Variables Template
|
||||
# Copy this file to .env and modify the values as needed
|
||||
|
||||
# PostgreSQL数据库配置
|
||||
POSTGRES_HOST=postgres
|
||||
POSTGRES_PORT=5432
|
||||
POSTGRES_DB=nofx
|
||||
POSTGRES_USER=nofx
|
||||
POSTGRES_PASSWORD=nofx123456
|
||||
|
||||
# Redis配置
|
||||
REDIS_HOST=redis
|
||||
REDIS_PORT=6379
|
||||
REDIS_PASSWORD=redis123456
|
||||
|
||||
# 数据加密密钥
|
||||
DATA_ENCRYPTION_KEY=my_secret_encryption_key
|
||||
|
||||
# Ports Configuration
|
||||
# Backend API server port (internal: 8080, external: configurable)
|
||||
NOFX_BACKEND_PORT=8080
|
||||
|
||||
54
.github/workflows/test.yml
vendored
Normal file
54
.github/workflows/test.yml
vendored
Normal 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
10
.gitignore
vendored
@@ -34,6 +34,16 @@ config.db*
|
||||
nofx.db
|
||||
configbak.json
|
||||
|
||||
# 生产配置
|
||||
nginx/
|
||||
certs/
|
||||
beta_codes.txt
|
||||
|
||||
# 密钥文件
|
||||
keys/
|
||||
*.key
|
||||
*.pem
|
||||
|
||||
# 决策日志
|
||||
decision_logs/
|
||||
coin_pool_cache/
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -13,7 +13,7 @@ NOFX 项目的所有重要更改都将记录在此文件中。
|
||||
|
||||
### 新增
|
||||
- 多语言文档系统(英文/中文/俄语/乌克兰语)
|
||||
- 完整的快速开始指南(Docker、PM2、自定义 API)
|
||||
- 完整的快速开始指南(Docker、自定义 API)
|
||||
- 架构文档,包含系统设计细节
|
||||
- 用户指南,包含 FAQ 和故障排除
|
||||
- 社区文档,包含悬赏计划
|
||||
|
||||
153
Makefile
Normal file
153
Makefile
Normal 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"
|
||||
12
README.ja.md
12
README.ja.md
@@ -293,8 +293,8 @@ nano config.json # または任意のエディタを使用
|
||||
|
||||
```bash
|
||||
# オプション1:便利スクリプトを使用(推奨)
|
||||
chmod +x start.sh
|
||||
./start.sh start --build
|
||||
chmod +x scripts/start.sh
|
||||
./scripts/start.sh start --build
|
||||
|
||||
> #### Docker Composeバージョンに関する注意
|
||||
>
|
||||
@@ -315,10 +315,10 @@ docker compose up -d --build
|
||||
#### システム管理
|
||||
|
||||
```bash
|
||||
./start.sh logs # ログを表示
|
||||
./start.sh status # ステータスを確認
|
||||
./start.sh stop # サービスを停止
|
||||
./start.sh restart # サービスを再起動
|
||||
./scripts/start.sh logs # ログを表示
|
||||
./scripts/start.sh status # ステータスを確認
|
||||
./scripts/start.sh stop # サービスを停止
|
||||
./scripts/start.sh restart # サービスを再起動
|
||||
```
|
||||
|
||||
**📖 詳細なDockerデプロイガイド、トラブルシューティング、高度な設定について:**
|
||||
|
||||
12
README.md
12
README.md
@@ -337,8 +337,8 @@ nano config.json # or use any editor
|
||||
#### Step 2: One-Click Start
|
||||
```bash
|
||||
# Option 1: Use convenience script (Recommended)
|
||||
chmod +x start.sh
|
||||
./start.sh start --build
|
||||
chmod +x scripts/start.sh
|
||||
./scripts/start.sh start --build
|
||||
|
||||
> #### Docker Compose Version Notes
|
||||
>
|
||||
@@ -363,10 +363,10 @@ Open your browser and visit: **http://localhost:3000**
|
||||
|
||||
#### Manage Your System
|
||||
```bash
|
||||
./start.sh logs # View logs
|
||||
./start.sh status # Check status
|
||||
./start.sh stop # Stop services
|
||||
./start.sh restart # Restart services
|
||||
./scripts/start.sh logs # View logs
|
||||
./scripts/start.sh status # Check status
|
||||
./scripts/start.sh stop # Stop services
|
||||
./scripts/start.sh restart # Restart services
|
||||
```
|
||||
|
||||
**📖 For detailed Docker deployment guide, troubleshooting, and advanced configuration:**
|
||||
|
||||
@@ -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
1046
config/database_pg.go
Normal file
File diff suppressed because it is too large
Load Diff
179
db/init.sql
Normal file
179
db/init.sql
Normal 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);
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
---
|
||||
|
||||
@@ -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 |
|
||||
|
||||
@@ -93,7 +93,7 @@ nofx/
|
||||
| `github.com/gin-gonic/gin` | HTTP API 框架 | v1.9+ |
|
||||
| `github.com/adshao/go-binance/v2` | Binance API 客户端 | v2.4+ |
|
||||
| `github.com/markcheno/go-talib` | 技术指标(TA-Lib) | 最新 |
|
||||
| `github.com/mattn/go-sqlite3` | SQLite 数据库驱动 | v1.14+ |
|
||||
| `github.com/lib/pq` | PostgreSQL 数据库驱动 | v1.10+ |
|
||||
| `github.com/golang-jwt/jwt/v5` | JWT 认证 | v5.0+ |
|
||||
| `github.com/pquerna/otp` | 2FA/TOTP 支持 | v1.4+ |
|
||||
| `golang.org/x/crypto` | 密码哈希(bcrypt) | 最新 |
|
||||
@@ -282,7 +282,6 @@ GET /api/decisions/latest # 最近决策
|
||||
- 基于 JWT token 的认证
|
||||
- 使用 TOTP 的 2FA(Google Authenticator)
|
||||
- Bcrypt 密码哈希
|
||||
- 管理员模式(简化的单用户模式)
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -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(可选)
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
@@ -152,18 +152,17 @@ docker compose up -d
|
||||
## 数据与隐私
|
||||
|
||||
### 我的数据存储在哪里?
|
||||
所有数据都**本地存储**在您的机器上,使用 SQLite 数据库:
|
||||
- `config.db` - 交易员配置
|
||||
- `trading.db` - 交易历史
|
||||
所有数据都**本地存储**在 PostgreSQL(Docker 卷 `postgres_data`)中,另有:
|
||||
- `decision_logs/` - AI 决策记录
|
||||
|
||||
### API 密钥安全吗?
|
||||
API 密钥存储在本地数据库中。永远不要分享您的数据库或 `.env` 文件。我们建议使用带 IP 白名单限制的 API 密钥。
|
||||
|
||||
### 可以导出交易历史吗?
|
||||
可以!交易数据是 SQLite 格式。您可以直接查询:
|
||||
可以!使用 `pg_dump` 或 `psql` 导出数据:
|
||||
```bash
|
||||
sqlite3 trading.db "SELECT * FROM trades;"
|
||||
docker compose exec postgres \
|
||||
psql -U nofx -d nofx -c "SELECT * FROM trades;"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
@@ -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, устранению неполадок и расширенной конфигурации:**
|
||||
|
||||
@@ -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, усунення несправностей та розширеної конфігурації:**
|
||||
|
||||
@@ -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
38
go.sum
@@ -34,8 +34,6 @@ github.com/decred/dcrd/crypto/blake256 v1.1.0 h1:zPMNGQCm0g4QTY27fOCorQW7EryeQ/U
|
||||
github.com/decred/dcrd/crypto/blake256 v1.1.0/go.mod h1:2OfgNZ5wDpcsFmHmCK5gZTPcCXqlm2ArzUIkw9czNJo=
|
||||
github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.0 h1:NMZiJj8QnKe1LgsbDayM4UoHwbvwDRwnI3hwNaAHRnc=
|
||||
github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.0/go.mod h1:ZXNYxsqcloTdSy/rNShjYzMhyjf0LaoftYK0p+A3h40=
|
||||
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
|
||||
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
|
||||
github.com/elastic/go-sysinfo v1.15.4 h1:A3zQcunCxik14MgXu39cXFXcIw2sFXZ0zL886eyiv1Q=
|
||||
github.com/elastic/go-sysinfo v1.15.4/go.mod h1:ZBVXmqS368dOn/jvijV/zHLfakWTYHBZPk3G244lHrU=
|
||||
github.com/elastic/go-windows v1.0.2 h1:yoLLsAsV5cfg9FLhZ9EXZ2n2sQFKeDYrHenkcivY4vI=
|
||||
@@ -86,8 +84,6 @@ github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX
|
||||
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
|
||||
github.com/google/gofuzz v1.2.0 h1:xRy4A+RhZaiKjJ1bPfwQ8sedCA+YS2YcCHW6ec7JMi0=
|
||||
github.com/google/gofuzz v1.2.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
|
||||
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17kjQEVQ1XRhq2/JR1M3sGqeJoxs=
|
||||
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA=
|
||||
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||
github.com/gopherjs/gopherjs v0.0.0-20181017120253-0766667cb4d1/go.mod h1:wJfORRmW1u3UXTncJ5qlYoELFm8eSnnEO6hX4iZ3EWY=
|
||||
@@ -117,6 +113,8 @@ github.com/leanovate/gopter v0.2.11 h1:vRjThO1EKPb/1NsDXuDrzldR28RLkBflWYcU9CvzW
|
||||
github.com/leanovate/gopter v0.2.11/go.mod h1:aK3tzZP/C+p1m3SPRE4SYZFGP7jjkuSI4f7Xvpt0S9c=
|
||||
github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ=
|
||||
github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI=
|
||||
github.com/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw=
|
||||
github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o=
|
||||
github.com/mailru/easyjson v0.9.1 h1:LbtsOm5WAswyWbvTEOqhypdPeZzHavpZx96/n553mR8=
|
||||
github.com/mailru/easyjson v0.9.1/go.mod h1:1+xMtQp2MRNVL/V1bOzuP3aP8VNwRW55fQUto+XFtTU=
|
||||
github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg=
|
||||
@@ -136,8 +134,6 @@ github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421 h1:ZqeYNhU3OH
|
||||
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
|
||||
github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M=
|
||||
github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
|
||||
github.com/ncruces/go-strftime v0.1.9 h1:bY0MQC28UADQmHmaF5dgpLmImcShSi2kHU9XLdhx/f4=
|
||||
github.com/ncruces/go-strftime v0.1.9/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
|
||||
github.com/olekukonko/tablewriter v0.0.5 h1:P2Ga83D34wi1o9J6Wh1mRuqd4mF/x/lgBS7N7AbDhec=
|
||||
github.com/olekukonko/tablewriter v0.0.5/go.mod h1:hPp6KlRPjbx+hW8ykQs1w3UBbZlj6HuIJcUGPhkA7kY=
|
||||
github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4=
|
||||
@@ -154,8 +150,6 @@ github.com/quic-go/qpack v0.5.1 h1:giqksBPnT/HDtZ6VhtFKgoLOWmlyo9Ei6u9PqzIMbhI=
|
||||
github.com/quic-go/qpack v0.5.1/go.mod h1:+PC4XFrEskIVkcLzpEkbLqq1uCoxPhQuvK5rH1ZgaEg=
|
||||
github.com/quic-go/quic-go v0.54.0 h1:6s1YB9QotYI6Ospeiguknbp2Znb/jZYjZLRXn9kMQBg=
|
||||
github.com/quic-go/quic-go v0.54.0/go.mod h1:e68ZEaCdyviluZmy44P6Iey98v/Wfz6HCjQEm+l8zTY=
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
|
||||
github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
|
||||
github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
|
||||
github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ=
|
||||
@@ -218,8 +212,6 @@ golang.org/x/arch v0.20.0/go.mod h1:bdwinDaKcfZUGpH09BB7ZmOfhalA8lQdzl62l8gGWsk=
|
||||
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
|
||||
golang.org/x/crypto v0.42.0 h1:chiH31gIWm57EkTXpwnqf8qeuMUi0yekh6mT2AvFlqI=
|
||||
golang.org/x/crypto v0.42.0/go.mod h1:4+rDnOTJhQCx2q7/j6rAN5XDw8kPjeaXEUR2eL94ix8=
|
||||
golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b h1:M2rDM6z3Fhozi9O7NWsxAkg/yqS/lQJ6PmkyIV3YP+o=
|
||||
golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b/go.mod h1:3//PLf8L/X+8b4vuAfHzxeRUl04Adcb341+IGKfnqS8=
|
||||
golang.org/x/mod v0.27.0 h1:kb+q2PyFnEADO2IEF935ehFUXlWiNjJWtRNgBLSfbxQ=
|
||||
golang.org/x/mod v0.27.0/go.mod h1:rWI627Fq0DEoudcK+MBkNkCe0EetEaDSwJJkCcjpazc=
|
||||
golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
|
||||
@@ -255,29 +247,3 @@ gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
howett.net/plist v1.0.1 h1:37GdZ8tP09Q35o9ych3ehygcsL+HqKSwzctveSlarvM=
|
||||
howett.net/plist v1.0.1/go.mod h1:lqaXoTrLY4hg8tnEzNru53gicrbv7rrk+2xJA/7hw9g=
|
||||
modernc.org/cc/v4 v4.26.5 h1:xM3bX7Mve6G8K8b+T11ReenJOT+BmVqQj0FY5T4+5Y4=
|
||||
modernc.org/cc/v4 v4.26.5/go.mod h1:uVtb5OGqUKpoLWhqwNQo/8LwvoiEBLvZXIQ/SmO6mL0=
|
||||
modernc.org/ccgo/v4 v4.28.1 h1:wPKYn5EC/mYTqBO373jKjvX2n+3+aK7+sICCv4Fjy1A=
|
||||
modernc.org/ccgo/v4 v4.28.1/go.mod h1:uD+4RnfrVgE6ec9NGguUNdhqzNIeeomeXf6CL0GTE5Q=
|
||||
modernc.org/fileutil v1.3.40 h1:ZGMswMNc9JOCrcrakF1HrvmergNLAmxOPjizirpfqBA=
|
||||
modernc.org/fileutil v1.3.40/go.mod h1:HxmghZSZVAz/LXcMNwZPA/DRrQZEVP9VX0V4LQGQFOc=
|
||||
modernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI=
|
||||
modernc.org/gc/v2 v2.6.5/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito=
|
||||
modernc.org/goabi0 v0.2.0 h1:HvEowk7LxcPd0eq6mVOAEMai46V+i7Jrj13t4AzuNks=
|
||||
modernc.org/goabi0 v0.2.0/go.mod h1:CEFRnnJhKvWT1c1JTI3Avm+tgOWbkOu5oPA8eH8LnMI=
|
||||
modernc.org/libc v1.66.10 h1:yZkb3YeLx4oynyR+iUsXsybsX4Ubx7MQlSYEw4yj59A=
|
||||
modernc.org/libc v1.66.10/go.mod h1:8vGSEwvoUoltr4dlywvHqjtAqHBaw0j1jI7iFBTAr2I=
|
||||
modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU=
|
||||
modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg=
|
||||
modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI=
|
||||
modernc.org/memory v1.11.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw=
|
||||
modernc.org/opt v0.1.4 h1:2kNGMRiUjrp4LcaPuLY2PzUfqM/w9N23quVwhKt5Qm8=
|
||||
modernc.org/opt v0.1.4/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns=
|
||||
modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w=
|
||||
modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE=
|
||||
modernc.org/sqlite v1.40.0 h1:bNWEDlYhNPAUdUdBzjAvn8icAs/2gaKlj4vM+tQ6KdQ=
|
||||
modernc.org/sqlite v1.40.0/go.mod h1:9fjQZ0mB1LLP0GYrp39oOJXx/I2sxEnZtzCmEQIKvGE=
|
||||
modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0=
|
||||
modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A=
|
||||
modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y=
|
||||
modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM=
|
||||
|
||||
@@ -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 != "" {
|
||||
|
||||
@@ -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
258
pm2.sh
@@ -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
685
proxy/README.md
Normal 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模式默认1800(30分钟) |
|
||||
| `blacklist_ttl` | int | 否 | 黑名单IP的TTL(刷新次数),默认5 |
|
||||
|
||||
## 使用方法
|
||||
|
||||
### 1. 初始化代理管理器
|
||||
|
||||
在 `main.go` 或初始化代码中:
|
||||
|
||||
```go
|
||||
import (
|
||||
"nofx/proxy"
|
||||
"time"
|
||||
)
|
||||
|
||||
// 方式1:使用配置结构体初始化
|
||||
proxyConfig := &proxy.Config{
|
||||
Enabled: true,
|
||||
Mode: "single",
|
||||
Timeout: 30 * time.Second,
|
||||
ProxyURL: "http://127.0.0.1:7890",
|
||||
BlacklistTTL: 5,
|
||||
}
|
||||
|
||||
err := proxy.InitGlobalProxyManager(proxyConfig)
|
||||
if err != nil {
|
||||
log.Fatalf("初始化代理管理器失败: %v", err)
|
||||
}
|
||||
```
|
||||
|
||||
### 2. 获取代理HTTP客户端
|
||||
|
||||
在需要发送HTTP请求的地方:
|
||||
|
||||
```go
|
||||
// 获取代理客户端(包含ProxyID用于黑名单管理)
|
||||
proxyClient, err := proxy.GetProxyHTTPClient()
|
||||
if err != nil {
|
||||
log.Printf("获取代理客户端失败: %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
// 使用代理客户端发送请求
|
||||
resp, err := proxyClient.Client.Get("https://api.example.com/data")
|
||||
if err != nil {
|
||||
// 请求失败,将此代理加入黑名单
|
||||
proxy.AddBlacklist(proxyClient.ProxyID)
|
||||
log.Printf("请求失败,代理IP %s 已加入黑名单", proxyClient.IP)
|
||||
return
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
// 处理响应...
|
||||
```
|
||||
|
||||
### 3. 黑名单管理
|
||||
|
||||
```go
|
||||
// 添加失败的代理到黑名单
|
||||
proxy.AddBlacklist(proxyClient.ProxyID)
|
||||
|
||||
// 获取黑名单状态
|
||||
total, blacklisted, available := proxy.GetGlobalProxyManager().GetBlacklistStatus()
|
||||
log.Printf("代理状态: 总计%d个,黑名单%d个,可用%d个", total, blacklisted, available)
|
||||
```
|
||||
|
||||
### 4. 手动刷新IP列表
|
||||
|
||||
```go
|
||||
err := proxy.RefreshIPList()
|
||||
if err != nil {
|
||||
log.Printf("刷新IP列表失败: %v", err)
|
||||
}
|
||||
```
|
||||
|
||||
### 5. 检查代理是否启用
|
||||
|
||||
```go
|
||||
if proxy.IsEnabled() {
|
||||
log.Println("代理已启用")
|
||||
} else {
|
||||
log.Println("代理未启用,使用直连")
|
||||
}
|
||||
```
|
||||
|
||||
## 三种模式详解
|
||||
|
||||
### Mode 1: Single(单代理模式)
|
||||
|
||||
适用场景:本地代理工具(如Clash、V2Ray)或单个固定代理服务器
|
||||
|
||||
```json
|
||||
{
|
||||
"proxy": {
|
||||
"enabled": true,
|
||||
"mode": "single",
|
||||
"proxy_url": "http://127.0.0.1:7890"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
特点:
|
||||
- 简单直接,适合本地开发和测试
|
||||
- 所有请求通过同一个代理
|
||||
- 不需要刷新和轮换
|
||||
|
||||
### Mode 2: Pool(代理池模式)
|
||||
|
||||
适用场景:拥有多个固定代理服务器,需要轮换使用
|
||||
|
||||
```json
|
||||
{
|
||||
"proxy": {
|
||||
"enabled": true,
|
||||
"mode": "pool",
|
||||
"proxy_list": [
|
||||
"http://proxy1.example.com:8080",
|
||||
"http://user:pass@proxy2.example.com:8080",
|
||||
"socks5://proxy3.example.com:1080"
|
||||
],
|
||||
"blacklist_ttl": 5
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
特点:
|
||||
- 支持多协议:HTTP、HTTPS、SOCKS5
|
||||
- 随机选择代理,分散请求压力
|
||||
- 失败的代理自动加入黑名单
|
||||
- 黑名单IP经过TTL次刷新后自动恢复
|
||||
|
||||
### Mode 3: BrightData(动态IP模式)
|
||||
|
||||
适用场景:使用Bright Data等提供API的动态代理服务
|
||||
|
||||
```json
|
||||
{
|
||||
"proxy": {
|
||||
"enabled": true,
|
||||
"mode": "brightdata",
|
||||
"brightdata_endpoint": "https://api.brightdata.com/zones/get_ips",
|
||||
"brightdata_token": "your_api_token",
|
||||
"brightdata_zone": "residential",
|
||||
"proxy_host": "brd.superproxy.io:22225",
|
||||
"proxy_user": "brd-customer-xxx-zone-residential-ip-%s",
|
||||
"proxy_password": "your_password",
|
||||
"refresh_interval": 1800,
|
||||
"blacklist_ttl": 5
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
特点:
|
||||
- 从API动态获取可用IP列表
|
||||
- 自动定时刷新(默认30分钟)
|
||||
- 支持用户名模板(`%s` 替换为IP地址)
|
||||
- 黑名单TTL机制避免频繁切换
|
||||
|
||||
**用户名模板说明**:
|
||||
```
|
||||
proxy_user: "brd-customer-xxx-zone-residential-ip-%s"
|
||||
↑
|
||||
自动替换为IP地址
|
||||
```
|
||||
|
||||
## 核心API
|
||||
|
||||
### 全局函数
|
||||
|
||||
```go
|
||||
// 初始化全局代理管理器(只执行一次)
|
||||
func InitGlobalProxyManager(config *Config) error
|
||||
|
||||
// 获取全局代理管理器实例
|
||||
func GetGlobalProxyManager() *ProxyManager
|
||||
|
||||
// 获取代理HTTP客户端(包含ProxyID和IP信息)
|
||||
func GetProxyHTTPClient() (*ProxyClient, error)
|
||||
|
||||
// 将代理IP添加到黑名单
|
||||
func AddBlacklist(proxyID int)
|
||||
|
||||
// 刷新IP列表
|
||||
func RefreshIPList() error
|
||||
|
||||
// 检查代理是否启用
|
||||
func IsEnabled() bool
|
||||
```
|
||||
|
||||
### ProxyManager 方法
|
||||
|
||||
```go
|
||||
// 获取代理客户端
|
||||
func (m *ProxyManager) GetProxyClient() (*ProxyClient, error)
|
||||
|
||||
// 刷新IP列表
|
||||
func (m *ProxyManager) RefreshIPList() error
|
||||
|
||||
// 添加到黑名单
|
||||
func (m *ProxyManager) AddBlacklist(proxyID int)
|
||||
|
||||
// 获取黑名单状态
|
||||
func (m *ProxyManager) GetBlacklistStatus() (total, blacklisted, available int)
|
||||
|
||||
// 启动自动刷新
|
||||
func (m *ProxyManager) StartAutoRefresh()
|
||||
|
||||
// 停止自动刷新
|
||||
func (m *ProxyManager) StopAutoRefresh()
|
||||
```
|
||||
|
||||
## 黑名单机制
|
||||
|
||||
### 工作原理
|
||||
|
||||
1. **添加黑名单**:当代理请求失败时,调用 `AddBlacklist(proxyID)` 将该IP加入黑名单
|
||||
2. **TTL倒计时**:每次刷新IP列表时,黑名单中的IP的TTL减1
|
||||
3. **自动恢复**:当TTL归零时,IP自动从黑名单移除,重新可用
|
||||
|
||||
### 线程安全保证
|
||||
|
||||
```go
|
||||
// 添加黑名单使用写锁
|
||||
func (m *ProxyManager) AddBlacklist(proxyID int) {
|
||||
m.mutex.Lock()
|
||||
defer m.mutex.Unlock()
|
||||
|
||||
// 防越界检查
|
||||
if proxyID < 0 || proxyID >= len(m.ipList) {
|
||||
log.Printf("⚠️ 无效的 ProxyID: %d", proxyID)
|
||||
return
|
||||
}
|
||||
|
||||
ip := m.ipList[proxyID].IP
|
||||
m.blacklist[proxyID] = ip
|
||||
m.ipBlacklist[ip] = m.config.BlacklistTTL
|
||||
}
|
||||
|
||||
// 获取代理使用读锁(支持并发)
|
||||
func (m *ProxyManager) getRandomProxy() (int, *ProxyIP, error) {
|
||||
m.mutex.RLock()
|
||||
defer m.mutex.RUnlock()
|
||||
// ... 读取操作
|
||||
}
|
||||
```
|
||||
|
||||
### 示例流程
|
||||
|
||||
```
|
||||
初始状态:5个代理IP,TTL=3
|
||||
IP列表: [IP1, IP2, IP3, IP4, IP5]
|
||||
黑名单: {}
|
||||
|
||||
第1次失败:IP2请求失败
|
||||
IP列表: [IP1, IP2, IP3, IP4, IP5]
|
||||
黑名单: {IP2: TTL=3}
|
||||
|
||||
第1次刷新:TTL-1
|
||||
黑名单: {IP2: TTL=2}
|
||||
|
||||
第2次刷新:TTL-1
|
||||
黑名单: {IP2: TTL=1}
|
||||
|
||||
第3次刷新:TTL-1
|
||||
黑名单: {IP2: TTL=0} → 从黑名单移除
|
||||
|
||||
第3次刷新后:
|
||||
IP列表: [IP1, IP2, IP3, IP4, IP5]
|
||||
黑名单: {} ← IP2已恢复可用
|
||||
```
|
||||
|
||||
## 完整使用示例
|
||||
|
||||
### 示例1:币安API请求(单代理模式)
|
||||
|
||||
```go
|
||||
package main
|
||||
|
||||
import (
|
||||
"log"
|
||||
"nofx/proxy"
|
||||
"time"
|
||||
)
|
||||
|
||||
func main() {
|
||||
// 初始化代理
|
||||
err := proxy.InitGlobalProxyManager(&proxy.Config{
|
||||
Enabled: true,
|
||||
Mode: "single",
|
||||
ProxyURL: "http://127.0.0.1:7890",
|
||||
Timeout: 30 * time.Second,
|
||||
})
|
||||
if err != nil {
|
||||
log.Fatalf("初始化代理失败: %v", err)
|
||||
}
|
||||
|
||||
// 获取币安数据
|
||||
proxyClient, err := proxy.GetProxyHTTPClient()
|
||||
if err != nil {
|
||||
log.Fatalf("获取代理客户端失败: %v", err)
|
||||
}
|
||||
|
||||
resp, err := proxyClient.Client.Get("https://fapi.binance.com/fapi/v1/ticker/24hr")
|
||||
if err != nil {
|
||||
log.Printf("请求失败: %v", err)
|
||||
return
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
log.Printf("请求成功,使用代理: %s", proxyClient.IP)
|
||||
}
|
||||
```
|
||||
|
||||
### 示例2:OI数据获取(代理池模式 + 黑名单)
|
||||
|
||||
```go
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
"nofx/proxy"
|
||||
"time"
|
||||
)
|
||||
|
||||
func fetchOIData(symbol string) error {
|
||||
proxyClient, err := proxy.GetProxyHTTPClient()
|
||||
if err != nil {
|
||||
return fmt.Errorf("获取代理失败: %w", err)
|
||||
}
|
||||
|
||||
url := fmt.Sprintf("https://fapi.binance.com/futures/data/openInterestHist?symbol=%s&period=5m&limit=1", symbol)
|
||||
resp, err := proxyClient.Client.Get(url)
|
||||
if err != nil {
|
||||
// 请求失败,加入黑名单
|
||||
proxy.AddBlacklist(proxyClient.ProxyID)
|
||||
return fmt.Errorf("请求失败 (代理: %s): %w", proxyClient.IP, err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != 200 {
|
||||
// 状态码异常,加入黑名单
|
||||
proxy.AddBlacklist(proxyClient.ProxyID)
|
||||
return fmt.Errorf("状态码异常: %d (代理: %s)", resp.StatusCode, proxyClient.IP)
|
||||
}
|
||||
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
log.Printf("✓ 获取 %s OI数据成功 (代理: %s): %s", symbol, proxyClient.IP, string(body))
|
||||
return nil
|
||||
}
|
||||
|
||||
func main() {
|
||||
// 初始化代理池
|
||||
err := proxy.InitGlobalProxyManager(&proxy.Config{
|
||||
Enabled: true,
|
||||
Mode: "pool",
|
||||
ProxyList: []string{
|
||||
"http://proxy1.example.com:8080",
|
||||
"http://proxy2.example.com:8080",
|
||||
"http://proxy3.example.com:8080",
|
||||
},
|
||||
Timeout: 30 * time.Second,
|
||||
BlacklistTTL: 5,
|
||||
})
|
||||
if err != nil {
|
||||
log.Fatalf("初始化代理失败: %v", err)
|
||||
}
|
||||
|
||||
// 循环获取数据
|
||||
symbols := []string{"BTCUSDT", "ETHUSDT", "SOLUSDT"}
|
||||
for {
|
||||
for _, symbol := range symbols {
|
||||
if err := fetchOIData(symbol); err != nil {
|
||||
log.Printf("⚠️ %v", err)
|
||||
}
|
||||
time.Sleep(1 * time.Second)
|
||||
}
|
||||
time.Sleep(10 * time.Second)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 示例3:Bright Data动态IP
|
||||
|
||||
```go
|
||||
package main
|
||||
|
||||
import (
|
||||
"log"
|
||||
"nofx/proxy"
|
||||
"time"
|
||||
)
|
||||
|
||||
func main() {
|
||||
// 初始化Bright Data代理
|
||||
err := proxy.InitGlobalProxyManager(&proxy.Config{
|
||||
Enabled: true,
|
||||
Mode: "brightdata",
|
||||
BrightDataEndpoint: "https://api.brightdata.com/zones/get_ips",
|
||||
BrightDataToken: "your_token",
|
||||
BrightDataZone: "residential",
|
||||
ProxyHost: "brd.superproxy.io:22225",
|
||||
ProxyUser: "brd-customer-xxx-zone-residential-ip-%s",
|
||||
ProxyPassword: "your_password",
|
||||
RefreshInterval: 30 * time.Minute,
|
||||
Timeout: 30 * time.Second,
|
||||
BlacklistTTL: 5,
|
||||
})
|
||||
if err != nil {
|
||||
log.Fatalf("初始化代理失败: %v", err)
|
||||
}
|
||||
|
||||
// 代理会自动每30分钟刷新IP列表
|
||||
log.Println("✓ Bright Data代理已启动,自动刷新已开启")
|
||||
|
||||
// 获取并使用代理
|
||||
for i := 0; i < 10; i++ {
|
||||
proxyClient, err := proxy.GetProxyHTTPClient()
|
||||
if err != nil {
|
||||
log.Printf("获取代理失败: %v", err)
|
||||
continue
|
||||
}
|
||||
|
||||
resp, err := proxyClient.Client.Get("https://api.ipify.org?format=json")
|
||||
if err != nil {
|
||||
proxy.AddBlacklist(proxyClient.ProxyID)
|
||||
log.Printf("请求失败,代理已加入黑名单: %s", proxyClient.IP)
|
||||
continue
|
||||
}
|
||||
resp.Body.Close()
|
||||
|
||||
log.Printf("✓ 请求成功 (代理ID: %d, IP: %s)", proxyClient.ProxyID, proxyClient.IP)
|
||||
time.Sleep(2 * time.Second)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## 注意事项
|
||||
|
||||
### 1. 模块解耦性
|
||||
|
||||
- ✅ 代理模块完全独立,不依赖其他业务模块
|
||||
- ✅ 禁用代理时自动使用直连,对业务代码透明
|
||||
- ✅ 适合多租户/多客户环境,可按需启用
|
||||
|
||||
### 2. 线程安全
|
||||
|
||||
- ✅ 所有公开方法都是线程安全的
|
||||
- ✅ 支持高并发场景下的代理获取和黑名单操作
|
||||
- ✅ 读写锁优化性能:读操作可并发,写操作独占
|
||||
|
||||
### 3. 错误处理
|
||||
|
||||
```go
|
||||
proxyClient, err := proxy.GetProxyHTTPClient()
|
||||
if err != nil {
|
||||
// 可能的错误:
|
||||
// - 代理IP列表为空
|
||||
// - 所有代理都在黑名单中
|
||||
// - 代理URL解析失败
|
||||
log.Printf("获取代理失败: %v", err)
|
||||
|
||||
// 建议:降级为直连或重试
|
||||
return
|
||||
}
|
||||
```
|
||||
|
||||
### 4. 性能优化建议
|
||||
|
||||
- 对于高频请求,复用 `http.Client` 而不是每次创建新的
|
||||
- 合理设置 `refresh_interval` 避免频繁刷新
|
||||
- `blacklist_ttl` 建议设置为 3-10,平衡恢复速度和稳定性
|
||||
|
||||
### 5. 安全建议
|
||||
|
||||
- 生产环境中代理密钥应使用环境变量或密钥管理服务
|
||||
- 避免在日志中打印完整的代理URL(包含密码)
|
||||
- TLS验证默认开启,如需跳过请谨慎评估风险
|
||||
|
||||
### 6. 调试技巧
|
||||
|
||||
```go
|
||||
// 获取当前代理状态
|
||||
total, blacklisted, available := proxy.GetGlobalProxyManager().GetBlacklistStatus()
|
||||
log.Printf("代理池状态: 总计=%d, 黑名单=%d, 可用=%d", total, blacklisted, available)
|
||||
|
||||
// 检查是否启用
|
||||
if !proxy.IsEnabled() {
|
||||
log.Println("代理未启用,请检查配置")
|
||||
}
|
||||
```
|
||||
|
||||
## 故障排查
|
||||
|
||||
### 问题1:获取代理失败 - "代理IP列表为空"
|
||||
|
||||
**原因**:
|
||||
- `single` 模式:未配置 `proxy_url`
|
||||
- `pool` 模式:`proxy_list` 为空
|
||||
- `brightdata` 模式:API返回空列表或请求失败
|
||||
|
||||
**解决方案**:
|
||||
```bash
|
||||
# 检查配置文件
|
||||
cat config.json | grep -A 15 "proxy"
|
||||
|
||||
# 检查日志,查看初始化信息
|
||||
# 应该看到类似:🌐 HTTP 代理已启用 (xxx模式)
|
||||
```
|
||||
|
||||
### 问题2:所有代理都在黑名单中
|
||||
|
||||
**原因**:请求持续失败,所有IP被加入黑名单
|
||||
|
||||
**解决方案**:
|
||||
```go
|
||||
// 方案1:手动刷新IP列表(会触发TTL倒计时)
|
||||
proxy.RefreshIPList()
|
||||
|
||||
// 方案2:降低blacklist_ttl,加快恢复速度
|
||||
// config.json: "blacklist_ttl": 2 (默认5)
|
||||
|
||||
// 方案3:检查代理本身是否可用
|
||||
// 使用curl测试代理:
|
||||
// curl -x http://proxy_url https://api.binance.com/api/v3/ping
|
||||
```
|
||||
|
||||
### 问题3:Bright Data模式无法获取IP
|
||||
|
||||
**原因**:
|
||||
- API端点配置错误
|
||||
- Token无效或过期
|
||||
- Zone参数不正确
|
||||
|
||||
**解决方案**:
|
||||
```bash
|
||||
# 手动测试API
|
||||
curl -H "Authorization: Bearer YOUR_TOKEN" \
|
||||
"https://api.brightdata.com/zones/get_ips?zone=residential"
|
||||
|
||||
# 检查返回格式是否符合:
|
||||
# {"ips": [{"ip": "1.2.3.4", ...}, ...]}
|
||||
```
|
||||
|
||||
### 问题4:代理连接超时
|
||||
|
||||
**原因**:代理服务器响应慢或网络不稳定
|
||||
|
||||
**解决方案**:
|
||||
```json
|
||||
{
|
||||
"proxy": {
|
||||
"timeout": 60 // 增加超时时间(秒)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## 扩展开发
|
||||
|
||||
### 添加新的Provider
|
||||
|
||||
实现 `IPProvider` 接口即可:
|
||||
|
||||
```go
|
||||
// custom_provider.go
|
||||
package proxy
|
||||
|
||||
type CustomProvider struct {
|
||||
// 自定义字段
|
||||
}
|
||||
|
||||
func NewCustomProvider(config string) *CustomProvider {
|
||||
return &CustomProvider{}
|
||||
}
|
||||
|
||||
func (p *CustomProvider) GetIPList() ([]ProxyIP, error) {
|
||||
// 实现获取IP列表的逻辑
|
||||
return []ProxyIP{}, nil
|
||||
}
|
||||
|
||||
func (p *CustomProvider) RefreshIPList() ([]ProxyIP, error) {
|
||||
// 实现刷新IP列表的逻辑
|
||||
return p.GetIPList()
|
||||
}
|
||||
```
|
||||
|
||||
然后在 `proxy_manager.go` 的 `NewProxyManager` 中添加新模式:
|
||||
|
||||
```go
|
||||
case "custom":
|
||||
m.provider = NewCustomProvider(config.CustomEndpoint)
|
||||
log.Printf("🌐 HTTP 代理已启用 (自定义模式)")
|
||||
```
|
||||
|
||||
## 更新日志
|
||||
|
||||
### v1.0.0 (当前版本)
|
||||
- ✅ 支持三种代理模式:single、pool、brightdata
|
||||
- ✅ 线程安全的IP轮换和黑名单管理
|
||||
- ✅ 自动刷新机制(30分钟默认)
|
||||
- ✅ TTL黑名单自动恢复
|
||||
- ✅ 防越界保护
|
||||
- ✅ ProxyID追踪机制
|
||||
|
||||
|
||||
## 技术支持
|
||||
|
||||
如有问题或建议,请联系项目维护者 @hzb1115
|
||||
。
|
||||
105
proxy/brightdata_provider.go
Normal file
105
proxy/brightdata_provider.go
Normal 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
42
proxy/fixed_provider.go
Normal 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
10
proxy/provider.go
Normal 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
47
proxy/proxy_client.go
Normal 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
346
proxy/proxy_manager.go
Normal 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
19
proxy/single_provider.go
Normal 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
40
proxy/types.go
Normal 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(刷新次数)
|
||||
}
|
||||
@@ -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
|
||||
76
scripts/generate_rsa_keys/main.go
Normal file
76
scripts/generate_rsa_keys/main.go
Normal 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
87
scripts/import_beta_codes.sh
Executable 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
160
scripts/import_default_patch.sh
Executable 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 "🎉 操作完成"
|
||||
367
scripts/migrate_sensitive_data/main.go
Normal file
367
scripts/migrate_sensitive_data/main.go
Normal 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
87
scripts/view_pg_data.sh
Executable 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;"
|
||||
@@ -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;
|
||||
"
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user