diff --git a/backend/cmd/main.go b/backend/cmd/main.go index 55334b8..1719575 100644 --- a/backend/cmd/main.go +++ b/backend/cmd/main.go @@ -94,7 +94,8 @@ func startServer(cfg *config.Config) { // S2A 代理 API mux.HandleFunc("/api/s2a/test", api.CORS(handleS2ATest)) - mux.HandleFunc("/api/s2a/proxy/", api.CORS(handleS2AProxy)) // 通配代理 + mux.HandleFunc("/api/s2a/proxy/", api.CORS(handleS2AProxy)) // 通配代理 + mux.HandleFunc("/api/s2a/clean-errors", api.CORS(api.HandleCleanErrorAccounts)) // 清理错误账号 // 邮箱服务 API mux.HandleFunc("/api/mail/services", api.CORS(handleMailServices)) diff --git a/backend/internal/api/s2a_clean.go b/backend/internal/api/s2a_clean.go new file mode 100644 index 0000000..dcc6474 --- /dev/null +++ b/backend/internal/api/s2a_clean.go @@ -0,0 +1,224 @@ +fpackage api + +import ( + "encoding/json" + "fmt" + "io" + "net/http" + "time" + + "codex-pool/internal/config" + "codex-pool/internal/logger" +) + +// S2AAccountItem S2A 账号信息 +type S2AAccountItem struct { + ID int `json:"id"` + Email string `json:"email"` + Status string `json:"status"` +} + +// S2AAccountsResponse S2A 账号列表响应 +type S2AAccountsResponse struct { + Code int `json:"code"` + Message string `json:"message"` + Data struct { + Items []S2AAccountItem `json:"items"` + Total int `json:"total"` + Pages int `json:"pages"` + } `json:"data"` +} + +// S2ADeleteResponse S2A 删除响应 +type S2ADeleteResponse struct { + Code int `json:"code"` + Message string `json:"message"` +} + +// HandleCleanErrorAccounts POST /api/s2a/clean-errors - 批量删除错误账号 +func HandleCleanErrorAccounts(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + Error(w, http.StatusMethodNotAllowed, "仅支持 POST") + return + } + + if config.Global == nil || config.Global.S2AApiBase == "" || config.Global.S2AAdminKey == "" { + Error(w, http.StatusBadRequest, "S2A 配置未设置") + return + } + + logger.Info("开始清理错误账号...", "", "s2a") + + // Step 1: 获取所有错误账号 + errorAccounts, err := fetchAllErrorAccounts() + if err != nil { + logger.Error(fmt.Sprintf("获取错误账号列表失败: %v", err), "", "s2a") + Error(w, http.StatusInternalServerError, fmt.Sprintf("获取错误账号列表失败: %v", err)) + return + } + + if len(errorAccounts) == 0 { + logger.Info("没有错误账号需要清理", "", "s2a") + Success(w, map[string]interface{}{ + "message": "没有错误账号需要清理", + "total": 0, + "success": 0, + "failed": 0, + }) + return + } + + logger.Info(fmt.Sprintf("找到 %d 个错误账号,开始删除...", len(errorAccounts)), "", "s2a") + + // Step 2: 逐条删除 + success := 0 + failed := 0 + var details []map[string]interface{} + + for _, account := range errorAccounts { + err := deleteS2AAccount(account.ID) + if err != nil { + failed++ + details = append(details, map[string]interface{}{ + "id": account.ID, + "email": account.Email, + "success": false, + "error": err.Error(), + }) + logger.Warning(fmt.Sprintf("删除账号失败: ID=%d, Email=%s, Error=%v", account.ID, account.Email, err), account.Email, "s2a") + } else { + success++ + details = append(details, map[string]interface{}{ + "id": account.ID, + "email": account.Email, + "success": true, + }) + logger.Success(fmt.Sprintf("删除账号成功: ID=%d, Email=%s", account.ID, account.Email), account.Email, "s2a") + } + } + + logger.Success(fmt.Sprintf("清理错误账号完成: 成功=%d, 失败=%d, 总数=%d", success, failed, len(errorAccounts)), "", "s2a") + + Success(w, map[string]interface{}{ + "message": fmt.Sprintf("清理完成: 成功 %d, 失败 %d", success, failed), + "total": len(errorAccounts), + "success": success, + "failed": failed, + "details": details, + }) +} + +// fetchAllErrorAccounts 分页获取所有错误账号 +func fetchAllErrorAccounts() ([]S2AAccountItem, error) { + var allAccounts []S2AAccountItem + page := 1 + pageSize := 100 + + client := &http.Client{Timeout: 30 * time.Second} + + for { + url := fmt.Sprintf("%s/api/v1/admin/accounts?page=%d&page_size=%d&status=error&timezone=Asia/Shanghai", + config.Global.S2AApiBase, page, pageSize) + + req, err := http.NewRequest("GET", url, nil) + if err != nil { + return nil, fmt.Errorf("创建请求失败: %v", err) + } + + setS2AHeaders(req) + + resp, err := client.Do(req) + if err != nil { + return nil, fmt.Errorf("请求失败: %v", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("HTTP %d", resp.StatusCode) + } + + body, err := io.ReadAll(resp.Body) + if err != nil { + return nil, fmt.Errorf("读取响应失败: %v", err) + } + + var result S2AAccountsResponse + if err := json.Unmarshal(body, &result); err != nil { + return nil, fmt.Errorf("解析响应失败: %v", err) + } + + if result.Code != 0 { + return nil, fmt.Errorf("API 错误: %s", result.Message) + } + + if len(result.Data.Items) == 0 { + break + } + + allAccounts = append(allAccounts, result.Data.Items...) + + logger.Info(fmt.Sprintf("获取错误账号: 第 %d 页, 本页 %d 个, 累计 %d 个", + page, len(result.Data.Items), len(allAccounts)), "", "s2a") + + if page >= result.Data.Pages { + break + } + page++ + } + + return allAccounts, nil +} + +// deleteS2AAccount 删除单个 S2A 账号 +func deleteS2AAccount(accountID int) error { + client := &http.Client{Timeout: 30 * time.Second} + + url := fmt.Sprintf("%s/api/v1/admin/accounts/%d", config.Global.S2AApiBase, accountID) + + req, err := http.NewRequest("DELETE", url, nil) + if err != nil { + return fmt.Errorf("创建请求失败: %v", err) + } + + setS2AHeaders(req) + + resp, err := client.Do(req) + if err != nil { + return fmt.Errorf("请求失败: %v", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return fmt.Errorf("HTTP %d", resp.StatusCode) + } + + body, err := io.ReadAll(resp.Body) + if err != nil { + return fmt.Errorf("读取响应失败: %v", err) + } + + var result S2ADeleteResponse + if err := json.Unmarshal(body, &result); err != nil { + return fmt.Errorf("解析响应失败: %v", err) + } + + if result.Code != 0 { + return fmt.Errorf("%s", result.Message) + } + + return nil +} + +// setS2AHeaders 设置 S2A 请求头 +func setS2AHeaders(req *http.Request) { + req.Header.Set("Accept", "application/json") + req.Header.Set("Content-Type", "application/json") + req.Header.Set("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36") + + // 认证方式: x-api-key 或 authorization + if config.Global.S2AAdminKey != "" { + req.Header.Set("X-API-Key", config.Global.S2AAdminKey) + } + // 也设置 Authorization 作为备用 + req.Header.Set("Authorization", "Bearer "+config.Global.S2AAdminKey) +} diff --git a/frontend/src/pages/Dashboard.tsx b/frontend/src/pages/Dashboard.tsx index 4a644d9..cc024d4 100644 --- a/frontend/src/pages/Dashboard.tsx +++ b/frontend/src/pages/Dashboard.tsx @@ -1,6 +1,6 @@ import { useState, useEffect } from 'react' import { Link } from 'react-router-dom' -import { Upload, RefreshCw, Settings } from 'lucide-react' +import { Upload, RefreshCw, Settings, Trash2 } from 'lucide-react' import { PoolStatus, RecentRecords } from '../components/dashboard' import { Card, CardHeader, CardTitle, CardContent, Button } from '../components/common' import { useS2AApi } from '../hooks/useS2AApi' @@ -15,6 +15,7 @@ export default function Dashboard() { const { config } = useConfig() const [stats, setStats] = useState(null) const [refreshing, setRefreshing] = useState(false) + const [cleaning, setCleaning] = useState(false) const fetchStats = async () => { if (!isConnected) return @@ -26,6 +27,40 @@ export default function Dashboard() { setRefreshing(false) } + const handleCleanErrors = async () => { + if (!isConnected) return + + const errorCount = stats?.error_accounts || 0 + if (errorCount === 0) { + alert('没有错误账号需要清理') + return + } + + const confirmed = window.confirm( + `确定要清理 ${errorCount} 个错误账号吗?此操作不可撤销。` + ) + if (!confirmed) return + + setCleaning(true) + try { + const res = await fetch('/api/s2a/clean-errors', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + }) + const data = await res.json() + if (res.ok && data.code === 0) { + alert(`清理完成!成功: ${data.data.success}, 失败: ${data.data.failed}`) + // 刷新统计数据 + fetchStats() + } else { + alert('清理失败: ' + (data.message || '未知错误')) + } + } catch (e) { + alert('清理失败: ' + (e instanceof Error ? e.message : '网络错误')) + } + setCleaning(false) + } + useEffect(() => { fetchStats() // eslint-disable-next-line react-hooks/exhaustive-deps @@ -56,6 +91,17 @@ export default function Dashboard() { 上传入库 + diff --git a/frontend/src/pages/Monitor.tsx b/frontend/src/pages/Monitor.tsx index 0b1f3a7..e84393c 100644 --- a/frontend/src/pages/Monitor.tsx +++ b/frontend/src/pages/Monitor.tsx @@ -1,4 +1,4 @@ -import { useState, useEffect, useCallback } from 'react' +import { useState, useEffect, useCallback, useRef } from 'react' import { Target, Activity, @@ -69,6 +69,9 @@ export default function Monitor() { // 倒计时状态 const [countdown, setCountdown] = useState(60) + // 保存的轮询间隔(用于定时器,避免输入时触发重置) + const savedPollingIntervalRef = useRef(60) + // 使用后端 S2A 代理访问 S2A 服务器 const proxyBase = '/api/s2a/proxy' @@ -207,7 +210,7 @@ export default function Monitor() { const handleSavePollingSettings = async () => { setLoading(true) try { - await fetch('/api/monitor/settings/save', { + const res = await fetch('/api/monitor/settings/save', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ @@ -218,10 +221,19 @@ export default function Monitor() { polling_interval: pollingInterval, }), }) - // 重置倒计时 - setCountdown(pollingInterval) + const data = await res.json() + if (res.ok && data.code === 0) { + // 保存成功,更新 ref 并重置倒计时 + savedPollingIntervalRef.current = pollingInterval + setCountdown(pollingInterval) + console.log('轮询设置已保存') + } else { + console.error('保存轮询设置失败:', data.message) + alert('保存失败: ' + (data.message || '未知错误')) + } } catch (e) { console.error('保存轮询设置失败:', e) + alert('保存失败: ' + (e instanceof Error ? e.message : '网络错误')) } setLoading(false) } @@ -268,7 +280,10 @@ export default function Monitor() { setAutoAdd(s.auto_add || false) setMinInterval(s.min_interval || 300) setPollingEnabled(s.polling_enabled || false) - setPollingInterval(s.polling_interval || 60) + const interval = s.polling_interval || 60 + setPollingInterval(interval) + savedPollingIntervalRef.current = interval // 同步更新 ref + setCountdown(interval) } } } catch (e) { @@ -276,34 +291,37 @@ export default function Monitor() { } } - // 初始化 + // 初始化 - 只在组件挂载时执行一次 useEffect(() => { loadMonitorSettings() fetchPoolStatus() refreshStats() fetchAutoAddLogs() - }, [fetchPoolStatus, refreshStats]) + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []) // 倒计时定时器 - 当启用轮询时 useEffect(() => { - // 初始化倒计时 - setCountdown(pollingInterval) - - if (!pollingEnabled) return + if (!pollingEnabled) { + setCountdown(savedPollingIntervalRef.current) + return + } const timer = setInterval(() => { setCountdown(prev => { if (prev <= 1) { // 倒计时结束,刷新数据并重置 refreshStats() - return pollingInterval + return savedPollingIntervalRef.current } return prev - 1 }) }, 1000) return () => clearInterval(timer) - }, [pollingEnabled, pollingInterval, refreshStats]) + // 只依赖 pollingEnabled,不依赖 pollingInterval(避免用户输入时重置) + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [pollingEnabled]) // 计算健康状态 const healthySummary = healthResults.reduce(