From 6648e6506c2b5eadc64c4041f83a893c21bbbf80 Mon Sep 17 00:00:00 2001 From: song Date: Sun, 28 Dec 2025 15:54:42 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E6=B7=BB=E5=8A=A0=20Antigravity=20(Clo?= =?UTF-8?q?ud=20AI=20Companion)=20OAuth=20=E6=8E=88=E6=9D=83=E6=94=AF?= =?UTF-8?q?=E6=8C=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/cmd/server/wire.go | 15 +- backend/cmd/server/wire_gen.go | 11 +- .../admin/antigravity_oauth_handler.go | 67 ++++ backend/internal/handler/handler.go | 27 +- backend/internal/handler/wire.go | 29 +- backend/internal/pkg/antigravity/client.go | 216 ++++++++++++ backend/internal/pkg/antigravity/oauth.go | 179 ++++++++++ backend/internal/server/routes/admin.go | 8 + .../service/antigravity_oauth_service.go | 267 +++++++++++++++ backend/internal/service/domain_constants.go | 7 +- backend/internal/service/wire.go | 3 +- frontend/src/api/admin/antigravity.ts | 56 ++++ frontend/src/api/admin/index.ts | 7 +- .../components/account/CreateAccountModal.vue | 310 +++++++++++------- .../account/OAuthAuthorizationFlow.vue | 21 +- .../src/components/common/PlatformIcon.vue | 4 + .../components/common/PlatformTypeBadge.vue | 7 + .../src/composables/useAntigravityOAuth.ts | 115 +++++++ frontend/src/i18n/locales/en.ts | 30 +- frontend/src/i18n/locales/zh.ts | 28 +- frontend/src/types/index.ts | 6 +- frontend/src/views/admin/AccountsView.vue | 3 +- 22 files changed, 1249 insertions(+), 167 deletions(-) create mode 100644 backend/internal/handler/admin/antigravity_oauth_handler.go create mode 100644 backend/internal/pkg/antigravity/client.go create mode 100644 backend/internal/pkg/antigravity/oauth.go create mode 100644 backend/internal/service/antigravity_oauth_service.go create mode 100644 frontend/src/api/admin/antigravity.ts create mode 100644 frontend/src/composables/useAntigravityOAuth.ts diff --git a/backend/cmd/server/wire.go b/backend/cmd/server/wire.go index 596c8516..1aa31ab6 100644 --- a/backend/cmd/server/wire.go +++ b/backend/cmd/server/wire.go @@ -29,26 +29,26 @@ type Application struct { func initializeApplication(buildInfo handler.BuildInfo) (*Application, error) { wire.Build( - // 基础设施层 ProviderSets + // Infrastructure layer ProviderSets config.ProviderSet, infrastructure.ProviderSet, - // 业务层 ProviderSets + // Business layer ProviderSets repository.ProviderSet, service.ProviderSet, middleware.ProviderSet, handler.ProviderSet, - // 服务器层 ProviderSet + // Server layer ProviderSet server.ProviderSet, // BuildInfo provider provideServiceBuildInfo, - // 清理函数提供者 + // Cleanup function provider provideCleanup, - // 应用程序结构体 + // Application struct wire.Struct(new(Application), "Server", "Cleanup"), ) return nil, nil @@ -70,6 +70,7 @@ func provideCleanup( oauth *service.OAuthService, openaiOAuth *service.OpenAIOAuthService, geminiOAuth *service.GeminiOAuthService, + antigravityOAuth *service.AntigravityOAuthService, ) func() { return func() { ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) @@ -104,6 +105,10 @@ func provideCleanup( geminiOAuth.Stop() return nil }}, + {"AntigravityOAuthService", func() error { + antigravityOAuth.Stop() + return nil + }}, {"Redis", func() error { return rdb.Close() }}, diff --git a/backend/cmd/server/wire_gen.go b/backend/cmd/server/wire_gen.go index 9904aa0d..b27d0535 100644 --- a/backend/cmd/server/wire_gen.go +++ b/backend/cmd/server/wire_gen.go @@ -97,6 +97,8 @@ func initializeApplication(buildInfo handler.BuildInfo) (*Application, error) { oAuthHandler := admin.NewOAuthHandler(oAuthService) openAIOAuthHandler := admin.NewOpenAIOAuthHandler(openAIOAuthService, adminService) geminiOAuthHandler := admin.NewGeminiOAuthHandler(geminiOAuthService) + antigravityOAuthService := service.NewAntigravityOAuthService(proxyRepository) + antigravityOAuthHandler := admin.NewAntigravityOAuthHandler(antigravityOAuthService) proxyHandler := admin.NewProxyHandler(adminService) adminRedeemHandler := admin.NewRedeemHandler(adminService) settingHandler := admin.NewSettingHandler(settingService, emailService) @@ -107,7 +109,7 @@ func initializeApplication(buildInfo handler.BuildInfo) (*Application, error) { systemHandler := handler.ProvideSystemHandler(updateService) adminSubscriptionHandler := admin.NewSubscriptionHandler(subscriptionService) adminUsageHandler := admin.NewUsageHandler(usageService, apiKeyService, adminService) - adminHandlers := handler.ProvideAdminHandlers(dashboardHandler, adminUserHandler, groupHandler, accountHandler, oAuthHandler, openAIOAuthHandler, geminiOAuthHandler, proxyHandler, adminRedeemHandler, settingHandler, systemHandler, adminSubscriptionHandler, adminUsageHandler) + adminHandlers := handler.ProvideAdminHandlers(dashboardHandler, adminUserHandler, groupHandler, accountHandler, oAuthHandler, openAIOAuthHandler, geminiOAuthHandler, antigravityOAuthHandler, proxyHandler, adminRedeemHandler, settingHandler, systemHandler, adminSubscriptionHandler, adminUsageHandler) gatewayCache := repository.NewGatewayCache(client) pricingRemoteClient := repository.NewPricingRemoteClient() pricingService, err := service.ProvidePricingService(configConfig, pricingRemoteClient) @@ -132,7 +134,7 @@ func initializeApplication(buildInfo handler.BuildInfo) (*Application, error) { engine := server.ProvideRouter(configConfig, handlers, jwtAuthMiddleware, adminAuthMiddleware, apiKeyAuthMiddleware, apiKeyService, subscriptionService) httpServer := server.ProvideHTTPServer(configConfig, engine) tokenRefreshService := service.ProvideTokenRefreshService(accountRepository, oAuthService, openAIOAuthService, geminiOAuthService, configConfig) - v := provideCleanup(db, client, tokenRefreshService, pricingService, emailQueueService, oAuthService, openAIOAuthService, geminiOAuthService) + v := provideCleanup(db, client, tokenRefreshService, pricingService, emailQueueService, oAuthService, openAIOAuthService, geminiOAuthService, antigravityOAuthService) application := &Application{ Server: httpServer, Cleanup: v, @@ -163,6 +165,7 @@ func provideCleanup( oauth *service.OAuthService, openaiOAuth *service.OpenAIOAuthService, geminiOAuth *service.GeminiOAuthService, + antigravityOAuth *service.AntigravityOAuthService, ) func() { return func() { ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) @@ -196,6 +199,10 @@ func provideCleanup( geminiOAuth.Stop() return nil }}, + {"AntigravityOAuthService", func() error { + antigravityOAuth.Stop() + return nil + }}, {"Redis", func() error { return rdb.Close() }}, diff --git a/backend/internal/handler/admin/antigravity_oauth_handler.go b/backend/internal/handler/admin/antigravity_oauth_handler.go new file mode 100644 index 00000000..18541684 --- /dev/null +++ b/backend/internal/handler/admin/antigravity_oauth_handler.go @@ -0,0 +1,67 @@ +package admin + +import ( + "github.com/Wei-Shaw/sub2api/internal/pkg/response" + "github.com/Wei-Shaw/sub2api/internal/service" + "github.com/gin-gonic/gin" +) + +type AntigravityOAuthHandler struct { + antigravityOAuthService *service.AntigravityOAuthService +} + +func NewAntigravityOAuthHandler(antigravityOAuthService *service.AntigravityOAuthService) *AntigravityOAuthHandler { + return &AntigravityOAuthHandler{antigravityOAuthService: antigravityOAuthService} +} + +type AntigravityGenerateAuthURLRequest struct { + ProxyID *int64 `json:"proxy_id"` +} + +// GenerateAuthURL generates Google OAuth authorization URL +// POST /api/v1/admin/antigravity/oauth/auth-url +func (h *AntigravityOAuthHandler) GenerateAuthURL(c *gin.Context) { + var req AntigravityGenerateAuthURLRequest + if err := c.ShouldBindJSON(&req); err != nil { + response.BadRequest(c, "请求无效: "+err.Error()) + return + } + + result, err := h.antigravityOAuthService.GenerateAuthURL(c.Request.Context(), req.ProxyID) + if err != nil { + response.InternalError(c, "生成授权链接失败: "+err.Error()) + return + } + + response.Success(c, result) +} + +type AntigravityExchangeCodeRequest struct { + SessionID string `json:"session_id" binding:"required"` + State string `json:"state" binding:"required"` + Code string `json:"code" binding:"required"` + ProxyID *int64 `json:"proxy_id"` +} + +// ExchangeCode 用 authorization code 交换 token +// POST /api/v1/admin/antigravity/oauth/exchange-code +func (h *AntigravityOAuthHandler) ExchangeCode(c *gin.Context) { + var req AntigravityExchangeCodeRequest + if err := c.ShouldBindJSON(&req); err != nil { + response.BadRequest(c, "请求无效: "+err.Error()) + return + } + + tokenInfo, err := h.antigravityOAuthService.ExchangeCode(c.Request.Context(), &service.AntigravityExchangeCodeInput{ + SessionID: req.SessionID, + State: req.State, + Code: req.Code, + ProxyID: req.ProxyID, + }) + if err != nil { + response.BadRequest(c, "Token 交换失败: "+err.Error()) + return + } + + response.Success(c, tokenInfo) +} diff --git a/backend/internal/handler/handler.go b/backend/internal/handler/handler.go index af28bc1f..85105a30 100644 --- a/backend/internal/handler/handler.go +++ b/backend/internal/handler/handler.go @@ -6,19 +6,20 @@ import ( // AdminHandlers contains all admin-related HTTP handlers type AdminHandlers struct { - Dashboard *admin.DashboardHandler - User *admin.UserHandler - Group *admin.GroupHandler - Account *admin.AccountHandler - OAuth *admin.OAuthHandler - OpenAIOAuth *admin.OpenAIOAuthHandler - GeminiOAuth *admin.GeminiOAuthHandler - Proxy *admin.ProxyHandler - Redeem *admin.RedeemHandler - Setting *admin.SettingHandler - System *admin.SystemHandler - Subscription *admin.SubscriptionHandler - Usage *admin.UsageHandler + Dashboard *admin.DashboardHandler + User *admin.UserHandler + Group *admin.GroupHandler + Account *admin.AccountHandler + OAuth *admin.OAuthHandler + OpenAIOAuth *admin.OpenAIOAuthHandler + GeminiOAuth *admin.GeminiOAuthHandler + AntigravityOAuth *admin.AntigravityOAuthHandler + Proxy *admin.ProxyHandler + Redeem *admin.RedeemHandler + Setting *admin.SettingHandler + System *admin.SystemHandler + Subscription *admin.SubscriptionHandler + Usage *admin.UsageHandler } // Handlers contains all HTTP handlers diff --git a/backend/internal/handler/wire.go b/backend/internal/handler/wire.go index f6e2c031..fc9f1642 100644 --- a/backend/internal/handler/wire.go +++ b/backend/internal/handler/wire.go @@ -16,6 +16,7 @@ func ProvideAdminHandlers( oauthHandler *admin.OAuthHandler, openaiOAuthHandler *admin.OpenAIOAuthHandler, geminiOAuthHandler *admin.GeminiOAuthHandler, + antigravityOAuthHandler *admin.AntigravityOAuthHandler, proxyHandler *admin.ProxyHandler, redeemHandler *admin.RedeemHandler, settingHandler *admin.SettingHandler, @@ -24,19 +25,20 @@ func ProvideAdminHandlers( usageHandler *admin.UsageHandler, ) *AdminHandlers { return &AdminHandlers{ - Dashboard: dashboardHandler, - User: userHandler, - Group: groupHandler, - Account: accountHandler, - OAuth: oauthHandler, - OpenAIOAuth: openaiOAuthHandler, - GeminiOAuth: geminiOAuthHandler, - Proxy: proxyHandler, - Redeem: redeemHandler, - Setting: settingHandler, - System: systemHandler, - Subscription: subscriptionHandler, - Usage: usageHandler, + Dashboard: dashboardHandler, + User: userHandler, + Group: groupHandler, + Account: accountHandler, + OAuth: oauthHandler, + OpenAIOAuth: openaiOAuthHandler, + GeminiOAuth: geminiOAuthHandler, + AntigravityOAuth: antigravityOAuthHandler, + Proxy: proxyHandler, + Redeem: redeemHandler, + Setting: settingHandler, + System: systemHandler, + Subscription: subscriptionHandler, + Usage: usageHandler, } } @@ -98,6 +100,7 @@ var ProviderSet = wire.NewSet( admin.NewOAuthHandler, admin.NewOpenAIOAuthHandler, admin.NewGeminiOAuthHandler, + admin.NewAntigravityOAuthHandler, admin.NewProxyHandler, admin.NewRedeemHandler, admin.NewSettingHandler, diff --git a/backend/internal/pkg/antigravity/client.go b/backend/internal/pkg/antigravity/client.go new file mode 100644 index 00000000..7a419dba --- /dev/null +++ b/backend/internal/pkg/antigravity/client.go @@ -0,0 +1,216 @@ +package antigravity + +import ( + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "net/url" + "strings" + "time" +) + +// TokenResponse Google OAuth token 响应 +type TokenResponse struct { + AccessToken string `json:"access_token"` + ExpiresIn int64 `json:"expires_in"` + TokenType string `json:"token_type"` + Scope string `json:"scope,omitempty"` + RefreshToken string `json:"refresh_token,omitempty"` +} + +// UserInfo Google 用户信息 +type UserInfo struct { + Email string `json:"email"` + Name string `json:"name,omitempty"` + GivenName string `json:"given_name,omitempty"` + FamilyName string `json:"family_name,omitempty"` + Picture string `json:"picture,omitempty"` +} + +// LoadCodeAssistRequest loadCodeAssist 请求 +type LoadCodeAssistRequest struct { + Metadata struct { + IDEType string `json:"ideType"` + } `json:"metadata"` +} + +// LoadCodeAssistResponse loadCodeAssist 响应 +type LoadCodeAssistResponse struct { + CloudAICompanionProject string `json:"cloudaicompanionProject"` +} + +// Client Antigravity API 客户端 +type Client struct { + httpClient *http.Client + proxyURL string +} + +func NewClient(proxyURL string) *Client { + client := &http.Client{ + Timeout: 30 * time.Second, + } + + if strings.TrimSpace(proxyURL) != "" { + if proxyURLParsed, err := url.Parse(proxyURL); err == nil { + client.Transport = &http.Transport{ + Proxy: http.ProxyURL(proxyURLParsed), + } + } + } + + return &Client{ + httpClient: client, + proxyURL: proxyURL, + } +} + +// ExchangeCode 用 authorization code 交换 token +func (c *Client) ExchangeCode(ctx context.Context, code, codeVerifier string) (*TokenResponse, error) { + params := url.Values{} + params.Set("client_id", ClientID) + params.Set("client_secret", ClientSecret) + params.Set("code", code) + params.Set("redirect_uri", RedirectURI) + params.Set("grant_type", "authorization_code") + params.Set("code_verifier", codeVerifier) + + req, err := http.NewRequestWithContext(ctx, http.MethodPost, TokenURL, strings.NewReader(params.Encode())) + if err != nil { + return nil, fmt.Errorf("创建请求失败: %w", err) + } + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + + resp, err := c.httpClient.Do(req) + if err != nil { + return nil, fmt.Errorf("Token 交换请求失败: %w", err) + } + defer func() { _ = resp.Body.Close() }() + + bodyBytes, err := io.ReadAll(resp.Body) + if err != nil { + return nil, fmt.Errorf("读取响应失败: %w", err) + } + + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("Token 交换失败 (HTTP %d): %s", resp.StatusCode, string(bodyBytes)) + } + + var tokenResp TokenResponse + if err := json.Unmarshal(bodyBytes, &tokenResp); err != nil { + return nil, fmt.Errorf("Token 解析失败: %w", err) + } + + return &tokenResp, nil +} + +// RefreshToken 刷新 access_token +func (c *Client) RefreshToken(ctx context.Context, refreshToken string) (*TokenResponse, error) { + params := url.Values{} + params.Set("client_id", ClientID) + params.Set("client_secret", ClientSecret) + params.Set("refresh_token", refreshToken) + params.Set("grant_type", "refresh_token") + + req, err := http.NewRequestWithContext(ctx, http.MethodPost, TokenURL, strings.NewReader(params.Encode())) + if err != nil { + return nil, fmt.Errorf("创建请求失败: %w", err) + } + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + + resp, err := c.httpClient.Do(req) + if err != nil { + return nil, fmt.Errorf("Token 刷新请求失败: %w", err) + } + defer func() { _ = resp.Body.Close() }() + + bodyBytes, err := io.ReadAll(resp.Body) + if err != nil { + return nil, fmt.Errorf("读取响应失败: %w", err) + } + + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("Token 刷新失败 (HTTP %d): %s", resp.StatusCode, string(bodyBytes)) + } + + var tokenResp TokenResponse + if err := json.Unmarshal(bodyBytes, &tokenResp); err != nil { + return nil, fmt.Errorf("Token 解析失败: %w", err) + } + + return &tokenResp, nil +} + +// GetUserInfo 获取用户信息 +func (c *Client) GetUserInfo(ctx context.Context, accessToken string) (*UserInfo, error) { + req, err := http.NewRequestWithContext(ctx, http.MethodGet, UserInfoURL, nil) + if err != nil { + return nil, fmt.Errorf("创建请求失败: %w", err) + } + req.Header.Set("Authorization", "Bearer "+accessToken) + + resp, err := c.httpClient.Do(req) + if err != nil { + return nil, fmt.Errorf("用户信息请求失败: %w", err) + } + defer func() { _ = resp.Body.Close() }() + + bodyBytes, err := io.ReadAll(resp.Body) + if err != nil { + return nil, fmt.Errorf("读取响应失败: %w", err) + } + + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("获取用户信息失败 (HTTP %d): %s", resp.StatusCode, string(bodyBytes)) + } + + var userInfo UserInfo + if err := json.Unmarshal(bodyBytes, &userInfo); err != nil { + return nil, fmt.Errorf("用户信息解析失败: %w", err) + } + + return &userInfo, nil +} + +// LoadCodeAssist 获取 project_id +func (c *Client) LoadCodeAssist(ctx context.Context, accessToken string) (*LoadCodeAssistResponse, error) { + reqBody := LoadCodeAssistRequest{} + reqBody.Metadata.IDEType = "ANTIGRAVITY" + + bodyBytes, err := json.Marshal(reqBody) + if err != nil { + return nil, fmt.Errorf("序列化请求失败: %w", err) + } + + url := BaseURL + "/v1internal:loadCodeAssist" + req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, strings.NewReader(string(bodyBytes))) + if err != nil { + return nil, fmt.Errorf("创建请求失败: %w", err) + } + req.Header.Set("Authorization", "Bearer "+accessToken) + req.Header.Set("Content-Type", "application/json") + req.Header.Set("User-Agent", UserAgent) + + resp, err := c.httpClient.Do(req) + if err != nil { + return nil, fmt.Errorf("loadCodeAssist 请求失败: %w", err) + } + defer func() { _ = resp.Body.Close() }() + + respBodyBytes, err := io.ReadAll(resp.Body) + if err != nil { + return nil, fmt.Errorf("读取响应失败: %w", err) + } + + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("loadCodeAssist 失败 (HTTP %d): %s", resp.StatusCode, string(respBodyBytes)) + } + + var loadResp LoadCodeAssistResponse + if err := json.Unmarshal(respBodyBytes, &loadResp); err != nil { + return nil, fmt.Errorf("响应解析失败: %w", err) + } + + return &loadResp, nil +} diff --git a/backend/internal/pkg/antigravity/oauth.go b/backend/internal/pkg/antigravity/oauth.go new file mode 100644 index 00000000..54ac8bb1 --- /dev/null +++ b/backend/internal/pkg/antigravity/oauth.go @@ -0,0 +1,179 @@ +package antigravity + +import ( + "crypto/rand" + "crypto/sha256" + "encoding/base64" + "encoding/hex" + "fmt" + "net/url" + "strings" + "sync" + "time" +) + +const ( + // Google OAuth 端点 + AuthorizeURL = "https://accounts.google.com/o/oauth2/v2/auth" + TokenURL = "https://oauth2.googleapis.com/token" + UserInfoURL = "https://www.googleapis.com/oauth2/v2/userinfo" + + // Antigravity OAuth 客户端凭证 + ClientID = "1071006060591-tmhssin2h21lcre235vtolojh4g403ep.apps.googleusercontent.com" + ClientSecret = "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" + + // 固定的 redirect_uri(用户需手动复制 code) + RedirectURI = "http://localhost:8085/callback" + + // OAuth scopes + Scopes = "https://www.googleapis.com/auth/cloud-platform " + + "https://www.googleapis.com/auth/userinfo.email " + + "https://www.googleapis.com/auth/userinfo.profile " + + "https://www.googleapis.com/auth/cclog " + + "https://www.googleapis.com/auth/experimentsandconfigs" + + // API 端点 + BaseURL = "https://cloudcode-pa.googleapis.com" + + // User-Agent + UserAgent = "antigravity/1.11.9 windows/amd64" + + // Session 过期时间 + SessionTTL = 30 * time.Minute +) + +// OAuthSession 保存 OAuth 授权流程的临时状态 +type OAuthSession struct { + State string `json:"state"` + CodeVerifier string `json:"code_verifier"` + ProxyURL string `json:"proxy_url,omitempty"` + CreatedAt time.Time `json:"created_at"` +} + +// SessionStore OAuth session 存储 +type SessionStore struct { + mu sync.RWMutex + sessions map[string]*OAuthSession + stopCh chan struct{} +} + +func NewSessionStore() *SessionStore { + store := &SessionStore{ + sessions: make(map[string]*OAuthSession), + stopCh: make(chan struct{}), + } + go store.cleanup() + return store +} + +func (s *SessionStore) Set(sessionID string, session *OAuthSession) { + s.mu.Lock() + defer s.mu.Unlock() + s.sessions[sessionID] = session +} + +func (s *SessionStore) Get(sessionID string) (*OAuthSession, bool) { + s.mu.RLock() + defer s.mu.RUnlock() + session, ok := s.sessions[sessionID] + if !ok { + return nil, false + } + if time.Since(session.CreatedAt) > SessionTTL { + return nil, false + } + return session, true +} + +func (s *SessionStore) Delete(sessionID string) { + s.mu.Lock() + defer s.mu.Unlock() + delete(s.sessions, sessionID) +} + +func (s *SessionStore) Stop() { + select { + case <-s.stopCh: + return + default: + close(s.stopCh) + } +} + +func (s *SessionStore) cleanup() { + ticker := time.NewTicker(5 * time.Minute) + defer ticker.Stop() + for { + select { + case <-s.stopCh: + return + case <-ticker.C: + s.mu.Lock() + for id, session := range s.sessions { + if time.Since(session.CreatedAt) > SessionTTL { + delete(s.sessions, id) + } + } + s.mu.Unlock() + } + } +} + +func GenerateRandomBytes(n int) ([]byte, error) { + b := make([]byte, n) + _, err := rand.Read(b) + if err != nil { + return nil, err + } + return b, nil +} + +func GenerateState() (string, error) { + bytes, err := GenerateRandomBytes(32) + if err != nil { + return "", err + } + return base64URLEncode(bytes), nil +} + +func GenerateSessionID() (string, error) { + bytes, err := GenerateRandomBytes(16) + if err != nil { + return "", err + } + return hex.EncodeToString(bytes), nil +} + +func GenerateCodeVerifier() (string, error) { + bytes, err := GenerateRandomBytes(32) + if err != nil { + return "", err + } + return base64URLEncode(bytes), nil +} + +func GenerateCodeChallenge(verifier string) string { + hash := sha256.Sum256([]byte(verifier)) + return base64URLEncode(hash[:]) +} + +func base64URLEncode(data []byte) string { + return strings.TrimRight(base64.URLEncoding.EncodeToString(data), "=") +} + +// BuildAuthorizationURL 构建 Google OAuth 授权 URL +func BuildAuthorizationURL(state, codeChallenge string) string { + params := url.Values{} + params.Set("client_id", ClientID) + params.Set("redirect_uri", RedirectURI) + params.Set("response_type", "code") + params.Set("scope", Scopes) + params.Set("state", state) + params.Set("code_challenge", codeChallenge) + params.Set("code_challenge_method", "S256") + params.Set("access_type", "offline") + params.Set("prompt", "consent") + params.Set("include_granted_scopes", "true") + + return fmt.Sprintf("%s?%s", AuthorizeURL, params.Encode()) +} diff --git a/backend/internal/server/routes/admin.go b/backend/internal/server/routes/admin.go index 591335dd..cf157f8e 100644 --- a/backend/internal/server/routes/admin.go +++ b/backend/internal/server/routes/admin.go @@ -148,6 +148,14 @@ func registerGeminiOAuthRoutes(admin *gin.RouterGroup, h *handler.Handlers) { } } +func registerAntigravityOAuthRoutes(admin *gin.RouterGroup, h *handler.Handlers) { + antigravity := admin.Group("/antigravity") + { + antigravity.POST("/oauth/auth-url", h.Admin.AntigravityOAuth.GenerateAuthURL) + antigravity.POST("/oauth/exchange-code", h.Admin.AntigravityOAuth.ExchangeCode) + } +} + func registerProxyRoutes(admin *gin.RouterGroup, h *handler.Handlers) { proxies := admin.Group("/proxies") { diff --git a/backend/internal/service/antigravity_oauth_service.go b/backend/internal/service/antigravity_oauth_service.go new file mode 100644 index 00000000..57565631 --- /dev/null +++ b/backend/internal/service/antigravity_oauth_service.go @@ -0,0 +1,267 @@ +package service + +import ( + "context" + "fmt" + "strconv" + "strings" + "time" + + "github.com/Wei-Shaw/sub2api/internal/pkg/antigravity" +) + +type AntigravityOAuthService struct { + sessionStore *antigravity.SessionStore + proxyRepo ProxyRepository +} + +func NewAntigravityOAuthService(proxyRepo ProxyRepository) *AntigravityOAuthService { + return &AntigravityOAuthService{ + sessionStore: antigravity.NewSessionStore(), + proxyRepo: proxyRepo, + } +} + +// AntigravityAuthURLResult is the result of generating an authorization URL +type AntigravityAuthURLResult struct { + AuthURL string `json:"auth_url"` + SessionID string `json:"session_id"` + State string `json:"state"` +} + +// GenerateAuthURL 生成 Google OAuth 授权链接 +func (s *AntigravityOAuthService) GenerateAuthURL(ctx context.Context, proxyID *int64) (*AntigravityAuthURLResult, error) { + state, err := antigravity.GenerateState() + if err != nil { + return nil, fmt.Errorf("生成 state 失败: %w", err) + } + + codeVerifier, err := antigravity.GenerateCodeVerifier() + if err != nil { + return nil, fmt.Errorf("生成 code_verifier 失败: %w", err) + } + + sessionID, err := antigravity.GenerateSessionID() + if err != nil { + return nil, fmt.Errorf("生成 session_id 失败: %w", err) + } + + var proxyURL string + if proxyID != nil { + proxy, err := s.proxyRepo.GetByID(ctx, *proxyID) + if err == nil && proxy != nil { + proxyURL = proxy.URL() + } + } + + session := &antigravity.OAuthSession{ + State: state, + CodeVerifier: codeVerifier, + ProxyURL: proxyURL, + CreatedAt: time.Now(), + } + s.sessionStore.Set(sessionID, session) + + codeChallenge := antigravity.GenerateCodeChallenge(codeVerifier) + authURL := antigravity.BuildAuthorizationURL(state, codeChallenge) + + return &AntigravityAuthURLResult{ + AuthURL: authURL, + SessionID: sessionID, + State: state, + }, nil +} + +// AntigravityExchangeCodeInput 交换 code 的输入 +type AntigravityExchangeCodeInput struct { + SessionID string + State string + Code string + ProxyID *int64 +} + +// AntigravityTokenInfo token 信息 +type AntigravityTokenInfo struct { + AccessToken string `json:"access_token"` + RefreshToken string `json:"refresh_token"` + ExpiresIn int64 `json:"expires_in"` + ExpiresAt int64 `json:"expires_at"` + TokenType string `json:"token_type"` + Email string `json:"email,omitempty"` + ProjectID string `json:"project_id,omitempty"` +} + +// ExchangeCode 用 authorization code 交换 token +func (s *AntigravityOAuthService) ExchangeCode(ctx context.Context, input *AntigravityExchangeCodeInput) (*AntigravityTokenInfo, error) { + session, ok := s.sessionStore.Get(input.SessionID) + if !ok { + return nil, fmt.Errorf("session 不存在或已过期") + } + + if strings.TrimSpace(input.State) == "" || input.State != session.State { + return nil, fmt.Errorf("state 无效") + } + + // 确定代理 URL + proxyURL := session.ProxyURL + if input.ProxyID != nil { + proxy, err := s.proxyRepo.GetByID(ctx, *input.ProxyID) + if err == nil && proxy != nil { + proxyURL = proxy.URL() + } + } + + client := antigravity.NewClient(proxyURL) + + // 交换 token + tokenResp, err := client.ExchangeCode(ctx, input.Code, session.CodeVerifier) + if err != nil { + return nil, fmt.Errorf("Token 交换失败: %w", err) + } + + // 删除 session + s.sessionStore.Delete(input.SessionID) + + // 计算过期时间(减去 5 分钟安全窗口) + expiresAt := time.Now().Unix() + tokenResp.ExpiresIn - 300 + + result := &AntigravityTokenInfo{ + AccessToken: tokenResp.AccessToken, + RefreshToken: tokenResp.RefreshToken, + ExpiresIn: tokenResp.ExpiresIn, + ExpiresAt: expiresAt, + TokenType: tokenResp.TokenType, + } + + // 获取用户信息 + userInfo, err := client.GetUserInfo(ctx, tokenResp.AccessToken) + if err != nil { + fmt.Printf("[AntigravityOAuth] 警告: 获取用户信息失败: %v\n", err) + } else { + result.Email = userInfo.Email + } + + // 获取 project_id + loadResp, err := client.LoadCodeAssist(ctx, tokenResp.AccessToken) + if err != nil { + fmt.Printf("[AntigravityOAuth] 警告: 获取 project_id 失败: %v\n", err) + } else if loadResp != nil && loadResp.CloudAICompanionProject != "" { + result.ProjectID = loadResp.CloudAICompanionProject + } + + return result, nil +} + +// RefreshToken 刷新 token +func (s *AntigravityOAuthService) RefreshToken(ctx context.Context, refreshToken, proxyURL string) (*AntigravityTokenInfo, error) { + var lastErr error + + for attempt := 0; attempt <= 3; attempt++ { + if attempt > 0 { + backoff := time.Duration(1< 30*time.Second { + backoff = 30 * time.Second + } + time.Sleep(backoff) + } + + client := antigravity.NewClient(proxyURL) + tokenResp, err := client.RefreshToken(ctx, refreshToken) + if err == nil { + expiresAt := time.Now().Unix() + tokenResp.ExpiresIn - 300 + return &AntigravityTokenInfo{ + AccessToken: tokenResp.AccessToken, + RefreshToken: tokenResp.RefreshToken, + ExpiresIn: tokenResp.ExpiresIn, + ExpiresAt: expiresAt, + TokenType: tokenResp.TokenType, + }, nil + } + + if isNonRetryableAntigravityOAuthError(err) { + return nil, err + } + lastErr = err + } + + return nil, fmt.Errorf("Token 刷新失败 (重试后): %w", lastErr) +} + +func isNonRetryableAntigravityOAuthError(err error) bool { + msg := err.Error() + nonRetryable := []string{ + "invalid_grant", + "invalid_client", + "unauthorized_client", + "access_denied", + } + for _, needle := range nonRetryable { + if strings.Contains(msg, needle) { + return true + } + } + return false +} + +// RefreshAccountToken 刷新账户的 token +func (s *AntigravityOAuthService) RefreshAccountToken(ctx context.Context, account *Account) (*AntigravityTokenInfo, error) { + if account.Platform != PlatformAntigravity || account.Type != AccountTypeOAuth { + return nil, fmt.Errorf("非 Antigravity OAuth 账户") + } + + refreshToken := account.GetCredential("refresh_token") + if strings.TrimSpace(refreshToken) == "" { + return nil, fmt.Errorf("无可用的 refresh_token") + } + + var proxyURL string + if account.ProxyID != nil { + proxy, err := s.proxyRepo.GetByID(ctx, *account.ProxyID) + if err == nil && proxy != nil { + proxyURL = proxy.URL() + } + } + + tokenInfo, err := s.RefreshToken(ctx, refreshToken, proxyURL) + if err != nil { + return nil, err + } + + // 保留原有的 project_id 和 email + existingProjectID := strings.TrimSpace(account.GetCredential("project_id")) + if existingProjectID != "" { + tokenInfo.ProjectID = existingProjectID + } + existingEmail := strings.TrimSpace(account.GetCredential("email")) + if existingEmail != "" { + tokenInfo.Email = existingEmail + } + + return tokenInfo, nil +} + +// BuildAccountCredentials 构建账户凭证 +func (s *AntigravityOAuthService) BuildAccountCredentials(tokenInfo *AntigravityTokenInfo) map[string]any { + creds := map[string]any{ + "access_token": tokenInfo.AccessToken, + "expires_at": strconv.FormatInt(tokenInfo.ExpiresAt, 10), + } + if tokenInfo.RefreshToken != "" { + creds["refresh_token"] = tokenInfo.RefreshToken + } + if tokenInfo.TokenType != "" { + creds["token_type"] = tokenInfo.TokenType + } + if tokenInfo.Email != "" { + creds["email"] = tokenInfo.Email + } + if tokenInfo.ProjectID != "" { + creds["project_id"] = tokenInfo.ProjectID + } + return creds +} + +// Stop 停止服务 +func (s *AntigravityOAuthService) Stop() { + s.sessionStore.Stop() +} diff --git a/backend/internal/service/domain_constants.go b/backend/internal/service/domain_constants.go index b0f3fc9e..2e879263 100644 --- a/backend/internal/service/domain_constants.go +++ b/backend/internal/service/domain_constants.go @@ -18,9 +18,10 @@ const ( // Platform constants const ( - PlatformAnthropic = "anthropic" - PlatformOpenAI = "openai" - PlatformGemini = "gemini" + PlatformAnthropic = "anthropic" + PlatformOpenAI = "openai" + PlatformGemini = "gemini" + PlatformAntigravity = "antigravity" ) // Account type constants diff --git a/backend/internal/service/wire.go b/backend/internal/service/wire.go index 007cdfff..e1012acb 100644 --- a/backend/internal/service/wire.go +++ b/backend/internal/service/wire.go @@ -17,7 +17,7 @@ type BuildInfo struct { func ProvidePricingService(cfg *config.Config, remoteClient PricingRemoteClient) (*PricingService, error) { svc := NewPricingService(cfg, remoteClient) if err := svc.Initialize(); err != nil { - // 价格服务初始化失败不应阻止启动,使用回退价格 + // Pricing service initialization failure should not block startup, use fallback prices println("[Service] Warning: Pricing service initialization failed:", err.Error()) } return svc, nil @@ -81,6 +81,7 @@ var ProviderSet = wire.NewSet( NewOAuthService, NewOpenAIOAuthService, NewGeminiOAuthService, + NewAntigravityOAuthService, NewGeminiTokenProvider, NewGeminiMessagesCompatService, NewRateLimitService, diff --git a/frontend/src/api/admin/antigravity.ts b/frontend/src/api/admin/antigravity.ts new file mode 100644 index 00000000..0392da6f --- /dev/null +++ b/frontend/src/api/admin/antigravity.ts @@ -0,0 +1,56 @@ +/** + * Admin Antigravity API endpoints + * Handles Antigravity (Google Cloud AI Companion) OAuth flows for administrators + */ + +import { apiClient } from '../client' + +export interface AntigravityAuthUrlResponse { + auth_url: string + session_id: string + state: string +} + +export interface AntigravityAuthUrlRequest { + proxy_id?: number +} + +export interface AntigravityExchangeCodeRequest { + session_id: string + state: string + code: string + proxy_id?: number +} + +export interface AntigravityTokenInfo { + access_token?: string + refresh_token?: string + token_type?: string + expires_at?: number | string + expires_in?: number + project_id?: string + email?: string + [key: string]: unknown +} + +export async function generateAuthUrl( + payload: AntigravityAuthUrlRequest +): Promise { + const { data } = await apiClient.post( + '/admin/antigravity/oauth/auth-url', + payload + ) + return data +} + +export async function exchangeCode( + payload: AntigravityExchangeCodeRequest +): Promise { + const { data } = await apiClient.post( + '/admin/antigravity/oauth/exchange-code', + payload + ) + return data +} + +export default { generateAuthUrl, exchangeCode } diff --git a/frontend/src/api/admin/index.ts b/frontend/src/api/admin/index.ts index 55477c87..7c98b74e 100644 --- a/frontend/src/api/admin/index.ts +++ b/frontend/src/api/admin/index.ts @@ -14,6 +14,7 @@ import systemAPI from './system' import subscriptionsAPI from './subscriptions' import usageAPI from './usage' import geminiAPI from './gemini' +import antigravityAPI from './antigravity' /** * Unified admin API object for convenient access @@ -29,7 +30,8 @@ export const adminAPI = { system: systemAPI, subscriptions: subscriptionsAPI, usage: usageAPI, - gemini: geminiAPI + gemini: geminiAPI, + antigravity: antigravityAPI } export { @@ -43,7 +45,8 @@ export { systemAPI, subscriptionsAPI, usageAPI, - geminiAPI + geminiAPI, + antigravityAPI } export default adminAPI diff --git a/frontend/src/components/account/CreateAccountModal.vue b/frontend/src/components/account/CreateAccountModal.vue index c0f061d9..ce182b80 100644 --- a/frontend/src/components/account/CreateAccountModal.vue +++ b/frontend/src/components/account/CreateAccountModal.vue @@ -125,6 +125,31 @@ Gemini + @@ -477,6 +502,36 @@ + +
+ +
+
+
+ + + +
+
+ OAuth + {{ t('admin.accounts.types.antigravityOauth') }} +
+
+
+
+
@@ -1072,6 +1127,7 @@ import { } from '@/composables/useAccountOAuth' import { useOpenAIOAuth } from '@/composables/useOpenAIOAuth' import { useGeminiOAuth } from '@/composables/useGeminiOAuth' +import { useAntigravityOAuth } from '@/composables/useAntigravityOAuth' import type { Proxy, Group, AccountPlatform, AccountType } from '@/types' import Modal from '@/components/common/Modal.vue' import ProxySelector from '@/components/common/ProxySelector.vue' @@ -1094,6 +1150,7 @@ const { t } = useI18n() const oauthStepTitle = computed(() => { if (form.platform === 'openai') return t('admin.accounts.oauth.openai.title') if (form.platform === 'gemini') return t('admin.accounts.oauth.gemini.title') + if (form.platform === 'antigravity') return t('admin.accounts.oauth.antigravity.title') return t('admin.accounts.oauth.title') }) @@ -1115,29 +1172,34 @@ const appStore = useAppStore() const oauth = useAccountOAuth() // For Anthropic OAuth const openaiOAuth = useOpenAIOAuth() // For OpenAI OAuth const geminiOAuth = useGeminiOAuth() // For Gemini OAuth +const antigravityOAuth = useAntigravityOAuth() // For Antigravity OAuth // Computed: current OAuth state for template binding const currentAuthUrl = computed(() => { if (form.platform === 'openai') return openaiOAuth.authUrl.value if (form.platform === 'gemini') return geminiOAuth.authUrl.value + if (form.platform === 'antigravity') return antigravityOAuth.authUrl.value return oauth.authUrl.value }) const currentSessionId = computed(() => { if (form.platform === 'openai') return openaiOAuth.sessionId.value if (form.platform === 'gemini') return geminiOAuth.sessionId.value + if (form.platform === 'antigravity') return antigravityOAuth.sessionId.value return oauth.sessionId.value }) const currentOAuthLoading = computed(() => { if (form.platform === 'openai') return openaiOAuth.loading.value if (form.platform === 'gemini') return geminiOAuth.loading.value + if (form.platform === 'antigravity') return antigravityOAuth.loading.value return oauth.loading.value }) const currentOAuthError = computed(() => { if (form.platform === 'openai') return openaiOAuth.error.value if (form.platform === 'gemini') return geminiOAuth.error.value + if (form.platform === 'antigravity') return antigravityOAuth.error.value return oauth.error.value }) @@ -1366,6 +1428,9 @@ const canExchangeCode = computed(() => { if (form.platform === 'gemini') { return authCode.trim() && geminiOAuth.sessionId.value && !geminiOAuth.loading.value } + if (form.platform === 'antigravity') { + return authCode.trim() && antigravityOAuth.sessionId.value && !antigravityOAuth.loading.value + } return authCode.trim() && oauth.sessionId.value && !oauth.loading.value }) @@ -1410,10 +1475,15 @@ watch( if (newPlatform !== 'anthropic') { interceptWarmupRequests.value = false } + // Antigravity only supports OAuth + if (newPlatform === 'antigravity') { + accountCategory.value = 'oauth-based' + } // Reset OAuth states oauth.resetState() openaiOAuth.resetState() geminiOAuth.resetState() + antigravityOAuth.resetState() } ) @@ -1542,6 +1612,7 @@ const resetForm = () => { oauth.resetState() openaiOAuth.resetState() geminiOAuth.resetState() + antigravityOAuth.resetState() oauthFlowRef.value?.reset() } @@ -1620,6 +1691,7 @@ const goBackToBasicInfo = () => { oauth.resetState() openaiOAuth.resetState() geminiOAuth.resetState() + antigravityOAuth.resetState() oauthFlowRef.value?.reset() } @@ -1628,114 +1700,133 @@ const handleGenerateUrl = async () => { await openaiOAuth.generateAuthUrl(form.proxy_id) } else if (form.platform === 'gemini') { await geminiOAuth.generateAuthUrl(form.proxy_id, oauthFlowRef.value?.projectId, geminiOAuthType.value) + } else if (form.platform === 'antigravity') { + await antigravityOAuth.generateAuthUrl(form.proxy_id) } else { await oauth.generateAuthUrl(addMethod.value, form.proxy_id) } } -const handleExchangeCode = async () => { - const authCode = oauthFlowRef.value?.authCode || '' +// Create account and handle success/failure +const createAccountAndFinish = async ( + platform: AccountPlatform, + type: AccountType, + credentials: Record, + extra?: Record +) => { + await adminAPI.accounts.create({ + name: form.name, + platform, + type, + credentials, + extra, + proxy_id: form.proxy_id, + concurrency: form.concurrency, + priority: form.priority, + group_ids: form.group_ids + }) + appStore.showSuccess(t('admin.accounts.accountCreated')) + emit('created') + handleClose() +} - // For OpenAI - if (form.platform === 'openai') { - if (!authCode.trim() || !openaiOAuth.sessionId.value) return +// OpenAI OAuth 授权码兑换 +const handleOpenAIExchange = async (authCode: string) => { + if (!authCode.trim() || !openaiOAuth.sessionId.value) return - openaiOAuth.loading.value = true - openaiOAuth.error.value = '' + openaiOAuth.loading.value = true + openaiOAuth.error.value = '' - try { - const tokenInfo = await openaiOAuth.exchangeAuthCode( - authCode.trim(), - openaiOAuth.sessionId.value, - form.proxy_id - ) + try { + const tokenInfo = await openaiOAuth.exchangeAuthCode( + authCode.trim(), + openaiOAuth.sessionId.value, + form.proxy_id + ) + if (!tokenInfo) return - if (!tokenInfo) { - return // Error already handled by composable - } - - const credentials = openaiOAuth.buildCredentials(tokenInfo) - const extra = openaiOAuth.buildExtraInfo(tokenInfo) - - // Note: intercept_warmup_requests is Anthropic-only, not applicable to OpenAI - - await adminAPI.accounts.create({ - name: form.name, - platform: 'openai', - type: 'oauth', - credentials, - extra, - proxy_id: form.proxy_id, - concurrency: form.concurrency, - priority: form.priority, - group_ids: form.group_ids - }) - - appStore.showSuccess(t('admin.accounts.accountCreated')) - emit('created') - handleClose() - } catch (error: any) { - openaiOAuth.error.value = error.response?.data?.detail || t('admin.accounts.oauth.authFailed') - appStore.showError(openaiOAuth.error.value) - } finally { - openaiOAuth.loading.value = false - } - return + const credentials = openaiOAuth.buildCredentials(tokenInfo) + const extra = openaiOAuth.buildExtraInfo(tokenInfo) + await createAccountAndFinish('openai', 'oauth', credentials, extra) + } catch (error: any) { + openaiOAuth.error.value = error.response?.data?.detail || t('admin.accounts.oauth.authFailed') + appStore.showError(openaiOAuth.error.value) + } finally { + openaiOAuth.loading.value = false } +} - // For Gemini - if (form.platform === 'gemini') { - if (!authCode.trim() || !geminiOAuth.sessionId.value) return +// Gemini OAuth 授权码兑换 +const handleGeminiExchange = async (authCode: string) => { + if (!authCode.trim() || !geminiOAuth.sessionId.value) return - geminiOAuth.loading.value = true - geminiOAuth.error.value = '' + geminiOAuth.loading.value = true + geminiOAuth.error.value = '' - try { - const stateFromInput = oauthFlowRef.value?.oauthState || '' - const stateToUse = stateFromInput || geminiOAuth.state.value - if (!stateToUse) { - geminiOAuth.error.value = t('admin.accounts.oauth.authFailed') - appStore.showError(geminiOAuth.error.value) - return - } - - const tokenInfo = await geminiOAuth.exchangeAuthCode({ - code: authCode.trim(), - sessionId: geminiOAuth.sessionId.value, - state: stateToUse, - proxyId: form.proxy_id, - oauthType: geminiOAuthType.value - }) - if (!tokenInfo) return - - const credentials = geminiOAuth.buildCredentials(tokenInfo) - - // Note: intercept_warmup_requests is Anthropic-only, not applicable to Gemini - - await adminAPI.accounts.create({ - name: form.name, - platform: 'gemini', - type: 'oauth', - credentials, - proxy_id: form.proxy_id, - concurrency: form.concurrency, - priority: form.priority, - group_ids: form.group_ids - }) - - appStore.showSuccess(t('admin.accounts.accountCreated')) - emit('created') - handleClose() - } catch (error: any) { - geminiOAuth.error.value = error.response?.data?.detail || t('admin.accounts.oauth.authFailed') + try { + const stateFromInput = oauthFlowRef.value?.oauthState || '' + const stateToUse = stateFromInput || geminiOAuth.state.value + if (!stateToUse) { + geminiOAuth.error.value = t('admin.accounts.oauth.authFailed') appStore.showError(geminiOAuth.error.value) - } finally { - geminiOAuth.loading.value = false + return } - return - } - // For Anthropic + const tokenInfo = await geminiOAuth.exchangeAuthCode({ + code: authCode.trim(), + sessionId: geminiOAuth.sessionId.value, + state: stateToUse, + proxyId: form.proxy_id, + oauthType: geminiOAuthType.value + }) + if (!tokenInfo) return + + const credentials = geminiOAuth.buildCredentials(tokenInfo) + await createAccountAndFinish('gemini', 'oauth', credentials) + } catch (error: any) { + geminiOAuth.error.value = error.response?.data?.detail || t('admin.accounts.oauth.authFailed') + appStore.showError(geminiOAuth.error.value) + } finally { + geminiOAuth.loading.value = false + } +} + +// Antigravity OAuth 授权码兑换 +const handleAntigravityExchange = async (authCode: string) => { + if (!authCode.trim() || !antigravityOAuth.sessionId.value) return + + antigravityOAuth.loading.value = true + antigravityOAuth.error.value = '' + + try { + const stateFromInput = oauthFlowRef.value?.oauthState || '' + const stateToUse = stateFromInput || antigravityOAuth.state.value + if (!stateToUse) { + antigravityOAuth.error.value = t('admin.accounts.oauth.authFailed') + appStore.showError(antigravityOAuth.error.value) + return + } + + const tokenInfo = await antigravityOAuth.exchangeAuthCode({ + code: authCode.trim(), + sessionId: antigravityOAuth.sessionId.value, + state: stateToUse, + proxyId: form.proxy_id + }) + if (!tokenInfo) return + + const credentials = antigravityOAuth.buildCredentials(tokenInfo) + await createAccountAndFinish('antigravity', 'oauth', credentials) + } catch (error: any) { + antigravityOAuth.error.value = error.response?.data?.detail || t('admin.accounts.oauth.authFailed') + appStore.showError(antigravityOAuth.error.value) + } finally { + antigravityOAuth.loading.value = false + } +} + +// Anthropic OAuth 授权码兑换 +const handleAnthropicExchange = async (authCode: string) => { if (!authCode.trim() || !oauth.sessionId.value) return oauth.loading.value = true @@ -1755,28 +1846,11 @@ const handleExchangeCode = async () => { }) const extra = oauth.buildExtraInfo(tokenInfo) - - // Merge interceptWarmupRequests into credentials const credentials = { ...tokenInfo, ...(interceptWarmupRequests.value ? { intercept_warmup_requests: true } : {}) } - - await adminAPI.accounts.create({ - name: form.name, - platform: form.platform, - type: addMethod.value, // Use addMethod as type: 'oauth' or 'setup-token' - credentials, - extra, - proxy_id: form.proxy_id, - concurrency: form.concurrency, - priority: form.priority, - group_ids: form.group_ids - }) - - appStore.showSuccess(t('admin.accounts.accountCreated')) - emit('created') - handleClose() + await createAccountAndFinish(form.platform, addMethod.value as AccountType, credentials, extra) } catch (error: any) { oauth.error.value = error.response?.data?.detail || t('admin.accounts.oauth.authFailed') appStore.showError(oauth.error.value) @@ -1785,6 +1859,22 @@ const handleExchangeCode = async () => { } } +// 主入口:根据平台路由到对应处理函数 +const handleExchangeCode = async () => { + const authCode = oauthFlowRef.value?.authCode || '' + + switch (form.platform) { + case 'openai': + return handleOpenAIExchange(authCode) + case 'gemini': + return handleGeminiExchange(authCode) + case 'antigravity': + return handleAntigravityExchange(authCode) + default: + return handleAnthropicExchange(authCode) + } +} + const handleCookieAuth = async (sessionKey: string) => { oauth.loading.value = true oauth.error.value = '' diff --git a/frontend/src/components/account/OAuthAuthorizationFlow.vue b/frontend/src/components/account/OAuthAuthorizationFlow.vue index 84760a27..afaed880 100644 --- a/frontend/src/components/account/OAuthAuthorizationFlow.vue +++ b/frontend/src/components/account/OAuthAuthorizationFlow.vue @@ -527,7 +527,7 @@ interface Props { allowMultiple?: boolean methodLabel?: string showCookieOption?: boolean // Whether to show cookie auto-auth option - platform?: 'anthropic' | 'openai' | 'gemini' // Platform type for different UI/text + platform?: 'anthropic' | 'openai' | 'gemini' | 'antigravity' // Platform type for different UI/text showProjectId?: boolean // New prop to control project ID visibility } @@ -560,6 +560,7 @@ const isOpenAI = computed(() => props.platform === 'openai') const getOAuthKey = (key: string) => { if (props.platform === 'openai') return `admin.accounts.oauth.openai.${key}` if (props.platform === 'gemini') return `admin.accounts.oauth.gemini.${key}` + if (props.platform === 'antigravity') return `admin.accounts.oauth.antigravity.${key}` return `admin.accounts.oauth.${key}` } @@ -575,9 +576,11 @@ const oauthAuthCodeDesc = computed(() => t(getOAuthKey('authCodeDesc'))) const oauthAuthCode = computed(() => t(getOAuthKey('authCode'))) const oauthAuthCodePlaceholder = computed(() => t(getOAuthKey('authCodePlaceholder'))) const oauthAuthCodeHint = computed(() => t(getOAuthKey('authCodeHint'))) -const oauthImportantNotice = computed(() => - props.platform === 'openai' ? t('admin.accounts.oauth.openai.importantNotice') : '' -) +const oauthImportantNotice = computed(() => { + if (props.platform === 'openai') return t('admin.accounts.oauth.openai.importantNotice') + if (props.platform === 'antigravity') return t('admin.accounts.oauth.antigravity.importantNotice') + return '' +}) // Local state const inputMethod = ref(props.showCookieOption ? 'manual' : 'manual') @@ -603,10 +606,10 @@ watch(inputMethod, (newVal) => { emit('update:inputMethod', newVal) }) -// Auto-extract code from OpenAI callback URL -// e.g., http://localhost:1455/auth/callback?code=ac_xxx...&scope=...&state=... +// Auto-extract code from callback URL (OpenAI/Gemini/Antigravity) +// e.g., http://localhost:8085/callback?code=xxx...&state=... watch(authCodeInput, (newVal) => { - if (props.platform !== 'openai' && props.platform !== 'gemini') return + if (props.platform !== 'openai' && props.platform !== 'gemini' && props.platform !== 'antigravity') return const trimmed = newVal.trim() // Check if it looks like a URL with code parameter @@ -616,7 +619,7 @@ watch(authCodeInput, (newVal) => { const url = new URL(trimmed) const code = url.searchParams.get('code') const stateParam = url.searchParams.get('state') - if (props.platform === 'gemini' && stateParam) { + if ((props.platform === 'gemini' || props.platform === 'antigravity') && stateParam) { oauthState.value = stateParam } if (code && code !== trimmed) { @@ -627,7 +630,7 @@ watch(authCodeInput, (newVal) => { // If URL parsing fails, try regex extraction const match = trimmed.match(/[?&]code=([^&]+)/) const stateMatch = trimmed.match(/[?&]state=([^&]+)/) - if (props.platform === 'gemini' && stateMatch && stateMatch[1]) { + if ((props.platform === 'gemini' || props.platform === 'antigravity') && stateMatch && stateMatch[1]) { oauthState.value = stateMatch[1] } if (match && match[1] && match[1] !== trimmed) { diff --git a/frontend/src/components/common/PlatformIcon.vue b/frontend/src/components/common/PlatformIcon.vue index 7ac3f812..1e137ae5 100644 --- a/frontend/src/components/common/PlatformIcon.vue +++ b/frontend/src/components/common/PlatformIcon.vue @@ -15,6 +15,10 @@ + + + + () const platformLabel = computed(() => { if (props.platform === 'anthropic') return 'Anthropic' if (props.platform === 'openai') return 'OpenAI' + if (props.platform === 'antigravity') return 'Antigravity' return 'Gemini' }) @@ -95,6 +96,9 @@ const platformClass = computed(() => { if (props.platform === 'openai') { return 'bg-emerald-100 text-emerald-700 dark:bg-emerald-900/30 dark:text-emerald-400' } + if (props.platform === 'antigravity') { + return 'bg-purple-100 text-purple-700 dark:bg-purple-900/30 dark:text-purple-400' + } return 'bg-blue-100 text-blue-700 dark:bg-blue-900/30 dark:text-blue-400' }) @@ -105,6 +109,9 @@ const typeClass = computed(() => { if (props.platform === 'openai') { return 'bg-emerald-100 text-emerald-600 dark:bg-emerald-900/30 dark:text-emerald-400' } + if (props.platform === 'antigravity') { + return 'bg-purple-100 text-purple-600 dark:bg-purple-900/30 dark:text-purple-400' + } return 'bg-blue-100 text-blue-600 dark:bg-blue-900/30 dark:text-blue-400' }) diff --git a/frontend/src/composables/useAntigravityOAuth.ts b/frontend/src/composables/useAntigravityOAuth.ts new file mode 100644 index 00000000..2c1a4cfe --- /dev/null +++ b/frontend/src/composables/useAntigravityOAuth.ts @@ -0,0 +1,115 @@ +import { ref } from 'vue' +import { useI18n } from 'vue-i18n' +import { useAppStore } from '@/stores/app' +import { adminAPI } from '@/api/admin' +import type { AntigravityTokenInfo } from '@/api/admin/antigravity' + +export function useAntigravityOAuth() { + const appStore = useAppStore() + const { t } = useI18n() + + const authUrl = ref('') + const sessionId = ref('') + const state = ref('') + const loading = ref(false) + const error = ref('') + + const resetState = () => { + authUrl.value = '' + sessionId.value = '' + state.value = '' + loading.value = false + error.value = '' + } + + const generateAuthUrl = async (proxyId: number | null | undefined): Promise => { + loading.value = true + authUrl.value = '' + sessionId.value = '' + state.value = '' + error.value = '' + + try { + const payload: Record = {} + if (proxyId) payload.proxy_id = proxyId + + const response = await adminAPI.antigravity.generateAuthUrl(payload as any) + authUrl.value = response.auth_url + sessionId.value = response.session_id + state.value = response.state + return true + } catch (err: any) { + error.value = + err.response?.data?.detail || t('admin.accounts.oauth.antigravity.failedToGenerateUrl') + appStore.showError(error.value) + return false + } finally { + loading.value = false + } + } + + const exchangeAuthCode = async (params: { + code: string + sessionId: string + state: string + proxyId?: number | null + }): Promise => { + const code = params.code?.trim() + if (!code || !params.sessionId || !params.state) { + error.value = t('admin.accounts.oauth.antigravity.missingExchangeParams') + return null + } + + loading.value = true + error.value = '' + + try { + const payload: Record = { + session_id: params.sessionId, + state: params.state, + code + } + if (params.proxyId) payload.proxy_id = params.proxyId + + const tokenInfo = await adminAPI.antigravity.exchangeCode(payload as any) + return tokenInfo as AntigravityTokenInfo + } catch (err: any) { + error.value = + err.response?.data?.detail || t('admin.accounts.oauth.antigravity.failedToExchangeCode') + appStore.showError(error.value) + return null + } finally { + loading.value = false + } + } + + const buildCredentials = (tokenInfo: AntigravityTokenInfo): Record => { + let expiresAt: string | undefined + if (typeof tokenInfo.expires_at === 'number' && Number.isFinite(tokenInfo.expires_at)) { + expiresAt = Math.floor(tokenInfo.expires_at).toString() + } else if (typeof tokenInfo.expires_at === 'string' && tokenInfo.expires_at.trim()) { + expiresAt = tokenInfo.expires_at.trim() + } + + return { + access_token: tokenInfo.access_token, + refresh_token: tokenInfo.refresh_token, + token_type: tokenInfo.token_type, + expires_at: expiresAt, + project_id: tokenInfo.project_id, + email: tokenInfo.email + } + } + + return { + authUrl, + sessionId, + state, + loading, + error, + resetState, + generateAuthUrl, + exchangeAuthCode, + buildCredentials + } +} diff --git a/frontend/src/i18n/locales/en.ts b/frontend/src/i18n/locales/en.ts index 0996432a..bef1d84a 100644 --- a/frontend/src/i18n/locales/en.ts +++ b/frontend/src/i18n/locales/en.ts @@ -820,14 +820,16 @@ export default { anthropic: 'Anthropic', claude: 'Claude', openai: 'OpenAI', - gemini: 'Gemini' + gemini: 'Gemini', + antigravity: 'Antigravity' }, types: { oauth: 'OAuth', chatgptOauth: 'ChatGPT OAuth', responsesApi: 'Responses API', googleOauth: 'Google OAuth', - codeAssist: 'Code Assist' + codeAssist: 'Code Assist', + antigravityOauth: 'Antigravity OAuth' }, columns: { name: 'Name', @@ -1056,7 +1058,28 @@ export default { 'AI Studio OAuth is not configured: set GEMINI_OAUTH_CLIENT_ID / GEMINI_OAUTH_CLIENT_SECRET and add Redirect URI: http://localhost:1455/auth/callback (Consent screen scopes must include https://www.googleapis.com/auth/generative-language.retriever)', aiStudioNotConfigured: 'AI Studio OAuth is not configured: set GEMINI_OAUTH_CLIENT_ID / GEMINI_OAUTH_CLIENT_SECRET and add Redirect URI: http://localhost:1455/auth/callback' - } + }, + // Antigravity specific + antigravity: { + title: 'Antigravity Account Authorization', + followSteps: 'Follow these steps to authorize your Antigravity account:', + step1GenerateUrl: 'Generate the authorization URL', + generateAuthUrl: 'Generate Auth URL', + step2OpenUrl: 'Open the URL in your browser and complete authorization', + openUrlDesc: 'Open the authorization URL in a new tab, log in to your Google account and authorize.', + importantNotice: + 'Important: The page may take a while to load after authorization. Please wait patiently. When the browser address bar shows http://localhost..., authorization is complete.', + step3EnterCode: 'Enter Authorization URL or Code', + authCodeDesc: + 'After authorization, when the page URL becomes http://localhost:xxx/auth/callback?code=...:', + authCode: 'Authorization URL or Code', + authCodePlaceholder: + 'Option 1: Copy the complete URL\n(http://localhost:xxx/auth/callback?code=...)\nOption 2: Copy only the code parameter value', + authCodeHint: 'You can copy the entire URL or just the code parameter value, the system will auto-detect', + failedToGenerateUrl: 'Failed to generate Antigravity auth URL', + missingExchangeParams: 'Missing code, session ID, or state', + failedToExchangeCode: 'Failed to exchange Antigravity auth code' + } }, // Gemini specific (platform-wide) gemini: { @@ -1070,6 +1093,7 @@ export default { claudeCodeAccount: 'Claude Code Account', openaiAccount: 'OpenAI Account', geminiAccount: 'Gemini Account', + antigravityAccount: 'Antigravity Account', inputMethod: 'Input Method', reAuthorizedSuccess: 'Account re-authorized successfully', // Test Modal diff --git a/frontend/src/i18n/locales/zh.ts b/frontend/src/i18n/locales/zh.ts index 483edd07..e55c7ca9 100644 --- a/frontend/src/i18n/locales/zh.ts +++ b/frontend/src/i18n/locales/zh.ts @@ -940,7 +940,8 @@ export default { claude: 'Claude', openai: 'OpenAI', anthropic: 'Anthropic', - gemini: 'Gemini' + gemini: 'Gemini', + antigravity: 'Antigravity' }, types: { oauth: 'OAuth', @@ -948,6 +949,7 @@ export default { responsesApi: 'Responses API', googleOauth: 'Google OAuth', codeAssist: 'Code Assist', + antigravityOauth: 'Antigravity OAuth', api_key: 'API Key', cookie: 'Cookie' }, @@ -1178,7 +1180,28 @@ export default { aiStudioNotConfiguredShort: '未配置', aiStudioNotConfiguredTip: 'AI Studio OAuth 未配置:请先设置 GEMINI_OAUTH_CLIENT_ID / GEMINI_OAUTH_CLIENT_SECRET,并在 Google OAuth Client 添加 Redirect URI:http://localhost:1455/auth/callback(Consent Screen scopes 需包含 https://www.googleapis.com/auth/generative-language.retriever)', aiStudioNotConfigured: 'AI Studio OAuth 未配置:请先设置 GEMINI_OAUTH_CLIENT_ID / GEMINI_OAUTH_CLIENT_SECRET,并在 Google OAuth Client 添加 Redirect URI:http://localhost:1455/auth/callback' - } + }, + // Antigravity specific + antigravity: { + title: 'Antigravity 账户授权', + followSteps: '请按照以下步骤完成 Antigravity 账户的授权:', + step1GenerateUrl: '生成授权链接', + generateAuthUrl: '生成授权链接', + step2OpenUrl: '在浏览器中打开链接并完成授权', + openUrlDesc: '请在新标签页中打开授权链接,登录您的 Google 账户并授权。', + importantNotice: + '重要提示:授权后页面可能会加载较长时间,请耐心等待。当浏览器地址栏变为 http://localhost... 开头时,表示授权已完成。', + step3EnterCode: '输入授权链接或 Code', + authCodeDesc: + '授权完成后,当页面地址变为 http://localhost:xxx/auth/callback?code=... 时:', + authCode: '授权链接或 Code', + authCodePlaceholder: + '方式1:复制完整的链接\n(http://localhost:xxx/auth/callback?code=...)\n方式2:仅复制 code 参数的值', + authCodeHint: '您可以直接复制整个链接或仅复制 code 参数值,系统会自动识别', + failedToGenerateUrl: '生成 Antigravity 授权链接失败', + missingExchangeParams: '缺少 code / session_id / state', + failedToExchangeCode: 'Antigravity 授权码兑换失败' + } }, // Gemini specific (platform-wide) gemini: { @@ -1191,6 +1214,7 @@ export default { claudeCodeAccount: 'Claude Code 账号', openaiAccount: 'OpenAI 账号', geminiAccount: 'Gemini 账号', + antigravityAccount: 'Antigravity 账号', inputMethod: '输入方式', reAuthorizedSuccess: '账号重新授权成功', // Test Modal diff --git a/frontend/src/types/index.ts b/frontend/src/types/index.ts index f43cba42..e0d95267 100644 --- a/frontend/src/types/index.ts +++ b/frontend/src/types/index.ts @@ -220,7 +220,7 @@ export interface PaginationConfig { // ==================== API Key & Group Types ==================== -export type GroupPlatform = 'anthropic' | 'openai' | 'gemini' +export type GroupPlatform = 'anthropic' | 'openai' | 'gemini' | 'antigravity' export type SubscriptionType = 'standard' | 'subscription' @@ -256,7 +256,7 @@ export interface ApiKey { export interface CreateApiKeyRequest { name: string group_id?: number | null - custom_key?: string // 可选的自定义API Key + custom_key?: string // Optional custom API Key } export interface UpdateApiKeyRequest { @@ -284,7 +284,7 @@ export interface UpdateGroupRequest { // ==================== Account & Proxy Types ==================== -export type AccountPlatform = 'anthropic' | 'openai' | 'gemini' +export type AccountPlatform = 'anthropic' | 'openai' | 'gemini' | 'antigravity' export type AccountType = 'oauth' | 'setup-token' | 'apikey' export type OAuthAddMethod = 'oauth' | 'setup-token' export type ProxyProtocol = 'http' | 'https' | 'socks5' diff --git a/frontend/src/views/admin/AccountsView.vue b/frontend/src/views/admin/AccountsView.vue index 44bb82e3..f0bdd8c8 100644 --- a/frontend/src/views/admin/AccountsView.vue +++ b/frontend/src/views/admin/AccountsView.vue @@ -594,7 +594,8 @@ const platformOptions = computed(() => [ { value: '', label: t('admin.accounts.allPlatforms') }, { value: 'anthropic', label: t('admin.accounts.platforms.anthropic') }, { value: 'openai', label: t('admin.accounts.platforms.openai') }, - { value: 'gemini', label: t('admin.accounts.platforms.gemini') } + { value: 'gemini', label: t('admin.accounts.platforms.gemini') }, + { value: 'antigravity', label: t('admin.accounts.platforms.antigravity') } ]) const typeOptions = computed(() => [