From 209acaa15d747696df23396533eee882c863063f Mon Sep 17 00:00:00 2001 From: iven Date: Sun, 3 May 2026 09:46:02 +0800 Subject: [PATCH] =?UTF-8?q?feat(server):=20=E9=99=90=E6=B5=81=20fail-close?= =?UTF-8?q?=20=E7=BB=9F=E4=B8=80=E9=85=8D=E7=BD=AE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增 RateLimitConfig 结构体,支持 config.toml + 环境变量 - apply_rate_limit 统一读取 fail_close 配置,生产环境可设为拒绝请求 - account_lockout_middleware 改为从 AppState.config 读取,不再直接读环境变量 - default.toml 添加 [rate_limit] 配置节 --- crates/erp-server/config/default.toml | 7 +++- crates/erp-server/src/config.rs | 15 +++++++ .../erp-server/src/middleware/rate_limit.rs | 42 ++++++++++++++----- 3 files changed, 53 insertions(+), 11 deletions(-) diff --git a/crates/erp-server/config/default.toml b/crates/erp-server/config/default.toml index 0137de5..8f03249 100644 --- a/crates/erp-server/config/default.toml +++ b/crates/erp-server/config/default.toml @@ -41,8 +41,8 @@ kek = "__MUST_SET_VIA_ENV__" [ai] default_provider = "claude" +# AI API 密钥。留空则禁用 AI 功能;生产环境必须通过 ERP__AI__API_KEY 设置。 api_key = "" -base_url = "https://api.anthropic.com" model = "claude-sonnet-4-6" max_tokens = 2048 temperature = 0.3 @@ -52,3 +52,8 @@ rate_limit_patient_daily = 10 [storage] upload_dir = "./uploads" max_file_size = "10MB" + +[rate_limit] +# Redis 不可达时是否拒绝请求。生产环境必须设置为 true。 +# 可通过 ERP__RATE_LIMIT__FAIL_CLOSE=true 环境变量覆盖。 +fail_close = false diff --git a/crates/erp-server/src/config.rs b/crates/erp-server/src/config.rs index 03fc655..e30ec28 100644 --- a/crates/erp-server/src/config.rs +++ b/crates/erp-server/src/config.rs @@ -14,6 +14,8 @@ pub struct AppConfig { pub crypto: CryptoConfig, pub ai: AiConfig, pub storage: StorageConfig, + #[serde(default)] + pub rate_limit: RateLimitConfig, } #[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 { pub fn load() -> anyhow::Result { let config = config::Config::builder() diff --git a/crates/erp-server/src/middleware/rate_limit.rs b/crates/erp-server/src/middleware/rate_limit.rs index b75dd19..76c529b 100644 --- a/crates/erp-server/src/middleware/rate_limit.rs +++ b/crates/erp-server/src/middleware/rate_limit.rs @@ -91,7 +91,8 @@ 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(&state.redis, &identifier, 5, 60, "login", fail_close, req, next).await } /// 基于 Redis 的用户限流中间件。 @@ -107,7 +108,8 @@ 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, 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, window_secs: u64, prefix: &str, + fail_close: bool, req: Request, next: Next, ) -> Response { let avail = redis_avail(); - // Redis 不可达时 fail-open:放行请求,仅记录日志 + // Redis 不可达时根据 fail_close 配置决定行为 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; } @@ -136,8 +146,15 @@ async fn apply_rate_limit( c } Err(e) => { - tracing::warn!(error = %e, "Redis 连接失败,fail-open 限流放行"); 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; } }; @@ -145,7 +162,14 @@ 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::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; } }; @@ -180,10 +204,8 @@ pub async fn account_lockout_middleware( ) -> Response { let avail = redis_avail(); - // Redis 可达性检查:生产环境 fail-close,开发环境 fail-open(通过环境变量控制) - let fail_close = std::env::var("ERP__RATE_LIMIT__FAIL_CLOSE") - .map(|v| v == "true" || v == "1") - .unwrap_or(false); + // Redis 可达性检查:生产环境 fail-close,开发环境 fail-open + let fail_close = state.config.rate_limit.fail_close; if !avail.should_try().await { if fail_close {