From 91b1d812ceb1197f484976c0dd7c92f12eda8cb4 Mon Sep 17 00:00:00 2001 From: QTom Date: Tue, 24 Mar 2026 14:39:33 +0800 Subject: [PATCH] =?UTF-8?q?feat(openai):=20Mobile=20RT=20=E8=A1=A5?= =?UTF-8?q?=E5=85=A8=20plan=5Ftype=E3=80=81=E7=B2=BE=E7=A1=AE=E5=8C=B9?= =?UTF-8?q?=E9=85=8D=E8=B4=A6=E5=8F=B7=E3=80=81=E5=88=B7=E6=96=B0=E6=97=B6?= =?UTF-8?q?=E8=87=AA=E5=8A=A8=E8=AE=BE=E7=BD=AE=E9=9A=90=E7=A7=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 1. accounts/check 补全 plan_type:当 id_token 缺少 plan_type(如 Mobile RT), 自动调用 accounts/check 端点获取订阅类型 2. orgID 精确匹配账号:从 JWT 提取 poid 匹配正确账号,避免 Go map 遍历顺序随机导致 plan_type 不稳定 3. RT 刷新时设置隐私:调用 disableOpenAITraining 关闭训练数据共享, 结果存入 extra.privacy_mode,后续跳过重复设置 Co-Authored-By: Claude Opus 4.6 (1M context) --- backend/cmd/server/wire_gen.go | 1 + backend/internal/pkg/openai/oauth.go | 1 + .../internal/service/openai_oauth_service.go | 38 ++++- .../service/openai_privacy_service.go | 133 ++++++++++++++++++ frontend/src/composables/useOpenAIOAuth.ts | 4 + 5 files changed, 174 insertions(+), 3 deletions(-) diff --git a/backend/cmd/server/wire_gen.go b/backend/cmd/server/wire_gen.go index 63c5ed0e..2c1ac5b0 100644 --- a/backend/cmd/server/wire_gen.go +++ b/backend/cmd/server/wire_gen.go @@ -114,6 +114,7 @@ func initializeApplication(buildInfo handler.BuildInfo) (*Application, error) { oAuthService := service.NewOAuthService(proxyRepository, claudeOAuthClient) openAIOAuthClient := repository.NewOpenAIOAuthClient() openAIOAuthService := service.NewOpenAIOAuthService(proxyRepository, openAIOAuthClient) + openAIOAuthService.SetPrivacyClientFactory(privacyClientFactory) geminiOAuthClient := repository.NewGeminiOAuthClient(configConfig) geminiCliCodeAssistClient := repository.NewGeminiCliCodeAssistClient() driveClient := repository.NewGeminiDriveClient() diff --git a/backend/internal/pkg/openai/oauth.go b/backend/internal/pkg/openai/oauth.go index a35a5ea6..6b8521bd 100644 --- a/backend/internal/pkg/openai/oauth.go +++ b/backend/internal/pkg/openai/oauth.go @@ -270,6 +270,7 @@ type OpenAIAuthClaims struct { ChatGPTUserID string `json:"chatgpt_user_id"` ChatGPTPlanType string `json:"chatgpt_plan_type"` UserID string `json:"user_id"` + POID string `json:"poid"` // organization ID in access_token JWT Organizations []OrganizationClaim `json:"organizations"` } diff --git a/backend/internal/service/openai_oauth_service.go b/backend/internal/service/openai_oauth_service.go index bd82e107..0a1266d9 100644 --- a/backend/internal/service/openai_oauth_service.go +++ b/backend/internal/service/openai_oauth_service.go @@ -29,9 +29,10 @@ type soraSessionChunk struct { // OpenAIOAuthService handles OpenAI OAuth authentication flows type OpenAIOAuthService struct { - sessionStore *openai.SessionStore - proxyRepo ProxyRepository - oauthClient OpenAIOAuthClient + sessionStore *openai.SessionStore + proxyRepo ProxyRepository + oauthClient OpenAIOAuthClient + privacyClientFactory PrivacyClientFactory // 用于调用 chatgpt.com/backend-api(ImpersonateChrome) } // NewOpenAIOAuthService creates a new OpenAI OAuth service @@ -43,6 +44,12 @@ func NewOpenAIOAuthService(proxyRepo ProxyRepository, oauthClient OpenAIOAuthCli } } +// SetPrivacyClientFactory 注入 ImpersonateChrome 客户端工厂, +// 用于调用 chatgpt.com/backend-api 获取账号信息(plan_type 等)。 +func (s *OpenAIOAuthService) SetPrivacyClientFactory(factory PrivacyClientFactory) { + s.privacyClientFactory = factory +} + // OpenAIAuthURLResult contains the authorization URL and session info type OpenAIAuthURLResult struct { AuthURL string `json:"auth_url"` @@ -131,6 +138,7 @@ type OpenAITokenInfo struct { 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"` } // ExchangeCode exchanges authorization code for tokens @@ -251,6 +259,30 @@ 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 + } + } + 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 + } + } + } + + // 尝试设置隐私(关闭训练数据共享),best-effort + if tokenInfo.AccessToken != "" && s.privacyClientFactory != nil { + tokenInfo.PrivacyMode = disableOpenAITraining(ctx, s.privacyClientFactory, tokenInfo.AccessToken, proxyURL) + } + return tokenInfo, nil } diff --git a/backend/internal/service/openai_privacy_service.go b/backend/internal/service/openai_privacy_service.go index 90cd522d..d5966006 100644 --- a/backend/internal/service/openai_privacy_service.go +++ b/backend/internal/service/openai_privacy_service.go @@ -69,6 +69,139 @@ func disableOpenAITraining(ctx context.Context, clientFactory PrivacyClientFacto return PrivacyModeTrainingOff } +// ChatGPTAccountInfo 从 chatgpt.com/backend-api/accounts/check 获取的账号信息 +type ChatGPTAccountInfo struct { + PlanType string + Email string +} + +const chatGPTAccountsCheckURL = "https://chatgpt.com/backend-api/accounts/check/v4-2023-04-27" + +// fetchChatGPTAccountInfo calls ChatGPT backend-api to get account info (plan_type, etc.). +// Used as fallback when id_token doesn't contain these fields (e.g., Mobile RT). +// orgID is used to match the correct account when multiple accounts exist (e.g., personal + team). +// Returns nil on any failure (best-effort, non-blocking). +func fetchChatGPTAccountInfo(ctx context.Context, clientFactory PrivacyClientFactory, accessToken, proxyURL, orgID string) *ChatGPTAccountInfo { + if accessToken == "" || clientFactory == nil { + return nil + } + + ctx, cancel := context.WithTimeout(ctx, 15*time.Second) + defer cancel() + + client, err := clientFactory(proxyURL) + if err != nil { + slog.Debug("chatgpt_account_check_client_error", "error", err.Error()) + return nil + } + + var result map[string]any + resp, err := client.R(). + SetContext(ctx). + SetHeader("Authorization", "Bearer "+accessToken). + SetHeader("Origin", "https://chatgpt.com"). + SetHeader("Referer", "https://chatgpt.com/"). + SetHeader("Accept", "application/json"). + SetSuccessResult(&result). + Get(chatGPTAccountsCheckURL) + + if err != nil { + slog.Debug("chatgpt_account_check_request_error", "error", err.Error()) + return nil + } + + if !resp.IsSuccessState() { + slog.Debug("chatgpt_account_check_failed", "status", resp.StatusCode, "body", truncate(resp.String(), 200)) + return nil + } + + info := &ChatGPTAccountInfo{} + + accounts, ok := result["accounts"].(map[string]any) + if !ok { + slog.Debug("chatgpt_account_check_no_accounts", "body", truncate(resp.String(), 300)) + return nil + } + + // 优先匹配 orgID 对应的账号(access_token JWT 中的 poid) + if orgID != "" { + if matched := extractPlanFromAccount(accounts, orgID); matched != "" { + info.PlanType = matched + } + } + + // 未匹配到时,遍历所有账号:优先 is_default,次选非 free + if info.PlanType == "" { + var defaultPlan, paidPlan, anyPlan string + for _, acctRaw := range accounts { + acct, ok := acctRaw.(map[string]any) + if !ok { + continue + } + planType := extractPlanType(acct) + if planType == "" { + continue + } + if anyPlan == "" { + anyPlan = planType + } + if account, ok := acct["account"].(map[string]any); ok { + if isDefault, _ := account["is_default"].(bool); isDefault { + defaultPlan = planType + } + } + if !strings.EqualFold(planType, "free") && paidPlan == "" { + paidPlan = planType + } + } + // 优先级:default > 非 free > 任意 + switch { + case defaultPlan != "": + info.PlanType = defaultPlan + case paidPlan != "": + info.PlanType = paidPlan + default: + info.PlanType = anyPlan + } + } + + if info.PlanType == "" { + slog.Debug("chatgpt_account_check_no_plan_type", "body", truncate(resp.String(), 300)) + return nil + } + + slog.Info("chatgpt_account_check_success", "plan_type", info.PlanType, "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) +} + +// extractPlanType 从单个 account 对象中提取 plan_type +func extractPlanType(acct map[string]any) string { + if account, ok := acct["account"].(map[string]any); ok { + if planType, ok := account["plan_type"].(string); ok && planType != "" { + return planType + } + } + if entitlement, ok := acct["entitlement"].(map[string]any); ok { + if subPlan, ok := entitlement["subscription_plan"].(string); ok && subPlan != "" { + return subPlan + } + } + return "" +} + func truncate(s string, n int) string { if len(s) <= n { return s diff --git a/frontend/src/composables/useOpenAIOAuth.ts b/frontend/src/composables/useOpenAIOAuth.ts index b3a8ed87..adea5646 100644 --- a/frontend/src/composables/useOpenAIOAuth.ts +++ b/frontend/src/composables/useOpenAIOAuth.ts @@ -14,6 +14,7 @@ export interface OpenAITokenInfo { email?: string name?: string plan_type?: string + privacy_mode?: string // OpenAI specific IDs (extracted from ID Token) chatgpt_account_id?: string chatgpt_user_id?: string @@ -231,6 +232,9 @@ export function useOpenAIOAuth(options?: UseOpenAIOAuthOptions) { if (tokenInfo.name) { extra.name = tokenInfo.name } + if (tokenInfo.privacy_mode) { + extra.privacy_mode = tokenInfo.privacy_mode + } return Object.keys(extra).length > 0 ? extra : undefined }