feat(server): 限流 fail-close 统一配置
- 新增 RateLimitConfig 结构体,支持 config.toml + 环境变量 - apply_rate_limit 统一读取 fail_close 配置,生产环境可设为拒绝请求 - account_lockout_middleware 改为从 AppState.config 读取,不再直接读环境变量 - default.toml 添加 [rate_limit] 配置节
This commit is contained in:
@@ -41,8 +41,8 @@ kek = "__MUST_SET_VIA_ENV__"
|
|||||||
|
|
||||||
[ai]
|
[ai]
|
||||||
default_provider = "claude"
|
default_provider = "claude"
|
||||||
|
# AI API 密钥。留空则禁用 AI 功能;生产环境必须通过 ERP__AI__API_KEY 设置。
|
||||||
api_key = ""
|
api_key = ""
|
||||||
base_url = "https://api.anthropic.com"
|
|
||||||
model = "claude-sonnet-4-6"
|
model = "claude-sonnet-4-6"
|
||||||
max_tokens = 2048
|
max_tokens = 2048
|
||||||
temperature = 0.3
|
temperature = 0.3
|
||||||
@@ -52,3 +52,8 @@ rate_limit_patient_daily = 10
|
|||||||
[storage]
|
[storage]
|
||||||
upload_dir = "./uploads"
|
upload_dir = "./uploads"
|
||||||
max_file_size = "10MB"
|
max_file_size = "10MB"
|
||||||
|
|
||||||
|
[rate_limit]
|
||||||
|
# Redis 不可达时是否拒绝请求。生产环境必须设置为 true。
|
||||||
|
# 可通过 ERP__RATE_LIMIT__FAIL_CLOSE=true 环境变量覆盖。
|
||||||
|
fail_close = false
|
||||||
|
|||||||
@@ -14,6 +14,8 @@ pub struct AppConfig {
|
|||||||
pub crypto: CryptoConfig,
|
pub crypto: CryptoConfig,
|
||||||
pub ai: AiConfig,
|
pub ai: AiConfig,
|
||||||
pub storage: StorageConfig,
|
pub storage: StorageConfig,
|
||||||
|
#[serde(default)]
|
||||||
|
pub rate_limit: RateLimitConfig,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Deserialize)]
|
#[derive(Debug, Clone, Deserialize)]
|
||||||
@@ -123,6 +125,19 @@ impl StorageConfig {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Deserialize)]
|
||||||
|
pub struct RateLimitConfig {
|
||||||
|
/// Redis 不可达时是否拒绝请求(生产环境必须为 true)。
|
||||||
|
#[serde(default)]
|
||||||
|
pub fail_close: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for RateLimitConfig {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self { fail_close: false }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
impl AppConfig {
|
impl AppConfig {
|
||||||
pub fn load() -> anyhow::Result<Self> {
|
pub fn load() -> anyhow::Result<Self> {
|
||||||
let config = config::Config::builder()
|
let config = config::Config::builder()
|
||||||
|
|||||||
@@ -91,7 +91,8 @@ pub async fn rate_limit_by_ip(
|
|||||||
next: Next,
|
next: Next,
|
||||||
) -> Response {
|
) -> Response {
|
||||||
let identifier = extract_client_ip(req.headers());
|
let identifier = extract_client_ip(req.headers());
|
||||||
apply_rate_limit(&state.redis, &identifier, 5, 60, "login", req, next).await
|
let fail_close = state.config.rate_limit.fail_close;
|
||||||
|
apply_rate_limit(&state.redis, &identifier, 5, 60, "login", fail_close, req, next).await
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 基于 Redis 的用户限流中间件。
|
/// 基于 Redis 的用户限流中间件。
|
||||||
@@ -107,7 +108,8 @@ pub async fn rate_limit_by_user(
|
|||||||
.get::<erp_core::types::TenantContext>()
|
.get::<erp_core::types::TenantContext>()
|
||||||
.map(|ctx| ctx.user_id.to_string())
|
.map(|ctx| ctx.user_id.to_string())
|
||||||
.unwrap_or_else(|| "anonymous".to_string());
|
.unwrap_or_else(|| "anonymous".to_string());
|
||||||
apply_rate_limit(&state.redis, &identifier, 100, 60, "write", req, next).await
|
let fail_close = state.config.rate_limit.fail_close;
|
||||||
|
apply_rate_limit(&state.redis, &identifier, 100, 60, "write", fail_close, req, next).await
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 执行限流检查。
|
/// 执行限流检查。
|
||||||
@@ -117,14 +119,22 @@ async fn apply_rate_limit(
|
|||||||
max_requests: u64,
|
max_requests: u64,
|
||||||
window_secs: u64,
|
window_secs: u64,
|
||||||
prefix: &str,
|
prefix: &str,
|
||||||
|
fail_close: bool,
|
||||||
req: Request<Body>,
|
req: Request<Body>,
|
||||||
next: Next,
|
next: Next,
|
||||||
) -> Response {
|
) -> Response {
|
||||||
let avail = redis_avail();
|
let avail = redis_avail();
|
||||||
|
|
||||||
// Redis 不可达时 fail-open:放行请求,仅记录日志
|
// Redis 不可达时根据 fail_close 配置决定行为
|
||||||
if !avail.should_try().await {
|
if !avail.should_try().await {
|
||||||
tracing::warn!("Redis 不可达,fail-open 限流放行");
|
if fail_close {
|
||||||
|
tracing::error!("Redis 不可达,fail-close 拒绝请求 [{}]", prefix);
|
||||||
|
return (StatusCode::SERVICE_UNAVAILABLE, axum::Json(RateLimitResponse {
|
||||||
|
error: "service_unavailable".to_string(),
|
||||||
|
message: "安全服务暂不可用,请稍后重试".to_string(),
|
||||||
|
})).into_response();
|
||||||
|
}
|
||||||
|
tracing::warn!("Redis 不可达,fail-open 限流放行 [{}]", prefix);
|
||||||
return next.run(req).await;
|
return next.run(req).await;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -136,8 +146,15 @@ async fn apply_rate_limit(
|
|||||||
c
|
c
|
||||||
}
|
}
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
tracing::warn!(error = %e, "Redis 连接失败,fail-open 限流放行");
|
|
||||||
avail.mark_failed().await;
|
avail.mark_failed().await;
|
||||||
|
if fail_close {
|
||||||
|
tracing::error!(error = %e, "Redis 连接失败,fail-close 拒绝请求 [{}]", prefix);
|
||||||
|
return (StatusCode::SERVICE_UNAVAILABLE, axum::Json(RateLimitResponse {
|
||||||
|
error: "service_unavailable".to_string(),
|
||||||
|
message: "安全服务暂不可用,请稍后重试".to_string(),
|
||||||
|
})).into_response();
|
||||||
|
}
|
||||||
|
tracing::warn!(error = %e, "Redis 连接失败,fail-open 限流放行 [{}]", prefix);
|
||||||
return next.run(req).await;
|
return next.run(req).await;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -145,7 +162,14 @@ async fn apply_rate_limit(
|
|||||||
let count: i64 = match redis::cmd("INCR").arg(&key).query_async(&mut conn).await {
|
let count: i64 = match redis::cmd("INCR").arg(&key).query_async(&mut conn).await {
|
||||||
Ok(n) => n,
|
Ok(n) => n,
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
tracing::warn!(error = %e, "Redis INCR 失败,fail-open 限流放行");
|
if fail_close {
|
||||||
|
tracing::error!(error = %e, "Redis INCR 失败,fail-close 拒绝请求 [{}]", prefix);
|
||||||
|
return (StatusCode::SERVICE_UNAVAILABLE, axum::Json(RateLimitResponse {
|
||||||
|
error: "service_unavailable".to_string(),
|
||||||
|
message: "安全服务暂不可用,请稍后重试".to_string(),
|
||||||
|
})).into_response();
|
||||||
|
}
|
||||||
|
tracing::warn!(error = %e, "Redis INCR 失败,fail-open 限流放行 [{}]", prefix);
|
||||||
return next.run(req).await;
|
return next.run(req).await;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -180,10 +204,8 @@ pub async fn account_lockout_middleware(
|
|||||||
) -> Response {
|
) -> Response {
|
||||||
let avail = redis_avail();
|
let avail = redis_avail();
|
||||||
|
|
||||||
// Redis 可达性检查:生产环境 fail-close,开发环境 fail-open(通过环境变量控制)
|
// Redis 可达性检查:生产环境 fail-close,开发环境 fail-open
|
||||||
let fail_close = std::env::var("ERP__RATE_LIMIT__FAIL_CLOSE")
|
let fail_close = state.config.rate_limit.fail_close;
|
||||||
.map(|v| v == "true" || v == "1")
|
|
||||||
.unwrap_or(false);
|
|
||||||
|
|
||||||
if !avail.should_try().await {
|
if !avail.should_try().await {
|
||||||
if fail_close {
|
if fail_close {
|
||||||
|
|||||||
Reference in New Issue
Block a user