From 6cb288b4f235f251121c96d96c669ff609f6d864 Mon Sep 17 00:00:00 2001 From: iven Date: Mon, 1 Jun 2026 22:34:02 +0800 Subject: [PATCH] =?UTF-8?q?feat(diary):=20=E7=8F=AD=E7=BA=A7=E7=A0=81?= =?UTF-8?q?=E9=AA=8C=E8=AF=81=E6=B7=BB=E5=8A=A05=E6=AC=A1=E9=94=99?= =?UTF-8?q?=E8=AF=AF=E9=94=81=E5=AE=9A=20=E2=80=94=20Redis=E8=AE=A1?= =?UTF-8?q?=E6=95=B0=20+=2030=E5=88=86=E9=92=9F=E5=86=B7=E5=8D=B4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- crates/erp-diary/Cargo.toml | 1 + crates/erp-diary/src/handler/class_handler.rs | 1 + crates/erp-diary/src/service/class_service.rs | 79 ++++++++++++++++++- crates/erp-diary/src/state.rs | 2 + crates/erp-server/src/state.rs | 1 + 5 files changed, 81 insertions(+), 3 deletions(-) diff --git a/crates/erp-diary/Cargo.toml b/crates/erp-diary/Cargo.toml index 311adbc..88b8f71 100644 --- a/crates/erp-diary/Cargo.toml +++ b/crates/erp-diary/Cargo.toml @@ -19,3 +19,4 @@ anyhow.workspace = true utoipa.workspace = true validator.workspace = true async-trait.workspace = true +redis.workspace = true diff --git a/crates/erp-diary/src/handler/class_handler.rs b/crates/erp-diary/src/handler/class_handler.rs index 5ad284c..f4f66d8 100644 --- a/crates/erp-diary/src/handler/class_handler.rs +++ b/crates/erp-diary/src/handler/class_handler.rs @@ -93,6 +93,7 @@ where req.class_code, None, // 昵称暂不通过此接口传递 &state.db, + state.redis.as_ref(), &state.event_bus, ) .await?; diff --git a/crates/erp-diary/src/service/class_service.rs b/crates/erp-diary/src/service/class_service.rs index 0cf8004..5658160 100644 --- a/crates/erp-diary/src/service/class_service.rs +++ b/crates/erp-diary/src/service/class_service.rs @@ -96,15 +96,39 @@ impl ClassService { /// /// 验证班级码有效性和过期状态,检查是否已是成员, /// 创建 class_member 记录并更新 member_count。 + /// 包含 Redis 错误锁定:连续 5 次错误后锁定 30 分钟。 pub async fn join_class( tenant_id: Uuid, user_id: Uuid, class_code: String, nickname: Option, db: &DatabaseConnection, + redis: Option<&redis::Client>, event_bus: &EventBus, ) -> DiaryResult { let now = Utc::now(); + let lock_key = format!("class_code_attempts:{user_id}"); + + // 0. 检查错误锁定 + if let Some(redis_client) = redis { + if let Ok(mut conn) = redis_client.get_multiplexed_async_connection().await { + let attempts: i64 = redis::cmd("GET") + .arg(&lock_key) + .query_async(&mut conn) + .await + .unwrap_or(0); + + if attempts >= 5 { + let ttl: i64 = redis::cmd("TTL") + .arg(&lock_key) + .query_async(&mut conn) + .await + .unwrap_or(1800); + let lockout_minutes = (ttl.max(0) as u32 / 60).max(1); + return Err(DiaryError::ClassCodeLocked { lockout_minutes }); + } + } + } // 1. 查找班级码对应的班级 let class_model = school_class::Entity::find() @@ -112,17 +136,27 @@ impl ClassService { .filter(school_class::Column::TenantId.eq(tenant_id)) .filter(school_class::Column::DeletedAt.is_null()) .one(db) - .await? - .ok_or(DiaryError::InvalidClassCode)?; + .await?; + + // 班级码无效 → 增加错误计数 + let class_model = match class_model { + Some(m) => m, + None => { + Self::increment_fail_count(redis, &lock_key).await; + return Err(DiaryError::InvalidClassCode); + } + }; // 2. 检查班级是否激活 if !class_model.is_active { + Self::increment_fail_count(redis, &lock_key).await; return Err(DiaryError::BadRequest("班级已停用".to_string())); } // 3. 检查是否过期 if let Some(expires) = class_model.expires_at { if now > expires { + Self::increment_fail_count(redis, &lock_key).await; return Err(DiaryError::ClassCodeExpired); } } @@ -166,7 +200,18 @@ impl ClassService { active_class.version = Set(active_class.version.unwrap() + 1); let updated_class = active_class.update(db).await?; - // 7. 发布 StudentJoinedClass 事件 + // 7. 成功加入 → 清除错误计数 + if let Some(redis_client) = redis { + if let Ok(mut conn) = redis_client.get_multiplexed_async_connection().await { + let _: () = redis::cmd("DEL") + .arg(&lock_key) + .query_async(&mut conn) + .await + .unwrap_or(()); + } + } + + // 8. 发布 StudentJoinedClass 事件 event_bus .publish( DomainEvent::new( @@ -184,6 +229,34 @@ impl ClassService { Ok(class_model_to_resp(updated_class)) } + /// 增加班级码验证失败计数(Redis INCR + 30分钟过期) + async fn increment_fail_count(redis: Option<&redis::Client>, lock_key: &str) { + if let Some(redis_client) = redis { + if let Ok(mut conn) = redis_client.get_multiplexed_async_connection().await { + let _: i64 = redis::cmd("INCR") + .arg(lock_key) + .query_async(&mut conn) + .await + .unwrap_or(0); + // 设置 30 分钟过期(仅在第一次失败时设置,后续 INCR 不重置 TTL) + let ttl: i64 = redis::cmd("TTL") + .arg(lock_key) + .query_async(&mut conn) + .await + .unwrap_or(-1); + if ttl == -1 { + // key 存在但没有过期时间 → 设置 30 分钟 + let _: () = redis::cmd("EXPIRE") + .arg(lock_key) + .arg(1800i64) + .query_async(&mut conn) + .await + .unwrap_or(()); + } + } + } + } + /// 获取班级详情 pub async fn get_class( tenant_id: Uuid, diff --git a/crates/erp-diary/src/state.rs b/crates/erp-diary/src/state.rs index 112ec26..4df38de 100644 --- a/crates/erp-diary/src/state.rs +++ b/crates/erp-diary/src/state.rs @@ -10,4 +10,6 @@ pub struct DiaryState { pub db: DatabaseConnection, pub event_bus: EventBus, pub crypto: PiiCrypto, + /// Redis 客户端,用于班级码错误锁定等速率限制场景 + pub redis: Option, } diff --git a/crates/erp-server/src/state.rs b/crates/erp-server/src/state.rs index a636792..690d330 100644 --- a/crates/erp-server/src/state.rs +++ b/crates/erp-server/src/state.rs @@ -116,6 +116,7 @@ impl FromRef for erp_diary::DiaryState { db: state.db.clone(), event_bus: state.event_bus.clone(), crypto: state.pii_crypto.clone(), + redis: Some(state.redis.clone()), } } }