feat: Implement initial Codex Pool backend server with comprehensive APIs for configuration, S2A integration, owner management, batch processing, monitoring, and corresponding frontend pages.
This commit is contained in:
@@ -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))
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -17,6 +17,7 @@ type MonitorSettings struct {
|
||||
CheckInterval int `json:"check_interval"` // 自动补号检查间隔(秒)
|
||||
PollingEnabled bool `json:"polling_enabled"`
|
||||
PollingInterval int `json:"polling_interval"`
|
||||
ReplenishUseProxy bool `json:"replenish_use_proxy"` // 补号时使用代理
|
||||
}
|
||||
|
||||
// HandleGetMonitorSettings 获取监控设置
|
||||
@@ -38,6 +39,7 @@ func HandleGetMonitorSettings(w http.ResponseWriter, r *http.Request) {
|
||||
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]
|
||||
|
||||
@@ -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"`
|
||||
}
|
||||
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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() {
|
||||
该名称将显示在侧边栏标题和浏览器标签页
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* 代理地址配置 */}
|
||||
<div className="pt-4 border-t border-slate-200 dark:border-slate-700">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300">
|
||||
全局代理地址
|
||||
</label>
|
||||
{/* 代理状态徽章 */}
|
||||
{proxyStatus === 'success' && (
|
||||
<span className="inline-flex items-center gap-1 px-2 py-0.5 text-xs font-medium rounded-full bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-400">
|
||||
<Wifi className="h-3 w-3" />
|
||||
代理可用
|
||||
</span>
|
||||
)}
|
||||
{proxyStatus === 'error' && (
|
||||
<span className="inline-flex items-center gap-1 px-2 py-0.5 text-xs font-medium rounded-full bg-red-100 text-red-700 dark:bg-red-900/30 dark:text-red-400">
|
||||
<WifiOff className="h-3 w-3" />
|
||||
连接失败
|
||||
</span>
|
||||
)}
|
||||
{proxyStatus === 'unknown' && defaultProxy && (
|
||||
<span className="inline-flex items-center gap-1 px-2 py-0.5 text-xs font-medium rounded-full bg-slate-100 text-slate-600 dark:bg-slate-700 dark:text-slate-400">
|
||||
<HelpCircle className="h-3 w-3" />
|
||||
未测试
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex gap-3">
|
||||
<Input
|
||||
value={defaultProxy}
|
||||
onChange={(e) => {
|
||||
setDefaultProxy(e.target.value)
|
||||
setProxyStatus('unknown')
|
||||
}}
|
||||
placeholder="http://127.0.0.1:7890"
|
||||
className="flex-1"
|
||||
/>
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={handleSaveProxy}
|
||||
disabled={savingProxy}
|
||||
icon={savingProxy ? <Loader2 className="h-4 w-4 animate-spin" /> : <Save className="h-4 w-4" />}
|
||||
className="shrink-0"
|
||||
>
|
||||
{savingProxy ? '保存中...' : '保存代理'}
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={handleTestProxy}
|
||||
disabled={testingProxy || !defaultProxy.trim()}
|
||||
icon={testingProxy ? <Loader2 className="h-4 w-4 animate-spin" /> : <Wifi className="h-4 w-4" />}
|
||||
className="shrink-0"
|
||||
>
|
||||
{testingProxy ? '测试中...' : '测试连接'}
|
||||
</Button>
|
||||
</div>
|
||||
<p className="mt-1 text-xs text-slate-500 dark:text-slate-400">
|
||||
设置全局默认代理地址,用于批量入库和自动补号
|
||||
{proxyOriginIP && (
|
||||
<span className="ml-2 text-green-600 dark:text-green-400">
|
||||
出口IP: {proxyOriginIP}
|
||||
</span>
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
|
||||
@@ -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="开启后,当号池不足时自动补充账号"
|
||||
/>
|
||||
</div>
|
||||
<div className="p-3 rounded-lg bg-slate-50 dark:bg-slate-800/50 border border-slate-200 dark:border-slate-700">
|
||||
<Switch
|
||||
checked={replenishUseProxy}
|
||||
onChange={setReplenishUseProxy}
|
||||
disabled={!autoAdd || !globalProxy}
|
||||
label="补号时使用代理"
|
||||
description={globalProxy ? `当前代理: ${globalProxy}` : '请先在系统配置中设置代理地址'}
|
||||
/>
|
||||
</div>
|
||||
<Input
|
||||
label="最小间隔 (秒)"
|
||||
type="number"
|
||||
|
||||
@@ -70,10 +70,13 @@ export default function Upload() {
|
||||
const [membersPerTeam, setMembersPerTeam] = useState(4)
|
||||
const [concurrentTeams, setConcurrentTeams] = useState(2)
|
||||
const [browserType, setBrowserType] = useState<'chromedp' | 'rod'>('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() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Input
|
||||
label="代理地址(可选)"
|
||||
placeholder="http://127.0.0.1:7890"
|
||||
value={proxy}
|
||||
onChange={(e) => setProxy(e.target.value)}
|
||||
disabled={isRunning}
|
||||
<div className="p-3 rounded-lg bg-slate-50 dark:bg-slate-800/50 border border-slate-200 dark:border-slate-700">
|
||||
<Switch
|
||||
checked={useProxy}
|
||||
onChange={setUseProxy}
|
||||
disabled={isRunning || !globalProxy}
|
||||
label="使用全局代理"
|
||||
description={globalProxy ? `当前代理: ${globalProxy}` : '请先在系统配置中设置代理地址'}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="p-3 rounded-lg bg-slate-50 dark:bg-slate-800/50 border border-slate-200 dark:border-slate-700">
|
||||
<Switch
|
||||
|
||||
@@ -175,6 +175,10 @@ export interface AppConfig {
|
||||
email: {
|
||||
services: MailServiceConfig[] // 多个邮箱服务配置
|
||||
}
|
||||
proxy: {
|
||||
default: string // 全局默认代理地址
|
||||
enabled: boolean // 是否启用代理
|
||||
}
|
||||
}
|
||||
|
||||
// 默认配置
|
||||
@@ -203,6 +207,10 @@ export const defaultConfig: AppConfig = {
|
||||
},
|
||||
],
|
||||
},
|
||||
proxy: {
|
||||
default: '',
|
||||
enabled: false,
|
||||
},
|
||||
}
|
||||
|
||||
// 检查结果
|
||||
|
||||
Reference in New Issue
Block a user