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:
@@ -1122,6 +1122,14 @@ func (h *AccountHandler) BulkUpdate(c *gin.Context) {
|
|||||||
SkipMixedChannelCheck: skipCheck,
|
SkipMixedChannelCheck: skipCheck,
|
||||||
})
|
})
|
||||||
if err != nil {
|
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)
|
response.ErrorFrom(c, err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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/check-mixed-channel", accountHandler.CheckMixedChannel)
|
||||||
router.POST("/api/v1/admin/accounts", accountHandler.Create)
|
router.POST("/api/v1/admin/accounts", accountHandler.Create)
|
||||||
router.PUT("/api/v1/admin/accounts/:id", accountHandler.Update)
|
router.PUT("/api/v1/admin/accounts/:id", accountHandler.Update)
|
||||||
|
router.POST("/api/v1/admin/accounts/bulk-update", accountHandler.BulkUpdate)
|
||||||
return router
|
return router
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -145,3 +146,53 @@ func TestAccountHandlerUpdateMixedChannelConflictSimplifiedResponse(t *testing.T
|
|||||||
require.False(t, hasDetails)
|
require.False(t, hasDetails)
|
||||||
require.False(t, hasRequireConfirmation)
|
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"])
|
||||||
|
}
|
||||||
|
|||||||
@@ -10,22 +10,23 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
type stubAdminService struct {
|
type stubAdminService struct {
|
||||||
users []service.User
|
users []service.User
|
||||||
apiKeys []service.APIKey
|
apiKeys []service.APIKey
|
||||||
groups []service.Group
|
groups []service.Group
|
||||||
accounts []service.Account
|
accounts []service.Account
|
||||||
proxies []service.Proxy
|
proxies []service.Proxy
|
||||||
proxyCounts []service.ProxyWithAccountCount
|
proxyCounts []service.ProxyWithAccountCount
|
||||||
redeems []service.RedeemCode
|
redeems []service.RedeemCode
|
||||||
createdAccounts []*service.CreateAccountInput
|
createdAccounts []*service.CreateAccountInput
|
||||||
createdProxies []*service.CreateProxyInput
|
createdProxies []*service.CreateProxyInput
|
||||||
updatedProxyIDs []int64
|
updatedProxyIDs []int64
|
||||||
updatedProxies []*service.UpdateProxyInput
|
updatedProxies []*service.UpdateProxyInput
|
||||||
testedProxyIDs []int64
|
testedProxyIDs []int64
|
||||||
createAccountErr error
|
createAccountErr error
|
||||||
updateAccountErr error
|
updateAccountErr error
|
||||||
checkMixedErr error
|
bulkUpdateAccountErr error
|
||||||
lastMixedCheck struct {
|
checkMixedErr error
|
||||||
|
lastMixedCheck struct {
|
||||||
accountID int64
|
accountID int64
|
||||||
platform string
|
platform string
|
||||||
groupIDs []int64
|
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) {
|
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 {
|
func (s *stubAdminService) CheckMixedChannelRisk(ctx context.Context, currentAccountID int64, currentAccountPlatform string, groupIDs []int64) error {
|
||||||
|
|||||||
@@ -1539,30 +1539,31 @@ func (s *adminServiceImpl) BulkUpdateAccounts(ctx context.Context, input *BulkUp
|
|||||||
|
|
||||||
needMixedChannelCheck := input.GroupIDs != nil && !input.SkipMixedChannelCheck
|
needMixedChannelCheck := input.GroupIDs != nil && !input.SkipMixedChannelCheck
|
||||||
|
|
||||||
// 预加载账号平台信息(混合渠道检查或 Sora 同步需要)。
|
// 预加载账号平台信息(混合渠道检查需要)。
|
||||||
platformByID := map[int64]string{}
|
platformByID := map[int64]string{}
|
||||||
groupAccountsByID := map[int64][]Account{}
|
|
||||||
groupNameByID := map[int64]string{}
|
|
||||||
if needMixedChannelCheck {
|
if needMixedChannelCheck {
|
||||||
accounts, err := s.accountRepo.GetByIDs(ctx, input.AccountIDs)
|
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 {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
groupAccountsByID = loadedAccounts
|
for _, account := range accounts {
|
||||||
groupNameByID = loadedNames
|
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 {
|
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).
|
// Handle group bindings per account (requires individual operations).
|
||||||
for _, accountID := range input.AccountIDs {
|
for _, accountID := range input.AccountIDs {
|
||||||
entry := BulkUpdateAccountResult{AccountID: accountID}
|
entry := BulkUpdateAccountResult{AccountID: accountID}
|
||||||
platform := ""
|
|
||||||
|
|
||||||
if input.GroupIDs != nil {
|
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 {
|
if err := s.accountRepo.BindGroups(ctx, accountID, *input.GroupIDs); err != nil {
|
||||||
entry.Success = false
|
entry.Success = false
|
||||||
entry.Error = err.Error()
|
entry.Error = err.Error()
|
||||||
@@ -1642,9 +1617,6 @@ func (s *adminServiceImpl) BulkUpdateAccounts(ctx context.Context, input *BulkUp
|
|||||||
result.Results = append(result.Results, entry)
|
result.Results = append(result.Results, entry)
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
if !input.SkipMixedChannelCheck && platform != "" {
|
|
||||||
updateMixedChannelPreloadedAccounts(groupAccountsByID, *input.GroupIDs, accountID, platform)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
entry.Success = true
|
entry.Success = true
|
||||||
@@ -2316,41 +2288,6 @@ func (s *adminServiceImpl) checkMixedChannelRisk(ctx context.Context, currentAcc
|
|||||||
return nil
|
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 {
|
func (s *adminServiceImpl) validateGroupIDsExist(ctx context.Context, groupIDs []int64) error {
|
||||||
if len(groupIDs) == 0 {
|
if len(groupIDs) == 0 {
|
||||||
return nil
|
return nil
|
||||||
@@ -2380,71 +2317,6 @@ func (s *adminServiceImpl) validateGroupIDsExist(ctx context.Context, groupIDs [
|
|||||||
return nil
|
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.
|
// 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 {
|
func (s *adminServiceImpl) CheckMixedChannelRisk(ctx context.Context, currentAccountID int64, currentAccountPlatform string, groupIDs []int64) error {
|
||||||
return s.checkMixedChannelRisk(ctx, currentAccountID, currentAccountPlatform, groupIDs)
|
return s.checkMixedChannelRisk(ctx, currentAccountID, currentAccountPlatform, groupIDs)
|
||||||
|
|||||||
@@ -139,34 +139,34 @@ func TestAdminService_BulkUpdateAccounts_NilGroupRepoReturnsError(t *testing.T)
|
|||||||
require.Contains(t, err.Error(), "group repository not configured")
|
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{
|
repo := &accountRepoStubForBulkUpdate{
|
||||||
getByIDsAccounts: []*Account{
|
getByIDsAccounts: []*Account{
|
||||||
{ID: 1, Platform: PlatformAnthropic},
|
{ID: 1, Platform: PlatformAntigravity},
|
||||||
{ID: 2, Platform: PlatformAntigravity},
|
|
||||||
},
|
},
|
||||||
|
// Group 10 already contains an Anthropic account.
|
||||||
listByGroupData: map[int64][]Account{
|
listByGroupData: map[int64][]Account{
|
||||||
10: {},
|
10: {{ID: 99, Platform: PlatformAnthropic}},
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
svc := &adminServiceImpl{
|
svc := &adminServiceImpl{
|
||||||
accountRepo: repo,
|
accountRepo: repo,
|
||||||
groupRepo: &groupRepoStubForAdmin{getByID: &Group{ID: 10, Name: "目标分组"}},
|
groupRepo: &groupRepoStubForAdmin{getByID: &Group{ID: 10, Name: "target-group"}},
|
||||||
}
|
}
|
||||||
|
|
||||||
groupIDs := []int64{10}
|
groupIDs := []int64{10}
|
||||||
input := &BulkUpdateAccountsInput{
|
input := &BulkUpdateAccountsInput{
|
||||||
AccountIDs: []int64{1, 2},
|
AccountIDs: []int64{1},
|
||||||
GroupIDs: &groupIDs,
|
GroupIDs: &groupIDs,
|
||||||
}
|
}
|
||||||
|
|
||||||
result, err := svc.BulkUpdateAccounts(context.Background(), input)
|
result, err := svc.BulkUpdateAccounts(context.Background(), input)
|
||||||
require.NoError(t, err)
|
require.Nil(t, result)
|
||||||
require.Equal(t, 1, result.Success)
|
require.Error(t, err)
|
||||||
require.Equal(t, 1, result.Failed)
|
require.Contains(t, err.Error(), "mixed channel")
|
||||||
require.ElementsMatch(t, []int64{1}, result.SuccessIDs)
|
// No BindGroups should have been called since the check runs before any write.
|
||||||
require.ElementsMatch(t, []int64{2}, result.FailedIDs)
|
require.Empty(t, repo.bindGroupsCalls)
|
||||||
require.Len(t, result.Results, 2)
|
|
||||||
require.Contains(t, result.Results[1].Error, "mixed channel")
|
|
||||||
require.Equal(t, []int64{1}, repo.bindGroupsCalls)
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -267,6 +267,7 @@ apiClient.interceptors.response.use(
|
|||||||
return Promise.reject({
|
return Promise.reject({
|
||||||
status,
|
status,
|
||||||
code: apiData.code,
|
code: apiData.code,
|
||||||
|
error: apiData.error,
|
||||||
message: apiData.message || apiData.detail || error.message
|
message: apiData.message || apiData.detail || error.message
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -756,6 +756,17 @@
|
|||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
</BaseDialog>
|
</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>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
@@ -765,6 +776,7 @@ import { useAppStore } from '@/stores/app'
|
|||||||
import { adminAPI } from '@/api/admin'
|
import { adminAPI } from '@/api/admin'
|
||||||
import type { Proxy as ProxyConfig, AdminGroup, AccountPlatform, AccountType } from '@/types'
|
import type { Proxy as ProxyConfig, AdminGroup, AccountPlatform, AccountType } from '@/types'
|
||||||
import BaseDialog from '@/components/common/BaseDialog.vue'
|
import BaseDialog from '@/components/common/BaseDialog.vue'
|
||||||
|
import ConfirmDialog from '@/components/common/ConfirmDialog.vue'
|
||||||
import Select from '@/components/common/Select.vue'
|
import Select from '@/components/common/Select.vue'
|
||||||
import ProxySelector from '@/components/common/ProxySelector.vue'
|
import ProxySelector from '@/components/common/ProxySelector.vue'
|
||||||
import GroupSelector from '@/components/common/GroupSelector.vue'
|
import GroupSelector from '@/components/common/GroupSelector.vue'
|
||||||
@@ -844,6 +856,9 @@ const enableRpmLimit = ref(false)
|
|||||||
|
|
||||||
// State - field values
|
// State - field values
|
||||||
const submitting = ref(false)
|
const submitting = ref(false)
|
||||||
|
const showMixedChannelWarning = ref(false)
|
||||||
|
const mixedChannelWarningMessage = ref('')
|
||||||
|
const pendingUpdatesForConfirm = ref<Record<string, unknown> | null>(null)
|
||||||
const baseUrl = ref('')
|
const baseUrl = ref('')
|
||||||
const modelRestrictionMode = ref<'whitelist' | 'mapping'>('whitelist')
|
const modelRestrictionMode = ref<'whitelist' | 'mapping'>('whitelist')
|
||||||
const allowedModels = ref<string[]>([])
|
const allowedModels = ref<string[]>([])
|
||||||
@@ -1237,10 +1252,46 @@ const buildUpdatePayload = (): Record<string, unknown> | null => {
|
|||||||
return Object.keys(updates).length > 0 ? updates : 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 = () => {
|
const handleClose = () => {
|
||||||
|
showMixedChannelWarning.value = false
|
||||||
|
mixedChannelWarningMessage.value = ''
|
||||||
|
pendingUpdatesForConfirm.value = null
|
||||||
|
mixedChannelConfirmed.value = false
|
||||||
emit('close')
|
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 () => {
|
const handleSubmit = async () => {
|
||||||
if (props.accountIds.length === 0) {
|
if (props.accountIds.length === 0) {
|
||||||
appStore.showError(t('admin.accounts.bulkEdit.noSelection'))
|
appStore.showError(t('admin.accounts.bulkEdit.noSelection'))
|
||||||
@@ -1265,12 +1316,24 @@ const handleSubmit = async () => {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
const updates = buildUpdatePayload()
|
const built = buildUpdatePayload()
|
||||||
if (!updates) {
|
if (!built) {
|
||||||
appStore.showError(t('admin.accounts.bulkEdit.noFieldsSelected'))
|
appStore.showError(t('admin.accounts.bulkEdit.noFieldsSelected'))
|
||||||
return
|
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
|
submitting.value = true
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -1287,17 +1350,38 @@ const handleSubmit = async () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (success > 0) {
|
if (success > 0) {
|
||||||
|
pendingUpdatesForConfirm.value = null
|
||||||
emit('updated')
|
emit('updated')
|
||||||
handleClose()
|
handleClose()
|
||||||
}
|
}
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
appStore.showError(error.response?.data?.detail || t('admin.accounts.bulkEdit.failed'))
|
// 兜底:多平台混合场景下,预检查跳过,由后端 409 触发确认框
|
||||||
console.error('Error bulk updating accounts:', error)
|
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 {
|
} finally {
|
||||||
submitting.value = false
|
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
|
// Reset form when modal closes
|
||||||
watch(
|
watch(
|
||||||
() => props.show,
|
() => props.show,
|
||||||
@@ -1330,10 +1414,12 @@ watch(
|
|||||||
rateMultiplier.value = 1
|
rateMultiplier.value = 1
|
||||||
status.value = 'active'
|
status.value = 'active'
|
||||||
groupIds.value = []
|
groupIds.value = []
|
||||||
rpmLimitEnabled.value = false
|
|
||||||
bulkBaseRpm.value = null
|
// Reset mixed channel warning state
|
||||||
bulkRpmStrategy.value = 'tiered'
|
showMixedChannelWarning.value = false
|
||||||
bulkRpmStickyBuffer.value = null
|
mixedChannelWarningMessage.value = ''
|
||||||
|
pendingUpdatesForConfirm.value = null
|
||||||
|
mixedChannelConfirmed.value = false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1961,7 +1961,7 @@ const ensureAntigravityMixedChannelConfirmed = async (onConfirm: () => Promise<v
|
|||||||
})
|
})
|
||||||
return false
|
return false
|
||||||
} catch (error: any) {
|
} 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
|
return false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1984,9 +1984,9 @@ const submitUpdateAccount = async (accountID: number, updatePayload: Record<stri
|
|||||||
emit('updated', updatedAccount)
|
emit('updated', updatedAccount)
|
||||||
handleClose()
|
handleClose()
|
||||||
} catch (error: any) {
|
} 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({
|
openMixedChannelDialog({
|
||||||
message: error.response?.data?.message,
|
message: error.message,
|
||||||
onConfirm: async () => {
|
onConfirm: async () => {
|
||||||
antigravityMixedChannelConfirmed.value = true
|
antigravityMixedChannelConfirmed.value = true
|
||||||
await submitUpdateAccount(accountID, updatePayload)
|
await submitUpdateAccount(accountID, updatePayload)
|
||||||
@@ -1994,7 +1994,7 @@ const submitUpdateAccount = async (accountID: number, updatePayload: Record<stri
|
|||||||
})
|
})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
appStore.showError(error.response?.data?.message || error.response?.data?.detail || t('admin.accounts.failedToUpdate'))
|
appStore.showError(error.message || t('admin.accounts.failedToUpdate'))
|
||||||
} finally {
|
} finally {
|
||||||
submitting.value = false
|
submitting.value = false
|
||||||
}
|
}
|
||||||
@@ -2245,7 +2245,7 @@ const handleSubmit = async () => {
|
|||||||
|
|
||||||
await submitUpdateAccount(accountID, updatePayload)
|
await submitUpdateAccount(accountID, updatePayload)
|
||||||
} catch (error: any) {
|
} 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'))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user