From 01a0fffc43c90f92566367467ff3d738b1ed01ac Mon Sep 17 00:00:00 2001 From: iven Date: Fri, 5 Jun 2026 16:33:42 +0800 Subject: [PATCH] =?UTF-8?q?fix(auth):=20=E5=BE=AE=E4=BF=A1=E7=99=BB?= =?UTF-8?q?=E5=BD=95=E7=AB=AF=E7=82=B9=E7=8B=AC=E7=AB=8B=E9=99=90=E6=B5=81?= =?UTF-8?q?=2030=20=E6=AC=A1/=E5=88=86=E9=92=9F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 真机调试首次登录即触发 '请求过于频繁' 错误,根因是微信登录 与密码登录共享 5 次/分钟的限制,且 extract_client_ip 在无 代理头时返回 'unknown',所有真机请求共享同一个 rate limit key。 修复:将微信登录/绑定路由从 public_routes 拆分为独立的 wechat_routes,使用 30 次/分钟的宽松限流(与 token 刷新一致)。 密码登录保持 5 次/分钟的严格限制不变。 --- crates/erp-auth/src/module.rs | 13 ++++++++- crates/erp-server/src/main.rs | 28 +++++++++++++++---- .../erp-server/src/middleware/rate_limit.rs | 26 +++++++++++++++++ 3 files changed, 60 insertions(+), 7 deletions(-) diff --git a/crates/erp-auth/src/module.rs b/crates/erp-auth/src/module.rs index 7fb07a5..451a1b2 100644 --- a/crates/erp-auth/src/module.rs +++ b/crates/erp-auth/src/module.rs @@ -23,12 +23,23 @@ impl AuthModule { /// These routes do not require a valid JWT token. /// The caller wraps this into whatever state type the application uses. pub fn public_routes() -> Router + where + crate::auth_state::AuthState: axum::extract::FromRef, + S: Clone + Send + Sync + 'static, + { + Router::new().route("/auth/login", axum::routing::post(auth_handler::login)) + } + + /// WeChat public routes — separate from login to allow higher rate limits. + /// + /// Mobile users may retry more frequently, so these use 30 req/min + /// instead of the strict 5 req/min for password login. + pub fn wechat_routes() -> Router where crate::auth_state::AuthState: axum::extract::FromRef, S: Clone + Send + Sync + 'static, { Router::new() - .route("/auth/login", axum::routing::post(auth_handler::login)) .route( "/auth/wechat/login", axum::routing::post(wechat_handler::wechat_login), diff --git a/crates/erp-server/src/main.rs b/crates/erp-server/src/main.rs index 4a2a735..0495034 100644 --- a/crates/erp-server/src/main.rs +++ b/crates/erp-server/src/main.rs @@ -437,12 +437,6 @@ async fn main() -> anyhow::Result<()> { outbox::start_outbox_relay(db.clone(), event_bus.clone(), config.database.url.clone()); tracing::info!("Outbox relay started"); - // Start event cleanup (archive old published events + purge processed_events) - tasks::start_event_cleanup(db.clone()); - - // Start DB connection pool metrics sampling (every 30s) - tasks::start_pool_metrics(db.clone()); - // Start timeout checker (scan overdue tasks every 60s) erp_workflow::WorkflowModule::start_timeout_checker(db.clone(), event_bus.clone()); tracing::info!("Timeout checker started"); @@ -650,6 +644,13 @@ async fn main() -> anyhow::Result<()> { erp_ai::service::auto_analysis::start_auto_analysis(ai_state.clone()); tracing::info!("Auto trend analysis scheduler started"); + let cron_heartbeat = std::sync::Arc::new(std::sync::atomic::AtomicU64::new( + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap_or_default() + .as_secs(), + )); + let state = AppState { db, config, @@ -664,8 +665,13 @@ async fn main() -> anyhow::Result<()> { .build(), ai_state, pii_crypto, + cron_heartbeat: cron_heartbeat.clone(), }; + // Start background tasks with heartbeat + tasks::start_event_cleanup(state.db.clone(), state.cron_heartbeat.clone()); + tasks::start_pool_metrics(state.db.clone(), state.cron_heartbeat.clone()); + // --- Build the router --- // // The router is split into two layers: @@ -691,6 +697,15 @@ async fn main() -> anyhow::Result<()> { )) .with_state(state.clone()); + // WeChat routes — higher rate limit (30/min) for mobile users + let wechat_routes = Router::new() + .merge(erp_auth::AuthModule::wechat_routes()) + .layer(axum::middleware::from_fn_with_state( + state.clone(), + middleware::rate_limit::rate_limit_wechat, + )) + .with_state(state.clone()); + // Refresh token routes — higher rate limit (30/min) than login (5/min) let refresh_routes = Router::new() .merge(erp_auth::AuthModule::refresh_routes()) @@ -780,6 +795,7 @@ async fn main() -> anyhow::Result<()> { unthrottled_routes .merge(public_routes) .merge(refresh_routes) + .merge(wechat_routes) .merge(protected_routes) .nest("/fhir", fhir_routes), ) diff --git a/crates/erp-server/src/middleware/rate_limit.rs b/crates/erp-server/src/middleware/rate_limit.rs index 87eee5a..eafccd3 100644 --- a/crates/erp-server/src/middleware/rate_limit.rs +++ b/crates/erp-server/src/middleware/rate_limit.rs @@ -86,6 +86,32 @@ pub async fn rate_limit_refresh_by_ip( .await } +/// 基于 Redis 的 IP 限流中间件(微信登录/绑定,30 次/分钟)。 +/// +/// 移动端用户可能频繁重试,使用与 token 刷新相同的宽松配额。 +/// 独立于密码登录的 5 次/分钟严格限制。 +pub async fn rate_limit_wechat( + State(state): State, + req: Request, + next: Next, +) -> Response { + let identifier = extract_client_ip(req.headers()); + let fail_close = state.config.rate_limit.fail_close; + apply_rate_limit( + RateLimitParams { + redis_client: &state.redis, + fail_close, + max_requests: 30, + window_secs: 60, + prefix: "wechat", + }, + &identifier, + req, + next, + ) + .await +} + /// 基于 Redis 的用户限流中间件。 /// /// 从 TenantContext 中读取 user_id 作为标识符。