From 0f67f1c21ffbdec09dd39c27dfdf99772be8a5e2 Mon Sep 17 00:00:00 2001 From: iven Date: Mon, 11 May 2026 10:22:05 +0800 Subject: [PATCH] =?UTF-8?q?fix(server):=20=E9=99=90=E6=B5=81=E4=B8=AD?= =?UTF-8?q?=E9=97=B4=E4=BB=B6=20fail-close=20=E5=AE=89=E5=85=A8=E5=8A=A0?= =?UTF-8?q?=E5=9B=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit RateLimitConfig 添加 fail_close 字段(默认 true),Redis 不可达时 拒绝请求返回 503 而非静默放行。开发环境可通过 ERP__RATE_LIMIT__FAIL_CLOSE=false 回退旧行为。 --- crates/erp-server/config/default.toml | 2 +- crates/erp-server/src/config.rs | 22 +++++- .../erp-server/src/middleware/rate_limit.rs | 79 ++++++++++++++++--- 3 files changed, 87 insertions(+), 16 deletions(-) diff --git a/crates/erp-server/config/default.toml b/crates/erp-server/config/default.toml index 972caf7..a6873f9 100644 --- a/crates/erp-server/config/default.toml +++ b/crates/erp-server/config/default.toml @@ -66,4 +66,4 @@ secret_key = "dev-only-secret-key-change-in-production" [rate_limit] # Redis 不可达时是否拒绝请求(fail-close)。默认 true = 安全优先。 # 开发环境可设为 false 以避免 Redis 依赖:ERP__RATE_LIMIT__FAIL_CLOSE=false -fail_close = false +fail_close = true diff --git a/crates/erp-server/src/config.rs b/crates/erp-server/src/config.rs index 6b39d39..49e54b5 100644 --- a/crates/erp-server/src/config.rs +++ b/crates/erp-server/src/config.rs @@ -159,8 +159,26 @@ impl StorageConfig { } } -#[derive(Debug, Clone, Deserialize, Default)] -pub struct RateLimitConfig {} +#[derive(Debug, Clone, Deserialize)] +pub struct RateLimitConfig { + /// Redis 不可达时是否拒绝请求(fail-close)。 + /// true = 安全优先,Redis 故障时返回 503。 + /// false = 可用性优先,Redis 故障时放行。 + #[serde(default = "default_fail_close")] + pub fail_close: bool, +} + +fn default_fail_close() -> bool { + true +} + +impl Default for RateLimitConfig { + fn default() -> Self { + Self { + fail_close: default_fail_close(), + } + } +} impl AppConfig { pub fn load() -> anyhow::Result { diff --git a/crates/erp-server/src/middleware/rate_limit.rs b/crates/erp-server/src/middleware/rate_limit.rs index 695c62e..1acb2b4 100644 --- a/crates/erp-server/src/middleware/rate_limit.rs +++ b/crates/erp-server/src/middleware/rate_limit.rs @@ -29,7 +29,20 @@ pub async fn rate_limit_by_ip( next: Next, ) -> Response { 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( + RateLimitParams { + redis_client: &state.redis, + fail_close, + max_requests: 5, + window_secs: 60, + prefix: "login", + }, + &identifier, + req, + next, + ) + .await } /// 基于 Redis 的用户限流中间件。 @@ -45,25 +58,57 @@ pub async fn rate_limit_by_user( .get::() .map(|ctx| ctx.user_id.to_string()) .unwrap_or_else(|| "anonymous".to_string()); - apply_rate_limit(&state.redis, &identifier, 300, 60, "api", req, next).await + let fail_close = state.config.rate_limit.fail_close; + apply_rate_limit( + RateLimitParams { + redis_client: &state.redis, + fail_close, + max_requests: 300, + window_secs: 60, + prefix: "api", + }, + &identifier, + req, + next, + ) + .await +} + +/// Redis 不可达时的安全响应(fail-close 模式)。 +fn service_unavailable(prefix: &str) -> Response { + let body = RateLimitResponse { + error: "Service Unavailable".to_string(), + message: "服务暂时不可用,请稍后重试".to_string(), + }; + tracing::error!("Redis 不可达,fail-close 模式拒绝请求 [{}]", prefix); + (StatusCode::SERVICE_UNAVAILABLE, axum::Json(body)).into_response() +} + +/// 限流参数,打包以避免函数签名过长。 +struct RateLimitParams<'a> { + redis_client: &'a redis::Client, + fail_close: bool, + max_requests: u64, + window_secs: u64, + prefix: &'a str, } /// 执行限流检查。 async fn apply_rate_limit( - redis_client: &redis::Client, + params: RateLimitParams<'_>, identifier: &str, - max_requests: u64, - window_secs: u64, - prefix: &str, req: Request, next: Next, ) -> Response { - let key = format!("rate_limit:{}:{}", prefix, identifier); + let key = format!("rate_limit:{}:{}", params.prefix, identifier); - let mut conn = match redis_client.get_multiplexed_async_connection().await { + let mut conn = match params.redis_client.get_multiplexed_async_connection().await { Ok(c) => c, Err(e) => { - tracing::error!(error = %e, "Redis 连接失败,限流放行 [{}]", prefix); + tracing::error!(error = %e, "Redis 连接失败 [{}]", params.prefix); + if params.fail_close { + return service_unavailable(params.prefix); + } return next.run(req).await; } }; @@ -71,17 +116,20 @@ async fn apply_rate_limit( let count: i64 = match redis::cmd("INCR").arg(&key).query_async(&mut conn).await { Ok(n) => n, Err(e) => { - tracing::error!(error = %e, "Redis INCR 失败,限流放行 [{}]", prefix); + tracing::error!(error = %e, "Redis INCR 失败 [{}]", params.prefix); + if params.fail_close { + return service_unavailable(params.prefix); + } return next.run(req).await; } }; // 首次请求设置 TTL if count == 1 { - let _: Result<(), _> = conn.expire(&key, window_secs as i64).await; + let _: Result<(), _> = conn.expire(&key, params.window_secs as i64).await; } - if count > max_requests as i64 { + if count > params.max_requests as i64 { let body = RateLimitResponse { error: "Too Many Requests".to_string(), message: "请求过于频繁,请稍后重试".to_string(), @@ -104,11 +152,16 @@ pub async fn account_lockout_middleware( req: Request, next: Next, ) -> Response { + let fail_close = state.config.rate_limit.fail_close; + // 获取 Redis 连接 let mut conn = match state.redis.get_multiplexed_async_connection().await { Ok(c) => c, Err(e) => { - tracing::error!(error = %e, "Redis 连接失败,登录锁定放行"); + tracing::error!(error = %e, "Redis 连接失败"); + if fail_close { + return service_unavailable("login_lockout"); + } return next.run(req).await; } };