diff --git a/backend/cmd/server/wire.go b/backend/cmd/server/wire.go
index 80364bf2..89bdbdca 100644
--- a/backend/cmd/server/wire.go
+++ b/backend/cmd/server/wire.go
@@ -41,6 +41,9 @@ func initializeApplication(buildInfo handler.BuildInfo) (*Application, error) {
// Server layer ProviderSet
server.ProviderSet,
+ // Privacy client factory for OpenAI training opt-out
+ providePrivacyClientFactory,
+
// BuildInfo provider
provideServiceBuildInfo,
@@ -53,6 +56,10 @@ func initializeApplication(buildInfo handler.BuildInfo) (*Application, error) {
return nil, nil
}
+func providePrivacyClientFactory() service.PrivacyClientFactory {
+ return repository.CreatePrivacyReqClient
+}
+
func provideServiceBuildInfo(buildInfo handler.BuildInfo) service.BuildInfo {
return service.BuildInfo{
Version: buildInfo.Version,
diff --git a/backend/cmd/server/wire_gen.go b/backend/cmd/server/wire_gen.go
index 034c70ec..4d4517d2 100644
--- a/backend/cmd/server/wire_gen.go
+++ b/backend/cmd/server/wire_gen.go
@@ -104,7 +104,8 @@ func initializeApplication(buildInfo handler.BuildInfo) (*Application, error) {
proxyRepository := repository.NewProxyRepository(client, db)
proxyExitInfoProber := repository.NewProxyExitInfoProber(configConfig)
proxyLatencyCache := repository.NewProxyLatencyCache(redisClient)
- adminService := service.NewAdminService(userRepository, groupRepository, accountRepository, soraAccountRepository, proxyRepository, apiKeyRepository, redeemCodeRepository, userGroupRateRepository, billingCacheService, proxyExitInfoProber, proxyLatencyCache, apiKeyAuthCacheInvalidator, client, settingService, subscriptionService, userSubscriptionRepository)
+ privacyClientFactory := providePrivacyClientFactory()
+ adminService := service.NewAdminService(userRepository, groupRepository, accountRepository, soraAccountRepository, proxyRepository, apiKeyRepository, redeemCodeRepository, userGroupRateRepository, billingCacheService, proxyExitInfoProber, proxyLatencyCache, apiKeyAuthCacheInvalidator, client, settingService, subscriptionService, userSubscriptionRepository, privacyClientFactory)
concurrencyCache := repository.ProvideConcurrencyCache(redisClient, configConfig)
concurrencyService := service.ProvideConcurrencyService(concurrencyCache, accountRepository, configConfig)
adminUserHandler := admin.NewUserHandler(adminService, concurrencyService)
@@ -226,7 +227,7 @@ func initializeApplication(buildInfo handler.BuildInfo) (*Application, error) {
opsCleanupService := service.ProvideOpsCleanupService(opsRepository, db, redisClient, configConfig)
opsScheduledReportService := service.ProvideOpsScheduledReportService(opsService, userService, emailService, redisClient, configConfig)
soraMediaCleanupService := service.ProvideSoraMediaCleanupService(soraMediaStorage, configConfig)
- tokenRefreshService := service.ProvideTokenRefreshService(accountRepository, soraAccountRepository, oAuthService, openAIOAuthService, geminiOAuthService, antigravityOAuthService, compositeTokenCacheInvalidator, schedulerCache, configConfig, tempUnschedCache)
+ tokenRefreshService := service.ProvideTokenRefreshService(accountRepository, soraAccountRepository, oAuthService, openAIOAuthService, geminiOAuthService, antigravityOAuthService, compositeTokenCacheInvalidator, schedulerCache, configConfig, tempUnschedCache, privacyClientFactory, proxyRepository)
accountExpiryService := service.ProvideAccountExpiryService(accountRepository)
subscriptionExpiryService := service.ProvideSubscriptionExpiryService(userSubscriptionRepository)
scheduledTestRunnerService := service.ProvideScheduledTestRunnerService(scheduledTestPlanRepository, scheduledTestService, accountTestService, rateLimitService, configConfig)
@@ -245,6 +246,10 @@ type Application struct {
Cleanup func()
}
+func providePrivacyClientFactory() service.PrivacyClientFactory {
+ return repository.CreatePrivacyReqClient
+}
+
func provideServiceBuildInfo(buildInfo handler.BuildInfo) service.BuildInfo {
return service.BuildInfo{
Version: buildInfo.Version,
diff --git a/backend/internal/handler/admin/account_handler.go b/backend/internal/handler/admin/account_handler.go
index 7c4d4638..57c2dad1 100644
--- a/backend/internal/handler/admin/account_handler.go
+++ b/backend/internal/handler/admin/account_handler.go
@@ -865,6 +865,9 @@ func (h *AccountHandler) refreshSingleAccount(ctx context.Context, account *serv
}
}
+ // OpenAI OAuth: 刷新成功后检查并设置 privacy_mode
+ h.adminService.EnsureOpenAIPrivacy(ctx, updatedAccount)
+
return updatedAccount, "", nil
}
diff --git a/backend/internal/handler/admin/admin_service_stub_test.go b/backend/internal/handler/admin/admin_service_stub_test.go
index 84a9f102..2852dbae 100644
--- a/backend/internal/handler/admin/admin_service_stub_test.go
+++ b/backend/internal/handler/admin/admin_service_stub_test.go
@@ -429,5 +429,9 @@ func (s *stubAdminService) ResetAccountQuota(ctx context.Context, id int64) erro
return nil
}
+func (s *stubAdminService) EnsureOpenAIPrivacy(ctx context.Context, account *service.Account) string {
+ return ""
+}
+
// Ensure stub implements interface.
var _ service.AdminService = (*stubAdminService)(nil)
diff --git a/backend/internal/handler/admin/openai_oauth_handler.go b/backend/internal/handler/admin/openai_oauth_handler.go
index 5d354fd3..4e6179db 100644
--- a/backend/internal/handler/admin/openai_oauth_handler.go
+++ b/backend/internal/handler/admin/openai_oauth_handler.go
@@ -289,6 +289,7 @@ func (h *OpenAIOAuthHandler) CreateAccountFromOAuth(c *gin.Context) {
Platform: platform,
Type: "oauth",
Credentials: credentials,
+ Extra: nil,
ProxyID: req.ProxyID,
Concurrency: req.Concurrency,
Priority: req.Priority,
diff --git a/backend/internal/repository/req_client_pool.go b/backend/internal/repository/req_client_pool.go
index 79b24396..32501f7b 100644
--- a/backend/internal/repository/req_client_pool.go
+++ b/backend/internal/repository/req_client_pool.go
@@ -73,3 +73,14 @@ func buildReqClientKey(opts reqClientOptions) string {
opts.ForceHTTP2,
)
}
+
+// CreatePrivacyReqClient creates an HTTP client for OpenAI privacy settings API
+// This is exported for use by OpenAIPrivacyService
+// Uses Chrome TLS fingerprint impersonation to bypass Cloudflare checks
+func CreatePrivacyReqClient(proxyURL string) (*req.Client, error) {
+ return getSharedReqClient(reqClientOptions{
+ ProxyURL: proxyURL,
+ Timeout: 30 * time.Second,
+ Impersonate: true, // Enable Chrome TLS fingerprint impersonation
+ })
+}
diff --git a/backend/internal/server/api_contract_test.go b/backend/internal/server/api_contract_test.go
index 0b36bf66..a1ce896e 100644
--- a/backend/internal/server/api_contract_test.go
+++ b/backend/internal/server/api_contract_test.go
@@ -645,7 +645,7 @@ func newContractDeps(t *testing.T) *contractDeps {
settingRepo := newStubSettingRepo()
settingService := service.NewSettingService(settingRepo, cfg)
- adminService := service.NewAdminService(userRepo, groupRepo, &accountRepo, nil, proxyRepo, apiKeyRepo, redeemRepo, nil, nil, nil, nil, nil, nil, nil, nil, nil)
+ adminService := service.NewAdminService(userRepo, groupRepo, &accountRepo, nil, proxyRepo, apiKeyRepo, redeemRepo, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil)
authHandler := handler.NewAuthHandler(cfg, nil, userService, settingService, nil, redeemService, nil)
apiKeyHandler := handler.NewAPIKeyHandler(apiKeyService)
usageHandler := handler.NewUsageHandler(usageService, apiKeyService)
diff --git a/backend/internal/service/admin_service.go b/backend/internal/service/admin_service.go
index dec4ed33..23309a3e 100644
--- a/backend/internal/service/admin_service.go
+++ b/backend/internal/service/admin_service.go
@@ -57,6 +57,8 @@ type AdminService interface {
RefreshAccountCredentials(ctx context.Context, id int64) (*Account, error)
ClearAccountError(ctx context.Context, id int64) (*Account, error)
SetAccountError(ctx context.Context, id int64, errorMsg string) error
+ // EnsureOpenAIPrivacy 检查 OpenAI OAuth 账号 privacy_mode,未设置则尝试关闭训练数据共享并持久化。
+ EnsureOpenAIPrivacy(ctx context.Context, account *Account) string
SetAccountSchedulable(ctx context.Context, id int64, schedulable bool) (*Account, error)
BulkUpdateAccounts(ctx context.Context, input *BulkUpdateAccountsInput) (*BulkUpdateAccountsResult, error)
CheckMixedChannelRisk(ctx context.Context, currentAccountID int64, currentAccountPlatform string, groupIDs []int64) error
@@ -433,6 +435,7 @@ type adminServiceImpl struct {
settingService *SettingService
defaultSubAssigner DefaultSubscriptionAssigner
userSubRepo UserSubscriptionRepository
+ privacyClientFactory PrivacyClientFactory
}
type userGroupRateBatchReader interface {
@@ -461,6 +464,7 @@ func NewAdminService(
settingService *SettingService,
defaultSubAssigner DefaultSubscriptionAssigner,
userSubRepo UserSubscriptionRepository,
+ privacyClientFactory PrivacyClientFactory,
) AdminService {
return &adminServiceImpl{
userRepo: userRepo,
@@ -479,6 +483,7 @@ func NewAdminService(
settingService: settingService,
defaultSubAssigner: defaultSubAssigner,
userSubRepo: userSubRepo,
+ privacyClientFactory: privacyClientFactory,
}
}
@@ -1420,13 +1425,30 @@ func (s *adminServiceImpl) CreateAccount(ctx context.Context, input *CreateAccou
}
}
+ // OpenAI OAuth: attempt to disable training data sharing
+ extra := input.Extra
+ if input.Platform == PlatformOpenAI && input.Type == AccountTypeOAuth {
+ if token, _ := input.Credentials["access_token"].(string); token != "" {
+ var proxyURL string
+ if input.ProxyID != nil {
+ if p, err := s.proxyRepo.GetByID(ctx, *input.ProxyID); err == nil && p != nil {
+ proxyURL = p.URL()
+ }
+ }
+ if extra == nil {
+ extra = make(map[string]any)
+ }
+ extra["privacy_mode"] = disableOpenAITraining(ctx, s.privacyClientFactory, token, proxyURL)
+ }
+ }
+
account := &Account{
Name: input.Name,
Notes: normalizeAccountNotes(input.Notes),
Platform: input.Platform,
Type: input.Type,
Credentials: input.Credentials,
- Extra: input.Extra,
+ Extra: extra,
ProxyID: input.ProxyID,
Concurrency: input.Concurrency,
Priority: input.Priority,
@@ -2502,3 +2524,39 @@ func (e *MixedChannelError) Error() string {
func (s *adminServiceImpl) ResetAccountQuota(ctx context.Context, id int64) error {
return s.accountRepo.ResetQuotaUsed(ctx, id)
}
+
+// EnsureOpenAIPrivacy 检查 OpenAI OAuth 账号是否已设置 privacy_mode,
+// 未设置则调用 disableOpenAITraining 并持久化到 Extra,返回设置的 mode 值。
+func (s *adminServiceImpl) EnsureOpenAIPrivacy(ctx context.Context, account *Account) string {
+ if account.Platform != PlatformOpenAI || account.Type != AccountTypeOAuth {
+ return ""
+ }
+ if s.privacyClientFactory == nil {
+ return ""
+ }
+ if account.Extra != nil {
+ if _, ok := account.Extra["privacy_mode"]; ok {
+ return ""
+ }
+ }
+
+ token, _ := account.Credentials["access_token"].(string)
+ if token == "" {
+ return ""
+ }
+
+ var proxyURL string
+ if account.ProxyID != nil {
+ if p, err := s.proxyRepo.GetByID(ctx, *account.ProxyID); err == nil && p != nil {
+ proxyURL = p.URL()
+ }
+ }
+
+ mode := disableOpenAITraining(ctx, s.privacyClientFactory, token, proxyURL)
+ if mode == "" {
+ return ""
+ }
+
+ _ = s.accountRepo.UpdateExtra(ctx, account.ID, map[string]any{"privacy_mode": mode})
+ return mode
+}
diff --git a/backend/internal/service/openai_privacy_service.go b/backend/internal/service/openai_privacy_service.go
new file mode 100644
index 00000000..90cd522d
--- /dev/null
+++ b/backend/internal/service/openai_privacy_service.go
@@ -0,0 +1,77 @@
+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"
+)
+
+// 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/").
+ 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
+}
+
+func truncate(s string, n int) string {
+ if len(s) <= n {
+ return s
+ }
+ return s[:n] + fmt.Sprintf("...(%d more)", len(s)-n)
+}
diff --git a/backend/internal/service/token_refresh_service.go b/backend/internal/service/token_refresh_service.go
index 73035687..1825257c 100644
--- a/backend/internal/service/token_refresh_service.go
+++ b/backend/internal/service/token_refresh_service.go
@@ -21,6 +21,10 @@ type TokenRefreshService struct {
schedulerCache SchedulerCache // 用于同步更新调度器缓存,解决 token 刷新后缓存不一致问题
tempUnschedCache TempUnschedCache // 用于清除 Redis 中的临时不可调度缓存
+ // OpenAI privacy: 刷新成功后检查并设置 training opt-out
+ privacyClientFactory PrivacyClientFactory
+ proxyRepo ProxyRepository
+
stopCh chan struct{}
wg sync.WaitGroup
}
@@ -72,6 +76,12 @@ func (s *TokenRefreshService) SetSoraAccountRepo(repo SoraAccountRepository) {
}
}
+// SetPrivacyDeps 注入 OpenAI privacy opt-out 所需依赖
+func (s *TokenRefreshService) SetPrivacyDeps(factory PrivacyClientFactory, proxyRepo ProxyRepository) {
+ s.privacyClientFactory = factory
+ s.proxyRepo = proxyRepo
+}
+
// Start 启动后台刷新服务
func (s *TokenRefreshService) Start() {
if !s.cfg.Enabled {
@@ -277,6 +287,8 @@ func (s *TokenRefreshService) refreshWithRetry(ctx context.Context, account *Acc
slog.Debug("token_refresh.scheduler_cache_synced", "account_id", account.ID)
}
}
+ // OpenAI OAuth: 刷新成功后,检查是否已设置 privacy_mode,未设置则尝试关闭训练数据共享
+ s.ensureOpenAIPrivacy(ctx, account)
return nil
}
@@ -341,3 +353,49 @@ func isNonRetryableRefreshError(err error) bool {
}
return false
}
+
+// ensureOpenAIPrivacy 检查 OpenAI OAuth 账号是否已设置 privacy_mode,
+// 未设置则调用 disableOpenAITraining 并持久化结果到 Extra。
+func (s *TokenRefreshService) ensureOpenAIPrivacy(ctx context.Context, account *Account) {
+ if account.Platform != PlatformOpenAI || account.Type != AccountTypeOAuth {
+ return
+ }
+ if s.privacyClientFactory == nil {
+ return
+ }
+ // 已设置过则跳过
+ if account.Extra != nil {
+ if _, ok := account.Extra["privacy_mode"]; ok {
+ return
+ }
+ }
+
+ token, _ := account.Credentials["access_token"].(string)
+ if token == "" {
+ return
+ }
+
+ var proxyURL string
+ if account.ProxyID != nil && s.proxyRepo != nil {
+ if p, err := s.proxyRepo.GetByID(ctx, *account.ProxyID); err == nil && p != nil {
+ proxyURL = p.URL()
+ }
+ }
+
+ mode := disableOpenAITraining(ctx, s.privacyClientFactory, token, proxyURL)
+ if mode == "" {
+ return
+ }
+
+ if err := s.accountRepo.UpdateExtra(ctx, account.ID, map[string]any{"privacy_mode": mode}); err != nil {
+ slog.Warn("token_refresh.update_privacy_mode_failed",
+ "account_id", account.ID,
+ "error", err,
+ )
+ } else {
+ slog.Info("token_refresh.privacy_mode_set",
+ "account_id", account.ID,
+ "privacy_mode", mode,
+ )
+ }
+}
diff --git a/backend/internal/service/wire.go b/backend/internal/service/wire.go
index 7457b77e..4d0c2271 100644
--- a/backend/internal/service/wire.go
+++ b/backend/internal/service/wire.go
@@ -49,10 +49,14 @@ func ProvideTokenRefreshService(
schedulerCache SchedulerCache,
cfg *config.Config,
tempUnschedCache TempUnschedCache,
+ privacyClientFactory PrivacyClientFactory,
+ proxyRepo ProxyRepository,
) *TokenRefreshService {
svc := NewTokenRefreshService(accountRepo, oauthService, openaiOAuthService, geminiOAuthService, antigravityOAuthService, cacheInvalidator, schedulerCache, cfg, tempUnschedCache)
// 注入 Sora 账号扩展表仓储,用于 OpenAI Token 刷新时同步 sora_accounts 表
svc.SetSoraAccountRepo(soraAccountRepo)
+ // 注入 OpenAI privacy opt-out 依赖
+ svc.SetPrivacyDeps(privacyClientFactory, proxyRepo)
svc.Start()
return svc
}
diff --git a/frontend/src/components/common/PlatformTypeBadge.vue b/frontend/src/components/common/PlatformTypeBadge.vue
index fd035a5c..f0625e88 100644
--- a/frontend/src/components/common/PlatformTypeBadge.vue
+++ b/frontend/src/components/common/PlatformTypeBadge.vue
@@ -1,50 +1,67 @@
-
-
-
-
- {{ platformLabel }}
-
-
-
-
-
diff --git a/frontend/src/i18n/locales/en.ts b/frontend/src/i18n/locales/en.ts
index 9fd0c006..9f847eb6 100644
--- a/frontend/src/i18n/locales/en.ts
+++ b/frontend/src/i18n/locales/en.ts
@@ -1743,6 +1743,9 @@ export default {
expiresAt: 'Expires At',
actions: 'Actions'
},
+ privacyTrainingOff: 'Training data sharing disabled',
+ privacyCfBlocked: 'Blocked by Cloudflare, training may still be on',
+ privacyFailed: 'Failed to disable training',
// Capacity status tooltips
capacity: {
windowCost: {
diff --git a/frontend/src/i18n/locales/zh.ts b/frontend/src/i18n/locales/zh.ts
index d139cd34..ddaced42 100644
--- a/frontend/src/i18n/locales/zh.ts
+++ b/frontend/src/i18n/locales/zh.ts
@@ -1792,6 +1792,9 @@ export default {
expiresAt: '过期时间',
actions: '操作'
},
+ privacyTrainingOff: '已关闭训练数据共享',
+ privacyCfBlocked: '被 Cloudflare 拦截,训练可能仍开启',
+ privacyFailed: '关闭训练数据共享失败',
// 容量状态提示
capacity: {
windowCost: {
diff --git a/frontend/src/views/admin/AccountsView.vue b/frontend/src/views/admin/AccountsView.vue
index f5aff935..a6a6d369 100644
--- a/frontend/src/views/admin/AccountsView.vue
+++ b/frontend/src/views/admin/AccountsView.vue
@@ -171,7 +171,7 @@
-
-
+