mirror of
https://github.com/Wei-Shaw/sub2api.git
synced 2026-10-07 12:57:57 +08:00
fix(openai-oauth): 个人订阅到期时间不再被 POID workspace 的 entitlement 覆盖
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
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user