From c13c81f09da0ad1795bb3ee5da6894f446c7d11f Mon Sep 17 00:00:00 2001 From: QTom Date: Fri, 27 Mar 2026 09:37:53 +0800 Subject: [PATCH 1/4] =?UTF-8?q?feat(privacy):=20=E4=B8=BA=20OpenAI=20OAuth?= =?UTF-8?q?=20=E8=B4=A6=E5=8F=B7=E6=B7=BB=E5=8A=A0=E5=89=8D=E7=AB=AF?= =?UTF-8?q?=E6=89=8B=E5=8A=A8=E8=AE=BE=E7=BD=AE=E9=9A=90=E7=A7=81=E6=8C=89?= =?UTF-8?q?=E9=92=AE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 复用已有的 set-privacy API 端点,Handler 通过 platform 分发到 ForceOpenAIPrivacy / ForceAntigravityPrivacy,前端 AccountActionMenu 扩展隐私按钮支持 OpenAI OAuth 账号。 Co-Authored-By: Claude Opus 4.6 (1M context) --- .../internal/handler/admin/account_handler.go | 17 ++++++-- .../handler/admin/admin_service_stub_test.go | 4 ++ backend/internal/service/admin_service.go | 39 +++++++++++++++++++ .../admin/account/AccountActionMenu.vue | 4 +- frontend/src/views/admin/AccountsView.vue | 2 +- 5 files changed, 60 insertions(+), 6 deletions(-) diff --git a/backend/internal/handler/admin/account_handler.go b/backend/internal/handler/admin/account_handler.go index 6711abae..edcd9976 100644 --- a/backend/internal/handler/admin/account_handler.go +++ b/backend/internal/handler/admin/account_handler.go @@ -1896,7 +1896,7 @@ func (h *AccountHandler) GetAvailableModels(c *gin.Context) { response.Success(c, models) } -// SetPrivacy handles setting privacy for a single Antigravity OAuth account +// SetPrivacy handles setting privacy for a single OpenAI/Antigravity OAuth account // POST /api/v1/admin/accounts/:id/set-privacy func (h *AccountHandler) SetPrivacy(c *gin.Context) { accountID, err := strconv.ParseInt(c.Param("id"), 10, 64) @@ -1909,11 +1909,20 @@ func (h *AccountHandler) SetPrivacy(c *gin.Context) { response.NotFound(c, "Account not found") return } - if account.Platform != service.PlatformAntigravity || account.Type != service.AccountTypeOAuth { - response.BadRequest(c, "Only Antigravity OAuth accounts support privacy setting") + if account.Type != service.AccountTypeOAuth { + response.BadRequest(c, "Only OAuth accounts support privacy setting") + return + } + var mode string + switch account.Platform { + case service.PlatformOpenAI: + mode = h.adminService.ForceOpenAIPrivacy(c.Request.Context(), account) + case service.PlatformAntigravity: + mode = h.adminService.ForceAntigravityPrivacy(c.Request.Context(), account) + default: + response.BadRequest(c, "Only OpenAI and Antigravity OAuth accounts support privacy setting") return } - mode := h.adminService.ForceAntigravityPrivacy(c.Request.Context(), account) if mode == "" { response.BadRequest(c, "Cannot set privacy: missing access_token") return diff --git a/backend/internal/handler/admin/admin_service_stub_test.go b/backend/internal/handler/admin/admin_service_stub_test.go index 745c5610..9759cef5 100644 --- a/backend/internal/handler/admin/admin_service_stub_test.go +++ b/backend/internal/handler/admin/admin_service_stub_test.go @@ -449,6 +449,10 @@ func (s *stubAdminService) EnsureAntigravityPrivacy(ctx context.Context, account return "" } +func (s *stubAdminService) ForceOpenAIPrivacy(ctx context.Context, account *service.Account) string { + return "" +} + func (s *stubAdminService) ForceAntigravityPrivacy(ctx context.Context, account *service.Account) string { return "" } diff --git a/backend/internal/service/admin_service.go b/backend/internal/service/admin_service.go index d028cc22..fa175d5d 100644 --- a/backend/internal/service/admin_service.go +++ b/backend/internal/service/admin_service.go @@ -67,6 +67,8 @@ type AdminService interface { EnsureOpenAIPrivacy(ctx context.Context, account *Account) string // EnsureAntigravityPrivacy 检查 Antigravity OAuth 账号 privacy_mode,未设置则调用 setUserSettings 并持久化。 EnsureAntigravityPrivacy(ctx context.Context, account *Account) string + // ForceOpenAIPrivacy 强制重新设置 OpenAI OAuth 账号隐私,无论当前状态。 + ForceOpenAIPrivacy(ctx context.Context, account *Account) string // ForceAntigravityPrivacy 强制重新设置 Antigravity OAuth 账号隐私,无论当前状态。 ForceAntigravityPrivacy(ctx context.Context, account *Account) string SetAccountSchedulable(ctx context.Context, id int64, schedulable bool) (*Account, error) @@ -2664,6 +2666,43 @@ func (s *adminServiceImpl) EnsureOpenAIPrivacy(ctx context.Context, account *Acc return mode } +// ForceOpenAIPrivacy 强制重新设置 OpenAI OAuth 账号隐私,无论当前状态。 +func (s *adminServiceImpl) ForceOpenAIPrivacy(ctx context.Context, account *Account) string { + if account.Platform != PlatformOpenAI || account.Type != AccountTypeOAuth { + return "" + } + if s.privacyClientFactory == nil { + 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 "" + } + + if err := s.accountRepo.UpdateExtra(ctx, account.ID, map[string]any{"privacy_mode": mode}); err != nil { + logger.LegacyPrintf("service.admin", "force_update_openai_privacy_mode_failed: account_id=%d err=%v", account.ID, err) + return mode + } + if account.Extra == nil { + account.Extra = make(map[string]any) + } + account.Extra["privacy_mode"] = mode + return mode +} + // EnsureAntigravityPrivacy 检查 Antigravity OAuth 账号隐私状态。 // 如果 Extra["privacy_mode"] 已存在(无论成功或失败),直接跳过。 // 仅对从未设置过隐私的账号执行 setUserSettings + fetchUserInfo 流程。 diff --git a/frontend/src/components/admin/account/AccountActionMenu.vue b/frontend/src/components/admin/account/AccountActionMenu.vue index e682ddaf..06bd23ab 100644 --- a/frontend/src/components/admin/account/AccountActionMenu.vue +++ b/frontend/src/components/admin/account/AccountActionMenu.vue @@ -32,7 +32,7 @@ {{ t('admin.accounts.refreshToken') }} - @@ -80,6 +80,8 @@ const hasRecoverableState = computed(() => { return props.account?.status === 'error' || Boolean(isRateLimited.value) || Boolean(isOverloaded.value) || Boolean(isTempUnschedulable.value) }) const isAntigravityOAuth = computed(() => props.account?.platform === 'antigravity' && props.account?.type === 'oauth') +const isOpenAIOAuth = computed(() => props.account?.platform === 'openai' && props.account?.type === 'oauth') +const supportsPrivacy = computed(() => isAntigravityOAuth.value || isOpenAIOAuth.value) const hasQuotaLimit = computed(() => { return (props.account?.type === 'apikey' || props.account?.type === 'bedrock') && ( (props.account?.quota_limit ?? 0) > 0 || diff --git a/frontend/src/views/admin/AccountsView.vue b/frontend/src/views/admin/AccountsView.vue index 85f27d69..35e0fcec 100644 --- a/frontend/src/views/admin/AccountsView.vue +++ b/frontend/src/views/admin/AccountsView.vue @@ -1262,7 +1262,7 @@ const handleSetPrivacy = async (a: Account) => { appStore.showSuccess(t('common.success')) } catch (error: any) { console.error('Failed to set privacy:', error) - appStore.showError(error?.response?.data?.message || t('admin.accounts.privacyAntigravityFailed')) + appStore.showError(error?.response?.data?.message || t('admin.accounts.privacyFailed')) } } const handleDelete = (a: Account) => { deletingAcc.value = a; showDeleteDialog.value = true } From 47a544230a1228545aabf272967dfec2b67ee379 Mon Sep 17 00:00:00 2001 From: QTom Date: Fri, 27 Mar 2026 13:35:48 +0800 Subject: [PATCH 2/4] =?UTF-8?q?fix(privacy):=20=E5=88=B7=E6=96=B0=E4=BB=A4?= =?UTF-8?q?=E7=89=8C=E5=A4=B1=E8=B4=A5=E6=97=B6=E4=B9=9F=E5=B0=9D=E8=AF=95?= =?UTF-8?q?=E8=AE=BE=E7=BD=AE=20OpenAI=20=E9=9A=90=E7=A7=81=E6=A8=A1?= =?UTF-8?q?=E5=BC=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 刷新失败不代表 access_token 无效,在后台定时刷新(不可重试错误 + 重试耗尽)和前端批量/单次刷新的失败路径中,均利用可能仍有效的 access_token 调用隐私设置。 Co-Authored-By: Claude Opus 4.6 (1M context) --- backend/internal/handler/admin/account_handler.go | 2 ++ backend/internal/service/token_refresh_service.go | 5 +++++ 2 files changed, 7 insertions(+) diff --git a/backend/internal/handler/admin/account_handler.go b/backend/internal/handler/admin/account_handler.go index edcd9976..2fc1c806 100644 --- a/backend/internal/handler/admin/account_handler.go +++ b/backend/internal/handler/admin/account_handler.go @@ -785,6 +785,8 @@ func (h *AccountHandler) refreshSingleAccount(ctx context.Context, account *serv if account.IsOpenAI() { tokenInfo, err := h.openaiOAuthService.RefreshAccountToken(ctx, account) if err != nil { + // 刷新失败但 access_token 可能仍有效,尝试设置隐私 + h.adminService.EnsureOpenAIPrivacy(ctx, account) return nil, "", err } diff --git a/backend/internal/service/token_refresh_service.go b/backend/internal/service/token_refresh_service.go index b8e56357..eb3e5592 100644 --- a/backend/internal/service/token_refresh_service.go +++ b/backend/internal/service/token_refresh_service.go @@ -300,6 +300,8 @@ func (s *TokenRefreshService) refreshWithRetry(ctx context.Context, account *Acc "error", setErr, ) } + // 刷新失败但 access_token 可能仍有效,尝试设置隐私 + s.ensureOpenAIPrivacy(ctx, account) return err } @@ -327,6 +329,9 @@ func (s *TokenRefreshService) refreshWithRetry(ctx context.Context, account *Acc "error", lastErr, ) + // 刷新失败但 access_token 可能仍有效,尝试设置隐私 + s.ensureOpenAIPrivacy(ctx, account) + // 设置临时不可调度 10 分钟(不标记 error,保持 status=active 让下个刷新周期能继续尝试) until := time.Now().Add(tokenRefreshTempUnschedDuration) reason := fmt.Sprintf("token refresh retry exhausted: %v", lastErr) From c489f23810bd40464fc70e66b0368f23843187b0 Mon Sep 17 00:00:00 2001 From: QTom Date: Fri, 27 Mar 2026 13:54:27 +0800 Subject: [PATCH 3/4] =?UTF-8?q?feat(privacy):=20=E5=88=9B=E5=BB=BA/?= =?UTF-8?q?=E6=89=B9=E9=87=8F=E5=88=9B=E5=BB=BA=20OpenAI=20OAuth=20?= =?UTF-8?q?=E8=B4=A6=E5=8F=B7=E6=97=B6=E5=BC=82=E6=AD=A5=E8=AE=BE=E7=BD=AE?= =?UTF-8?q?=E9=9A=90=E7=A7=81=E6=A8=A1=E5=BC=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 参照 Antigravity 的模式,单个创建时同步调用 ForceOpenAIPrivacy, 批量创建时收集 OpenAI OAuth 账号后异步 goroutine 设置,避免阻塞请求。 Co-Authored-By: Claude Opus 4.6 (1M context) --- .../internal/handler/admin/account_handler.go | 41 +++++++++++++++---- 1 file changed, 32 insertions(+), 9 deletions(-) diff --git a/backend/internal/handler/admin/account_handler.go b/backend/internal/handler/admin/account_handler.go index 2fc1c806..ce5cffe4 100644 --- a/backend/internal/handler/admin/account_handler.go +++ b/backend/internal/handler/admin/account_handler.go @@ -539,6 +539,8 @@ func (h *AccountHandler) Create(c *gin.Context) { } // Antigravity OAuth: 新账号直接设置隐私 h.adminService.ForceAntigravityPrivacy(ctx, account) + // OpenAI OAuth: 新账号直接设置隐私 + h.adminService.ForceOpenAIPrivacy(ctx, account) return h.buildAccountResponseWithRuntime(ctx, account), nil }) if err != nil { @@ -1161,8 +1163,9 @@ func (h *AccountHandler) BatchCreate(c *gin.Context) { success := 0 failed := 0 results := make([]gin.H, 0, len(req.Accounts)) - // 收集需要异步设置隐私的 Antigravity OAuth 账号 - var privacyAccounts []*service.Account + // 收集需要异步设置隐私的 OAuth 账号 + var antigravityPrivacyAccounts []*service.Account + var openaiPrivacyAccounts []*service.Account for _, item := range req.Accounts { if item.RateMultiplier != nil && *item.RateMultiplier < 0 { @@ -1205,9 +1208,14 @@ func (h *AccountHandler) BatchCreate(c *gin.Context) { }) continue } - // 收集 Antigravity OAuth 账号,稍后异步设置隐私 - if account.Platform == service.PlatformAntigravity && account.Type == service.AccountTypeOAuth { - privacyAccounts = append(privacyAccounts, account) + // 收集需要异步设置隐私的 OAuth 账号 + if account.Type == service.AccountTypeOAuth { + switch account.Platform { + case service.PlatformAntigravity: + antigravityPrivacyAccounts = append(antigravityPrivacyAccounts, account) + case service.PlatformOpenAI: + openaiPrivacyAccounts = append(openaiPrivacyAccounts, account) + } } success++ results = append(results, gin.H{ @@ -1217,9 +1225,10 @@ func (h *AccountHandler) BatchCreate(c *gin.Context) { }) } - // 异步设置 Antigravity 隐私,避免批量创建时阻塞请求 - if len(privacyAccounts) > 0 { - adminSvc := h.adminService + // 异步设置隐私,避免批量创建时阻塞请求 + adminSvc := h.adminService + if len(antigravityPrivacyAccounts) > 0 { + accounts := antigravityPrivacyAccounts go func() { defer func() { if r := recover(); r != nil { @@ -1227,11 +1236,25 @@ func (h *AccountHandler) BatchCreate(c *gin.Context) { } }() bgCtx := context.Background() - for _, acc := range privacyAccounts { + for _, acc := range accounts { adminSvc.ForceAntigravityPrivacy(bgCtx, acc) } }() } + if len(openaiPrivacyAccounts) > 0 { + accounts := openaiPrivacyAccounts + go func() { + defer func() { + if r := recover(); r != nil { + slog.Error("batch_create_openai_privacy_panic", "recover", r) + } + }() + bgCtx := context.Background() + for _, acc := range accounts { + adminSvc.ForceOpenAIPrivacy(bgCtx, acc) + } + }() + } return gin.H{ "success": success, From c729ee425f41e5c1bbfc92bf458a3c709570abc2 Mon Sep 17 00:00:00 2001 From: QTom Date: Fri, 27 Mar 2026 14:44:02 +0800 Subject: [PATCH 4/4] =?UTF-8?q?fix(gateway):=20=E4=BF=AE=E5=A4=8D=20OpenAI?= =?UTF-8?q?=E2=86=92Anthropic=20=E8=BD=AC=E6=8D=A2=E8=B7=AF=E5=BE=84=20sys?= =?UTF-8?q?tem=20prompt=20=E8=A2=AB=E9=9D=99=E9=BB=98=E4=B8=A2=E5=BC=83?= =?UTF-8?q?=E7=9A=84=20bug?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit injectClaudeCodePrompt 和 systemIncludesClaudeCodePrompt 的 type switch 无法匹配 json.RawMessage 类型(Go typed nil 陷阱),导致 ForwardAsResponses 和 ForwardAsChatCompletions 路径中用户 system prompt 被替换为仅 Claude Code banner。新增 normalizeSystemParam 将 json.RawMessage 转为标准 Go 类型。 Co-Authored-By: Claude Opus 4.6 (1M context) --- .../internal/service/gateway_prompt_test.go | 44 +++++++++++++++++++ backend/internal/service/gateway_service.go | 20 +++++++++ 2 files changed, 64 insertions(+) diff --git a/backend/internal/service/gateway_prompt_test.go b/backend/internal/service/gateway_prompt_test.go index 52c75d1d..356536b0 100644 --- a/backend/internal/service/gateway_prompt_test.go +++ b/backend/internal/service/gateway_prompt_test.go @@ -124,6 +124,27 @@ func TestSystemIncludesClaudeCodePrompt(t *testing.T) { }, want: false, }, + // json.RawMessage cases (conversion path: ForwardAsResponses / ForwardAsChatCompletions) + { + name: "json.RawMessage string with Claude Code prompt", + system: json.RawMessage(`"` + claudeCodeSystemPrompt + `"`), + want: true, + }, + { + name: "json.RawMessage string without Claude Code prompt", + system: json.RawMessage(`"You are a helpful assistant"`), + want: false, + }, + { + name: "json.RawMessage nil (empty)", + system: json.RawMessage(nil), + want: false, + }, + { + name: "json.RawMessage empty string", + system: json.RawMessage(`""`), + want: false, + }, } for _, tt := range tests { @@ -202,6 +223,29 @@ func TestInjectClaudeCodePrompt(t *testing.T) { wantSystemLen: 1, wantFirstText: claudeCodeSystemPrompt, }, + // json.RawMessage cases (conversion path: ForwardAsResponses / ForwardAsChatCompletions) + { + name: "json.RawMessage string system", + body: `{"model":"claude-3","system":"Custom prompt"}`, + system: json.RawMessage(`"Custom prompt"`), + wantSystemLen: 2, + wantFirstText: claudeCodeSystemPrompt, + wantSecondText: claudePrefix + "\n\nCustom prompt", + }, + { + name: "json.RawMessage nil system", + body: `{"model":"claude-3"}`, + system: json.RawMessage(nil), + wantSystemLen: 1, + wantFirstText: claudeCodeSystemPrompt, + }, + { + name: "json.RawMessage Claude Code prompt (should not duplicate)", + body: `{"model":"claude-3","system":"` + claudeCodeSystemPrompt + `"}`, + system: json.RawMessage(`"` + claudeCodeSystemPrompt + `"`), + wantSystemLen: 1, + wantFirstText: claudeCodeSystemPrompt, + }, } for _, tt := range tests { diff --git a/backend/internal/service/gateway_service.go b/backend/internal/service/gateway_service.go index cb90343b..5b7a97b0 100644 --- a/backend/internal/service/gateway_service.go +++ b/backend/internal/service/gateway_service.go @@ -3749,9 +3749,28 @@ func isClaudeCodeRequest(ctx context.Context, c *gin.Context, parsed *ParsedRequ return isClaudeCodeClient(c.GetHeader("User-Agent"), parsed.MetadataUserID) } +// normalizeSystemParam 将 json.RawMessage 类型的 system 参数转为标准 Go 类型(string / []any / nil), +// 避免 type switch 中 json.RawMessage(底层 []byte)无法匹配 case string / case []any / case nil 的问题。 +// 这是 Go 的 typed nil 陷阱:(json.RawMessage, nil) ≠ (nil, nil)。 +func normalizeSystemParam(system any) any { + raw, ok := system.(json.RawMessage) + if !ok { + return system + } + if len(raw) == 0 { + return nil + } + var parsed any + if err := json.Unmarshal(raw, &parsed); err != nil { + return nil + } + return parsed +} + // systemIncludesClaudeCodePrompt 检查 system 中是否已包含 Claude Code 提示词 // 使用前缀匹配支持多种变体(标准版、Agent SDK 版等) func systemIncludesClaudeCodePrompt(system any) bool { + system = normalizeSystemParam(system) switch v := system.(type) { case string: return hasClaudeCodePrefix(v) @@ -3780,6 +3799,7 @@ func hasClaudeCodePrefix(text string) bool { // injectClaudeCodePrompt 在 system 开头注入 Claude Code 提示词 // 处理 null、字符串、数组三种格式 func injectClaudeCodePrompt(body []byte, system any) []byte { + system = normalizeSystemParam(system) claudeCodeBlock, err := marshalAnthropicSystemTextBlock(claudeCodeSystemPrompt, true) if err != nil { logger.LegacyPrintf("service.gateway", "Warning: failed to build Claude Code prompt block: %v", err)