package admin import ( "strconv" "strings" "time" "github.com/Wei-Shaw/sub2api/internal/handler/dto" "github.com/Wei-Shaw/sub2api/internal/pkg/openai" "github.com/Wei-Shaw/sub2api/internal/pkg/response" "github.com/Wei-Shaw/sub2api/internal/service" "github.com/gin-gonic/gin" ) // OpenAIOAuthHandler handles OpenAI OAuth-related operations type OpenAIOAuthHandler struct { openaiOAuthService *service.OpenAIOAuthService adminService service.AdminService quotaService *service.OpenAIQuotaService } func oauthPlatformFromPath(c *gin.Context) string { return service.PlatformOpenAI } // NewOpenAIOAuthHandler creates a new OpenAI OAuth handler func NewOpenAIOAuthHandler( openaiOAuthService *service.OpenAIOAuthService, adminService service.AdminService, quotaService *service.OpenAIQuotaService, ) *OpenAIOAuthHandler { return &OpenAIOAuthHandler{ openaiOAuthService: openaiOAuthService, adminService: adminService, quotaService: quotaService, } } // OpenAIGenerateAuthURLRequest represents the request for generating OpenAI auth URL type OpenAIGenerateAuthURLRequest struct { ProxyID *int64 `json:"proxy_id"` RedirectURI string `json:"redirect_uri"` } // GenerateAuthURL generates OpenAI OAuth authorization URL // POST /api/v1/admin/openai/generate-auth-url func (h *OpenAIOAuthHandler) GenerateAuthURL(c *gin.Context) { var req OpenAIGenerateAuthURLRequest if err := c.ShouldBindJSON(&req); err != nil { // Allow empty body req = OpenAIGenerateAuthURLRequest{} } result, err := h.openaiOAuthService.GenerateAuthURL( c.Request.Context(), req.ProxyID, req.RedirectURI, oauthPlatformFromPath(c), ) if err != nil { response.ErrorFrom(c, err) return } response.Success(c, result) } // OpenAIExchangeCodeRequest represents the request for exchanging OpenAI auth code type OpenAIExchangeCodeRequest struct { SessionID string `json:"session_id" binding:"required"` Code string `json:"code" binding:"required"` State string `json:"state" binding:"required"` RedirectURI string `json:"redirect_uri"` ProxyID *int64 `json:"proxy_id"` } // ExchangeCode exchanges OpenAI authorization code for tokens // POST /api/v1/admin/openai/exchange-code func (h *OpenAIOAuthHandler) ExchangeCode(c *gin.Context) { var req OpenAIExchangeCodeRequest if err := c.ShouldBindJSON(&req); err != nil { response.BadRequest(c, "Invalid request: "+err.Error()) return } tokenInfo, err := h.openaiOAuthService.ExchangeCode(c.Request.Context(), &service.OpenAIExchangeCodeInput{ SessionID: req.SessionID, Code: req.Code, State: req.State, RedirectURI: req.RedirectURI, ProxyID: req.ProxyID, }) if err != nil { response.ErrorFrom(c, err) return } response.Success(c, tokenInfo) } // OpenAIRefreshTokenRequest represents the request for refreshing OpenAI token type OpenAIRefreshTokenRequest struct { RefreshToken string `json:"refresh_token"` RT string `json:"rt"` ClientID string `json:"client_id"` ProxyID *int64 `json:"proxy_id"` } type OpenAICodexPATCreateRequest struct { AccessToken string `json:"access_token" binding:"required"` Name string `json:"name"` Notes *string `json:"notes"` GroupIDs []int64 `json:"group_ids"` ProxyID *int64 `json:"proxy_id"` Concurrency *int `json:"concurrency"` Priority *int `json:"priority"` RateMultiplier *float64 `json:"rate_multiplier"` LoadFactor *int `json:"load_factor"` ExpiresAt *int64 `json:"expires_at"` AutoPauseOnExpired *bool `json:"auto_pause_on_expired"` CredentialExtras map[string]any `json:"credential_extras"` Extra map[string]any `json:"extra"` SkipDefaultGroupBind *bool `json:"skip_default_group_bind"` ConfirmMixedChannelRisk *bool `json:"confirm_mixed_channel_risk"` } // RefreshToken refreshes an OpenAI OAuth token // POST /api/v1/admin/openai/refresh-token func (h *OpenAIOAuthHandler) RefreshToken(c *gin.Context) { var req OpenAIRefreshTokenRequest if err := c.ShouldBindJSON(&req); err != nil { response.BadRequest(c, "Invalid request: "+err.Error()) return } refreshToken := strings.TrimSpace(req.RefreshToken) if refreshToken == "" { refreshToken = strings.TrimSpace(req.RT) } if refreshToken == "" { response.BadRequest(c, "refresh_token is required") return } var proxyURL string if req.ProxyID != nil { proxy, err := h.adminService.GetProxy(c.Request.Context(), *req.ProxyID) if err == nil && proxy != nil { proxyURL = proxy.URL() } } // 未指定 client_id 时,根据请求路径平台自动设置默认值,避免 repository 层盲猜 clientID := strings.TrimSpace(req.ClientID) if clientID == "" { platform := oauthPlatformFromPath(c) clientID, _ = openai.OAuthClientConfigByPlatform(platform) } tokenInfo, err := h.openaiOAuthService.RefreshTokenWithClientID(c.Request.Context(), refreshToken, proxyURL, clientID) if err != nil { response.ErrorFrom(c, err) return } response.Success(c, tokenInfo) } // RefreshAccountToken refreshes token for a specific OpenAI account // POST /api/v1/admin/openai/accounts/:id/refresh func (h *OpenAIOAuthHandler) RefreshAccountToken(c *gin.Context) { accountID, err := strconv.ParseInt(c.Param("id"), 10, 64) if err != nil { response.BadRequest(c, "Invalid account ID") return } // Get account account, err := h.adminService.GetAccount(c.Request.Context(), accountID) if err != nil { response.ErrorFrom(c, err) return } platform := oauthPlatformFromPath(c) if account.Platform != platform { response.BadRequest(c, "Account platform does not match OAuth endpoint") return } // Only refresh OAuth-based accounts if !account.IsOAuth() { response.BadRequest(c, "Cannot refresh non-OAuth account credentials") return } // spark 影子账号凭据透传母账号、自身恒空,刷新无意义;在调用上游前早拒,避免先打上游 // 再被凭据写守卫拦下的无谓副作用(外审第6轮)。 if account.IsCredentialShadow() { response.BadRequest(c, "Cannot refresh spark shadow account; its credentials are managed by the parent account") return } // Use OpenAI OAuth service to refresh token tokenInfo, err := h.openaiOAuthService.RefreshAccountToken(c.Request.Context(), account) if err != nil { response.ErrorFrom(c, err) return } // Build new credentials from token info newCredentials := h.openaiOAuthService.BuildAccountCredentials(tokenInfo) // Preserve non-token settings from existing credentials for k, v := range account.Credentials { if _, exists := newCredentials[k]; !exists { newCredentials[k] = v } } newCredentials = service.NormalizeOpenAIPersonalAccessTokenCredentials(account, tokenInfo, newCredentials) updatedAccount, err := h.adminService.UpdateAccount(c.Request.Context(), accountID, &service.UpdateAccountInput{ Credentials: newCredentials, }) if err != nil { response.ErrorFrom(c, err) return } response.Success(c, dto.AccountFromService(updatedAccount)) } // CreateAccountFromOAuth creates a new OpenAI OAuth account from token info // POST /api/v1/admin/openai/create-from-oauth func (h *OpenAIOAuthHandler) CreateAccountFromOAuth(c *gin.Context) { var req struct { SessionID string `json:"session_id" binding:"required"` Code string `json:"code" binding:"required"` State string `json:"state" binding:"required"` RedirectURI string `json:"redirect_uri"` ProxyID *int64 `json:"proxy_id"` Name string `json:"name"` Concurrency int `json:"concurrency"` Priority int `json:"priority"` GroupIDs []int64 `json:"group_ids"` } if err := c.ShouldBindJSON(&req); err != nil { response.BadRequest(c, "Invalid request: "+err.Error()) return } // Exchange code for tokens tokenInfo, err := h.openaiOAuthService.ExchangeCode(c.Request.Context(), &service.OpenAIExchangeCodeInput{ SessionID: req.SessionID, Code: req.Code, State: req.State, RedirectURI: req.RedirectURI, ProxyID: req.ProxyID, }) if err != nil { response.ErrorFrom(c, err) return } // Build credentials from token info credentials := h.openaiOAuthService.BuildAccountCredentials(tokenInfo) platform := oauthPlatformFromPath(c) // Use email as default name if not provided name := req.Name if name == "" && tokenInfo.Email != "" { name = tokenInfo.Email } if name == "" { name = "OpenAI OAuth Account" } // Create account account, err := h.adminService.CreateAccount(c.Request.Context(), &service.CreateAccountInput{ Name: name, Platform: platform, Type: "oauth", Credentials: credentials, Extra: nil, ProxyID: req.ProxyID, Concurrency: req.Concurrency, Priority: req.Priority, GroupIDs: req.GroupIDs, }) if err != nil { response.ErrorFrom(c, err) return } response.Success(c, dto.AccountFromService(account)) } // CreateAccountFromCodexPAT creates an OpenAI OAuth account from a Codex at-* personal access token. // POST /api/v1/admin/openai/create-from-codex-pat func (h *OpenAIOAuthHandler) CreateAccountFromCodexPAT(c *gin.Context) { var req OpenAICodexPATCreateRequest if err := c.ShouldBindJSON(&req); err != nil { response.BadRequest(c, "Invalid request: "+err.Error()) return } if err := service.ValidateOpenAILongContextBillingExtra(service.PlatformOpenAI, req.Extra); err != nil { response.ErrorFrom(c, err) return } if req.Concurrency != nil && *req.Concurrency < 0 { response.BadRequest(c, "concurrency must be >= 0") return } if req.Priority != nil && *req.Priority < 0 { response.BadRequest(c, "priority must be >= 0") return } if req.RateMultiplier != nil && *req.RateMultiplier < 0 { response.BadRequest(c, "rate_multiplier must be >= 0") return } if req.LoadFactor != nil && *req.LoadFactor > 10000 { response.BadRequest(c, "load_factor must be <= 10000") return } var proxyURL string if req.ProxyID != nil { proxy, err := h.adminService.GetProxy(c.Request.Context(), *req.ProxyID) if err != nil { response.ErrorFrom(c, err) return } if proxy != nil { proxyURL = proxy.URL() } } tokenInfo, err := h.openaiOAuthService.ValidateCodexPersonalAccessToken(c.Request.Context(), req.AccessToken, proxyURL) if err != nil { response.ErrorFrom(c, err) return } credentials := mergeCodexImportMap( h.openaiOAuthService.BuildAccountCredentials(tokenInfo), sanitizeCodexImportCredentialExtras(req.CredentialExtras), ) extra := mergeCodexImportMap(req.Extra, map[string]any{ "import_source": "codex_personal_access_token", "auth_provider": "codex_personal_access_token", "imported_at": time.Now().UTC().Format(time.RFC3339), "access_token_sha256": codexTokenFingerprint(req.AccessToken), }) concurrency := 3 if req.Concurrency != nil { concurrency = *req.Concurrency } priority := 50 if req.Priority != nil { priority = *req.Priority } skipDefaultGroupBind := false if req.SkipDefaultGroupBind != nil { skipDefaultGroupBind = *req.SkipDefaultGroupBind } account, err := h.adminService.CreateAccount(c.Request.Context(), &service.CreateAccountInput{ Name: buildOpenAICodexPATAccountName(req.Name, tokenInfo), Notes: req.Notes, Platform: service.PlatformOpenAI, Type: service.AccountTypeOAuth, Credentials: credentials, Extra: extra, ProxyID: req.ProxyID, Concurrency: concurrency, Priority: priority, RateMultiplier: req.RateMultiplier, LoadFactor: req.LoadFactor, GroupIDs: req.GroupIDs, ExpiresAt: req.ExpiresAt, AutoPauseOnExpired: req.AutoPauseOnExpired, SkipDefaultGroupBind: skipDefaultGroupBind, SkipMixedChannelCheck: req.ConfirmMixedChannelRisk != nil && *req.ConfirmMixedChannelRisk, }) if err != nil { response.ErrorFrom(c, err) return } response.Success(c, dto.AccountFromService(account)) } func buildOpenAICodexPATAccountName(name string, tokenInfo *service.OpenAITokenInfo) string { name = strings.TrimSpace(name) if name != "" { return name } if tokenInfo != nil { for _, candidate := range []string{tokenInfo.Email, tokenInfo.ChatGPTAccountID, tokenInfo.ChatGPTUserID} { if candidate = strings.TrimSpace(candidate); candidate != "" { return candidate } } } return "Codex PAT Account" } // QueryQuota queries the rate-limit / quota usage for an OpenAI account. // GET /api/v1/admin/openai/accounts/:id/quota func (h *OpenAIOAuthHandler) QueryQuota(c *gin.Context) { accountID, err := strconv.ParseInt(c.Param("id"), 10, 64) if err != nil { response.BadRequest(c, "Invalid account ID") return } if h.quotaService == nil { response.BadRequest(c, "openai quota service is not enabled") return } usage, err := h.quotaService.QueryUsage(c.Request.Context(), accountID) if err != nil { response.ErrorFrom(c, err) return } response.Success(c, usage) } // CreateShadowRequest is the request body for CreateShadow. type CreateShadowRequest struct { Name string `json:"name"` Priority int `json:"priority"` Concurrency int `json:"concurrency"` GroupIDs []int64 `json:"group_ids"` } // CreateShadow creates a spark-dimension shadow account for a parent OpenAI OAuth account. // POST /api/v1/admin/accounts/:id/shadow func (h *OpenAIOAuthHandler) CreateShadow(c *gin.Context) { parentID, err := strconv.ParseInt(c.Param("id"), 10, 64) if err != nil { response.BadRequest(c, "Invalid account ID") return } var req CreateShadowRequest if err := c.ShouldBindJSON(&req); err != nil { response.BadRequest(c, "Invalid request: "+err.Error()) return } shadow, err := h.adminService.CreateShadow(c.Request.Context(), parentID, service.ShadowOptions{ Name: req.Name, Priority: req.Priority, Concurrency: req.Concurrency, GroupIDs: req.GroupIDs, }) if err != nil { response.ErrorFrom(c, err) return } response.Success(c, dto.AccountFromServiceShallow(shadow)) } // ResetQuota consumes one rate-limit reset credit for an OpenAI account. // POST /api/v1/admin/openai/accounts/:id/reset-quota func (h *OpenAIOAuthHandler) ResetQuota(c *gin.Context) { accountID, err := strconv.ParseInt(c.Param("id"), 10, 64) if err != nil { response.BadRequest(c, "Invalid account ID") return } if h.quotaService == nil { response.BadRequest(c, "openai quota service is not enabled") return } result, err := h.quotaService.ResetCredit(c.Request.Context(), accountID) if err != nil { response.ErrorFrom(c, err) return } response.Success(c, result) }