use std::collections::HashMap; use livekit_api::access_token::{AccessToken, VideoGrants}; use livekit_api::services::LiveKitApi; use livekit_api::services::room::{CreateRoomOptions, UpdateParticipantOptions}; use livekit_protocol as proto; use sea_orm::{ColumnTrait, EntityTrait, QueryFilter}; use serde::{Deserialize, Serialize}; use time::OffsetDateTime; use utoipa::ToSchema; use uuid::Uuid; use crate::errors::ChatError; use crate::services::service::ChatService; use crate::share::lookup::{lookup_space_by_name, require_room_readable}; use crate::share::realtime::RoomEvent; /// 举手状态。审批通过后 approved,被收回后 revoked(可再次举手)。 #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, ToSchema)] #[serde(rename_all = "snake_case")] pub enum HandState { Pending, Approved, Denied, Revoked, } #[derive(Debug, Clone, Serialize, Deserialize, ToSchema)] pub struct HandEntry { pub state: HandState, pub requested_at: i64, pub decided_by: Option, } #[derive(Debug, Clone, Serialize, ToSchema)] pub struct HandDto { pub user_id: i64, pub display_name: String, pub avatar_url: Option, pub state: HandState, pub requested_at: i64, } /// 一个参与者窗口在画布里的摆位,坐标按画布宽高归一化(0~1), /// 这样不同分辨率、不同窗口大小的成员看到的是同一套布局。 #[derive(Debug, Clone, Copy, Serialize, Deserialize, ToSchema)] pub struct VoiceTileDto { pub x: f64, pub y: f64, /// 归一化宽度。 pub w: f64, /// 归一化高度,和 `w` 一样按画布的可用宽高分别归一,因此窗口可以自由 /// 拉伸而不是只能等比缩放。旧记录没有这个字段,读出来是 `None`,客户端 /// 按宽度的 3/4 补一个经典比例。 #[serde(default)] pub h: Option, /// 服务端写入时刻(秒)。请求里被忽略,响应里供客户端判断“谁更新”, /// 这样并发拖拽时一个迟到的 GET 不会把刚摆好的窗口拽回旧位置。 #[serde(default)] pub updated_at: i64, } #[derive(Debug, Clone, Default, Serialize, Deserialize, ToSchema)] pub struct VoiceLayoutDto { /// key 为参与者用户 id。 #[serde(default)] pub tiles: HashMap, } /// 布局写入是**按 key 合并**的补丁而不是整表替换:每个客户端只提交自己刚拖的 /// 那一格,收回的格子里列在 `removed`,两个人同时摆不同窗口互不吞没。 #[derive(Debug, Clone, Default, Deserialize, ToSchema)] pub struct VoiceLayoutPatchDto { /// 需要新增或覆盖的窗口(key = 参与者用户 id)。 #[serde(default)] pub tiles: HashMap, /// 需要收回自动网格的窗口 key。 #[serde(default)] pub removed: Vec, } /// 本次写入的服务端时刻,客户端用它给本地副本盖戳。 #[derive(Debug, Clone, Copy, Serialize, ToSchema)] pub struct VoiceLayoutSavedDto { pub updated_at: i64, } /// 房间成员共享的窗口数量上限,避免布局被当成存储滥用。 const MAX_LAYOUT_TILES: usize = 64; #[derive(Debug, Clone, Serialize, ToSchema)] pub struct VoiceConfigDto { pub enabled: bool, pub url: String, pub recording_enabled: bool, } #[derive(Debug, Clone, Serialize, ToSchema)] pub struct VoiceTokenDto { pub token: String, pub url: String, pub room_name: String, pub expires_in_secs: u64, pub can_publish: bool, pub is_stage: bool, pub hand_state: Option, } #[derive(Debug, Clone, Serialize, Deserialize, ToSchema)] pub struct VoiceTrackDto { pub sid: String, pub kind: String, pub source: String, pub muted: bool, } /// 房间参与者快照。 /// /// 权威来源是 LiveKit 的 list_participants,Redis 只补主持人静音等本地状态; /// 这是「谁在这个频道里」的服务端视图,用于侧栏人数与未加入时的预览。 /// 会中界面的发言高亮、静音图标与画面轨道一律直接读浏览器里的 Room, /// 因为本接口不是事件流,返回值天然是快照。 #[derive(Debug, Clone, Serialize, ToSchema)] pub struct VoiceParticipantDto { pub user_id: i64, pub display_name: String, pub avatar_url: Option, pub joined_at: i64, pub can_publish: bool, pub moderator_muted: bool, /// 快照值:list_participants 不返回发言状态,因此这里恒为 false; /// 实时发言指示由前端从 Room 的 activeSpeakers 读取。 pub speaking: bool, pub tracks: Vec, } #[derive(Debug, Clone, Serialize, ToSchema)] pub struct VoiceRoomStateDto { pub room_id: Uuid, pub count: u32, } #[derive(Debug, Clone, Serialize, ToSchema)] pub struct VoiceSpaceStatesDto { pub rooms: Vec, } #[derive(Debug, Clone, Serialize, ToSchema)] pub struct VoiceHandsDto { pub hands: Vec, pub can_moderate: bool, pub my_state: Option, } #[derive(Debug, Clone, Serialize, ToSchema)] pub struct VoiceModerationDto { pub ok: bool, pub action: String, pub user_id: i64, } #[derive(Debug, Clone, Serialize, ToSchema)] pub struct VoiceRecordingDto { pub egress_id: String, pub active: bool, } /// Redis 中一位参与者的在场记录。 #[derive(Debug, Clone, Serialize, Deserialize)] pub struct VoiceMember { pub user_id: i64, pub display_name: String, pub avatar_url: Option, pub joined_at: i64, pub can_publish: bool, #[serde(default)] pub moderator_muted: bool, #[serde(default)] pub tracks: Vec, pub livekit_sid: Option, } pub fn voice_key(room_id: Uuid) -> String { format!("chat:voice:{room_id}") } pub fn hands_key(room_id: Uuid) -> String { format!("chat:voice:{room_id}:hands") } /// 布局合并脚本:读写必须在 Redis 里原子完成,否则两个人同时摆不同窗口时, /// 后写的那次会带着自己读到的旧快照覆盖掉前一次的结果。 const MERGE_LAYOUT_SCRIPT: &str = r" local key = KEYS[1] local stamp = tonumber(ARGV[1]) local max_tiles = tonumber(ARGV[3]) local ttl = tonumber(ARGV[4]) local patch = cjson.decode(ARGV[2]) local current = redis.call('GET', key) local layout = { tiles = {} } if current then local ok, decoded = pcall(cjson.decode, current) if ok and type(decoded) == 'table' and type(decoded.tiles) == 'table' then layout = decoded end end local removed = patch.removed if type(removed) == 'table' then for _, id in ipairs(removed) do layout.tiles[id] = nil end end local added = patch.tiles if type(added) == 'table' then for id, tile in pairs(added) do layout.tiles[id] = { x = tonumber(tile.x), y = tonumber(tile.y), w = tonumber(tile.w), -- 旧客户端(或旧记录回写)不带 h:tonumber(nil) 是 nil,这个键就不会写进 -- JSON,读出来仍是“未指定”,而不是塌成 0。 h = tonumber(tile.h), updated_at = stamp, } end end local count = 0 for _ in pairs(layout.tiles) do count = count + 1 end if count > max_tiles then return { err = 'too many tiles' } end redis.call('SET', key, cjson.encode(layout), 'EX', ttl) return stamp "; pub fn layout_key(room_id: Uuid) -> String { format!("chat:voice:{room_id}:layout") } fn now_secs() -> i64 { OffsetDateTime::now_utc().unix_timestamp() } fn livekit(service: &ChatService) -> Result { let cfg = &service.settings.livekit; if !cfg.enabled { return Err(ChatError::BadRequest("livekit is not enabled".to_string())); } Ok(LiveKitApi::with_api_key( cfg.api_url.trim(), cfg.api_key.trim(), cfg.api_secret.trim(), )) } /// 语音能力的全局限定:房间必须存在、必须是 Voice/Stage、调用者可读。 pub async fn require_voice_room( service: &ChatService, room_id: Uuid, uid: i64, ) -> Result { let ctx = require_room_readable(service, room_id, uid).await?; if !matches!( ctx.room.kind, models::room::rooms::RoomKind::Voice | models::room::rooms::RoomKind::Stage ) { return Err(ChatError::BadRequest( "room is not a voice channel".to_string(), )); } Ok(ctx.room) } /// 调用者是否能主持(房间角色 >= Moderator,或空间 manage_channels)。 pub async fn can_moderate_voice( service: &ChatService, room_id: Uuid, uid: i64, ) -> Result { let ctx = crate::share::lookup::lookup_room_context(service, room_id).await?; match &ctx.space { Some(space) => crate::share::authz::can_manage_space(service, space, uid).await, None => Ok(false), } } /// Stage 成员是否拥有发言权:房间角色 >= Moderator,或已有 approved 举手。 pub async fn has_publish_grant( service: &ChatService, room_id: Uuid, uid: i64, ) -> Result { if can_moderate_voice(service, room_id, uid).await? { return Ok(true); } Ok(matches!( read_hand(service, room_id, uid).await?.map(|h| h.state), Some(HandState::Approved) )) } /// 参与者 id 的解析:identity 是 uid 字符串。 pub fn identity_to_uid(identity: &str) -> Option { identity.parse::().ok() } pub async fn display_names( service: &ChatService, uids: &[i64], ) -> HashMap)> { let mut map = HashMap::new(); let users = match service.audience.load_user_briefs(uids).await { Ok(users) => users, Err(_) => return map, }; for (uid, user) in users { let avatar_url = api::resolve_user_avatar_url(uid, &user.avatar_url); let avatar = if avatar_url.trim().is_empty() { None } else { Some(avatar_url) }; let name = if user.display_name.trim().is_empty() { user.username } else { user.display_name }; map.insert(uid, (name, avatar)); } map } /// 把 Redis 里的一位成员写回(webhook 与对账共用)。 pub async fn write_member( service: &ChatService, room_id: Uuid, member: &VoiceMember, ) -> Result<(), ChatError> { let ttl = service.settings.livekit.presence_ttl_secs.max(60); let value = serde_json::to_string(member)?; let mut conn = service.participants.redis.connection_clone(); use cache::RedisConnection; let key = voice_key(room_id); let field = member.user_id.to_string(); let result: Result = match &mut conn { RedisConnection::Standalone(c) => { let written: Result = redis::cmd("HSET") .arg(&key) .arg(&field) .arg(&value) .query_async(c) .await; if written.is_ok() { let _: Result = redis::cmd("EXPIRE").arg(&key).arg(ttl).query_async(c).await; } written } RedisConnection::Cluster(c) => { let written: Result = redis::cmd("HSET") .arg(&key) .arg(&field) .arg(&value) .query_async(c) .await; if written.is_ok() { let _: Result = redis::cmd("EXPIRE").arg(&key).arg(ttl).query_async(c).await; } written } }; result.map(|_| ()).map_err(ChatError::from) } pub async fn remove_member( service: &ChatService, room_id: Uuid, uid: i64, ) -> Result<(), ChatError> { let mut conn = service.participants.redis.connection_clone(); use cache::RedisConnection; let key = voice_key(room_id); let field = uid.to_string(); let _: Result = match &mut conn { RedisConnection::Standalone(c) => { redis::cmd("HDEL") .arg(&key) .arg(&field) .query_async(c) .await } RedisConnection::Cluster(c) => { redis::cmd("HDEL") .arg(&key) .arg(&field) .query_async(c) .await } }; let hkey = hands_key(room_id); let _: Result = match &mut conn { RedisConnection::Standalone(c) => { redis::cmd("HDEL") .arg(&hkey) .arg(&field) .query_async(c) .await } RedisConnection::Cluster(c) => { redis::cmd("HDEL") .arg(&hkey) .arg(&field) .query_async(c) .await } }; Ok(()) } pub async fn clear_room(service: &ChatService, room_id: Uuid) -> Result<(), ChatError> { let mut conn = service.participants.redis.connection_clone(); use cache::RedisConnection; for key in [voice_key(room_id), hands_key(room_id)] { let _: Result = match &mut conn { RedisConnection::Standalone(c) => redis::cmd("DEL").arg(&key).query_async(c).await, RedisConnection::Cluster(c) => redis::cmd("DEL").arg(&key).query_async(c).await, }; } Ok(()) } /// 读取房间当前在 Redis 中的成员快照。 pub async fn read_members( service: &ChatService, room_id: Uuid, ) -> Result, ChatError> { let mut conn = service.participants.redis.connection_clone(); use cache::RedisConnection; let key = voice_key(room_id); let raw: Result, redis::RedisError> = match &mut conn { RedisConnection::Standalone(c) => redis::cmd("HGETALL").arg(&key).query_async(c).await, RedisConnection::Cluster(c) => redis::cmd("HGETALL").arg(&key).query_async(c).await, }; let raw = raw.unwrap_or_default(); let mut members: Vec = raw .into_values() .filter_map(|value| serde_json::from_str::(&value).ok()) .collect(); members.sort_by_key(|m| m.joined_at); Ok(members) } pub async fn read_hand( service: &ChatService, room_id: Uuid, uid: i64, ) -> Result, ChatError> { let mut conn = service.participants.redis.connection_clone(); use cache::RedisConnection; let key = hands_key(room_id); let field = uid.to_string(); let raw: Result, redis::RedisError> = match &mut conn { RedisConnection::Standalone(c) => { redis::cmd("HGET") .arg(&key) .arg(&field) .query_async(c) .await } RedisConnection::Cluster(c) => { redis::cmd("HGET") .arg(&key) .arg(&field) .query_async(c) .await } }; Ok(raw .ok() .flatten() .and_then(|v| serde_json::from_str(&v).ok())) } pub async fn read_hands( service: &ChatService, room_id: Uuid, ) -> Result, ChatError> { let mut conn = service.participants.redis.connection_clone(); use cache::RedisConnection; let key = hands_key(room_id); let raw: Result, redis::RedisError> = match &mut conn { RedisConnection::Standalone(c) => redis::cmd("HGETALL").arg(&key).query_async(c).await, RedisConnection::Cluster(c) => redis::cmd("HGETALL").arg(&key).query_async(c).await, }; let mut map = HashMap::new(); for (field, value) in raw.unwrap_or_default() { if let (Ok(uid), Ok(entry)) = ( field.parse::(), serde_json::from_str::(&value), ) { map.insert(uid, entry); } } Ok(map) } pub async fn write_hand( service: &ChatService, room_id: Uuid, uid: i64, entry: &HandEntry, ) -> Result<(), ChatError> { let ttl = service.settings.livekit.hand_ttl_secs.max(60); let value = serde_json::to_string(entry)?; let mut conn = service.participants.redis.connection_clone(); use cache::RedisConnection; let key = hands_key(room_id); let field = uid.to_string(); let result: Result = match &mut conn { RedisConnection::Standalone(c) => { let written: Result = redis::cmd("HSET") .arg(&key) .arg(&field) .arg(&value) .query_async(c) .await; if written.is_ok() { let _: Result = redis::cmd("EXPIRE").arg(&key).arg(ttl).query_async(c).await; } written } RedisConnection::Cluster(c) => { let written: Result = redis::cmd("HSET") .arg(&key) .arg(&field) .arg(&value) .query_async(c) .await; if written.is_ok() { let _: Result = redis::cmd("EXPIRE").arg(&key).arg(ttl).query_async(c).await; } written } }; result.map(|_| ()).map_err(ChatError::from) } impl ChatService { /// 前端搭接参数。 pub fn voice_config(&self) -> VoiceConfigDto { let cfg = &self.settings.livekit; VoiceConfigDto { enabled: cfg.enabled, url: cfg.browser_url().to_string(), recording_enabled: cfg.recording_enabled, } } /// 为调用者签发 LiveKit 访问令牌。 pub async fn voice_token( &self, caller: i64, room_id: Uuid, ) -> Result { let cfg = self.settings.livekit.clone(); if !cfg.enabled { return Err(ChatError::BadRequest("livekit is not enabled".to_string())); } let room = require_voice_room(self, room_id, caller).await?; let is_stage = room.kind == models::room::rooms::RoomKind::Stage; let can_publish = if is_stage { has_publish_grant(self, room_id, caller).await? } else { true }; let api = livekit(self)?; let room_name = room_id.to_string(); let limit = room .user_limit .map(|v| v.max(1) as u32) .unwrap_or(cfg.default_max_participants); if let Ok(participants) = api.room().list_participants(&room_name).await { let already_inside = participants .iter() .any(|p| identity_to_uid(&p.identity) == Some(caller)); if !already_inside && participants.len() as u32 >= limit { return Err(ChatError::Conflict("voice room is full".to_string())); } } let _ = api .room() .create_room( &room_name, CreateRoomOptions { empty_timeout: cfg.empty_timeout_secs, departure_timeout: cfg.departure_timeout_secs, max_participants: limit, ..Default::default() }, ) .await; let names = display_names(self, &[caller]).await; let (display_name, _) = names .get(&caller) .cloned() .unwrap_or_else(|| (caller.to_string(), None)); let grants = VideoGrants { room_join: true, room: room_name.clone(), can_subscribe: true, can_publish, can_publish_data: true, can_publish_sources: if can_publish { { vec![ "camera".to_string(), "microphone".to_string(), "screen_share".to_string(), "screen_share_audio".to_string(), ] } } else { Vec::new() }, ..Default::default() }; let token = AccessToken::with_api_key(cfg.api_key.trim(), cfg.api_secret.trim()) .with_identity(&caller.to_string()) .with_name(&display_name) .with_ttl(std::time::Duration::from_secs(cfg.token_ttl_secs)) .with_grants(grants) .to_jwt() .map_err(|error| ChatError::Internal(format!("livekit token: {error}")))?; let hand_state = if is_stage { read_hand(self, room_id, caller).await?.map(|h| h.state) } else { None }; Ok(VoiceTokenDto { token, url: cfg.browser_url().to_string(), room_name, expires_in_secs: cfg.token_ttl_secs, can_publish, is_stage, hand_state, }) } /// 以 LiveKit 为权威源对账,并回写 Redis。 pub async fn voice_participants( &self, caller: i64, room_id: Uuid, ) -> Result, ChatError> { require_voice_room(self, room_id, caller).await?; let api = livekit(self)?; let room_name = room_id.to_string(); let participants = api .room() .list_participants(&room_name) .await .map_err(|error| ChatError::Internal(format!("livekit list participants: {error}")))?; let uids: Vec = participants .iter() .filter_map(|p| identity_to_uid(&p.identity)) .collect(); let names = display_names(self, &uids).await; let stored: HashMap = read_members(self, room_id) .await? .into_iter() .map(|m| (m.user_id, m)) .collect(); let mut out = Vec::with_capacity(participants.len()); for participant in participants { let Some(uid) = identity_to_uid(&participant.identity) else { continue; }; let can_publish = participant .permission .as_ref() .map(|p| p.can_publish) .unwrap_or(true); let moderator_muted = stored.get(&uid).map(|m| m.moderator_muted).unwrap_or(false); let tracks: Vec = participant .tracks .iter() .map(|t| VoiceTrackDto { sid: t.sid.clone(), kind: proto::TrackType::try_from(t.r#type) .map(|k| k.as_str_name().to_string()) .unwrap_or_else(|_| "UNKNOWN".to_string()), source: proto::TrackSource::try_from(t.source) .map(|s| s.as_str_name().to_string()) .unwrap_or_else(|_| "UNKNOWN".to_string()), muted: t.muted, }) .collect(); let (display_name, avatar_url) = names .get(&uid) .cloned() .unwrap_or_else(|| (participant.name.clone(), None)); let member = VoiceMember { user_id: uid, display_name: display_name.clone(), avatar_url: avatar_url.clone(), joined_at: participant.joined_at, can_publish, moderator_muted, tracks: tracks.clone(), livekit_sid: Some(participant.sid.clone()), }; write_member(self, room_id, &member).await?; out.push(VoiceParticipantDto { user_id: uid, display_name, avatar_url, joined_at: participant.joined_at, can_publish, moderator_muted, speaking: false, tracks, }); } out.sort_by_key(|p| p.joined_at); Ok(out) } /// 侧栏用的整空间语音人数(纯 Redis 读,缺房间不报错)。 pub async fn voice_space_states( &self, caller: i64, space_name: &str, ) -> Result { let space = lookup_space_by_name(self, space_name).await?; crate::share::lookup::require_space_member(self, &space, caller).await?; let rooms = models::room::rooms::Entity::find() .filter(models::room::rooms::Column::SpaceId.eq(space.id())) .filter(models::room::rooms::Column::IsActive.eq(true)) .filter(models::room::rooms::Column::Kind.is_in([ models::room::rooms::RoomKind::Voice, models::room::rooms::RoomKind::Stage, ])) .all(self.database.read()) .await?; let mut out = Vec::with_capacity(rooms.len()); for room in rooms { let count = read_members(self, room.id).await?.len() as u32; out.push(VoiceRoomStateDto { room_id: room.id, count, }); } Ok(VoiceSpaceStatesDto { rooms: out }) } /// 读取房间共享的窗口布局;没有存过就是空布局(前端回落到自动网格)。 pub async fn voice_layout( &self, caller: i64, room_id: Uuid, ) -> Result { require_voice_room(self, room_id, caller).await?; self.voice_layout_raw(room_id).await } /// 纯读取,不做权限校验:调用方要么刚校验过,要么只用于合并写入。 async fn voice_layout_raw(&self, room_id: Uuid) -> Result { let mut conn = self.participants.redis.connection_clone(); use cache::RedisConnection; let key = layout_key(room_id); let raw: Result, redis::RedisError> = match &mut conn { RedisConnection::Standalone(c) => redis::cmd("GET").arg(&key).query_async(c).await, RedisConnection::Cluster(c) => redis::cmd("GET").arg(&key).query_async(c).await, }; let raw = raw.map_err(ChatError::from)?; let Some(raw) = raw else { return Ok(VoiceLayoutDto::default()); }; Ok(serde_json::from_str(&raw).unwrap_or_default()) } /// 按 key 合并布局补丁并广播给房间里的所有人。坐标一律夹到 0~1,越界写入 /// 不可能发生;返回写入时刻,让客户端能分辨“我发的”和“比我新的”。 pub async fn save_voice_layout( &self, caller: i64, room_id: Uuid, patch: VoiceLayoutPatchDto, ) -> Result { require_voice_room(self, room_id, caller).await?; if patch.tiles.is_empty() && patch.removed.is_empty() { return Err(ChatError::BadRequest("empty layout patch".to_string())); } let stamp = now_secs(); let payload = serde_json::json!({ "tiles": patch.tiles, "removed": patch.removed, }) .to_string(); let ttl = self.settings.livekit.presence_ttl_secs.max(60); let mut conn = self.participants.redis.connection_clone(); use cache::RedisConnection; let key = layout_key(room_id); let mut command = redis::cmd("EVAL"); command .arg(MERGE_LAYOUT_SCRIPT) .arg(1_u8) .arg(&key) .arg(stamp) .arg(&payload) .arg(MAX_LAYOUT_TILES as i64) .arg(ttl); let written: Result = match &mut conn { RedisConnection::Standalone(c) => command.clone().query_async(c).await, RedisConnection::Cluster(c) => command.clone().query_async(c).await, }; let written = match written { Ok(updated_at) => Ok(updated_at), // The script signals a full map with an error reply; turn it back into // the same 400 the previous whole-map write produced. Err(error) if error.to_string().contains("too many tiles") => Err( ChatError::BadRequest(format!("at most {MAX_LAYOUT_TILES} windows can be placed")), ), Err(error) => Err(ChatError::from(error)), }?; self.audience .realtime .publish_room(&RoomEvent::VoiceLayout { room_id }) .await; Ok(VoiceLayoutSavedDto { updated_at: written, }) } /// 举手。Stage 频道之外、已有发言权都不允许。 pub async fn raise_hand(&self, caller: i64, room_id: Uuid) -> Result { let room = require_voice_room(self, room_id, caller).await?; if room.kind != models::room::rooms::RoomKind::Stage { return Err(ChatError::BadRequest( "hands are only available in stage channels".to_string(), )); } if has_publish_grant(self, room_id, caller).await? { return Err(ChatError::Conflict("already able to speak".to_string())); } let entry = HandEntry { state: HandState::Pending, requested_at: now_secs(), decided_by: None, }; write_hand(self, room_id, caller, &entry).await?; self.audience .realtime .publish_room(&RoomEvent::VoiceHands { room_id }) .await; let names = display_names(self, &[caller]).await; let (display_name, avatar_url) = names .get(&caller) .cloned() .unwrap_or_else(|| (caller.to_string(), None)); Ok(HandDto { user_id: caller, display_name, avatar_url, state: entry.state, requested_at: entry.requested_at, }) } pub async fn cancel_hand(&self, caller: i64, room_id: Uuid) -> Result<(), ChatError> { require_voice_room(self, room_id, caller).await?; let existing = read_hand(self, room_id, caller).await?; if matches!(existing.map(|h| h.state), Some(HandState::Approved)) { return Err(ChatError::Conflict( "an approved speaker must be revoked by a moderator".to_string(), )); } let mut conn = self.participants.redis.connection_clone(); use cache::RedisConnection; let key = hands_key(room_id); let field = caller.to_string(); let _: Result = match &mut conn { RedisConnection::Standalone(c) => { redis::cmd("HDEL") .arg(&key) .arg(&field) .query_async(c) .await } RedisConnection::Cluster(c) => { redis::cmd("HDEL") .arg(&key) .arg(&field) .query_async(c) .await } }; self.audience .realtime .publish_room(&RoomEvent::VoiceHands { room_id }) .await; Ok(()) } pub async fn list_hands(&self, caller: i64, room_id: Uuid) -> Result { require_voice_room(self, room_id, caller).await?; let can_moderate = can_moderate_voice(self, room_id, caller).await?; let stored = read_hands(self, room_id).await?; let my_state = stored.get(&caller).map(|h| h.state); let uids: Vec = stored.keys().copied().collect(); let names = display_names(self, &uids).await; let mut hands: Vec = stored .into_iter() .filter(|(_, entry)| can_moderate || matches!(entry.state, HandState::Approved)) .map(|(uid, entry)| { let (display_name, avatar_url) = names .get(&uid) .cloned() .unwrap_or_else(|| (uid.to_string(), None)); HandDto { user_id: uid, display_name, avatar_url, state: entry.state, requested_at: entry.requested_at, } }) .collect(); hands.sort_by_key(|h| h.requested_at); Ok(VoiceHandsDto { hands, can_moderate, my_state, }) } async fn decide_hand( &self, caller: i64, room_id: Uuid, target: i64, approve: bool, ) -> Result { require_voice_room(self, room_id, caller).await?; if !can_moderate_voice(self, room_id, caller).await? { return Err(ChatError::Forbidden); } let Some(existing) = read_hand(self, room_id, target).await? else { return Err(ChatError::NotFound); }; if !matches!(existing.state, HandState::Pending | HandState::Revoked) { return Err(ChatError::Conflict("hand is not pending".to_string())); } let entry = HandEntry { state: if approve { HandState::Approved } else { HandState::Denied }, requested_at: existing.requested_at, decided_by: Some(caller), }; write_hand(self, room_id, target, &entry).await?; if approve { let _ = self.apply_publish_permission(room_id, target, true).await; } self.audience .realtime .publish_room(&RoomEvent::VoiceHands { room_id }) .await; let action = if approve { "speak_granted" } else { "speak_denied" }; self.audience .realtime .publish_room(&RoomEvent::VoiceModeration { room_id, user_id: target, action: action.to_string(), }) .await; Ok(VoiceModerationDto { ok: true, action: action.to_string(), user_id: target, }) } pub async fn approve_hand( &self, caller: i64, room_id: Uuid, target: i64, ) -> Result { self.decide_hand(caller, room_id, target, true).await } pub async fn deny_hand( &self, caller: i64, room_id: Uuid, target: i64, ) -> Result { self.decide_hand(caller, room_id, target, false).await } /// 收回发言权:翻转 can_publish 并静音已发布的麦克风。 pub async fn revoke_hand( &self, caller: i64, room_id: Uuid, target: i64, ) -> Result { require_voice_room(self, room_id, caller).await?; if !can_moderate_voice(self, room_id, caller).await? { return Err(ChatError::Forbidden); } let existing = read_hand(self, room_id, target).await?; let requested_at = existing .as_ref() .map(|h| h.requested_at) .unwrap_or_else(now_secs); write_hand( self, room_id, target, &HandEntry { state: HandState::Revoked, requested_at, decided_by: Some(caller), }, ) .await?; let _ = self.apply_publish_permission(room_id, target, false).await; self.audience .realtime .publish_room(&RoomEvent::VoiceHands { room_id }) .await; self.audience .realtime .publish_room(&RoomEvent::VoiceModeration { room_id, user_id: target, action: "speak_revoked".to_string(), }) .await; Ok(VoiceModerationDto { ok: true, action: "speak_revoked".to_string(), user_id: target, }) } /// 服务端翻转某人的发布权限(对 LiveKit 与 Redis 同时生效)。 pub async fn apply_publish_permission( &self, room_id: Uuid, target: i64, can_publish: bool, ) -> Result<(), ChatError> { let api = livekit(self)?; let room_name = room_id.to_string(); let identity = target.to_string(); let sources: Vec = if can_publish { vec![ proto::TrackSource::Camera as i32, proto::TrackSource::Microphone as i32, proto::TrackSource::ScreenShare as i32, proto::TrackSource::ScreenShareAudio as i32, ] } else { Vec::new() }; let _ = api .room() .update_participant( &room_name, &identity, UpdateParticipantOptions { permission: Some(proto::ParticipantPermission { can_subscribe: true, can_publish, can_publish_data: true, can_publish_sources: sources, ..Default::default() }), ..Default::default() }, ) .await; if let Some(mut member) = read_members(self, room_id) .await? .into_iter() .find(|m| m.user_id == target) { member.can_publish = can_publish; write_member(self, room_id, &member).await?; } Ok(()) } /// 主持人强制静音/解除静音某人的麦克风。 pub async fn moderate_mute( &self, caller: i64, room_id: Uuid, target: i64, muted: bool, ) -> Result { require_voice_room(self, room_id, caller).await?; if !can_moderate_voice(self, room_id, caller).await? { return Err(ChatError::Forbidden); } let api = livekit(self)?; let room_name = room_id.to_string(); let identity = target.to_string(); if muted { let _ = api .room() .update_participant( &room_name, &identity, UpdateParticipantOptions { permission: Some(proto::ParticipantPermission { can_subscribe: true, can_publish: false, can_publish_data: true, can_publish_sources: Vec::new(), ..Default::default() }), ..Default::default() }, ) .await; if let Ok(participants) = api.room().list_participants(&room_name).await && let Some(participant) = participants.iter().find(|p| p.identity == identity) { for track in &participant.tracks { if track.source == proto::TrackSource::Microphone as i32 && !track.muted { let _ = api .room() .mute_published_track(&room_name, &identity, &track.sid, true) .await; } } } } else { let _ = self.apply_publish_permission(room_id, target, true).await; } if let Some(mut member) = read_members(self, room_id) .await? .into_iter() .find(|m| m.user_id == target) { member.moderator_muted = muted; write_member(self, room_id, &member).await?; } let action = if muted { "muted" } else { "unmuted" }.to_string(); self.audience .realtime .publish_room(&RoomEvent::VoiceModeration { room_id, user_id: target, action: action.clone(), }) .await; Ok(VoiceModerationDto { ok: true, action, user_id: target, }) } /// 强制移出通话(可再次加入)。 pub async fn kick_participant( &self, caller: i64, room_id: Uuid, target: i64, ) -> Result { require_voice_room(self, room_id, caller).await?; if !can_moderate_voice(self, room_id, caller).await? { return Err(ChatError::Forbidden); } if caller == target { return Err(ChatError::BadRequest("cannot remove yourself".to_string())); } let api = livekit(self)?; let room_name = room_id.to_string(); let identity = target.to_string(); let _ = api.room().remove_participant(&room_name, &identity).await; remove_member(self, room_id, target).await?; self.audience .realtime .publish_room(&RoomEvent::VoiceModeration { room_id, user_id: target, action: "removed".to_string(), }) .await; Ok(VoiceModerationDto { ok: true, action: "removed".to_string(), user_id: target, }) } /// 录制预留:需要 egress 组件,本次只做接口占位。 pub async fn start_recording( &self, caller: i64, room_id: Uuid, ) -> Result { self.recording_unavailable(caller, room_id).await } pub async fn stop_recording( &self, caller: i64, room_id: Uuid, ) -> Result { self.recording_unavailable(caller, room_id).await } async fn recording_unavailable( &self, caller: i64, room_id: Uuid, ) -> Result { require_voice_room(self, room_id, caller).await?; if !can_moderate_voice(self, room_id, caller).await? { return Err(ChatError::Forbidden); } if !self.settings.livekit.recording_enabled { return Err(ChatError::BadRequest( "recording is not enabled".to_string(), )); } Err(ChatError::BadRequest( "recording requires the livekit egress component".to_string(), )) } }