Files
sub2api/backend/internal/service/openai_privacy_service.go
T
li 358e4a89a1 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
2026-08-10 09:29:14 +08:00

363 lines
12 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package service
import (
"context"
"fmt"
"log/slog"
"strings"
"time"
"github.com/imroc/req/v3"
)
// PrivacyClientFactory creates an HTTP client for privacy API calls.
// Injected from repository layer to avoid import cycles.
type PrivacyClientFactory func(proxyURL string) (*req.Client, error)
const (
openAISettingsURL = "https://chatgpt.com/backend-api/settings/account_user_setting"
PrivacyModeTrainingOff = "training_off"
PrivacyModeFailed = "training_set_failed"
PrivacyModeCFBlocked = "training_set_cf_blocked"
)
func shouldSkipOpenAIPrivacyEnsure(extra map[string]any) bool {
if extra == nil {
return false
}
raw, ok := extra["privacy_mode"]
if !ok {
return false
}
mode, _ := raw.(string)
mode = strings.TrimSpace(mode)
return mode != PrivacyModeFailed && mode != PrivacyModeCFBlocked
}
// disableOpenAITraining calls ChatGPT settings API to turn off "Improve the model for everyone".
// Returns privacy_mode value: "training_off" on success, "cf_blocked" / "failed" on failure.
func disableOpenAITraining(ctx context.Context, clientFactory PrivacyClientFactory, accessToken, proxyURL string) string {
if accessToken == "" || clientFactory == nil {
return ""
}
ctx, cancel := context.WithTimeout(ctx, 15*time.Second)
defer cancel()
client, err := clientFactory(proxyURL)
if err != nil {
slog.Warn("openai_privacy_client_error", "error", err.Error())
return PrivacyModeFailed
}
resp, err := client.R().
SetContext(ctx).
SetHeader("Authorization", "Bearer "+accessToken).
SetHeader("Origin", "https://chatgpt.com").
SetHeader("Referer", "https://chatgpt.com/").
SetHeader("Accept", "application/json").
SetHeader("sec-fetch-mode", "cors").
SetHeader("sec-fetch-site", "same-origin").
SetHeader("sec-fetch-dest", "empty").
SetQueryParam("feature", "training_allowed").
SetQueryParam("value", "false").
Patch(openAISettingsURL)
if err != nil {
slog.Warn("openai_privacy_request_error", "error", err.Error())
return PrivacyModeFailed
}
if resp.StatusCode == 403 || resp.StatusCode == 503 {
body := resp.String()
if strings.Contains(body, "cloudflare") || strings.Contains(body, "cf-") || strings.Contains(body, "Just a moment") {
slog.Warn("openai_privacy_cf_blocked", "status", resp.StatusCode)
return PrivacyModeCFBlocked
}
}
if !resp.IsSuccessState() {
slog.Warn("openai_privacy_failed", "status", resp.StatusCode, "body", truncate(resp.String(), 200))
return PrivacyModeFailed
}
slog.Info("openai_privacy_training_disabled")
return PrivacyModeTrainingOff
}
// ChatGPTAccountInfo 从 chatgpt.com/backend-api/accounts/check 获取的账号信息
type ChatGPTAccountInfo struct {
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)
}
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).
// 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 acctRaw, ok := accounts[orgID]; ok {
if acct, ok := acctRaw.(map[string]any); ok {
if isUsableChatGPTAccountCandidate(acct, time.Now()) {
fillAccountInfo(info, acct, orgID)
}
}
}
}
// 未匹配到时,遍历所有账号:优先 is_default,次选非 free
if info.PlanType == "" {
type candidate struct {
planType string
expiresAt string
accountID string
}
var defaultC, paidC, anyC candidate
for key, acctRaw := range accounts {
acct, ok := acctRaw.(map[string]any)
if !ok {
continue
}
if !isUsableChatGPTAccountCandidate(acct, time.Now()) {
continue
}
planType := extractPlanType(acct)
if planType == "" {
continue
}
ea := extractEntitlementExpiresAt(acct)
id := chatGPTAccountObjectID(acct, key)
if anyC.planType == "" {
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, id}
}
}
if !strings.EqualFold(planType, "free") && paidC.planType == "" {
paidC = candidate{planType, ea, id}
}
}
// 优先级:default > 非 free > 任意
switch {
case defaultC.planType != "":
info.PlanType, info.SubscriptionExpiresAt, info.AccountID = defaultC.planType, defaultC.expiresAt, defaultC.accountID
case paidC.planType != "":
info.PlanType, info.SubscriptionExpiresAt, info.AccountID = paidC.planType, paidC.expiresAt, paidC.accountID
default:
info.PlanType, info.SubscriptionExpiresAt, info.AccountID = anyC.planType, anyC.expiresAt, anyC.accountID
}
}
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, "subscription_expires_at", info.SubscriptionExpiresAt, "org_id", orgID)
return info
}
// fetchChatGPTSubscriptionExpiresAt reads the lightweight subscription endpoint used by
// ChatGPT/Codex clients. Some Plus accounts no longer expose entitlement.expires_at in
// accounts/check, but this endpoint still returns active_until.
func fetchChatGPTSubscriptionExpiresAt(ctx context.Context, clientFactory PrivacyClientFactory, accessToken, proxyURL, accountID string) string {
accountID = strings.TrimSpace(accountID)
if accessToken == "" || accountID == "" || clientFactory == nil {
return ""
}
ctx, cancel := context.WithTimeout(ctx, 15*time.Second)
defer cancel()
client, err := clientFactory(proxyURL)
if err != nil {
slog.Debug("chatgpt_subscription_client_error", "error", err.Error())
return ""
}
var result struct {
PlanType string `json:"plan_type"`
ActiveUntil string `json:"active_until"`
WillRenew bool `json:"will_renew"`
ID string `json:"id"`
}
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).
SetQueryParam("account_id", accountID).
Get(chatGPTSubscriptionsURL)
if err != nil {
slog.Debug("chatgpt_subscription_request_error", "error", err.Error())
return ""
}
if !resp.IsSuccessState() {
slog.Debug("chatgpt_subscription_failed", "status", resp.StatusCode, "body", truncate(resp.String(), 200))
return ""
}
activeUntil := strings.TrimSpace(result.ActiveUntil)
if activeUntil == "" {
slog.Debug("chatgpt_subscription_no_active_until", "plan_type", result.PlanType, "has_subscription_id", strings.TrimSpace(result.ID) != "", "will_renew", result.WillRenew)
return ""
}
if _, err := time.Parse(time.RFC3339, activeUntil); err != nil {
slog.Debug("chatgpt_subscription_bad_active_until", "active_until", activeUntil, "error", err.Error())
return ""
}
slog.Info("chatgpt_subscription_success", "plan_type", result.PlanType, "subscription_expires_at", activeUntil, "account_id", accountID)
return activeUntil
}
// 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
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 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 {
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
}
return s[:n] + fmt.Sprintf("...(%d more)", len(s)-n)
}