From 89905ec43d45b408cf3a7ef73dc99da7e199cf68 Mon Sep 17 00:00:00 2001 From: Edric Li Date: Mon, 9 Feb 2026 22:22:19 +0800 Subject: [PATCH 01/18] feat: failover and temp-unschedule on Google "Invalid project resource name" 400 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Google 后端间歇性返回 400 "Invalid project resource name" 错误, 此前该错误直接透传给客户端且不触发账号切换,导致请求失败。 - 在 Antigravity 和 Gemini 两个平台的所有转发路径中, 精确匹配该错误消息后触发 failover 自动换号重试 - 命中后将账号临时封禁 1 小时,避免反复调度到同一故障账号 - 提取共享函数 isGoogleProjectConfigError / tempUnscheduleGoogleConfigError 消除跨 Service 的代码重复 --- .../service/antigravity_gateway_service.go | 62 +++++++++++++++++++ .../service/gemini_messages_compat_service.go | 61 ++++++++++++++++++ 2 files changed, 123 insertions(+) diff --git a/backend/internal/service/antigravity_gateway_service.go b/backend/internal/service/antigravity_gateway_service.go index 81a1c149..71dee705 100644 --- a/backend/internal/service/antigravity_gateway_service.go +++ b/backend/internal/service/antigravity_gateway_service.go @@ -1285,6 +1285,28 @@ func (s *AntigravityGatewayService) Forward(ctx context.Context, c *gin.Context, s.handleUpstreamError(ctx, prefix, account, resp.StatusCode, resp.Header, respBody, originalModel, 0, "", isStickySession) + // 精确匹配服务端配置类 400 错误,触发 failover + 临时封禁 + if resp.StatusCode == http.StatusBadRequest { + msg := strings.ToLower(strings.TrimSpace(extractAntigravityErrorMessage(respBody))) + if isGoogleProjectConfigError(msg) { + upstreamMsg := sanitizeUpstreamErrorMessage(strings.TrimSpace(extractAntigravityErrorMessage(respBody))) + upstreamDetail := s.getUpstreamErrorDetail(respBody) + log.Printf("%s status=400 google_config_error failover=true upstream_message=%q account=%d", prefix, upstreamMsg, account.ID) + appendOpsUpstreamError(c, OpsUpstreamErrorEvent{ + Platform: account.Platform, + AccountID: account.ID, + AccountName: account.Name, + UpstreamStatusCode: resp.StatusCode, + UpstreamRequestID: resp.Header.Get("x-request-id"), + Kind: "failover", + Message: upstreamMsg, + Detail: upstreamDetail, + }) + tempUnscheduleGoogleConfigError(ctx, s.accountRepo, account.ID, prefix) + return nil, &UpstreamFailoverError{StatusCode: resp.StatusCode, ResponseBody: respBody} + } + } + if s.shouldFailoverUpstreamError(resp.StatusCode) { upstreamMsg := strings.TrimSpace(extractAntigravityErrorMessage(respBody)) upstreamMsg = sanitizeUpstreamErrorMessage(upstreamMsg) @@ -1825,6 +1847,23 @@ func (s *AntigravityGatewayService) ForwardGemini(ctx context.Context, c *gin.Co // Always record upstream context for Ops error logs, even when we will failover. setOpsUpstreamError(c, resp.StatusCode, upstreamMsg, upstreamDetail) + // 精确匹配服务端配置类 400 错误,触发 failover + 临时封禁 + if resp.StatusCode == http.StatusBadRequest && isGoogleProjectConfigError(strings.ToLower(upstreamMsg)) { + log.Printf("%s status=400 google_config_error failover=true upstream_message=%q account=%d", prefix, upstreamMsg, account.ID) + appendOpsUpstreamError(c, OpsUpstreamErrorEvent{ + Platform: account.Platform, + AccountID: account.ID, + AccountName: account.Name, + UpstreamStatusCode: resp.StatusCode, + UpstreamRequestID: requestID, + Kind: "failover", + Message: upstreamMsg, + Detail: upstreamDetail, + }) + tempUnscheduleGoogleConfigError(ctx, s.accountRepo, account.ID, prefix) + return nil, &UpstreamFailoverError{StatusCode: resp.StatusCode, ResponseBody: unwrappedForOps} + } + if s.shouldFailoverUpstreamError(resp.StatusCode) { appendOpsUpstreamError(c, OpsUpstreamErrorEvent{ Platform: account.Platform, @@ -1920,6 +1959,29 @@ func (s *AntigravityGatewayService) shouldFailoverUpstreamError(statusCode int) } } +// isGoogleProjectConfigError 判断(已提取的小写)错误消息是否属于 Google 服务端配置类问题。 +// 只精确匹配已知的服务端侧错误,避免对客户端请求错误做无意义重试。 +// 适用于所有走 Google 后端的平台(Antigravity、Gemini)。 +func isGoogleProjectConfigError(lowerMsg string) bool { + // Google 间歇性 Bug:Project ID 有效但被临时识别失败 + return strings.Contains(lowerMsg, "invalid project resource name") +} + +// googleConfigErrorCooldown 服务端配置类 400 错误的临时封禁时长 +const googleConfigErrorCooldown = 60 * time.Minute + +// tempUnscheduleGoogleConfigError 对服务端配置类 400 错误触发临时封禁, +// 避免短时间内反复调度到同一个有问题的账号。 +func tempUnscheduleGoogleConfigError(ctx context.Context, repo AccountRepository, accountID int64, logPrefix string) { + until := time.Now().Add(googleConfigErrorCooldown) + reason := "400: invalid project resource name (auto temp-unschedule 1h)" + if err := repo.SetTempUnschedulable(ctx, accountID, until, reason); err != nil { + log.Printf("%s temp_unschedule_failed account=%d error=%v", logPrefix, accountID, err) + } else { + log.Printf("%s temp_unscheduled account=%d until=%v reason=%q", logPrefix, accountID, until.Format("15:04:05"), reason) + } +} + // sleepAntigravityBackoffWithContext 带 context 取消检查的退避等待 // 返回 true 表示正常完成等待,false 表示 context 已取消 func sleepAntigravityBackoffWithContext(ctx context.Context, attempt int) bool { diff --git a/backend/internal/service/gemini_messages_compat_service.go b/backend/internal/service/gemini_messages_compat_service.go index 792c8f4b..1e59c5fd 100644 --- a/backend/internal/service/gemini_messages_compat_service.go +++ b/backend/internal/service/gemini_messages_compat_service.go @@ -880,6 +880,38 @@ func (s *GeminiMessagesCompatService) Forward(ctx context.Context, c *gin.Contex // ErrorPolicyNone → 原有逻辑 s.handleGeminiUpstreamError(ctx, account, resp.StatusCode, resp.Header, respBody) + // 精确匹配服务端配置类 400 错误,触发 failover + 临时封禁 + if resp.StatusCode == http.StatusBadRequest { + msg400 := strings.ToLower(strings.TrimSpace(extractUpstreamErrorMessage(respBody))) + if isGoogleProjectConfigError(msg400) { + upstreamReqID := resp.Header.Get(requestIDHeader) + if upstreamReqID == "" { + upstreamReqID = resp.Header.Get("x-goog-request-id") + } + upstreamMsg := sanitizeUpstreamErrorMessage(strings.TrimSpace(extractUpstreamErrorMessage(respBody))) + upstreamDetail := "" + if s.cfg != nil && s.cfg.Gateway.LogUpstreamErrorBody { + maxBytes := s.cfg.Gateway.LogUpstreamErrorBodyMaxBytes + if maxBytes <= 0 { + maxBytes = 2048 + } + upstreamDetail = truncateString(string(respBody), maxBytes) + } + log.Printf("[Gemini] status=400 google_config_error failover=true upstream_message=%q account=%d", upstreamMsg, account.ID) + appendOpsUpstreamError(c, OpsUpstreamErrorEvent{ + Platform: account.Platform, + AccountID: account.ID, + AccountName: account.Name, + UpstreamStatusCode: resp.StatusCode, + UpstreamRequestID: upstreamReqID, + Kind: "failover", + Message: upstreamMsg, + Detail: upstreamDetail, + }) + tempUnscheduleGoogleConfigError(ctx, s.accountRepo, account.ID, "[Gemini]") + return nil, &UpstreamFailoverError{StatusCode: resp.StatusCode, ResponseBody: respBody} + } + } if s.shouldFailoverGeminiUpstreamError(resp.StatusCode) { upstreamReqID := resp.Header.Get(requestIDHeader) if upstreamReqID == "" { @@ -1330,6 +1362,35 @@ func (s *GeminiMessagesCompatService) ForwardNative(ctx context.Context, c *gin. // ErrorPolicyNone → 原有逻辑 s.handleGeminiUpstreamError(ctx, account, resp.StatusCode, resp.Header, respBody) + // 精确匹配服务端配置类 400 错误,触发 failover + 临时封禁 + if resp.StatusCode == http.StatusBadRequest { + msg400 := strings.ToLower(strings.TrimSpace(extractUpstreamErrorMessage(respBody))) + if isGoogleProjectConfigError(msg400) { + evBody := unwrapIfNeeded(isOAuth, respBody) + upstreamMsg := sanitizeUpstreamErrorMessage(strings.TrimSpace(extractUpstreamErrorMessage(evBody))) + upstreamDetail := "" + if s.cfg != nil && s.cfg.Gateway.LogUpstreamErrorBody { + maxBytes := s.cfg.Gateway.LogUpstreamErrorBodyMaxBytes + if maxBytes <= 0 { + maxBytes = 2048 + } + upstreamDetail = truncateString(string(evBody), maxBytes) + } + log.Printf("[Gemini] status=400 google_config_error failover=true upstream_message=%q account=%d", upstreamMsg, account.ID) + appendOpsUpstreamError(c, OpsUpstreamErrorEvent{ + Platform: account.Platform, + AccountID: account.ID, + AccountName: account.Name, + UpstreamStatusCode: resp.StatusCode, + UpstreamRequestID: requestID, + Kind: "failover", + Message: upstreamMsg, + Detail: upstreamDetail, + }) + tempUnscheduleGoogleConfigError(ctx, s.accountRepo, account.ID, "[Gemini]") + return nil, &UpstreamFailoverError{StatusCode: resp.StatusCode, ResponseBody: evBody} + } + } if s.shouldFailoverGeminiUpstreamError(resp.StatusCode) { evBody := unwrapIfNeeded(isOAuth, respBody) upstreamMsg := strings.TrimSpace(extractUpstreamErrorMessage(evBody)) From 61c73287dc4e4b551c593a84223ddb9df605cb0f Mon Sep 17 00:00:00 2001 From: Edric Li Date: Mon, 9 Feb 2026 23:25:30 +0800 Subject: [PATCH 02/18] feat: failover and temp-unschedule on empty stream response - Empty stream responses now return UpstreamFailoverError instead of plain 502, triggering automatic account switching (up to 10 retries) - Add tempUnscheduleEmptyResponse: accounts returning empty responses are temp-unscheduled for 30 minutes - Apply to both Claude and Gemini non-streaming paths - Align googleConfigErrorCooldown from 60m to 30m for consistency --- .../service/antigravity_gateway_service.go | 44 ++++++++++++++++--- 1 file changed, 37 insertions(+), 7 deletions(-) diff --git a/backend/internal/service/antigravity_gateway_service.go b/backend/internal/service/antigravity_gateway_service.go index 71dee705..a5fd1535 100644 --- a/backend/internal/service/antigravity_gateway_service.go +++ b/backend/internal/service/antigravity_gateway_service.go @@ -1351,6 +1351,10 @@ func (s *AntigravityGatewayService) Forward(ctx context.Context, c *gin.Context, streamRes, err := s.handleClaudeStreamToNonStreaming(c, resp, startTime, originalModel) if err != nil { log.Printf("%s status=stream_collect_error error=%v", prefix, err) + var failoverErr *UpstreamFailoverError + if errors.As(err, &failoverErr) && failoverErr.StatusCode == http.StatusBadGateway { + tempUnscheduleEmptyResponse(ctx, s.accountRepo, account.ID, prefix) + } return nil, err } usage = streamRes.usage @@ -1920,6 +1924,10 @@ handleSuccess: streamRes, err := s.handleGeminiStreamToNonStreaming(c, resp, startTime) if err != nil { log.Printf("%s status=stream_collect_error error=%v", prefix, err) + var failoverErr *UpstreamFailoverError + if errors.As(err, &failoverErr) && failoverErr.StatusCode == http.StatusBadGateway { + tempUnscheduleEmptyResponse(ctx, s.accountRepo, account.ID, prefix) + } return nil, err } usage = streamRes.usage @@ -1968,13 +1976,28 @@ func isGoogleProjectConfigError(lowerMsg string) bool { } // googleConfigErrorCooldown 服务端配置类 400 错误的临时封禁时长 -const googleConfigErrorCooldown = 60 * time.Minute +const googleConfigErrorCooldown = 30 * time.Minute // tempUnscheduleGoogleConfigError 对服务端配置类 400 错误触发临时封禁, // 避免短时间内反复调度到同一个有问题的账号。 func tempUnscheduleGoogleConfigError(ctx context.Context, repo AccountRepository, accountID int64, logPrefix string) { until := time.Now().Add(googleConfigErrorCooldown) - reason := "400: invalid project resource name (auto temp-unschedule 1h)" + reason := "400: invalid project resource name (auto temp-unschedule 30m)" + if err := repo.SetTempUnschedulable(ctx, accountID, until, reason); err != nil { + log.Printf("%s temp_unschedule_failed account=%d error=%v", logPrefix, accountID, err) + } else { + log.Printf("%s temp_unscheduled account=%d until=%v reason=%q", logPrefix, accountID, until.Format("15:04:05"), reason) + } +} + +// emptyResponseCooldown 空流式响应的临时封禁时长 +const emptyResponseCooldown = 30 * time.Minute + +// tempUnscheduleEmptyResponse 对空流式响应触发临时封禁, +// 避免短时间内反复调度到同一个返回空响应的账号。 +func tempUnscheduleEmptyResponse(ctx context.Context, repo AccountRepository, accountID int64, logPrefix string) { + until := time.Now().Add(emptyResponseCooldown) + reason := "empty stream response (auto temp-unschedule 30m)" if err := repo.SetTempUnschedulable(ctx, accountID, until, reason); err != nil { log.Printf("%s temp_unschedule_failed account=%d error=%v", logPrefix, accountID, err) } else { @@ -2786,9 +2809,13 @@ returnResponse: // 选择最后一个有效响应 finalResponse := pickGeminiCollectResult(last, lastWithParts) - // 处理空响应情况 + // 处理空响应情况 — 触发 failover 切换账号重试 if last == nil && lastWithParts == nil { - log.Printf("[antigravity-Forward] warning: empty stream response, no valid chunks received") + log.Printf("[antigravity-Forward] warning: empty stream response (gemini non-stream), triggering failover") + return nil, &UpstreamFailoverError{ + StatusCode: http.StatusBadGateway, + ResponseBody: []byte(`{"error":"empty stream response from upstream"}`), + } } // 如果收集到了图片 parts,需要合并到最终响应中 @@ -3201,10 +3228,13 @@ returnResponse: // 选择最后一个有效响应 finalResponse := pickGeminiCollectResult(last, lastWithParts) - // 处理空响应情况 + // 处理空响应情况 — 触发 failover 切换账号重试 if last == nil && lastWithParts == nil { - log.Printf("[antigravity-Forward] warning: empty stream response, no valid chunks received") - return nil, s.writeClaudeError(c, http.StatusBadGateway, "upstream_error", "Empty response from upstream") + log.Printf("[antigravity-Forward] warning: empty stream response (claude non-stream), triggering failover") + return nil, &UpstreamFailoverError{ + StatusCode: http.StatusBadGateway, + ResponseBody: []byte(`{"error":"empty stream response from upstream"}`), + } } // 将收集的所有 parts 合并到最终响应中 From d6c2921f2ba02c4886650620e2b469311779576d Mon Sep 17 00:00:00 2001 From: Edric Li Date: Tue, 10 Feb 2026 00:53:54 +0800 Subject: [PATCH 03/18] feat: same-account retry before failover for transient errors For retryable transient errors (Google 400 "invalid project resource name" and empty stream responses), retry on the same account up to 2 times (with 500ms delay) before switching to another account. - Add RetryableOnSameAccount field to UpstreamFailoverError - Add same-account retry loop in both Gemini and Claude/OpenAI handler paths - Move temp-unschedule from service layer to handler layer (only after all same-account retries exhausted) - Reduce temp-unschedule cooldown from 30 minutes to 1 minute --- backend/internal/handler/gateway_handler.go | 57 ++++++++++++++++++- .../service/antigravity_gateway_service.go | 40 ++++++------- backend/internal/service/gateway_service.go | 21 ++++++- .../service/gemini_messages_compat_service.go | 6 +- 4 files changed, 91 insertions(+), 33 deletions(-) diff --git a/backend/internal/handler/gateway_handler.go b/backend/internal/handler/gateway_handler.go index 6900fa55..b5fb379e 100644 --- a/backend/internal/handler/gateway_handler.go +++ b/backend/internal/handler/gateway_handler.go @@ -235,6 +235,7 @@ func (h *GatewayHandler) Messages(c *gin.Context) { maxAccountSwitches := h.maxAccountSwitchesGemini switchCount := 0 failedAccountIDs := make(map[int64]struct{}) + sameAccountRetryCount := make(map[int64]int) // 同账号重试计数 var lastFailoverErr *service.UpstreamFailoverError var forceCacheBilling bool // 粘性会话切换时的缓存计费标记 @@ -339,11 +340,28 @@ func (h *GatewayHandler) Messages(c *gin.Context) { if err != nil { var failoverErr *service.UpstreamFailoverError if errors.As(err, &failoverErr) { - failedAccountIDs[account.ID] = struct{}{} lastFailoverErr = failoverErr if needForceCacheBilling(hasBoundSession, failoverErr) { forceCacheBilling = true } + + // 同账号重试:对 RetryableOnSameAccount 的临时性错误,先在同一账号上重试 + if failoverErr.RetryableOnSameAccount && sameAccountRetryCount[account.ID] < maxSameAccountRetries { + sameAccountRetryCount[account.ID]++ + log.Printf("Account %d: retryable error %d, same-account retry %d/%d", + account.ID, failoverErr.StatusCode, sameAccountRetryCount[account.ID], maxSameAccountRetries) + if !sleepSameAccountRetryDelay(c.Request.Context()) { + return + } + continue + } + + // 同账号重试用尽,执行临时封禁并切换账号 + if failoverErr.RetryableOnSameAccount { + h.gatewayService.TempUnscheduleRetryableError(c.Request.Context(), account.ID, failoverErr) + } + + failedAccountIDs[account.ID] = struct{}{} if switchCount >= maxAccountSwitches { h.handleFailoverExhausted(c, failoverErr, service.PlatformGemini, streamStarted) return @@ -400,6 +418,7 @@ func (h *GatewayHandler) Messages(c *gin.Context) { maxAccountSwitches := h.maxAccountSwitches switchCount := 0 failedAccountIDs := make(map[int64]struct{}) + sameAccountRetryCount := make(map[int64]int) // 同账号重试计数 var lastFailoverErr *service.UpstreamFailoverError retryWithFallback := false var forceCacheBilling bool // 粘性会话切换时的缓存计费标记 @@ -539,11 +558,28 @@ func (h *GatewayHandler) Messages(c *gin.Context) { } var failoverErr *service.UpstreamFailoverError if errors.As(err, &failoverErr) { - failedAccountIDs[account.ID] = struct{}{} lastFailoverErr = failoverErr if needForceCacheBilling(hasBoundSession, failoverErr) { forceCacheBilling = true } + + // 同账号重试:对 RetryableOnSameAccount 的临时性错误,先在同一账号上重试 + if failoverErr.RetryableOnSameAccount && sameAccountRetryCount[account.ID] < maxSameAccountRetries { + sameAccountRetryCount[account.ID]++ + log.Printf("Account %d: retryable error %d, same-account retry %d/%d", + account.ID, failoverErr.StatusCode, sameAccountRetryCount[account.ID], maxSameAccountRetries) + if !sleepSameAccountRetryDelay(c.Request.Context()) { + return + } + continue + } + + // 同账号重试用尽,执行临时封禁并切换账号 + if failoverErr.RetryableOnSameAccount { + h.gatewayService.TempUnscheduleRetryableError(c.Request.Context(), account.ID, failoverErr) + } + + failedAccountIDs[account.ID] = struct{}{} if switchCount >= maxAccountSwitches { h.handleFailoverExhausted(c, failoverErr, account.Platform, streamStarted) return @@ -823,6 +859,23 @@ func needForceCacheBilling(hasBoundSession bool, failoverErr *service.UpstreamFa return hasBoundSession || (failoverErr != nil && failoverErr.ForceCacheBilling) } +const ( + // maxSameAccountRetries 同账号重试次数上限(针对 RetryableOnSameAccount 错误) + maxSameAccountRetries = 2 + // sameAccountRetryDelay 同账号重试间隔 + sameAccountRetryDelay = 500 * time.Millisecond +) + +// sleepSameAccountRetryDelay 同账号重试固定延时,返回 false 表示 context 已取消。 +func sleepSameAccountRetryDelay(ctx context.Context) bool { + select { + case <-ctx.Done(): + return false + case <-time.After(sameAccountRetryDelay): + return true + } +} + // sleepFailoverDelay 账号切换线性递增延时:第1次0s、第2次1s、第3次2s… // 返回 false 表示 context 已取消。 func sleepFailoverDelay(ctx context.Context, switchCount int) bool { diff --git a/backend/internal/service/antigravity_gateway_service.go b/backend/internal/service/antigravity_gateway_service.go index a5fd1535..9c2b9027 100644 --- a/backend/internal/service/antigravity_gateway_service.go +++ b/backend/internal/service/antigravity_gateway_service.go @@ -1285,7 +1285,7 @@ func (s *AntigravityGatewayService) Forward(ctx context.Context, c *gin.Context, s.handleUpstreamError(ctx, prefix, account, resp.StatusCode, resp.Header, respBody, originalModel, 0, "", isStickySession) - // 精确匹配服务端配置类 400 错误,触发 failover + 临时封禁 + // 精确匹配服务端配置类 400 错误,触发同账号重试 + failover if resp.StatusCode == http.StatusBadRequest { msg := strings.ToLower(strings.TrimSpace(extractAntigravityErrorMessage(respBody))) if isGoogleProjectConfigError(msg) { @@ -1302,8 +1302,7 @@ func (s *AntigravityGatewayService) Forward(ctx context.Context, c *gin.Context, Message: upstreamMsg, Detail: upstreamDetail, }) - tempUnscheduleGoogleConfigError(ctx, s.accountRepo, account.ID, prefix) - return nil, &UpstreamFailoverError{StatusCode: resp.StatusCode, ResponseBody: respBody} + return nil, &UpstreamFailoverError{StatusCode: resp.StatusCode, ResponseBody: respBody, RetryableOnSameAccount: true} } } @@ -1351,10 +1350,6 @@ func (s *AntigravityGatewayService) Forward(ctx context.Context, c *gin.Context, streamRes, err := s.handleClaudeStreamToNonStreaming(c, resp, startTime, originalModel) if err != nil { log.Printf("%s status=stream_collect_error error=%v", prefix, err) - var failoverErr *UpstreamFailoverError - if errors.As(err, &failoverErr) && failoverErr.StatusCode == http.StatusBadGateway { - tempUnscheduleEmptyResponse(ctx, s.accountRepo, account.ID, prefix) - } return nil, err } usage = streamRes.usage @@ -1851,7 +1846,7 @@ func (s *AntigravityGatewayService) ForwardGemini(ctx context.Context, c *gin.Co // Always record upstream context for Ops error logs, even when we will failover. setOpsUpstreamError(c, resp.StatusCode, upstreamMsg, upstreamDetail) - // 精确匹配服务端配置类 400 错误,触发 failover + 临时封禁 + // 精确匹配服务端配置类 400 错误,触发同账号重试 + failover if resp.StatusCode == http.StatusBadRequest && isGoogleProjectConfigError(strings.ToLower(upstreamMsg)) { log.Printf("%s status=400 google_config_error failover=true upstream_message=%q account=%d", prefix, upstreamMsg, account.ID) appendOpsUpstreamError(c, OpsUpstreamErrorEvent{ @@ -1864,8 +1859,7 @@ func (s *AntigravityGatewayService) ForwardGemini(ctx context.Context, c *gin.Co Message: upstreamMsg, Detail: upstreamDetail, }) - tempUnscheduleGoogleConfigError(ctx, s.accountRepo, account.ID, prefix) - return nil, &UpstreamFailoverError{StatusCode: resp.StatusCode, ResponseBody: unwrappedForOps} + return nil, &UpstreamFailoverError{StatusCode: resp.StatusCode, ResponseBody: unwrappedForOps, RetryableOnSameAccount: true} } if s.shouldFailoverUpstreamError(resp.StatusCode) { @@ -1924,10 +1918,6 @@ handleSuccess: streamRes, err := s.handleGeminiStreamToNonStreaming(c, resp, startTime) if err != nil { log.Printf("%s status=stream_collect_error error=%v", prefix, err) - var failoverErr *UpstreamFailoverError - if errors.As(err, &failoverErr) && failoverErr.StatusCode == http.StatusBadGateway { - tempUnscheduleEmptyResponse(ctx, s.accountRepo, account.ID, prefix) - } return nil, err } usage = streamRes.usage @@ -1976,13 +1966,13 @@ func isGoogleProjectConfigError(lowerMsg string) bool { } // googleConfigErrorCooldown 服务端配置类 400 错误的临时封禁时长 -const googleConfigErrorCooldown = 30 * time.Minute +const googleConfigErrorCooldown = 1 * time.Minute // tempUnscheduleGoogleConfigError 对服务端配置类 400 错误触发临时封禁, // 避免短时间内反复调度到同一个有问题的账号。 func tempUnscheduleGoogleConfigError(ctx context.Context, repo AccountRepository, accountID int64, logPrefix string) { until := time.Now().Add(googleConfigErrorCooldown) - reason := "400: invalid project resource name (auto temp-unschedule 30m)" + reason := "400: invalid project resource name (auto temp-unschedule 1m)" if err := repo.SetTempUnschedulable(ctx, accountID, until, reason); err != nil { log.Printf("%s temp_unschedule_failed account=%d error=%v", logPrefix, accountID, err) } else { @@ -1991,13 +1981,13 @@ func tempUnscheduleGoogleConfigError(ctx context.Context, repo AccountRepository } // emptyResponseCooldown 空流式响应的临时封禁时长 -const emptyResponseCooldown = 30 * time.Minute +const emptyResponseCooldown = 1 * time.Minute // tempUnscheduleEmptyResponse 对空流式响应触发临时封禁, // 避免短时间内反复调度到同一个返回空响应的账号。 func tempUnscheduleEmptyResponse(ctx context.Context, repo AccountRepository, accountID int64, logPrefix string) { until := time.Now().Add(emptyResponseCooldown) - reason := "empty stream response (auto temp-unschedule 30m)" + reason := "empty stream response (auto temp-unschedule 1m)" if err := repo.SetTempUnschedulable(ctx, accountID, until, reason); err != nil { log.Printf("%s temp_unschedule_failed account=%d error=%v", logPrefix, accountID, err) } else { @@ -2809,12 +2799,13 @@ returnResponse: // 选择最后一个有效响应 finalResponse := pickGeminiCollectResult(last, lastWithParts) - // 处理空响应情况 — 触发 failover 切换账号重试 + // 处理空响应情况 — 触发同账号重试 + failover 切换账号 if last == nil && lastWithParts == nil { log.Printf("[antigravity-Forward] warning: empty stream response (gemini non-stream), triggering failover") return nil, &UpstreamFailoverError{ - StatusCode: http.StatusBadGateway, - ResponseBody: []byte(`{"error":"empty stream response from upstream"}`), + StatusCode: http.StatusBadGateway, + ResponseBody: []byte(`{"error":"empty stream response from upstream"}`), + RetryableOnSameAccount: true, } } @@ -3228,12 +3219,13 @@ returnResponse: // 选择最后一个有效响应 finalResponse := pickGeminiCollectResult(last, lastWithParts) - // 处理空响应情况 — 触发 failover 切换账号重试 + // 处理空响应情况 — 触发同账号重试 + failover 切换账号 if last == nil && lastWithParts == nil { log.Printf("[antigravity-Forward] warning: empty stream response (claude non-stream), triggering failover") return nil, &UpstreamFailoverError{ - StatusCode: http.StatusBadGateway, - ResponseBody: []byte(`{"error":"empty stream response from upstream"}`), + StatusCode: http.StatusBadGateway, + ResponseBody: []byte(`{"error":"empty stream response from upstream"}`), + RetryableOnSameAccount: true, } } diff --git a/backend/internal/service/gateway_service.go b/backend/internal/service/gateway_service.go index 4e723232..01e1acb4 100644 --- a/backend/internal/service/gateway_service.go +++ b/backend/internal/service/gateway_service.go @@ -362,15 +362,30 @@ type ForwardResult struct { // UpstreamFailoverError indicates an upstream error that should trigger account failover. type UpstreamFailoverError struct { - StatusCode int - ResponseBody []byte // 上游响应体,用于错误透传规则匹配 - ForceCacheBilling bool // Antigravity 粘性会话切换时设为 true + StatusCode int + ResponseBody []byte // 上游响应体,用于错误透传规则匹配 + ForceCacheBilling bool // Antigravity 粘性会话切换时设为 true + RetryableOnSameAccount bool // 临时性错误(如 Google 间歇性 400、空响应),应在同一账号上重试 N 次再切换 } func (e *UpstreamFailoverError) Error() string { return fmt.Sprintf("upstream error: %d (failover)", e.StatusCode) } +// TempUnscheduleRetryableError 对 RetryableOnSameAccount 类型的 failover 错误触发临时封禁。 +// 由 handler 层在同账号重试全部用尽、切换账号时调用。 +func (s *GatewayService) TempUnscheduleRetryableError(ctx context.Context, accountID int64, failoverErr *UpstreamFailoverError) { + if failoverErr == nil || !failoverErr.RetryableOnSameAccount { + return + } + // 根据状态码选择封禁策略 + if failoverErr.StatusCode == http.StatusBadRequest { + tempUnscheduleGoogleConfigError(ctx, s.accountRepo, accountID, "[handler]") + } else if failoverErr.StatusCode == http.StatusBadGateway { + tempUnscheduleEmptyResponse(ctx, s.accountRepo, accountID, "[handler]") + } +} + // GatewayService handles API gateway operations type GatewayService struct { accountRepo AccountRepository diff --git a/backend/internal/service/gemini_messages_compat_service.go b/backend/internal/service/gemini_messages_compat_service.go index 1e59c5fd..7fa375ca 100644 --- a/backend/internal/service/gemini_messages_compat_service.go +++ b/backend/internal/service/gemini_messages_compat_service.go @@ -908,8 +908,7 @@ func (s *GeminiMessagesCompatService) Forward(ctx context.Context, c *gin.Contex Message: upstreamMsg, Detail: upstreamDetail, }) - tempUnscheduleGoogleConfigError(ctx, s.accountRepo, account.ID, "[Gemini]") - return nil, &UpstreamFailoverError{StatusCode: resp.StatusCode, ResponseBody: respBody} + return nil, &UpstreamFailoverError{StatusCode: resp.StatusCode, ResponseBody: respBody, RetryableOnSameAccount: true} } } if s.shouldFailoverGeminiUpstreamError(resp.StatusCode) { @@ -1387,8 +1386,7 @@ func (s *GeminiMessagesCompatService) ForwardNative(ctx context.Context, c *gin. Message: upstreamMsg, Detail: upstreamDetail, }) - tempUnscheduleGoogleConfigError(ctx, s.accountRepo, account.ID, "[Gemini]") - return nil, &UpstreamFailoverError{StatusCode: resp.StatusCode, ResponseBody: evBody} + return nil, &UpstreamFailoverError{StatusCode: resp.StatusCode, ResponseBody: evBody, RetryableOnSameAccount: true} } } if s.shouldFailoverGeminiUpstreamError(resp.StatusCode) { From 6114f69cca038fb7a897386fa2aa745f8dd96ab0 Mon Sep 17 00:00:00 2001 From: Edric Li Date: Tue, 10 Feb 2026 02:03:06 +0800 Subject: [PATCH 04/18] =?UTF-8?q?feat:=20MODEL=5FCAPACITY=5FEXHAUSTED=20?= =?UTF-8?q?=E4=BD=BF=E7=94=A8=E5=9B=BA=E5=AE=9A1s=E9=97=B4=E9=9A=94?= =?UTF-8?q?=E9=87=8D=E8=AF=9560=E6=AC=A1=EF=BC=8C=E4=B8=8D=E5=88=87?= =?UTF-8?q?=E6=8D=A2=E8=B4=A6=E5=8F=B7?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit MODEL_CAPACITY_EXHAUSTED (503) 表示模型容量不足,所有账号共享同一容量池, 切换账号无意义。改为固定1s间隔重试最多60次,重试耗尽后直接返回上游错误。 - 新增 antigravityModelCapacityRetryMaxAttempts=60 和 antigravityModelCapacityRetryWait=1s - shouldTriggerAntigravitySmartRetry 新增 isModelCapacityExhausted 返回值 - handleSmartRetry 对 MODEL_CAPACITY_EXHAUSTED 使用独立重试策略 - handleModelRateLimit 对 MODEL_CAPACITY_EXHAUSTED 仅标记 Handled,不设限流 - 重试耗尽后不设置模型限流、不清除粘性会话、不切换账号 --- .../service/antigravity_gateway_service.go | 113 +++++++++++++----- .../service/antigravity_rate_limit_test.go | 76 +++++++----- .../service/antigravity_smart_retry_test.go | 101 ++++++++++++---- 3 files changed, 207 insertions(+), 83 deletions(-) diff --git a/backend/internal/service/antigravity_gateway_service.go b/backend/internal/service/antigravity_gateway_service.go index 9c2b9027..54b6d383 100644 --- a/backend/internal/service/antigravity_gateway_service.go +++ b/backend/internal/service/antigravity_gateway_service.go @@ -39,6 +39,12 @@ const ( antigravitySmartRetryMaxAttempts = 1 // 智能重试最大次数(仅重试 1 次,防止重复限流/长期等待) antigravityDefaultRateLimitDuration = 30 * time.Second // 默认限流时间(无 retryDelay 时使用) + // MODEL_CAPACITY_EXHAUSTED 专用重试参数 + // 模型容量不足时,所有账号共享同一容量池,切换账号无意义 + // 使用固定 1s 间隔重试,最多重试 60 次 + antigravityModelCapacityRetryMaxAttempts = 60 + antigravityModelCapacityRetryWait = 1 * time.Second + // Google RPC 状态和类型常量 googleRPCStatusResourceExhausted = "RESOURCE_EXHAUSTED" googleRPCStatusUnavailable = "UNAVAILABLE" @@ -144,7 +150,7 @@ func (s *AntigravityGatewayService) handleSmartRetry(p antigravityRetryLoopParam } // 判断是否触发智能重试 - shouldSmartRetry, shouldRateLimitModel, waitDuration, modelName := shouldTriggerAntigravitySmartRetry(p.account, respBody) + shouldSmartRetry, shouldRateLimitModel, waitDuration, modelName, isModelCapacityExhausted := shouldTriggerAntigravitySmartRetry(p.account, respBody) // 情况1: retryDelay >= 阈值,限流模型并切换账号 if shouldRateLimitModel { @@ -174,14 +180,21 @@ func (s *AntigravityGatewayService) handleSmartRetry(p antigravityRetryLoopParam } } - // 情况2: retryDelay < 阈值,智能重试(最多 antigravitySmartRetryMaxAttempts 次) + // 情况2: retryDelay < 阈值(或 MODEL_CAPACITY_EXHAUSTED),智能重试 if shouldSmartRetry { var lastRetryResp *http.Response var lastRetryBody []byte - for attempt := 1; attempt <= antigravitySmartRetryMaxAttempts; attempt++ { + // MODEL_CAPACITY_EXHAUSTED 使用独立的重试参数(60 次,固定 1s 间隔) + maxAttempts := antigravitySmartRetryMaxAttempts + if isModelCapacityExhausted { + maxAttempts = antigravityModelCapacityRetryMaxAttempts + waitDuration = antigravityModelCapacityRetryWait + } + + for attempt := 1; attempt <= maxAttempts; attempt++ { log.Printf("%s status=%d oauth_smart_retry attempt=%d/%d delay=%v model=%s account=%d", - p.prefix, resp.StatusCode, attempt, antigravitySmartRetryMaxAttempts, waitDuration, modelName, p.account.ID) + p.prefix, resp.StatusCode, attempt, maxAttempts, waitDuration, modelName, p.account.ID) select { case <-p.ctx.Done(): @@ -207,13 +220,13 @@ func (s *AntigravityGatewayService) handleSmartRetry(p antigravityRetryLoopParam retryResp, retryErr := p.httpUpstream.Do(retryReq, p.proxyURL, p.account.ID, p.account.Concurrency) if retryErr == nil && retryResp != nil && retryResp.StatusCode != http.StatusTooManyRequests && retryResp.StatusCode != http.StatusServiceUnavailable { - log.Printf("%s status=%d smart_retry_success attempt=%d/%d", p.prefix, retryResp.StatusCode, attempt, antigravitySmartRetryMaxAttempts) + log.Printf("%s status=%d smart_retry_success attempt=%d/%d", p.prefix, retryResp.StatusCode, attempt, maxAttempts) return &smartRetryResult{action: smartRetryActionBreakWithResp, resp: retryResp} } // 网络错误时,继续重试 if retryErr != nil || retryResp == nil { - log.Printf("%s status=smart_retry_network_error attempt=%d/%d error=%v", p.prefix, attempt, antigravitySmartRetryMaxAttempts, retryErr) + log.Printf("%s status=smart_retry_network_error attempt=%d/%d error=%v", p.prefix, attempt, maxAttempts, retryErr) continue } @@ -227,26 +240,43 @@ func (s *AntigravityGatewayService) handleSmartRetry(p antigravityRetryLoopParam _ = retryResp.Body.Close() } - // 解析新的重试信息,用于下次重试的等待时间 - if attempt < antigravitySmartRetryMaxAttempts && lastRetryBody != nil { - newShouldRetry, _, newWaitDuration, _ := shouldTriggerAntigravitySmartRetry(p.account, lastRetryBody) + // 解析新的重试信息,用于下次重试的等待时间(MODEL_CAPACITY_EXHAUSTED 使用固定循环,跳过) + if !isModelCapacityExhausted && attempt < maxAttempts && lastRetryBody != nil { + newShouldRetry, _, newWaitDuration, _, _ := shouldTriggerAntigravitySmartRetry(p.account, lastRetryBody) if newShouldRetry && newWaitDuration > 0 { waitDuration = newWaitDuration } } } - // 所有重试都失败,限流当前模型并切换账号 - rateLimitDuration := waitDuration - if rateLimitDuration <= 0 { - rateLimitDuration = antigravityDefaultRateLimitDuration - } + // 所有重试都失败 retryBody := lastRetryBody if retryBody == nil { retryBody = respBody } + + // MODEL_CAPACITY_EXHAUSTED:模型容量不足,切换账号无意义 + // 直接返回上游错误响应,不设置模型限流,不切换账号 + if isModelCapacityExhausted { + log.Printf("%s status=%d smart_retry_exhausted_model_capacity attempts=%d model=%s account=%d body=%s (model capacity exhausted, not switching account)", + p.prefix, resp.StatusCode, maxAttempts, modelName, p.account.ID, truncateForLog(retryBody, 200)) + return &smartRetryResult{ + action: smartRetryActionBreakWithResp, + resp: &http.Response{ + StatusCode: resp.StatusCode, + Header: resp.Header.Clone(), + Body: io.NopCloser(bytes.NewReader(retryBody)), + }, + } + } + + // RATE_LIMIT_EXCEEDED:账号级限流,限流当前模型并切换账号 + rateLimitDuration := waitDuration + if rateLimitDuration <= 0 { + rateLimitDuration = antigravityDefaultRateLimitDuration + } log.Printf("%s status=%d smart_retry_exhausted attempts=%d model=%s account=%d upstream_retry_delay=%v body=%s (switch account)", - p.prefix, resp.StatusCode, antigravitySmartRetryMaxAttempts, modelName, p.account.ID, rateLimitDuration, truncateForLog(retryBody, 200)) + p.prefix, resp.StatusCode, maxAttempts, modelName, p.account.ID, rateLimitDuration, truncateForLog(retryBody, 200)) resetAt := time.Now().Add(rateLimitDuration) if p.accountRepo != nil && modelName != "" { @@ -2053,8 +2083,9 @@ func antigravityFallbackCooldownSeconds() (time.Duration, bool) { // antigravitySmartRetryInfo 智能重试所需的信息 type antigravitySmartRetryInfo struct { - RetryDelay time.Duration // 重试延迟时间 - ModelName string // 限流的模型名称(如 "claude-sonnet-4-5") + RetryDelay time.Duration // 重试延迟时间 + ModelName string // 限流的模型名称(如 "claude-sonnet-4-5") + IsModelCapacityExhausted bool // 是否为模型容量不足(MODEL_CAPACITY_EXHAUSTED) } // parseAntigravitySmartRetryInfo 解析 Google RPC RetryInfo 和 ErrorInfo 信息 @@ -2169,31 +2200,40 @@ func parseAntigravitySmartRetryInfo(body []byte) *antigravitySmartRetryInfo { } return &antigravitySmartRetryInfo{ - RetryDelay: retryDelay, - ModelName: modelName, + RetryDelay: retryDelay, + ModelName: modelName, + IsModelCapacityExhausted: hasModelCapacityExhausted, } } // shouldTriggerAntigravitySmartRetry 判断是否应该触发智能重试 // 返回: -// - shouldRetry: 是否应该智能重试(retryDelay < antigravityRateLimitThreshold) -// - shouldRateLimitModel: 是否应该限流模型(retryDelay >= antigravityRateLimitThreshold) -// - waitDuration: 等待时间(智能重试时使用,shouldRateLimitModel=true 时为 0) +// - shouldRetry: 是否应该智能重试(retryDelay < antigravityRateLimitThreshold,或 MODEL_CAPACITY_EXHAUSTED) +// - shouldRateLimitModel: 是否应该限流模型并切换账号(仅 RATE_LIMIT_EXCEEDED 且 retryDelay >= 阈值) +// - waitDuration: 等待时间 // - modelName: 限流的模型名称 -func shouldTriggerAntigravitySmartRetry(account *Account, respBody []byte) (shouldRetry bool, shouldRateLimitModel bool, waitDuration time.Duration, modelName string) { +// - isModelCapacityExhausted: 是否为模型容量不足(MODEL_CAPACITY_EXHAUSTED) +func shouldTriggerAntigravitySmartRetry(account *Account, respBody []byte) (shouldRetry bool, shouldRateLimitModel bool, waitDuration time.Duration, modelName string, isModelCapacityExhausted bool) { if account.Platform != PlatformAntigravity { - return false, false, 0, "" + return false, false, 0, "", false } info := parseAntigravitySmartRetryInfo(respBody) if info == nil { - return false, false, 0, "" + return false, false, 0, "", false } + // MODEL_CAPACITY_EXHAUSTED(模型容量不足):所有账号共享同一模型容量池 + // 切换账号无意义,使用固定 1s 间隔重试 + if info.IsModelCapacityExhausted { + return true, false, antigravityModelCapacityRetryWait, info.ModelName, true + } + + // RATE_LIMIT_EXCEEDED(账号级限流): // retryDelay >= 阈值:直接限流模型,不重试 // 注意:如果上游未提供 retryDelay,parseAntigravitySmartRetryInfo 已设置为默认 30s if info.RetryDelay >= antigravityRateLimitThreshold { - return false, true, info.RetryDelay, info.ModelName + return false, true, info.RetryDelay, info.ModelName, false } // retryDelay < 阈值:智能重试 @@ -2202,7 +2242,7 @@ func shouldTriggerAntigravitySmartRetry(account *Account, respBody []byte) (shou waitDuration = antigravitySmartRetryMinWait } - return true, false, waitDuration, info.ModelName + return true, false, waitDuration, info.ModelName, false } // handleModelRateLimitParams 模型级限流处理参数 @@ -2228,8 +2268,9 @@ type handleModelRateLimitResult struct { // handleModelRateLimit 处理模型级限流(在原有逻辑之前调用) // 仅处理 429/503,解析模型名和 retryDelay -// - retryDelay < antigravityRateLimitThreshold: 返回 ShouldRetry=true,由调用方等待后重试 -// - retryDelay >= antigravityRateLimitThreshold: 设置模型限流 + 清除粘性会话 + 返回 SwitchError +// - MODEL_CAPACITY_EXHAUSTED: 返回 Handled=true(实际重试由 handleSmartRetry 处理) +// - RATE_LIMIT_EXCEEDED + retryDelay < 阈值: 返回 ShouldRetry=true,由调用方等待后重试 +// - RATE_LIMIT_EXCEEDED + retryDelay >= 阈值: 设置模型限流 + 清除粘性会话 + 返回 SwitchError func (s *AntigravityGatewayService) handleModelRateLimit(p *handleModelRateLimitParams) *handleModelRateLimitResult { if p.statusCode != 429 && p.statusCode != 503 { return &handleModelRateLimitResult{Handled: false} @@ -2240,7 +2281,17 @@ func (s *AntigravityGatewayService) handleModelRateLimit(p *handleModelRateLimit return &handleModelRateLimitResult{Handled: false} } - // < antigravityRateLimitThreshold: 等待后重试 + // MODEL_CAPACITY_EXHAUSTED:模型容量不足,所有账号共享同一容量池 + // 切换账号无意义,不设置模型限流(实际重试由 handleSmartRetry 处理) + if info.IsModelCapacityExhausted { + log.Printf("%s status=%d model_capacity_exhausted model=%s (not switching account, retry handled by smart retry)", + p.prefix, p.statusCode, info.ModelName) + return &handleModelRateLimitResult{ + Handled: true, + } + } + + // RATE_LIMIT_EXCEEDED: < antigravityRateLimitThreshold: 等待后重试 if info.RetryDelay < antigravityRateLimitThreshold { log.Printf("%s status=%d model_rate_limit_wait model=%s wait=%v", p.prefix, p.statusCode, info.ModelName, info.RetryDelay) @@ -2251,7 +2302,7 @@ func (s *AntigravityGatewayService) handleModelRateLimit(p *handleModelRateLimit } } - // >= antigravityRateLimitThreshold: 设置限流 + 清除粘性会话 + 切换账号 + // RATE_LIMIT_EXCEEDED: >= antigravityRateLimitThreshold: 设置限流 + 清除粘性会话 + 切换账号 s.setModelRateLimitAndClearSession(p, info) return &handleModelRateLimitResult{ diff --git a/backend/internal/service/antigravity_rate_limit_test.go b/backend/internal/service/antigravity_rate_limit_test.go index 59cc9331..7175d578 100644 --- a/backend/internal/service/antigravity_rate_limit_test.go +++ b/backend/internal/service/antigravity_rate_limit_test.go @@ -188,13 +188,14 @@ func TestHandleUpstreamError_429_NonModelRateLimit(t *testing.T) { require.Equal(t, "claude-sonnet-4-5", repo.modelRateLimitCalls[0].modelKey) } -// TestHandleUpstreamError_503_ModelRateLimit 测试 503 模型限流场景 -func TestHandleUpstreamError_503_ModelRateLimit(t *testing.T) { +// TestHandleUpstreamError_503_ModelCapacityExhausted 测试 503 模型容量不足场景 +// MODEL_CAPACITY_EXHAUSTED 时应等待重试,不切换账号 +func TestHandleUpstreamError_503_ModelCapacityExhausted(t *testing.T) { repo := &stubAntigravityAccountRepo{} svc := &AntigravityGatewayService{accountRepo: repo} account := &Account{ID: 3, Name: "acc-3", Platform: PlatformAntigravity} - // 503 + MODEL_CAPACITY_EXHAUSTED → 模型限流 + // 503 + MODEL_CAPACITY_EXHAUSTED → 等待重试,不切换账号 body := []byte(`{ "error": { "status": "UNAVAILABLE", @@ -207,13 +208,13 @@ func TestHandleUpstreamError_503_ModelRateLimit(t *testing.T) { result := svc.handleUpstreamError(context.Background(), "[test]", account, http.StatusServiceUnavailable, http.Header{}, body, "gemini-3-pro-high", 0, "", false) - // 应该触发模型限流 + // MODEL_CAPACITY_EXHAUSTED 应该标记为已处理,不切换账号,不设置模型限流 + // 实际重试由 handleSmartRetry 处理 require.NotNil(t, result) require.True(t, result.Handled) - require.NotNil(t, result.SwitchError) - require.Equal(t, "gemini-3-pro-high", result.SwitchError.RateLimitedModel) - require.Len(t, repo.modelRateLimitCalls, 1) - require.Equal(t, "gemini-3-pro-high", repo.modelRateLimitCalls[0].modelKey) + require.False(t, result.ShouldRetry, "MODEL_CAPACITY_EXHAUSTED should not trigger retry from handleModelRateLimit path") + require.Nil(t, result.SwitchError, "MODEL_CAPACITY_EXHAUSTED should not trigger account switch") + require.Empty(t, repo.modelRateLimitCalls, "MODEL_CAPACITY_EXHAUSTED should not set model rate limit") } // TestHandleUpstreamError_503_NonModelRateLimit 测试 503 非模型限流场景(不处理) @@ -301,11 +302,12 @@ func TestParseGeminiRateLimitResetTime_QuotaResetDelay_RoundsUp(t *testing.T) { func TestParseAntigravitySmartRetryInfo(t *testing.T) { tests := []struct { - name string - body string - expectedDelay time.Duration - expectedModel string - expectedNil bool + name string + body string + expectedDelay time.Duration + expectedModel string + expectedNil bool + expectedIsModelCapacityExhausted bool }{ { name: "valid complete response with RATE_LIMIT_EXCEEDED", @@ -368,8 +370,9 @@ func TestParseAntigravitySmartRetryInfo(t *testing.T) { "message": "No capacity available for model gemini-3-pro-high on the server" } }`, - expectedDelay: 39 * time.Second, - expectedModel: "gemini-3-pro-high", + expectedDelay: 39 * time.Second, + expectedModel: "gemini-3-pro-high", + expectedIsModelCapacityExhausted: true, }, { name: "503 UNAVAILABLE without MODEL_CAPACITY_EXHAUSTED - should return nil", @@ -480,6 +483,9 @@ func TestParseAntigravitySmartRetryInfo(t *testing.T) { if result.ModelName != tt.expectedModel { t.Errorf("ModelName = %q, want %q", result.ModelName, tt.expectedModel) } + if result.IsModelCapacityExhausted != tt.expectedIsModelCapacityExhausted { + t.Errorf("IsModelCapacityExhausted = %v, want %v", result.IsModelCapacityExhausted, tt.expectedIsModelCapacityExhausted) + } }) } } @@ -491,13 +497,14 @@ func TestShouldTriggerAntigravitySmartRetry(t *testing.T) { apiKeyAccount := &Account{Type: AccountTypeAPIKey} tests := []struct { - name string - account *Account - body string - expectedShouldRetry bool - expectedShouldRateLimit bool - minWait time.Duration - modelName string + name string + account *Account + body string + expectedShouldRetry bool + expectedShouldRateLimit bool + expectedIsModelCapacityExhausted bool + minWait time.Duration + modelName string }{ { name: "OAuth account with short delay (< 7s) - smart retry", @@ -611,13 +618,14 @@ func TestShouldTriggerAntigravitySmartRetry(t *testing.T) { ] } }`, - expectedShouldRetry: false, - expectedShouldRateLimit: true, - minWait: 39 * time.Second, - modelName: "gemini-3-pro-high", + expectedShouldRetry: true, + expectedShouldRateLimit: false, + expectedIsModelCapacityExhausted: true, + minWait: 1 * time.Second, + modelName: "gemini-3-pro-high", }, { - name: "503 UNAVAILABLE with MODEL_CAPACITY_EXHAUSTED - no retryDelay - use default rate limit", + name: "503 UNAVAILABLE with MODEL_CAPACITY_EXHAUSTED - no retryDelay - use fixed wait", account: oauthAccount, body: `{ "error": { @@ -629,10 +637,11 @@ func TestShouldTriggerAntigravitySmartRetry(t *testing.T) { "message": "No capacity available for model gemini-2.5-flash on the server" } }`, - expectedShouldRetry: false, - expectedShouldRateLimit: true, - minWait: 30 * time.Second, - modelName: "gemini-2.5-flash", + expectedShouldRetry: true, + expectedShouldRateLimit: false, + expectedIsModelCapacityExhausted: true, + minWait: 1 * time.Second, + modelName: "gemini-2.5-flash", }, { name: "429 RESOURCE_EXHAUSTED with RATE_LIMIT_EXCEEDED - no retryDelay - use default rate limit", @@ -656,13 +665,16 @@ func TestShouldTriggerAntigravitySmartRetry(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - shouldRetry, shouldRateLimit, wait, model := shouldTriggerAntigravitySmartRetry(tt.account, []byte(tt.body)) + shouldRetry, shouldRateLimit, wait, model, isModelCapacityExhausted := shouldTriggerAntigravitySmartRetry(tt.account, []byte(tt.body)) if shouldRetry != tt.expectedShouldRetry { t.Errorf("shouldRetry = %v, want %v", shouldRetry, tt.expectedShouldRetry) } if shouldRateLimit != tt.expectedShouldRateLimit { t.Errorf("shouldRateLimit = %v, want %v", shouldRateLimit, tt.expectedShouldRateLimit) } + if isModelCapacityExhausted != tt.expectedIsModelCapacityExhausted { + t.Errorf("isModelCapacityExhausted = %v, want %v", isModelCapacityExhausted, tt.expectedIsModelCapacityExhausted) + } if shouldRetry { if wait < tt.minWait { t.Errorf("wait = %v, want >= %v", wait, tt.minWait) diff --git a/backend/internal/service/antigravity_smart_retry_test.go b/backend/internal/service/antigravity_smart_retry_test.go index a7e0d296..432c80e5 100644 --- a/backend/internal/service/antigravity_smart_retry_test.go +++ b/backend/internal/service/antigravity_smart_retry_test.go @@ -294,8 +294,9 @@ func TestHandleSmartRetry_ShortDelay_SmartRetryFailed_ReturnsSwitchError(t *test require.Len(t, upstream.calls, 1, "should have made one retry call (max attempts)") } -// TestHandleSmartRetry_503_ModelCapacityExhausted_ReturnsSwitchError 测试 503 MODEL_CAPACITY_EXHAUSTED 返回 switchError -func TestHandleSmartRetry_503_ModelCapacityExhausted_ReturnsSwitchError(t *testing.T) { +// TestHandleSmartRetry_503_ModelCapacityExhausted_RetrySuccess 测试 503 MODEL_CAPACITY_EXHAUSTED 重试成功 +// MODEL_CAPACITY_EXHAUSTED 使用固定 1s 间隔重试,不切换账号 +func TestHandleSmartRetry_503_ModelCapacityExhausted_RetrySuccess(t *testing.T) { repo := &stubAntigravityAccountRepo{} account := &Account{ ID: 3, @@ -304,7 +305,7 @@ func TestHandleSmartRetry_503_ModelCapacityExhausted_ReturnsSwitchError(t *testi Platform: PlatformAntigravity, } - // 503 + MODEL_CAPACITY_EXHAUSTED + 39s >= 7s 阈值 + // 503 + MODEL_CAPACITY_EXHAUSTED + 39s(上游 retryDelay 应被忽略,使用固定 1s) respBody := []byte(`{ "error": { "code": 503, @@ -322,6 +323,14 @@ func TestHandleSmartRetry_503_ModelCapacityExhausted_ReturnsSwitchError(t *testi Body: io.NopCloser(bytes.NewReader(respBody)), } + // mock: 第 1 次重试返回 200 成功 + upstream := &mockSmartRetryUpstream{ + responses: []*http.Response{ + {StatusCode: http.StatusOK, Header: http.Header{}, Body: io.NopCloser(strings.NewReader(`{"ok":true}`))}, + }, + errors: []error{nil}, + } + params := antigravityRetryLoopParams{ ctx: context.Background(), prefix: "[test]", @@ -330,6 +339,7 @@ func TestHandleSmartRetry_503_ModelCapacityExhausted_ReturnsSwitchError(t *testi action: "generateContent", body: []byte(`{"input":"test"}`), accountRepo: repo, + httpUpstream: upstream, isStickySession: true, handleError: func(ctx context.Context, prefix string, account *Account, statusCode int, headers http.Header, body []byte, requestedModel string, groupID int64, sessionHash string, isStickySession bool) *handleModelRateLimitResult { return nil @@ -343,16 +353,67 @@ func TestHandleSmartRetry_503_ModelCapacityExhausted_ReturnsSwitchError(t *testi require.NotNil(t, result) require.Equal(t, smartRetryActionBreakWithResp, result.action) - require.Nil(t, result.resp) + require.NotNil(t, result.resp, "should return successful response") + require.Equal(t, http.StatusOK, result.resp.StatusCode) require.Nil(t, result.err) - require.NotNil(t, result.switchError, "should return switchError for 503 model capacity exhausted") - require.Equal(t, account.ID, result.switchError.OriginalAccountID) - require.Equal(t, "gemini-3-pro-high", result.switchError.RateLimitedModel) - require.True(t, result.switchError.IsStickySession) + require.Nil(t, result.switchError, "MODEL_CAPACITY_EXHAUSTED should not return switchError") - // 验证模型限流已设置 - require.Len(t, repo.modelRateLimitCalls, 1) - require.Equal(t, "gemini-3-pro-high", repo.modelRateLimitCalls[0].modelKey) + // 不应设置模型限流 + require.Empty(t, repo.modelRateLimitCalls, "MODEL_CAPACITY_EXHAUSTED should not set model rate limit") + require.Len(t, upstream.calls, 1, "should have made one retry call before success") +} + +// TestHandleSmartRetry_503_ModelCapacityExhausted_ContextCancel 测试 MODEL_CAPACITY_EXHAUSTED 上下文取消 +func TestHandleSmartRetry_503_ModelCapacityExhausted_ContextCancel(t *testing.T) { + repo := &stubAntigravityAccountRepo{} + account := &Account{ + ID: 3, + Name: "acc-3", + Type: AccountTypeOAuth, + Platform: PlatformAntigravity, + } + + respBody := []byte(`{ + "error": { + "code": 503, + "status": "UNAVAILABLE", + "details": [ + {"@type": "type.googleapis.com/google.rpc.ErrorInfo", "metadata": {"model": "gemini-3-pro-high"}, "reason": "MODEL_CAPACITY_EXHAUSTED"}, + {"@type": "type.googleapis.com/google.rpc.RetryInfo", "retryDelay": "39s"} + ] + } + }`) + resp := &http.Response{ + StatusCode: http.StatusServiceUnavailable, + Header: http.Header{}, + Body: io.NopCloser(bytes.NewReader(respBody)), + } + + // 立即取消上下文,验证重试循环能正确退出 + ctx, cancel := context.WithCancel(context.Background()) + cancel() + + params := antigravityRetryLoopParams{ + ctx: ctx, + prefix: "[test]", + account: account, + accessToken: "token", + action: "generateContent", + body: []byte(`{"input":"test"}`), + accountRepo: repo, + handleError: func(ctx context.Context, prefix string, account *Account, statusCode int, headers http.Header, body []byte, requestedModel string, groupID int64, sessionHash string, isStickySession bool) *handleModelRateLimitResult { + return nil + }, + } + + svc := &AntigravityGatewayService{} + result := svc.handleSmartRetry(params, resp, respBody, "https://ag-1.test", 0, []string{"https://ag-1.test"}) + + require.NotNil(t, result) + require.Equal(t, smartRetryActionBreakWithResp, result.action) + require.Error(t, result.err, "should return context error") + require.Nil(t, result.switchError, "should not return switchError on context cancel") + require.Empty(t, repo.modelRateLimitCalls, "should not set model rate limit on context cancel") } // TestHandleSmartRetry_NonAntigravityAccount_ContinuesDefaultLogic 测试非 Antigravity 平台账号走默认逻辑 @@ -1129,20 +1190,20 @@ func TestHandleSmartRetry_ShortDelay_NetworkError_StickySession_ClearsSession(t } // TestHandleSmartRetry_ShortDelay_503_StickySession_FailedRetry_ClearsSession -// 503 + 短延迟 + 粘性会话 + 重试失败 → 清除粘性绑定 +// 429 + 短延迟 + 粘性会话 + 重试失败 → 清除粘性绑定 func TestHandleSmartRetry_ShortDelay_503_StickySession_FailedRetry_ClearsSession(t *testing.T) { failRespBody := `{ "error": { - "code": 503, - "status": "UNAVAILABLE", + "code": 429, + "status": "RESOURCE_EXHAUSTED", "details": [ - {"@type": "type.googleapis.com/google.rpc.ErrorInfo", "metadata": {"model": "gemini-3-pro"}, "reason": "MODEL_CAPACITY_EXHAUSTED"}, + {"@type": "type.googleapis.com/google.rpc.ErrorInfo", "metadata": {"model": "gemini-3-pro"}, "reason": "RATE_LIMIT_EXCEEDED"}, {"@type": "type.googleapis.com/google.rpc.RetryInfo", "retryDelay": "0.5s"} ] } }` failResp := &http.Response{ - StatusCode: http.StatusServiceUnavailable, + StatusCode: http.StatusTooManyRequests, Header: http.Header{}, Body: io.NopCloser(strings.NewReader(failRespBody)), } @@ -1162,16 +1223,16 @@ func TestHandleSmartRetry_ShortDelay_503_StickySession_FailedRetry_ClearsSession respBody := []byte(`{ "error": { - "code": 503, - "status": "UNAVAILABLE", + "code": 429, + "status": "RESOURCE_EXHAUSTED", "details": [ - {"@type": "type.googleapis.com/google.rpc.ErrorInfo", "metadata": {"model": "gemini-3-pro"}, "reason": "MODEL_CAPACITY_EXHAUSTED"}, + {"@type": "type.googleapis.com/google.rpc.ErrorInfo", "metadata": {"model": "gemini-3-pro"}, "reason": "RATE_LIMIT_EXCEEDED"}, {"@type": "type.googleapis.com/google.rpc.RetryInfo", "retryDelay": "0.5s"} ] } }`) resp := &http.Response{ - StatusCode: http.StatusServiceUnavailable, + StatusCode: http.StatusTooManyRequests, Header: http.Header{}, Body: io.NopCloser(bytes.NewReader(respBody)), } From d95e04fd1f6b6caab8304513b7a8740912c79770 Mon Sep 17 00:00:00 2001 From: Edric Li Date: Tue, 10 Feb 2026 11:42:39 +0800 Subject: [PATCH 05/18] =?UTF-8?q?feat:=20=E9=94=99=E8=AF=AF=E9=80=8F?= =?UTF-8?q?=E4=BC=A0=E8=A7=84=E5=88=99=E6=94=AF=E6=8C=81=20skip=5Fmonitori?= =?UTF-8?q?ng=20=E8=B7=B3=E8=BF=87=E8=BF=90=E7=BB=B4=E7=9B=91=E6=8E=A7?= =?UTF-8?q?=E8=AE=B0=E5=BD=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 在每条错误透传规则上新增 skip_monitoring 选项,开启后匹配该规则的错误 不会被记录到 ops_error_logs,减少监控噪音。默认关闭,不影响现有规则。 --- backend/ent/errorpassthroughrule.go | 13 +++- .../errorpassthroughrule.go | 10 +++ backend/ent/errorpassthroughrule/where.go | 15 +++++ backend/ent/errorpassthroughrule_create.go | 65 +++++++++++++++++++ backend/ent/errorpassthroughrule_update.go | 34 ++++++++++ backend/ent/migrate/schema.go | 1 + backend/ent/mutation.go | 56 +++++++++++++++- backend/ent/runtime/runtime.go | 4 ++ backend/ent/schema/error_passthrough_rule.go | 6 ++ .../admin/error_passthrough_handler.go | 9 +++ backend/internal/handler/ops_error_logger.go | 7 ++ .../internal/model/error_passthrough_rule.go | 1 + .../repository/error_passthrough_repo.go | 7 +- .../service/error_passthrough_runtime.go | 5 ++ .../service/error_passthrough_runtime_test.go | 55 ++++++++++++++++ .../internal/service/ops_upstream_context.go | 4 ++ ...d_skip_monitoring_to_error_passthrough.sql | 4 ++ frontend/src/api/admin/errorPassthrough.ts | 3 + .../admin/ErrorPassthroughRulesModal.vue | 27 ++++++++ frontend/src/i18n/locales/en.ts | 3 + frontend/src/i18n/locales/zh.ts | 3 + 21 files changed, 328 insertions(+), 4 deletions(-) create mode 100644 backend/migrations/053_add_skip_monitoring_to_error_passthrough.sql diff --git a/backend/ent/errorpassthroughrule.go b/backend/ent/errorpassthroughrule.go index 1932f626..62468719 100644 --- a/backend/ent/errorpassthroughrule.go +++ b/backend/ent/errorpassthroughrule.go @@ -44,6 +44,8 @@ type ErrorPassthroughRule struct { PassthroughBody bool `json:"passthrough_body,omitempty"` // CustomMessage holds the value of the "custom_message" field. CustomMessage *string `json:"custom_message,omitempty"` + // SkipMonitoring holds the value of the "skip_monitoring" field. + SkipMonitoring bool `json:"skip_monitoring,omitempty"` // Description holds the value of the "description" field. Description *string `json:"description,omitempty"` selectValues sql.SelectValues @@ -56,7 +58,7 @@ func (*ErrorPassthroughRule) scanValues(columns []string) ([]any, error) { switch columns[i] { case errorpassthroughrule.FieldErrorCodes, errorpassthroughrule.FieldKeywords, errorpassthroughrule.FieldPlatforms: values[i] = new([]byte) - case errorpassthroughrule.FieldEnabled, errorpassthroughrule.FieldPassthroughCode, errorpassthroughrule.FieldPassthroughBody: + case errorpassthroughrule.FieldEnabled, errorpassthroughrule.FieldPassthroughCode, errorpassthroughrule.FieldPassthroughBody, errorpassthroughrule.FieldSkipMonitoring: values[i] = new(sql.NullBool) case errorpassthroughrule.FieldID, errorpassthroughrule.FieldPriority, errorpassthroughrule.FieldResponseCode: values[i] = new(sql.NullInt64) @@ -171,6 +173,12 @@ func (_m *ErrorPassthroughRule) assignValues(columns []string, values []any) err _m.CustomMessage = new(string) *_m.CustomMessage = value.String } + case errorpassthroughrule.FieldSkipMonitoring: + if value, ok := values[i].(*sql.NullBool); !ok { + return fmt.Errorf("unexpected type %T for field skip_monitoring", values[i]) + } else if value.Valid { + _m.SkipMonitoring = value.Bool + } case errorpassthroughrule.FieldDescription: if value, ok := values[i].(*sql.NullString); !ok { return fmt.Errorf("unexpected type %T for field description", values[i]) @@ -257,6 +265,9 @@ func (_m *ErrorPassthroughRule) String() string { builder.WriteString(*v) } builder.WriteString(", ") + builder.WriteString("skip_monitoring=") + builder.WriteString(fmt.Sprintf("%v", _m.SkipMonitoring)) + builder.WriteString(", ") if v := _m.Description; v != nil { builder.WriteString("description=") builder.WriteString(*v) diff --git a/backend/ent/errorpassthroughrule/errorpassthroughrule.go b/backend/ent/errorpassthroughrule/errorpassthroughrule.go index d7be4f03..859fc761 100644 --- a/backend/ent/errorpassthroughrule/errorpassthroughrule.go +++ b/backend/ent/errorpassthroughrule/errorpassthroughrule.go @@ -39,6 +39,8 @@ const ( FieldPassthroughBody = "passthrough_body" // FieldCustomMessage holds the string denoting the custom_message field in the database. FieldCustomMessage = "custom_message" + // FieldSkipMonitoring holds the string denoting the skip_monitoring field in the database. + FieldSkipMonitoring = "skip_monitoring" // FieldDescription holds the string denoting the description field in the database. FieldDescription = "description" // Table holds the table name of the errorpassthroughrule in the database. @@ -61,6 +63,7 @@ var Columns = []string{ FieldResponseCode, FieldPassthroughBody, FieldCustomMessage, + FieldSkipMonitoring, FieldDescription, } @@ -95,6 +98,8 @@ var ( DefaultPassthroughCode bool // DefaultPassthroughBody holds the default value on creation for the "passthrough_body" field. DefaultPassthroughBody bool + // DefaultSkipMonitoring holds the default value on creation for the "skip_monitoring" field. + DefaultSkipMonitoring bool ) // OrderOption defines the ordering options for the ErrorPassthroughRule queries. @@ -155,6 +160,11 @@ func ByCustomMessage(opts ...sql.OrderTermOption) OrderOption { return sql.OrderByField(FieldCustomMessage, opts...).ToFunc() } +// BySkipMonitoring orders the results by the skip_monitoring field. +func BySkipMonitoring(opts ...sql.OrderTermOption) OrderOption { + return sql.OrderByField(FieldSkipMonitoring, opts...).ToFunc() +} + // ByDescription orders the results by the description field. func ByDescription(opts ...sql.OrderTermOption) OrderOption { return sql.OrderByField(FieldDescription, opts...).ToFunc() diff --git a/backend/ent/errorpassthroughrule/where.go b/backend/ent/errorpassthroughrule/where.go index 56839d52..87654678 100644 --- a/backend/ent/errorpassthroughrule/where.go +++ b/backend/ent/errorpassthroughrule/where.go @@ -104,6 +104,11 @@ func CustomMessage(v string) predicate.ErrorPassthroughRule { return predicate.ErrorPassthroughRule(sql.FieldEQ(FieldCustomMessage, v)) } +// SkipMonitoring applies equality check predicate on the "skip_monitoring" field. It's identical to SkipMonitoringEQ. +func SkipMonitoring(v bool) predicate.ErrorPassthroughRule { + return predicate.ErrorPassthroughRule(sql.FieldEQ(FieldSkipMonitoring, v)) +} + // Description applies equality check predicate on the "description" field. It's identical to DescriptionEQ. func Description(v string) predicate.ErrorPassthroughRule { return predicate.ErrorPassthroughRule(sql.FieldEQ(FieldDescription, v)) @@ -544,6 +549,16 @@ func CustomMessageContainsFold(v string) predicate.ErrorPassthroughRule { return predicate.ErrorPassthroughRule(sql.FieldContainsFold(FieldCustomMessage, v)) } +// SkipMonitoringEQ applies the EQ predicate on the "skip_monitoring" field. +func SkipMonitoringEQ(v bool) predicate.ErrorPassthroughRule { + return predicate.ErrorPassthroughRule(sql.FieldEQ(FieldSkipMonitoring, v)) +} + +// SkipMonitoringNEQ applies the NEQ predicate on the "skip_monitoring" field. +func SkipMonitoringNEQ(v bool) predicate.ErrorPassthroughRule { + return predicate.ErrorPassthroughRule(sql.FieldNEQ(FieldSkipMonitoring, v)) +} + // DescriptionEQ applies the EQ predicate on the "description" field. func DescriptionEQ(v string) predicate.ErrorPassthroughRule { return predicate.ErrorPassthroughRule(sql.FieldEQ(FieldDescription, v)) diff --git a/backend/ent/errorpassthroughrule_create.go b/backend/ent/errorpassthroughrule_create.go index 4dc08dce..8173936b 100644 --- a/backend/ent/errorpassthroughrule_create.go +++ b/backend/ent/errorpassthroughrule_create.go @@ -172,6 +172,20 @@ func (_c *ErrorPassthroughRuleCreate) SetNillableCustomMessage(v *string) *Error return _c } +// SetSkipMonitoring sets the "skip_monitoring" field. +func (_c *ErrorPassthroughRuleCreate) SetSkipMonitoring(v bool) *ErrorPassthroughRuleCreate { + _c.mutation.SetSkipMonitoring(v) + return _c +} + +// SetNillableSkipMonitoring sets the "skip_monitoring" field if the given value is not nil. +func (_c *ErrorPassthroughRuleCreate) SetNillableSkipMonitoring(v *bool) *ErrorPassthroughRuleCreate { + if v != nil { + _c.SetSkipMonitoring(*v) + } + return _c +} + // SetDescription sets the "description" field. func (_c *ErrorPassthroughRuleCreate) SetDescription(v string) *ErrorPassthroughRuleCreate { _c.mutation.SetDescription(v) @@ -249,6 +263,10 @@ func (_c *ErrorPassthroughRuleCreate) defaults() { v := errorpassthroughrule.DefaultPassthroughBody _c.mutation.SetPassthroughBody(v) } + if _, ok := _c.mutation.SkipMonitoring(); !ok { + v := errorpassthroughrule.DefaultSkipMonitoring + _c.mutation.SetSkipMonitoring(v) + } } // check runs all checks and user-defined validators on the builder. @@ -287,6 +305,9 @@ func (_c *ErrorPassthroughRuleCreate) check() error { if _, ok := _c.mutation.PassthroughBody(); !ok { return &ValidationError{Name: "passthrough_body", err: errors.New(`ent: missing required field "ErrorPassthroughRule.passthrough_body"`)} } + if _, ok := _c.mutation.SkipMonitoring(); !ok { + return &ValidationError{Name: "skip_monitoring", err: errors.New(`ent: missing required field "ErrorPassthroughRule.skip_monitoring"`)} + } return nil } @@ -366,6 +387,10 @@ func (_c *ErrorPassthroughRuleCreate) createSpec() (*ErrorPassthroughRule, *sqlg _spec.SetField(errorpassthroughrule.FieldCustomMessage, field.TypeString, value) _node.CustomMessage = &value } + if value, ok := _c.mutation.SkipMonitoring(); ok { + _spec.SetField(errorpassthroughrule.FieldSkipMonitoring, field.TypeBool, value) + _node.SkipMonitoring = value + } if value, ok := _c.mutation.Description(); ok { _spec.SetField(errorpassthroughrule.FieldDescription, field.TypeString, value) _node.Description = &value @@ -608,6 +633,18 @@ func (u *ErrorPassthroughRuleUpsert) ClearCustomMessage() *ErrorPassthroughRuleU return u } +// SetSkipMonitoring sets the "skip_monitoring" field. +func (u *ErrorPassthroughRuleUpsert) SetSkipMonitoring(v bool) *ErrorPassthroughRuleUpsert { + u.Set(errorpassthroughrule.FieldSkipMonitoring, v) + return u +} + +// UpdateSkipMonitoring sets the "skip_monitoring" field to the value that was provided on create. +func (u *ErrorPassthroughRuleUpsert) UpdateSkipMonitoring() *ErrorPassthroughRuleUpsert { + u.SetExcluded(errorpassthroughrule.FieldSkipMonitoring) + return u +} + // SetDescription sets the "description" field. func (u *ErrorPassthroughRuleUpsert) SetDescription(v string) *ErrorPassthroughRuleUpsert { u.Set(errorpassthroughrule.FieldDescription, v) @@ -888,6 +925,20 @@ func (u *ErrorPassthroughRuleUpsertOne) ClearCustomMessage() *ErrorPassthroughRu }) } +// SetSkipMonitoring sets the "skip_monitoring" field. +func (u *ErrorPassthroughRuleUpsertOne) SetSkipMonitoring(v bool) *ErrorPassthroughRuleUpsertOne { + return u.Update(func(s *ErrorPassthroughRuleUpsert) { + s.SetSkipMonitoring(v) + }) +} + +// UpdateSkipMonitoring sets the "skip_monitoring" field to the value that was provided on create. +func (u *ErrorPassthroughRuleUpsertOne) UpdateSkipMonitoring() *ErrorPassthroughRuleUpsertOne { + return u.Update(func(s *ErrorPassthroughRuleUpsert) { + s.UpdateSkipMonitoring() + }) +} + // SetDescription sets the "description" field. func (u *ErrorPassthroughRuleUpsertOne) SetDescription(v string) *ErrorPassthroughRuleUpsertOne { return u.Update(func(s *ErrorPassthroughRuleUpsert) { @@ -1337,6 +1388,20 @@ func (u *ErrorPassthroughRuleUpsertBulk) ClearCustomMessage() *ErrorPassthroughR }) } +// SetSkipMonitoring sets the "skip_monitoring" field. +func (u *ErrorPassthroughRuleUpsertBulk) SetSkipMonitoring(v bool) *ErrorPassthroughRuleUpsertBulk { + return u.Update(func(s *ErrorPassthroughRuleUpsert) { + s.SetSkipMonitoring(v) + }) +} + +// UpdateSkipMonitoring sets the "skip_monitoring" field to the value that was provided on create. +func (u *ErrorPassthroughRuleUpsertBulk) UpdateSkipMonitoring() *ErrorPassthroughRuleUpsertBulk { + return u.Update(func(s *ErrorPassthroughRuleUpsert) { + s.UpdateSkipMonitoring() + }) +} + // SetDescription sets the "description" field. func (u *ErrorPassthroughRuleUpsertBulk) SetDescription(v string) *ErrorPassthroughRuleUpsertBulk { return u.Update(func(s *ErrorPassthroughRuleUpsert) { diff --git a/backend/ent/errorpassthroughrule_update.go b/backend/ent/errorpassthroughrule_update.go index 9d52aa49..7e42d9fc 100644 --- a/backend/ent/errorpassthroughrule_update.go +++ b/backend/ent/errorpassthroughrule_update.go @@ -227,6 +227,20 @@ func (_u *ErrorPassthroughRuleUpdate) ClearCustomMessage() *ErrorPassthroughRule return _u } +// SetSkipMonitoring sets the "skip_monitoring" field. +func (_u *ErrorPassthroughRuleUpdate) SetSkipMonitoring(v bool) *ErrorPassthroughRuleUpdate { + _u.mutation.SetSkipMonitoring(v) + return _u +} + +// SetNillableSkipMonitoring sets the "skip_monitoring" field if the given value is not nil. +func (_u *ErrorPassthroughRuleUpdate) SetNillableSkipMonitoring(v *bool) *ErrorPassthroughRuleUpdate { + if v != nil { + _u.SetSkipMonitoring(*v) + } + return _u +} + // SetDescription sets the "description" field. func (_u *ErrorPassthroughRuleUpdate) SetDescription(v string) *ErrorPassthroughRuleUpdate { _u.mutation.SetDescription(v) @@ -387,6 +401,9 @@ func (_u *ErrorPassthroughRuleUpdate) sqlSave(ctx context.Context) (_node int, e if _u.mutation.CustomMessageCleared() { _spec.ClearField(errorpassthroughrule.FieldCustomMessage, field.TypeString) } + if value, ok := _u.mutation.SkipMonitoring(); ok { + _spec.SetField(errorpassthroughrule.FieldSkipMonitoring, field.TypeBool, value) + } if value, ok := _u.mutation.Description(); ok { _spec.SetField(errorpassthroughrule.FieldDescription, field.TypeString, value) } @@ -611,6 +628,20 @@ func (_u *ErrorPassthroughRuleUpdateOne) ClearCustomMessage() *ErrorPassthroughR return _u } +// SetSkipMonitoring sets the "skip_monitoring" field. +func (_u *ErrorPassthroughRuleUpdateOne) SetSkipMonitoring(v bool) *ErrorPassthroughRuleUpdateOne { + _u.mutation.SetSkipMonitoring(v) + return _u +} + +// SetNillableSkipMonitoring sets the "skip_monitoring" field if the given value is not nil. +func (_u *ErrorPassthroughRuleUpdateOne) SetNillableSkipMonitoring(v *bool) *ErrorPassthroughRuleUpdateOne { + if v != nil { + _u.SetSkipMonitoring(*v) + } + return _u +} + // SetDescription sets the "description" field. func (_u *ErrorPassthroughRuleUpdateOne) SetDescription(v string) *ErrorPassthroughRuleUpdateOne { _u.mutation.SetDescription(v) @@ -801,6 +832,9 @@ func (_u *ErrorPassthroughRuleUpdateOne) sqlSave(ctx context.Context) (_node *Er if _u.mutation.CustomMessageCleared() { _spec.ClearField(errorpassthroughrule.FieldCustomMessage, field.TypeString) } + if value, ok := _u.mutation.SkipMonitoring(); ok { + _spec.SetField(errorpassthroughrule.FieldSkipMonitoring, field.TypeBool, value) + } if value, ok := _u.mutation.Description(); ok { _spec.SetField(errorpassthroughrule.FieldDescription, field.TypeString, value) } diff --git a/backend/ent/migrate/schema.go b/backend/ent/migrate/schema.go index cfd4a72b..ef5f1e04 100644 --- a/backend/ent/migrate/schema.go +++ b/backend/ent/migrate/schema.go @@ -325,6 +325,7 @@ var ( {Name: "response_code", Type: field.TypeInt, Nullable: true}, {Name: "passthrough_body", Type: field.TypeBool, Default: true}, {Name: "custom_message", Type: field.TypeString, Nullable: true, Size: 2147483647}, + {Name: "skip_monitoring", Type: field.TypeBool, Default: false}, {Name: "description", Type: field.TypeString, Nullable: true, Size: 2147483647}, } // ErrorPassthroughRulesTable holds the schema information for the "error_passthrough_rules" table. diff --git a/backend/ent/mutation.go b/backend/ent/mutation.go index 969d9357..76360820 100644 --- a/backend/ent/mutation.go +++ b/backend/ent/mutation.go @@ -5776,6 +5776,7 @@ type ErrorPassthroughRuleMutation struct { addresponse_code *int passthrough_body *bool custom_message *string + skip_monitoring *bool description *string clearedFields map[string]struct{} done bool @@ -6503,6 +6504,42 @@ func (m *ErrorPassthroughRuleMutation) ResetCustomMessage() { delete(m.clearedFields, errorpassthroughrule.FieldCustomMessage) } +// SetSkipMonitoring sets the "skip_monitoring" field. +func (m *ErrorPassthroughRuleMutation) SetSkipMonitoring(b bool) { + m.skip_monitoring = &b +} + +// SkipMonitoring returns the value of the "skip_monitoring" field in the mutation. +func (m *ErrorPassthroughRuleMutation) SkipMonitoring() (r bool, exists bool) { + v := m.skip_monitoring + if v == nil { + return + } + return *v, true +} + +// OldSkipMonitoring returns the old "skip_monitoring" field's value of the ErrorPassthroughRule entity. +// If the ErrorPassthroughRule object wasn't provided to the builder, the object is fetched from the database. +// An error is returned if the mutation operation is not UpdateOne, or the database query fails. +func (m *ErrorPassthroughRuleMutation) OldSkipMonitoring(ctx context.Context) (v bool, err error) { + if !m.op.Is(OpUpdateOne) { + return v, errors.New("OldSkipMonitoring is only allowed on UpdateOne operations") + } + if m.id == nil || m.oldValue == nil { + return v, errors.New("OldSkipMonitoring requires an ID field in the mutation") + } + oldValue, err := m.oldValue(ctx) + if err != nil { + return v, fmt.Errorf("querying old value for OldSkipMonitoring: %w", err) + } + return oldValue.SkipMonitoring, nil +} + +// ResetSkipMonitoring resets all changes to the "skip_monitoring" field. +func (m *ErrorPassthroughRuleMutation) ResetSkipMonitoring() { + m.skip_monitoring = nil +} + // SetDescription sets the "description" field. func (m *ErrorPassthroughRuleMutation) SetDescription(s string) { m.description = &s @@ -6586,7 +6623,7 @@ func (m *ErrorPassthroughRuleMutation) Type() string { // order to get all numeric fields that were incremented/decremented, call // AddedFields(). func (m *ErrorPassthroughRuleMutation) Fields() []string { - fields := make([]string, 0, 14) + fields := make([]string, 0, 15) if m.created_at != nil { fields = append(fields, errorpassthroughrule.FieldCreatedAt) } @@ -6626,6 +6663,9 @@ func (m *ErrorPassthroughRuleMutation) Fields() []string { if m.custom_message != nil { fields = append(fields, errorpassthroughrule.FieldCustomMessage) } + if m.skip_monitoring != nil { + fields = append(fields, errorpassthroughrule.FieldSkipMonitoring) + } if m.description != nil { fields = append(fields, errorpassthroughrule.FieldDescription) } @@ -6663,6 +6703,8 @@ func (m *ErrorPassthroughRuleMutation) Field(name string) (ent.Value, bool) { return m.PassthroughBody() case errorpassthroughrule.FieldCustomMessage: return m.CustomMessage() + case errorpassthroughrule.FieldSkipMonitoring: + return m.SkipMonitoring() case errorpassthroughrule.FieldDescription: return m.Description() } @@ -6700,6 +6742,8 @@ func (m *ErrorPassthroughRuleMutation) OldField(ctx context.Context, name string return m.OldPassthroughBody(ctx) case errorpassthroughrule.FieldCustomMessage: return m.OldCustomMessage(ctx) + case errorpassthroughrule.FieldSkipMonitoring: + return m.OldSkipMonitoring(ctx) case errorpassthroughrule.FieldDescription: return m.OldDescription(ctx) } @@ -6802,6 +6846,13 @@ func (m *ErrorPassthroughRuleMutation) SetField(name string, value ent.Value) er } m.SetCustomMessage(v) return nil + case errorpassthroughrule.FieldSkipMonitoring: + v, ok := value.(bool) + if !ok { + return fmt.Errorf("unexpected type %T for field %s", value, name) + } + m.SetSkipMonitoring(v) + return nil case errorpassthroughrule.FieldDescription: v, ok := value.(string) if !ok { @@ -6963,6 +7014,9 @@ func (m *ErrorPassthroughRuleMutation) ResetField(name string) error { case errorpassthroughrule.FieldCustomMessage: m.ResetCustomMessage() return nil + case errorpassthroughrule.FieldSkipMonitoring: + m.ResetSkipMonitoring() + return nil case errorpassthroughrule.FieldDescription: m.ResetDescription() return nil diff --git a/backend/ent/runtime/runtime.go b/backend/ent/runtime/runtime.go index e5c34929..7713224c 100644 --- a/backend/ent/runtime/runtime.go +++ b/backend/ent/runtime/runtime.go @@ -326,6 +326,10 @@ func init() { errorpassthroughruleDescPassthroughBody := errorpassthroughruleFields[9].Descriptor() // errorpassthroughrule.DefaultPassthroughBody holds the default value on creation for the passthrough_body field. errorpassthroughrule.DefaultPassthroughBody = errorpassthroughruleDescPassthroughBody.Default.(bool) + // errorpassthroughruleDescSkipMonitoring is the schema descriptor for skip_monitoring field. + errorpassthroughruleDescSkipMonitoring := errorpassthroughruleFields[11].Descriptor() + // errorpassthroughrule.DefaultSkipMonitoring holds the default value on creation for the skip_monitoring field. + errorpassthroughrule.DefaultSkipMonitoring = errorpassthroughruleDescSkipMonitoring.Default.(bool) groupMixin := schema.Group{}.Mixin() groupMixinHooks1 := groupMixin[1].Hooks() group.Hooks[0] = groupMixinHooks1[0] diff --git a/backend/ent/schema/error_passthrough_rule.go b/backend/ent/schema/error_passthrough_rule.go index 4a861f38..63a81230 100644 --- a/backend/ent/schema/error_passthrough_rule.go +++ b/backend/ent/schema/error_passthrough_rule.go @@ -105,6 +105,12 @@ func (ErrorPassthroughRule) Fields() []ent.Field { Optional(). Nillable(), + // skip_monitoring: 是否跳过运维监控记录 + // true: 匹配此规则的错误不会被记录到 ops_error_logs + // false: 正常记录到运维监控(默认行为) + field.Bool("skip_monitoring"). + Default(false), + // description: 规则描述,用于说明规则的用途 field.Text("description"). Optional(). diff --git a/backend/internal/handler/admin/error_passthrough_handler.go b/backend/internal/handler/admin/error_passthrough_handler.go index c32db561..25aaa5c7 100644 --- a/backend/internal/handler/admin/error_passthrough_handler.go +++ b/backend/internal/handler/admin/error_passthrough_handler.go @@ -32,6 +32,7 @@ type CreateErrorPassthroughRuleRequest struct { ResponseCode *int `json:"response_code"` PassthroughBody *bool `json:"passthrough_body"` CustomMessage *string `json:"custom_message"` + SkipMonitoring *bool `json:"skip_monitoring"` Description *string `json:"description"` } @@ -48,6 +49,7 @@ type UpdateErrorPassthroughRuleRequest struct { ResponseCode *int `json:"response_code"` PassthroughBody *bool `json:"passthrough_body"` CustomMessage *string `json:"custom_message"` + SkipMonitoring *bool `json:"skip_monitoring"` Description *string `json:"description"` } @@ -122,6 +124,9 @@ func (h *ErrorPassthroughHandler) Create(c *gin.Context) { } else { rule.PassthroughBody = true } + if req.SkipMonitoring != nil { + rule.SkipMonitoring = *req.SkipMonitoring + } rule.ResponseCode = req.ResponseCode rule.CustomMessage = req.CustomMessage rule.Description = req.Description @@ -190,6 +195,7 @@ func (h *ErrorPassthroughHandler) Update(c *gin.Context) { ResponseCode: existing.ResponseCode, PassthroughBody: existing.PassthroughBody, CustomMessage: existing.CustomMessage, + SkipMonitoring: existing.SkipMonitoring, Description: existing.Description, } @@ -230,6 +236,9 @@ func (h *ErrorPassthroughHandler) Update(c *gin.Context) { if req.Description != nil { rule.Description = req.Description } + if req.SkipMonitoring != nil { + rule.SkipMonitoring = *req.SkipMonitoring + } // 确保切片不为 nil if rule.ErrorCodes == nil { diff --git a/backend/internal/handler/ops_error_logger.go b/backend/internal/handler/ops_error_logger.go index 36ffde63..2dbf60ad 100644 --- a/backend/internal/handler/ops_error_logger.go +++ b/backend/internal/handler/ops_error_logger.go @@ -544,6 +544,13 @@ func OpsErrorLoggerMiddleware(ops *service.OpsService) gin.HandlerFunc { body := w.buf.Bytes() parsed := parseOpsErrorResponse(body) + // Skip logging if a passthrough rule with skip_monitoring=true matched. + if v, ok := c.Get(service.OpsSkipPassthroughKey); ok { + if skip, _ := v.(bool); skip { + return + } + } + // Skip logging if the error should be filtered based on settings if shouldSkipOpsErrorLog(c.Request.Context(), ops, parsed.Message, string(body), c.Request.URL.Path) { return diff --git a/backend/internal/model/error_passthrough_rule.go b/backend/internal/model/error_passthrough_rule.go index d4fc16e3..620736cd 100644 --- a/backend/internal/model/error_passthrough_rule.go +++ b/backend/internal/model/error_passthrough_rule.go @@ -18,6 +18,7 @@ type ErrorPassthroughRule struct { ResponseCode *int `json:"response_code"` // 自定义状态码(passthrough_code=false 时使用) PassthroughBody bool `json:"passthrough_body"` // 是否透传原始错误信息 CustomMessage *string `json:"custom_message"` // 自定义错误信息(passthrough_body=false 时使用) + SkipMonitoring bool `json:"skip_monitoring"` // 是否跳过运维监控记录 Description *string `json:"description"` // 规则描述 CreatedAt time.Time `json:"created_at"` UpdatedAt time.Time `json:"updated_at"` diff --git a/backend/internal/repository/error_passthrough_repo.go b/backend/internal/repository/error_passthrough_repo.go index a58ab60f..ae989359 100644 --- a/backend/internal/repository/error_passthrough_repo.go +++ b/backend/internal/repository/error_passthrough_repo.go @@ -54,7 +54,8 @@ func (r *errorPassthroughRepository) Create(ctx context.Context, rule *model.Err SetPriority(rule.Priority). SetMatchMode(rule.MatchMode). SetPassthroughCode(rule.PassthroughCode). - SetPassthroughBody(rule.PassthroughBody) + SetPassthroughBody(rule.PassthroughBody). + SetSkipMonitoring(rule.SkipMonitoring) if len(rule.ErrorCodes) > 0 { builder.SetErrorCodes(rule.ErrorCodes) @@ -90,7 +91,8 @@ func (r *errorPassthroughRepository) Update(ctx context.Context, rule *model.Err SetPriority(rule.Priority). SetMatchMode(rule.MatchMode). SetPassthroughCode(rule.PassthroughCode). - SetPassthroughBody(rule.PassthroughBody) + SetPassthroughBody(rule.PassthroughBody). + SetSkipMonitoring(rule.SkipMonitoring) // 处理可选字段 if len(rule.ErrorCodes) > 0 { @@ -149,6 +151,7 @@ func (r *errorPassthroughRepository) toModel(e *ent.ErrorPassthroughRule) *model Platforms: e.Platforms, PassthroughCode: e.PassthroughCode, PassthroughBody: e.PassthroughBody, + SkipMonitoring: e.SkipMonitoring, CreatedAt: e.CreatedAt, UpdatedAt: e.UpdatedAt, } diff --git a/backend/internal/service/error_passthrough_runtime.go b/backend/internal/service/error_passthrough_runtime.go index 65085d6f..011c3ce4 100644 --- a/backend/internal/service/error_passthrough_runtime.go +++ b/backend/internal/service/error_passthrough_runtime.go @@ -61,6 +61,11 @@ func applyErrorPassthroughRule( errMsg = *rule.CustomMessage } + // 命中 skip_monitoring 时在 context 中标记,供 ops_error_logger 跳过记录。 + if rule.SkipMonitoring { + c.Set(OpsSkipPassthroughKey, true) + } + // 与现有 failover 场景保持一致:命中规则时统一返回 upstream_error。 errType = "upstream_error" return status, errType, errMsg, true diff --git a/backend/internal/service/error_passthrough_runtime_test.go b/backend/internal/service/error_passthrough_runtime_test.go index 393e6e59..f963913b 100644 --- a/backend/internal/service/error_passthrough_runtime_test.go +++ b/backend/internal/service/error_passthrough_runtime_test.go @@ -194,6 +194,61 @@ func TestGeminiWriteGeminiMappedError_AppliesRuleFor422(t *testing.T) { assert.Equal(t, "Gemini上游失败", errField["message"]) } +func TestApplyErrorPassthroughRule_SkipMonitoringSetsContextKey(t *testing.T) { + gin.SetMode(gin.TestMode) + rec := httptest.NewRecorder() + c, _ := gin.CreateTestContext(rec) + + rule := newNonFailoverPassthroughRule(http.StatusBadRequest, "prompt is too long", http.StatusBadRequest, "上下文超限") + rule.SkipMonitoring = true + + ruleSvc := &ErrorPassthroughService{} + ruleSvc.setLocalCache([]*model.ErrorPassthroughRule{rule}) + BindErrorPassthroughService(c, ruleSvc) + + _, _, _, matched := applyErrorPassthroughRule( + c, + PlatformAnthropic, + http.StatusBadRequest, + []byte(`{"error":{"message":"prompt is too long"}}`), + http.StatusBadGateway, + "upstream_error", + "Upstream request failed", + ) + + assert.True(t, matched) + v, exists := c.Get(OpsSkipPassthroughKey) + assert.True(t, exists, "OpsSkipPassthroughKey should be set when skip_monitoring=true") + assert.True(t, v.(bool)) +} + +func TestApplyErrorPassthroughRule_NoSkipMonitoringDoesNotSetContextKey(t *testing.T) { + gin.SetMode(gin.TestMode) + rec := httptest.NewRecorder() + c, _ := gin.CreateTestContext(rec) + + rule := newNonFailoverPassthroughRule(http.StatusBadRequest, "prompt is too long", http.StatusBadRequest, "上下文超限") + rule.SkipMonitoring = false + + ruleSvc := &ErrorPassthroughService{} + ruleSvc.setLocalCache([]*model.ErrorPassthroughRule{rule}) + BindErrorPassthroughService(c, ruleSvc) + + _, _, _, matched := applyErrorPassthroughRule( + c, + PlatformAnthropic, + http.StatusBadRequest, + []byte(`{"error":{"message":"prompt is too long"}}`), + http.StatusBadGateway, + "upstream_error", + "Upstream request failed", + ) + + assert.True(t, matched) + _, exists := c.Get(OpsSkipPassthroughKey) + assert.False(t, exists, "OpsSkipPassthroughKey should NOT be set when skip_monitoring=false") +} + func newNonFailoverPassthroughRule(statusCode int, keyword string, respCode int, customMessage string) *model.ErrorPassthroughRule { return &model.ErrorPassthroughRule{ ID: 1, diff --git a/backend/internal/service/ops_upstream_context.go b/backend/internal/service/ops_upstream_context.go index 96bcc9fe..65048147 100644 --- a/backend/internal/service/ops_upstream_context.go +++ b/backend/internal/service/ops_upstream_context.go @@ -20,6 +20,10 @@ const ( // retry the specific upstream attempt (not just the client request). // This value is sanitized+trimmed before being persisted. OpsUpstreamRequestBodyKey = "ops_upstream_request_body" + + // OpsSkipPassthroughKey 由 applyErrorPassthroughRule 在命中 skip_monitoring=true 的规则时设置。 + // ops_error_logger 中间件检查此 key,为 true 时跳过错误记录。 + OpsSkipPassthroughKey = "ops_skip_passthrough" ) func setOpsUpstreamError(c *gin.Context, upstreamStatusCode int, upstreamMessage, upstreamDetail string) { diff --git a/backend/migrations/053_add_skip_monitoring_to_error_passthrough.sql b/backend/migrations/053_add_skip_monitoring_to_error_passthrough.sql new file mode 100644 index 00000000..71dbf181 --- /dev/null +++ b/backend/migrations/053_add_skip_monitoring_to_error_passthrough.sql @@ -0,0 +1,4 @@ +-- Add skip_monitoring field to error_passthrough_rules table +-- When true, errors matching this rule will not be recorded in ops_error_logs +ALTER TABLE error_passthrough_rules +ADD COLUMN IF NOT EXISTS skip_monitoring BOOLEAN NOT NULL DEFAULT false; diff --git a/frontend/src/api/admin/errorPassthrough.ts b/frontend/src/api/admin/errorPassthrough.ts index 4c545ad5..e27c5be6 100644 --- a/frontend/src/api/admin/errorPassthrough.ts +++ b/frontend/src/api/admin/errorPassthrough.ts @@ -21,6 +21,7 @@ export interface ErrorPassthroughRule { response_code: number | null passthrough_body: boolean custom_message: string | null + skip_monitoring: boolean description: string | null created_at: string updated_at: string @@ -41,6 +42,7 @@ export interface CreateRuleRequest { response_code?: number | null passthrough_body?: boolean custom_message?: string | null + skip_monitoring?: boolean description?: string | null } @@ -59,6 +61,7 @@ export interface UpdateRuleRequest { response_code?: number | null passthrough_body?: boolean custom_message?: string | null + skip_monitoring?: boolean description?: string | null } diff --git a/frontend/src/components/admin/ErrorPassthroughRulesModal.vue b/frontend/src/components/admin/ErrorPassthroughRulesModal.vue index b93319c5..2ed6ded3 100644 --- a/frontend/src/components/admin/ErrorPassthroughRulesModal.vue +++ b/frontend/src/components/admin/ErrorPassthroughRulesModal.vue @@ -148,6 +148,16 @@ {{ rule.passthrough_body ? t('admin.errorPassthrough.passthrough') : t('admin.errorPassthrough.custom') }} +
+ + + {{ t('admin.errorPassthrough.skipMonitoring') }} + +
@@ -366,6 +376,19 @@ + +
+ + + {{ t('admin.errorPassthrough.form.skipMonitoring') }} + +
+

{{ t('admin.errorPassthrough.form.skipMonitoringHint') }}

+
{ form.response_code = null form.passthrough_body = true form.custom_message = null + form.skip_monitoring = false form.description = null errorCodesInput.value = '' keywordsInput.value = '' @@ -520,6 +545,7 @@ const handleEdit = (rule: ErrorPassthroughRule) => { form.response_code = rule.response_code form.passthrough_body = rule.passthrough_body form.custom_message = rule.custom_message + form.skip_monitoring = rule.skip_monitoring form.description = rule.description errorCodesInput.value = rule.error_codes.join(', ') keywordsInput.value = rule.keywords.join('\n') @@ -575,6 +601,7 @@ const handleSubmit = async () => { response_code: form.passthrough_code ? null : form.response_code, passthrough_body: form.passthrough_body, custom_message: form.passthrough_body ? null : form.custom_message, + skip_monitoring: form.skip_monitoring, description: form.description?.trim() || null } diff --git a/frontend/src/i18n/locales/en.ts b/frontend/src/i18n/locales/en.ts index dc53e697..5d9d21b7 100644 --- a/frontend/src/i18n/locales/en.ts +++ b/frontend/src/i18n/locales/en.ts @@ -3353,6 +3353,7 @@ export default { custom: 'Custom', code: 'Code', body: 'Body', + skipMonitoring: 'Skip Monitoring', // Columns columns: { @@ -3397,6 +3398,8 @@ export default { passthroughBody: 'Passthrough upstream error message', customMessage: 'Custom error message', customMessagePlaceholder: 'Error message to return to client...', + skipMonitoring: 'Skip monitoring', + skipMonitoringHint: 'When enabled, errors matching this rule will not be recorded in ops monitoring', enabled: 'Enable this rule' }, diff --git a/frontend/src/i18n/locales/zh.ts b/frontend/src/i18n/locales/zh.ts index 728d7744..84f7ee76 100644 --- a/frontend/src/i18n/locales/zh.ts +++ b/frontend/src/i18n/locales/zh.ts @@ -3527,6 +3527,7 @@ export default { custom: '自定义', code: '状态码', body: '消息体', + skipMonitoring: '跳过监控', // Columns columns: { @@ -3571,6 +3572,8 @@ export default { passthroughBody: '透传上游错误信息', customMessage: '自定义错误信息', customMessagePlaceholder: '返回给客户端的错误信息...', + skipMonitoring: '跳过运维监控记录', + skipMonitoringHint: '开启后,匹配此规则的错误不会被记录到运维监控中', enabled: '启用此规则' }, From 1f647b120abade49da01611d523dfae78546ab65 Mon Sep 17 00:00:00 2001 From: song Date: Tue, 10 Feb 2026 13:51:29 +0800 Subject: [PATCH 06/18] =?UTF-8?q?feat(antigravity):=20=E8=BD=AC=E5=8F=91?= =?UTF-8?q?=E4=B8=8E=E6=B5=8B=E8=AF=95=E6=94=AF=E6=8C=81daily/prod?= =?UTF-8?q?=E5=8D=95URL=E5=88=87=E6=8D=A2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../service/antigravity_gateway_service.go | 30 ++++++++++++++----- .../service/antigravity_rate_limit_test.go | 16 ++++++++++ 2 files changed, 39 insertions(+), 7 deletions(-) diff --git a/backend/internal/service/antigravity_gateway_service.go b/backend/internal/service/antigravity_gateway_service.go index b6d0da06..65cf8c93 100644 --- a/backend/internal/service/antigravity_gateway_service.go +++ b/backend/internal/service/antigravity_gateway_service.go @@ -70,6 +70,7 @@ var antigravityPassthroughErrorMessages = []string{ const ( antigravityBillingModelEnv = "GATEWAY_ANTIGRAVITY_BILL_WITH_MAPPED_MODEL" + antigravityForwardBaseURLEnv = "GATEWAY_ANTIGRAVITY_FORWARD_BASE_URL" antigravityFallbackSecondsEnv = "GATEWAY_ANTIGRAVITY_FALLBACK_COOLDOWN_SECONDS" ) @@ -131,6 +132,20 @@ type antigravityRetryLoopResult struct { resp *http.Response } +// resolveAntigravityForwardBaseURL 解析转发用 base URL。 +// 默认使用 daily(ForwardBaseURLs 的首个地址);当环境变量为 prod 时使用第二个地址。 +func resolveAntigravityForwardBaseURL() string { + baseURLs := antigravity.ForwardBaseURLs() + if len(baseURLs) == 0 { + return "" + } + mode := strings.ToLower(strings.TrimSpace(os.Getenv(antigravityForwardBaseURLEnv))) + if mode == "prod" && len(baseURLs) > 1 { + return baseURLs[1] + } + return baseURLs[0] +} + // smartRetryAction 智能重试的处理结果 type smartRetryAction int @@ -466,10 +481,11 @@ func (s *AntigravityGatewayService) antigravityRetryLoop(p antigravityRetryLoopP } } - availableURLs := antigravity.DefaultURLAvailability.GetAvailableURLs() - if len(availableURLs) == 0 { - availableURLs = antigravity.BaseURLs + baseURL := resolveAntigravityForwardBaseURL() + if baseURL == "" { + return nil, errors.New("no antigravity forward base url configured") } + availableURLs := []string{baseURL} var resp *http.Response var usedBaseURL string @@ -907,11 +923,11 @@ func (s *AntigravityGatewayService) TestConnection(ctx context.Context, account proxyURL = account.Proxy.URL() } - // URL fallback 循环 - availableURLs := antigravity.DefaultURLAvailability.GetAvailableURLs() - if len(availableURLs) == 0 { - availableURLs = antigravity.BaseURLs // 所有 URL 都不可用时,重试所有 + baseURL := resolveAntigravityForwardBaseURL() + if baseURL == "" { + return nil, errors.New("no antigravity forward base url configured") } + availableURLs := []string{baseURL} var lastErr error for urlIdx, baseURL := range availableURLs { diff --git a/backend/internal/service/antigravity_rate_limit_test.go b/backend/internal/service/antigravity_rate_limit_test.go index 59cc9331..0484207c 100644 --- a/backend/internal/service/antigravity_rate_limit_test.go +++ b/backend/internal/service/antigravity_rate_limit_test.go @@ -915,6 +915,22 @@ func TestIsAntigravityAccountSwitchError(t *testing.T) { } } +func TestResolveAntigravityForwardBaseURL_DefaultDaily(t *testing.T) { + t.Setenv(antigravityForwardBaseURLEnv, "") + + oldBaseURLs := append([]string(nil), antigravity.BaseURLs...) + defer func() { + antigravity.BaseURLs = oldBaseURLs + }() + + prodURL := "https://prod.test" + dailyURL := "https://daily.test" + antigravity.BaseURLs = []string{dailyURL, prodURL} + + resolved := resolveAntigravityForwardBaseURL() + require.Equal(t, dailyURL, resolved) +} + func TestAntigravityAccountSwitchError_Error(t *testing.T) { err := &AntigravityAccountSwitchError{ OriginalAccountID: 789, From b161312183950a9a6cccda22947ef14529eb229f Mon Sep 17 00:00:00 2001 From: song Date: Tue, 10 Feb 2026 14:36:09 +0800 Subject: [PATCH 07/18] =?UTF-8?q?test(antigravity):=20=E6=9B=B4=E6=96=B0?= =?UTF-8?q?=E5=8D=95URL=E7=AD=96=E7=95=A5=E4=B8=8B=E7=9A=84=E9=87=8D?= =?UTF-8?q?=E8=AF=95=E6=96=AD=E8=A8=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../service/antigravity_rate_limit_test.go | 17 ++++++++++------- 1 file changed, 10 insertions(+), 7 deletions(-) diff --git a/backend/internal/service/antigravity_rate_limit_test.go b/backend/internal/service/antigravity_rate_limit_test.go index 0484207c..ac41aced 100644 --- a/backend/internal/service/antigravity_rate_limit_test.go +++ b/backend/internal/service/antigravity_rate_limit_test.go @@ -86,7 +86,9 @@ func (s *stubAntigravityAccountRepo) SetModelRateLimit(ctx context.Context, id i return nil } -func TestAntigravityRetryLoop_URLFallback_UsesLatestSuccess(t *testing.T) { +func TestAntigravityRetryLoop_NoURLFallback_UsesConfiguredBaseURL(t *testing.T) { + t.Setenv(antigravityForwardBaseURLEnv, "") + oldBaseURLs := append([]string(nil), antigravity.BaseURLs...) oldAvailability := antigravity.DefaultURLAvailability defer func() { @@ -131,15 +133,16 @@ func TestAntigravityRetryLoop_URLFallback_UsesLatestSuccess(t *testing.T) { require.NotNil(t, result) require.NotNil(t, result.resp) defer func() { _ = result.resp.Body.Close() }() - require.Equal(t, http.StatusOK, result.resp.StatusCode) - require.False(t, handleErrorCalled) - require.Len(t, upstream.calls, 2) - require.True(t, strings.HasPrefix(upstream.calls[0], base1)) - require.True(t, strings.HasPrefix(upstream.calls[1], base2)) + require.Equal(t, http.StatusTooManyRequests, result.resp.StatusCode) + require.True(t, handleErrorCalled) + require.Len(t, upstream.calls, antigravityMaxRetries) + for _, callURL := range upstream.calls { + require.True(t, strings.HasPrefix(callURL, base1)) + } available := antigravity.DefaultURLAvailability.GetAvailableURLs() require.NotEmpty(t, available) - require.Equal(t, base2, available[0]) + require.Equal(t, base1, available[0]) } // TestHandleUpstreamError_429_ModelRateLimit 测试 429 模型限流场景 From 2d4236f76e55211de02ff7d75f4ad9a089648aac Mon Sep 17 00:00:00 2001 From: Edric Li Date: Tue, 10 Feb 2026 20:56:01 +0800 Subject: [PATCH 08/18] =?UTF-8?q?fix:=20=E4=BF=AE=E5=A4=8D=E9=94=99?= =?UTF-8?q?=E8=AF=AF=E9=80=8F=E4=BC=A0=E8=A7=84=E5=88=99=20skip=5Fmonitori?= =?UTF-8?q?ng=20=E6=9C=AA=E7=94=9F=E6=95=88=E7=9A=84=E9=97=AE=E9=A2=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - ops_error_logger: status < 400 分支增加 OpsSkipPassthroughKey 检查 - ops_upstream_context: 新增 checkSkipMonitoringForUpstreamEvent,中间重试/故障转移事件也能触发跳过标记 - gateway_handler/openai_gateway_handler/gemini_v1beta_handler: handleFailoverExhausted 匹配规则后设置 OpsSkipPassthroughKey - antigravity_gateway_service: writeMappedClaudeError 增加 applyErrorPassthroughRule 调用 --- backend/internal/handler/gateway_handler.go | 4 +++ .../internal/handler/gemini_v1beta_handler.go | 4 +++ .../handler/openai_gateway_handler.go | 4 +++ backend/internal/handler/ops_error_logger.go | 7 +++++ .../service/antigravity_gateway_service.go | 15 +++++++++ .../internal/service/ops_upstream_context.go | 31 +++++++++++++++++++ 6 files changed, 65 insertions(+) diff --git a/backend/internal/handler/gateway_handler.go b/backend/internal/handler/gateway_handler.go index c28ee846..c2b6bf09 100644 --- a/backend/internal/handler/gateway_handler.go +++ b/backend/internal/handler/gateway_handler.go @@ -971,6 +971,10 @@ func (h *GatewayHandler) handleFailoverExhausted(c *gin.Context, failoverErr *se msg = *rule.CustomMessage } + if rule.SkipMonitoring { + c.Set(service.OpsSkipPassthroughKey, true) + } + h.handleStreamingAwareError(c, respCode, "upstream_error", msg, streamStarted) return } diff --git a/backend/internal/handler/gemini_v1beta_handler.go b/backend/internal/handler/gemini_v1beta_handler.go index f8fb0dcb..3d25505b 100644 --- a/backend/internal/handler/gemini_v1beta_handler.go +++ b/backend/internal/handler/gemini_v1beta_handler.go @@ -554,6 +554,10 @@ func (h *GatewayHandler) handleGeminiFailoverExhausted(c *gin.Context, failoverE msg = *rule.CustomMessage } + if rule.SkipMonitoring { + c.Set(service.OpsSkipPassthroughKey, true) + } + googleError(c, respCode, msg) return } diff --git a/backend/internal/handler/openai_gateway_handler.go b/backend/internal/handler/openai_gateway_handler.go index 835297b8..c08a8b0e 100644 --- a/backend/internal/handler/openai_gateway_handler.go +++ b/backend/internal/handler/openai_gateway_handler.go @@ -354,6 +354,10 @@ func (h *OpenAIGatewayHandler) handleFailoverExhausted(c *gin.Context, failoverE msg = *rule.CustomMessage } + if rule.SkipMonitoring { + c.Set(service.OpsSkipPassthroughKey, true) + } + h.handleStreamingAwareError(c, respCode, "upstream_error", msg, streamStarted) return } diff --git a/backend/internal/handler/ops_error_logger.go b/backend/internal/handler/ops_error_logger.go index 2dbf60ad..cb62ceae 100644 --- a/backend/internal/handler/ops_error_logger.go +++ b/backend/internal/handler/ops_error_logger.go @@ -537,6 +537,13 @@ func OpsErrorLoggerMiddleware(ops *service.OpsService) gin.HandlerFunc { // Store request headers/body only when an upstream error occurred to keep overhead minimal. entry.RequestHeadersJSON = extractOpsRetryRequestHeaders(c) + // Skip logging if a passthrough rule with skip_monitoring=true matched. + if v, ok := c.Get(service.OpsSkipPassthroughKey); ok { + if skip, _ := v.(bool); skip { + return + } + } + enqueueOpsErrorLog(ops, entry, requestBody) return } diff --git a/backend/internal/service/antigravity_gateway_service.go b/backend/internal/service/antigravity_gateway_service.go index a517f243..a110f4e0 100644 --- a/backend/internal/service/antigravity_gateway_service.go +++ b/backend/internal/service/antigravity_gateway_service.go @@ -3251,6 +3251,21 @@ func (s *AntigravityGatewayService) writeMappedClaudeError(c *gin.Context, accou log.Printf("[antigravity-Forward] upstream_error status=%d body=%s", upstreamStatus, truncateForLog(body, maxBytes)) } + // 检查错误透传规则 + if ptStatus, ptErrType, ptErrMsg, matched := applyErrorPassthroughRule( + c, account.Platform, upstreamStatus, body, + 0, "", "", + ); matched { + c.JSON(ptStatus, gin.H{ + "type": "error", + "error": gin.H{"type": ptErrType, "message": ptErrMsg}, + }) + if upstreamMsg == "" { + return fmt.Errorf("upstream error: %d", upstreamStatus) + } + return fmt.Errorf("upstream error: %d message=%s", upstreamStatus, upstreamMsg) + } + var statusCode int var errType, errMsg string diff --git a/backend/internal/service/ops_upstream_context.go b/backend/internal/service/ops_upstream_context.go index 65048147..3514df79 100644 --- a/backend/internal/service/ops_upstream_context.go +++ b/backend/internal/service/ops_upstream_context.go @@ -107,6 +107,37 @@ func appendOpsUpstreamError(c *gin.Context, ev OpsUpstreamErrorEvent) { evCopy := ev existing = append(existing, &evCopy) c.Set(OpsUpstreamErrorsKey, existing) + + checkSkipMonitoringForUpstreamEvent(c, &evCopy) +} + +// checkSkipMonitoringForUpstreamEvent checks whether the upstream error event +// matches a passthrough rule with skip_monitoring=true and, if so, sets the +// OpsSkipPassthroughKey on the context. This ensures intermediate retry / +// failover errors (which never go through the final applyErrorPassthroughRule +// path) can still suppress ops_error_logs recording. +func checkSkipMonitoringForUpstreamEvent(c *gin.Context, ev *OpsUpstreamErrorEvent) { + if ev.UpstreamStatusCode == 0 { + return + } + + svc := getBoundErrorPassthroughService(c) + if svc == nil { + return + } + + // Use the best available body representation for keyword matching. + // Even when body is empty, MatchRule can still match rules that only + // specify ErrorCodes (no Keywords), so we always call it. + body := ev.Detail + if body == "" { + body = ev.Message + } + + rule := svc.MatchRule(ev.Platform, ev.UpstreamStatusCode, []byte(body)) + if rule != nil && rule.SkipMonitoring { + c.Set(OpsSkipPassthroughKey, true) + } } func marshalOpsUpstreamErrors(events []*OpsUpstreamErrorEvent) *string { From c4d67154431c94aa04c7f3e8f2fadf48fbdeed20 Mon Sep 17 00:00:00 2001 From: liuxiongfeng Date: Tue, 10 Feb 2026 20:59:54 +0800 Subject: [PATCH 09/18] chore: squash merge customizations from develop-old-0.1.77 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 定制文档: CLAUDE.md, AGENTS.md - UI定制: 微信客服按钮, 首页改造, 移除GitHub链接 - 部署运维: docker-compose.yml, 压测脚本 - CI/gitignore 小改动 --- .github/workflows/backend-ci.yml | 2 + .gitignore | 1 + AGENTS.md | 723 ++++++++++++++++++ CLAUDE.md | 723 ++++++++++++++++++ deploy/docker-compose.yml | 41 +- frontend/public/wechat-qr.jpg | Bin 0 -> 151392 bytes .../components/common/WechatServiceButton.vue | 104 +++ frontend/src/components/layout/AppHeader.vue | 17 - frontend/src/views/HomeView.vue | 173 ++++- stress_test_gemini_session.sh | 127 +++ 10 files changed, 1832 insertions(+), 79 deletions(-) create mode 100644 AGENTS.md create mode 100644 CLAUDE.md create mode 100644 frontend/public/wechat-qr.jpg create mode 100644 frontend/src/components/common/WechatServiceButton.vue create mode 100644 stress_test_gemini_session.sh diff --git a/.github/workflows/backend-ci.yml b/.github/workflows/backend-ci.yml index 2596a18c..84575a96 100644 --- a/.github/workflows/backend-ci.yml +++ b/.github/workflows/backend-ci.yml @@ -17,6 +17,7 @@ jobs: go-version-file: backend/go.mod check-latest: false cache: true + cache-dependency-path: backend/go.sum - name: Verify Go version run: | go version | grep -q 'go1.25.7' @@ -36,6 +37,7 @@ jobs: go-version-file: backend/go.mod check-latest: false cache: true + cache-dependency-path: backend/go.sum - name: Verify Go version run: | go version | grep -q 'go1.25.7' diff --git a/.gitignore b/.gitignore index 48172982..2f2bfbdf 100644 --- a/.gitignore +++ b/.gitignore @@ -78,6 +78,7 @@ Desktop.ini # =================== tmp/ temp/ +logs/ *.tmp *.temp *.log diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 00000000..a7a3e34a --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,723 @@ +# Sub2API 开发说明 + +## 版本管理策略 + +### 版本号规则 + +我们在官方版本号后面添加自己的小版本号: + +- 官方版本:`v0.1.68` +- 我们的版本:`v0.1.68.1`、`v0.1.68.2`(递增) + +### 分支策略 + +| 分支 | 说明 | +|------|------| +| `main` | 我们的主分支,包含所有定制功能 | +| `release/custom-X.Y.Z` | 基于官方 `vX.Y.Z` 的发布分支 | +| `upstream/main` | 上游官方仓库 | + +--- + +## 发布流程(基于新官方版本) + +当官方发布新版本(如 `v0.1.69`)时: + +### 1. 同步上游并创建发布分支 + +```bash +# 获取上游最新代码 +git fetch upstream --tags + +# 基于官方标签创建新的发布分支 +git checkout v0.1.69 -b release/custom-0.1.69 + +# 合并我们的 main 分支(包含所有定制功能) +git merge main --no-edit + +# 解决可能的冲突后继续 +``` + +### 2. 更新版本号并打标签 + +```bash +# 更新版本号文件 +echo "0.1.69.1" > backend/cmd/server/VERSION +git add backend/cmd/server/VERSION +git commit -m "chore: bump version to 0.1.69.1" + +# 打上我们自己的标签 +git tag v0.1.69.1 + +# 推送分支和标签 +git push origin release/custom-0.1.69 +git push origin v0.1.69.1 +``` + +### 3. 更新 main 分支 + +```bash +# 将发布分支合并回 main,保持 main 包含最新定制功能 +git checkout main +git merge release/custom-0.1.69 +git push origin main +``` + +--- + +## 热修复发布(在现有版本上修复) + +当需要在当前版本上发布修复时: + +```bash +# 在当前发布分支上修复 +git checkout release/custom-0.1.68 +# ... 进行修复 ... +git commit -m "fix: 修复描述" + +# 递增小版本号 +echo "0.1.68.2" > backend/cmd/server/VERSION +git add backend/cmd/server/VERSION +git commit -m "chore: bump version to 0.1.68.2" + +# 打标签并推送 +git tag v0.1.68.2 +git push origin release/custom-0.1.68 +git push origin v0.1.68.2 + +# 同步修复到 main +git checkout main +git cherry-pick +git push origin main +``` + +--- + +## 服务器部署流程 + +### 前置条件 + +- 本地已配置 SSH 别名 `clicodeplus` 连接到服务器 +- 服务器部署目录:`/root/sub2api`(正式)、`/root/sub2api-beta`(测试) +- 服务器使用 Docker Compose 部署 + +### 部署环境说明 + +| 环境 | 目录 | 端口 | 数据库 | 容器名 | +|------|------|------|--------|--------| +| 正式 | `/root/sub2api` | 8080 | `sub2api` | `sub2api` | +| Beta | `/root/sub2api-beta` | 8084 | `beta` | `sub2api-beta` | + +### 外部数据库 + +正式和 Beta 环境**共用外部 PostgreSQL 数据库**(非容器内数据库),配置在 `.env` 文件中: +- `DATABASE_HOST`:外部数据库地址 +- `DATABASE_SSLMODE`:SSL 模式(通常为 `require`) +- `POSTGRES_USER` / `POSTGRES_DB`:用户名和数据库名 + +#### 数据库操作命令 + +通过 SSH 在服务器上执行数据库操作: + +```bash +# 正式环境 - 查询迁移记录 +ssh clicodeplus "source /root/sub2api/deploy/.env && PGPASSWORD=\"\$POSTGRES_PASSWORD\" psql -h \$DATABASE_HOST -U \$POSTGRES_USER -d \$POSTGRES_DB -c 'SELECT * FROM schema_migrations ORDER BY applied_at DESC LIMIT 5;'" + +# Beta 环境 - 查询迁移记录 +ssh clicodeplus "source /root/sub2api-beta/deploy/.env && PGPASSWORD=\"\$POSTGRES_PASSWORD\" psql -h \$DATABASE_HOST -U \$POSTGRES_USER -d \$POSTGRES_DB -c 'SELECT * FROM schema_migrations ORDER BY applied_at DESC LIMIT 5;'" + +# Beta 环境 - 清除指定迁移记录(重新执行迁移) +ssh clicodeplus "source /root/sub2api-beta/deploy/.env && PGPASSWORD=\"\$POSTGRES_PASSWORD\" psql -h \$DATABASE_HOST -U \$POSTGRES_USER -d \$POSTGRES_DB -c \"DELETE FROM schema_migrations WHERE filename LIKE '%049%';\"" + +# Beta 环境 - 更新账号数据 +ssh clicodeplus "source /root/sub2api-beta/deploy/.env && PGPASSWORD=\"\$POSTGRES_PASSWORD\" psql -h \$DATABASE_HOST -U \$POSTGRES_USER -d \$POSTGRES_DB -c \"UPDATE accounts SET credentials = credentials - 'model_mapping' WHERE platform = 'antigravity';\"" +``` + +> **注意**:使用 `source .env` 加载环境变量,避免在命令行中暴露密码。 + +### 部署步骤 + +**重要:每次部署都必须递增版本号!** + +#### 0. 递增版本号(本地操作) + +每次部署前,先在本地递增小版本号: + +```bash +# 查看当前版本号 +cat backend/cmd/server/VERSION +# 假设当前是 0.1.69.1 + +# 递增版本号 +echo "0.1.69.2" > backend/cmd/server/VERSION +git add backend/cmd/server/VERSION +git commit -m "chore: bump version to 0.1.69.2" +git push origin release/custom-0.1.69 +``` + +#### 1. 服务器拉取代码 + +```bash +ssh clicodeplus "cd /root/sub2api && git fetch fork && git checkout -B release/custom-0.1.69 fork/release/custom-0.1.69" +``` + +#### 2. 服务器构建镜像 + +```bash +ssh clicodeplus "cd /root/sub2api && docker build --no-cache -t sub2api:latest -f Dockerfile ." +``` + +#### 3. 更新镜像标签并重启服务 + +```bash +ssh clicodeplus "docker tag sub2api:latest weishaw/sub2api:latest" +ssh clicodeplus "cd /root/sub2api/deploy && docker compose up -d --force-recreate sub2api" +``` + +#### 4. 验证部署 + +```bash +# 查看启动日志 +ssh clicodeplus "docker logs sub2api --tail 20" + +# 确认版本号(必须与步骤 0 中设置的版本号一致) +ssh clicodeplus "cat /root/sub2api/backend/cmd/server/VERSION" + +# 检查容器状态 +ssh clicodeplus "docker ps | grep sub2api" +``` + +--- + +## Beta 并行部署(不影响现网) + +目标:在同一台服务器上并行启动一个 beta 实例(例如端口 `8084`),**严禁改动/重启**现网实例(默认目录 `/root/sub2api`)。 + +### 设计原则 + +- **新目录**:beta 使用独立目录,例如 `/root/sub2api-beta`。 +- **敏感信息只放 `.env`**:beta 的数据库密码、JWT_SECRET 等只写入 `/root/sub2api-beta/deploy/.env`,不要提交到 git。 +- **独立 Compose Project**:通过 `docker compose -p sub2api-beta ...` 启动,确保 network/volume 隔离。 +- **独立端口**:通过 `.env` 的 `SERVER_PORT` 映射宿主机端口(例如 `8084:8080`)。 + +### 前置检查 + +```bash +# 1) 确保 8084 未被占用 +ssh clicodeplus "ss -ltnp | grep :8084 || echo '8084 is free'" + +# 2) 确认现网容器还在(只读检查) +ssh clicodeplus "docker ps --format 'table {{.Names}}\t{{.Image}}\t{{.Ports}}' | sed -n '1,200p'" +``` + +### 首次部署步骤 + +```bash +# 0) 进入服务器 +ssh clicodeplus + +# 1) 克隆代码到新目录(示例使用你的 fork) +cd /root +git clone https://github.com/touwaeriol/sub2api.git sub2api-beta +cd /root/sub2api-beta +git checkout release/custom-0.1.71 + +# 2) 准备 beta 的 .env(敏感信息只写这里) +cd /root/sub2api-beta/deploy + +# 推荐:从现网 .env 复制,保证除 DB 名/用户/端口外完全一致 +cp -f /root/sub2api/deploy/.env ./.env + +# 仅修改以下三项(其他保持不变) +perl -pi -e 's/^SERVER_PORT=.*/SERVER_PORT=8084/' ./.env +perl -pi -e 's/^POSTGRES_USER=.*/POSTGRES_USER=beta/' ./.env +perl -pi -e 's/^POSTGRES_DB=.*/POSTGRES_DB=beta/' ./.env + +# 3) 写 compose override(避免与现网容器名冲突,镜像使用本地构建的 sub2api:beta) +cat > docker-compose.override.yml <<'YAML' +services: + sub2api: + image: sub2api:beta + container_name: sub2api-beta + redis: + container_name: sub2api-beta-redis +YAML + +# 4) 构建 beta 镜像(基于当前代码) +cd /root/sub2api-beta +docker build -t sub2api:beta -f Dockerfile . + +# 5) 启动 beta(独立 project,确保不影响现网) +cd /root/sub2api-beta/deploy +docker compose -p sub2api-beta --env-file .env -f docker-compose.yml -f docker-compose.override.yml up -d + +# 6) 验证 beta +curl -fsS http://127.0.0.1:8084/health +docker logs sub2api-beta --tail 50 +``` + +### 数据库配置约定(beta) + +- 数据库地址/SSL/密码:与现网一致(从现网 `.env` 复制即可)。 +- 仅修改: + - `POSTGRES_USER=beta` + - `POSTGRES_DB=beta` + +注意:需要数据库侧已存在 `beta` 用户与 `beta` 数据库,并授予权限;否则容器会启动失败并不断重启。 + +### 更新 beta(拉代码 + 仅重建 beta 容器) + +```bash +ssh clicodeplus "set -e; cd /root/sub2api-beta && git fetch --all --tags && git checkout -f release/custom-0.1.71 && git reset --hard origin/release/custom-0.1.71" +ssh clicodeplus "cd /root/sub2api-beta && docker build -t sub2api:beta -f Dockerfile ." +ssh clicodeplus "cd /root/sub2api-beta/deploy && docker compose -p sub2api-beta --env-file .env -f docker-compose.yml -f docker-compose.override.yml up -d --no-deps --force-recreate sub2api" +ssh clicodeplus "curl -fsS http://127.0.0.1:8084/health" +``` + +### 停止/回滚 beta(只影响 beta) + +```bash +ssh clicodeplus "cd /root/sub2api-beta/deploy && docker compose -p sub2api-beta -f docker-compose.yml -f docker-compose.override.yml down" +``` + +--- + +## 服务器首次部署 + +### 1. 克隆代码并配置远程仓库 + +```bash +ssh clicodeplus +cd /root +git clone https://github.com/Wei-Shaw/sub2api.git +cd sub2api + +# 添加 fork 仓库 +git remote add fork https://github.com/touwaeriol/sub2api.git +``` + +### 2. 切换到定制分支并配置环境 + +```bash +git fetch fork +git checkout -B release/custom-0.1.69 fork/release/custom-0.1.69 + +cd deploy +cp .env.example .env +vim .env # 配置 DATABASE_URL, REDIS_URL, JWT_SECRET 等 +``` + +### 3. 构建并启动 + +```bash +cd /root/sub2api +docker build -t sub2api:latest -f Dockerfile . +docker tag sub2api:latest weishaw/sub2api:latest +cd deploy && docker compose up -d +``` + +### 6. 启动服务 + +```bash +# 进入 deploy 目录 +cd deploy + +# 启动所有服务(PostgreSQL、Redis、sub2api) +docker compose up -d + +# 查看服务状态 +docker compose ps +``` + +### 7. 验证部署 + +```bash +# 查看应用日志 +docker logs sub2api --tail 50 + +# 检查健康状态 +curl http://localhost:8080/health + +# 确认版本号 +cat /root/sub2api/backend/cmd/server/VERSION +``` + +### 8. 常用运维命令 + +```bash +# 查看实时日志 +docker logs -f sub2api + +# 重启服务 +docker compose restart sub2api + +# 停止所有服务 +docker compose down + +# 停止并删除数据卷(慎用!会删除数据库数据) +docker compose down -v + +# 查看资源使用情况 +docker stats sub2api +``` + +--- + +## 定制功能说明 + +当前定制分支包含以下功能(相对于官方版本): + +### UI/UX 定制 + +| 功能 | 说明 | +|------|------| +| 首页优化 | 面向用户的价值主张设计 | +| 移除 GitHub 链接 | 用户菜单中不显示 GitHub 导航 | +| 微信客服按钮 | 首页悬浮微信客服入口 | +| 限流时间精确显示 | 账号限流时间显示精确到秒 | + +### Antigravity 平台增强 + +| 功能 | 说明 | +|------|------| +| Scope 级别限流 | 按配额域(claude/gemini_text/gemini_image)独立限流,避免整个账号被锁定 | +| 模型级别限流 | 按具体模型(如 claude-opus-4-5)独立限流,更精细的限流控制 | +| 限流预检查 | 调度时预检查账号/模型限流状态,避免选中已限流账号 | +| 秒级冷却时间 | 支持 429 响应的秒级精确冷却时间 | +| 身份注入优化 | 模型身份信息注入 + 静默边界防止身份泄露 | +| thoughtSignature 修复 | Gemini 3 函数调用 400 错误修复 | +| max_tokens 自动修正 | 自动修正 max_tokens <= budget_tokens 导致的 400 错误 | + +### 调度算法优化 + +| 功能 | 说明 | +|------|------| +| 分层过滤选择 | 调度算法从全排序改为分层过滤,提升性能 | +| LRU 随机选择 | 相同 LRU 时间时随机选择,避免账号集中 | +| 限流等待阈值配置化 | 可配置的限流等待阈值 | + +### 运维增强 + +| 功能 | 说明 | +|------|------| +| Scope 限流统计 | 运维界面展示 Antigravity 账号 scope 级别限流统计 | +| 账号限流状态显示 | 账号列表显示 scope 和模型级别限流状态 | +| 清除限流按钮增强 | 有 scope/模型限流时也显示清除限流按钮 | + +### 其他修复 + +| 功能 | 说明 | +|------|------| +| .gitattributes | 确保迁移文件使用 LF 换行符(解决 Windows 下 SQL 摘要不一致) | +| 部署配置优化 | DATABASE_HOST 和 DATABASE_SSLMODE 可通过 .env 配置 | + +--- + +## 注意事项 + +1. **前端必须打包进镜像**:使用 `docker build` 在服务器上构建,Dockerfile 会自动编译前端并 embed 到后端二进制中 + +2. **镜像标签**:docker-compose.yml 使用 `weishaw/sub2api:latest`,本地构建后需要 `docker tag` 覆盖 + +3. **Windows 换行符问题**:已通过 `.gitattributes` 解决,确保 `*.sql` 文件始终使用 LF + +4. **版本号管理**:每次发布必须更新 `backend/cmd/server/VERSION` 并打标签 + +5. **合并冲突**:合并上游新版本时,重点关注以下文件可能的冲突: + - `backend/internal/service/antigravity_gateway_service.go` + - `backend/internal/service/gateway_service.go` + - `backend/internal/pkg/antigravity/request_transformer.go` + +--- + +## Go 代码规范 + +### 1. 函数设计 + +#### 单一职责原则 +- **函数行数**:单个函数常规不应超过 **30 行**,超过时应拆分为子函数。若某段逻辑确实不可拆分(如复杂的状态机、协议解析等),可以例外,但需添加注释说明原因 +- **嵌套层级**:避免超过 3 层嵌套,使用 early return 减少嵌套 + +```go +// ❌ 不推荐:深层嵌套 +func process(data []Item) { + for _, item := range data { + if item.Valid { + if item.Type == "A" { + if item.Status == "active" { + // 业务逻辑... + } + } + } + } +} + +// ✅ 推荐:early return +func process(data []Item) { + for _, item := range data { + if !item.Valid { + continue + } + if item.Type != "A" { + continue + } + if item.Status != "active" { + continue + } + // 业务逻辑... + } +} +``` + +#### 复杂逻辑提取 +将复杂的条件判断或处理逻辑提取为独立函数: + +```go +// ❌ 不推荐:内联复杂逻辑 +if resp.StatusCode == 429 || resp.StatusCode == 503 { + // 80+ 行处理逻辑... +} + +// ✅ 推荐:提取为独立函数 +result := handleRateLimitResponse(resp, params) +switch result.action { +case actionRetry: + continue +case actionBreak: + return result.resp, nil +} +``` + +### 2. 重复代码消除 + +#### 配置获取模式 +将重复的配置获取逻辑提取为方法: + +```go +// ❌ 不推荐:重复代码 +logBody := s.settingService != nil && s.settingService.cfg != nil && s.settingService.cfg.Gateway.LogUpstreamErrorBody +maxBytes := 2048 +if s.settingService != nil && s.settingService.cfg != nil && s.settingService.cfg.Gateway.LogUpstreamErrorBodyMaxBytes > 0 { + maxBytes = s.settingService.cfg.Gateway.LogUpstreamErrorBodyMaxBytes +} + +// ✅ 推荐:提取为方法 +func (s *Service) getLogConfig() (logBody bool, maxBytes int) { + maxBytes = 2048 + if s.settingService == nil || s.settingService.cfg == nil { + return false, maxBytes + } + cfg := s.settingService.cfg.Gateway + if cfg.LogUpstreamErrorBodyMaxBytes > 0 { + maxBytes = cfg.LogUpstreamErrorBodyMaxBytes + } + return cfg.LogUpstreamErrorBody, maxBytes +} +``` + +### 3. 常量管理 + +#### 避免魔法数字 +所有硬编码的数值都应定义为常量: + +```go +// ❌ 不推荐 +if retryDelay >= 10*time.Second { + resetAt := time.Now().Add(30 * time.Second) +} + +// ✅ 推荐 +const ( + rateLimitThreshold = 10 * time.Second + defaultRateLimitDuration = 30 * time.Second +) + +if retryDelay >= rateLimitThreshold { + resetAt := time.Now().Add(defaultRateLimitDuration) +} +``` + +#### 注释引用常量名 +在注释中引用常量名而非硬编码值: + +```go +// ❌ 不推荐 +// < 10s: 等待后重试 + +// ✅ 推荐 +// < rateLimitThreshold: 等待后重试 +``` + +### 4. 错误处理 + +#### 使用结构化日志 +优先使用 `slog` 进行结构化日志记录: + +```go +// ❌ 不推荐 +log.Printf("%s status=%d model_rate_limit_failed model=%s error=%v", prefix, statusCode, modelName, err) + +// ✅ 推荐 +slog.Error("failed to set model rate limit", + "prefix", prefix, + "status_code", statusCode, + "model", modelName, + "error", err, +) +``` + +### 5. 测试规范 + +#### Mock 函数签名同步 +修改函数签名时,必须同步更新所有测试中的 mock 函数: + +```go +// 如果修改了 handleError 签名 +handleError func(..., groupID int64, sessionHash string) *Result + +// 必须同步更新测试中的 mock +handleError: func(..., groupID int64, sessionHash string) *Result { + return nil +}, +``` + +#### 测试构建标签 +统一使用测试构建标签: + +```go +//go:build unit + +package service +``` + +### 6. 时间格式解析 + +#### 使用标准库 +优先使用 `time.ParseDuration`,支持所有 Go duration 格式: + +```go +// ❌ 不推荐:手动限制格式 +if !strings.HasSuffix(delay, "s") || strings.Contains(delay, "m") { + continue +} + +// ✅ 推荐:使用标准库 +dur, err := time.ParseDuration(delay) // 支持 "0.5s", "4m50s", "1h30m" 等 +``` + +### 7. 接口设计 + +#### 接口隔离原则 +定义最小化接口,只包含必需的方法: + +```go +// ❌ 不推荐:使用过于宽泛的接口 +type AccountRepository interface { + // 20+ 个方法... +} + +// ✅ 推荐:定义最小化接口 +type ModelRateLimiter interface { + SetModelRateLimit(ctx context.Context, id int64, modelKey string, resetAt time.Time) error +} +``` + +### 8. 并发安全 + +#### 共享数据保护 +访问可能被并发修改的数据时,确保线程安全: + +```go +// 如果 Account.Extra 可能被并发修改 +// 需要使用互斥锁或原子操作保护读取 +func (a *Account) GetRateLimitRemainingTime(model string) time.Duration { + a.mu.RLock() + defer a.mu.RUnlock() + // 读取 Extra 字段... +} +``` + +### 9. 命名规范 + +#### 一致的命名风格 +- 常量使用 camelCase:`rateLimitThreshold` +- 类型使用 PascalCase:`AntigravityQuotaScope` +- 同一概念使用统一命名:`Threshold` 或 `Limit`,不要混用 + +```go +// ❌ 不推荐:命名不一致 +antigravitySmartRetryMinWait // 使用 Min +antigravityRateLimitThreshold // 使用 Threshold + +// ✅ 推荐:统一风格 +antigravityMinRetryWait +antigravityRateLimitThreshold +``` + +### 10. 代码审查清单 + +在提交代码前,检查以下项目: + +- [ ] 函数是否超过 30 行?(不可拆分的逻辑除外,需注释说明) +- [ ] 嵌套是否超过 3 层? +- [ ] 是否有重复代码可以提取? +- [ ] 是否使用了魔法数字? +- [ ] Mock 函数签名是否与实际函数一致? +- [ ] 测试是否覆盖了新增逻辑? +- [ ] 日志是否包含足够的上下文信息? +- [ ] 是否考虑了并发安全? + +--- + +## CI 检查与发布门禁 + +### GitHub Actions 检查项 + +本项目有 4 个 CI 任务,**任何代码推送或发布前都必须全部通过**: + +| Workflow | Job | 说明 | 本地验证命令 | +|----------|-----|------|-------------| +| CI | `test` | 单元测试 + 集成测试 | `cd backend && make test-unit && make test-integration` | +| CI | `golangci-lint` | Go 代码静态检查(golangci-lint v2.7) | `cd backend && golangci-lint run --timeout=5m` | +| Security Scan | `backend-security` | govulncheck + gosec 安全扫描 | `cd backend && govulncheck ./... && gosec -severity high -confidence high ./...` | +| Security Scan | `frontend-security` | pnpm audit 前端依赖安全检查 | `cd frontend && pnpm audit --prod --audit-level=high` | + +### 向上游提交 PR + +PR 目标是上游官方仓库,**只包含通用功能改动**(bug fix、新功能、性能优化等)。 + +**以下文件禁止出现在 PR 中**(属于我们 fork 的定制化内容): +- `CLAUDE.md`、`AGENTS.md` — 我们的开发文档 +- `backend/cmd/server/VERSION` — 我们的版本号文件 +- UI 定制改动(GitHub 链接移除、微信客服按钮、首页定制等) +- 部署配置(`deploy/` 目录下的定制修改) + +**PR 流程**: +1. 从 `develop` 创建功能分支,只包含要提交给上游的改动 +2. 推送分支后,**等待 4 个 CI job 全部通过** +3. 确认通过后再创建 PR +4. 使用 `gh run list --repo touwaeriol/sub2api --branch ` 检查状态 + +### 自有分支推送(develop / main) + +推送到我们自己的 `develop` 或 `main` 分支时,包含所有改动(定制化 + 通用功能)。 + +**推送流程**: +1. 本地运行 `cd backend && make test-unit` 确保单元测试通过 +2. 本地运行 `cd backend && gofmt -l ./...` 确保格式正确 +3. 推送后确认 CI 和 Security Scan 两个 workflow 的 4 个 job 全部绿色 ✅ +4. 任何 job 失败必须立即修复,**禁止在 CI 未通过的状态下继续后续操作** + +### 发布版本 + +1. 确保 `main` 分支最新提交的 4 个 CI job 全部通过 +2. 递增 `backend/cmd/server/VERSION`,提交并推送 +3. 打 tag 推送后,确认 tag 触发的 3 个 workflow(CI、Security Scan、Release)全部通过 +4. **Release workflow 失败时禁止部署** — 必须先修复问题,删除旧 tag,重新打 tag +5. 使用 `gh run list --repo touwaeriol/sub2api --limit 10` 确认状态 + +### 常见 CI 失败原因及修复 +- **gofmt**:struct 字段对齐不一致 → 运行 `gofmt -w ` 修复 +- **golangci-lint**:未使用的变量/导入 → 删除或使用 `_` 忽略 +- **test 失败**:mock 函数签名不一致 → 同步更新 mock +- **gosec**:安全漏洞 → 根据提示修复或添加例外 diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 00000000..a7a3e34a --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,723 @@ +# Sub2API 开发说明 + +## 版本管理策略 + +### 版本号规则 + +我们在官方版本号后面添加自己的小版本号: + +- 官方版本:`v0.1.68` +- 我们的版本:`v0.1.68.1`、`v0.1.68.2`(递增) + +### 分支策略 + +| 分支 | 说明 | +|------|------| +| `main` | 我们的主分支,包含所有定制功能 | +| `release/custom-X.Y.Z` | 基于官方 `vX.Y.Z` 的发布分支 | +| `upstream/main` | 上游官方仓库 | + +--- + +## 发布流程(基于新官方版本) + +当官方发布新版本(如 `v0.1.69`)时: + +### 1. 同步上游并创建发布分支 + +```bash +# 获取上游最新代码 +git fetch upstream --tags + +# 基于官方标签创建新的发布分支 +git checkout v0.1.69 -b release/custom-0.1.69 + +# 合并我们的 main 分支(包含所有定制功能) +git merge main --no-edit + +# 解决可能的冲突后继续 +``` + +### 2. 更新版本号并打标签 + +```bash +# 更新版本号文件 +echo "0.1.69.1" > backend/cmd/server/VERSION +git add backend/cmd/server/VERSION +git commit -m "chore: bump version to 0.1.69.1" + +# 打上我们自己的标签 +git tag v0.1.69.1 + +# 推送分支和标签 +git push origin release/custom-0.1.69 +git push origin v0.1.69.1 +``` + +### 3. 更新 main 分支 + +```bash +# 将发布分支合并回 main,保持 main 包含最新定制功能 +git checkout main +git merge release/custom-0.1.69 +git push origin main +``` + +--- + +## 热修复发布(在现有版本上修复) + +当需要在当前版本上发布修复时: + +```bash +# 在当前发布分支上修复 +git checkout release/custom-0.1.68 +# ... 进行修复 ... +git commit -m "fix: 修复描述" + +# 递增小版本号 +echo "0.1.68.2" > backend/cmd/server/VERSION +git add backend/cmd/server/VERSION +git commit -m "chore: bump version to 0.1.68.2" + +# 打标签并推送 +git tag v0.1.68.2 +git push origin release/custom-0.1.68 +git push origin v0.1.68.2 + +# 同步修复到 main +git checkout main +git cherry-pick +git push origin main +``` + +--- + +## 服务器部署流程 + +### 前置条件 + +- 本地已配置 SSH 别名 `clicodeplus` 连接到服务器 +- 服务器部署目录:`/root/sub2api`(正式)、`/root/sub2api-beta`(测试) +- 服务器使用 Docker Compose 部署 + +### 部署环境说明 + +| 环境 | 目录 | 端口 | 数据库 | 容器名 | +|------|------|------|--------|--------| +| 正式 | `/root/sub2api` | 8080 | `sub2api` | `sub2api` | +| Beta | `/root/sub2api-beta` | 8084 | `beta` | `sub2api-beta` | + +### 外部数据库 + +正式和 Beta 环境**共用外部 PostgreSQL 数据库**(非容器内数据库),配置在 `.env` 文件中: +- `DATABASE_HOST`:外部数据库地址 +- `DATABASE_SSLMODE`:SSL 模式(通常为 `require`) +- `POSTGRES_USER` / `POSTGRES_DB`:用户名和数据库名 + +#### 数据库操作命令 + +通过 SSH 在服务器上执行数据库操作: + +```bash +# 正式环境 - 查询迁移记录 +ssh clicodeplus "source /root/sub2api/deploy/.env && PGPASSWORD=\"\$POSTGRES_PASSWORD\" psql -h \$DATABASE_HOST -U \$POSTGRES_USER -d \$POSTGRES_DB -c 'SELECT * FROM schema_migrations ORDER BY applied_at DESC LIMIT 5;'" + +# Beta 环境 - 查询迁移记录 +ssh clicodeplus "source /root/sub2api-beta/deploy/.env && PGPASSWORD=\"\$POSTGRES_PASSWORD\" psql -h \$DATABASE_HOST -U \$POSTGRES_USER -d \$POSTGRES_DB -c 'SELECT * FROM schema_migrations ORDER BY applied_at DESC LIMIT 5;'" + +# Beta 环境 - 清除指定迁移记录(重新执行迁移) +ssh clicodeplus "source /root/sub2api-beta/deploy/.env && PGPASSWORD=\"\$POSTGRES_PASSWORD\" psql -h \$DATABASE_HOST -U \$POSTGRES_USER -d \$POSTGRES_DB -c \"DELETE FROM schema_migrations WHERE filename LIKE '%049%';\"" + +# Beta 环境 - 更新账号数据 +ssh clicodeplus "source /root/sub2api-beta/deploy/.env && PGPASSWORD=\"\$POSTGRES_PASSWORD\" psql -h \$DATABASE_HOST -U \$POSTGRES_USER -d \$POSTGRES_DB -c \"UPDATE accounts SET credentials = credentials - 'model_mapping' WHERE platform = 'antigravity';\"" +``` + +> **注意**:使用 `source .env` 加载环境变量,避免在命令行中暴露密码。 + +### 部署步骤 + +**重要:每次部署都必须递增版本号!** + +#### 0. 递增版本号(本地操作) + +每次部署前,先在本地递增小版本号: + +```bash +# 查看当前版本号 +cat backend/cmd/server/VERSION +# 假设当前是 0.1.69.1 + +# 递增版本号 +echo "0.1.69.2" > backend/cmd/server/VERSION +git add backend/cmd/server/VERSION +git commit -m "chore: bump version to 0.1.69.2" +git push origin release/custom-0.1.69 +``` + +#### 1. 服务器拉取代码 + +```bash +ssh clicodeplus "cd /root/sub2api && git fetch fork && git checkout -B release/custom-0.1.69 fork/release/custom-0.1.69" +``` + +#### 2. 服务器构建镜像 + +```bash +ssh clicodeplus "cd /root/sub2api && docker build --no-cache -t sub2api:latest -f Dockerfile ." +``` + +#### 3. 更新镜像标签并重启服务 + +```bash +ssh clicodeplus "docker tag sub2api:latest weishaw/sub2api:latest" +ssh clicodeplus "cd /root/sub2api/deploy && docker compose up -d --force-recreate sub2api" +``` + +#### 4. 验证部署 + +```bash +# 查看启动日志 +ssh clicodeplus "docker logs sub2api --tail 20" + +# 确认版本号(必须与步骤 0 中设置的版本号一致) +ssh clicodeplus "cat /root/sub2api/backend/cmd/server/VERSION" + +# 检查容器状态 +ssh clicodeplus "docker ps | grep sub2api" +``` + +--- + +## Beta 并行部署(不影响现网) + +目标:在同一台服务器上并行启动一个 beta 实例(例如端口 `8084`),**严禁改动/重启**现网实例(默认目录 `/root/sub2api`)。 + +### 设计原则 + +- **新目录**:beta 使用独立目录,例如 `/root/sub2api-beta`。 +- **敏感信息只放 `.env`**:beta 的数据库密码、JWT_SECRET 等只写入 `/root/sub2api-beta/deploy/.env`,不要提交到 git。 +- **独立 Compose Project**:通过 `docker compose -p sub2api-beta ...` 启动,确保 network/volume 隔离。 +- **独立端口**:通过 `.env` 的 `SERVER_PORT` 映射宿主机端口(例如 `8084:8080`)。 + +### 前置检查 + +```bash +# 1) 确保 8084 未被占用 +ssh clicodeplus "ss -ltnp | grep :8084 || echo '8084 is free'" + +# 2) 确认现网容器还在(只读检查) +ssh clicodeplus "docker ps --format 'table {{.Names}}\t{{.Image}}\t{{.Ports}}' | sed -n '1,200p'" +``` + +### 首次部署步骤 + +```bash +# 0) 进入服务器 +ssh clicodeplus + +# 1) 克隆代码到新目录(示例使用你的 fork) +cd /root +git clone https://github.com/touwaeriol/sub2api.git sub2api-beta +cd /root/sub2api-beta +git checkout release/custom-0.1.71 + +# 2) 准备 beta 的 .env(敏感信息只写这里) +cd /root/sub2api-beta/deploy + +# 推荐:从现网 .env 复制,保证除 DB 名/用户/端口外完全一致 +cp -f /root/sub2api/deploy/.env ./.env + +# 仅修改以下三项(其他保持不变) +perl -pi -e 's/^SERVER_PORT=.*/SERVER_PORT=8084/' ./.env +perl -pi -e 's/^POSTGRES_USER=.*/POSTGRES_USER=beta/' ./.env +perl -pi -e 's/^POSTGRES_DB=.*/POSTGRES_DB=beta/' ./.env + +# 3) 写 compose override(避免与现网容器名冲突,镜像使用本地构建的 sub2api:beta) +cat > docker-compose.override.yml <<'YAML' +services: + sub2api: + image: sub2api:beta + container_name: sub2api-beta + redis: + container_name: sub2api-beta-redis +YAML + +# 4) 构建 beta 镜像(基于当前代码) +cd /root/sub2api-beta +docker build -t sub2api:beta -f Dockerfile . + +# 5) 启动 beta(独立 project,确保不影响现网) +cd /root/sub2api-beta/deploy +docker compose -p sub2api-beta --env-file .env -f docker-compose.yml -f docker-compose.override.yml up -d + +# 6) 验证 beta +curl -fsS http://127.0.0.1:8084/health +docker logs sub2api-beta --tail 50 +``` + +### 数据库配置约定(beta) + +- 数据库地址/SSL/密码:与现网一致(从现网 `.env` 复制即可)。 +- 仅修改: + - `POSTGRES_USER=beta` + - `POSTGRES_DB=beta` + +注意:需要数据库侧已存在 `beta` 用户与 `beta` 数据库,并授予权限;否则容器会启动失败并不断重启。 + +### 更新 beta(拉代码 + 仅重建 beta 容器) + +```bash +ssh clicodeplus "set -e; cd /root/sub2api-beta && git fetch --all --tags && git checkout -f release/custom-0.1.71 && git reset --hard origin/release/custom-0.1.71" +ssh clicodeplus "cd /root/sub2api-beta && docker build -t sub2api:beta -f Dockerfile ." +ssh clicodeplus "cd /root/sub2api-beta/deploy && docker compose -p sub2api-beta --env-file .env -f docker-compose.yml -f docker-compose.override.yml up -d --no-deps --force-recreate sub2api" +ssh clicodeplus "curl -fsS http://127.0.0.1:8084/health" +``` + +### 停止/回滚 beta(只影响 beta) + +```bash +ssh clicodeplus "cd /root/sub2api-beta/deploy && docker compose -p sub2api-beta -f docker-compose.yml -f docker-compose.override.yml down" +``` + +--- + +## 服务器首次部署 + +### 1. 克隆代码并配置远程仓库 + +```bash +ssh clicodeplus +cd /root +git clone https://github.com/Wei-Shaw/sub2api.git +cd sub2api + +# 添加 fork 仓库 +git remote add fork https://github.com/touwaeriol/sub2api.git +``` + +### 2. 切换到定制分支并配置环境 + +```bash +git fetch fork +git checkout -B release/custom-0.1.69 fork/release/custom-0.1.69 + +cd deploy +cp .env.example .env +vim .env # 配置 DATABASE_URL, REDIS_URL, JWT_SECRET 等 +``` + +### 3. 构建并启动 + +```bash +cd /root/sub2api +docker build -t sub2api:latest -f Dockerfile . +docker tag sub2api:latest weishaw/sub2api:latest +cd deploy && docker compose up -d +``` + +### 6. 启动服务 + +```bash +# 进入 deploy 目录 +cd deploy + +# 启动所有服务(PostgreSQL、Redis、sub2api) +docker compose up -d + +# 查看服务状态 +docker compose ps +``` + +### 7. 验证部署 + +```bash +# 查看应用日志 +docker logs sub2api --tail 50 + +# 检查健康状态 +curl http://localhost:8080/health + +# 确认版本号 +cat /root/sub2api/backend/cmd/server/VERSION +``` + +### 8. 常用运维命令 + +```bash +# 查看实时日志 +docker logs -f sub2api + +# 重启服务 +docker compose restart sub2api + +# 停止所有服务 +docker compose down + +# 停止并删除数据卷(慎用!会删除数据库数据) +docker compose down -v + +# 查看资源使用情况 +docker stats sub2api +``` + +--- + +## 定制功能说明 + +当前定制分支包含以下功能(相对于官方版本): + +### UI/UX 定制 + +| 功能 | 说明 | +|------|------| +| 首页优化 | 面向用户的价值主张设计 | +| 移除 GitHub 链接 | 用户菜单中不显示 GitHub 导航 | +| 微信客服按钮 | 首页悬浮微信客服入口 | +| 限流时间精确显示 | 账号限流时间显示精确到秒 | + +### Antigravity 平台增强 + +| 功能 | 说明 | +|------|------| +| Scope 级别限流 | 按配额域(claude/gemini_text/gemini_image)独立限流,避免整个账号被锁定 | +| 模型级别限流 | 按具体模型(如 claude-opus-4-5)独立限流,更精细的限流控制 | +| 限流预检查 | 调度时预检查账号/模型限流状态,避免选中已限流账号 | +| 秒级冷却时间 | 支持 429 响应的秒级精确冷却时间 | +| 身份注入优化 | 模型身份信息注入 + 静默边界防止身份泄露 | +| thoughtSignature 修复 | Gemini 3 函数调用 400 错误修复 | +| max_tokens 自动修正 | 自动修正 max_tokens <= budget_tokens 导致的 400 错误 | + +### 调度算法优化 + +| 功能 | 说明 | +|------|------| +| 分层过滤选择 | 调度算法从全排序改为分层过滤,提升性能 | +| LRU 随机选择 | 相同 LRU 时间时随机选择,避免账号集中 | +| 限流等待阈值配置化 | 可配置的限流等待阈值 | + +### 运维增强 + +| 功能 | 说明 | +|------|------| +| Scope 限流统计 | 运维界面展示 Antigravity 账号 scope 级别限流统计 | +| 账号限流状态显示 | 账号列表显示 scope 和模型级别限流状态 | +| 清除限流按钮增强 | 有 scope/模型限流时也显示清除限流按钮 | + +### 其他修复 + +| 功能 | 说明 | +|------|------| +| .gitattributes | 确保迁移文件使用 LF 换行符(解决 Windows 下 SQL 摘要不一致) | +| 部署配置优化 | DATABASE_HOST 和 DATABASE_SSLMODE 可通过 .env 配置 | + +--- + +## 注意事项 + +1. **前端必须打包进镜像**:使用 `docker build` 在服务器上构建,Dockerfile 会自动编译前端并 embed 到后端二进制中 + +2. **镜像标签**:docker-compose.yml 使用 `weishaw/sub2api:latest`,本地构建后需要 `docker tag` 覆盖 + +3. **Windows 换行符问题**:已通过 `.gitattributes` 解决,确保 `*.sql` 文件始终使用 LF + +4. **版本号管理**:每次发布必须更新 `backend/cmd/server/VERSION` 并打标签 + +5. **合并冲突**:合并上游新版本时,重点关注以下文件可能的冲突: + - `backend/internal/service/antigravity_gateway_service.go` + - `backend/internal/service/gateway_service.go` + - `backend/internal/pkg/antigravity/request_transformer.go` + +--- + +## Go 代码规范 + +### 1. 函数设计 + +#### 单一职责原则 +- **函数行数**:单个函数常规不应超过 **30 行**,超过时应拆分为子函数。若某段逻辑确实不可拆分(如复杂的状态机、协议解析等),可以例外,但需添加注释说明原因 +- **嵌套层级**:避免超过 3 层嵌套,使用 early return 减少嵌套 + +```go +// ❌ 不推荐:深层嵌套 +func process(data []Item) { + for _, item := range data { + if item.Valid { + if item.Type == "A" { + if item.Status == "active" { + // 业务逻辑... + } + } + } + } +} + +// ✅ 推荐:early return +func process(data []Item) { + for _, item := range data { + if !item.Valid { + continue + } + if item.Type != "A" { + continue + } + if item.Status != "active" { + continue + } + // 业务逻辑... + } +} +``` + +#### 复杂逻辑提取 +将复杂的条件判断或处理逻辑提取为独立函数: + +```go +// ❌ 不推荐:内联复杂逻辑 +if resp.StatusCode == 429 || resp.StatusCode == 503 { + // 80+ 行处理逻辑... +} + +// ✅ 推荐:提取为独立函数 +result := handleRateLimitResponse(resp, params) +switch result.action { +case actionRetry: + continue +case actionBreak: + return result.resp, nil +} +``` + +### 2. 重复代码消除 + +#### 配置获取模式 +将重复的配置获取逻辑提取为方法: + +```go +// ❌ 不推荐:重复代码 +logBody := s.settingService != nil && s.settingService.cfg != nil && s.settingService.cfg.Gateway.LogUpstreamErrorBody +maxBytes := 2048 +if s.settingService != nil && s.settingService.cfg != nil && s.settingService.cfg.Gateway.LogUpstreamErrorBodyMaxBytes > 0 { + maxBytes = s.settingService.cfg.Gateway.LogUpstreamErrorBodyMaxBytes +} + +// ✅ 推荐:提取为方法 +func (s *Service) getLogConfig() (logBody bool, maxBytes int) { + maxBytes = 2048 + if s.settingService == nil || s.settingService.cfg == nil { + return false, maxBytes + } + cfg := s.settingService.cfg.Gateway + if cfg.LogUpstreamErrorBodyMaxBytes > 0 { + maxBytes = cfg.LogUpstreamErrorBodyMaxBytes + } + return cfg.LogUpstreamErrorBody, maxBytes +} +``` + +### 3. 常量管理 + +#### 避免魔法数字 +所有硬编码的数值都应定义为常量: + +```go +// ❌ 不推荐 +if retryDelay >= 10*time.Second { + resetAt := time.Now().Add(30 * time.Second) +} + +// ✅ 推荐 +const ( + rateLimitThreshold = 10 * time.Second + defaultRateLimitDuration = 30 * time.Second +) + +if retryDelay >= rateLimitThreshold { + resetAt := time.Now().Add(defaultRateLimitDuration) +} +``` + +#### 注释引用常量名 +在注释中引用常量名而非硬编码值: + +```go +// ❌ 不推荐 +// < 10s: 等待后重试 + +// ✅ 推荐 +// < rateLimitThreshold: 等待后重试 +``` + +### 4. 错误处理 + +#### 使用结构化日志 +优先使用 `slog` 进行结构化日志记录: + +```go +// ❌ 不推荐 +log.Printf("%s status=%d model_rate_limit_failed model=%s error=%v", prefix, statusCode, modelName, err) + +// ✅ 推荐 +slog.Error("failed to set model rate limit", + "prefix", prefix, + "status_code", statusCode, + "model", modelName, + "error", err, +) +``` + +### 5. 测试规范 + +#### Mock 函数签名同步 +修改函数签名时,必须同步更新所有测试中的 mock 函数: + +```go +// 如果修改了 handleError 签名 +handleError func(..., groupID int64, sessionHash string) *Result + +// 必须同步更新测试中的 mock +handleError: func(..., groupID int64, sessionHash string) *Result { + return nil +}, +``` + +#### 测试构建标签 +统一使用测试构建标签: + +```go +//go:build unit + +package service +``` + +### 6. 时间格式解析 + +#### 使用标准库 +优先使用 `time.ParseDuration`,支持所有 Go duration 格式: + +```go +// ❌ 不推荐:手动限制格式 +if !strings.HasSuffix(delay, "s") || strings.Contains(delay, "m") { + continue +} + +// ✅ 推荐:使用标准库 +dur, err := time.ParseDuration(delay) // 支持 "0.5s", "4m50s", "1h30m" 等 +``` + +### 7. 接口设计 + +#### 接口隔离原则 +定义最小化接口,只包含必需的方法: + +```go +// ❌ 不推荐:使用过于宽泛的接口 +type AccountRepository interface { + // 20+ 个方法... +} + +// ✅ 推荐:定义最小化接口 +type ModelRateLimiter interface { + SetModelRateLimit(ctx context.Context, id int64, modelKey string, resetAt time.Time) error +} +``` + +### 8. 并发安全 + +#### 共享数据保护 +访问可能被并发修改的数据时,确保线程安全: + +```go +// 如果 Account.Extra 可能被并发修改 +// 需要使用互斥锁或原子操作保护读取 +func (a *Account) GetRateLimitRemainingTime(model string) time.Duration { + a.mu.RLock() + defer a.mu.RUnlock() + // 读取 Extra 字段... +} +``` + +### 9. 命名规范 + +#### 一致的命名风格 +- 常量使用 camelCase:`rateLimitThreshold` +- 类型使用 PascalCase:`AntigravityQuotaScope` +- 同一概念使用统一命名:`Threshold` 或 `Limit`,不要混用 + +```go +// ❌ 不推荐:命名不一致 +antigravitySmartRetryMinWait // 使用 Min +antigravityRateLimitThreshold // 使用 Threshold + +// ✅ 推荐:统一风格 +antigravityMinRetryWait +antigravityRateLimitThreshold +``` + +### 10. 代码审查清单 + +在提交代码前,检查以下项目: + +- [ ] 函数是否超过 30 行?(不可拆分的逻辑除外,需注释说明) +- [ ] 嵌套是否超过 3 层? +- [ ] 是否有重复代码可以提取? +- [ ] 是否使用了魔法数字? +- [ ] Mock 函数签名是否与实际函数一致? +- [ ] 测试是否覆盖了新增逻辑? +- [ ] 日志是否包含足够的上下文信息? +- [ ] 是否考虑了并发安全? + +--- + +## CI 检查与发布门禁 + +### GitHub Actions 检查项 + +本项目有 4 个 CI 任务,**任何代码推送或发布前都必须全部通过**: + +| Workflow | Job | 说明 | 本地验证命令 | +|----------|-----|------|-------------| +| CI | `test` | 单元测试 + 集成测试 | `cd backend && make test-unit && make test-integration` | +| CI | `golangci-lint` | Go 代码静态检查(golangci-lint v2.7) | `cd backend && golangci-lint run --timeout=5m` | +| Security Scan | `backend-security` | govulncheck + gosec 安全扫描 | `cd backend && govulncheck ./... && gosec -severity high -confidence high ./...` | +| Security Scan | `frontend-security` | pnpm audit 前端依赖安全检查 | `cd frontend && pnpm audit --prod --audit-level=high` | + +### 向上游提交 PR + +PR 目标是上游官方仓库,**只包含通用功能改动**(bug fix、新功能、性能优化等)。 + +**以下文件禁止出现在 PR 中**(属于我们 fork 的定制化内容): +- `CLAUDE.md`、`AGENTS.md` — 我们的开发文档 +- `backend/cmd/server/VERSION` — 我们的版本号文件 +- UI 定制改动(GitHub 链接移除、微信客服按钮、首页定制等) +- 部署配置(`deploy/` 目录下的定制修改) + +**PR 流程**: +1. 从 `develop` 创建功能分支,只包含要提交给上游的改动 +2. 推送分支后,**等待 4 个 CI job 全部通过** +3. 确认通过后再创建 PR +4. 使用 `gh run list --repo touwaeriol/sub2api --branch ` 检查状态 + +### 自有分支推送(develop / main) + +推送到我们自己的 `develop` 或 `main` 分支时,包含所有改动(定制化 + 通用功能)。 + +**推送流程**: +1. 本地运行 `cd backend && make test-unit` 确保单元测试通过 +2. 本地运行 `cd backend && gofmt -l ./...` 确保格式正确 +3. 推送后确认 CI 和 Security Scan 两个 workflow 的 4 个 job 全部绿色 ✅ +4. 任何 job 失败必须立即修复,**禁止在 CI 未通过的状态下继续后续操作** + +### 发布版本 + +1. 确保 `main` 分支最新提交的 4 个 CI job 全部通过 +2. 递增 `backend/cmd/server/VERSION`,提交并推送 +3. 打 tag 推送后,确认 tag 触发的 3 个 workflow(CI、Security Scan、Release)全部通过 +4. **Release workflow 失败时禁止部署** — 必须先修复问题,删除旧 tag,重新打 tag +5. 使用 `gh run list --repo touwaeriol/sub2api --limit 10` 确认状态 + +### 常见 CI 失败原因及修复 +- **gofmt**:struct 字段对齐不一致 → 运行 `gofmt -w ` 修复 +- **golangci-lint**:未使用的变量/导入 → 删除或使用 `_` 忽略 +- **test 失败**:mock 函数签名不一致 → 同步更新 mock +- **gosec**:安全漏洞 → 根据提示修复或添加例外 diff --git a/deploy/docker-compose.yml b/deploy/docker-compose.yml index 033731ac..f1d19f84 100644 --- a/deploy/docker-compose.yml +++ b/deploy/docker-compose.yml @@ -47,13 +47,15 @@ services: # ======================================================================= # Database Configuration (PostgreSQL) + # Default: uses local postgres container + # External DB: set DATABASE_HOST and DATABASE_SSLMODE in .env # ======================================================================= - - DATABASE_HOST=postgres - - DATABASE_PORT=5432 + - DATABASE_HOST=${DATABASE_HOST:-postgres} + - DATABASE_PORT=${DATABASE_PORT:-5432} - DATABASE_USER=${POSTGRES_USER:-sub2api} - DATABASE_PASSWORD=${POSTGRES_PASSWORD:?POSTGRES_PASSWORD is required} - DATABASE_DBNAME=${POSTGRES_DB:-sub2api} - - DATABASE_SSLMODE=disable + - DATABASE_SSLMODE=${DATABASE_SSLMODE:-disable} # ======================================================================= # Redis Configuration @@ -128,8 +130,6 @@ services: # Examples: http://host:port, socks5://host:port - UPDATE_PROXY_URL=${UPDATE_PROXY_URL:-} depends_on: - postgres: - condition: service_healthy redis: condition: service_healthy networks: @@ -141,35 +141,6 @@ services: retries: 3 start_period: 30s - # =========================================================================== - # PostgreSQL Database - # =========================================================================== - postgres: - image: postgres:18-alpine - container_name: sub2api-postgres - restart: unless-stopped - ulimits: - nofile: - soft: 100000 - hard: 100000 - volumes: - - postgres_data:/var/lib/postgresql/data - environment: - - POSTGRES_USER=${POSTGRES_USER:-sub2api} - - POSTGRES_PASSWORD=${POSTGRES_PASSWORD:?POSTGRES_PASSWORD is required} - - POSTGRES_DB=${POSTGRES_DB:-sub2api} - - TZ=${TZ:-Asia/Shanghai} - networks: - - sub2api-network - healthcheck: - test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-sub2api} -d ${POSTGRES_DB:-sub2api}"] - interval: 10s - timeout: 5s - retries: 5 - start_period: 10s - # 注意:不暴露端口到宿主机,应用通过内部网络连接 - # 如需调试,可临时添加:ports: ["127.0.0.1:5433:5432"] - # =========================================================================== # Redis Cache # =========================================================================== @@ -209,8 +180,6 @@ services: volumes: sub2api_data: driver: local - postgres_data: - driver: local redis_data: driver: local diff --git a/frontend/public/wechat-qr.jpg b/frontend/public/wechat-qr.jpg new file mode 100644 index 0000000000000000000000000000000000000000..659068d835e86ccd58f5a429f270cce802590ec9 GIT binary patch literal 151392 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 literal 0 HcmV?d00001 diff --git a/frontend/src/components/common/WechatServiceButton.vue b/frontend/src/components/common/WechatServiceButton.vue new file mode 100644 index 00000000..9ee8d3d5 --- /dev/null +++ b/frontend/src/components/common/WechatServiceButton.vue @@ -0,0 +1,104 @@ + + + + + diff --git a/frontend/src/components/layout/AppHeader.vue b/frontend/src/components/layout/AppHeader.vue index a6b4030f..53a0c01e 100644 --- a/frontend/src/components/layout/AppHeader.vue +++ b/frontend/src/components/layout/AppHeader.vue @@ -121,23 +121,6 @@ {{ t('nav.apiKeys') }} - - - - - - {{ t('nav.github') }} -
diff --git a/frontend/src/views/HomeView.vue b/frontend/src/views/HomeView.vue index 6a3753f1..babcf046 100644 --- a/frontend/src/views/HomeView.vue +++ b/frontend/src/views/HomeView.vue @@ -122,8 +122,11 @@ > {{ siteName }} -

- {{ siteSubtitle }} +

+ {{ t('home.heroSubtitle') }} +

+

+ {{ t('home.heroDescription') }}

@@ -177,7 +180,7 @@ -
+
@@ -204,6 +207,63 @@
+ +
+

+ {{ t('home.painPoints.title') }} +

+
+ +
+
+ + + +
+

{{ t('home.painPoints.items.expensive.title') }}

+

{{ t('home.painPoints.items.expensive.desc') }}

+
+ +
+
+ + + +
+

{{ t('home.painPoints.items.complex.title') }}

+

{{ t('home.painPoints.items.complex.desc') }}

+
+ +
+
+ + + +
+

{{ t('home.painPoints.items.unstable.title') }}

+

{{ t('home.painPoints.items.unstable.desc') }}

+
+ +
+
+ + + +
+

{{ t('home.painPoints.items.noControl.title') }}

+

{{ t('home.painPoints.items.noControl.desc') }}

+
+
+
+ + +
+

+ {{ t('home.solutions.title') }} +

+

{{ t('home.solutions.subtitle') }}

+
+
@@ -369,6 +429,77 @@ >
+ + +
+

+ {{ t('home.comparison.title') }} +

+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
{{ t('home.comparison.headers.feature') }}{{ t('home.comparison.headers.official') }}{{ t('home.comparison.headers.us') }}
{{ t('home.comparison.items.pricing.feature') }}{{ t('home.comparison.items.pricing.official') }}{{ t('home.comparison.items.pricing.us') }}
{{ t('home.comparison.items.models.feature') }}{{ t('home.comparison.items.models.official') }}{{ t('home.comparison.items.models.us') }}
{{ t('home.comparison.items.management.feature') }}{{ t('home.comparison.items.management.official') }}{{ t('home.comparison.items.management.us') }}
{{ t('home.comparison.items.stability.feature') }}{{ t('home.comparison.items.stability.official') }}{{ t('home.comparison.items.stability.us') }}
{{ t('home.comparison.items.control.feature') }}{{ t('home.comparison.items.control.official') }}{{ t('home.comparison.items.control.us') }}
+
+
+ + +
+

+ {{ t('home.cta.title') }} +

+

+ {{ t('home.cta.description') }} +

+ + {{ t('home.cta.button') }} + + + + {{ t('home.goToDashboard') }} + + +
@@ -380,27 +511,20 @@

© {{ currentYear }} {{ siteName }}. {{ t('home.footer.allRightsReserved') }}

- + + {{ t('home.docs') }} + + + + @@ -410,6 +534,7 @@ import { useI18n } from 'vue-i18n' import { useAuthStore, useAppStore } from '@/stores' import LocaleSwitcher from '@/components/common/LocaleSwitcher.vue' import Icon from '@/components/icons/Icon.vue' +import WechatServiceButton from '@/components/common/WechatServiceButton.vue' const { t } = useI18n() @@ -419,7 +544,6 @@ const appStore = useAppStore() // Site settings - directly from appStore (already initialized from injected config) const siteName = computed(() => appStore.cachedPublicSettings?.site_name || appStore.siteName || 'Sub2API') const siteLogo = computed(() => appStore.cachedPublicSettings?.site_logo || appStore.siteLogo || '') -const siteSubtitle = computed(() => appStore.cachedPublicSettings?.site_subtitle || 'AI API Gateway Platform') const docUrl = computed(() => appStore.cachedPublicSettings?.doc_url || appStore.docUrl || '') const homeContent = computed(() => appStore.cachedPublicSettings?.home_content || '') @@ -432,9 +556,6 @@ const isHomeContentUrl = computed(() => { // Theme const isDark = ref(document.documentElement.classList.contains('dark')) -// GitHub URL -const githubUrl = 'https://github.com/Wei-Shaw/sub2api' - // Auth state const isAuthenticated = computed(() => authStore.isAuthenticated) const isAdmin = computed(() => authStore.isAdmin) diff --git a/stress_test_gemini_session.sh b/stress_test_gemini_session.sh new file mode 100644 index 00000000..1f2aca57 --- /dev/null +++ b/stress_test_gemini_session.sh @@ -0,0 +1,127 @@ +#!/bin/bash + +# Gemini 粘性会话压力测试脚本 +# 测试目标:验证不同会话分配不同账号,同一会话保持同一账号 + +BASE_URL="http://host.clicodeplus.com:8080" +API_KEY="sk-32ad0a3197e528c840ea84f0dc6b2056dd3fead03526b5c605a60709bd408f7e" +MODEL="gemini-2.5-flash" + +# 创建临时目录存放结果 +RESULT_DIR="/tmp/gemini_stress_test_$(date +%s)" +mkdir -p "$RESULT_DIR" + +echo "==========================================" +echo "Gemini 粘性会话压力测试" +echo "结果目录: $RESULT_DIR" +echo "==========================================" + +# 函数:发送请求并记录 +send_request() { + local session_id=$1 + local round=$2 + local system_prompt=$3 + local contents=$4 + local output_file="$RESULT_DIR/session_${session_id}_round_${round}.json" + + local request_body=$(cat < "$output_file" 2>&1 + + echo "[Session $session_id Round $round] 完成" +} + +# 会话1:数学计算器(累加序列) +run_session_1() { + local sys_prompt="你是一个数学计算器,只返回计算结果数字,不要任何解释" + + # Round 1: 1+1=? + send_request 1 1 "$sys_prompt" '[{"role":"user","parts":[{"text":"1+1=?"}]}]' + + # Round 2: 继续 2+2=?(累加历史) + send_request 1 2 "$sys_prompt" '[{"role":"user","parts":[{"text":"1+1=?"}]},{"role":"model","parts":[{"text":"2"}]},{"role":"user","parts":[{"text":"2+2=?"}]}]' + + # Round 3: 继续 3+3=? + send_request 1 3 "$sys_prompt" '[{"role":"user","parts":[{"text":"1+1=?"}]},{"role":"model","parts":[{"text":"2"}]},{"role":"user","parts":[{"text":"2+2=?"}]},{"role":"model","parts":[{"text":"4"}]},{"role":"user","parts":[{"text":"3+3=?"}]}]' + + # Round 4: 批量计算 10+10, 20+20, 30+30 + send_request 1 4 "$sys_prompt" '[{"role":"user","parts":[{"text":"1+1=?"}]},{"role":"model","parts":[{"text":"2"}]},{"role":"user","parts":[{"text":"2+2=?"}]},{"role":"model","parts":[{"text":"4"}]},{"role":"user","parts":[{"text":"3+3=?"}]},{"role":"model","parts":[{"text":"6"}]},{"role":"user","parts":[{"text":"计算: 10+10=? 20+20=? 30+30=?"}]}]' +} + +# 会话2:英文翻译器(不同系统提示词 = 不同会话) +run_session_2() { + local sys_prompt="你是一个英文翻译器,将中文翻译成英文,只返回翻译结果" + + send_request 2 1 "$sys_prompt" '[{"role":"user","parts":[{"text":"你好"}]}]' + send_request 2 2 "$sys_prompt" '[{"role":"user","parts":[{"text":"你好"}]},{"role":"model","parts":[{"text":"Hello"}]},{"role":"user","parts":[{"text":"世界"}]}]' + send_request 2 3 "$sys_prompt" '[{"role":"user","parts":[{"text":"你好"}]},{"role":"model","parts":[{"text":"Hello"}]},{"role":"user","parts":[{"text":"世界"}]},{"role":"model","parts":[{"text":"World"}]},{"role":"user","parts":[{"text":"早上好"}]}]' +} + +# 会话3:日文翻译器 +run_session_3() { + local sys_prompt="你是一个日文翻译器,将中文翻译成日文,只返回翻译结果" + + send_request 3 1 "$sys_prompt" '[{"role":"user","parts":[{"text":"你好"}]}]' + send_request 3 2 "$sys_prompt" '[{"role":"user","parts":[{"text":"你好"}]},{"role":"model","parts":[{"text":"こんにちは"}]},{"role":"user","parts":[{"text":"谢谢"}]}]' + send_request 3 3 "$sys_prompt" '[{"role":"user","parts":[{"text":"你好"}]},{"role":"model","parts":[{"text":"こんにちは"}]},{"role":"user","parts":[{"text":"谢谢"}]},{"role":"model","parts":[{"text":"ありがとう"}]},{"role":"user","parts":[{"text":"再见"}]}]' +} + +# 会话4:乘法计算器(另一个数学会话,但系统提示词不同) +run_session_4() { + local sys_prompt="你是一个乘法专用计算器,只计算乘法,返回数字结果" + + send_request 4 1 "$sys_prompt" '[{"role":"user","parts":[{"text":"2*3=?"}]}]' + send_request 4 2 "$sys_prompt" '[{"role":"user","parts":[{"text":"2*3=?"}]},{"role":"model","parts":[{"text":"6"}]},{"role":"user","parts":[{"text":"4*5=?"}]}]' + send_request 4 3 "$sys_prompt" '[{"role":"user","parts":[{"text":"2*3=?"}]},{"role":"model","parts":[{"text":"6"}]},{"role":"user","parts":[{"text":"4*5=?"}]},{"role":"model","parts":[{"text":"20"}]},{"role":"user","parts":[{"text":"计算: 10*10=? 20*20=?"}]}]' +} + +# 会话5:诗人(完全不同的角色) +run_session_5() { + local sys_prompt="你是一位诗人,用简短的诗句回应每个话题,每次只写一句诗" + + send_request 5 1 "$sys_prompt" '[{"role":"user","parts":[{"text":"春天"}]}]' + send_request 5 2 "$sys_prompt" '[{"role":"user","parts":[{"text":"春天"}]},{"role":"model","parts":[{"text":"春风拂面花满枝"}]},{"role":"user","parts":[{"text":"夏天"}]}]' + send_request 5 3 "$sys_prompt" '[{"role":"user","parts":[{"text":"春天"}]},{"role":"model","parts":[{"text":"春风拂面花满枝"}]},{"role":"user","parts":[{"text":"夏天"}]},{"role":"model","parts":[{"text":"蝉鸣蛙声伴荷香"}]},{"role":"user","parts":[{"text":"秋天"}]}]' +} + +echo "" +echo "开始并发测试 5 个独立会话..." +echo "" + +# 并发运行所有会话 +run_session_1 & +run_session_2 & +run_session_3 & +run_session_4 & +run_session_5 & + +# 等待所有后台任务完成 +wait + +echo "" +echo "==========================================" +echo "所有请求完成,结果保存在: $RESULT_DIR" +echo "==========================================" + +# 显示结果摘要 +echo "" +echo "响应摘要:" +for f in "$RESULT_DIR"/*.json; do + filename=$(basename "$f") + response=$(cat "$f" | head -c 200) + echo "[$filename]: ${response}..." +done + +echo "" +echo "请检查服务器日志确认账号分配情况" From d269659e61fa8e714a7210ef54d6fe2681a77e13 Mon Sep 17 00:00:00 2001 From: liuxiongfeng Date: Tue, 10 Feb 2026 21:28:52 +0800 Subject: [PATCH 10/18] chore: bump version to 0.1.78.2 --- backend/cmd/server/VERSION | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/backend/cmd/server/VERSION b/backend/cmd/server/VERSION index 5087e794..3d46fb65 100644 --- a/backend/cmd/server/VERSION +++ b/backend/cmd/server/VERSION @@ -1 +1 @@ -0.1.76 \ No newline at end of file +0.1.78.2 From a54b81cf74ffaadec468e6f611d0c512647b5e7d Mon Sep 17 00:00:00 2001 From: Edric Li Date: Tue, 10 Feb 2026 21:40:31 +0800 Subject: [PATCH 11/18] =?UTF-8?q?perf:=20=E9=94=99=E8=AF=AF=E5=A4=84?= =?UTF-8?q?=E7=90=86=E6=80=A7=E8=83=BD=E4=BC=98=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - MatchRule 延迟/限制 body ToLower,先用 statusCode 短路,只在需要关键词匹配时转换且限制 8KB - 预计算规则的小写关键词/平台和 error code set,消除运行时重复 ToLower 和线性扫描 - MODEL_CAPACITY_EXHAUSTED 全局去重,避免并发请求重复重试同一模型 - 503 重试 body 读取限制从 2MB 降至 8KB - time.After 替换为 time.NewTimer,防止 context 取消时 timer 泄漏 --- .../service/antigravity_gateway_service.go | 59 ++++++- .../service/error_passthrough_service.go | 145 ++++++++++++------ .../service/error_passthrough_service_test.go | 80 +++++++--- 3 files changed, 206 insertions(+), 78 deletions(-) diff --git a/backend/internal/service/antigravity_gateway_service.go b/backend/internal/service/antigravity_gateway_service.go index a110f4e0..7d3e5f19 100644 --- a/backend/internal/service/antigravity_gateway_service.go +++ b/backend/internal/service/antigravity_gateway_service.go @@ -16,6 +16,7 @@ import ( "os" "strconv" "strings" + "sync" "sync/atomic" "time" @@ -66,6 +67,9 @@ const ( // 单账号 503 退避重试:原地重试的总累计等待时间上限 // 超过此上限将不再重试,直接返回 503 antigravitySingleAccountSmartRetryTotalMaxWait = 30 * time.Second + + // MODEL_CAPACITY_EXHAUSTED 全局去重:重试全部失败后的 cooldown 时间 + antigravityModelCapacityCooldown = 10 * time.Second ) // antigravityPassthroughErrorMessages 透传给客户端的错误消息白名单(小写) @@ -74,6 +78,12 @@ var antigravityPassthroughErrorMessages = []string{ "prompt is too long", } +// MODEL_CAPACITY_EXHAUSTED 全局去重:避免多个并发请求同时对同一模型进行容量耗尽重试 +var ( + modelCapacityExhaustedMu sync.RWMutex + modelCapacityExhaustedUntil = make(map[string]time.Time) // modelName -> cooldown until +) + const ( antigravityBillingModelEnv = "GATEWAY_ANTIGRAVITY_BILL_WITH_MAPPED_MODEL" antigravityFallbackSecondsEnv = "GATEWAY_ANTIGRAVITY_FALLBACK_COOLDOWN_SECONDS" @@ -211,17 +221,38 @@ func (s *AntigravityGatewayService) handleSmartRetry(p antigravityRetryLoopParam if isModelCapacityExhausted { maxAttempts = antigravityModelCapacityRetryMaxAttempts waitDuration = antigravityModelCapacityRetryWait + + // 全局去重:如果其他 goroutine 已在重试同一模型且尚在 cooldown 中,直接返回 503 + if modelName != "" { + modelCapacityExhaustedMu.RLock() + cooldownUntil, exists := modelCapacityExhaustedUntil[modelName] + modelCapacityExhaustedMu.RUnlock() + if exists && time.Now().Before(cooldownUntil) { + log.Printf("%s status=%d model_capacity_exhausted_dedup model=%s account=%d cooldown_until=%v (skip retry)", + p.prefix, resp.StatusCode, modelName, p.account.ID, cooldownUntil.Format("15:04:05")) + return &smartRetryResult{ + action: smartRetryActionBreakWithResp, + resp: &http.Response{ + StatusCode: resp.StatusCode, + Header: resp.Header.Clone(), + Body: io.NopCloser(bytes.NewReader(respBody)), + }, + } + } + } } for attempt := 1; attempt <= maxAttempts; attempt++ { log.Printf("%s status=%d oauth_smart_retry attempt=%d/%d delay=%v model=%s account=%d", p.prefix, resp.StatusCode, attempt, maxAttempts, waitDuration, modelName, p.account.ID) + timer := time.NewTimer(waitDuration) select { case <-p.ctx.Done(): + timer.Stop() log.Printf("%s status=context_canceled_during_smart_retry", p.prefix) return &smartRetryResult{action: smartRetryActionBreakWithResp, err: p.ctx.Err()} - case <-time.After(waitDuration): + case <-timer.C: } // 智能重试:创建新请求 @@ -242,6 +273,12 @@ func (s *AntigravityGatewayService) handleSmartRetry(p antigravityRetryLoopParam retryResp, retryErr := p.httpUpstream.Do(retryReq, p.proxyURL, p.account.ID, p.account.Concurrency) if retryErr == nil && retryResp != nil && retryResp.StatusCode != http.StatusTooManyRequests && retryResp.StatusCode != http.StatusServiceUnavailable { log.Printf("%s status=%d smart_retry_success attempt=%d/%d", p.prefix, retryResp.StatusCode, attempt, maxAttempts) + // 重试成功,清除 MODEL_CAPACITY_EXHAUSTED cooldown + if isModelCapacityExhausted && modelName != "" { + modelCapacityExhaustedMu.Lock() + delete(modelCapacityExhaustedUntil, modelName) + modelCapacityExhaustedMu.Unlock() + } return &smartRetryResult{action: smartRetryActionBreakWithResp, resp: retryResp} } @@ -257,7 +294,7 @@ func (s *AntigravityGatewayService) handleSmartRetry(p antigravityRetryLoopParam } lastRetryResp = retryResp if retryResp != nil { - lastRetryBody, _ = io.ReadAll(io.LimitReader(retryResp.Body, 2<<20)) + lastRetryBody, _ = io.ReadAll(io.LimitReader(retryResp.Body, 8<<10)) _ = retryResp.Body.Close() } @@ -283,6 +320,12 @@ func (s *AntigravityGatewayService) handleSmartRetry(p antigravityRetryLoopParam // MODEL_CAPACITY_EXHAUSTED:模型容量不足,切换账号无意义 // 直接返回上游错误响应,不设置模型限流,不切换账号 if isModelCapacityExhausted { + // 设置 cooldown,让后续请求快速失败,避免重复重试 + if modelName != "" { + modelCapacityExhaustedMu.Lock() + modelCapacityExhaustedUntil[modelName] = time.Now().Add(antigravityModelCapacityCooldown) + modelCapacityExhaustedMu.Unlock() + } log.Printf("%s status=%d smart_retry_exhausted_model_capacity attempts=%d model=%s account=%d body=%s (model capacity exhausted, not switching account)", p.prefix, resp.StatusCode, maxAttempts, modelName, p.account.ID, truncateForLog(retryBody, 200)) return &smartRetryResult{ @@ -395,11 +438,13 @@ func (s *AntigravityGatewayService) handleSingleAccountRetryInPlace( log.Printf("%s status=%d single_account_503_retry attempt=%d/%d delay=%v total_waited=%v model=%s account=%d", p.prefix, resp.StatusCode, attempt, antigravitySingleAccountSmartRetryMaxAttempts, waitDuration, totalWaited, modelName, p.account.ID) + timer := time.NewTimer(waitDuration) select { case <-p.ctx.Done(): + timer.Stop() log.Printf("%s status=context_canceled_during_single_account_retry", p.prefix) return &smartRetryResult{action: smartRetryActionBreakWithResp, err: p.ctx.Err()} - case <-time.After(waitDuration): + case <-timer.C: } totalWaited += waitDuration @@ -433,7 +478,7 @@ func (s *AntigravityGatewayService) handleSingleAccountRetryInPlace( _ = lastRetryResp.Body.Close() } lastRetryResp = retryResp - lastRetryBody, _ = io.ReadAll(io.LimitReader(retryResp.Body, 2<<20)) + lastRetryBody, _ = io.ReadAll(io.LimitReader(retryResp.Body, 8<<10)) _ = retryResp.Body.Close() // 解析新的重试信息,更新下次等待时间 @@ -1404,7 +1449,7 @@ func (s *AntigravityGatewayService) Forward(ctx context.Context, c *gin.Context, break } - retryBody, _ := io.ReadAll(io.LimitReader(retryResp.Body, 2<<20)) + retryBody, _ := io.ReadAll(io.LimitReader(retryResp.Body, 8<<10)) _ = retryResp.Body.Close() if retryResp.StatusCode == http.StatusTooManyRequests { retryBaseURL := "" @@ -2211,10 +2256,12 @@ func sleepAntigravityBackoffWithContext(ctx context.Context, attempt int) bool { sleepFor = 0 } + timer := time.NewTimer(sleepFor) select { case <-ctx.Done(): + timer.Stop() return false - case <-time.After(sleepFor): + case <-timer.C: return true } } diff --git a/backend/internal/service/error_passthrough_service.go b/backend/internal/service/error_passthrough_service.go index c3e0f630..caf12676 100644 --- a/backend/internal/service/error_passthrough_service.go +++ b/backend/internal/service/error_passthrough_service.go @@ -45,10 +45,20 @@ type ErrorPassthroughService struct { cache ErrorPassthroughCache // 本地内存缓存,用于快速匹配 - localCache []*model.ErrorPassthroughRule + localCache []*cachedPassthroughRule localCacheMu sync.RWMutex } +// cachedPassthroughRule 预计算的规则缓存,避免运行时重复 ToLower +type cachedPassthroughRule struct { + *model.ErrorPassthroughRule + lowerKeywords []string // 预计算的小写关键词 + lowerPlatforms []string // 预计算的小写平台 + errorCodeSet map[int]struct{} // 预计算的 error code set +} + +const maxBodyMatchLen = 8 << 10 // 8KB,错误信息不会在 8KB 之后才出现 + // NewErrorPassthroughService 创建错误透传规则服务 func NewErrorPassthroughService( repo ErrorPassthroughRepository, @@ -150,17 +160,19 @@ func (s *ErrorPassthroughService) MatchRule(platform string, statusCode int, bod return nil } - bodyStr := strings.ToLower(string(body)) + lowerPlatform := strings.ToLower(platform) + var bodyLower string // 延迟初始化,只在需要关键词匹配时计算 + var bodyLowerDone bool for _, rule := range rules { if !rule.Enabled { continue } - if !s.platformMatches(rule, platform) { + if !s.platformMatchesCached(rule, lowerPlatform) { continue } - if s.ruleMatches(rule, statusCode, bodyStr) { - return rule + if s.ruleMatchesOptimized(rule, statusCode, body, &bodyLower, &bodyLowerDone) { + return rule.ErrorPassthroughRule } } @@ -168,7 +180,7 @@ func (s *ErrorPassthroughService) MatchRule(platform string, statusCode int, bod } // getCachedRules 获取缓存的规则列表(按优先级排序) -func (s *ErrorPassthroughService) getCachedRules() []*model.ErrorPassthroughRule { +func (s *ErrorPassthroughService) getCachedRules() []*cachedPassthroughRule { s.localCacheMu.RLock() rules := s.localCache s.localCacheMu.RUnlock() @@ -223,17 +235,39 @@ func (s *ErrorPassthroughService) reloadRulesFromDB(ctx context.Context) error { return nil } -// setLocalCache 设置本地缓存 +// setLocalCache 设置本地缓存,预计算小写值和 set 以避免运行时重复计算 func (s *ErrorPassthroughService) setLocalCache(rules []*model.ErrorPassthroughRule) { + cached := make([]*cachedPassthroughRule, len(rules)) + for i, r := range rules { + cr := &cachedPassthroughRule{ErrorPassthroughRule: r} + if len(r.Keywords) > 0 { + cr.lowerKeywords = make([]string, len(r.Keywords)) + for j, kw := range r.Keywords { + cr.lowerKeywords[j] = strings.ToLower(kw) + } + } + if len(r.Platforms) > 0 { + cr.lowerPlatforms = make([]string, len(r.Platforms)) + for j, p := range r.Platforms { + cr.lowerPlatforms[j] = strings.ToLower(p) + } + } + if len(r.ErrorCodes) > 0 { + cr.errorCodeSet = make(map[int]struct{}, len(r.ErrorCodes)) + for _, code := range r.ErrorCodes { + cr.errorCodeSet[code] = struct{}{} + } + } + cached[i] = cr + } + // 按优先级排序 - sorted := make([]*model.ErrorPassthroughRule, len(rules)) - copy(sorted, rules) - sort.Slice(sorted, func(i, j int) bool { - return sorted[i].Priority < sorted[j].Priority + sort.Slice(cached, func(i, j int) bool { + return cached[i].Priority < cached[j].Priority }) s.localCacheMu.Lock() - s.localCache = sorted + s.localCache = cached s.localCacheMu.Unlock() } @@ -273,62 +307,79 @@ func (s *ErrorPassthroughService) invalidateAndNotify(ctx context.Context) { } } -// platformMatches 检查平台是否匹配 -func (s *ErrorPassthroughService) platformMatches(rule *model.ErrorPassthroughRule, platform string) bool { - // 如果没有配置平台限制,则匹配所有平台 - if len(rule.Platforms) == 0 { +// ensureBodyLower 延迟初始化 body 的小写版本,只做一次转换,限制 8KB +func ensureBodyLower(body []byte, bodyLower *string, done *bool) string { + if *done { + return *bodyLower + } + b := body + if len(b) > maxBodyMatchLen { + b = b[:maxBodyMatchLen] + } + *bodyLower = strings.ToLower(string(b)) + *done = true + return *bodyLower +} + +// platformMatchesCached 使用预计算的小写平台检查是否匹配 +func (s *ErrorPassthroughService) platformMatchesCached(rule *cachedPassthroughRule, lowerPlatform string) bool { + if len(rule.lowerPlatforms) == 0 { return true } - - platform = strings.ToLower(platform) - for _, p := range rule.Platforms { - if strings.ToLower(p) == platform { + for _, p := range rule.lowerPlatforms { + if p == lowerPlatform { return true } } - return false } -// ruleMatches 检查规则是否匹配 -func (s *ErrorPassthroughService) ruleMatches(rule *model.ErrorPassthroughRule, statusCode int, bodyLower string) bool { - hasErrorCodes := len(rule.ErrorCodes) > 0 - hasKeywords := len(rule.Keywords) > 0 +// ruleMatchesOptimized 优化的规则匹配,支持短路和延迟 body 转换 +func (s *ErrorPassthroughService) ruleMatchesOptimized(rule *cachedPassthroughRule, statusCode int, body []byte, bodyLower *string, bodyLowerDone *bool) bool { + hasErrorCodes := len(rule.errorCodeSet) > 0 + hasKeywords := len(rule.lowerKeywords) > 0 - // 如果没有配置任何条件,不匹配 if !hasErrorCodes && !hasKeywords { return false } - codeMatch := !hasErrorCodes || s.containsInt(rule.ErrorCodes, statusCode) - keywordMatch := !hasKeywords || s.containsAnyKeyword(bodyLower, rule.Keywords) + codeMatch := !hasErrorCodes || s.containsIntSet(rule.errorCodeSet, statusCode) if rule.MatchMode == model.MatchModeAll { - // "all" 模式:所有配置的条件都必须满足 - return codeMatch && keywordMatch + // "all" 模式:所有配置的条件都必须满足,短路 + if hasErrorCodes && !codeMatch { + return false + } + if hasKeywords { + return s.containsAnyKeywordCached(ensureBodyLower(body, bodyLower, bodyLowerDone), rule.lowerKeywords) + } + return codeMatch } - // "any" 模式:任一条件满足即可 + // "any" 模式:任一条件满足即可,短路 if hasErrorCodes && hasKeywords { - return codeMatch || keywordMatch + if codeMatch { + return true + } + return s.containsAnyKeywordCached(ensureBodyLower(body, bodyLower, bodyLowerDone), rule.lowerKeywords) } - return codeMatch && keywordMatch + // 只配置了一种条件 + if hasKeywords { + return s.containsAnyKeywordCached(ensureBodyLower(body, bodyLower, bodyLowerDone), rule.lowerKeywords) + } + return codeMatch } -// containsInt 检查切片是否包含指定整数 -func (s *ErrorPassthroughService) containsInt(slice []int, val int) bool { - for _, v := range slice { - if v == val { - return true - } - } - return false -} - -// containsAnyKeyword 检查字符串是否包含任一关键词(不区分大小写) -func (s *ErrorPassthroughService) containsAnyKeyword(bodyLower string, keywords []string) bool { - for _, kw := range keywords { - if strings.Contains(bodyLower, strings.ToLower(kw)) { +// containsIntSet 使用 map 查找替代线性扫描 +func (s *ErrorPassthroughService) containsIntSet(set map[int]struct{}, val int) bool { + _, ok := set[val] + return ok +} + +// containsAnyKeywordCached 使用预计算的小写关键词检查匹配 +func (s *ErrorPassthroughService) containsAnyKeywordCached(bodyLower string, lowerKeywords []string) bool { + for _, kw := range lowerKeywords { + if strings.Contains(bodyLower, kw) { return true } } diff --git a/backend/internal/service/error_passthrough_service_test.go b/backend/internal/service/error_passthrough_service_test.go index 74c98d86..96ddd637 100644 --- a/backend/internal/service/error_passthrough_service_test.go +++ b/backend/internal/service/error_passthrough_service_test.go @@ -145,32 +145,58 @@ func newTestService(rules []*model.ErrorPassthroughRule) *ErrorPassthroughServic return svc } +// newCachedRuleForTest 从 model.ErrorPassthroughRule 创建 cachedPassthroughRule(测试用) +func newCachedRuleForTest(rule *model.ErrorPassthroughRule) *cachedPassthroughRule { + cr := &cachedPassthroughRule{ErrorPassthroughRule: rule} + if len(rule.Keywords) > 0 { + cr.lowerKeywords = make([]string, len(rule.Keywords)) + for j, kw := range rule.Keywords { + cr.lowerKeywords[j] = strings.ToLower(kw) + } + } + if len(rule.Platforms) > 0 { + cr.lowerPlatforms = make([]string, len(rule.Platforms)) + for j, p := range rule.Platforms { + cr.lowerPlatforms[j] = strings.ToLower(p) + } + } + if len(rule.ErrorCodes) > 0 { + cr.errorCodeSet = make(map[int]struct{}, len(rule.ErrorCodes)) + for _, code := range rule.ErrorCodes { + cr.errorCodeSet[code] = struct{}{} + } + } + return cr +} + // ============================================================================= -// 测试 ruleMatches 核心匹配逻辑 +// 测试 ruleMatchesOptimized 核心匹配逻辑 // ============================================================================= func TestRuleMatches_NoConditions(t *testing.T) { // 没有配置任何条件时,不应该匹配 svc := newTestService(nil) - rule := &model.ErrorPassthroughRule{ + rule := newCachedRuleForTest(&model.ErrorPassthroughRule{ Enabled: true, ErrorCodes: []int{}, Keywords: []string{}, MatchMode: model.MatchModeAny, - } + }) - assert.False(t, svc.ruleMatches(rule, 422, "some error message"), + var bodyLower string + var bodyLowerDone bool + assert.False(t, svc.ruleMatchesOptimized(rule, 422, []byte("some error message"), &bodyLower, &bodyLowerDone), "没有配置条件时不应该匹配") } func TestRuleMatches_OnlyErrorCodes_AnyMode(t *testing.T) { svc := newTestService(nil) - rule := &model.ErrorPassthroughRule{ + rule := newCachedRuleForTest(&model.ErrorPassthroughRule{ Enabled: true, ErrorCodes: []int{422, 400}, Keywords: []string{}, MatchMode: model.MatchModeAny, - } + }) tests := []struct { name string @@ -186,7 +212,9 @@ func TestRuleMatches_OnlyErrorCodes_AnyMode(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - result := svc.ruleMatches(rule, tt.statusCode, tt.body) + var bodyLower string + var bodyLowerDone bool + result := svc.ruleMatchesOptimized(rule, tt.statusCode, []byte(tt.body), &bodyLower, &bodyLowerDone) assert.Equal(t, tt.expected, result) }) } @@ -194,12 +222,12 @@ func TestRuleMatches_OnlyErrorCodes_AnyMode(t *testing.T) { func TestRuleMatches_OnlyKeywords_AnyMode(t *testing.T) { svc := newTestService(nil) - rule := &model.ErrorPassthroughRule{ + rule := newCachedRuleForTest(&model.ErrorPassthroughRule{ Enabled: true, ErrorCodes: []int{}, Keywords: []string{"context limit", "model not supported"}, MatchMode: model.MatchModeAny, - } + }) tests := []struct { name string @@ -210,16 +238,14 @@ func TestRuleMatches_OnlyKeywords_AnyMode(t *testing.T) { {"关键词匹配 context limit", 500, "error: context limit reached", true}, {"关键词匹配 model not supported", 400, "the model not supported here", true}, {"关键词不匹配", 422, "some other error", false}, - // 注意:ruleMatches 接收的 body 参数应该是已经转换为小写的 - // 实际使用时,MatchRule 会先将 body 转换为小写再传给 ruleMatches - {"关键词大小写 - 输入已小写", 500, "context limit exceeded", true}, + {"关键词大小写 - 自动转换", 500, "Context Limit exceeded", true}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - // 模拟 MatchRule 的行为:先转换为小写 - bodyLower := strings.ToLower(tt.body) - result := svc.ruleMatches(rule, tt.statusCode, bodyLower) + var bodyLower string + var bodyLowerDone bool + result := svc.ruleMatchesOptimized(rule, tt.statusCode, []byte(tt.body), &bodyLower, &bodyLowerDone) assert.Equal(t, tt.expected, result) }) } @@ -228,12 +254,12 @@ func TestRuleMatches_OnlyKeywords_AnyMode(t *testing.T) { func TestRuleMatches_BothConditions_AnyMode(t *testing.T) { // any 模式:错误码 OR 关键词 svc := newTestService(nil) - rule := &model.ErrorPassthroughRule{ + rule := newCachedRuleForTest(&model.ErrorPassthroughRule{ Enabled: true, ErrorCodes: []int{422, 400}, Keywords: []string{"context limit"}, MatchMode: model.MatchModeAny, - } + }) tests := []struct { name string @@ -274,7 +300,9 @@ func TestRuleMatches_BothConditions_AnyMode(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - result := svc.ruleMatches(rule, tt.statusCode, tt.body) + var bodyLower string + var bodyLowerDone bool + result := svc.ruleMatchesOptimized(rule, tt.statusCode, []byte(tt.body), &bodyLower, &bodyLowerDone) assert.Equal(t, tt.expected, result, tt.reason) }) } @@ -283,12 +311,12 @@ func TestRuleMatches_BothConditions_AnyMode(t *testing.T) { func TestRuleMatches_BothConditions_AllMode(t *testing.T) { // all 模式:错误码 AND 关键词 svc := newTestService(nil) - rule := &model.ErrorPassthroughRule{ + rule := newCachedRuleForTest(&model.ErrorPassthroughRule{ Enabled: true, ErrorCodes: []int{422, 400}, Keywords: []string{"context limit"}, MatchMode: model.MatchModeAll, - } + }) tests := []struct { name string @@ -329,14 +357,16 @@ func TestRuleMatches_BothConditions_AllMode(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - result := svc.ruleMatches(rule, tt.statusCode, tt.body) + var bodyLower string + var bodyLowerDone bool + result := svc.ruleMatchesOptimized(rule, tt.statusCode, []byte(tt.body), &bodyLower, &bodyLowerDone) assert.Equal(t, tt.expected, result, tt.reason) }) } } // ============================================================================= -// 测试 platformMatches 平台匹配逻辑 +// 测试 platformMatchesCached 平台匹配逻辑 // ============================================================================= func TestPlatformMatches(t *testing.T) { @@ -394,10 +424,10 @@ func TestPlatformMatches(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - rule := &model.ErrorPassthroughRule{ + rule := newCachedRuleForTest(&model.ErrorPassthroughRule{ Platforms: tt.rulePlatforms, - } - result := svc.platformMatches(rule, tt.requestPlatform) + }) + result := svc.platformMatchesCached(rule, strings.ToLower(tt.requestPlatform)) assert.Equal(t, tt.expected, result) }) } From 378e476e48737f07ae2afefb83ebfb2f3cc43f90 Mon Sep 17 00:00:00 2001 From: Edric Li Date: Tue, 10 Feb 2026 22:08:49 +0800 Subject: [PATCH 12/18] =?UTF-8?q?fix:=20=E4=BF=AE=E5=A4=8D=20CI=20?= =?UTF-8?q?=E6=A3=80=E6=9F=A5=E5=A4=B1=E8=B4=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - gofmt: 修复 error_passthrough_service.go 格式问题 - errcheck: 修复 error_passthrough_runtime_test.go 类型断言未检查 - staticcheck: if-else 改为 switch (gateway_service.go) - test: 修复两个测试用例错误使用 MODEL_CAPACITY_EXHAUSTED 导致走错路径 --- .../antigravity_single_account_retry_test.go | 16 +++++++++------- .../service/error_passthrough_runtime_test.go | 4 +++- .../service/error_passthrough_service.go | 2 +- backend/internal/service/gateway_service.go | 5 +++-- 4 files changed, 16 insertions(+), 11 deletions(-) diff --git a/backend/internal/service/antigravity_single_account_retry_test.go b/backend/internal/service/antigravity_single_account_retry_test.go index d5813553..8b01cc31 100644 --- a/backend/internal/service/antigravity_single_account_retry_test.go +++ b/backend/internal/service/antigravity_single_account_retry_test.go @@ -153,13 +153,14 @@ func TestHandleSmartRetry_503_LongDelay_NoSingleAccountRetry_StillSwitches(t *te Platform: PlatformAntigravity, } - // 503 + 39s >= 7s 阈值 + // 503 + 39s >= 7s 阈值(使用 RATE_LIMIT_EXCEEDED 而非 MODEL_CAPACITY_EXHAUSTED, + // 因为 MODEL_CAPACITY_EXHAUSTED 走独立的重试路径,不触发 shouldRateLimitModel) respBody := []byte(`{ "error": { "code": 503, - "status": "UNAVAILABLE", + "status": "RESOURCE_EXHAUSTED", "details": [ - {"@type": "type.googleapis.com/google.rpc.ErrorInfo", "metadata": {"model": "gemini-3-pro-high"}, "reason": "MODEL_CAPACITY_EXHAUSTED"}, + {"@type": "type.googleapis.com/google.rpc.ErrorInfo", "metadata": {"model": "gemini-3-pro-high"}, "reason": "RATE_LIMIT_EXCEEDED"}, {"@type": "type.googleapis.com/google.rpc.RetryInfo", "retryDelay": "39s"} ] } @@ -339,13 +340,14 @@ func TestHandleSmartRetry_503_ShortDelay_SingleAccountRetry_NoRateLimit(t *testi // TestHandleSmartRetry_503_ShortDelay_NoSingleAccountRetry_SetsRateLimit // 对照组:503 + retryDelay < 7s + 无 SingleAccountRetry → 智能重试耗尽后照常设限流 +// 使用 RATE_LIMIT_EXCEEDED 而非 MODEL_CAPACITY_EXHAUSTED,因为后者走独立的 60 次重试路径 func TestHandleSmartRetry_503_ShortDelay_NoSingleAccountRetry_SetsRateLimit(t *testing.T) { failRespBody := `{ "error": { "code": 503, - "status": "UNAVAILABLE", + "status": "RESOURCE_EXHAUSTED", "details": [ - {"@type": "type.googleapis.com/google.rpc.ErrorInfo", "metadata": {"model": "gemini-3-flash"}, "reason": "MODEL_CAPACITY_EXHAUSTED"}, + {"@type": "type.googleapis.com/google.rpc.ErrorInfo", "metadata": {"model": "gemini-3-flash"}, "reason": "RATE_LIMIT_EXCEEDED"}, {"@type": "type.googleapis.com/google.rpc.RetryInfo", "retryDelay": "0.1s"} ] } @@ -371,9 +373,9 @@ func TestHandleSmartRetry_503_ShortDelay_NoSingleAccountRetry_SetsRateLimit(t *t respBody := []byte(`{ "error": { "code": 503, - "status": "UNAVAILABLE", + "status": "RESOURCE_EXHAUSTED", "details": [ - {"@type": "type.googleapis.com/google.rpc.ErrorInfo", "metadata": {"model": "gemini-3-flash"}, "reason": "MODEL_CAPACITY_EXHAUSTED"}, + {"@type": "type.googleapis.com/google.rpc.ErrorInfo", "metadata": {"model": "gemini-3-flash"}, "reason": "RATE_LIMIT_EXCEEDED"}, {"@type": "type.googleapis.com/google.rpc.RetryInfo", "retryDelay": "0.1s"} ] } diff --git a/backend/internal/service/error_passthrough_runtime_test.go b/backend/internal/service/error_passthrough_runtime_test.go index f963913b..0a45e57a 100644 --- a/backend/internal/service/error_passthrough_runtime_test.go +++ b/backend/internal/service/error_passthrough_runtime_test.go @@ -219,7 +219,9 @@ func TestApplyErrorPassthroughRule_SkipMonitoringSetsContextKey(t *testing.T) { assert.True(t, matched) v, exists := c.Get(OpsSkipPassthroughKey) assert.True(t, exists, "OpsSkipPassthroughKey should be set when skip_monitoring=true") - assert.True(t, v.(bool)) + boolVal, ok := v.(bool) + assert.True(t, ok, "value should be bool") + assert.True(t, boolVal) } func TestApplyErrorPassthroughRule_NoSkipMonitoringDoesNotSetContextKey(t *testing.T) { diff --git a/backend/internal/service/error_passthrough_service.go b/backend/internal/service/error_passthrough_service.go index caf12676..da8c9ccf 100644 --- a/backend/internal/service/error_passthrough_service.go +++ b/backend/internal/service/error_passthrough_service.go @@ -161,7 +161,7 @@ func (s *ErrorPassthroughService) MatchRule(platform string, statusCode int, bod } lowerPlatform := strings.ToLower(platform) - var bodyLower string // 延迟初始化,只在需要关键词匹配时计算 + var bodyLower string // 延迟初始化,只在需要关键词匹配时计算 var bodyLowerDone bool for _, rule := range rules { diff --git a/backend/internal/service/gateway_service.go b/backend/internal/service/gateway_service.go index 07cb1028..71b1f594 100644 --- a/backend/internal/service/gateway_service.go +++ b/backend/internal/service/gateway_service.go @@ -385,9 +385,10 @@ func (s *GatewayService) TempUnscheduleRetryableError(ctx context.Context, accou return } // 根据状态码选择封禁策略 - if failoverErr.StatusCode == http.StatusBadRequest { + switch failoverErr.StatusCode { + case http.StatusBadRequest: tempUnscheduleGoogleConfigError(ctx, s.accountRepo, accountID, "[handler]") - } else if failoverErr.StatusCode == http.StatusBadGateway { + case http.StatusBadGateway: tempUnscheduleEmptyResponse(ctx, s.accountRepo, accountID, "[handler]") } } From f2770da880ac2d82cef242c31bca1c5ce161c231 Mon Sep 17 00:00:00 2001 From: liuxiongfeng Date: Tue, 10 Feb 2026 23:13:37 +0800 Subject: [PATCH 13/18] refactor: extract failover error handling into shared HandleFailoverError MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Extract duplicated failover error handling from gateway_handler.go (Gemini-compat & Claude paths) and gemini_v1beta_handler.go into shared failover_loop.go - Introduce TempUnscheduler interface for testability (GatewayService implicitly satisfies it) - Add comprehensive unit tests for HandleFailoverError (32 test cases covering all paths) - Fix golangci-lint issues: errcheck in test type assertion, staticcheck QF1003 if/else→switch --- backend/internal/handler/failover_loop.go | 125 ++++ .../internal/handler/failover_loop_test.go | 656 ++++++++++++++++++ backend/internal/handler/gateway_handler.go | 151 +--- .../internal/handler/gemini_v1beta_handler.go | 50 +- .../service/error_passthrough_runtime_test.go | 4 +- backend/internal/service/gateway_service.go | 5 +- 6 files changed, 844 insertions(+), 147 deletions(-) create mode 100644 backend/internal/handler/failover_loop.go create mode 100644 backend/internal/handler/failover_loop_test.go diff --git a/backend/internal/handler/failover_loop.go b/backend/internal/handler/failover_loop.go new file mode 100644 index 00000000..fdba5620 --- /dev/null +++ b/backend/internal/handler/failover_loop.go @@ -0,0 +1,125 @@ +package handler + +import ( + "context" + "log" + "time" + + "sub2api/internal/service" +) + +// TempUnscheduler 用于 HandleFailoverError 中同账号重试耗尽后的临时封禁。 +// GatewayService 隐式实现此接口。 +type TempUnscheduler interface { + TempUnscheduleRetryableError(ctx context.Context, accountID int64, failoverErr *service.UpstreamFailoverError) +} + +// FailoverAction 表示 failover 错误处理后的下一步动作 +type FailoverAction int + +const ( + // FailoverRetry 同账号重试(调用方应 continue 重新进入循环,不更换账号) + FailoverRetry FailoverAction = iota + // FailoverSwitch 切换账号(调用方应 continue 重新选择账号) + FailoverSwitch + // FailoverExhausted 切换次数耗尽(调用方应返回错误响应) + FailoverExhausted + // FailoverCanceled context 已取消(调用方应直接 return) + FailoverCanceled +) + +const ( + // maxSameAccountRetries 同账号重试次数上限(针对 RetryableOnSameAccount 错误) + maxSameAccountRetries = 2 + // sameAccountRetryDelay 同账号重试间隔 + sameAccountRetryDelay = 500 * time.Millisecond +) + +// FailoverState 跨循环迭代共享的 failover 状态 +type FailoverState struct { + SwitchCount int + MaxSwitches int + FailedAccountIDs map[int64]struct{} + SameAccountRetryCount map[int64]int + LastFailoverErr *service.UpstreamFailoverError + ForceCacheBilling bool + hasBoundSession bool +} + +// NewFailoverState 创建 failover 状态 +func NewFailoverState(maxSwitches int, hasBoundSession bool) *FailoverState { + return &FailoverState{ + MaxSwitches: maxSwitches, + FailedAccountIDs: make(map[int64]struct{}), + SameAccountRetryCount: make(map[int64]int), + hasBoundSession: hasBoundSession, + } +} + +// HandleFailoverError 处理 UpstreamFailoverError,返回下一步动作。 +// 包含:缓存计费判断、同账号重试、临时封禁、切换计数、Antigravity 延时。 +func (s *FailoverState) HandleFailoverError( + ctx context.Context, + gatewayService TempUnscheduler, + accountID int64, + platform string, + failoverErr *service.UpstreamFailoverError, +) FailoverAction { + s.LastFailoverErr = failoverErr + + // 缓存计费判断 + if needForceCacheBilling(s.hasBoundSession, failoverErr) { + s.ForceCacheBilling = true + } + + // 同账号重试:对 RetryableOnSameAccount 的临时性错误,先在同一账号上重试 + if failoverErr.RetryableOnSameAccount && s.SameAccountRetryCount[accountID] < maxSameAccountRetries { + s.SameAccountRetryCount[accountID]++ + log.Printf("Account %d: retryable error %d, same-account retry %d/%d", + accountID, failoverErr.StatusCode, s.SameAccountRetryCount[accountID], maxSameAccountRetries) + if !sleepWithContext(ctx, sameAccountRetryDelay) { + return FailoverCanceled + } + return FailoverRetry + } + + // 同账号重试用尽,执行临时封禁 + if failoverErr.RetryableOnSameAccount { + gatewayService.TempUnscheduleRetryableError(ctx, accountID, failoverErr) + } + + // 加入失败列表 + s.FailedAccountIDs[accountID] = struct{}{} + + // 检查是否耗尽 + if s.SwitchCount >= s.MaxSwitches { + return FailoverExhausted + } + + // 递增切换计数 + s.SwitchCount++ + log.Printf("Account %d: upstream error %d, switching account %d/%d", + accountID, failoverErr.StatusCode, s.SwitchCount, s.MaxSwitches) + + // Antigravity 平台换号线性递增延时 + if platform == service.PlatformAntigravity { + if !sleepFailoverDelay(ctx, s.SwitchCount) { + return FailoverCanceled + } + } + + return FailoverSwitch +} + +// sleepWithContext 等待指定时长,返回 false 表示 context 已取消。 +func sleepWithContext(ctx context.Context, d time.Duration) bool { + if d <= 0 { + return true + } + select { + case <-ctx.Done(): + return false + case <-time.After(d): + return true + } +} diff --git a/backend/internal/handler/failover_loop_test.go b/backend/internal/handler/failover_loop_test.go new file mode 100644 index 00000000..00b8fec9 --- /dev/null +++ b/backend/internal/handler/failover_loop_test.go @@ -0,0 +1,656 @@ +package handler + +import ( + "context" + "testing" + "time" + + "sub2api/internal/service" + + "github.com/stretchr/testify/require" +) + +// --------------------------------------------------------------------------- +// Mock +// --------------------------------------------------------------------------- + +// mockTempUnscheduler 记录 TempUnscheduleRetryableError 的调用信息。 +type mockTempUnscheduler struct { + calls []tempUnscheduleCall +} + +type tempUnscheduleCall struct { + accountID int64 + failoverErr *service.UpstreamFailoverError +} + +func (m *mockTempUnscheduler) TempUnscheduleRetryableError(_ context.Context, accountID int64, failoverErr *service.UpstreamFailoverError) { + m.calls = append(m.calls, tempUnscheduleCall{accountID: accountID, failoverErr: failoverErr}) +} + +// --------------------------------------------------------------------------- +// Helper +// --------------------------------------------------------------------------- + +func newTestFailoverErr(statusCode int, retryable, forceBilling bool) *service.UpstreamFailoverError { + return &service.UpstreamFailoverError{ + StatusCode: statusCode, + RetryableOnSameAccount: retryable, + ForceCacheBilling: forceBilling, + } +} + +// --------------------------------------------------------------------------- +// NewFailoverState 测试 +// --------------------------------------------------------------------------- + +func TestNewFailoverState(t *testing.T) { + t.Run("初始化字段正确", func(t *testing.T) { + fs := NewFailoverState(5, true) + require.Equal(t, 5, fs.MaxSwitches) + require.Equal(t, 0, fs.SwitchCount) + require.NotNil(t, fs.FailedAccountIDs) + require.Empty(t, fs.FailedAccountIDs) + require.NotNil(t, fs.SameAccountRetryCount) + require.Empty(t, fs.SameAccountRetryCount) + require.Nil(t, fs.LastFailoverErr) + require.False(t, fs.ForceCacheBilling) + require.True(t, fs.hasBoundSession) + }) + + t.Run("无绑定会话", func(t *testing.T) { + fs := NewFailoverState(3, false) + require.Equal(t, 3, fs.MaxSwitches) + require.False(t, fs.hasBoundSession) + }) + + t.Run("零最大切换次数", func(t *testing.T) { + fs := NewFailoverState(0, false) + require.Equal(t, 0, fs.MaxSwitches) + }) +} + +// --------------------------------------------------------------------------- +// sleepWithContext 测试 +// --------------------------------------------------------------------------- + +func TestSleepWithContext(t *testing.T) { + t.Run("零时长立即返回true", func(t *testing.T) { + start := time.Now() + ok := sleepWithContext(context.Background(), 0) + require.True(t, ok) + require.Less(t, time.Since(start), 50*time.Millisecond) + }) + + t.Run("负时长立即返回true", func(t *testing.T) { + start := time.Now() + ok := sleepWithContext(context.Background(), -1*time.Second) + require.True(t, ok) + require.Less(t, time.Since(start), 50*time.Millisecond) + }) + + t.Run("正常等待后返回true", func(t *testing.T) { + start := time.Now() + ok := sleepWithContext(context.Background(), 50*time.Millisecond) + elapsed := time.Since(start) + require.True(t, ok) + require.GreaterOrEqual(t, elapsed, 40*time.Millisecond) + require.Less(t, elapsed, 500*time.Millisecond) + }) + + t.Run("已取消context立即返回false", func(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + cancel() + + start := time.Now() + ok := sleepWithContext(ctx, 5*time.Second) + require.False(t, ok) + require.Less(t, time.Since(start), 50*time.Millisecond) + }) + + t.Run("等待期间context取消返回false", func(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + go func() { + time.Sleep(30 * time.Millisecond) + cancel() + }() + + start := time.Now() + ok := sleepWithContext(ctx, 5*time.Second) + elapsed := time.Since(start) + require.False(t, ok) + require.Less(t, elapsed, 500*time.Millisecond) + }) +} + +// --------------------------------------------------------------------------- +// HandleFailoverError — 基本切换流程 +// --------------------------------------------------------------------------- + +func TestHandleFailoverError_BasicSwitch(t *testing.T) { + t.Run("非重试错误_非Antigravity_直接切换", func(t *testing.T) { + mock := &mockTempUnscheduler{} + fs := NewFailoverState(3, false) + err := newTestFailoverErr(500, false, false) + + action := fs.HandleFailoverError(context.Background(), mock, 100, "openai", err) + + require.Equal(t, FailoverSwitch, action) + require.Equal(t, 1, fs.SwitchCount) + require.Contains(t, fs.FailedAccountIDs, int64(100)) + require.Equal(t, err, fs.LastFailoverErr) + require.False(t, fs.ForceCacheBilling) + require.Empty(t, mock.calls, "不应调用 TempUnschedule") + }) + + t.Run("非重试错误_Antigravity_第一次切换无延迟", func(t *testing.T) { + // switchCount 从 0→1 时,sleepFailoverDelay(ctx, 1) 的延时 = (1-1)*1s = 0 + mock := &mockTempUnscheduler{} + fs := NewFailoverState(3, false) + err := newTestFailoverErr(500, false, false) + + start := time.Now() + action := fs.HandleFailoverError(context.Background(), mock, 100, service.PlatformAntigravity, err) + elapsed := time.Since(start) + + require.Equal(t, FailoverSwitch, action) + require.Equal(t, 1, fs.SwitchCount) + require.Less(t, elapsed, 200*time.Millisecond, "第一次切换延迟应为 0") + }) + + t.Run("非重试错误_Antigravity_第二次切换有1秒延迟", func(t *testing.T) { + // switchCount 从 1→2 时,sleepFailoverDelay(ctx, 2) 的延时 = (2-1)*1s = 1s + mock := &mockTempUnscheduler{} + fs := NewFailoverState(3, false) + fs.SwitchCount = 1 // 模拟已切换一次 + + err := newTestFailoverErr(500, false, false) + start := time.Now() + action := fs.HandleFailoverError(context.Background(), mock, 200, service.PlatformAntigravity, err) + elapsed := time.Since(start) + + require.Equal(t, FailoverSwitch, action) + require.Equal(t, 2, fs.SwitchCount) + require.GreaterOrEqual(t, elapsed, 800*time.Millisecond, "第二次切换延迟应约 1s") + require.Less(t, elapsed, 3*time.Second) + }) + + t.Run("连续切换直到耗尽", func(t *testing.T) { + mock := &mockTempUnscheduler{} + fs := NewFailoverState(2, false) + + // 第一次切换:0→1 + err1 := newTestFailoverErr(500, false, false) + action := fs.HandleFailoverError(context.Background(), mock, 100, "openai", err1) + require.Equal(t, FailoverSwitch, action) + require.Equal(t, 1, fs.SwitchCount) + + // 第二次切换:1→2 + err2 := newTestFailoverErr(502, false, false) + action = fs.HandleFailoverError(context.Background(), mock, 200, "openai", err2) + require.Equal(t, FailoverSwitch, action) + require.Equal(t, 2, fs.SwitchCount) + + // 第三次已耗尽:SwitchCount(2) >= MaxSwitches(2) + err3 := newTestFailoverErr(503, false, false) + action = fs.HandleFailoverError(context.Background(), mock, 300, "openai", err3) + require.Equal(t, FailoverExhausted, action) + require.Equal(t, 2, fs.SwitchCount, "耗尽时不应继续递增") + + // 验证失败账号列表 + require.Len(t, fs.FailedAccountIDs, 3) + require.Contains(t, fs.FailedAccountIDs, int64(100)) + require.Contains(t, fs.FailedAccountIDs, int64(200)) + require.Contains(t, fs.FailedAccountIDs, int64(300)) + + // LastFailoverErr 应为最后一次的错误 + require.Equal(t, err3, fs.LastFailoverErr) + }) + + t.Run("MaxSwitches为0时首次即耗尽", func(t *testing.T) { + mock := &mockTempUnscheduler{} + fs := NewFailoverState(0, false) + err := newTestFailoverErr(500, false, false) + + action := fs.HandleFailoverError(context.Background(), mock, 100, "openai", err) + require.Equal(t, FailoverExhausted, action) + require.Equal(t, 0, fs.SwitchCount) + require.Contains(t, fs.FailedAccountIDs, int64(100)) + }) +} + +// --------------------------------------------------------------------------- +// HandleFailoverError — 缓存计费 (ForceCacheBilling) +// --------------------------------------------------------------------------- + +func TestHandleFailoverError_CacheBilling(t *testing.T) { + t.Run("hasBoundSession为true时设置ForceCacheBilling", func(t *testing.T) { + mock := &mockTempUnscheduler{} + fs := NewFailoverState(3, true) // hasBoundSession=true + err := newTestFailoverErr(500, false, false) + + fs.HandleFailoverError(context.Background(), mock, 100, "openai", err) + require.True(t, fs.ForceCacheBilling) + }) + + t.Run("failoverErr.ForceCacheBilling为true时设置", func(t *testing.T) { + mock := &mockTempUnscheduler{} + fs := NewFailoverState(3, false) + err := newTestFailoverErr(500, false, true) // ForceCacheBilling=true + + fs.HandleFailoverError(context.Background(), mock, 100, "openai", err) + require.True(t, fs.ForceCacheBilling) + }) + + t.Run("两者均为false时不设置", func(t *testing.T) { + mock := &mockTempUnscheduler{} + fs := NewFailoverState(3, false) + err := newTestFailoverErr(500, false, false) + + fs.HandleFailoverError(context.Background(), mock, 100, "openai", err) + require.False(t, fs.ForceCacheBilling) + }) + + t.Run("一旦设置不会被后续错误重置", func(t *testing.T) { + mock := &mockTempUnscheduler{} + fs := NewFailoverState(3, false) + + // 第一次:ForceCacheBilling=true → 设置 + err1 := newTestFailoverErr(500, false, true) + fs.HandleFailoverError(context.Background(), mock, 100, "openai", err1) + require.True(t, fs.ForceCacheBilling) + + // 第二次:ForceCacheBilling=false → 仍然保持 true + err2 := newTestFailoverErr(502, false, false) + fs.HandleFailoverError(context.Background(), mock, 200, "openai", err2) + require.True(t, fs.ForceCacheBilling, "ForceCacheBilling 一旦设置不应被重置") + }) +} + +// --------------------------------------------------------------------------- +// HandleFailoverError — 同账号重试 (RetryableOnSameAccount) +// --------------------------------------------------------------------------- + +func TestHandleFailoverError_SameAccountRetry(t *testing.T) { + t.Run("第一次重试返回FailoverRetry", func(t *testing.T) { + mock := &mockTempUnscheduler{} + fs := NewFailoverState(3, false) + err := newTestFailoverErr(400, true, false) + + start := time.Now() + action := fs.HandleFailoverError(context.Background(), mock, 100, "openai", err) + elapsed := time.Since(start) + + require.Equal(t, FailoverRetry, action) + require.Equal(t, 1, fs.SameAccountRetryCount[100]) + require.Equal(t, 0, fs.SwitchCount, "同账号重试不应增加切换计数") + require.NotContains(t, fs.FailedAccountIDs, int64(100), "同账号重试不应加入失败列表") + require.Empty(t, mock.calls, "同账号重试期间不应调用 TempUnschedule") + // 验证等待了 sameAccountRetryDelay (500ms) + require.GreaterOrEqual(t, elapsed, 400*time.Millisecond) + require.Less(t, elapsed, 2*time.Second) + }) + + t.Run("第二次重试仍返回FailoverRetry", func(t *testing.T) { + mock := &mockTempUnscheduler{} + fs := NewFailoverState(3, false) + err := newTestFailoverErr(400, true, false) + + // 第一次 + action := fs.HandleFailoverError(context.Background(), mock, 100, "openai", err) + require.Equal(t, FailoverRetry, action) + require.Equal(t, 1, fs.SameAccountRetryCount[100]) + + // 第二次 + action = fs.HandleFailoverError(context.Background(), mock, 100, "openai", err) + require.Equal(t, FailoverRetry, action) + require.Equal(t, 2, fs.SameAccountRetryCount[100]) + + require.Empty(t, mock.calls, "两次重试期间均不应调用 TempUnschedule") + }) + + t.Run("第三次重试耗尽_触发TempUnschedule并切换", func(t *testing.T) { + mock := &mockTempUnscheduler{} + fs := NewFailoverState(3, false) + err := newTestFailoverErr(400, true, false) + + // 第一次、第二次重试 + fs.HandleFailoverError(context.Background(), mock, 100, "openai", err) + fs.HandleFailoverError(context.Background(), mock, 100, "openai", err) + require.Equal(t, 2, fs.SameAccountRetryCount[100]) + + // 第三次:重试已达到 maxSameAccountRetries(2),应切换账号 + action := fs.HandleFailoverError(context.Background(), mock, 100, "openai", err) + require.Equal(t, FailoverSwitch, action) + require.Equal(t, 1, fs.SwitchCount) + require.Contains(t, fs.FailedAccountIDs, int64(100)) + + // 验证 TempUnschedule 被调用 + require.Len(t, mock.calls, 1) + require.Equal(t, int64(100), mock.calls[0].accountID) + require.Equal(t, err, mock.calls[0].failoverErr) + }) + + t.Run("不同账号独立跟踪重试次数", func(t *testing.T) { + mock := &mockTempUnscheduler{} + fs := NewFailoverState(5, false) + err := newTestFailoverErr(400, true, false) + + // 账号 100 第一次重试 + action := fs.HandleFailoverError(context.Background(), mock, 100, "openai", err) + require.Equal(t, FailoverRetry, action) + require.Equal(t, 1, fs.SameAccountRetryCount[100]) + + // 账号 200 第一次重试(独立计数) + action = fs.HandleFailoverError(context.Background(), mock, 200, "openai", err) + require.Equal(t, FailoverRetry, action) + require.Equal(t, 1, fs.SameAccountRetryCount[200]) + require.Equal(t, 1, fs.SameAccountRetryCount[100], "账号 100 的计数不应受影响") + }) + + t.Run("重试耗尽后再次遇到同账号_直接切换", func(t *testing.T) { + mock := &mockTempUnscheduler{} + fs := NewFailoverState(5, false) + err := newTestFailoverErr(400, true, false) + + // 耗尽账号 100 的重试 + fs.HandleFailoverError(context.Background(), mock, 100, "openai", err) // retry 1 + fs.HandleFailoverError(context.Background(), mock, 100, "openai", err) // retry 2 + action := fs.HandleFailoverError(context.Background(), mock, 100, "openai", err) // exhausted → switch + require.Equal(t, FailoverSwitch, action) + + // 再次遇到账号 100,计数仍为 2,条件不满足 → 直接切换 + action = fs.HandleFailoverError(context.Background(), mock, 100, "openai", err) + require.Equal(t, FailoverSwitch, action) + require.Len(t, mock.calls, 2, "第二次耗尽也应调用 TempUnschedule") + }) +} + +// --------------------------------------------------------------------------- +// HandleFailoverError — TempUnschedule 调用验证 +// --------------------------------------------------------------------------- + +func TestHandleFailoverError_TempUnschedule(t *testing.T) { + t.Run("非重试错误不调用TempUnschedule", func(t *testing.T) { + mock := &mockTempUnscheduler{} + fs := NewFailoverState(3, false) + err := newTestFailoverErr(500, false, false) // RetryableOnSameAccount=false + + fs.HandleFailoverError(context.Background(), mock, 100, "openai", err) + require.Empty(t, mock.calls) + }) + + t.Run("重试错误耗尽后调用TempUnschedule_传入正确参数", func(t *testing.T) { + mock := &mockTempUnscheduler{} + fs := NewFailoverState(3, false) + err := newTestFailoverErr(502, true, false) + + // 耗尽重试 + fs.HandleFailoverError(context.Background(), mock, 42, "openai", err) + fs.HandleFailoverError(context.Background(), mock, 42, "openai", err) + fs.HandleFailoverError(context.Background(), mock, 42, "openai", err) + + require.Len(t, mock.calls, 1) + require.Equal(t, int64(42), mock.calls[0].accountID) + require.Equal(t, 502, mock.calls[0].failoverErr.StatusCode) + require.True(t, mock.calls[0].failoverErr.RetryableOnSameAccount) + }) +} + +// --------------------------------------------------------------------------- +// HandleFailoverError — Context 取消 +// --------------------------------------------------------------------------- + +func TestHandleFailoverError_ContextCanceled(t *testing.T) { + t.Run("同账号重试sleep期间context取消", func(t *testing.T) { + mock := &mockTempUnscheduler{} + fs := NewFailoverState(3, false) + err := newTestFailoverErr(400, true, false) + + ctx, cancel := context.WithCancel(context.Background()) + cancel() // 立即取消 + + start := time.Now() + action := fs.HandleFailoverError(ctx, mock, 100, "openai", err) + elapsed := time.Since(start) + + require.Equal(t, FailoverCanceled, action) + require.Less(t, elapsed, 100*time.Millisecond, "应立即返回") + // 重试计数仍应递增 + require.Equal(t, 1, fs.SameAccountRetryCount[100]) + }) + + t.Run("Antigravity延迟期间context取消", func(t *testing.T) { + mock := &mockTempUnscheduler{} + fs := NewFailoverState(3, false) + fs.SwitchCount = 1 // 下一次 switchCount=2 → delay = 1s + err := newTestFailoverErr(500, false, false) + + ctx, cancel := context.WithCancel(context.Background()) + cancel() // 立即取消 + + start := time.Now() + action := fs.HandleFailoverError(ctx, mock, 100, service.PlatformAntigravity, err) + elapsed := time.Since(start) + + require.Equal(t, FailoverCanceled, action) + require.Less(t, elapsed, 100*time.Millisecond, "应立即返回而非等待 1s") + }) +} + +// --------------------------------------------------------------------------- +// HandleFailoverError — FailedAccountIDs 跟踪 +// --------------------------------------------------------------------------- + +func TestHandleFailoverError_FailedAccountIDs(t *testing.T) { + t.Run("切换时添加到失败列表", func(t *testing.T) { + mock := &mockTempUnscheduler{} + fs := NewFailoverState(3, false) + + fs.HandleFailoverError(context.Background(), mock, 100, "openai", newTestFailoverErr(500, false, false)) + require.Contains(t, fs.FailedAccountIDs, int64(100)) + + fs.HandleFailoverError(context.Background(), mock, 200, "openai", newTestFailoverErr(502, false, false)) + require.Contains(t, fs.FailedAccountIDs, int64(200)) + require.Len(t, fs.FailedAccountIDs, 2) + }) + + t.Run("耗尽时也添加到失败列表", func(t *testing.T) { + mock := &mockTempUnscheduler{} + fs := NewFailoverState(0, false) + + action := fs.HandleFailoverError(context.Background(), mock, 100, "openai", newTestFailoverErr(500, false, false)) + require.Equal(t, FailoverExhausted, action) + require.Contains(t, fs.FailedAccountIDs, int64(100)) + }) + + t.Run("同账号重试期间不添加到失败列表", func(t *testing.T) { + mock := &mockTempUnscheduler{} + fs := NewFailoverState(3, false) + + action := fs.HandleFailoverError(context.Background(), mock, 100, "openai", newTestFailoverErr(400, true, false)) + require.Equal(t, FailoverRetry, action) + require.NotContains(t, fs.FailedAccountIDs, int64(100)) + }) + + t.Run("同一账号多次切换不重复添加", func(t *testing.T) { + mock := &mockTempUnscheduler{} + fs := NewFailoverState(5, false) + + fs.HandleFailoverError(context.Background(), mock, 100, "openai", newTestFailoverErr(500, false, false)) + fs.HandleFailoverError(context.Background(), mock, 100, "openai", newTestFailoverErr(500, false, false)) + require.Len(t, fs.FailedAccountIDs, 1, "map 天然去重") + }) +} + +// --------------------------------------------------------------------------- +// HandleFailoverError — LastFailoverErr 更新 +// --------------------------------------------------------------------------- + +func TestHandleFailoverError_LastFailoverErr(t *testing.T) { + t.Run("每次调用都更新LastFailoverErr", func(t *testing.T) { + mock := &mockTempUnscheduler{} + fs := NewFailoverState(3, false) + + err1 := newTestFailoverErr(500, false, false) + fs.HandleFailoverError(context.Background(), mock, 100, "openai", err1) + require.Equal(t, err1, fs.LastFailoverErr) + + err2 := newTestFailoverErr(502, false, false) + fs.HandleFailoverError(context.Background(), mock, 200, "openai", err2) + require.Equal(t, err2, fs.LastFailoverErr) + }) + + t.Run("同账号重试时也更新LastFailoverErr", func(t *testing.T) { + mock := &mockTempUnscheduler{} + fs := NewFailoverState(3, false) + + err := newTestFailoverErr(400, true, false) + fs.HandleFailoverError(context.Background(), mock, 100, "openai", err) + require.Equal(t, err, fs.LastFailoverErr) + }) +} + +// --------------------------------------------------------------------------- +// HandleFailoverError — 综合集成场景 +// --------------------------------------------------------------------------- + +func TestHandleFailoverError_IntegrationScenario(t *testing.T) { + t.Run("模拟完整failover流程_多账号混合重试与切换", func(t *testing.T) { + mock := &mockTempUnscheduler{} + fs := NewFailoverState(3, true) // hasBoundSession=true + + // 1. 账号 100 遇到可重试错误,同账号重试 2 次 + retryErr := newTestFailoverErr(400, true, false) + action := fs.HandleFailoverError(context.Background(), mock, 100, "openai", retryErr) + require.Equal(t, FailoverRetry, action) + require.True(t, fs.ForceCacheBilling, "hasBoundSession=true 应设置 ForceCacheBilling") + + action = fs.HandleFailoverError(context.Background(), mock, 100, "openai", retryErr) + require.Equal(t, FailoverRetry, action) + + // 2. 账号 100 重试耗尽 → TempUnschedule + 切换 + action = fs.HandleFailoverError(context.Background(), mock, 100, "openai", retryErr) + require.Equal(t, FailoverSwitch, action) + require.Equal(t, 1, fs.SwitchCount) + require.Len(t, mock.calls, 1) + + // 3. 账号 200 遇到不可重试错误 → 直接切换 + switchErr := newTestFailoverErr(500, false, false) + action = fs.HandleFailoverError(context.Background(), mock, 200, "openai", switchErr) + require.Equal(t, FailoverSwitch, action) + require.Equal(t, 2, fs.SwitchCount) + + // 4. 账号 300 遇到不可重试错误 → 再切换 + action = fs.HandleFailoverError(context.Background(), mock, 300, "openai", switchErr) + require.Equal(t, FailoverSwitch, action) + require.Equal(t, 3, fs.SwitchCount) + + // 5. 账号 400 → 已耗尽 (SwitchCount=3 >= MaxSwitches=3) + action = fs.HandleFailoverError(context.Background(), mock, 400, "openai", switchErr) + require.Equal(t, FailoverExhausted, action) + + // 最终状态验证 + require.Equal(t, 3, fs.SwitchCount, "耗尽时不再递增") + require.Len(t, fs.FailedAccountIDs, 4, "4个不同账号都在失败列表中") + require.True(t, fs.ForceCacheBilling) + require.Len(t, mock.calls, 1, "只有账号 100 触发了 TempUnschedule") + }) + + t.Run("模拟Antigravity平台完整流程", func(t *testing.T) { + mock := &mockTempUnscheduler{} + fs := NewFailoverState(2, false) + + err := newTestFailoverErr(500, false, false) + + // 第一次切换:delay = 0s + start := time.Now() + action := fs.HandleFailoverError(context.Background(), mock, 100, service.PlatformAntigravity, err) + elapsed := time.Since(start) + require.Equal(t, FailoverSwitch, action) + require.Less(t, elapsed, 200*time.Millisecond, "第一次切换延迟为 0") + + // 第二次切换:delay = 1s + start = time.Now() + action = fs.HandleFailoverError(context.Background(), mock, 200, service.PlatformAntigravity, err) + elapsed = time.Since(start) + require.Equal(t, FailoverSwitch, action) + require.GreaterOrEqual(t, elapsed, 800*time.Millisecond, "第二次切换延迟约 1s") + + // 第三次:耗尽(无延迟,因为在检查延迟之前就返回了) + start = time.Now() + action = fs.HandleFailoverError(context.Background(), mock, 300, service.PlatformAntigravity, err) + elapsed = time.Since(start) + require.Equal(t, FailoverExhausted, action) + require.Less(t, elapsed, 200*time.Millisecond, "耗尽时不应有延迟") + }) + + t.Run("ForceCacheBilling通过错误标志设置", func(t *testing.T) { + mock := &mockTempUnscheduler{} + fs := NewFailoverState(3, false) // hasBoundSession=false + + // 第一次:ForceCacheBilling=false + err1 := newTestFailoverErr(500, false, false) + fs.HandleFailoverError(context.Background(), mock, 100, "openai", err1) + require.False(t, fs.ForceCacheBilling) + + // 第二次:ForceCacheBilling=true(Antigravity 粘性会话切换) + err2 := newTestFailoverErr(500, false, true) + fs.HandleFailoverError(context.Background(), mock, 200, "openai", err2) + require.True(t, fs.ForceCacheBilling, "错误标志应触发 ForceCacheBilling") + + // 第三次:ForceCacheBilling=false,但状态仍保持 true + err3 := newTestFailoverErr(500, false, false) + fs.HandleFailoverError(context.Background(), mock, 300, "openai", err3) + require.True(t, fs.ForceCacheBilling, "不应重置") + }) +} + +// --------------------------------------------------------------------------- +// HandleFailoverError — 边界条件 +// --------------------------------------------------------------------------- + +func TestHandleFailoverError_EdgeCases(t *testing.T) { + t.Run("StatusCode为0的错误也能正常处理", func(t *testing.T) { + mock := &mockTempUnscheduler{} + fs := NewFailoverState(3, false) + err := newTestFailoverErr(0, false, false) + + action := fs.HandleFailoverError(context.Background(), mock, 100, "openai", err) + require.Equal(t, FailoverSwitch, action) + }) + + t.Run("AccountID为0也能正常跟踪", func(t *testing.T) { + mock := &mockTempUnscheduler{} + fs := NewFailoverState(3, false) + err := newTestFailoverErr(500, true, false) + + action := fs.HandleFailoverError(context.Background(), mock, 0, "openai", err) + require.Equal(t, FailoverRetry, action) + require.Equal(t, 1, fs.SameAccountRetryCount[0]) + }) + + t.Run("负AccountID也能正常跟踪", func(t *testing.T) { + mock := &mockTempUnscheduler{} + fs := NewFailoverState(3, false) + err := newTestFailoverErr(500, true, false) + + action := fs.HandleFailoverError(context.Background(), mock, -1, "openai", err) + require.Equal(t, FailoverRetry, action) + require.Equal(t, 1, fs.SameAccountRetryCount[-1]) + }) + + t.Run("空平台名称不触发Antigravity延迟", func(t *testing.T) { + mock := &mockTempUnscheduler{} + fs := NewFailoverState(3, false) + fs.SwitchCount = 1 + err := newTestFailoverErr(500, false, false) + + start := time.Now() + action := fs.HandleFailoverError(context.Background(), mock, 100, "", err) + elapsed := time.Since(start) + + require.Equal(t, FailoverSwitch, action) + require.Less(t, elapsed, 200*time.Millisecond, "空平台不应触发 Antigravity 延迟") + }) +} diff --git a/backend/internal/handler/gateway_handler.go b/backend/internal/handler/gateway_handler.go index c28ee846..d0d0d9ff 100644 --- a/backend/internal/handler/gateway_handler.go +++ b/backend/internal/handler/gateway_handler.go @@ -232,12 +232,7 @@ func (h *GatewayHandler) Messages(c *gin.Context) { hasBoundSession := sessionKey != "" && sessionBoundAccountID > 0 if platform == service.PlatformGemini { - maxAccountSwitches := h.maxAccountSwitchesGemini - switchCount := 0 - failedAccountIDs := make(map[int64]struct{}) - sameAccountRetryCount := make(map[int64]int) // 同账号重试计数 - var lastFailoverErr *service.UpstreamFailoverError - var forceCacheBilling bool // 粘性会话切换时的缓存计费标记 + fs := NewFailoverState(h.maxAccountSwitchesGemini, hasBoundSession) // 单账号分组提前设置 SingleAccountRetry 标记,让 Service 层首次 503 就不设模型限流标记。 // 避免单账号分组收到 503 (MODEL_CAPACITY_EXHAUSTED) 时设 29s 限流,导致后续请求连续快速失败。 @@ -247,27 +242,27 @@ func (h *GatewayHandler) Messages(c *gin.Context) { } for { - selection, err := h.gatewayService.SelectAccountWithLoadAwareness(c.Request.Context(), apiKey.GroupID, sessionKey, reqModel, failedAccountIDs, "") // Gemini 不使用会话限制 + selection, err := h.gatewayService.SelectAccountWithLoadAwareness(c.Request.Context(), apiKey.GroupID, sessionKey, reqModel, fs.FailedAccountIDs, "") // Gemini 不使用会话限制 if err != nil { - if len(failedAccountIDs) == 0 { + if len(fs.FailedAccountIDs) == 0 { h.handleStreamingAwareError(c, http.StatusServiceUnavailable, "api_error", "No available accounts: "+err.Error(), streamStarted) return } // Antigravity 单账号退避重试:分组内没有其他可用账号时, // 对 503 错误不直接返回,而是清除排除列表、等待退避后重试同一个账号。 // 谷歌上游 503 (MODEL_CAPACITY_EXHAUSTED) 通常是暂时性的,等几秒就能恢复。 - if lastFailoverErr != nil && lastFailoverErr.StatusCode == http.StatusServiceUnavailable && switchCount <= maxAccountSwitches { - if sleepAntigravitySingleAccountBackoff(c.Request.Context(), switchCount) { - log.Printf("Antigravity single-account 503 retry: clearing failed accounts, retry %d/%d", switchCount, maxAccountSwitches) - failedAccountIDs = make(map[int64]struct{}) + if fs.LastFailoverErr != nil && fs.LastFailoverErr.StatusCode == http.StatusServiceUnavailable && fs.SwitchCount <= fs.MaxSwitches { + if sleepAntigravitySingleAccountBackoff(c.Request.Context(), fs.SwitchCount) { + log.Printf("Antigravity single-account 503 retry: clearing failed accounts, retry %d/%d", fs.SwitchCount, fs.MaxSwitches) + fs.FailedAccountIDs = make(map[int64]struct{}) // 设置 context 标记,让 Service 层预检查等待限流过期而非直接切换 ctx := context.WithValue(c.Request.Context(), ctxkey.SingleAccountRetry, true) c.Request = c.Request.WithContext(ctx) continue } } - if lastFailoverErr != nil { - h.handleFailoverExhausted(c, lastFailoverErr, service.PlatformGemini, streamStarted) + if fs.LastFailoverErr != nil { + h.handleFailoverExhausted(c, fs.LastFailoverErr, service.PlatformGemini, streamStarted) } else { h.handleFailoverExhaustedSimple(c, 502, streamStarted) } @@ -346,8 +341,8 @@ func (h *GatewayHandler) Messages(c *gin.Context) { // 转发请求 - 根据账号平台分流 var result *service.ForwardResult requestCtx := c.Request.Context() - if switchCount > 0 { - requestCtx = context.WithValue(requestCtx, ctxkey.AccountSwitchCount, switchCount) + if fs.SwitchCount > 0 { + requestCtx = context.WithValue(requestCtx, ctxkey.AccountSwitchCount, fs.SwitchCount) } if account.Platform == service.PlatformAntigravity { result, err = h.antigravityGatewayService.ForwardGemini(requestCtx, c, account, reqModel, "generateContent", reqStream, body, hasBoundSession) @@ -360,40 +355,16 @@ func (h *GatewayHandler) Messages(c *gin.Context) { if err != nil { var failoverErr *service.UpstreamFailoverError if errors.As(err, &failoverErr) { - lastFailoverErr = failoverErr - if needForceCacheBilling(hasBoundSession, failoverErr) { - forceCacheBilling = true - } - - // 同账号重试:对 RetryableOnSameAccount 的临时性错误,先在同一账号上重试 - if failoverErr.RetryableOnSameAccount && sameAccountRetryCount[account.ID] < maxSameAccountRetries { - sameAccountRetryCount[account.ID]++ - log.Printf("Account %d: retryable error %d, same-account retry %d/%d", - account.ID, failoverErr.StatusCode, sameAccountRetryCount[account.ID], maxSameAccountRetries) - if !sleepSameAccountRetryDelay(c.Request.Context()) { - return - } + action := fs.HandleFailoverError(c.Request.Context(), h.gatewayService, account.ID, account.Platform, failoverErr) + switch action { + case FailoverRetry, FailoverSwitch: continue - } - - // 同账号重试用尽,执行临时封禁并切换账号 - if failoverErr.RetryableOnSameAccount { - h.gatewayService.TempUnscheduleRetryableError(c.Request.Context(), account.ID, failoverErr) - } - - failedAccountIDs[account.ID] = struct{}{} - if switchCount >= maxAccountSwitches { - h.handleFailoverExhausted(c, failoverErr, service.PlatformGemini, streamStarted) + case FailoverExhausted: + h.handleFailoverExhausted(c, fs.LastFailoverErr, service.PlatformGemini, streamStarted) + return + case FailoverCanceled: return } - switchCount++ - log.Printf("Account %d: upstream error %d, switching account %d/%d", account.ID, failoverErr.StatusCode, switchCount, maxAccountSwitches) - if account.Platform == service.PlatformAntigravity { - if !sleepFailoverDelay(c.Request.Context(), switchCount) { - return - } - } - continue } // 错误响应已在Forward中处理,这里只记录日志 log.Printf("Forward request failed: %v", err) @@ -421,7 +392,7 @@ func (h *GatewayHandler) Messages(c *gin.Context) { }); err != nil { log.Printf("Record usage failed: %v", err) } - }(result, account, userAgent, clientIP, forceCacheBilling) + }(result, account, userAgent, clientIP, fs.ForceCacheBilling) return } } @@ -442,37 +413,32 @@ func (h *GatewayHandler) Messages(c *gin.Context) { } for { - maxAccountSwitches := h.maxAccountSwitches - switchCount := 0 - failedAccountIDs := make(map[int64]struct{}) - sameAccountRetryCount := make(map[int64]int) // 同账号重试计数 - var lastFailoverErr *service.UpstreamFailoverError + fs := NewFailoverState(h.maxAccountSwitches, hasBoundSession) retryWithFallback := false - var forceCacheBilling bool // 粘性会话切换时的缓存计费标记 for { // 选择支持该模型的账号 - selection, err := h.gatewayService.SelectAccountWithLoadAwareness(c.Request.Context(), currentAPIKey.GroupID, sessionKey, reqModel, failedAccountIDs, parsedReq.MetadataUserID) + selection, err := h.gatewayService.SelectAccountWithLoadAwareness(c.Request.Context(), currentAPIKey.GroupID, sessionKey, reqModel, fs.FailedAccountIDs, parsedReq.MetadataUserID) if err != nil { - if len(failedAccountIDs) == 0 { + if len(fs.FailedAccountIDs) == 0 { h.handleStreamingAwareError(c, http.StatusServiceUnavailable, "api_error", "No available accounts: "+err.Error(), streamStarted) return } // Antigravity 单账号退避重试:分组内没有其他可用账号时, // 对 503 错误不直接返回,而是清除排除列表、等待退避后重试同一个账号。 // 谷歌上游 503 (MODEL_CAPACITY_EXHAUSTED) 通常是暂时性的,等几秒就能恢复。 - if lastFailoverErr != nil && lastFailoverErr.StatusCode == http.StatusServiceUnavailable && switchCount <= maxAccountSwitches { - if sleepAntigravitySingleAccountBackoff(c.Request.Context(), switchCount) { - log.Printf("Antigravity single-account 503 retry: clearing failed accounts, retry %d/%d", switchCount, maxAccountSwitches) - failedAccountIDs = make(map[int64]struct{}) + if fs.LastFailoverErr != nil && fs.LastFailoverErr.StatusCode == http.StatusServiceUnavailable && fs.SwitchCount <= fs.MaxSwitches { + if sleepAntigravitySingleAccountBackoff(c.Request.Context(), fs.SwitchCount) { + log.Printf("Antigravity single-account 503 retry: clearing failed accounts, retry %d/%d", fs.SwitchCount, fs.MaxSwitches) + fs.FailedAccountIDs = make(map[int64]struct{}) // 设置 context 标记,让 Service 层预检查等待限流过期而非直接切换 ctx := context.WithValue(c.Request.Context(), ctxkey.SingleAccountRetry, true) c.Request = c.Request.WithContext(ctx) continue } } - if lastFailoverErr != nil { - h.handleFailoverExhausted(c, lastFailoverErr, platform, streamStarted) + if fs.LastFailoverErr != nil { + h.handleFailoverExhausted(c, fs.LastFailoverErr, platform, streamStarted) } else { h.handleFailoverExhaustedSimple(c, 502, streamStarted) } @@ -549,8 +515,8 @@ func (h *GatewayHandler) Messages(c *gin.Context) { // 转发请求 - 根据账号平台分流 var result *service.ForwardResult requestCtx := c.Request.Context() - if switchCount > 0 { - requestCtx = context.WithValue(requestCtx, ctxkey.AccountSwitchCount, switchCount) + if fs.SwitchCount > 0 { + requestCtx = context.WithValue(requestCtx, ctxkey.AccountSwitchCount, fs.SwitchCount) } if account.Platform == service.PlatformAntigravity && account.Type != service.AccountTypeAPIKey { result, err = h.antigravityGatewayService.Forward(requestCtx, c, account, body, hasBoundSession) @@ -598,40 +564,16 @@ func (h *GatewayHandler) Messages(c *gin.Context) { } var failoverErr *service.UpstreamFailoverError if errors.As(err, &failoverErr) { - lastFailoverErr = failoverErr - if needForceCacheBilling(hasBoundSession, failoverErr) { - forceCacheBilling = true - } - - // 同账号重试:对 RetryableOnSameAccount 的临时性错误,先在同一账号上重试 - if failoverErr.RetryableOnSameAccount && sameAccountRetryCount[account.ID] < maxSameAccountRetries { - sameAccountRetryCount[account.ID]++ - log.Printf("Account %d: retryable error %d, same-account retry %d/%d", - account.ID, failoverErr.StatusCode, sameAccountRetryCount[account.ID], maxSameAccountRetries) - if !sleepSameAccountRetryDelay(c.Request.Context()) { - return - } + action := fs.HandleFailoverError(c.Request.Context(), h.gatewayService, account.ID, account.Platform, failoverErr) + switch action { + case FailoverRetry, FailoverSwitch: continue - } - - // 同账号重试用尽,执行临时封禁并切换账号 - if failoverErr.RetryableOnSameAccount { - h.gatewayService.TempUnscheduleRetryableError(c.Request.Context(), account.ID, failoverErr) - } - - failedAccountIDs[account.ID] = struct{}{} - if switchCount >= maxAccountSwitches { - h.handleFailoverExhausted(c, failoverErr, account.Platform, streamStarted) + case FailoverExhausted: + h.handleFailoverExhausted(c, fs.LastFailoverErr, account.Platform, streamStarted) + return + case FailoverCanceled: return } - switchCount++ - log.Printf("Account %d: upstream error %d, switching account %d/%d", account.ID, failoverErr.StatusCode, switchCount, maxAccountSwitches) - if account.Platform == service.PlatformAntigravity { - if !sleepFailoverDelay(c.Request.Context(), switchCount) { - return - } - } - continue } // 错误响应已在Forward中处理,这里只记录日志 log.Printf("Account %d: Forward request failed: %v", account.ID, err) @@ -659,7 +601,7 @@ func (h *GatewayHandler) Messages(c *gin.Context) { }); err != nil { log.Printf("Record usage failed: %v", err) } - }(result, account, userAgent, clientIP, forceCacheBilling) + }(result, account, userAgent, clientIP, fs.ForceCacheBilling) return } if !retryWithFallback { @@ -899,23 +841,6 @@ func needForceCacheBilling(hasBoundSession bool, failoverErr *service.UpstreamFa return hasBoundSession || (failoverErr != nil && failoverErr.ForceCacheBilling) } -const ( - // maxSameAccountRetries 同账号重试次数上限(针对 RetryableOnSameAccount 错误) - maxSameAccountRetries = 2 - // sameAccountRetryDelay 同账号重试间隔 - sameAccountRetryDelay = 500 * time.Millisecond -) - -// sleepSameAccountRetryDelay 同账号重试固定延时,返回 false 表示 context 已取消。 -func sleepSameAccountRetryDelay(ctx context.Context) bool { - select { - case <-ctx.Done(): - return false - case <-time.After(sameAccountRetryDelay): - return true - } -} - // sleepFailoverDelay 账号切换线性递增延时:第1次0s、第2次1s、第3次2s… // 返回 false 表示 context 已取消。 func sleepFailoverDelay(ctx context.Context, switchCount int) bool { diff --git a/backend/internal/handler/gemini_v1beta_handler.go b/backend/internal/handler/gemini_v1beta_handler.go index f8fb0dcb..14666093 100644 --- a/backend/internal/handler/gemini_v1beta_handler.go +++ b/backend/internal/handler/gemini_v1beta_handler.go @@ -321,11 +321,7 @@ func (h *GatewayHandler) GeminiV1BetaModels(c *gin.Context) { hasBoundSession := sessionKey != "" && sessionBoundAccountID > 0 cleanedForUnknownBinding := false - maxAccountSwitches := h.maxAccountSwitchesGemini - switchCount := 0 - failedAccountIDs := make(map[int64]struct{}) - var lastFailoverErr *service.UpstreamFailoverError - var forceCacheBilling bool // 粘性会话切换时的缓存计费标记 + fs := NewFailoverState(h.maxAccountSwitchesGemini, hasBoundSession) // 单账号分组提前设置 SingleAccountRetry 标记,让 Service 层首次 503 就不设模型限流标记。 // 避免单账号分组收到 503 (MODEL_CAPACITY_EXHAUSTED) 时设 29s 限流,导致后续请求连续快速失败。 @@ -335,26 +331,26 @@ func (h *GatewayHandler) GeminiV1BetaModels(c *gin.Context) { } for { - selection, err := h.gatewayService.SelectAccountWithLoadAwareness(c.Request.Context(), apiKey.GroupID, sessionKey, modelName, failedAccountIDs, "") // Gemini 不使用会话限制 + selection, err := h.gatewayService.SelectAccountWithLoadAwareness(c.Request.Context(), apiKey.GroupID, sessionKey, modelName, fs.FailedAccountIDs, "") // Gemini 不使用会话限制 if err != nil { - if len(failedAccountIDs) == 0 { + if len(fs.FailedAccountIDs) == 0 { googleError(c, http.StatusServiceUnavailable, "No available Gemini accounts: "+err.Error()) return } // Antigravity 单账号退避重试:分组内没有其他可用账号时, // 对 503 错误不直接返回,而是清除排除列表、等待退避后重试同一个账号。 // 谷歌上游 503 (MODEL_CAPACITY_EXHAUSTED) 通常是暂时性的,等几秒就能恢复。 - if lastFailoverErr != nil && lastFailoverErr.StatusCode == http.StatusServiceUnavailable && switchCount <= maxAccountSwitches { - if sleepAntigravitySingleAccountBackoff(c.Request.Context(), switchCount) { - log.Printf("Antigravity single-account 503 retry: clearing failed accounts, retry %d/%d", switchCount, maxAccountSwitches) - failedAccountIDs = make(map[int64]struct{}) + if fs.LastFailoverErr != nil && fs.LastFailoverErr.StatusCode == http.StatusServiceUnavailable && fs.SwitchCount <= fs.MaxSwitches { + if sleepAntigravitySingleAccountBackoff(c.Request.Context(), fs.SwitchCount) { + log.Printf("Antigravity single-account 503 retry: clearing failed accounts, retry %d/%d", fs.SwitchCount, fs.MaxSwitches) + fs.FailedAccountIDs = make(map[int64]struct{}) // 设置 context 标记,让 Service 层预检查等待限流过期而非直接切换 ctx := context.WithValue(c.Request.Context(), ctxkey.SingleAccountRetry, true) c.Request = c.Request.WithContext(ctx) continue } } - h.handleGeminiFailoverExhausted(c, lastFailoverErr) + h.handleGeminiFailoverExhausted(c, fs.LastFailoverErr) return } account := selection.Account @@ -429,8 +425,8 @@ func (h *GatewayHandler) GeminiV1BetaModels(c *gin.Context) { // 5) forward (根据平台分流) var result *service.ForwardResult requestCtx := c.Request.Context() - if switchCount > 0 { - requestCtx = context.WithValue(requestCtx, ctxkey.AccountSwitchCount, switchCount) + if fs.SwitchCount > 0 { + requestCtx = context.WithValue(requestCtx, ctxkey.AccountSwitchCount, fs.SwitchCount) } if account.Platform == service.PlatformAntigravity && account.Type != service.AccountTypeAPIKey { result, err = h.antigravityGatewayService.ForwardGemini(requestCtx, c, account, modelName, action, stream, body, hasBoundSession) @@ -443,24 +439,16 @@ func (h *GatewayHandler) GeminiV1BetaModels(c *gin.Context) { if err != nil { var failoverErr *service.UpstreamFailoverError if errors.As(err, &failoverErr) { - failedAccountIDs[account.ID] = struct{}{} - if needForceCacheBilling(hasBoundSession, failoverErr) { - forceCacheBilling = true - } - if switchCount >= maxAccountSwitches { - lastFailoverErr = failoverErr - h.handleGeminiFailoverExhausted(c, lastFailoverErr) + action := fs.HandleFailoverError(c.Request.Context(), h.gatewayService, account.ID, account.Platform, failoverErr) + switch action { + case FailoverRetry, FailoverSwitch: + continue + case FailoverExhausted: + h.handleGeminiFailoverExhausted(c, fs.LastFailoverErr) + return + case FailoverCanceled: return } - lastFailoverErr = failoverErr - switchCount++ - log.Printf("Gemini account %d: upstream error %d, switching account %d/%d", account.ID, failoverErr.StatusCode, switchCount, maxAccountSwitches) - if account.Platform == service.PlatformAntigravity { - if !sleepFailoverDelay(c.Request.Context(), switchCount) { - return - } - } - continue } // ForwardNative already wrote the response log.Printf("Gemini native forward failed: %v", err) @@ -506,7 +494,7 @@ func (h *GatewayHandler) GeminiV1BetaModels(c *gin.Context) { }); err != nil { log.Printf("Record usage failed: %v", err) } - }(result, account, userAgent, clientIP, forceCacheBilling) + }(result, account, userAgent, clientIP, fs.ForceCacheBilling) return } } diff --git a/backend/internal/service/error_passthrough_runtime_test.go b/backend/internal/service/error_passthrough_runtime_test.go index f963913b..4a4309f9 100644 --- a/backend/internal/service/error_passthrough_runtime_test.go +++ b/backend/internal/service/error_passthrough_runtime_test.go @@ -219,7 +219,9 @@ func TestApplyErrorPassthroughRule_SkipMonitoringSetsContextKey(t *testing.T) { assert.True(t, matched) v, exists := c.Get(OpsSkipPassthroughKey) assert.True(t, exists, "OpsSkipPassthroughKey should be set when skip_monitoring=true") - assert.True(t, v.(bool)) + boolVal, ok := v.(bool) + assert.True(t, ok, "value should be a bool") + assert.True(t, boolVal) } func TestApplyErrorPassthroughRule_NoSkipMonitoringDoesNotSetContextKey(t *testing.T) { diff --git a/backend/internal/service/gateway_service.go b/backend/internal/service/gateway_service.go index 07cb1028..71b1f594 100644 --- a/backend/internal/service/gateway_service.go +++ b/backend/internal/service/gateway_service.go @@ -385,9 +385,10 @@ func (s *GatewayService) TempUnscheduleRetryableError(ctx context.Context, accou return } // 根据状态码选择封禁策略 - if failoverErr.StatusCode == http.StatusBadRequest { + switch failoverErr.StatusCode { + case http.StatusBadRequest: tempUnscheduleGoogleConfigError(ctx, s.accountRepo, accountID, "[handler]") - } else if failoverErr.StatusCode == http.StatusBadGateway { + case http.StatusBadGateway: tempUnscheduleEmptyResponse(ctx, s.accountRepo, accountID, "[handler]") } } From a095468850ef1c6be2ad5a96aa0e897a785f0849 Mon Sep 17 00:00:00 2001 From: liuxiongfeng Date: Tue, 10 Feb 2026 23:35:21 +0800 Subject: [PATCH 14/18] fix: correct import path in failover_loop.go and failover_loop_test.go Use github.com/Wei-Shaw/sub2api/internal/service instead of sub2api/internal/service to match the module path in go.mod. --- backend/internal/handler/failover_loop.go | 2 +- backend/internal/handler/failover_loop_test.go | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/backend/internal/handler/failover_loop.go b/backend/internal/handler/failover_loop.go index fdba5620..a161a866 100644 --- a/backend/internal/handler/failover_loop.go +++ b/backend/internal/handler/failover_loop.go @@ -5,7 +5,7 @@ import ( "log" "time" - "sub2api/internal/service" + "github.com/Wei-Shaw/sub2api/internal/service" ) // TempUnscheduler 用于 HandleFailoverError 中同账号重试耗尽后的临时封禁。 diff --git a/backend/internal/handler/failover_loop_test.go b/backend/internal/handler/failover_loop_test.go index 00b8fec9..b534f02e 100644 --- a/backend/internal/handler/failover_loop_test.go +++ b/backend/internal/handler/failover_loop_test.go @@ -5,7 +5,7 @@ import ( "testing" "time" - "sub2api/internal/service" + "github.com/Wei-Shaw/sub2api/internal/service" "github.com/stretchr/testify/require" ) From f702c6665926cbe92845c6df1bd1fc90bd5ce7a9 Mon Sep 17 00:00:00 2001 From: liuxiongfeng Date: Tue, 10 Feb 2026 23:40:37 +0800 Subject: [PATCH 15/18] fix: resolve gofmt alignment issue in failover_loop_test.go Move inline comments to separate lines to avoid gofmt consecutive-line comment alignment requirements. --- backend/internal/handler/failover_loop_test.go | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/backend/internal/handler/failover_loop_test.go b/backend/internal/handler/failover_loop_test.go index b534f02e..ff48e77e 100644 --- a/backend/internal/handler/failover_loop_test.go +++ b/backend/internal/handler/failover_loop_test.go @@ -354,9 +354,10 @@ func TestHandleFailoverError_SameAccountRetry(t *testing.T) { err := newTestFailoverErr(400, true, false) // 耗尽账号 100 的重试 - fs.HandleFailoverError(context.Background(), mock, 100, "openai", err) // retry 1 - fs.HandleFailoverError(context.Background(), mock, 100, "openai", err) // retry 2 - action := fs.HandleFailoverError(context.Background(), mock, 100, "openai", err) // exhausted → switch + fs.HandleFailoverError(context.Background(), mock, 100, "openai", err) + fs.HandleFailoverError(context.Background(), mock, 100, "openai", err) + // 第三次: 重试耗尽 → 切换 + action := fs.HandleFailoverError(context.Background(), mock, 100, "openai", err) require.Equal(t, FailoverSwitch, action) // 再次遇到账号 100,计数仍为 2,条件不满足 → 直接切换 From 57a778dccf905e9ac5e9864e22c07db50ac94707 Mon Sep 17 00:00:00 2001 From: liuxiongfeng Date: Tue, 10 Feb 2026 23:48:23 +0800 Subject: [PATCH 16/18] chore: bump version to 0.1.79.1 --- backend/cmd/server/VERSION | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/backend/cmd/server/VERSION b/backend/cmd/server/VERSION index 3d46fb65..0612b239 100644 --- a/backend/cmd/server/VERSION +++ b/backend/cmd/server/VERSION @@ -1 +1 @@ -0.1.78.2 +0.1.79.1 From 91ad94d941ea977bcb67ea68a7e7201fe3ace32e Mon Sep 17 00:00:00 2001 From: liuxiongfeng Date: Wed, 11 Feb 2026 00:20:35 +0800 Subject: [PATCH 17/18] =?UTF-8?q?docs:=20=E9=83=A8=E7=BD=B2=E6=B5=81?= =?UTF-8?q?=E7=A8=8B=E6=94=B9=E4=B8=BA=E6=9E=84=E5=BB=BA=E6=9C=8D=E5=8A=A1?= =?UTF-8?q?=E5=99=A8=E4=B8=8E=E7=94=9F=E4=BA=A7=E6=9C=8D=E5=8A=A1=E5=99=A8?= =?UTF-8?q?=E5=88=86=E7=A6=BB?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 镜像构建从生产服务器(clicodeplus)迁移到构建服务器(us-asaki-root), 通过 docker save/load 管道传输,避免编译时资源占用影响线上服务。 --- AGENTS.md | 143 ++++++++++++++++++++++++++++++++++++------------------ CLAUDE.md | 143 ++++++++++++++++++++++++++++++++++++------------------ 2 files changed, 190 insertions(+), 96 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index a7a3e34a..9532d448 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -97,13 +97,22 @@ git push origin main ### 前置条件 -- 本地已配置 SSH 别名 `clicodeplus` 连接到服务器 -- 服务器部署目录:`/root/sub2api`(正式)、`/root/sub2api-beta`(测试) -- 服务器使用 Docker Compose 部署 +- 本地已配置 SSH 别名 `clicodeplus` 连接到生产服务器(运行服务) +- 本地已配置 SSH 别名 `us-asaki-root` 连接到构建服务器(拉取代码、构建镜像) +- 生产服务器部署目录:`/root/sub2api`(正式)、`/root/sub2api-beta`(测试) +- 生产服务器使用 Docker Compose 部署 +- **镜像统一在构建服务器上构建**,避免生产服务器因编译占用 CPU/内存影响线上服务 + +### 服务器角色说明 + +| 服务器 | SSH 别名 | 职责 | +|--------|----------|------| +| 构建服务器 | `us-asaki-root` | 拉取代码、`docker build` 构建镜像 | +| 生产服务器 | `clicodeplus` | 加载镜像、运行服务、部署验证 | ### 部署环境说明 -| 环境 | 目录 | 端口 | 数据库 | 容器名 | +| 环境 | 目录(生产服务器) | 端口 | 数据库 | 容器名 | |------|------|------|--------|--------| | 正式 | `/root/sub2api` | 8080 | `sub2api` | `sub2api` | | Beta | `/root/sub2api-beta` | 8084 | `beta` | `sub2api-beta` | @@ -155,26 +164,33 @@ git commit -m "chore: bump version to 0.1.69.2" git push origin release/custom-0.1.69 ``` -#### 1. 服务器拉取代码 +#### 1. 构建服务器拉取代码 ```bash -ssh clicodeplus "cd /root/sub2api && git fetch fork && git checkout -B release/custom-0.1.69 fork/release/custom-0.1.69" +ssh us-asaki-root "cd /root/sub2api && git fetch fork && git checkout -B release/custom-0.1.69 fork/release/custom-0.1.69" ``` -#### 2. 服务器构建镜像 +#### 2. 构建服务器构建镜像 ```bash -ssh clicodeplus "cd /root/sub2api && docker build --no-cache -t sub2api:latest -f Dockerfile ." +ssh us-asaki-root "cd /root/sub2api && docker build --no-cache -t sub2api:latest -f Dockerfile ." ``` -#### 3. 更新镜像标签并重启服务 +#### 3. 传输镜像到生产服务器并加载 + +```bash +# 导出镜像 → 通过管道传输 → 生产服务器加载 +ssh us-asaki-root "docker save sub2api:latest" | ssh clicodeplus "docker load" +``` + +#### 4. 更新镜像标签并重启服务 ```bash ssh clicodeplus "docker tag sub2api:latest weishaw/sub2api:latest" ssh clicodeplus "cd /root/sub2api/deploy && docker compose up -d --force-recreate sub2api" ``` -#### 4. 验证部署 +#### 5. 验证部署 ```bash # 查看启动日志 @@ -213,8 +229,8 @@ ssh clicodeplus "docker ps --format 'table {{.Names}}\t{{.Image}}\t{{.Ports}}' | ### 首次部署步骤 ```bash -# 0) 进入服务器 -ssh clicodeplus +# 0) 进入构建服务器 +ssh us-asaki-root # 1) 克隆代码到新目录(示例使用你的 fork) cd /root @@ -222,7 +238,23 @@ git clone https://github.com/touwaeriol/sub2api.git sub2api-beta cd /root/sub2api-beta git checkout release/custom-0.1.71 -# 2) 准备 beta 的 .env(敏感信息只写这里) +# 2) 构建 beta 镜像 +docker build -t sub2api:beta -f Dockerfile . +exit + +# 3) 传输镜像到生产服务器 +ssh us-asaki-root "docker save sub2api:beta" | ssh clicodeplus "docker load" + +# 4) 在生产服务器上准备 beta 环境 +ssh clicodeplus + +# 克隆代码(仅用于 deploy 配置和版本号确认,不在此构建) +cd /root +git clone https://github.com/touwaeriol/sub2api.git sub2api-beta +cd /root/sub2api-beta +git checkout release/custom-0.1.71 + +# 5) 准备 beta 的 .env(敏感信息只写这里) cd /root/sub2api-beta/deploy # 推荐:从现网 .env 复制,保证除 DB 名/用户/端口外完全一致 @@ -233,7 +265,7 @@ perl -pi -e 's/^SERVER_PORT=.*/SERVER_PORT=8084/' ./.env perl -pi -e 's/^POSTGRES_USER=.*/POSTGRES_USER=beta/' ./.env perl -pi -e 's/^POSTGRES_DB=.*/POSTGRES_DB=beta/' ./.env -# 3) 写 compose override(避免与现网容器名冲突,镜像使用本地构建的 sub2api:beta) +# 6) 写 compose override(避免与现网容器名冲突,镜像使用构建服务器传输的 sub2api:beta) cat > docker-compose.override.yml <<'YAML' services: sub2api: @@ -243,15 +275,11 @@ services: container_name: sub2api-beta-redis YAML -# 4) 构建 beta 镜像(基于当前代码) -cd /root/sub2api-beta -docker build -t sub2api:beta -f Dockerfile . - -# 5) 启动 beta(独立 project,确保不影响现网) +# 7) 启动 beta(独立 project,确保不影响现网) cd /root/sub2api-beta/deploy docker compose -p sub2api-beta --env-file .env -f docker-compose.yml -f docker-compose.override.yml up -d -# 6) 验证 beta +# 8) 验证 beta curl -fsS http://127.0.0.1:8084/health docker logs sub2api-beta --tail 50 ``` @@ -265,11 +293,20 @@ docker logs sub2api-beta --tail 50 注意:需要数据库侧已存在 `beta` 用户与 `beta` 数据库,并授予权限;否则容器会启动失败并不断重启。 -### 更新 beta(拉代码 + 仅重建 beta 容器) +### 更新 beta(构建服务器构建 + 传输 + 仅重启 beta 容器) ```bash +# 1) 构建服务器拉取代码并构建镜像 +ssh us-asaki-root "set -e; cd /root/sub2api-beta && git fetch --all --tags && git checkout -f release/custom-0.1.71 && git reset --hard origin/release/custom-0.1.71" +ssh us-asaki-root "cd /root/sub2api-beta && docker build -t sub2api:beta -f Dockerfile ." + +# 2) 传输镜像到生产服务器 +ssh us-asaki-root "docker save sub2api:beta" | ssh clicodeplus "docker load" + +# 3) 生产服务器同步代码(用于版本号确认和 deploy 配置) ssh clicodeplus "set -e; cd /root/sub2api-beta && git fetch --all --tags && git checkout -f release/custom-0.1.71 && git reset --hard origin/release/custom-0.1.71" -ssh clicodeplus "cd /root/sub2api-beta && docker build -t sub2api:beta -f Dockerfile ." + +# 4) 重启 beta 容器 ssh clicodeplus "cd /root/sub2api-beta/deploy && docker compose -p sub2api-beta --env-file .env -f docker-compose.yml -f docker-compose.override.yml up -d --no-deps --force-recreate sub2api" ssh clicodeplus "curl -fsS http://127.0.0.1:8084/health" ``` @@ -284,7 +321,36 @@ ssh clicodeplus "cd /root/sub2api-beta/deploy && docker compose -p sub2api-beta ## 服务器首次部署 -### 1. 克隆代码并配置远程仓库 +### 1. 构建服务器:克隆代码并配置远程仓库 + +```bash +ssh us-asaki-root +cd /root +git clone https://github.com/Wei-Shaw/sub2api.git +cd sub2api + +# 添加 fork 仓库 +git remote add fork https://github.com/touwaeriol/sub2api.git +``` + +### 2. 构建服务器:切换到定制分支并构建镜像 + +```bash +git fetch fork +git checkout -B release/custom-0.1.69 fork/release/custom-0.1.69 + +cd /root/sub2api +docker build -t sub2api:latest -f Dockerfile . +exit +``` + +### 3. 传输镜像到生产服务器 + +```bash +ssh us-asaki-root "docker save sub2api:latest" | ssh clicodeplus "docker load" +``` + +### 4. 生产服务器:克隆代码并配置环境 ```bash ssh clicodeplus @@ -294,42 +360,23 @@ cd sub2api # 添加 fork 仓库 git remote add fork https://github.com/touwaeriol/sub2api.git -``` - -### 2. 切换到定制分支并配置环境 - -```bash git fetch fork git checkout -B release/custom-0.1.69 fork/release/custom-0.1.69 +# 配置环境变量 cd deploy cp .env.example .env vim .env # 配置 DATABASE_URL, REDIS_URL, JWT_SECRET 等 ``` -### 3. 构建并启动 +### 5. 生产服务器:更新镜像标签并启动服务 ```bash -cd /root/sub2api -docker build -t sub2api:latest -f Dockerfile . docker tag sub2api:latest weishaw/sub2api:latest -cd deploy && docker compose up -d +cd /root/sub2api/deploy && docker compose up -d ``` -### 6. 启动服务 - -```bash -# 进入 deploy 目录 -cd deploy - -# 启动所有服务(PostgreSQL、Redis、sub2api) -docker compose up -d - -# 查看服务状态 -docker compose ps -``` - -### 7. 验证部署 +### 6. 验证部署 ```bash # 查看应用日志 @@ -342,7 +389,7 @@ curl http://localhost:8080/health cat /root/sub2api/backend/cmd/server/VERSION ``` -### 8. 常用运维命令 +### 7. 常用运维命令 ```bash # 查看实时日志 @@ -415,7 +462,7 @@ docker stats sub2api ## 注意事项 -1. **前端必须打包进镜像**:使用 `docker build` 在服务器上构建,Dockerfile 会自动编译前端并 embed 到后端二进制中 +1. **前端必须打包进镜像**:使用 `docker build` 在构建服务器(`us-asaki-root`)上构建,Dockerfile 会自动编译前端并 embed 到后端二进制中,构建完成后通过 `docker save | docker load` 传输到生产服务器(`clicodeplus`) 2. **镜像标签**:docker-compose.yml 使用 `weishaw/sub2api:latest`,本地构建后需要 `docker tag` 覆盖 diff --git a/CLAUDE.md b/CLAUDE.md index a7a3e34a..9532d448 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -97,13 +97,22 @@ git push origin main ### 前置条件 -- 本地已配置 SSH 别名 `clicodeplus` 连接到服务器 -- 服务器部署目录:`/root/sub2api`(正式)、`/root/sub2api-beta`(测试) -- 服务器使用 Docker Compose 部署 +- 本地已配置 SSH 别名 `clicodeplus` 连接到生产服务器(运行服务) +- 本地已配置 SSH 别名 `us-asaki-root` 连接到构建服务器(拉取代码、构建镜像) +- 生产服务器部署目录:`/root/sub2api`(正式)、`/root/sub2api-beta`(测试) +- 生产服务器使用 Docker Compose 部署 +- **镜像统一在构建服务器上构建**,避免生产服务器因编译占用 CPU/内存影响线上服务 + +### 服务器角色说明 + +| 服务器 | SSH 别名 | 职责 | +|--------|----------|------| +| 构建服务器 | `us-asaki-root` | 拉取代码、`docker build` 构建镜像 | +| 生产服务器 | `clicodeplus` | 加载镜像、运行服务、部署验证 | ### 部署环境说明 -| 环境 | 目录 | 端口 | 数据库 | 容器名 | +| 环境 | 目录(生产服务器) | 端口 | 数据库 | 容器名 | |------|------|------|--------|--------| | 正式 | `/root/sub2api` | 8080 | `sub2api` | `sub2api` | | Beta | `/root/sub2api-beta` | 8084 | `beta` | `sub2api-beta` | @@ -155,26 +164,33 @@ git commit -m "chore: bump version to 0.1.69.2" git push origin release/custom-0.1.69 ``` -#### 1. 服务器拉取代码 +#### 1. 构建服务器拉取代码 ```bash -ssh clicodeplus "cd /root/sub2api && git fetch fork && git checkout -B release/custom-0.1.69 fork/release/custom-0.1.69" +ssh us-asaki-root "cd /root/sub2api && git fetch fork && git checkout -B release/custom-0.1.69 fork/release/custom-0.1.69" ``` -#### 2. 服务器构建镜像 +#### 2. 构建服务器构建镜像 ```bash -ssh clicodeplus "cd /root/sub2api && docker build --no-cache -t sub2api:latest -f Dockerfile ." +ssh us-asaki-root "cd /root/sub2api && docker build --no-cache -t sub2api:latest -f Dockerfile ." ``` -#### 3. 更新镜像标签并重启服务 +#### 3. 传输镜像到生产服务器并加载 + +```bash +# 导出镜像 → 通过管道传输 → 生产服务器加载 +ssh us-asaki-root "docker save sub2api:latest" | ssh clicodeplus "docker load" +``` + +#### 4. 更新镜像标签并重启服务 ```bash ssh clicodeplus "docker tag sub2api:latest weishaw/sub2api:latest" ssh clicodeplus "cd /root/sub2api/deploy && docker compose up -d --force-recreate sub2api" ``` -#### 4. 验证部署 +#### 5. 验证部署 ```bash # 查看启动日志 @@ -213,8 +229,8 @@ ssh clicodeplus "docker ps --format 'table {{.Names}}\t{{.Image}}\t{{.Ports}}' | ### 首次部署步骤 ```bash -# 0) 进入服务器 -ssh clicodeplus +# 0) 进入构建服务器 +ssh us-asaki-root # 1) 克隆代码到新目录(示例使用你的 fork) cd /root @@ -222,7 +238,23 @@ git clone https://github.com/touwaeriol/sub2api.git sub2api-beta cd /root/sub2api-beta git checkout release/custom-0.1.71 -# 2) 准备 beta 的 .env(敏感信息只写这里) +# 2) 构建 beta 镜像 +docker build -t sub2api:beta -f Dockerfile . +exit + +# 3) 传输镜像到生产服务器 +ssh us-asaki-root "docker save sub2api:beta" | ssh clicodeplus "docker load" + +# 4) 在生产服务器上准备 beta 环境 +ssh clicodeplus + +# 克隆代码(仅用于 deploy 配置和版本号确认,不在此构建) +cd /root +git clone https://github.com/touwaeriol/sub2api.git sub2api-beta +cd /root/sub2api-beta +git checkout release/custom-0.1.71 + +# 5) 准备 beta 的 .env(敏感信息只写这里) cd /root/sub2api-beta/deploy # 推荐:从现网 .env 复制,保证除 DB 名/用户/端口外完全一致 @@ -233,7 +265,7 @@ perl -pi -e 's/^SERVER_PORT=.*/SERVER_PORT=8084/' ./.env perl -pi -e 's/^POSTGRES_USER=.*/POSTGRES_USER=beta/' ./.env perl -pi -e 's/^POSTGRES_DB=.*/POSTGRES_DB=beta/' ./.env -# 3) 写 compose override(避免与现网容器名冲突,镜像使用本地构建的 sub2api:beta) +# 6) 写 compose override(避免与现网容器名冲突,镜像使用构建服务器传输的 sub2api:beta) cat > docker-compose.override.yml <<'YAML' services: sub2api: @@ -243,15 +275,11 @@ services: container_name: sub2api-beta-redis YAML -# 4) 构建 beta 镜像(基于当前代码) -cd /root/sub2api-beta -docker build -t sub2api:beta -f Dockerfile . - -# 5) 启动 beta(独立 project,确保不影响现网) +# 7) 启动 beta(独立 project,确保不影响现网) cd /root/sub2api-beta/deploy docker compose -p sub2api-beta --env-file .env -f docker-compose.yml -f docker-compose.override.yml up -d -# 6) 验证 beta +# 8) 验证 beta curl -fsS http://127.0.0.1:8084/health docker logs sub2api-beta --tail 50 ``` @@ -265,11 +293,20 @@ docker logs sub2api-beta --tail 50 注意:需要数据库侧已存在 `beta` 用户与 `beta` 数据库,并授予权限;否则容器会启动失败并不断重启。 -### 更新 beta(拉代码 + 仅重建 beta 容器) +### 更新 beta(构建服务器构建 + 传输 + 仅重启 beta 容器) ```bash +# 1) 构建服务器拉取代码并构建镜像 +ssh us-asaki-root "set -e; cd /root/sub2api-beta && git fetch --all --tags && git checkout -f release/custom-0.1.71 && git reset --hard origin/release/custom-0.1.71" +ssh us-asaki-root "cd /root/sub2api-beta && docker build -t sub2api:beta -f Dockerfile ." + +# 2) 传输镜像到生产服务器 +ssh us-asaki-root "docker save sub2api:beta" | ssh clicodeplus "docker load" + +# 3) 生产服务器同步代码(用于版本号确认和 deploy 配置) ssh clicodeplus "set -e; cd /root/sub2api-beta && git fetch --all --tags && git checkout -f release/custom-0.1.71 && git reset --hard origin/release/custom-0.1.71" -ssh clicodeplus "cd /root/sub2api-beta && docker build -t sub2api:beta -f Dockerfile ." + +# 4) 重启 beta 容器 ssh clicodeplus "cd /root/sub2api-beta/deploy && docker compose -p sub2api-beta --env-file .env -f docker-compose.yml -f docker-compose.override.yml up -d --no-deps --force-recreate sub2api" ssh clicodeplus "curl -fsS http://127.0.0.1:8084/health" ``` @@ -284,7 +321,36 @@ ssh clicodeplus "cd /root/sub2api-beta/deploy && docker compose -p sub2api-beta ## 服务器首次部署 -### 1. 克隆代码并配置远程仓库 +### 1. 构建服务器:克隆代码并配置远程仓库 + +```bash +ssh us-asaki-root +cd /root +git clone https://github.com/Wei-Shaw/sub2api.git +cd sub2api + +# 添加 fork 仓库 +git remote add fork https://github.com/touwaeriol/sub2api.git +``` + +### 2. 构建服务器:切换到定制分支并构建镜像 + +```bash +git fetch fork +git checkout -B release/custom-0.1.69 fork/release/custom-0.1.69 + +cd /root/sub2api +docker build -t sub2api:latest -f Dockerfile . +exit +``` + +### 3. 传输镜像到生产服务器 + +```bash +ssh us-asaki-root "docker save sub2api:latest" | ssh clicodeplus "docker load" +``` + +### 4. 生产服务器:克隆代码并配置环境 ```bash ssh clicodeplus @@ -294,42 +360,23 @@ cd sub2api # 添加 fork 仓库 git remote add fork https://github.com/touwaeriol/sub2api.git -``` - -### 2. 切换到定制分支并配置环境 - -```bash git fetch fork git checkout -B release/custom-0.1.69 fork/release/custom-0.1.69 +# 配置环境变量 cd deploy cp .env.example .env vim .env # 配置 DATABASE_URL, REDIS_URL, JWT_SECRET 等 ``` -### 3. 构建并启动 +### 5. 生产服务器:更新镜像标签并启动服务 ```bash -cd /root/sub2api -docker build -t sub2api:latest -f Dockerfile . docker tag sub2api:latest weishaw/sub2api:latest -cd deploy && docker compose up -d +cd /root/sub2api/deploy && docker compose up -d ``` -### 6. 启动服务 - -```bash -# 进入 deploy 目录 -cd deploy - -# 启动所有服务(PostgreSQL、Redis、sub2api) -docker compose up -d - -# 查看服务状态 -docker compose ps -``` - -### 7. 验证部署 +### 6. 验证部署 ```bash # 查看应用日志 @@ -342,7 +389,7 @@ curl http://localhost:8080/health cat /root/sub2api/backend/cmd/server/VERSION ``` -### 8. 常用运维命令 +### 7. 常用运维命令 ```bash # 查看实时日志 @@ -415,7 +462,7 @@ docker stats sub2api ## 注意事项 -1. **前端必须打包进镜像**:使用 `docker build` 在服务器上构建,Dockerfile 会自动编译前端并 embed 到后端二进制中 +1. **前端必须打包进镜像**:使用 `docker build` 在构建服务器(`us-asaki-root`)上构建,Dockerfile 会自动编译前端并 embed 到后端二进制中,构建完成后通过 `docker save | docker load` 传输到生产服务器(`clicodeplus`) 2. **镜像标签**:docker-compose.yml 使用 `weishaw/sub2api:latest`,本地构建后需要 `docker tag` 覆盖 From 30d25084f0e0cea7ac6a2f2b4121f2ea077785bb Mon Sep 17 00:00:00 2001 From: liuxiongfeng Date: Wed, 11 Feb 2026 00:22:19 +0800 Subject: [PATCH 18/18] chore: bump version to 0.1.79.2 --- backend/cmd/server/VERSION | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/backend/cmd/server/VERSION b/backend/cmd/server/VERSION index 0612b239..a98898f3 100644 --- a/backend/cmd/server/VERSION +++ b/backend/cmd/server/VERSION @@ -1 +1 @@ -0.1.79.1 +0.1.79.2