Skip to content

Commit a63de12

Browse files
author
QTom
committed
feat: GPT 隐私模式 + no-train 前端展示优化
1 parent 826090e commit a63de12

15 files changed

Lines changed: 305 additions & 37 deletions

File tree

backend/cmd/server/wire.go

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,9 @@ func initializeApplication(buildInfo handler.BuildInfo) (*Application, error) {
4141
// Server layer ProviderSet
4242
server.ProviderSet,
4343

44+
// Privacy client factory for OpenAI training opt-out
45+
providePrivacyClientFactory,
46+
4447
// BuildInfo provider
4548
provideServiceBuildInfo,
4649

@@ -53,6 +56,10 @@ func initializeApplication(buildInfo handler.BuildInfo) (*Application, error) {
5356
return nil, nil
5457
}
5558

59+
func providePrivacyClientFactory() service.PrivacyClientFactory {
60+
return repository.CreatePrivacyReqClient
61+
}
62+
5663
func provideServiceBuildInfo(buildInfo handler.BuildInfo) service.BuildInfo {
5764
return service.BuildInfo{
5865
Version: buildInfo.Version,

backend/cmd/server/wire_gen.go

Lines changed: 7 additions & 2 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

backend/internal/handler/admin/account_handler.go

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -865,6 +865,9 @@ func (h *AccountHandler) refreshSingleAccount(ctx context.Context, account *serv
865865
}
866866
}
867867

868+
// OpenAI OAuth: 刷新成功后检查并设置 privacy_mode
869+
h.adminService.EnsureOpenAIPrivacy(ctx, updatedAccount)
870+
868871
return updatedAccount, "", nil
869872
}
870873

backend/internal/handler/admin/admin_service_stub_test.go

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -429,5 +429,9 @@ func (s *stubAdminService) ResetAccountQuota(ctx context.Context, id int64) erro
429429
return nil
430430
}
431431

432+
func (s *stubAdminService) EnsureOpenAIPrivacy(ctx context.Context, account *service.Account) string {
433+
return ""
434+
}
435+
432436
// Ensure stub implements interface.
433437
var _ service.AdminService = (*stubAdminService)(nil)

backend/internal/handler/admin/openai_oauth_handler.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -289,6 +289,7 @@ func (h *OpenAIOAuthHandler) CreateAccountFromOAuth(c *gin.Context) {
289289
Platform: platform,
290290
Type: "oauth",
291291
Credentials: credentials,
292+
Extra: nil,
292293
ProxyID: req.ProxyID,
293294
Concurrency: req.Concurrency,
294295
Priority: req.Priority,

backend/internal/repository/req_client_pool.go

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -73,3 +73,14 @@ func buildReqClientKey(opts reqClientOptions) string {
7373
opts.ForceHTTP2,
7474
)
7575
}
76+
77+
// CreatePrivacyReqClient creates an HTTP client for OpenAI privacy settings API
78+
// This is exported for use by OpenAIPrivacyService
79+
// Uses Chrome TLS fingerprint impersonation to bypass Cloudflare checks
80+
func CreatePrivacyReqClient(proxyURL string) (*req.Client, error) {
81+
return getSharedReqClient(reqClientOptions{
82+
ProxyURL: proxyURL,
83+
Timeout: 30 * time.Second,
84+
Impersonate: true, // Enable Chrome TLS fingerprint impersonation
85+
})
86+
}

backend/internal/server/api_contract_test.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -645,7 +645,7 @@ func newContractDeps(t *testing.T) *contractDeps {
645645
settingRepo := newStubSettingRepo()
646646
settingService := service.NewSettingService(settingRepo, cfg)
647647

648-
adminService := service.NewAdminService(userRepo, groupRepo, &accountRepo, nil, proxyRepo, apiKeyRepo, redeemRepo, nil, nil, nil, nil, nil, nil, nil, nil, nil)
648+
adminService := service.NewAdminService(userRepo, groupRepo, &accountRepo, nil, proxyRepo, apiKeyRepo, redeemRepo, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil)
649649
authHandler := handler.NewAuthHandler(cfg, nil, userService, settingService, nil, redeemService, nil)
650650
apiKeyHandler := handler.NewAPIKeyHandler(apiKeyService)
651651
usageHandler := handler.NewUsageHandler(usageService, apiKeyService)

backend/internal/service/admin_service.go

Lines changed: 59 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,8 @@ type AdminService interface {
5757
RefreshAccountCredentials(ctx context.Context, id int64) (*Account, error)
5858
ClearAccountError(ctx context.Context, id int64) (*Account, error)
5959
SetAccountError(ctx context.Context, id int64, errorMsg string) error
60+
// EnsureOpenAIPrivacy 检查 OpenAI OAuth 账号 privacy_mode,未设置则尝试关闭训练数据共享并持久化。
61+
EnsureOpenAIPrivacy(ctx context.Context, account *Account) string
6062
SetAccountSchedulable(ctx context.Context, id int64, schedulable bool) (*Account, error)
6163
BulkUpdateAccounts(ctx context.Context, input *BulkUpdateAccountsInput) (*BulkUpdateAccountsResult, error)
6264
CheckMixedChannelRisk(ctx context.Context, currentAccountID int64, currentAccountPlatform string, groupIDs []int64) error
@@ -433,6 +435,7 @@ type adminServiceImpl struct {
433435
settingService *SettingService
434436
defaultSubAssigner DefaultSubscriptionAssigner
435437
userSubRepo UserSubscriptionRepository
438+
privacyClientFactory PrivacyClientFactory
436439
}
437440

438441
type userGroupRateBatchReader interface {
@@ -461,6 +464,7 @@ func NewAdminService(
461464
settingService *SettingService,
462465
defaultSubAssigner DefaultSubscriptionAssigner,
463466
userSubRepo UserSubscriptionRepository,
467+
privacyClientFactory PrivacyClientFactory,
464468
) AdminService {
465469
return &adminServiceImpl{
466470
userRepo: userRepo,
@@ -479,6 +483,7 @@ func NewAdminService(
479483
settingService: settingService,
480484
defaultSubAssigner: defaultSubAssigner,
481485
userSubRepo: userSubRepo,
486+
privacyClientFactory: privacyClientFactory,
482487
}
483488
}
484489

@@ -1420,13 +1425,30 @@ func (s *adminServiceImpl) CreateAccount(ctx context.Context, input *CreateAccou
14201425
}
14211426
}
14221427

1428+
// OpenAI OAuth: attempt to disable training data sharing
1429+
extra := input.Extra
1430+
if input.Platform == PlatformOpenAI && input.Type == AccountTypeOAuth {
1431+
if token, _ := input.Credentials["access_token"].(string); token != "" {
1432+
var proxyURL string
1433+
if input.ProxyID != nil {
1434+
if p, err := s.proxyRepo.GetByID(ctx, *input.ProxyID); err == nil && p != nil {
1435+
proxyURL = p.URL()
1436+
}
1437+
}
1438+
if extra == nil {
1439+
extra = make(map[string]any)
1440+
}
1441+
extra["privacy_mode"] = disableOpenAITraining(ctx, s.privacyClientFactory, token, proxyURL)
1442+
}
1443+
}
1444+
14231445
account := &Account{
14241446
Name: input.Name,
14251447
Notes: normalizeAccountNotes(input.Notes),
14261448
Platform: input.Platform,
14271449
Type: input.Type,
14281450
Credentials: input.Credentials,
1429-
Extra: input.Extra,
1451+
Extra: extra,
14301452
ProxyID: input.ProxyID,
14311453
Concurrency: input.Concurrency,
14321454
Priority: input.Priority,
@@ -2502,3 +2524,39 @@ func (e *MixedChannelError) Error() string {
25022524
func (s *adminServiceImpl) ResetAccountQuota(ctx context.Context, id int64) error {
25032525
return s.accountRepo.ResetQuotaUsed(ctx, id)
25042526
}
2527+
2528+
// EnsureOpenAIPrivacy 检查 OpenAI OAuth 账号是否已设置 privacy_mode,
2529+
// 未设置则调用 disableOpenAITraining 并持久化到 Extra,返回设置的 mode 值。
2530+
func (s *adminServiceImpl) EnsureOpenAIPrivacy(ctx context.Context, account *Account) string {
2531+
if account.Platform != PlatformOpenAI || account.Type != AccountTypeOAuth {
2532+
return ""
2533+
}
2534+
if s.privacyClientFactory == nil {
2535+
return ""
2536+
}
2537+
if account.Extra != nil {
2538+
if _, ok := account.Extra["privacy_mode"]; ok {
2539+
return ""
2540+
}
2541+
}
2542+
2543+
token, _ := account.Credentials["access_token"].(string)
2544+
if token == "" {
2545+
return ""
2546+
}
2547+
2548+
var proxyURL string
2549+
if account.ProxyID != nil {
2550+
if p, err := s.proxyRepo.GetByID(ctx, *account.ProxyID); err == nil && p != nil {
2551+
proxyURL = p.URL()
2552+
}
2553+
}
2554+
2555+
mode := disableOpenAITraining(ctx, s.privacyClientFactory, token, proxyURL)
2556+
if mode == "" {
2557+
return ""
2558+
}
2559+
2560+
_ = s.accountRepo.UpdateExtra(ctx, account.ID, map[string]any{"privacy_mode": mode})
2561+
return mode
2562+
}
Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,77 @@
1+
package service
2+
3+
import (
4+
"context"
5+
"fmt"
6+
"log/slog"
7+
"strings"
8+
"time"
9+
10+
"github.com/imroc/req/v3"
11+
)
12+
13+
// PrivacyClientFactory creates an HTTP client for privacy API calls.
14+
// Injected from repository layer to avoid import cycles.
15+
type PrivacyClientFactory func(proxyURL string) (*req.Client, error)
16+
17+
const (
18+
openAISettingsURL = "https://chatgpt.com/backend-api/settings/account_user_setting"
19+
20+
PrivacyModeTrainingOff = "training_off"
21+
PrivacyModeFailed = "training_set_failed"
22+
PrivacyModeCFBlocked = "training_set_cf_blocked"
23+
)
24+
25+
// disableOpenAITraining calls ChatGPT settings API to turn off "Improve the model for everyone".
26+
// Returns privacy_mode value: "training_off" on success, "cf_blocked" / "failed" on failure.
27+
func disableOpenAITraining(ctx context.Context, clientFactory PrivacyClientFactory, accessToken, proxyURL string) string {
28+
if accessToken == "" || clientFactory == nil {
29+
return ""
30+
}
31+
32+
ctx, cancel := context.WithTimeout(ctx, 15*time.Second)
33+
defer cancel()
34+
35+
client, err := clientFactory(proxyURL)
36+
if err != nil {
37+
slog.Warn("openai_privacy_client_error", "error", err.Error())
38+
return PrivacyModeFailed
39+
}
40+
41+
resp, err := client.R().
42+
SetContext(ctx).
43+
SetHeader("Authorization", "Bearer "+accessToken).
44+
SetHeader("Origin", "https://chatgpt.com").
45+
SetHeader("Referer", "https://chatgpt.com/").
46+
SetQueryParam("feature", "training_allowed").
47+
SetQueryParam("value", "false").
48+
Patch(openAISettingsURL)
49+
50+
if err != nil {
51+
slog.Warn("openai_privacy_request_error", "error", err.Error())
52+
return PrivacyModeFailed
53+
}
54+
55+
if resp.StatusCode == 403 || resp.StatusCode == 503 {
56+
body := resp.String()
57+
if strings.Contains(body, "cloudflare") || strings.Contains(body, "cf-") || strings.Contains(body, "Just a moment") {
58+
slog.Warn("openai_privacy_cf_blocked", "status", resp.StatusCode)
59+
return PrivacyModeCFBlocked
60+
}
61+
}
62+
63+
if !resp.IsSuccessState() {
64+
slog.Warn("openai_privacy_failed", "status", resp.StatusCode, "body", truncate(resp.String(), 200))
65+
return PrivacyModeFailed
66+
}
67+
68+
slog.Info("openai_privacy_training_disabled")
69+
return PrivacyModeTrainingOff
70+
}
71+
72+
func truncate(s string, n int) string {
73+
if len(s) <= n {
74+
return s
75+
}
76+
return s[:n] + fmt.Sprintf("...(%d more)", len(s)-n)
77+
}

backend/internal/service/token_refresh_service.go

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,10 @@ type TokenRefreshService struct {
2121
schedulerCache SchedulerCache // 用于同步更新调度器缓存,解决 token 刷新后缓存不一致问题
2222
tempUnschedCache TempUnschedCache // 用于清除 Redis 中的临时不可调度缓存
2323

24+
// OpenAI privacy: 刷新成功后检查并设置 training opt-out
25+
privacyClientFactory PrivacyClientFactory
26+
proxyRepo ProxyRepository
27+
2428
stopCh chan struct{}
2529
wg sync.WaitGroup
2630
}
@@ -72,6 +76,12 @@ func (s *TokenRefreshService) SetSoraAccountRepo(repo SoraAccountRepository) {
7276
}
7377
}
7478

79+
// SetPrivacyDeps 注入 OpenAI privacy opt-out 所需依赖
80+
func (s *TokenRefreshService) SetPrivacyDeps(factory PrivacyClientFactory, proxyRepo ProxyRepository) {
81+
s.privacyClientFactory = factory
82+
s.proxyRepo = proxyRepo
83+
}
84+
7585
// Start 启动后台刷新服务
7686
func (s *TokenRefreshService) Start() {
7787
if !s.cfg.Enabled {
@@ -277,6 +287,8 @@ func (s *TokenRefreshService) refreshWithRetry(ctx context.Context, account *Acc
277287
slog.Debug("token_refresh.scheduler_cache_synced", "account_id", account.ID)
278288
}
279289
}
290+
// OpenAI OAuth: 刷新成功后,检查是否已设置 privacy_mode,未设置则尝试关闭训练数据共享
291+
s.ensureOpenAIPrivacy(ctx, account)
280292
return nil
281293
}
282294

@@ -341,3 +353,49 @@ func isNonRetryableRefreshError(err error) bool {
341353
}
342354
return false
343355
}
356+
357+
// ensureOpenAIPrivacy 检查 OpenAI OAuth 账号是否已设置 privacy_mode,
358+
// 未设置则调用 disableOpenAITraining 并持久化结果到 Extra。
359+
func (s *TokenRefreshService) ensureOpenAIPrivacy(ctx context.Context, account *Account) {
360+
if account.Platform != PlatformOpenAI || account.Type != AccountTypeOAuth {
361+
return
362+
}
363+
if s.privacyClientFactory == nil {
364+
return
365+
}
366+
// 已设置过则跳过
367+
if account.Extra != nil {
368+
if _, ok := account.Extra["privacy_mode"]; ok {
369+
return
370+
}
371+
}
372+
373+
token, _ := account.Credentials["access_token"].(string)
374+
if token == "" {
375+
return
376+
}
377+
378+
var proxyURL string
379+
if account.ProxyID != nil && s.proxyRepo != nil {
380+
if p, err := s.proxyRepo.GetByID(ctx, *account.ProxyID); err == nil && p != nil {
381+
proxyURL = p.URL()
382+
}
383+
}
384+
385+
mode := disableOpenAITraining(ctx, s.privacyClientFactory, token, proxyURL)
386+
if mode == "" {
387+
return
388+
}
389+
390+
if err := s.accountRepo.UpdateExtra(ctx, account.ID, map[string]any{"privacy_mode": mode}); err != nil {
391+
slog.Warn("token_refresh.update_privacy_mode_failed",
392+
"account_id", account.ID,
393+
"error", err,
394+
)
395+
} else {
396+
slog.Info("token_refresh.privacy_mode_set",
397+
"account_id", account.ID,
398+
"privacy_mode", mode,
399+
)
400+
}
401+
}

0 commit comments

Comments
 (0)