fix: E2E 测试发现的后端 BUG 修复 — 限流拆分 + 积分查询 + 错误码修正
- 拆分 refresh token 限流为独立中间件(30次/分 vs 登录5次/分) - 修复积分 recent-activity 500:JOIN 通过 points_account 中间表 - 修复患者/医生不存在返回 400 → 正确的 404 NotFound
This commit is contained in:
@@ -29,7 +29,6 @@ impl AuthModule {
|
|||||||
{
|
{
|
||||||
Router::new()
|
Router::new()
|
||||||
.route("/auth/login", axum::routing::post(auth_handler::login))
|
.route("/auth/login", axum::routing::post(auth_handler::login))
|
||||||
.route("/auth/refresh", axum::routing::post(auth_handler::refresh))
|
|
||||||
.route(
|
.route(
|
||||||
"/auth/wechat/login",
|
"/auth/wechat/login",
|
||||||
axum::routing::post(wechat_handler::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<S>() -> Router<S>
|
||||||
|
where
|
||||||
|
crate::auth_state::AuthState: axum::extract::FromRef<S>,
|
||||||
|
S: Clone + Send + Sync + 'static,
|
||||||
|
{
|
||||||
|
Router::new().route("/auth/refresh", axum::routing::post(auth_handler::refresh))
|
||||||
|
}
|
||||||
|
|
||||||
/// Build protected (authenticated) routes for the auth module.
|
/// Build protected (authenticated) routes for the auth module.
|
||||||
///
|
///
|
||||||
/// These routes require a valid JWT token, verified by the middleware layer.
|
/// These routes require a valid JWT token, verified by the middleware layer.
|
||||||
|
|||||||
@@ -128,7 +128,7 @@ impl From<HealthError> for AppError {
|
|||||||
match err {
|
match err {
|
||||||
HealthError::Validation(s) => AppError::Validation(s),
|
HealthError::Validation(s) => AppError::Validation(s),
|
||||||
HealthError::PatientNotFound | HealthError::DoctorNotFound => {
|
HealthError::PatientNotFound | HealthError::DoctorNotFound => {
|
||||||
AppError::Validation(err.to_string())
|
AppError::NotFound(err.to_string())
|
||||||
}
|
}
|
||||||
HealthError::AppointmentNotFound
|
HealthError::AppointmentNotFound
|
||||||
| HealthError::ScheduleNotFound
|
| HealthError::ScheduleNotFound
|
||||||
|
|||||||
@@ -74,7 +74,8 @@ pub async fn get_points_recent_activity(
|
|||||||
CASE WHEN pt.amount >= 0 THEN 'earn' ELSE 'spend' END AS type,
|
CASE WHEN pt.amount >= 0 THEN 'earn' ELSE 'spend' END AS type,
|
||||||
pt.created_at::text
|
pt.created_at::text
|
||||||
FROM points_transaction pt
|
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
|
WHERE pt.tenant_id = $1 AND pt.deleted_at IS NULL
|
||||||
ORDER BY pt.created_at DESC
|
ORDER BY pt.created_at DESC
|
||||||
LIMIT $2
|
LIMIT $2
|
||||||
|
|||||||
@@ -669,6 +669,15 @@ async fn main() -> anyhow::Result<()> {
|
|||||||
))
|
))
|
||||||
.with_state(state.clone());
|
.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
|
// Unthrottled public routes (health, docs, brand) — no rate limiting
|
||||||
let unthrottled_routes = Router::new()
|
let unthrottled_routes = Router::new()
|
||||||
.merge(handlers::health::health_check_router())
|
.merge(handlers::health::health_check_router())
|
||||||
@@ -746,6 +755,7 @@ async fn main() -> anyhow::Result<()> {
|
|||||||
"/api/v1",
|
"/api/v1",
|
||||||
unthrottled_routes
|
unthrottled_routes
|
||||||
.merge(public_routes)
|
.merge(public_routes)
|
||||||
|
.merge(refresh_routes)
|
||||||
.merge(protected_routes)
|
.merge(protected_routes)
|
||||||
.nest("/fhir", fhir_routes),
|
.nest("/fhir", fhir_routes),
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -40,10 +40,7 @@ struct RateLimitResponse {
|
|||||||
const ACCOUNT_LOCKOUT_MAX_FAILURES: i64 = 5;
|
const ACCOUNT_LOCKOUT_MAX_FAILURES: i64 = 5;
|
||||||
const ACCOUNT_LOCKOUT_TTL_SECS: i64 = 900; // 15 分钟
|
const ACCOUNT_LOCKOUT_TTL_SECS: i64 = 900; // 15 分钟
|
||||||
|
|
||||||
/// 基于 Redis 的 IP 限流中间件。
|
/// 基于 Redis 的 IP 限流中间件(登录等敏感操作,5 次/分钟)。
|
||||||
///
|
|
||||||
/// 使用 INCR + EXPIRE 实现固定窗口计数器。
|
|
||||||
/// 超限返回 HTTP 429 Too Many Requests。
|
|
||||||
pub async fn rate_limit_by_ip(
|
pub async fn rate_limit_by_ip(
|
||||||
State(state): State<AppState>,
|
State(state): State<AppState>,
|
||||||
req: Request<Body>,
|
req: Request<Body>,
|
||||||
@@ -66,6 +63,29 @@ pub async fn rate_limit_by_ip(
|
|||||||
.await
|
.await
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 基于 Redis 的 IP 限流中间件(Token 刷新,30 次/分钟)。
|
||||||
|
pub async fn rate_limit_refresh_by_ip(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
req: Request<Body>,
|
||||||
|
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 的用户限流中间件。
|
/// 基于 Redis 的用户限流中间件。
|
||||||
///
|
///
|
||||||
/// 从 TenantContext 中读取 user_id 作为标识符。
|
/// 从 TenantContext 中读取 user_id 作为标识符。
|
||||||
|
|||||||
Reference in New Issue
Block a user