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 {
+ }
+}