From e19df67829248c955b67176797322de372e9f6b3 Mon Sep 17 00:00:00 2001 From: kyx236 Date: Sun, 1 Feb 2026 06:49:28 +0800 Subject: [PATCH] feat: implement team registration management feature with backend API and frontend UI. --- backend/cmd/main.go | 7 + backend/internal/api/team_reg_exec.go | 536 +++++++++++++++++++++ backend/team-reg | Bin 0 -> 15738128 bytes frontend/src/App.tsx | 3 +- frontend/src/components/layout/Sidebar.tsx | 2 + frontend/src/pages/TeamReg.tsx | 476 ++++++++++++++++++ frontend/src/pages/index.ts | 2 + 7 files changed, 1025 insertions(+), 1 deletion(-) create mode 100644 backend/internal/api/team_reg_exec.go create mode 100644 backend/team-reg create mode 100644 frontend/src/pages/TeamReg.tsx diff --git a/backend/cmd/main.go b/backend/cmd/main.go index b6dd95f..8db06b0 100644 --- a/backend/cmd/main.go +++ b/backend/cmd/main.go @@ -154,6 +154,13 @@ func startServer(cfg *config.Config) { mux.HandleFunc("/api/monitor/settings", api.CORS(api.HandleGetMonitorSettings)) mux.HandleFunc("/api/monitor/settings/save", api.CORS(api.HandleSaveMonitorSettings)) + // Team-Reg 自动注册 API + mux.HandleFunc("/api/team-reg/start", api.CORS(api.HandleTeamRegStart)) + mux.HandleFunc("/api/team-reg/stop", api.CORS(api.HandleTeamRegStop)) + mux.HandleFunc("/api/team-reg/status", api.CORS(api.HandleTeamRegStatus)) + mux.HandleFunc("/api/team-reg/logs", api.HandleTeamRegLogs) // SSE + mux.HandleFunc("/api/team-reg/import", api.CORS(api.HandleTeamRegImport)) + // 嵌入的前端静态文件 if web.IsEmbedded() { webFS := web.GetFileSystem() diff --git a/backend/internal/api/team_reg_exec.go b/backend/internal/api/team_reg_exec.go new file mode 100644 index 0000000..bda7089 --- /dev/null +++ b/backend/internal/api/team_reg_exec.go @@ -0,0 +1,536 @@ +package api + +import ( + "bufio" + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "os" + "os/exec" + "path/filepath" + "runtime" + "sort" + "strings" + "sync" + "time" + + "codex-pool/internal/database" + "codex-pool/internal/logger" +) + +// TeamRegConfig 注册配置 +type TeamRegConfig struct { + Count int `json:"count"` // 注册数量 + Concurrency int `json:"concurrency"` // 并发线程数 + Proxy string `json:"proxy"` // 代理地址 + AutoImport bool `json:"auto_import"` // 完成后自动导入 +} + +// TeamRegState 运行状态 +type TeamRegState struct { + Running bool `json:"running"` + StartedAt time.Time `json:"started_at"` + Config TeamRegConfig `json:"config"` + Logs []string `json:"logs"` + OutputFile string `json:"output_file"` // 生成的 JSON 文件 + Imported int `json:"imported"` // 已导入数量 + mu sync.Mutex + cmd *exec.Cmd + cancel context.CancelFunc + stdin io.WriteCloser +} + +var teamRegState = &TeamRegState{ + Logs: make([]string, 0), +} + +// HandleTeamRegStart POST /api/team-reg/start - 启动注册进程 +func HandleTeamRegStart(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) + return + } + + teamRegState.mu.Lock() + if teamRegState.Running { + teamRegState.mu.Unlock() + json.NewEncoder(w).Encode(map[string]interface{}{ + "success": false, + "message": "已有注册任务在运行中", + }) + return + } + + var config TeamRegConfig + if err := json.NewDecoder(r.Body).Decode(&config); err != nil { + teamRegState.mu.Unlock() + http.Error(w, "Invalid request body", http.StatusBadRequest) + return + } + + // 验证参数 + if config.Count < 1 { + config.Count = 1 + } + if config.Count > 100 { + config.Count = 100 + } + if config.Concurrency < 1 { + config.Concurrency = 1 + } + if config.Concurrency > 10 { + config.Concurrency = 10 + } + + // 重置状态 + teamRegState.Running = true + teamRegState.StartedAt = time.Now() + teamRegState.Config = config + teamRegState.Logs = make([]string, 0) + teamRegState.OutputFile = "" + teamRegState.Imported = 0 + teamRegState.mu.Unlock() + + // 启动进程 + go runTeamRegProcess(config) + + json.NewEncoder(w).Encode(map[string]interface{}{ + "success": true, + "message": "注册任务已启动", + }) +} + +// HandleTeamRegStop POST /api/team-reg/stop - 停止注册进程 +func HandleTeamRegStop(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) + return + } + + teamRegState.mu.Lock() + defer teamRegState.mu.Unlock() + + if !teamRegState.Running { + json.NewEncoder(w).Encode(map[string]interface{}{ + "success": false, + "message": "没有正在运行的任务", + }) + return + } + + // 发送 Ctrl+C 信号 + if teamRegState.cancel != nil { + teamRegState.cancel() + } + + // 如果进程还在,强制终止 + if teamRegState.cmd != nil && teamRegState.cmd.Process != nil { + teamRegState.cmd.Process.Kill() + } + + teamRegState.Running = false + addTeamRegLog("[系统] 任务已被用户停止") + + json.NewEncoder(w).Encode(map[string]interface{}{ + "success": true, + "message": "任务已停止", + }) +} + +// HandleTeamRegStatus GET /api/team-reg/status - 获取状态和日志 +func HandleTeamRegStatus(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) + return + } + + teamRegState.mu.Lock() + state := map[string]interface{}{ + "running": teamRegState.Running, + "started_at": teamRegState.StartedAt, + "config": teamRegState.Config, + "logs": teamRegState.Logs, + "output_file": teamRegState.OutputFile, + "imported": teamRegState.Imported, + } + teamRegState.mu.Unlock() + + json.NewEncoder(w).Encode(state) +} + +// HandleTeamRegLogs GET /api/team-reg/logs - SSE 实时日志流 +func HandleTeamRegLogs(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "text/event-stream") + w.Header().Set("Cache-Control", "no-cache") + w.Header().Set("Connection", "keep-alive") + w.Header().Set("Access-Control-Allow-Origin", "*") + + flusher, ok := w.(http.Flusher) + if !ok { + http.Error(w, "Streaming not supported", http.StatusInternalServerError) + return + } + + lastIndex := 0 + ticker := time.NewTicker(500 * time.Millisecond) + defer ticker.Stop() + + for { + select { + case <-r.Context().Done(): + return + case <-ticker.C: + teamRegState.mu.Lock() + running := teamRegState.Running + logs := teamRegState.Logs + teamRegState.mu.Unlock() + + // 发送新日志 + if len(logs) > lastIndex { + for i := lastIndex; i < len(logs); i++ { + fmt.Fprintf(w, "data: %s\n\n", logs[i]) + } + lastIndex = len(logs) + flusher.Flush() + } + + // 发送状态 + if !running { + fmt.Fprintf(w, "event: done\ndata: finished\n\n") + flusher.Flush() + return + } + } + } +} + +// HandleTeamRegImport POST /api/team-reg/import - 导入生成的 JSON 到数据库 +func HandleTeamRegImport(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) + return + } + + teamRegState.mu.Lock() + outputFile := teamRegState.OutputFile + teamRegState.mu.Unlock() + + if outputFile == "" { + json.NewEncoder(w).Encode(map[string]interface{}{ + "success": false, + "message": "没有可导入的文件", + }) + return + } + + count, err := importAccountsFromJSON(outputFile) + if err != nil { + json.NewEncoder(w).Encode(map[string]interface{}{ + "success": false, + "message": fmt.Sprintf("导入失败: %v", err), + }) + return + } + + teamRegState.mu.Lock() + teamRegState.Imported = count + teamRegState.mu.Unlock() + + json.NewEncoder(w).Encode(map[string]interface{}{ + "success": true, + "message": fmt.Sprintf("成功导入 %d 个账号", count), + "count": count, + }) +} + +// runTeamRegProcess 执行 team-reg 进程 +func runTeamRegProcess(config TeamRegConfig) { + defer func() { + teamRegState.mu.Lock() + teamRegState.Running = false + teamRegState.mu.Unlock() + }() + + // 查找 team-reg 可执行文件 + execPath := findTeamRegExecutable() + if execPath == "" { + addTeamRegLog("[错误] 找不到 team-reg 可执行文件") + addTeamRegLog("[提示] 请确保 team-reg 文件位于 backend 目录下") + return + } + + addTeamRegLog(fmt.Sprintf("[系统] 找到可执行文件: %s", execPath)) + + // Linux/macOS 上自动设置执行权限 + if runtime.GOOS != "windows" { + if err := os.Chmod(execPath, 0755); err != nil { + addTeamRegLog(fmt.Sprintf("[警告] 设置执行权限失败: %v", err)) + } else { + addTeamRegLog("[系统] 已设置执行权限 (chmod +x)") + } + } + + addTeamRegLog(fmt.Sprintf("[系统] 配置: 数量=%d, 并发=%d, 代理=%s", + config.Count, config.Concurrency, config.Proxy)) + + // 创建上下文用于取消 + ctx, cancel := context.WithCancel(context.Background()) + teamRegState.mu.Lock() + teamRegState.cancel = cancel + teamRegState.mu.Unlock() + + // 创建命令 + cmd := exec.CommandContext(ctx, execPath) + + // 设置工作目录(输出文件会保存在这里) + workDir := filepath.Dir(execPath) + cmd.Dir = workDir + + // 获取 stdin, stdout, stderr + stdin, err := cmd.StdinPipe() + if err != nil { + addTeamRegLog(fmt.Sprintf("[错误] 无法获取 stdin: %v", err)) + return + } + + stdout, err := cmd.StdoutPipe() + if err != nil { + addTeamRegLog(fmt.Sprintf("[错误] 无法获取 stdout: %v", err)) + return + } + + stderr, err := cmd.StderrPipe() + if err != nil { + addTeamRegLog(fmt.Sprintf("[错误] 无法获取 stderr: %v", err)) + return + } + + teamRegState.mu.Lock() + teamRegState.cmd = cmd + teamRegState.stdin = stdin + teamRegState.mu.Unlock() + + // 启动进程 + addTeamRegLog("[系统] 启动 team-reg 进程...") + if err := cmd.Start(); err != nil { + addTeamRegLog(fmt.Sprintf("[错误] 启动失败: %v", err)) + return + } + + // 合并 stdout 和 stderr 读取 + go readOutput(stdout, workDir, config) + go readOutput(stderr, workDir, config) + + // 等待一小段时间让程序启动 + time.Sleep(500 * time.Millisecond) + + // 发送输入参数 + addTeamRegLog(fmt.Sprintf("[输入] 注册数量: %d", config.Count)) + fmt.Fprintf(stdin, "%d\n", config.Count) + time.Sleep(200 * time.Millisecond) + + addTeamRegLog(fmt.Sprintf("[输入] 并发线程数: %d", config.Concurrency)) + fmt.Fprintf(stdin, "%d\n", config.Concurrency) + time.Sleep(200 * time.Millisecond) + + addTeamRegLog(fmt.Sprintf("[输入] 代理地址: %s", config.Proxy)) + fmt.Fprintf(stdin, "%s\n", config.Proxy) + + // 等待进程完成 + err = cmd.Wait() + if err != nil { + if ctx.Err() == context.Canceled { + addTeamRegLog("[系统] 进程已被取消") + } else { + addTeamRegLog(fmt.Sprintf("[系统] 进程退出: %v", err)) + } + } else { + addTeamRegLog("[系统] 进程正常完成") + } + + // 查找输出文件 + outputFile := findLatestOutputFile(workDir) + if outputFile != "" { + teamRegState.mu.Lock() + teamRegState.OutputFile = outputFile + teamRegState.mu.Unlock() + addTeamRegLog(fmt.Sprintf("[系统] 输出文件: %s", filepath.Base(outputFile))) + + // 自动导入 + if config.AutoImport { + addTeamRegLog("[系统] 自动导入账号到数据库...") + count, err := importAccountsFromJSON(outputFile) + if err != nil { + addTeamRegLog(fmt.Sprintf("[错误] 导入失败: %v", err)) + } else { + teamRegState.mu.Lock() + teamRegState.Imported = count + teamRegState.mu.Unlock() + addTeamRegLog(fmt.Sprintf("[系统] 成功导入 %d 个账号", count)) + } + } + } + + // 发送回车退出程序(如果还在运行) + time.Sleep(500 * time.Millisecond) + if stdin != nil { + fmt.Fprintf(stdin, "\n") + } +} + +// readOutput 读取进程输出 +func readOutput(reader io.Reader, workDir string, config TeamRegConfig) { + scanner := bufio.NewScanner(reader) + for scanner.Scan() { + line := scanner.Text() + // 过滤空行和只有空格的行 + trimmed := strings.TrimSpace(line) + if trimmed != "" { + addTeamRegLog(trimmed) + } + } +} + +// addTeamRegLog 添加日志 +func addTeamRegLog(log string) { + teamRegState.mu.Lock() + defer teamRegState.mu.Unlock() + + timestamp := time.Now().Format("15:04:05") + fullLog := fmt.Sprintf("[%s] %s", timestamp, log) + teamRegState.Logs = append(teamRegState.Logs, fullLog) + + // 限制日志数量 + if len(teamRegState.Logs) > 1000 { + teamRegState.Logs = teamRegState.Logs[len(teamRegState.Logs)-1000:] + } + + // 同时输出到系统日志 + logger.Info(fmt.Sprintf("[TeamReg] %s", log), "", "team-reg") +} + +// findTeamRegExecutable 查找 team-reg 可执行文件 +func findTeamRegExecutable() string { + // 可能的文件名 + var names []string + if runtime.GOOS == "windows" { + names = []string{"team-reg.exe", "team-reg"} + } else { + names = []string{"team-reg", "team-reg.exe"} + } + + // 可能的路径 + paths := []string{ + ".", // 当前目录 + "..", // 上级目录 + "../", // 项目根目录 + filepath.Join("..", ".."), // 更上级 + } + + // 获取可执行文件所在目录 + execDir, err := os.Executable() + if err == nil { + execDir = filepath.Dir(execDir) + paths = append(paths, execDir, filepath.Join(execDir, "..")) + } + + for _, basePath := range paths { + for _, name := range names { + fullPath := filepath.Join(basePath, name) + if absPath, err := filepath.Abs(fullPath); err == nil { + if _, err := os.Stat(absPath); err == nil { + return absPath + } + } + } + } + + return "" +} + +// findLatestOutputFile 查找最新的输出文件 +func findLatestOutputFile(dir string) string { + pattern := filepath.Join(dir, "accounts-*.json") + matches, err := filepath.Glob(pattern) + if err != nil || len(matches) == 0 { + return "" + } + + // 按修改时间排序,取最新的 + sort.Slice(matches, func(i, j int) bool { + fi, _ := os.Stat(matches[i]) + fj, _ := os.Stat(matches[j]) + if fi == nil || fj == nil { + return false + } + return fi.ModTime().After(fj.ModTime()) + }) + + // 确保是最近创建的文件(5分钟内) + fi, err := os.Stat(matches[0]) + if err != nil { + return "" + } + if time.Since(fi.ModTime()) > 5*time.Minute { + return "" + } + + return matches[0] +} + +// TeamRegAccount team-reg 输出的账号格式 +type TeamRegAccount struct { + Account string `json:"account"` + Password string `json:"password"` + Token string `json:"token"` + AccountID string `json:"account_id"` + PlanType string `json:"plan_type"` +} + +// importAccountsFromJSON 从 JSON 文件导入账号 +func importAccountsFromJSON(filePath string) (int, error) { + if database.Instance == nil { + return 0, fmt.Errorf("数据库未初始化") + } + + data, err := os.ReadFile(filePath) + if err != nil { + return 0, err + } + + var accounts []TeamRegAccount + if err := json.Unmarshal(data, &accounts); err != nil { + return 0, err + } + + // 转换为 database.TeamOwner 格式 + var owners []database.TeamOwner + for _, acc := range accounts { + if acc.Account == "" || acc.Password == "" { + continue + } + + // 提取 account_id(去掉 org- 前缀如果有的话) + accountID := acc.AccountID + if strings.HasPrefix(accountID, "org-") { + accountID = strings.TrimPrefix(accountID, "org-") + } + + owners = append(owners, database.TeamOwner{ + Email: acc.Account, + Password: acc.Password, + Token: acc.Token, + AccountID: accountID, + }) + } + + // 批量导入 + count, err := database.Instance.AddTeamOwners(owners) + if err != nil { + return 0, err + } + + return count, nil +} diff --git a/backend/team-reg b/backend/team-reg new file mode 100644 index 0000000000000000000000000000000000000000..a0cd0bd9fdfdf75a16c1762cb048e0feb37f6bae GIT binary patch literal 15738128 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