mirror of
https://github.com/iflytek/skillhub.git
synced 2026-09-07 08:26:00 +00:00
feat(ratelimit): make thresholds runtime-configurable
Validated locally and in CI, including runtime configuration documentation.
This commit is contained in:
parent
7e37935da8
commit
7fc1df5043
5 changed files with 249 additions and 14 deletions
|
|
@ -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`
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
*
|
||||
* <p>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.
|
||||
*
|
||||
* <p>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<String, CategoryLimit> categories = new HashMap<>();
|
||||
|
||||
public boolean isEnabled() {
|
||||
return enabled;
|
||||
}
|
||||
|
||||
public void setEnabled(boolean enabled) {
|
||||
this.enabled = enabled;
|
||||
}
|
||||
|
||||
public Map<String, CategoryLimit> getCategories() {
|
||||
return categories;
|
||||
}
|
||||
|
||||
public void setCategories(Map<String, CategoryLimit> 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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 class RateLimitInterceptor implements HandlerInterceptor {
|
|||
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<Void> body = apiResponseFactory.error(429, "error.rateLimit.exceeded");
|
||||
|
|
@ -85,14 +99,15 @@ public class RateLimitInterceptor implements HandlerInterceptor {
|
|||
|
||||
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 @@ public class RateLimitInterceptor implements HandlerInterceptor {
|
|||
boolean ipAllowed = rateLimiter.tryAcquire(
|
||||
"ratelimit:download:ip:" + identity.ipHash() + resourceSuffix,
|
||||
limit,
|
||||
rateLimit.windowSeconds()
|
||||
windowSeconds
|
||||
);
|
||||
if (!ipAllowed) {
|
||||
return false;
|
||||
|
|
@ -109,7 +124,7 @@ public class RateLimitInterceptor implements HandlerInterceptor {
|
|||
return rateLimiter.tryAcquire(
|
||||
"ratelimit:download:anon:" + identity.cookieHash() + resourceSuffix,
|
||||
limit,
|
||||
rateLimit.windowSeconds()
|
||||
windowSeconds
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue