Skip to content

Commit 000e621

Browse files
author
QTom
committed
feat(admin): 添加管理员直接修改用户 API Key 分组的功能
- 新增 PUT /api/v1/admin/api-keys/:id 端点,允许管理员修改任意用户 API Key 的分组绑定 - 跳过用户级权限校验但保留分组有效性验证,修改后触发认证缓存失效 - Service 层支持三态语义:nil=不修改,0=解绑,>0=绑定,<0=拒绝 - 指针值拷贝保证安全隔离,负数 groupID 返回 400 INVALID_GROUP_ID - 前端 UserApiKeysModal 新增可点击的分组选择下拉框,支持多 Key 并发更新 - 下拉支持视口翻转和滚动关闭,按钮有 disabled 和加载状态 - 覆盖:后端 20 个单元测试 (Service 11 + Handler 9) + 前端 16 个 E2E 测试 - golangci-lint 0 issues, make test-unit 全部通过
1 parent 9d79506 commit 000e621

16 files changed

Lines changed: 877 additions & 14 deletions

File tree

backend/cmd/server/wire_gen.go

Lines changed: 2 additions & 1 deletion
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.APIKey, 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 &k, nil
419+
}
420+
}
421+
return nil, service.ErrAPIKeyNotFound
422+
}
423+
406424
// Ensure stub implements interface.
407425
var _ service.AdminService = (*stubAdminService)(nil)
Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
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+
apiKey, err := h.adminService.AdminUpdateAPIKeyGroupID(c.Request.Context(), keyID, req.GroupID)
46+
if err != nil {
47+
response.ErrorFrom(c, err)
48+
return
49+
}
50+
51+
response.Success(c, dto.APIKeyFromService(apiKey))
52+
}
Lines changed: 195 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,195 @@
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 apiKey struct {
83+
ID int64 `json:"id"`
84+
GroupID *int64 `json:"group_id"`
85+
}
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)
90+
}
91+
92+
func TestAdminAPIKeyHandler_UpdateGroup_Unbind(t *testing.T) {
93+
svc := newStubAdminService()
94+
gid := int64(2)
95+
svc.apiKeys[0].GroupID = &gid
96+
router := setupAPIKeyHandler(svc)
97+
body := `{"group_id": 0}`
98+
99+
rec := httptest.NewRecorder()
100+
req := httptest.NewRequest(http.MethodPut, "/api/v1/admin/api-keys/10", bytes.NewBufferString(body))
101+
req.Header.Set("Content-Type", "application/json")
102+
router.ServeHTTP(rec, req)
103+
104+
require.Equal(t, http.StatusOK, rec.Code)
105+
106+
var resp struct {
107+
Data struct {
108+
GroupID *int64 `json:"group_id"`
109+
} `json:"data"`
110+
}
111+
require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &resp))
112+
require.Nil(t, resp.Data.GroupID)
113+
}
114+
115+
func TestAdminAPIKeyHandler_UpdateGroup_ServiceError(t *testing.T) {
116+
svc := &failingUpdateGroupService{
117+
stubAdminService: newStubAdminService(),
118+
err: errors.New("internal failure"),
119+
}
120+
router := setupAPIKeyHandler(svc)
121+
body := `{"group_id": 2}`
122+
123+
rec := httptest.NewRecorder()
124+
req := httptest.NewRequest(http.MethodPut, "/api/v1/admin/api-keys/10", bytes.NewBufferString(body))
125+
req.Header.Set("Content-Type", "application/json")
126+
router.ServeHTTP(rec, req)
127+
128+
require.Equal(t, http.StatusInternalServerError, rec.Code)
129+
}
130+
131+
// H2: empty body → group_id is nil → no-op, returns original key
132+
func TestAdminAPIKeyHandler_UpdateGroup_EmptyBody_NoChange(t *testing.T) {
133+
router := setupAPIKeyHandler(newStubAdminService())
134+
135+
rec := httptest.NewRecorder()
136+
req := httptest.NewRequest(http.MethodPut, "/api/v1/admin/api-keys/10", bytes.NewBufferString(`{}`))
137+
req.Header.Set("Content-Type", "application/json")
138+
router.ServeHTTP(rec, req)
139+
140+
require.Equal(t, http.StatusOK, rec.Code)
141+
142+
var resp struct {
143+
Code int `json:"code"`
144+
Data struct {
145+
ID int64 `json:"id"`
146+
} `json:"data"`
147+
}
148+
require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &resp))
149+
require.Equal(t, 0, resp.Code)
150+
require.Equal(t, int64(10), resp.Data.ID)
151+
}
152+
153+
// M2: service returns GROUP_NOT_ACTIVE → handler maps to 400
154+
func TestAdminAPIKeyHandler_UpdateGroup_GroupNotActive(t *testing.T) {
155+
svc := &failingUpdateGroupService{
156+
stubAdminService: newStubAdminService(),
157+
err: infraerrors.BadRequest("GROUP_NOT_ACTIVE", "target group is not active"),
158+
}
159+
router := setupAPIKeyHandler(svc)
160+
161+
rec := httptest.NewRecorder()
162+
req := httptest.NewRequest(http.MethodPut, "/api/v1/admin/api-keys/10", bytes.NewBufferString(`{"group_id": 5}`))
163+
req.Header.Set("Content-Type", "application/json")
164+
router.ServeHTTP(rec, req)
165+
166+
require.Equal(t, http.StatusBadRequest, rec.Code)
167+
require.Contains(t, rec.Body.String(), "GROUP_NOT_ACTIVE")
168+
}
169+
170+
// M2: service returns INVALID_GROUP_ID → handler maps to 400
171+
func TestAdminAPIKeyHandler_UpdateGroup_NegativeGroupID(t *testing.T) {
172+
svc := &failingUpdateGroupService{
173+
stubAdminService: newStubAdminService(),
174+
err: infraerrors.BadRequest("INVALID_GROUP_ID", "group_id must be non-negative"),
175+
}
176+
router := setupAPIKeyHandler(svc)
177+
178+
rec := httptest.NewRecorder()
179+
req := httptest.NewRequest(http.MethodPut, "/api/v1/admin/api-keys/10", bytes.NewBufferString(`{"group_id": -5}`))
180+
req.Header.Set("Content-Type", "application/json")
181+
router.ServeHTTP(rec, req)
182+
183+
require.Equal(t, http.StatusBadRequest, rec.Code)
184+
require.Contains(t, rec.Body.String(), "INVALID_GROUP_ID")
185+
}
186+
187+
// failingUpdateGroupService overrides AdminUpdateAPIKeyGroupID to return an error.
188+
type failingUpdateGroupService struct {
189+
*stubAdminService
190+
err error
191+
}
192+
193+
func (f *failingUpdateGroupService) AdminUpdateAPIKeyGroupID(_ context.Context, _ int64, _ *int64) (*service.APIKey, error) {
194+
return nil, f.err
195+
}

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/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/server/routes/admin.go

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -75,6 +75,16 @@ func RegisterAdminRoutes(
7575

7676
// 错误透传规则管理
7777
registerErrorPassthroughRoutes(admin, h)
78+
79+
// API Key 管理
80+
registerAdminAPIKeyRoutes(admin, h)
81+
}
82+
}
83+
84+
func registerAdminAPIKeyRoutes(admin *gin.RouterGroup, h *handler.Handlers) {
85+
apiKeys := admin.Group("/api-keys")
86+
{
87+
apiKeys.PUT("/:id", h.Admin.APIKey.UpdateGroup)
7888
}
7989
}
8090

0 commit comments

Comments
 (0)