From 8cb7356bbc05025ad5a24c2b75a479747814dfdd Mon Sep 17 00:00:00 2001 From: liuxiongfeng Date: Fri, 13 Feb 2026 03:05:45 +0800 Subject: [PATCH] feat: add mixed-channel precheck flow for antigravity accounts --- .../internal/handler/admin/account_handler.go | 69 +++++-- .../account_handler_mixed_channel_test.go | 147 ++++++++++++++ .../handler/admin/admin_service_stub_test.go | 47 +++-- backend/internal/server/routes/admin.go | 1 + backend/internal/service/admin_service.go | 6 + frontend/src/api/admin/accounts.ts | 15 +- .../components/account/CreateAccountModal.vue | 186 ++++++++++++++---- .../components/account/EditAccountModal.vue | 166 +++++++++++++--- .../src/composables/useMixedChannelWarning.ts | 107 ---------- frontend/src/types/index.ts | 20 ++ 10 files changed, 567 insertions(+), 197 deletions(-) create mode 100644 backend/internal/handler/admin/account_handler_mixed_channel_test.go delete mode 100644 frontend/src/composables/useMixedChannelWarning.ts diff --git a/backend/internal/handler/admin/account_handler.go b/backend/internal/handler/admin/account_handler.go index 85400c6f..efc0ec2f 100644 --- a/backend/internal/handler/admin/account_handler.go +++ b/backend/internal/handler/admin/account_handler.go @@ -133,6 +133,13 @@ type BulkUpdateAccountsRequest struct { ConfirmMixedChannelRisk *bool `json:"confirm_mixed_channel_risk"` // 用户确认混合渠道风险 } +// CheckMixedChannelRequest represents check mixed channel risk request +type CheckMixedChannelRequest struct { + Platform string `json:"platform" binding:"required"` + GroupIDs []int64 `json:"group_ids"` + AccountID *int64 `json:"account_id"` +} + // AccountWithConcurrency extends Account with real-time concurrency info type AccountWithConcurrency struct { *dto.Account @@ -278,6 +285,50 @@ func (h *AccountHandler) GetByID(c *gin.Context) { response.Success(c, dto.AccountFromService(account)) } +// CheckMixedChannel handles checking mixed channel risk for account-group binding. +// POST /api/v1/admin/accounts/check-mixed-channel +func (h *AccountHandler) CheckMixedChannel(c *gin.Context) { + var req CheckMixedChannelRequest + if err := c.ShouldBindJSON(&req); err != nil { + response.BadRequest(c, "Invalid request: "+err.Error()) + return + } + + if len(req.GroupIDs) == 0 { + response.Success(c, gin.H{"has_risk": false}) + return + } + + accountID := int64(0) + if req.AccountID != nil { + accountID = *req.AccountID + } + + err := h.adminService.CheckMixedChannelRisk(c.Request.Context(), accountID, req.Platform, req.GroupIDs) + if err != nil { + var mixedErr *service.MixedChannelError + if errors.As(err, &mixedErr) { + response.Success(c, gin.H{ + "has_risk": true, + "error": "mixed_channel_warning", + "message": mixedErr.Error(), + "details": gin.H{ + "group_id": mixedErr.GroupID, + "group_name": mixedErr.GroupName, + "current_platform": mixedErr.CurrentPlatform, + "other_platform": mixedErr.OtherPlatform, + }, + }) + return + } + + response.ErrorFrom(c, err) + return + } + + response.Success(c, gin.H{"has_risk": false}) +} + // Create handles creating a new account // POST /api/v1/admin/accounts func (h *AccountHandler) Create(c *gin.Context) { @@ -314,17 +365,10 @@ func (h *AccountHandler) Create(c *gin.Context) { // 检查是否为混合渠道错误 var mixedErr *service.MixedChannelError if errors.As(err, &mixedErr) { - // 返回特殊错误码要求确认 + // 创建接口仅返回最小必要字段,详细信息由专门检查接口提供 c.JSON(409, gin.H{ "error": "mixed_channel_warning", "message": mixedErr.Error(), - "details": gin.H{ - "group_id": mixedErr.GroupID, - "group_name": mixedErr.GroupName, - "current_platform": mixedErr.CurrentPlatform, - "other_platform": mixedErr.OtherPlatform, - }, - "require_confirmation": true, }) return } @@ -378,17 +422,10 @@ func (h *AccountHandler) Update(c *gin.Context) { // 检查是否为混合渠道错误 var mixedErr *service.MixedChannelError if errors.As(err, &mixedErr) { - // 返回特殊错误码要求确认 + // 更新接口仅返回最小必要字段,详细信息由专门检查接口提供 c.JSON(409, gin.H{ "error": "mixed_channel_warning", "message": mixedErr.Error(), - "details": gin.H{ - "group_id": mixedErr.GroupID, - "group_name": mixedErr.GroupName, - "current_platform": mixedErr.CurrentPlatform, - "other_platform": mixedErr.OtherPlatform, - }, - "require_confirmation": true, }) return } diff --git a/backend/internal/handler/admin/account_handler_mixed_channel_test.go b/backend/internal/handler/admin/account_handler_mixed_channel_test.go new file mode 100644 index 00000000..ad004844 --- /dev/null +++ b/backend/internal/handler/admin/account_handler_mixed_channel_test.go @@ -0,0 +1,147 @@ +package admin + +import ( + "bytes" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + + "github.com/Wei-Shaw/sub2api/internal/service" + "github.com/gin-gonic/gin" + "github.com/stretchr/testify/require" +) + +func setupAccountMixedChannelRouter(adminSvc *stubAdminService) *gin.Engine { + gin.SetMode(gin.TestMode) + router := gin.New() + accountHandler := NewAccountHandler(adminSvc, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil) + router.POST("/api/v1/admin/accounts/check-mixed-channel", accountHandler.CheckMixedChannel) + router.POST("/api/v1/admin/accounts", accountHandler.Create) + router.PUT("/api/v1/admin/accounts/:id", accountHandler.Update) + return router +} + +func TestAccountHandlerCheckMixedChannelNoRisk(t *testing.T) { + adminSvc := newStubAdminService() + router := setupAccountMixedChannelRouter(adminSvc) + + body, _ := json.Marshal(map[string]any{ + "platform": "antigravity", + "group_ids": []int64{27}, + }) + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodPost, "/api/v1/admin/accounts/check-mixed-channel", bytes.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + router.ServeHTTP(rec, req) + + require.Equal(t, http.StatusOK, rec.Code) + var resp map[string]any + require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &resp)) + require.Equal(t, float64(0), resp["code"]) + data, ok := resp["data"].(map[string]any) + require.True(t, ok) + require.Equal(t, false, data["has_risk"]) + require.Equal(t, int64(0), adminSvc.lastMixedCheck.accountID) + require.Equal(t, "antigravity", adminSvc.lastMixedCheck.platform) + require.Equal(t, []int64{27}, adminSvc.lastMixedCheck.groupIDs) +} + +func TestAccountHandlerCheckMixedChannelWithRisk(t *testing.T) { + adminSvc := newStubAdminService() + adminSvc.checkMixedErr = &service.MixedChannelError{ + GroupID: 27, + GroupName: "claude-max", + CurrentPlatform: "Antigravity", + OtherPlatform: "Anthropic", + } + router := setupAccountMixedChannelRouter(adminSvc) + + body, _ := json.Marshal(map[string]any{ + "platform": "antigravity", + "group_ids": []int64{27}, + "account_id": 99, + }) + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodPost, "/api/v1/admin/accounts/check-mixed-channel", bytes.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + router.ServeHTTP(rec, req) + + require.Equal(t, http.StatusOK, rec.Code) + var resp map[string]any + require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &resp)) + require.Equal(t, float64(0), resp["code"]) + data, ok := resp["data"].(map[string]any) + require.True(t, ok) + require.Equal(t, true, data["has_risk"]) + require.Equal(t, "mixed_channel_warning", data["error"]) + details, ok := data["details"].(map[string]any) + require.True(t, ok) + require.Equal(t, float64(27), details["group_id"]) + require.Equal(t, "claude-max", details["group_name"]) + require.Equal(t, "Antigravity", details["current_platform"]) + require.Equal(t, "Anthropic", details["other_platform"]) + require.Equal(t, int64(99), adminSvc.lastMixedCheck.accountID) +} + +func TestAccountHandlerCreateMixedChannelConflictSimplifiedResponse(t *testing.T) { + adminSvc := newStubAdminService() + adminSvc.createAccountErr = &service.MixedChannelError{ + GroupID: 27, + GroupName: "claude-max", + CurrentPlatform: "Antigravity", + OtherPlatform: "Anthropic", + } + router := setupAccountMixedChannelRouter(adminSvc) + + body, _ := json.Marshal(map[string]any{ + "name": "ag-oauth-1", + "platform": "antigravity", + "type": "oauth", + "credentials": map[string]any{"refresh_token": "rt"}, + "group_ids": []int64{27}, + }) + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodPost, "/api/v1/admin/accounts", bytes.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + router.ServeHTTP(rec, req) + + require.Equal(t, http.StatusConflict, rec.Code) + var resp map[string]any + require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &resp)) + require.Equal(t, "mixed_channel_warning", resp["error"]) + require.Contains(t, resp["message"], "mixed_channel_warning") + _, hasDetails := resp["details"] + _, hasRequireConfirmation := resp["require_confirmation"] + require.False(t, hasDetails) + require.False(t, hasRequireConfirmation) +} + +func TestAccountHandlerUpdateMixedChannelConflictSimplifiedResponse(t *testing.T) { + adminSvc := newStubAdminService() + adminSvc.updateAccountErr = &service.MixedChannelError{ + GroupID: 27, + GroupName: "claude-max", + CurrentPlatform: "Antigravity", + OtherPlatform: "Anthropic", + } + router := setupAccountMixedChannelRouter(adminSvc) + + body, _ := json.Marshal(map[string]any{ + "group_ids": []int64{27}, + }) + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodPut, "/api/v1/admin/accounts/3", bytes.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + router.ServeHTTP(rec, req) + + require.Equal(t, http.StatusConflict, rec.Code) + var resp map[string]any + require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &resp)) + require.Equal(t, "mixed_channel_warning", resp["error"]) + require.Contains(t, resp["message"], "mixed_channel_warning") + _, hasDetails := resp["details"] + _, hasRequireConfirmation := resp["require_confirmation"] + require.False(t, hasDetails) + require.False(t, hasRequireConfirmation) +} diff --git a/backend/internal/handler/admin/admin_service_stub_test.go b/backend/internal/handler/admin/admin_service_stub_test.go index cbbfe942..9c4c253a 100644 --- a/backend/internal/handler/admin/admin_service_stub_test.go +++ b/backend/internal/handler/admin/admin_service_stub_test.go @@ -10,19 +10,27 @@ import ( ) type stubAdminService struct { - users []service.User - apiKeys []service.APIKey - groups []service.Group - accounts []service.Account - proxies []service.Proxy - proxyCounts []service.ProxyWithAccountCount - redeems []service.RedeemCode - createdAccounts []*service.CreateAccountInput - createdProxies []*service.CreateProxyInput - updatedProxyIDs []int64 - updatedProxies []*service.UpdateProxyInput - testedProxyIDs []int64 - mu sync.Mutex + users []service.User + apiKeys []service.APIKey + groups []service.Group + accounts []service.Account + proxies []service.Proxy + proxyCounts []service.ProxyWithAccountCount + redeems []service.RedeemCode + createdAccounts []*service.CreateAccountInput + createdProxies []*service.CreateProxyInput + updatedProxyIDs []int64 + updatedProxies []*service.UpdateProxyInput + testedProxyIDs []int64 + createAccountErr error + updateAccountErr error + checkMixedErr error + lastMixedCheck struct { + accountID int64 + platform string + groupIDs []int64 + } + mu sync.Mutex } func newStubAdminService() *stubAdminService { @@ -188,11 +196,17 @@ func (s *stubAdminService) CreateAccount(ctx context.Context, input *service.Cre s.mu.Lock() s.createdAccounts = append(s.createdAccounts, input) s.mu.Unlock() + if s.createAccountErr != nil { + return nil, s.createAccountErr + } account := service.Account{ID: 300, Name: input.Name, Status: service.StatusActive} return &account, nil } func (s *stubAdminService) UpdateAccount(ctx context.Context, id int64, input *service.UpdateAccountInput) (*service.Account, error) { + if s.updateAccountErr != nil { + return nil, s.updateAccountErr + } account := service.Account{ID: id, Name: input.Name, Status: service.StatusActive} return &account, nil } @@ -224,6 +238,13 @@ func (s *stubAdminService) BulkUpdateAccounts(ctx context.Context, input *servic return &service.BulkUpdateAccountsResult{Success: 1, Failed: 0, SuccessIDs: []int64{1}}, nil } +func (s *stubAdminService) CheckMixedChannelRisk(ctx context.Context, currentAccountID int64, currentAccountPlatform string, groupIDs []int64) error { + s.lastMixedCheck.accountID = currentAccountID + s.lastMixedCheck.platform = currentAccountPlatform + s.lastMixedCheck.groupIDs = append([]int64(nil), groupIDs...) + return s.checkMixedErr +} + func (s *stubAdminService) ListProxies(ctx context.Context, page, pageSize int, protocol, status, search string) ([]service.Proxy, int64, error) { search = strings.TrimSpace(strings.ToLower(search)) filtered := make([]service.Proxy, 0, len(s.proxies)) diff --git a/backend/internal/server/routes/admin.go b/backend/internal/server/routes/admin.go index 4509b4bc..693d997a 100644 --- a/backend/internal/server/routes/admin.go +++ b/backend/internal/server/routes/admin.go @@ -208,6 +208,7 @@ func registerAccountRoutes(admin *gin.RouterGroup, h *handler.Handlers) { accounts.GET("", h.Admin.Account.List) accounts.GET("/:id", h.Admin.Account.GetByID) accounts.POST("", h.Admin.Account.Create) + accounts.POST("/check-mixed-channel", h.Admin.Account.CheckMixedChannel) accounts.POST("/sync/crs", h.Admin.Account.SyncFromCRS) accounts.POST("/sync/crs/preview", h.Admin.Account.PreviewFromCRS) accounts.PUT("/:id", h.Admin.Account.Update) diff --git a/backend/internal/service/admin_service.go b/backend/internal/service/admin_service.go index 06354e1e..788e7f67 100644 --- a/backend/internal/service/admin_service.go +++ b/backend/internal/service/admin_service.go @@ -50,6 +50,7 @@ type AdminService interface { SetAccountError(ctx context.Context, id int64, errorMsg string) error 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 // Proxy management ListProxies(ctx context.Context, page, pageSize int, protocol, status, search string) ([]Proxy, int64, error) @@ -1706,6 +1707,11 @@ func (s *adminServiceImpl) checkMixedChannelRisk(ctx context.Context, currentAcc return nil } +// CheckMixedChannelRisk checks whether target groups contain mixed channels for the current account platform. +func (s *adminServiceImpl) CheckMixedChannelRisk(ctx context.Context, currentAccountID int64, currentAccountPlatform string, groupIDs []int64) error { + return s.checkMixedChannelRisk(ctx, currentAccountID, currentAccountPlatform, groupIDs) +} + func (s *adminServiceImpl) attachProxyLatency(ctx context.Context, proxies []ProxyWithAccountCount) { if s.proxyLatencyCache == nil || len(proxies) == 0 { return diff --git a/frontend/src/api/admin/accounts.ts b/frontend/src/api/admin/accounts.ts index 4cb1a6f2..f71ba4ac 100644 --- a/frontend/src/api/admin/accounts.ts +++ b/frontend/src/api/admin/accounts.ts @@ -15,7 +15,9 @@ import type { AccountUsageStatsResponse, TempUnschedulableStatus, AdminDataPayload, - AdminDataImportResult + AdminDataImportResult, + CheckMixedChannelRequest, + CheckMixedChannelResponse } from '@/types' /** @@ -80,6 +82,16 @@ export async function update(id: number, updates: UpdateAccountRequest): Promise return data } +/** + * Check mixed-channel risk for account-group binding. + */ +export async function checkMixedChannelRisk( + payload: CheckMixedChannelRequest +): Promise { + const { data } = await apiClient.post('/admin/accounts/check-mixed-channel', payload) + return data +} + /** * Delete account * @param id - Account ID @@ -458,6 +470,7 @@ export const accountsAPI = { getById, create, update, + checkMixedChannelRisk, delete: deleteAccount, toggleStatus, testAccount, diff --git a/frontend/src/components/account/CreateAccountModal.vue b/frontend/src/components/account/CreateAccountModal.vue index 345266de..3c14d75d 100644 --- a/frontend/src/components/account/CreateAccountModal.vue +++ b/frontend/src/components/account/CreateAccountModal.vue @@ -1932,7 +1932,7 @@ ([]) const geminiOAuthType = ref<'code_assist' | 'google_one' | 'ai_studio'>('google_one') const geminiAIStudioOAuthEnabled = ref(false) -const mixedChannelWarning = useMixedChannelWarning() -const showMixedChannelWarning = mixedChannelWarning.show -const mixedChannelWarningDetails = mixedChannelWarning.details +const showMixedChannelWarning = ref(false) +const mixedChannelWarningDetails = ref<{ groupName: string; currentPlatform: string; otherPlatform: string } | null>( + null +) +const mixedChannelWarningRawMessage = ref('') +const mixedChannelWarningAction = ref<(() => Promise) | null>(null) +const antigravityMixedChannelConfirmed = ref(false) const showAdvancedOAuth = ref(false) const showGeminiHelpDialog = ref(false) @@ -2137,6 +2147,13 @@ const geminiSelectedTier = computed(() => { } }) +const mixedChannelWarningMessageText = computed(() => { + if (mixedChannelWarningDetails.value) { + return t('admin.accounts.mixedChannelWarning', mixedChannelWarningDetails.value) + } + return mixedChannelWarningRawMessage.value +}) + const geminiQuotaDocs = { codeAssist: 'https://developers.google.com/gemini-code-assist/resources/quotas', aiStudio: 'https://ai.google.dev/pricing', @@ -2528,6 +2545,105 @@ const splitTempUnschedKeywords = (value: string) => { .filter((item) => item.length > 0) } +const isAntigravityAccount = (platform: AccountPlatform) => platform === 'antigravity' + +const buildMixedChannelDetails = (resp?: CheckMixedChannelResponse) => { + const details = resp?.details + if (!details) { + return null + } + return { + groupName: details.group_name || 'Unknown', + currentPlatform: details.current_platform || 'Unknown', + otherPlatform: details.other_platform || 'Unknown' + } +} + +const clearMixedChannelDialog = () => { + showMixedChannelWarning.value = false + mixedChannelWarningDetails.value = null + mixedChannelWarningRawMessage.value = '' + mixedChannelWarningAction.value = null +} + +const openMixedChannelDialog = (opts: { + response?: CheckMixedChannelResponse + message?: string + onConfirm: () => Promise +}) => { + mixedChannelWarningDetails.value = buildMixedChannelDetails(opts.response) + mixedChannelWarningRawMessage.value = + opts.message || opts.response?.message || t('admin.accounts.failedToCreate') + mixedChannelWarningAction.value = opts.onConfirm + showMixedChannelWarning.value = true +} + +const withAntigravityConfirmFlag = (payload: CreateAccountRequest): CreateAccountRequest => { + if (isAntigravityAccount(payload.platform) && antigravityMixedChannelConfirmed.value) { + return { + ...payload, + confirm_mixed_channel_risk: true + } + } + const cloned = { ...payload } + delete cloned.confirm_mixed_channel_risk + return cloned +} + +const ensureAntigravityMixedChannelConfirmed = async (onConfirm: () => Promise): Promise => { + if (!isAntigravityAccount(form.platform)) { + return true + } + if (antigravityMixedChannelConfirmed.value) { + return true + } + + try { + const result = await adminAPI.accounts.checkMixedChannelRisk({ + platform: form.platform, + group_ids: form.group_ids + }) + if (!result.has_risk) { + return true + } + openMixedChannelDialog({ + response: result, + onConfirm: async () => { + antigravityMixedChannelConfirmed.value = true + await onConfirm() + } + }) + return false + } catch (error: any) { + appStore.showError(error.response?.data?.message || error.response?.data?.detail || t('admin.accounts.failedToCreate')) + return false + } +} + +const submitCreateAccount = async (payload: CreateAccountRequest) => { + submitting.value = true + try { + await adminAPI.accounts.create(withAntigravityConfirmFlag(payload)) + appStore.showSuccess(t('admin.accounts.accountCreated')) + emit('created') + handleClose() + } catch (error: any) { + if (error.response?.status === 409 && error.response?.data?.error === 'mixed_channel_warning' && isAntigravityAccount(form.platform)) { + openMixedChannelDialog({ + message: error.response?.data?.message, + onConfirm: async () => { + antigravityMixedChannelConfirmed.value = true + await submitCreateAccount(payload) + } + }) + return + } + appStore.showError(error.response?.data?.message || error.response?.data?.detail || t('admin.accounts.failedToCreate')) + } finally { + submitting.value = false + } +} + // Methods const resetForm = () => { step.value = 1 @@ -2583,45 +2699,45 @@ const resetForm = () => { geminiOAuth.resetState() antigravityOAuth.resetState() oauthFlowRef.value?.reset() - mixedChannelWarning.cancel() + antigravityMixedChannelConfirmed.value = false + clearMixedChannelDialog() } const handleClose = () => { - mixedChannelWarning.cancel() + antigravityMixedChannelConfirmed.value = false + clearMixedChannelDialog() emit('close') } // Helper function to create account with mixed channel warning handling -const doCreateAccount = async (payload: any) => { - submitting.value = true - try { - await mixedChannelWarning.tryRequest(payload, (p) => adminAPI.accounts.create(p), { - onSuccess: () => { - appStore.showSuccess(t('admin.accounts.accountCreated')) - emit('created') - handleClose() - }, - onError: (error: any) => { - appStore.showError(error.response?.data?.detail || t('admin.accounts.failedToCreate')) - } - }) - } finally { - submitting.value = false +const doCreateAccount = async (payload: CreateAccountRequest) => { + const canContinue = await ensureAntigravityMixedChannelConfirmed(async () => { + await submitCreateAccount(payload) + }) + if (!canContinue) { + return } + await submitCreateAccount(payload) } // Handle mixed channel warning confirmation const handleMixedChannelConfirm = async () => { + const action = mixedChannelWarningAction.value + if (!action) { + clearMixedChannelDialog() + return + } + clearMixedChannelDialog() submitting.value = true try { - await mixedChannelWarning.confirm() + await action() } finally { submitting.value = false } } const handleMixedChannelCancel = () => { - mixedChannelWarning.cancel() + clearMixedChannelDialog() } const handleSubmit = async () => { @@ -2631,6 +2747,12 @@ const handleSubmit = async () => { appStore.showError(t('admin.accounts.pleaseEnterAccountName')) return } + const canContinue = await ensureAntigravityMixedChannelConfirmed(async () => { + step.value = 2 + }) + if (!canContinue) { + return + } step.value = 2 return } @@ -2666,15 +2788,8 @@ const handleSubmit = async () => { credentials.model_mapping = antigravityModelMapping } - submitting.value = true - try { - const extra = mixedScheduling.value ? { mixed_scheduling: true } : undefined - await createAccountAndFinish(form.platform, 'apikey', credentials, extra) - } catch (error: any) { - appStore.showError(error.response?.data?.detail || t('admin.accounts.failedToCreate')) - } finally { - submitting.value = false - } + const extra = mixedScheduling.value ? { mixed_scheduling: true } : undefined + await createAccountAndFinish(form.platform, 'apikey', credentials, extra) return } @@ -2951,7 +3066,7 @@ const handleAntigravityValidateRT = async (refreshTokenInput: string) => { const accountName = refreshTokens.length > 1 ? `${form.name} #${i + 1}` : form.name // Note: Antigravity doesn't have buildExtraInfo, so we pass empty extra or rely on credentials - await adminAPI.accounts.create({ + const createPayload = withAntigravityConfirmFlag({ name: accountName, notes: form.notes, platform: 'antigravity', @@ -2966,6 +3081,7 @@ const handleAntigravityValidateRT = async (refreshTokenInput: string) => { expires_at: form.expires_at, auto_pause_on_expired: autoPauseOnExpired.value }) + await adminAPI.accounts.create(createPayload) successCount++ } catch (error: any) { failedCount++ diff --git a/frontend/src/components/account/EditAccountModal.vue b/frontend/src/components/account/EditAccountModal.vue index 8f761039..5c7faaed 100644 --- a/frontend/src/components/account/EditAccountModal.vue +++ b/frontend/src/components/account/EditAccountModal.vue @@ -970,7 +970,7 @@ ([]) const tempUnschedEnabled = ref(false) const tempUnschedRules = ref([]) -const mixedChannelWarning = useMixedChannelWarning() -const showMixedChannelWarning = mixedChannelWarning.show -const mixedChannelWarningDetails = mixedChannelWarning.details +const showMixedChannelWarning = ref(false) +const mixedChannelWarningDetails = ref<{ groupName: string; currentPlatform: string; otherPlatform: string } | null>( + null +) +const mixedChannelWarningRawMessage = ref('') +const mixedChannelWarningAction = ref<(() => Promise) | null>(null) +const antigravityMixedChannelConfirmed = ref(false) // Quota control state (Anthropic OAuth/SetupToken only) const windowCostEnabled = ref(false) @@ -1114,6 +1117,13 @@ const defaultBaseUrl = computed(() => { return 'https://api.anthropic.com' }) +const mixedChannelWarningMessageText = computed(() => { + if (mixedChannelWarningDetails.value) { + return t('admin.accounts.mixedChannelWarning', mixedChannelWarningDetails.value) + } + return mixedChannelWarningRawMessage.value +}) + const form = reactive({ name: '', notes: '', @@ -1143,6 +1153,11 @@ watch( () => props.account, (newAccount) => { if (newAccount) { + antigravityMixedChannelConfirmed.value = false + showMixedChannelWarning.value = false + mixedChannelWarningDetails.value = null + mixedChannelWarningRawMessage.value = '' + mixedChannelWarningAction.value = null form.name = newAccount.name form.notes = newAccount.notes || '' form.proxy_id = newAccount.proxy_id @@ -1520,20 +1535,123 @@ function toPositiveNumber(value: unknown) { return Math.trunc(num) } +const isAntigravityAccount = () => props.account?.platform === 'antigravity' + +const buildMixedChannelDetails = (resp?: CheckMixedChannelResponse) => { + const details = resp?.details + if (!details) { + return null + } + return { + groupName: details.group_name || 'Unknown', + currentPlatform: details.current_platform || 'Unknown', + otherPlatform: details.other_platform || 'Unknown' + } +} + +const clearMixedChannelDialog = () => { + showMixedChannelWarning.value = false + mixedChannelWarningDetails.value = null + mixedChannelWarningRawMessage.value = '' + mixedChannelWarningAction.value = null +} + +const openMixedChannelDialog = (opts: { + response?: CheckMixedChannelResponse + message?: string + onConfirm: () => Promise +}) => { + mixedChannelWarningDetails.value = buildMixedChannelDetails(opts.response) + mixedChannelWarningRawMessage.value = + opts.message || opts.response?.message || t('admin.accounts.failedToUpdate') + mixedChannelWarningAction.value = opts.onConfirm + showMixedChannelWarning.value = true +} + +const withAntigravityConfirmFlag = (payload: Record) => { + if (isAntigravityAccount() && antigravityMixedChannelConfirmed.value) { + return { + ...payload, + confirm_mixed_channel_risk: true + } + } + const cloned = { ...payload } + delete cloned.confirm_mixed_channel_risk + return cloned +} + +const ensureAntigravityMixedChannelConfirmed = async (onConfirm: () => Promise): Promise => { + if (!isAntigravityAccount()) { + return true + } + if (antigravityMixedChannelConfirmed.value) { + return true + } + if (!props.account) { + return false + } + + try { + const result = await adminAPI.accounts.checkMixedChannelRisk({ + platform: props.account.platform, + group_ids: form.group_ids, + account_id: props.account.id + }) + if (!result.has_risk) { + return true + } + openMixedChannelDialog({ + response: result, + onConfirm: async () => { + antigravityMixedChannelConfirmed.value = true + await onConfirm() + } + }) + return false + } catch (error: any) { + appStore.showError(error.response?.data?.message || error.response?.data?.detail || t('admin.accounts.failedToUpdate')) + return false + } +} + const formatDateTimeLocal = formatDateTimeLocalInput const parseDateTimeLocal = parseDateTimeLocalInput // Methods const handleClose = () => { - mixedChannelWarning.cancel() + antigravityMixedChannelConfirmed.value = false + clearMixedChannelDialog() emit('close') } +const submitUpdateAccount = async (accountID: number, updatePayload: Record) => { + submitting.value = true + try { + await adminAPI.accounts.update(accountID, withAntigravityConfirmFlag(updatePayload)) + appStore.showSuccess(t('admin.accounts.accountUpdated')) + emit('updated') + handleClose() + } catch (error: any) { + if (error.response?.status === 409 && error.response?.data?.error === 'mixed_channel_warning' && isAntigravityAccount()) { + openMixedChannelDialog({ + message: error.response?.data?.message, + onConfirm: async () => { + antigravityMixedChannelConfirmed.value = true + await submitUpdateAccount(accountID, updatePayload) + } + }) + return + } + appStore.showError(error.response?.data?.message || error.response?.data?.detail || t('admin.accounts.failedToUpdate')) + } finally { + submitting.value = false + } +} + const handleSubmit = async () => { if (!props.account) return const accountID = props.account.id - submitting.value = true const updatePayload: Record = { ...form } try { // 后端期望 proxy_id: 0 表示清除代理,而不是 null @@ -1565,7 +1683,6 @@ const handleSubmit = async () => { newCredentials.api_key = currentCredentials.api_key } else { appStore.showError(t('admin.accounts.apiKeyIsRequired')) - submitting.value = false return } @@ -1585,7 +1702,6 @@ const handleSubmit = async () => { newCredentials.intercept_warmup_requests = true } if (!applyTempUnschedConfig(newCredentials)) { - submitting.value = false return } @@ -1601,7 +1717,6 @@ const handleSubmit = async () => { } if (!applyTempUnschedConfig(newCredentials)) { - submitting.value = false return } @@ -1617,7 +1732,6 @@ const handleSubmit = async () => { delete newCredentials.intercept_warmup_requests } if (!applyTempUnschedConfig(newCredentials)) { - submitting.value = false return } @@ -1700,34 +1814,36 @@ const handleSubmit = async () => { updatePayload.extra = newExtra } - await mixedChannelWarning.tryRequest(updatePayload, (p) => adminAPI.accounts.update(accountID, p), { - onSuccess: () => { - appStore.showSuccess(t('admin.accounts.accountUpdated')) - emit('updated') - handleClose() - }, - onError: (error: any) => { - appStore.showError(error.response?.data?.message || error.response?.data?.detail || t('admin.accounts.failedToUpdate')) - } + const canContinue = await ensureAntigravityMixedChannelConfirmed(async () => { + await submitUpdateAccount(accountID, updatePayload) }) + if (!canContinue) { + return + } + + await submitUpdateAccount(accountID, updatePayload) } catch (error: any) { appStore.showError(error.response?.data?.message || error.response?.data?.detail || t('admin.accounts.failedToUpdate')) - } finally { - submitting.value = false } } // Handle mixed channel warning confirmation const handleMixedChannelConfirm = async () => { + const action = mixedChannelWarningAction.value + if (!action) { + clearMixedChannelDialog() + return + } + clearMixedChannelDialog() submitting.value = true try { - await mixedChannelWarning.confirm() + await action() } finally { submitting.value = false } } const handleMixedChannelCancel = () => { - mixedChannelWarning.cancel() + clearMixedChannelDialog() } diff --git a/frontend/src/composables/useMixedChannelWarning.ts b/frontend/src/composables/useMixedChannelWarning.ts deleted file mode 100644 index 469b369b..00000000 --- a/frontend/src/composables/useMixedChannelWarning.ts +++ /dev/null @@ -1,107 +0,0 @@ -import { ref } from 'vue' - -export interface MixedChannelWarningDetails { - groupName: string - currentPlatform: string - otherPlatform: string -} - -function isMixedChannelWarningError(error: any): boolean { - return error?.response?.status === 409 && error?.response?.data?.error === 'mixed_channel_warning' -} - -function extractMixedChannelWarningDetails(error: any): MixedChannelWarningDetails { - const details = error?.response?.data?.details || {} - return { - groupName: details.group_name || 'Unknown', - currentPlatform: details.current_platform || 'Unknown', - otherPlatform: details.other_platform || 'Unknown' - } -} - -export function useMixedChannelWarning() { - const show = ref(false) - const details = ref(null) - - const pendingPayload = ref(null) - const pendingRequest = ref<((payload: any) => Promise) | null>(null) - const pendingOnSuccess = ref<(() => void) | null>(null) - const pendingOnError = ref<((error: any) => void) | null>(null) - - const clearPending = () => { - pendingPayload.value = null - pendingRequest.value = null - pendingOnSuccess.value = null - pendingOnError.value = null - details.value = null - } - - const tryRequest = async ( - payload: any, - request: (payload: any) => Promise, - opts?: { - onSuccess?: () => void - onError?: (error: any) => void - } - ): Promise => { - try { - await request(payload) - opts?.onSuccess?.() - return true - } catch (error: any) { - if (isMixedChannelWarningError(error)) { - details.value = extractMixedChannelWarningDetails(error) - pendingPayload.value = payload - pendingRequest.value = request - pendingOnSuccess.value = opts?.onSuccess || null - pendingOnError.value = opts?.onError || null - show.value = true - return false - } - - if (opts?.onError) { - opts.onError(error) - return false - } - throw error - } - } - - const confirm = async (): Promise => { - show.value = false - if (!pendingPayload.value || !pendingRequest.value) { - clearPending() - return false - } - - pendingPayload.value.confirm_mixed_channel_risk = true - - try { - await pendingRequest.value(pendingPayload.value) - pendingOnSuccess.value?.() - return true - } catch (error: any) { - if (pendingOnError.value) { - pendingOnError.value(error) - return false - } - throw error - } finally { - clearPending() - } - } - - const cancel = () => { - show.value = false - clearPending() - } - - return { - show, - details, - tryRequest, - confirm, - cancel - } -} - diff --git a/frontend/src/types/index.ts b/frontend/src/types/index.ts index a250820b..100b1617 100644 --- a/frontend/src/types/index.ts +++ b/frontend/src/types/index.ts @@ -716,6 +716,26 @@ export interface UpdateAccountRequest { confirm_mixed_channel_risk?: boolean } +export interface CheckMixedChannelRequest { + platform: AccountPlatform + group_ids: number[] + account_id?: number +} + +export interface MixedChannelWarningDetails { + group_id: number + group_name: string + current_platform: string + other_platform: string +} + +export interface CheckMixedChannelResponse { + has_risk: boolean + error?: string + message?: string + details?: MixedChannelWarningDetails +} + export interface CreateProxyRequest { name: string protocol: ProxyProtocol