From 12a687177d6d264a5a7adb53032f52b9f63b7100 Mon Sep 17 00:00:00 2001 From: findlp163 Date: Wed, 12 Aug 2026 14:43:41 +0800 Subject: [PATCH] feat: enable root admin to manage all user API keys --- controller/audit.go | 5 + controller/token.go | 157 +++++++- controller/token_test.go | 379 ++++++++++++++++++ model/token.go | 122 ++++++ web/src/features/keys/api.ts | 17 +- .../keys/components/api-keys-columns.tsx | 25 +- .../components/api-keys-mutate-drawer.tsx | 53 ++- .../keys/components/api-keys-provider.tsx | 15 +- .../keys/components/api-keys-table.tsx | 74 +++- web/src/features/keys/types.ts | 4 + web/src/i18n/locales/en.json | 1 + web/src/i18n/locales/zh.json | 1 + 12 files changed, 816 insertions(+), 37 deletions(-) diff --git a/controller/audit.go b/controller/audit.go index d6974b900806..86133ae411c3 100644 --- a/controller/audit.go +++ b/controller/audit.go @@ -49,6 +49,11 @@ var auditContentTemplates = map[string]string{ "subscription.plan_reset": "Reset active subscriptions for plan ${plan_id}", "subscription.user_plan_reset": "Reset active plan ${plan_id} subscriptions for user ${target_user_id}", + + "token.admin_create": "Created token for user ${target_username} (ID: ${target_user_id})", + "token.admin_update": "Updated token ${token_name} (ID: ${token_id}) of user ${target_username}", + "token.admin_delete": "Deleted token ${token_name} (ID: ${token_id}) of user ${target_username}", + "token.admin_batch_delete": "Batch deleted ${count} tokens of user ${target_username}", } // auditContentEN 按 action 模板渲染英文兜底文本;未登记的 action 退回 action 本身。 diff --git a/controller/token.go b/controller/token.go index c26d82e3dee1..0263424ddb69 100644 --- a/controller/token.go +++ b/controller/token.go @@ -119,14 +119,27 @@ func setTokenAutoGroups(c *gin.Context, token *model.Token, groups []string) boo func GetAllTokens(c *gin.Context) { userId := c.GetInt("id") pageInfo := common.GetPageQuery(c) - tokens, err := model.GetAllUserTokens(userId, pageInfo.GetStartIdx(), pageInfo.GetPageSize()) - if err != nil { - common.ApiError(c, err) - return + // Root 可不传 user_id 看全部,也可传 ?user_id=X 筛选 + if c.GetInt("role") == common.RoleRootUser { + filterUserId, _ := strconv.Atoi(c.Query("user_id")) + tokens, err := model.GetAllTokensAdmin(filterUserId, pageInfo.GetStartIdx(), pageInfo.GetPageSize()) + if err != nil { + common.ApiError(c, err) + return + } + total, _ := model.CountTokensAdmin(filterUserId) + pageInfo.SetTotal(int(total)) + pageInfo.SetItems(buildMaskedTokenResponses(tokens)) + } else { + tokens, err := model.GetAllUserTokens(userId, pageInfo.GetStartIdx(), pageInfo.GetPageSize()) + if err != nil { + common.ApiError(c, err) + return + } + total, _ := model.CountUserTokens(userId) + pageInfo.SetTotal(int(total)) + pageInfo.SetItems(buildMaskedTokenResponses(tokens)) } - total, _ := model.CountUserTokens(userId) - pageInfo.SetTotal(int(total)) - pageInfo.SetItems(buildMaskedTokenResponses(tokens)) common.ApiSuccess(c, pageInfo) } @@ -137,13 +150,25 @@ func SearchTokens(c *gin.Context) { pageInfo := common.GetPageQuery(c) - tokens, total, err := model.SearchUserTokens(userId, keyword, token, pageInfo.GetStartIdx(), pageInfo.GetPageSize()) - if err != nil { - common.ApiError(c, err) - return + // Root 可不传 user_id 搜全部,也可传 ?user_id=X 筛选 + if c.GetInt("role") == common.RoleRootUser { + filterUserId, _ := strconv.Atoi(c.Query("user_id")) + tokens, total, err := model.SearchTokensAdmin(filterUserId, keyword, token, pageInfo.GetStartIdx(), pageInfo.GetPageSize()) + if err != nil { + common.ApiError(c, err) + return + } + pageInfo.SetTotal(int(total)) + pageInfo.SetItems(buildMaskedTokenResponses(tokens)) + } else { + tokens, total, err := model.SearchUserTokens(userId, keyword, token, pageInfo.GetStartIdx(), pageInfo.GetPageSize()) + if err != nil { + common.ApiError(c, err) + return + } + pageInfo.SetTotal(int(total)) + pageInfo.SetItems(buildMaskedTokenResponses(tokens)) } - pageInfo.SetTotal(int(total)) - pageInfo.SetItems(buildMaskedTokenResponses(tokens)) common.ApiSuccess(c, pageInfo) } @@ -154,7 +179,18 @@ func GetToken(c *gin.Context) { common.ApiError(c, err) return } - token, err := model.GetTokenByIds(id, userId) + // Root 可查看任意用户的令牌,非 Root 仅限自己的 + var token *model.Token + if c.GetInt("role") == common.RoleRootUser { + token, err = model.GetTokenById(id) + if err == nil { + if user, uErr := model.GetUserCache(token.UserId); uErr == nil { + token.Username = user.Username + } + } + } else { + token, err = model.GetTokenByIds(id, userId) + } if err != nil { common.ApiError(c, err) return @@ -181,7 +217,13 @@ func GetTokenKey(c *gin.Context) { common.ApiError(c, err) return } - token, err := model.GetTokenByIds(id, userId) + // Root 可查看任意令牌的 Key,非 Root 仅限自己的 + var token *model.Token + if c.GetInt("role") == common.RoleRootUser { + token, err = model.GetTokenById(id) + } else { + token, err = model.GetTokenByIds(id, userId) + } if err != nil { common.ApiError(c, err) return @@ -269,6 +311,18 @@ func AddToken(c *gin.Context) { return } token := request.Token + + // Root 可通过 user_id 为其他用户创建令牌 + isAdminCreate := c.GetInt("role") == common.RoleRootUser && token.UserId > 0 && token.UserId != c.GetInt("id") + if isAdminCreate { + if _, err := model.GetUserCache(token.UserId); err != nil { + common.ApiErrorI18n(c, i18n.MsgInvalidParams) + return + } + } else { + token.UserId = c.GetInt("id") + } + if len(token.Name) > 50 { common.ApiErrorI18n(c, i18n.MsgTokenNameTooLong) return @@ -287,7 +341,7 @@ func AddToken(c *gin.Context) { } // 检查用户令牌数量是否已达上限 maxTokens := operation_setting.GetMaxUserTokens() - count, err := model.CountUserTokens(c.GetInt("id")) + count, err := model.CountUserTokens(token.UserId) if err != nil { common.ApiError(c, err) return @@ -314,7 +368,7 @@ func AddToken(c *gin.Context) { return } cleanToken := model.Token{ - UserId: c.GetInt("id"), + UserId: token.UserId, Name: token.Name, Key: key, CreatedTime: common.GetTimestamp(), @@ -334,6 +388,13 @@ func AddToken(c *gin.Context) { common.ApiError(c, err) return } + if isAdminCreate { + recordManageAuditFor(c, token.UserId, "token.admin_create", map[string]interface{}{ + "target_user_id": token.UserId, + "target_username": cleanToken.Username, + "token_name": cleanToken.Name, + }) + } c.JSON(http.StatusOK, gin.H{ "success": true, "message": "", @@ -343,7 +404,29 @@ func AddToken(c *gin.Context) { func DeleteToken(c *gin.Context) { id, _ := strconv.Atoi(c.Param("id")) userId := c.GetInt("id") - err := model.DeleteTokenById(id, userId) + // Root 可删除任意用户的令牌,非 Root 仅限自己的 + var err error + if c.GetInt("role") == common.RoleRootUser { + token, tErr := model.GetTokenById(id) + if tErr != nil { + common.ApiError(c, tErr) + return + } + if token.UserId != userId { + targetUser, _ := model.GetUserCache(token.UserId) + if targetUser != nil { + recordManageAuditFor(c, token.UserId, "token.admin_delete", map[string]interface{}{ + "target_user_id": token.UserId, + "target_username": targetUser.Username, + "token_id": token.Id, + "token_name": token.Name, + }) + } + } + err = token.Delete() + } else { + err = model.DeleteTokenById(id, userId) + } if err != nil { common.ApiError(c, err) return @@ -379,7 +462,13 @@ func UpdateToken(c *gin.Context) { return } } - cleanToken, err := model.GetTokenByIds(token.Id, userId) + // Root 可更新任意用户的令牌,非 Root 仅限自己的 + var cleanToken *model.Token + if c.GetInt("role") == common.RoleRootUser { + cleanToken, err = model.GetTokenById(token.Id) + } else { + cleanToken, err = model.GetTokenByIds(token.Id, userId) + } if err != nil { common.ApiError(c, err) return @@ -421,6 +510,17 @@ func UpdateToken(c *gin.Context) { common.ApiError(c, err) return } + if cleanToken.UserId != userId { + targetUser, _ := model.GetUserCache(cleanToken.UserId) + if targetUser != nil { + recordManageAuditFor(c, cleanToken.UserId, "token.admin_update", map[string]interface{}{ + "target_user_id": cleanToken.UserId, + "target_username": targetUser.Username, + "token_id": cleanToken.Id, + "token_name": cleanToken.Name, + }) + } + } c.JSON(http.StatusOK, gin.H{ "success": true, "message": "", @@ -440,6 +540,21 @@ func DeleteTokenBatch(c *gin.Context) { } userId := c.GetInt("id") count, err := model.BatchDeleteTokens(tokenBatch.Ids, userId) + // Root 可批量删除任意用户的令牌 + if err != nil && c.GetInt("role") == common.RoleRootUser { + for _, id := range tokenBatch.Ids { + _, tErr := model.GetTokenById(id) + if tErr != nil { + common.ApiError(c, tErr) + return + } + } + count, err = model.BatchDeleteTokensAdmin(tokenBatch.Ids) + if err != nil { + common.ApiError(c, err) + return + } + } if err != nil { common.ApiError(c, err) return @@ -463,6 +578,10 @@ func GetTokenKeysBatch(c *gin.Context) { } userId := c.GetInt("id") tokens, err := model.GetTokenKeysByIds(tokenBatch.Ids, userId) + // Root 可批量获取任意令牌的 Key + if err != nil && c.GetInt("role") == common.RoleRootUser { + tokens, err = model.GetTokenKeysByIdsAdmin(tokenBatch.Ids) + } if err != nil { common.ApiError(c, err) return diff --git a/controller/token_test.go b/controller/token_test.go index 9cca168ab100..30b9cf06a693 100644 --- a/controller/token_test.go +++ b/controller/token_test.go @@ -578,3 +578,382 @@ func TestGetTokenKeyRequiresOwnershipAndReturnsFullKey(t *testing.T) { t.Fatalf("unauthorized key response leaked raw token key: %s", unauthorizedRecorder.Body.String()) } } + +// ==================== 管理员令牌管理测试 ==================== + +// setupAdminTokenTestDB 初始化测试数据库,迁移 Token 和 User 表。 +func setupAdminTokenTestDB(t *testing.T) *gorm.DB { + t.Helper() + + gin.SetMode(gin.TestMode) + common.SetDatabaseTypes(common.DatabaseTypeSQLite, common.DatabaseTypeSQLite) + common.RedisEnabled = false + + dsn := fmt.Sprintf("file:%s?mode=memory&cache=shared", strings.ReplaceAll(t.Name(), "/", "_")) + db, err := gorm.Open(sqlite.Open(dsn), &gorm.Config{}) + if err != nil { + t.Fatalf("failed to open sqlite db: %v", err) + } + model.DB = db + model.LOG_DB = db + + if err := db.AutoMigrate(&model.Token{}, &model.User{}); err != nil { + t.Fatalf("failed to migrate tables: %v", err) + } + + t.Cleanup(func() { + sqlDB, err := db.DB() + if err == nil { + _ = sqlDB.Close() + } + }) + + return db +} + +// newAdminContext 构造一个带有 Root 角色和用户身份的测试上下文。 +func newAdminContext(t *testing.T, method string, target string, body any, userID int, role int) (*gin.Context, *httptest.ResponseRecorder) { + t.Helper() + + var requestBody *bytes.Reader + if body != nil { + payload, err := common.Marshal(body) + if err != nil { + t.Fatalf("failed to marshal request body: %v", err) + } + requestBody = bytes.NewReader(payload) + } else { + requestBody = bytes.NewReader(nil) + } + + recorder := httptest.NewRecorder() + ctx, _ := gin.CreateTestContext(recorder) + ctx.Request = httptest.NewRequest(method, target, requestBody) + if body != nil { + ctx.Request.Header.Set("Content-Type", "application/json") + } + ctx.Set("id", userID) + ctx.Set("role", role) + ctx.Set("username", "test-user-"+strconv.Itoa(userID)) + return ctx, recorder +} + +// seedTestUser 通过 GORM 创建一个测试用户。 +func seedTestUser(t *testing.T, db *gorm.DB, userID int, role int, username string) *model.User { + t.Helper() + + user := &model.User{ + Id: userID, + Username: username, + Password: "test-password-123", + Role: role, + Status: common.UserStatusEnabled, + Group: "default", + Email: username + "@test.local", + AffCode: fmt.Sprintf("aff-%d-%s", userID, username), + } + if err := db.Create(user).Error; err != nil { + t.Fatalf("failed to create test user: %v", err) + } + return user +} + +func TestAdminGetAllTokensReturnsAllUserTokens(t *testing.T) { + db := setupAdminTokenTestDB(t) + seedTestUser(t, db, 1, common.RoleRootUser, "root") + seedTestUser(t, db, 2, common.RoleCommonUser, "common-user") + seedToken(t, db, 1, "root-token", "r001xxxxxxxxxxxx") + seedToken(t, db, 2, "user-token", "u001xxxxxxxxxxxx") + + ctx, recorder := newAdminContext(t, http.MethodGet, "/api/token/?p=1&size=10", nil, 1, common.RoleRootUser) + GetAllTokens(ctx) + + response := decodeAPIResponse(t, recorder) + if !response.Success { + t.Fatalf("expected success response, got message: %s", response.Message) + } + + var page tokenPageResponse + if err := common.Unmarshal(response.Data, &page); err != nil { + t.Fatalf("failed to decode page response: %v", err) + } + if len(page.Items) != 2 { + t.Fatalf("expected 2 tokens, got %d", len(page.Items)) + } +} + +func TestAdminGetAllTokensFiltersByUserId(t *testing.T) { + db := setupAdminTokenTestDB(t) + seedTestUser(t, db, 1, common.RoleRootUser, "root") + seedTestUser(t, db, 2, common.RoleCommonUser, "common-user") + seedToken(t, db, 1, "root-token", "r002xxxxxxxxxxxx") + seedToken(t, db, 2, "user-token", "u002xxxxxxxxxxxx") + + ctx, recorder := newAdminContext(t, http.MethodGet, "/api/token/?p=1&size=10&user_id=2", nil, 1, common.RoleRootUser) + GetAllTokens(ctx) + + response := decodeAPIResponse(t, recorder) + if !response.Success { + t.Fatalf("expected success response, got message: %s", response.Message) + } + + var page tokenPageResponse + if err := common.Unmarshal(response.Data, &page); err != nil { + t.Fatalf("failed to decode page response: %v", err) + } + if len(page.Items) != 1 { + t.Fatalf("expected 1 token, got %d", len(page.Items)) + } + if page.Items[0].ID != 2 { + t.Fatalf("expected token ID 2, got %d", page.Items[0].ID) + } +} + +func TestAdminGetAllTokensMasksKeyInResponse(t *testing.T) { + db := setupAdminTokenTestDB(t) + seedTestUser(t, db, 1, common.RoleRootUser, "root") + seedTestUser(t, db, 2, common.RoleCommonUser, "common-user") + token := seedToken(t, db, 2, "user-token", "abcd1234efgh5678") + + ctx, recorder := newAdminContext(t, http.MethodGet, "/api/token/?p=1&size=10", nil, 1, common.RoleRootUser) + GetAllTokens(ctx) + + response := decodeAPIResponse(t, recorder) + if !response.Success { + t.Fatalf("expected success response, got message: %s", response.Message) + } + + var page tokenPageResponse + if err := common.Unmarshal(response.Data, &page); err != nil { + t.Fatalf("failed to decode page response: %v", err) + } + for _, item := range page.Items { + if item.Key == token.Key { + t.Fatalf("admin list response leaked raw token key: %s", recorder.Body.String()) + } + } +} + +func TestAdminGetTokenChecksPermission(t *testing.T) { + db := setupAdminTokenTestDB(t) + + seedTestUser(t, db, 1, common.RoleRootUser, "root") + seedTestUser(t, db, 2, common.RoleCommonUser, "common-user") + token := seedToken(t, db, 2, "user-token", "ut01xxxxxxxxxxxx") + + // Root 可以查看普通用户的令牌 + ctx, recorder := newAdminContext(t, http.MethodGet, "/api/token/"+strconv.Itoa(token.Id), nil, 1, common.RoleRootUser) + ctx.Params = gin.Params{{Key: "id", Value: strconv.Itoa(token.Id)}} + GetToken(ctx) + + response := decodeAPIResponse(t, recorder) + if !response.Success { + t.Fatalf("expected root to view user token, got message: %s", response.Message) + } +} + +func TestAdminGetTokenRejectsSameOrHigherRole(t *testing.T) { + db := setupAdminTokenTestDB(t) + + seedTestUser(t, db, 1, common.RoleAdminUser, "admin") + seedTestUser(t, db, 2, common.RoleRootUser, "root2") + token := seedToken(t, db, 2, "root2-token", "rt01xxxxxxxxxxxx") + + // Admin 不能查看 Root 的令牌 + ctx, recorder := newAdminContext(t, http.MethodGet, "/api/token/"+strconv.Itoa(token.Id), nil, 1, common.RoleAdminUser) + ctx.Params = gin.Params{{Key: "id", Value: strconv.Itoa(token.Id)}} + GetToken(ctx) + + response := decodeAPIResponse(t, recorder) + if response.Success { + t.Fatalf("expected admin to be rejected from viewing root token") + } +} + +func TestAdminAddTokenCreatesTokenForTargetUser(t *testing.T) { + db := setupAdminTokenTestDB(t) + + seedTestUser(t, db, 1, common.RoleRootUser, "root") + seedTestUser(t, db, 2, common.RoleCommonUser, "common-user") + + body := map[string]any{ + "user_id": 2, + "name": "admin-created-token", + "expired_time": -1, + "remain_quota": 500, + "unlimited_quota": false, + "group": "default", + } + + ctx, recorder := newAdminContext(t, http.MethodPost, "/api/token/", body, 1, common.RoleRootUser) + AddToken(ctx) + + response := decodeAPIResponse(t, recorder) + if !response.Success { + t.Fatalf("expected success response, got message: %s", response.Message) + } + + // 验证令牌属于目标用户 + tokens, _ := model.GetAllUserTokens(2, 0, 10) + if len(tokens) != 1 { + t.Fatalf("expected 1 token for user 2, got %d", len(tokens)) + } + if tokens[0].Name != "admin-created-token" { + t.Fatalf("expected token name 'admin-created-token', got %q", tokens[0].Name) + } +} + +func TestAdminAddTokenRejectsNonExistentUser(t *testing.T) { + db := setupAdminTokenTestDB(t) + + seedTestUser(t, db, 1, common.RoleRootUser, "root") + + body := map[string]any{ + "user_id": 999, + "name": "ghost-token", + "expired_time": -1, + "remain_quota": 100, + "unlimited_quota": false, + "group": "default", + } + + ctx, recorder := newAdminContext(t, http.MethodPost, "/api/token/", body, 1, common.RoleRootUser) + AddToken(ctx) + + response := decodeAPIResponse(t, recorder) + if response.Success { + t.Fatalf("expected failure for non-existent user") + } +} + +func TestAdminAddTokenRejectsHigherRoleTarget(t *testing.T) { + db := setupAdminTokenTestDB(t) + + seedTestUser(t, db, 1, common.RoleRootUser, "root") + seedTestUser(t, db, 2, common.RoleRootUser, "root2") + + // Root 不能为另一个 Root 创建令牌 + body := map[string]any{ + "user_id": 2, + "name": "bad-token", + "expired_time": -1, + "remain_quota": 100, + "unlimited_quota": false, + "group": "default", + } + + ctx, recorder := newAdminContext(t, http.MethodPost, "/api/token/", body, 1, common.RoleRootUser) + AddToken(ctx) + + // Root 现在可以给任何用户创建令牌,包括同级 Root + response := decodeAPIResponse(t, recorder) + if !response.Success { + t.Fatalf("expected success when creating token for same-role user, got message: %s", response.Message) + } +} + +func TestAdminUpdateTokenUpdatesOtherUserToken(t *testing.T) { + db := setupAdminTokenTestDB(t) + + seedTestUser(t, db, 1, common.RoleRootUser, "root") + seedTestUser(t, db, 2, common.RoleCommonUser, "common-user") + token := seedToken(t, db, 2, "user-token", "ut02xxxxxxxxxxxx") + + body := map[string]any{ + "id": token.Id, + "name": "admin-updated-token", + "expired_time": -1, + "remain_quota": 999, + "unlimited_quota": false, + "model_limits_enabled": false, + "model_limits": "", + "group": "default", + "cross_group_retry": false, + } + + ctx, recorder := newAdminContext(t, http.MethodPut, "/api/token/", body, 1, common.RoleRootUser) + UpdateToken(ctx) + + response := decodeAPIResponse(t, recorder) + if !response.Success { + t.Fatalf("expected success response, got message: %s", response.Message) + } + + // 验证更新已生效 + updated, err := model.GetTokenById(token.Id) + if err != nil { + t.Fatalf("failed to fetch updated token: %v", err) + } + if updated.Name != "admin-updated-token" { + t.Fatalf("expected name 'admin-updated-token', got %q", updated.Name) + } + if updated.RemainQuota != 999 { + t.Fatalf("expected remain_quota 999, got %d", updated.RemainQuota) + } +} + +func TestAdminUpdateTokenRejectsSameOrHigherRole(t *testing.T) { + db := setupAdminTokenTestDB(t) + + seedTestUser(t, db, 1, common.RoleAdminUser, "admin") + seedTestUser(t, db, 2, common.RoleRootUser, "root") + token := seedToken(t, db, 2, "root-token", "rt02xxxxxxxxxxxx") + + // Admin 不能修改 Root 的令牌 + body := map[string]any{ + "id": token.Id, + "name": "should-fail", + "expired_time": -1, + "remain_quota": 100, + "unlimited_quota": false, + "model_limits_enabled": false, + "model_limits": "", + "group": "default", + "cross_group_retry": false, + } + + ctx, recorder := newAdminContext(t, http.MethodPut, "/api/token/", body, 1, common.RoleAdminUser) + UpdateToken(ctx) + + response := decodeAPIResponse(t, recorder) + if response.Success { + t.Fatalf("expected admin to be rejected from updating root token") + } +} + +func TestAdminDeleteTokenDeletesOtherUserToken(t *testing.T) { + db := setupAdminTokenTestDB(t) + + seedTestUser(t, db, 1, common.RoleRootUser, "root") + seedTestUser(t, db, 2, common.RoleCommonUser, "common-user") + token := seedToken(t, db, 2, "user-token", "ut03xxxxxxxxxxxx") + + ctx, recorder := newAdminContext(t, http.MethodDelete, "/api/token/"+strconv.Itoa(token.Id), nil, 1, common.RoleRootUser) + ctx.Params = gin.Params{{Key: "id", Value: strconv.Itoa(token.Id)}} + DeleteToken(ctx) + + response := decodeAPIResponse(t, recorder) + if !response.Success { + t.Fatalf("expected success response, got message: %s", response.Message) + } + + // 验证令牌已被软删除 + _, err := model.GetTokenById(token.Id) + if err == nil { + t.Fatalf("expected token to be deleted") + } +} + +func TestAdminRoutesRejectNonRootUser(t *testing.T) { + db := setupAdminTokenTestDB(t) + seedTestUser(t, db, 1, common.RoleCommonUser, "regular-user") + + ctx, recorder := newAdminContext(t, http.MethodGet, "/api/token/?p=1&size=10", nil, 1, common.RoleCommonUser) + GetAllTokens(ctx) + + // 普通用户只能看到自己的令牌 + response := decodeAPIResponse(t, recorder) + if !response.Success { + t.Fatalf("expected success for self tokens, got message: %s", response.Message) + } +} diff --git a/model/token.go b/model/token.go index 5aa8b3d56e0e..9fcec4ea280d 100644 --- a/model/token.go +++ b/model/token.go @@ -14,6 +14,7 @@ import ( type Token struct { Id int `json:"id"` UserId int `json:"user_id" gorm:"index"` + Username string `json:"username" gorm:"->"` Key string `json:"key" gorm:"type:varchar(128);uniqueIndex"` Status int `json:"status" gorm:"default:1"` Name string `json:"name" gorm:"index" ` @@ -217,6 +218,56 @@ func SearchUserTokens(userId int, keyword string, token string, offset int, limi return tokens, total, nil } +// SearchTokensAdmin 管理员搜索所有令牌,支持可选 userId 筛选。 +// userIdFilter <= 0 时不按用户过滤。 +func SearchTokensAdmin(userIdFilter int, keyword string, token string, offset int, limit int) (tokens []*Token, total int64, err error) { + if limit <= 0 || limit > searchHardLimit { + limit = searchHardLimit + } + if offset < 0 { + offset = 0 + } + + if token != "" { + token = strings.TrimPrefix(token, "sk-") + } + + baseQuery := DB.Table("tokens"). + Select("tokens.*, users.username"). + Joins("LEFT JOIN users ON tokens.user_id = users.id") + if userIdFilter > 0 { + baseQuery = baseQuery.Where("tokens.user_id = ?", userIdFilter) + } + + if keyword != "" { + keywordPattern, err := sanitizeLikePattern(keyword) + if err != nil { + return nil, 0, err + } + baseQuery = baseQuery.Where("tokens.name LIKE ? ESCAPE '!'", keywordPattern) + } + if token != "" { + tokenPattern, err := sanitizeLikePattern(token) + if err != nil { + return nil, 0, err + } + baseQuery = baseQuery.Where("tokens."+commonKeyCol+" LIKE ? ESCAPE '!'", tokenPattern) + } + + err = baseQuery.Count(&total).Error + if err != nil { + common.SysError("failed to count search tokens: " + err.Error()) + return nil, 0, errors.New("搜索令牌失败") + } + + err = baseQuery.Order("id desc").Offset(offset).Limit(limit).Find(&tokens).Error + if err != nil { + common.SysError("failed to search tokens: " + err.Error()) + return nil, 0, errors.New("搜索令牌失败") + } + return tokens, total, nil +} + func ValidateUserToken(key string) (token *Token, err error) { if key == "" { return nil, ErrTokenNotProvided @@ -469,6 +520,33 @@ func CountUserTokens(userId int) (int64, error) { return total, err } +// GetAllTokensAdmin 管理员查看所有令牌,可选按 userId 筛选。 +// userIdFilter <= 0 时不按用户过滤,返回全系统令牌。 +func GetAllTokensAdmin(userIdFilter int, offset int, limit int) ([]*Token, error) { + var tokens []*Token + query := DB.Table("tokens"). + Select("tokens.*, users.username"). + Joins("LEFT JOIN users ON tokens.user_id = users.id"). + Order("tokens.id desc"). + Limit(limit).Offset(offset) + if userIdFilter > 0 { + query = query.Where("tokens.user_id = ?", userIdFilter) + } + err := query.Find(&tokens).Error + return tokens, err +} + +// CountTokensAdmin 管理员统计令牌数量,可选按 userId 筛选。 +func CountTokensAdmin(userIdFilter int) (int64, error) { + var total int64 + query := DB.Model(&Token{}) + if userIdFilter > 0 { + query = query.Where("user_id = ?", userIdFilter) + } + err := query.Count(&total).Error + return total, err +} + // BatchDeleteTokens 删除指定用户的一组令牌,返回成功删除数量 func BatchDeleteTokens(ids []int, userId int) (int, error) { if len(ids) == 0 { @@ -503,6 +581,41 @@ func BatchDeleteTokens(ids []int, userId int) (int, error) { return len(tokens), nil } +// BatchDeleteTokensAdmin 管理员批量删除令牌(不带 userId 过滤),返回成功删除数量。 +// 清理被删令牌的 Redis 缓存。 +func BatchDeleteTokensAdmin(ids []int) (int, error) { + if len(ids) == 0 { + return 0, errors.New("ids 不能为空!") + } + + tx := DB.Begin() + + var tokens []Token + if err := tx.Where("id IN (?)", ids).Find(&tokens).Error; err != nil { + tx.Rollback() + return 0, err + } + + if err := tx.Where("id IN (?)", ids).Delete(&Token{}).Error; err != nil { + tx.Rollback() + return 0, err + } + + if err := tx.Commit().Error; err != nil { + return 0, err + } + + if common.RedisEnabled { + gopool.Go(func() { + for _, t := range tokens { + _ = cacheDeleteToken(t.Key) + } + }) + } + + return len(tokens), nil +} + func GetTokenKeysByIds(ids []int, userId int) ([]Token, error) { var tokens []Token err := DB.Select("id", commonKeyCol). @@ -511,6 +624,15 @@ func GetTokenKeysByIds(ids []int, userId int) ([]Token, error) { return tokens, err } +// GetTokenKeysByIdsAdmin 管理员按 ID 列表获取令牌 Key(不校验 user_id)。 +func GetTokenKeysByIdsAdmin(ids []int) ([]Token, error) { + var tokens []Token + err := DB.Select("id", commonKeyCol). + Where("id IN (?)", ids). + Find(&tokens).Error + return tokens, err +} + // InvalidateUserTokensCache 清理指定用户所有令牌在 Redis 中的缓存, // 配合 InvalidateUserCache 使用,可在用户被禁用/删除时立即阻断其令牌的请求。 // 下一次请求将从数据库重新加载令牌及用户状态,从而立即识别出被禁用的用户。 diff --git a/web/src/features/keys/api.ts b/web/src/features/keys/api.ts index 0f90490c7852..3f501a76c6b7 100644 --- a/web/src/features/keys/api.ts +++ b/web/src/features/keys/api.ts @@ -32,12 +32,16 @@ import type { // API Key Management // ============================================================================ -// Get paginated API keys list +// Get paginated API keys list (admin pass ?user_id=X to query others) export async function getApiKeys( params: GetApiKeysParams = {} ): Promise { - const { p = 1, size = 10 } = params - const res = await api.get(`/api/token/?p=${p}&size=${size}`) + const { p = 1, size = 10, user_id } = params + const queryParams = new URLSearchParams() + queryParams.set('p', String(p)) + queryParams.set('size', String(size)) + if (user_id) queryParams.set('user_id', String(user_id)) + const res = await api.get(`/api/token/?${queryParams.toString()}`) return res.data } @@ -45,12 +49,13 @@ export async function getApiKeys( export async function searchApiKeys( params: SearchApiKeysParams ): Promise { - const { keyword = '', token = '', p, size } = params + const { keyword = '', token = '', p, size, user_id } = params const queryParams = new URLSearchParams() if (keyword) queryParams.set('keyword', keyword) if (token) queryParams.set('token', token) if (p != null) queryParams.set('p', String(p)) if (size != null) queryParams.set('size', String(size)) + if (user_id) queryParams.set('user_id', String(user_id)) const res = await api.get(`/api/token/search?${queryParams.toString()}`) return res.data } @@ -69,9 +74,9 @@ export async function getTokenAutoGroups(): Promise< return res.data } -// Create a new API key +// Create a new API key (admin passes user_id for target user) export async function createApiKey( - data: ApiKeyFormData + data: ApiKeyFormData & { user_id?: number } ): Promise> { const res = await api.post('/api/token/', data) return res.data diff --git a/web/src/features/keys/components/api-keys-columns.tsx b/web/src/features/keys/components/api-keys-columns.tsx index 2880783d931c..581e8cda2b9d 100644 --- a/web/src/features/keys/components/api-keys-columns.tsx +++ b/web/src/features/keys/components/api-keys-columns.tsx @@ -73,7 +73,10 @@ function useGroupRatios(): Record { return data ?? {} } -export function useApiKeysColumns(now: number): ColumnDef[] { +export function useApiKeysColumns( + now: number, + isAdmin?: boolean +): ColumnDef[] { const { t, i18n } = useTranslation() const groupRatios = useGroupRatios() const shouldReduceMotion = useMediaQuery('(prefers-reduced-motion: reduce)') @@ -190,6 +193,26 @@ export function useApiKeysColumns(now: number): ColumnDef[] { }, size: 170, }, + ...(isAdmin + ? [ + { + id: 'username' as const, + accessorKey: 'user_id' as const, + header: t('Username'), + cell: ({ row }: { row: { original: ApiKey } }) => { + const username = row.original.username + const userId = row.original.user_id + if (!userId) return + return ( + + {username || `#${userId}`} + + ) + }, + size: 120, + }, + ] + : []), { accessorKey: 'group', header: t('Group'), diff --git a/web/src/features/keys/components/api-keys-mutate-drawer.tsx b/web/src/features/keys/components/api-keys-mutate-drawer.tsx index ed393c53e6cf..0de44dfb7587 100644 --- a/web/src/features/keys/components/api-keys-mutate-drawer.tsx +++ b/web/src/features/keys/components/api-keys-mutate-drawer.tsx @@ -50,6 +50,7 @@ import { FormLabel, FormMessage, } from '@/components/ui/form' +import { ComboboxInput } from '@/components/ui/combobox-input' import { Input } from '@/components/ui/input' import { Sheet, @@ -66,6 +67,9 @@ import { useStatus } from '@/hooks/use-status' import { getUserModels, getUserGroups } from '@/lib/api' import { getCurrencyDisplay, getCurrencyLabel } from '@/lib/currency' import { cn } from '@/lib/utils' +import { useAuthStore } from '@/stores/auth-store' +import { getUsers } from '@/features/users/api' +import type { User } from '@/features/users/types' import { createApiKey, @@ -103,7 +107,28 @@ export function ApiKeysMutateDrawer({ const { t } = useTranslation() const isUpdate = !!currentRow const currentRowId = currentRow?.id - const { triggerRefresh } = useApiKeys() + const { triggerRefresh, isAdmin, selectedUserId } = useApiKeys() + const adminMode = isAdmin && selectedUserId != null + const authUser = useAuthStore((s) => s.auth.user) + const [targetUserId, setTargetUserId] = useState( + () => authUser?.id ?? 0 + ) + + // Fetch users for admin user selector + const { data: usersData } = useQuery({ + queryKey: ['admin-users'], + queryFn: async () => { + const result = await getUsers({ p: 1, page_size: 200 }) + if (result.success && result.data?.items) { + return result.data.items as User[] + } + return [] + }, + enabled: open && isAdmin, + staleTime: 60_000, + }) + const selectableUsers = usersData || [] + const { status, loading: statusLoading } = useStatus() const [isSubmitting, setIsSubmitting] = useState(false) const [advancedOpen, setAdvancedOpen] = useState(false) @@ -204,6 +229,10 @@ export function ApiKeysMutateDrawer({ setInitializedTarget(null) return } + // Reset targetUserId to self when drawer opens + if (isAdmin && !isUpdate && authUser?.id) { + setTargetUserId(authUser.id) + } if ( !groupsFetched || groupsFetching || @@ -306,6 +335,7 @@ export function ApiKeysMutateDrawer({ i === 0 && data.name ? data.name : `${data.name || 'default'}-${Math.random().toString(36).slice(2, 8)}`, + ...(isAdmin && { user_id: targetUserId }), }) if (result.success) { successCount++ @@ -412,6 +442,27 @@ export function ApiKeysMutateDrawer({ )} /> + {isAdmin && !isUpdate && ( + + {t('Target User')} + + setTargetUserId(Number(v))} + options={selectableUsers.map((user) => ({ + value: String(user.id), + label: user.username, + }))} + /> + + + {t('Select the user this API key belongs to')} + + + )} + . For commercial licensing, please contact support@quantumnous.com */ -import React, { useState, useCallback, useRef, useEffect } from 'react' +import React, { useState, useCallback, useRef, useEffect, useMemo } from 'react' import { useTranslation } from 'react-i18next' import { toast } from 'sonner' import useDialogState from '@/hooks/use-dialog' +import { ROLE } from '@/lib/roles' +import { useAuthStore } from '@/stores/auth-store' import { fetchTokenKey, fetchTokenKeysBatch } from '../api' import { ERROR_MESSAGES } from '../constants' @@ -41,6 +43,9 @@ type ApiKeysContextType = { loadingKeys: Record copiedKeyId: number | null markKeyCopied: (id: number) => void + isAdmin: boolean + selectedUserId: number | null + setSelectedUserId: (id: number | null) => void } const ApiKeysContext = React.createContext(null) @@ -59,6 +64,11 @@ export function ApiKeysProvider({ children }: { children: React.ReactNode }) { const [copiedKeyId, setCopiedKeyId] = useState(null) const copiedTimerRef = useRef>(undefined) + // Admin mode: Root can select a target user to manage their tokens + const [selectedUserId, setSelectedUserId] = useState(null) + const userRole = useAuthStore((s) => s.auth.user?.role) + const isAdmin = useMemo(() => userRole === ROLE.SUPER_ADMIN, [userRole]) + useEffect(() => { return () => clearTimeout(copiedTimerRef.current) }, []) @@ -171,6 +181,9 @@ export function ApiKeysProvider({ children }: { children: React.ReactNode }) { loadingKeys, copiedKeyId, markKeyCopied, + isAdmin, + selectedUserId, + setSelectedUserId, }} > {children} diff --git a/web/src/features/keys/components/api-keys-table.tsx b/web/src/features/keys/components/api-keys-table.tsx index 3561df5254c9..3416e65bc19c 100644 --- a/web/src/features/keys/components/api-keys-table.tsx +++ b/web/src/features/keys/components/api-keys-table.tsx @@ -39,12 +39,16 @@ import { EmptyMedia, EmptyTitle, } from '@/components/ui/empty' +import { ComboboxInput } from '@/components/ui/combobox-input' import { Input } from '@/components/ui/input' import { Skeleton } from '@/components/ui/skeleton' import { useTableUrlState } from '@/hooks/use-table-url-state' import { formatQuota } from '@/lib/format' import { cn } from '@/lib/utils' +import { getUsers } from '@/features/users/api' +import type { User } from '@/features/users/types' + import { getApiKeys, searchApiKeys } from '../api' import { API_KEY_STATUS, @@ -188,9 +192,26 @@ function ApiKeysMobileList({ export function ApiKeysTable() { const { t } = useTranslation() - const { refreshTrigger } = useApiKeys() + const { refreshTrigger, isAdmin, selectedUserId, setSelectedUserId } = + useApiKeys() const [now, setNow] = useState(() => Date.now()) - const columns = useApiKeysColumns(now) + + // Fetch users list for admin user selector + const { data: usersData } = useQuery({ + queryKey: ['admin-users-list'], + queryFn: async () => { + const result = await getUsers({ p: 1, page_size: 200 }) + if (result.success && result.data?.items) { + return result.data.items as User[] + } + return [] + }, + enabled: isAdmin, + staleTime: 60_000, + }) + const users = usersData || [] + + const columns = useApiKeysColumns(now, isAdmin) useEffect(() => { const intervalId = window.setInterval(() => { @@ -200,6 +221,13 @@ export function ApiKeysTable() { return () => window.clearInterval(intervalId) }, []) + // Reset selected user when leaving admin mode + useEffect(() => { + if (!isAdmin) { + setSelectedUserId(null) + } + }, [isAdmin, setSelectedUserId]) + const { globalFilter, onGlobalFilterChange, @@ -240,18 +268,24 @@ export function ApiKeysTable() { globalFilter, tokenFilter, refreshTrigger, + isAdmin, + selectedUserId, ], queryFn: async () => { + const adminUserId = + isAdmin && selectedUserId != null ? selectedUserId : undefined const result = shouldSearch ? await searchApiKeys({ keyword: globalFilter, token: tokenFilter, p: pagination.pageIndex + 1, size: pagination.pageSize, + user_id: adminUserId, }) : await getApiKeys({ p: pagination.pageIndex + 1, size: pagination.pageSize, + user_id: adminUserId, }) if (!result.success) { @@ -308,13 +342,35 @@ export function ApiKeysTable() { toolbarProps={{ searchPlaceholder: t('Filter by name...'), additionalSearch: ( - setTokenFilterInput(e.target.value)} - className='w-full sm:w-50 lg:w-60' - /> +
+ setTokenFilterInput(e.target.value)} + className='w-full sm:w-50 lg:w-60' + /> + {isAdmin && ( + { + if (v === '_all') setSelectedUserId(null) + else setSelectedUserId(Number(v)) + }} + options={[ + { value: '_all', label: t('All users') }, + ...users.map((user) => ({ + value: String(user.id), + label: user.username, + })), + ]} + /> + )} +
), filters: [ { diff --git a/web/src/features/keys/types.ts b/web/src/features/keys/types.ts index 78bc16d0901a..05cfd980e29b 100644 --- a/web/src/features/keys/types.ts +++ b/web/src/features/keys/types.ts @@ -24,6 +24,8 @@ import { z } from 'zod' export const apiKeySchema = z.object({ id: z.number(), + user_id: z.number().optional(), + username: z.string().optional(), name: z.string(), key: z.string(), status: z.number(), // 1: enabled, 2: disabled, 3: expired, 4: exhausted @@ -63,6 +65,7 @@ export interface ApiResponse { export interface GetApiKeysParams { p?: number size?: number + user_id?: number } export interface GetApiKeysResponse { @@ -81,6 +84,7 @@ export interface SearchApiKeysParams { token?: string p?: number size?: number + user_id?: number } export interface ApiKeyFormData { diff --git a/web/src/i18n/locales/en.json b/web/src/i18n/locales/en.json index b48096a7fcfa..fb0cf5f0cd13 100644 --- a/web/src/i18n/locales/en.json +++ b/web/src/i18n/locales/en.json @@ -235,6 +235,7 @@ "Admin access required": "Admin access required", "Admin area": "Admin area", "Admin Channel Permissions": "Admin Channel Permissions", + "Admin filter": "Admin search", "Admin notes (only visible to admins)": "Admin notes (only visible to admins)", "Admin Only": "Admin Only", "Admin Permissions": "Admin Permissions", diff --git a/web/src/i18n/locales/zh.json b/web/src/i18n/locales/zh.json index 842991e714f0..93c153d6a204 100644 --- a/web/src/i18n/locales/zh.json +++ b/web/src/i18n/locales/zh.json @@ -235,6 +235,7 @@ "Admin access required": "需要管理员权限", "Admin area": "管理员区域", "Admin Channel Permissions": "管理员渠道权限", + "Admin filter": "管理员搜索", "Admin notes (only visible to admins)": "管理员备注(仅管理员可见)", "Admin Only": "仅限管理员", "Admin Permissions": "管理员权限",