diff --git a/backend/cmd/main.go b/backend/cmd/main.go index 71a2b26..6b62521 100644 --- a/backend/cmd/main.go +++ b/backend/cmd/main.go @@ -164,6 +164,7 @@ func startServer(cfg *config.Config) { // CodexAuth 代理池 API mux.HandleFunc("/api/codex-proxy", api.CORS(api.HandleCodexProxies)) + mux.HandleFunc("/api/codex-proxy/test", api.CORS(api.HandleCodexProxies)) // 嵌入的前端静态文件 if web.IsEmbedded() { diff --git a/backend/internal/api/codex_proxy.go b/backend/internal/api/codex_proxy.go index 0141b72..9534f1f 100644 --- a/backend/internal/api/codex_proxy.go +++ b/backend/internal/api/codex_proxy.go @@ -7,6 +7,7 @@ import ( "strings" "codex-pool/internal/database" + "codex-pool/internal/proxyutil" ) // HandleCodexProxies 处理代理池请求 @@ -15,6 +16,10 @@ func HandleCodexProxies(w http.ResponseWriter, r *http.Request) { case http.MethodGet: listCodexProxies(w, r) case http.MethodPost: + if strings.HasSuffix(r.URL.Path, "/test") { + testCodexProxy(w, r) + return + } addCodexProxy(w, r) case http.MethodDelete: deleteCodexProxy(w, r) @@ -170,3 +175,56 @@ func toggleCodexProxy(w http.ResponseWriter, r *http.Request) { Success(w, map[string]string{"message": "状态已切换"}) } + +// testCodexProxy 测试代理连通性 +func testCodexProxy(w http.ResponseWriter, r *http.Request) { + idStr := r.URL.Query().Get("id") + if idStr == "" { + Error(w, http.StatusBadRequest, "缺少代理 ID") + return + } + + id, err := strconv.ParseInt(idStr, 10, 64) + if err != nil { + Error(w, http.StatusBadRequest, "无效的代理 ID") + return + } + + // 获取代理详情 + proxies, err := database.Instance.GetCodexProxies() + if err != nil { + Error(w, http.StatusInternalServerError, "获取代理失败") + return + } + + var targetProxy *database.CodexProxy + for _, p := range proxies { + if p.ID == id { + targetProxy = &p + break + } + } + + if targetProxy == nil { + Error(w, http.StatusNotFound, "未找到代理") + return + } + + // 执行测试 (异步执行以防前端超时,但用户想要同步结果,所以这里同步执行) + // 因为是在管理后台点击,15s 超时是可以接受的 + result, err := proxyutil.TestProxy(targetProxy.ProxyURL) + if err != nil { + database.Instance.UpdateCodexProxyTestResult(id, "", false) + Error(w, http.StatusInternalServerError, "测试过程出错: "+err.Error()) + return + } + + // 更新数据库 + err = database.Instance.UpdateCodexProxyTestResult(id, result.Location, result.Success) + if err != nil { + Error(w, http.StatusInternalServerError, "更新测试结果失败") + return + } + + Success(w, result) +} diff --git a/backend/internal/client/tls.go b/backend/internal/client/tls.go index 09f9fcc..fa93816 100644 --- a/backend/internal/client/tls.go +++ b/backend/internal/client/tls.go @@ -13,6 +13,7 @@ import ( "codex-pool/internal/proxyutil" "github.com/Noooste/azuretls-client" + fhttp "github.com/Noooste/fhttp" "github.com/andybalholm/brotli" http2 "github.com/bogdanfinn/fhttp" tls_client "github.com/bogdanfinn/tls-client" @@ -475,14 +476,29 @@ func (c *TLSClient) PostJSON(urlStr string, body io.Reader) (*http.Response, err // GetCookie 获取指定 URL 的 Cookie func (c *TLSClient) GetCookie(urlStr string, name string) string { - if c.clientType == ClientTypeAzureTLS { - return "" // azuretls cookie 管理待完善,目前主要依赖 tls-client 或手动 handle - } - u, err := url.Parse(urlStr) if err != nil { return "" } + + // azuretls 使用标准的 http.CookieJar + if c.clientType == ClientTypeAzureTLS { + if c.azureSession == nil || c.azureSession.CookieJar == nil { + return "" + } + cookies := c.azureSession.CookieJar.Cookies(u) + for _, cookie := range cookies { + if cookie.Name == name { + return cookie.Value + } + } + return "" + } + + // tls-client + if c.tlsClient == nil { + return "" + } cookies := c.tlsClient.GetCookies(u) for _, cookie := range cookies { if cookie.Name == name { @@ -494,12 +510,29 @@ func (c *TLSClient) GetCookie(urlStr string, name string) string { // SetCookie 设置 Cookie func (c *TLSClient) SetCookie(urlStr string, cookie *http.Cookie) { - if c.clientType == ClientTypeAzureTLS { + u, err := url.Parse(urlStr) + if err != nil { return } - u, err := url.Parse(urlStr) - if err != nil { + // azuretls 使用 Noooste/fhttp 的 Cookie 类型 + if c.clientType == ClientTypeAzureTLS { + if c.azureSession == nil || c.azureSession.CookieJar == nil { + return + } + c.azureSession.CookieJar.SetCookies(u, []*fhttp.Cookie{ + { + Name: cookie.Name, + Value: cookie.Value, + Path: cookie.Path, + Domain: cookie.Domain, + }, + }) + return + } + + // tls-client + if c.tlsClient == nil { return } c.tlsClient.SetCookies(u, []*http2.Cookie{ diff --git a/backend/internal/database/sqlite.go b/backend/internal/database/sqlite.go index 7c775d1..0c9f1f9 100644 --- a/backend/internal/database/sqlite.go +++ b/backend/internal/database/sqlite.go @@ -54,10 +54,14 @@ func Init(dbPath string) error { // migrate 数据库迁移 func (d *DB) migrate() error { // 添加 last_checked_at 列(如果不存在) - _, err := d.db.Exec(`ALTER TABLE team_owners ADD COLUMN last_checked_at DATETIME`) - if err != nil && !isColumnExistsError(err) { - return err - } + _, _ = d.db.Exec(`ALTER TABLE team_owners ADD COLUMN last_checked_at DATETIME`) + + // 添加 last_test_at 列 (如果不存在) + _, _ = d.db.Exec(`ALTER TABLE codex_auth_proxies ADD COLUMN last_test_at DATETIME`) + + // 添加 location 列 (如果不存在) + _, _ = d.db.Exec(`ALTER TABLE codex_auth_proxies ADD COLUMN location TEXT`) + return nil } @@ -135,6 +139,8 @@ func (d *DB) createTables() error { last_used_at DATETIME, success_count INTEGER DEFAULT 0, fail_count INTEGER DEFAULT 0, + location TEXT, + last_test_at DATETIME, created_at DATETIME DEFAULT CURRENT_TIMESTAMP ); @@ -272,7 +278,7 @@ func (d *DB) GetTeamOwners(status string, limit, offset int) ([]TeamOwner, int, return owners, total, nil } -// GetPendingOwners 获取待处理(排除已使用和处理中的) +// GetPendingOwners 获取待处理(排除已使用 and 处理中的) func (d *DB) GetPendingOwners() ([]TeamOwner, error) { rows, err := d.db.Query(` SELECT id, email, password, token, account_id, status, created_at, last_checked_at @@ -760,6 +766,8 @@ type CodexProxy struct { LastUsedAt *time.Time `json:"last_used_at,omitempty"` SuccessCount int `json:"success_count"` FailCount int `json:"fail_count"` + Location string `json:"location"` + LastTestAt *time.Time `json:"last_test_at,omitempty"` CreatedAt time.Time `json:"created_at"` } @@ -813,7 +821,7 @@ func (d *DB) AddCodexProxies(proxies []string) (int, error) { // GetCodexProxies 获取代理列表 func (d *DB) GetCodexProxies() ([]CodexProxy, error) { rows, err := d.db.Query(` - SELECT id, proxy_url, COALESCE(description, ''), is_enabled, last_used_at, success_count, fail_count, created_at + SELECT id, proxy_url, COALESCE(description, ''), is_enabled, last_used_at, success_count, fail_count, COALESCE(location, ''), last_test_at, created_at FROM codex_auth_proxies ORDER BY created_at DESC `) @@ -825,14 +833,17 @@ func (d *DB) GetCodexProxies() ([]CodexProxy, error) { var proxies []CodexProxy for rows.Next() { var p CodexProxy - var lastUsedAt sql.NullTime - err := rows.Scan(&p.ID, &p.ProxyURL, &p.Description, &p.IsEnabled, &lastUsedAt, &p.SuccessCount, &p.FailCount, &p.CreatedAt) + var lastUsedAt, lastTestAt sql.NullTime + err := rows.Scan(&p.ID, &p.ProxyURL, &p.Description, &p.IsEnabled, &lastUsedAt, &p.SuccessCount, &p.FailCount, &p.Location, &lastTestAt, &p.CreatedAt) if err != nil { continue } if lastUsedAt.Valid { p.LastUsedAt = &lastUsedAt.Time } + if lastTestAt.Valid { + p.LastTestAt = &lastTestAt.Time + } proxies = append(proxies, p) } return proxies, nil @@ -841,7 +852,7 @@ func (d *DB) GetCodexProxies() ([]CodexProxy, error) { // GetEnabledCodexProxies 获取已启用的代理列表 func (d *DB) GetEnabledCodexProxies() ([]CodexProxy, error) { rows, err := d.db.Query(` - SELECT id, proxy_url, COALESCE(description, ''), is_enabled, last_used_at, success_count, fail_count, created_at + SELECT id, proxy_url, COALESCE(description, ''), is_enabled, last_used_at, success_count, fail_count, COALESCE(location, ''), last_test_at, created_at FROM codex_auth_proxies WHERE is_enabled = 1 ORDER BY success_count DESC, fail_count ASC @@ -854,14 +865,17 @@ func (d *DB) GetEnabledCodexProxies() ([]CodexProxy, error) { var proxies []CodexProxy for rows.Next() { var p CodexProxy - var lastUsedAt sql.NullTime - err := rows.Scan(&p.ID, &p.ProxyURL, &p.Description, &p.IsEnabled, &lastUsedAt, &p.SuccessCount, &p.FailCount, &p.CreatedAt) + var lastUsedAt, lastTestAt sql.NullTime + err := rows.Scan(&p.ID, &p.ProxyURL, &p.Description, &p.IsEnabled, &lastUsedAt, &p.SuccessCount, &p.FailCount, &p.Location, &lastTestAt, &p.CreatedAt) if err != nil { continue } if lastUsedAt.Valid { p.LastUsedAt = &lastUsedAt.Time } + if lastTestAt.Valid { + p.LastTestAt = &lastTestAt.Time + } proxies = append(proxies, p) } return proxies, nil @@ -897,6 +911,24 @@ func (d *DB) UpdateCodexProxyStats(proxyURL string, success bool) error { return err } +// UpdateCodexProxyTestResult 更新代理测试结果 +func (d *DB) UpdateCodexProxyTestResult(id int64, location string, success bool) error { + if success { + _, err := d.db.Exec(` + UPDATE codex_auth_proxies + SET location = ?, last_test_at = CURRENT_TIMESTAMP, success_count = success_count + 1 + WHERE id = ? + `, location, id) + return err + } + _, err := d.db.Exec(` + UPDATE codex_auth_proxies + SET last_test_at = CURRENT_TIMESTAMP, fail_count = fail_count + 1 + WHERE id = ? + `, id) + return err +} + // ToggleCodexProxy 切换代理启用状态 func (d *DB) ToggleCodexProxy(id int64) error { _, err := d.db.Exec("UPDATE codex_auth_proxies SET is_enabled = 1 - is_enabled WHERE id = ?", id) diff --git a/backend/internal/proxyutil/test_proxy.go b/backend/internal/proxyutil/test_proxy.go new file mode 100644 index 0000000..21397e0 --- /dev/null +++ b/backend/internal/proxyutil/test_proxy.go @@ -0,0 +1,77 @@ +package proxyutil + +import ( + "encoding/json" + "fmt" + "net/http" + "net/url" + "time" +) + +// ProxyTestResult 代理测试结果 +type ProxyTestResult struct { + Success bool `json:"success"` + Location string `json:"location"` + IP string `json:"ip"` + Error string `json:"error,omitempty"` +} + +// IPApiResponse ip-api.com 响应结构 +type IPApiResponse struct { + Status string `json:"status"` + Country string `json:"country"` + CountryCode string `json:"countryCode"` + Region string `json:"region"` + RegionName string `json:"regionName"` + City string `json:"city"` + Query string `json:"query"` + Message string `json:"message"` +} + +// TestProxy 测试代理连通性并获取归属地 +func TestProxy(proxyURL string) (*ProxyTestResult, error) { + normalized, err := Normalize(proxyURL) + if err != nil { + return nil, fmt.Errorf("代理地址格式错误: %w", err) + } + + // 解析代理 URL + parsedProxy, err := url.Parse(normalized) + if err != nil { + return nil, fmt.Errorf("解析代理错误: %w", err) + } + + // 创建带代理的客户端 + client := &http.Client{ + Transport: &http.Transport{ + Proxy: http.ProxyURL(parsedProxy), + }, + Timeout: 15 * time.Second, + } + + // 请求 IP 查询接口 + resp, err := client.Get("http://ip-api.com/json/") + if err != nil { + return &ProxyTestResult{Success: false, Error: err.Error()}, nil + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return &ProxyTestResult{Success: false, Error: fmt.Sprintf("HTTP %d", resp.StatusCode)}, nil + } + + var ipInfo IPApiResponse + if err := json.NewDecoder(resp.Body).Decode(&ipInfo); err != nil { + return &ProxyTestResult{Success: false, Error: "解析响应失败: " + err.Error()}, nil + } + + if ipInfo.Status != "success" { + return &ProxyTestResult{Success: false, Error: "查询失败: " + ipInfo.Message}, nil + } + + return &ProxyTestResult{ + Success: true, + Location: ipInfo.CountryCode, + IP: ipInfo.Query, + }, nil +} diff --git a/frontend/src/pages/CodexProxyConfig.tsx b/frontend/src/pages/CodexProxyConfig.tsx index b619385..99f2ecf 100644 --- a/frontend/src/pages/CodexProxyConfig.tsx +++ b/frontend/src/pages/CodexProxyConfig.tsx @@ -2,7 +2,7 @@ import { useState, useEffect, useCallback } from 'react' import { Globe, Plus, Trash2, ToggleLeft, ToggleRight, Loader2, Save, RefreshCcw, CheckCircle, XCircle, - AlertTriangle, Clock + AlertTriangle, Clock, MapPin, Play } from 'lucide-react' import { Card, CardHeader, CardTitle, CardContent, Button, Input } from '../components/common' @@ -14,6 +14,8 @@ interface CodexProxy { last_used_at: string | null success_count: number fail_count: number + location: string + last_test_at: string | null created_at: string } @@ -28,6 +30,7 @@ export default function CodexProxyConfig() { const [stats, setStats] = useState({ total: 0, enabled: 0, disabled: 0 }) const [loading, setLoading] = useState(true) const [saving, setSaving] = useState(false) + const [testingIds, setTestingIds] = useState>(new Set()) const [message, setMessage] = useState<{ type: 'success' | 'error', text: string } | null>(null) // 单个添加 @@ -143,6 +146,37 @@ export default function CodexProxyConfig() { } } + // 测试代理 + const handleTestProxy = async (id: number) => { + if (testingIds.has(id)) return + setTestingIds(prev => new Set(prev).add(id)) + try { + const res = await fetch(`/api/codex-proxy/test?id=${id}`, { method: 'POST' }) + const data = await res.json() + if (data.code === 0) { + // 局部更新代理信息 + setProxies(prev => prev.map(p => + p.id === id + ? { ...p, location: data.data.location, last_test_at: new Date().toISOString(), success_count: p.success_count + 1 } + : p + )) + } else { + alert(`测试失败: ${data.message}`) + // 虽然失败也需要刷新列表以获取最新的统计数据 + fetchProxies() + } + } catch (error) { + console.error('测试代理出错:', error) + alert('网络错误,测试失败') + } finally { + setTestingIds(prev => { + const next = new Set(prev) + next.delete(id) + return next + }) + } + } + // 清空所有 const handleClearAll = async () => { if (!confirm('确定要清空所有代理吗?此操作不可恢复!')) return @@ -204,7 +238,7 @@ export default function CodexProxyConfig() { CodexAuth 代理池

- 管理 CodexAuth API 授权使用的代理池,支持随机轮换 + 管理 CodexAuth API 授权使用的代理池,支持归属地自动识别

@@ -357,10 +391,12 @@ export default function CodexProxyConfig() {
{proxies.map((proxy) => { const successRate = getSuccessRate(proxy) + const istesting = testingIds.has(proxy.id) + return (
{formatProxyDisplay(proxy.proxy_url)} + {proxy.location && ( + + + {proxy.location} + + )} {proxy.description && ( ({proxy.description}) )}
-
- +
+ - {formatTime(proxy.last_used_at)} + 测试: {formatTime(proxy.last_test_at)} @@ -400,9 +442,21 @@ export default function CodexProxyConfig() {
+