diff --git a/backend/cmd/server/wire_gen.go b/backend/cmd/server/wire_gen.go index 8969bf4411..bdc357d482 100644 --- a/backend/cmd/server/wire_gen.go +++ b/backend/cmd/server/wire_gen.go @@ -189,7 +189,7 @@ func initializeApplication(buildInfo handler.BuildInfo) (*Application, error) { adminAccountRepository := repository.NewAdminAccountRepository(client, db, schedulerCache) proxyExitInfoProber := repository.NewProxyExitInfoProber(configConfig) proxyLatencyCache := repository.NewProxyLatencyCache(redisClient) - adminService := service.NewAdminService(userRepository, adminGroupRepository, adminAccountRepository, proxyRepository, apiKeyRepository, redeemCodeRepository, userGroupRateRepository, userRPMCache, billingCacheService, proxyExitInfoProber, proxyLatencyCache, apiKeyAuthCacheInvalidator, client, settingService, subscriptionService, userSubscriptionRepository, privacyClientFactory, openAIGatewayService, affiliateService, compositeModelRouteRepository, compositeRouteResolver) + adminService := service.NewAdminService(userRepository, adminGroupRepository, adminAccountRepository, proxyRepository, apiKeyRepository, redeemCodeRepository, userGroupRateRepository, userRPMCache, billingCacheService, proxyExitInfoProber, proxyLatencyCache, apiKeyAuthCacheInvalidator, client, settingService, subscriptionService, userSubscriptionRepository, privacyClientFactory, openAIGatewayService, affiliateService, compositeModelRouteRepository, compositeRouteResolver, channelService) adminUserHandler := admin.NewUserHandler(adminService, concurrencyService, serviceUserPlatformQuotaRepository, billingCache, totpService, userService, settingService) groupCapacityService := service.NewGroupCapacityService(accountRepository, groupRepository, concurrencyService, sessionLimitCache, rpmCache) groupHandler := admin.NewGroupHandler(adminService, dashboardService, groupCapacityService) diff --git a/backend/internal/server/api_contract_test.go b/backend/internal/server/api_contract_test.go index 91bdd6a360..79dfb919a6 100644 --- a/backend/internal/server/api_contract_test.go +++ b/backend/internal/server/api_contract_test.go @@ -1468,7 +1468,7 @@ func newContractDeps(t *testing.T) *contractDeps { settingRepo := newStubSettingRepo() settingService := service.NewSettingService(settingRepo, cfg) - adminService := service.NewAdminService(userRepo, groupRepo, &accountRepo, proxyRepo, apiKeyRepo, redeemRepo, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil) + adminService := service.NewAdminService(userRepo, groupRepo, &accountRepo, proxyRepo, apiKeyRepo, redeemRepo, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil) authHandler := handler.NewAuthHandler(cfg, nil, userService, settingService, nil, redeemService, nil, nil) apiKeyHandler := handler.NewAPIKeyHandler(apiKeyService) usageHandler := handler.NewUsageHandler(usageService, apiKeyService, nil, nil) diff --git a/backend/internal/service/admin_group.go b/backend/internal/service/admin_group.go index 98f4ab6d31..ac6259509d 100644 --- a/backend/internal/service/admin_group.go +++ b/backend/internal/service/admin_group.go @@ -641,6 +641,9 @@ func (s *adminServiceImpl) UpdateGroup(ctx context.Context, id int64, input *Upd return nil, err } + // 渠道缓存里存了 groupID → platform 的映射,改了平台要让它失效(见函数末尾) + previousPlatform := group.Platform + if input.Name != "" { group.Name = input.Name } @@ -900,6 +903,13 @@ func (s *adminServiceImpl) UpdateGroup(ctx context.Context, id int64, input *Upd s.authCacheInvalidator.InvalidateAuthCacheByGroupID(ctx, id) } + // 平台变了就失效渠道缓存:该缓存持有 groupID → platform,而渠道定价 / 模型映射 / + // 模型白名单都按平台严格隔离。不失效的话,缓存最长 10 分钟仍按旧平台匹配, + // 期间定价查不到会静默回落到 LiteLLM 价格表、映射与白名单也不生效。 + if group.Platform != previousPlatform && s.channelCacheInvalidator != nil { + s.channelCacheInvalidator.InvalidateCache() + } + // 如果指定了复制账号的源分组,同步绑定(替换当前分组的账号) if len(input.CopyAccountsFromGroupIDs) > 0 { // 去重源分组 IDs diff --git a/backend/internal/service/admin_group_platform_cache_test.go b/backend/internal/service/admin_group_platform_cache_test.go new file mode 100644 index 0000000000..d0f0ac752c --- /dev/null +++ b/backend/internal/service/admin_group_platform_cache_test.go @@ -0,0 +1,87 @@ +//go:build unit + +package service + +import ( + "context" + "testing" + + "github.com/stretchr/testify/require" +) + +// groupPlatformRepoStub 只实现 UpdateGroup 走到的两个方法,其余靠内嵌接口占位。 +type groupPlatformRepoStub struct { + GroupRepository + group *Group + updated *Group +} + +func (r *groupPlatformRepoStub) GetByID(_ context.Context, _ int64) (*Group, error) { + cloned := *r.group + return &cloned, nil +} + +func (r *groupPlatformRepoStub) Update(_ context.Context, group *Group) error { + r.updated = group + return nil +} + +type channelCacheInvalidatorSpy struct { + calls int +} + +func (s *channelCacheInvalidatorSpy) InvalidateCache() { s.calls++ } + +// 渠道缓存持有 groupID → platform,而渠道定价/模型映射/模型白名单都按平台严格隔离。 +// 改了分组平台却不失效缓存,最长 10 分钟内这些查找仍按旧平台匹配(静默走错价)。 +func TestUpdateGroupInvalidatesChannelCacheOnPlatformChange(t *testing.T) { + tests := []struct { + name string + fromPlatform string + inputPlatform string + wantCalls int + }{ + { + name: "platform changed invalidates", + fromPlatform: PlatformAnthropic, + inputPlatform: PlatformOpenAI, + wantCalls: 1, + }, + { + name: "same platform does not invalidate", + fromPlatform: PlatformAnthropic, + inputPlatform: PlatformAnthropic, + wantCalls: 0, + }, + { + // 请求里不带 platform 字段时不应该动缓存 + name: "platform omitted does not invalidate", + fromPlatform: PlatformAnthropic, + inputPlatform: "", + wantCalls: 0, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + repo := &groupPlatformRepoStub{group: &Group{ID: 7, Name: "g", Platform: tt.fromPlatform}} + spy := &channelCacheInvalidatorSpy{} + svc := &adminServiceImpl{groupRepo: repo, channelCacheInvalidator: spy} + + got, err := svc.UpdateGroup(context.Background(), 7, &UpdateGroupInput{Platform: tt.inputPlatform}) + require.NoError(t, err) + require.NotNil(t, got) + require.Equal(t, tt.wantCalls, spy.calls) + }) + } +} + +// 依赖可以不注入(例如测试或裁剪构建),此时不应 panic——缓存靠 TTL 自然重建。 +func TestUpdateGroupWithoutChannelCacheInvalidator(t *testing.T) { + repo := &groupPlatformRepoStub{group: &Group{ID: 7, Name: "g", Platform: PlatformAnthropic}} + svc := &adminServiceImpl{groupRepo: repo} + + got, err := svc.UpdateGroup(context.Background(), 7, &UpdateGroupInput{Platform: PlatformOpenAI}) + require.NoError(t, err) + require.Equal(t, PlatformOpenAI, got.Platform) +} diff --git a/backend/internal/service/admin_service.go b/backend/internal/service/admin_service.go index 31d0e48649..3030b945b9 100644 --- a/backend/internal/service/admin_service.go +++ b/backend/internal/service/admin_service.go @@ -666,6 +666,14 @@ type adminServiceImpl struct { affiliateService adminRechargeAffiliateAccruer compositeRouteRepo CompositeModelRouteRepository compositeResolver *CompositeRouteResolver + // 分组平台变更后用来失效渠道缓存;可为 nil(缓存会在 TTL 到期后自然重建) + channelCacheInvalidator ChannelCacheInvalidator +} + +// ChannelCacheInvalidator 失效渠道缓存。 +// 窄接口,避免 admin 服务依赖整个 ChannelService——与 APIKeyAuthCacheInvalidator 同一思路。 +type ChannelCacheInvalidator interface { + InvalidateCache() } type adminRechargeAffiliateAccruer interface { @@ -699,6 +707,7 @@ func NewAdminService( affiliateService *AffiliateService, compositeRouteRepo CompositeModelRouteRepository, compositeResolver *CompositeRouteResolver, + channelCacheInvalidator ChannelCacheInvalidator, ) AdminService { return &adminServiceImpl{ userRepo: userRepo, @@ -725,5 +734,7 @@ func NewAdminService( affiliateService: affiliateService, compositeRouteRepo: compositeRouteRepo, compositeResolver: compositeResolver, + + channelCacheInvalidator: channelCacheInvalidator, } } diff --git a/backend/internal/service/channel_service.go b/backend/internal/service/channel_service.go index 744b8f43d7..3a8c5556ad 100644 --- a/backend/internal/service/channel_service.go +++ b/backend/internal/service/channel_service.go @@ -375,6 +375,13 @@ func channelLookupPlatform(ctx context.Context, groupPlatform string) string { } return groupPlatform } + +// InvalidateCache 失效并重建渠道缓存。 +// 供渠道以外、但会影响渠道缓存内容的变更调用(如分组平台变更)。 +func (s *ChannelService) InvalidateCache() { + s.invalidateCache() +} + func (s *ChannelService) invalidateCache() { s.cache.Store((*channelCache)(nil)) s.cacheSF.Forget("channel_cache") diff --git a/backend/internal/service/wire.go b/backend/internal/service/wire.go index 93555730a7..b70b27d9de 100644 --- a/backend/internal/service/wire.go +++ b/backend/internal/service/wire.go @@ -852,6 +852,7 @@ var ProviderSet = wire.NewSet( ProvideScheduledTestRunnerService, NewGroupCapacityService, NewChannelService, + wire.Bind(new(ChannelCacheInvalidator), new(*ChannelService)), NewModelPricingResolver, NewContentModerationService, NewAffiliateService,