From d0b8760eb956341caf3dd8c39018d05edb47edab Mon Sep 17 00:00:00 2001 From: infinityf4p <186150610+infinityf4p@users.noreply.github.com> Date: Thu, 2 Jul 2026 14:37:30 +0800 Subject: [PATCH] fix: preserve OpenAI plan type from inactive workspaces --- .../internal/service/openai_oauth_service.go | 10 ++- .../service/openai_privacy_service.go | 54 +++++++++++- .../service/openai_subscription_test.go | 85 +++++++++++++++++++ 3 files changed, 144 insertions(+), 5 deletions(-) diff --git a/backend/internal/service/openai_oauth_service.go b/backend/internal/service/openai_oauth_service.go index 11d462a04b..d4869e1487 100644 --- a/backend/internal/service/openai_oauth_service.go +++ b/backend/internal/service/openai_oauth_service.go @@ -270,7 +270,11 @@ func (s *OpenAIOAuthService) enrichTokenInfo(ctx context.Context, tokenInfo *Ope } } if info := fetchChatGPTAccountInfo(ctx, s.privacyClientFactory, tokenInfo.AccessToken, proxyURL, orgID); info != nil { - if info.PlanType != "" { + // chatgpt_plan_type from the ID token is the canonical personal-plan value. + // accounts/check is a multi-account/workspace endpoint; inactive team or + // business workspaces can otherwise overwrite Pro/Free with internal + // workspace billing plan names such as self_serve_business_usage_based. + if shouldApplyChatGPTAccountInfoPlanType(tokenInfo.PlanType, info.PlanType) { tokenInfo.PlanType = info.PlanType } if info.SubscriptionExpiresAt != "" { @@ -290,6 +294,10 @@ func (s *OpenAIOAuthService) enrichTokenInfo(ctx context.Context, tokenInfo *Ope tokenInfo.PrivacyMode = disableOpenAITraining(ctx, s.privacyClientFactory, tokenInfo.AccessToken, proxyURL) } +func shouldApplyChatGPTAccountInfoPlanType(current, candidate string) bool { + return strings.TrimSpace(candidate) != "" && strings.TrimSpace(current) == "" +} + func resolveChatGPTSubscriptionAccountID(tokenInfo *OpenAITokenInfo, orgID string) string { for _, candidate := range []string{ tokenInfo.ChatGPTAccountID, diff --git a/backend/internal/service/openai_privacy_service.go b/backend/internal/service/openai_privacy_service.go index 99cbb7267e..5c19cc4927 100644 --- a/backend/internal/service/openai_privacy_service.go +++ b/backend/internal/service/openai_privacy_service.go @@ -93,9 +93,10 @@ type ChatGPTAccountInfo struct { SubscriptionExpiresAt string // entitlement.expires_at (RFC3339) } -const chatGPTAccountsCheckURL = "https://chatgpt.com/backend-api/accounts/check/v4-2023-04-27" - -var chatGPTSubscriptionsURL = "https://chatgpt.com/backend-api/subscriptions" +var ( + chatGPTAccountsCheckURL = "https://chatgpt.com/backend-api/accounts/check/v4-2023-04-27" + chatGPTSubscriptionsURL = "https://chatgpt.com/backend-api/subscriptions" +) // 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). @@ -147,7 +148,9 @@ func fetchChatGPTAccountInfo(ctx context.Context, clientFactory PrivacyClientFac if orgID != "" { if acctRaw, ok := accounts[orgID]; ok { if acct, ok := acctRaw.(map[string]any); ok { - fillAccountInfo(info, acct) + if isUsableChatGPTAccountCandidate(acct, time.Now()) { + fillAccountInfo(info, acct) + } } } } @@ -164,6 +167,9 @@ func fetchChatGPTAccountInfo(ctx context.Context, clientFactory PrivacyClientFac if !ok { continue } + if !isUsableChatGPTAccountCandidate(acct, time.Now()) { + continue + } planType := extractPlanType(acct) if planType == "" { continue @@ -278,6 +284,46 @@ func extractPlanType(acct map[string]any) string { return "" } +func isUsableChatGPTAccountCandidate(acct map[string]any, now time.Time) bool { + if acct == nil || hasChatGPTAccountDeactivatedMarker(acct) { + return false + } + if account, ok := acct["account"].(map[string]any); ok && hasChatGPTAccountDeactivatedMarker(account) { + return false + } + + expiresAt := extractEntitlementExpiresAt(acct) + if expiresAt == "" { + return true + } + expiry, err := time.Parse(time.RFC3339, expiresAt) + if err != nil { + return true + } + return expiry.After(now) +} + +func hasChatGPTAccountDeactivatedMarker(obj map[string]any) bool { + for _, key := range []string{"deactivated", "is_deactivated", "disabled", "is_disabled"} { + if value, ok := obj[key].(bool); ok && value { + return true + } + } + for _, key := range []string{"deactivated_at", "disabled_at", "deleted_at"} { + if value, ok := obj[key].(string); ok && strings.TrimSpace(value) != "" { + return true + } + } + for _, key := range []string{"status", "state"} { + value, _ := obj[key].(string) + switch strings.ToLower(strings.TrimSpace(value)) { + case "deactivated", "disabled", "deleted", "inactive", "suspended": + return true + } + } + return false +} + // extractEntitlementExpiresAt 从 entitlement 中提取 expires_at。 // 预期为 RFC3339 字符串格式,如 "2026-05-02T20:32:12+00:00"。 func extractEntitlementExpiresAt(acct map[string]any) string { diff --git a/backend/internal/service/openai_subscription_test.go b/backend/internal/service/openai_subscription_test.go index 89df54dbac..86bb243504 100644 --- a/backend/internal/service/openai_subscription_test.go +++ b/backend/internal/service/openai_subscription_test.go @@ -40,3 +40,88 @@ func TestFetchChatGPTSubscriptionExpiresAt(t *testing.T) { require.Equal(t, wantExpiresAt, got) } + +func TestFetchChatGPTAccountInfo_SkipsExpiredWorkspaceCandidate(t *testing.T) { + expiredAt := time.Now().Add(-24 * time.Hour).UTC().Format(time.RFC3339) + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + require.Equal(t, "/backend-api/accounts/check/v4-2023-04-27", r.URL.Path) + require.Equal(t, "Bearer access-token", r.Header.Get("Authorization")) + + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{ + "accounts": map[string]any{ + "org-expired-workspace": map[string]any{ + "account": map[string]any{ + "plan_type": "self_serve_business_usage_based", + "is_default": true, + }, + "entitlement": map[string]any{ + "expires_at": expiredAt, + }, + }, + "personal-account": map[string]any{ + "account": map[string]any{ + "plan_type": "free", + }, + }, + }, + }) + })) + defer server.Close() + + oldURL := chatGPTAccountsCheckURL + chatGPTAccountsCheckURL = server.URL + "/backend-api/accounts/check/v4-2023-04-27" + t.Cleanup(func() { chatGPTAccountsCheckURL = oldURL }) + + got := fetchChatGPTAccountInfo(context.Background(), func(proxyURL string) (*req.Client, error) { + return req.C().SetTimeout(5 * time.Second), nil + }, "access-token", "", "org-expired-workspace") + + require.NotNil(t, got) + require.Equal(t, "free", got.PlanType) + require.Empty(t, got.SubscriptionExpiresAt) +} + +func TestFetchChatGPTAccountInfo_SkipsDeactivatedWorkspaceCandidate(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + require.Equal(t, "/backend-api/accounts/check/v4-2023-04-27", r.URL.Path) + + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{ + "accounts": map[string]any{ + "org-deactivated-workspace": map[string]any{ + "account": map[string]any{ + "plan_type": "self_serve_business_usage_based", + "is_default": true, + "is_deactivated": true, + }, + }, + "personal-account": map[string]any{ + "account": map[string]any{ + "plan_type": "pro", + }, + }, + }, + }) + })) + defer server.Close() + + oldURL := chatGPTAccountsCheckURL + chatGPTAccountsCheckURL = server.URL + "/backend-api/accounts/check/v4-2023-04-27" + t.Cleanup(func() { chatGPTAccountsCheckURL = oldURL }) + + got := fetchChatGPTAccountInfo(context.Background(), func(proxyURL string) (*req.Client, error) { + return req.C().SetTimeout(5 * time.Second), nil + }, "access-token", "", "org-deactivated-workspace") + + require.NotNil(t, got) + require.Equal(t, "pro", got.PlanType) +} + +func TestShouldApplyChatGPTAccountInfoPlanType(t *testing.T) { + require.False(t, shouldApplyChatGPTAccountInfoPlanType("pro", "self_serve_business_usage_based")) + require.False(t, shouldApplyChatGPTAccountInfoPlanType("free", "team")) + require.False(t, shouldApplyChatGPTAccountInfoPlanType("", "")) + require.True(t, shouldApplyChatGPTAccountInfoPlanType("", "pro")) +}