Skip to content

Commit 3cc407b

Browse files
authored
Merge pull request #900 from ischanx/feat/admin-bind-subscription-group
feat: 允许管理员为持有有效订阅的用户绑定订阅类型分组
2 parents 00a0a12 + b08767a commit 3cc407b

5 files changed

Lines changed: 107 additions & 23 deletions

File tree

backend/cmd/server/wire_gen.go

Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

backend/internal/server/api_contract_test.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -645,7 +645,7 @@ func newContractDeps(t *testing.T) *contractDeps {
645645
settingRepo := newStubSettingRepo()
646646
settingService := service.NewSettingService(settingRepo, cfg)
647647

648-
adminService := service.NewAdminService(userRepo, groupRepo, &accountRepo, nil, proxyRepo, apiKeyRepo, redeemRepo, nil, nil, nil, nil, nil, nil, nil, nil)
648+
adminService := service.NewAdminService(userRepo, groupRepo, &accountRepo, nil, proxyRepo, apiKeyRepo, redeemRepo, nil, nil, nil, nil, nil, nil, nil, nil, nil)
649649
authHandler := handler.NewAuthHandler(cfg, nil, userService, settingService, nil, redeemService, nil)
650650
apiKeyHandler := handler.NewAPIKeyHandler(apiKeyService)
651651
usageHandler := handler.NewUsageHandler(usageService, apiKeyService)

backend/internal/service/admin_service.go

Lines changed: 14 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -432,6 +432,7 @@ type adminServiceImpl struct {
432432
entClient *dbent.Client // 用于开启数据库事务
433433
settingService *SettingService
434434
defaultSubAssigner DefaultSubscriptionAssigner
435+
userSubRepo UserSubscriptionRepository
435436
}
436437

437438
type userGroupRateBatchReader interface {
@@ -459,6 +460,7 @@ func NewAdminService(
459460
entClient *dbent.Client,
460461
settingService *SettingService,
461462
defaultSubAssigner DefaultSubscriptionAssigner,
463+
userSubRepo UserSubscriptionRepository,
462464
) AdminService {
463465
return &adminServiceImpl{
464466
userRepo: userRepo,
@@ -476,6 +478,7 @@ func NewAdminService(
476478
entClient: entClient,
477479
settingService: settingService,
478480
defaultSubAssigner: defaultSubAssigner,
481+
userSubRepo: userSubRepo,
479482
}
480483
}
481484

@@ -1277,17 +1280,25 @@ func (s *adminServiceImpl) AdminUpdateAPIKeyGroupID(ctx context.Context, keyID i
12771280
if group.Status != StatusActive {
12781281
return nil, infraerrors.BadRequest("GROUP_NOT_ACTIVE", "target group is not active")
12791282
}
1280-
// 订阅类型分组:不允许通过此 API 直接绑定,需通过订阅管理流程
1283+
// 订阅类型分组:用户须持有该分组的有效订阅才可绑定
12811284
if group.IsSubscriptionType() {
1282-
return nil, infraerrors.BadRequest("SUBSCRIPTION_GROUP_NOT_ALLOWED", "subscription groups must be managed through the subscription workflow")
1285+
if s.userSubRepo == nil {
1286+
return nil, infraerrors.InternalServer("SUBSCRIPTION_REPOSITORY_UNAVAILABLE", "subscription repository is not configured")
1287+
}
1288+
if _, err := s.userSubRepo.GetActiveByUserIDAndGroupID(ctx, apiKey.UserID, *groupID); err != nil {
1289+
if errors.Is(err, ErrSubscriptionNotFound) {
1290+
return nil, infraerrors.BadRequest("SUBSCRIPTION_REQUIRED", "user does not have an active subscription for this group")
1291+
}
1292+
return nil, err
1293+
}
12831294
}
12841295

12851296
gid := *groupID
12861297
apiKey.GroupID = &gid
12871298
apiKey.Group = group
12881299

12891300
// 专属标准分组:使用事务保证「添加分组权限」与「更新 API Key」的原子性
1290-
if group.IsExclusive {
1301+
if group.IsExclusive && !group.IsSubscriptionType() {
12911302
opCtx := ctx
12921303
var tx *dbent.Tx
12931304
if s.entClient == nil {

backend/internal/service/admin_service_apikey_test.go

Lines changed: 90 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -32,28 +32,44 @@ func (s *userRepoStubForGroupUpdate) AddGroupToAllowedGroups(_ context.Context,
3232
return s.addGroupErr
3333
}
3434

35-
func (s *userRepoStubForGroupUpdate) Create(context.Context, *User) error { panic("unexpected") }
36-
func (s *userRepoStubForGroupUpdate) GetByID(context.Context, int64) (*User, error) { panic("unexpected") }
37-
func (s *userRepoStubForGroupUpdate) GetByEmail(context.Context, string) (*User, error) { panic("unexpected") }
38-
func (s *userRepoStubForGroupUpdate) GetFirstAdmin(context.Context) (*User, error) { panic("unexpected") }
39-
func (s *userRepoStubForGroupUpdate) Update(context.Context, *User) error { panic("unexpected") }
40-
func (s *userRepoStubForGroupUpdate) Delete(context.Context, int64) error { panic("unexpected") }
35+
func (s *userRepoStubForGroupUpdate) Create(context.Context, *User) error { panic("unexpected") }
36+
func (s *userRepoStubForGroupUpdate) GetByID(context.Context, int64) (*User, error) {
37+
panic("unexpected")
38+
}
39+
func (s *userRepoStubForGroupUpdate) GetByEmail(context.Context, string) (*User, error) {
40+
panic("unexpected")
41+
}
42+
func (s *userRepoStubForGroupUpdate) GetFirstAdmin(context.Context) (*User, error) {
43+
panic("unexpected")
44+
}
45+
func (s *userRepoStubForGroupUpdate) Update(context.Context, *User) error { panic("unexpected") }
46+
func (s *userRepoStubForGroupUpdate) Delete(context.Context, int64) error { panic("unexpected") }
4147
func (s *userRepoStubForGroupUpdate) List(context.Context, pagination.PaginationParams) ([]User, *pagination.PaginationResult, error) {
4248
panic("unexpected")
4349
}
4450
func (s *userRepoStubForGroupUpdate) ListWithFilters(context.Context, pagination.PaginationParams, UserListFilters) ([]User, *pagination.PaginationResult, error) {
4551
panic("unexpected")
4652
}
47-
func (s *userRepoStubForGroupUpdate) UpdateBalance(context.Context, int64, float64) error { panic("unexpected") }
48-
func (s *userRepoStubForGroupUpdate) DeductBalance(context.Context, int64, float64) error { panic("unexpected") }
49-
func (s *userRepoStubForGroupUpdate) UpdateConcurrency(context.Context, int64, int) error { panic("unexpected") }
50-
func (s *userRepoStubForGroupUpdate) ExistsByEmail(context.Context, string) (bool, error) { panic("unexpected") }
53+
func (s *userRepoStubForGroupUpdate) UpdateBalance(context.Context, int64, float64) error {
54+
panic("unexpected")
55+
}
56+
func (s *userRepoStubForGroupUpdate) DeductBalance(context.Context, int64, float64) error {
57+
panic("unexpected")
58+
}
59+
func (s *userRepoStubForGroupUpdate) UpdateConcurrency(context.Context, int64, int) error {
60+
panic("unexpected")
61+
}
62+
func (s *userRepoStubForGroupUpdate) ExistsByEmail(context.Context, string) (bool, error) {
63+
panic("unexpected")
64+
}
5165
func (s *userRepoStubForGroupUpdate) RemoveGroupFromAllowedGroups(context.Context, int64) (int64, error) {
5266
panic("unexpected")
5367
}
54-
func (s *userRepoStubForGroupUpdate) UpdateTotpSecret(context.Context, int64, *string) error { panic("unexpected") }
55-
func (s *userRepoStubForGroupUpdate) EnableTotp(context.Context, int64) error { panic("unexpected") }
56-
func (s *userRepoStubForGroupUpdate) DisableTotp(context.Context, int64) error { panic("unexpected") }
68+
func (s *userRepoStubForGroupUpdate) UpdateTotpSecret(context.Context, int64, *string) error {
69+
panic("unexpected")
70+
}
71+
func (s *userRepoStubForGroupUpdate) EnableTotp(context.Context, int64) error { panic("unexpected") }
72+
func (s *userRepoStubForGroupUpdate) DisableTotp(context.Context, int64) error { panic("unexpected") }
5773

5874
// apiKeyRepoStubForGroupUpdate implements APIKeyRepository for AdminUpdateAPIKeyGroupID tests.
5975
type apiKeyRepoStubForGroupUpdate struct {
@@ -194,6 +210,29 @@ func (s *groupRepoStubForGroupUpdate) UpdateSortOrders(context.Context, []GroupS
194210
panic("unexpected")
195211
}
196212

213+
type userSubRepoStubForGroupUpdate struct {
214+
userSubRepoNoop
215+
getActiveSub *UserSubscription
216+
getActiveErr error
217+
called bool
218+
calledUserID int64
219+
calledGroupID int64
220+
}
221+
222+
func (s *userSubRepoStubForGroupUpdate) GetActiveByUserIDAndGroupID(_ context.Context, userID, groupID int64) (*UserSubscription, error) {
223+
s.called = true
224+
s.calledUserID = userID
225+
s.calledGroupID = groupID
226+
if s.getActiveErr != nil {
227+
return nil, s.getActiveErr
228+
}
229+
if s.getActiveSub == nil {
230+
return nil, ErrSubscriptionNotFound
231+
}
232+
clone := *s.getActiveSub
233+
return &clone, nil
234+
}
235+
197236
// ---------------------------------------------------------------------------
198237
// Tests
199238
// ---------------------------------------------------------------------------
@@ -386,14 +425,49 @@ func TestAdminService_AdminUpdateAPIKeyGroupID_NonExclusiveGroup_NoAllowedGroupU
386425
func TestAdminService_AdminUpdateAPIKeyGroupID_SubscriptionGroup_Blocked(t *testing.T) {
387426
existing := &APIKey{ID: 1, UserID: 42, Key: "sk-test", GroupID: nil}
388427
apiKeyRepo := &apiKeyRepoStubForGroupUpdate{key: existing}
389-
groupRepo := &groupRepoStubForGroupUpdate{group: &Group{ID: 10, Name: "Sub", Status: StatusActive, IsExclusive: true, SubscriptionType: SubscriptionTypeSubscription}}
428+
groupRepo := &groupRepoStubForGroupUpdate{group: &Group{ID: 10, Name: "Sub", Status: StatusActive, IsExclusive: false, SubscriptionType: SubscriptionTypeSubscription}}
429+
userRepo := &userRepoStubForGroupUpdate{}
430+
userSubRepo := &userSubRepoStubForGroupUpdate{getActiveErr: ErrSubscriptionNotFound}
431+
svc := &adminServiceImpl{apiKeyRepo: apiKeyRepo, groupRepo: groupRepo, userRepo: userRepo, userSubRepo: userSubRepo}
432+
433+
// 无有效订阅时应拒绝绑定
434+
_, err := svc.AdminUpdateAPIKeyGroupID(context.Background(), 1, int64Ptr(10))
435+
require.Error(t, err)
436+
require.Equal(t, "SUBSCRIPTION_REQUIRED", infraerrors.Reason(err))
437+
require.True(t, userSubRepo.called)
438+
require.Equal(t, int64(42), userSubRepo.calledUserID)
439+
require.Equal(t, int64(10), userSubRepo.calledGroupID)
440+
require.False(t, userRepo.addGroupCalled)
441+
}
442+
443+
func TestAdminService_AdminUpdateAPIKeyGroupID_SubscriptionGroup_RequiresRepo(t *testing.T) {
444+
existing := &APIKey{ID: 1, UserID: 42, Key: "sk-test", GroupID: nil}
445+
apiKeyRepo := &apiKeyRepoStubForGroupUpdate{key: existing}
446+
groupRepo := &groupRepoStubForGroupUpdate{group: &Group{ID: 10, Name: "Sub", Status: StatusActive, IsExclusive: false, SubscriptionType: SubscriptionTypeSubscription}}
390447
userRepo := &userRepoStubForGroupUpdate{}
391448
svc := &adminServiceImpl{apiKeyRepo: apiKeyRepo, groupRepo: groupRepo, userRepo: userRepo}
392449

393-
// 订阅类型分组应被阻止绑定
394450
_, err := svc.AdminUpdateAPIKeyGroupID(context.Background(), 1, int64Ptr(10))
395451
require.Error(t, err)
396-
require.Equal(t, "SUBSCRIPTION_GROUP_NOT_ALLOWED", infraerrors.Reason(err))
452+
require.Equal(t, "SUBSCRIPTION_REPOSITORY_UNAVAILABLE", infraerrors.Reason(err))
453+
require.False(t, userRepo.addGroupCalled)
454+
}
455+
456+
func TestAdminService_AdminUpdateAPIKeyGroupID_SubscriptionGroup_AllowsActiveSubscription(t *testing.T) {
457+
existing := &APIKey{ID: 1, UserID: 42, Key: "sk-test", GroupID: nil}
458+
apiKeyRepo := &apiKeyRepoStubForGroupUpdate{key: existing}
459+
groupRepo := &groupRepoStubForGroupUpdate{group: &Group{ID: 10, Name: "Sub", Status: StatusActive, IsExclusive: true, SubscriptionType: SubscriptionTypeSubscription}}
460+
userRepo := &userRepoStubForGroupUpdate{}
461+
userSubRepo := &userSubRepoStubForGroupUpdate{
462+
getActiveSub: &UserSubscription{ID: 99, UserID: 42, GroupID: 10},
463+
}
464+
svc := &adminServiceImpl{apiKeyRepo: apiKeyRepo, groupRepo: groupRepo, userRepo: userRepo, userSubRepo: userSubRepo}
465+
466+
got, err := svc.AdminUpdateAPIKeyGroupID(context.Background(), 1, int64Ptr(10))
467+
require.NoError(t, err)
468+
require.True(t, userSubRepo.called)
469+
require.NotNil(t, got.APIKey.GroupID)
470+
require.Equal(t, int64(10), *got.APIKey.GroupID)
397471
require.False(t, userRepo.addGroupCalled)
398472
}
399473

frontend/src/components/admin/user/UserApiKeysModal.vue

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -162,8 +162,7 @@ const load = async () => {
162162
const loadGroups = async () => {
163163
try {
164164
const groups = await adminAPI.groups.getAll()
165-
// 过滤掉订阅类型分组(需通过订阅管理流程绑定)
166-
allGroups.value = groups.filter((g) => g.subscription_type !== 'subscription')
165+
allGroups.value = groups
167166
} catch (error) {
168167
console.error('Failed to load groups:', error)
169168
}

0 commit comments

Comments
 (0)