diff --git a/backend/internal/handler/admin/account_handler.go b/backend/internal/handler/admin/account_handler.go index a2a8dd43..df82476c 100644 --- a/backend/internal/handler/admin/account_handler.go +++ b/backend/internal/handler/admin/account_handler.go @@ -139,6 +139,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 @@ -389,6 +396,50 @@ func (h *AccountHandler) GetByID(c *gin.Context) { response.Success(c, h.buildAccountResponseWithRuntime(c.Request.Context(), 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) { @@ -431,17 +482,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 } @@ -501,17 +545,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 9f3dcf80..848122e4 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 4b4d97c3..36efacc8 100644 --- a/backend/internal/server/routes/admin.go +++ b/backend/internal/server/routes/admin.go @@ -219,6 +219,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 8614f24a..47339661 100644 --- a/backend/internal/service/admin_service.go +++ b/backend/internal/service/admin_service.go @@ -54,6 +54,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) @@ -2114,6 +2115,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 89b11783..1b8ae9ad 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' /** @@ -133,6 +135,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 @@ -535,6 +547,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 64253447..83b65159 100644 --- a/frontend/src/components/account/CreateAccountModal.vue +++ b/frontend/src/components/account/CreateAccountModal.vue @@ -2157,7 +2157,7 @@ const geminiOAuthType = ref<'code_assist' | 'google_one' | 'ai_studio'>('google_one') const geminiAIStudioOAuthEnabled = ref(false) -// Mixed channel warning dialog state const showMixedChannelWarning = ref(false) -const mixedChannelWarningDetails = ref<{ groupName: string; currentPlatform: string; otherPlatform: string } | null>(null) -const pendingCreatePayload = ref(null) +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) @@ -2379,6 +2389,13 @@ const isOpenAIModelRestrictionDisabled = computed(() => form.platform === 'openai' && openaiPassthroughEnabled.value ) +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', @@ -2795,6 +2812,105 @@ const splitTempUnschedKeywords = (value: string) => { .filter((item) => item.length > 0) } +const needsMixedChannelCheck = (platform: AccountPlatform) => platform === 'antigravity' || platform === 'anthropic' + +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 (needsMixedChannelCheck(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 (!needsMixedChannelCheck(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' && needsMixedChannelCheck(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 @@ -2856,9 +2972,13 @@ const resetForm = () => { geminiOAuth.resetState() antigravityOAuth.resetState() oauthFlowRef.value?.reset() + antigravityMixedChannelConfirmed.value = false + clearMixedChannelDialog() } const handleClose = () => { + antigravityMixedChannelConfirmed.value = false + clearMixedChannelDialog() emit('close') } @@ -2917,56 +3037,34 @@ const buildSoraExtra = ( } // Helper function to create account with mixed channel warning handling -const doCreateAccount = async (payload: any) => { +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 adminAPI.accounts.create(payload) - appStore.showSuccess(t('admin.accounts.accountCreated')) - emit('created') - handleClose() - } catch (error: any) { - // Handle 409 mixed_channel_warning - show confirmation dialog - if (error.response?.status === 409 && error.response?.data?.error === 'mixed_channel_warning') { - const details = error.response.data.details || {} - mixedChannelWarningDetails.value = { - groupName: details.group_name || 'Unknown', - currentPlatform: details.current_platform || 'Unknown', - otherPlatform: details.other_platform || 'Unknown' - } - pendingCreatePayload.value = payload - showMixedChannelWarning.value = true - } else { - appStore.showError(error.response?.data?.detail || t('admin.accounts.failedToCreate')) - } + await action() } finally { submitting.value = false } } -// Handle mixed channel warning confirmation -const handleMixedChannelConfirm = async () => { - showMixedChannelWarning.value = false - if (pendingCreatePayload.value) { - pendingCreatePayload.value.confirm_mixed_channel_risk = true - submitting.value = true - try { - await adminAPI.accounts.create(pendingCreatePayload.value) - appStore.showSuccess(t('admin.accounts.accountCreated')) - emit('created') - handleClose() - } catch (error: any) { - appStore.showError(error.response?.data?.detail || t('admin.accounts.failedToCreate')) - } finally { - submitting.value = false - pendingCreatePayload.value = null - } - } -} - const handleMixedChannelCancel = () => { - showMixedChannelWarning.value = false - pendingCreatePayload.value = null - mixedChannelWarningDetails.value = null + clearMixedChannelDialog() } const handleSubmit = async () => { @@ -2976,6 +3074,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 } @@ -3132,7 +3236,7 @@ const createAccountAndFinish = async ( if (!applyTempUnschedConfig(credentials)) { return } - await adminAPI.accounts.create({ + await doCreateAccount({ name: form.name, notes: form.notes, platform, @@ -3147,9 +3251,6 @@ const createAccountAndFinish = async ( expires_at: form.expires_at, auto_pause_on_expired: autoPauseOnExpired.value }) - appStore.showSuccess(t('admin.accounts.accountCreated')) - emit('created') - handleClose() } // OpenAI OAuth 授权码兑换 @@ -3497,7 +3598,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', @@ -3512,6 +3613,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 3d75df7d..c29aa54b 100644 --- a/frontend/src/components/account/EditAccountModal.vue +++ b/frontend/src/components/account/EditAccountModal.vue @@ -1139,7 +1139,7 @@ ('edit-mod const getAntigravityModelMappingKey = createStableObjectKeyResolver('edit-antigravity-model-mapping') const getTempUnschedRuleKey = createStableObjectKeyResolver('edit-temp-unsched-rule') -// Mixed channel warning dialog state const showMixedChannelWarning = ref(false) -const mixedChannelWarningDetails = ref<{ groupName: string; currentPlatform: string; otherPlatform: string } | null>(null) -const pendingUpdatePayload = ref | null>(null) +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) @@ -1298,6 +1301,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: '', @@ -1327,6 +1337,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 @@ -1726,18 +1741,123 @@ function toPositiveNumber(value: unknown) { return Math.trunc(num) } +const needsMixedChannelCheck = () => props.account?.platform === 'antigravity' || props.account?.platform === 'anthropic' + +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 (needsMixedChannelCheck() && 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 (!needsMixedChannelCheck()) { + 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 = () => { + antigravityMixedChannelConfirmed.value = false + clearMixedChannelDialog() emit('close') } +const submitUpdateAccount = async (accountID: number, updatePayload: Record) => { + submitting.value = true + try { + const updatedAccount = await adminAPI.accounts.update(accountID, withAntigravityConfirmFlag(updatePayload)) + appStore.showSuccess(t('admin.accounts.accountUpdated')) + emit('updated', updatedAccount) + handleClose() + } catch (error: any) { + if (error.response?.status === 409 && error.response?.data?.error === 'mixed_channel_warning' && needsMixedChannelCheck()) { + 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 @@ -1769,7 +1889,6 @@ const handleSubmit = async () => { newCredentials.api_key = currentCredentials.api_key } else { appStore.showError(t('admin.accounts.apiKeyIsRequired')) - submitting.value = false return } @@ -1792,7 +1911,6 @@ const handleSubmit = async () => { // Add intercept warmup requests setting applyInterceptWarmup(newCredentials, interceptWarmupRequests.value, 'edit') if (!applyTempUnschedConfig(newCredentials)) { - submitting.value = false return } @@ -1811,7 +1929,6 @@ const handleSubmit = async () => { applyInterceptWarmup(newCredentials, interceptWarmupRequests.value, 'edit') if (!applyTempUnschedConfig(newCredentials)) { - submitting.value = false return } @@ -1823,7 +1940,6 @@ const handleSubmit = async () => { applyInterceptWarmup(newCredentials, interceptWarmupRequests.value, 'edit') if (!applyTempUnschedConfig(newCredentials)) { - submitting.value = false return } @@ -1953,52 +2069,36 @@ const handleSubmit = async () => { updatePayload.extra = newExtra } - const updatedAccount = await adminAPI.accounts.update(props.account.id, updatePayload) - appStore.showSuccess(t('admin.accounts.accountUpdated')) - emit('updated', updatedAccount) - handleClose() - } catch (error: any) { - // Handle 409 mixed_channel_warning - show confirmation dialog - if (error.response?.status === 409 && error.response?.data?.error === 'mixed_channel_warning') { - const details = error.response.data.details || {} - mixedChannelWarningDetails.value = { - groupName: details.group_name || 'Unknown', - currentPlatform: details.current_platform || 'Unknown', - otherPlatform: details.other_platform || 'Unknown' - } - pendingUpdatePayload.value = updatePayload - showMixedChannelWarning.value = true - } else { - 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 } - } finally { - submitting.value = false + + await submitUpdateAccount(accountID, updatePayload) + } catch (error: any) { + appStore.showError(error.response?.data?.message || error.response?.data?.detail || t('admin.accounts.failedToUpdate')) } } // Handle mixed channel warning confirmation const handleMixedChannelConfirm = async () => { - showMixedChannelWarning.value = false - if (pendingUpdatePayload.value && props.account) { - pendingUpdatePayload.value.confirm_mixed_channel_risk = true - submitting.value = true - try { - const updatedAccount = await adminAPI.accounts.update(props.account.id, pendingUpdatePayload.value) - appStore.showSuccess(t('admin.accounts.accountUpdated')) - emit('updated', updatedAccount) - handleClose() - } catch (error: any) { - appStore.showError(error.response?.data?.message || error.response?.data?.detail || t('admin.accounts.failedToUpdate')) - } finally { - submitting.value = false - pendingUpdatePayload.value = null - } + const action = mixedChannelWarningAction.value + if (!action) { + clearMixedChannelDialog() + return + } + clearMixedChannelDialog() + submitting.value = true + try { + await action() + } finally { + submitting.value = false } } const handleMixedChannelCancel = () => { - showMixedChannelWarning.value = false - pendingUpdatePayload.value = null - mixedChannelWarningDetails.value = null + clearMixedChannelDialog() } diff --git a/frontend/src/types/index.ts b/frontend/src/types/index.ts index 1284c176..70fe5a27 100644 --- a/frontend/src/types/index.ts +++ b/frontend/src/types/index.ts @@ -766,6 +766,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