From 703952c9cbe75b9d5dd281d93c1fd874a2860482 Mon Sep 17 00:00:00 2001 From: PA733 Date: Thu, 28 May 2026 20:04:51 +0800 Subject: [PATCH 1/2] fix: set privacy return real error --- .../internal/handler/admin/account_handler.go | 54 +++++++++++-- .../handler/admin/admin_service_stub_test.go | 8 ++ backend/internal/service/admin_service.go | 76 ++++++++++++++----- .../service/antigravity_privacy_service.go | 65 ++++++++++++++-- .../service/openai_privacy_service.go | 64 ++++++++++++++-- frontend/src/i18n/locales/en.ts | 1 + frontend/src/i18n/locales/zh.ts | 1 + frontend/src/utils/apiError.ts | 3 +- frontend/src/views/admin/AccountsView.vue | 44 ++++++++++- 9 files changed, 272 insertions(+), 44 deletions(-) diff --git a/backend/internal/handler/admin/account_handler.go b/backend/internal/handler/admin/account_handler.go index 4f566a8be9f..aca28cc0562 100644 --- a/backend/internal/handler/admin/account_handler.go +++ b/backend/internal/handler/admin/account_handler.go @@ -2148,18 +2148,22 @@ func (h *AccountHandler) SetPrivacy(c *gin.Context) { response.BadRequest(c, "Only OAuth accounts support privacy setting") return } - var mode string + var privacyResult service.PrivacySetResult switch account.Platform { case service.PlatformOpenAI: - mode = h.adminService.ForceOpenAIPrivacy(c.Request.Context(), account) + privacyResult = h.adminService.ForceOpenAIPrivacyDetailed(c.Request.Context(), account) case service.PlatformAntigravity: - mode = h.adminService.ForceAntigravityPrivacy(c.Request.Context(), account) + privacyResult = h.adminService.ForceAntigravityPrivacyDetailed(c.Request.Context(), account) default: response.BadRequest(c, "Only OpenAI and Antigravity OAuth accounts support privacy setting") return } - if mode == "" { - response.BadRequest(c, "Cannot set privacy: missing access_token") + if privacyResult.Mode == "" { + message := privacyResult.Message + if message == "" { + message = "Cannot set privacy" + } + response.ErrorWithDetails(c, http.StatusBadRequest, message, privacyResult.Reason, privacyResultMetadata(account, privacyResult)) return } // 从 DB 重新读取以确保返回最新状态 @@ -2169,13 +2173,51 @@ func (h *AccountHandler) SetPrivacy(c *gin.Context) { if account.Extra == nil { account.Extra = make(map[string]any) } - account.Extra["privacy_mode"] = mode + account.Extra["privacy_mode"] = privacyResult.Mode + if !privacyResult.Success { + response.ErrorWithDetails(c, http.StatusBadGateway, privacyResult.Message, privacyFailureReason(privacyResult), privacyResultMetadata(account, privacyResult)) + return + } response.Success(c, h.buildAccountResponseWithRuntime(c.Request.Context(), account)) return } + if !privacyResult.Success { + response.ErrorWithDetails(c, http.StatusBadGateway, privacyResult.Message, privacyFailureReason(privacyResult), privacyResultMetadata(updated, privacyResult)) + return + } response.Success(c, h.buildAccountResponseWithRuntime(c.Request.Context(), updated)) } +func privacyFailureReason(result service.PrivacySetResult) string { + if strings.TrimSpace(result.Reason) != "" { + return result.Reason + } + return "PRIVACY_SET_FAILED" +} + +func privacyResultMetadata(account *service.Account, result service.PrivacySetResult) map[string]string { + metadata := map[string]string{ + "privacy_mode": result.Mode, + "stage": result.Stage, + } + if account != nil { + metadata["account_id"] = strconv.FormatInt(account.ID, 10) + metadata["platform"] = account.Platform + } + if result.StatusCode != 0 { + metadata["status_code"] = strconv.Itoa(result.StatusCode) + } + if strings.TrimSpace(result.Detail) != "" { + metadata["detail"] = result.Detail + } + for key, value := range metadata { + if strings.TrimSpace(value) == "" { + delete(metadata, key) + } + } + return metadata +} + // RefreshTier handles refreshing Google One tier for a single account // POST /api/v1/admin/accounts/:id/refresh-tier func (h *AccountHandler) RefreshTier(c *gin.Context) { diff --git a/backend/internal/handler/admin/admin_service_stub_test.go b/backend/internal/handler/admin/admin_service_stub_test.go index fd0ec459ec4..ef0a63b91b2 100644 --- a/backend/internal/handler/admin/admin_service_stub_test.go +++ b/backend/internal/handler/admin/admin_service_stub_test.go @@ -616,10 +616,18 @@ func (s *stubAdminService) ForceOpenAIPrivacy(ctx context.Context, account *serv return "" } +func (s *stubAdminService) ForceOpenAIPrivacyDetailed(ctx context.Context, account *service.Account) service.PrivacySetResult { + return service.PrivacySetResult{} +} + func (s *stubAdminService) ForceAntigravityPrivacy(ctx context.Context, account *service.Account) string { return "" } +func (s *stubAdminService) ForceAntigravityPrivacyDetailed(ctx context.Context, account *service.Account) service.PrivacySetResult { + return service.PrivacySetResult{} +} + func (s *stubAdminService) ReplaceUserGroup(ctx context.Context, userID, oldGroupID, newGroupID int64) (*service.ReplaceUserGroupResult, error) { return &service.ReplaceUserGroupResult{MigratedKeys: 0}, nil } diff --git a/backend/internal/service/admin_service.go b/backend/internal/service/admin_service.go index d46b636f2cb..6d652ce9145 100644 --- a/backend/internal/service/admin_service.go +++ b/backend/internal/service/admin_service.go @@ -90,8 +90,10 @@ type AdminService interface { EnsureAntigravityPrivacy(ctx context.Context, account *Account) string // ForceOpenAIPrivacy 强制重新设置 OpenAI OAuth 账号隐私,无论当前状态。 ForceOpenAIPrivacy(ctx context.Context, account *Account) string + ForceOpenAIPrivacyDetailed(ctx context.Context, account *Account) PrivacySetResult // ForceAntigravityPrivacy 强制重新设置 Antigravity OAuth 账号隐私,无论当前状态。 ForceAntigravityPrivacy(ctx context.Context, account *Account) string + ForceAntigravityPrivacyDetailed(ctx context.Context, account *Account) PrivacySetResult 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 @@ -3702,16 +3704,32 @@ func (s *adminServiceImpl) EnsureOpenAIPrivacy(ctx context.Context, account *Acc // ForceOpenAIPrivacy 强制重新设置 OpenAI OAuth 账号隐私,无论当前状态。 func (s *adminServiceImpl) ForceOpenAIPrivacy(ctx context.Context, account *Account) string { + return s.ForceOpenAIPrivacyDetailed(ctx, account).Mode +} + +func (s *adminServiceImpl) ForceOpenAIPrivacyDetailed(ctx context.Context, account *Account) PrivacySetResult { if account.Platform != PlatformOpenAI || account.Type != AccountTypeOAuth { - return "" + return PrivacySetResult{ + Reason: "PRIVACY_UNSUPPORTED_ACCOUNT", + Message: "Only OpenAI OAuth accounts support OpenAI privacy setting", + Stage: "precheck", + } } if s.privacyClientFactory == nil { - return "" + return PrivacySetResult{ + Reason: "PRIVACY_CLIENT_UNAVAILABLE", + Message: "Privacy client is not configured", + Stage: "precheck", + } } token, _ := account.Credentials["access_token"].(string) if token == "" { - return "" + return PrivacySetResult{ + Reason: "PRIVACY_SET_NOT_EXECUTABLE", + Message: "Cannot set privacy: missing access_token", + Stage: "precheck", + } } var proxyURL string @@ -3721,20 +3739,24 @@ func (s *adminServiceImpl) ForceOpenAIPrivacy(ctx context.Context, account *Acco } } - mode := disableOpenAITraining(ctx, s.privacyClientFactory, token, proxyURL) - if mode == "" { - return "" + result := disableOpenAITrainingDetailed(ctx, s.privacyClientFactory, token, proxyURL) + if result.Mode == "" { + return result } - if err := s.accountRepo.UpdateExtra(ctx, account.ID, map[string]any{"privacy_mode": mode}); err != nil { + if err := s.accountRepo.UpdateExtra(ctx, account.ID, map[string]any{"privacy_mode": result.Mode}); err != nil { logger.LegacyPrintf("service.admin", "force_update_openai_privacy_mode_failed: account_id=%d err=%v", account.ID, err) - return mode + result.Success = false + result.Reason = "PRIVACY_PERSIST_FAILED" + result.Message = "Privacy setting completed upstream but failed to persist privacy_mode" + result.Detail = strings.TrimSpace(result.Detail + " persist privacy_mode failed: " + err.Error()) + return result } if account.Extra == nil { account.Extra = make(map[string]any) } - account.Extra["privacy_mode"] = mode - return mode + account.Extra["privacy_mode"] = result.Mode + return result } // EnsureAntigravityPrivacy 检查 Antigravity OAuth 账号隐私状态。 @@ -3779,13 +3801,25 @@ func (s *adminServiceImpl) EnsureAntigravityPrivacy(ctx context.Context, account // ForceAntigravityPrivacy 强制重新设置 Antigravity OAuth 账号隐私,无论当前状态。 func (s *adminServiceImpl) ForceAntigravityPrivacy(ctx context.Context, account *Account) string { + return s.ForceAntigravityPrivacyDetailed(ctx, account).Mode +} + +func (s *adminServiceImpl) ForceAntigravityPrivacyDetailed(ctx context.Context, account *Account) PrivacySetResult { if account.Platform != PlatformAntigravity || account.Type != AccountTypeOAuth { - return "" + return PrivacySetResult{ + Reason: "PRIVACY_UNSUPPORTED_ACCOUNT", + Message: "Only Antigravity OAuth accounts support Antigravity privacy setting", + Stage: "precheck", + } } token, _ := account.Credentials["access_token"].(string) if token == "" { - return "" + return PrivacySetResult{ + Reason: "PRIVACY_SET_NOT_EXECUTABLE", + Message: "Cannot set privacy: missing access_token", + Stage: "precheck", + } } projectID, _ := account.Credentials["project_id"].(string) @@ -3797,15 +3831,19 @@ func (s *adminServiceImpl) ForceAntigravityPrivacy(ctx context.Context, account } } - mode := setAntigravityPrivacy(ctx, token, projectID, proxyURL) - if mode == "" { - return "" + result := setAntigravityPrivacyDetailed(ctx, token, projectID, proxyURL) + if result.Mode == "" { + return result } - if err := s.accountRepo.UpdateExtra(ctx, account.ID, map[string]any{"privacy_mode": mode}); err != nil { + if err := s.accountRepo.UpdateExtra(ctx, account.ID, map[string]any{"privacy_mode": result.Mode}); err != nil { logger.LegacyPrintf("service.admin", "force_update_antigravity_privacy_mode_failed: account_id=%d err=%v", account.ID, err) - return mode + result.Success = false + result.Reason = "PRIVACY_PERSIST_FAILED" + result.Message = "Privacy setting completed upstream but failed to persist privacy_mode" + result.Detail = strings.TrimSpace(result.Detail + " persist privacy_mode failed: " + err.Error()) + return result } - applyAntigravityPrivacyMode(account, mode) - return mode + applyAntigravityPrivacyMode(account, result.Mode) + return result } diff --git a/backend/internal/service/antigravity_privacy_service.go b/backend/internal/service/antigravity_privacy_service.go index 50fe07f6fee..0626b5677a5 100644 --- a/backend/internal/service/antigravity_privacy_service.go +++ b/backend/internal/service/antigravity_privacy_service.go @@ -2,6 +2,7 @@ package service import ( "context" + "fmt" "log/slog" "strings" "time" @@ -21,8 +22,16 @@ const ( // // 返回 privacy_mode 值:"privacy_set" 成功,"privacy_set_failed" 失败,空串表示无法执行。 func setAntigravityPrivacy(ctx context.Context, accessToken, projectID, proxyURL string) string { + return setAntigravityPrivacyDetailed(ctx, accessToken, projectID, proxyURL).Mode +} + +func setAntigravityPrivacyDetailed(ctx context.Context, accessToken, projectID, proxyURL string) PrivacySetResult { if accessToken == "" { - return "" + return PrivacySetResult{ + Reason: "PRIVACY_SET_NOT_EXECUTABLE", + Message: "Cannot set privacy: missing access token", + Stage: "precheck", + } } ctx, cancel := context.WithTimeout(ctx, 10*time.Second) @@ -31,41 +40,81 @@ func setAntigravityPrivacy(ctx context.Context, accessToken, projectID, proxyURL client, err := antigravity.NewClient(proxyURL) if err != nil { slog.Warn("antigravity_privacy_client_error", "error", err.Error()) - return AntigravityPrivacyFailed + return PrivacySetResult{ + Mode: AntigravityPrivacyFailed, + Reason: "PRIVACY_CLIENT_ERROR", + Message: "Failed to create privacy API client", + Stage: "client", + Detail: truncate(err.Error(), 300), + } } // 第 1 步:调用 setUserSettings,检查返回值 setResp, err := client.SetUserSettings(ctx, accessToken) if err != nil { slog.Warn("antigravity_privacy_set_failed", "error", err.Error()) - return AntigravityPrivacyFailed + return PrivacySetResult{ + Mode: AntigravityPrivacyFailed, + Reason: "PRIVACY_REQUEST_ERROR", + Message: "setUserSettings request failed", + Stage: "set_user_settings", + Detail: truncate(err.Error(), 300), + } } if !setResp.IsSuccess() { slog.Warn("antigravity_privacy_set_response_not_empty", "user_settings", setResp.UserSettings, ) - return AntigravityPrivacyFailed + return PrivacySetResult{ + Mode: AntigravityPrivacyFailed, + Reason: "PRIVACY_SET_RESPONSE_INVALID", + Message: "setUserSettings did not clear privacy settings", + Stage: "set_user_settings", + Detail: truncate(fmt.Sprintf("userSettings=%v", setResp.UserSettings), 300), + } } // 第 2 步:调用 fetchUserInfo 二次验证隐私是否已生效 if strings.TrimSpace(projectID) == "" { slog.Warn("antigravity_privacy_missing_project_id") - return AntigravityPrivacyFailed + return PrivacySetResult{ + Mode: AntigravityPrivacyFailed, + Reason: "PRIVACY_MISSING_PROJECT_ID", + Message: "Cannot verify privacy setting: missing project_id", + Stage: "verify", + } } userInfo, err := client.FetchUserInfo(ctx, accessToken, projectID) if err != nil { slog.Warn("antigravity_privacy_verify_failed", "error", err.Error()) - return AntigravityPrivacyFailed + return PrivacySetResult{ + Mode: AntigravityPrivacyFailed, + Reason: "PRIVACY_VERIFY_REQUEST_ERROR", + Message: "fetchUserInfo verification request failed", + Stage: "verify", + Detail: truncate(err.Error(), 300), + } } if !userInfo.IsPrivate() { slog.Warn("antigravity_privacy_verify_not_private", "user_settings", userInfo.UserSettings, ) - return AntigravityPrivacyFailed + return PrivacySetResult{ + Mode: AntigravityPrivacyFailed, + Reason: "PRIVACY_VERIFY_NOT_PRIVATE", + Message: "Privacy verification still shows telemetry settings enabled", + Stage: "verify", + Detail: truncate(fmt.Sprintf("userSettings=%v", userInfo.UserSettings), 300), + } } slog.Info("antigravity_privacy_set_success") - return AntigravityPrivacySet + return PrivacySetResult{ + Mode: AntigravityPrivacySet, + Success: true, + Message: "Telemetry and marketing email settings disabled", + Stage: "verify", + } } func applyAntigravityPrivacyMode(account *Account, mode string) { diff --git a/backend/internal/service/openai_privacy_service.go b/backend/internal/service/openai_privacy_service.go index da6dbefc93f..b48e4362ce9 100644 --- a/backend/internal/service/openai_privacy_service.go +++ b/backend/internal/service/openai_privacy_service.go @@ -22,6 +22,16 @@ const ( PrivacyModeCFBlocked = "training_set_cf_blocked" ) +type PrivacySetResult struct { + Mode string + Success bool + Reason string + Message string + StatusCode int + Stage string + Detail string +} + func shouldSkipOpenAIPrivacyEnsure(extra map[string]any) bool { if extra == nil { return false @@ -38,8 +48,16 @@ func shouldSkipOpenAIPrivacyEnsure(extra map[string]any) bool { // 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 { + return disableOpenAITrainingDetailed(ctx, clientFactory, accessToken, proxyURL).Mode +} + +func disableOpenAITrainingDetailed(ctx context.Context, clientFactory PrivacyClientFactory, accessToken, proxyURL string) PrivacySetResult { if accessToken == "" || clientFactory == nil { - return "" + return PrivacySetResult{ + Reason: "PRIVACY_SET_NOT_EXECUTABLE", + Message: "Cannot set privacy: missing access token or privacy client", + Stage: "precheck", + } } ctx, cancel := context.WithTimeout(ctx, 15*time.Second) @@ -48,7 +66,13 @@ func disableOpenAITraining(ctx context.Context, clientFactory PrivacyClientFacto client, err := clientFactory(proxyURL) if err != nil { slog.Warn("openai_privacy_client_error", "error", err.Error()) - return PrivacyModeFailed + return PrivacySetResult{ + Mode: PrivacyModeFailed, + Reason: "PRIVACY_CLIENT_ERROR", + Message: "Failed to create privacy API client", + Stage: "client", + Detail: truncate(err.Error(), 300), + } } resp, err := client.R(). @@ -66,24 +90,50 @@ func disableOpenAITraining(ctx context.Context, clientFactory PrivacyClientFacto if err != nil { slog.Warn("openai_privacy_request_error", "error", err.Error()) - return PrivacyModeFailed + return PrivacySetResult{ + Mode: PrivacyModeFailed, + Reason: "PRIVACY_REQUEST_ERROR", + Message: "Privacy API request failed", + Stage: "request", + Detail: truncate(err.Error(), 300), + } } 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 + return PrivacySetResult{ + Mode: PrivacyModeCFBlocked, + Reason: "PRIVACY_CLOUDFLARE_BLOCKED", + Message: "Privacy API was blocked by Cloudflare", + StatusCode: resp.StatusCode, + Stage: "upstream_response", + Detail: truncate(body, 300), + } } } if !resp.IsSuccessState() { - slog.Warn("openai_privacy_failed", "status", resp.StatusCode, "body", truncate(resp.String(), 200)) - return PrivacyModeFailed + body := resp.String() + slog.Warn("openai_privacy_failed", "status", resp.StatusCode, "body", truncate(body, 200)) + return PrivacySetResult{ + Mode: PrivacyModeFailed, + Reason: "PRIVACY_UPSTREAM_FAILED", + Message: "Privacy API returned a non-success response", + StatusCode: resp.StatusCode, + Stage: "upstream_response", + Detail: truncate(body, 300), + } } slog.Info("openai_privacy_training_disabled") - return PrivacyModeTrainingOff + return PrivacySetResult{ + Mode: PrivacyModeTrainingOff, + Success: true, + Message: "Training data sharing disabled", + Stage: "upstream_response", + } } // ChatGPTAccountInfo 从 chatgpt.com/backend-api/accounts/check 获取的账号信息 diff --git a/frontend/src/i18n/locales/en.ts b/frontend/src/i18n/locales/en.ts index ff5ea651cf8..82fea98ff8b 100644 --- a/frontend/src/i18n/locales/en.ts +++ b/frontend/src/i18n/locales/en.ts @@ -3089,6 +3089,7 @@ export default { privacyFailed: 'Failed to disable training', privacyAntigravitySet: 'Telemetry and marketing emails disabled', privacyAntigravityFailed: 'Privacy setting failed', + privacySetSuccess: 'Privacy settings updated', setPrivacy: 'Set Privacy', subscriptionAbnormal: 'Abnormal', subscriptionExpires: 'Expires', diff --git a/frontend/src/i18n/locales/zh.ts b/frontend/src/i18n/locales/zh.ts index b8ac7d2cc24..ff75b74c4cb 100644 --- a/frontend/src/i18n/locales/zh.ts +++ b/frontend/src/i18n/locales/zh.ts @@ -3127,6 +3127,7 @@ export default { privacyFailed: '关闭训练数据共享失败', privacyAntigravitySet: '已关闭遥测和营销邮件', privacyAntigravityFailed: '隐私设置失败', + privacySetSuccess: '隐私设置已更新', setPrivacy: '设置隐私', subscriptionAbnormal: '异常', subscriptionExpires: '到期', diff --git a/frontend/src/utils/apiError.ts b/frontend/src/utils/apiError.ts index 07a17aca139..87c7c7d9c48 100644 --- a/frontend/src/utils/apiError.ts +++ b/frontend/src/utils/apiError.ts @@ -17,6 +17,7 @@ interface ApiErrorLike { detail?: string message?: string code?: number | string + metadata?: Record } } } @@ -42,7 +43,7 @@ export function extractApiErrorCode(err: unknown): string | undefined { export function extractApiErrorMetadata(err: unknown): Record | undefined { if (!err || typeof err !== 'object') return undefined const e = err as ApiErrorLike - return e.metadata + return e.metadata ?? e.response?.data?.metadata } type TranslateFn = (key: string, params?: Record) => string diff --git a/frontend/src/views/admin/AccountsView.vue b/frontend/src/views/admin/AccountsView.vue index c602225c2a6..8ce3eda7ae8 100644 --- a/frontend/src/views/admin/AccountsView.vue +++ b/frontend/src/views/admin/AccountsView.vue @@ -413,6 +413,7 @@ import Icon from '@/components/icons/Icon.vue' import ErrorPassthroughRulesModal from '@/components/admin/ErrorPassthroughRulesModal.vue' import TLSFingerprintProfilesModal from '@/components/admin/TLSFingerprintProfilesModal.vue' import { buildOpenAIUsageRefreshKey } from '@/utils/accountUsageRefresh' +import { extractApiErrorMessage, extractApiErrorMetadata } from '@/utils/apiError' import { formatDateTime, formatRelativeTime } from '@/utils/format' import type { Account, AccountPlatform, AccountType, Proxy as AccountProxy, AdminGroup, WindowStats, ClaudeModel } from '@/types' @@ -1584,12 +1585,49 @@ const handleSetPrivacy = async (a: Account) => { const updated = await adminAPI.accounts.setPrivacy(a.id) patchAccountInList(updated) enterAutoRefreshSilentWindow() - appStore.showSuccess(t('common.success')) - } catch (error: any) { + const privacyMode = typeof updated.extra?.privacy_mode === 'string' ? updated.extra.privacy_mode : '' + if (isPrivacyModeSuccess(privacyMode)) { + appStore.showSuccess(t('admin.accounts.privacySetSuccess')) + } else { + appStore.showError(formatPrivacyFailureMessage(t('admin.accounts.privacyFailed'), { privacy_mode: privacyMode })) + } + } catch (error: unknown) { console.error('Failed to set privacy:', error) - appStore.showError(error?.response?.data?.message || t('admin.accounts.privacyFailed')) + const metadata = extractApiErrorMetadata(error) + if (typeof metadata?.privacy_mode === 'string') { + patchAccountPrivacyModeInList(a.id, metadata.privacy_mode) + } + const message = extractApiErrorMessage(error, t('admin.accounts.privacyFailed')) + appStore.showError(formatPrivacyFailureMessage(message, metadata), 9000) } } + +const isPrivacyModeSuccess = (mode: string) => mode === 'training_off' || mode === 'privacy_set' + +const patchAccountPrivacyModeInList = (accountID: number, privacyMode: string) => { + const account = accounts.value.find(item => item.id === accountID) + if (!account) return + patchAccountInList({ + ...account, + extra: { + ...(account.extra ?? {}), + privacy_mode: privacyMode, + }, + }) +} + +const formatPrivacyFailureMessage = (message: string, metadata?: Record) => { + const details: string[] = [] + const privacyMode = typeof metadata?.privacy_mode === 'string' ? metadata.privacy_mode : '' + const stage = typeof metadata?.stage === 'string' ? metadata.stage : '' + const statusCode = typeof metadata?.status_code === 'string' ? metadata.status_code : '' + const detail = typeof metadata?.detail === 'string' ? metadata.detail : '' + if (privacyMode) details.push(`mode=${privacyMode}`) + if (stage) details.push(`stage=${stage}`) + if (statusCode) details.push(`status=${statusCode}`) + if (detail) details.push(detail) + return details.length > 0 ? `${message}: ${details.join(' | ')}` : message +} const handleDelete = (a: Account) => { deletingAcc.value = a; showDeleteDialog.value = true } const confirmDelete = async () => { if(!deletingAcc.value) return; try { await adminAPI.accounts.delete(deletingAcc.value.id); showDeleteDialog.value = false; deletingAcc.value = null; reload() } catch (error) { console.error('Failed to delete account:', error) } } const handleToggleSchedulable = async (a: Account) => { From 3aeb3d84b30c6fb6c69e13e722a9e01b724b8de4 Mon Sep 17 00:00:00 2001 From: PA733 Date: Thu, 28 May 2026 21:31:04 +0800 Subject: [PATCH 2/2] refactor: enhance code quality --- .../admin/account_handler_privacy_test.go | 126 ++++++++++++++++++ .../handler/admin/admin_service_stub_test.go | 11 +- backend/internal/service/admin_service.go | 7 + .../service/openai_privacy_retry_test.go | 27 ++++ .../service/openai_privacy_service.go | 3 +- frontend/src/views/admin/AccountsView.vue | 9 +- 6 files changed, 172 insertions(+), 11 deletions(-) create mode 100644 backend/internal/handler/admin/account_handler_privacy_test.go diff --git a/backend/internal/handler/admin/account_handler_privacy_test.go b/backend/internal/handler/admin/account_handler_privacy_test.go new file mode 100644 index 00000000000..07a81cbe632 --- /dev/null +++ b/backend/internal/handler/admin/account_handler_privacy_test.go @@ -0,0 +1,126 @@ +package admin + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + + "github.com/Wei-Shaw/sub2api/internal/pkg/response" + "github.com/Wei-Shaw/sub2api/internal/service" + "github.com/gin-gonic/gin" + "github.com/stretchr/testify/require" +) + +func setupAccountPrivacyRouter(adminSvc *stubAdminService) *gin.Engine { + gin.SetMode(gin.TestMode) + router := gin.New() + handler := NewAccountHandler(adminSvc, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil) + router.POST("/api/v1/admin/accounts/:id/set-privacy", handler.SetPrivacy) + return router +} + +func TestAccountHandlerSetPrivacyNotExecutableReturnsBadRequest(t *testing.T) { + adminSvc := newStubAdminService() + adminSvc.getAccountValue = &service.Account{ + ID: 11, + Name: "openai-oauth", + Platform: service.PlatformOpenAI, + Type: service.AccountTypeOAuth, + Status: service.StatusActive, + } + adminSvc.forceOpenAIPrivacy = service.PrivacySetResult{ + Reason: "PRIVACY_SET_NOT_EXECUTABLE", + Message: "Cannot set privacy: missing access_token", + Stage: "precheck", + } + + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodPost, "/api/v1/admin/accounts/11/set-privacy", nil) + setupAccountPrivacyRouter(adminSvc).ServeHTTP(rec, req) + + require.Equal(t, http.StatusBadRequest, rec.Code) + var body response.Response + require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &body)) + require.Equal(t, http.StatusBadRequest, body.Code) + require.Equal(t, "Cannot set privacy: missing access_token", body.Message) + require.Equal(t, "PRIVACY_SET_NOT_EXECUTABLE", body.Reason) + require.Equal(t, map[string]string{ + "account_id": "11", + "platform": service.PlatformOpenAI, + "stage": "precheck", + }, body.Metadata) +} + +func TestAccountHandlerSetPrivacyFailureReturnsBadGatewayWithMetadata(t *testing.T) { + adminSvc := newStubAdminService() + adminSvc.getAccountValue = &service.Account{ + ID: 12, + Name: "openai-oauth", + Platform: service.PlatformOpenAI, + Type: service.AccountTypeOAuth, + Status: service.StatusActive, + Extra: map[string]any{ + "privacy_mode": service.PrivacyModeFailed, + }, + } + adminSvc.forceOpenAIPrivacy = service.PrivacySetResult{ + Mode: service.PrivacyModeFailed, + Reason: "PRIVACY_UPSTREAM_FAILED", + Message: "Privacy API returned a non-success response", + StatusCode: http.StatusUnauthorized, + Stage: "upstream_response", + Detail: "upstream said no", + } + + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodPost, "/api/v1/admin/accounts/12/set-privacy", nil) + setupAccountPrivacyRouter(adminSvc).ServeHTTP(rec, req) + + require.Equal(t, http.StatusBadGateway, rec.Code) + var body response.Response + require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &body)) + require.Equal(t, http.StatusBadGateway, body.Code) + require.Equal(t, "Privacy API returned a non-success response", body.Message) + require.Equal(t, "PRIVACY_UPSTREAM_FAILED", body.Reason) + require.Equal(t, map[string]string{ + "account_id": "12", + "detail": "upstream said no", + "platform": service.PlatformOpenAI, + "privacy_mode": service.PrivacyModeFailed, + "stage": "upstream_response", + "status_code": "401", + }, body.Metadata) +} + +func TestAccountHandlerSetPrivacySuccessReturnsAccount(t *testing.T) { + adminSvc := newStubAdminService() + adminSvc.getAccountValue = &service.Account{ + ID: 13, + Name: "openai-oauth", + Platform: service.PlatformOpenAI, + Type: service.AccountTypeOAuth, + Status: service.StatusActive, + Extra: map[string]any{ + "privacy_mode": service.PrivacyModeTrainingOff, + }, + } + adminSvc.forceOpenAIPrivacy = service.PrivacySetResult{ + Mode: service.PrivacyModeTrainingOff, + Success: true, + Message: "Training data sharing disabled", + Stage: "upstream_response", + } + + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodPost, "/api/v1/admin/accounts/13/set-privacy", nil) + setupAccountPrivacyRouter(adminSvc).ServeHTTP(rec, req) + + require.Equal(t, http.StatusOK, rec.Code) + var body response.Response + require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &body)) + require.Equal(t, 0, body.Code) + require.Equal(t, "success", body.Message) + require.Empty(t, body.Reason) + require.Nil(t, body.Metadata) +} diff --git a/backend/internal/handler/admin/admin_service_stub_test.go b/backend/internal/handler/admin/admin_service_stub_test.go index ef0a63b91b2..6341fc70378 100644 --- a/backend/internal/handler/admin/admin_service_stub_test.go +++ b/backend/internal/handler/admin/admin_service_stub_test.go @@ -29,6 +29,9 @@ type stubAdminService struct { updateAccountErr error bulkUpdateAccountErr error checkMixedErr error + getAccountValue *service.Account + forceOpenAIPrivacy service.PrivacySetResult + forceAGPrivacy service.PrivacySetResult lastMixedCheck struct { accountID int64 platform string @@ -324,6 +327,10 @@ func (s *stubAdminService) ListAccounts(ctx context.Context, page, pageSize int, } func (s *stubAdminService) GetAccount(ctx context.Context, id int64) (*service.Account, error) { + if s.getAccountValue != nil { + account := *s.getAccountValue + return &account, nil + } account := service.Account{ID: id, Name: "account", Status: service.StatusActive} return &account, nil } @@ -617,7 +624,7 @@ func (s *stubAdminService) ForceOpenAIPrivacy(ctx context.Context, account *serv } func (s *stubAdminService) ForceOpenAIPrivacyDetailed(ctx context.Context, account *service.Account) service.PrivacySetResult { - return service.PrivacySetResult{} + return s.forceOpenAIPrivacy } func (s *stubAdminService) ForceAntigravityPrivacy(ctx context.Context, account *service.Account) string { @@ -625,7 +632,7 @@ func (s *stubAdminService) ForceAntigravityPrivacy(ctx context.Context, account } func (s *stubAdminService) ForceAntigravityPrivacyDetailed(ctx context.Context, account *service.Account) service.PrivacySetResult { - return service.PrivacySetResult{} + return s.forceAGPrivacy } func (s *stubAdminService) ReplaceUserGroup(ctx context.Context, userID, oldGroupID, newGroupID int64) (*service.ReplaceUserGroupResult, error) { diff --git a/backend/internal/service/admin_service.go b/backend/internal/service/admin_service.go index 6d652ce9145..e8f4c02e3dd 100644 --- a/backend/internal/service/admin_service.go +++ b/backend/internal/service/admin_service.go @@ -3823,6 +3823,13 @@ func (s *adminServiceImpl) ForceAntigravityPrivacyDetailed(ctx context.Context, } projectID, _ := account.Credentials["project_id"].(string) + if strings.TrimSpace(projectID) == "" { + return PrivacySetResult{ + Reason: "PRIVACY_MISSING_PROJECT_ID", + Message: "Cannot verify privacy setting: missing project_id", + Stage: "precheck", + } + } var proxyURL string if account.ProxyID != nil { diff --git a/backend/internal/service/openai_privacy_retry_test.go b/backend/internal/service/openai_privacy_retry_test.go index 24534ea948d..cb032b4fff0 100644 --- a/backend/internal/service/openai_privacy_retry_test.go +++ b/backend/internal/service/openai_privacy_retry_test.go @@ -48,6 +48,33 @@ func TestAdminService_EnsureOpenAIPrivacy_RetriesNonSuccessModes(t *testing.T) { } } +func TestAdminService_ForceAntigravityPrivacyDetailed_MissingProjectIDNotExecutable(t *testing.T) { + t.Parallel() + + svc := &adminServiceImpl{ + accountRepo: &mockAccountRepoForGemini{}, + } + account := &Account{ + ID: 303, + Platform: PlatformAntigravity, + Type: AccountTypeOAuth, + Credentials: map[string]any{ + "access_token": "token-3", + }, + Extra: map[string]any{ + "privacy_mode": AntigravityPrivacySet, + }, + } + + got := svc.ForceAntigravityPrivacyDetailed(context.Background(), account) + + require.Empty(t, got.Mode) + require.False(t, got.Success) + require.Equal(t, "PRIVACY_MISSING_PROJECT_ID", got.Reason) + require.Equal(t, "precheck", got.Stage) + require.Equal(t, AntigravityPrivacySet, account.Extra["privacy_mode"]) +} + func TestTokenRefreshService_ensureOpenAIPrivacy_RetriesNonSuccessModes(t *testing.T) { t.Parallel() diff --git a/backend/internal/service/openai_privacy_service.go b/backend/internal/service/openai_privacy_service.go index b48e4362ce9..94fadc8a1d9 100644 --- a/backend/internal/service/openai_privacy_service.go +++ b/backend/internal/service/openai_privacy_service.go @@ -46,7 +46,8 @@ func shouldSkipOpenAIPrivacyEnsure(extra map[string]any) bool { } // 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. +// Returns privacy_mode value: PrivacyModeTrainingOff on success, PrivacyModeCFBlocked +// or PrivacyModeFailed on failure, and an empty string when the operation cannot run. func disableOpenAITraining(ctx context.Context, clientFactory PrivacyClientFactory, accessToken, proxyURL string) string { return disableOpenAITrainingDetailed(ctx, clientFactory, accessToken, proxyURL).Mode } diff --git a/frontend/src/views/admin/AccountsView.vue b/frontend/src/views/admin/AccountsView.vue index 8ce3eda7ae8..7666ac8bf63 100644 --- a/frontend/src/views/admin/AccountsView.vue +++ b/frontend/src/views/admin/AccountsView.vue @@ -1585,12 +1585,7 @@ const handleSetPrivacy = async (a: Account) => { const updated = await adminAPI.accounts.setPrivacy(a.id) patchAccountInList(updated) enterAutoRefreshSilentWindow() - const privacyMode = typeof updated.extra?.privacy_mode === 'string' ? updated.extra.privacy_mode : '' - if (isPrivacyModeSuccess(privacyMode)) { - appStore.showSuccess(t('admin.accounts.privacySetSuccess')) - } else { - appStore.showError(formatPrivacyFailureMessage(t('admin.accounts.privacyFailed'), { privacy_mode: privacyMode })) - } + appStore.showSuccess(t('admin.accounts.privacySetSuccess')) } catch (error: unknown) { console.error('Failed to set privacy:', error) const metadata = extractApiErrorMetadata(error) @@ -1602,8 +1597,6 @@ const handleSetPrivacy = async (a: Account) => { } } -const isPrivacyModeSuccess = (mode: string) => mode === 'training_off' || mode === 'privacy_set' - const patchAccountPrivacyModeInList = (accountID: number, privacyMode: string) => { const account = accounts.value.find(item => item.id === accountID) if (!account) return