Skip to content

Commit fd57fa4

Browse files
authored
Merge pull request Wei-Shaw#690 from touwaeriol/pr/bulk-edit-mixed-channel-warning
feat: add mixed-channel warning for bulk account edit
2 parents 8c4d22b + c08889b commit fd57fa4

8 files changed

Lines changed: 211 additions & 189 deletions

File tree

backend/internal/handler/admin/account_handler.go

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1122,6 +1122,14 @@ func (h *AccountHandler) BulkUpdate(c *gin.Context) {
11221122
SkipMixedChannelCheck: skipCheck,
11231123
})
11241124
if err != nil {
1125+
var mixedErr *service.MixedChannelError
1126+
if errors.As(err, &mixedErr) {
1127+
c.JSON(409, gin.H{
1128+
"error": "mixed_channel_warning",
1129+
"message": mixedErr.Error(),
1130+
})
1131+
return
1132+
}
11251133
response.ErrorFrom(c, err)
11261134
return
11271135
}

backend/internal/handler/admin/account_handler_mixed_channel_test.go

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ func setupAccountMixedChannelRouter(adminSvc *stubAdminService) *gin.Engine {
1919
router.POST("/api/v1/admin/accounts/check-mixed-channel", accountHandler.CheckMixedChannel)
2020
router.POST("/api/v1/admin/accounts", accountHandler.Create)
2121
router.PUT("/api/v1/admin/accounts/:id", accountHandler.Update)
22+
router.POST("/api/v1/admin/accounts/bulk-update", accountHandler.BulkUpdate)
2223
return router
2324
}
2425

@@ -145,3 +146,53 @@ func TestAccountHandlerUpdateMixedChannelConflictSimplifiedResponse(t *testing.T
145146
require.False(t, hasDetails)
146147
require.False(t, hasRequireConfirmation)
147148
}
149+
150+
func TestAccountHandlerBulkUpdateMixedChannelConflict(t *testing.T) {
151+
adminSvc := newStubAdminService()
152+
adminSvc.bulkUpdateAccountErr = &service.MixedChannelError{
153+
GroupID: 27,
154+
GroupName: "claude-max",
155+
CurrentPlatform: "Antigravity",
156+
OtherPlatform: "Anthropic",
157+
}
158+
router := setupAccountMixedChannelRouter(adminSvc)
159+
160+
body, _ := json.Marshal(map[string]any{
161+
"account_ids": []int64{1, 2, 3},
162+
"group_ids": []int64{27},
163+
})
164+
rec := httptest.NewRecorder()
165+
req := httptest.NewRequest(http.MethodPost, "/api/v1/admin/accounts/bulk-update", bytes.NewReader(body))
166+
req.Header.Set("Content-Type", "application/json")
167+
router.ServeHTTP(rec, req)
168+
169+
require.Equal(t, http.StatusConflict, rec.Code)
170+
var resp map[string]any
171+
require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &resp))
172+
require.Equal(t, "mixed_channel_warning", resp["error"])
173+
require.Contains(t, resp["message"], "claude-max")
174+
}
175+
176+
func TestAccountHandlerBulkUpdateMixedChannelConfirmSkips(t *testing.T) {
177+
adminSvc := newStubAdminService()
178+
router := setupAccountMixedChannelRouter(adminSvc)
179+
180+
body, _ := json.Marshal(map[string]any{
181+
"account_ids": []int64{1, 2},
182+
"group_ids": []int64{27},
183+
"confirm_mixed_channel_risk": true,
184+
})
185+
rec := httptest.NewRecorder()
186+
req := httptest.NewRequest(http.MethodPost, "/api/v1/admin/accounts/bulk-update", bytes.NewReader(body))
187+
req.Header.Set("Content-Type", "application/json")
188+
router.ServeHTTP(rec, req)
189+
190+
require.Equal(t, http.StatusOK, rec.Code)
191+
var resp map[string]any
192+
require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &resp))
193+
require.Equal(t, float64(0), resp["code"])
194+
data, ok := resp["data"].(map[string]any)
195+
require.True(t, ok)
196+
require.Equal(t, float64(2), data["success"])
197+
require.Equal(t, float64(0), data["failed"])
198+
}

backend/internal/handler/admin/admin_service_stub_test.go

Lines changed: 21 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -10,22 +10,23 @@ import (
1010
)
1111

1212
type stubAdminService struct {
13-
users []service.User
14-
apiKeys []service.APIKey
15-
groups []service.Group
16-
accounts []service.Account
17-
proxies []service.Proxy
18-
proxyCounts []service.ProxyWithAccountCount
19-
redeems []service.RedeemCode
20-
createdAccounts []*service.CreateAccountInput
21-
createdProxies []*service.CreateProxyInput
22-
updatedProxyIDs []int64
23-
updatedProxies []*service.UpdateProxyInput
24-
testedProxyIDs []int64
25-
createAccountErr error
26-
updateAccountErr error
27-
checkMixedErr error
28-
lastMixedCheck struct {
13+
users []service.User
14+
apiKeys []service.APIKey
15+
groups []service.Group
16+
accounts []service.Account
17+
proxies []service.Proxy
18+
proxyCounts []service.ProxyWithAccountCount
19+
redeems []service.RedeemCode
20+
createdAccounts []*service.CreateAccountInput
21+
createdProxies []*service.CreateProxyInput
22+
updatedProxyIDs []int64
23+
updatedProxies []*service.UpdateProxyInput
24+
testedProxyIDs []int64
25+
createAccountErr error
26+
updateAccountErr error
27+
bulkUpdateAccountErr error
28+
checkMixedErr error
29+
lastMixedCheck struct {
2930
accountID int64
3031
platform string
3132
groupIDs []int64
@@ -235,7 +236,10 @@ func (s *stubAdminService) SetAccountSchedulable(ctx context.Context, id int64,
235236
}
236237

237238
func (s *stubAdminService) BulkUpdateAccounts(ctx context.Context, input *service.BulkUpdateAccountsInput) (*service.BulkUpdateAccountsResult, error) {
238-
return &service.BulkUpdateAccountsResult{Success: 1, Failed: 0, SuccessIDs: []int64{1}}, nil
239+
if s.bulkUpdateAccountErr != nil {
240+
return nil, s.bulkUpdateAccountErr
241+
}
242+
return &service.BulkUpdateAccountsResult{Success: len(input.AccountIDs), Failed: 0, SuccessIDs: input.AccountIDs}, nil
239243
}
240244

241245
func (s *stubAdminService) CheckMixedChannelRisk(ctx context.Context, currentAccountID int64, currentAccountPlatform string, groupIDs []int64) error {

backend/internal/service/admin_service.go

Lines changed: 17 additions & 145 deletions
Original file line numberDiff line numberDiff line change
@@ -1539,30 +1539,31 @@ func (s *adminServiceImpl) BulkUpdateAccounts(ctx context.Context, input *BulkUp
15391539

15401540
needMixedChannelCheck := input.GroupIDs != nil && !input.SkipMixedChannelCheck
15411541

1542-
// 预加载账号平台信息(混合渠道检查或 Sora 同步需要)。
1542+
// 预加载账号平台信息(混合渠道检查需要)。
15431543
platformByID := map[int64]string{}
1544-
groupAccountsByID := map[int64][]Account{}
1545-
groupNameByID := map[int64]string{}
15461544
if needMixedChannelCheck {
15471545
accounts, err := s.accountRepo.GetByIDs(ctx, input.AccountIDs)
15481546
if err != nil {
1549-
if needMixedChannelCheck {
1550-
return nil, err
1551-
}
1552-
} else {
1553-
for _, account := range accounts {
1554-
if account != nil {
1555-
platformByID[account.ID] = account.Platform
1556-
}
1547+
return nil, err
1548+
}
1549+
for _, account := range accounts {
1550+
if account != nil {
1551+
platformByID[account.ID] = account.Platform
15571552
}
15581553
}
1554+
}
15591555

1560-
loadedAccounts, loadedNames, err := s.preloadMixedChannelRiskData(ctx, *input.GroupIDs)
1561-
if err != nil {
1562-
return nil, err
1556+
// 预检查混合渠道风险:在任何写操作之前,若发现风险立即返回错误。
1557+
if needMixedChannelCheck {
1558+
for _, accountID := range input.AccountIDs {
1559+
platform := platformByID[accountID]
1560+
if platform == "" {
1561+
continue
1562+
}
1563+
if err := s.checkMixedChannelRisk(ctx, accountID, platform, *input.GroupIDs); err != nil {
1564+
return nil, err
1565+
}
15631566
}
1564-
groupAccountsByID = loadedAccounts
1565-
groupNameByID = loadedNames
15661567
}
15671568

15681569
if input.RateMultiplier != nil {
@@ -1606,34 +1607,8 @@ func (s *adminServiceImpl) BulkUpdateAccounts(ctx context.Context, input *BulkUp
16061607
// Handle group bindings per account (requires individual operations).
16071608
for _, accountID := range input.AccountIDs {
16081609
entry := BulkUpdateAccountResult{AccountID: accountID}
1609-
platform := ""
16101610

16111611
if input.GroupIDs != nil {
1612-
// 检查混合渠道风险(除非用户已确认)
1613-
if !input.SkipMixedChannelCheck {
1614-
platform = platformByID[accountID]
1615-
if platform == "" {
1616-
account, err := s.accountRepo.GetByID(ctx, accountID)
1617-
if err != nil {
1618-
entry.Success = false
1619-
entry.Error = err.Error()
1620-
result.Failed++
1621-
result.FailedIDs = append(result.FailedIDs, accountID)
1622-
result.Results = append(result.Results, entry)
1623-
continue
1624-
}
1625-
platform = account.Platform
1626-
}
1627-
if err := s.checkMixedChannelRiskWithPreloaded(accountID, platform, *input.GroupIDs, groupAccountsByID, groupNameByID); err != nil {
1628-
entry.Success = false
1629-
entry.Error = err.Error()
1630-
result.Failed++
1631-
result.FailedIDs = append(result.FailedIDs, accountID)
1632-
result.Results = append(result.Results, entry)
1633-
continue
1634-
}
1635-
}
1636-
16371612
if err := s.accountRepo.BindGroups(ctx, accountID, *input.GroupIDs); err != nil {
16381613
entry.Success = false
16391614
entry.Error = err.Error()
@@ -1642,9 +1617,6 @@ func (s *adminServiceImpl) BulkUpdateAccounts(ctx context.Context, input *BulkUp
16421617
result.Results = append(result.Results, entry)
16431618
continue
16441619
}
1645-
if !input.SkipMixedChannelCheck && platform != "" {
1646-
updateMixedChannelPreloadedAccounts(groupAccountsByID, *input.GroupIDs, accountID, platform)
1647-
}
16481620
}
16491621

16501622
entry.Success = true
@@ -2316,41 +2288,6 @@ func (s *adminServiceImpl) checkMixedChannelRisk(ctx context.Context, currentAcc
23162288
return nil
23172289
}
23182290

2319-
func (s *adminServiceImpl) preloadMixedChannelRiskData(ctx context.Context, groupIDs []int64) (map[int64][]Account, map[int64]string, error) {
2320-
accountsByGroup := make(map[int64][]Account)
2321-
groupNameByID := make(map[int64]string)
2322-
if len(groupIDs) == 0 {
2323-
return accountsByGroup, groupNameByID, nil
2324-
}
2325-
2326-
seen := make(map[int64]struct{}, len(groupIDs))
2327-
for _, groupID := range groupIDs {
2328-
if groupID <= 0 {
2329-
continue
2330-
}
2331-
if _, ok := seen[groupID]; ok {
2332-
continue
2333-
}
2334-
seen[groupID] = struct{}{}
2335-
2336-
accounts, err := s.accountRepo.ListByGroup(ctx, groupID)
2337-
if err != nil {
2338-
return nil, nil, fmt.Errorf("get accounts in group %d: %w", groupID, err)
2339-
}
2340-
accountsByGroup[groupID] = accounts
2341-
2342-
group, err := s.groupRepo.GetByID(ctx, groupID)
2343-
if err != nil {
2344-
continue
2345-
}
2346-
if group != nil {
2347-
groupNameByID[groupID] = group.Name
2348-
}
2349-
}
2350-
2351-
return accountsByGroup, groupNameByID, nil
2352-
}
2353-
23542291
func (s *adminServiceImpl) validateGroupIDsExist(ctx context.Context, groupIDs []int64) error {
23552292
if len(groupIDs) == 0 {
23562293
return nil
@@ -2380,71 +2317,6 @@ func (s *adminServiceImpl) validateGroupIDsExist(ctx context.Context, groupIDs [
23802317
return nil
23812318
}
23822319

2383-
func (s *adminServiceImpl) checkMixedChannelRiskWithPreloaded(currentAccountID int64, currentAccountPlatform string, groupIDs []int64, accountsByGroup map[int64][]Account, groupNameByID map[int64]string) error {
2384-
currentPlatform := getAccountPlatform(currentAccountPlatform)
2385-
if currentPlatform == "" {
2386-
return nil
2387-
}
2388-
2389-
for _, groupID := range groupIDs {
2390-
accounts := accountsByGroup[groupID]
2391-
for _, account := range accounts {
2392-
if currentAccountID > 0 && account.ID == currentAccountID {
2393-
continue
2394-
}
2395-
2396-
otherPlatform := getAccountPlatform(account.Platform)
2397-
if otherPlatform == "" {
2398-
continue
2399-
}
2400-
2401-
if currentPlatform != otherPlatform {
2402-
groupName := fmt.Sprintf("Group %d", groupID)
2403-
if name := strings.TrimSpace(groupNameByID[groupID]); name != "" {
2404-
groupName = name
2405-
}
2406-
2407-
return &MixedChannelError{
2408-
GroupID: groupID,
2409-
GroupName: groupName,
2410-
CurrentPlatform: currentPlatform,
2411-
OtherPlatform: otherPlatform,
2412-
}
2413-
}
2414-
}
2415-
}
2416-
2417-
return nil
2418-
}
2419-
2420-
func updateMixedChannelPreloadedAccounts(accountsByGroup map[int64][]Account, groupIDs []int64, accountID int64, platform string) {
2421-
if len(groupIDs) == 0 || accountID <= 0 || platform == "" {
2422-
return
2423-
}
2424-
for _, groupID := range groupIDs {
2425-
if groupID <= 0 {
2426-
continue
2427-
}
2428-
accounts := accountsByGroup[groupID]
2429-
found := false
2430-
for i := range accounts {
2431-
if accounts[i].ID != accountID {
2432-
continue
2433-
}
2434-
accounts[i].Platform = platform
2435-
found = true
2436-
break
2437-
}
2438-
if !found {
2439-
accounts = append(accounts, Account{
2440-
ID: accountID,
2441-
Platform: platform,
2442-
})
2443-
}
2444-
accountsByGroup[groupID] = accounts
2445-
}
2446-
}
2447-
24482320
// CheckMixedChannelRisk checks whether target groups contain mixed channels for the current account platform.
24492321
func (s *adminServiceImpl) CheckMixedChannelRisk(ctx context.Context, currentAccountID int64, currentAccountPlatform string, groupIDs []int64) error {
24502322
return s.checkMixedChannelRisk(ctx, currentAccountID, currentAccountPlatform, groupIDs)

backend/internal/service/admin_service_bulk_update_test.go

Lines changed: 14 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -139,34 +139,34 @@ func TestAdminService_BulkUpdateAccounts_NilGroupRepoReturnsError(t *testing.T)
139139
require.Contains(t, err.Error(), "group repository not configured")
140140
}
141141

142-
func TestAdminService_BulkUpdateAccounts_MixedChannelCheckUsesUpdatedSnapshot(t *testing.T) {
142+
// TestAdminService_BulkUpdateAccounts_MixedChannelPreCheckBlocksOnExistingConflict verifies
143+
// that the global pre-check detects a conflict with existing group members and returns an
144+
// error before any DB write is performed.
145+
func TestAdminService_BulkUpdateAccounts_MixedChannelPreCheckBlocksOnExistingConflict(t *testing.T) {
143146
repo := &accountRepoStubForBulkUpdate{
144147
getByIDsAccounts: []*Account{
145-
{ID: 1, Platform: PlatformAnthropic},
146-
{ID: 2, Platform: PlatformAntigravity},
148+
{ID: 1, Platform: PlatformAntigravity},
147149
},
150+
// Group 10 already contains an Anthropic account.
148151
listByGroupData: map[int64][]Account{
149-
10: {},
152+
10: {{ID: 99, Platform: PlatformAnthropic}},
150153
},
151154
}
152155
svc := &adminServiceImpl{
153156
accountRepo: repo,
154-
groupRepo: &groupRepoStubForAdmin{getByID: &Group{ID: 10, Name: "目标分组"}},
157+
groupRepo: &groupRepoStubForAdmin{getByID: &Group{ID: 10, Name: "target-group"}},
155158
}
156159

157160
groupIDs := []int64{10}
158161
input := &BulkUpdateAccountsInput{
159-
AccountIDs: []int64{1, 2},
162+
AccountIDs: []int64{1},
160163
GroupIDs: &groupIDs,
161164
}
162165

163166
result, err := svc.BulkUpdateAccounts(context.Background(), input)
164-
require.NoError(t, err)
165-
require.Equal(t, 1, result.Success)
166-
require.Equal(t, 1, result.Failed)
167-
require.ElementsMatch(t, []int64{1}, result.SuccessIDs)
168-
require.ElementsMatch(t, []int64{2}, result.FailedIDs)
169-
require.Len(t, result.Results, 2)
170-
require.Contains(t, result.Results[1].Error, "mixed channel")
171-
require.Equal(t, []int64{1}, repo.bindGroupsCalls)
167+
require.Nil(t, result)
168+
require.Error(t, err)
169+
require.Contains(t, err.Error(), "mixed channel")
170+
// No BindGroups should have been called since the check runs before any write.
171+
require.Empty(t, repo.bindGroupsCalls)
172172
}

frontend/src/api/client.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -267,6 +267,7 @@ apiClient.interceptors.response.use(
267267
return Promise.reject({
268268
status,
269269
code: apiData.code,
270+
error: apiData.error,
270271
message: apiData.message || apiData.detail || error.message
271272
})
272273
}

0 commit comments

Comments
 (0)