diff --git a/docs/09-deployment.md b/docs/09-deployment.md index 332a79bc1..2bbf6c8c8 100644 --- a/docs/09-deployment.md +++ b/docs/09-deployment.md @@ -211,6 +211,36 @@ Sentinel 配置优先于 Cluster 和单机 `host`/`port`。在 Kubernetes 等 Se ## 7 配置管理 +### 7.1 请求限流配置 + +限流默认开启。未配置分类覆盖时,各接口使用代码中 `@RateLimit` 声明的默认值,现有部署无需调整。 + +可通过环境变量关闭全部限流,或按分类覆盖额度和时间窗口: + +```bash +SKILLHUB_RATELIMIT_ENABLED=false +SKILLHUB_RATELIMIT_CATEGORIES_SEARCH_ANONYMOUS=100 +SKILLHUB_RATELIMIT_CATEGORIES_SEARCH_AUTHENTICATED=300 +SKILLHUB_RATELIMIT_CATEGORIES_SEARCH_WINDOW_SECONDS=60 +``` + +支持的配置字段为 `authenticated`、`anonymous` 和 `window-seconds`。分类名称来自接口的 +`@RateLimit(category = "...")`,例如 `search`、`download`、`publish` 和 `resolve`。只设置其中一个字段时, +其他字段仍回退到接口默认值。 + +Docker Compose 用户需要显式传入变量,宿主机环境变量不会自动注入容器: + +```yaml +services: + server: + environment: + SKILLHUB_RATELIMIT_CATEGORIES_SEARCH_ANONYMOUS: "100" + SKILLHUB_RATELIMIT_CATEGORIES_SEARCH_AUTHENTICATED: "300" + SKILLHUB_RATELIMIT_CATEGORIES_SEARCH_WINDOW_SECONDS: "60" +``` + +修改后重启 server 容器生效。超过额度时接口返回 HTTP `429`;该配置只调整阈值,不改变 Redis 限流算法或响应格式。 + 前端运行时配置通过 `web/runtime-config.js.template` 注入。与认证兼容层相关的新变量如下: - `SKILLHUB_WEB_AUTH_DIRECT_ENABLED` diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/config/RateLimitProperties.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/config/RateLimitProperties.java new file mode 100644 index 000000000..5d0895ae6 --- /dev/null +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/config/RateLimitProperties.java @@ -0,0 +1,93 @@ +package com.iflytek.skillhub.config; + +import java.util.HashMap; +import java.util.Map; +import org.springframework.boot.context.properties.ConfigurationProperties; +import org.springframework.stereotype.Component; + +/** + * Runtime-configurable overrides for request rate limiting. + * + *

The compile-time {@link com.iflytek.skillhub.ratelimit.RateLimit} annotation on each endpoint + * supplies the built-in defaults. Values set here — typically via {@code SKILLHUB_RATELIMIT_*} + * environment variables — override those defaults per {@code category}, and {@code enabled=false} + * turns request rate limiting off entirely. When nothing is configured the annotation defaults are + * used unchanged, so existing deployments behave exactly as before. + * + *

An override applies to every endpoint that shares the same {@code category}. Only the fields + * you set are overridden; the rest fall back to the annotation. + */ +@Component +@ConfigurationProperties(prefix = "skillhub.ratelimit") +public class RateLimitProperties { + + /** Master switch. When {@code false} the interceptor performs no quota checks. */ + private boolean enabled = true; + + /** Per-category overrides keyed by {@code RateLimit#category} (e.g. "search", "download", "publish"). */ + private Map categories = new HashMap<>(); + + public boolean isEnabled() { + return enabled; + } + + public void setEnabled(boolean enabled) { + this.enabled = enabled; + } + + public Map getCategories() { + return categories; + } + + public void setCategories(Map categories) { + this.categories = categories; + } + + /** Configured authenticated quota for {@code category}, or {@code fallback} when unset. */ + public int authenticatedFor(String category, int fallback) { + CategoryLimit c = categories.get(category); + return c != null && c.getAuthenticated() != null ? c.getAuthenticated() : fallback; + } + + /** Configured anonymous quota for {@code category}, or {@code fallback} when unset. */ + public int anonymousFor(String category, int fallback) { + CategoryLimit c = categories.get(category); + return c != null && c.getAnonymous() != null ? c.getAnonymous() : fallback; + } + + /** Configured window (seconds) for {@code category}, or {@code fallback} when unset. */ + public int windowSecondsFor(String category, int fallback) { + CategoryLimit c = categories.get(category); + return c != null && c.getWindowSeconds() != null ? c.getWindowSeconds() : fallback; + } + + public static class CategoryLimit { + private Integer authenticated; + private Integer anonymous; + private Integer windowSeconds; + + public Integer getAuthenticated() { + return authenticated; + } + + public void setAuthenticated(Integer authenticated) { + this.authenticated = authenticated; + } + + public Integer getAnonymous() { + return anonymous; + } + + public void setAnonymous(Integer anonymous) { + this.anonymous = anonymous; + } + + public Integer getWindowSeconds() { + return windowSeconds; + } + + public void setWindowSeconds(Integer windowSeconds) { + this.windowSeconds = windowSeconds; + } + } +} diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/ratelimit/RateLimitInterceptor.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/ratelimit/RateLimitInterceptor.java index da421b5dd..6da411c48 100644 --- a/server/skillhub-app/src/main/java/com/iflytek/skillhub/ratelimit/RateLimitInterceptor.java +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/ratelimit/RateLimitInterceptor.java @@ -1,6 +1,7 @@ package com.iflytek.skillhub.ratelimit; import com.fasterxml.jackson.databind.ObjectMapper; +import com.iflytek.skillhub.config.RateLimitProperties; import com.iflytek.skillhub.dto.ApiResponse; import com.iflytek.skillhub.dto.ApiResponseFactory; import com.iflytek.skillhub.metrics.SkillHubMetrics; @@ -28,19 +29,22 @@ public class RateLimitInterceptor implements HandlerInterceptor { private final ApiResponseFactory apiResponseFactory; private final ObjectMapper objectMapper; private final SkillHubMetrics metrics; + private final RateLimitProperties properties; public RateLimitInterceptor(RateLimiter rateLimiter, ClientIpResolver clientIpResolver, AnonymousDownloadIdentityService anonymousDownloadIdentityService, ApiResponseFactory apiResponseFactory, ObjectMapper objectMapper, - SkillHubMetrics metrics) { + SkillHubMetrics metrics, + RateLimitProperties properties) { this.rateLimiter = rateLimiter; this.clientIpResolver = clientIpResolver; this.anonymousDownloadIdentityService = anonymousDownloadIdentityService; this.apiResponseFactory = apiResponseFactory; this.objectMapper = objectMapper; this.metrics = metrics; + this.properties = properties; } @Override @@ -56,23 +60,33 @@ public boolean preHandle(HttpServletRequest request, HttpServletResponse respons return true; } + // Master switch: when disabled, perform no quota checks at all. + if (!properties.isEnabled()) { + return true; + } + // Determine if user is authenticated String userId = (String) request.getAttribute("userId"); boolean isAuthenticated = userId != null; - // Get limit based on authentication status - int limit = isAuthenticated ? rateLimit.authenticated() : rateLimit.anonymous(); - String resourceSuffix = resolveResourceSuffix(rateLimit.category(), request); + // Effective limits: runtime config overrides (per category) fall back to the + // annotation defaults, so unconfigured deployments behave exactly as before. + String category = rateLimit.category(); + int windowSeconds = properties.windowSecondsFor(category, rateLimit.windowSeconds()); + int limit = isAuthenticated + ? properties.authenticatedFor(category, rateLimit.authenticated()) + : properties.anonymousFor(category, rateLimit.anonymous()); + String resourceSuffix = resolveResourceSuffix(category, request); boolean allowed = isAuthenticated ? rateLimiter.tryAcquire( - "ratelimit:" + rateLimit.category() + ":user:" + userId + resourceSuffix, + "ratelimit:" + category + ":user:" + userId + resourceSuffix, limit, - rateLimit.windowSeconds()) - : checkAnonymousLimit(request, response, rateLimit, limit, resourceSuffix); + windowSeconds) + : checkAnonymousLimit(request, response, category, limit, windowSeconds, resourceSuffix); if (!allowed) { - metrics.incrementRateLimitExceeded(rateLimit.category()); + metrics.incrementRateLimitExceeded(category); response.setStatus(HttpStatus.TOO_MANY_REQUESTS.value()); response.setContentType(MediaType.APPLICATION_JSON_VALUE); ApiResponse body = apiResponseFactory.error(429, "error.rateLimit.exceeded"); @@ -85,14 +99,15 @@ public boolean preHandle(HttpServletRequest request, HttpServletResponse respons private boolean checkAnonymousLimit(HttpServletRequest request, HttpServletResponse response, - RateLimit rateLimit, + String category, int limit, + int windowSeconds, String resourceSuffix) { - if (!"download".equals(rateLimit.category())) { + if (!"download".equals(category)) { return rateLimiter.tryAcquire( - "ratelimit:" + rateLimit.category() + ":ip:" + clientIpResolver.resolve(request) + resourceSuffix, + "ratelimit:" + category + ":ip:" + clientIpResolver.resolve(request) + resourceSuffix, limit, - rateLimit.windowSeconds() + windowSeconds ); } @@ -101,7 +116,7 @@ private boolean checkAnonymousLimit(HttpServletRequest request, boolean ipAllowed = rateLimiter.tryAcquire( "ratelimit:download:ip:" + identity.ipHash() + resourceSuffix, limit, - rateLimit.windowSeconds() + windowSeconds ); if (!ipAllowed) { return false; @@ -109,7 +124,7 @@ private boolean checkAnonymousLimit(HttpServletRequest request, return rateLimiter.tryAcquire( "ratelimit:download:anon:" + identity.cookieHash() + resourceSuffix, limit, - rateLimit.windowSeconds() + windowSeconds ); } diff --git a/server/skillhub-app/src/main/resources/application.yml b/server/skillhub-app/src/main/resources/application.yml index b290fc77f..2039d8449 100644 --- a/server/skillhub-app/src/main/resources/application.yml +++ b/server/skillhub-app/src/main/resources/application.yml @@ -152,6 +152,26 @@ skillhub: candidate-multiplier: 8 max-candidates: 120 ratelimit: + # Master switch for per-endpoint request rate limiting. Set false (or + # SKILLHUB_RATELIMIT_ENABLED=false) to turn quota checks off entirely. + enabled: ${SKILLHUB_RATELIMIT_ENABLED:true} + # Per-category threshold overrides. Unset categories use the built-in + # @RateLimit annotation defaults, so this block is optional and changes + # nothing until you set a value. Each override applies to every endpoint + # sharing that category. Override via env vars, e.g.: + # SKILLHUB_RATELIMIT_CATEGORIES_SEARCH_AUTHENTICATED=120 + # SKILLHUB_RATELIMIT_CATEGORIES_SEARCH_ANONYMOUS=40 + # SKILLHUB_RATELIMIT_CATEGORIES_PUBLISH_WINDOW_SECONDS=3600 + # categories: + # search: + # authenticated: 60 + # anonymous: 20 + # download: + # authenticated: 120 + # anonymous: 30 + # publish: + # authenticated: 10 + # window-seconds: 60 download: anonymous-cookie-name: ${SKILLHUB_DOWNLOAD_ANON_COOKIE_NAME:skillhub_anon_dl} anonymous-cookie-max-age: ${SKILLHUB_DOWNLOAD_ANON_COOKIE_MAX_AGE:P30D} diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/config/RateLimitPropertiesTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/config/RateLimitPropertiesTest.java new file mode 100644 index 000000000..5564334e2 --- /dev/null +++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/config/RateLimitPropertiesTest.java @@ -0,0 +1,77 @@ +package com.iflytek.skillhub.config; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.util.Map; +import org.junit.jupiter.api.Test; +import org.springframework.boot.context.properties.EnableConfigurationProperties; +import org.springframework.boot.test.context.runner.ApplicationContextRunner; +import org.springframework.context.annotation.Configuration; +import org.springframework.core.env.SystemEnvironmentPropertySource; + +class RateLimitPropertiesTest { + + private final ApplicationContextRunner contextRunner = new ApplicationContextRunner() + .withUserConfiguration(TestConfiguration.class); + + @Test + void enabledByDefaultAndNoOverrides() { + RateLimitProperties properties = new RateLimitProperties(); + + assertThat(properties.isEnabled()).isTrue(); + assertThat(properties.getCategories()).isEmpty(); + } + + @Test + void fallsBackToAnnotationDefaultsWhenCategoryUnset() { + RateLimitProperties properties = new RateLimitProperties(); + + assertThat(properties.authenticatedFor("search", 60)).isEqualTo(60); + assertThat(properties.anonymousFor("search", 20)).isEqualTo(20); + assertThat(properties.windowSecondsFor("search", 60)).isEqualTo(60); + } + + @Test + void overridesOnlyTheFieldsThatAreSet() { + RateLimitProperties.CategoryLimit search = new RateLimitProperties.CategoryLimit(); + search.setAuthenticated(120); + // anonymous and windowSeconds intentionally left null + + RateLimitProperties properties = new RateLimitProperties(); + properties.getCategories().put("search", search); + + assertThat(properties.authenticatedFor("search", 60)).isEqualTo(120); + assertThat(properties.anonymousFor("search", 20)).isEqualTo(20); + assertThat(properties.windowSecondsFor("search", 60)).isEqualTo(60); + } + + @Test + void overrideAppliesPerCategoryOnly() { + RateLimitProperties.CategoryLimit publish = new RateLimitProperties.CategoryLimit(); + publish.setAuthenticated(5); + publish.setWindowSeconds(3600); + + RateLimitProperties properties = new RateLimitProperties(); + properties.getCategories().put("publish", publish); + + assertThat(properties.authenticatedFor("publish", 10)).isEqualTo(5); + assertThat(properties.windowSecondsFor("publish", 60)).isEqualTo(3600); + // A different category is unaffected. + assertThat(properties.authenticatedFor("download", 120)).isEqualTo(120); + } + + @Test + void bindsDocumentedCategoryOverrideFromEnvironmentVariable() { + // The *-systemEnvironment suffix activates Spring Boot's environment-variable name adaptation. + contextRunner.withInitializer(context -> context.getEnvironment().getPropertySources().addFirst( + new SystemEnvironmentPropertySource("test-systemEnvironment", Map.of( + "SKILLHUB_RATELIMIT_CATEGORIES_SEARCH_AUTHENTICATED", "120")))) + .run(context -> assertThat(context.getBean(RateLimitProperties.class) + .authenticatedFor("search", 60)).isEqualTo(120)); + } + + @Configuration(proxyBeanMethods = false) + @EnableConfigurationProperties(RateLimitProperties.class) + static class TestConfiguration { + } +}