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 作为标识符。