mirror of
https://github.com/NoFxAiOS/nofx.git
synced 2025-12-06 13:54:41 +08:00
merge fix
This commit is contained in:
15
.env.example
15
.env.example
@@ -1,21 +1,6 @@
|
||||
# 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
|
||||
|
||||
@@ -17,6 +17,7 @@ import (
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
@@ -22,20 +22,5 @@
|
||||
"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
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
179
db/init.sql
179
db/init.sql
@@ -1,179 +0,0 @@
|
||||
-- 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);
|
||||
38
go.sum
38
go.sum
@@ -34,6 +34,8 @@ 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=
|
||||
@@ -84,6 +86,8 @@ 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=
|
||||
@@ -113,8 +117,6 @@ 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=
|
||||
@@ -134,6 +136,8 @@ 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=
|
||||
@@ -150,6 +154,8 @@ 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=
|
||||
@@ -212,6 +218,8 @@ 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=
|
||||
@@ -247,3 +255,29 @@ 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.DatabaseInterface) error {
|
||||
func (tm *TraderManager) LoadTradersFromDatabase(database *config.Database) error {
|
||||
tm.mu.Lock()
|
||||
defer tm.mu.Unlock()
|
||||
|
||||
@@ -182,7 +182,7 @@ func (tm *TraderManager) LoadTradersFromDatabase(database config.DatabaseInterfa
|
||||
}
|
||||
|
||||
// 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.DatabaseInterface, 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.Database, 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.DatabaseInterface, 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.Database, 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.DatabaseInterface, userID string) error {
|
||||
func (tm *TraderManager) LoadUserTraders(database *config.Database, 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.DatabaseInterface, 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.Database, userID string) error {
|
||||
// 处理交易币种列表
|
||||
var tradingCoins []string
|
||||
if traderCfg.TradingSymbols != "" {
|
||||
|
||||
@@ -1,559 +0,0 @@
|
||||
你是专业的加密货币交易AI,在合约市场进行自主交易。
|
||||
|
||||
# 核心目标
|
||||
|
||||
最大化夏普比率(Sharpe Ratio)
|
||||
|
||||
夏普比率 = 平均收益 / 收益波动率
|
||||
|
||||
这意味着:
|
||||
- 高质量交易(高胜率、大盈亏比)→ 提升夏普
|
||||
- 稳定收益、控制回撤 → 提升夏普
|
||||
- 耐心持仓、让利润奔跑 → 提升夏普
|
||||
- 频繁交易、小盈小亏 → 增加波动,严重降低夏普
|
||||
- 过度交易、手续费损耗 → 直接亏损
|
||||
- 过早平仓、频繁进出 → 错失大行情
|
||||
|
||||
关键认知: 系统每3分钟扫描一次,但不意味着每次都要交易!
|
||||
大多数时候应该是 `wait` 或 `hold`,只在极佳机会时才开仓。
|
||||
|
||||
---
|
||||
|
||||
# 零号原则:疑惑优先(最高优先级)
|
||||
|
||||
⚠️ **当你不确定时,默认选择 wait**
|
||||
|
||||
这是最高优先级原则,覆盖所有其他规则:
|
||||
|
||||
- **有任何疑虑** → 选 wait(不要尝试"勉强开仓")
|
||||
- **完全确定**(信心 ≥85 且无任何犹豫)→ 才开仓
|
||||
- **不确定是否违反某条款** = 视为违反 → 选 wait
|
||||
- **宁可错过机会,不做模糊决策**
|
||||
|
||||
## 灰色地带处理
|
||||
|
||||
```
|
||||
场景 1:指标不够明确(如 MACD 接近 0,RSI 在 45)
|
||||
→ 判定:信号不足 → wait
|
||||
|
||||
场景 2:技术位存在但不够强(如只有 15m EMA20,无 1h 确认)
|
||||
→ 判定:技术位不明确 → wait
|
||||
|
||||
场景 3:信心度刚好 85,但内心犹豫
|
||||
→ 判定:实际信心不足 → wait
|
||||
|
||||
场景 4:BTC 方向勉强算多头,但不够强
|
||||
→ 判定:BTC 状态不明确 → wait
|
||||
```
|
||||
|
||||
## 自我检查
|
||||
|
||||
在输出决策前问自己:
|
||||
1. 我是否 100% 确定这是高质量机会?
|
||||
2. 如果用自己的钱,我会开这单吗?
|
||||
3. 我能清楚说出 3 个开仓理由吗?
|
||||
|
||||
**3 个问题任一回答"否" → 选 wait**
|
||||
|
||||
---
|
||||
|
||||
# 可用动作 (Actions)
|
||||
|
||||
## 开平仓动作
|
||||
|
||||
1. **open_long**: 开多仓(看涨)
|
||||
- 用于: 看涨信号强烈时
|
||||
- 必须设置: 止损价格、止盈价格
|
||||
|
||||
2. **open_short**: 开空仓(看跌)
|
||||
- 用于: 看跌信号强烈时
|
||||
- 必须设置: 止损价格、止盈价格
|
||||
|
||||
3. **close_long**: 平掉多仓
|
||||
- 用于: 止盈、止损、或趋势反转(针对多头持仓)
|
||||
|
||||
4. **close_short**: 平掉空仓
|
||||
- 用于: 止盈、止损、或趋势反转(针对空头持仓)
|
||||
|
||||
5. **wait**: 观望,不持仓
|
||||
- 用于: 没有明确信号,或资金不足
|
||||
|
||||
6. **hold**: 持有当前仓位
|
||||
- 用于: 持仓表现符合预期,继续等待
|
||||
|
||||
## 动态调整动作 (新增)
|
||||
|
||||
6. **update_stop_loss**: 调整止损价格
|
||||
- 用于: 持仓盈利后追踪止损(锁定利润)
|
||||
- 参数: new_stop_loss(新止损价格)
|
||||
- 建议: 盈利 >3% 时,将止损移至成本价或更高
|
||||
|
||||
7. **update_take_profit**: 调整止盈价格
|
||||
- 用于: 优化目标位,适应技术位变化
|
||||
- 参数: new_take_profit(新止盈价格)
|
||||
- 建议: 接近阻力位但未突破时提前止盈,或突破后追高
|
||||
|
||||
8. **partial_close**: 部分平仓
|
||||
- 用于: 分批止盈,降低风险
|
||||
- 参数: close_percentage(平仓百分比 0-100)
|
||||
- 建议: 盈利达到第一目标时先平仓 50-70%
|
||||
|
||||
---
|
||||
|
||||
# 动态止盈止损与部分平仓指引
|
||||
|
||||
- `partial_close` 用于锁定阶段性收益或降低风险,建议使用清晰比例(如 25% / 50% / 75%),并说明目的(例:"锁定关键阻力前利润""减半仓等待回踩确认")。
|
||||
- 执行部分平仓后,应评估是否需要同步上调止损 / 下调止盈,确保剩余仓位符合新的风险回报结构。
|
||||
- `update_stop_loss` / `update_take_profit` 优先用于顺势推进(如跟踪新高新低),避免在无新证据下放宽止损。
|
||||
- 若计划分批退出,请在 `reasoning` 中描述剩余仓位的策略与失效条件,避免出现"减仓后不知道如何处理剩余部位"的情况。
|
||||
|
||||
---
|
||||
|
||||
# 决策流程(严格顺序)
|
||||
|
||||
## 第 0 步:疑惑检查
|
||||
**在所有分析之前,先问自己:我对当前市场有清晰判断吗?**
|
||||
|
||||
- 若感到困惑、矛盾、不确定 → 直接输出 wait
|
||||
- 若完全清晰 → 继续后续步骤
|
||||
|
||||
## 第 1 步:冷却期检查
|
||||
|
||||
开仓前必须满足:
|
||||
- ✅ 距上次开仓 ≥9 分钟
|
||||
- ✅ 当前持仓已持有 ≥30 分钟(若有持仓)
|
||||
- ✅ 刚止损后已观望 ≥6 分钟
|
||||
- ✅ 刚止盈后已观望 ≥3 分钟(若想同方向再入场)
|
||||
|
||||
**不满足 → 输出 wait,reasoning 写明"冷却中"**
|
||||
|
||||
## 第 2 步:连续亏损检查(V5.5.1 新增)
|
||||
|
||||
检查连续亏损状态,触发暂停机制:
|
||||
|
||||
- **连续 2 笔亏损** → 暂停交易 45 分钟(3 个 15m 周期)
|
||||
- **连续 3 笔亏损** → 暂停交易 24 小时
|
||||
- **连续 4 笔亏损** → 暂停交易 72 小时,需人工审查
|
||||
- **单日亏损 >5%** → 立即停止交易,等待人工介入
|
||||
|
||||
⚠️ **暂停期间禁止任何开仓操作,只允许 hold/wait 和持仓管理**
|
||||
|
||||
**若在暂停期内 → 输出 wait,reasoning 写明"连续亏损暂停中"**
|
||||
|
||||
## 第 3 步:夏普比率检查
|
||||
|
||||
- 夏普 < -0.5 → 强制停手 6 周期(18 分钟)
|
||||
- 夏普 -0.5 ~ 0 → 只做信心度 >90 的交易
|
||||
- 夏普 0 ~ 0.7 → 维持当前策略
|
||||
- 夏普 > 0.7 → 可适度扩大仓位
|
||||
|
||||
## 第 4 步:评估持仓
|
||||
|
||||
如果有持仓:
|
||||
1. 趋势是否改变?→ 考虑 close
|
||||
2. 盈利 >3%?→ 考虑 update_stop_loss(移至成本价)
|
||||
3. 盈利达到第一目标?→ 考虑 partial_close(锁定部分利润)
|
||||
4. 接近阻力位?→ 考虑 update_take_profit(调整目标)
|
||||
5. 持仓表现符合预期?→ hold
|
||||
|
||||
## 第 5 步:BTC 状态确认(V5.5.1 新增 - 最关键)
|
||||
|
||||
⚠️ **BTC 是市场领导者,交易任何币种前必须先确认 BTC 状态**
|
||||
|
||||
### 若交易山寨币
|
||||
|
||||
分析 BTC 的多周期趋势方向:
|
||||
- **15m MACD** 方向?(>0 多头,<0 空头)
|
||||
- **1h MACD** 方向?
|
||||
- **4h MACD** 方向?
|
||||
|
||||
**判断标准**:
|
||||
- ✅ **BTC 多周期一致(3 个都 >0 或都 <0)** → BTC 状态明确
|
||||
- ✅ **BTC 多周期中性(2 个同向,1 个反向)** → BTC 状态尚可
|
||||
- ❌ **BTC 多周期矛盾(15m 多头但 1h/4h 空头)** → BTC 状态不明
|
||||
|
||||
**特殊情况检查**:
|
||||
- ❌ BTC 处于整数关口(如 100,000)± 2% → 高度不确定
|
||||
- ❌ BTC 单日波动 >5% → 市场剧烈震荡
|
||||
- ❌ BTC 刚突破/跌破关键技术位 → 等待确认
|
||||
|
||||
**不通过 → 输出 wait,reasoning 写明"BTC 状态不明确"**
|
||||
|
||||
### 若交易 BTC 本身
|
||||
|
||||
使用更高时间框架判断:
|
||||
- **4h MACD** 方向?
|
||||
- **1d MACD** 方向?
|
||||
- **1w MACD** 方向?
|
||||
|
||||
**判断标准**:
|
||||
- ❌ 4h/1d/1w 方向矛盾 → wait
|
||||
- ❌ 处于整数关口(100,000 / 95,000)± 2% → wait
|
||||
- ❌ 1d 波动率 >8% → 极端波动,wait
|
||||
|
||||
⚠️ **交易 BTC 本身应更加谨慎,使用更高时间框架过滤**
|
||||
|
||||
## 第 6 步:多空确认清单(V5.5.1 新增)
|
||||
|
||||
**在评估新机会前,必须先通过方向确认清单**
|
||||
|
||||
⚠️ **至少 5/8 项一致才能开仓,4/8 不足**
|
||||
|
||||
### 做多确认清单
|
||||
|
||||
| 指标 | 做多条件 | 当前状态 |
|
||||
|------|---------|---------|
|
||||
| MACD | >0(多头) | [分析时填写] |
|
||||
| 价格 vs EMA20 | 价格 > EMA20 | [分析时填写] |
|
||||
| RSI | <35(超卖反弹)或 35-50 | [分析时填写] |
|
||||
| BuySellRatio | >0.7(强买)或 >0.55 | [分析时填写] |
|
||||
| 成交量 | 放大(>1.5x 均量) | [分析时填写] |
|
||||
| BTC 状态 | 多头或中性 | [分析时填写] |
|
||||
| 资金费率 | <0(空恐慌)或 -0.01~0.01 | [分析时填写] |
|
||||
| **OI 持仓量** | **变化 >+5%** | [分析时填写] |
|
||||
|
||||
### 做空确认清单
|
||||
|
||||
| 指标 | 做空条件 | 当前状态 |
|
||||
|------|---------|---------|
|
||||
| MACD | <0(空头) | [分析时填写] |
|
||||
| 价格 vs EMA20 | 价格 < EMA20 | [分析时填写] |
|
||||
| RSI | >65(超买回落)或 50-65 | [分析时填写] |
|
||||
| BuySellRatio | <0.3(强卖)或 <0.45 | [分析时填写] |
|
||||
| 成交量 | 放大(>1.5x 均量) | [分析时填写] |
|
||||
| BTC 状态 | 空头或中性 | [分析时填写] |
|
||||
| 资金费率 | >0(多贪婪)或 -0.01~0.01 | [分析时填写] |
|
||||
| **OI 持仓量** | **变化 >+5%** | [分析时填写] |
|
||||
|
||||
**一致性不足 → 输出 wait,reasoning 写明"指标一致性不足:仅 X/8 项一致"**
|
||||
|
||||
### 信号优先级排序(V5.5.1 新增)
|
||||
|
||||
当多个指标出现矛盾时,按以下优先级权重判断:
|
||||
|
||||
**优先级排序(从高到低)**:
|
||||
1. 🔴 **趋势共振**(15m/1h/4h MACD 方向一致)- 权重最高
|
||||
2. 🟠 **放量确认**(成交量 >1.5x 均量)- 动能验证
|
||||
3. 🟡 **BTC 状态**(若交易山寨币)- 市场领导者方向
|
||||
4. 🟢 **RSI 区间**(是否处于合理反转区)- 超买超卖确认
|
||||
5. 🔵 **价格 vs EMA20**(趋势方向确认)- 技术位支撑
|
||||
6. 🟣 **BuySellRatio**(多空力量对比)- 情绪指标
|
||||
7. ⚪ **MACD 柱状图**(短期动能)- 辅助确认
|
||||
8. ⚫ **OI 持仓量变化**(资金流入确认)- 真实突破验证
|
||||
|
||||
#### 应用原则
|
||||
|
||||
- **前 3 项(趋势共振 + 放量 + BTC)全部一致** → 可在其他指标不完美时开仓(5/8 即可)
|
||||
- **前 3 项出现矛盾** → 即使其他指标支持,也应 wait(优先级低的指标不可靠)
|
||||
- **OI 持仓量若无数据** → 可忽略该项,改为 5/7 项一致即可开仓
|
||||
|
||||
## 第 7 步:防假突破检测(V5.5.1 新增)
|
||||
|
||||
在开仓前额外检查以下假突破信号,若触发则禁止开仓:
|
||||
|
||||
### 做多禁止条件
|
||||
- ❌ **15m RSI >70 但 1h RSI <60** → 假突破,15m 可能超买但 1h 未跟上
|
||||
- ❌ **当前 K 线长上影 > 实体长度 × 2** → 上方抛压大,假突破概率高
|
||||
- ❌ **价格突破但成交量萎缩(<均量 × 0.8)** → 缺乏动能,易回撤
|
||||
|
||||
### 做空禁止条件
|
||||
- ❌ **15m RSI <30 但 1h RSI >40** → 假跌破,15m 可能超卖但 1h 未跟上
|
||||
- ❌ **当前 K 线长下影 > 实体长度 × 2** → 下方承接力强,假跌破概率高
|
||||
- ❌ **价格跌破但成交量萎缩(<均量 × 0.8)** → 缺乏动能,易反弹
|
||||
|
||||
### K 线形态过滤
|
||||
- ❌ **十字星 K 线(实体 < 总长度 × 0.2)且处于关键位** → 方向不明,观望
|
||||
- ❌ **连续 3 根 K 线实体极小(实体 < ATR × 0.3)** → 波动率下降,无趋势
|
||||
|
||||
**触发任一防假突破条件 → 输出 wait,reasoning 写明"防假突破:[具体原因]"**
|
||||
|
||||
## 第 8 步:计算信心度并评估机会
|
||||
|
||||
如果无持仓或资金充足,且通过所有检查:
|
||||
|
||||
### 信心度客观评分公式(V5.5.1 新增)
|
||||
|
||||
#### 基础分:60 分
|
||||
|
||||
从 60 分开始,根据以下条件加减分:
|
||||
|
||||
#### 加分项(每项 +5 分,最高 100 分)
|
||||
|
||||
1. ✅ **多空确认清单 ≥5/8 项一致**:+5 分
|
||||
2. ✅ **BTC 状态明确支持**(若交易山寨):+5 分
|
||||
3. ✅ **多时间框架共振**(15m/1h/4h MACD 同向):+5 分
|
||||
4. ✅ **强技术位明确**(1h/4h EMA20 或整数关口):+5 分
|
||||
5. ✅ **成交量确认**(放量 >1.5x 均量):+5 分
|
||||
6. ✅ **资金费率支持**(极端恐慌做多 或 极端贪婪做空):+5 分
|
||||
7. ✅ **风险回报比 ≥1:4**(超过最低要求 1:3):+5 分
|
||||
8. ✅ **止盈技术位距离 2-5%**(理想范围):+5 分
|
||||
|
||||
#### 减分项(每项 -10 分)
|
||||
|
||||
1. ❌ **指标矛盾**(MACD vs 价格 或 RSI vs BuySellRatio):-10 分
|
||||
2. ❌ **BTC 状态不明**(多周期矛盾):-10 分
|
||||
3. ❌ **技术位不清晰**(无强技术位或距离 <0.5%):-10 分
|
||||
4. ❌ **成交量萎缩**(<均量 × 0.7):-10 分
|
||||
|
||||
#### 评分示例
|
||||
|
||||
**场景 1:高质量机会**
|
||||
```
|
||||
基础分:60
|
||||
+ 多空确认 6/8 项:+5
|
||||
+ BTC 多头支持:+5
|
||||
+ 15m/1h/4h 共振:+5
|
||||
+ 1h EMA20 明确:+5
|
||||
+ 成交量 2x 均量:+5
|
||||
+ 风险回报比 1:4.5:+5
|
||||
→ 总分 90 ✅ 可开仓
|
||||
```
|
||||
|
||||
**场景 2:模糊信号**
|
||||
```
|
||||
基础分:60
|
||||
+ 多空确认 4/8 项:0(不足 5/8,不加分)
|
||||
- BTC 状态不明:-10
|
||||
- 15m 多头但 1h 空头(矛盾):-10
|
||||
+ 技术位明确:+5
|
||||
→ 总分 45 ❌ 低于 85,拒绝开仓
|
||||
```
|
||||
|
||||
#### 强制规则
|
||||
|
||||
- **信心度 <85** → 禁止开仓
|
||||
- **信心度 85-90** → 风险预算 1.5%
|
||||
- **信心度 90-95** → 风险预算 2%
|
||||
- **信心度 >95** → 风险预算 2.5%(慎用)
|
||||
|
||||
⚠️ **若多次交易失败但信心度都 ≥90,说明评分虚高,需降低基础分到 50**
|
||||
|
||||
### 最终决策
|
||||
|
||||
1. 分析技术指标(EMA、MACD、RSI)
|
||||
2. 确认多空方向一致性(至少 5/8 项)
|
||||
3. 使用客观公式计算信心度(≥85 才开仓)
|
||||
4. 设置止损、止盈、失效条件
|
||||
5. 调整滑点(见下文)
|
||||
|
||||
---
|
||||
|
||||
# 仓位管理框架
|
||||
|
||||
## 仓位计算公式
|
||||
|
||||
**重要**:position_size_usd 是**名义价值**(包含杠杆),非保证金需求。
|
||||
|
||||
**计算步骤**:
|
||||
1. **可用保证金** = Available Cash × 0.95 × Allocation %(预留5%给手续费)
|
||||
2. **名义价值** = 可用保证金 × Leverage
|
||||
3. **position_size_usd** = 名义价值(这是 JSON 中应填写的值)
|
||||
4. **Position Size (Coins)** = position_size_usd / Current Price
|
||||
|
||||
**示例**:Available Cash = $500, Leverage = 5x, Allocation = 100%
|
||||
- 可用保证金 = $500 × 0.95 × 100% = $475
|
||||
- position_size_usd = $475 × 5 = **$2,375** ← JSON 中填写此值
|
||||
- 实际占用保证金 = $475,剩余 $25 用于手续费
|
||||
|
||||
## 杠杆选择指引
|
||||
|
||||
基于信心度的杠杆配置:
|
||||
- 信心度 <85 → 不开仓
|
||||
- 信心度 85-90 → 杠杆 1-3x,风险预算 1.5%
|
||||
- 信心度 90-95 → 杠杆 3-8x,风险预算 2%
|
||||
- 信心度 >95: 最高 20x 杠杆(谨慎)
|
||||
|
||||
## 风险控制原则
|
||||
|
||||
1. 单笔交易风险不超过账户 2-3%
|
||||
2. 避免单一币种集中度 >40%
|
||||
3. 确保清算价格距离入场价 >15%
|
||||
4. 小额仓位 (<$500) 手续费占比高,需谨慎
|
||||
|
||||
---
|
||||
|
||||
# 风险管理协议 (强制)
|
||||
|
||||
每笔交易必须指定:
|
||||
|
||||
1. **profit_target** (止盈价格)
|
||||
- 最低盈亏比 2:1(盈利 = 2 × 亏损)
|
||||
- 基于技术阻力位、斐波那契、或波动带
|
||||
- 建议在技术位前 0.1-0.2% 设置(防止未成交)
|
||||
|
||||
2. **stop_loss** (止损价格)
|
||||
- 限制单笔亏损在账户 1-3%
|
||||
- 放置在关键支撑/阻力位之外
|
||||
- **滑点调整(V5.5.1 新增)**:
|
||||
- 做多:止损价格下移 0.05%(50,000 → 49,975)
|
||||
- 做空:止损价格上移 0.05%
|
||||
- 预留滑点缓冲,防止实际成交价偏移
|
||||
|
||||
3. **invalidation_condition** (失效条件)
|
||||
- 明确的市场信号,证明交易逻辑失效
|
||||
- 例如: "BTC跌破$100k","RSI跌破30","资金费率转负"
|
||||
|
||||
4. **confidence** (信心度 0-1)
|
||||
- 使用客观评分公式计算(基础分 60 + 条件加减分)
|
||||
- <0.85: 禁止开仓
|
||||
- 0.85-0.90: 风险预算 1.5%
|
||||
- 0.90-0.95: 风险预算 2%
|
||||
- >0.95: 风险预算 2.5%(谨慎使用,警惕过度自信)
|
||||
|
||||
5. **risk_usd** (风险金额)
|
||||
- 计算公式: |入场价 - 止损价| × 仓位数量 × 杠杆
|
||||
- 必须 ≤ 账户净值 × 风险预算(1.5-2.5%)
|
||||
|
||||
6. **slippage_buffer** (滑点缓冲 - V5.5.1 新增)
|
||||
- 预期滑点:0.01-0.1%(取决于仓位大小)
|
||||
- 小仓位(<1000 USDT):0.01-0.02%
|
||||
- 中仓位(1000-5000 USDT):0.02-0.05%
|
||||
- 大仓位(>5000 USDT):0.05-0.1%
|
||||
- **收益检查**:预期收益 > (手续费 + 滑点) × 3
|
||||
|
||||
---
|
||||
|
||||
# 数据解读指南
|
||||
|
||||
## 技术指标说明
|
||||
|
||||
**EMA (指数移动平均线)**: 趋势方向
|
||||
- 价格 > EMA → 上升趋势
|
||||
- 价格 < EMA → 下降趋势
|
||||
|
||||
**MACD (移动平均收敛发散)**: 动量
|
||||
- MACD > 0 → 看涨动量
|
||||
- MACD < 0 → 看跌动量
|
||||
|
||||
**RSI (相对强弱指数)**: 超买/超卖
|
||||
- RSI > 70 → 超买(可能回调)
|
||||
- RSI < 30 → 超卖(可能反弹)
|
||||
- RSI 40-60 → 中性区
|
||||
|
||||
**ATR (平均真实波幅)**: 波动性
|
||||
- 高 ATR → 高波动(止损需更宽)
|
||||
- 低 ATR → 低波动(止损可收紧)
|
||||
|
||||
**持仓量 (Open Interest)**: 市场参与度
|
||||
- 上涨 + OI 增加 → 强势上涨
|
||||
- 下跌 + OI 增加 → 强势下跌
|
||||
- OI 下降 → 趋势减弱
|
||||
- **OI 变化 >+5%** → 真实突破确认(V5.5.1 强调)
|
||||
|
||||
**资金费率 (Funding Rate)**: 市场情绪
|
||||
- 正费率 → 看涨(多方支付空方)
|
||||
- 负费率 → 看跌(空方支付多方)
|
||||
- 极端费率 (>0.01%) → 可能反转信号
|
||||
|
||||
## 数据顺序 (重要)
|
||||
|
||||
⚠️ **所有价格和指标数据按时间排序: 旧 → 新**
|
||||
|
||||
**数组最后一个元素 = 最新数据点**
|
||||
**数组第一个元素 = 最旧数据点**
|
||||
|
||||
---
|
||||
|
||||
# 动态止盈止损策略
|
||||
|
||||
## 追踪止损 (update_stop_loss)
|
||||
|
||||
**使用时机**:
|
||||
1. 持仓盈利 3-5% → 移动止损至成本价(保本)
|
||||
2. 持仓盈利 10% → 移动止损至入场价 +5%(锁定部分利润)
|
||||
3. 价格持续上涨,每上涨 5%,止损上移 3%
|
||||
|
||||
**示例**:
|
||||
```
|
||||
入场: $100, 初始止损: $98 (-2%)
|
||||
价格涨至 $105 (+5%) → 移动止损至 $100 (保本)
|
||||
价格涨至 $110 (+10%) → 移动止损至 $105 (锁定 +5%)
|
||||
```
|
||||
|
||||
## 调整止盈 (update_take_profit)
|
||||
|
||||
**使用时机**:
|
||||
1. 价格接近目标但遇到强阻力 → 提前降低止盈价格
|
||||
2. 价格突破预期阻力位 → 追高止盈价格
|
||||
3. 技术位发生变化(支撑/阻力位突破)
|
||||
|
||||
## 部分平仓 (partial_close)
|
||||
|
||||
**使用时机**:
|
||||
1. 盈利达到第一目标 (5-10%) → 平仓 50%,剩余继续持有
|
||||
2. 市场不确定性增加 → 先平仓 70%,保留 30% 观察
|
||||
3. 盈利达到预期的 2/3 → 平仓 1/2,让剩余仓位追求更大目标
|
||||
|
||||
**示例**:
|
||||
```
|
||||
持仓: 10 BTC,成本 $100,目标 $120
|
||||
价格涨至 $110 (+10%) → partial_close 50% (平掉 5 BTC)
|
||||
→ 锁定利润: 5 × $10 = $50
|
||||
→ 剩余 5 BTC 继续持有,追求 $120 目标
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
# 交易哲学 & 最佳实践
|
||||
|
||||
## 核心原则
|
||||
|
||||
1. **资本保全第一**: 保护资本比追求收益更重要
|
||||
2. **纪律胜于情绪**: 执行退出方案,不随意移动止损
|
||||
3. **质量优于数量**: 少量高信念交易胜过大量低信念交易
|
||||
4. **适应波动性**: 根据市场条件调整仓位
|
||||
5. **尊重趋势**: 不要与强趋势作对
|
||||
6. **BTC 优先**: 交易山寨币前必须确认 BTC 状态(V5.5.1 强调)
|
||||
|
||||
## 常见误区避免
|
||||
|
||||
- ⚠️ **过度交易**: 频繁交易导致手续费侵蚀利润
|
||||
- ⚠️ **复仇式交易**: 亏损后加码试图"翻本"
|
||||
- ⚠️ **分析瘫痪**: 过度等待完美信号
|
||||
- ⚠️ **忽视相关性**: BTC 常引领山寨币,优先观察 BTC
|
||||
- ⚠️ **过度杠杆**: 放大收益同时放大亏损
|
||||
- ⚠️ **假突破陷阱**: 15m 超买但 1h 未跟上,可能是假突破(V5.5.1 新增)
|
||||
- ⚠️ **信心度虚高**: 主观判断 90 分,但客观评分可能只有 65 分(V5.5.1 新增)
|
||||
|
||||
## 交易频率认知
|
||||
|
||||
量化标准:
|
||||
- 优秀交易: 每天 2-4 笔 = 每小时 0.1-0.2 笔
|
||||
- 过度交易: 每小时 >2 笔 = 严重问题
|
||||
- 最佳节奏: 开仓后持有至少 30-60 分钟
|
||||
|
||||
自查:
|
||||
- 每个周期都交易 → 标准太低
|
||||
- 持仓 <30 分钟就平仓 → 太急躁
|
||||
- 连续 2 次止损后仍想立即开仓 → 需暂停 45 分钟(V5.5.1 强制)
|
||||
|
||||
---
|
||||
|
||||
# 最终提醒
|
||||
|
||||
1. 每次决策前仔细阅读用户提示
|
||||
2. 验证仓位计算(仔细检查数学)
|
||||
3. 确保 JSON 输出有效且完整
|
||||
4. 使用客观公式计算信心评分(不要夸大)
|
||||
5. 坚持退出计划(不要过早放弃止损)
|
||||
6. **先检查 BTC 状态,再决定是否开仓**(V5.5.1 核心)
|
||||
7. **疑惑时,选择 wait**(最高原则)
|
||||
|
||||
记住: 你在用真金白银交易真实市场。每个决策都有后果。系统化交易,严格管理风险,让概率随时间为你服务。
|
||||
|
||||
---
|
||||
|
||||
# V5.5.1 核心改进总结
|
||||
|
||||
1. ✅ **BTC 状态检查**(第 5 步)- 交易山寨币的最关键保护
|
||||
2. ✅ **多空确认清单**(第 6 步)- 5/8 项一致,防假信号
|
||||
3. ✅ **客观信心度评分**(第 8 步)- 基础分 60 + 条件加减分
|
||||
4. ✅ **防假突破逻辑**(第 7 步)- RSI 多周期 + K 线形态过滤
|
||||
5. ✅ **连续止损暂停**(第 2 步)- 2 次 45min,3 次 24h,4 次 72h
|
||||
6. ✅ **OI 持仓量确认**(第 6 步清单第 8 项)- >+5% 真实突破
|
||||
7. ✅ **信号优先级排序**(第 6 步)- 趋势共振 > 放量 > BTC > RSI...
|
||||
8. ✅ **滑点处理**(风险管理协议第 2/6 项)- 0.05% 缓冲 + 收益检查
|
||||
|
||||
**设计哲学**:让 AI 自主判断趋势或震荡,不预设策略 A/B,信任强推理模型的能力。
|
||||
|
||||
现在,分析下面提供的市场数据并做出交易决策。
|
||||
@@ -1,194 +0,0 @@
|
||||
你是专业的加密货币交易AI,在合约市场进行自主交易。
|
||||
|
||||
# 核心目标
|
||||
|
||||
最大化夏普比率(Sharpe Ratio)
|
||||
|
||||
夏普比率 = 平均收益 / 收益波动率
|
||||
|
||||
这意味着:
|
||||
- 高质量交易(高胜率、大盈亏比)→ 提升夏普
|
||||
- 稳定收益、控制回撤 → 提升夏普
|
||||
- 耐心持仓、让利润奔跑 → 提升夏普
|
||||
- 频繁交易、小盈小亏 → 增加波动,严重降低夏普
|
||||
- 过度交易、手续费损耗 → 直接亏损
|
||||
|
||||
关键认知:系统每3分钟扫描一次,但不意味着每次都要交易!
|
||||
大多数时候应该是 `wait` 或 `hold`,只在极佳机会时才开仓。
|
||||
|
||||
---
|
||||
|
||||
# 零号原则:疑惑优先
|
||||
|
||||
⚠️ 当你不确定时,默认选择 `wait`。
|
||||
|
||||
这是覆盖所有其他规则的最高优先级:
|
||||
- 任何环节产生疑虑 → 立刻选择 `wait`
|
||||
- 只有当信心 ≥80 且论据充分、条件完全满足时才允许开仓(✅ 从85降至80)
|
||||
- 不确定是否违规 → 视同违规,直接 `wait`
|
||||
|
||||
---
|
||||
|
||||
# 基础交易约束
|
||||
|
||||
- 禁止对同一标的同时持有多空(NO hedging)
|
||||
- 禁止在既有仓位上加码(NO pyramiding)
|
||||
- 允许使用 `partial_close` 锁定利润或降低风险
|
||||
- 每笔交易必须预先设定止损与止盈,止损允许的账户亏损不超过 1-3%
|
||||
- 确保预估清算价距离 ≥15%,避免被强平
|
||||
|
||||
---
|
||||
|
||||
# 仓位管理框架
|
||||
|
||||
## 杠杆选择指引
|
||||
|
||||
基于信心度的杠杆配置:
|
||||
- 信心度 <80 → 不开仓(✅ 从85降至80)
|
||||
- 信心度 80-85 → 杠杆 1-3x,风险预算 1.5%
|
||||
- 信心度 85-92 → 杠杆 3-5x,风险预算 2%
|
||||
- 信心度 >92 → 杠杆 5-8x(谨慎),风险预算 2.5%
|
||||
|
||||
---
|
||||
|
||||
# 决策流程(强制顺序)
|
||||
|
||||
1. **冷却期检查**
|
||||
- 距离上一次开仓 ≥6 分钟(✅ 从9分钟降至6分钟)
|
||||
- 若有持仓:持仓时间 ≥20 分钟(✅ 从30分钟降至20分钟)
|
||||
- 止损出场后至少观望 6 分钟
|
||||
→ 任意条件不满足 → `action = "wait"`
|
||||
|
||||
2. **夏普 / 连亏防御**
|
||||
- 夏普 < -0.5 → 停手 6 个周期(18 分钟)
|
||||
- 连续 2 次亏损 → 暂停 30 分钟(✅ 从45分钟降至30分钟)
|
||||
- 连续 3 次亏损 → 暂停 12 小时(✅ 从24小时降至12小时)
|
||||
- 连续 4 次亏损 → 暂停 48 小时(✅ 从72小时降至48小时)
|
||||
|
||||
3. **持仓管理优先**
|
||||
- 若已有持仓:先评估是否需要平仓或调整止盈止损
|
||||
|
||||
4. **BTC 状态评估(若数据可用)**
|
||||
- 标准模式:拥有 15m / 1h / 4h → 至少两条周期同向且无矛盾视为支持
|
||||
- 简化模式:仅 15m / 4h → 同向视为支持
|
||||
- 若完全缺少 BTC 数据 → 跳过此步,但开仓信心阈值上调至 85
|
||||
|
||||
5. **多周期趋势确认**(✅ 降低要求)
|
||||
|
||||
开仓前必须验证多周期趋势一致性:
|
||||
|
||||
**做多时检查**:
|
||||
- 检查 3m / 15m / 1h / 4h 的价格与 EMA20 关系
|
||||
- 至少 2 个周期显示价格 > EMA20(✅ 从3个降至2个)
|
||||
- 4h MACD ≥ -0.5(✅ 从-0.2放宽至-0.5)
|
||||
|
||||
**做空时检查**:
|
||||
- 至少 2 个周期显示价格 < EMA20(✅ 从3个降至2个)
|
||||
- 4h MACD ≤ +0.5(✅ 从+0.2放宽至+0.5)
|
||||
|
||||
**趋势共振评分**:
|
||||
- 4 个周期全部同向 → 趋势极强(信心 +10)
|
||||
- 3 个周期同向 → 趋势确认(信心 +5)
|
||||
- 2 个周期同向 → 趋势可接受(允许开仓)
|
||||
|
||||
6. **新机会评估**
|
||||
- 多空确认清单 ≥4/8 项通过(✅ 从5/8降至4/8)
|
||||
- 风险回报比 ≥1:2.5(✅ 从1:3降至1:2.5)
|
||||
- 预计收益 > 手续费 ×3
|
||||
- 清算距离 ≥15%
|
||||
- 信心评分 ≥80(若跳过 BTC 检查则 ≥85)
|
||||
|
||||
---
|
||||
|
||||
# 多空确认清单(至少通过 4/8)(✅ 降低要求)
|
||||
|
||||
### 做多确认
|
||||
|
||||
| 指标 | 条件 |
|
||||
|------|------|
|
||||
| 15m MACD | >0(短期动能向上) |
|
||||
| 价格 vs EMA20 | 价格高于 15m / 1h EMA20 |
|
||||
| RSI | <45(超卖或温和超卖)(✅ 从30-40放宽至<45) |
|
||||
| BuySellRatio | ≥0.55(✅ 从0.60降至0.55) |
|
||||
| 成交量 | 近 20 根均量 ×1.3 以上(✅ 从1.5降至1.3) |
|
||||
| BTC 状态* | 多头或中性 |
|
||||
| 资金费率 | <0.02 或 -0.01~0.02 |
|
||||
| 持仓量 OI 变化 | 近 4 小时上升 >+3%(✅ 从+5%降至+3%) |
|
||||
|
||||
### 做空确认
|
||||
|
||||
| 指标 | 条件 |
|
||||
|------|------|
|
||||
| 15m MACD | <0(短期动能向下) |
|
||||
| 价格 vs EMA20 | 价格低于 15m / 1h EMA20 |
|
||||
| RSI | >60(超买或温和超买)(✅ 从65-70放宽至>60) |
|
||||
| BuySellRatio | ≤0.45(✅ 从0.40提高至0.45) |
|
||||
| 成交量 | 近 20 根均量 ×1.3 以上 |
|
||||
| BTC 状态* | 空头或中性 |
|
||||
| 资金费率 | >-0.02 或 -0.02~0.01 |
|
||||
| 持仓量 OI 变化 | 近 4 小时上升 >+3% |
|
||||
|
||||
---
|
||||
|
||||
# 客观信心评分(基础分 60)
|
||||
|
||||
1. **基础分:60**
|
||||
2. **加分项(每项 +5,最高 100)**
|
||||
- 多空确认清单 ≥4 项通过
|
||||
- BTC 状态明确支持
|
||||
- 多周期趋势共振(2 个周期同向 +3,3 个周期同向 +5,4 个周期全同向 +10)
|
||||
- 15m / 1h / 4h MACD 同向
|
||||
- 关键技术位明确(1h / 4h EMA、整数关口)
|
||||
- 成交量放大(>1.3× 均量)
|
||||
- 资金费率情绪背离
|
||||
- 风险回报 ≥1:3
|
||||
3. **减分项(每项 -10)**
|
||||
- 指标互相矛盾(MACD 与价格背离)
|
||||
- BTC 状态不明仍计划大幅开仓
|
||||
- 技术位不清晰或过近(<0.5%)
|
||||
- 成交量萎缩(< 均量 ×0.7)
|
||||
4. **阈值规则**
|
||||
- <80 → 禁止开仓
|
||||
- 80-85 → 风险预算 1.5%,杠杆 1-3x
|
||||
- 85-92 → 风险预算 2%,杠杆 3-5x
|
||||
- >92 → 风险预算 2.5%,杠杆 5-8x
|
||||
|
||||
---
|
||||
|
||||
# 最终检查清单(开仓前必须全部通过)
|
||||
|
||||
1. 冷却期合格(6分钟)
|
||||
2. 夏普 / 连亏未触发停手
|
||||
3. **多周期趋势确认通过(至少 2 个周期同向)**
|
||||
4. BTC 状态明确支持(或缺失时已说明并提高阈值)
|
||||
5. 多空确认清单 ≥4/8
|
||||
6. 风险回报 ≥1:2.5
|
||||
7. 预计收益 > 手续费 ×3
|
||||
8. 清算距离 ≥15%
|
||||
9. 客观信心评分 ≥80(缺 BTC 数据时 ≥85)
|
||||
10. 失效条件已定义且写入 reasoning
|
||||
|
||||
任意一项未通过 → 立即选择 `wait`,并说明具体原因。
|
||||
|
||||
---
|
||||
|
||||
## 版本说明
|
||||
|
||||
**adaptive_relaxed v1.0 - 保守优化版**
|
||||
|
||||
核心调整:
|
||||
1. ✅ 信心度阈值:85 → 80
|
||||
2. ✅ 冷却期:9分钟 → 6分钟
|
||||
3. ✅ 多周期趋势:3个同向 → 2个同向
|
||||
4. ✅ 多空确认清单:5/8 → 4/8
|
||||
5. ✅ RSI 放宽:30-40/65-70 → <45/>60
|
||||
6. ✅ BuySellRatio 放宽:0.60/0.40 → 0.55/0.45
|
||||
7. ✅ 成交量要求:1.5× → 1.3×
|
||||
8. ✅ OI 变化:+5% → +3%
|
||||
9. ✅ 风险回报比:1:3 → 1:2.5
|
||||
|
||||
预期效果:
|
||||
- 交易频率增加 50-80%(一天 8-15 笔)
|
||||
- 保持 50%+ 胜率
|
||||
- 允许更多山寨币机会
|
||||
- 保持核心風控(夏普、連虧停手)
|
||||
685
proxy/README.md
685
proxy/README.md
@@ -1,685 +0,0 @@
|
||||
# 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
|
||||
。
|
||||
@@ -1,105 +0,0 @@
|
||||
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
|
||||
}
|
||||
@@ -1,42 +0,0 @@
|
||||
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
|
||||
}
|
||||
@@ -1,10 +0,0 @@
|
||||
package proxy
|
||||
|
||||
// IPProvider IP提供者接口
|
||||
type IPProvider interface {
|
||||
// GetIPList 获取IP列表
|
||||
GetIPList() ([]ProxyIP, error)
|
||||
|
||||
// RefreshIPList 刷新IP列表(可选实现)
|
||||
RefreshIPList() ([]ProxyIP, error)
|
||||
}
|
||||
@@ -1,47 +0,0 @@
|
||||
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)
|
||||
}
|
||||
@@ -1,346 +0,0 @@
|
||||
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)
|
||||
}
|
||||
@@ -1,19 +0,0 @@
|
||||
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()
|
||||
}
|
||||
@@ -1,40 +0,0 @@
|
||||
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,228 +0,0 @@
|
||||
#!/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
|
||||
|
||||
BETA_CODES_FILE="beta_codes.txt"
|
||||
COUNT=1
|
||||
LIST_ONLY=false
|
||||
CODE_LENGTH=6
|
||||
|
||||
# 字符集(避免易混淆字符:0/O, 1/I/l)
|
||||
CHARSET="23456789abcdefghjkmnpqrstuvwxyz"
|
||||
|
||||
# 显示帮助信息
|
||||
show_help() {
|
||||
cat << EOF
|
||||
用法: $0 [选项]
|
||||
|
||||
选项:
|
||||
-c COUNT 生成内测码数量 (默认: 1)
|
||||
-l 列出现有内测码
|
||||
-f FILE 内测码文件路径 (默认: beta_codes.txt)
|
||||
-h 显示此帮助信息
|
||||
|
||||
示例:
|
||||
$0 -c 10 # 生成10个内测码
|
||||
$0 -l # 列出现有内测码
|
||||
$0 -f custom.txt -c 5 # 在自定义文件中生成5个内测码
|
||||
EOF
|
||||
}
|
||||
|
||||
# 生成随机内测码
|
||||
generate_beta_code() {
|
||||
local length="$1"
|
||||
local charset="$2"
|
||||
local code=""
|
||||
|
||||
for ((i=0; i<length; i++)); do
|
||||
local random_index=$((RANDOM % ${#charset}))
|
||||
code+="${charset:$random_index:1}"
|
||||
done
|
||||
|
||||
echo "$code"
|
||||
}
|
||||
|
||||
# 读取现有内测码
|
||||
read_existing_codes() {
|
||||
local file="$1"
|
||||
if [ -f "$file" ]; then
|
||||
grep -v '^$' "$file" 2>/dev/null | tr -d ' \t' | grep -v '^#' || true
|
||||
fi
|
||||
}
|
||||
|
||||
# 检查内测码是否已存在
|
||||
code_exists() {
|
||||
local code="$1"
|
||||
local file="$2"
|
||||
if [ -f "$file" ]; then
|
||||
grep -Fxq "$code" "$file" 2>/dev/null
|
||||
else
|
||||
return 1
|
||||
fi
|
||||
}
|
||||
|
||||
# 添加内测码到文件
|
||||
add_code_to_file() {
|
||||
local code="$1"
|
||||
local file="$2"
|
||||
echo "$code" >> "$file"
|
||||
}
|
||||
|
||||
# 验证内测码格式
|
||||
validate_code() {
|
||||
local code="$1"
|
||||
# 检查长度
|
||||
if [ ${#code} -ne $CODE_LENGTH ]; then
|
||||
return 1
|
||||
fi
|
||||
# 检查字符是否都在允许的字符集中
|
||||
if [[ ! "$code" =~ ^[$CHARSET]+$ ]]; then
|
||||
return 1
|
||||
fi
|
||||
return 0
|
||||
}
|
||||
|
||||
# 去重并排序内测码
|
||||
dedupe_and_sort_codes() {
|
||||
local file="$1"
|
||||
if [ -f "$file" ]; then
|
||||
# 过滤空行和注释,去重并排序
|
||||
grep -v '^$' "$file" | grep -v '^#' | sort -u > "${file}.tmp" && mv "${file}.tmp" "$file"
|
||||
fi
|
||||
}
|
||||
|
||||
# 解析命令行参数
|
||||
while getopts "c:lf:h" opt; do
|
||||
case $opt in
|
||||
c)
|
||||
COUNT="$OPTARG"
|
||||
if ! [[ "$COUNT" =~ ^[0-9]+$ ]] || [ "$COUNT" -lt 1 ]; then
|
||||
echo "错误: count 必须是正整数" >&2
|
||||
exit 1
|
||||
fi
|
||||
;;
|
||||
l)
|
||||
LIST_ONLY=true
|
||||
;;
|
||||
f)
|
||||
BETA_CODES_FILE="$OPTARG"
|
||||
;;
|
||||
h)
|
||||
show_help
|
||||
exit 0
|
||||
;;
|
||||
\?)
|
||||
echo "无效选项: -$OPTARG" >&2
|
||||
echo "使用 -h 查看帮助信息" >&2
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
done
|
||||
|
||||
# 如果是列出现有内测码
|
||||
if [ "$LIST_ONLY" = true ]; then
|
||||
if [ -f "$BETA_CODES_FILE" ]; then
|
||||
existing_codes=$(read_existing_codes "$BETA_CODES_FILE")
|
||||
if [ -z "$existing_codes" ]; then
|
||||
echo "内测码列表为空"
|
||||
else
|
||||
count=$(echo "$existing_codes" | wc -l | tr -d ' ')
|
||||
echo "当前内测码 ($count 个):"
|
||||
echo "$existing_codes" | nl -w3 -s'. '
|
||||
fi
|
||||
else
|
||||
echo "内测码文件不存在: $BETA_CODES_FILE"
|
||||
fi
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# 读取现有内测码
|
||||
existing_codes=$(read_existing_codes "$BETA_CODES_FILE")
|
||||
|
||||
# 生成新内测码
|
||||
new_codes=()
|
||||
max_attempts=1000 # 防止无限循环
|
||||
|
||||
echo "正在生成 $COUNT 个内测码..."
|
||||
|
||||
for ((i=1; i<=COUNT; i++)); do
|
||||
attempts=0
|
||||
while [ $attempts -lt $max_attempts ]; do
|
||||
code=$(generate_beta_code $CODE_LENGTH "$CHARSET")
|
||||
|
||||
# 验证格式
|
||||
if ! validate_code "$code"; then
|
||||
((attempts++))
|
||||
continue
|
||||
fi
|
||||
|
||||
# 检查是否已存在
|
||||
if code_exists "$code" "$BETA_CODES_FILE"; then
|
||||
((attempts++))
|
||||
continue
|
||||
fi
|
||||
|
||||
# 检查是否与本次生成的重复
|
||||
duplicate=false
|
||||
for existing_code in "${new_codes[@]}"; do
|
||||
if [ "$code" = "$existing_code" ]; then
|
||||
duplicate=true
|
||||
break
|
||||
fi
|
||||
done
|
||||
|
||||
if [ "$duplicate" = false ]; then
|
||||
new_codes+=("$code")
|
||||
break
|
||||
fi
|
||||
|
||||
((attempts++))
|
||||
done
|
||||
|
||||
if [ $attempts -eq $max_attempts ]; then
|
||||
echo "警告: 生成第 $i 个内测码时达到最大尝试次数,可能字符空间不足" >&2
|
||||
break
|
||||
fi
|
||||
done
|
||||
|
||||
# 检查是否成功生成了内测码
|
||||
if [ ${#new_codes[@]} -eq 0 ]; then
|
||||
echo "未能生成任何新的内测码"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# 添加到文件
|
||||
for code in "${new_codes[@]}"; do
|
||||
add_code_to_file "$code" "$BETA_CODES_FILE"
|
||||
done
|
||||
|
||||
# 去重并排序
|
||||
dedupe_and_sort_codes "$BETA_CODES_FILE"
|
||||
|
||||
echo "成功生成 ${#new_codes[@]} 个内测码:"
|
||||
printf ' %s\n' "${new_codes[@]}"
|
||||
echo
|
||||
echo "内测码文件: $BETA_CODES_FILE"
|
||||
|
||||
# 显示当前总数
|
||||
if [ -f "$BETA_CODES_FILE" ]; then
|
||||
total_count=$(read_existing_codes "$BETA_CODES_FILE" | wc -l | tr -d ' ')
|
||||
echo "当前内测码总计: $total_count 个"
|
||||
fi
|
||||
|
||||
# 显示文件头部信息(如果是新文件)
|
||||
if [ ! -s "$BETA_CODES_FILE" ] || [ $(wc -l < "$BETA_CODES_FILE") -eq ${#new_codes[@]} ]; then
|
||||
echo
|
||||
echo "内测码规则:"
|
||||
echo "- 长度: $CODE_LENGTH 位"
|
||||
echo "- 字符集: 数字 2-9, 小写字母 a-z (排除 0,1,i,l,o 避免混淆)"
|
||||
echo "- 每个内测码唯一且不重复"
|
||||
fi
|
||||
@@ -1,76 +0,0 @@
|
||||
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注意: 请妥善保管私钥文件,不要提交到版本控制系统中!")
|
||||
}
|
||||
@@ -1,87 +0,0 @@
|
||||
#!/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 "✅ 导入完成(重复的已跳过)"
|
||||
@@ -1,160 +0,0 @@
|
||||
#!/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 "🎉 操作完成"
|
||||
@@ -1,367 +0,0 @@
|
||||
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()
|
||||
}
|
||||
@@ -1,87 +0,0 @@
|
||||
#!/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;"
|
||||
@@ -173,28 +173,6 @@ check_config() {
|
||||
print_success "配置文件存在"
|
||||
}
|
||||
|
||||
# ------------------------------------------------------------------------
|
||||
# Validation: Beta Code File (beta_codes.txt)
|
||||
# ------------------------------------------------------------------------
|
||||
check_beta_codes_file() {
|
||||
local beta_file="beta_codes.txt"
|
||||
|
||||
if [ -d "$beta_file" ]; then
|
||||
print_warning "beta_codes.txt 是目录,正在删除后重建文件..."
|
||||
rm -rf "$beta_file"
|
||||
touch "$beta_file"
|
||||
chmod 600 "$beta_file"
|
||||
print_info "✓ 已重新创建 beta_codes.txt(权限: 600)"
|
||||
elif [ ! -f "$beta_file" ]; then
|
||||
print_warning "beta_codes.txt 不存在,正在创建空文件..."
|
||||
touch "$beta_file"
|
||||
chmod 600 "$beta_file"
|
||||
print_info "✓ 已创建空的内测码文件(权限: 600)"
|
||||
else
|
||||
print_success "内测码文件存在"
|
||||
fi
|
||||
}
|
||||
|
||||
# ------------------------------------------------------------------------
|
||||
# Utility: Read Environment Variables
|
||||
# ------------------------------------------------------------------------
|
||||
@@ -282,7 +260,6 @@ start() {
|
||||
# 确保必要的文件和目录存在(修复 Docker volume 挂载问题)
|
||||
if [ ! -f "config.db" ]; then
|
||||
print_info "创建数据库文件..."
|
||||
touch config.db
|
||||
install -m 600 /dev/null config.db
|
||||
fi
|
||||
if [ ! -d "decision_logs" ]; then
|
||||
@@ -436,7 +413,6 @@ main() {
|
||||
check_env
|
||||
check_encryption
|
||||
check_config
|
||||
check_beta_codes_file
|
||||
check_database
|
||||
start "$2"
|
||||
;;
|
||||
110
web/package-lock.json
generated
110
web/package-lock.json
generated
@@ -120,7 +120,6 @@
|
||||
"resolved": "https://registry.npmjs.org/@babel/core/-/core-7.28.5.tgz",
|
||||
"integrity": "sha512-e7jT4DxYvIDLk1ZHmU/m/mB19rex9sv0c2ftBtjSBv+kVM/902eh0fINUzD7UwLLNR+jU585GxUJ8/EBfAM5fw==",
|
||||
"dev": true,
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@babel/code-frame": "^7.27.1",
|
||||
"@babel/generator": "^7.28.5",
|
||||
@@ -452,7 +451,6 @@
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
},
|
||||
@@ -476,7 +474,6 @@
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
@@ -1170,6 +1167,7 @@
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
@@ -1181,6 +1179,7 @@
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@inquirer/core": "^10.3.0",
|
||||
"@inquirer/type": "^3.0.9"
|
||||
@@ -1204,6 +1203,7 @@
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@inquirer/ansi": "^1.0.1",
|
||||
"@inquirer/figures": "^1.0.14",
|
||||
@@ -1233,6 +1233,7 @@
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
@@ -1244,6 +1245,7 @@
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"color-convert": "^2.0.1"
|
||||
},
|
||||
@@ -1260,7 +1262,8 @@
|
||||
"integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true
|
||||
"optional": true,
|
||||
"peer": true
|
||||
},
|
||||
"node_modules/@inquirer/core/node_modules/string-width": {
|
||||
"version": "4.2.3",
|
||||
@@ -1269,6 +1272,7 @@
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"emoji-regex": "^8.0.0",
|
||||
"is-fullwidth-code-point": "^3.0.0",
|
||||
@@ -1285,6 +1289,7 @@
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"ansi-regex": "^5.0.1"
|
||||
},
|
||||
@@ -1299,6 +1304,7 @@
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"ansi-styles": "^4.0.0",
|
||||
"string-width": "^4.1.0",
|
||||
@@ -1315,6 +1321,7 @@
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
@@ -1326,6 +1333,7 @@
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
},
|
||||
@@ -1407,6 +1415,7 @@
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@open-draft/deferred-promise": "^2.2.0",
|
||||
"@open-draft/logger": "^0.3.0",
|
||||
@@ -1460,7 +1469,8 @@
|
||||
"integrity": "sha512-CecwLWx3rhxVQF6V4bAgPS5t+So2sTbPgAzafKkVizyi7tlwpcFpdFqq+wqF2OwNBmqFuu6tOyouTuxgpMfzmA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true
|
||||
"optional": true,
|
||||
"peer": true
|
||||
},
|
||||
"node_modules/@open-draft/logger": {
|
||||
"version": "0.3.0",
|
||||
@@ -1469,6 +1479,7 @@
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"is-node-process": "^1.2.0",
|
||||
"outvariant": "^1.4.0"
|
||||
@@ -1480,7 +1491,8 @@
|
||||
"integrity": "sha512-U69T3ItWHvLwGg5eJ0n3I62nWuE6ilHlmz7zM0npLBRvPRd7e6NYmg54vvRtP5mZG7kZqZCFVdsTWo7BPtBujg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true
|
||||
"optional": true,
|
||||
"peer": true
|
||||
},
|
||||
"node_modules/@pkgjs/parseargs": {
|
||||
"version": "0.11.0",
|
||||
@@ -2258,7 +2270,8 @@
|
||||
"resolved": "https://registry.npmjs.org/@types/aria-query/-/aria-query-5.0.4.tgz",
|
||||
"integrity": "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
"license": "MIT",
|
||||
"peer": true
|
||||
},
|
||||
"node_modules/@types/babel__core": {
|
||||
"version": "7.20.5",
|
||||
@@ -2379,7 +2392,6 @@
|
||||
"resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.26.tgz",
|
||||
"integrity": "sha512-RFA/bURkcKzx/X9oumPG9Vp3D3JUgus/d0b67KB0t5S/raciymilkOa66olh78MUI92QLbEJevO7rvqU/kjwKA==",
|
||||
"devOptional": true,
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@types/prop-types": "*",
|
||||
"csstype": "^3.0.2"
|
||||
@@ -2390,7 +2402,6 @@
|
||||
"resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-18.3.7.tgz",
|
||||
"integrity": "sha512-MEe3UeoENYVFXzoXEWsvcpg6ZvlrFNlOQ7EOsvhI3CfAXwzPfO8Qwuxd40nepsYKqyyVQnTdEfv68q91yLcKrQ==",
|
||||
"devOptional": true,
|
||||
"peer": true,
|
||||
"peerDependencies": {
|
||||
"@types/react": "^18.0.0"
|
||||
}
|
||||
@@ -2401,7 +2412,8 @@
|
||||
"integrity": "sha512-xMAgYwceFhRA2zY+XbEA7mxYbA093wdiW8Vu6gZPGWy9cmOyU9XesH1tNcEWsKFd5Vzrqx5T3D38PWx1FIIXkA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true
|
||||
"optional": true,
|
||||
"peer": true
|
||||
},
|
||||
"node_modules/@typescript-eslint/eslint-plugin": {
|
||||
"version": "8.46.3",
|
||||
@@ -2439,7 +2451,6 @@
|
||||
"integrity": "sha512-6m1I5RmHBGTnUGS113G04DMu3CpSdxCAU/UvtjNWL4Nuf3MW9tQhiJqRlHzChIkhy6kZSAQmc+I1bcGjE3yNKg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@typescript-eslint/scope-manager": "8.46.3",
|
||||
"@typescript-eslint/types": "8.46.3",
|
||||
@@ -2764,7 +2775,6 @@
|
||||
"integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"bin": {
|
||||
"acorn": "bin/acorn"
|
||||
},
|
||||
@@ -3188,7 +3198,6 @@
|
||||
"url": "https://github.com/sponsors/ai"
|
||||
}
|
||||
],
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"baseline-browser-mapping": "^2.8.19",
|
||||
"caniuse-lite": "^1.0.30001751",
|
||||
@@ -3467,6 +3476,7 @@
|
||||
"dev": true,
|
||||
"license": "ISC",
|
||||
"optional": true,
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">= 12"
|
||||
}
|
||||
@@ -3478,6 +3488,7 @@
|
||||
"dev": true,
|
||||
"license": "ISC",
|
||||
"optional": true,
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"string-width": "^4.2.0",
|
||||
"strip-ansi": "^6.0.1",
|
||||
@@ -3494,6 +3505,7 @@
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
@@ -3505,6 +3517,7 @@
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"color-convert": "^2.0.1"
|
||||
},
|
||||
@@ -3521,7 +3534,8 @@
|
||||
"integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true
|
||||
"optional": true,
|
||||
"peer": true
|
||||
},
|
||||
"node_modules/cliui/node_modules/string-width": {
|
||||
"version": "4.2.3",
|
||||
@@ -3530,6 +3544,7 @@
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"emoji-regex": "^8.0.0",
|
||||
"is-fullwidth-code-point": "^3.0.0",
|
||||
@@ -3546,6 +3561,7 @@
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"ansi-regex": "^5.0.1"
|
||||
},
|
||||
@@ -3560,6 +3576,7 @@
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"ansi-styles": "^4.0.0",
|
||||
"string-width": "^4.1.0",
|
||||
@@ -4031,7 +4048,8 @@
|
||||
"resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.5.16.tgz",
|
||||
"integrity": "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
"license": "MIT",
|
||||
"peer": true
|
||||
},
|
||||
"node_modules/dom-helpers": {
|
||||
"version": "5.2.1",
|
||||
@@ -4354,7 +4372,6 @@
|
||||
"integrity": "sha512-BhHmn2yNOFA9H9JmmIVKJmd288g9hrVRDkdoIgRCRuSySRUHH7r/DI6aAXW9T1WwUuY3DFgrcaqB+deURBLR5g==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@eslint-community/eslint-utils": "^4.8.0",
|
||||
"@eslint-community/regexpp": "^4.12.1",
|
||||
@@ -4415,7 +4432,6 @@
|
||||
"integrity": "sha512-82GZUjRS0p/jganf6q1rEO25VSoHH0hKPCTrgillPjdI/3bgBhAE1QzHrHTizjpRvy6pGAvKjDJtk2pF9NDq8w==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"bin": {
|
||||
"eslint-config-prettier": "bin/cli.js"
|
||||
},
|
||||
@@ -5046,6 +5062,7 @@
|
||||
"dev": true,
|
||||
"license": "ISC",
|
||||
"optional": true,
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": "6.* || 8.* || >= 10.*"
|
||||
}
|
||||
@@ -5218,6 +5235,7 @@
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": "^12.22.0 || ^14.16.0 || ^16.0.0 || >=17.0.0"
|
||||
}
|
||||
@@ -5321,7 +5339,8 @@
|
||||
"integrity": "sha512-IScLbePpkvO846sIwOtOTDjutRMWdXdJmXdMvk6gCBHxFO8d+QKOQedyZSxFTTFYRSmlgSTDtXqqq4pcenBXLQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true
|
||||
"optional": true,
|
||||
"peer": true
|
||||
},
|
||||
"node_modules/hermes-estree": {
|
||||
"version": "0.25.1",
|
||||
@@ -5724,7 +5743,8 @@
|
||||
"integrity": "sha512-Vg4o6/fqPxIjtxgUH5QLJhwZ7gW5diGCVlXpuUfELC62CuxM1iHcRe51f2W1FDy04Ai4KJkagKjx3XaqyfRKXw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true
|
||||
"optional": true,
|
||||
"peer": true
|
||||
},
|
||||
"node_modules/is-number": {
|
||||
"version": "7.0.0",
|
||||
@@ -5955,7 +5975,6 @@
|
||||
"resolved": "https://registry.npmjs.org/jiti/-/jiti-1.21.7.tgz",
|
||||
"integrity": "sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A==",
|
||||
"dev": true,
|
||||
"peer": true,
|
||||
"bin": {
|
||||
"jiti": "bin/jiti.js"
|
||||
}
|
||||
@@ -5984,7 +6003,6 @@
|
||||
"integrity": "sha512-8i7LzZj7BF8uplX+ZyOlIz86V6TAsSs+np6m1kpW9u0JWi4z/1t+FzcK1aek+ybTnAC4KhBL4uXCNT0wcUIeCw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"cssstyle": "^4.1.0",
|
||||
"data-urls": "^5.0.0",
|
||||
@@ -6359,6 +6377,7 @@
|
||||
"integrity": "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"bin": {
|
||||
"lz-string": "bin/bin.js"
|
||||
}
|
||||
@@ -6504,6 +6523,7 @@
|
||||
"hasInstallScript": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@inquirer/confirm": "^5.0.0",
|
||||
"@mswjs/interceptors": "^0.40.0",
|
||||
@@ -6549,6 +6569,7 @@
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"tldts-core": "^7.0.17"
|
||||
},
|
||||
@@ -6562,7 +6583,8 @@
|
||||
"integrity": "sha512-DieYoGrP78PWKsrXr8MZwtQ7GLCUeLxihtjC1jZsW1DnvSMdKPitJSe8OSYDM2u5H6g3kWJZpePqkp43TfLh0g==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true
|
||||
"optional": true,
|
||||
"peer": true
|
||||
},
|
||||
"node_modules/msw/node_modules/tough-cookie": {
|
||||
"version": "6.0.0",
|
||||
@@ -6571,6 +6593,7 @@
|
||||
"dev": true,
|
||||
"license": "BSD-3-Clause",
|
||||
"optional": true,
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"tldts": "^7.0.5"
|
||||
},
|
||||
@@ -6585,6 +6608,7 @@
|
||||
"dev": true,
|
||||
"license": "ISC",
|
||||
"optional": true,
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": "^18.17.0 || >=20.5.0"
|
||||
}
|
||||
@@ -6824,7 +6848,8 @@
|
||||
"integrity": "sha512-+Sl2UErvtsoajRDKCE5/dBz4DIvHXQQnAxtQTF04OJxY0+DyZXSo5P5Bb7XYWOh81syohlYL24hbDwxedPUJCA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true
|
||||
"optional": true,
|
||||
"peer": true
|
||||
},
|
||||
"node_modules/own-keys": {
|
||||
"version": "1.0.1",
|
||||
@@ -6961,7 +6986,8 @@
|
||||
"integrity": "sha512-Yhpw4T9C6hPpgPeA28us07OJeqZ5EzQTkbfwuhsUg0c237RomFoETJgmp2sa3F/41gfLE6G5cqcYwznmeEeOlQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true
|
||||
"optional": true,
|
||||
"peer": true
|
||||
},
|
||||
"node_modules/pathe": {
|
||||
"version": "1.1.2",
|
||||
@@ -7058,7 +7084,6 @@
|
||||
"url": "https://github.com/sponsors/ai"
|
||||
}
|
||||
],
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"nanoid": "^3.3.11",
|
||||
"picocolors": "^1.1.1",
|
||||
@@ -7212,7 +7237,6 @@
|
||||
"integrity": "sha512-I7AIg5boAr5R0FFtJ6rCfD+LFsWHp81dolrFD8S79U9tb8Az2nGrJncnMSnys+bpQJfRUzqs9hnA81OAA3hCuQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"bin": {
|
||||
"prettier": "bin/prettier.cjs"
|
||||
},
|
||||
@@ -7242,6 +7266,7 @@
|
||||
"integrity": "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"ansi-regex": "^5.0.1",
|
||||
"ansi-styles": "^5.0.0",
|
||||
@@ -7257,6 +7282,7 @@
|
||||
"integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
@@ -7267,6 +7293,7 @@
|
||||
"integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
},
|
||||
@@ -7279,7 +7306,8 @@
|
||||
"resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz",
|
||||
"integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
"license": "MIT",
|
||||
"peer": true
|
||||
},
|
||||
"node_modules/prop-types": {
|
||||
"version": "15.8.1",
|
||||
@@ -7330,7 +7358,6 @@
|
||||
"version": "18.3.1",
|
||||
"resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz",
|
||||
"integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"loose-envify": "^1.1.0"
|
||||
},
|
||||
@@ -7342,7 +7369,6 @@
|
||||
"version": "18.3.1",
|
||||
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz",
|
||||
"integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"loose-envify": "^1.1.0",
|
||||
"scheduler": "^0.23.2"
|
||||
@@ -7626,6 +7652,7 @@
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
@@ -7683,7 +7710,8 @@
|
||||
"integrity": "sha512-LPRKoHnLKd/r3dVxcwO7vhCW+orkOGj9ViueosEBK6ie89CijnfRlhaDhHq/3Hxu4CkWQtxwlBG0mzTQY6uQjw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true
|
||||
"optional": true,
|
||||
"peer": true
|
||||
},
|
||||
"node_modules/reusify": {
|
||||
"version": "1.1.0",
|
||||
@@ -8102,6 +8130,7 @@
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">= 0.8"
|
||||
}
|
||||
@@ -8133,7 +8162,8 @@
|
||||
"integrity": "sha512-vMgjE/GGEPEFnhFub6pa4FmJBRBVOLpIII2hvCZ8Kzb7K0hlHo7mQv6xYrBvCL2LtAIBwFUK8wvuJgTVSQ5MFQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true
|
||||
"optional": true,
|
||||
"peer": true
|
||||
},
|
||||
"node_modules/string-argv": {
|
||||
"version": "0.3.2",
|
||||
@@ -8572,7 +8602,6 @@
|
||||
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz",
|
||||
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
|
||||
"dev": true,
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
},
|
||||
@@ -8713,6 +8742,7 @@
|
||||
"dev": true,
|
||||
"license": "(MIT OR CC0-1.0)",
|
||||
"optional": true,
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=16"
|
||||
},
|
||||
@@ -8803,7 +8833,6 @@
|
||||
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
|
||||
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
|
||||
"dev": true,
|
||||
"peer": true,
|
||||
"bin": {
|
||||
"tsc": "bin/tsc",
|
||||
"tsserver": "bin/tsserver"
|
||||
@@ -8838,6 +8867,7 @@
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"peer": true,
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/kettanaito"
|
||||
}
|
||||
@@ -8965,7 +8995,6 @@
|
||||
"resolved": "https://registry.npmjs.org/vite/-/vite-6.4.1.tgz",
|
||||
"integrity": "sha512-+Oxm7q9hDoLMyJOYfUYBuHQo+dkAloi33apOPP56pzj+vsdJDzr+j1NISE5pyaAuKL4A3UD34qd0lx5+kfKp2g==",
|
||||
"dev": true,
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"esbuild": "^0.25.0",
|
||||
"fdir": "^6.4.4",
|
||||
@@ -9570,7 +9599,6 @@
|
||||
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz",
|
||||
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
|
||||
"dev": true,
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
},
|
||||
@@ -10107,7 +10135,6 @@
|
||||
"integrity": "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"esbuild": "^0.21.3",
|
||||
"postcss": "^8.4.43",
|
||||
@@ -10490,6 +10517,7 @@
|
||||
"dev": true,
|
||||
"license": "ISC",
|
||||
"optional": true,
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
}
|
||||
@@ -10520,6 +10548,7 @@
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"cliui": "^8.0.1",
|
||||
"escalade": "^3.1.1",
|
||||
@@ -10540,6 +10569,7 @@
|
||||
"dev": true,
|
||||
"license": "ISC",
|
||||
"optional": true,
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
@@ -10551,6 +10581,7 @@
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
@@ -10561,7 +10592,8 @@
|
||||
"integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true
|
||||
"optional": true,
|
||||
"peer": true
|
||||
},
|
||||
"node_modules/yargs/node_modules/string-width": {
|
||||
"version": "4.2.3",
|
||||
@@ -10570,6 +10602,7 @@
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"emoji-regex": "^8.0.0",
|
||||
"is-fullwidth-code-point": "^3.0.0",
|
||||
@@ -10586,6 +10619,7 @@
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"ansi-regex": "^5.0.1"
|
||||
},
|
||||
@@ -10613,6 +10647,7 @@
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
},
|
||||
@@ -10626,7 +10661,6 @@
|
||||
"integrity": "sha512-JInaHOamG8pt5+Ey8kGmdcAcg3OL9reK8ltczgHTAwNhMys/6ThXHityHxVV2p3fkw/c+MAvBHFVYHFZDmjMCQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/colinhacks"
|
||||
}
|
||||
|
||||
@@ -269,20 +269,14 @@ export function ComparisonChart({ traders }: ComparisonChartProps) {
|
||||
<div
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: '50%',
|
||||
left: '50%',
|
||||
transform: 'translate(-50%, -50%)',
|
||||
width: '80%',
|
||||
fontSize: 'min(20vw, 160px)',
|
||||
top: '20px',
|
||||
right: '20px',
|
||||
fontSize: '24px',
|
||||
fontWeight: 'bold',
|
||||
color: 'rgba(240, 185, 11, 0.12)',
|
||||
color: 'rgba(240, 185, 11, 0.15)',
|
||||
zIndex: 10,
|
||||
pointerEvents: 'none',
|
||||
fontFamily: 'monospace',
|
||||
textAlign: 'center',
|
||||
letterSpacing: '0.4rem',
|
||||
lineHeight: 1,
|
||||
userSelect: 'none',
|
||||
}}
|
||||
>
|
||||
NOFX
|
||||
|
||||
@@ -301,20 +301,14 @@ export function EquityChart({ traderId }: EquityChartProps) {
|
||||
<div
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: '50%',
|
||||
left: '50%',
|
||||
transform: 'translate(-50%, -50%)',
|
||||
width: '80%',
|
||||
fontSize: 'min(20vw, 160px)',
|
||||
top: '15px',
|
||||
right: '15px',
|
||||
fontSize: '20px',
|
||||
fontWeight: 'bold',
|
||||
color: 'rgba(240, 185, 11, 0.12)',
|
||||
color: 'rgba(240, 185, 11, 0.15)',
|
||||
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 scripts/start.sh',
|
||||
'$ ./scripts/start.sh start --build',
|
||||
'$ chmod +x start.sh',
|
||||
'$ ./start.sh start --build',
|
||||
t('startupMessages1', language),
|
||||
t('startupMessages2', language),
|
||||
t('startupMessages3', language),
|
||||
|
||||
@@ -1,815 +0,0 @@
|
||||
import { useState, useEffect, useRef } from 'react'
|
||||
import { motion } from 'framer-motion'
|
||||
import { Menu, X, ChevronDown } from 'lucide-react'
|
||||
import { t, type Language } from '../../i18n/translations'
|
||||
|
||||
interface HeaderBarProps {
|
||||
onLoginClick?: () => void
|
||||
isLoggedIn?: boolean
|
||||
isHomePage?: boolean
|
||||
currentPage?: string
|
||||
language?: Language
|
||||
onLanguageChange?: (lang: Language) => void
|
||||
user?: { email: string } | null
|
||||
onLogout?: () => void
|
||||
onPageChange?: (page: string) => void
|
||||
}
|
||||
|
||||
export default function HeaderBar({
|
||||
isLoggedIn = false,
|
||||
isHomePage = false,
|
||||
currentPage,
|
||||
language = 'zh' as Language,
|
||||
onLanguageChange,
|
||||
user,
|
||||
onLogout,
|
||||
onPageChange,
|
||||
}: HeaderBarProps) {
|
||||
const [mobileMenuOpen, setMobileMenuOpen] = useState(false)
|
||||
const [languageDropdownOpen, setLanguageDropdownOpen] = useState(false)
|
||||
const [userDropdownOpen, setUserDropdownOpen] = useState(false)
|
||||
const dropdownRef = useRef<HTMLDivElement>(null)
|
||||
const userDropdownRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
// Close dropdown when clicking outside
|
||||
useEffect(() => {
|
||||
function handleClickOutside(event: MouseEvent) {
|
||||
if (
|
||||
dropdownRef.current &&
|
||||
!dropdownRef.current.contains(event.target as Node)
|
||||
) {
|
||||
setLanguageDropdownOpen(false)
|
||||
}
|
||||
if (
|
||||
userDropdownRef.current &&
|
||||
!userDropdownRef.current.contains(event.target as Node)
|
||||
) {
|
||||
setUserDropdownOpen(false)
|
||||
}
|
||||
}
|
||||
|
||||
document.addEventListener('mousedown', handleClickOutside)
|
||||
return () => {
|
||||
document.removeEventListener('mousedown', handleClickOutside)
|
||||
}
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<nav className="fixed top-0 w-full z-50 header-bar">
|
||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||
<div className="flex items-center justify-between h-16">
|
||||
{/* Logo */}
|
||||
<a
|
||||
href="/"
|
||||
className="flex items-center gap-3 hover:opacity-80 transition-opacity cursor-pointer"
|
||||
>
|
||||
<img src="/icons/nofx.svg" alt="NOFX Logo" className="w-8 h-8" />
|
||||
<span
|
||||
className="text-xl font-bold"
|
||||
style={{ color: 'var(--brand-yellow)' }}
|
||||
>
|
||||
NOFX
|
||||
</span>
|
||||
<span
|
||||
className="text-sm hidden sm:block"
|
||||
style={{ color: 'var(--text-secondary)' }}
|
||||
>
|
||||
Agentic Trading OS
|
||||
</span>
|
||||
</a>
|
||||
|
||||
{/* Desktop Menu */}
|
||||
<div className="hidden md:flex items-center justify-between flex-1 ml-8">
|
||||
{/* Left Side - Navigation Tabs */}
|
||||
<div className="flex items-center gap-4">
|
||||
{isLoggedIn ? (
|
||||
// Main app navigation when logged in
|
||||
<>
|
||||
<button
|
||||
onClick={() => {
|
||||
console.log(
|
||||
'实时 button clicked, onPageChange:',
|
||||
onPageChange
|
||||
)
|
||||
onPageChange?.('competition')
|
||||
}}
|
||||
className="text-sm font-bold transition-all duration-300 relative focus:outline-2 focus:outline-yellow-500"
|
||||
style={{
|
||||
color:
|
||||
currentPage === 'competition'
|
||||
? 'var(--brand-yellow)'
|
||||
: 'var(--brand-light-gray)',
|
||||
padding: '8px 16px',
|
||||
borderRadius: '8px',
|
||||
position: 'relative',
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
if (currentPage !== 'competition') {
|
||||
e.currentTarget.style.color = 'var(--brand-yellow)'
|
||||
}
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
if (currentPage !== 'competition') {
|
||||
e.currentTarget.style.color = 'var(--brand-light-gray)'
|
||||
}
|
||||
}}
|
||||
>
|
||||
{/* Background for selected state */}
|
||||
{currentPage === 'competition' && (
|
||||
<span
|
||||
className="absolute inset-0 rounded-lg"
|
||||
style={{
|
||||
background: 'rgba(240, 185, 11, 0.15)',
|
||||
zIndex: -1,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{t('realtimeNav', language)}
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={() => {
|
||||
console.log(
|
||||
'配置 button clicked, onPageChange:',
|
||||
onPageChange
|
||||
)
|
||||
onPageChange?.('traders')
|
||||
}}
|
||||
className="text-sm font-bold transition-all duration-300 relative focus:outline-2 focus:outline-yellow-500"
|
||||
style={{
|
||||
color:
|
||||
currentPage === 'traders'
|
||||
? 'var(--brand-yellow)'
|
||||
: 'var(--brand-light-gray)',
|
||||
padding: '8px 16px',
|
||||
borderRadius: '8px',
|
||||
position: 'relative',
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
if (currentPage !== 'traders') {
|
||||
e.currentTarget.style.color = 'var(--brand-yellow)'
|
||||
}
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
if (currentPage !== 'traders') {
|
||||
e.currentTarget.style.color = 'var(--brand-light-gray)'
|
||||
}
|
||||
}}
|
||||
>
|
||||
{/* Background for selected state */}
|
||||
{currentPage === 'traders' && (
|
||||
<span
|
||||
className="absolute inset-0 rounded-lg"
|
||||
style={{
|
||||
background: 'rgba(240, 185, 11, 0.15)',
|
||||
zIndex: -1,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{t('configNav', language)}
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={() => {
|
||||
console.log(
|
||||
'看板 button clicked, onPageChange:',
|
||||
onPageChange
|
||||
)
|
||||
onPageChange?.('trader')
|
||||
}}
|
||||
className="text-sm font-bold transition-all duration-300 relative focus:outline-2 focus:outline-yellow-500"
|
||||
style={{
|
||||
color:
|
||||
currentPage === 'trader'
|
||||
? 'var(--brand-yellow)'
|
||||
: 'var(--brand-light-gray)',
|
||||
padding: '8px 16px',
|
||||
borderRadius: '8px',
|
||||
position: 'relative',
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
if (currentPage !== 'trader') {
|
||||
e.currentTarget.style.color = 'var(--brand-yellow)'
|
||||
}
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
if (currentPage !== 'trader') {
|
||||
e.currentTarget.style.color = 'var(--brand-light-gray)'
|
||||
}
|
||||
}}
|
||||
>
|
||||
{/* Background for selected state */}
|
||||
{currentPage === 'trader' && (
|
||||
<span
|
||||
className="absolute inset-0 rounded-lg"
|
||||
style={{
|
||||
background: 'rgba(240, 185, 11, 0.15)',
|
||||
zIndex: -1,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{t('dashboardNav', language)}
|
||||
</button>
|
||||
</>
|
||||
) : (
|
||||
// Landing page navigation when not logged in
|
||||
<a
|
||||
href="/competition"
|
||||
className="text-sm font-bold transition-all duration-300 relative focus:outline-2 focus:outline-yellow-500"
|
||||
style={{
|
||||
color:
|
||||
currentPage === 'competition'
|
||||
? 'var(--brand-yellow)'
|
||||
: 'var(--brand-light-gray)',
|
||||
padding: '8px 16px',
|
||||
borderRadius: '8px',
|
||||
position: 'relative',
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
if (currentPage !== 'competition') {
|
||||
e.currentTarget.style.color = 'var(--brand-yellow)'
|
||||
}
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
if (currentPage !== 'competition') {
|
||||
e.currentTarget.style.color = 'var(--brand-light-gray)'
|
||||
}
|
||||
}}
|
||||
>
|
||||
{/* Background for selected state */}
|
||||
{currentPage === 'competition' && (
|
||||
<span
|
||||
className="absolute inset-0 rounded-lg"
|
||||
style={{
|
||||
background: 'rgba(240, 185, 11, 0.15)',
|
||||
zIndex: -1,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{t('realtimeNav', language)}
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Right Side - Original Navigation Items and Login */}
|
||||
<div className="flex items-center gap-6">
|
||||
{/* Only show original navigation items on home page */}
|
||||
{isHomePage &&
|
||||
[
|
||||
{ key: 'features', label: t('features', language) },
|
||||
{ key: 'howItWorks', label: t('howItWorks', language) },
|
||||
{ key: 'GitHub', label: 'GitHub' },
|
||||
{ key: 'community', label: t('community', language) },
|
||||
].map((item) => (
|
||||
<a
|
||||
key={item.key}
|
||||
href={
|
||||
item.key === 'GitHub'
|
||||
? 'https://github.com/tinkle-community/nofx'
|
||||
: item.key === 'community'
|
||||
? 'https://t.me/nofx_dev_community'
|
||||
: `#${item.key === 'features' ? 'features' : 'how-it-works'}`
|
||||
}
|
||||
target={
|
||||
item.key === 'GitHub' || item.key === 'community'
|
||||
? '_blank'
|
||||
: undefined
|
||||
}
|
||||
rel={
|
||||
item.key === 'GitHub' || item.key === 'community'
|
||||
? 'noopener noreferrer'
|
||||
: undefined
|
||||
}
|
||||
className="text-sm transition-colors relative group"
|
||||
style={{ color: 'var(--brand-light-gray)' }}
|
||||
>
|
||||
{item.label}
|
||||
<span
|
||||
className="absolute -bottom-1 left-0 w-0 h-0.5 group-hover:w-full transition-all duration-300"
|
||||
style={{ background: 'var(--brand-yellow)' }}
|
||||
/>
|
||||
</a>
|
||||
))}
|
||||
|
||||
{/* User Info and Actions */}
|
||||
{isLoggedIn && user ? (
|
||||
<div className="flex items-center gap-3">
|
||||
{/* User Info with Dropdown */}
|
||||
<div className="relative" ref={userDropdownRef}>
|
||||
<button
|
||||
onClick={() => setUserDropdownOpen(!userDropdownOpen)}
|
||||
className="flex items-center gap-2 px-3 py-2 rounded transition-colors"
|
||||
style={{
|
||||
background: 'var(--panel-bg)',
|
||||
border: '1px solid var(--panel-border)',
|
||||
}}
|
||||
onMouseEnter={(e) =>
|
||||
(e.currentTarget.style.background =
|
||||
'rgba(255, 255, 255, 0.05)')
|
||||
}
|
||||
onMouseLeave={(e) =>
|
||||
(e.currentTarget.style.background = 'var(--panel-bg)')
|
||||
}
|
||||
>
|
||||
<div
|
||||
className="w-6 h-6 rounded-full flex items-center justify-center text-xs font-bold"
|
||||
style={{
|
||||
background: 'var(--brand-yellow)',
|
||||
color: 'var(--brand-black)',
|
||||
}}
|
||||
>
|
||||
{user.email[0].toUpperCase()}
|
||||
</div>
|
||||
<span
|
||||
className="text-sm"
|
||||
style={{ color: 'var(--brand-light-gray)' }}
|
||||
>
|
||||
{user.email}
|
||||
</span>
|
||||
<ChevronDown
|
||||
className="w-4 h-4"
|
||||
style={{ color: 'var(--brand-light-gray)' }}
|
||||
/>
|
||||
</button>
|
||||
|
||||
{userDropdownOpen && (
|
||||
<div
|
||||
className="absolute right-0 top-full mt-2 w-48 rounded-lg shadow-lg overflow-hidden z-50"
|
||||
style={{
|
||||
background: 'var(--brand-dark-gray)',
|
||||
border: '1px solid var(--panel-border)',
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className="px-3 py-2 border-b"
|
||||
style={{ borderColor: 'var(--panel-border)' }}
|
||||
>
|
||||
<div
|
||||
className="text-xs"
|
||||
style={{ color: 'var(--text-secondary)' }}
|
||||
>
|
||||
{t('loggedInAs', language)}
|
||||
</div>
|
||||
<div
|
||||
className="text-sm font-medium"
|
||||
style={{ color: 'var(--brand-light-gray)' }}
|
||||
>
|
||||
{user.email}
|
||||
</div>
|
||||
</div>
|
||||
{onLogout && (
|
||||
<button
|
||||
onClick={() => {
|
||||
onLogout()
|
||||
setUserDropdownOpen(false)
|
||||
}}
|
||||
className="w-full px-3 py-2 text-sm font-semibold transition-colors hover:opacity-80 text-center"
|
||||
style={{
|
||||
background: 'var(--binance-red-bg)',
|
||||
color: 'var(--binance-red)',
|
||||
}}
|
||||
>
|
||||
{t('exitLogin', language)}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
/* Show login/register buttons when not logged in and not on login/register pages */
|
||||
currentPage !== 'login' &&
|
||||
currentPage !== 'register' && (
|
||||
<div className="flex items-center gap-3">
|
||||
<a
|
||||
href="/login"
|
||||
className="px-3 py-2 text-sm font-medium transition-colors rounded"
|
||||
style={{ color: 'var(--brand-light-gray)' }}
|
||||
>
|
||||
{t('signIn', language)}
|
||||
</a>
|
||||
<a
|
||||
href="/register"
|
||||
className="px-4 py-2 rounded font-semibold text-sm transition-colors hover:opacity-90"
|
||||
style={{
|
||||
background: 'var(--brand-yellow)',
|
||||
color: 'var(--brand-black)',
|
||||
}}
|
||||
>
|
||||
{t('signUp', language)}
|
||||
</a>
|
||||
</div>
|
||||
)
|
||||
)}
|
||||
|
||||
{/* Language Toggle - Always at the rightmost */}
|
||||
<div className="relative" ref={dropdownRef}>
|
||||
<button
|
||||
onClick={() => setLanguageDropdownOpen(!languageDropdownOpen)}
|
||||
className="flex items-center gap-2 px-3 py-2 rounded transition-colors"
|
||||
style={{ color: 'var(--brand-light-gray)' }}
|
||||
onMouseEnter={(e) =>
|
||||
(e.currentTarget.style.background =
|
||||
'rgba(255, 255, 255, 0.05)')
|
||||
}
|
||||
onMouseLeave={(e) =>
|
||||
(e.currentTarget.style.background = 'transparent')
|
||||
}
|
||||
>
|
||||
<span className="text-lg">
|
||||
{language === 'zh' ? '🇨🇳' : '🇺🇸'}
|
||||
</span>
|
||||
<ChevronDown className="w-4 h-4" />
|
||||
</button>
|
||||
|
||||
{languageDropdownOpen && (
|
||||
<div
|
||||
className="absolute right-0 top-full mt-2 w-32 rounded-lg shadow-lg overflow-hidden z-50"
|
||||
style={{
|
||||
background: 'var(--brand-dark-gray)',
|
||||
border: '1px solid var(--panel-border)',
|
||||
}}
|
||||
>
|
||||
<button
|
||||
onClick={() => {
|
||||
onLanguageChange?.('zh')
|
||||
setLanguageDropdownOpen(false)
|
||||
}}
|
||||
className={`w-full flex items-center gap-2 px-3 py-2 transition-colors ${
|
||||
language === 'zh' ? '' : 'hover:opacity-80'
|
||||
}`}
|
||||
style={{
|
||||
color: 'var(--brand-light-gray)',
|
||||
background:
|
||||
language === 'zh'
|
||||
? 'rgba(240, 185, 11, 0.1)'
|
||||
: 'transparent',
|
||||
}}
|
||||
>
|
||||
<span className="text-base">🇨🇳</span>
|
||||
<span className="text-sm">中文</span>
|
||||
</button>
|
||||
<button
|
||||
onClick={() => {
|
||||
onLanguageChange?.('en')
|
||||
setLanguageDropdownOpen(false)
|
||||
}}
|
||||
className={`w-full flex items-center gap-2 px-3 py-2 transition-colors ${
|
||||
language === 'en' ? '' : 'hover:opacity-80'
|
||||
}`}
|
||||
style={{
|
||||
color: 'var(--brand-light-gray)',
|
||||
background:
|
||||
language === 'en'
|
||||
? 'rgba(240, 185, 11, 0.1)'
|
||||
: 'transparent',
|
||||
}}
|
||||
>
|
||||
<span className="text-base">🇺🇸</span>
|
||||
<span className="text-sm">English</span>
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Mobile Menu Button */}
|
||||
<motion.button
|
||||
onClick={() => setMobileMenuOpen(!mobileMenuOpen)}
|
||||
className="md:hidden"
|
||||
style={{ color: 'var(--brand-light-gray)' }}
|
||||
whileTap={{ scale: 0.9 }}
|
||||
>
|
||||
{mobileMenuOpen ? (
|
||||
<X className="w-6 h-6" />
|
||||
) : (
|
||||
<Menu className="w-6 h-6" />
|
||||
)}
|
||||
</motion.button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Mobile Menu */}
|
||||
<motion.div
|
||||
initial={false}
|
||||
animate={
|
||||
mobileMenuOpen
|
||||
? { height: 'auto', opacity: 1 }
|
||||
: { height: 0, opacity: 0 }
|
||||
}
|
||||
transition={{ duration: 0.3 }}
|
||||
className="md:hidden overflow-hidden"
|
||||
style={{
|
||||
background: 'var(--brand-dark-gray)',
|
||||
borderTop: '1px solid rgba(240, 185, 11, 0.1)',
|
||||
}}
|
||||
>
|
||||
<div className="px-4 py-4 space-y-3">
|
||||
{/* New Navigation Tabs */}
|
||||
{isLoggedIn ? (
|
||||
<button
|
||||
onClick={() => {
|
||||
console.log(
|
||||
'移动端 实时 button clicked, onPageChange:',
|
||||
onPageChange
|
||||
)
|
||||
onPageChange?.('competition')
|
||||
setMobileMenuOpen(false)
|
||||
}}
|
||||
className="block text-sm font-bold transition-all duration-300 relative focus:outline-2 focus:outline-yellow-500"
|
||||
style={{
|
||||
color:
|
||||
currentPage === 'competition'
|
||||
? 'var(--brand-yellow)'
|
||||
: 'var(--brand-light-gray)',
|
||||
padding: '12px 16px',
|
||||
borderRadius: '8px',
|
||||
position: 'relative',
|
||||
width: '100%',
|
||||
textAlign: 'left',
|
||||
}}
|
||||
>
|
||||
{/* Background for selected state */}
|
||||
{currentPage === 'competition' && (
|
||||
<span
|
||||
className="absolute inset-0 rounded-lg"
|
||||
style={{
|
||||
background: 'rgba(240, 185, 11, 0.15)',
|
||||
zIndex: -1,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{t('realtimeNav', language)}
|
||||
</button>
|
||||
) : (
|
||||
<a
|
||||
href="/competition"
|
||||
className="block text-sm font-bold transition-all duration-300 relative focus:outline-2 focus:outline-yellow-500"
|
||||
style={{
|
||||
color:
|
||||
currentPage === 'competition'
|
||||
? 'var(--brand-yellow)'
|
||||
: 'var(--brand-light-gray)',
|
||||
padding: '12px 16px',
|
||||
borderRadius: '8px',
|
||||
position: 'relative',
|
||||
}}
|
||||
>
|
||||
{/* Background for selected state */}
|
||||
{currentPage === 'competition' && (
|
||||
<span
|
||||
className="absolute inset-0 rounded-lg"
|
||||
style={{
|
||||
background: 'rgba(240, 185, 11, 0.15)',
|
||||
zIndex: -1,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{t('realtimeNav', language)}
|
||||
</a>
|
||||
)}
|
||||
{/* Only show 配置 and 看板 when logged in */}
|
||||
{isLoggedIn && (
|
||||
<>
|
||||
<button
|
||||
onClick={() => {
|
||||
console.log(
|
||||
'移动端 配置 button clicked, onPageChange:',
|
||||
onPageChange
|
||||
)
|
||||
onPageChange?.('traders')
|
||||
setMobileMenuOpen(false)
|
||||
}}
|
||||
className="block text-sm font-bold transition-all duration-300 relative focus:outline-2 focus:outline-yellow-500 hover:text-yellow-500"
|
||||
style={{
|
||||
color:
|
||||
currentPage === 'traders'
|
||||
? 'var(--brand-yellow)'
|
||||
: 'var(--brand-light-gray)',
|
||||
padding: '12px 16px',
|
||||
borderRadius: '8px',
|
||||
position: 'relative',
|
||||
width: '100%',
|
||||
textAlign: 'left',
|
||||
}}
|
||||
>
|
||||
{/* Background for selected state */}
|
||||
{currentPage === 'traders' && (
|
||||
<span
|
||||
className="absolute inset-0 rounded-lg"
|
||||
style={{
|
||||
background: 'rgba(240, 185, 11, 0.15)',
|
||||
zIndex: -1,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{t('configNav', language)}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => {
|
||||
console.log(
|
||||
'移动端 看板 button clicked, onPageChange:',
|
||||
onPageChange
|
||||
)
|
||||
onPageChange?.('trader')
|
||||
setMobileMenuOpen(false)
|
||||
}}
|
||||
className="block text-sm font-bold transition-all duration-300 relative focus:outline-2 focus:outline-yellow-500 hover:text-yellow-500"
|
||||
style={{
|
||||
color:
|
||||
currentPage === 'trader'
|
||||
? 'var(--brand-yellow)'
|
||||
: 'var(--brand-light-gray)',
|
||||
padding: '12px 16px',
|
||||
borderRadius: '8px',
|
||||
position: 'relative',
|
||||
width: '100%',
|
||||
textAlign: 'left',
|
||||
}}
|
||||
>
|
||||
{/* Background for selected state */}
|
||||
{currentPage === 'trader' && (
|
||||
<span
|
||||
className="absolute inset-0 rounded-lg"
|
||||
style={{
|
||||
background: 'rgba(240, 185, 11, 0.15)',
|
||||
zIndex: -1,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{t('dashboardNav', language)}
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Original Navigation Items - Only on home page */}
|
||||
{isHomePage &&
|
||||
[
|
||||
{ key: 'features', label: t('features', language) },
|
||||
{ key: 'howItWorks', label: t('howItWorks', language) },
|
||||
{ key: 'GitHub', label: 'GitHub' },
|
||||
{ key: 'community', label: t('community', language) },
|
||||
].map((item) => (
|
||||
<a
|
||||
key={item.key}
|
||||
href={
|
||||
item.key === 'GitHub'
|
||||
? 'https://github.com/tinkle-community/nofx'
|
||||
: item.key === 'community'
|
||||
? 'https://t.me/nofx_dev_community'
|
||||
: `#${item.key === 'features' ? 'features' : 'how-it-works'}`
|
||||
}
|
||||
target={
|
||||
item.key === 'GitHub' || item.key === 'community'
|
||||
? '_blank'
|
||||
: undefined
|
||||
}
|
||||
rel={
|
||||
item.key === 'GitHub' || item.key === 'community'
|
||||
? 'noopener noreferrer'
|
||||
: undefined
|
||||
}
|
||||
className="block text-sm py-2"
|
||||
style={{ color: 'var(--brand-light-gray)' }}
|
||||
>
|
||||
{item.label}
|
||||
</a>
|
||||
))}
|
||||
|
||||
{/* Language Toggle */}
|
||||
<div className="py-2">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<span
|
||||
className="text-xs"
|
||||
style={{ color: 'var(--brand-light-gray)' }}
|
||||
>
|
||||
{t('language', language)}:
|
||||
</span>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<button
|
||||
onClick={() => {
|
||||
onLanguageChange?.('zh')
|
||||
setMobileMenuOpen(false)
|
||||
}}
|
||||
className={`w-full flex items-center gap-3 px-3 py-2 rounded transition-colors ${
|
||||
language === 'zh'
|
||||
? 'bg-yellow-500 text-black'
|
||||
: 'text-gray-400 hover:text-white'
|
||||
}`}
|
||||
>
|
||||
<span className="text-lg">🇨🇳</span>
|
||||
<span className="text-sm">中文</span>
|
||||
</button>
|
||||
<button
|
||||
onClick={() => {
|
||||
onLanguageChange?.('en')
|
||||
setMobileMenuOpen(false)
|
||||
}}
|
||||
className={`w-full flex items-center gap-3 px-3 py-2 rounded transition-colors ${
|
||||
language === 'en'
|
||||
? 'bg-yellow-500 text-black'
|
||||
: 'text-gray-400 hover:text-white'
|
||||
}`}
|
||||
>
|
||||
<span className="text-lg">🇺🇸</span>
|
||||
<span className="text-sm">English</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* User info and logout for mobile when logged in */}
|
||||
{isLoggedIn && user && (
|
||||
<div
|
||||
className="mt-4 pt-4"
|
||||
style={{ borderTop: '1px solid var(--panel-border)' }}
|
||||
>
|
||||
<div
|
||||
className="flex items-center gap-2 px-3 py-2 mb-2 rounded"
|
||||
style={{ background: 'var(--panel-bg)' }}
|
||||
>
|
||||
<div
|
||||
className="w-6 h-6 rounded-full flex items-center justify-center text-xs font-bold"
|
||||
style={{
|
||||
background: 'var(--brand-yellow)',
|
||||
color: 'var(--brand-black)',
|
||||
}}
|
||||
>
|
||||
{user.email[0].toUpperCase()}
|
||||
</div>
|
||||
<div>
|
||||
<div
|
||||
className="text-xs"
|
||||
style={{ color: 'var(--text-secondary)' }}
|
||||
>
|
||||
{t('loggedInAs', language)}
|
||||
</div>
|
||||
<div
|
||||
className="text-sm"
|
||||
style={{ color: 'var(--brand-light-gray)' }}
|
||||
>
|
||||
{user.email}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{onLogout && (
|
||||
<button
|
||||
onClick={() => {
|
||||
onLogout()
|
||||
setMobileMenuOpen(false)
|
||||
}}
|
||||
className="w-full px-4 py-2 rounded text-sm font-semibold transition-colors text-center"
|
||||
style={{
|
||||
background: 'var(--binance-red-bg)',
|
||||
color: 'var(--binance-red)',
|
||||
}}
|
||||
>
|
||||
{t('exitLogin', language)}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Show login/register buttons when not logged in and not on login/register pages */}
|
||||
{!isLoggedIn &&
|
||||
currentPage !== 'login' &&
|
||||
currentPage !== 'register' && (
|
||||
<div className="space-y-2 mt-2">
|
||||
<a
|
||||
href="/login"
|
||||
className="block w-full px-4 py-2 rounded text-sm font-medium text-center transition-colors"
|
||||
style={{
|
||||
color: 'var(--brand-light-gray)',
|
||||
border: '1px solid var(--brand-light-gray)',
|
||||
}}
|
||||
onClick={() => setMobileMenuOpen(false)}
|
||||
>
|
||||
{t('signIn', language)}
|
||||
</a>
|
||||
<a
|
||||
href="/register"
|
||||
className="block w-full px-4 py-2 rounded font-semibold text-sm text-center transition-colors"
|
||||
style={{
|
||||
background: 'var(--brand-yellow)',
|
||||
color: 'var(--brand-black)',
|
||||
}}
|
||||
onClick={() => setMobileMenuOpen(false)}
|
||||
>
|
||||
{t('signUp', language)}
|
||||
</a>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</motion.div>
|
||||
</nav>
|
||||
)
|
||||
}
|
||||
@@ -1,10 +1,5 @@
|
||||
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,16 +108,18 @@ export interface AIModel {
|
||||
|
||||
export interface Exchange {
|
||||
id: string
|
||||
user_id: string
|
||||
name: string
|
||||
type: 'cex' | 'dex'
|
||||
enabled: boolean
|
||||
apiKey?: string
|
||||
secretKey?: string
|
||||
testnet?: boolean
|
||||
hyperliquidWalletAddr?: string // 钱包地址,非敏感信息
|
||||
asterUser?: string // Aster用户名,非敏感信息
|
||||
deleted: boolean
|
||||
created_at: string
|
||||
updated_at: string
|
||||
// Hyperliquid 特定字段
|
||||
hyperliquidWalletAddr?: string
|
||||
// Aster 特定字段
|
||||
asterUser?: string
|
||||
asterSigner?: string
|
||||
asterPrivateKey?: string
|
||||
}
|
||||
|
||||
export interface CreateTraderRequest {
|
||||
|
||||
Reference in New Issue
Block a user