openai_privacy_service.go 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362
  1. package service
  2. import (
  3. "context"
  4. "fmt"
  5. "log/slog"
  6. "strings"
  7. "time"
  8. "github.com/imroc/req/v3"
  9. )
  10. // PrivacyClientFactory creates an HTTP client for privacy API calls.
  11. // Injected from repository layer to avoid import cycles.
  12. type PrivacyClientFactory func(proxyURL string) (*req.Client, error)
  13. const (
  14. openAISettingsURL = "https://chatgpt.com/backend-api/settings/account_user_setting"
  15. PrivacyModeTrainingOff = "training_off"
  16. PrivacyModeFailed = "training_set_failed"
  17. PrivacyModeCFBlocked = "training_set_cf_blocked"
  18. )
  19. func shouldSkipOpenAIPrivacyEnsure(extra map[string]any) bool {
  20. if extra == nil {
  21. return false
  22. }
  23. raw, ok := extra["privacy_mode"]
  24. if !ok {
  25. return false
  26. }
  27. mode, _ := raw.(string)
  28. mode = strings.TrimSpace(mode)
  29. return mode != PrivacyModeFailed && mode != PrivacyModeCFBlocked
  30. }
  31. // disableOpenAITraining calls ChatGPT settings API to turn off "Improve the model for everyone".
  32. // Returns privacy_mode value: "training_off" on success, "cf_blocked" / "failed" on failure.
  33. func disableOpenAITraining(ctx context.Context, clientFactory PrivacyClientFactory, accessToken, proxyURL string) string {
  34. if accessToken == "" || clientFactory == nil {
  35. return ""
  36. }
  37. ctx, cancel := context.WithTimeout(ctx, 15*time.Second)
  38. defer cancel()
  39. client, err := clientFactory(proxyURL)
  40. if err != nil {
  41. slog.Warn("openai_privacy_client_error", "error", err.Error())
  42. return PrivacyModeFailed
  43. }
  44. resp, err := client.R().
  45. SetContext(ctx).
  46. SetHeader("Authorization", "Bearer "+accessToken).
  47. SetHeader("Origin", "https://chatgpt.com").
  48. SetHeader("Referer", "https://chatgpt.com/").
  49. SetHeader("Accept", "application/json").
  50. SetHeader("sec-fetch-mode", "cors").
  51. SetHeader("sec-fetch-site", "same-origin").
  52. SetHeader("sec-fetch-dest", "empty").
  53. SetQueryParam("feature", "training_allowed").
  54. SetQueryParam("value", "false").
  55. Patch(openAISettingsURL)
  56. if err != nil {
  57. slog.Warn("openai_privacy_request_error", "error", err.Error())
  58. return PrivacyModeFailed
  59. }
  60. if resp.StatusCode == 403 || resp.StatusCode == 503 {
  61. body := resp.String()
  62. if strings.Contains(body, "cloudflare") || strings.Contains(body, "cf-") || strings.Contains(body, "Just a moment") {
  63. slog.Warn("openai_privacy_cf_blocked", "status", resp.StatusCode)
  64. return PrivacyModeCFBlocked
  65. }
  66. }
  67. if !resp.IsSuccessState() {
  68. slog.Warn("openai_privacy_failed", "status", resp.StatusCode, "body", truncate(resp.String(), 200))
  69. return PrivacyModeFailed
  70. }
  71. slog.Info("openai_privacy_training_disabled")
  72. return PrivacyModeTrainingOff
  73. }
  74. // ChatGPTAccountInfo 从 chatgpt.com/backend-api/accounts/check 获取的账号信息
  75. type ChatGPTAccountInfo struct {
  76. PlanType string
  77. Email string
  78. // AccountID 是本条信息所属账号的标识(优先取 account.account_id,否则取 accounts
  79. // 的 map key)。accounts/check 是多账号/工作区端点,调用方需要据此判断拿到的
  80. // plan_type / expires_at 到底属于个人账号还是某个 workspace。
  81. AccountID string
  82. SubscriptionExpiresAt string // entitlement.expires_at (RFC3339)
  83. }
  84. var (
  85. chatGPTAccountsCheckURL = "https://chatgpt.com/backend-api/accounts/check/v4-2023-04-27"
  86. chatGPTSubscriptionsURL = "https://chatgpt.com/backend-api/subscriptions"
  87. )
  88. // fetchChatGPTAccountInfo calls ChatGPT backend-api to get account info (plan_type, etc.).
  89. // Used as fallback when id_token doesn't contain these fields (e.g., Mobile RT).
  90. // orgID is used to match the correct account when multiple accounts exist (e.g., personal + team).
  91. // Returns nil on any failure (best-effort, non-blocking).
  92. func fetchChatGPTAccountInfo(ctx context.Context, clientFactory PrivacyClientFactory, accessToken, proxyURL, orgID string) *ChatGPTAccountInfo {
  93. if accessToken == "" || clientFactory == nil {
  94. return nil
  95. }
  96. ctx, cancel := context.WithTimeout(ctx, 15*time.Second)
  97. defer cancel()
  98. client, err := clientFactory(proxyURL)
  99. if err != nil {
  100. slog.Debug("chatgpt_account_check_client_error", "error", err.Error())
  101. return nil
  102. }
  103. var result map[string]any
  104. resp, err := client.R().
  105. SetContext(ctx).
  106. SetHeader("Authorization", "Bearer "+accessToken).
  107. SetHeader("Origin", "https://chatgpt.com").
  108. SetHeader("Referer", "https://chatgpt.com/").
  109. SetHeader("Accept", "application/json").
  110. SetSuccessResult(&result).
  111. Get(chatGPTAccountsCheckURL)
  112. if err != nil {
  113. slog.Debug("chatgpt_account_check_request_error", "error", err.Error())
  114. return nil
  115. }
  116. if !resp.IsSuccessState() {
  117. slog.Debug("chatgpt_account_check_failed", "status", resp.StatusCode, "body", truncate(resp.String(), 200))
  118. return nil
  119. }
  120. info := &ChatGPTAccountInfo{}
  121. accounts, ok := result["accounts"].(map[string]any)
  122. if !ok {
  123. slog.Debug("chatgpt_account_check_no_accounts", "body", truncate(resp.String(), 300))
  124. return nil
  125. }
  126. // 优先匹配 orgID 对应的账号(access_token JWT 中的 poid)
  127. if orgID != "" {
  128. if acctRaw, ok := accounts[orgID]; ok {
  129. if acct, ok := acctRaw.(map[string]any); ok {
  130. if isUsableChatGPTAccountCandidate(acct, time.Now()) {
  131. fillAccountInfo(info, acct, orgID)
  132. }
  133. }
  134. }
  135. }
  136. // 未匹配到时,遍历所有账号:优先 is_default,次选非 free
  137. if info.PlanType == "" {
  138. type candidate struct {
  139. planType string
  140. expiresAt string
  141. accountID string
  142. }
  143. var defaultC, paidC, anyC candidate
  144. for key, acctRaw := range accounts {
  145. acct, ok := acctRaw.(map[string]any)
  146. if !ok {
  147. continue
  148. }
  149. if !isUsableChatGPTAccountCandidate(acct, time.Now()) {
  150. continue
  151. }
  152. planType := extractPlanType(acct)
  153. if planType == "" {
  154. continue
  155. }
  156. ea := extractEntitlementExpiresAt(acct)
  157. id := chatGPTAccountObjectID(acct, key)
  158. if anyC.planType == "" {
  159. anyC = candidate{planType, ea, id}
  160. }
  161. if account, ok := acct["account"].(map[string]any); ok {
  162. if isDefault, _ := account["is_default"].(bool); isDefault {
  163. defaultC = candidate{planType, ea, id}
  164. }
  165. }
  166. if !strings.EqualFold(planType, "free") && paidC.planType == "" {
  167. paidC = candidate{planType, ea, id}
  168. }
  169. }
  170. // 优先级:default > 非 free > 任意
  171. switch {
  172. case defaultC.planType != "":
  173. info.PlanType, info.SubscriptionExpiresAt, info.AccountID = defaultC.planType, defaultC.expiresAt, defaultC.accountID
  174. case paidC.planType != "":
  175. info.PlanType, info.SubscriptionExpiresAt, info.AccountID = paidC.planType, paidC.expiresAt, paidC.accountID
  176. default:
  177. info.PlanType, info.SubscriptionExpiresAt, info.AccountID = anyC.planType, anyC.expiresAt, anyC.accountID
  178. }
  179. }
  180. if info.PlanType == "" {
  181. slog.Debug("chatgpt_account_check_no_plan_type", "body", truncate(resp.String(), 300))
  182. return nil
  183. }
  184. slog.Info("chatgpt_account_check_success", "plan_type", info.PlanType, "subscription_expires_at", info.SubscriptionExpiresAt, "org_id", orgID)
  185. return info
  186. }
  187. // fetchChatGPTSubscriptionExpiresAt reads the lightweight subscription endpoint used by
  188. // ChatGPT/Codex clients. Some Plus accounts no longer expose entitlement.expires_at in
  189. // accounts/check, but this endpoint still returns active_until.
  190. func fetchChatGPTSubscriptionExpiresAt(ctx context.Context, clientFactory PrivacyClientFactory, accessToken, proxyURL, accountID string) string {
  191. accountID = strings.TrimSpace(accountID)
  192. if accessToken == "" || accountID == "" || clientFactory == nil {
  193. return ""
  194. }
  195. ctx, cancel := context.WithTimeout(ctx, 15*time.Second)
  196. defer cancel()
  197. client, err := clientFactory(proxyURL)
  198. if err != nil {
  199. slog.Debug("chatgpt_subscription_client_error", "error", err.Error())
  200. return ""
  201. }
  202. var result struct {
  203. PlanType string `json:"plan_type"`
  204. ActiveUntil string `json:"active_until"`
  205. WillRenew bool `json:"will_renew"`
  206. ID string `json:"id"`
  207. }
  208. resp, err := client.R().
  209. SetContext(ctx).
  210. SetHeader("Authorization", "Bearer "+accessToken).
  211. SetHeader("Origin", "https://chatgpt.com").
  212. SetHeader("Referer", "https://chatgpt.com/").
  213. SetHeader("Accept", "application/json").
  214. SetSuccessResult(&result).
  215. SetQueryParam("account_id", accountID).
  216. Get(chatGPTSubscriptionsURL)
  217. if err != nil {
  218. slog.Debug("chatgpt_subscription_request_error", "error", err.Error())
  219. return ""
  220. }
  221. if !resp.IsSuccessState() {
  222. slog.Debug("chatgpt_subscription_failed", "status", resp.StatusCode, "body", truncate(resp.String(), 200))
  223. return ""
  224. }
  225. activeUntil := strings.TrimSpace(result.ActiveUntil)
  226. if activeUntil == "" {
  227. slog.Debug("chatgpt_subscription_no_active_until", "plan_type", result.PlanType, "has_subscription_id", strings.TrimSpace(result.ID) != "", "will_renew", result.WillRenew)
  228. return ""
  229. }
  230. if _, err := time.Parse(time.RFC3339, activeUntil); err != nil {
  231. slog.Debug("chatgpt_subscription_bad_active_until", "active_until", activeUntil, "error", err.Error())
  232. return ""
  233. }
  234. slog.Info("chatgpt_subscription_success", "plan_type", result.PlanType, "subscription_expires_at", activeUntil, "account_id", accountID)
  235. return activeUntil
  236. }
  237. // fillAccountInfo 从单个 account 对象中提取 plan_type 和 subscription_expires_at。
  238. // fallbackID 是该对象在 accounts 里的 map key,用于 account.account_id 缺失时兜底。
  239. func fillAccountInfo(info *ChatGPTAccountInfo, acct map[string]any, fallbackID string) {
  240. info.PlanType = extractPlanType(acct)
  241. info.SubscriptionExpiresAt = extractEntitlementExpiresAt(acct)
  242. info.AccountID = chatGPTAccountObjectID(acct, fallbackID)
  243. }
  244. // chatGPTAccountObjectID 取单个 account 对象的账号标识。
  245. // accounts 的 map key 有时是 "default" 这类别名,所以优先读 account.account_id。
  246. func chatGPTAccountObjectID(acct map[string]any, fallbackID string) string {
  247. if account, ok := acct["account"].(map[string]any); ok {
  248. if id, ok := account["account_id"].(string); ok && strings.TrimSpace(id) != "" {
  249. return strings.TrimSpace(id)
  250. }
  251. }
  252. return strings.TrimSpace(fallbackID)
  253. }
  254. // extractPlanType 从单个 account 对象中提取 plan_type
  255. func extractPlanType(acct map[string]any) string {
  256. if account, ok := acct["account"].(map[string]any); ok {
  257. if planType, ok := account["plan_type"].(string); ok && planType != "" {
  258. return planType
  259. }
  260. }
  261. if entitlement, ok := acct["entitlement"].(map[string]any); ok {
  262. if subPlan, ok := entitlement["subscription_plan"].(string); ok && subPlan != "" {
  263. return subPlan
  264. }
  265. }
  266. return ""
  267. }
  268. func isUsableChatGPTAccountCandidate(acct map[string]any, now time.Time) bool {
  269. if acct == nil || hasChatGPTAccountDeactivatedMarker(acct) {
  270. return false
  271. }
  272. if account, ok := acct["account"].(map[string]any); ok && hasChatGPTAccountDeactivatedMarker(account) {
  273. return false
  274. }
  275. expiresAt := extractEntitlementExpiresAt(acct)
  276. if expiresAt == "" {
  277. return true
  278. }
  279. expiry, err := time.Parse(time.RFC3339, expiresAt)
  280. if err != nil {
  281. return true
  282. }
  283. return expiry.After(now)
  284. }
  285. func hasChatGPTAccountDeactivatedMarker(obj map[string]any) bool {
  286. for _, key := range []string{"deactivated", "is_deactivated", "disabled", "is_disabled"} {
  287. if value, ok := obj[key].(bool); ok && value {
  288. return true
  289. }
  290. }
  291. for _, key := range []string{"deactivated_at", "disabled_at", "deleted_at"} {
  292. if value, ok := obj[key].(string); ok && strings.TrimSpace(value) != "" {
  293. return true
  294. }
  295. }
  296. for _, key := range []string{"status", "state"} {
  297. value, _ := obj[key].(string)
  298. switch strings.ToLower(strings.TrimSpace(value)) {
  299. case "deactivated", "disabled", "deleted", "inactive", "suspended":
  300. return true
  301. }
  302. }
  303. return false
  304. }
  305. // extractEntitlementExpiresAt 从 entitlement 中提取 expires_at。
  306. // 预期为 RFC3339 字符串格式,如 "2026-05-02T20:32:12+00:00"。
  307. func extractEntitlementExpiresAt(acct map[string]any) string {
  308. entitlement, ok := acct["entitlement"].(map[string]any)
  309. if !ok {
  310. return ""
  311. }
  312. ea, _ := entitlement["expires_at"].(string)
  313. return ea
  314. }
  315. func truncate(s string, n int) string {
  316. if len(s) <= n {
  317. return s
  318. }
  319. return s[:n] + fmt.Sprintf("...(%d more)", len(s)-n)
  320. }