feat(diary): 班级码验证添加5次错误锁定 — Redis计数 + 30分钟冷却
This commit is contained in:
@@ -19,3 +19,4 @@ anyhow.workspace = true
|
||||
utoipa.workspace = true
|
||||
validator.workspace = true
|
||||
async-trait.workspace = true
|
||||
redis.workspace = true
|
||||
|
||||
@@ -93,6 +93,7 @@ where
|
||||
req.class_code,
|
||||
None, // 昵称暂不通过此接口传递
|
||||
&state.db,
|
||||
state.redis.as_ref(),
|
||||
&state.event_bus,
|
||||
)
|
||||
.await?;
|
||||
|
||||
@@ -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<String>,
|
||||
db: &DatabaseConnection,
|
||||
redis: Option<&redis::Client>,
|
||||
event_bus: &EventBus,
|
||||
) -> DiaryResult<ClassResp> {
|
||||
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,
|
||||
|
||||
@@ -10,4 +10,6 @@ pub struct DiaryState {
|
||||
pub db: DatabaseConnection,
|
||||
pub event_bus: EventBus,
|
||||
pub crypto: PiiCrypto,
|
||||
/// Redis 客户端,用于班级码错误锁定等速率限制场景
|
||||
pub redis: Option<redis::Client>,
|
||||
}
|
||||
|
||||
@@ -116,6 +116,7 @@ impl FromRef<AppState> for erp_diary::DiaryState {
|
||||
db: state.db.clone(),
|
||||
event_bus: state.event_bus.clone(),
|
||||
crypto: state.pii_crypto.clone(),
|
||||
redis: Some(state.redis.clone()),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user