feat(diary): 班级码验证添加5次错误锁定 — Redis计数 + 30分钟冷却

This commit is contained in:
iven
2026-06-01 22:34:02 +08:00
parent 0c6a33d96b
commit 6cb288b4f2
5 changed files with 81 additions and 3 deletions

View File

@@ -19,3 +19,4 @@ anyhow.workspace = true
utoipa.workspace = true
validator.workspace = true
async-trait.workspace = true
redis.workspace = true

View File

@@ -93,6 +93,7 @@ where
req.class_code,
None, // 昵称暂不通过此接口传递
&state.db,
state.redis.as_ref(),
&state.event_bus,
)
.await?;

View File

@@ -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,

View File

@@ -10,4 +10,6 @@ pub struct DiaryState {
pub db: DatabaseConnection,
pub event_bus: EventBus,
pub crypto: PiiCrypto,
/// Redis 客户端,用于班级码错误锁定等速率限制场景
pub redis: Option<redis::Client>,
}

View File

@@ -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()),
}
}
}