feat(admin): 完整实现管理员修改用户 API Key 分组的功能

## 核心功能
- 添加 AdminUpdateAPIKeyGroupID 服务方法,支持绑定/解绑/保持不变三态语义
- 实现 UserRepository.AddGroupToAllowedGroups 接口,自动同步专属分组权限
- 添加 HTTP PUT /api-keys/:id handler 端点,支持管理员直接修改 API Key 分组

## 事务一致性
- 使用 ent Tx 保证专属分组绑定时「添加权限」和「更新 Key」的原子性
- Repository 方法支持 clientFromContext,兼容事务内调用
- 事务失败时自动回滚,避免权限孤立

## 业务逻辑
- 订阅类型分组阻断,需通过订阅管理流程
- 非活跃分组拒绝绑定
- 负 ID 和非法 ID 验证
- 自动授权响应,告知管理员成功授权的分组

## 代码质量
- 16 个单元测试覆盖所有业务路径和边界用例
- 7 个 handler 集成测试覆盖 HTTP 层
- GroupRepo stub 返回克隆副本,防止测试间数据泄漏
- API 类型安全修复(PaginatedResponse<ApiKey>)
- 前端 ref 回调类型对齐 Vue 规范

## 国际化支持
- 中英文提示信息完整
- 自动授权成功/失败提示
This commit is contained in:
QTom
2026-02-28 17:33:30 +08:00
parent 000e621eb6
commit 9a91815b94
18 changed files with 302 additions and 55 deletions

View File

@@ -103,7 +103,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) adminService := service.NewAdminService(userRepository, groupRepository, accountRepository, soraAccountRepository, proxyRepository, apiKeyRepository, redeemCodeRepository, userGroupRateRepository, billingCacheService, proxyExitInfoProber, proxyLatencyCache, apiKeyAuthCacheInvalidator, client)
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

@@ -403,7 +403,7 @@ func (s *stubAdminService) UpdateGroupSortOrders(ctx context.Context, updates []
return nil return nil
} }
func (s *stubAdminService) AdminUpdateAPIKeyGroupID(ctx context.Context, keyID int64, groupID *int64) (*service.APIKey, error) { func (s *stubAdminService) AdminUpdateAPIKeyGroupID(ctx context.Context, keyID int64, groupID *int64) (*service.AdminUpdateAPIKeyGroupIDResult, error) {
for i := range s.apiKeys { for i := range s.apiKeys {
if s.apiKeys[i].ID == keyID { if s.apiKeys[i].ID == keyID {
k := s.apiKeys[i] k := s.apiKeys[i]
@@ -415,7 +415,7 @@ func (s *stubAdminService) AdminUpdateAPIKeyGroupID(ctx context.Context, keyID i
k.GroupID = &gid k.GroupID = &gid
} }
} }
return &k, nil return &service.AdminUpdateAPIKeyGroupIDResult{APIKey: &k}, nil
} }
} }
return nil, service.ErrAPIKeyNotFound return nil, service.ErrAPIKeyNotFound

View File

@@ -42,11 +42,22 @@ func (h *AdminAPIKeyHandler) UpdateGroup(c *gin.Context) {
return return
} }
apiKey, err := h.adminService.AdminUpdateAPIKeyGroupID(c.Request.Context(), keyID, req.GroupID) result, err := h.adminService.AdminUpdateAPIKeyGroupID(c.Request.Context(), keyID, req.GroupID)
if err != nil { if err != nil {
response.ErrorFrom(c, err) response.ErrorFrom(c, err)
return return
} }
response.Success(c, dto.APIKeyFromService(apiKey)) resp := struct {
APIKey *dto.APIKey `json:"api_key"`
AutoGrantedGroupAccess bool `json:"auto_granted_group_access"`
GrantedGroupID *int64 `json:"granted_group_id,omitempty"`
GrantedGroupName string `json:"granted_group_name,omitempty"`
}{
APIKey: dto.APIKeyFromService(result.APIKey),
AutoGrantedGroupAccess: result.AutoGrantedGroupAccess,
GrantedGroupID: result.GrantedGroupID,
GrantedGroupName: result.GrantedGroupName,
}
response.Success(c, resp)
} }

View File

@@ -79,14 +79,17 @@ func TestAdminAPIKeyHandler_UpdateGroup_BindGroup(t *testing.T) {
require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &resp)) require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &resp))
require.Equal(t, 0, resp.Code) require.Equal(t, 0, resp.Code)
var apiKey struct { var data struct {
ID int64 `json:"id"` APIKey struct {
GroupID *int64 `json:"group_id"` ID int64 `json:"id"`
GroupID *int64 `json:"group_id"`
} `json:"api_key"`
AutoGrantedGroupAccess bool `json:"auto_granted_group_access"`
} }
require.NoError(t, json.Unmarshal(resp.Data, &apiKey)) require.NoError(t, json.Unmarshal(resp.Data, &data))
require.Equal(t, int64(10), apiKey.ID) require.Equal(t, int64(10), data.APIKey.ID)
require.NotNil(t, apiKey.GroupID) require.NotNil(t, data.APIKey.GroupID)
require.Equal(t, int64(2), *apiKey.GroupID) require.Equal(t, int64(2), *data.APIKey.GroupID)
} }
func TestAdminAPIKeyHandler_UpdateGroup_Unbind(t *testing.T) { func TestAdminAPIKeyHandler_UpdateGroup_Unbind(t *testing.T) {
@@ -105,11 +108,13 @@ func TestAdminAPIKeyHandler_UpdateGroup_Unbind(t *testing.T) {
var resp struct { var resp struct {
Data struct { Data struct {
GroupID *int64 `json:"group_id"` APIKey struct {
GroupID *int64 `json:"group_id"`
} `json:"api_key"`
} `json:"data"` } `json:"data"`
} }
require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &resp)) require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &resp))
require.Nil(t, resp.Data.GroupID) require.Nil(t, resp.Data.APIKey.GroupID)
} }
func TestAdminAPIKeyHandler_UpdateGroup_ServiceError(t *testing.T) { func TestAdminAPIKeyHandler_UpdateGroup_ServiceError(t *testing.T) {
@@ -142,12 +147,14 @@ func TestAdminAPIKeyHandler_UpdateGroup_EmptyBody_NoChange(t *testing.T) {
var resp struct { var resp struct {
Code int `json:"code"` Code int `json:"code"`
Data struct { Data struct {
ID int64 `json:"id"` APIKey struct {
ID int64 `json:"id"`
} `json:"api_key"`
} `json:"data"` } `json:"data"`
} }
require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &resp)) require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &resp))
require.Equal(t, 0, resp.Code) require.Equal(t, 0, resp.Code)
require.Equal(t, int64(10), resp.Data.ID) require.Equal(t, int64(10), resp.Data.APIKey.ID)
} }
// M2: service returns GROUP_NOT_ACTIVE → handler maps to 400 // M2: service returns GROUP_NOT_ACTIVE → handler maps to 400
@@ -190,6 +197,6 @@ type failingUpdateGroupService struct {
err error err error
} }
func (f *failingUpdateGroupService) AdminUpdateAPIKeyGroupID(_ context.Context, _ int64, _ *int64) (*service.APIKey, error) { func (f *failingUpdateGroupService) AdminUpdateAPIKeyGroupID(_ context.Context, _ int64, _ *int64) (*service.AdminUpdateAPIKeyGroupIDResult, error) {
return nil, f.err return nil, f.err
} }

View File

@@ -171,8 +171,9 @@ func (r *apiKeyRepository) Update(ctx context.Context, key *service.APIKey) erro
// 则会更新已删除的记录。 // 则会更新已删除的记录。
// 这里选择 Update().Where(),确保只有未软删除记录能被更新。 // 这里选择 Update().Where(),确保只有未软删除记录能被更新。
// 同时显式设置 updated_at避免二次查询带来的并发可见性问题。 // 同时显式设置 updated_at避免二次查询带来的并发可见性问题。
client := clientFromContext(ctx, r.client)
now := time.Now() now := time.Now()
builder := r.client.APIKey.Update(). builder := client.APIKey.Update().
Where(apikey.IDEQ(key.ID), apikey.DeletedAtIsNil()). Where(apikey.IDEQ(key.ID), apikey.DeletedAtIsNil()).
SetName(key.Name). SetName(key.Name).
SetStatus(key.Status). SetStatus(key.Status).

View File

@@ -429,6 +429,16 @@ func (r *userRepository) ExistsByEmail(ctx context.Context, email string) (bool,
return r.client.User.Query().Where(dbuser.EmailEQ(email)).Exist(ctx) return r.client.User.Query().Where(dbuser.EmailEQ(email)).Exist(ctx)
} }
func (r *userRepository) AddGroupToAllowedGroups(ctx context.Context, userID int64, groupID int64) error {
client := clientFromContext(ctx, r.client)
return client.UserAllowedGroup.Create().
SetUserID(userID).
SetGroupID(groupID).
OnConflictColumns(userallowedgroup.FieldUserID, userallowedgroup.FieldGroupID).
DoNothing().
Exec(ctx)
}
func (r *userRepository) RemoveGroupFromAllowedGroups(ctx context.Context, groupID int64) (int64, error) { func (r *userRepository) RemoveGroupFromAllowedGroups(ctx context.Context, groupID int64) (int64, error) {
// 仅操作 user_allowed_groups 联接表legacy users.allowed_groups 列已弃用。 // 仅操作 user_allowed_groups 联接表legacy users.allowed_groups 列已弃用。
affected, err := r.client.UserAllowedGroup.Delete(). affected, err := r.client.UserAllowedGroup.Delete().

View File

@@ -619,7 +619,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) adminService := service.NewAdminService(userRepo, groupRepo, &accountRepo, nil, proxyRepo, apiKeyRepo, redeemRepo, 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)
@@ -779,6 +779,10 @@ func (r *stubUserRepo) RemoveGroupFromAllowedGroups(ctx context.Context, groupID
return 0, errors.New("not implemented") return 0, errors.New("not implemented")
} }
func (r *stubUserRepo) AddGroupToAllowedGroups(ctx context.Context, userID int64, groupID int64) error {
return errors.New("not implemented")
}
func (r *stubUserRepo) UpdateTotpSecret(ctx context.Context, userID int64, encryptedSecret *string) error { func (r *stubUserRepo) UpdateTotpSecret(ctx context.Context, userID int64, encryptedSecret *string) error {
return errors.New("not implemented") return errors.New("not implemented")
} }

View File

@@ -181,6 +181,10 @@ func (s *stubUserRepo) RemoveGroupFromAllowedGroups(ctx context.Context, groupID
panic("unexpected RemoveGroupFromAllowedGroups call") panic("unexpected RemoveGroupFromAllowedGroups call")
} }
func (s *stubUserRepo) AddGroupToAllowedGroups(ctx context.Context, userID int64, groupID int64) error {
panic("unexpected AddGroupToAllowedGroups call")
}
func (s *stubUserRepo) UpdateTotpSecret(ctx context.Context, userID int64, encryptedSecret *string) error { func (s *stubUserRepo) UpdateTotpSecret(ctx context.Context, userID int64, encryptedSecret *string) error {
panic("unexpected UpdateTotpSecret call") panic("unexpected UpdateTotpSecret call")
} }

View File

@@ -9,6 +9,7 @@ import (
"strings" "strings"
"time" "time"
dbent "github.com/Wei-Shaw/sub2api/ent"
infraerrors "github.com/Wei-Shaw/sub2api/internal/pkg/errors" infraerrors "github.com/Wei-Shaw/sub2api/internal/pkg/errors"
"github.com/Wei-Shaw/sub2api/internal/pkg/httpclient" "github.com/Wei-Shaw/sub2api/internal/pkg/httpclient"
"github.com/Wei-Shaw/sub2api/internal/pkg/logger" "github.com/Wei-Shaw/sub2api/internal/pkg/logger"
@@ -44,7 +45,7 @@ type AdminService interface {
UpdateGroupSortOrders(ctx context.Context, updates []GroupSortOrderUpdate) error UpdateGroupSortOrders(ctx context.Context, updates []GroupSortOrderUpdate) error
// API Key management (admin) // API Key management (admin)
AdminUpdateAPIKeyGroupID(ctx context.Context, keyID int64, groupID *int64) (*APIKey, error) AdminUpdateAPIKeyGroupID(ctx context.Context, keyID int64, groupID *int64) (*AdminUpdateAPIKeyGroupIDResult, error)
// Account management // Account management
ListAccounts(ctx context.Context, page, pageSize int, platform, accountType, status, search string, groupID int64) ([]Account, int64, error) ListAccounts(ctx context.Context, page, pageSize int, platform, accountType, status, search string, groupID int64) ([]Account, int64, error)
@@ -246,6 +247,14 @@ type BulkUpdateAccountResult struct {
Error string `json:"error,omitempty"` Error string `json:"error,omitempty"`
} }
// AdminUpdateAPIKeyGroupIDResult is the result of AdminUpdateAPIKeyGroupID.
type AdminUpdateAPIKeyGroupIDResult struct {
APIKey *APIKey
AutoGrantedGroupAccess bool // true if a new exclusive group permission was auto-added
GrantedGroupID *int64 // the group ID that was auto-granted
GrantedGroupName string // the group name that was auto-granted
}
// BulkUpdateAccountsResult is the aggregated response for bulk updates. // BulkUpdateAccountsResult is the aggregated response for bulk updates.
type BulkUpdateAccountsResult struct { type BulkUpdateAccountsResult struct {
Success int `json:"success"` Success int `json:"success"`
@@ -410,6 +419,7 @@ type adminServiceImpl struct {
proxyProber ProxyExitInfoProber proxyProber ProxyExitInfoProber
proxyLatencyCache ProxyLatencyCache proxyLatencyCache ProxyLatencyCache
authCacheInvalidator APIKeyAuthCacheInvalidator authCacheInvalidator APIKeyAuthCacheInvalidator
entClient *dbent.Client // 用于开启数据库事务
} }
type userGroupRateBatchReader interface { type userGroupRateBatchReader interface {
@@ -434,6 +444,7 @@ func NewAdminService(
proxyProber ProxyExitInfoProber, proxyProber ProxyExitInfoProber,
proxyLatencyCache ProxyLatencyCache, proxyLatencyCache ProxyLatencyCache,
authCacheInvalidator APIKeyAuthCacheInvalidator, authCacheInvalidator APIKeyAuthCacheInvalidator,
entClient *dbent.Client,
) AdminService { ) AdminService {
return &adminServiceImpl{ return &adminServiceImpl{
userRepo: userRepo, userRepo: userRepo,
@@ -448,6 +459,7 @@ func NewAdminService(
proxyProber: proxyProber, proxyProber: proxyProber,
proxyLatencyCache: proxyLatencyCache, proxyLatencyCache: proxyLatencyCache,
authCacheInvalidator: authCacheInvalidator, authCacheInvalidator: authCacheInvalidator,
entClient: entClient,
} }
} }
@@ -1191,7 +1203,7 @@ func (s *adminServiceImpl) UpdateGroupSortOrders(ctx context.Context, updates []
// AdminUpdateAPIKeyGroupID 管理员修改 API Key 分组绑定 // AdminUpdateAPIKeyGroupID 管理员修改 API Key 分组绑定
// groupID: nil=不修改, 指向0=解绑, 指向正整数=绑定到目标分组 // groupID: nil=不修改, 指向0=解绑, 指向正整数=绑定到目标分组
func (s *adminServiceImpl) AdminUpdateAPIKeyGroupID(ctx context.Context, keyID int64, groupID *int64) (*APIKey, error) { func (s *adminServiceImpl) AdminUpdateAPIKeyGroupID(ctx context.Context, keyID int64, groupID *int64) (*AdminUpdateAPIKeyGroupIDResult, error) {
apiKey, err := s.apiKeyRepo.GetByID(ctx, keyID) apiKey, err := s.apiKeyRepo.GetByID(ctx, keyID)
if err != nil { if err != nil {
return nil, err return nil, err
@@ -1199,15 +1211,17 @@ func (s *adminServiceImpl) AdminUpdateAPIKeyGroupID(ctx context.Context, keyID i
if groupID == nil { if groupID == nil {
// nil 表示不修改,直接返回 // nil 表示不修改,直接返回
return apiKey, nil return &AdminUpdateAPIKeyGroupIDResult{APIKey: apiKey}, nil
} }
if *groupID < 0 { if *groupID < 0 {
return nil, infraerrors.BadRequest("INVALID_GROUP_ID", "group_id must be non-negative") return nil, infraerrors.BadRequest("INVALID_GROUP_ID", "group_id must be non-negative")
} }
result := &AdminUpdateAPIKeyGroupIDResult{}
if *groupID == 0 { if *groupID == 0 {
// 0 表示解绑分组 // 0 表示解绑分组(不修改 user_allowed_groups避免影响用户其他 Key
apiKey.GroupID = nil apiKey.GroupID = nil
apiKey.Group = nil apiKey.Group = nil
} else { } else {
@@ -1219,11 +1233,58 @@ 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() {
return nil, infraerrors.BadRequest("SUBSCRIPTION_GROUP_NOT_ALLOWED", "subscription groups must be managed through the subscription workflow")
}
gid := *groupID gid := *groupID
apiKey.GroupID = &gid apiKey.GroupID = &gid
apiKey.Group = group apiKey.Group = group
// 专属标准分组:使用事务保证「添加分组权限」与「更新 API Key」的原子性
if group.IsExclusive {
opCtx := ctx
var tx *dbent.Tx
if s.entClient == nil {
logger.LegacyPrintf("service.admin", "Warning: entClient is nil, skipping transaction protection for exclusive group binding")
} else {
var txErr error
tx, txErr = s.entClient.Tx(ctx)
if txErr != nil {
return nil, fmt.Errorf("begin transaction: %w", txErr)
}
defer func() { _ = tx.Rollback() }()
opCtx = dbent.NewTxContext(ctx, tx)
}
if addErr := s.userRepo.AddGroupToAllowedGroups(opCtx, apiKey.UserID, gid); addErr != nil {
return nil, fmt.Errorf("add group to user allowed groups: %w", addErr)
}
if err := s.apiKeyRepo.Update(opCtx, apiKey); err != nil {
return nil, fmt.Errorf("update api key: %w", err)
}
if tx != nil {
if err := tx.Commit(); err != nil {
return nil, fmt.Errorf("commit transaction: %w", err)
}
}
result.AutoGrantedGroupAccess = true
result.GrantedGroupID = &gid
result.GrantedGroupName = group.Name
// 失效认证缓存(在事务提交后执行)
if s.authCacheInvalidator != nil {
s.authCacheInvalidator.InvalidateAuthCacheByKey(ctx, apiKey.Key)
}
result.APIKey = apiKey
return result, nil
}
} }
// 非专属分组 / 解绑:无需事务,单步更新即可
if err := s.apiKeyRepo.Update(ctx, apiKey); err != nil { if err := s.apiKeyRepo.Update(ctx, apiKey); err != nil {
return nil, fmt.Errorf("update api key: %w", err) return nil, fmt.Errorf("update api key: %w", err)
} }
@@ -1233,7 +1294,8 @@ func (s *adminServiceImpl) AdminUpdateAPIKeyGroupID(ctx context.Context, keyID i
s.authCacheInvalidator.InvalidateAuthCacheByKey(ctx, apiKey.Key) s.authCacheInvalidator.InvalidateAuthCacheByKey(ctx, apiKey.Key)
} }
return apiKey, nil result.APIKey = apiKey
return result, nil
} }
// Account management implementations // Account management implementations

View File

@@ -17,6 +17,44 @@ import (
// Stubs // Stubs
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// userRepoStubForGroupUpdate implements UserRepository for AdminUpdateAPIKeyGroupID tests.
type userRepoStubForGroupUpdate struct {
addGroupErr error
addGroupCalled bool
addedUserID int64
addedGroupID int64
}
func (s *userRepoStubForGroupUpdate) AddGroupToAllowedGroups(_ context.Context, userID int64, groupID int64) error {
s.addGroupCalled = true
s.addedUserID = userID
s.addedGroupID = groupID
return s.addGroupErr
}
func (s *userRepoStubForGroupUpdate) Create(context.Context, *User) error { panic("unexpected") }
func (s *userRepoStubForGroupUpdate) GetByID(context.Context, int64) (*User, error) { panic("unexpected") }
func (s *userRepoStubForGroupUpdate) GetByEmail(context.Context, string) (*User, error) { panic("unexpected") }
func (s *userRepoStubForGroupUpdate) GetFirstAdmin(context.Context) (*User, error) { panic("unexpected") }
func (s *userRepoStubForGroupUpdate) Update(context.Context, *User) error { panic("unexpected") }
func (s *userRepoStubForGroupUpdate) Delete(context.Context, int64) error { panic("unexpected") }
func (s *userRepoStubForGroupUpdate) List(context.Context, pagination.PaginationParams) ([]User, *pagination.PaginationResult, error) {
panic("unexpected")
}
func (s *userRepoStubForGroupUpdate) ListWithFilters(context.Context, pagination.PaginationParams, UserListFilters) ([]User, *pagination.PaginationResult, error) {
panic("unexpected")
}
func (s *userRepoStubForGroupUpdate) UpdateBalance(context.Context, int64, float64) error { panic("unexpected") }
func (s *userRepoStubForGroupUpdate) DeductBalance(context.Context, int64, float64) error { panic("unexpected") }
func (s *userRepoStubForGroupUpdate) UpdateConcurrency(context.Context, int64, int) error { panic("unexpected") }
func (s *userRepoStubForGroupUpdate) ExistsByEmail(context.Context, string) (bool, error) { panic("unexpected") }
func (s *userRepoStubForGroupUpdate) RemoveGroupFromAllowedGroups(context.Context, int64) (int64, error) {
panic("unexpected")
}
func (s *userRepoStubForGroupUpdate) UpdateTotpSecret(context.Context, int64, *string) error { panic("unexpected") }
func (s *userRepoStubForGroupUpdate) EnableTotp(context.Context, int64) error { panic("unexpected") }
func (s *userRepoStubForGroupUpdate) DisableTotp(context.Context, int64) error { panic("unexpected") }
// apiKeyRepoStubForGroupUpdate implements APIKeyRepository for AdminUpdateAPIKeyGroupID tests. // apiKeyRepoStubForGroupUpdate implements APIKeyRepository for AdminUpdateAPIKeyGroupID tests.
type apiKeyRepoStubForGroupUpdate struct { type apiKeyRepoStubForGroupUpdate struct {
key *APIKey key *APIKey
@@ -102,7 +140,8 @@ func (s *groupRepoStubForGroupUpdate) GetByID(_ context.Context, id int64) (*Gro
if s.getErr != nil { if s.getErr != nil {
return nil, s.getErr return nil, s.getErr
} }
return s.group, nil clone := *s.group
return &clone, nil
} }
// Unused methods panic on unexpected call. // Unused methods panic on unexpected call.
@@ -165,7 +204,7 @@ func TestAdminService_AdminUpdateAPIKeyGroupID_NilGroupID_NoOp(t *testing.T) {
got, err := svc.AdminUpdateAPIKeyGroupID(context.Background(), 1, nil) got, err := svc.AdminUpdateAPIKeyGroupID(context.Background(), 1, nil)
require.NoError(t, err) require.NoError(t, err)
require.Equal(t, int64(1), got.ID) require.Equal(t, int64(1), got.APIKey.ID)
// Update should NOT have been called (updated stays nil) // Update should NOT have been called (updated stays nil)
require.Nil(t, repo.updated) require.Nil(t, repo.updated)
} }
@@ -178,8 +217,8 @@ func TestAdminService_AdminUpdateAPIKeyGroupID_Unbind(t *testing.T) {
got, err := svc.AdminUpdateAPIKeyGroupID(context.Background(), 1, int64Ptr(0)) got, err := svc.AdminUpdateAPIKeyGroupID(context.Background(), 1, int64Ptr(0))
require.NoError(t, err) require.NoError(t, err)
require.Nil(t, got.GroupID, "group_id should be nil after unbind") require.Nil(t, got.APIKey.GroupID, "group_id should be nil after unbind")
require.Nil(t, got.Group, "group object should be nil after unbind") require.Nil(t, got.APIKey.Group, "group object should be nil after unbind")
require.NotNil(t, repo.updated, "Update should have been called") require.NotNil(t, repo.updated, "Update should have been called")
require.Nil(t, repo.updated.GroupID) require.Nil(t, repo.updated.GroupID)
require.Equal(t, []string{"sk-test"}, cache.keys, "cache should be invalidated") require.Equal(t, []string{"sk-test"}, cache.keys, "cache should be invalidated")
@@ -194,15 +233,15 @@ func TestAdminService_AdminUpdateAPIKeyGroupID_BindActiveGroup(t *testing.T) {
got, err := svc.AdminUpdateAPIKeyGroupID(context.Background(), 1, int64Ptr(10)) got, err := svc.AdminUpdateAPIKeyGroupID(context.Background(), 1, int64Ptr(10))
require.NoError(t, err) require.NoError(t, err)
require.NotNil(t, got.GroupID) require.NotNil(t, got.APIKey.GroupID)
require.Equal(t, int64(10), *got.GroupID) require.Equal(t, int64(10), *got.APIKey.GroupID)
require.Equal(t, int64(10), *apiKeyRepo.updated.GroupID) require.Equal(t, int64(10), *apiKeyRepo.updated.GroupID)
require.Equal(t, []string{"sk-test"}, cache.keys) require.Equal(t, []string{"sk-test"}, cache.keys)
// M3: verify correct group ID was passed to repo // M3: verify correct group ID was passed to repo
require.Equal(t, int64(10), groupRepo.lastGetByIDArg) require.Equal(t, int64(10), groupRepo.lastGetByIDArg)
// C1 fix: verify Group object is populated // C1 fix: verify Group object is populated
require.NotNil(t, got.Group) require.NotNil(t, got.APIKey.Group)
require.Equal(t, "Pro", got.Group.Name) require.Equal(t, "Pro", got.APIKey.Group.Name)
} }
func TestAdminService_AdminUpdateAPIKeyGroupID_SameGroup_Idempotent(t *testing.T) { func TestAdminService_AdminUpdateAPIKeyGroupID_SameGroup_Idempotent(t *testing.T) {
@@ -214,8 +253,8 @@ func TestAdminService_AdminUpdateAPIKeyGroupID_SameGroup_Idempotent(t *testing.T
got, err := svc.AdminUpdateAPIKeyGroupID(context.Background(), 1, int64Ptr(10)) got, err := svc.AdminUpdateAPIKeyGroupID(context.Background(), 1, int64Ptr(10))
require.NoError(t, err) require.NoError(t, err)
require.NotNil(t, got.GroupID) require.NotNil(t, got.APIKey.GroupID)
require.Equal(t, int64(10), *got.GroupID) require.Equal(t, int64(10), *got.APIKey.GroupID)
// Update is still called (current impl doesn't short-circuit on same group) // Update is still called (current impl doesn't short-circuit on same group)
require.NotNil(t, apiKeyRepo.updated) require.NotNil(t, apiKeyRepo.updated)
require.Equal(t, []string{"sk-test"}, cache.keys) require.Equal(t, []string{"sk-test"}, cache.keys)
@@ -272,10 +311,10 @@ func TestAdminService_AdminUpdateAPIKeyGroupID_PointerIsolation(t *testing.T) {
inputGID := int64(10) inputGID := int64(10)
got, err := svc.AdminUpdateAPIKeyGroupID(context.Background(), 1, &inputGID) got, err := svc.AdminUpdateAPIKeyGroupID(context.Background(), 1, &inputGID)
require.NoError(t, err) require.NoError(t, err)
require.NotNil(t, got.GroupID) require.NotNil(t, got.APIKey.GroupID)
// Mutating the input pointer must NOT affect the stored value // Mutating the input pointer must NOT affect the stored value
inputGID = 999 inputGID = 999
require.Equal(t, int64(10), *got.GroupID) require.Equal(t, int64(10), *got.APIKey.GroupID)
require.Equal(t, int64(10), *apiKeyRepo.updated.GroupID) require.Equal(t, int64(10), *apiKeyRepo.updated.GroupID)
} }
@@ -288,6 +327,94 @@ func TestAdminService_AdminUpdateAPIKeyGroupID_NilCacheInvalidator(t *testing.T)
got, err := svc.AdminUpdateAPIKeyGroupID(context.Background(), 1, int64Ptr(7)) got, err := svc.AdminUpdateAPIKeyGroupID(context.Background(), 1, int64Ptr(7))
require.NoError(t, err) require.NoError(t, err)
require.NotNil(t, got.GroupID) require.NotNil(t, got.APIKey.GroupID)
require.Equal(t, int64(7), *got.GroupID) require.Equal(t, int64(7), *got.APIKey.GroupID)
}
// ---------------------------------------------------------------------------
// Tests: AllowedGroup auto-sync
// ---------------------------------------------------------------------------
func TestAdminService_AdminUpdateAPIKeyGroupID_ExclusiveGroup_AddsAllowedGroup(t *testing.T) {
existing := &APIKey{ID: 1, UserID: 42, Key: "sk-test", GroupID: nil}
apiKeyRepo := &apiKeyRepoStubForGroupUpdate{key: existing}
groupRepo := &groupRepoStubForGroupUpdate{group: &Group{ID: 10, Name: "Exclusive", Status: StatusActive, IsExclusive: true, SubscriptionType: SubscriptionTypeStandard}}
userRepo := &userRepoStubForGroupUpdate{}
cache := &authCacheInvalidatorStub{}
svc := &adminServiceImpl{apiKeyRepo: apiKeyRepo, groupRepo: groupRepo, userRepo: userRepo, authCacheInvalidator: cache}
got, err := svc.AdminUpdateAPIKeyGroupID(context.Background(), 1, int64Ptr(10))
require.NoError(t, err)
require.NotNil(t, got.APIKey.GroupID)
require.Equal(t, int64(10), *got.APIKey.GroupID)
// 验证 AddGroupToAllowedGroups 被调用,且参数正确
require.True(t, userRepo.addGroupCalled)
require.Equal(t, int64(42), userRepo.addedUserID)
require.Equal(t, int64(10), userRepo.addedGroupID)
// 验证 result 标记了自动授权
require.True(t, got.AutoGrantedGroupAccess)
require.NotNil(t, got.GrantedGroupID)
require.Equal(t, int64(10), *got.GrantedGroupID)
require.Equal(t, "Exclusive", got.GrantedGroupName)
}
func TestAdminService_AdminUpdateAPIKeyGroupID_NonExclusiveGroup_NoAllowedGroupUpdate(t *testing.T) {
existing := &APIKey{ID: 1, UserID: 42, Key: "sk-test", GroupID: nil}
apiKeyRepo := &apiKeyRepoStubForGroupUpdate{key: existing}
groupRepo := &groupRepoStubForGroupUpdate{group: &Group{ID: 10, Name: "Public", Status: StatusActive, IsExclusive: false, SubscriptionType: SubscriptionTypeStandard}}
userRepo := &userRepoStubForGroupUpdate{}
cache := &authCacheInvalidatorStub{}
svc := &adminServiceImpl{apiKeyRepo: apiKeyRepo, groupRepo: groupRepo, userRepo: userRepo, authCacheInvalidator: cache}
got, err := svc.AdminUpdateAPIKeyGroupID(context.Background(), 1, int64Ptr(10))
require.NoError(t, err)
require.NotNil(t, got.APIKey.GroupID)
// 非专属分组不触发 AddGroupToAllowedGroups
require.False(t, userRepo.addGroupCalled)
require.False(t, got.AutoGrantedGroupAccess)
}
func TestAdminService_AdminUpdateAPIKeyGroupID_SubscriptionGroup_Blocked(t *testing.T) {
existing := &APIKey{ID: 1, UserID: 42, Key: "sk-test", GroupID: nil}
apiKeyRepo := &apiKeyRepoStubForGroupUpdate{key: existing}
groupRepo := &groupRepoStubForGroupUpdate{group: &Group{ID: 10, Name: "Sub", Status: StatusActive, IsExclusive: true, SubscriptionType: SubscriptionTypeSubscription}}
userRepo := &userRepoStubForGroupUpdate{}
svc := &adminServiceImpl{apiKeyRepo: apiKeyRepo, groupRepo: groupRepo, userRepo: userRepo}
// 订阅类型分组应被阻止绑定
_, err := svc.AdminUpdateAPIKeyGroupID(context.Background(), 1, int64Ptr(10))
require.Error(t, err)
require.Equal(t, "SUBSCRIPTION_GROUP_NOT_ALLOWED", infraerrors.Reason(err))
require.False(t, userRepo.addGroupCalled)
}
func TestAdminService_AdminUpdateAPIKeyGroupID_ExclusiveGroup_AllowedGroupAddFails_ReturnsError(t *testing.T) {
existing := &APIKey{ID: 1, UserID: 42, Key: "sk-test", GroupID: nil}
apiKeyRepo := &apiKeyRepoStubForGroupUpdate{key: existing}
groupRepo := &groupRepoStubForGroupUpdate{group: &Group{ID: 10, Name: "Exclusive", Status: StatusActive, IsExclusive: true, SubscriptionType: SubscriptionTypeStandard}}
userRepo := &userRepoStubForGroupUpdate{addGroupErr: errors.New("db error")}
svc := &adminServiceImpl{apiKeyRepo: apiKeyRepo, groupRepo: groupRepo, userRepo: userRepo}
// 严格模式AddGroupToAllowedGroups 失败时,整体操作报错
_, err := svc.AdminUpdateAPIKeyGroupID(context.Background(), 1, int64Ptr(10))
require.Error(t, err)
require.Contains(t, err.Error(), "add group to user allowed groups")
require.True(t, userRepo.addGroupCalled)
// apiKey 不应被更新
require.Nil(t, apiKeyRepo.updated)
}
func TestAdminService_AdminUpdateAPIKeyGroupID_Unbind_NoAllowedGroupUpdate(t *testing.T) {
existing := &APIKey{ID: 1, UserID: 42, Key: "sk-test", GroupID: int64Ptr(10), Group: &Group{ID: 10, Name: "Exclusive"}}
apiKeyRepo := &apiKeyRepoStubForGroupUpdate{key: existing}
userRepo := &userRepoStubForGroupUpdate{}
cache := &authCacheInvalidatorStub{}
svc := &adminServiceImpl{apiKeyRepo: apiKeyRepo, userRepo: userRepo, authCacheInvalidator: cache}
got, err := svc.AdminUpdateAPIKeyGroupID(context.Background(), 1, int64Ptr(0))
require.NoError(t, err)
require.Nil(t, got.APIKey.GroupID)
// 解绑时不修改 allowed_groups
require.False(t, userRepo.addGroupCalled)
require.False(t, got.AutoGrantedGroupAccess)
} }

View File

@@ -93,6 +93,10 @@ func (s *userRepoStub) RemoveGroupFromAllowedGroups(ctx context.Context, groupID
panic("unexpected RemoveGroupFromAllowedGroups call") panic("unexpected RemoveGroupFromAllowedGroups call")
} }
func (s *userRepoStub) AddGroupToAllowedGroups(ctx context.Context, userID int64, groupID int64) error {
panic("unexpected AddGroupToAllowedGroups call")
}
func (s *userRepoStub) UpdateTotpSecret(ctx context.Context, userID int64, encryptedSecret *string) error { func (s *userRepoStub) UpdateTotpSecret(ctx context.Context, userID int64, encryptedSecret *string) error {
panic("unexpected UpdateTotpSecret call") panic("unexpected UpdateTotpSecret call")
} }

View File

@@ -40,6 +40,8 @@ type UserRepository interface {
UpdateConcurrency(ctx context.Context, id int64, amount int) error UpdateConcurrency(ctx context.Context, id int64, amount int) error
ExistsByEmail(ctx context.Context, email string) (bool, error) ExistsByEmail(ctx context.Context, email string) (bool, error)
RemoveGroupFromAllowedGroups(ctx context.Context, groupID int64) (int64, error) RemoveGroupFromAllowedGroups(ctx context.Context, groupID int64) (int64, error)
// AddGroupToAllowedGroups 将指定分组增量添加到用户的 allowed_groups幂等冲突忽略
AddGroupToAllowedGroups(ctx context.Context, userID int64, groupID int64) error
// TOTP 双因素认证 // TOTP 双因素认证
UpdateTotpSecret(ctx context.Context, userID int64, encryptedSecret *string) error UpdateTotpSecret(ctx context.Context, userID int64, encryptedSecret *string) error

View File

@@ -45,7 +45,8 @@ func (m *mockUserRepo) ExistsByEmail(context.Context, string) (bool, error) { re
func (m *mockUserRepo) RemoveGroupFromAllowedGroups(context.Context, int64) (int64, error) { func (m *mockUserRepo) RemoveGroupFromAllowedGroups(context.Context, int64) (int64, error) {
return 0, nil return 0, nil
} }
func (m *mockUserRepo) UpdateTotpSecret(context.Context, int64, *string) error { return nil } func (m *mockUserRepo) AddGroupToAllowedGroups(context.Context, int64, int64) error { return nil }
func (m *mockUserRepo) UpdateTotpSecret(context.Context, int64, *string) error { return nil }
func (m *mockUserRepo) EnableTotp(context.Context, int64) error { return nil } func (m *mockUserRepo) EnableTotp(context.Context, int64) error { return nil }
func (m *mockUserRepo) DisableTotp(context.Context, int64) error { return nil } func (m *mockUserRepo) DisableTotp(context.Context, int64) error { return nil }

View File

@@ -6,14 +6,21 @@
import { apiClient } from '../client' import { apiClient } from '../client'
import type { ApiKey } from '@/types' import type { ApiKey } from '@/types'
export interface UpdateApiKeyGroupResult {
api_key: ApiKey
auto_granted_group_access: boolean
granted_group_id?: number
granted_group_name?: string
}
/** /**
* Update an API key's group binding * Update an API key's group binding
* @param id - API Key ID * @param id - API Key ID
* @param groupId - Group ID (0 to unbind, positive to bind, null/undefined to skip) * @param groupId - Group ID (0 to unbind, positive to bind, null/undefined to skip)
* @returns Updated API key * @returns Updated API key with auto-grant info
*/ */
export async function updateApiKeyGroup(id: number, groupId: number | null): Promise<ApiKey> { export async function updateApiKeyGroup(id: number, groupId: number | null): Promise<UpdateApiKeyGroupResult> {
const { data } = await apiClient.put<ApiKey>(`/admin/api-keys/${id}`, { const { data } = await apiClient.put<UpdateApiKeyGroupResult>(`/admin/api-keys/${id}`, {
group_id: groupId === null ? 0 : groupId group_id: groupId === null ? 0 : groupId
}) })
return data return data

View File

@@ -4,7 +4,7 @@
*/ */
import { apiClient } from '../client' import { apiClient } from '../client'
import type { AdminUser, UpdateUserRequest, PaginatedResponse } from '@/types' import type { AdminUser, UpdateUserRequest, PaginatedResponse, ApiKey } from '@/types'
/** /**
* List all users with pagination * List all users with pagination
@@ -145,8 +145,8 @@ export async function toggleStatus(id: number, status: 'active' | 'disabled'): P
* @param id - User ID * @param id - User ID
* @returns List of user's API keys * @returns List of user's API keys
*/ */
export async function getUserApiKeys(id: number): Promise<PaginatedResponse<any>> { export async function getUserApiKeys(id: number): Promise<PaginatedResponse<ApiKey>> {
const { data } = await apiClient.get<PaginatedResponse<any>>(`/admin/users/${id}/api-keys`) const { data } = await apiClient.get<PaginatedResponse<ApiKey>>(`/admin/users/${id}/api-keys`)
return data return data
} }

View File

@@ -21,7 +21,7 @@
<div class="flex items-center gap-1"> <div class="flex items-center gap-1">
<span>{{ t('admin.users.group') }}:</span> <span>{{ t('admin.users.group') }}:</span>
<button <button
:ref="(el) => setGroupButtonRef(key.id, el as HTMLElement)" :ref="(el) => setGroupButtonRef(key.id, el)"
@click="openGroupSelector(key)" @click="openGroupSelector(key)"
class="-mx-1 -my-0.5 flex cursor-pointer items-center gap-1 rounded-md px-1 py-0.5 transition-colors hover:bg-gray-100 dark:hover:bg-dark-700" class="-mx-1 -my-0.5 flex cursor-pointer items-center gap-1 rounded-md px-1 py-0.5 transition-colors hover:bg-gray-100 dark:hover:bg-dark-700"
:disabled="updatingKeyIds.has(key.id)" :disabled="updatingKeyIds.has(key.id)"
@@ -98,7 +98,7 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { ref, computed, watch, onMounted, onUnmounted } from 'vue' import { ref, computed, watch, onMounted, onUnmounted, type ComponentPublicInstance } from 'vue'
import { useI18n } from 'vue-i18n' import { useI18n } from 'vue-i18n'
import { useAppStore } from '@/stores/app' import { useAppStore } from '@/stores/app'
import { adminAPI } from '@/api/admin' import { adminAPI } from '@/api/admin'
@@ -128,8 +128,8 @@ const selectedKeyForGroup = computed(() => {
return apiKeys.value.find((k) => k.id === groupSelectorKeyId.value) || null return apiKeys.value.find((k) => k.id === groupSelectorKeyId.value) || null
}) })
const setGroupButtonRef = (keyId: number, el: HTMLElement | null) => { const setGroupButtonRef = (keyId: number, el: Element | ComponentPublicInstance | null) => {
if (el) { if (el instanceof HTMLElement) {
groupButtonRefs.value.set(keyId, el) groupButtonRefs.value.set(keyId, el)
} else { } else {
groupButtonRefs.value.delete(keyId) groupButtonRefs.value.delete(keyId)
@@ -162,7 +162,8 @@ 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)
} }
@@ -200,15 +201,19 @@ const changeGroup = async (key: ApiKey, newGroupId: number | null) => {
updatingKeyIds.value.add(key.id) updatingKeyIds.value.add(key.id)
try { try {
const updated = await adminAPI.apiKeys.updateApiKeyGroup(key.id, newGroupId) const result = await adminAPI.apiKeys.updateApiKeyGroup(key.id, newGroupId)
// Update local data // Update local data
const idx = apiKeys.value.findIndex((k) => k.id === key.id) const idx = apiKeys.value.findIndex((k) => k.id === key.id)
if (idx !== -1) { if (idx !== -1) {
apiKeys.value[idx] = updated apiKeys.value[idx] = result.api_key
} }
appStore.showSuccess(t('admin.users.groupChangedSuccess')) if (result.auto_granted_group_access && result.granted_group_name) {
} catch (error) { appStore.showSuccess(t('admin.users.groupChangedWithGrant', { group: result.granted_group_name }))
appStore.showError(t('admin.users.groupChangeFailed')) } else {
appStore.showSuccess(t('admin.users.groupChangedSuccess'))
}
} catch (error: any) {
appStore.showError(error?.message || t('admin.users.groupChangeFailed'))
} finally { } finally {
updatingKeyIds.value.delete(key.id) updatingKeyIds.value.delete(key.id)
} }

View File

@@ -1077,6 +1077,7 @@ export default {
group: 'Group', group: 'Group',
none: 'None', none: 'None',
groupChangedSuccess: 'Group updated successfully', groupChangedSuccess: 'Group updated successfully',
groupChangedWithGrant: 'Group updated. User auto-granted access to "{group}"',
groupChangeFailed: 'Failed to update group', groupChangeFailed: 'Failed to update group',
noUsersYet: 'No users yet', noUsersYet: 'No users yet',
createFirstUser: 'Create your first user to get started.', createFirstUser: 'Create your first user to get started.',

View File

@@ -1105,6 +1105,7 @@ export default {
group: '分组', group: '分组',
none: '无', none: '无',
groupChangedSuccess: '分组修改成功', groupChangedSuccess: '分组修改成功',
groupChangedWithGrant: '分组修改成功,已自动为用户添加「{group}」分组权限',
groupChangeFailed: '分组修改失败', groupChangeFailed: '分组修改失败',
noUsersYet: '暂无用户', noUsersYet: '暂无用户',
createFirstUser: '创建您的第一个用户以开始使用系统', createFirstUser: '创建您的第一个用户以开始使用系统',