Merge pull request #690 from touwaeriol/pr/bulk-edit-mixed-channel-warning

feat: add mixed-channel warning for bulk account edit
This commit is contained in:
Wesley Liddick
2026-03-01 18:25:05 +08:00
committed by GitHub
8 changed files with 213 additions and 191 deletions

View File

@@ -1122,6 +1122,14 @@ func (h *AccountHandler) BulkUpdate(c *gin.Context) {
SkipMixedChannelCheck: skipCheck,
})
if err != nil {
var mixedErr *service.MixedChannelError
if errors.As(err, &mixedErr) {
c.JSON(409, gin.H{
"error": "mixed_channel_warning",
"message": mixedErr.Error(),
})
return
}
response.ErrorFrom(c, err)
return
}

View File

@@ -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"])
}

View File

@@ -10,22 +10,23 @@ 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
createAccountErr error
updateAccountErr error
checkMixedErr error
lastMixedCheck 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
createAccountErr error
updateAccountErr error
bulkUpdateAccountErr error
checkMixedErr error
lastMixedCheck struct {
accountID int64
platform string
groupIDs []int64
@@ -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 {

View File

@@ -1539,30 +1539,31 @@ func (s *adminServiceImpl) BulkUpdateAccounts(ctx context.Context, input *BulkUp
needMixedChannelCheck := input.GroupIDs != nil && !input.SkipMixedChannelCheck
// 预加载账号平台信息(混合渠道检查或 Sora 同步需要)。
// 预加载账号平台信息(混合渠道检查需要)。
platformByID := map[int64]string{}
groupAccountsByID := map[int64][]Account{}
groupNameByID := map[int64]string{}
if needMixedChannelCheck {
accounts, err := s.accountRepo.GetByIDs(ctx, input.AccountIDs)
if err != nil {
if needMixedChannelCheck {
return nil, err
}
} else {
for _, account := range accounts {
if account != nil {
platformByID[account.ID] = account.Platform
}
}
}
loadedAccounts, loadedNames, err := s.preloadMixedChannelRiskData(ctx, *input.GroupIDs)
if err != nil {
return nil, err
}
groupAccountsByID = loadedAccounts
groupNameByID = loadedNames
for _, account := range accounts {
if account != nil {
platformByID[account.ID] = account.Platform
}
}
}
// 预检查混合渠道风险:在任何写操作之前,若发现风险立即返回错误。
if needMixedChannelCheck {
for _, accountID := range input.AccountIDs {
platform := platformByID[accountID]
if platform == "" {
continue
}
if err := s.checkMixedChannelRisk(ctx, accountID, platform, *input.GroupIDs); err != nil {
return nil, err
}
}
}
if input.RateMultiplier != nil {
@@ -1606,34 +1607,8 @@ func (s *adminServiceImpl) BulkUpdateAccounts(ctx context.Context, input *BulkUp
// Handle group bindings per account (requires individual operations).
for _, accountID := range input.AccountIDs {
entry := BulkUpdateAccountResult{AccountID: accountID}
platform := ""
if input.GroupIDs != nil {
// 检查混合渠道风险(除非用户已确认)
if !input.SkipMixedChannelCheck {
platform = platformByID[accountID]
if platform == "" {
account, err := s.accountRepo.GetByID(ctx, accountID)
if err != nil {
entry.Success = false
entry.Error = err.Error()
result.Failed++
result.FailedIDs = append(result.FailedIDs, accountID)
result.Results = append(result.Results, entry)
continue
}
platform = account.Platform
}
if err := s.checkMixedChannelRiskWithPreloaded(accountID, platform, *input.GroupIDs, groupAccountsByID, groupNameByID); err != nil {
entry.Success = false
entry.Error = err.Error()
result.Failed++
result.FailedIDs = append(result.FailedIDs, accountID)
result.Results = append(result.Results, entry)
continue
}
}
if err := s.accountRepo.BindGroups(ctx, accountID, *input.GroupIDs); err != nil {
entry.Success = false
entry.Error = err.Error()
@@ -1642,9 +1617,6 @@ func (s *adminServiceImpl) BulkUpdateAccounts(ctx context.Context, input *BulkUp
result.Results = append(result.Results, entry)
continue
}
if !input.SkipMixedChannelCheck && platform != "" {
updateMixedChannelPreloadedAccounts(groupAccountsByID, *input.GroupIDs, accountID, platform)
}
}
entry.Success = true
@@ -2316,41 +2288,6 @@ func (s *adminServiceImpl) checkMixedChannelRisk(ctx context.Context, currentAcc
return nil
}
func (s *adminServiceImpl) preloadMixedChannelRiskData(ctx context.Context, groupIDs []int64) (map[int64][]Account, map[int64]string, error) {
accountsByGroup := make(map[int64][]Account)
groupNameByID := make(map[int64]string)
if len(groupIDs) == 0 {
return accountsByGroup, groupNameByID, nil
}
seen := make(map[int64]struct{}, len(groupIDs))
for _, groupID := range groupIDs {
if groupID <= 0 {
continue
}
if _, ok := seen[groupID]; ok {
continue
}
seen[groupID] = struct{}{}
accounts, err := s.accountRepo.ListByGroup(ctx, groupID)
if err != nil {
return nil, nil, fmt.Errorf("get accounts in group %d: %w", groupID, err)
}
accountsByGroup[groupID] = accounts
group, err := s.groupRepo.GetByID(ctx, groupID)
if err != nil {
continue
}
if group != nil {
groupNameByID[groupID] = group.Name
}
}
return accountsByGroup, groupNameByID, nil
}
func (s *adminServiceImpl) validateGroupIDsExist(ctx context.Context, groupIDs []int64) error {
if len(groupIDs) == 0 {
return nil
@@ -2380,71 +2317,6 @@ func (s *adminServiceImpl) validateGroupIDsExist(ctx context.Context, groupIDs [
return nil
}
func (s *adminServiceImpl) checkMixedChannelRiskWithPreloaded(currentAccountID int64, currentAccountPlatform string, groupIDs []int64, accountsByGroup map[int64][]Account, groupNameByID map[int64]string) error {
currentPlatform := getAccountPlatform(currentAccountPlatform)
if currentPlatform == "" {
return nil
}
for _, groupID := range groupIDs {
accounts := accountsByGroup[groupID]
for _, account := range accounts {
if currentAccountID > 0 && account.ID == currentAccountID {
continue
}
otherPlatform := getAccountPlatform(account.Platform)
if otherPlatform == "" {
continue
}
if currentPlatform != otherPlatform {
groupName := fmt.Sprintf("Group %d", groupID)
if name := strings.TrimSpace(groupNameByID[groupID]); name != "" {
groupName = name
}
return &MixedChannelError{
GroupID: groupID,
GroupName: groupName,
CurrentPlatform: currentPlatform,
OtherPlatform: otherPlatform,
}
}
}
}
return nil
}
func updateMixedChannelPreloadedAccounts(accountsByGroup map[int64][]Account, groupIDs []int64, accountID int64, platform string) {
if len(groupIDs) == 0 || accountID <= 0 || platform == "" {
return
}
for _, groupID := range groupIDs {
if groupID <= 0 {
continue
}
accounts := accountsByGroup[groupID]
found := false
for i := range accounts {
if accounts[i].ID != accountID {
continue
}
accounts[i].Platform = platform
found = true
break
}
if !found {
accounts = append(accounts, Account{
ID: accountID,
Platform: platform,
})
}
accountsByGroup[groupID] = accounts
}
}
// 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)

View File

@@ -139,34 +139,34 @@ func TestAdminService_BulkUpdateAccounts_NilGroupRepoReturnsError(t *testing.T)
require.Contains(t, err.Error(), "group repository not configured")
}
func TestAdminService_BulkUpdateAccounts_MixedChannelCheckUsesUpdatedSnapshot(t *testing.T) {
// TestAdminService_BulkUpdateAccounts_MixedChannelPreCheckBlocksOnExistingConflict verifies
// that the global pre-check detects a conflict with existing group members and returns an
// error before any DB write is performed.
func TestAdminService_BulkUpdateAccounts_MixedChannelPreCheckBlocksOnExistingConflict(t *testing.T) {
repo := &accountRepoStubForBulkUpdate{
getByIDsAccounts: []*Account{
{ID: 1, Platform: PlatformAnthropic},
{ID: 2, Platform: PlatformAntigravity},
{ID: 1, Platform: PlatformAntigravity},
},
// Group 10 already contains an Anthropic account.
listByGroupData: map[int64][]Account{
10: {},
10: {{ID: 99, Platform: PlatformAnthropic}},
},
}
svc := &adminServiceImpl{
accountRepo: repo,
groupRepo: &groupRepoStubForAdmin{getByID: &Group{ID: 10, Name: "目标分组"}},
groupRepo: &groupRepoStubForAdmin{getByID: &Group{ID: 10, Name: "target-group"}},
}
groupIDs := []int64{10}
input := &BulkUpdateAccountsInput{
AccountIDs: []int64{1, 2},
AccountIDs: []int64{1},
GroupIDs: &groupIDs,
}
result, err := svc.BulkUpdateAccounts(context.Background(), input)
require.NoError(t, err)
require.Equal(t, 1, result.Success)
require.Equal(t, 1, result.Failed)
require.ElementsMatch(t, []int64{1}, result.SuccessIDs)
require.ElementsMatch(t, []int64{2}, result.FailedIDs)
require.Len(t, result.Results, 2)
require.Contains(t, result.Results[1].Error, "mixed channel")
require.Equal(t, []int64{1}, repo.bindGroupsCalls)
require.Nil(t, result)
require.Error(t, err)
require.Contains(t, err.Error(), "mixed channel")
// No BindGroups should have been called since the check runs before any write.
require.Empty(t, repo.bindGroupsCalls)
}

View File

@@ -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
})
}

View File

@@ -756,6 +756,17 @@
</div>
</template>
</BaseDialog>
<ConfirmDialog
:show="showMixedChannelWarning"
:title="t('admin.accounts.mixedChannelWarningTitle')"
:message="mixedChannelWarningMessage"
:confirm-text="t('common.confirm')"
:cancel-text="t('common.cancel')"
:danger="true"
@confirm="handleMixedChannelConfirm"
@cancel="handleMixedChannelCancel"
/>
</template>
<script setup lang="ts">
@@ -765,6 +776,7 @@ import { useAppStore } from '@/stores/app'
import { adminAPI } from '@/api/admin'
import type { Proxy as ProxyConfig, AdminGroup, AccountPlatform, AccountType } from '@/types'
import BaseDialog from '@/components/common/BaseDialog.vue'
import ConfirmDialog from '@/components/common/ConfirmDialog.vue'
import Select from '@/components/common/Select.vue'
import ProxySelector from '@/components/common/ProxySelector.vue'
import GroupSelector from '@/components/common/GroupSelector.vue'
@@ -844,6 +856,9 @@ const enableRpmLimit = ref(false)
// State - field values
const submitting = ref(false)
const showMixedChannelWarning = ref(false)
const mixedChannelWarningMessage = ref('')
const pendingUpdatesForConfirm = ref<Record<string, unknown> | null>(null)
const baseUrl = ref('')
const modelRestrictionMode = ref<'whitelist' | 'mapping'>('whitelist')
const allowedModels = ref<string[]>([])
@@ -1237,10 +1252,46 @@ const buildUpdatePayload = (): Record<string, unknown> | null => {
return Object.keys(updates).length > 0 ? updates : null
}
const mixedChannelConfirmed = ref(false)
// 是否需要预检查:改了分组 + 全是单一的 antigravity 或 anthropic 平台
// 多平台混合的情况由 submitBulkUpdate 的 409 catch 兜底
const canPreCheck = () =>
enableGroups.value &&
groupIds.value.length > 0 &&
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')
}
// 预检查:提交前调接口检测,有风险就弹窗阻止,返回 false 表示需要用户确认
const preCheckMixedChannelRisk = async (built: Record<string, unknown>): Promise<boolean> => {
if (!canPreCheck()) return true
if (mixedChannelConfirmed.value) return true
try {
const result = await adminAPI.accounts.checkMixedChannelRisk({
platform: props.selectedPlatforms[0],
group_ids: groupIds.value
})
if (!result.has_risk) return true
pendingUpdatesForConfirm.value = built
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'))
@@ -1265,12 +1316,24 @@ const handleSubmit = async () => {
return
}
const updates = buildUpdatePayload()
if (!updates) {
const built = buildUpdatePayload()
if (!built) {
appStore.showError(t('admin.accounts.bulkEdit.noFieldsSelected'))
return
}
const canContinue = await preCheckMixedChannelRisk(built)
if (!canContinue) return
await submitBulkUpdate(built)
}
const submitBulkUpdate = async (baseUpdates: Record<string, unknown>) => {
// 无论是预检查确认还是 409 兜底确认,只要 mixedChannelConfirmed 为 true 就带上 flag
const updates = mixedChannelConfirmed.value
? { ...baseUpdates, confirm_mixed_channel_risk: true }
: baseUpdates
submitting.value = true
try {
@@ -1287,17 +1350,38 @@ const handleSubmit = async () => {
}
if (success > 0) {
pendingUpdatesForConfirm.value = null
emit('updated')
handleClose()
}
} catch (error: any) {
appStore.showError(error.response?.data?.detail || t('admin.accounts.bulkEdit.failed'))
console.error('Error bulk updating accounts:', error)
// 兜底:多平台混合场景下,预检查跳过,由后端 409 触发确认框
if (error.status === 409 && error.error === 'mixed_channel_warning') {
pendingUpdatesForConfirm.value = baseUpdates
mixedChannelWarningMessage.value = error.message
showMixedChannelWarning.value = true
} else {
appStore.showError(error.message || t('admin.accounts.bulkEdit.failed'))
console.error('Error bulk updating accounts:', error)
}
} finally {
submitting.value = false
}
}
const handleMixedChannelConfirm = async () => {
showMixedChannelWarning.value = false
mixedChannelConfirmed.value = true
if (pendingUpdatesForConfirm.value) {
await submitBulkUpdate(pendingUpdatesForConfirm.value)
}
}
const handleMixedChannelCancel = () => {
showMixedChannelWarning.value = false
pendingUpdatesForConfirm.value = null
}
// Reset form when modal closes
watch(
() => props.show,
@@ -1330,10 +1414,12 @@ watch(
rateMultiplier.value = 1
status.value = 'active'
groupIds.value = []
rpmLimitEnabled.value = false
bulkBaseRpm.value = null
bulkRpmStrategy.value = 'tiered'
bulkRpmStickyBuffer.value = null
// Reset mixed channel warning state
showMixedChannelWarning.value = false
mixedChannelWarningMessage.value = ''
pendingUpdatesForConfirm.value = null
mixedChannelConfirmed.value = false
}
}
)

View File

@@ -1961,7 +1961,7 @@ const ensureAntigravityMixedChannelConfirmed = async (onConfirm: () => Promise<v
})
return false
} 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'))
return false
}
}
@@ -1984,9 +1984,9 @@ const submitUpdateAccount = async (accountID: number, updatePayload: Record<stri
emit('updated', updatedAccount)
handleClose()
} catch (error: any) {
if (error.response?.status === 409 && error.response?.data?.error === 'mixed_channel_warning' && needsMixedChannelCheck()) {
if (error.status === 409 && error.error === 'mixed_channel_warning' && needsMixedChannelCheck()) {
openMixedChannelDialog({
message: error.response?.data?.message,
message: error.message,
onConfirm: async () => {
antigravityMixedChannelConfirmed.value = true
await submitUpdateAccount(accountID, updatePayload)
@@ -1994,7 +1994,7 @@ const submitUpdateAccount = async (accountID: number, updatePayload: Record<stri
})
return
}
appStore.showError(error.response?.data?.message || error.response?.data?.detail || t('admin.accounts.failedToUpdate'))
appStore.showError(error.message || t('admin.accounts.failedToUpdate'))
} finally {
submitting.value = false
}
@@ -2245,7 +2245,7 @@ const handleSubmit = async () => {
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'))
}
}