use crate::error::RoomError; use crate::service::RoomService; use crate::ws_context::WsUserContext; use chrono::Utc; use models::rooms::{ RoomMemberRole, room, room_ai, room_category, room_member, room_message, room_pin, room_thread, }; use models::projects::{project_members, MemberRole as Role}; use queue::ProjectRoomEvent; use sea_orm::*; use uuid::Uuid; impl RoomService { /// Cache TTL for room list (in seconds). const ROOM_LIST_CACHE_TTL: u64 = 60; pub async fn room_list( &self, project_name: String, only_public: Option, ctx: &WsUserContext, ) -> Result, RoomError> { let user_id = ctx.user_id; let project = self.utils_find_project_by_name(project_name).await?; self.check_project_access(project.id, user_id).await?; // Try cache first let cache_key = format!( "room:list:{}:{}:public={}", project.id, user_id, only_public.unwrap_or(false) ); if let Ok(mut conn) = self.cache.conn().await { if let Ok(Some(cached)) = redis::cmd("GET") .arg(&cache_key) .query_async::>(&mut conn) .await { if let Ok(responses) = serde_json::from_str::>(&cached) { tracing::debug!(cache_key = %cache_key, "room_list: cache hit"); return Ok(responses); } } } tracing::debug!(cache_key = %cache_key, "room_list: cache miss"); let mut query = room::Entity::find().filter(room::Column::Project.eq(project.id)); if only_public.unwrap_or(false) { query = query.filter(room::Column::Public.eq(true)); } let models = query .order_by_desc(room::Column::LastMsgAt) .all(&self.db) .await?; let room_ids: Vec = models.iter().map(|r| r.id).collect(); let latest_seqs: std::collections::HashMap = room_message::Entity::find() .select_only() .column(room_message::Column::Room) .column_as(room_message::Column::Seq.max(), "max_seq") .filter(room_message::Column::Room.is_in(room_ids.clone())) .group_by(room_message::Column::Room) .into_tuple::<(Uuid, Option)>() .all(&self.db) .await? .into_iter() .map(|(room, seq)| (room, seq.unwrap_or(0))) .collect(); let member_read_seqs: std::collections::HashMap = room_member::Entity::find() .filter(room_member::Column::User.eq(user_id)) .filter(room_member::Column::Room.is_in(room_ids)) .all(&self.db) .await? .into_iter() .map(|m| (m.room, m.last_read_seq.unwrap_or(0))) .collect(); let mut responses = Vec::new(); for model in models { let last_read_seq = member_read_seqs.get(&model.id).copied().unwrap_or(0); let latest_seq = latest_seqs.get(&model.id).copied().unwrap_or(0); let unread_count = std::cmp::max(latest_seq - last_read_seq, 0); let mut response = super::RoomResponse::from(model); response.unread_count = unread_count; responses.push(response); } // Cache the result if let Ok(mut conn) = self.cache.conn().await { if let Ok(json) = serde_json::to_string(&responses) { let _: Option = redis::cmd("SETEX") .arg(&cache_key) .arg(Self::ROOM_LIST_CACHE_TTL) .arg(&json) .query_async(&mut conn) .await .inspect_err(|e| { tracing::warn!(cache_key = %cache_key, error = %e, "room_list: failed to cache"); }) .ok(); } } Ok(responses) } pub async fn room_create( &self, project_name: String, request: super::RoomCreateRequest, ctx: &WsUserContext, ) -> Result { let user_id = ctx.user_id; let project = self.utils_find_project_by_name(project_name).await?; self.require_project_admin(project.id, user_id).await?; Self::validate_name(&request.room_name, super::MAX_ROOM_NAME_LEN)?; if let Some(category_id) = request.category { let category = room_category::Entity::find_by_id(category_id) .one(&self.db) .await? .ok_or_else(|| RoomError::NotFound("Room category not found".to_string()))?; if category.project != project.id { return Err(RoomError::BadRequest( "category does not belong to this project".to_string(), )); } } let txn = self.db.begin().await?; let room_name = request.room_name.clone(); let room_model = room::ActiveModel { id: Set(Uuid::now_v7()), project: Set(project.id), room_name: Set(request.room_name), public: Set(request.public), category: Set(request.category), created_by: Set(user_id), created_at: Set(Utc::now()), last_msg_at: Set(Utc::now()), } .insert(&txn) .await?; room_member::ActiveModel { room: Set(room_model.id), user: Set(user_id), role: Set(RoomMemberRole::Owner), first_msg_in: Set(None), joined_at: Set(Some(Utc::now())), last_read_seq: Set(None), do_not_disturb: Set(false), dnd_start_hour: Set(None), dnd_end_hour: Set(None), } .insert(&txn) .await?; // Inherit project members into room members let project_members_list = project_members::Entity::find() .filter(project_members::Column::Project.eq(project.id)) .all(&txn) .await?; for pm in project_members_list { if pm.user != user_id { let role = match pm.scope_role() { Ok(Role::Owner) => RoomMemberRole::Owner, Ok(Role::Admin) => RoomMemberRole::Admin, Ok(_) | Err(_) => RoomMemberRole::Member, }; room_member::ActiveModel { room: Set(room_model.id), user: Set(pm.user), role: Set(role), first_msg_in: Set(None), joined_at: Set(Some(Utc::now())), last_read_seq: Set(None), do_not_disturb: Set(false), dnd_start_hour: Set(None), dnd_end_hour: Set(None), } .insert(&txn) .await .ok(); } } txn.commit().await?; // Invalidate room list cache for this project self.invalidate_room_list_cache(project.id).await; self.spawn_room_workers(room_model.id); let event = ProjectRoomEvent { event_type: super::RoomEventType::RoomCreated.as_str().into(), project_id: project.id, room_id: Some(room_model.id), category_id: None, message_id: None, seq: None, timestamp: Utc::now(), }; let _ = self .queue .publish_project_room_event(project.id, event) .await; self.notify_project_members( project.id, super::NotificationType::RoomCreated, format!("新房间已创建: {}", room_name), None, Some(room_model.id), ); Ok(super::RoomResponse::from(room_model)) } pub async fn room_get( &self, room_id: Uuid, ctx: &WsUserContext, ) -> Result { let user_id = ctx.user_id; let model = self.find_room_or_404(room_id).await?; self.ensure_room_visible_for_user(&model, user_id).await?; Ok(super::RoomResponse::from(model)) } pub async fn room_update( &self, room_id: Uuid, request: super::RoomUpdateRequest, ctx: &WsUserContext, ) -> Result { let user_id = ctx.user_id; let room_model = self.find_room_or_404(room_id).await?; self.require_room_admin(room_id, user_id).await?; if let Some(category_id) = request.category { let category = room_category::Entity::find_by_id(category_id) .one(&self.db) .await? .ok_or_else(|| RoomError::NotFound("Room category not found".to_string()))?; if category.project != room_model.project { return Err(RoomError::BadRequest( "category does not belong to this project".to_string(), )); } } let mut active: room::ActiveModel = room_model.into(); let renamed = request.room_name.is_some(); let moved = request.category.is_some(); if let Some(room_name) = request.room_name { active.room_name = Set(room_name); } if let Some(public) = request.public { active.public = Set(public); } if request.category.is_some() { active.category = Set(request.category); } let updated = active.update(&self.db).await?; // Invalidate room list cache self.invalidate_room_list_cache(updated.project).await; if renamed { let event = ProjectRoomEvent { event_type: super::RoomEventType::RoomRenamed.as_str().into(), project_id: updated.project, room_id: Some(updated.id), category_id: None, message_id: None, seq: None, timestamp: Utc::now(), }; let _ = self .queue .publish_project_room_event(updated.project, event) .await; } if moved { let event = ProjectRoomEvent { event_type: super::RoomEventType::RoomMoved.as_str().into(), project_id: updated.project, room_id: Some(updated.id), category_id: None, message_id: None, seq: None, timestamp: Utc::now(), }; let _ = self .queue .publish_project_room_event(updated.project, event) .await; } Ok(super::RoomResponse::from(updated)) } pub async fn room_delete(&self, room_id: Uuid, ctx: &WsUserContext) -> Result<(), RoomError> { let user_id = ctx.user_id; let room_model = self.find_room_or_404(room_id).await?; self.require_room_admin(room_id, user_id).await?; let project_id = room_model.project; let txn = self.db.begin().await?; room_message::Entity::delete_many() .filter(room_message::Column::Room.eq(room_id)) .exec(&txn) .await?; room_pin::Entity::delete_many() .filter(room_pin::Column::Room.eq(room_id)) .exec(&txn) .await?; room_thread::Entity::delete_many() .filter(room_thread::Column::Room.eq(room_id)) .exec(&txn) .await?; room_member::Entity::delete_many() .filter(room_member::Column::Room.eq(room_id)) .exec(&txn) .await?; room_ai::Entity::delete_many() .filter(room_ai::Column::Room.eq(room_id)) .exec(&txn) .await?; room::Entity::delete_by_id(room_id).exec(&txn).await?; txn.commit().await?; // Invalidate room list cache self.invalidate_room_list_cache(project_id).await; self.room_manager.shutdown_room(room_id).await; // Clean up Redis seq key so re-creating the room starts fresh let seq_key = format!("room:seq:{}", room_id); if let Ok(mut conn) = self.cache.conn().await { let _: Option = redis::cmd("DEL") .arg(&seq_key) .query_async(&mut conn) .await .inspect_err(|e| { tracing::warn!(seq_key = %seq_key, error = %e, "room_delete: failed to DEL seq key"); }) .ok(); } let event = ProjectRoomEvent { event_type: super::RoomEventType::RoomDeleted.as_str().into(), project_id, room_id: Some(room_id), category_id: None, message_id: None, seq: None, timestamp: Utc::now(), }; let _ = self .queue .publish_project_room_event(project_id, event) .await; self.notify_project_members( project_id, super::NotificationType::RoomDeleted, format!("房间 {} 已被删除", room_model.room_name), None, Some(room_id), ); Ok(()) } /// Invalidate all room list cache entries for a project. async fn invalidate_room_list_cache(&self, project_id: Uuid) { let pattern = format!("room:list:{}:*", project_id); if let Ok(mut conn) = self.cache.conn().await { // Use SCAN to find matching keys, then DELETE them let mut cursor: u64 = 0; loop { let (new_cursor, keys): (u64, Vec) = match redis::cmd("SCAN") .arg(cursor) .arg("MATCH") .arg(&pattern) .arg("COUNT") .arg(100) .query_async(&mut conn) .await { Ok(result) => result, Err(e) => { tracing::warn!(error = %e, "invalidate_room_list_cache: SCAN failed"); break; } }; cursor = new_cursor; if !keys.is_empty() { // Delete keys in batches let keys_refs: Vec<&str> = keys.iter().map(|s| s.as_str()).collect(); if let Err(e) = redis::cmd("DEL") .arg(&keys_refs) .query_async::(&mut conn) .await { tracing::warn!(error = %e, "invalidate_room_list_cache: DEL failed"); } else { tracing::debug!(keys_count = keys.len(), "invalidate_room_list_cache: deleted"); } } if cursor == 0 { break; } } } } }