From 979114db45fe817a2741d63f971c2287533b0bc0 Mon Sep 17 00:00:00 2001 From: IanShaw027 <131567472+IanShaw027@users.noreply.github.com> Date: Thu, 5 Feb 2026 13:57:02 +0800 Subject: [PATCH 1/3] =?UTF-8?q?fix(gemini):=20=E4=BF=AE=E5=A4=8D=E5=B7=B2?= =?UTF-8?q?=E6=B3=A8=E5=86=8C=E7=94=A8=E6=88=B7=20OAuth=20=E6=8E=88?= =?UTF-8?q?=E6=9D=83=E6=97=B6=E9=94=99=E8=AF=AF=E8=B0=83=E7=94=A8=20onboar?= =?UTF-8?q?dUser=20=E7=9A=84=E9=97=AE=E9=A2=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 问题:Google One Ultra 等已注册用户在 OAuth 授权时,如果 LoadCodeAssist 返回了 currentTier/paidTier 但没有返回 cloudaicompanionProject,之前的 逻辑会继续调用 onboardUser,导致 INVALID_ARGUMENT 错误。 修复:对齐 Gemini CLI 的处理逻辑: - 当检测到用户已注册(有 currentTier/paidTier)时,不再调用 onboardUser - 先尝试从 Cloud Resource Manager 获取可用项目 - 如果仍无法获取,返回友好的错误提示,引导用户手动填写 Project ID 这个修复解决了 Google One 订阅用户无法正常授权的问题。 --- .../internal/service/gemini_oauth_service.go | 26 +++++++++++++++++++ 1 file changed, 26 insertions(+) diff --git a/backend/internal/service/gemini_oauth_service.go b/backend/internal/service/gemini_oauth_service.go index bc84baeb..fd2932e6 100644 --- a/backend/internal/service/gemini_oauth_service.go +++ b/backend/internal/service/gemini_oauth_service.go @@ -944,6 +944,32 @@ func (s *GeminiOAuthService) fetchProjectID(ctx context.Context, accessToken, pr return strings.TrimSpace(loadResp.CloudAICompanionProject), tierID, nil } + // 关键逻辑:对齐 Gemini CLI 对“已注册用户”的处理方式。 + // 当 LoadCodeAssist 返回了 currentTier / paidTier(表示账号已注册)但没有返回 cloudaicompanionProject 时: + // - 不要再调用 onboardUser(通常不会再分配 project_id,且可能触发 INVALID_ARGUMENT) + // - 先尝试从 Cloud Resource Manager 获取可用项目;仍失败则提示用户手动填写 project_id + if loadResp != nil { + registeredTierID := strings.TrimSpace(loadResp.GetTier()) + if registeredTierID != "" { + // 已注册但未返回 cloudaicompanionProject,这在 Google One 用户中较常见:需要用户自行提供 project_id。 + log.Printf("[GeminiOAuth] User has tier (%s) but no cloudaicompanionProject, trying Cloud Resource Manager...", registeredTierID) + + // Try to get project from Cloud Resource Manager + fallback, fbErr := fetchProjectIDFromResourceManager(ctx, accessToken, proxyURL) + if fbErr == nil && strings.TrimSpace(fallback) != "" { + log.Printf("[GeminiOAuth] Found project from Cloud Resource Manager: %s", fallback) + return strings.TrimSpace(fallback), tierID, nil + } + + // No project found - user must provide project_id manually + log.Printf("[GeminiOAuth] No project found from Cloud Resource Manager, user must provide project_id manually") + return "", tierID, fmt.Errorf("user is registered (tier: %s) but no project_id available. Please provide Project ID manually in the authorization form, or create a project at https://console.cloud.google.com", registeredTierID) + } + } + + // 未检测到 currentTier/paidTier,视为新用户,继续调用 onboardUser + log.Printf("[GeminiOAuth] No currentTier/paidTier found, proceeding with onboardUser (tierID: %s)", tierID) + req := &geminicli.OnboardUserRequest{ TierID: tierID, Metadata: geminicli.LoadCodeAssistMetadata{ From d2527e36eb8a6b57bbf1f9c1629da206bc067900 Mon Sep 17 00:00:00 2001 From: ianshaw Date: Thu, 5 Feb 2026 20:13:06 +0800 Subject: [PATCH 2/3] =?UTF-8?q?feat(gemini):=20=E5=A2=9E=E5=BC=BA=20API=20?= =?UTF-8?q?=E6=8E=88=E6=9D=83=E9=94=99=E8=AF=AF=E5=A4=84=E7=90=86=EF=BC=8C?= =?UTF-8?q?=E8=87=AA=E5=8A=A8=E6=8F=90=E5=8F=96=E5=B9=B6=E6=98=BE=E7=A4=BA?= =?UTF-8?q?=E6=BF=80=E6=B4=BB=20URL?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 当 Gemini for Google Cloud API 未启用时(SERVICE_DISABLED 错误), 系统现在会: - 自动检测 403 PERMISSION_DENIED 错误 - 从错误响应中提取 API 激活 URL - 向用户显示清晰的错误消息和可点击的激活链接 - 提供操作指引(启用后等待几分钟) 新增文件: - internal/pkg/googleapi/error.go: Google API 错误解析器 - internal/pkg/googleapi/error_test.go: 完整的测试覆盖 - GEMINI_API_ERROR_HANDLING.md: 实现文档 修改文件: - internal/repository/geminicli_codeassist_client.go: 在 LoadCodeAssist 和 OnboardUser 中增强错误处理 这大大改善了用户体验,用户不再需要手动从错误日志中查找激活 URL。 --- backend/internal/pkg/googleapi/error.go | 109 +++++++++++++ backend/internal/pkg/googleapi/error_test.go | 143 ++++++++++++++++++ .../repository/geminicli_codeassist_client.go | 35 ++++- 3 files changed, 281 insertions(+), 6 deletions(-) create mode 100644 backend/internal/pkg/googleapi/error.go create mode 100644 backend/internal/pkg/googleapi/error_test.go diff --git a/backend/internal/pkg/googleapi/error.go b/backend/internal/pkg/googleapi/error.go new file mode 100644 index 00000000..b6374e02 --- /dev/null +++ b/backend/internal/pkg/googleapi/error.go @@ -0,0 +1,109 @@ +// Package googleapi provides helpers for Google-style API responses. +package googleapi + +import ( + "encoding/json" + "fmt" + "strings" +) + +// ErrorResponse represents a Google API error response +type ErrorResponse struct { + Error ErrorDetail `json:"error"` +} + +// ErrorDetail contains the error details from Google API +type ErrorDetail struct { + Code int `json:"code"` + Message string `json:"message"` + Status string `json:"status"` + Details []json.RawMessage `json:"details,omitempty"` +} + +// ErrorDetailInfo contains additional error information +type ErrorDetailInfo struct { + Type string `json:"@type"` + Reason string `json:"reason,omitempty"` + Domain string `json:"domain,omitempty"` + Metadata map[string]string `json:"metadata,omitempty"` +} + +// ErrorHelp contains help links +type ErrorHelp struct { + Type string `json:"@type"` + Links []HelpLink `json:"links,omitempty"` +} + +// HelpLink represents a help link +type HelpLink struct { + Description string `json:"description"` + URL string `json:"url"` +} + +// ParseError parses a Google API error response and extracts key information +func ParseError(body string) (*ErrorResponse, error) { + var errResp ErrorResponse + if err := json.Unmarshal([]byte(body), &errResp); err != nil { + return nil, fmt.Errorf("failed to parse error response: %w", err) + } + return &errResp, nil +} + +// ExtractActivationURL extracts the API activation URL from error details +func ExtractActivationURL(body string) string { + var errResp ErrorResponse + if err := json.Unmarshal([]byte(body), &errResp); err != nil { + return "" + } + + // Check error details for activation URL + for _, detailRaw := range errResp.Error.Details { + // Parse as ErrorDetailInfo + var info ErrorDetailInfo + if err := json.Unmarshal(detailRaw, &info); err == nil { + if info.Metadata != nil { + if activationURL, ok := info.Metadata["activationUrl"]; ok && activationURL != "" { + return activationURL + } + } + } + + // Parse as ErrorHelp + var help ErrorHelp + if err := json.Unmarshal(detailRaw, &help); err == nil { + for _, link := range help.Links { + if strings.Contains(link.Description, "activation") || + strings.Contains(link.Description, "API activation") || + strings.Contains(link.URL, "/apis/api/") { + return link.URL + } + } + } + } + + return "" +} + +// IsServiceDisabledError checks if the error is a SERVICE_DISABLED error +func IsServiceDisabledError(body string) bool { + var errResp ErrorResponse + if err := json.Unmarshal([]byte(body), &errResp); err != nil { + return false + } + + // Check if it's a 403 PERMISSION_DENIED with SERVICE_DISABLED reason + if errResp.Error.Code != 403 || errResp.Error.Status != "PERMISSION_DENIED" { + return false + } + + for _, detailRaw := range errResp.Error.Details { + var info ErrorDetailInfo + if err := json.Unmarshal(detailRaw, &info); err == nil { + if info.Reason == "SERVICE_DISABLED" { + return true + } + } + } + + return false +} diff --git a/backend/internal/pkg/googleapi/error_test.go b/backend/internal/pkg/googleapi/error_test.go new file mode 100644 index 00000000..992dcf85 --- /dev/null +++ b/backend/internal/pkg/googleapi/error_test.go @@ -0,0 +1,143 @@ +package googleapi + +import ( + "testing" +) + +func TestExtractActivationURL(t *testing.T) { + // Test case from the user's error message + errorBody := `{ + "error": { + "code": 403, + "message": "Gemini for Google Cloud API has not been used in project project-6eca5881-ab73-4736-843 before or it is disabled. Enable it by visiting https://console.developers.google.com/apis/api/cloudaicompanion.googleapis.com/overview?project=project-6eca5881-ab73-4736-843 then retry. If you enabled this API recently, wait a few minutes for the action to propagate to our systems and retry.", + "status": "PERMISSION_DENIED", + "details": [ + { + "@type": "type.googleapis.com/google.rpc.ErrorInfo", + "reason": "SERVICE_DISABLED", + "domain": "googleapis.com", + "metadata": { + "service": "cloudaicompanion.googleapis.com", + "activationUrl": "https://console.developers.google.com/apis/api/cloudaicompanion.googleapis.com/overview?project=project-6eca5881-ab73-4736-843", + "consumer": "projects/project-6eca5881-ab73-4736-843", + "serviceTitle": "Gemini for Google Cloud API", + "containerInfo": "project-6eca5881-ab73-4736-843" + } + }, + { + "@type": "type.googleapis.com/google.rpc.LocalizedMessage", + "locale": "en-US", + "message": "Gemini for Google Cloud API has not been used in project project-6eca5881-ab73-4736-843 before or it is disabled. Enable it by visiting https://console.developers.google.com/apis/api/cloudaicompanion.googleapis.com/overview?project=project-6eca5881-ab73-4736-843 then retry. If you enabled this API recently, wait a few minutes for the action to propagate to our systems and retry." + }, + { + "@type": "type.googleapis.com/google.rpc.Help", + "links": [ + { + "description": "Google developers console API activation", + "url": "https://console.developers.google.com/apis/api/cloudaicompanion.googleapis.com/overview?project=project-6eca5881-ab73-4736-843" + } + ] + } + ] + } + }` + + activationURL := ExtractActivationURL(errorBody) + expectedURL := "https://console.developers.google.com/apis/api/cloudaicompanion.googleapis.com/overview?project=project-6eca5881-ab73-4736-843" + + if activationURL != expectedURL { + t.Errorf("Expected activation URL %s, got %s", expectedURL, activationURL) + } +} + +func TestIsServiceDisabledError(t *testing.T) { + tests := []struct { + name string + body string + expected bool + }{ + { + name: "SERVICE_DISABLED error", + body: `{ + "error": { + "code": 403, + "status": "PERMISSION_DENIED", + "details": [ + { + "@type": "type.googleapis.com/google.rpc.ErrorInfo", + "reason": "SERVICE_DISABLED" + } + ] + } + }`, + expected: true, + }, + { + name: "Other 403 error", + body: `{ + "error": { + "code": 403, + "status": "PERMISSION_DENIED", + "details": [ + { + "@type": "type.googleapis.com/google.rpc.ErrorInfo", + "reason": "OTHER_REASON" + } + ] + } + }`, + expected: false, + }, + { + name: "404 error", + body: `{ + "error": { + "code": 404, + "status": "NOT_FOUND" + } + }`, + expected: false, + }, + { + name: "Invalid JSON", + body: `invalid json`, + expected: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := IsServiceDisabledError(tt.body) + if result != tt.expected { + t.Errorf("Expected %v, got %v", tt.expected, result) + } + }) + } +} + +func TestParseError(t *testing.T) { + errorBody := `{ + "error": { + "code": 403, + "message": "API not enabled", + "status": "PERMISSION_DENIED" + } + }` + + errResp, err := ParseError(errorBody) + if err != nil { + t.Fatalf("Failed to parse error: %v", err) + } + + if errResp.Error.Code != 403 { + t.Errorf("Expected code 403, got %d", errResp.Error.Code) + } + + if errResp.Error.Status != "PERMISSION_DENIED" { + t.Errorf("Expected status PERMISSION_DENIED, got %s", errResp.Error.Status) + } + + if errResp.Error.Message != "API not enabled" { + t.Errorf("Expected message 'API not enabled', got %s", errResp.Error.Message) + } +} diff --git a/backend/internal/repository/geminicli_codeassist_client.go b/backend/internal/repository/geminicli_codeassist_client.go index d7f54e85..b63be1ad 100644 --- a/backend/internal/repository/geminicli_codeassist_client.go +++ b/backend/internal/repository/geminicli_codeassist_client.go @@ -6,6 +6,7 @@ import ( "time" "github.com/Wei-Shaw/sub2api/internal/pkg/geminicli" + "github.com/Wei-Shaw/sub2api/internal/pkg/googleapi" "github.com/Wei-Shaw/sub2api/internal/service" "github.com/imroc/req/v3" @@ -38,9 +39,20 @@ func (c *geminiCliCodeAssistClient) LoadCodeAssist(ctx context.Context, accessTo return nil, fmt.Errorf("request failed: %w", err) } if !resp.IsSuccessState() { - body := geminicli.SanitizeBodyForLogs(resp.String()) - fmt.Printf("[CodeAssist] LoadCodeAssist failed: status %d, body: %s\n", resp.StatusCode, body) - return nil, fmt.Errorf("loadCodeAssist failed: status %d, body: %s", resp.StatusCode, body) + body := resp.String() + sanitizedBody := geminicli.SanitizeBodyForLogs(body) + fmt.Printf("[CodeAssist] LoadCodeAssist failed: status %d, body: %s\n", resp.StatusCode, sanitizedBody) + + // Check if this is a SERVICE_DISABLED error and extract activation URL + if googleapi.IsServiceDisabledError(body) { + activationURL := googleapi.ExtractActivationURL(body) + if activationURL != "" { + return nil, fmt.Errorf("Gemini for Google Cloud API is not enabled for this project. Please enable it by visiting: %s\n\nAfter enabling the API, wait a few minutes for the changes to propagate, then try again", activationURL) + } + return nil, fmt.Errorf("Gemini for Google Cloud API is not enabled for this project. Please enable it in the Google Cloud Console at: https://console.cloud.google.com/apis/library/cloudaicompanion.googleapis.com") + } + + return nil, fmt.Errorf("loadCodeAssist failed: status %d, body: %s", resp.StatusCode, sanitizedBody) } fmt.Printf("[CodeAssist] LoadCodeAssist success: status %d, response: %+v\n", resp.StatusCode, out) return &out, nil @@ -67,9 +79,20 @@ func (c *geminiCliCodeAssistClient) OnboardUser(ctx context.Context, accessToken return nil, fmt.Errorf("request failed: %w", err) } if !resp.IsSuccessState() { - body := geminicli.SanitizeBodyForLogs(resp.String()) - fmt.Printf("[CodeAssist] OnboardUser failed: status %d, body: %s\n", resp.StatusCode, body) - return nil, fmt.Errorf("onboardUser failed: status %d, body: %s", resp.StatusCode, body) + body := resp.String() + sanitizedBody := geminicli.SanitizeBodyForLogs(body) + fmt.Printf("[CodeAssist] OnboardUser failed: status %d, body: %s\n", resp.StatusCode, sanitizedBody) + + // Check if this is a SERVICE_DISABLED error and extract activation URL + if googleapi.IsServiceDisabledError(body) { + activationURL := googleapi.ExtractActivationURL(body) + if activationURL != "" { + return nil, fmt.Errorf("Gemini for Google Cloud API is not enabled for this project. Please enable it by visiting: %s\n\nAfter enabling the API, wait a few minutes for the changes to propagate, then try again", activationURL) + } + return nil, fmt.Errorf("Gemini for Google Cloud API is not enabled for this project. Please enable it in the Google Cloud Console at: https://console.cloud.google.com/apis/library/cloudaicompanion.googleapis.com") + } + + return nil, fmt.Errorf("onboardUser failed: status %d, body: %s", resp.StatusCode, sanitizedBody) } fmt.Printf("[CodeAssist] OnboardUser success: status %d, response: %+v\n", resp.StatusCode, out) return &out, nil From 7b46bbb6286afd1f07d9b9fe39e6051ce0e5f0e3 Mon Sep 17 00:00:00 2001 From: ianshaw Date: Thu, 5 Feb 2026 20:47:15 +0800 Subject: [PATCH 3/3] =?UTF-8?q?fix(lint):=20=E4=BF=AE=E5=A4=8D=E9=94=99?= =?UTF-8?q?=E8=AF=AF=E6=B6=88=E6=81=AF=E5=A4=A7=E5=86=99=E9=97=AE=E9=A2=98?= =?UTF-8?q?=E4=BB=A5=E7=AC=A6=E5=90=88=20Go=20=E6=83=AF=E4=BE=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../internal/repository/geminicli_codeassist_client.go | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/backend/internal/repository/geminicli_codeassist_client.go b/backend/internal/repository/geminicli_codeassist_client.go index b63be1ad..4f63280d 100644 --- a/backend/internal/repository/geminicli_codeassist_client.go +++ b/backend/internal/repository/geminicli_codeassist_client.go @@ -47,9 +47,9 @@ func (c *geminiCliCodeAssistClient) LoadCodeAssist(ctx context.Context, accessTo if googleapi.IsServiceDisabledError(body) { activationURL := googleapi.ExtractActivationURL(body) if activationURL != "" { - return nil, fmt.Errorf("Gemini for Google Cloud API is not enabled for this project. Please enable it by visiting: %s\n\nAfter enabling the API, wait a few minutes for the changes to propagate, then try again", activationURL) + return nil, fmt.Errorf("gemini API not enabled for this project, please enable it by visiting: %s\n\nAfter enabling the API, wait a few minutes for the changes to propagate, then try again", activationURL) } - return nil, fmt.Errorf("Gemini for Google Cloud API is not enabled for this project. Please enable it in the Google Cloud Console at: https://console.cloud.google.com/apis/library/cloudaicompanion.googleapis.com") + return nil, fmt.Errorf("gemini API not enabled for this project, please enable it in the Google Cloud Console at: https://console.cloud.google.com/apis/library/cloudaicompanion.googleapis.com") } return nil, fmt.Errorf("loadCodeAssist failed: status %d, body: %s", resp.StatusCode, sanitizedBody) @@ -87,9 +87,9 @@ func (c *geminiCliCodeAssistClient) OnboardUser(ctx context.Context, accessToken if googleapi.IsServiceDisabledError(body) { activationURL := googleapi.ExtractActivationURL(body) if activationURL != "" { - return nil, fmt.Errorf("Gemini for Google Cloud API is not enabled for this project. Please enable it by visiting: %s\n\nAfter enabling the API, wait a few minutes for the changes to propagate, then try again", activationURL) + return nil, fmt.Errorf("gemini API not enabled for this project, please enable it by visiting: %s\n\nAfter enabling the API, wait a few minutes for the changes to propagate, then try again", activationURL) } - return nil, fmt.Errorf("Gemini for Google Cloud API is not enabled for this project. Please enable it in the Google Cloud Console at: https://console.cloud.google.com/apis/library/cloudaicompanion.googleapis.com") + return nil, fmt.Errorf("gemini API not enabled for this project, please enable it in the Google Cloud Console at: https://console.cloud.google.com/apis/library/cloudaicompanion.googleapis.com") } return nil, fmt.Errorf("onboardUser failed: status %d, body: %s", resp.StatusCode, sanitizedBody)