diff --git a/backend/internal/service/account_test_service.go b/backend/internal/service/account_test_service.go index fec98e12..8218c2db 100644 --- a/backend/internal/service/account_test_service.go +++ b/backend/internal/service/account_test_service.go @@ -551,6 +551,11 @@ func (s *AccountTestService) testOpenAIAccountConnection(c *gin.Context, account account.RateLimitResetAt = resetAt } } + // 401 Unauthorized: 标记账号为永久错误 + if resp.StatusCode == http.StatusUnauthorized && s.accountRepo != nil { + errMsg := fmt.Sprintf("Authentication failed (401): %s", string(body)) + _ = s.accountRepo.SetError(ctx, account.ID, errMsg) + } return s.sendErrorAndEnd(c, fmt.Sprintf("API returned %d: %s", resp.StatusCode, string(body))) } diff --git a/backend/internal/service/admin_service.go b/backend/internal/service/admin_service.go index 2433bc6f..b6d7d634 100644 --- a/backend/internal/service/admin_service.go +++ b/backend/internal/service/admin_service.go @@ -1642,16 +1642,29 @@ func (s *adminServiceImpl) CreateAccount(ctx context.Context, input *CreateAccou } } - // Antigravity OAuth 账号:创建后异步设置隐私 - if account.Platform == PlatformAntigravity && account.Type == AccountTypeOAuth { - go func() { - defer func() { - if r := recover(); r != nil { - slog.Error("create_account_antigravity_privacy_panic", "account_id", account.ID, "recover", r) - } + // OAuth 账号:创建后异步设置隐私。 + // 使用 Ensure(幂等)而非 Force:新建账号 Extra 为空时效果相同,但更安全。 + if account.Type == AccountTypeOAuth { + switch account.Platform { + case PlatformOpenAI: + go func() { + defer func() { + if r := recover(); r != nil { + slog.Error("create_account_openai_privacy_panic", "account_id", account.ID, "recover", r) + } + }() + s.EnsureOpenAIPrivacy(context.Background(), account) }() - s.EnsureAntigravityPrivacy(context.Background(), account) - }() + case PlatformAntigravity: + go func() { + defer func() { + if r := recover(); r != nil { + slog.Error("create_account_antigravity_privacy_panic", "account_id", account.ID, "recover", r) + } + }() + s.EnsureAntigravityPrivacy(context.Background(), account) + }() + } } return account, nil diff --git a/backend/internal/service/openai_oauth_service.go b/backend/internal/service/openai_oauth_service.go index 0f004b01..f575cd62 100644 --- a/backend/internal/service/openai_oauth_service.go +++ b/backend/internal/service/openai_oauth_service.go @@ -127,18 +127,19 @@ type OpenAIExchangeCodeInput struct { // OpenAITokenInfo represents the token information for OpenAI type OpenAITokenInfo struct { - AccessToken string `json:"access_token"` - RefreshToken string `json:"refresh_token"` - IDToken string `json:"id_token,omitempty"` - ExpiresIn int64 `json:"expires_in"` - ExpiresAt int64 `json:"expires_at"` - ClientID string `json:"client_id,omitempty"` - Email string `json:"email,omitempty"` - ChatGPTAccountID string `json:"chatgpt_account_id,omitempty"` - ChatGPTUserID string `json:"chatgpt_user_id,omitempty"` - OrganizationID string `json:"organization_id,omitempty"` - PlanType string `json:"plan_type,omitempty"` - PrivacyMode string `json:"privacy_mode,omitempty"` + AccessToken string `json:"access_token"` + RefreshToken string `json:"refresh_token"` + IDToken string `json:"id_token,omitempty"` + ExpiresIn int64 `json:"expires_in"` + ExpiresAt int64 `json:"expires_at"` + ClientID string `json:"client_id,omitempty"` + Email string `json:"email,omitempty"` + ChatGPTAccountID string `json:"chatgpt_account_id,omitempty"` + ChatGPTUserID string `json:"chatgpt_user_id,omitempty"` + OrganizationID string `json:"organization_id,omitempty"` + PlanType string `json:"plan_type,omitempty"` + SubscriptionExpiresAt string `json:"subscription_expires_at,omitempty"` + PrivacyMode string `json:"privacy_mode,omitempty"` } // ExchangeCode exchanges authorization code for tokens @@ -214,6 +215,8 @@ func (s *OpenAIOAuthService) ExchangeCode(ctx context.Context, input *OpenAIExch tokenInfo.PlanType = userInfo.PlanType } + s.enrichTokenInfo(ctx, tokenInfo, proxyURL) + return tokenInfo, nil } @@ -259,31 +262,40 @@ func (s *OpenAIOAuthService) RefreshTokenWithClientID(ctx context.Context, refre tokenInfo.PlanType = userInfo.PlanType } - // id_token 中缺少 plan_type 时(如 Mobile RT),尝试通过 ChatGPT backend-api 补全 - if tokenInfo.PlanType == "" && tokenInfo.AccessToken != "" && s.privacyClientFactory != nil { - // 从 access_token JWT 中提取 orgID(poid),用于匹配正确的账号 - orgID := tokenInfo.OrganizationID - if orgID == "" { - if atClaims, err := openai.DecodeIDToken(tokenInfo.AccessToken); err == nil && atClaims.OpenAIAuth != nil { - orgID = atClaims.OpenAIAuth.POID - } + s.enrichTokenInfo(ctx, tokenInfo, proxyURL) + + return tokenInfo, nil +} + +// enrichTokenInfo 通过 ChatGPT backend-api 补全 tokenInfo 并设置隐私(best-effort)。 +// 从 accounts/check 获取最新 plan_type、subscription_expires_at、email, +// 然后尝试关闭训练数据共享。适用于所有获取/刷新 token 的路径。 +func (s *OpenAIOAuthService) enrichTokenInfo(ctx context.Context, tokenInfo *OpenAITokenInfo, proxyURL string) { + if tokenInfo.AccessToken == "" || s.privacyClientFactory == nil { + return + } + + // 从 access_token JWT 中提取 orgID(poid),用于匹配正确的账号 + orgID := tokenInfo.OrganizationID + if orgID == "" { + if atClaims, err := openai.DecodeIDToken(tokenInfo.AccessToken); err == nil && atClaims.OpenAIAuth != nil { + orgID = atClaims.OpenAIAuth.POID } - if info := fetchChatGPTAccountInfo(ctx, s.privacyClientFactory, tokenInfo.AccessToken, proxyURL, orgID); info != nil { - if tokenInfo.PlanType == "" && info.PlanType != "" { - tokenInfo.PlanType = info.PlanType - } - if tokenInfo.Email == "" && info.Email != "" { - tokenInfo.Email = info.Email - } + } + if info := fetchChatGPTAccountInfo(ctx, s.privacyClientFactory, tokenInfo.AccessToken, proxyURL, orgID); info != nil { + if info.PlanType != "" { + tokenInfo.PlanType = info.PlanType + } + if info.SubscriptionExpiresAt != "" { + tokenInfo.SubscriptionExpiresAt = info.SubscriptionExpiresAt + } + if tokenInfo.Email == "" && info.Email != "" { + tokenInfo.Email = info.Email } } // 尝试设置隐私(关闭训练数据共享),best-effort - if tokenInfo.AccessToken != "" && s.privacyClientFactory != nil { - tokenInfo.PrivacyMode = disableOpenAITraining(ctx, s.privacyClientFactory, tokenInfo.AccessToken, proxyURL) - } - - return tokenInfo, nil + tokenInfo.PrivacyMode = disableOpenAITraining(ctx, s.privacyClientFactory, tokenInfo.AccessToken, proxyURL) } // ExchangeSoraSessionToken exchanges Sora session_token to access_token. @@ -567,6 +579,9 @@ func (s *OpenAIOAuthService) BuildAccountCredentials(tokenInfo *OpenAITokenInfo) if tokenInfo.PlanType != "" { creds["plan_type"] = tokenInfo.PlanType } + if tokenInfo.SubscriptionExpiresAt != "" { + creds["subscription_expires_at"] = tokenInfo.SubscriptionExpiresAt + } if strings.TrimSpace(tokenInfo.ClientID) != "" { creds["client_id"] = strings.TrimSpace(tokenInfo.ClientID) } diff --git a/backend/internal/service/openai_privacy_service.go b/backend/internal/service/openai_privacy_service.go index 6bc71ab9..da6dbefc 100644 --- a/backend/internal/service/openai_privacy_service.go +++ b/backend/internal/service/openai_privacy_service.go @@ -56,6 +56,10 @@ func disableOpenAITraining(ctx context.Context, clientFactory PrivacyClientFacto SetHeader("Authorization", "Bearer "+accessToken). SetHeader("Origin", "https://chatgpt.com"). SetHeader("Referer", "https://chatgpt.com/"). + SetHeader("Accept", "application/json"). + SetHeader("sec-fetch-mode", "cors"). + SetHeader("sec-fetch-site", "same-origin"). + SetHeader("sec-fetch-dest", "empty"). SetQueryParam("feature", "training_allowed"). SetQueryParam("value", "false"). Patch(openAISettingsURL) @@ -84,8 +88,9 @@ func disableOpenAITraining(ctx context.Context, clientFactory PrivacyClientFacto // ChatGPTAccountInfo 从 chatgpt.com/backend-api/accounts/check 获取的账号信息 type ChatGPTAccountInfo struct { - PlanType string - Email string + PlanType string + Email string + SubscriptionExpiresAt string // entitlement.expires_at (RFC3339) } const chatGPTAccountsCheckURL = "https://chatgpt.com/backend-api/accounts/check/v4-2023-04-27" @@ -138,14 +143,20 @@ func fetchChatGPTAccountInfo(ctx context.Context, clientFactory PrivacyClientFac // 优先匹配 orgID 对应的账号(access_token JWT 中的 poid) if orgID != "" { - if matched := extractPlanFromAccount(accounts, orgID); matched != "" { - info.PlanType = matched + if acctRaw, ok := accounts[orgID]; ok { + if acct, ok := acctRaw.(map[string]any); ok { + fillAccountInfo(info, acct) + } } } // 未匹配到时,遍历所有账号:优先 is_default,次选非 free if info.PlanType == "" { - var defaultPlan, paidPlan, anyPlan string + type candidate struct { + planType string + expiresAt string + } + var defaultC, paidC, anyC candidate for _, acctRaw := range accounts { acct, ok := acctRaw.(map[string]any) if !ok { @@ -155,26 +166,27 @@ func fetchChatGPTAccountInfo(ctx context.Context, clientFactory PrivacyClientFac if planType == "" { continue } - if anyPlan == "" { - anyPlan = planType + ea := extractEntitlementExpiresAt(acct) + if anyC.planType == "" { + anyC = candidate{planType, ea} } if account, ok := acct["account"].(map[string]any); ok { if isDefault, _ := account["is_default"].(bool); isDefault { - defaultPlan = planType + defaultC = candidate{planType, ea} } } - if !strings.EqualFold(planType, "free") && paidPlan == "" { - paidPlan = planType + if !strings.EqualFold(planType, "free") && paidC.planType == "" { + paidC = candidate{planType, ea} } } // 优先级:default > 非 free > 任意 switch { - case defaultPlan != "": - info.PlanType = defaultPlan - case paidPlan != "": - info.PlanType = paidPlan + case defaultC.planType != "": + info.PlanType, info.SubscriptionExpiresAt = defaultC.planType, defaultC.expiresAt + case paidC.planType != "": + info.PlanType, info.SubscriptionExpiresAt = paidC.planType, paidC.expiresAt default: - info.PlanType = anyPlan + info.PlanType, info.SubscriptionExpiresAt = anyC.planType, anyC.expiresAt } } @@ -183,21 +195,14 @@ func fetchChatGPTAccountInfo(ctx context.Context, clientFactory PrivacyClientFac return nil } - slog.Info("chatgpt_account_check_success", "plan_type", info.PlanType, "org_id", orgID) + slog.Info("chatgpt_account_check_success", "plan_type", info.PlanType, "subscription_expires_at", info.SubscriptionExpiresAt, "org_id", orgID) return info } -// extractPlanFromAccount 从 accounts map 中按 key(account_id)精确匹配并提取 plan_type -func extractPlanFromAccount(accounts map[string]any, accountKey string) string { - acctRaw, ok := accounts[accountKey] - if !ok { - return "" - } - acct, ok := acctRaw.(map[string]any) - if !ok { - return "" - } - return extractPlanType(acct) +// fillAccountInfo 从单个 account 对象中提取 plan_type 和 subscription_expires_at +func fillAccountInfo(info *ChatGPTAccountInfo, acct map[string]any) { + info.PlanType = extractPlanType(acct) + info.SubscriptionExpiresAt = extractEntitlementExpiresAt(acct) } // extractPlanType 从单个 account 对象中提取 plan_type @@ -215,6 +220,17 @@ func extractPlanType(acct map[string]any) string { return "" } +// extractEntitlementExpiresAt 从 entitlement 中提取 expires_at。 +// 预期为 RFC3339 字符串格式,如 "2026-05-02T20:32:12+00:00"。 +func extractEntitlementExpiresAt(acct map[string]any) string { + entitlement, ok := acct["entitlement"].(map[string]any) + if !ok { + return "" + } + ea, _ := entitlement["expires_at"].(string) + return ea +} + func truncate(s string, n int) string { if len(s) <= n { return s diff --git a/backend/internal/service/ratelimit_service.go b/backend/internal/service/ratelimit_service.go index aa0ae200..4f5b57cc 100644 --- a/backend/internal/service/ratelimit_service.go +++ b/backend/internal/service/ratelimit_service.go @@ -161,6 +161,16 @@ func (s *RateLimitService) HandleUpstreamError(ctx context.Context, account *Acc shouldDisable = true break } + // OpenAI: {"detail":"Unauthorized"} 表示 token 完全无效(非标准 OpenAI 错误格式),直接标记 error + if account.Platform == PlatformOpenAI && gjson.GetBytes(responseBody, "detail").String() == "Unauthorized" { + msg := "Unauthorized (401): account authentication failed permanently" + if upstreamMsg != "" { + msg = "Unauthorized (401): " + upstreamMsg + } + s.handleAuthError(ctx, account, msg) + shouldDisable = true + break + } // OAuth 账号在 401 错误时临时不可调度(给 token 刷新窗口);非 OAuth 账号保持原有 SetError 行为。 // Antigravity 除外:其 401 由 applyErrorPolicy 的 temp_unschedulable_rules 自行控制。 if account.Type == AccountTypeOAuth && account.Platform != PlatformAntigravity { diff --git a/backend/internal/service/token_refresher.go b/backend/internal/service/token_refresher.go index 5a214161..6d732b9c 100644 --- a/backend/internal/service/token_refresher.go +++ b/backend/internal/service/token_refresher.go @@ -109,11 +109,11 @@ func (r *OpenAITokenRefresher) CanRefresh(account *Account) bool { } // NeedsRefresh 检查token是否需要刷新 -// 基于 expires_at 字段判断是否在刷新窗口内 +// expires_at 缺失且处于限流状态时需要刷新,防止限流期间 token 静默过期 func (r *OpenAITokenRefresher) NeedsRefresh(account *Account, refreshWindow time.Duration) bool { expiresAt := account.GetCredentialAsTime("expires_at") if expiresAt == nil { - return false + return account.IsRateLimited() } return time.Until(*expiresAt) < refreshWindow diff --git a/frontend/src/components/common/PlatformTypeBadge.vue b/frontend/src/components/common/PlatformTypeBadge.vue index 6faa29f1..05a3aaa3 100644 --- a/frontend/src/components/common/PlatformTypeBadge.vue +++ b/frontend/src/components/common/PlatformTypeBadge.vue @@ -45,6 +45,10 @@ {{ privacyBadge.label }} + +