gitdataai/libs/room/src/room.rs
ZhenYi 57779822dc refactor(room): migrate from slog to tracing + upgrade metrics to 0.22
- Remove all use slog::* imports and log: slog::Logger fields
- Replace slog macros with tracing::{info!, warn!, error!, debug!}
- metrics.rs: upgrade metrics 0.21→0.22, remove register_*! macros,
  use functional API: metrics::gauge!(), metrics::counter!(),
  metrics::histogram!(), metrics::describe_gauge!() etc.
- RoomMetrics: all fields now use functional metrics API, dynamic
  room_id labels passed as owned String to avoid lifetime issues
- RoomService: remove pub log: slog::Logger field
- connection.rs: remove log from subscribe_room_events,
  subscribe_project_room_events, subscribe_task_events_fn
2026-04-21 22:28:52 +08:00

442 lines
15 KiB
Rust

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<bool>,
ctx: &WsUserContext,
) -> Result<Vec<super::RoomResponse>, 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::<Option<String>>(&mut conn)
.await
{
if let Ok(responses) = serde_json::from_str::<Vec<super::RoomResponse>>(&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<Uuid> = models.iter().map(|r| r.id).collect();
let latest_seqs: std::collections::HashMap<Uuid, i64> = 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<i64>)>()
.all(&self.db)
.await?
.into_iter()
.map(|(room, seq)| (room, seq.unwrap_or(0)))
.collect();
let member_read_seqs: std::collections::HashMap<Uuid, i64> = 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<String> = 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<super::RoomResponse, RoomError> {
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<super::RoomResponse, RoomError> {
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<super::RoomResponse, 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?;
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<String> = 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<String>) = 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::<i64>(&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;
}
}
}
}
}