diff --git a/backend/cmd/main.go b/backend/cmd/main.go index c1ebe25..8c69f82 100644 --- a/backend/cmd/main.go +++ b/backend/cmd/main.go @@ -7,6 +7,7 @@ import ( "io" "net" "net/http" + "net/url" "os" "path/filepath" "strconv" @@ -98,6 +99,7 @@ func startServer(cfg *config.Config) { // 基础 API mux.HandleFunc("/api/health", api.CORS(handleHealth)) mux.HandleFunc("/api/config", api.CORS(handleConfig)) + mux.HandleFunc("/api/proxy/test", api.CORS(handleProxyTest)) // 代理测试 // 日志 API mux.HandleFunc("/api/logs", api.CORS(handleGetLogs)) @@ -126,8 +128,8 @@ func startServer(cfg *config.Config) { mux.HandleFunc("/api/upload/validate", api.CORS(api.HandleUploadValidate)) // 母号封禁检查 API - mux.HandleFunc("/api/db/owners/ban-check", api.CORS(api.HandleManualBanCheck)) // 手动触发检查 - mux.HandleFunc("/api/db/owners/ban-check/status", api.CORS(api.HandleBanCheckStatus)) // 检查状态 + mux.HandleFunc("/api/db/owners/ban-check", api.CORS(api.HandleManualBanCheck)) // 手动触发检查 + mux.HandleFunc("/api/db/owners/ban-check/status", api.CORS(api.HandleBanCheckStatus)) // 检查状态 mux.HandleFunc("/api/db/owners/ban-check/settings", api.CORS(api.HandleBanCheckSettings)) // 配置 // 注册测试 API @@ -956,3 +958,84 @@ func handleCleanerSettings(w http.ResponseWriter, r *http.Request) { api.Error(w, http.StatusMethodNotAllowed, "不支持的方法") } } + +// handleProxyTest POST /api/proxy/test - 测试代理连接 +func handleProxyTest(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + api.Error(w, http.StatusMethodNotAllowed, "仅支持 POST") + return + } + + var req struct { + ProxyURL string `json:"proxy_url"` + } + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + api.Error(w, http.StatusBadRequest, "请求格式错误") + return + } + + proxyURL := req.ProxyURL + if proxyURL == "" { + api.Error(w, http.StatusBadRequest, "代理地址不能为空") + return + } + + logger.Info(fmt.Sprintf("测试代理连接: %s", proxyURL), "", "proxy") + + // 解析代理 URL + proxyParsed, err := parseProxyURL(proxyURL) + if err != nil { + logger.Error(fmt.Sprintf("代理地址格式错误: %v", err), "", "proxy") + api.Error(w, http.StatusBadRequest, fmt.Sprintf("代理地址格式错误: %v", err)) + return + } + + // 创建带代理的 HTTP 客户端 + client := &http.Client{ + Timeout: 15 * time.Second, + Transport: &http.Transport{ + Proxy: http.ProxyURL(proxyParsed), + }, + } + + // 测试请求 - 使用 httpbin.org/ip 获取出口 IP + testURL := "https://httpbin.org/ip" + resp, err := client.Get(testURL) + if err != nil { + logger.Error(fmt.Sprintf("代理连接失败: %v", err), "", "proxy") + api.Error(w, http.StatusBadGateway, fmt.Sprintf("代理连接失败: %v", err)) + return + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + logger.Error(fmt.Sprintf("代理测试失败: HTTP %d", resp.StatusCode), "", "proxy") + api.Error(w, http.StatusBadGateway, fmt.Sprintf("代理测试失败: HTTP %d", resp.StatusCode)) + return + } + + // 解析响应获取出口 IP + var ipResp struct { + Origin string `json:"origin"` + } + if err := json.NewDecoder(resp.Body).Decode(&ipResp); err != nil { + logger.Warning(fmt.Sprintf("解析代理响应失败: %v", err), "", "proxy") + } + + logger.Success(fmt.Sprintf("代理连接成功, 出口IP: %s", ipResp.Origin), "", "proxy") + + api.Success(w, map[string]interface{}{ + "connected": true, + "message": "代理连接成功", + "origin_ip": ipResp.Origin, + }) +} + +// parseProxyURL 解析代理 URL +func parseProxyURL(proxyURL string) (*url.URL, error) { + // 如果没有协议前缀,默认添加 http:// + if !strings.HasPrefix(proxyURL, "http://") && !strings.HasPrefix(proxyURL, "https://") && !strings.HasPrefix(proxyURL, "socks5://") { + proxyURL = "http://" + proxyURL + } + return url.Parse(proxyURL) +} diff --git a/backend/internal/api/monitor.go b/backend/internal/api/monitor.go index 37180bb..b302d41 100644 --- a/backend/internal/api/monitor.go +++ b/backend/internal/api/monitor.go @@ -11,12 +11,13 @@ import ( // MonitorSettings 监控设置 type MonitorSettings struct { - Target int `json:"target"` - AutoAdd bool `json:"auto_add"` - MinInterval int `json:"min_interval"` - CheckInterval int `json:"check_interval"` // 自动补号检查间隔(秒) - PollingEnabled bool `json:"polling_enabled"` - PollingInterval int `json:"polling_interval"` + Target int `json:"target"` + AutoAdd bool `json:"auto_add"` + MinInterval int `json:"min_interval"` + CheckInterval int `json:"check_interval"` // 自动补号检查间隔(秒) + PollingEnabled bool `json:"polling_enabled"` + PollingInterval int `json:"polling_interval"` + ReplenishUseProxy bool `json:"replenish_use_proxy"` // 补号时使用代理 } // HandleGetMonitorSettings 获取监控设置 @@ -32,12 +33,13 @@ func HandleGetMonitorSettings(w http.ResponseWriter, r *http.Request) { } settings := MonitorSettings{ - Target: 50, - AutoAdd: false, - MinInterval: 300, - CheckInterval: 60, - PollingEnabled: false, - PollingInterval: 60, + Target: 50, + AutoAdd: false, + MinInterval: 300, + CheckInterval: 60, + PollingEnabled: false, + PollingInterval: 60, + ReplenishUseProxy: false, } if val, _ := database.Instance.GetConfig("monitor_target"); val != "" { @@ -66,6 +68,9 @@ func HandleGetMonitorSettings(w http.ResponseWriter, r *http.Request) { settings.PollingInterval = v } } + if val, _ := database.Instance.GetConfig("monitor_replenish_use_proxy"); val == "true" { + settings.ReplenishUseProxy = true + } Success(w, settings) } @@ -120,6 +125,9 @@ func HandleSaveMonitorSettings(w http.ResponseWriter, r *http.Request) { if err := database.Instance.SetConfig("monitor_polling_interval", strconv.Itoa(settings.PollingInterval)); err != nil { saveErrors = append(saveErrors, "polling_interval: "+err.Error()) } + if err := database.Instance.SetConfig("monitor_replenish_use_proxy", strconv.FormatBool(settings.ReplenishUseProxy)); err != nil { + saveErrors = append(saveErrors, "replenish_use_proxy: "+err.Error()) + } if len(saveErrors) > 0 { errMsg := "保存监控设置部分失败: " + saveErrors[0] diff --git a/backend/internal/api/s2a_clean.go b/backend/internal/api/s2a_clean.go index 86eeb1a..6cf5ebc 100644 --- a/backend/internal/api/s2a_clean.go +++ b/backend/internal/api/s2a_clean.go @@ -14,7 +14,7 @@ import ( // S2AAccountItem S2A 账号信息 type S2AAccountItem struct { ID int `json:"id"` - Email string `json:"email"` + Email string `json:"account"` // S2A API 返回的字段名是 account Status string `json:"status"` } diff --git a/frontend/src/context/ConfigContext.tsx b/frontend/src/context/ConfigContext.tsx index 966fe43..adfeaa7 100644 --- a/frontend/src/context/ConfigContext.tsx +++ b/frontend/src/context/ConfigContext.tsx @@ -46,6 +46,10 @@ export function ConfigProvider({ children }: { children: ReactNode }) { priority: serverConfig.priority || 0, groupIds: serverConfig.group_ids || [], }, + proxy: { + default: serverConfig.default_proxy || '', + enabled: serverConfig.proxy_enabled || false, + }, })) // 更新站点名称 if (serverConfig.site_name) { diff --git a/frontend/src/pages/Config.tsx b/frontend/src/pages/Config.tsx index 6c63a32..bce59cb 100644 --- a/frontend/src/pages/Config.tsx +++ b/frontend/src/pages/Config.tsx @@ -10,7 +10,10 @@ import { XCircle, Save, Loader2, - Globe + Globe, + Wifi, + WifiOff, + HelpCircle } from 'lucide-react' import { Card, CardHeader, CardTitle, CardContent, Button, Input } from '../components/common' import { useConfig } from '../hooks/useConfig' @@ -18,23 +21,29 @@ import { useConfig } from '../hooks/useConfig' export default function Config() { const { config, isConnected, refreshConfig } = useConfig() const [siteName, setSiteName] = useState('') + const [defaultProxy, setDefaultProxy] = useState('') const [saving, setSaving] = useState(false) + const [savingProxy, setSavingProxy] = useState(false) + const [testingProxy, setTestingProxy] = useState(false) + const [proxyStatus, setProxyStatus] = useState<'unknown' | 'success' | 'error'>('unknown') + const [proxyOriginIP, setProxyOriginIP] = useState('') const [message, setMessage] = useState<{ type: 'success' | 'error', text: string } | null>(null) - // 加载站点名称配置 + // 加载站点名称和代理配置 useEffect(() => { - const fetchSiteName = async () => { + const fetchConfig = async () => { try { const res = await fetch('/api/config') const data = await res.json() if (data.code === 0 && data.data) { setSiteName(data.data.site_name || 'Codex Pool') + setDefaultProxy(data.data.default_proxy || '') } } catch (error) { - console.error('Failed to fetch site name:', error) + console.error('Failed to fetch config:', error) } } - fetchSiteName() + fetchConfig() }, []) // 保存站点名称 @@ -61,6 +70,65 @@ export default function Config() { } } + // 保存代理地址 + const handleSaveProxy = async () => { + setSavingProxy(true) + setMessage(null) + try { + const res = await fetch('/api/config', { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ default_proxy: defaultProxy }), + }) + const data = await res.json() + if (data.code === 0) { + setMessage({ type: 'success', text: '代理地址已保存' }) + setProxyStatus('unknown') // 保存后重置状态 + setProxyOriginIP('') + refreshConfig() + } else { + setMessage({ type: 'error', text: data.message || '保存失败' }) + } + } catch { + setMessage({ type: 'error', text: '网络错误' }) + } finally { + setSavingProxy(false) + } + } + + // 测试代理连接 + const handleTestProxy = async () => { + if (!defaultProxy.trim()) { + setMessage({ type: 'error', text: '请先输入代理地址' }) + return + } + setTestingProxy(true) + setMessage(null) + setProxyStatus('unknown') + setProxyOriginIP('') + try { + const res = await fetch('/api/proxy/test', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ proxy_url: defaultProxy }), + }) + const data = await res.json() + if (data.code === 0 && data.data?.connected) { + setProxyStatus('success') + setProxyOriginIP(data.data.origin_ip || '') + setMessage({ type: 'success', text: `代理连接成功${data.data.origin_ip ? `, 出口IP: ${data.data.origin_ip}` : ''}` }) + } else { + setProxyStatus('error') + setMessage({ type: 'error', text: data.message || '代理连接失败' }) + } + } catch (e) { + setProxyStatus('error') + setMessage({ type: 'error', text: e instanceof Error ? e.message : '网络错误' }) + } finally { + setTestingProxy(false) + } + } + const configItems = [ { to: '/config/s2a', @@ -178,6 +246,72 @@ export default function Config() { 该名称将显示在侧边栏标题和浏览器标签页

+ + {/* 代理地址配置 */} +
+
+ + {/* 代理状态徽章 */} + {proxyStatus === 'success' && ( + + + 代理可用 + + )} + {proxyStatus === 'error' && ( + + + 连接失败 + + )} + {proxyStatus === 'unknown' && defaultProxy && ( + + + 未测试 + + )} +
+
+ { + setDefaultProxy(e.target.value) + setProxyStatus('unknown') + }} + placeholder="http://127.0.0.1:7890" + className="flex-1" + /> + + +
+

+ 设置全局默认代理地址,用于批量入库和自动补号 + {proxyOriginIP && ( + + 出口IP: {proxyOriginIP} + + )} +

+
diff --git a/frontend/src/pages/Monitor.tsx b/frontend/src/pages/Monitor.tsx index f70058b..5ec55ca 100644 --- a/frontend/src/pages/Monitor.tsx +++ b/frontend/src/pages/Monitor.tsx @@ -67,6 +67,8 @@ export default function Monitor() { const [checkInterval, setCheckInterval] = useState(60) const [pollingEnabled, setPollingEnabled] = useState(false) const [pollingInterval, setPollingInterval] = useState(60) + const [replenishUseProxy, setReplenishUseProxy] = useState(false) // 补号时使用代理 + const [globalProxy, setGlobalProxy] = useState('') // 全局代理地址(只读显示) // 倒计时状态 const [countdown, setCountdown] = useState(60) @@ -129,6 +131,7 @@ export default function Monitor() { check_interval: checkInterval, polling_enabled: pollingEnabled, polling_interval: pollingInterval, + replenish_use_proxy: replenishUseProxy, }), }) const data = await res.json() @@ -253,6 +256,7 @@ export default function Monitor() { // 从后端加载监控设置 const loadMonitorSettings = async () => { try { + // 加载监控设置 const res = await fetch('/api/monitor/settings') if (res.ok) { const json = await res.json() @@ -264,6 +268,7 @@ export default function Monitor() { const checkIntervalVal = s.check_interval || 60 const pollingEnabledVal = s.polling_enabled || false const interval = s.polling_interval || 60 + const replenishUseProxyVal = s.replenish_use_proxy || false setTargetInput(target) setAutoAdd(autoAddVal) @@ -271,11 +276,21 @@ export default function Monitor() { setCheckInterval(checkIntervalVal) setPollingEnabled(pollingEnabledVal) setPollingInterval(interval) + setReplenishUseProxy(replenishUseProxyVal) savedPollingIntervalRef.current = interval setCountdown(interval) // 返回加载的配置用于后续刷新 - return { target, autoAdd: autoAddVal, minInterval: minIntervalVal, checkInterval: checkIntervalVal, pollingEnabled: pollingEnabledVal, pollingInterval: interval } + return { target, autoAdd: autoAddVal, minInterval: minIntervalVal, checkInterval: checkIntervalVal, pollingEnabled: pollingEnabledVal, pollingInterval: interval, replenishUseProxy: replenishUseProxyVal } + } + } + + // 加载全局代理配置 + const configRes = await fetch('/api/config') + if (configRes.ok) { + const configJson = await configRes.json() + if (configJson.code === 0 && configJson.data) { + setGlobalProxy(configJson.data.default_proxy || '') } } } catch (e) { @@ -511,6 +526,15 @@ export default function Monitor() { description="开启后,当号池不足时自动补充账号" /> +
+ +
('chromedp') - const [proxy, setProxy] = useState('') + const [useProxy, setUseProxy] = useState(false) // 是否使用全局代理 const [includeOwner, setIncludeOwner] = useState(false) // 母号也入库 const [processCount, setProcessCount] = useState(0) // 处理数量,0表示全部 + // 获取全局代理地址 + const globalProxy = config.proxy?.default || '' + const hasConfig = config.s2a.apiBase && config.s2a.adminKey // Load stats @@ -191,7 +194,7 @@ export default function Upload() { concurrent_teams: Math.min(concurrentTeams, stats?.valid || 1), browser_type: browserType, headless: true, // 始终使用无头模式 - proxy, + proxy: useProxy ? globalProxy : '', include_owner: includeOwner, // 母号也入库 process_count: processCount, // 处理数量,0表示全部 }), @@ -209,7 +212,7 @@ export default function Upload() { alert('启动失败') } setLoading(false) - }, [stats, membersPerTeam, concurrentTeams, browserType, proxy, includeOwner, processCount, fetchStatus]) + }, [stats, membersPerTeam, concurrentTeams, browserType, useProxy, globalProxy, includeOwner, processCount, fetchStatus]) // 停止处理 const handleStop = useCallback(async () => { @@ -469,13 +472,15 @@ export default function Upload() { - setProxy(e.target.value)} - disabled={isRunning} - /> +
+ +