- 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
442 lines
15 KiB
Rust
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;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|