feat: 允许管理员为持有有效订阅的用户绑定订阅类型分组

之前管理员无法通过 API 密钥管理将用户绑定到订阅类型分组(直接返回错误)。
现在改为检查用户是否持有该分组的有效订阅,有则允许绑定,无则拒绝。

- admin_service: 新增 userSubRepo 依赖,替换硬拒绝为订阅校验
- admin_service: 区分 ErrSubscriptionNotFound 和内部错误,避免 DB 故障被误报
- wire_gen/api_contract_test: 同步新增参数
- UserApiKeysModal: 管理员分组下拉不再过滤订阅类型分组

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
ischanx
2026-03-10 00:51:43 +08:00
parent c8eff34388
commit 767a41e263
4 changed files with 13 additions and 6 deletions

View File

@@ -104,7 +104,7 @@ func initializeApplication(buildInfo handler.BuildInfo) (*Application, error) {
proxyRepository := repository.NewProxyRepository(client, db) proxyRepository := repository.NewProxyRepository(client, db)
proxyExitInfoProber := repository.NewProxyExitInfoProber(configConfig) proxyExitInfoProber := repository.NewProxyExitInfoProber(configConfig)
proxyLatencyCache := repository.NewProxyLatencyCache(redisClient) proxyLatencyCache := repository.NewProxyLatencyCache(redisClient)
adminService := service.NewAdminService(userRepository, groupRepository, accountRepository, soraAccountRepository, proxyRepository, apiKeyRepository, redeemCodeRepository, userGroupRateRepository, billingCacheService, proxyExitInfoProber, proxyLatencyCache, apiKeyAuthCacheInvalidator, client, settingService, subscriptionService) adminService := service.NewAdminService(userRepository, groupRepository, accountRepository, soraAccountRepository, proxyRepository, apiKeyRepository, redeemCodeRepository, userGroupRateRepository, billingCacheService, proxyExitInfoProber, proxyLatencyCache, apiKeyAuthCacheInvalidator, client, settingService, subscriptionService, userSubscriptionRepository)
concurrencyCache := repository.ProvideConcurrencyCache(redisClient, configConfig) concurrencyCache := repository.ProvideConcurrencyCache(redisClient, configConfig)
concurrencyService := service.ProvideConcurrencyService(concurrencyCache, accountRepository, configConfig) concurrencyService := service.ProvideConcurrencyService(concurrencyCache, accountRepository, configConfig)
adminUserHandler := admin.NewUserHandler(adminService, concurrencyService) adminUserHandler := admin.NewUserHandler(adminService, concurrencyService)

View File

@@ -645,7 +645,7 @@ func newContractDeps(t *testing.T) *contractDeps {
settingRepo := newStubSettingRepo() settingRepo := newStubSettingRepo()
settingService := service.NewSettingService(settingRepo, cfg) settingService := service.NewSettingService(settingRepo, cfg)
adminService := service.NewAdminService(userRepo, groupRepo, &accountRepo, nil, proxyRepo, apiKeyRepo, redeemRepo, nil, nil, nil, nil, nil, nil, nil, nil) adminService := service.NewAdminService(userRepo, groupRepo, &accountRepo, nil, proxyRepo, apiKeyRepo, redeemRepo, nil, nil, nil, nil, nil, nil, nil, nil, nil)
authHandler := handler.NewAuthHandler(cfg, nil, userService, settingService, nil, redeemService, nil) authHandler := handler.NewAuthHandler(cfg, nil, userService, settingService, nil, redeemService, nil)
apiKeyHandler := handler.NewAPIKeyHandler(apiKeyService) apiKeyHandler := handler.NewAPIKeyHandler(apiKeyService)
usageHandler := handler.NewUsageHandler(usageService, apiKeyService) usageHandler := handler.NewUsageHandler(usageService, apiKeyService)

View File

@@ -432,6 +432,7 @@ type adminServiceImpl struct {
entClient *dbent.Client // 用于开启数据库事务 entClient *dbent.Client // 用于开启数据库事务
settingService *SettingService settingService *SettingService
defaultSubAssigner DefaultSubscriptionAssigner defaultSubAssigner DefaultSubscriptionAssigner
userSubRepo UserSubscriptionRepository
} }
type userGroupRateBatchReader interface { type userGroupRateBatchReader interface {
@@ -459,6 +460,7 @@ func NewAdminService(
entClient *dbent.Client, entClient *dbent.Client,
settingService *SettingService, settingService *SettingService,
defaultSubAssigner DefaultSubscriptionAssigner, defaultSubAssigner DefaultSubscriptionAssigner,
userSubRepo UserSubscriptionRepository,
) AdminService { ) AdminService {
return &adminServiceImpl{ return &adminServiceImpl{
userRepo: userRepo, userRepo: userRepo,
@@ -476,6 +478,7 @@ func NewAdminService(
entClient: entClient, entClient: entClient,
settingService: settingService, settingService: settingService,
defaultSubAssigner: defaultSubAssigner, defaultSubAssigner: defaultSubAssigner,
userSubRepo: userSubRepo,
} }
} }
@@ -1277,9 +1280,14 @@ func (s *adminServiceImpl) AdminUpdateAPIKeyGroupID(ctx context.Context, keyID i
if group.Status != StatusActive { if group.Status != StatusActive {
return nil, infraerrors.BadRequest("GROUP_NOT_ACTIVE", "target group is not active") return nil, infraerrors.BadRequest("GROUP_NOT_ACTIVE", "target group is not active")
} }
// 订阅类型分组:不允许通过此 API 直接绑定,需通过订阅管理流程 // 订阅类型分组:用户须持有该分组的有效订阅才可绑定
if group.IsSubscriptionType() { if group.IsSubscriptionType() {
return nil, infraerrors.BadRequest("SUBSCRIPTION_GROUP_NOT_ALLOWED", "subscription groups must be managed through the subscription workflow") if _, err := s.userSubRepo.GetActiveByUserIDAndGroupID(ctx, apiKey.UserID, *groupID); err != nil {
if errors.Is(err, ErrSubscriptionNotFound) {
return nil, infraerrors.BadRequest("SUBSCRIPTION_REQUIRED", "user does not have an active subscription for this group")
}
return nil, err
}
} }
gid := *groupID gid := *groupID

View File

@@ -162,8 +162,7 @@ const load = async () => {
const loadGroups = async () => { const loadGroups = async () => {
try { try {
const groups = await adminAPI.groups.getAll() const groups = await adminAPI.groups.getAll()
// 过滤掉订阅类型分组(需通过订阅管理流程绑定) allGroups.value = groups
allGroups.value = groups.filter((g) => g.subscription_type !== 'subscription')
} catch (error) { } catch (error) {
console.error('Failed to load groups:', error) console.error('Failed to load groups:', error)
} }