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 }: