From 5e0d78944009c0c7e3027b98037892cb6b84cfbf Mon Sep 17 00:00:00 2001 From: QTom Date: Mon, 9 Feb 2026 10:38:26 +0800 Subject: [PATCH 1/7] =?UTF-8?q?feat(admin):=20=E6=96=B0=E5=A2=9E=20CRS=20?= =?UTF-8?q?=E5=90=8C=E6=AD=A5=E9=A2=84=E8=A7=88=E5=92=8C=E8=B4=A6=E5=8F=B7?= =?UTF-8?q?=E9=80=89=E6=8B=A9=E5=8A=9F=E8=83=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 后端新增 PreviewFromCRS 接口,允许用户先预览 CRS 中的账号 - 后端支持在同步时选择特定账号,不选中的账号将被跳过 - 前端重构 SyncFromCrsModal 为三步向导:输入凭据 → 预览账号 → 执行同步 - 改进表单无障碍性:添加 for/id 关联和 required 属性 - 修复 Back 按钮返回时的状态清理 - 新增 buildSelectedSet 和 shouldCreateAccount 的单元测试 - 完整的向后兼容性:旧客户端不发送 selected_account_ids 时行为不变 --- .../internal/handler/admin/account_handler.go | 46 +++- backend/internal/repository/account_repo.go | 28 ++ backend/internal/server/routes/admin.go | 1 + backend/internal/service/account_service.go | 3 + .../service/account_service_delete_test.go | 4 + .../internal/service/crs_sync_helpers_test.go | 112 ++++++++ backend/internal/service/crs_sync_service.go | 176 +++++++++++- .../service/gateway_multiplatform_test.go | 3 + .../service/gemini_multiplatform_test.go | 3 + frontend/src/api/admin/accounts.ts | 38 ++- .../components/account/SyncFromCrsModal.vue | 256 ++++++++++++++++-- frontend/src/i18n/locales/en.ts | 17 +- frontend/src/i18n/locales/zh.ts | 17 +- 13 files changed, 653 insertions(+), 51 deletions(-) create mode 100644 backend/internal/service/crs_sync_helpers_test.go diff --git a/backend/internal/handler/admin/account_handler.go b/backend/internal/handler/admin/account_handler.go index 9a13b57c..85400c6f 100644 --- a/backend/internal/handler/admin/account_handler.go +++ b/backend/internal/handler/admin/account_handler.go @@ -424,10 +424,17 @@ type TestAccountRequest struct { } type SyncFromCRSRequest struct { - BaseURL string `json:"base_url" binding:"required"` - Username string `json:"username" binding:"required"` - Password string `json:"password" binding:"required"` - SyncProxies *bool `json:"sync_proxies"` + BaseURL string `json:"base_url" binding:"required"` + Username string `json:"username" binding:"required"` + Password string `json:"password" binding:"required"` + SyncProxies *bool `json:"sync_proxies"` + SelectedAccountIDs []string `json:"selected_account_ids"` +} + +type PreviewFromCRSRequest struct { + BaseURL string `json:"base_url" binding:"required"` + Username string `json:"username" binding:"required"` + Password string `json:"password" binding:"required"` } // Test handles testing account connectivity with SSE streaming @@ -466,10 +473,11 @@ func (h *AccountHandler) SyncFromCRS(c *gin.Context) { } result, err := h.crsSyncService.SyncFromCRS(c.Request.Context(), service.SyncFromCRSInput{ - BaseURL: req.BaseURL, - Username: req.Username, - Password: req.Password, - SyncProxies: syncProxies, + BaseURL: req.BaseURL, + Username: req.Username, + Password: req.Password, + SyncProxies: syncProxies, + SelectedAccountIDs: req.SelectedAccountIDs, }) if err != nil { // Provide detailed error message for CRS sync failures @@ -480,6 +488,28 @@ func (h *AccountHandler) SyncFromCRS(c *gin.Context) { response.Success(c, result) } +// PreviewFromCRS handles previewing accounts from CRS before sync +// POST /api/v1/admin/accounts/sync/crs/preview +func (h *AccountHandler) PreviewFromCRS(c *gin.Context) { + var req PreviewFromCRSRequest + if err := c.ShouldBindJSON(&req); err != nil { + response.BadRequest(c, "Invalid request: "+err.Error()) + return + } + + result, err := h.crsSyncService.PreviewFromCRS(c.Request.Context(), service.SyncFromCRSInput{ + BaseURL: req.BaseURL, + Username: req.Username, + Password: req.Password, + }) + if err != nil { + response.InternalError(c, "CRS preview failed: "+err.Error()) + return + } + + response.Success(c, result) +} + // Refresh handles refreshing account credentials // POST /api/v1/admin/accounts/:id/refresh func (h *AccountHandler) Refresh(c *gin.Context) { diff --git a/backend/internal/repository/account_repo.go b/backend/internal/repository/account_repo.go index 7fb7d4ed..d73e0521 100644 --- a/backend/internal/repository/account_repo.go +++ b/backend/internal/repository/account_repo.go @@ -282,6 +282,34 @@ func (r *accountRepository) GetByCRSAccountID(ctx context.Context, crsAccountID return &accounts[0], nil } +func (r *accountRepository) ListCRSAccountIDs(ctx context.Context) (map[string]int64, error) { + rows, err := r.sql.QueryContext(ctx, ` + SELECT id, extra->>'crs_account_id' + FROM accounts + WHERE deleted_at IS NULL + AND extra->>'crs_account_id' IS NOT NULL + AND extra->>'crs_account_id' != '' + `) + if err != nil { + return nil, err + } + defer func() { _ = rows.Close() }() + + result := make(map[string]int64) + for rows.Next() { + var id int64 + var crsID string + if err := rows.Scan(&id, &crsID); err != nil { + return nil, err + } + result[crsID] = id + } + if err := rows.Err(); err != nil { + return nil, err + } + return result, nil +} + func (r *accountRepository) Update(ctx context.Context, account *service.Account) error { if account == nil { return nil diff --git a/backend/internal/server/routes/admin.go b/backend/internal/server/routes/admin.go index bd6788b2..39c5d2fc 100644 --- a/backend/internal/server/routes/admin.go +++ b/backend/internal/server/routes/admin.go @@ -209,6 +209,7 @@ func registerAccountRoutes(admin *gin.RouterGroup, h *handler.Handlers) { accounts.GET("/:id", h.Admin.Account.GetByID) accounts.POST("", h.Admin.Account.Create) accounts.POST("/sync/crs", h.Admin.Account.SyncFromCRS) + accounts.POST("/sync/crs/preview", h.Admin.Account.PreviewFromCRS) accounts.PUT("/:id", h.Admin.Account.Update) accounts.DELETE("/:id", h.Admin.Account.Delete) accounts.POST("/:id/test", h.Admin.Account.Test) diff --git a/backend/internal/service/account_service.go b/backend/internal/service/account_service.go index 9bf58988..6c0cca31 100644 --- a/backend/internal/service/account_service.go +++ b/backend/internal/service/account_service.go @@ -25,6 +25,9 @@ type AccountRepository interface { // GetByCRSAccountID finds an account previously synced from CRS. // Returns (nil, nil) if not found. GetByCRSAccountID(ctx context.Context, crsAccountID string) (*Account, error) + // ListCRSAccountIDs returns a map of crs_account_id -> local account ID + // for all accounts that have been synced from CRS. + ListCRSAccountIDs(ctx context.Context) (map[string]int64, error) Update(ctx context.Context, account *Account) error Delete(ctx context.Context, id int64) error diff --git a/backend/internal/service/account_service_delete_test.go b/backend/internal/service/account_service_delete_test.go index af3a3784..25bd0576 100644 --- a/backend/internal/service/account_service_delete_test.go +++ b/backend/internal/service/account_service_delete_test.go @@ -54,6 +54,10 @@ func (s *accountRepoStub) GetByCRSAccountID(ctx context.Context, crsAccountID st panic("unexpected GetByCRSAccountID call") } +func (s *accountRepoStub) ListCRSAccountIDs(ctx context.Context) (map[string]int64, error) { + panic("unexpected ListCRSAccountIDs call") +} + func (s *accountRepoStub) Update(ctx context.Context, account *Account) error { panic("unexpected Update call") } diff --git a/backend/internal/service/crs_sync_helpers_test.go b/backend/internal/service/crs_sync_helpers_test.go new file mode 100644 index 00000000..0dc05335 --- /dev/null +++ b/backend/internal/service/crs_sync_helpers_test.go @@ -0,0 +1,112 @@ +package service + +import ( + "testing" +) + +func TestBuildSelectedSet(t *testing.T) { + tests := []struct { + name string + ids []string + wantNil bool + wantSize int + }{ + { + name: "nil input returns nil (backward compatible: create all)", + ids: nil, + wantNil: true, + }, + { + name: "empty slice returns empty map (create none)", + ids: []string{}, + wantNil: false, + wantSize: 0, + }, + { + name: "single ID", + ids: []string{"abc-123"}, + wantNil: false, + wantSize: 1, + }, + { + name: "multiple IDs", + ids: []string{"a", "b", "c"}, + wantNil: false, + wantSize: 3, + }, + { + name: "duplicate IDs are deduplicated", + ids: []string{"a", "a", "b"}, + wantNil: false, + wantSize: 2, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := buildSelectedSet(tt.ids) + if tt.wantNil { + if got != nil { + t.Errorf("buildSelectedSet(%v) = %v, want nil", tt.ids, got) + } + return + } + if got == nil { + t.Fatalf("buildSelectedSet(%v) = nil, want non-nil map", tt.ids) + } + if len(got) != tt.wantSize { + t.Errorf("buildSelectedSet(%v) has %d entries, want %d", tt.ids, len(got), tt.wantSize) + } + // Verify all unique IDs are present + for _, id := range tt.ids { + if _, ok := got[id]; !ok { + t.Errorf("buildSelectedSet(%v) missing key %q", tt.ids, id) + } + } + }) + } +} + +func TestShouldCreateAccount(t *testing.T) { + tests := []struct { + name string + crsID string + selectedSet map[string]struct{} + want bool + }{ + { + name: "nil set allows all (backward compatible)", + crsID: "any-id", + selectedSet: nil, + want: true, + }, + { + name: "empty set blocks all", + crsID: "any-id", + selectedSet: map[string]struct{}{}, + want: false, + }, + { + name: "ID in set is allowed", + crsID: "abc-123", + selectedSet: map[string]struct{}{"abc-123": {}, "def-456": {}}, + want: true, + }, + { + name: "ID not in set is blocked", + crsID: "xyz-789", + selectedSet: map[string]struct{}{"abc-123": {}, "def-456": {}}, + want: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := shouldCreateAccount(tt.crsID, tt.selectedSet) + if got != tt.want { + t.Errorf("shouldCreateAccount(%q, %v) = %v, want %v", + tt.crsID, tt.selectedSet, got, tt.want) + } + }) + } +} diff --git a/backend/internal/service/crs_sync_service.go b/backend/internal/service/crs_sync_service.go index a6ccb967..040b2357 100644 --- a/backend/internal/service/crs_sync_service.go +++ b/backend/internal/service/crs_sync_service.go @@ -45,10 +45,11 @@ func NewCRSSyncService( } type SyncFromCRSInput struct { - BaseURL string - Username string - Password string - SyncProxies bool + BaseURL string + Username string + Password string + SyncProxies bool + SelectedAccountIDs []string // if non-empty, only create new accounts with these CRS IDs } type SyncFromCRSItemResult struct { @@ -190,25 +191,27 @@ type crsGeminiAPIKeyAccount struct { Extra map[string]any `json:"extra"` } -func (s *CRSSyncService) SyncFromCRS(ctx context.Context, input SyncFromCRSInput) (*SyncFromCRSResult, error) { +// fetchCRSExport validates the connection parameters, authenticates with CRS, +// and returns the exported accounts. Shared by SyncFromCRS and PreviewFromCRS. +func (s *CRSSyncService) fetchCRSExport(ctx context.Context, baseURL, username, password string) (*crsExportResponse, error) { if s.cfg == nil { return nil, errors.New("config is not available") } - baseURL := strings.TrimSpace(input.BaseURL) + normalizedURL := strings.TrimSpace(baseURL) if s.cfg.Security.URLAllowlist.Enabled { - normalized, err := normalizeBaseURL(baseURL, s.cfg.Security.URLAllowlist.CRSHosts, s.cfg.Security.URLAllowlist.AllowPrivateHosts) + normalized, err := normalizeBaseURL(normalizedURL, s.cfg.Security.URLAllowlist.CRSHosts, s.cfg.Security.URLAllowlist.AllowPrivateHosts) if err != nil { return nil, err } - baseURL = normalized + normalizedURL = normalized } else { - normalized, err := urlvalidator.ValidateURLFormat(baseURL, s.cfg.Security.URLAllowlist.AllowInsecureHTTP) + normalized, err := urlvalidator.ValidateURLFormat(normalizedURL, s.cfg.Security.URLAllowlist.AllowInsecureHTTP) if err != nil { return nil, fmt.Errorf("invalid base_url: %w", err) } - baseURL = normalized + normalizedURL = normalized } - if strings.TrimSpace(input.Username) == "" || strings.TrimSpace(input.Password) == "" { + if strings.TrimSpace(username) == "" || strings.TrimSpace(password) == "" { return nil, errors.New("username and password are required") } @@ -221,12 +224,16 @@ func (s *CRSSyncService) SyncFromCRS(ctx context.Context, input SyncFromCRSInput client = &http.Client{Timeout: 20 * time.Second} } - adminToken, err := crsLogin(ctx, client, baseURL, input.Username, input.Password) + adminToken, err := crsLogin(ctx, client, normalizedURL, username, password) if err != nil { return nil, err } - exported, err := crsExportAccounts(ctx, client, baseURL, adminToken) + return crsExportAccounts(ctx, client, normalizedURL, adminToken) +} + +func (s *CRSSyncService) SyncFromCRS(ctx context.Context, input SyncFromCRSInput) (*SyncFromCRSResult, error) { + exported, err := s.fetchCRSExport(ctx, input.BaseURL, input.Username, input.Password) if err != nil { return nil, err } @@ -241,6 +248,8 @@ func (s *CRSSyncService) SyncFromCRS(ctx context.Context, input SyncFromCRSInput ), } + selectedSet := buildSelectedSet(input.SelectedAccountIDs) + var proxies []Proxy if input.SyncProxies { proxies, _ = s.proxyRepo.ListActive(ctx) @@ -329,6 +338,13 @@ func (s *CRSSyncService) SyncFromCRS(ctx context.Context, input SyncFromCRSInput } if existing == nil { + if !shouldCreateAccount(src.ID, selectedSet) { + item.Action = "skipped" + item.Error = "not selected" + result.Skipped++ + result.Items = append(result.Items, item) + continue + } account := &Account{ Name: defaultName(src.Name, src.ID), Platform: PlatformAnthropic, @@ -446,6 +462,13 @@ func (s *CRSSyncService) SyncFromCRS(ctx context.Context, input SyncFromCRSInput } if existing == nil { + if !shouldCreateAccount(src.ID, selectedSet) { + item.Action = "skipped" + item.Error = "not selected" + result.Skipped++ + result.Items = append(result.Items, item) + continue + } account := &Account{ Name: defaultName(src.Name, src.ID), Platform: PlatformAnthropic, @@ -569,6 +592,13 @@ func (s *CRSSyncService) SyncFromCRS(ctx context.Context, input SyncFromCRSInput } if existing == nil { + if !shouldCreateAccount(src.ID, selectedSet) { + item.Action = "skipped" + item.Error = "not selected" + result.Skipped++ + result.Items = append(result.Items, item) + continue + } account := &Account{ Name: defaultName(src.Name, src.ID), Platform: PlatformOpenAI, @@ -690,6 +720,13 @@ func (s *CRSSyncService) SyncFromCRS(ctx context.Context, input SyncFromCRSInput } if existing == nil { + if !shouldCreateAccount(src.ID, selectedSet) { + item.Action = "skipped" + item.Error = "not selected" + result.Skipped++ + result.Items = append(result.Items, item) + continue + } account := &Account{ Name: defaultName(src.Name, src.ID), Platform: PlatformOpenAI, @@ -798,6 +835,13 @@ func (s *CRSSyncService) SyncFromCRS(ctx context.Context, input SyncFromCRSInput } if existing == nil { + if !shouldCreateAccount(src.ID, selectedSet) { + item.Action = "skipped" + item.Error = "not selected" + result.Skipped++ + result.Items = append(result.Items, item) + continue + } account := &Account{ Name: defaultName(src.Name, src.ID), Platform: PlatformGemini, @@ -909,6 +953,13 @@ func (s *CRSSyncService) SyncFromCRS(ctx context.Context, input SyncFromCRSInput } if existing == nil { + if !shouldCreateAccount(src.ID, selectedSet) { + item.Action = "skipped" + item.Error = "not selected" + result.Skipped++ + result.Items = append(result.Items, item) + continue + } account := &Account{ Name: defaultName(src.Name, src.ID), Platform: PlatformGemini, @@ -1253,3 +1304,102 @@ func (s *CRSSyncService) refreshOAuthToken(ctx context.Context, account *Account return newCredentials } + +// buildSelectedSet converts a slice of selected CRS account IDs to a set for O(1) lookup. +// Returns nil if ids is nil (field not sent → backward compatible: create all). +// Returns an empty map if ids is non-nil but empty (user selected none → create none). +func buildSelectedSet(ids []string) map[string]struct{} { + if ids == nil { + return nil + } + set := make(map[string]struct{}, len(ids)) + for _, id := range ids { + set[id] = struct{}{} + } + return set +} + +// shouldCreateAccount checks if a new CRS account should be created based on user selection. +// Returns true if selectedSet is nil (backward compatible: create all) or if crsID is in the set. +func shouldCreateAccount(crsID string, selectedSet map[string]struct{}) bool { + if selectedSet == nil { + return true + } + _, ok := selectedSet[crsID] + return ok +} + +// PreviewFromCRSResult contains the preview of accounts from CRS before sync. +type PreviewFromCRSResult struct { + NewAccounts []CRSPreviewAccount `json:"new_accounts"` + ExistingAccounts []CRSPreviewAccount `json:"existing_accounts"` +} + +// CRSPreviewAccount represents a single account in the preview result. +type CRSPreviewAccount struct { + CRSAccountID string `json:"crs_account_id"` + Kind string `json:"kind"` + Name string `json:"name"` + Platform string `json:"platform"` + Type string `json:"type"` +} + +// PreviewFromCRS connects to CRS, fetches all accounts, and classifies them +// as new or existing by batch-querying local crs_account_id mappings. +func (s *CRSSyncService) PreviewFromCRS(ctx context.Context, input SyncFromCRSInput) (*PreviewFromCRSResult, error) { + exported, err := s.fetchCRSExport(ctx, input.BaseURL, input.Username, input.Password) + if err != nil { + return nil, err + } + + // Batch query all existing CRS account IDs + existingCRSIDs, err := s.accountRepo.ListCRSAccountIDs(ctx) + if err != nil { + return nil, fmt.Errorf("failed to list existing CRS accounts: %w", err) + } + + result := &PreviewFromCRSResult{ + NewAccounts: make([]CRSPreviewAccount, 0), + ExistingAccounts: make([]CRSPreviewAccount, 0), + } + + classify := func(crsID, kind, name, platform, accountType string) { + preview := CRSPreviewAccount{ + CRSAccountID: crsID, + Kind: kind, + Name: defaultName(name, crsID), + Platform: platform, + Type: accountType, + } + if _, exists := existingCRSIDs[crsID]; exists { + result.ExistingAccounts = append(result.ExistingAccounts, preview) + } else { + result.NewAccounts = append(result.NewAccounts, preview) + } + } + + for _, src := range exported.Data.ClaudeAccounts { + authType := strings.TrimSpace(src.AuthType) + if authType == "" { + authType = AccountTypeOAuth + } + classify(src.ID, src.Kind, src.Name, PlatformAnthropic, authType) + } + for _, src := range exported.Data.ClaudeConsoleAccounts { + classify(src.ID, src.Kind, src.Name, PlatformAnthropic, AccountTypeAPIKey) + } + for _, src := range exported.Data.OpenAIOAuthAccounts { + classify(src.ID, src.Kind, src.Name, PlatformOpenAI, AccountTypeOAuth) + } + for _, src := range exported.Data.OpenAIResponsesAccounts { + classify(src.ID, src.Kind, src.Name, PlatformOpenAI, AccountTypeAPIKey) + } + for _, src := range exported.Data.GeminiOAuthAccounts { + classify(src.ID, src.Kind, src.Name, PlatformGemini, AccountTypeOAuth) + } + for _, src := range exported.Data.GeminiAPIKeyAccounts { + classify(src.ID, src.Kind, src.Name, PlatformGemini, AccountTypeAPIKey) + } + + return result, nil +} diff --git a/backend/internal/service/gateway_multiplatform_test.go b/backend/internal/service/gateway_multiplatform_test.go index 8dbb30ab..b4b93ace 100644 --- a/backend/internal/service/gateway_multiplatform_test.go +++ b/backend/internal/service/gateway_multiplatform_test.go @@ -77,6 +77,9 @@ func (m *mockAccountRepoForPlatform) Create(ctx context.Context, account *Accoun func (m *mockAccountRepoForPlatform) GetByCRSAccountID(ctx context.Context, crsAccountID string) (*Account, error) { return nil, nil } +func (m *mockAccountRepoForPlatform) ListCRSAccountIDs(ctx context.Context) (map[string]int64, error) { + return nil, nil +} func (m *mockAccountRepoForPlatform) Update(ctx context.Context, account *Account) error { return nil } diff --git a/backend/internal/service/gemini_multiplatform_test.go b/backend/internal/service/gemini_multiplatform_test.go index d03b75df..080352ba 100644 --- a/backend/internal/service/gemini_multiplatform_test.go +++ b/backend/internal/service/gemini_multiplatform_test.go @@ -66,6 +66,9 @@ func (m *mockAccountRepoForGemini) Create(ctx context.Context, account *Account) func (m *mockAccountRepoForGemini) GetByCRSAccountID(ctx context.Context, crsAccountID string) (*Account, error) { return nil, nil } +func (m *mockAccountRepoForGemini) ListCRSAccountIDs(ctx context.Context) (map[string]int64, error) { + return nil, nil +} func (m *mockAccountRepoForGemini) Update(ctx context.Context, account *Account) error { return nil } func (m *mockAccountRepoForGemini) Delete(ctx context.Context, id int64) error { return nil } func (m *mockAccountRepoForGemini) List(ctx context.Context, params pagination.PaginationParams) ([]Account, *pagination.PaginationResult, error) { diff --git a/frontend/src/api/admin/accounts.ts b/frontend/src/api/admin/accounts.ts index 0dec4da5..4cb1a6f2 100644 --- a/frontend/src/api/admin/accounts.ts +++ b/frontend/src/api/admin/accounts.ts @@ -327,11 +327,34 @@ export async function getAvailableModels(id: number): Promise { return data } +export interface CRSPreviewAccount { + crs_account_id: string + kind: string + name: string + platform: string + type: string +} + +export interface PreviewFromCRSResult { + new_accounts: CRSPreviewAccount[] + existing_accounts: CRSPreviewAccount[] +} + +export async function previewFromCrs(params: { + base_url: string + username: string + password: string +}): Promise { + const { data } = await apiClient.post('/admin/accounts/sync/crs/preview', params) + return data +} + export async function syncFromCrs(params: { base_url: string username: string password: string sync_proxies?: boolean + selected_account_ids?: string[] }): Promise<{ created: number updated: number @@ -345,7 +368,19 @@ export async function syncFromCrs(params: { error?: string }> }> { - const { data } = await apiClient.post('/admin/accounts/sync/crs', params) + const { data } = await apiClient.post<{ + created: number + updated: number + skipped: number + failed: number + items: Array<{ + crs_account_id: string + kind: string + name: string + action: string + error?: string + }> + }>('/admin/accounts/sync/crs', params) return data } @@ -442,6 +477,7 @@ export const accountsAPI = { batchCreate, batchUpdateCredentials, bulkUpdate, + previewFromCrs, syncFromCrs, exportData, importData, diff --git a/frontend/src/components/account/SyncFromCrsModal.vue b/frontend/src/components/account/SyncFromCrsModal.vue index 4bd0320a..5191b7cc 100644 --- a/frontend/src/components/account/SyncFromCrsModal.vue +++ b/frontend/src/components/account/SyncFromCrsModal.vue @@ -6,15 +6,20 @@ close-on-click-outside @close="handleClose" > -
+ +
{{ t('admin.accounts.syncFromCrsDesc') }}
- 已有账号仅同步 CRS - 返回的字段,缺失字段保持原值;凭据按键合并,不会清空未下发的键;未勾选"同步代理"时保留原有代理。 + {{ t('admin.accounts.crsUpdateBehaviorNote') }}
- +
- - + +
- +
@@ -58,9 +67,101 @@ {{ t('admin.accounts.syncProxies') }}
+ + +
+ +
+
+ {{ t('admin.accounts.crsExistingAccounts') }} + ({{ previewResult.existing_accounts.length }}) +
+
+
+ {{ acc.platform }} / {{ acc.type }} + {{ acc.name }} +
+
+
+ + +
+
+
+ {{ t('admin.accounts.crsNewAccounts') }} + ({{ previewResult.new_accounts.length }}) +
+
+ + +
+
+
+ +
+
+ {{ t('admin.accounts.crsSelectedCount', { count: selectedIds.size }) }} +
+
+ + +
+ {{ t('admin.accounts.syncProxies') }}: + + {{ form.sync_proxies ? t('common.yes') : t('common.no') }} + +
+ + +
+ {{ t('admin.accounts.crsNoNewAccounts') }} + + {{ t('admin.accounts.crsWillUpdate', { count: previewResult.existing_accounts.length }) }} + +
+
+ + +
@@ -84,21 +185,56 @@
- +
@@ -110,6 +246,7 @@ import { useI18n } from 'vue-i18n' import BaseDialog from '@/components/common/BaseDialog.vue' import { useAppStore } from '@/stores/app' import { adminAPI } from '@/api/admin' +import type { PreviewFromCRSResult } from '@/api/admin/accounts' interface Props { show: boolean @@ -126,7 +263,12 @@ const emit = defineEmits() const { t } = useI18n() const appStore = useAppStore() +type Step = 'input' | 'preview' | 'result' +const currentStep = ref('input') +const previewing = ref(false) const syncing = ref(false) +const previewResult = ref(null) +const selectedIds = ref(new Set()) const result = ref> | null>(null) const form = reactive({ @@ -136,28 +278,90 @@ const form = reactive({ sync_proxies: true }) +const hasNewButNoneSelected = computed(() => { + if (!previewResult.value) return false + return previewResult.value.new_accounts.length > 0 && selectedIds.value.size === 0 +}) + const errorItems = computed(() => { if (!result.value?.items) return [] - return result.value.items.filter((i) => i.action === 'failed' || i.action === 'skipped') + return result.value.items.filter( + (i) => i.action === 'failed' || (i.action === 'skipped' && i.error !== 'not selected') + ) }) watch( () => props.show, (open) => { if (open) { + currentStep.value = 'input' + previewResult.value = null + selectedIds.value = new Set() result.value = null + form.base_url = '' + form.username = '' + form.password = '' + form.sync_proxies = true } } ) const handleClose = () => { - // 防止在同步进行中关闭对话框 - if (syncing.value) { + if (syncing.value || previewing.value) { return } emit('close') } +const handleBack = () => { + currentStep.value = 'input' + previewResult.value = null + selectedIds.value = new Set() +} + +const selectAll = () => { + if (!previewResult.value) return + selectedIds.value = new Set(previewResult.value.new_accounts.map((a) => a.crs_account_id)) +} + +const selectNone = () => { + selectedIds.value = new Set() +} + +const toggleSelect = (id: string) => { + const s = new Set(selectedIds.value) + if (s.has(id)) { + s.delete(id) + } else { + s.add(id) + } + selectedIds.value = s +} + +const handlePreview = async () => { + if (!form.base_url.trim() || !form.username.trim() || !form.password.trim()) { + appStore.showError(t('admin.accounts.syncMissingFields')) + return + } + + previewing.value = true + try { + const res = await adminAPI.accounts.previewFromCrs({ + base_url: form.base_url.trim(), + username: form.username.trim(), + password: form.password + }) + previewResult.value = res + // Auto-select all new accounts + selectedIds.value = new Set(res.new_accounts.map((a) => a.crs_account_id)) + currentStep.value = 'preview' + } catch (error: any) { + appStore.showError(error?.message || t('admin.accounts.crsPreviewFailed')) + } finally { + previewing.value = false + } +} + const handleSync = async () => { if (!form.base_url.trim() || !form.username.trim() || !form.password.trim()) { appStore.showError(t('admin.accounts.syncMissingFields')) @@ -170,16 +374,18 @@ const handleSync = async () => { base_url: form.base_url.trim(), username: form.username.trim(), password: form.password, - sync_proxies: form.sync_proxies + sync_proxies: form.sync_proxies, + selected_account_ids: [...selectedIds.value] }) result.value = res + currentStep.value = 'result' if (res.failed > 0) { appStore.showError(t('admin.accounts.syncCompletedWithErrors', res)) } else { appStore.showSuccess(t('admin.accounts.syncCompleted', res)) - emit('synced') } + emit('synced') } catch (error: any) { appStore.showError(error?.message || t('admin.accounts.syncFailed')) } finally { diff --git a/frontend/src/i18n/locales/en.ts b/frontend/src/i18n/locales/en.ts index 3103a23f..a2d42cb1 100644 --- a/frontend/src/i18n/locales/en.ts +++ b/frontend/src/i18n/locales/en.ts @@ -1309,10 +1309,23 @@ export default { syncResult: 'Sync Result', syncResultSummary: 'Created {created}, updated {updated}, skipped {skipped}, failed {failed}', syncErrors: 'Errors / Skipped Details', - syncCompleted: 'Sync completed: created {created}, updated {updated}', + syncCompleted: 'Sync completed: created {created}, updated {updated}, skipped {skipped}', syncCompletedWithErrors: - 'Sync completed with errors: failed {failed} (created {created}, updated {updated})', + 'Sync completed with errors: failed {failed} (created {created}, updated {updated}, skipped {skipped})', syncFailed: 'Sync failed', + crsPreview: 'Preview', + crsPreviewing: 'Previewing...', + crsPreviewFailed: 'Preview failed', + crsExistingAccounts: 'Existing accounts (will be updated)', + crsNewAccounts: 'New accounts (select to sync)', + crsSelectAll: 'Select all', + crsSelectNone: 'Select none', + crsNoNewAccounts: 'All CRS accounts are already synced.', + crsWillUpdate: 'Will update {count} existing accounts.', + crsSelectedCount: '{count} new accounts selected', + crsUpdateBehaviorNote: + 'Existing accounts only sync fields returned by CRS; missing fields keep their current values. Credentials are merged by key — keys not returned by CRS are preserved. Proxies are kept when "Sync proxies" is unchecked.', + crsBack: 'Back', editAccount: 'Edit Account', deleteAccount: 'Delete Account', searchAccounts: 'Search accounts...', diff --git a/frontend/src/i18n/locales/zh.ts b/frontend/src/i18n/locales/zh.ts index a1221318..6d49e169 100644 --- a/frontend/src/i18n/locales/zh.ts +++ b/frontend/src/i18n/locales/zh.ts @@ -1397,9 +1397,22 @@ export default { syncResult: '同步结果', syncResultSummary: '创建 {created},更新 {updated},跳过 {skipped},失败 {failed}', syncErrors: '错误/跳过详情', - syncCompleted: '同步完成:创建 {created},更新 {updated}', - syncCompletedWithErrors: '同步完成但有错误:失败 {failed}(创建 {created},更新 {updated})', + syncCompleted: '同步完成:创建 {created},更新 {updated},跳过 {skipped}', + syncCompletedWithErrors: '同步完成但有错误:失败 {failed}(创建 {created},更新 {updated},跳过 {skipped})', syncFailed: '同步失败', + crsPreview: '预览', + crsPreviewing: '预览中...', + crsPreviewFailed: '预览失败', + crsExistingAccounts: '将自动更新的已有账号', + crsNewAccounts: '新账号(可选择)', + crsSelectAll: '全选', + crsSelectNone: '全不选', + crsNoNewAccounts: '所有 CRS 账号均已同步。', + crsWillUpdate: '将更新 {count} 个已有账号。', + crsSelectedCount: '已选择 {count} 个新账号', + crsUpdateBehaviorNote: + '已有账号仅同步 CRS 返回的字段,缺失字段保持原值;凭据按键合并,不会清空未下发的键;未勾选"同步代理"时保留原有代理。', + crsBack: '返回', editAccount: '编辑账号', deleteAccount: '删除账号', deleteConfirmMessage: "确定要删除账号 '{name}' 吗?", From 04cedce9a1494d10718992339748f4586ee46a9b Mon Sep 17 00:00:00 2001 From: QTom Date: Mon, 9 Feb 2026 11:40:37 +0800 Subject: [PATCH 2/7] =?UTF-8?q?test:=20=E4=B8=BA=20stubAccountRepo=20?= =?UTF-8?q?=E6=B7=BB=E5=8A=A0=20ListCRSAccountIDs=20=E6=96=B9=E6=B3=95?= =?UTF-8?q?=E5=AE=9E=E7=8E=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/internal/server/api_contract_test.go | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/backend/internal/server/api_contract_test.go b/backend/internal/server/api_contract_test.go index 23db0fde..fa6806ae 100644 --- a/backend/internal/server/api_contract_test.go +++ b/backend/internal/server/api_contract_test.go @@ -1049,6 +1049,10 @@ func (s *stubAccountRepo) BulkUpdate(ctx context.Context, ids []int64, updates s return int64(len(ids)), nil } +func (s *stubAccountRepo) ListCRSAccountIDs(ctx context.Context) (map[string]int64, error) { + return nil, errors.New("not implemented") +} + type stubProxyRepo struct{} func (stubProxyRepo) Create(ctx context.Context, proxy *service.Proxy) error { From 51572b5da006bc9cb396bbc2d957e2e7c418ba76 Mon Sep 17 00:00:00 2001 From: shaw Date: Mon, 9 Feb 2026 09:42:29 +0800 Subject: [PATCH 3/7] chore: update version --- backend/cmd/server/VERSION | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/backend/cmd/server/VERSION b/backend/cmd/server/VERSION index bc88be6e..5087e794 100644 --- a/backend/cmd/server/VERSION +++ b/backend/cmd/server/VERSION @@ -1 +1 @@ -0.1.74.7 +0.1.76 \ No newline at end of file From 470b37be7e12d385ebf66acb9d40b7e4a64c5f9a Mon Sep 17 00:00:00 2001 From: erio Date: Mon, 9 Feb 2026 14:33:05 +0800 Subject: [PATCH 4/7] fix: pass platform prop to GroupBadge in GroupSelector GroupBadge in GroupSelector was missing the platform prop, causing all group badges in account edit/detail pages to use fallback colors instead of platform-specific colors (e.g. Claude=orange, Gemini=blue). --- frontend/src/components/common/GroupSelector.vue | 1 + 1 file changed, 1 insertion(+) diff --git a/frontend/src/components/common/GroupSelector.vue b/frontend/src/components/common/GroupSelector.vue index d5f950f2..582b6f0b 100644 --- a/frontend/src/components/common/GroupSelector.vue +++ b/frontend/src/components/common/GroupSelector.vue @@ -22,6 +22,7 @@ /> Date: Mon, 9 Feb 2026 19:54:54 +0800 Subject: [PATCH 5/7] feat: ErrorPolicySkipped returns 500 instead of upstream status code When custom error codes are enabled and the upstream error code is NOT in the configured list, return HTTP 500 to the client instead of transparently forwarding the original status code. Also adds integration test TestCustomErrorCode599 verifying that 429, 500, 503, 401, 403 all return 500 without triggering SetRateLimited or SetError. --- .../service/antigravity_gateway_service.go | 17 +-- .../service/error_policy_integration_test.go | 108 +++++++++++++++++- backend/internal/service/error_policy_test.go | 8 +- .../service/gemini_messages_compat_service.go | 4 +- 4 files changed, 125 insertions(+), 12 deletions(-) diff --git a/backend/internal/service/antigravity_gateway_service.go b/backend/internal/service/antigravity_gateway_service.go index 014b3c86..c295627e 100644 --- a/backend/internal/service/antigravity_gateway_service.go +++ b/backend/internal/service/antigravity_gateway_service.go @@ -371,12 +371,12 @@ urlFallbackLoop: _ = resp.Body.Close() // ★ 统一入口:自定义错误码 + 临时不可调度 - if handled, policyErr := s.applyErrorPolicy(p, resp.StatusCode, resp.Header, respBody); handled { + if handled, outStatus, policyErr := s.applyErrorPolicy(p, resp.StatusCode, resp.Header, respBody); handled { if policyErr != nil { return nil, policyErr } resp = &http.Response{ - StatusCode: resp.StatusCode, + StatusCode: outStatus, Header: resp.Header.Clone(), Body: io.NopCloser(bytes.NewReader(respBody)), } @@ -610,21 +610,22 @@ func (s *AntigravityGatewayService) checkErrorPolicy(ctx context.Context, accoun return s.rateLimitService.CheckErrorPolicy(ctx, account, statusCode, body) } -// applyErrorPolicy 应用错误策略结果,返回是否应终止当前循环 -func (s *AntigravityGatewayService) applyErrorPolicy(p antigravityRetryLoopParams, statusCode int, headers http.Header, respBody []byte) (handled bool, retErr error) { +// applyErrorPolicy 应用错误策略结果,返回是否应终止当前循环及应返回的状态码。 +// ErrorPolicySkipped 时 outStatus 为 500(前端约定:未命中的错误返回 500)。 +func (s *AntigravityGatewayService) applyErrorPolicy(p antigravityRetryLoopParams, statusCode int, headers http.Header, respBody []byte) (handled bool, outStatus int, retErr error) { switch s.checkErrorPolicy(p.ctx, p.account, statusCode, respBody) { case ErrorPolicySkipped: - return true, nil + return true, http.StatusInternalServerError, nil case ErrorPolicyMatched: _ = p.handleError(p.ctx, p.prefix, p.account, statusCode, headers, respBody, p.requestedModel, p.groupID, p.sessionHash, p.isStickySession) - return true, nil + return true, statusCode, nil case ErrorPolicyTempUnscheduled: slog.Info("temp_unschedulable_matched", "prefix", p.prefix, "status_code", statusCode, "account_id", p.account.ID) - return true, &AntigravityAccountSwitchError{OriginalAccountID: p.account.ID, IsStickySession: p.isStickySession} + return true, statusCode, &AntigravityAccountSwitchError{OriginalAccountID: p.account.ID, IsStickySession: p.isStickySession} } - return false, nil + return false, statusCode, nil } // mapAntigravityModel 获取映射后的模型名 diff --git a/backend/internal/service/error_policy_integration_test.go b/backend/internal/service/error_policy_integration_test.go index 9f8ad938..a8b42a2c 100644 --- a/backend/internal/service/error_policy_integration_test.go +++ b/backend/internal/service/error_policy_integration_test.go @@ -116,7 +116,7 @@ func TestRetryLoop_ErrorPolicy_CustomErrorCodes(t *testing.T) { customCodes: []any{float64(500)}, expectHandleError: 0, expectUpstream: 1, - expectStatusCode: 429, + expectStatusCode: 500, }, { name: "500_in_custom_codes_matched", @@ -364,3 +364,109 @@ func TestRetryLoop_ErrorPolicy_NoPolicy_OriginalBehavior(t *testing.T) { require.Equal(t, antigravityMaxRetries, upstream.calls, "should exhaust all retries") require.Equal(t, 1, handleErrorCount, "handleError should be called once after retries exhausted") } + +// --------------------------------------------------------------------------- +// epTrackingRepo — records SetRateLimited / SetError calls for verification. +// --------------------------------------------------------------------------- + +type epTrackingRepo struct { + mockAccountRepoForGemini + rateLimitedCalls int + rateLimitedID int64 + setErrCalls int + setErrID int64 + tempCalls int +} + +func (r *epTrackingRepo) SetRateLimited(_ context.Context, id int64, _ time.Time) error { + r.rateLimitedCalls++ + r.rateLimitedID = id + return nil +} + +func (r *epTrackingRepo) SetError(_ context.Context, id int64, _ string) error { + r.setErrCalls++ + r.setErrID = id + return nil +} + +func (r *epTrackingRepo) SetTempUnschedulable(_ context.Context, _ int64, _ time.Time, _ string) error { + r.tempCalls++ + return nil +} + +// --------------------------------------------------------------------------- +// TestCustomErrorCode599_SkippedErrors_Return500_NoRateLimit +// +// 核心场景:自定义错误码设为 [599](一个不会真正出现的错误码), +// 当上游返回 429/500/503/401 时: +// - 返回给客户端的状态码必须是 500(而不是透传原始状态码) +// - 不调用 SetRateLimited(不进入限流状态) +// - 不调用 SetError(不停止调度) +// - 不调用 handleError +// --------------------------------------------------------------------------- + +func TestCustomErrorCode599_SkippedErrors_Return500_NoRateLimit(t *testing.T) { + errorCodes := []int{429, 500, 503, 401, 403} + + for _, upstreamStatus := range errorCodes { + t.Run(http.StatusText(upstreamStatus), func(t *testing.T) { + saveAndSetBaseURLs(t) + + upstream := &epFixedUpstream{ + statusCode: upstreamStatus, + body: `{"error":"some upstream error"}`, + } + repo := &epTrackingRepo{} + rlSvc := NewRateLimitService(repo, nil, &config.Config{}, nil, nil) + svc := &AntigravityGatewayService{rateLimitService: rlSvc} + + account := &Account{ + ID: 500, + Type: AccountTypeAPIKey, + Platform: PlatformAntigravity, + Schedulable: true, + Status: StatusActive, + Concurrency: 1, + Credentials: map[string]any{ + "custom_error_codes_enabled": true, + "custom_error_codes": []any{float64(599)}, + }, + } + + var handleErrorCount int + p := newRetryParams(account, upstream, func(_ context.Context, _ string, _ *Account, _ int, _ http.Header, _ []byte, _ string, _ int64, _ string, _ bool) *handleModelRateLimitResult { + handleErrorCount++ + return nil + }) + + result, err := svc.antigravityRetryLoop(p) + + // 不应返回 error(Skipped 不触发账号切换) + require.NoError(t, err, "should not return error") + require.NotNil(t, result, "result should not be nil") + require.NotNil(t, result.resp, "response should not be nil") + defer func() { _ = result.resp.Body.Close() }() + + // 状态码必须是 500(不透传原始状态码) + require.Equal(t, http.StatusInternalServerError, result.resp.StatusCode, + "skipped error should return 500, not %d", upstreamStatus) + + // 不调用 handleError + require.Equal(t, 0, handleErrorCount, + "handleError should NOT be called for skipped errors") + + // 不标记限流 + require.Equal(t, 0, repo.rateLimitedCalls, + "SetRateLimited should NOT be called for skipped errors") + + // 不停止调度 + require.Equal(t, 0, repo.setErrCalls, + "SetError should NOT be called for skipped errors") + + // 只调用一次上游(不重试) + require.Equal(t, 1, upstream.calls, + "should call upstream exactly once (no retry)") + }) + } +} diff --git a/backend/internal/service/error_policy_test.go b/backend/internal/service/error_policy_test.go index a8b69c22..9d7d025e 100644 --- a/backend/internal/service/error_policy_test.go +++ b/backend/internal/service/error_policy_test.go @@ -158,6 +158,7 @@ func TestApplyErrorPolicy(t *testing.T) { statusCode int body []byte expectedHandled bool + expectedStatus int // expected outStatus expectedSwitchErr bool // expect *AntigravityAccountSwitchError handleErrorCalls int }{ @@ -171,6 +172,7 @@ func TestApplyErrorPolicy(t *testing.T) { statusCode: 500, body: []byte(`"error"`), expectedHandled: false, + expectedStatus: 500, // passthrough handleErrorCalls: 0, }, { @@ -187,6 +189,7 @@ func TestApplyErrorPolicy(t *testing.T) { statusCode: 500, // not in custom codes body: []byte(`"error"`), expectedHandled: true, + expectedStatus: http.StatusInternalServerError, // skipped → 500 handleErrorCalls: 0, }, { @@ -203,6 +206,7 @@ func TestApplyErrorPolicy(t *testing.T) { statusCode: 500, body: []byte(`"error"`), expectedHandled: true, + expectedStatus: 500, // matched → original status handleErrorCalls: 1, }, { @@ -225,6 +229,7 @@ func TestApplyErrorPolicy(t *testing.T) { statusCode: 503, body: []byte(`overloaded`), expectedHandled: true, + expectedStatus: 503, // temp_unscheduled → original status expectedSwitchErr: true, handleErrorCalls: 0, }, @@ -250,9 +255,10 @@ func TestApplyErrorPolicy(t *testing.T) { isStickySession: true, } - handled, retErr := svc.applyErrorPolicy(p, tt.statusCode, http.Header{}, tt.body) + handled, outStatus, retErr := svc.applyErrorPolicy(p, tt.statusCode, http.Header{}, tt.body) require.Equal(t, tt.expectedHandled, handled, "handled mismatch") + require.Equal(t, tt.expectedStatus, outStatus, "outStatus mismatch") require.Equal(t, tt.handleErrorCalls, handleErrorCount, "handleError call count mismatch") if tt.expectedSwitchErr { diff --git a/backend/internal/service/gemini_messages_compat_service.go b/backend/internal/service/gemini_messages_compat_service.go index d77f6f92..9197021f 100644 --- a/backend/internal/service/gemini_messages_compat_service.go +++ b/backend/internal/service/gemini_messages_compat_service.go @@ -839,7 +839,7 @@ func (s *GeminiMessagesCompatService) Forward(ctx context.Context, c *gin.Contex if upstreamReqID == "" { upstreamReqID = resp.Header.Get("x-goog-request-id") } - return nil, s.writeGeminiMappedError(c, account, resp.StatusCode, upstreamReqID, respBody) + return nil, s.writeGeminiMappedError(c, account, http.StatusInternalServerError, upstreamReqID, respBody) case ErrorPolicyMatched, ErrorPolicyTempUnscheduled: s.handleGeminiUpstreamError(ctx, account, resp.StatusCode, resp.Header, respBody) upstreamReqID := resp.Header.Get(requestIDHeader) @@ -1283,7 +1283,7 @@ func (s *GeminiMessagesCompatService) ForwardNative(ctx context.Context, c *gin. if contentType == "" { contentType = "application/json" } - c.Data(resp.StatusCode, contentType, respBody) + c.Data(http.StatusInternalServerError, contentType, respBody) return nil, fmt.Errorf("gemini upstream error: %d (skipped by error policy)", resp.StatusCode) case ErrorPolicyMatched, ErrorPolicyTempUnscheduled: s.handleGeminiUpstreamError(ctx, account, resp.StatusCode, resp.Header, respBody) From 6892e84ad27fc03e34e11bd41d2894feb325becf Mon Sep 17 00:00:00 2001 From: erio Date: Mon, 9 Feb 2026 18:53:52 +0800 Subject: [PATCH 6/7] fix: skip rate limiting when custom error codes don't match upstream status Add ShouldHandleErrorCode guard at the entry of handleGeminiUpstreamError and AntigravityGatewayService.handleUpstreamError so that accounts with custom error codes (e.g. [599]) are not rate-limited when the upstream returns a non-matching status (e.g. 429). --- backend/internal/service/antigravity_gateway_service.go | 4 ++++ backend/internal/service/gemini_messages_compat_service.go | 4 ++++ 2 files changed, 8 insertions(+) diff --git a/backend/internal/service/antigravity_gateway_service.go b/backend/internal/service/antigravity_gateway_service.go index c295627e..81a1c149 100644 --- a/backend/internal/service/antigravity_gateway_service.go +++ b/backend/internal/service/antigravity_gateway_service.go @@ -2243,6 +2243,10 @@ func (s *AntigravityGatewayService) handleUpstreamError( requestedModel string, groupID int64, sessionHash string, isStickySession bool, ) *handleModelRateLimitResult { + // 遵守自定义错误码策略:未命中则跳过所有限流处理 + if !account.ShouldHandleErrorCode(statusCode) { + return nil + } // 模型级限流处理(优先) result := s.handleModelRateLimit(&handleModelRateLimitParams{ ctx: ctx, diff --git a/backend/internal/service/gemini_messages_compat_service.go b/backend/internal/service/gemini_messages_compat_service.go index 9197021f..335e1f81 100644 --- a/backend/internal/service/gemini_messages_compat_service.go +++ b/backend/internal/service/gemini_messages_compat_service.go @@ -2597,6 +2597,10 @@ func asInt(v any) (int, bool) { } func (s *GeminiMessagesCompatService) handleGeminiUpstreamError(ctx context.Context, account *Account, statusCode int, headers http.Header, body []byte) { + // 遵守自定义错误码策略:未命中则跳过所有限流处理 + if !account.ShouldHandleErrorCode(statusCode) { + return + } if s.rateLimitService != nil && (statusCode == 401 || statusCode == 403 || statusCode == 529) { s.rateLimitService.HandleUpstreamError(ctx, account, statusCode, headers, body) return From a70d37a676aec8f0736a38013b865edd3ddaa754 Mon Sep 17 00:00:00 2001 From: erio Date: Mon, 9 Feb 2026 19:22:32 +0800 Subject: [PATCH 7/7] fix: Gemini error policy check should precede retry logic --- .../service/gemini_messages_compat_service.go | 36 +++++++++++++++++++ 1 file changed, 36 insertions(+) diff --git a/backend/internal/service/gemini_messages_compat_service.go b/backend/internal/service/gemini_messages_compat_service.go index 335e1f81..792c8f4b 100644 --- a/backend/internal/service/gemini_messages_compat_service.go +++ b/backend/internal/service/gemini_messages_compat_service.go @@ -770,6 +770,14 @@ func (s *GeminiMessagesCompatService) Forward(ctx context.Context, c *gin.Contex break } + // 错误策略优先:匹配则跳过重试直接处理。 + if matched, rebuilt := s.checkErrorPolicyInLoop(ctx, account, resp); matched { + resp = rebuilt + break + } else { + resp = rebuilt + } + if resp.StatusCode >= 400 && s.shouldRetryGeminiUpstreamError(account, resp.StatusCode) { respBody, _ := io.ReadAll(io.LimitReader(resp.Body, 2<<20)) _ = resp.Body.Close() @@ -1176,6 +1184,14 @@ func (s *GeminiMessagesCompatService) ForwardNative(ctx context.Context, c *gin. return nil, s.writeGoogleError(c, http.StatusBadGateway, "Upstream request failed after retries: "+safeErr) } + // 错误策略优先:匹配则跳过重试直接处理。 + if matched, rebuilt := s.checkErrorPolicyInLoop(ctx, account, resp); matched { + resp = rebuilt + break + } else { + resp = rebuilt + } + if resp.StatusCode >= 400 && s.shouldRetryGeminiUpstreamError(account, resp.StatusCode) { respBody, _ := io.ReadAll(io.LimitReader(resp.Body, 2<<20)) _ = resp.Body.Close() @@ -1425,6 +1441,26 @@ func (s *GeminiMessagesCompatService) ForwardNative(ctx context.Context, c *gin. }, nil } +// checkErrorPolicyInLoop 在重试循环内预检查错误策略。 +// 返回 true 表示策略已匹配(调用者应 break),resp 已重建可直接使用。 +// 返回 false 表示 ErrorPolicyNone,resp 已重建,调用者继续走重试逻辑。 +func (s *GeminiMessagesCompatService) checkErrorPolicyInLoop( + ctx context.Context, account *Account, resp *http.Response, +) (matched bool, rebuilt *http.Response) { + if resp.StatusCode < 400 || s.rateLimitService == nil { + return false, resp + } + body, _ := io.ReadAll(io.LimitReader(resp.Body, 2<<20)) + _ = resp.Body.Close() + rebuilt = &http.Response{ + StatusCode: resp.StatusCode, + Header: resp.Header.Clone(), + Body: io.NopCloser(bytes.NewReader(body)), + } + policy := s.rateLimitService.CheckErrorPolicy(ctx, account, resp.StatusCode, body) + return policy != ErrorPolicyNone, rebuilt +} + func (s *GeminiMessagesCompatService) shouldRetryGeminiUpstreamError(account *Account, statusCode int) bool { switch statusCode { case 429, 500, 502, 503, 504, 529: