Skip to content

Commit 4587c3e

Browse files
authored
Merge pull request Wei-Shaw#670 from DaydreamCoding/feat/admin-apikey-group-update
feat(admin): 添加管理员直接修改用户 API Key 分组的功能
2 parents be18bc6 + 6f9e690 commit 4587c3e

26 files changed

Lines changed: 1137 additions & 21 deletions

backend/cmd/server/wire_gen.go

Lines changed: 3 additions & 2 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

backend/go.mod

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -109,6 +109,7 @@ require (
109109
github.com/goccy/go-json v0.10.2 // indirect
110110
github.com/google/go-cmp v0.7.0 // indirect
111111
github.com/google/go-querystring v1.1.0 // indirect
112+
github.com/google/subcommands v1.2.0 // indirect
112113
github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.3 // indirect
113114
github.com/hashicorp/hcl v1.0.0 // indirect
114115
github.com/hashicorp/hcl/v2 v2.18.1 // indirect
@@ -177,6 +178,7 @@ require (
177178
golang.org/x/mod v0.32.0 // indirect
178179
golang.org/x/sys v0.41.0 // indirect
179180
golang.org/x/text v0.34.0 // indirect
181+
golang.org/x/tools v0.41.0 // indirect
180182
google.golang.org/genproto/googleapis/rpc v0.0.0-20250929231259-57b25ae835d4 // indirect
181183
gopkg.in/ini.v1 v1.67.0 // indirect
182184
modernc.org/libc v1.67.6 // indirect

backend/go.sum

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -182,6 +182,8 @@ github.com/google/go-querystring v1.1.0/go.mod h1:Kcdr2DB4koayq7X8pmAG4sNG59So17
182182
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
183183
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17kjQEVQ1XRhq2/JR1M3sGqeJoxs=
184184
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA=
185+
github.com/google/subcommands v1.2.0 h1:vWQspBTo2nEqTUFita5/KeEWlUL8kQObDFbub/EN9oE=
186+
github.com/google/subcommands v1.2.0/go.mod h1:ZjhPrFU+Olkh9WazFPsl27BQ4UPiG37m3yTrtFlrHVk=
185187
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
186188
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
187189
github.com/google/wire v0.7.0 h1:JxUKI6+CVBgCO2WToKy/nQk0sS+amI9z9EjVmdaocj4=

backend/internal/handler/admin/admin_service_stub_test.go

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -403,5 +403,23 @@ 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.AdminUpdateAPIKeyGroupIDResult, error) {
407+
for i := range s.apiKeys {
408+
if s.apiKeys[i].ID == keyID {
409+
k := s.apiKeys[i]
410+
if groupID != nil {
411+
if *groupID == 0 {
412+
k.GroupID = nil
413+
} else {
414+
gid := *groupID
415+
k.GroupID = &gid
416+
}
417+
}
418+
return &service.AdminUpdateAPIKeyGroupIDResult{APIKey: &k}, nil
419+
}
420+
}
421+
return nil, service.ErrAPIKeyNotFound
422+
}
423+
406424
// Ensure stub implements interface.
407425
var _ service.AdminService = (*stubAdminService)(nil)
Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
package admin
2+
3+
import (
4+
"strconv"
5+
6+
"github.com/Wei-Shaw/sub2api/internal/handler/dto"
7+
"github.com/Wei-Shaw/sub2api/internal/pkg/response"
8+
"github.com/Wei-Shaw/sub2api/internal/service"
9+
10+
"github.com/gin-gonic/gin"
11+
)
12+
13+
// AdminAPIKeyHandler handles admin API key management
14+
type AdminAPIKeyHandler struct {
15+
adminService service.AdminService
16+
}
17+
18+
// NewAdminAPIKeyHandler creates a new admin API key handler
19+
func NewAdminAPIKeyHandler(adminService service.AdminService) *AdminAPIKeyHandler {
20+
return &AdminAPIKeyHandler{
21+
adminService: adminService,
22+
}
23+
}
24+
25+
// AdminUpdateAPIKeyGroupRequest represents the request to update an API key's group
26+
type AdminUpdateAPIKeyGroupRequest struct {
27+
GroupID *int64 `json:"group_id"` // nil=不修改, 0=解绑, >0=绑定到目标分组
28+
}
29+
30+
// UpdateGroup handles updating an API key's group binding
31+
// PUT /api/v1/admin/api-keys/:id
32+
func (h *AdminAPIKeyHandler) UpdateGroup(c *gin.Context) {
33+
keyID, err := strconv.ParseInt(c.Param("id"), 10, 64)
34+
if err != nil {
35+
response.BadRequest(c, "Invalid API key ID")
36+
return
37+
}
38+
39+
var req AdminUpdateAPIKeyGroupRequest
40+
if err := c.ShouldBindJSON(&req); err != nil {
41+
response.BadRequest(c, "Invalid request: "+err.Error())
42+
return
43+
}
44+
45+
result, err := h.adminService.AdminUpdateAPIKeyGroupID(c.Request.Context(), keyID, req.GroupID)
46+
if err != nil {
47+
response.ErrorFrom(c, err)
48+
return
49+
}
50+
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)
63+
}
Lines changed: 202 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,202 @@
1+
package admin
2+
3+
import (
4+
"bytes"
5+
"context"
6+
"encoding/json"
7+
"errors"
8+
"net/http"
9+
"net/http/httptest"
10+
"testing"
11+
12+
infraerrors "github.com/Wei-Shaw/sub2api/internal/pkg/errors"
13+
"github.com/Wei-Shaw/sub2api/internal/service"
14+
"github.com/gin-gonic/gin"
15+
"github.com/stretchr/testify/require"
16+
)
17+
18+
func setupAPIKeyHandler(adminSvc service.AdminService) *gin.Engine {
19+
gin.SetMode(gin.TestMode)
20+
router := gin.New()
21+
h := NewAdminAPIKeyHandler(adminSvc)
22+
router.PUT("/api/v1/admin/api-keys/:id", h.UpdateGroup)
23+
return router
24+
}
25+
26+
func TestAdminAPIKeyHandler_UpdateGroup_InvalidID(t *testing.T) {
27+
router := setupAPIKeyHandler(newStubAdminService())
28+
body := `{"group_id": 2}`
29+
30+
rec := httptest.NewRecorder()
31+
req := httptest.NewRequest(http.MethodPut, "/api/v1/admin/api-keys/abc", bytes.NewBufferString(body))
32+
req.Header.Set("Content-Type", "application/json")
33+
router.ServeHTTP(rec, req)
34+
35+
require.Equal(t, http.StatusBadRequest, rec.Code)
36+
require.Contains(t, rec.Body.String(), "Invalid API key ID")
37+
}
38+
39+
func TestAdminAPIKeyHandler_UpdateGroup_InvalidJSON(t *testing.T) {
40+
router := setupAPIKeyHandler(newStubAdminService())
41+
42+
rec := httptest.NewRecorder()
43+
req := httptest.NewRequest(http.MethodPut, "/api/v1/admin/api-keys/10", bytes.NewBufferString(`{bad json`))
44+
req.Header.Set("Content-Type", "application/json")
45+
router.ServeHTTP(rec, req)
46+
47+
require.Equal(t, http.StatusBadRequest, rec.Code)
48+
require.Contains(t, rec.Body.String(), "Invalid request")
49+
}
50+
51+
func TestAdminAPIKeyHandler_UpdateGroup_KeyNotFound(t *testing.T) {
52+
router := setupAPIKeyHandler(newStubAdminService())
53+
body := `{"group_id": 2}`
54+
55+
rec := httptest.NewRecorder()
56+
req := httptest.NewRequest(http.MethodPut, "/api/v1/admin/api-keys/999", bytes.NewBufferString(body))
57+
req.Header.Set("Content-Type", "application/json")
58+
router.ServeHTTP(rec, req)
59+
60+
// ErrAPIKeyNotFound maps to 404
61+
require.Equal(t, http.StatusNotFound, rec.Code)
62+
}
63+
64+
func TestAdminAPIKeyHandler_UpdateGroup_BindGroup(t *testing.T) {
65+
router := setupAPIKeyHandler(newStubAdminService())
66+
body := `{"group_id": 2}`
67+
68+
rec := httptest.NewRecorder()
69+
req := httptest.NewRequest(http.MethodPut, "/api/v1/admin/api-keys/10", bytes.NewBufferString(body))
70+
req.Header.Set("Content-Type", "application/json")
71+
router.ServeHTTP(rec, req)
72+
73+
require.Equal(t, http.StatusOK, rec.Code)
74+
75+
var resp struct {
76+
Code int `json:"code"`
77+
Data json.RawMessage `json:"data"`
78+
}
79+
require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &resp))
80+
require.Equal(t, 0, resp.Code)
81+
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"`
88+
}
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)
93+
}
94+
95+
func TestAdminAPIKeyHandler_UpdateGroup_Unbind(t *testing.T) {
96+
svc := newStubAdminService()
97+
gid := int64(2)
98+
svc.apiKeys[0].GroupID = &gid
99+
router := setupAPIKeyHandler(svc)
100+
body := `{"group_id": 0}`
101+
102+
rec := httptest.NewRecorder()
103+
req := httptest.NewRequest(http.MethodPut, "/api/v1/admin/api-keys/10", bytes.NewBufferString(body))
104+
req.Header.Set("Content-Type", "application/json")
105+
router.ServeHTTP(rec, req)
106+
107+
require.Equal(t, http.StatusOK, rec.Code)
108+
109+
var resp struct {
110+
Data struct {
111+
APIKey struct {
112+
GroupID *int64 `json:"group_id"`
113+
} `json:"api_key"`
114+
} `json:"data"`
115+
}
116+
require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &resp))
117+
require.Nil(t, resp.Data.APIKey.GroupID)
118+
}
119+
120+
func TestAdminAPIKeyHandler_UpdateGroup_ServiceError(t *testing.T) {
121+
svc := &failingUpdateGroupService{
122+
stubAdminService: newStubAdminService(),
123+
err: errors.New("internal failure"),
124+
}
125+
router := setupAPIKeyHandler(svc)
126+
body := `{"group_id": 2}`
127+
128+
rec := httptest.NewRecorder()
129+
req := httptest.NewRequest(http.MethodPut, "/api/v1/admin/api-keys/10", bytes.NewBufferString(body))
130+
req.Header.Set("Content-Type", "application/json")
131+
router.ServeHTTP(rec, req)
132+
133+
require.Equal(t, http.StatusInternalServerError, rec.Code)
134+
}
135+
136+
// H2: empty body → group_id is nil → no-op, returns original key
137+
func TestAdminAPIKeyHandler_UpdateGroup_EmptyBody_NoChange(t *testing.T) {
138+
router := setupAPIKeyHandler(newStubAdminService())
139+
140+
rec := httptest.NewRecorder()
141+
req := httptest.NewRequest(http.MethodPut, "/api/v1/admin/api-keys/10", bytes.NewBufferString(`{}`))
142+
req.Header.Set("Content-Type", "application/json")
143+
router.ServeHTTP(rec, req)
144+
145+
require.Equal(t, http.StatusOK, rec.Code)
146+
147+
var resp struct {
148+
Code int `json:"code"`
149+
Data struct {
150+
APIKey struct {
151+
ID int64 `json:"id"`
152+
} `json:"api_key"`
153+
} `json:"data"`
154+
}
155+
require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &resp))
156+
require.Equal(t, 0, resp.Code)
157+
require.Equal(t, int64(10), resp.Data.APIKey.ID)
158+
}
159+
160+
// M2: service returns GROUP_NOT_ACTIVE → handler maps to 400
161+
func TestAdminAPIKeyHandler_UpdateGroup_GroupNotActive(t *testing.T) {
162+
svc := &failingUpdateGroupService{
163+
stubAdminService: newStubAdminService(),
164+
err: infraerrors.BadRequest("GROUP_NOT_ACTIVE", "target group is not active"),
165+
}
166+
router := setupAPIKeyHandler(svc)
167+
168+
rec := httptest.NewRecorder()
169+
req := httptest.NewRequest(http.MethodPut, "/api/v1/admin/api-keys/10", bytes.NewBufferString(`{"group_id": 5}`))
170+
req.Header.Set("Content-Type", "application/json")
171+
router.ServeHTTP(rec, req)
172+
173+
require.Equal(t, http.StatusBadRequest, rec.Code)
174+
require.Contains(t, rec.Body.String(), "GROUP_NOT_ACTIVE")
175+
}
176+
177+
// M2: service returns INVALID_GROUP_ID → handler maps to 400
178+
func TestAdminAPIKeyHandler_UpdateGroup_NegativeGroupID(t *testing.T) {
179+
svc := &failingUpdateGroupService{
180+
stubAdminService: newStubAdminService(),
181+
err: infraerrors.BadRequest("INVALID_GROUP_ID", "group_id must be non-negative"),
182+
}
183+
router := setupAPIKeyHandler(svc)
184+
185+
rec := httptest.NewRecorder()
186+
req := httptest.NewRequest(http.MethodPut, "/api/v1/admin/api-keys/10", bytes.NewBufferString(`{"group_id": -5}`))
187+
req.Header.Set("Content-Type", "application/json")
188+
router.ServeHTTP(rec, req)
189+
190+
require.Equal(t, http.StatusBadRequest, rec.Code)
191+
require.Contains(t, rec.Body.String(), "INVALID_GROUP_ID")
192+
}
193+
194+
// failingUpdateGroupService overrides AdminUpdateAPIKeyGroupID to return an error.
195+
type failingUpdateGroupService struct {
196+
*stubAdminService
197+
err error
198+
}
199+
200+
func (f *failingUpdateGroupService) AdminUpdateAPIKeyGroupID(_ context.Context, _ int64, _ *int64) (*service.AdminUpdateAPIKeyGroupIDResult, error) {
201+
return nil, f.err
202+
}

backend/internal/handler/handler.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@ type AdminHandlers struct {
2626
Usage *admin.UsageHandler
2727
UserAttribute *admin.UserAttributeHandler
2828
ErrorPassthrough *admin.ErrorPassthroughHandler
29+
APIKey *admin.AdminAPIKeyHandler
2930
}
3031

3132
// Handlers contains all HTTP handlers

backend/internal/handler/sora_client_handler_test.go

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -945,6 +945,9 @@ func (r *stubUserRepoForHandler) RemoveGroupFromAllowedGroups(context.Context, i
945945
func (r *stubUserRepoForHandler) UpdateTotpSecret(context.Context, int64, *string) error { return nil }
946946
func (r *stubUserRepoForHandler) EnableTotp(context.Context, int64) error { return nil }
947947
func (r *stubUserRepoForHandler) DisableTotp(context.Context, int64) error { return nil }
948+
func (r *stubUserRepoForHandler) AddGroupToAllowedGroups(context.Context, int64, int64) error {
949+
return nil
950+
}
948951

949952
// ==================== NewSoraClientHandler ====================
950953

backend/internal/handler/wire.go

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@ func ProvideAdminHandlers(
2929
usageHandler *admin.UsageHandler,
3030
userAttributeHandler *admin.UserAttributeHandler,
3131
errorPassthroughHandler *admin.ErrorPassthroughHandler,
32+
apiKeyHandler *admin.AdminAPIKeyHandler,
3233
) *AdminHandlers {
3334
return &AdminHandlers{
3435
Dashboard: dashboardHandler,
@@ -51,6 +52,7 @@ func ProvideAdminHandlers(
5152
Usage: usageHandler,
5253
UserAttribute: userAttributeHandler,
5354
ErrorPassthrough: errorPassthroughHandler,
55+
APIKey: apiKeyHandler,
5456
}
5557
}
5658

@@ -138,6 +140,7 @@ var ProviderSet = wire.NewSet(
138140
admin.NewUsageHandler,
139141
admin.NewUserAttributeHandler,
140142
admin.NewErrorPassthroughHandler,
143+
admin.NewAdminAPIKeyHandler,
141144

142145
// AdminHandlers and Handlers constructors
143146
ProvideAdminHandlers,

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).

0 commit comments

Comments
 (0)