From 38dde08648ec8c8bb7a0c0e6ce07e253bb1b60b7 Mon Sep 17 00:00:00 2001 From: kyx236 Date: Fri, 30 Jan 2026 10:15:31 +0800 Subject: [PATCH] feat: Implement core backend server with file upload, monitoring, S2A API proxy, and team processing features. --- backend/cmd/main.go | 1 + backend/cmd/upload.go | 273 ++++++++++++++++++ .../src/components/upload/FileDropzone.tsx | 2 +- frontend/src/pages/Monitor.tsx | 58 ++-- frontend/src/pages/Upload.tsx | 5 +- 5 files changed, 307 insertions(+), 32 deletions(-) create mode 100644 backend/cmd/upload.go diff --git a/backend/cmd/main.go b/backend/cmd/main.go index cfe0deb..652c6c2 100644 --- a/backend/cmd/main.go +++ b/backend/cmd/main.go @@ -104,6 +104,7 @@ func startServer(cfg *config.Config) { mux.HandleFunc("/api/db/owners", api.CORS(handleGetOwners)) mux.HandleFunc("/api/db/owners/stats", api.CORS(handleGetOwnerStats)) mux.HandleFunc("/api/db/owners/clear", api.CORS(handleClearOwners)) + mux.HandleFunc("/api/upload/validate", api.CORS(handleUploadValidate)) // 注册测试 API mux.HandleFunc("/api/register/test", api.CORS(handleRegisterTest)) diff --git a/backend/cmd/upload.go b/backend/cmd/upload.go new file mode 100644 index 0000000..0f9fd58 --- /dev/null +++ b/backend/cmd/upload.go @@ -0,0 +1,273 @@ +package main + +import ( + "encoding/json" + "fmt" + "io" + "net/http" + "strings" + "unicode" + + "codex-pool/internal/api" + "codex-pool/internal/database" +) + +type uploadValidateRequest struct { + Content string `json:"content"` + Accounts []accountRecord `json:"accounts"` +} + +type accountRecord struct { + Account string `json:"account"` + Email string `json:"email"` + Password string `json:"password"` + Token string `json:"token"` + AccessTok string `json:"access_token"` + AccountID string `json:"account_id"` +} + +func handleUploadValidate(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + api.Error(w, http.StatusMethodNotAllowed, "仅支持 POST") + return + } + if database.Instance == nil { + api.Error(w, http.StatusInternalServerError, "数据库未初始化") + return + } + + body, err := io.ReadAll(http.MaxBytesReader(w, r.Body, 10<<20)) + if err != nil { + api.Error(w, http.StatusBadRequest, "读取请求失败") + return + } + + var req uploadValidateRequest + if err := json.Unmarshal(body, &req); err != nil { + // 如果不是 JSON,直接把 body 当作原始内容 + req.Content = string(body) + } + + var records []accountRecord + switch { + case len(req.Accounts) > 0: + records = req.Accounts + case strings.TrimSpace(req.Content) != "": + parsed, parseErr := parseAccountsFlexible(req.Content) + if parseErr != nil { + api.Error(w, http.StatusBadRequest, parseErr.Error()) + return + } + records = parsed + default: + api.Error(w, http.StatusBadRequest, "未提供账号内容") + return + } + + owners := make([]database.TeamOwner, 0, len(records)) + for i, rec := range records { + owner, err := normalizeOwner(rec, i+1) + if err != nil { + api.Error(w, http.StatusBadRequest, err.Error()) + return + } + owners = append(owners, owner) + } + if len(owners) == 0 { + api.Error(w, http.StatusBadRequest, "未解析到有效账号") + return + } + + inserted, err := database.Instance.AddTeamOwners(owners) + if err != nil { + api.Error(w, http.StatusInternalServerError, fmt.Sprintf("写入数据库失败: %v", err)) + return + } + + stats := database.Instance.GetOwnerStats() + api.Success(w, map[string]interface{}{ + "imported": inserted, + "total": len(owners), + "stats": stats, + }) +} + +func normalizeOwner(rec accountRecord, index int) (database.TeamOwner, error) { + email := strings.TrimSpace(rec.Email) + if email == "" { + email = strings.TrimSpace(rec.Account) + } + if email == "" { + return database.TeamOwner{}, fmt.Errorf("第 %d 条记录缺少 account/email 字段", index) + } + + password := strings.TrimSpace(rec.Password) + if password == "" { + return database.TeamOwner{}, fmt.Errorf("第 %d 条记录缺少 password 字段", index) + } + + token := strings.TrimSpace(rec.Token) + if token == "" { + token = strings.TrimSpace(rec.AccessTok) + } + if token == "" { + return database.TeamOwner{}, fmt.Errorf("第 %d 条记录缺少 token 字段", index) + } + + accountID := strings.TrimSpace(rec.AccountID) + + return database.TeamOwner{ + Email: email, + Password: password, + Token: token, + AccountID: accountID, + }, nil +} + +func parseAccountsFlexible(raw string) ([]accountRecord, error) { + raw = strings.TrimSpace(strings.TrimPrefix(raw, "\uFEFF")) + if raw == "" { + return nil, fmt.Errorf("内容为空") + } + + cleaned := stripJSONComments(raw) + cleaned = removeTrailingCommas(cleaned) + trimmed := strings.TrimSpace(cleaned) + if trimmed == "" { + return nil, fmt.Errorf("内容为空") + } + + if strings.HasPrefix(trimmed, "[") { + var arr []accountRecord + if err := json.Unmarshal([]byte(trimmed), &arr); err == nil { + return arr, nil + } + } else if strings.HasPrefix(trimmed, "{") { + var single accountRecord + if err := json.Unmarshal([]byte(trimmed), &single); err == nil { + return []accountRecord{single}, nil + } + } + + // JSONL 回退 + lines := strings.Split(raw, "\n") + records := make([]accountRecord, 0, len(lines)) + for i, line := range lines { + line = strings.TrimSpace(stripJSONComments(line)) + if line == "" { + continue + } + if strings.HasPrefix(line, "#") { + continue + } + line = strings.TrimSpace(removeTrailingCommas(line)) + if line == "" { + continue + } + var rec accountRecord + if err := json.Unmarshal([]byte(line), &rec); err != nil { + return nil, fmt.Errorf("第 %d 行解析失败: %v", i+1, err) + } + records = append(records, rec) + } + if len(records) == 0 { + return nil, fmt.Errorf("未解析到有效账号") + } + return records, nil +} + +func stripJSONComments(input string) string { + var b strings.Builder + b.Grow(len(input)) + + inString := false + escaped := false + for i := 0; i < len(input); i++ { + ch := input[i] + if inString { + b.WriteByte(ch) + if escaped { + escaped = false + continue + } + if ch == '\\' { + escaped = true + } else if ch == '"' { + inString = false + } + continue + } + + if ch == '"' { + inString = true + b.WriteByte(ch) + continue + } + + if ch == '/' && i+1 < len(input) && input[i+1] == '/' { + for i+1 < len(input) && input[i+1] != '\n' { + i++ + } + continue + } + if ch == '/' && i+1 < len(input) && input[i+1] == '*' { + i += 2 + for i+1 < len(input) { + if input[i] == '*' && input[i+1] == '/' { + i++ + break + } + i++ + } + continue + } + + b.WriteByte(ch) + } + + return b.String() +} + +func removeTrailingCommas(input string) string { + var b strings.Builder + b.Grow(len(input)) + + inString := false + escaped := false + for i := 0; i < len(input); i++ { + ch := input[i] + if inString { + b.WriteByte(ch) + if escaped { + escaped = false + continue + } + if ch == '\\' { + escaped = true + } else if ch == '"' { + inString = false + } + continue + } + + if ch == '"' { + inString = true + b.WriteByte(ch) + continue + } + + if ch == ',' { + j := i + 1 + for j < len(input) && unicode.IsSpace(rune(input[j])) { + j++ + } + if j < len(input) && (input[j] == ']' || input[j] == '}') { + continue + } + } + + b.WriteByte(ch) + } + + return b.String() +} diff --git a/frontend/src/components/upload/FileDropzone.tsx b/frontend/src/components/upload/FileDropzone.tsx index acf6cdf..43bfaad 100644 --- a/frontend/src/components/upload/FileDropzone.tsx +++ b/frontend/src/components/upload/FileDropzone.tsx @@ -98,7 +98,7 @@ export default function FileDropzone({ onFileSelect, disabled = false, error }:
- 支持格式: [{"account": "email", "password": "pwd", "token": "..."}] + 支持格式: JSON 数组/单对象/JSONL(支持注释与末尾逗号)
diff --git a/frontend/src/pages/Monitor.tsx b/frontend/src/pages/Monitor.tsx index 69dc491..d7fe727 100644 --- a/frontend/src/pages/Monitor.tsx +++ b/frontend/src/pages/Monitor.tsx @@ -65,31 +65,29 @@ export default function Monitor() { const [pollingEnabled, setPollingEnabled] = useState(false) const [pollingInterval, setPollingInterval] = useState(60) - // 使用后端代理路径 - const proxyBase = '/api/s2a/proxy' + // 监控接口属于本地后端服务(通常在 8088 端口) + const localBase = window.location.protocol + '//' + window.location.hostname + ':8088' - // 辅助函数:解包 S2A 响应 - const requestS2A = async (url: string, options: RequestInit = {}) => { - const res = await fetch(url, options) + // 辅助函数:处理本地请求 + const requestLocal = async (path: string, options: RequestInit = {}) => { + const res = await fetch(`${localBase}${path}`, options) if (!res.ok) throw new Error(`HTTP ${res.status}`) - const data = await res.json() - if (data && typeof data === 'object' && 'code' in data && 'data' in data) { - if (data.code !== 0) throw new Error(data.message || 'API error') - return data.data - } - return data + return res.json() } // 获取号池状态 const fetchPoolStatus = useCallback(async () => { try { - const data = await requestS2A(`${proxyBase}/api/pool/status`) - setPoolStatus(data) - setTargetInput(data.target) - setAutoAdd(data.auto_add) - setMinInterval(data.min_interval) - setPollingEnabled(data.polling_enabled) - setPollingInterval(data.polling_interval) + const res = await requestLocal('/api/pool/status') + if (res.code === 0) { + const data = res.data + setPoolStatus(data) + setTargetInput(data.target) + setAutoAdd(data.auto_add) + setMinInterval(data.min_interval) + setPollingEnabled(data.polling_enabled) + setPollingInterval(data.polling_interval) + } } catch (e) { console.error('获取号池状态失败:', e) } @@ -99,8 +97,10 @@ export default function Monitor() { const refreshStats = useCallback(async () => { setRefreshing(true) try { - const data = await requestS2A(`${proxyBase}/api/pool/refresh`, { method: 'POST' }) - setStats(data) + const res = await requestLocal('/api/pool/refresh', { method: 'POST' }) + if (res.code === 0) { + setStats(res.data) + } await fetchPoolStatus() } catch (e) { console.error('刷新统计失败:', e) @@ -112,7 +112,7 @@ export default function Monitor() { const handleSetTarget = async () => { setLoading(true) try { - await requestS2A(`${proxyBase}/api/pool/target`, { + await requestLocal('/api/pool/target', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ @@ -132,7 +132,7 @@ export default function Monitor() { const handleTogglePolling = async () => { setLoading(true) try { - await requestS2A(`${proxyBase}/api/pool/polling`, { + await requestLocal('/api/pool/polling', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ @@ -152,7 +152,7 @@ export default function Monitor() { const handleHealthCheck = async (autoPause: boolean = false) => { setCheckingHealth(true) try { - await requestS2A(`${proxyBase}/api/health-check/start`, { + await requestLocal('/api/health-check/start', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ auto_pause: autoPause }), @@ -160,8 +160,10 @@ export default function Monitor() { // 等待一会儿再获取结果 setTimeout(async () => { try { - const data = await requestS2A(`${proxyBase}/api/health-check/results`) - setHealthResults(data || []) + const res = await requestLocal('/api/health-check/results') + if (res.code === 0) { + setHealthResults(res.data || []) + } } catch (e) { console.error('获取健康检查结果失败:', e) } @@ -176,8 +178,10 @@ export default function Monitor() { // 获取自动补号日志 const fetchAutoAddLogs = async () => { try { - const data = await requestS2A(`${proxyBase}/api/auto-add/logs`) - setAutoAddLogs(data || []) + const res = await requestLocal('/api/auto-add/logs') + if (res.code === 0) { + setAutoAddLogs(res.data || []) + } } catch (e) { console.error('获取日志失败:', e) } diff --git a/frontend/src/pages/Upload.tsx b/frontend/src/pages/Upload.tsx index fe47407..66a1fad 100644 --- a/frontend/src/pages/Upload.tsx +++ b/frontend/src/pages/Upload.tsx @@ -120,13 +120,10 @@ export default function Upload() { try { const text = await file.text() - const json = JSON.parse(text) - const accounts = Array.isArray(json) ? json : [json] - const res = await fetch('/api/upload/validate', { method: 'POST', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ accounts }), + body: JSON.stringify({ content: text, filename: file.name }), }) const data = await res.json()