diff --git a/backend/internal/repository/req_client_pool.go b/backend/internal/repository/req_client_pool.go
index 95ab27ce32..26f3421ae0 100644
--- a/backend/internal/repository/req_client_pool.go
+++ b/backend/internal/repository/req_client_pool.go
@@ -17,7 +17,7 @@ import (
type reqClientOptions struct {
ProxyURL string // 代理 URL(支持 http/https/socks5)
Timeout time.Duration // 请求超时时间
- Impersonate bool // 是否模拟 Chrome 浏览器指纹
+ Impersonate bool // 是否模拟浏览器指纹(当前为 Firefox,Chrome 伪装会被 chatgpt.com 的 Cloudflare 质询)
ForceHTTP2 bool // 是否强制使用 HTTP/2
}
@@ -50,7 +50,12 @@ func getSharedReqClient(opts reqClientOptions) (*req.Client, error) {
client = client.EnableForceHTTP2()
}
if opts.Impersonate {
- client = client.ImpersonateChrome()
+ // chatgpt.com 的 Cloudflare 会对 req 内置的 Chrome 伪装(UA 固定为 Chrome/120,
+ // 与 sec-ch-ua 等 Client Hints 一起已明显过时)直接返回 403 cf-mitigated=challenge,
+ // 导致 accounts/check、subscriptions、隐私设置等 backend-api 调用全部失败,
+ // 订阅到期时间因此长期不更新(见 issue #4825)。Firefox 伪装的 UA/头部组合
+ // 在同一出口 IP 下稳定通过,故改用 Firefox 指纹。
+ client = client.ImpersonateFirefox()
}
trimmed, _, err := proxyurl.Parse(opts.ProxyURL)
if err != nil {
@@ -95,6 +100,6 @@ func CreatePrivacyReqClient(proxyURL string) (*req.Client, error) {
return getSharedReqClient(reqClientOptions{
ProxyURL: proxyURL,
Timeout: 30 * time.Second,
- Impersonate: true, // Enable Chrome TLS fingerprint impersonation
+ Impersonate: true, // Enable browser TLS fingerprint impersonation (Firefox, see getSharedReqClient)
})
}
diff --git a/backend/internal/repository/req_client_pool_test.go b/backend/internal/repository/req_client_pool_test.go
index 3a27841c5a..12f168f937 100644
--- a/backend/internal/repository/req_client_pool_test.go
+++ b/backend/internal/repository/req_client_pool_test.go
@@ -140,3 +140,12 @@ func TestInstrumentReqClientRecordsDependency(t *testing.T) {
header := collector.HeaderValue(time.Now(), "bypass")
require.True(t, strings.Contains(header, "dep_http;dur="), header)
}
+
+func TestGetSharedReqClient_ImpersonateUsesFirefoxFingerprint(t *testing.T) {
+ sharedReqClients = sync.Map{}
+ client, err := getSharedReqClient(reqClientOptions{Timeout: time.Second, Impersonate: true})
+ require.NoError(t, err)
+ // chatgpt.com 的 Cloudflare 会质询 req 内置的 Chrome/120 伪装,必须保持 Firefox 指纹。
+ require.Contains(t, client.Headers.Get("User-Agent"), "Firefox/")
+ require.NotContains(t, client.Headers.Get("User-Agent"), "Chrome/")
+}
diff --git a/backend/internal/service/openai_privacy_cf_challenge_test.go b/backend/internal/service/openai_privacy_cf_challenge_test.go
new file mode 100644
index 0000000000..28b96dacc9
--- /dev/null
+++ b/backend/internal/service/openai_privacy_cf_challenge_test.go
@@ -0,0 +1,25 @@
+package service
+
+import "testing"
+
+func TestIsCloudflareChallengeResponse(t *testing.T) {
+ cases := []struct {
+ name string
+ cfMitigated string
+ body string
+ want bool
+ }{
+ {"cf-mitigated challenge header", "challenge", "...", true},
+ {"header case-insensitive with spaces", " Challenge ", "", true},
+ {"body marker only", "", "
Just a moment...", true},
+ {"plain json error", "", `{"detail":"Unauthorized"}`, false},
+ {"empty", "", "", false},
+ }
+ for _, tc := range cases {
+ t.Run(tc.name, func(t *testing.T) {
+ if got := isCloudflareChallengeResponse(tc.cfMitigated, tc.body); got != tc.want {
+ t.Fatalf("isCloudflareChallengeResponse(%q, %q) = %v, want %v", tc.cfMitigated, tc.body, got, tc.want)
+ }
+ })
+ }
+}
diff --git a/backend/internal/service/openai_privacy_service.go b/backend/internal/service/openai_privacy_service.go
index 192843346b..b2fe4e73b7 100644
--- a/backend/internal/service/openai_privacy_service.go
+++ b/backend/internal/service/openai_privacy_service.go
@@ -71,7 +71,7 @@ func disableOpenAITraining(ctx context.Context, clientFactory PrivacyClientFacto
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") {
+ if isCloudflareChallengeResponse(resp.Header.Get("cf-mitigated"), body) {
slog.Warn("openai_privacy_cf_blocked", "status", resp.StatusCode)
return PrivacyModeCFBlocked
}
@@ -86,6 +86,15 @@ func disableOpenAITraining(ctx context.Context, clientFactory PrivacyClientFacto
return PrivacyModeTrainingOff
}
+// isCloudflareChallengeResponse 判断 chatgpt.com 返回的是否为 Cloudflare 质询/拦截页。
+// 优先看 cf-mitigated 响应头(质询时为 "challenge"),再回退到正文关键字。
+func isCloudflareChallengeResponse(cfMitigated, body string) bool {
+ if strings.EqualFold(strings.TrimSpace(cfMitigated), "challenge") {
+ return true
+ }
+ return strings.Contains(body, "cloudflare") || strings.Contains(body, "cf-") || strings.Contains(body, "Just a moment")
+}
+
// ChatGPTAccountInfo 从 chatgpt.com/backend-api/accounts/check 获取的账号信息
type ChatGPTAccountInfo struct {
PlanType string
@@ -131,12 +140,12 @@ func fetchChatGPTAccountInfo(ctx context.Context, clientFactory PrivacyClientFac
Get(chatGPTAccountsCheckURL)
if err != nil {
- slog.Debug("chatgpt_account_check_request_error", "error", err.Error())
+ slog.Warn("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))
+ slog.Warn("chatgpt_account_check_failed", "status", resp.StatusCode, "cf_challenge", isCloudflareChallengeResponse(resp.Header.Get("cf-mitigated"), resp.String()), "body", truncate(resp.String(), 200))
return nil
}
@@ -247,11 +256,11 @@ func fetchChatGPTSubscriptionExpiresAt(ctx context.Context, clientFactory Privac
SetQueryParam("account_id", accountID).
Get(chatGPTSubscriptionsURL)
if err != nil {
- slog.Debug("chatgpt_subscription_request_error", "error", err.Error())
+ slog.Warn("chatgpt_subscription_request_error", "error", err.Error())
return ""
}
if !resp.IsSuccessState() {
- slog.Debug("chatgpt_subscription_failed", "status", resp.StatusCode, "body", truncate(resp.String(), 200))
+ slog.Warn("chatgpt_subscription_failed", "status", resp.StatusCode, "cf_challenge", isCloudflareChallengeResponse(resp.Header.Get("cf-mitigated"), resp.String()), "body", truncate(resp.String(), 200))
return ""
}