Skip to content

Commit 9a91815

Browse files
author
QTom
committed
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 规范 ## 国际化支持 - 中英文提示信息完整 - 自动授权成功/失败提示
1 parent 000e621 commit 9a91815

18 files changed

Lines changed: 302 additions & 55 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/handler/admin/admin_service_stub_test.go

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -403,7 +403,7 @@ func (s *stubAdminService) UpdateGroupSortOrders(ctx context.Context, updates []
403403
return nil
404404
}
405405

406-
func (s *stubAdminService) AdminUpdateAPIKeyGroupID(ctx context.Context, keyID int64, groupID *int64) (*service.APIKey, error) {
406+
func (s *stubAdminService) AdminUpdateAPIKeyGroupID(ctx context.Context, keyID int64, groupID *int64) (*service.AdminUpdateAPIKeyGroupIDResult, error) {
407407
for i := range s.apiKeys {
408408
if s.apiKeys[i].ID == keyID {
409409
k := s.apiKeys[i]
@@ -415,7 +415,7 @@ func (s *stubAdminService) AdminUpdateAPIKeyGroupID(ctx context.Context, keyID i
415415
k.GroupID = &gid
416416
}
417417
}
418-
return &k, nil
418+
return &service.AdminUpdateAPIKeyGroupIDResult{APIKey: &k}, nil
419419
}
420420
}
421421
return nil, service.ErrAPIKeyNotFound

backend/internal/handler/admin/apikey_handler.go

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -42,11 +42,22 @@ func (h *AdminAPIKeyHandler) UpdateGroup(c *gin.Context) {
4242
return
4343
}
4444

45-
apiKey, err := h.adminService.AdminUpdateAPIKeyGroupID(c.Request.Context(), keyID, req.GroupID)
45+
result, err := h.adminService.AdminUpdateAPIKeyGroupID(c.Request.Context(), keyID, req.GroupID)
4646
if err != nil {
4747
response.ErrorFrom(c, err)
4848
return
4949
}
5050

51-
response.Success(c, dto.APIKeyFromService(apiKey))
51+
resp := struct {
52+
APIKey *dto.APIKey `json:"api_key"`
53+
AutoGrantedGroupAccess bool `json:"auto_granted_group_access"`
54+
GrantedGroupID *int64 `json:"granted_group_id,omitempty"`
55+
GrantedGroupName string `json:"granted_group_name,omitempty"`
56+
}{
57+
APIKey: dto.APIKeyFromService(result.APIKey),
58+
AutoGrantedGroupAccess: result.AutoGrantedGroupAccess,
59+
GrantedGroupID: result.GrantedGroupID,
60+
GrantedGroupName: result.GrantedGroupName,
61+
}
62+
response.Success(c, resp)
5263
}

backend/internal/handler/admin/apikey_handler_test.go

Lines changed: 19 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -79,14 +79,17 @@ func TestAdminAPIKeyHandler_UpdateGroup_BindGroup(t *testing.T) {
7979
require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &resp))
8080
require.Equal(t, 0, resp.Code)
8181

82-
var apiKey struct {
83-
ID int64 `json:"id"`
84-
GroupID *int64 `json:"group_id"`
82+
var data struct {
83+
APIKey struct {
84+
ID int64 `json:"id"`
85+
GroupID *int64 `json:"group_id"`
86+
} `json:"api_key"`
87+
AutoGrantedGroupAccess bool `json:"auto_granted_group_access"`
8588
}
86-
require.NoError(t, json.Unmarshal(resp.Data, &apiKey))
87-
require.Equal(t, int64(10), apiKey.ID)
88-
require.NotNil(t, apiKey.GroupID)
89-
require.Equal(t, int64(2), *apiKey.GroupID)
89+
require.NoError(t, json.Unmarshal(resp.Data, &data))
90+
require.Equal(t, int64(10), data.APIKey.ID)
91+
require.NotNil(t, data.APIKey.GroupID)
92+
require.Equal(t, int64(2), *data.APIKey.GroupID)
9093
}
9194

9295
func TestAdminAPIKeyHandler_UpdateGroup_Unbind(t *testing.T) {
@@ -105,11 +108,13 @@ func TestAdminAPIKeyHandler_UpdateGroup_Unbind(t *testing.T) {
105108

106109
var resp struct {
107110
Data struct {
108-
GroupID *int64 `json:"group_id"`
111+
APIKey struct {
112+
GroupID *int64 `json:"group_id"`
113+
} `json:"api_key"`
109114
} `json:"data"`
110115
}
111116
require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &resp))
112-
require.Nil(t, resp.Data.GroupID)
117+
require.Nil(t, resp.Data.APIKey.GroupID)
113118
}
114119

115120
func TestAdminAPIKeyHandler_UpdateGroup_ServiceError(t *testing.T) {
@@ -142,12 +147,14 @@ func TestAdminAPIKeyHandler_UpdateGroup_EmptyBody_NoChange(t *testing.T) {
142147
var resp struct {
143148
Code int `json:"code"`
144149
Data struct {
145-
ID int64 `json:"id"`
150+
APIKey struct {
151+
ID int64 `json:"id"`
152+
} `json:"api_key"`
146153
} `json:"data"`
147154
}
148155
require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &resp))
149156
require.Equal(t, 0, resp.Code)
150-
require.Equal(t, int64(10), resp.Data.ID)
157+
require.Equal(t, int64(10), resp.Data.APIKey.ID)
151158
}
152159

153160
// M2: service returns GROUP_NOT_ACTIVE → handler maps to 400
@@ -190,6 +197,6 @@ type failingUpdateGroupService struct {
190197
err error
191198
}
192199

193-
func (f *failingUpdateGroupService) AdminUpdateAPIKeyGroupID(_ context.Context, _ int64, _ *int64) (*service.APIKey, error) {
200+
func (f *failingUpdateGroupService) AdminUpdateAPIKeyGroupID(_ context.Context, _ int64, _ *int64) (*service.AdminUpdateAPIKeyGroupIDResult, error) {
194201
return nil, f.err
195202
}

backend/internal/repository/api_key_repo.go

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -171,8 +171,9 @@ func (r *apiKeyRepository) Update(ctx context.Context, key *service.APIKey) erro
171171
// 则会更新已删除的记录。
172172
// 这里选择 Update().Where(),确保只有未软删除记录能被更新。
173173
// 同时显式设置 updated_at,避免二次查询带来的并发可见性问题。
174+
client := clientFromContext(ctx, r.client)
174175
now := time.Now()
175-
builder := r.client.APIKey.Update().
176+
builder := client.APIKey.Update().
176177
Where(apikey.IDEQ(key.ID), apikey.DeletedAtIsNil()).
177178
SetName(key.Name).
178179
SetStatus(key.Status).

backend/internal/repository/user_repo.go

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -429,6 +429,16 @@ func (r *userRepository) ExistsByEmail(ctx context.Context, email string) (bool,
429429
return r.client.User.Query().Where(dbuser.EmailEQ(email)).Exist(ctx)
430430
}
431431

432+
func (r *userRepository) AddGroupToAllowedGroups(ctx context.Context, userID int64, groupID int64) error {
433+
client := clientFromContext(ctx, r.client)
434+
return client.UserAllowedGroup.Create().
435+
SetUserID(userID).
436+
SetGroupID(groupID).
437+
OnConflictColumns(userallowedgroup.FieldUserID, userallowedgroup.FieldGroupID).
438+
DoNothing().
439+
Exec(ctx)
440+
}
441+
432442
func (r *userRepository) RemoveGroupFromAllowedGroups(ctx context.Context, groupID int64) (int64, error) {
433443
// 仅操作 user_allowed_groups 联接表,legacy users.allowed_groups 列已弃用。
434444
affected, err := r.client.UserAllowedGroup.Delete().

backend/internal/server/api_contract_test.go

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -619,7 +619,7 @@ func newContractDeps(t *testing.T) *contractDeps {
619619
settingRepo := newStubSettingRepo()
620620
settingService := service.NewSettingService(settingRepo, cfg)
621621

622-
adminService := service.NewAdminService(userRepo, groupRepo, &accountRepo, nil, proxyRepo, apiKeyRepo, redeemRepo, nil, nil, nil, nil, nil)
622+
adminService := service.NewAdminService(userRepo, groupRepo, &accountRepo, nil, proxyRepo, apiKeyRepo, redeemRepo, nil, nil, nil, nil, nil, nil)
623623
authHandler := handler.NewAuthHandler(cfg, nil, userService, settingService, nil, redeemService, nil)
624624
apiKeyHandler := handler.NewAPIKeyHandler(apiKeyService)
625625
usageHandler := handler.NewUsageHandler(usageService, apiKeyService)
@@ -779,6 +779,10 @@ func (r *stubUserRepo) RemoveGroupFromAllowedGroups(ctx context.Context, groupID
779779
return 0, errors.New("not implemented")
780780
}
781781

782+
func (r *stubUserRepo) AddGroupToAllowedGroups(ctx context.Context, userID int64, groupID int64) error {
783+
return errors.New("not implemented")
784+
}
785+
782786
func (r *stubUserRepo) UpdateTotpSecret(ctx context.Context, userID int64, encryptedSecret *string) error {
783787
return errors.New("not implemented")
784788
}

backend/internal/server/middleware/admin_auth_test.go

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -181,6 +181,10 @@ func (s *stubUserRepo) RemoveGroupFromAllowedGroups(ctx context.Context, groupID
181181
panic("unexpected RemoveGroupFromAllowedGroups call")
182182
}
183183

184+
func (s *stubUserRepo) AddGroupToAllowedGroups(ctx context.Context, userID int64, groupID int64) error {
185+
panic("unexpected AddGroupToAllowedGroups call")
186+
}
187+
184188
func (s *stubUserRepo) UpdateTotpSecret(ctx context.Context, userID int64, encryptedSecret *string) error {
185189
panic("unexpected UpdateTotpSecret call")
186190
}

backend/internal/service/admin_service.go

Lines changed: 67 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import (
99
"strings"
1010
"time"
1111

12+
dbent "github.com/Wei-Shaw/sub2api/ent"
1213
infraerrors "github.com/Wei-Shaw/sub2api/internal/pkg/errors"
1314
"github.com/Wei-Shaw/sub2api/internal/pkg/httpclient"
1415
"github.com/Wei-Shaw/sub2api/internal/pkg/logger"
@@ -44,7 +45,7 @@ type AdminService interface {
4445
UpdateGroupSortOrders(ctx context.Context, updates []GroupSortOrderUpdate) error
4546

4647
// API Key management (admin)
47-
AdminUpdateAPIKeyGroupID(ctx context.Context, keyID int64, groupID *int64) (*APIKey, error)
48+
AdminUpdateAPIKeyGroupID(ctx context.Context, keyID int64, groupID *int64) (*AdminUpdateAPIKeyGroupIDResult, error)
4849

4950
// Account management
5051
ListAccounts(ctx context.Context, page, pageSize int, platform, accountType, status, search string, groupID int64) ([]Account, int64, error)
@@ -246,6 +247,14 @@ type BulkUpdateAccountResult struct {
246247
Error string `json:"error,omitempty"`
247248
}
248249

250+
// AdminUpdateAPIKeyGroupIDResult is the result of AdminUpdateAPIKeyGroupID.
251+
type AdminUpdateAPIKeyGroupIDResult struct {
252+
APIKey *APIKey
253+
AutoGrantedGroupAccess bool // true if a new exclusive group permission was auto-added
254+
GrantedGroupID *int64 // the group ID that was auto-granted
255+
GrantedGroupName string // the group name that was auto-granted
256+
}
257+
249258
// BulkUpdateAccountsResult is the aggregated response for bulk updates.
250259
type BulkUpdateAccountsResult struct {
251260
Success int `json:"success"`
@@ -410,6 +419,7 @@ type adminServiceImpl struct {
410419
proxyProber ProxyExitInfoProber
411420
proxyLatencyCache ProxyLatencyCache
412421
authCacheInvalidator APIKeyAuthCacheInvalidator
422+
entClient *dbent.Client // 用于开启数据库事务
413423
}
414424

415425
type userGroupRateBatchReader interface {
@@ -434,6 +444,7 @@ func NewAdminService(
434444
proxyProber ProxyExitInfoProber,
435445
proxyLatencyCache ProxyLatencyCache,
436446
authCacheInvalidator APIKeyAuthCacheInvalidator,
447+
entClient *dbent.Client,
437448
) AdminService {
438449
return &adminServiceImpl{
439450
userRepo: userRepo,
@@ -448,6 +459,7 @@ func NewAdminService(
448459
proxyProber: proxyProber,
449460
proxyLatencyCache: proxyLatencyCache,
450461
authCacheInvalidator: authCacheInvalidator,
462+
entClient: entClient,
451463
}
452464
}
453465

@@ -1191,23 +1203,25 @@ func (s *adminServiceImpl) UpdateGroupSortOrders(ctx context.Context, updates []
11911203

11921204
// AdminUpdateAPIKeyGroupID 管理员修改 API Key 分组绑定
11931205
// groupID: nil=不修改, 指向0=解绑, 指向正整数=绑定到目标分组
1194-
func (s *adminServiceImpl) AdminUpdateAPIKeyGroupID(ctx context.Context, keyID int64, groupID *int64) (*APIKey, error) {
1206+
func (s *adminServiceImpl) AdminUpdateAPIKeyGroupID(ctx context.Context, keyID int64, groupID *int64) (*AdminUpdateAPIKeyGroupIDResult, error) {
11951207
apiKey, err := s.apiKeyRepo.GetByID(ctx, keyID)
11961208
if err != nil {
11971209
return nil, err
11981210
}
11991211

12001212
if groupID == nil {
12011213
// nil 表示不修改,直接返回
1202-
return apiKey, nil
1214+
return &AdminUpdateAPIKeyGroupIDResult{APIKey: apiKey}, nil
12031215
}
12041216

12051217
if *groupID < 0 {
12061218
return nil, infraerrors.BadRequest("INVALID_GROUP_ID", "group_id must be non-negative")
12071219
}
12081220

1221+
result := &AdminUpdateAPIKeyGroupIDResult{}
1222+
12091223
if *groupID == 0 {
1210-
// 0 表示解绑分组
1224+
// 0 表示解绑分组(不修改 user_allowed_groups,避免影响用户其他 Key)
12111225
apiKey.GroupID = nil
12121226
apiKey.Group = nil
12131227
} else {
@@ -1219,11 +1233,58 @@ func (s *adminServiceImpl) AdminUpdateAPIKeyGroupID(ctx context.Context, keyID i
12191233
if group.Status != StatusActive {
12201234
return nil, infraerrors.BadRequest("GROUP_NOT_ACTIVE", "target group is not active")
12211235
}
1236+
// 订阅类型分组:不允许通过此 API 直接绑定,需通过订阅管理流程
1237+
if group.IsSubscriptionType() {
1238+
return nil, infraerrors.BadRequest("SUBSCRIPTION_GROUP_NOT_ALLOWED", "subscription groups must be managed through the subscription workflow")
1239+
}
1240+
12221241
gid := *groupID
12231242
apiKey.GroupID = &gid
12241243
apiKey.Group = group
1244+
1245+
// 专属标准分组:使用事务保证「添加分组权限」与「更新 API Key」的原子性
1246+
if group.IsExclusive {
1247+
opCtx := ctx
1248+
var tx *dbent.Tx
1249+
if s.entClient == nil {
1250+
logger.LegacyPrintf("service.admin", "Warning: entClient is nil, skipping transaction protection for exclusive group binding")
1251+
} else {
1252+
var txErr error
1253+
tx, txErr = s.entClient.Tx(ctx)
1254+
if txErr != nil {
1255+
return nil, fmt.Errorf("begin transaction: %w", txErr)
1256+
}
1257+
defer func() { _ = tx.Rollback() }()
1258+
opCtx = dbent.NewTxContext(ctx, tx)
1259+
}
1260+
1261+
if addErr := s.userRepo.AddGroupToAllowedGroups(opCtx, apiKey.UserID, gid); addErr != nil {
1262+
return nil, fmt.Errorf("add group to user allowed groups: %w", addErr)
1263+
}
1264+
if err := s.apiKeyRepo.Update(opCtx, apiKey); err != nil {
1265+
return nil, fmt.Errorf("update api key: %w", err)
1266+
}
1267+
if tx != nil {
1268+
if err := tx.Commit(); err != nil {
1269+
return nil, fmt.Errorf("commit transaction: %w", err)
1270+
}
1271+
}
1272+
1273+
result.AutoGrantedGroupAccess = true
1274+
result.GrantedGroupID = &gid
1275+
result.GrantedGroupName = group.Name
1276+
1277+
// 失效认证缓存(在事务提交后执行)
1278+
if s.authCacheInvalidator != nil {
1279+
s.authCacheInvalidator.InvalidateAuthCacheByKey(ctx, apiKey.Key)
1280+
}
1281+
1282+
result.APIKey = apiKey
1283+
return result, nil
1284+
}
12251285
}
12261286

1287+
// 非专属分组 / 解绑:无需事务,单步更新即可
12271288
if err := s.apiKeyRepo.Update(ctx, apiKey); err != nil {
12281289
return nil, fmt.Errorf("update api key: %w", err)
12291290
}
@@ -1233,7 +1294,8 @@ func (s *adminServiceImpl) AdminUpdateAPIKeyGroupID(ctx context.Context, keyID i
12331294
s.authCacheInvalidator.InvalidateAuthCacheByKey(ctx, apiKey.Key)
12341295
}
12351296

1236-
return apiKey, nil
1297+
result.APIKey = apiKey
1298+
return result, nil
12371299
}
12381300

12391301
// Account management implementations

0 commit comments

Comments
 (0)