diff --git a/backend/internal/service/openai_oauth_service.go b/backend/internal/service/openai_oauth_service.go index 5c071168..39e27b62 100644 --- a/backend/internal/service/openai_oauth_service.go +++ b/backend/internal/service/openai_oauth_service.go @@ -137,8 +137,9 @@ type OpenAITokenInfo struct { 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"` + 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,32 +262,40 @@ func (s *OpenAIOAuthService) RefreshTokenWithClientID(ctx context.Context, refre tokenInfo.PlanType = userInfo.PlanType } - // 每次刷新都通过 ChatGPT backend-api 获取最新的 plan_type, - // 因为账号订阅类型可能每月变化,id_token 中的值是签发时的快照,不一定反映当前状态。 - if 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 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. @@ -568,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 b34c1da2..da6dbefc 100644 --- a/backend/internal/service/openai_privacy_service.go +++ b/backend/internal/service/openai_privacy_service.go @@ -88,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" @@ -142,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 { @@ -159,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 } } @@ -187,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 @@ -219,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/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 }} + +