Merge pull request #490 from IanShaw027/fix/gemini-oauth-registered-user
fix(gemini): 修复已注册用户 OAuth 授权问题并增强错误提示
This commit is contained in:
109
backend/internal/pkg/googleapi/error.go
Normal file
109
backend/internal/pkg/googleapi/error.go
Normal file
@@ -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
|
||||||
|
}
|
||||||
143
backend/internal/pkg/googleapi/error_test.go
Normal file
143
backend/internal/pkg/googleapi/error_test.go
Normal file
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -6,6 +6,7 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/Wei-Shaw/sub2api/internal/pkg/geminicli"
|
"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/Wei-Shaw/sub2api/internal/service"
|
||||||
|
|
||||||
"github.com/imroc/req/v3"
|
"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)
|
return nil, fmt.Errorf("request failed: %w", err)
|
||||||
}
|
}
|
||||||
if !resp.IsSuccessState() {
|
if !resp.IsSuccessState() {
|
||||||
body := geminicli.SanitizeBodyForLogs(resp.String())
|
body := resp.String()
|
||||||
fmt.Printf("[CodeAssist] LoadCodeAssist failed: status %d, body: %s\n", resp.StatusCode, body)
|
sanitizedBody := geminicli.SanitizeBodyForLogs(body)
|
||||||
return nil, fmt.Errorf("loadCodeAssist failed: status %d, body: %s", resp.StatusCode, 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 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 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)
|
||||||
}
|
}
|
||||||
fmt.Printf("[CodeAssist] LoadCodeAssist success: status %d, response: %+v\n", resp.StatusCode, out)
|
fmt.Printf("[CodeAssist] LoadCodeAssist success: status %d, response: %+v\n", resp.StatusCode, out)
|
||||||
return &out, nil
|
return &out, nil
|
||||||
@@ -67,9 +79,20 @@ func (c *geminiCliCodeAssistClient) OnboardUser(ctx context.Context, accessToken
|
|||||||
return nil, fmt.Errorf("request failed: %w", err)
|
return nil, fmt.Errorf("request failed: %w", err)
|
||||||
}
|
}
|
||||||
if !resp.IsSuccessState() {
|
if !resp.IsSuccessState() {
|
||||||
body := geminicli.SanitizeBodyForLogs(resp.String())
|
body := resp.String()
|
||||||
fmt.Printf("[CodeAssist] OnboardUser failed: status %d, body: %s\n", resp.StatusCode, body)
|
sanitizedBody := geminicli.SanitizeBodyForLogs(body)
|
||||||
return nil, fmt.Errorf("onboardUser failed: status %d, body: %s", resp.StatusCode, 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 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 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)
|
||||||
}
|
}
|
||||||
fmt.Printf("[CodeAssist] OnboardUser success: status %d, response: %+v\n", resp.StatusCode, out)
|
fmt.Printf("[CodeAssist] OnboardUser success: status %d, response: %+v\n", resp.StatusCode, out)
|
||||||
return &out, nil
|
return &out, nil
|
||||||
|
|||||||
@@ -944,6 +944,32 @@ func (s *GeminiOAuthService) fetchProjectID(ctx context.Context, accessToken, pr
|
|||||||
return strings.TrimSpace(loadResp.CloudAICompanionProject), tierID, nil
|
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{
|
req := &geminicli.OnboardUserRequest{
|
||||||
TierID: tierID,
|
TierID: tierID,
|
||||||
Metadata: geminicli.LoadCodeAssistMetadata{
|
Metadata: geminicli.LoadCodeAssistMetadata{
|
||||||
|
|||||||
Reference in New Issue
Block a user