Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 30 additions & 0 deletions docs/09-deployment.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`
Expand Down
Original file line number Diff line number Diff line change
@@ -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;
}
}
}
Original file line number Diff line number Diff line change
@@ -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;
Expand Down Expand Up @@ -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
Expand All @@ -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<Void> body = apiResponseFactory.error(429, "error.rateLimit.exceeded");
Expand All @@ -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
);
}

Expand All @@ -101,15 +116,15 @@ private boolean checkAnonymousLimit(HttpServletRequest request,
boolean ipAllowed = rateLimiter.tryAcquire(
"ratelimit:download:ip:" + identity.ipHash() + resourceSuffix,
limit,
rateLimit.windowSeconds()
windowSeconds
);
if (!ipAllowed) {
return false;
}
return rateLimiter.tryAcquire(
"ratelimit:download:anon:" + identity.cookieHash() + resourceSuffix,
limit,
rateLimit.windowSeconds()
windowSeconds
);
}

Expand Down
20 changes: 20 additions & 0 deletions server/skillhub-app/src/main/resources/application.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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}
Expand Down
Original file line number Diff line number Diff line change
@@ -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