From 358e4a89a1803e2e8d6fe8da9e24402e0f7a7518 Mon Sep 17 00:00:00 2001 From: li Date: Mon, 10 Aug 2026 09:29:14 +0800 Subject: [PATCH] =?UTF-8?q?fix(openai-oauth):=20=E4=B8=AA=E4=BA=BA?= =?UTF-8?q?=E8=AE=A2=E9=98=85=E5=88=B0=E6=9C=9F=E6=97=B6=E9=97=B4=E4=B8=8D?= =?UTF-8?q?=E5=86=8D=E8=A2=AB=20POID=20workspace=20=E7=9A=84=20entitlement?= =?UTF-8?q?=20=E8=A6=86=E7=9B=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit enrichTokenInfo 里 plan_type 与 subscription_expires_at 的取值口径不一致: - plan_type 有 shouldApplyChatGPTAccountInfoPlanType 护着(#3641), id_token 里的个人套餐优先,accounts/check 只在个人值为空时补位; - subscription_expires_at 却是无条件覆盖。 accounts/check 是多账号/工作区端点,命中的记录由 access_token JWT 的 poid 决定。 当 poid 指向默认 Personal workspace、而 chatgpt_account_id 指向个人 ChatGPT 账号时 (两者是不同标识),拿到的 entitlement.expires_at 描述的是 workspace 权益。 配上被保护下来的个人 plan_type,账号页就显示成「个人 Pro + workspace 到期时间」。 代码里本来已经有正确的来源——用个人 chatgpt_account_id 查 /backend-api/subscriptions 的 active_until——但它被 `if TrimSpace(SubscriptionExpiresAt) == ""` 挡着,workspace 值一旦写进去就永远不触发。 改为让两个字段始终描述同一份订阅: - 套餐取自 accounts/check 时(id_token 没带 chatgpt_plan_type),到期时间跟着取 同一条记录,行为不变; - 套餐保留了 id_token 的个人值时,只有该记录确实属于个人账号才用它的 entitlement.expires_at;不属于就跳过,并强制回落到个人订阅端点。 为此给 ChatGPTAccountInfo 补 AccountID:优先读 account.account_id,缺失时退回 accounts 的 map key(key 可能是 "default" 这类别名)。两侧任一缺 ID 时判定为 无法区分并沿用旧行为,poid == chatgpt_account_id 的单人账号完全不受影响。 Fixes #5459 --- .../internal/service/openai_oauth_service.go | 30 ++- .../service/openai_privacy_service.go | 43 +++- .../service/openai_subscription_test.go | 219 ++++++++++++++++++ 3 files changed, 277 insertions(+), 15 deletions(-) diff --git a/backend/internal/service/openai_oauth_service.go b/backend/internal/service/openai_oauth_service.go index d4869e1487..5eef594e33 100644 --- a/backend/internal/service/openai_oauth_service.go +++ b/backend/internal/service/openai_oauth_service.go @@ -269,22 +269,35 @@ func (s *OpenAIOAuthService) enrichTokenInfo(ctx context.Context, tokenInfo *Ope orgID = atClaims.OpenAIAuth.POID } } + // accounts/check 命中的记录不属于个人账号时,必须改用个人订阅端点拿到期时间, + // 否则会把 workspace 权益的 expires_at 当成个人订阅到期日展示。 + forcePersonalSubscriptionLookup := false if info := fetchChatGPTAccountInfo(ctx, s.privacyClientFactory, tokenInfo.AccessToken, proxyURL, orgID); info != nil { // 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) { + appliedAccountInfoPlanType := shouldApplyChatGPTAccountInfoPlanType(tokenInfo.PlanType, info.PlanType) + if appliedAccountInfoPlanType { tokenInfo.PlanType = info.PlanType } + // plan_type 与 subscription_expires_at 必须描述同一份订阅。套餐取自 + // accounts/check 时,到期时间跟着取同一条记录;套餐保留了 JWT 里的个人值时, + // 只有该记录确实就是个人账号才能用它的 entitlement.expires_at——poid 指向的 + // 默认 Personal workspace 与 chatgpt_account_id 可以是两个不同的标识, + // 混用会显示成「个人 Pro + workspace 到期时间」。 if info.SubscriptionExpiresAt != "" { - tokenInfo.SubscriptionExpiresAt = info.SubscriptionExpiresAt + if appliedAccountInfoPlanType || chatGPTAccountInfoBelongsToTokenAccount(tokenInfo, info) { + tokenInfo.SubscriptionExpiresAt = info.SubscriptionExpiresAt + } else { + forcePersonalSubscriptionLookup = true + } } if tokenInfo.Email == "" && info.Email != "" { tokenInfo.Email = info.Email } } - if strings.TrimSpace(tokenInfo.SubscriptionExpiresAt) == "" { + if forcePersonalSubscriptionLookup || strings.TrimSpace(tokenInfo.SubscriptionExpiresAt) == "" { if expiresAt := fetchChatGPTSubscriptionExpiresAt(ctx, s.privacyClientFactory, tokenInfo.AccessToken, proxyURL, resolveChatGPTSubscriptionAccountID(tokenInfo, orgID)); expiresAt != "" { tokenInfo.SubscriptionExpiresAt = expiresAt } @@ -298,6 +311,17 @@ func shouldApplyChatGPTAccountInfoPlanType(current, candidate string) bool { return strings.TrimSpace(candidate) != "" && strings.TrimSpace(current) == "" } +// chatGPTAccountInfoBelongsToTokenAccount 判断 accounts/check 命中的那条记录是不是 +// token 自己的个人 ChatGPT 账号。两侧任一缺 ID 时无法区分,返回 true 保持既有行为。 +func chatGPTAccountInfoBelongsToTokenAccount(tokenInfo *OpenAITokenInfo, info *ChatGPTAccountInfo) bool { + personalID := strings.TrimSpace(tokenInfo.ChatGPTAccountID) + sourceID := strings.TrimSpace(info.AccountID) + if personalID == "" || sourceID == "" { + return true + } + return strings.EqualFold(personalID, sourceID) +} + 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 5c19cc4927..192843346b 100644 --- a/backend/internal/service/openai_privacy_service.go +++ b/backend/internal/service/openai_privacy_service.go @@ -88,8 +88,12 @@ 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 + // AccountID 是本条信息所属账号的标识(优先取 account.account_id,否则取 accounts + // 的 map key)。accounts/check 是多账号/工作区端点,调用方需要据此判断拿到的 + // plan_type / expires_at 到底属于个人账号还是某个 workspace。 + AccountID string SubscriptionExpiresAt string // entitlement.expires_at (RFC3339) } @@ -149,7 +153,7 @@ func fetchChatGPTAccountInfo(ctx context.Context, clientFactory PrivacyClientFac if acctRaw, ok := accounts[orgID]; ok { if acct, ok := acctRaw.(map[string]any); ok { if isUsableChatGPTAccountCandidate(acct, time.Now()) { - fillAccountInfo(info, acct) + fillAccountInfo(info, acct, orgID) } } } @@ -160,9 +164,10 @@ func fetchChatGPTAccountInfo(ctx context.Context, clientFactory PrivacyClientFac type candidate struct { planType string expiresAt string + accountID string } var defaultC, paidC, anyC candidate - for _, acctRaw := range accounts { + for key, acctRaw := range accounts { acct, ok := acctRaw.(map[string]any) if !ok { continue @@ -175,26 +180,27 @@ func fetchChatGPTAccountInfo(ctx context.Context, clientFactory PrivacyClientFac continue } ea := extractEntitlementExpiresAt(acct) + id := chatGPTAccountObjectID(acct, key) if anyC.planType == "" { - anyC = candidate{planType, ea} + anyC = candidate{planType, ea, id} } if account, ok := acct["account"].(map[string]any); ok { if isDefault, _ := account["is_default"].(bool); isDefault { - defaultC = candidate{planType, ea} + defaultC = candidate{planType, ea, id} } } if !strings.EqualFold(planType, "free") && paidC.planType == "" { - paidC = candidate{planType, ea} + paidC = candidate{planType, ea, id} } } // 优先级:default > 非 free > 任意 switch { case defaultC.planType != "": - info.PlanType, info.SubscriptionExpiresAt = defaultC.planType, defaultC.expiresAt + info.PlanType, info.SubscriptionExpiresAt, info.AccountID = defaultC.planType, defaultC.expiresAt, defaultC.accountID case paidC.planType != "": - info.PlanType, info.SubscriptionExpiresAt = paidC.planType, paidC.expiresAt + info.PlanType, info.SubscriptionExpiresAt, info.AccountID = paidC.planType, paidC.expiresAt, paidC.accountID default: - info.PlanType, info.SubscriptionExpiresAt = anyC.planType, anyC.expiresAt + info.PlanType, info.SubscriptionExpiresAt, info.AccountID = anyC.planType, anyC.expiresAt, anyC.accountID } } @@ -263,10 +269,23 @@ func fetchChatGPTSubscriptionExpiresAt(ctx context.Context, clientFactory Privac return activeUntil } -// fillAccountInfo 从单个 account 对象中提取 plan_type 和 subscription_expires_at -func fillAccountInfo(info *ChatGPTAccountInfo, acct map[string]any) { +// fillAccountInfo 从单个 account 对象中提取 plan_type 和 subscription_expires_at。 +// fallbackID 是该对象在 accounts 里的 map key,用于 account.account_id 缺失时兜底。 +func fillAccountInfo(info *ChatGPTAccountInfo, acct map[string]any, fallbackID string) { info.PlanType = extractPlanType(acct) info.SubscriptionExpiresAt = extractEntitlementExpiresAt(acct) + info.AccountID = chatGPTAccountObjectID(acct, fallbackID) +} + +// chatGPTAccountObjectID 取单个 account 对象的账号标识。 +// accounts 的 map key 有时是 "default" 这类别名,所以优先读 account.account_id。 +func chatGPTAccountObjectID(acct map[string]any, fallbackID string) string { + if account, ok := acct["account"].(map[string]any); ok { + if id, ok := account["account_id"].(string); ok && strings.TrimSpace(id) != "" { + return strings.TrimSpace(id) + } + } + return strings.TrimSpace(fallbackID) } // extractPlanType 从单个 account 对象中提取 plan_type diff --git a/backend/internal/service/openai_subscription_test.go b/backend/internal/service/openai_subscription_test.go index 86bb243504..17c6f789df 100644 --- a/backend/internal/service/openai_subscription_test.go +++ b/backend/internal/service/openai_subscription_test.go @@ -125,3 +125,222 @@ func TestShouldApplyChatGPTAccountInfoPlanType(t *testing.T) { require.False(t, shouldApplyChatGPTAccountInfoPlanType("", "")) require.True(t, shouldApplyChatGPTAccountInfoPlanType("", "pro")) } + +func TestChatGPTAccountInfoBelongsToTokenAccount(t *testing.T) { + require.False(t, chatGPTAccountInfoBelongsToTokenAccount( + &OpenAITokenInfo{ChatGPTAccountID: "personal-a"}, &ChatGPTAccountInfo{AccountID: "workspace-b"})) + require.True(t, chatGPTAccountInfoBelongsToTokenAccount( + &OpenAITokenInfo{ChatGPTAccountID: "personal-a"}, &ChatGPTAccountInfo{AccountID: "PERSONAL-A"})) + // 任一侧缺 ID 时无法区分,保持既有行为(采用 accounts/check 的值)。 + require.True(t, chatGPTAccountInfoBelongsToTokenAccount( + &OpenAITokenInfo{}, &ChatGPTAccountInfo{AccountID: "workspace-b"})) + require.True(t, chatGPTAccountInfoBelongsToTokenAccount( + &OpenAITokenInfo{ChatGPTAccountID: "personal-a"}, &ChatGPTAccountInfo{})) +} + +// accounts 的 map key 可能是 "default" 这类别名,account.account_id 才是账号标识。 +func TestFetchChatGPTAccountInfo_ReportsAccountID(t *testing.T) { + futureAt := time.Now().Add(720 * time.Hour).UTC().Format(time.RFC3339) + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{ + "accounts": map[string]any{ + "default": map[string]any{ + "account": map[string]any{ + "account_id": "personal-account-a", + "plan_type": "plus", + "is_default": true, + }, + "entitlement": map[string]any{"expires_at": futureAt}, + }, + }, + }) + })) + 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(), newTestPrivacyClientFactory(), "access-token", "", "") + require.NotNil(t, got) + require.Equal(t, "plus", got.PlanType) + require.Equal(t, "personal-account-a", got.AccountID, "应优先取 account.account_id 而不是 map key") +} + +// issue #5459:poid 指向的默认 Personal workspace 与 chatgpt_account_id 是两个不同的 +// 标识时,accounts/check 返回的是 workspace 的 entitlement.expires_at。plan_type 那侧 +// 已被 shouldApplyChatGPTAccountInfoPlanType 挡住(保留 JWT 里的个人套餐),到期时间 +// 这侧原先无条件覆盖,于是显示成「个人 Pro + workspace 到期时间」。 +func TestEnrichTokenInfo_WorkspaceEntitlementDoesNotOverridePersonalSubscription(t *testing.T) { + const ( + personalAccountID = "personal-account-a" + workspaceAccountID = "personal-workspace-b" + personalActiveUntil = "2027-03-01T00:00:00Z" + ) + workspaceExpiresAt := time.Now().Add(720 * time.Hour).UTC().Format(time.RFC3339) + + subscriptionCalls := 0 + server := newChatGPTBackendTestServer(t, chatGPTBackendTestServerConfig{ + accountsCheck: map[string]any{ + "accounts": map[string]any{ + workspaceAccountID: map[string]any{ + "account": map[string]any{ + "account_id": workspaceAccountID, + "plan_type": "pro", + "is_default": true, + }, + "entitlement": map[string]any{"expires_at": workspaceExpiresAt}, + }, + }, + }, + onSubscription: func(accountID string) map[string]any { + subscriptionCalls++ + require.Equal(t, personalAccountID, accountID, + "必须用个人 chatgpt_account_id 查订阅,而不是 poid workspace") + return map[string]any{"plan_type": "pro", "active_until": personalActiveUntil, "will_renew": true} + }, + }) + defer server.Close() + + tokenInfo := &OpenAITokenInfo{ + AccessToken: "access-token", + ChatGPTAccountID: personalAccountID, + OrganizationID: workspaceAccountID, + PlanType: "pro", // 来自 id_token 的个人套餐 + } + svc := &OpenAIOAuthService{privacyClientFactory: newTestPrivacyClientFactory()} + svc.enrichTokenInfo(context.Background(), tokenInfo, "") + + require.Equal(t, "pro", tokenInfo.PlanType) + require.Equal(t, personalActiveUntil, tokenInfo.SubscriptionExpiresAt, + "到期时间必须来自个人订阅 active_until,不能是 workspace 的 entitlement.expires_at") + require.NotEqual(t, workspaceExpiresAt, tokenInfo.SubscriptionExpiresAt) + require.Equal(t, 1, subscriptionCalls) +} + +// 单个人账号(poid == chatgpt_account_id)是绝大多数情况,行为必须保持不变: +// 直接用 accounts/check 的 entitlement,不额外打订阅端点。 +func TestEnrichTokenInfo_KeepsEntitlementWhenAccountMatches(t *testing.T) { + const personalAccountID = "personal-account-a" + entitlementExpiresAt := time.Now().Add(720 * time.Hour).UTC().Format(time.RFC3339) + + subscriptionCalls := 0 + server := newChatGPTBackendTestServer(t, chatGPTBackendTestServerConfig{ + accountsCheck: map[string]any{ + "accounts": map[string]any{ + personalAccountID: map[string]any{ + "account": map[string]any{ + "account_id": personalAccountID, + "plan_type": "plus", + "is_default": true, + }, + "entitlement": map[string]any{"expires_at": entitlementExpiresAt}, + }, + }, + }, + onSubscription: func(string) map[string]any { + subscriptionCalls++ + return map[string]any{} + }, + }) + defer server.Close() + + tokenInfo := &OpenAITokenInfo{ + AccessToken: "access-token", + ChatGPTAccountID: personalAccountID, + OrganizationID: personalAccountID, + PlanType: "plus", + } + svc := &OpenAIOAuthService{privacyClientFactory: newTestPrivacyClientFactory()} + svc.enrichTokenInfo(context.Background(), tokenInfo, "") + + require.Equal(t, entitlementExpiresAt, tokenInfo.SubscriptionExpiresAt) + require.Zero(t, subscriptionCalls, "账号一致时不应额外请求订阅端点") +} + +// 反向不变式:套餐本身就取自 accounts/check(JWT 没有 plan_type)时,到期时间必须 +// 跟着取同一条记录,否则会变成「workspace 套餐 + 个人到期时间」的另一种错配。 +func TestEnrichTokenInfo_WorkspacePlanTypeKeepsItsOwnExpiry(t *testing.T) { + const workspaceAccountID = "workspace-b" + workspaceExpiresAt := time.Now().Add(720 * time.Hour).UTC().Format(time.RFC3339) + + subscriptionCalls := 0 + server := newChatGPTBackendTestServer(t, chatGPTBackendTestServerConfig{ + accountsCheck: map[string]any{ + "accounts": map[string]any{ + workspaceAccountID: map[string]any{ + "account": map[string]any{ + "account_id": workspaceAccountID, + "plan_type": "self_serve_business_usage_based", + "is_default": true, + }, + "entitlement": map[string]any{"expires_at": workspaceExpiresAt}, + }, + }, + }, + onSubscription: func(string) map[string]any { + subscriptionCalls++ + return map[string]any{} + }, + }) + defer server.Close() + + tokenInfo := &OpenAITokenInfo{ + AccessToken: "access-token", + ChatGPTAccountID: "personal-account-a", + OrganizationID: workspaceAccountID, + // id_token 没带 chatgpt_plan_type + } + svc := &OpenAIOAuthService{privacyClientFactory: newTestPrivacyClientFactory()} + svc.enrichTokenInfo(context.Background(), tokenInfo, "") + + require.Equal(t, "self_serve_business_usage_based", tokenInfo.PlanType) + require.Equal(t, workspaceExpiresAt, tokenInfo.SubscriptionExpiresAt, + "套餐与到期时间必须来自同一条记录") + require.Zero(t, subscriptionCalls) +} + +type chatGPTBackendTestServerConfig struct { + accountsCheck map[string]any + onSubscription func(accountID string) map[string]any +} + +// newChatGPTBackendTestServer 同时接管 accounts/check 与 subscriptions 两个端点, +// 并在 t.Cleanup 里还原包级 URL 变量。 +func newChatGPTBackendTestServer(t *testing.T, cfg chatGPTBackendTestServerConfig) *httptest.Server { + t.Helper() + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + switch r.URL.Path { + case "/backend-api/accounts/check/v4-2023-04-27": + _ = json.NewEncoder(w).Encode(cfg.accountsCheck) + case "/backend-api/subscriptions": + body := map[string]any{} + if cfg.onSubscription != nil { + body = cfg.onSubscription(r.URL.Query().Get("account_id")) + } + _ = json.NewEncoder(w).Encode(body) + default: + w.WriteHeader(http.StatusNotFound) + } + })) + + oldAccounts, oldSubscriptions := chatGPTAccountsCheckURL, chatGPTSubscriptionsURL + chatGPTAccountsCheckURL = server.URL + "/backend-api/accounts/check/v4-2023-04-27" + chatGPTSubscriptionsURL = server.URL + "/backend-api/subscriptions" + t.Cleanup(func() { + chatGPTAccountsCheckURL, chatGPTSubscriptionsURL = oldAccounts, oldSubscriptions + }) + return server +} + +// enrichTokenInfo 收尾还会调用 disableOpenAITraining,它的 URL 是常量、指向真实 +// chatgpt.com,测试无法接管。给客户端一个短超时让它快速失败——该调用只写 +// PrivacyMode,不影响本组用例的断言。 +func newTestPrivacyClientFactory() PrivacyClientFactory { + return func(string) (*req.Client, error) { + return req.C().SetTimeout(time.Second), nil + } +}