From 770f96e53f048d326c9466180a73880d148270bd Mon Sep 17 00:00:00 2001 From: hzb1115 Date: Wed, 5 Nov 2025 21:41:36 -0500 Subject: [PATCH 1/9] feat(proxy): add proxy module --- config.json.example | 17 + config/config.go | 17 + proxy/README.md | 685 +++++++++++++++++++++++++++++++++++ proxy/brightdata_provider.go | 105 ++++++ proxy/fixed_provider.go | 42 +++ proxy/provider.go | 10 + proxy/proxy_client.go | 47 +++ proxy/proxy_manager.go | 346 ++++++++++++++++++ proxy/single_provider.go | 19 + proxy/types.go | 40 ++ 10 files changed, 1328 insertions(+) create mode 100644 proxy/README.md create mode 100644 proxy/brightdata_provider.go create mode 100644 proxy/fixed_provider.go create mode 100644 proxy/provider.go create mode 100644 proxy/proxy_client.go create mode 100644 proxy/proxy_manager.go create mode 100644 proxy/single_provider.go create mode 100644 proxy/types.go diff --git a/config.json.example b/config.json.example index ac9d5ac6..f495b077 100644 --- a/config.json.example +++ b/config.json.example @@ -20,4 +20,21 @@ "max_drawdown": 20.0, "stop_trading_minutes": 60, "jwt_secret": "Qk0kAa+d0iIEzXVHXbNbm+UaN3RNabmWtH8rDWZ5OPf+4GX8pBflAHodfpbipVMyrw1fsDanHsNBjhgbDeK9Jg==" + + + "proxy": { + "enabled": false, + "mode": "single", + "timeout": 30, + "proxy_url": "http://127.0.0.1:7890", + "proxy_list": [], + "brightdata_endpoint": "", + "brightdata_token": "", + "brightdata_zone": "", + "proxy_host": "", + "proxy_user": "", + "proxy_password": "", + "refresh_interval": 0, + "blacklist_ttl": 5 + } } \ No newline at end of file diff --git a/config/config.go b/config/config.go index 37a537db..95a897b2 100644 --- a/config/config.go +++ b/config/config.go @@ -60,8 +60,25 @@ type Config struct { MaxDrawdown float64 `json:"max_drawdown"` StopTradingMinutes int `json:"stop_trading_minutes"` Leverage LeverageConfig `json:"leverage"` // 杠杆配置 + Proxy *ProxyConfig `json:"proxy"` // HTTP 代理配置(可选) } +// ProxyConfig HTTP 代理配置 +type ProxyConfig struct { + Enabled bool `json:"enabled"` // 是否启用代理 + Mode string `json:"mode"` // 模式: "single", "pool", "brightdata" + Timeout int `json:"timeout"` // 超时时间(秒) + ProxyURL string `json:"proxy_url"` // 单个代理地址 + ProxyList []string `json:"proxy_list"` // 代理列表 + BrightDataEndpoint string `json:"brightdata_endpoint"` // Bright Data接口地址 + BrightDataToken string `json:"brightdata_token"` // Bright Data访问令牌 + BrightDataZone string `json:"brightdata_zone"` // Bright Data区域 + ProxyHost string `json:"proxy_host"` // 代理主机 + ProxyUser string `json:"proxy_user"` // 代理用户名模板 + ProxyPassword string `json:"proxy_password"` // 代理密码 + RefreshInterval int `json:"refresh_interval"` // 刷新间隔(秒) + BlacklistTTL int `json:"blacklist_ttl"` // 黑名单TTL +} // LoadConfig 从文件加载配置 func LoadConfig(filename string) (*Config, error) { data, err := os.ReadFile(filename) diff --git a/proxy/README.md b/proxy/README.md new file mode 100644 index 00000000..f48a35d4 --- /dev/null +++ b/proxy/README.md @@ -0,0 +1,685 @@ +# HTTP 代理模块 + +## 概述 + +这是一个高度解耦的HTTP代理管理模块,专为解决高频API请求被限流/封禁问题而设计。支持单代理、代理池和动态IP获取三种模式,提供线程安全的IP轮换和智能黑名单管理机制。 + +## 功能特性 + +- ✅ **三种工作模式**:单代理、固定代理池、Bright Data API动态获取 +- ✅ **线程安全**:所有操作使用读写锁保护,支持并发访问 +- ✅ **智能黑名单**:失败的代理IP手动加入黑名单,TTL机制自动恢复 +- ✅ **自动刷新**:支持定时刷新代理IP列表(默认30分钟) +- ✅ **随机轮换**:从可用IP池中随机选择,避免单点压力 +- ✅ **防越界保护**:多层数组边界检查,确保运行时安全 +- ✅ **可选启用**:未配置或禁用时自动使用直连,不影响独立客户 + +## 架构设计 + +``` +proxy/ +├── README.md # 本文档 +├── types.go # 核心数据结构定义 +├── provider.go # IP提供者接口定义 +├── single_provider.go # 单代理实现 +├── fixed_provider.go # 固定代理池实现 +├── brightdata_provider.go # Bright Data API实现 +└── proxy_manager.go # 代理管理器(核心逻辑) +``` + +### 设计原则 + +1. **接口抽象**:通过 `IPProvider` 接口实现不同代理源的统一管理 +2. **策略模式**:三种Provider实现可灵活切换 +3. **单例模式**:全局ProxyManager确保资源统一管理 +4. **防御性编程**:多层边界检查,优雅处理异常情况 + +## 配置说明 + +在 `config.json` 中添加 `proxy` 配置段: + +```json +{ + "proxy": { + "enabled": true, + "mode": "single", + "timeout": 30, + "proxy_url": "http://127.0.0.1:7890", + "proxy_list": [], + "brightdata_endpoint": "", + "brightdata_token": "", + "brightdata_zone": "", + "proxy_host": "", + "proxy_user": "", + "proxy_password": "", + "refresh_interval": 1800, + "blacklist_ttl": 5 + } +} +``` + +### 配置字段详解 + +| 字段 | 类型 | 必填 | 说明 | +|------|------|------|------| +| `enabled` | bool | 是 | 是否启用代理(false时使用直连) | +| `mode` | string | 是 | 代理模式:`single`/`pool`/`brightdata` | +| `timeout` | int | 否 | HTTP请求超时时间(秒),默认30 | +| `proxy_url` | string | single模式必填 | 单个代理地址,如 `http://127.0.0.1:7890` | +| `proxy_list` | []string | pool模式必填 | 代理列表,支持 `http://`、`https://`、`socks5://` | +| `brightdata_endpoint` | string | brightdata模式必填 | Bright Data API端点 | +| `brightdata_token` | string | brightdata模式可选 | Bright Data访问令牌 | +| `brightdata_zone` | string | brightdata模式可选 | Bright Data区域参数 | +| `proxy_host` | string | 否 | 代理主机(用于认证代理) | +| `proxy_user` | string | 否 | 代理用户名模板,支持 `%s` 占位符替换IP | +| `proxy_password` | string | 否 | 代理密码 | +| `refresh_interval` | int | 否 | IP列表刷新间隔(秒),brightdata模式默认1800(30分钟) | +| `blacklist_ttl` | int | 否 | 黑名单IP的TTL(刷新次数),默认5 | + +## 使用方法 + +### 1. 初始化代理管理器 + +在 `main.go` 或初始化代码中: + +```go +import ( + "nofx/proxy" + "time" +) + +// 方式1:使用配置结构体初始化 +proxyConfig := &proxy.Config{ + Enabled: true, + Mode: "single", + Timeout: 30 * time.Second, + ProxyURL: "http://127.0.0.1:7890", + BlacklistTTL: 5, +} + +err := proxy.InitGlobalProxyManager(proxyConfig) +if err != nil { + log.Fatalf("初始化代理管理器失败: %v", err) +} +``` + +### 2. 获取代理HTTP客户端 + +在需要发送HTTP请求的地方: + +```go +// 获取代理客户端(包含ProxyID用于黑名单管理) +proxyClient, err := proxy.GetProxyHTTPClient() +if err != nil { + log.Printf("获取代理客户端失败: %v", err) + return +} + +// 使用代理客户端发送请求 +resp, err := proxyClient.Client.Get("https://api.example.com/data") +if err != nil { + // 请求失败,将此代理加入黑名单 + proxy.AddBlacklist(proxyClient.ProxyID) + log.Printf("请求失败,代理IP %s 已加入黑名单", proxyClient.IP) + return +} +defer resp.Body.Close() + +// 处理响应... +``` + +### 3. 黑名单管理 + +```go +// 添加失败的代理到黑名单 +proxy.AddBlacklist(proxyClient.ProxyID) + +// 获取黑名单状态 +total, blacklisted, available := proxy.GetGlobalProxyManager().GetBlacklistStatus() +log.Printf("代理状态: 总计%d个,黑名单%d个,可用%d个", total, blacklisted, available) +``` + +### 4. 手动刷新IP列表 + +```go +err := proxy.RefreshIPList() +if err != nil { + log.Printf("刷新IP列表失败: %v", err) +} +``` + +### 5. 检查代理是否启用 + +```go +if proxy.IsEnabled() { + log.Println("代理已启用") +} else { + log.Println("代理未启用,使用直连") +} +``` + +## 三种模式详解 + +### Mode 1: Single(单代理模式) + +适用场景:本地代理工具(如Clash、V2Ray)或单个固定代理服务器 + +```json +{ + "proxy": { + "enabled": true, + "mode": "single", + "proxy_url": "http://127.0.0.1:7890" + } +} +``` + +特点: +- 简单直接,适合本地开发和测试 +- 所有请求通过同一个代理 +- 不需要刷新和轮换 + +### Mode 2: Pool(代理池模式) + +适用场景:拥有多个固定代理服务器,需要轮换使用 + +```json +{ + "proxy": { + "enabled": true, + "mode": "pool", + "proxy_list": [ + "http://proxy1.example.com:8080", + "http://user:pass@proxy2.example.com:8080", + "socks5://proxy3.example.com:1080" + ], + "blacklist_ttl": 5 + } +} +``` + +特点: +- 支持多协议:HTTP、HTTPS、SOCKS5 +- 随机选择代理,分散请求压力 +- 失败的代理自动加入黑名单 +- 黑名单IP经过TTL次刷新后自动恢复 + +### Mode 3: BrightData(动态IP模式) + +适用场景:使用Bright Data等提供API的动态代理服务 + +```json +{ + "proxy": { + "enabled": true, + "mode": "brightdata", + "brightdata_endpoint": "https://api.brightdata.com/zones/get_ips", + "brightdata_token": "your_api_token", + "brightdata_zone": "residential", + "proxy_host": "brd.superproxy.io:22225", + "proxy_user": "brd-customer-xxx-zone-residential-ip-%s", + "proxy_password": "your_password", + "refresh_interval": 1800, + "blacklist_ttl": 5 + } +} +``` + +特点: +- 从API动态获取可用IP列表 +- 自动定时刷新(默认30分钟) +- 支持用户名模板(`%s` 替换为IP地址) +- 黑名单TTL机制避免频繁切换 + +**用户名模板说明**: +``` +proxy_user: "brd-customer-xxx-zone-residential-ip-%s" + ↑ + 自动替换为IP地址 +``` + +## 核心API + +### 全局函数 + +```go +// 初始化全局代理管理器(只执行一次) +func InitGlobalProxyManager(config *Config) error + +// 获取全局代理管理器实例 +func GetGlobalProxyManager() *ProxyManager + +// 获取代理HTTP客户端(包含ProxyID和IP信息) +func GetProxyHTTPClient() (*ProxyClient, error) + +// 将代理IP添加到黑名单 +func AddBlacklist(proxyID int) + +// 刷新IP列表 +func RefreshIPList() error + +// 检查代理是否启用 +func IsEnabled() bool +``` + +### ProxyManager 方法 + +```go +// 获取代理客户端 +func (m *ProxyManager) GetProxyClient() (*ProxyClient, error) + +// 刷新IP列表 +func (m *ProxyManager) RefreshIPList() error + +// 添加到黑名单 +func (m *ProxyManager) AddBlacklist(proxyID int) + +// 获取黑名单状态 +func (m *ProxyManager) GetBlacklistStatus() (total, blacklisted, available int) + +// 启动自动刷新 +func (m *ProxyManager) StartAutoRefresh() + +// 停止自动刷新 +func (m *ProxyManager) StopAutoRefresh() +``` + +## 黑名单机制 + +### 工作原理 + +1. **添加黑名单**:当代理请求失败时,调用 `AddBlacklist(proxyID)` 将该IP加入黑名单 +2. **TTL倒计时**:每次刷新IP列表时,黑名单中的IP的TTL减1 +3. **自动恢复**:当TTL归零时,IP自动从黑名单移除,重新可用 + +### 线程安全保证 + +```go +// 添加黑名单使用写锁 +func (m *ProxyManager) AddBlacklist(proxyID int) { + m.mutex.Lock() + defer m.mutex.Unlock() + + // 防越界检查 + if proxyID < 0 || proxyID >= len(m.ipList) { + log.Printf("⚠️ 无效的 ProxyID: %d", proxyID) + return + } + + ip := m.ipList[proxyID].IP + m.blacklist[proxyID] = ip + m.ipBlacklist[ip] = m.config.BlacklistTTL +} + +// 获取代理使用读锁(支持并发) +func (m *ProxyManager) getRandomProxy() (int, *ProxyIP, error) { + m.mutex.RLock() + defer m.mutex.RUnlock() + // ... 读取操作 +} +``` + +### 示例流程 + +``` +初始状态:5个代理IP,TTL=3 +IP列表: [IP1, IP2, IP3, IP4, IP5] +黑名单: {} + +第1次失败:IP2请求失败 +IP列表: [IP1, IP2, IP3, IP4, IP5] +黑名单: {IP2: TTL=3} + +第1次刷新:TTL-1 +黑名单: {IP2: TTL=2} + +第2次刷新:TTL-1 +黑名单: {IP2: TTL=1} + +第3次刷新:TTL-1 +黑名单: {IP2: TTL=0} → 从黑名单移除 + +第3次刷新后: +IP列表: [IP1, IP2, IP3, IP4, IP5] +黑名单: {} ← IP2已恢复可用 +``` + +## 完整使用示例 + +### 示例1:币安API请求(单代理模式) + +```go +package main + +import ( + "log" + "nofx/proxy" + "time" +) + +func main() { + // 初始化代理 + err := proxy.InitGlobalProxyManager(&proxy.Config{ + Enabled: true, + Mode: "single", + ProxyURL: "http://127.0.0.1:7890", + Timeout: 30 * time.Second, + }) + if err != nil { + log.Fatalf("初始化代理失败: %v", err) + } + + // 获取币安数据 + proxyClient, err := proxy.GetProxyHTTPClient() + if err != nil { + log.Fatalf("获取代理客户端失败: %v", err) + } + + resp, err := proxyClient.Client.Get("https://fapi.binance.com/fapi/v1/ticker/24hr") + if err != nil { + log.Printf("请求失败: %v", err) + return + } + defer resp.Body.Close() + + log.Printf("请求成功,使用代理: %s", proxyClient.IP) +} +``` + +### 示例2:OI数据获取(代理池模式 + 黑名单) + +```go +package main + +import ( + "fmt" + "io" + "log" + "nofx/proxy" + "time" +) + +func fetchOIData(symbol string) error { + proxyClient, err := proxy.GetProxyHTTPClient() + if err != nil { + return fmt.Errorf("获取代理失败: %w", err) + } + + url := fmt.Sprintf("https://fapi.binance.com/futures/data/openInterestHist?symbol=%s&period=5m&limit=1", symbol) + resp, err := proxyClient.Client.Get(url) + if err != nil { + // 请求失败,加入黑名单 + proxy.AddBlacklist(proxyClient.ProxyID) + return fmt.Errorf("请求失败 (代理: %s): %w", proxyClient.IP, err) + } + defer resp.Body.Close() + + if resp.StatusCode != 200 { + // 状态码异常,加入黑名单 + proxy.AddBlacklist(proxyClient.ProxyID) + return fmt.Errorf("状态码异常: %d (代理: %s)", resp.StatusCode, proxyClient.IP) + } + + body, _ := io.ReadAll(resp.Body) + log.Printf("✓ 获取 %s OI数据成功 (代理: %s): %s", symbol, proxyClient.IP, string(body)) + return nil +} + +func main() { + // 初始化代理池 + err := proxy.InitGlobalProxyManager(&proxy.Config{ + Enabled: true, + Mode: "pool", + ProxyList: []string{ + "http://proxy1.example.com:8080", + "http://proxy2.example.com:8080", + "http://proxy3.example.com:8080", + }, + Timeout: 30 * time.Second, + BlacklistTTL: 5, + }) + if err != nil { + log.Fatalf("初始化代理失败: %v", err) + } + + // 循环获取数据 + symbols := []string{"BTCUSDT", "ETHUSDT", "SOLUSDT"} + for { + for _, symbol := range symbols { + if err := fetchOIData(symbol); err != nil { + log.Printf("⚠️ %v", err) + } + time.Sleep(1 * time.Second) + } + time.Sleep(10 * time.Second) + } +} +``` + +### 示例3:Bright Data动态IP + +```go +package main + +import ( + "log" + "nofx/proxy" + "time" +) + +func main() { + // 初始化Bright Data代理 + err := proxy.InitGlobalProxyManager(&proxy.Config{ + Enabled: true, + Mode: "brightdata", + BrightDataEndpoint: "https://api.brightdata.com/zones/get_ips", + BrightDataToken: "your_token", + BrightDataZone: "residential", + ProxyHost: "brd.superproxy.io:22225", + ProxyUser: "brd-customer-xxx-zone-residential-ip-%s", + ProxyPassword: "your_password", + RefreshInterval: 30 * time.Minute, + Timeout: 30 * time.Second, + BlacklistTTL: 5, + }) + if err != nil { + log.Fatalf("初始化代理失败: %v", err) + } + + // 代理会自动每30分钟刷新IP列表 + log.Println("✓ Bright Data代理已启动,自动刷新已开启") + + // 获取并使用代理 + for i := 0; i < 10; i++ { + proxyClient, err := proxy.GetProxyHTTPClient() + if err != nil { + log.Printf("获取代理失败: %v", err) + continue + } + + resp, err := proxyClient.Client.Get("https://api.ipify.org?format=json") + if err != nil { + proxy.AddBlacklist(proxyClient.ProxyID) + log.Printf("请求失败,代理已加入黑名单: %s", proxyClient.IP) + continue + } + resp.Body.Close() + + log.Printf("✓ 请求成功 (代理ID: %d, IP: %s)", proxyClient.ProxyID, proxyClient.IP) + time.Sleep(2 * time.Second) + } +} +``` + +## 注意事项 + +### 1. 模块解耦性 + +- ✅ 代理模块完全独立,不依赖其他业务模块 +- ✅ 禁用代理时自动使用直连,对业务代码透明 +- ✅ 适合多租户/多客户环境,可按需启用 + +### 2. 线程安全 + +- ✅ 所有公开方法都是线程安全的 +- ✅ 支持高并发场景下的代理获取和黑名单操作 +- ✅ 读写锁优化性能:读操作可并发,写操作独占 + +### 3. 错误处理 + +```go +proxyClient, err := proxy.GetProxyHTTPClient() +if err != nil { + // 可能的错误: + // - 代理IP列表为空 + // - 所有代理都在黑名单中 + // - 代理URL解析失败 + log.Printf("获取代理失败: %v", err) + + // 建议:降级为直连或重试 + return +} +``` + +### 4. 性能优化建议 + +- 对于高频请求,复用 `http.Client` 而不是每次创建新的 +- 合理设置 `refresh_interval` 避免频繁刷新 +- `blacklist_ttl` 建议设置为 3-10,平衡恢复速度和稳定性 + +### 5. 安全建议 + +- 生产环境中代理密钥应使用环境变量或密钥管理服务 +- 避免在日志中打印完整的代理URL(包含密码) +- TLS验证默认开启,如需跳过请谨慎评估风险 + +### 6. 调试技巧 + +```go +// 获取当前代理状态 +total, blacklisted, available := proxy.GetGlobalProxyManager().GetBlacklistStatus() +log.Printf("代理池状态: 总计=%d, 黑名单=%d, 可用=%d", total, blacklisted, available) + +// 检查是否启用 +if !proxy.IsEnabled() { + log.Println("代理未启用,请检查配置") +} +``` + +## 故障排查 + +### 问题1:获取代理失败 - "代理IP列表为空" + +**原因**: +- `single` 模式:未配置 `proxy_url` +- `pool` 模式:`proxy_list` 为空 +- `brightdata` 模式:API返回空列表或请求失败 + +**解决方案**: +```bash +# 检查配置文件 +cat config.json | grep -A 15 "proxy" + +# 检查日志,查看初始化信息 +# 应该看到类似:🌐 HTTP 代理已启用 (xxx模式) +``` + +### 问题2:所有代理都在黑名单中 + +**原因**:请求持续失败,所有IP被加入黑名单 + +**解决方案**: +```go +// 方案1:手动刷新IP列表(会触发TTL倒计时) +proxy.RefreshIPList() + +// 方案2:降低blacklist_ttl,加快恢复速度 +// config.json: "blacklist_ttl": 2 (默认5) + +// 方案3:检查代理本身是否可用 +// 使用curl测试代理: +// curl -x http://proxy_url https://api.binance.com/api/v3/ping +``` + +### 问题3:Bright Data模式无法获取IP + +**原因**: +- API端点配置错误 +- Token无效或过期 +- Zone参数不正确 + +**解决方案**: +```bash +# 手动测试API +curl -H "Authorization: Bearer YOUR_TOKEN" \ + "https://api.brightdata.com/zones/get_ips?zone=residential" + +# 检查返回格式是否符合: +# {"ips": [{"ip": "1.2.3.4", ...}, ...]} +``` + +### 问题4:代理连接超时 + +**原因**:代理服务器响应慢或网络不稳定 + +**解决方案**: +```json +{ + "proxy": { + "timeout": 60 // 增加超时时间(秒) + } +} +``` + +## 扩展开发 + +### 添加新的Provider + +实现 `IPProvider` 接口即可: + +```go +// custom_provider.go +package proxy + +type CustomProvider struct { + // 自定义字段 +} + +func NewCustomProvider(config string) *CustomProvider { + return &CustomProvider{} +} + +func (p *CustomProvider) GetIPList() ([]ProxyIP, error) { + // 实现获取IP列表的逻辑 + return []ProxyIP{}, nil +} + +func (p *CustomProvider) RefreshIPList() ([]ProxyIP, error) { + // 实现刷新IP列表的逻辑 + return p.GetIPList() +} +``` + +然后在 `proxy_manager.go` 的 `NewProxyManager` 中添加新模式: + +```go +case "custom": + m.provider = NewCustomProvider(config.CustomEndpoint) + log.Printf("🌐 HTTP 代理已启用 (自定义模式)") +``` + +## 更新日志 + +### v1.0.0 (当前版本) +- ✅ 支持三种代理模式:single、pool、brightdata +- ✅ 线程安全的IP轮换和黑名单管理 +- ✅ 自动刷新机制(30分钟默认) +- ✅ TTL黑名单自动恢复 +- ✅ 防越界保护 +- ✅ ProxyID追踪机制 + + +## 技术支持 + +如有问题或建议,请联系项目维护者 @hzb1115 +。 diff --git a/proxy/brightdata_provider.go b/proxy/brightdata_provider.go new file mode 100644 index 00000000..e8febd55 --- /dev/null +++ b/proxy/brightdata_provider.go @@ -0,0 +1,105 @@ +package proxy + +import ( + "encoding/json" + "fmt" + "io/ioutil" + "net/http" + "time" +) + +// BrightDataProvider Bright Data动态获取IP提供者 +type BrightDataProvider struct { + endpoint string + token string + zone string + client *http.Client +} + +// NewBrightDataProvider 创建Bright Data IP提供者 +func NewBrightDataProvider(endpoint, token, zone string) *BrightDataProvider { + return &BrightDataProvider{ + endpoint: endpoint, + token: token, + zone: zone, + client: &http.Client{ + Timeout: 30 * time.Second, + }, + } +} + +// BrightDataIPList Bright Data API返回的IP列表结构 +type BrightDataIPList struct { + IPs []struct { + IP string `json:"ip"` + Maxmind string `json:"maxmind"` + Ext map[string]interface{} `json:"ext"` + } `json:"ips"` +} + +func (p *BrightDataProvider) GetIPList() ([]ProxyIP, error) { + return p.fetchIPList() +} + +func (p *BrightDataProvider) RefreshIPList() ([]ProxyIP, error) { + return p.fetchIPList() +} + +func (p *BrightDataProvider) fetchIPList() ([]ProxyIP, error) { + // 构建请求URL + url := p.endpoint + if p.zone != "" { + url = fmt.Sprintf("%s?zone=%s", p.endpoint, p.zone) + } + + // 创建HTTP请求 + req, err := http.NewRequest("GET", url, nil) + if err != nil { + return nil, fmt.Errorf("创建HTTP请求失败: %w", err) + } + + // 设置授权头 + if p.token != "" { + req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", p.token)) + } + + // 发送请求 + resp, err := p.client.Do(req) + if err != nil { + return nil, fmt.Errorf("发送HTTP请求失败: %w", err) + } + defer resp.Body.Close() + + // 读取响应体 + body, err := ioutil.ReadAll(resp.Body) + if err != nil { + return nil, fmt.Errorf("读取HTTP响应失败: %w", err) + } + + // 检查状态码 + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("API返回错误状态码 %d: %s", resp.StatusCode, string(body)) + } + + // 解析JSON数据(支持Bright Data格式) + var ipList BrightDataIPList + if err := json.Unmarshal(body, &ipList); err != nil { + return nil, fmt.Errorf("解析JSON数据失败: %w", err) + } + + // 转换为ProxyIP列表 + result := make([]ProxyIP, 0, len(ipList.IPs)) + for _, ip := range ipList.IPs { + result = append(result, ProxyIP{ + IP: ip.IP, + Protocol: "http", + Ext: ip.Ext, + }) + } + + if len(result) == 0 { + return nil, fmt.Errorf("API返回的IP列表为空") + } + + return result, nil +} diff --git a/proxy/fixed_provider.go b/proxy/fixed_provider.go new file mode 100644 index 00000000..267b047e --- /dev/null +++ b/proxy/fixed_provider.go @@ -0,0 +1,42 @@ +package proxy + +import "strings" + +// FixedIPProvider 固定IP列表提供者 +type FixedIPProvider struct { + ips []ProxyIP +} + +// NewFixedIPProvider 创建固定IP列表提供者 +func NewFixedIPProvider(proxyURLs []string) *FixedIPProvider { + ips := make([]ProxyIP, 0, len(proxyURLs)) + for _, proxyURL := range proxyURLs { + // 简单解析代理URL + // 格式: http://ip:port 或 socks5://user:pass@ip:port + protocol := "http" + if strings.HasPrefix(proxyURL, "socks5://") { + protocol = "socks5" + proxyURL = strings.TrimPrefix(proxyURL, "socks5://") + } else if strings.HasPrefix(proxyURL, "http://") { + proxyURL = strings.TrimPrefix(proxyURL, "http://") + } else if strings.HasPrefix(proxyURL, "https://") { + protocol = "https" + proxyURL = strings.TrimPrefix(proxyURL, "https://") + } + + ips = append(ips, ProxyIP{ + IP: proxyURL, + Protocol: protocol, + }) + } + + return &FixedIPProvider{ips: ips} +} + +func (p *FixedIPProvider) GetIPList() ([]ProxyIP, error) { + return p.ips, nil +} + +func (p *FixedIPProvider) RefreshIPList() ([]ProxyIP, error) { + return p.ips, nil +} diff --git a/proxy/provider.go b/proxy/provider.go new file mode 100644 index 00000000..b4d6e06d --- /dev/null +++ b/proxy/provider.go @@ -0,0 +1,10 @@ +package proxy + +// IPProvider IP提供者接口 +type IPProvider interface { + // GetIPList 获取IP列表 + GetIPList() ([]ProxyIP, error) + + // RefreshIPList 刷新IP列表(可选实现) + RefreshIPList() ([]ProxyIP, error) +} diff --git a/proxy/proxy_client.go b/proxy/proxy_client.go new file mode 100644 index 00000000..cda50b00 --- /dev/null +++ b/proxy/proxy_client.go @@ -0,0 +1,47 @@ +package proxy + +import ( + "log" + "net/http" + "time" +) + +// --- 便捷函数(直接使用全局管理器) --- + +// GetProxyHTTPClient 获取代理 HTTP 客户端(返回 ProxyClient,包含 ProxyID) +func GetProxyHTTPClient() (*ProxyClient, error) { + return GetGlobalProxyManager().GetProxyClient() +} + +// NewHTTPClient 创建一个新的HTTP客户端(使用全局代理配置) +// 注意:不返回 ProxyID,如需 ProxyID 请使用 GetProxyHTTPClient() +func NewHTTPClient() *http.Client { + client, err := GetGlobalProxyManager().GetProxyClient() + if err != nil { + log.Printf("⚠️ 获取代理客户端失败,使用直连: %v", err) + return &http.Client{Timeout: 30 * time.Second} + } + return client.Client +} + +// NewHTTPClientWithTimeout 创建一个新的HTTP客户端并指定超时时间 +// 注意:不返回 ProxyID,如需 ProxyID 请使用 GetProxyHTTPClient() +func NewHTTPClientWithTimeout(timeout time.Duration) *http.Client { + client, err := GetGlobalProxyManager().GetProxyClient() + if err != nil { + log.Printf("⚠️ 获取代理客户端失败,使用直连: %v", err) + return &http.Client{Timeout: timeout} + } + client.Client.Timeout = timeout + return client.Client +} + +// GetTransport 获取HTTP Transport +func GetTransport() *http.Transport { + client, err := GetGlobalProxyManager().GetProxyClient() + if err != nil { + log.Printf("⚠️ 获取代理客户端失败,使用直连: %v", err) + return &http.Transport{} + } + return client.Client.Transport.(*http.Transport) +} \ No newline at end of file diff --git a/proxy/proxy_manager.go b/proxy/proxy_manager.go new file mode 100644 index 00000000..aaca00e4 --- /dev/null +++ b/proxy/proxy_manager.go @@ -0,0 +1,346 @@ +package proxy + +import ( + "crypto/tls" + "fmt" + "log" + "math/rand" + "net/http" + "net/url" + "sync" + "time" +) + +// ProxyManager 代理管理器 +type ProxyManager struct { + config *Config + provider IPProvider + + // IP池管理 + ipList []ProxyIP + blacklist map[int]string // ProxyID -> IP + ipBlacklist map[string]int // IP -> 剩余TTL + mutex sync.RWMutex // 读写锁,保证线程安全 + + // 刷新控制 + stopRefresh chan struct{} +} + +var ( + globalProxyManager *ProxyManager + once sync.Once +) + +// InitGlobalProxyManager 初始化全局代理管理器 +func InitGlobalProxyManager(config *Config) error { + var err error + once.Do(func() { + globalProxyManager, err = NewProxyManager(config) + if err == nil && config.Enabled && config.RefreshInterval > 0 { + globalProxyManager.StartAutoRefresh() + } + }) + return err +} + +// GetGlobalProxyManager 获取全局代理管理器 +func GetGlobalProxyManager() *ProxyManager { + if globalProxyManager == nil { + // 如果未初始化,使用默认配置(禁用代理) + _ = InitGlobalProxyManager(&Config{Enabled: false}) + } + return globalProxyManager +} + +// NewProxyManager 创建代理管理器 +func NewProxyManager(config *Config) (*ProxyManager, error) { + if config == nil { + config = &Config{Enabled: false} + } + + // 设置默认值 + if config.Timeout == 0 { + config.Timeout = 30 * time.Second + } + if config.BlacklistTTL == 0 { + config.BlacklistTTL = 5 // 默认 TTL 为 5 次刷新 + } + if config.RefreshInterval == 0 && config.Mode == "brightdata" { + config.RefreshInterval = 30 * time.Minute // 默认 30 分钟刷新一次 + } + + m := &ProxyManager{ + config: config, + blacklist: make(map[int]string), + ipBlacklist: make(map[string]int), + stopRefresh: make(chan struct{}), + } + + // 如果未启用代理,直接返回 + if !config.Enabled { + log.Printf("🌐 HTTP 代理未启用,使用直连") + return m, nil + } + + // 根据模式选择IP提供者 + switch config.Mode { + case "single": + // 单个代理模式 + if config.ProxyURL == "" { + return nil, fmt.Errorf("single模式下必须配置proxy_url") + } + m.provider = NewSingleProxyProvider(config.ProxyURL) + log.Printf("🌐 HTTP 代理已启用 (单代理模式): %s", config.ProxyURL) + + case "pool": + // 代理池模式(固定列表) + if len(config.ProxyList) == 0 { + return nil, fmt.Errorf("pool模式下必须配置proxy_list") + } + m.provider = NewFixedIPProvider(config.ProxyList) + log.Printf("🌐 HTTP 代理已启用 (代理池模式): %d个代理", len(config.ProxyList)) + + case "brightdata": + // Bright Data动态获取模式 + if config.BrightDataEndpoint == "" { + return nil, fmt.Errorf("brightdata模式下必须配置brightdata_endpoint") + } + m.provider = NewBrightDataProvider(config.BrightDataEndpoint, config.BrightDataToken, config.BrightDataZone) + log.Printf("🌐 HTTP 代理已启用 (Bright Data模式): %s", config.BrightDataEndpoint) + + default: + // 默认使用single模式 + if config.ProxyURL == "" { + return nil, fmt.Errorf("未知的proxy模式: %s", config.Mode) + } + m.provider = NewSingleProxyProvider(config.ProxyURL) + log.Printf("🌐 HTTP 代理已启用 (默认模式): %s", config.ProxyURL) + } + + // 初始化IP列表 + if err := m.RefreshIPList(); err != nil { + return nil, fmt.Errorf("初始化IP列表失败: %w", err) + } + + return m, nil +} + +// RefreshIPList 刷新IP列表(线程安全) +func (m *ProxyManager) RefreshIPList() error { + if m.provider == nil { + return nil + } + + ips, err := m.provider.RefreshIPList() + if err != nil { + return err + } + + m.mutex.Lock() + defer m.mutex.Unlock() + + // 清理黑名单,TTL倒计时 + validIPs := make([]ProxyIP, 0, len(ips)) + newBlacklist := make(map[int]string) + + for _, ip := range ips { + if ttl, inBlacklist := m.ipBlacklist[ip.IP]; inBlacklist { + // TTL 倒计时 + m.ipBlacklist[ip.IP] = ttl - 1 + if ttl > 0 { + // 仍在黑名单中,跳过 + continue + } + // TTL 归零,从黑名单移除 + delete(m.ipBlacklist, ip.IP) + log.Printf("✓ 代理IP已从黑名单恢复: %s", ip.IP) + } + validIPs = append(validIPs, ip) + } + + m.ipList = validIPs + m.blacklist = newBlacklist + + log.Printf("✓ 刷新代理IP列表: 总计%d个,黑名单%d个,可用%d个", + len(ips), len(m.ipBlacklist), len(validIPs)) + + return nil +} + +// StartAutoRefresh 启动自动刷新 +func (m *ProxyManager) StartAutoRefresh() { + if m.config.RefreshInterval <= 0 { + return + } + + go func() { + ticker := time.NewTicker(m.config.RefreshInterval) + defer ticker.Stop() + + for { + select { + case <-ticker.C: + if err := m.RefreshIPList(); err != nil { + log.Printf("⚠️ 自动刷新IP列表失败: %v", err) + } + case <-m.stopRefresh: + return + } + } + }() + + log.Printf("✓ 已启动代理IP自动刷新 (间隔: %v)", m.config.RefreshInterval) +} + +// StopAutoRefresh 停止自动刷新 +func (m *ProxyManager) StopAutoRefresh() { + close(m.stopRefresh) +} + +// getRandomProxy 随机获取一个可用代理(线程安全 - 读锁,确保不越界) +func (m *ProxyManager) getRandomProxy() (int, *ProxyIP, error) { + m.mutex.RLock() + defer m.mutex.RUnlock() + + if len(m.ipList) == 0 { + return -1, nil, fmt.Errorf("代理IP列表为空") + } + + // 找到所有未被黑名单的索引 + availableIndices := make([]int, 0, len(m.ipList)) + for i := range m.ipList { + if _, inBlacklist := m.blacklist[i]; !inBlacklist { + availableIndices = append(availableIndices, i) + } + } + + if len(availableIndices) == 0 { + return -1, nil, fmt.Errorf("所有代理IP都在黑名单中") + } + + // 随机选择一个(确保不越界) + randomIdx := availableIndices[rand.Intn(len(availableIndices))] + + // 二次检查,确保索引有效(防御性编程) + if randomIdx < 0 || randomIdx >= len(m.ipList) { + return -1, nil, fmt.Errorf("代理索引越界: %d (总数: %d)", randomIdx, len(m.ipList)) + } + + return randomIdx, &m.ipList[randomIdx], nil +} + +// buildProxyURL 构建代理URL +func (m *ProxyManager) buildProxyURL(ip *ProxyIP) string { + if m.config.ProxyHost != "" && m.config.ProxyUser != "" { + // 使用配置的代理主机和认证信息 + user := m.config.ProxyUser + if m.config.ProxyUser != "" && ip.IP != "" { + // 支持%s占位符替换IP + user = fmt.Sprintf(m.config.ProxyUser, ip.IP) + } + + protocol := ip.Protocol + if protocol == "" { + protocol = "http" + } + + if m.config.ProxyPassword != "" { + return fmt.Sprintf("%s://%s:%s@%s", protocol, user, m.config.ProxyPassword, m.config.ProxyHost) + } + return fmt.Sprintf("%s://%s@%s", protocol, user, m.config.ProxyHost) + } + + // 直接使用IP信息 + return ip.IP +} + +// GetProxyClient 获取代理客户端(线程安全) +func (m *ProxyManager) GetProxyClient() (*ProxyClient, error) { + if !m.config.Enabled { + // 未启用代理,返回普通HTTP客户端 + return &ProxyClient{ + ProxyID: -1, // -1 表示未使用代理 + IP: "direct", + Client: &http.Client{ + Timeout: m.config.Timeout, + }, + }, nil + } + + // 获取随机代理(使用读锁,确保不越界) + proxyID, proxyIP, err := m.getRandomProxy() + if err != nil { + return nil, err + } + + // 构建代理URL + proxyURLStr := m.buildProxyURL(proxyIP) + proxyURL, err := url.Parse(proxyURLStr) + if err != nil { + return nil, fmt.Errorf("解析代理URL失败: %w", err) + } + + // 创建Transport + transport := &http.Transport{ + Proxy: http.ProxyURL(proxyURL), + TLSClientConfig: &tls.Config{ + InsecureSkipVerify: false, + }, + MaxIdleConns: 100, + MaxIdleConnsPerHost: 10, + IdleConnTimeout: 90 * time.Second, + } + + return &ProxyClient{ + ProxyID: proxyID, + IP: proxyIP.IP, + Client: &http.Client{ + Transport: transport, + Timeout: m.config.Timeout, + }, + }, nil +} + +// AddBlacklist 将代理IP添加到黑名单(线程安全 - 写锁) +func (m *ProxyManager) AddBlacklist(proxyID int) { + m.mutex.Lock() + defer m.mutex.Unlock() + + // 检查 proxyID 有效性,防止越界 + if proxyID < 0 || proxyID >= len(m.ipList) { + log.Printf("⚠️ 无效的 ProxyID: %d (有效范围: 0-%d)", proxyID, len(m.ipList)-1) + return + } + + ip := m.ipList[proxyID].IP + m.blacklist[proxyID] = ip + m.ipBlacklist[ip] = m.config.BlacklistTTL + + log.Printf("⚠️ 代理IP已加入黑名单: %s (ProxyID: %d, TTL: %d)", ip, proxyID, m.config.BlacklistTTL) +} + +// GetBlacklistStatus 获取黑名单状态(线程安全 - 读锁) +func (m *ProxyManager) GetBlacklistStatus() (total int, blacklisted int, available int) { + m.mutex.RLock() + defer m.mutex.RUnlock() + + total = len(m.ipList) + blacklisted = len(m.ipBlacklist) + available = total - len(m.blacklist) + return +} + +// IsEnabled 检查代理是否启用 +func IsEnabled() bool { + return GetGlobalProxyManager().config.Enabled +} + +// RefreshIPList 刷新全局代理IP列表 +func RefreshIPList() error { + return GetGlobalProxyManager().RefreshIPList() +} + +// AddBlacklist 将代理IP添加到全局黑名单 +func AddBlacklist(proxyID int) { + GetGlobalProxyManager().AddBlacklist(proxyID) +} diff --git a/proxy/single_provider.go b/proxy/single_provider.go new file mode 100644 index 00000000..bbea9fce --- /dev/null +++ b/proxy/single_provider.go @@ -0,0 +1,19 @@ +package proxy + +// SingleProxyProvider 单个代理提供者(不使用IP池) +type SingleProxyProvider struct { + proxyURL string +} + +// NewSingleProxyProvider 创建单个代理提供者 +func NewSingleProxyProvider(proxyURL string) *SingleProxyProvider { + return &SingleProxyProvider{proxyURL: proxyURL} +} + +func (p *SingleProxyProvider) GetIPList() ([]ProxyIP, error) { + return []ProxyIP{{IP: p.proxyURL}}, nil +} + +func (p *SingleProxyProvider) RefreshIPList() ([]ProxyIP, error) { + return p.GetIPList() +} diff --git a/proxy/types.go b/proxy/types.go new file mode 100644 index 00000000..89678c86 --- /dev/null +++ b/proxy/types.go @@ -0,0 +1,40 @@ +package proxy + +import ( + "net/http" + "time" +) + +// ProxyIP 代理IP信息 +type ProxyIP struct { + IP string `json:"ip"` // IP地址 + Port string `json:"port"` // 端口(可选) + Username string `json:"username"` // 用户名(可选) + Password string `json:"password"` // 密码(可选) + Protocol string `json:"protocol"` // 协议: http, https, socks5 + Ext map[string]interface{} `json:"ext"` // 扩展信息 +} + +// ProxyClient 代理客户端 +type ProxyClient struct { + ProxyID int // IP池中的代理ID(索引) + IP string // 使用的IP地址 + *http.Client // HTTP客户端 +} + +// Config 代理配置 +type Config struct { + Enabled bool // 是否启用代理 + Mode string // 模式: "single", "pool", "brightdata" + Timeout time.Duration // 超时时间 + ProxyURL string // 单个代理地址 (single模式) + ProxyList []string // 代理列表 (pool模式) + BrightDataEndpoint string // Bright Data接口地址 (brightdata模式) + BrightDataToken string // Bright Data访问令牌 (brightdata模式) + BrightDataZone string // Bright Data区域 (brightdata模式) + ProxyHost string // 代理主机 + ProxyUser string // 代理用户名模板(支持%s占位符) + ProxyPassword string // 代理密码 + RefreshInterval time.Duration // IP列表刷新间隔 + BlacklistTTL int // 黑名单IP的TTL(刷新次数) +} From bbf34e70c28361567329ff8fab4d956e6b03fd70 Mon Sep 17 00:00:00 2001 From: icy Date: Thu, 6 Nov 2025 22:05:21 +0800 Subject: [PATCH 2/9] =?UTF-8?q?feat:=20improve=20model/exchange=20deletion?= =?UTF-8?q?=20and=20selection=20logic=20-=20Add=20soft=20delete=20support?= =?UTF-8?q?=20for=20AI=20models=20=20=20*=20Add=20deleted=20field=20to=20a?= =?UTF-8?q?i=5Fmodels=20table=20=20=20*=20Implement=20soft=20delete=20with?= =?UTF-8?q?=20sensitive=20data=20cleanup=20=20=20*=20Filter=20deleted=20re?= =?UTF-8?q?cords=20in=20queries=20-=20Replace=20browser=20confirm=20dialog?= =?UTF-8?q?s=20with=20custom=20styled=20modals=20=20=20*=20Create=20Delete?= =?UTF-8?q?ConfirmModal=20component=20with=20Binance=20theme=20=20=20*=20A?= =?UTF-8?q?dd=20proper=20warning=20messages=20and=20icons=20=20=20*=20Impr?= =?UTF-8?q?ove=20user=20experience=20with=20consistent=20styling=20-=20Fix?= =?UTF-8?q?=20duplicate=20model/exchange=20display=20in=20selection=20drop?= =?UTF-8?q?downs=20=20=20*=20Use=20supportedModels/supportedExchanges=20fo?= =?UTF-8?q?r=20modal=20selectors=20=20=20*=20Use=20configuredModels/config?= =?UTF-8?q?uredExchanges=20for=20panel=20display=20=20=20*=20Remove=20redu?= =?UTF-8?q?ndant=20selectableModels/selectableExchanges=20logic=20-=20Enha?= =?UTF-8?q?nce=20data=20management=20=20=20*=20Refresh=20data=20after=20de?= =?UTF-8?q?letion=20operations=20=20=20*=20Proper=20state=20cleanup=20afte?= =?UTF-8?q?r=20modal=20operations=20=20=20*=20Clear=20sensitive=20data=20d?= =?UTF-8?q?uring=20soft=20delete=20=F0=9F=A4=96=20Generated=20with=20[Clau?= =?UTF-8?q?de=20Code](https://claude.ai/code)=20Co-Authored-By:=20tinkle-c?= =?UTF-8?q?ommunity=20?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- config/database_pg.go | 64 ++++++++- db/init.sql | 4 + web/src/components/AITradersPage.tsx | 191 +++++++++++++++++++-------- web/src/i18n/translations.ts | 14 ++ 4 files changed, 216 insertions(+), 57 deletions(-) diff --git a/config/database_pg.go b/config/database_pg.go index a029948c..a7da471e 100644 --- a/config/database_pg.go +++ b/config/database_pg.go @@ -140,8 +140,9 @@ func (d *PostgreSQLDatabase) GetAIModels(userID string) ([]*AIModelConfig, error SELECT id, user_id, name, provider, enabled, api_key, COALESCE(custom_api_url, '') as custom_api_url, COALESCE(custom_model_name, '') as custom_model_name, + COALESCE(deleted, FALSE) as deleted, created_at, updated_at - FROM ai_models WHERE user_id = $1 ORDER BY id + FROM ai_models WHERE user_id = $1 AND COALESCE(deleted, FALSE) = FALSE ORDER BY id `, userID) if err != nil { return nil, err @@ -152,10 +153,11 @@ func (d *PostgreSQLDatabase) GetAIModels(userID string) ([]*AIModelConfig, error models := make([]*AIModelConfig, 0) for rows.Next() { var model AIModelConfig + var deleted bool // 临时变量,用于读取 deleted 字段但不保存到结构体 err := rows.Scan( &model.ID, &model.UserID, &model.Name, &model.Provider, &model.Enabled, &model.APIKey, &model.CustomAPIURL, &model.CustomModelName, - &model.CreatedAt, &model.UpdatedAt, + &deleted, &model.CreatedAt, &model.UpdatedAt, ) if err != nil { return nil, err @@ -168,7 +170,59 @@ func (d *PostgreSQLDatabase) GetAIModels(userID string) ([]*AIModelConfig, error // UpdateAIModel 更新AI模型配置,如果不存在则创建用户特定配置 func (d *PostgreSQLDatabase) UpdateAIModel(userID, id string, enabled bool, apiKey, customAPIURL, customModelName string) error { - // 先尝试精确匹配 ID(新版逻辑,支持多个相同 provider 的模型) + log.Printf("🔧 UpdateAIModel: userID=%s, id=%s, enabled=%v", userID, id, enabled) + + // 检查是否为删除操作(API Key 为空且 enabled 为 false 表示删除) + isDelete := !enabled && apiKey == "" && customAPIURL == "" && customModelName == "" + + if isDelete { + // 执行软删除:标记为已删除并清空敏感数据 + // 先尝试精确匹配 ID + var existingID string + err := d.db.QueryRow(` + SELECT id FROM ai_models WHERE user_id = $1 AND id = $2 LIMIT 1 + `, userID, id).Scan(&existingID) + + if err == nil { + // 找到了现有配置(精确匹配 ID),标记为删除并清空敏感数据 + _, err = d.db.Exec(` + UPDATE ai_models SET enabled = FALSE, deleted = TRUE, api_key = '', custom_api_url = '', custom_model_name = '', updated_at = CURRENT_TIMESTAMP + WHERE id = $1 AND user_id = $2 + `, existingID, userID) + if err != nil { + log.Printf("❌ UpdateAIModel: 标记删除失败: %v", err) + return err + } + log.Printf("🗑️ UpdateAIModel: 已标记删除用户 %s 的模型配置 %s", userID, existingID) + return nil + } + + // ID 不存在,尝试兼容旧逻辑:将 id 作为 provider 查找 + provider := id + err = d.db.QueryRow(` + SELECT id FROM ai_models WHERE user_id = $1 AND provider = $2 LIMIT 1 + `, userID, provider).Scan(&existingID) + + if err == nil { + // 找到了现有配置(通过 provider 匹配),标记为删除并清空敏感数据 + _, err = d.db.Exec(` + UPDATE ai_models SET enabled = FALSE, deleted = TRUE, api_key = '', custom_api_url = '', custom_model_name = '', updated_at = CURRENT_TIMESTAMP + WHERE id = $1 AND user_id = $2 + `, existingID, userID) + if err != nil { + log.Printf("❌ UpdateAIModel: 标记删除失败: %v", err) + return err + } + log.Printf("🗑️ UpdateAIModel: 已标记删除用户 %s 的模型配置 %s (通过provider匹配)", userID, existingID) + return nil + } + + // 没有找到配置,返回成功(幂等性) + log.Printf("ℹ️ UpdateAIModel: 模型配置不存在,跳过删除: %s", id) + return nil + } + + // 启用模型的情况:先尝试精确匹配 ID(新版逻辑,支持多个相同 provider 的模型) var existingID string err := d.db.QueryRow(` SELECT id FROM ai_models WHERE user_id = $1 AND id = $2 LIMIT 1 @@ -177,7 +231,7 @@ func (d *PostgreSQLDatabase) UpdateAIModel(userID, id string, enabled bool, apiK if err == nil { // 找到了现有配置(精确匹配 ID),更新它 _, err = d.db.Exec(` - UPDATE ai_models SET enabled = $1, api_key = $2, custom_api_url = $3, custom_model_name = $4, updated_at = CURRENT_TIMESTAMP + UPDATE ai_models SET enabled = $1, api_key = $2, custom_api_url = $3, custom_model_name = $4, deleted = FALSE, updated_at = CURRENT_TIMESTAMP WHERE id = $5 AND user_id = $6 `, enabled, apiKey, customAPIURL, customModelName, existingID, userID) return err @@ -193,7 +247,7 @@ func (d *PostgreSQLDatabase) UpdateAIModel(userID, id string, enabled bool, apiK // 找到了现有配置(通过 provider 匹配,兼容旧版),更新它 log.Printf("⚠️ 使用旧版 provider 匹配更新模型: %s -> %s", provider, existingID) _, err = d.db.Exec(` - UPDATE ai_models SET enabled = $1, api_key = $2, custom_api_url = $3, custom_model_name = $4, updated_at = CURRENT_TIMESTAMP + UPDATE ai_models SET enabled = $1, api_key = $2, custom_api_url = $3, custom_model_name = $4, deleted = FALSE, updated_at = CURRENT_TIMESTAMP WHERE id = $5 AND user_id = $6 `, enabled, apiKey, customAPIURL, customModelName, existingID, userID) return err diff --git a/db/init.sql b/db/init.sql index efec4646..c89fa78a 100644 --- a/db/init.sql +++ b/db/init.sql @@ -22,6 +22,7 @@ CREATE TABLE IF NOT EXISTS ai_models ( 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 @@ -167,6 +168,9 @@ INSERT INTO system_config (key, value) VALUES ('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); diff --git a/web/src/components/AITradersPage.tsx b/web/src/components/AITradersPage.tsx index 8b85d6c8..198821bd 100644 --- a/web/src/components/AITradersPage.tsx +++ b/web/src/components/AITradersPage.tsx @@ -1,4 +1,4 @@ -import React, { useState, useEffect, useMemo } from 'react' +import React, { useState, useEffect } from 'react' import useSWR from 'swr' import { api } from '../lib/api' import type { @@ -58,6 +58,8 @@ export function AITradersPage({ onTraderSelect }: AITradersPageProps) { const [showModelModal, setShowModelModal] = useState(false) const [showExchangeModal, setShowExchangeModal] = useState(false) const [showSignalSourceModal, setShowSignalSourceModal] = useState(false) + const [showDeleteConfirm, setShowDeleteConfirm] = useState(false) + const [deleteTarget, setDeleteTarget] = useState<{type: 'model' | 'exchange', id: string} | null>(null) const [editingModel, setEditingModel] = useState(null) const [editingExchange, setEditingExchange] = useState(null) const [editingTrader, setEditingTrader] = useState(null) @@ -135,20 +137,6 @@ export function AITradersPage({ onTraderSelect }: AITradersPageProps) { const configuredModels = allModels || [] const configuredExchanges = allExchanges || [] - const selectableModels = useMemo(() => { - return (supportedModels || []).map((model) => { - const configured = allModels?.find((m) => m.id === model.id) - return configured ? { ...model, ...configured } : model - }) - }, [supportedModels, allModels]) - - const selectableExchanges = useMemo(() => { - return (supportedExchanges || []).map((exchange) => { - const configured = allExchanges?.find((e) => e.id === exchange.id) - return configured ? { ...exchange, ...configured } : exchange - }) - }, [supportedExchanges, allExchanges]) - // 只在创建交易员时使用已启用且配置完整的 const enabledModels = allModels?.filter((m) => m.enabled && m.apiKey) || [] const enabledExchanges = @@ -313,8 +301,6 @@ export function AITradersPage({ onTraderSelect }: AITradersPageProps) { } const handleDeleteModelConfig = async (modelId: string) => { - if (!confirm(t('confirmDeleteModel', language))) return - try { const updatedModels = allModels?.map((m) => @@ -344,15 +330,31 @@ export function AITradersPage({ onTraderSelect }: AITradersPageProps) { } await api.updateModelConfigs(request) - setAllModels(updatedModels) + + // 重新获取用户配置以确保数据同步 + const refreshedModels = await api.getModelConfigs() + setAllModels(refreshedModels) + setShowModelModal(false) setEditingModel(null) + setShowDeleteConfirm(false) + setDeleteTarget(null) } catch (error) { console.error('Failed to delete model config:', error) alert(t('deleteConfigFailed', language)) } } + const handleConfirmDelete = () => { + if (!deleteTarget) return + + if (deleteTarget.type === 'model') { + handleDeleteModelConfig(deleteTarget.id) + } else if (deleteTarget.type === 'exchange') { + handleDeleteExchangeConfig(deleteTarget.id) + } + } + const handleSaveModelConfig = async ( modelId: string, apiKey: string, @@ -427,8 +429,6 @@ export function AITradersPage({ onTraderSelect }: AITradersPageProps) { } const handleDeleteExchangeConfig = async (exchangeId: string) => { - if (!confirm(t('confirmDeleteExchange', language))) return - try { const request = { exchanges: { @@ -451,6 +451,8 @@ export function AITradersPage({ onTraderSelect }: AITradersPageProps) { setAllExchanges(refreshed) setShowExchangeModal(false) setEditingExchange(null) + setShowDeleteConfirm(false) + setDeleteTarget(null) } catch (error) { console.error('Failed to delete exchange config:', error) alert(t('deleteExchangeConfigFailed', language)) @@ -1043,15 +1045,18 @@ export function AITradersPage({ onTraderSelect }: AITradersPageProps) { {/* Model Configuration Modal */} {showModelModal && ( { setShowModelModal(false) setEditingModel(null) }} + onDelete={(modelId) => { + setDeleteTarget({ type: 'model', id: modelId }) + setShowDeleteConfirm(true) + }} language={language} /> )} @@ -1059,15 +1064,18 @@ export function AITradersPage({ onTraderSelect }: AITradersPageProps) { {/* Exchange Configuration Modal */} {showExchangeModal && ( { setShowExchangeModal(false) setEditingExchange(null) }} + onDelete={(exchangeId) => { + setDeleteTarget({ type: 'exchange', id: exchangeId }) + setShowDeleteConfirm(true) + }} language={language} /> )} @@ -1082,6 +1090,27 @@ export function AITradersPage({ onTraderSelect }: AITradersPageProps) { language={language} /> )} + + {/* Delete Confirmation Modal */} + {showDeleteConfirm && deleteTarget && ( + { + setShowDeleteConfirm(false) + setDeleteTarget(null) + }} + language={language} + /> + )} ) } @@ -1255,17 +1284,80 @@ function SignalSourceModal({ ) } +// Delete Confirmation Modal Component +function DeleteConfirmModal({ + isOpen, + title, + message, + onConfirm, + onCancel, + language, +}: { + isOpen: boolean + title: string + message: string + onConfirm: () => void + onCancel: () => void + language: Language +}) { + if (!isOpen) return null + + return ( +
+
+
+
+ +
+

+ {title} +

+
+ +

+ {message} +

+ +
+ + +
+
+
+ ) +} + // Model Configuration Modal Component function ModelConfigModal({ - allModels, + supportedModels, configuredModels, editingModelId, onSave, - onDelete, onClose, + onDelete, language, }: { - allModels: AIModel[] + supportedModels: AIModel[] configuredModels: AIModel[] editingModelId: string | null onSave: ( @@ -1274,8 +1366,8 @@ function ModelConfigModal({ baseUrl?: string, modelName?: string ) => void - onDelete: (modelId: string) => void onClose: () => void + onDelete: (modelId: string) => void language: Language }) { const [selectedModelId, setSelectedModelId] = useState(editingModelId || '') @@ -1283,10 +1375,10 @@ function ModelConfigModal({ const [baseUrl, setBaseUrl] = useState('') const [modelName, setModelName] = useState('') - // 获取当前编辑的模型信息 - 编辑时从已配置的模型中查找,新建时从所有支持的模型中查找 + // 获取当前编辑的模型信息 - 编辑时从已配置的模型中查找,新建时从支持的模型中查找 const selectedModel = editingModelId - ? configuredModels?.find((m) => m.id === selectedModelId) - : allModels?.find((m) => m.id === selectedModelId) + ? configuredModels?.find((m) => m.id === selectedModelId) // 编辑:从已配置中获取完整信息 + : supportedModels?.find((m) => m.id === selectedModelId) // 新建:从支持列表获取基本信息 // 如果是编辑现有模型,初始化API Key、Base URL和Model Name useEffect(() => { @@ -1309,8 +1401,8 @@ function ModelConfigModal({ ) } - // 可选择的模型列表(所有支持的模型) - const availableModels = allModels || [] + // 可选择的模型列表:直接使用系统支持的模型 + const availableModels = supportedModels || [] return (
@@ -1327,14 +1419,10 @@ function ModelConfigModal({ {editingModelId && ( @@ -1528,15 +1616,15 @@ function ModelConfigModal({ // Exchange Configuration Modal Component function ExchangeConfigModal({ - allExchanges, + supportedExchanges, configuredExchanges, editingExchangeId, onSave, - onDelete, onClose, + onDelete, language, }: { - allExchanges: Exchange[] + supportedExchanges: Exchange[] configuredExchanges: Exchange[] editingExchangeId: string | null onSave: ( @@ -1549,8 +1637,8 @@ function ExchangeConfigModal({ asterSigner?: string, asterPrivateKey?: string ) => Promise - onDelete: (exchangeId: string) => void onClose: () => void + onDelete: (exchangeId: string) => void language: Language }) { const [selectedExchangeId, setSelectedExchangeId] = useState( @@ -1581,10 +1669,10 @@ function ExchangeConfigModal({ // 获取当前选择的交易所信息 // 编辑模式:从 configuredExchanges 查找(包含用户配置的 apiKey、secretKey 等) - // 新增模式:从 allExchanges 查找(系统支持的交易所列表) + // 新增模式:从 supportedExchanges 查找(系统支持的交易所列表) const selectedExchange = editingExchangeId ? configuredExchanges?.find(e => e.id === selectedExchangeId) - : allExchanges?.find(e => e.id === selectedExchangeId); + : supportedExchanges?.find(e => e.id === selectedExchangeId); // 如果是编辑现有交易所,初始化表单数据 useEffect(() => { @@ -1622,6 +1710,9 @@ function ExchangeConfigModal({ } }, [selectedExchangeId]) + // 可选择的交易所列表:直接使用系统支持的交易所 + const availableExchanges = supportedExchanges || [] + const handleCopyIP = (ip: string) => { navigator.clipboard.writeText(ip).then(() => { setCopiedIP(true) @@ -1693,17 +1784,13 @@ function ExchangeConfigModal({ {editingExchangeId && ( @@ -1732,7 +1819,7 @@ function ExchangeConfigModal({ required > - {(allExchanges || []).map((exchange) => ( + {availableExchanges.map((exchange) => (
) @@ -1691,21 +1670,18 @@ function ExchangeConfigModal({ ? t('hyperliquidExchangeName', language) : undefined - // 如果是编辑现有交易所,初始化表单数据 + // 如果是编辑现有交易所,清空所有敏感字段以保证安全 useEffect(() => { if (editingExchangeId && selectedExchange) { - setApiKey(selectedExchange.apiKey || '') - setSecretKey(selectedExchange.secretKey || '') - setPassphrase('') // Don't load existing passphrase for security + // 编辑模式下清空所有敏感字段,用户需要重新输入 + setApiKey('') + setSecretKey('') + setPassphrase('') setTestnet(selectedExchange.testnet || false) - - // Hyperliquid 字段 - setHyperliquidWalletAddr(selectedExchange.hyperliquidWalletAddr || '') - - // Aster 字段 - setAsterUser(selectedExchange.asterUser || '') - setAsterSigner(selectedExchange.asterSigner || '') - setAsterPrivateKey('') // Don't load existing private key for security + setHyperliquidWalletAddr('') + setAsterUser('') + setAsterSigner('') + setAsterPrivateKey('') } }, [editingExchangeId, selectedExchange]) diff --git a/web/src/lib/crypto.ts b/web/src/lib/crypto.ts index 61548cf0..26ceb96f 100644 --- a/web/src/lib/crypto.ts +++ b/web/src/lib/crypto.ts @@ -12,6 +12,11 @@ export class CryptoService { private static publicKeyPEM: string | null = null; static async initialize(publicKeyPEM: string) { + // 检查 Web Crypto API 是否可用 + if (!window.crypto || !window.crypto.subtle) { + throw new Error('Web Crypto API is not available. Please use HTTPS or localhost to access the application.'); + } + if (this.publicKey && this.publicKeyPEM === publicKeyPEM) { return; } diff --git a/web/src/types.ts b/web/src/types.ts index efe0aa73..b230f082 100644 --- a/web/src/types.ts +++ b/web/src/types.ts @@ -108,19 +108,14 @@ export interface AIModel { export interface Exchange { id: string + user_id: string name: string type: 'cex' | 'dex' enabled: boolean - apiKey?: string - secretKey?: string testnet?: boolean - // Hyperliquid 特定字段 - hyperliquidWalletAddr?: string - // Aster 特定字段 - asterUser?: string - asterSigner?: string - asterPrivateKey?: string - deleted?: boolean + deleted: boolean + created_at: string + updated_at: string } export interface CreateTraderRequest {