diff --git a/crates/erp-auth/src/module.rs b/crates/erp-auth/src/module.rs index 21213d3..7fb07a5 100644 --- a/crates/erp-auth/src/module.rs +++ b/crates/erp-auth/src/module.rs @@ -29,7 +29,6 @@ impl AuthModule { { Router::new() .route("/auth/login", axum::routing::post(auth_handler::login)) - .route("/auth/refresh", axum::routing::post(auth_handler::refresh)) .route( "/auth/wechat/login", axum::routing::post(wechat_handler::wechat_login), @@ -40,6 +39,15 @@ impl AuthModule { ) } + /// Refresh token routes — public but with higher rate limit (30/min vs 5/min for login). + pub fn refresh_routes() -> Router + where + crate::auth_state::AuthState: axum::extract::FromRef, + S: Clone + Send + Sync + 'static, + { + Router::new().route("/auth/refresh", axum::routing::post(auth_handler::refresh)) + } + /// Build protected (authenticated) routes for the auth module. /// /// These routes require a valid JWT token, verified by the middleware layer. diff --git a/crates/erp-health/src/error.rs b/crates/erp-health/src/error.rs index 4e41797..1791d84 100644 --- a/crates/erp-health/src/error.rs +++ b/crates/erp-health/src/error.rs @@ -128,7 +128,7 @@ impl From for AppError { match err { HealthError::Validation(s) => AppError::Validation(s), HealthError::PatientNotFound | HealthError::DoctorNotFound => { - AppError::Validation(err.to_string()) + AppError::NotFound(err.to_string()) } HealthError::AppointmentNotFound | HealthError::ScheduleNotFound diff --git a/crates/erp-health/src/service/stats_service/dashboard.rs b/crates/erp-health/src/service/stats_service/dashboard.rs index 4a2a531..e37e00e 100644 --- a/crates/erp-health/src/service/stats_service/dashboard.rs +++ b/crates/erp-health/src/service/stats_service/dashboard.rs @@ -74,7 +74,8 @@ pub async fn get_points_recent_activity( CASE WHEN pt.amount >= 0 THEN 'earn' ELSE 'spend' END AS type, pt.created_at::text FROM points_transaction pt - LEFT JOIN patient p ON p.id = pt.patient_id AND p.deleted_at IS NULL + LEFT JOIN points_account pa ON pa.id = pt.account_id AND pa.deleted_at IS NULL + LEFT JOIN patient p ON p.id = pa.patient_id AND p.deleted_at IS NULL WHERE pt.tenant_id = $1 AND pt.deleted_at IS NULL ORDER BY pt.created_at DESC LIMIT $2 diff --git a/crates/erp-server/src/main.rs b/crates/erp-server/src/main.rs index e41c530..723cd18 100644 --- a/crates/erp-server/src/main.rs +++ b/crates/erp-server/src/main.rs @@ -669,6 +669,15 @@ async fn main() -> anyhow::Result<()> { )) .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()) + .layer(axum::middleware::from_fn_with_state( + state.clone(), + middleware::rate_limit::rate_limit_refresh_by_ip, + )) + .with_state(state.clone()); + // Unthrottled public routes (health, docs, brand) — no rate limiting let unthrottled_routes = Router::new() .merge(handlers::health::health_check_router()) @@ -746,6 +755,7 @@ async fn main() -> anyhow::Result<()> { "/api/v1", unthrottled_routes .merge(public_routes) + .merge(refresh_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 178d0f2..87eee5a 100644 --- a/crates/erp-server/src/middleware/rate_limit.rs +++ b/crates/erp-server/src/middleware/rate_limit.rs @@ -40,10 +40,7 @@ struct RateLimitResponse { const ACCOUNT_LOCKOUT_MAX_FAILURES: i64 = 5; const ACCOUNT_LOCKOUT_TTL_SECS: i64 = 900; // 15 分钟 -/// 基于 Redis 的 IP 限流中间件。 -/// -/// 使用 INCR + EXPIRE 实现固定窗口计数器。 -/// 超限返回 HTTP 429 Too Many Requests。 +/// 基于 Redis 的 IP 限流中间件(登录等敏感操作,5 次/分钟)。 pub async fn rate_limit_by_ip( State(state): State, req: Request, @@ -66,6 +63,29 @@ pub async fn rate_limit_by_ip( .await } +/// 基于 Redis 的 IP 限流中间件(Token 刷新,30 次/分钟)。 +pub async fn rate_limit_refresh_by_ip( + 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: "refresh", + }, + &identifier, + req, + next, + ) + .await +} + /// 基于 Redis 的用户限流中间件。 /// /// 从 TenantContext 中读取 user_id 作为标识符。