From 9ecb3408223bbfc6a2fde9f60b7ddd24d622a8ab Mon Sep 17 00:00:00 2001 From: shaw Date: Wed, 30 Sep 2026 13:44:02 +0800 Subject: [PATCH] =?UTF-8?q?feat(api-key):=20=E5=A2=9E=E5=8A=A0=20API=20Key?= =?UTF-8?q?=20=E5=88=9B=E5=BB=BA=E6=95=B0=E9=87=8F=E4=B8=8E=E9=A2=91?= =?UTF-8?q?=E7=8E=87=E9=99=90=E5=88=B6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增 api_key_create.max_active_per_user(默认 200)与 api_key_create.max_per_user_per_hour(默认 20),0 表示不限制 - 创建频率按固定一小时窗口统计,自定义与自动生成的 Key 统一计入 - 创建计数独立于 Key 生命周期,删除或修改 Key 不再重置计数 - 移除不再使用的 DeleteCreateAttemptCount 缓存方法 --- backend/internal/config/config.go | 17 ++ backend/internal/repository/api_key_cache.go | 21 ++- .../api_key_cache_integration_test.go | 13 -- .../repository/api_key_create_count_test.go | 43 +++++ backend/internal/server/api_contract_test.go | 4 +- backend/internal/service/api_key_service.go | 45 ++++- .../service/api_key_service_cache_test.go | 4 +- .../api_key_service_create_limit_test.go | 158 ++++++++++++++++++ .../service/api_key_service_delete_test.go | 22 +-- .../service/api_key_service_quota_test.go | 4 +- .../auth_cache_invalidation_outbox_test.go | 6 +- deploy/config.example.yaml | 13 ++ 12 files changed, 302 insertions(+), 48 deletions(-) create mode 100644 backend/internal/repository/api_key_create_count_test.go create mode 100644 backend/internal/service/api_key_service_create_limit_test.go diff --git a/backend/internal/config/config.go b/backend/internal/config/config.go index 11b86e6b22..1f9a9bc655 100644 --- a/backend/internal/config/config.go +++ b/backend/internal/config/config.go @@ -89,6 +89,7 @@ type Config struct { Pricing PricingConfig `mapstructure:"pricing"` Gateway GatewayConfig `mapstructure:"gateway"` APIKeyAuth APIKeyAuthCacheConfig `mapstructure:"api_key_auth_cache"` + APIKeyCreate APIKeyCreateConfig `mapstructure:"api_key_create"` SubscriptionCache SubscriptionCacheConfig `mapstructure:"subscription_cache"` SubscriptionMaintenance SubscriptionMaintenanceConfig `mapstructure:"subscription_maintenance"` Dashboard DashboardCacheConfig `mapstructure:"dashboard_cache"` @@ -1696,6 +1697,14 @@ type APIKeyAuthCacheConfig struct { InvalidAbuse InvalidAuthAbuseConfig `mapstructure:"invalid_abuse"` } +// APIKeyCreateConfig 用户创建 API Key 的防滥用限制(0 表示不限制) +type APIKeyCreateConfig struct { + // MaxActivePerUser 单个用户同时存在(未删除)的 API Key 上限 + MaxActivePerUser int `mapstructure:"max_active_per_user"` + // MaxPerUserPerHour 单个用户每小时可创建的 API Key 次数(删除不返还次数) + MaxPerUserPerHour int `mapstructure:"max_per_user_per_hour"` +} + type InvalidAuthAbuseConfig struct { Enabled bool `mapstructure:"enabled"` Threshold int `mapstructure:"threshold"` @@ -2332,6 +2341,8 @@ func setDefaults() { viper.SetDefault("api_key_auth_cache.invalid_abuse.window_seconds", 60) viper.SetDefault("api_key_auth_cache.invalid_abuse.block_seconds", 60) viper.SetDefault("api_key_auth_cache.invalid_abuse.capacity", 16384) + viper.SetDefault("api_key_create.max_active_per_user", 200) + viper.SetDefault("api_key_create.max_per_user_per_hour", 20) // Subscription auth L1 cache viper.SetDefault("subscription_cache.l1_size", 16384) @@ -2711,6 +2722,12 @@ func (c *Config) Validate() error { return fmt.Errorf("server.h2c.max_upload_buffer_per_stream must be positive") } } + if c.APIKeyCreate.MaxActivePerUser < 0 { + return fmt.Errorf("api_key_create.max_active_per_user must be non-negative") + } + if c.APIKeyCreate.MaxPerUserPerHour < 0 { + return fmt.Errorf("api_key_create.max_per_user_per_hour must be non-negative") + } if c.APIKeyAuth.InvalidAbuse.Enabled { if c.APIKeyAuth.InvalidAbuse.Threshold < 10 { return fmt.Errorf("api_key_auth_cache.invalid_abuse.threshold must be at least 10") diff --git a/backend/internal/repository/api_key_cache.go b/backend/internal/repository/api_key_cache.go index ecc6026f40..899f781e72 100644 --- a/backend/internal/repository/api_key_cache.go +++ b/backend/internal/repository/api_key_cache.go @@ -15,6 +15,7 @@ import ( const ( apiKeyRateLimitKeyPrefix = "apikey:ratelimit:" apiKeyRateLimitDuration = 24 * time.Hour + apiKeyCreateCountKeyPrefix = "apikey:create_count:" apiKeyAuthCachePrefix = "apikey:auth:" authCacheInvalidateChannel = "auth:cache:invalidate" ) @@ -24,6 +25,11 @@ func apiKeyRateLimitKey(userID int64) string { return fmt.Sprintf("%s%d", apiKeyRateLimitKeyPrefix, userID) } +// apiKeyCreateCountKey generates the Redis key for per-user API key creation counting. +func apiKeyCreateCountKey(userID int64) string { + return fmt.Sprintf("%s%d", apiKeyCreateCountKeyPrefix, userID) +} + func apiKeyAuthCacheKey(key string) string { return fmt.Sprintf("%s%s", apiKeyAuthCachePrefix, key) } @@ -54,9 +60,18 @@ func (c *apiKeyCache) IncrementCreateAttemptCount(ctx context.Context, userID in return err } -func (c *apiKeyCache) DeleteCreateAttemptCount(ctx context.Context, userID int64) error { - key := apiKeyRateLimitKey(userID) - return c.rdb.Del(ctx, key).Err() +// IncrementCreateCount 在固定窗口内累加创建次数并返回累加后的值。 +// ExpireNX 只在首次创建计数键时设置过期,后续创建不会延长窗口; +// MULTI 保证 INCR 与 EXPIRE 同时生效,避免计数键丢失 TTL 后永久封禁。 +func (c *apiKeyCache) IncrementCreateCount(ctx context.Context, userID int64, window time.Duration) (int64, error) { + key := apiKeyCreateCountKey(userID) + pipe := c.rdb.TxPipeline() + incr := pipe.Incr(ctx, key) + pipe.ExpireNX(ctx, key, window) + if _, err := pipe.Exec(ctx); err != nil { + return 0, err + } + return incr.Val(), nil } func (c *apiKeyCache) IncrementDailyUsage(ctx context.Context, apiKey string) error { diff --git a/backend/internal/repository/api_key_cache_integration_test.go b/backend/internal/repository/api_key_cache_integration_test.go index e93949178f..63536e1fbb 100644 --- a/backend/internal/repository/api_key_cache_integration_test.go +++ b/backend/internal/repository/api_key_cache_integration_test.go @@ -51,19 +51,6 @@ func (s *ApiKeyCacheSuite) TestCreateAttemptCount() { s.AssertTTLWithin(ttl, 1*time.Second, apiKeyRateLimitDuration) }, }, - { - name: "delete_removes_key", - fn: func(ctx context.Context, rdb *redis.Client, cache *apiKeyCache) { - userID := int64(1) - - require.NoError(s.T(), cache.IncrementCreateAttemptCount(ctx, userID)) - require.NoError(s.T(), cache.DeleteCreateAttemptCount(ctx, userID), "DeleteCreateAttemptCount") - - count, err := cache.GetCreateAttemptCount(ctx, userID) - require.NoError(s.T(), err, "expected nil error after delete") - require.Equal(s.T(), 0, count, "expected zero count after delete") - }, - }, } for _, tt := range tests { diff --git a/backend/internal/repository/api_key_create_count_test.go b/backend/internal/repository/api_key_create_count_test.go new file mode 100644 index 0000000000..67bd0f7574 --- /dev/null +++ b/backend/internal/repository/api_key_create_count_test.go @@ -0,0 +1,43 @@ +package repository + +import ( + "context" + "testing" + "time" + + "github.com/alicebob/miniredis/v2" + "github.com/redis/go-redis/v9" + "github.com/stretchr/testify/require" +) + +func TestAPIKeyCacheIncrementCreateCountUsesFixedWindow(t *testing.T) { + server := miniredis.RunT(t) + client := redis.NewClient(&redis.Options{Addr: server.Addr()}) + defer func() { _ = client.Close() }() + cache := NewAPIKeyCache(client) + ctx := context.Background() + key := apiKeyCreateCountKey(7) + + count, err := cache.IncrementCreateCount(ctx, 7, time.Hour) + require.NoError(t, err) + require.Equal(t, int64(1), count) + require.Equal(t, time.Hour, server.TTL(key)) + + // 后续创建不得延长窗口,否则持续创建会让计数永不过期。 + server.FastForward(40 * time.Minute) + count, err = cache.IncrementCreateCount(ctx, 7, time.Hour) + require.NoError(t, err) + require.Equal(t, int64(2), count) + require.Equal(t, 20*time.Minute, server.TTL(key)) + + // 其他用户独立计数。 + count, err = cache.IncrementCreateCount(ctx, 8, time.Hour) + require.NoError(t, err) + require.Equal(t, int64(1), count) + + // 窗口到期后重新计数。 + server.FastForward(21 * time.Minute) + count, err = cache.IncrementCreateCount(ctx, 7, time.Hour) + require.NoError(t, err) + require.Equal(t, int64(1), count) +} diff --git a/backend/internal/server/api_contract_test.go b/backend/internal/server/api_contract_test.go index 327c425c35..3d7b757b4e 100644 --- a/backend/internal/server/api_contract_test.go +++ b/backend/internal/server/api_contract_test.go @@ -1737,8 +1737,8 @@ func (stubApiKeyCache) IncrementCreateAttemptCount(ctx context.Context, userID i return nil } -func (stubApiKeyCache) DeleteCreateAttemptCount(ctx context.Context, userID int64) error { - return nil +func (stubApiKeyCache) IncrementCreateCount(ctx context.Context, userID int64, window time.Duration) (int64, error) { + return 0, nil } func (stubApiKeyCache) IncrementDailyUsage(ctx context.Context, apiKey string) error { diff --git a/backend/internal/service/api_key_service.go b/backend/internal/service/api_key_service.go index 5288f4ff8b..b00b03d43f 100644 --- a/backend/internal/service/api_key_service.go +++ b/backend/internal/service/api_key_service.go @@ -30,6 +30,8 @@ var ( ErrAPIKeyTooShort = infraerrors.BadRequest("API_KEY_TOO_SHORT", "api key must be at least 16 characters") ErrAPIKeyInvalidChars = infraerrors.BadRequest("API_KEY_INVALID_CHARS", "api key can only contain letters, numbers, underscores, and hyphens") ErrAPIKeyRateLimited = infraerrors.TooManyRequests("API_KEY_RATE_LIMITED", "too many failed attempts, please try again later") + ErrAPIKeyCreateLimited = infraerrors.TooManyRequests("API_KEY_CREATE_RATE_LIMITED", "too many api keys created recently, please try again later") + ErrAPIKeyCountExceeded = infraerrors.Forbidden("API_KEY_COUNT_EXCEEDED", "api key count limit reached, please delete unused keys first") ErrAPIKeyAuthOverloaded = infraerrors.ServiceUnavailable("API_KEY_AUTH_OVERLOADED", "api key authentication is temporarily overloaded") ErrInvalidIPPattern = infraerrors.BadRequest("INVALID_IP_PATTERN", "invalid IP or CIDR pattern") // ErrAPIKeyExpired = infraerrors.Forbidden("API_KEY_EXPIRED", "api key has expired") @@ -48,6 +50,7 @@ const ( defaultAuthLookupConcurrency = 64 defaultNegativeAuthCacheSize = 16384 apiKeyMaxErrorsPerHour = 20 + apiKeyCreateCountWindow = time.Hour apiKeyLastUsedMinTouch = 30 * time.Second apiKeySortCurrentConcurrency = "current_concurrency" // DB 写失败后的短退避,避免请求路径持续同步重试造成写风暴与高延迟。 @@ -172,7 +175,7 @@ type APIKeyQuotaUsageState struct { type APIKeyCache interface { GetCreateAttemptCount(ctx context.Context, userID int64) (int, error) IncrementCreateAttemptCount(ctx context.Context, userID int64) error - DeleteCreateAttemptCount(ctx context.Context, userID int64) error + IncrementCreateCount(ctx context.Context, userID int64, window time.Duration) (int64, error) IncrementDailyUsage(ctx context.Context, apiKey string) error SetDailyUsageExpiry(ctx context.Context, apiKey string, ttl time.Duration) error @@ -435,6 +438,34 @@ func (s *APIKeyService) checkAPIKeyRateLimit(ctx context.Context, userID int64) return nil } +// checkAPIKeyCreateLimits 校验创建 API Key 的防滥用限制(对自定义与自动生成的 Key 一视同仁)。 +// 数量上限按未删除的 Key 计;创建次数按固定窗口累计,删除 Key 不返还次数, +// 以阻断"删除后反复新建"的循环。Redis 出错时与自定义 Key 限流一致,不阻止用户操作。 +func (s *APIKeyService) checkAPIKeyCreateLimits(ctx context.Context, userID int64) error { + if s.cfg == nil { + return nil + } + if maxActive := s.cfg.APIKeyCreate.MaxActivePerUser; maxActive > 0 { + count, err := s.apiKeyRepo.CountByUserID(ctx, userID) + if err != nil { + return fmt.Errorf("count api keys: %w", err) + } + if count >= int64(maxActive) { + return ErrAPIKeyCountExceeded + } + } + if maxPerHour := s.cfg.APIKeyCreate.MaxPerUserPerHour; maxPerHour > 0 && s.cache != nil { + count, err := s.cache.IncrementCreateCount(ctx, userID, apiKeyCreateCountWindow) + if err != nil { + return nil + } + if count > int64(maxPerHour) { + return ErrAPIKeyCreateLimited + } + } + return nil +} + // incrementAPIKeyErrorCount 增加用户创建自定义Key的错误计数 func (s *APIKeyService) incrementAPIKeyErrorCount(ctx context.Context, userID int64) { if s.cache == nil { @@ -530,6 +561,10 @@ func (s *APIKeyService) Create(ctx context.Context, userID int64, req CreateAPIK } } + if err := s.checkAPIKeyCreateLimits(ctx, userID); err != nil { + return nil, err + } + // 创建API Key记录 apiKey := &APIKey{ UserID: userID, @@ -820,10 +855,6 @@ func (s *APIKeyService) Update(ctx context.Context, id int64, userID int64, req if req.Status != nil { apiKey.Status = *req.Status fields.Status = true - // 如果状态改变,清除Redis缓存 - if s.cache != nil { - _ = s.cache.DeleteCreateAttemptCount(ctx, apiKey.UserID) - } } // Update quota fields @@ -931,9 +962,7 @@ func (s *APIKeyService) Delete(ctx context.Context, id int64, userID int64) erro } // 删除成功后再清理缓存,避免"缓存已清但删除失败"的竞态。 - if s.cache != nil { - _ = s.cache.DeleteCreateAttemptCount(ctx, userID) - } + // 注意:不清零创建相关计数,否则"删除后反复新建"即可绕过创建限流。 s.InvalidateAuthCacheByKey(ctx, key) s.lastUsedTouchL1.Delete(id) diff --git a/backend/internal/service/api_key_service_cache_test.go b/backend/internal/service/api_key_service_cache_test.go index ff5cffefef..9e9cc734da 100644 --- a/backend/internal/service/api_key_service_cache_test.go +++ b/backend/internal/service/api_key_service_cache_test.go @@ -138,8 +138,8 @@ func (s *authCacheStub) IncrementCreateAttemptCount(ctx context.Context, userID return nil } -func (s *authCacheStub) DeleteCreateAttemptCount(ctx context.Context, userID int64) error { - return nil +func (s *authCacheStub) IncrementCreateCount(ctx context.Context, userID int64, window time.Duration) (int64, error) { + return 0, nil } func (s *authCacheStub) IncrementDailyUsage(ctx context.Context, apiKey string) error { diff --git a/backend/internal/service/api_key_service_create_limit_test.go b/backend/internal/service/api_key_service_create_limit_test.go new file mode 100644 index 0000000000..b30397dc3c --- /dev/null +++ b/backend/internal/service/api_key_service_create_limit_test.go @@ -0,0 +1,158 @@ +//go:build unit + +package service + +import ( + "context" + "errors" + "testing" + "time" + + "github.com/Wei-Shaw/sub2api/internal/config" + "github.com/stretchr/testify/require" +) + +type createLimitAPIKeyRepoStub struct { + *apiKeyRepoStub + activeCount int64 + countErr error + created []*APIKey +} + +func (s *createLimitAPIKeyRepoStub) CountByUserID(ctx context.Context, userID int64) (int64, error) { + return s.activeCount, s.countErr +} + +func (s *createLimitAPIKeyRepoStub) ExistsByKey(ctx context.Context, key string) (bool, error) { + return false, nil +} + +func (s *createLimitAPIKeyRepoStub) Create(ctx context.Context, key *APIKey) error { + s.created = append(s.created, key) + return nil +} + +type createLimitCacheStub struct { + *apiKeyCacheStub + createCounts map[int64]int64 + incrErr error + windows []time.Duration +} + +func (s *createLimitCacheStub) IncrementCreateCount(ctx context.Context, userID int64, window time.Duration) (int64, error) { + s.windows = append(s.windows, window) + if s.incrErr != nil { + return 0, s.incrErr + } + s.createCounts[userID]++ + return s.createCounts[userID], nil +} + +func newCreateLimitService(repo *createLimitAPIKeyRepoStub, cache *createLimitCacheStub, maxActive, maxPerHour int) *APIKeyService { + cfg := &config.Config{} + cfg.APIKeyCreate.MaxActivePerUser = maxActive + cfg.APIKeyCreate.MaxPerUserPerHour = maxPerHour + return &APIKeyService{ + apiKeyRepo: repo, + userRepo: &userRepoStub{user: &User{ID: 7}}, + cache: cache, + cfg: cfg, + } +} + +func newCreateLimitStubs() (*createLimitAPIKeyRepoStub, *createLimitCacheStub) { + return &createLimitAPIKeyRepoStub{apiKeyRepoStub: &apiKeyRepoStub{}}, + &createLimitCacheStub{apiKeyCacheStub: &apiKeyCacheStub{}, createCounts: map[int64]int64{}} +} + +func TestAPIKeyServiceCreate_RateLimitsGeneratedKeys(t *testing.T) { + repo, cache := newCreateLimitStubs() + svc := newCreateLimitService(repo, cache, 0, 3) + + for i := 0; i < 3; i++ { + _, err := svc.Create(context.Background(), 7, CreateAPIKeyRequest{Name: "k"}) + require.NoError(t, err) + } + _, err := svc.Create(context.Background(), 7, CreateAPIKeyRequest{Name: "k"}) + require.ErrorIs(t, err, ErrAPIKeyCreateLimited) + require.Len(t, repo.created, 3) + require.Equal(t, apiKeyCreateCountWindow, cache.windows[0]) +} + +func TestAPIKeyServiceCreate_RateLimitsCustomKeys(t *testing.T) { + repo, cache := newCreateLimitStubs() + svc := newCreateLimitService(repo, cache, 0, 1) + custom := "sk-custom-key-0123456789" + + _, err := svc.Create(context.Background(), 7, CreateAPIKeyRequest{Name: "k", CustomKey: &custom}) + require.NoError(t, err) + _, err = svc.Create(context.Background(), 7, CreateAPIKeyRequest{Name: "k", CustomKey: &custom}) + require.ErrorIs(t, err, ErrAPIKeyCreateLimited) + require.Len(t, repo.created, 1) +} + +// 删除 Key 释放数量名额,但不返还创建次数,删建循环仍受每小时次数限制。 +func TestAPIKeyServiceCreate_DeleteDoesNotRefundCreateCount(t *testing.T) { + repo, cache := newCreateLimitStubs() + repo.apiKey = &APIKey{ID: 1, UserID: 7, Key: "k"} + svc := newCreateLimitService(repo, cache, 1, 2) + + _, err := svc.Create(context.Background(), 7, CreateAPIKeyRequest{Name: "k"}) + require.NoError(t, err) + repo.activeCount = 1 + _, err = svc.Create(context.Background(), 7, CreateAPIKeyRequest{Name: "k"}) + require.ErrorIs(t, err, ErrAPIKeyCountExceeded) + + require.NoError(t, svc.Delete(context.Background(), 1, 7)) + repo.activeCount = 0 + _, err = svc.Create(context.Background(), 7, CreateAPIKeyRequest{Name: "k"}) + require.NoError(t, err) + + repo.activeCount = 0 + _, err = svc.Create(context.Background(), 7, CreateAPIKeyRequest{Name: "k"}) + require.ErrorIs(t, err, ErrAPIKeyCreateLimited) + require.Len(t, repo.created, 2) +} + +func TestAPIKeyServiceCreate_ActiveLimitDoesNotConsumeCreateCount(t *testing.T) { + repo, cache := newCreateLimitStubs() + repo.activeCount = 5 + svc := newCreateLimitService(repo, cache, 5, 10) + + _, err := svc.Create(context.Background(), 7, CreateAPIKeyRequest{Name: "k"}) + require.ErrorIs(t, err, ErrAPIKeyCountExceeded) + require.Zero(t, cache.createCounts[7]) +} + +func TestAPIKeyServiceCreate_ZeroLimitsDisableChecks(t *testing.T) { + repo, cache := newCreateLimitStubs() + repo.countErr = errors.New("must not count") + svc := newCreateLimitService(repo, cache, 0, 0) + + for i := 0; i < 5; i++ { + _, err := svc.Create(context.Background(), 7, CreateAPIKeyRequest{Name: "k"}) + require.NoError(t, err) + } + require.Empty(t, cache.windows) +} + +func TestAPIKeyServiceCreate_CountErrorFailsClosed(t *testing.T) { + repo, cache := newCreateLimitStubs() + repo.countErr = errors.New("db down") + svc := newCreateLimitService(repo, cache, 5, 0) + + _, err := svc.Create(context.Background(), 7, CreateAPIKeyRequest{Name: "k"}) + require.Error(t, err) + require.Empty(t, repo.created) +} + +func TestAPIKeyServiceCreate_RedisErrorFailsOpen(t *testing.T) { + repo, cache := newCreateLimitStubs() + cache.incrErr = errors.New("redis down") + svc := newCreateLimitService(repo, cache, 0, 1) + + for i := 0; i < 3; i++ { + _, err := svc.Create(context.Background(), 7, CreateAPIKeyRequest{Name: "k"}) + require.NoError(t, err) + } +} diff --git a/backend/internal/service/api_key_service_delete_test.go b/backend/internal/service/api_key_service_delete_test.go index 1b5d003d28..a32c2c98e2 100644 --- a/backend/internal/service/api_key_service_delete_test.go +++ b/backend/internal/service/api_key_service_delete_test.go @@ -234,11 +234,7 @@ func (s *apiKeyRepoStub) GetRateLimitData(ctx context.Context, id int64) (*APIKe // apiKeyCacheStub 是 APIKeyCache 接口的测试桩实现。 // 用于验证删除操作时缓存清理逻辑是否被正确调用。 -// -// 设计说明: -// - invalidated: 记录被清除缓存的用户 ID 列表 type apiKeyCacheStub struct { - invalidated []int64 // 记录调用 DeleteCreateAttemptCount 时传入的用户 ID deleteAuthKeys []string // 记录调用 DeleteAuthCache 时传入的缓存 key } @@ -252,11 +248,9 @@ func (s *apiKeyCacheStub) IncrementCreateAttemptCount(ctx context.Context, userI return nil } -// DeleteCreateAttemptCount 记录被清除缓存的用户 ID。 -// 删除 API Key 时会调用此方法清除用户的创建尝试计数缓存。 -func (s *apiKeyCacheStub) DeleteCreateAttemptCount(ctx context.Context, userID int64) error { - s.invalidated = append(s.invalidated, userID) - return nil +// IncrementCreateCount 空实现,本测试不验证此行为 +func (s *apiKeyCacheStub) IncrementCreateCount(ctx context.Context, userID int64, window time.Duration) (int64, error) { + return 0, nil } // IncrementDailyUsage 空实现,本测试不验证此行为 @@ -306,8 +300,7 @@ func TestApiKeyService_Delete_OwnerMismatch(t *testing.T) { err := svc.Delete(context.Background(), 10, 2) // API Key ID=10, 调用者 userID=2 require.ErrorIs(t, err, ErrInsufficientPerms) - require.Empty(t, repo.deletedIDs) // 验证删除操作未被调用 - require.Empty(t, cache.invalidated) // 验证缓存未被清除 + require.Empty(t, repo.deletedIDs) // 验证删除操作未被调用 require.Empty(t, cache.deleteAuthKeys) } @@ -316,7 +309,7 @@ func TestApiKeyService_Delete_OwnerMismatch(t *testing.T) { // - GetKeyAndOwnerID 返回所有者 ID 为 7 // - 调用者 userID 为 7(匹配) // - Delete 成功执行 -// - 缓存被正确清除(使用 ownerID) +// - 认证缓存被正确清除 // - 返回 nil 错误 func TestApiKeyService_Delete_Success(t *testing.T) { repo := &apiKeyRepoStub{ @@ -328,8 +321,7 @@ func TestApiKeyService_Delete_Success(t *testing.T) { err := svc.Delete(context.Background(), 42, 7) // API Key ID=42, 调用者 userID=7 require.NoError(t, err) - require.Equal(t, []int64{42}, repo.deletedIDs) // 验证正确的 API Key 被删除 - require.Equal(t, []int64{7}, cache.invalidated) // 验证所有者的缓存被清除 + require.Equal(t, []int64{42}, repo.deletedIDs) // 验证正确的 API Key 被删除 require.Equal(t, []string{svc.authCacheKey("k")}, cache.deleteAuthKeys) _, exists := svc.lastUsedTouchL1.Load(int64(42)) require.False(t, exists, "delete should clear touch debounce cache") @@ -349,7 +341,6 @@ func TestApiKeyService_Delete_NotFound(t *testing.T) { err := svc.Delete(context.Background(), 99, 1) require.ErrorIs(t, err, ErrAPIKeyNotFound) require.Empty(t, repo.deletedIDs) - require.Empty(t, cache.invalidated) require.Empty(t, cache.deleteAuthKeys) } @@ -496,6 +487,5 @@ func TestApiKeyService_Delete_DeleteFails(t *testing.T) { require.Error(t, err) require.ErrorContains(t, err, "delete api key") require.Equal(t, []int64{3}, repo.deletedIDs) // 验证 DeleteWithAudit 被调用 - require.Empty(t, cache.invalidated) // 验证删除失败时缓存未被清除(新顺序:先删后清) require.Empty(t, cache.deleteAuthKeys) // 验证删除失败时 auth 缓存未被清除 } diff --git a/backend/internal/service/api_key_service_quota_test.go b/backend/internal/service/api_key_service_quota_test.go index 3729a1ebea..c31de6e30b 100644 --- a/backend/internal/service/api_key_service_quota_test.go +++ b/backend/internal/service/api_key_service_quota_test.go @@ -42,8 +42,8 @@ func (s *quotaStateCacheStub) IncrementCreateAttemptCount(context.Context, int64 return nil } -func (s *quotaStateCacheStub) DeleteCreateAttemptCount(context.Context, int64) error { - return nil +func (s *quotaStateCacheStub) IncrementCreateCount(context.Context, int64, time.Duration) (int64, error) { + return 0, nil } func (s *quotaStateCacheStub) IncrementDailyUsage(context.Context, string) error { diff --git a/backend/internal/service/auth_cache_invalidation_outbox_test.go b/backend/internal/service/auth_cache_invalidation_outbox_test.go index 2f08723a68..00e0000859 100644 --- a/backend/internal/service/auth_cache_invalidation_outbox_test.go +++ b/backend/internal/service/auth_cache_invalidation_outbox_test.go @@ -67,8 +67,10 @@ func (*authInvalidationCacheStub) GetCreateAttemptCount(context.Context, int64) func (*authInvalidationCacheStub) IncrementCreateAttemptCount(context.Context, int64) error { return nil } -func (*authInvalidationCacheStub) DeleteCreateAttemptCount(context.Context, int64) error { return nil } -func (*authInvalidationCacheStub) IncrementDailyUsage(context.Context, string) error { return nil } +func (*authInvalidationCacheStub) IncrementCreateCount(context.Context, int64, time.Duration) (int64, error) { + return 0, nil +} +func (*authInvalidationCacheStub) IncrementDailyUsage(context.Context, string) error { return nil } func (*authInvalidationCacheStub) SetDailyUsageExpiry(context.Context, string, time.Duration) error { return nil } diff --git a/deploy/config.example.yaml b/deploy/config.example.yaml index e1e1c6df7e..af892329c3 100644 --- a/deploy/config.example.yaml +++ b/deploy/config.example.yaml @@ -717,6 +717,19 @@ api_key_auth_cache: # 每进程最多跟踪的客户端身份数量,确保内存有界。 capacity: 16384 +# ============================================================================= +# API Key Creation Limits +# 用户创建 API Key 的防滥用限制(0 表示不限制) +# ============================================================================= +api_key_create: + # Maximum non-deleted API keys per user + # 单个用户同时存在(未删除)的 API Key 上限 + max_active_per_user: 200 + # Maximum API key creations per user per hour (custom and generated keys). + # Deleting a key does not refund the count, which blocks delete/recreate loops. + # 单个用户每小时可创建的 API Key 次数(自定义与自动生成均计入);删除 Key 不返还次数,阻断删建循环 + max_per_user_per_hour: 20 + # ============================================================================= # Dashboard Cache Configuration # 仪表盘缓存配置