From a63de121825d2c833ec6b1910da5de846b0edcc9 Mon Sep 17 00:00:00 2001 From: QTom Date: Thu, 12 Mar 2026 19:45:13 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20GPT=20=E9=9A=90=E7=A7=81=E6=A8=A1?= =?UTF-8?q?=E5=BC=8F=20+=20no-train=20=E5=89=8D=E7=AB=AF=E5=B1=95=E7=A4=BA?= =?UTF-8?q?=E4=BC=98=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/cmd/server/wire.go | 7 ++ backend/cmd/server/wire_gen.go | 9 +- .../internal/handler/admin/account_handler.go | 3 + .../handler/admin/admin_service_stub_test.go | 4 + .../handler/admin/openai_oauth_handler.go | 1 + .../internal/repository/req_client_pool.go | 11 +++ backend/internal/server/api_contract_test.go | 2 +- backend/internal/service/admin_service.go | 60 +++++++++++- .../service/openai_privacy_service.go | 77 +++++++++++++++ .../internal/service/token_refresh_service.go | 58 +++++++++++ backend/internal/service/wire.go | 4 + .../components/common/PlatformTypeBadge.vue | 98 +++++++++++++------ frontend/src/i18n/locales/en.ts | 3 + frontend/src/i18n/locales/zh.ts | 3 + frontend/src/views/admin/AccountsView.vue | 2 +- 15 files changed, 305 insertions(+), 37 deletions(-) create mode 100644 backend/internal/service/openai_privacy_service.go 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 @@ 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 @@ -