diff --git a/backend/internal/api/team_process.go b/backend/internal/api/team_process.go index 682f6b5..e5f3598 100644 --- a/backend/internal/api/team_process.go +++ b/backend/internal/api/team_process.go @@ -32,13 +32,14 @@ type TeamProcessRequest struct { // Owner 账号列表 Owners []TeamOwner `json:"owners"` // 配置 - MembersPerTeam int `json:"members_per_team"` // 每个 Team 的成员数 - ConcurrentTeams int `json:"concurrent_teams"` // 并发 Team 数量 - BrowserType string `json:"browser_type"` // "chromedp" 或 "rod" - Headless bool `json:"headless"` // 是否无头模式 - Proxy string `json:"proxy"` // 代理设置 - IncludeOwner bool `json:"include_owner"` // 母号也入库到 S2A - ProcessCount int `json:"process_count"` // 处理数量,0表示全部 + MembersPerTeam int `json:"members_per_team"` // 每个 Team 的成员数 + ConcurrentTeams int `json:"concurrent_teams"` // 并发 Team 数量 + ConcurrentS2A int `json:"concurrent_s2a"` // 入库并发数(默认2) + BrowserType string `json:"browser_type"` // "chromedp" 或 "rod" + Headless bool `json:"headless"` // 是否无头模式 + Proxy string `json:"proxy"` // 代理设置 + IncludeOwner bool `json:"include_owner"` // 母号也入库到 S2A + ProcessCount int `json:"process_count"` // 处理数量,0表示全部 } // TeamProcessResult 团队处理结果 @@ -125,6 +126,12 @@ func HandleTeamProcess(w http.ResponseWriter, r *http.Request) { if req.BrowserType == "" { req.BrowserType = "chromedp" // 默认使用 Chromedp } + if req.ConcurrentS2A <= 0 { + req.ConcurrentS2A = 2 // 默认入库并发数为 2 + } + if req.ConcurrentS2A > 4 { + req.ConcurrentS2A = 4 // 最大入库并发数为 4(避免浏览器资源耗尽) + } if req.Proxy == "" && config.Global != nil { req.Proxy = config.Global.GetProxy() // 使用新的代理获取方法 } @@ -549,6 +556,8 @@ func processSingleTeam(idx int, req TeamProcessRequest) (result TeamProcessResul registerMember := func(memberIdx int, email, password string) bool { name := register.GenerateName() birthdate := register.GenerateBirthdate() + memberLogPrefix := fmt.Sprintf("%s [成员 %d]", logPrefix, memberIdx+1) + regStartTime := time.Now() for attempt := 0; attempt < 2; attempt++ { // 最多尝试2次(首次+1次重试) // 检查是否应该停止 @@ -562,13 +571,14 @@ func processSingleTeam(idx int, req TeamProcessRequest) (result TeamProcessResul // 重试时使用新邮箱 currentEmail = mail.GenerateEmail() currentPassword = register.GeneratePassword() - logger.Warning(fmt.Sprintf("%s [成员 %d] 重试, 新邮箱: %s", logPrefix, memberIdx+1, currentEmail), currentEmail, "team") + logger.Warning(fmt.Sprintf("%s 重试 (第%d次), 新邮箱: %s", memberLogPrefix, attempt+1, currentEmail), currentEmail, "team") } // 发送邀请 + logger.Info(fmt.Sprintf("%s [发送邀请] %s", memberLogPrefix, currentEmail), currentEmail, "team") if err := inviter.SendInvites([]string{currentEmail}); err != nil { errStr := err.Error() - logger.Error(fmt.Sprintf("%s [成员 %d] 邀请失败: %v", logPrefix, memberIdx+1, err), currentEmail, "team") + logger.Error(fmt.Sprintf("%s [邀请失败] %v", memberLogPrefix, err), currentEmail, "team") // 检测 Team 已达邀请上限(401 或 maximum number of seats) if strings.Contains(errStr, "401") || strings.Contains(errStr, "maximum number of seats") { @@ -577,31 +587,37 @@ func processSingleTeam(idx int, req TeamProcessRequest) (result TeamProcessResul } continue } + logger.Info(fmt.Sprintf("%s [邀请成功]", memberLogPrefix), currentEmail, "team") // 再次检查是否应该停止(邀请期间其他 goroutine 可能已标记) if isTeamExhausted() { return false } + // 创建注册日志记录器 + regLogger := NewRegisterLogger(memberLogPrefix, currentEmail) + // 注册 - _, err := registerWithTimeout(currentEmail, currentPassword, name, birthdate, req.Proxy) + _, err := registerWithTimeoutLogged(currentEmail, currentPassword, name, birthdate, req.Proxy, regLogger) if err != nil { - logger.Error(fmt.Sprintf("%s [成员 %d] 注册失败: %v", logPrefix, memberIdx+1, err), currentEmail, "team") + logger.Error(fmt.Sprintf("%s [注册失败] %v", memberLogPrefix, err), currentEmail, "team") continue } // 成功 + regDuration := time.Since(regStartTime) memberMu.Lock() children[memberIdx] = MemberAccount{Email: currentEmail, Password: currentPassword, Success: true} memberMu.Unlock() - logger.Success(fmt.Sprintf("%s [成员 %d] ✓ 注册成功", logPrefix, memberIdx+1), currentEmail, "team") + logger.Success(fmt.Sprintf("%s ✓ 注册成功 (耗时: %.1fs)", memberLogPrefix, regDuration.Seconds()), currentEmail, "team") return true } return false } // 第一轮:并发注册4个成员 - logger.Info(fmt.Sprintf("%s 开始并发注册 %d 个成员", logPrefix, req.MembersPerTeam), owner.Email, "team") + logger.Info(fmt.Sprintf("%s ════════ 开始注册阶段 ════════ 目标: %d 个成员", logPrefix, req.MembersPerTeam), owner.Email, "team") + regPhaseStartTime := time.Now() for i := 0; i < req.MembersPerTeam; i++ { memberWg.Add(1) go func(idx int) { @@ -666,7 +682,8 @@ func processSingleTeam(idx int, req TeamProcessRequest) (result TeamProcessResul if len(failedSlots) > 0 { result.Errors = append(result.Errors, fmt.Sprintf("%d 个成员注册失败", len(failedSlots))) } - logger.Info(fmt.Sprintf("%s 注册完成: %d/%d 成功", logPrefix, result.Registered, req.MembersPerTeam), owner.Email, "team") + regPhaseDuration := time.Since(regPhaseStartTime) + logger.Info(fmt.Sprintf("%s ════════ 注册阶段完成 ════════ 成功: %d/%d, 耗时: %.1fs", logPrefix, result.Registered, req.MembersPerTeam, regPhaseDuration.Seconds()), owner.Email, "team") // 如果没有任何成员注册成功,跳过入库步骤 if len(registeredChildren) == 0 { @@ -676,91 +693,182 @@ func processSingleTeam(idx int, req TeamProcessRequest) (result TeamProcessResul return result } - // Step 4: S2A 授权入库(成员)- 带重试 + // Step 4: S2A 授权入库(成员)- 并发入库 + logger.Info(fmt.Sprintf("%s ════════ 开始入库阶段 ════════ 共 %d 个成员, 并发数: %d", logPrefix, len(registeredChildren), req.ConcurrentS2A), owner.Email, "team") + s2aStartTime := time.Now() + + // 入库结果 + type S2AResult struct { + Index int + Email string + Success bool + Error string + } + + s2aResults := make(chan S2AResult, len(registeredChildren)) + s2aSem := make(chan struct{}, req.ConcurrentS2A) // 并发控制信号量 + + var s2aWg sync.WaitGroup + for i, child := range registeredChildren { if !teamProcessState.Running { break } - var s2aSuccess bool - for attempt := 0; attempt < 2; attempt++ { // 最多重试1次 - if attempt > 0 { - logger.Warning(fmt.Sprintf("%s [成员 %d] 入库重试...", logPrefix, i+1), child.Email, "team") + s2aWg.Add(1) + go func(memberIdx int, memberChild MemberAccount) { + defer s2aWg.Done() + + // 获取信号量 + s2aSem <- struct{}{} + defer func() { <-s2aSem }() + + memberStartTime := time.Now() + memberLogPrefix := fmt.Sprintf("%s [成员 %d]", logPrefix, memberIdx+1) + + logger.Info(fmt.Sprintf("%s 开始入库 | 邮箱: %s", memberLogPrefix, memberChild.Email), memberChild.Email, "team") + + var s2aSuccess bool + var lastError string + + for attempt := 0; attempt < 2; attempt++ { // 最多重试1次 + if attempt > 0 { + logger.Warning(fmt.Sprintf("%s 入库重试 (第%d次)", memberLogPrefix, attempt+1), memberChild.Email, "team") + } + + // 创建日志回调 + authLogger := auth.NewAuthLogger(memberChild.Email, logPrefix, memberIdx+1, func(entry auth.AuthLogEntry) { + stepName := auth.StepName(entry.Step) + if entry.IsError { + logger.Error(fmt.Sprintf("%s [%s] %s (%.1fs)", memberLogPrefix, stepName, entry.Message, entry.Duration.Seconds()), memberChild.Email, "team") + } else { + logger.Info(fmt.Sprintf("%s [%s] %s", memberLogPrefix, stepName, entry.Message), memberChild.Email, "team") + } + }) + + // 获取授权 URL + logger.Info(fmt.Sprintf("%s 获取 S2A 授权 URL...", memberLogPrefix), memberChild.Email, "team") + s2aResp, err := auth.GenerateS2AAuthURL(config.Global.S2AApiBase, config.Global.S2AAdminKey, config.Global.ProxyID) + if err != nil { + lastError = fmt.Sprintf("获取授权URL失败: %v", err) + logger.Error(fmt.Sprintf("%s %s", memberLogPrefix, lastError), memberChild.Email, "team") + continue + } + logger.Info(fmt.Sprintf("%s 授权 URL 获取成功, SessionID: %s", memberLogPrefix, s2aResp.Data.SessionID[:8]+"..."), memberChild.Email, "team") + + // 根据配置选择浏览器自动化 + var code string + if req.BrowserType == "rod" { + code, err = auth.CompleteWithRodLogged(s2aResp.Data.AuthURL, memberChild.Email, memberChild.Password, teamID, req.Headless, req.Proxy, authLogger) + } else { + code, err = auth.CompleteWithChromedpLogged(s2aResp.Data.AuthURL, memberChild.Email, memberChild.Password, teamID, req.Headless, req.Proxy, authLogger) + } + if err != nil { + lastError = fmt.Sprintf("浏览器授权失败: %v", err) + logger.Error(fmt.Sprintf("%s %s (耗时: %.1fs)", memberLogPrefix, lastError, authLogger.TotalDuration().Seconds()), memberChild.Email, "team") + continue + } + logger.Info(fmt.Sprintf("%s 浏览器授权成功, 授权码: %s... (耗时: %.1fs)", memberLogPrefix, code[:8], authLogger.TotalDuration().Seconds()), memberChild.Email, "team") + + // 提交到 S2A + logger.Info(fmt.Sprintf("%s 正在提交到 S2A...", memberLogPrefix), memberChild.Email, "team") + _, err = auth.SubmitS2AOAuth( + config.Global.S2AApiBase, + config.Global.S2AAdminKey, + s2aResp.Data.SessionID, + code, + memberChild.Email, + config.Global.Concurrency, + config.Global.Priority, + config.Global.GroupIDs, + config.Global.ProxyID, + ) + if err != nil { + lastError = fmt.Sprintf("S2A提交失败: %v", err) + logger.Error(fmt.Sprintf("%s %s", memberLogPrefix, lastError), memberChild.Email, "team") + continue + } + + s2aSuccess = true + memberDuration := time.Since(memberStartTime) + logger.Success(fmt.Sprintf("%s ✓ 入库成功 (总耗时: %.1fs)", memberLogPrefix, memberDuration.Seconds()), memberChild.Email, "team") + break } - s2aResp, err := auth.GenerateS2AAuthURL(config.Global.S2AApiBase, config.Global.S2AAdminKey, config.Global.ProxyID) - if err != nil { - logger.Error(fmt.Sprintf("%s [成员 %d] 获取授权URL失败: %v", logPrefix, i+1, err), child.Email, "team") - continue + s2aResults <- S2AResult{ + Index: memberIdx, + Email: memberChild.Email, + Success: s2aSuccess, + Error: lastError, } + }(i, child) + } - // 根据配置选择浏览器自动化 - var code string - if req.BrowserType == "rod" { - code, err = auth.CompleteWithRod(s2aResp.Data.AuthURL, child.Email, child.Password, teamID, req.Headless, req.Proxy) - } else { - code, err = auth.CompleteWithChromedp(s2aResp.Data.AuthURL, child.Email, child.Password, teamID, req.Headless, req.Proxy) - } - if err != nil { - logger.Error(fmt.Sprintf("%s [成员 %d] 浏览器授权失败: %v", logPrefix, i+1, err), child.Email, "team") - continue - } + // 等待所有入库完成 + go func() { + s2aWg.Wait() + close(s2aResults) + }() - // 提交到 S2A - _, err = auth.SubmitS2AOAuth( - config.Global.S2AApiBase, - config.Global.S2AAdminKey, - s2aResp.Data.SessionID, - code, - child.Email, - config.Global.Concurrency, - config.Global.Priority, - config.Global.GroupIDs, - config.Global.ProxyID, - ) - if err != nil { - logger.Error(fmt.Sprintf("%s [成员 %d] S2A提交失败: %v", logPrefix, i+1, err), child.Email, "team") - continue - } - - s2aSuccess = true + // 收集入库结果 + for s2aRes := range s2aResults { + if s2aRes.Success { result.AddedToS2A++ - logger.Success(fmt.Sprintf("%s [成员 %d] ✓ 入库成功", logPrefix, i+1), child.Email, "team") - break - } - - if !s2aSuccess { - result.Errors = append(result.Errors, fmt.Sprintf("成员 %d 入库失败", i+1)) + } else { + result.Errors = append(result.Errors, fmt.Sprintf("成员 %d 入库失败: %s", s2aRes.Index+1, s2aRes.Error)) } } + s2aDuration := time.Since(s2aStartTime) + logger.Info(fmt.Sprintf("%s ════════ 入库阶段完成 ════════ 成功: %d/%d, 耗时: %.1fs", logPrefix, result.AddedToS2A, len(registeredChildren), s2aDuration.Seconds()), owner.Email, "team") + // Step 5: 母号也入库(如果开启)- 带重试 if req.IncludeOwner && teamProcessState.Running { - logger.Info(fmt.Sprintf("%s 开始将母号入库到 S2A", logPrefix), owner.Email, "team") + ownerLogPrefix := fmt.Sprintf("%s [母号 ]", logPrefix) + ownerStartTime := time.Now() + logger.Info(fmt.Sprintf("%s ════════ 开始母号入库 ════════", logPrefix), owner.Email, "team") + logger.Info(fmt.Sprintf("%s 开始入库 | 邮箱: %s", ownerLogPrefix, owner.Email), owner.Email, "team") var ownerSuccess bool + var lastError string for attempt := 0; attempt < 2; attempt++ { // 最多重试1次 if attempt > 0 { - logger.Warning(fmt.Sprintf("%s [母号] 入库重试...", logPrefix), owner.Email, "team") + logger.Warning(fmt.Sprintf("%s 入库重试 (第%d次)", ownerLogPrefix, attempt+1), owner.Email, "team") } + // 创建日志回调 + authLogger := auth.NewAuthLogger(owner.Email, logPrefix, 0, func(entry auth.AuthLogEntry) { + stepName := auth.StepName(entry.Step) + if entry.IsError { + logger.Error(fmt.Sprintf("%s [%s] %s (%.1fs)", ownerLogPrefix, stepName, entry.Message, entry.Duration.Seconds()), owner.Email, "team") + } else { + logger.Info(fmt.Sprintf("%s [%s] %s", ownerLogPrefix, stepName, entry.Message), owner.Email, "team") + } + }) + + logger.Info(fmt.Sprintf("%s 获取 S2A 授权 URL...", ownerLogPrefix), owner.Email, "team") s2aResp, err := auth.GenerateS2AAuthURL(config.Global.S2AApiBase, config.Global.S2AAdminKey, config.Global.ProxyID) if err != nil { - logger.Error(fmt.Sprintf("%s [母号] 获取授权URL失败: %v", logPrefix, err), owner.Email, "team") + lastError = fmt.Sprintf("获取授权URL失败: %v", err) + logger.Error(fmt.Sprintf("%s %s", ownerLogPrefix, lastError), owner.Email, "team") continue } + logger.Info(fmt.Sprintf("%s 授权 URL 获取成功, SessionID: %s", ownerLogPrefix, s2aResp.Data.SessionID[:8]+"..."), owner.Email, "team") var code string if req.BrowserType == "rod" { - code, err = auth.CompleteWithRod(s2aResp.Data.AuthURL, owner.Email, owner.Password, teamID, req.Headless, req.Proxy) + code, err = auth.CompleteWithRodLogged(s2aResp.Data.AuthURL, owner.Email, owner.Password, teamID, req.Headless, req.Proxy, authLogger) } else { - code, err = auth.CompleteWithChromedp(s2aResp.Data.AuthURL, owner.Email, owner.Password, teamID, req.Headless, req.Proxy) + code, err = auth.CompleteWithChromedpLogged(s2aResp.Data.AuthURL, owner.Email, owner.Password, teamID, req.Headless, req.Proxy, authLogger) } if err != nil { - logger.Error(fmt.Sprintf("%s [母号] 浏览器授权失败: %v", logPrefix, err), owner.Email, "team") + lastError = fmt.Sprintf("浏览器授权失败: %v", err) + logger.Error(fmt.Sprintf("%s %s (耗时: %.1fs)", ownerLogPrefix, lastError, authLogger.TotalDuration().Seconds()), owner.Email, "team") continue } + logger.Info(fmt.Sprintf("%s 浏览器授权成功, 授权码: %s... (耗时: %.1fs)", ownerLogPrefix, code[:8], authLogger.TotalDuration().Seconds()), owner.Email, "team") + logger.Info(fmt.Sprintf("%s 正在提交到 S2A...", ownerLogPrefix), owner.Email, "team") _, err = auth.SubmitS2AOAuth( config.Global.S2AApiBase, config.Global.S2AAdminKey, @@ -773,19 +881,22 @@ func processSingleTeam(idx int, req TeamProcessRequest) (result TeamProcessResul config.Global.ProxyID, ) if err != nil { - logger.Error(fmt.Sprintf("%s [母号] S2A提交失败: %v", logPrefix, err), owner.Email, "team") + lastError = fmt.Sprintf("S2A提交失败: %v", err) + logger.Error(fmt.Sprintf("%s %s", ownerLogPrefix, lastError), owner.Email, "team") continue } ownerSuccess = true result.AddedToS2A++ - logger.Success(fmt.Sprintf("%s [母号 ] ✓ 入库成功", logPrefix), owner.Email, "team") + ownerDuration := time.Since(ownerStartTime) + logger.Success(fmt.Sprintf("%s ✓ 入库成功 (总耗时: %.1fs)", ownerLogPrefix, ownerDuration.Seconds()), owner.Email, "team") break } if !ownerSuccess { - result.Errors = append(result.Errors, "母号入库失败") + result.Errors = append(result.Errors, fmt.Sprintf("母号入库失败: %s", lastError)) } + logger.Info(fmt.Sprintf("%s ════════ 母号入库完成 ════════", logPrefix), owner.Email, "team") } result.DurationMs = time.Since(startTime).Milliseconds() @@ -798,45 +909,97 @@ func processSingleTeam(idx int, req TeamProcessRequest) (result TeamProcessResul return result } +// RegisterLogger 注册日志记录器 +type RegisterLogger struct { + logPrefix string + email string + startTime time.Time +} + +// NewRegisterLogger 创建注册日志记录器 +func NewRegisterLogger(logPrefix, email string) *RegisterLogger { + return &RegisterLogger{ + logPrefix: logPrefix, + email: email, + startTime: time.Now(), + } +} + +// LogStep 记录步骤 +func (l *RegisterLogger) LogStep(step string) { + logger.Info(fmt.Sprintf("%s [%s]", l.logPrefix, step), l.email, "team") +} + +// LogStepDone 记录步骤完成 +func (l *RegisterLogger) LogStepDone(step string, duration time.Duration) { + logger.Info(fmt.Sprintf("%s [%s] 完成 (%.1fs)", l.logPrefix, step, duration.Seconds()), l.email, "team") +} + // registerWithTimeout 带超时的注册 func registerWithTimeout(email, password, name, birthdate, proxy string) (*register.ChatGPTReg, error) { + return registerWithTimeoutLogged(email, password, name, birthdate, proxy, nil) +} + +// registerWithTimeoutLogged 带超时和日志的注册 +func registerWithTimeoutLogged(email, password, name, birthdate, proxy string, regLogger *RegisterLogger) (*register.ChatGPTReg, error) { + logStep := func(step string) { + if regLogger != nil { + regLogger.LogStep(step) + } + } + reg, err := register.New(proxy) if err != nil { return nil, err } + logStep("初始化会话") if err := reg.InitSession(); err != nil { return nil, fmt.Errorf("初始化失败: %v", err) } + + logStep("获取授权URL") if err := reg.GetAuthorizeURL(email); err != nil { return nil, fmt.Errorf("获取授权URL失败: %v", err) } + + logStep("启动授权") if err := reg.StartAuthorize(); err != nil { return nil, fmt.Errorf("启动授权失败: %v", err) } + + logStep("提交注册信息") if err := reg.Register(email, password); err != nil { return nil, fmt.Errorf("注册失败: %v", err) } + + logStep("发送验证邮件") if err := reg.SendVerificationEmail(); err != nil { return nil, fmt.Errorf("发送邮件失败: %v", err) } // 短超时获取验证码 + logStep("等待验证码 (5s)") otpCode, err := mail.GetVerificationCode(email, 5*time.Second) if err != nil { + logStep("等待验证码 (15s)") otpCode, err = mail.GetVerificationCode(email, 15*time.Second) if err != nil { return nil, fmt.Errorf("验证码获取超时") } } + logStep("验证OTP") if err := reg.ValidateOTP(otpCode); err != nil { return nil, fmt.Errorf("OTP验证失败: %v", err) } + + logStep("创建账户") if err := reg.CreateAccount(name, birthdate); err != nil { return nil, fmt.Errorf("创建账户失败: %v", err) } + logStep("获取会话令牌") _ = reg.GetSessionToken() return reg, nil } diff --git a/backend/internal/auth/auth_log.go b/backend/internal/auth/auth_log.go new file mode 100644 index 0000000..5aa2d6d --- /dev/null +++ b/backend/internal/auth/auth_log.go @@ -0,0 +1,122 @@ +package auth + +import ( + "fmt" + "sync" + "time" +) + +// AuthStep 授权步骤 +type AuthStep string + +const ( + StepBrowserStart AuthStep = "browser_start" // 启动浏览器 + StepNavigate AuthStep = "navigate" // 访问授权页面 + StepInputEmail AuthStep = "input_email" // 输入邮箱 + StepSubmitEmail AuthStep = "submit_email" // 提交邮箱 + StepInputPassword AuthStep = "input_password" // 输入密码 + StepSubmitPassword AuthStep = "submit_password" // 提交密码 + StepSelectWorkspace AuthStep = "select_workspace" // 选择工作区 + StepConsent AuthStep = "consent" // 授权同意 + StepWaitCallback AuthStep = "wait_callback" // 等待回调 + StepExtractCode AuthStep = "extract_code" // 提取授权码 + StepComplete AuthStep = "complete" // 完成授权 +) + +// AuthLogEntry 授权日志条目 +type AuthLogEntry struct { + Step AuthStep + Message string + Duration time.Duration + Timestamp time.Time + IsError bool +} + +// AuthLogger 授权日志记录器 +type AuthLogger struct { + email string + teamPrefix string + memberIdx int + callback func(entry AuthLogEntry) + startTime time.Time + stepStart time.Time + mu sync.Mutex +} + +// NewAuthLogger 创建授权日志记录器 +func NewAuthLogger(email, teamPrefix string, memberIdx int, callback func(entry AuthLogEntry)) *AuthLogger { + return &AuthLogger{ + email: email, + teamPrefix: teamPrefix, + memberIdx: memberIdx, + callback: callback, + startTime: time.Now(), + stepStart: time.Now(), + } +} + +// LogStep 记录步骤 +func (l *AuthLogger) LogStep(step AuthStep, format string, args ...interface{}) { + l.mu.Lock() + defer l.mu.Unlock() + + now := time.Now() + entry := AuthLogEntry{ + Step: step, + Message: fmt.Sprintf(format, args...), + Duration: now.Sub(l.stepStart), + Timestamp: now, + IsError: false, + } + l.stepStart = now + + if l.callback != nil { + l.callback(entry) + } +} + +// LogError 记录错误 +func (l *AuthLogger) LogError(step AuthStep, format string, args ...interface{}) { + l.mu.Lock() + defer l.mu.Unlock() + + now := time.Now() + entry := AuthLogEntry{ + Step: step, + Message: fmt.Sprintf(format, args...), + Duration: now.Sub(l.stepStart), + Timestamp: now, + IsError: true, + } + l.stepStart = now + + if l.callback != nil { + l.callback(entry) + } +} + +// TotalDuration 获取总耗时 +func (l *AuthLogger) TotalDuration() time.Duration { + return time.Since(l.startTime) +} + +// StepName 获取步骤中文名称 +func StepName(step AuthStep) string { + names := map[AuthStep]string{ + StepBrowserStart: "启动浏览器", + StepNavigate: "访问授权页", + StepInputEmail: "输入邮箱", + StepSubmitEmail: "提交邮箱", + StepInputPassword: "输入密码", + StepSubmitPassword: "提交密码", + StepSelectWorkspace: "选择工作区", + StepConsent: "授权同意", + StepWaitCallback: "等待回调", + StepExtractCode: "提取授权码", + StepComplete: "完成授权", + } + if name, ok := names[step]; ok { + return name + } + return string(step) +} diff --git a/backend/internal/auth/chromedp.go b/backend/internal/auth/chromedp.go index 9421a42..0150324 100644 --- a/backend/internal/auth/chromedp.go +++ b/backend/internal/auth/chromedp.go @@ -16,6 +16,24 @@ import ( // CompleteWithChromedp 使用 chromedp 完成 S2A OAuth 授权 func CompleteWithChromedp(authURL, email, password, teamID string, headless bool, proxy string) (string, error) { + return CompleteWithChromedpLogged(authURL, email, password, teamID, headless, proxy, nil) +} + +// CompleteWithChromedpLogged 使用 chromedp 完成 S2A OAuth 授权(带日志回调) +func CompleteWithChromedpLogged(authURL, email, password, teamID string, headless bool, proxy string, logger *AuthLogger) (string, error) { + // 日志辅助函数 + logStep := func(step AuthStep, format string, args ...interface{}) { + if logger != nil { + logger.LogStep(step, format, args...) + } + } + logError := func(step AuthStep, format string, args ...interface{}) { + if logger != nil { + logger.LogError(step, format, args...) + } + } + + logStep(StepBrowserStart, "正在启动 Chromedp 浏览器...") // 获取随机浏览器配置 profile := GetRandomBrowserProfile() @@ -132,14 +150,17 @@ func CompleteWithChromedp(authURL, email, password, teamID string, headless bool tasks = append([]chromedp.Action{fetch.Enable().WithHandleAuthRequests(true)}, tasks...) } + logStep(StepNavigate, "正在访问授权页面...") err := chromedp.Run(ctx, tasks...) if err != nil { + logError(StepNavigate, "访问失败: %v", err) return "", fmt.Errorf("访问失败: %v", err) } time.Sleep(2 * time.Second) if callbackURL != "" { + logStep(StepExtractCode, "已捕获授权码回调") return ExtractCodeFromCallbackURL(callbackURL), nil } @@ -158,6 +179,7 @@ func CompleteWithChromedp(authURL, email, password, teamID string, headless bool `input[name="username"]`, } + logStep(StepInputEmail, "正在查找邮箱输入框...") var emailFilled bool for _, sel := range emailSelectors { err = chromedp.Run(ctx, chromedp.WaitVisible(sel, chromedp.ByQuery)) @@ -168,12 +190,14 @@ func CompleteWithChromedp(authURL, email, password, teamID string, headless bool ) if err == nil { emailFilled = true + logStep(StepInputEmail, "已输入邮箱") break } } } if !emailFilled { + logError(StepInputEmail, "未找到邮箱输入框") return "", fmt.Errorf("未找到邮箱输入框") } @@ -186,6 +210,7 @@ func CompleteWithChromedp(authURL, email, password, teamID string, headless bool `input[type="submit"]`, } + logStep(StepSubmitEmail, "正在提交邮箱...") for _, sel := range buttonSelectors { err = chromedp.Run(ctx, chromedp.Click(sel, chromedp.ByQuery)) if err == nil { @@ -196,14 +221,17 @@ func CompleteWithChromedp(authURL, email, password, teamID string, headless bool time.Sleep(1500 * time.Millisecond) if callbackURL != "" { + logStep(StepExtractCode, "已捕获授权码回调") return ExtractCodeFromCallbackURL(callbackURL), nil } _ = chromedp.Run(ctx, chromedp.Location(¤tURL)) if strings.Contains(currentURL, "code=") { + logStep(StepExtractCode, "已获取授权码") return ExtractCodeFromCallbackURL(currentURL), nil } + logStep(StepInputPassword, "正在查找密码输入框...") passwordSelectors := []string{ `input[name="current-password"]`, `input[name="password"]`, @@ -220,17 +248,20 @@ func CompleteWithChromedp(authURL, email, password, teamID string, headless bool ) if err == nil { passwordFilled = true + logStep(StepInputPassword, "已输入密码") break } } } if !passwordFilled { + logError(StepInputPassword, "未找到密码输入框") return "", fmt.Errorf("未找到密码输入框") } time.Sleep(300 * time.Millisecond) + logStep(StepSubmitPassword, "正在提交密码...") for _, sel := range buttonSelectors { err = chromedp.Run(ctx, chromedp.Click(sel, chromedp.ByQuery)) if err == nil { @@ -238,6 +269,7 @@ func CompleteWithChromedp(authURL, email, password, teamID string, headless bool } } + logStep(StepWaitCallback, "等待授权回调...") for i := 0; i < 30; i++ { time.Sleep(500 * time.Millisecond) @@ -248,10 +280,12 @@ func CompleteWithChromedp(authURL, email, password, teamID string, headless bool var url string if err := chromedp.Run(ctx, chromedp.Location(&url)); err == nil { if strings.Contains(url, "code=") { + logStep(StepExtractCode, "已获取授权码") return ExtractCodeFromCallbackURL(url), nil } if strings.Contains(url, "consent") { + logStep(StepConsent, "正在处理授权同意页面...") for _, sel := range buttonSelectors { err = chromedp.Run(ctx, chromedp.Click(sel, chromedp.ByQuery)) if err == nil { @@ -262,6 +296,7 @@ func CompleteWithChromedp(authURL, email, password, teamID string, headless bool } if strings.Contains(url, "authorize") && teamID != "" { + logStep(StepSelectWorkspace, "正在选择工作区: %s", teamID) err = chromedp.Run(ctx, chromedp.Click(fmt.Sprintf(`[data-workspace-id="%s"], [data-account-id="%s"]`, teamID, teamID), chromedp.ByQuery), ) @@ -270,8 +305,10 @@ func CompleteWithChromedp(authURL, email, password, teamID string, headless bool } if callbackURL != "" { + logStep(StepComplete, "授权完成") return ExtractCodeFromCallbackURL(callbackURL), nil } + logError(StepWaitCallback, "授权超时") return "", fmt.Errorf("授权超时") } diff --git a/backend/internal/auth/rod.go b/backend/internal/auth/rod.go index d120136..5f869ff 100644 --- a/backend/internal/auth/rod.go +++ b/backend/internal/auth/rod.go @@ -134,6 +134,23 @@ func (r *RodAuth) Close() { // CompleteOAuth 完成 OAuth 授权 func (r *RodAuth) CompleteOAuth(authURL, email, password, teamID string) (string, error) { + return r.CompleteOAuthLogged(authURL, email, password, teamID, nil) +} + +// CompleteOAuthLogged 完成 OAuth 授权(带日志回调) +func (r *RodAuth) CompleteOAuthLogged(authURL, email, password, teamID string, logger *AuthLogger) (string, error) { + // 日志辅助函数 + logStep := func(step AuthStep, format string, args ...interface{}) { + if logger != nil { + logger.LogStep(step, format, args...) + } + } + logError := func(step AuthStep, format string, args ...interface{}) { + if logger != nil { + logger.LogError(step, format, args...) + } + } + // Handle proxy auth (407) in headless mode. // When Fetch domain is enabled without patterns, requests will be paused and must be continued. // 只在代理需要认证时才启用 Fetch 域 @@ -194,24 +211,31 @@ func (r *RodAuth) CompleteOAuth(authURL, email, password, teamID string) (string // 增加超时时间到 90 秒 page = page.Timeout(90 * time.Second) + logStep(StepNavigate, "正在访问授权页面...") if err := page.Navigate(authURL); err != nil { + logError(StepNavigate, "访问失败: %v", err) return "", fmt.Errorf("访问授权URL失败: %v", err) } page.MustWaitDOMStable() if code := r.checkForCode(page); code != "" { + logStep(StepExtractCode, "已捕获授权码回调") return code, nil } + logStep(StepInputEmail, "正在查找邮箱输入框...") emailInput, err := page.Timeout(5 * time.Second).Element("input[name='email'], input[type='email'], input[name='username']") if err != nil { + logError(StepInputEmail, "未找到邮箱输入框") return "", fmt.Errorf("未找到邮箱输入框") } emailInput.MustSelectAllText().MustInput(email) + logStep(StepInputEmail, "已输入邮箱") time.Sleep(200 * time.Millisecond) + logStep(StepSubmitEmail, "正在提交邮箱...") if btn, _ := page.Timeout(2 * time.Second).Element("button[type='submit']"); btn != nil { btn.MustClick() } @@ -219,25 +243,32 @@ func (r *RodAuth) CompleteOAuth(authURL, email, password, teamID string) (string time.Sleep(1500 * time.Millisecond) if code := r.checkForCode(page); code != "" { + logStep(StepExtractCode, "已获取授权码") return code, nil } + logStep(StepInputPassword, "正在查找密码输入框...") passwordInput, err := page.Timeout(8 * time.Second).Element("input[type='password']") if err != nil { + logError(StepInputPassword, "未找到密码输入框") return "", fmt.Errorf("未找到密码输入框") } passwordInput.MustSelectAllText().MustInput(password) + logStep(StepInputPassword, "已输入密码") time.Sleep(200 * time.Millisecond) + logStep(StepSubmitPassword, "正在提交密码...") if btn, _ := page.Timeout(2 * time.Second).Element("button[type='submit']"); btn != nil { btn.MustClick() } + logStep(StepWaitCallback, "等待授权回调...") for i := 0; i < 66; i++ { time.Sleep(300 * time.Millisecond) if code := r.checkForCode(page); code != "" { + logStep(StepComplete, "授权完成") return code, nil } @@ -245,12 +276,14 @@ func (r *RodAuth) CompleteOAuth(authURL, email, password, teamID string) (string currentURL := info.URL if strings.Contains(currentURL, "consent") { + logStep(StepConsent, "正在处理授权同意页面...") if btn, _ := page.Timeout(500 * time.Millisecond).Element("button[type='submit']"); btn != nil { btn.Click(proto.InputMouseButtonLeft, 1) } } if strings.Contains(currentURL, "authorize") && teamID != "" { + logStep(StepSelectWorkspace, "正在选择工作区: %s", teamID) wsSelector := fmt.Sprintf("[data-workspace-id='%s'], [data-account-id='%s']", teamID, teamID) if wsBtn, _ := page.Timeout(500 * time.Millisecond).Element(wsSelector); wsBtn != nil { wsBtn.Click(proto.InputMouseButtonLeft, 1) @@ -258,6 +291,7 @@ func (r *RodAuth) CompleteOAuth(authURL, email, password, teamID string) (string } } + logError(StepWaitCallback, "授权超时") return "", fmt.Errorf("授权超时") } @@ -275,13 +309,33 @@ func (r *RodAuth) checkForCode(page *rod.Page) string { // CompleteWithRod 使用 Rod + Stealth 完成 S2A 授权 func CompleteWithRod(authURL, email, password, teamID string, headless bool, proxy string) (string, error) { + return CompleteWithRodLogged(authURL, email, password, teamID, headless, proxy, nil) +} + +// CompleteWithRodLogged 使用 Rod + Stealth 完成 S2A 授权(带日志回调) +func CompleteWithRodLogged(authURL, email, password, teamID string, headless bool, proxy string, logger *AuthLogger) (string, error) { + // 日志辅助函数 + logStep := func(step AuthStep, format string, args ...interface{}) { + if logger != nil { + logger.LogStep(step, format, args...) + } + } + logError := func(step AuthStep, format string, args ...interface{}) { + if logger != nil { + logger.LogError(step, format, args...) + } + } + + logStep(StepBrowserStart, "正在启动 Rod 浏览器...") auth, err := NewRodAuth(headless, proxy) if err != nil { + logError(StepBrowserStart, "启动失败: %v", err) return "", err } defer auth.Close() - return auth.CompleteOAuth(authURL, email, password, teamID) + logStep(StepBrowserStart, "浏览器启动成功") + return auth.CompleteOAuthLogged(authURL, email, password, teamID, logger) } // CompleteWithBrowser 使用 Rod 完成 S2A 授权 (别名) diff --git a/frontend/src/pages/Upload.tsx b/frontend/src/pages/Upload.tsx index 1474835..3aa6d07 100644 --- a/frontend/src/pages/Upload.tsx +++ b/frontend/src/pages/Upload.tsx @@ -69,6 +69,7 @@ export default function Upload() { // 配置 const [membersPerTeam, setMembersPerTeam] = useState(4) const [concurrentTeams, setConcurrentTeams] = useState(2) + const [concurrentS2A, setConcurrentS2A] = useState(2) // 入库并发数 const [browserType, setBrowserType] = useState<'chromedp' | 'rod'>('chromedp') const [useProxy, setUseProxy] = useState(false) // 是否使用全局代理 const [includeOwner, setIncludeOwner] = useState(false) // 母号也入库 @@ -192,6 +193,7 @@ export default function Upload() { body: JSON.stringify({ members_per_team: membersPerTeam, concurrent_teams: Math.min(concurrentTeams, stats?.valid || 1), + concurrent_s2a: concurrentS2A, // 入库并发数 browser_type: browserType, headless: true, // 始终使用无头模式 proxy: useProxy ? globalProxy : '', @@ -212,7 +214,7 @@ export default function Upload() { alert('启动失败') } setLoading(false) - }, [stats, membersPerTeam, concurrentTeams, browserType, useProxy, globalProxy, includeOwner, processCount, fetchStatus]) + }, [stats, membersPerTeam, concurrentTeams, concurrentS2A, browserType, useProxy, globalProxy, includeOwner, processCount, fetchStatus]) // 停止处理 const handleStop = useCallback(async () => { @@ -422,9 +424,9 @@ export default function Upload() {
-