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:
@@ -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)
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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).
|
||||||
|
|||||||
@@ -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().
|
||||||
|
|||||||
@@ -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")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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 }
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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.',
|
||||||
|
|||||||
@@ -1105,6 +1105,7 @@ export default {
|
|||||||
group: '分组',
|
group: '分组',
|
||||||
none: '无',
|
none: '无',
|
||||||
groupChangedSuccess: '分组修改成功',
|
groupChangedSuccess: '分组修改成功',
|
||||||
|
groupChangedWithGrant: '分组修改成功,已自动为用户添加「{group}」分组权限',
|
||||||
groupChangeFailed: '分组修改失败',
|
groupChangeFailed: '分组修改失败',
|
||||||
noUsersYet: '暂无用户',
|
noUsersYet: '暂无用户',
|
||||||
createFirstUser: '创建您的第一个用户以开始使用系统',
|
createFirstUser: '创建您的第一个用户以开始使用系统',
|
||||||
|
|||||||
Reference in New Issue
Block a user