diff --git a/backend/internal/handler/admin/account_handler_mixed_channel_test.go b/backend/internal/handler/admin/account_handler_mixed_channel_test.go index 61b99e03..24ec5bcf 100644 --- a/backend/internal/handler/admin/account_handler_mixed_channel_test.go +++ b/backend/internal/handler/admin/account_handler_mixed_channel_test.go @@ -19,6 +19,7 @@ func setupAccountMixedChannelRouter(adminSvc *stubAdminService) *gin.Engine { 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) + router.POST("/api/v1/admin/accounts/bulk-update", accountHandler.BulkUpdate) return router } @@ -145,3 +146,53 @@ func TestAccountHandlerUpdateMixedChannelConflictSimplifiedResponse(t *testing.T require.False(t, hasDetails) require.False(t, hasRequireConfirmation) } + +func TestAccountHandlerBulkUpdateMixedChannelConflict(t *testing.T) { + adminSvc := newStubAdminService() + adminSvc.bulkUpdateAccountErr = &service.MixedChannelError{ + GroupID: 27, + GroupName: "claude-max", + CurrentPlatform: "Antigravity", + OtherPlatform: "Anthropic", + } + router := setupAccountMixedChannelRouter(adminSvc) + + body, _ := json.Marshal(map[string]any{ + "account_ids": []int64{1, 2, 3}, + "group_ids": []int64{27}, + }) + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodPost, "/api/v1/admin/accounts/bulk-update", 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"], "claude-max") +} + +func TestAccountHandlerBulkUpdateMixedChannelConfirmSkips(t *testing.T) { + adminSvc := newStubAdminService() + router := setupAccountMixedChannelRouter(adminSvc) + + body, _ := json.Marshal(map[string]any{ + "account_ids": []int64{1, 2}, + "group_ids": []int64{27}, + "confirm_mixed_channel_risk": true, + }) + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodPost, "/api/v1/admin/accounts/bulk-update", 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, float64(2), data["success"]) + require.Equal(t, float64(0), data["failed"]) +} diff --git a/backend/internal/handler/admin/admin_service_stub_test.go b/backend/internal/handler/admin/admin_service_stub_test.go index 1d469bd7..c36e530d 100644 --- a/backend/internal/handler/admin/admin_service_stub_test.go +++ b/backend/internal/handler/admin/admin_service_stub_test.go @@ -22,9 +22,10 @@ type stubAdminService struct { updatedProxyIDs []int64 updatedProxies []*service.UpdateProxyInput testedProxyIDs []int64 - createAccountErr error - updateAccountErr error - checkMixedErr error + createAccountErr error + updateAccountErr error + bulkUpdateAccountErr error + checkMixedErr error lastMixedCheck struct { accountID int64 platform string @@ -235,7 +236,10 @@ func (s *stubAdminService) SetAccountSchedulable(ctx context.Context, id int64, } func (s *stubAdminService) BulkUpdateAccounts(ctx context.Context, input *service.BulkUpdateAccountsInput) (*service.BulkUpdateAccountsResult, error) { - return &service.BulkUpdateAccountsResult{Success: 1, Failed: 0, SuccessIDs: []int64{1}}, nil + if s.bulkUpdateAccountErr != nil { + return nil, s.bulkUpdateAccountErr + } + return &service.BulkUpdateAccountsResult{Success: len(input.AccountIDs), Failed: 0, SuccessIDs: input.AccountIDs}, nil } func (s *stubAdminService) CheckMixedChannelRisk(ctx context.Context, currentAccountID int64, currentAccountPlatform string, groupIDs []int64) error { diff --git a/frontend/src/api/client.ts b/frontend/src/api/client.ts index 22db5a44..95f9ff31 100644 --- a/frontend/src/api/client.ts +++ b/frontend/src/api/client.ts @@ -267,6 +267,7 @@ apiClient.interceptors.response.use( return Promise.reject({ status, code: apiData.code, + error: apiData.error, message: apiData.message || apiData.detail || error.message }) } diff --git a/frontend/src/components/account/BulkEditAccountModal.vue b/frontend/src/components/account/BulkEditAccountModal.vue index 38c07e69..10600e28 100644 --- a/frontend/src/components/account/BulkEditAccountModal.vue +++ b/frontend/src/components/account/BulkEditAccountModal.vue @@ -1252,14 +1252,43 @@ const buildUpdatePayload = (): Record | null => { return Object.keys(updates).length > 0 ? updates : null } +const mixedChannelConfirmed = ref(false) + +const needsMixedChannelCheck = () => + enableGroups.value && + props.selectedPlatforms.length === 1 && + (props.selectedPlatforms[0] === 'antigravity' || props.selectedPlatforms[0] === 'anthropic') + const handleClose = () => { showMixedChannelWarning.value = false mixedChannelWarningMessage.value = '' pendingUpdatesForConfirm.value = null + mixedChannelConfirmed.value = false emit('close') } -const handleSubmit = async (confirmMixedChannel = false) => { +const checkMixedChannelRisk = async (): Promise => { + if (!needsMixedChannelCheck()) return true + if (mixedChannelConfirmed.value) return true + if (groupIds.value.length === 0) return true + + try { + const result = await adminAPI.accounts.checkMixedChannelRisk({ + platform: props.selectedPlatforms[0], + group_ids: groupIds.value + }) + if (!result.has_risk) return true + + mixedChannelWarningMessage.value = result.message || t('admin.accounts.bulkEdit.failed') + showMixedChannelWarning.value = true + return false + } catch (error: any) { + appStore.showError(error.message || t('admin.accounts.bulkEdit.failed')) + return false + } +} + +const handleSubmit = async () => { if (props.accountIds.length === 0) { appStore.showError(t('admin.accounts.bulkEdit.noSelection')) return @@ -1283,16 +1312,24 @@ const handleSubmit = async (confirmMixedChannel = false) => { return } - let updates: Record - if (confirmMixedChannel && pendingUpdatesForConfirm.value) { - updates = { ...pendingUpdatesForConfirm.value, confirm_mixed_channel_risk: true } - } else { - const built = buildUpdatePayload() - if (!built) { - appStore.showError(t('admin.accounts.bulkEdit.noFieldsSelected')) - return - } - updates = built + const built = buildUpdatePayload() + if (!built) { + appStore.showError(t('admin.accounts.bulkEdit.noFieldsSelected')) + return + } + + const canContinue = await checkMixedChannelRisk() + if (!canContinue) { + pendingUpdatesForConfirm.value = built + return + } + + await submitBulkUpdate(built) +} + +const submitBulkUpdate = async (updates: Record) => { + if (mixedChannelConfirmed.value && needsMixedChannelCheck()) { + updates = { ...updates, confirm_mixed_channel_risk: true } } submitting.value = true @@ -1316,14 +1353,8 @@ const handleSubmit = async (confirmMixedChannel = false) => { handleClose() } } catch (error: any) { - if (error.response?.status === 409 && error.response?.data?.error === 'mixed_channel_warning') { - pendingUpdatesForConfirm.value = updates - mixedChannelWarningMessage.value = error.response.data.message - showMixedChannelWarning.value = true - } else { - appStore.showError(error.response?.data?.detail || t('admin.accounts.bulkEdit.failed')) - console.error('Error bulk updating accounts:', error) - } + appStore.showError(error.message || t('admin.accounts.bulkEdit.failed')) + console.error('Error bulk updating accounts:', error) } finally { submitting.value = false } @@ -1331,7 +1362,10 @@ const handleSubmit = async (confirmMixedChannel = false) => { const handleMixedChannelConfirm = async () => { showMixedChannelWarning.value = false - await handleSubmit(true) + mixedChannelConfirmed.value = true + if (pendingUpdatesForConfirm.value) { + await submitBulkUpdate(pendingUpdatesForConfirm.value) + } } const handleMixedChannelCancel = () => { @@ -1376,6 +1410,7 @@ watch( showMixedChannelWarning.value = false mixedChannelWarningMessage.value = '' pendingUpdatesForConfirm.value = null + mixedChannelConfirmed.value = false } } ) diff --git a/frontend/src/components/account/EditAccountModal.vue b/frontend/src/components/account/EditAccountModal.vue index 792bf580..184eff98 100644 --- a/frontend/src/components/account/EditAccountModal.vue +++ b/frontend/src/components/account/EditAccountModal.vue @@ -1961,7 +1961,7 @@ const ensureAntigravityMixedChannelConfirmed = async (onConfirm: () => Promise { antigravityMixedChannelConfirmed.value = true await submitUpdateAccount(accountID, updatePayload) @@ -1994,7 +1994,7 @@ const submitUpdateAccount = async (accountID: number, updatePayload: Record { await submitUpdateAccount(accountID, updatePayload) } catch (error: any) { - appStore.showError(error.response?.data?.message || error.response?.data?.detail || t('admin.accounts.failedToUpdate')) + appStore.showError(error.message || t('admin.accounts.failedToUpdate')) } }