feat(diary): 班级码验证添加5次错误锁定 — Redis计数 + 30分钟冷却
This commit is contained in:
@@ -19,3 +19,4 @@ anyhow.workspace = true
|
|||||||
utoipa.workspace = true
|
utoipa.workspace = true
|
||||||
validator.workspace = true
|
validator.workspace = true
|
||||||
async-trait.workspace = true
|
async-trait.workspace = true
|
||||||
|
redis.workspace = true
|
||||||
|
|||||||
@@ -93,6 +93,7 @@ where
|
|||||||
req.class_code,
|
req.class_code,
|
||||||
None, // 昵称暂不通过此接口传递
|
None, // 昵称暂不通过此接口传递
|
||||||
&state.db,
|
&state.db,
|
||||||
|
state.redis.as_ref(),
|
||||||
&state.event_bus,
|
&state.event_bus,
|
||||||
)
|
)
|
||||||
.await?;
|
.await?;
|
||||||
|
|||||||
@@ -96,15 +96,39 @@ impl ClassService {
|
|||||||
///
|
///
|
||||||
/// 验证班级码有效性和过期状态,检查是否已是成员,
|
/// 验证班级码有效性和过期状态,检查是否已是成员,
|
||||||
/// 创建 class_member 记录并更新 member_count。
|
/// 创建 class_member 记录并更新 member_count。
|
||||||
|
/// 包含 Redis 错误锁定:连续 5 次错误后锁定 30 分钟。
|
||||||
pub async fn join_class(
|
pub async fn join_class(
|
||||||
tenant_id: Uuid,
|
tenant_id: Uuid,
|
||||||
user_id: Uuid,
|
user_id: Uuid,
|
||||||
class_code: String,
|
class_code: String,
|
||||||
nickname: Option<String>,
|
nickname: Option<String>,
|
||||||
db: &DatabaseConnection,
|
db: &DatabaseConnection,
|
||||||
|
redis: Option<&redis::Client>,
|
||||||
event_bus: &EventBus,
|
event_bus: &EventBus,
|
||||||
) -> DiaryResult<ClassResp> {
|
) -> DiaryResult<ClassResp> {
|
||||||
let now = Utc::now();
|
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. 查找班级码对应的班级
|
// 1. 查找班级码对应的班级
|
||||||
let class_model = school_class::Entity::find()
|
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::TenantId.eq(tenant_id))
|
||||||
.filter(school_class::Column::DeletedAt.is_null())
|
.filter(school_class::Column::DeletedAt.is_null())
|
||||||
.one(db)
|
.one(db)
|
||||||
.await?
|
.await?;
|
||||||
.ok_or(DiaryError::InvalidClassCode)?;
|
|
||||||
|
// 班级码无效 → 增加错误计数
|
||||||
|
let class_model = match class_model {
|
||||||
|
Some(m) => m,
|
||||||
|
None => {
|
||||||
|
Self::increment_fail_count(redis, &lock_key).await;
|
||||||
|
return Err(DiaryError::InvalidClassCode);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
// 2. 检查班级是否激活
|
// 2. 检查班级是否激活
|
||||||
if !class_model.is_active {
|
if !class_model.is_active {
|
||||||
|
Self::increment_fail_count(redis, &lock_key).await;
|
||||||
return Err(DiaryError::BadRequest("班级已停用".to_string()));
|
return Err(DiaryError::BadRequest("班级已停用".to_string()));
|
||||||
}
|
}
|
||||||
|
|
||||||
// 3. 检查是否过期
|
// 3. 检查是否过期
|
||||||
if let Some(expires) = class_model.expires_at {
|
if let Some(expires) = class_model.expires_at {
|
||||||
if now > expires {
|
if now > expires {
|
||||||
|
Self::increment_fail_count(redis, &lock_key).await;
|
||||||
return Err(DiaryError::ClassCodeExpired);
|
return Err(DiaryError::ClassCodeExpired);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -166,7 +200,18 @@ impl ClassService {
|
|||||||
active_class.version = Set(active_class.version.unwrap() + 1);
|
active_class.version = Set(active_class.version.unwrap() + 1);
|
||||||
let updated_class = active_class.update(db).await?;
|
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
|
event_bus
|
||||||
.publish(
|
.publish(
|
||||||
DomainEvent::new(
|
DomainEvent::new(
|
||||||
@@ -184,6 +229,34 @@ impl ClassService {
|
|||||||
Ok(class_model_to_resp(updated_class))
|
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(
|
pub async fn get_class(
|
||||||
tenant_id: Uuid,
|
tenant_id: Uuid,
|
||||||
|
|||||||
@@ -10,4 +10,6 @@ pub struct DiaryState {
|
|||||||
pub db: DatabaseConnection,
|
pub db: DatabaseConnection,
|
||||||
pub event_bus: EventBus,
|
pub event_bus: EventBus,
|
||||||
pub crypto: PiiCrypto,
|
pub crypto: PiiCrypto,
|
||||||
|
/// Redis 客户端,用于班级码错误锁定等速率限制场景
|
||||||
|
pub redis: Option<redis::Client>,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -116,6 +116,7 @@ impl FromRef<AppState> for erp_diary::DiaryState {
|
|||||||
db: state.db.clone(),
|
db: state.db.clone(),
|
||||||
event_bus: state.event_bus.clone(),
|
event_bus: state.event_bus.clone(),
|
||||||
crypto: state.pii_crypto.clone(),
|
crypto: state.pii_crypto.clone(),
|
||||||
|
redis: Some(state.redis.clone()),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user