Skip to content
Clone
main
530 lines 20.0 KB Rust
use ::session::AuthedUser;
use api::http::{HeaderMap, Json, Path, Query, State};
use api::rest::{AppResponseData, NoData};
use livekit_api::access_token::TokenVerifier;
use livekit_api::webhooks::WebhookReceiver;
use serde::Deserialize;
use uuid::Uuid;
use crate::errors::ApiError;
use crate::services::service::ChatService;
use crate::services::voice::{
HandDto, VoiceConfigDto, VoiceHandsDto, VoiceLayoutDto, VoiceLayoutPatchDto,
VoiceLayoutSavedDto, VoiceModerationDto, VoiceParticipantDto, VoiceRecordingDto,
VoiceSpaceStatesDto, VoiceTokenDto,
};
#[utoipa::path(
get,
path = "/chat/voice/config",
tag = "chat",
description = "语音功能的搭接参数;未启用时 enabled=false,前端隐藏入口。",
responses(
(status = 200, description = "voice config", body = AppResponseData<VoiceConfigDto>),
(status = 401, description = "authentication required", body = AppResponseData<NoData>)
)
)]
pub async fn voice_config(
State(service): State<ChatService>,
AuthedUser(_uid): AuthedUser,
) -> Result<AppResponseData<VoiceConfigDto>, ApiError> {
Ok(AppResponseData::ok(service.voice_config()))
}
#[utoipa::path(
post,
path = "/chat/rooms/{room_id}/voice/token",
tag = "chat",
description = "为当前用户签发进入该语音频道的 LiveKit 令牌;房间满员返回 409。",
params(("room_id" = Uuid, Path, description = "room id")),
responses(
(status = 200, description = "voice token", body = AppResponseData<VoiceTokenDto>),
(status = 400, description = "not a voice channel or livekit disabled", body = AppResponseData<NoData>),
(status = 401, description = "authentication required", body = AppResponseData<NoData>),
(status = 403, description = "forbidden", body = AppResponseData<NoData>),
(status = 409, description = "room is full", body = AppResponseData<NoData>)
)
)]
pub async fn voice_token(
State(service): State<ChatService>,
AuthedUser(uid): AuthedUser,
Path(room_id): Path<Uuid>,
) -> Result<AppResponseData<VoiceTokenDto>, ApiError> {
Ok(AppResponseData::ok(
service.voice_token(uid, room_id).await?,
))
}
#[utoipa::path(
get,
path = "/chat/rooms/{room_id}/voice/participants",
tag = "chat",
description = "以 LiveKit 实时状态为准返回通话参与者,并回写 Redis 在场记录。",
params(("room_id" = Uuid, Path, description = "room id")),
responses(
(status = 200, description = "participants", body = AppResponseData<Vec<VoiceParticipantDto>>),
(status = 401, description = "authentication required", body = AppResponseData<NoData>),
(status = 403, description = "forbidden", body = AppResponseData<NoData>)
)
)]
pub async fn voice_participants(
State(service): State<ChatService>,
AuthedUser(uid): AuthedUser,
Path(room_id): Path<Uuid>,
) -> Result<AppResponseData<Vec<VoiceParticipantDto>>, ApiError> {
Ok(AppResponseData::ok(
service.voice_participants(uid, room_id).await?,
))
}
#[utoipa::path(
get,
path = "/chat/spaces/{space_name}/voice-states",
tag = "chat",
description = "侧栏用:该空间下每个语音/舞台频道当前在线人数。",
params(("space_name" = String, Path, description = "space name")),
responses(
(status = 200, description = "voice states", body = AppResponseData<VoiceSpaceStatesDto>),
(status = 401, description = "authentication required", body = AppResponseData<NoData>)
)
)]
pub async fn voice_space_states(
State(service): State<ChatService>,
AuthedUser(uid): AuthedUser,
Path(space_name): Path<String>,
) -> Result<AppResponseData<VoiceSpaceStatesDto>, ApiError> {
Ok(AppResponseData::ok(
service.voice_space_states(uid, &space_name).await?,
))
}
#[utoipa::path(
get,
path = "/chat/rooms/{room_id}/voice/hands",
tag = "chat",
description = "舞台举手列表;主持人看到全部申请,普通成员只看到自己与已通过者。",
params(("room_id" = Uuid, Path, description = "room id")),
responses(
(status = 200, description = "hands", body = AppResponseData<VoiceHandsDto>),
(status = 401, description = "authentication required", body = AppResponseData<NoData>)
)
)]
pub async fn list_hands(
State(service): State<ChatService>,
AuthedUser(uid): AuthedUser,
Path(room_id): Path<Uuid>,
) -> Result<AppResponseData<VoiceHandsDto>, ApiError> {
Ok(AppResponseData::ok(service.list_hands(uid, room_id).await?))
}
#[utoipa::path(
get,
path = "/chat/rooms/{room_id}/voice/layout",
tag = "chat",
description = "读取房间内共享的参与者窗口布局(坐标按画布宽高归一化)。",
params(("room_id" = Uuid, Path, description = "room id")),
responses(
(status = 200, description = "shared layout", body = AppResponseData<VoiceLayoutDto>),
(status = 401, description = "authentication required", body = AppResponseData<NoData>),
(status = 403, description = "operation not allowed", body = AppResponseData<NoData>)
)
)]
pub async fn get_voice_layout(
State(service): State<ChatService>,
AuthedUser(uid): AuthedUser,
Path(room_id): Path<Uuid>,
) -> Result<AppResponseData<VoiceLayoutDto>, ApiError> {
Ok(AppResponseData::ok(
service.voice_layout(uid, room_id).await?,
))
}
#[utoipa::path(
put,
path = "/chat/rooms/{room_id}/voice/layout",
tag = "chat",
description = "按 key 合并保存参与者窗口布局并广播给房间内所有人:请求只带自己改动的那些格子(收回的列在 removed 里),越界坐标会被夹回画布内。",
params(("room_id" = Uuid, Path, description = "room id")),
request_body = VoiceLayoutPatchDto,
responses(
(status = 200, description = "layout saved", body = AppResponseData<VoiceLayoutSavedDto>),
(status = 400, description = "request failed validation", body = AppResponseData<NoData>),
(status = 401, description = "authentication required", body = AppResponseData<NoData>),
(status = 403, description = "operation not allowed", body = AppResponseData<NoData>)
)
)]
pub async fn save_voice_layout(
State(service): State<ChatService>,
AuthedUser(uid): AuthedUser,
Path(room_id): Path<Uuid>,
Json(patch): Json<VoiceLayoutPatchDto>,
) -> Result<AppResponseData<VoiceLayoutSavedDto>, ApiError> {
Ok(AppResponseData::ok(
service.save_voice_layout(uid, room_id, patch).await?,
))
}
#[utoipa::path(
post,
path = "/chat/rooms/{room_id}/voice/hands",
tag = "chat",
description = "在舞台频道举手申请发言。",
params(("room_id" = Uuid, Path, description = "room id")),
responses(
(status = 200, description = "hand raised", body = AppResponseData<HandDto>),
(status = 400, description = "not a stage channel", body = AppResponseData<NoData>),
(status = 409, description = "already able to speak", body = AppResponseData<NoData>)
)
)]
pub async fn raise_hand(
State(service): State<ChatService>,
AuthedUser(uid): AuthedUser,
Path(room_id): Path<Uuid>,
) -> Result<AppResponseData<HandDto>, ApiError> {
Ok(AppResponseData::ok(service.raise_hand(uid, room_id).await?))
}
#[utoipa::path(
delete,
path = "/chat/rooms/{room_id}/voice/hands/@me",
tag = "chat",
description = "撤回自己的举手申请。",
params(("room_id" = Uuid, Path, description = "room id")),
responses(
(status = 200, description = "hand cancelled", body = AppResponseData<NoData>),
(status = 401, description = "authentication required", body = AppResponseData<NoData>),
(status = 409, description = "an approved speaker must be revoked by a moderator", body = AppResponseData<NoData>)
)
)]
pub async fn cancel_hand(
State(service): State<ChatService>,
AuthedUser(uid): AuthedUser,
Path(room_id): Path<Uuid>,
) -> Result<AppResponseData<NoData>, ApiError> {
service.cancel_hand(uid, room_id).await?;
Ok(AppResponseData::empty())
}
#[utoipa::path(
post,
path = "/chat/rooms/{room_id}/voice/hands/{user_id}/approve",
tag = "chat",
description = "主持人批准发言:翻转该成员 can_publish,无需重连。",
params(
("room_id" = Uuid, Path, description = "room id"),
("user_id" = i64, Path, description = "target user id")
),
responses(
(status = 200, description = "approved", body = AppResponseData<VoiceModerationDto>),
(status = 403, description = "forbidden", body = AppResponseData<NoData>),
(status = 404, description = "hand not found", body = AppResponseData<NoData>)
)
)]
pub async fn approve_hand(
State(service): State<ChatService>,
AuthedUser(uid): AuthedUser,
Path((room_id, user_id)): Path<(Uuid, i64)>,
) -> Result<AppResponseData<VoiceModerationDto>, ApiError> {
Ok(AppResponseData::ok(
service.approve_hand(uid, room_id, user_id).await?,
))
}
#[utoipa::path(
post,
path = "/chat/rooms/{room_id}/voice/hands/{user_id}/deny",
tag = "chat",
description = "主持人拒绝发言申请。",
params(
("room_id" = Uuid, Path, description = "room id"),
("user_id" = i64, Path, description = "target user id")
),
responses(
(status = 200, description = "denied", body = AppResponseData<VoiceModerationDto>),
(status = 403, description = "forbidden", body = AppResponseData<NoData>)
)
)]
pub async fn deny_hand(
State(service): State<ChatService>,
AuthedUser(uid): AuthedUser,
Path((room_id, user_id)): Path<(Uuid, i64)>,
) -> Result<AppResponseData<VoiceModerationDto>, ApiError> {
Ok(AppResponseData::ok(
service.deny_hand(uid, room_id, user_id).await?,
))
}
#[utoipa::path(
post,
path = "/chat/rooms/{room_id}/voice/hands/{user_id}/revoke",
tag = "chat",
description = "主持人收回某人的发言权:can_publish=false 并静音其麦克风。",
params(
("room_id" = Uuid, Path, description = "room id"),
("user_id" = i64, Path, description = "target user id")
),
responses(
(status = 200, description = "revoked", body = AppResponseData<VoiceModerationDto>),
(status = 403, description = "forbidden", body = AppResponseData<NoData>)
)
)]
pub async fn revoke_hand(
State(service): State<ChatService>,
AuthedUser(uid): AuthedUser,
Path((room_id, user_id)): Path<(Uuid, i64)>,
) -> Result<AppResponseData<VoiceModerationDto>, ApiError> {
Ok(AppResponseData::ok(
service.revoke_hand(uid, room_id, user_id).await?,
))
}
#[derive(Debug, Clone, Deserialize)]
pub struct ModerateQuery {
#[serde(default)]
pub muted: bool,
}
#[utoipa::path(
post,
path = "/chat/rooms/{room_id}/voice/participants/{user_id}/mute",
tag = "chat",
description = "主持人强制静音或解除静音某人的麦克风(?muted=false 解除)。",
params(
("room_id" = Uuid, Path, description = "room id"),
("user_id" = i64, Path, description = "target user id"),
("muted" = Option<bool>, Query, description = "false to unmute")
),
responses(
(status = 200, description = "moderated", body = AppResponseData<VoiceModerationDto>),
(status = 403, description = "forbidden", body = AppResponseData<NoData>)
)
)]
pub async fn moderate_mute(
State(service): State<ChatService>,
AuthedUser(uid): AuthedUser,
Path((room_id, user_id)): Path<(Uuid, i64)>,
Query(query): Query<ModerateQuery>,
) -> Result<AppResponseData<VoiceModerationDto>, ApiError> {
Ok(AppResponseData::ok(
service
.moderate_mute(uid, room_id, user_id, query.muted)
.await?,
))
}
#[utoipa::path(
delete,
path = "/chat/rooms/{room_id}/voice/participants/{user_id}",
tag = "chat",
description = "主持人把某人移出通话;该用户可以再次加入。",
params(
("room_id" = Uuid, Path, description = "room id"),
("user_id" = i64, Path, description = "target user id")
),
responses(
(status = 200, description = "removed", body = AppResponseData<VoiceModerationDto>),
(status = 403, description = "forbidden", body = AppResponseData<NoData>)
)
)]
pub async fn kick_participant(
State(service): State<ChatService>,
AuthedUser(uid): AuthedUser,
Path((room_id, user_id)): Path<(Uuid, i64)>,
) -> Result<AppResponseData<VoiceModerationDto>, ApiError> {
Ok(AppResponseData::ok(
service.kick_participant(uid, room_id, user_id).await?,
))
}
#[utoipa::path(
post,
path = "/chat/rooms/{room_id}/voice/recording/start",
tag = "chat",
description = "录制预留接口;[livekit].recording_enabled=false 时返回 400。",
params(("room_id" = Uuid, Path, description = "room id")),
responses(
(status = 200, description = "recording started", body = AppResponseData<VoiceRecordingDto>),
(status = 400, description = "recording is not enabled", body = AppResponseData<NoData>),
(status = 403, description = "forbidden", body = AppResponseData<NoData>)
)
)]
pub async fn start_recording(
State(service): State<ChatService>,
AuthedUser(uid): AuthedUser,
Path(room_id): Path<Uuid>,
) -> Result<AppResponseData<VoiceRecordingDto>, ApiError> {
Ok(AppResponseData::ok(
service.start_recording(uid, room_id).await?,
))
}
#[utoipa::path(
post,
path = "/chat/rooms/{room_id}/voice/recording/stop",
tag = "chat",
description = "停止录制(预留接口)。",
params(("room_id" = Uuid, Path, description = "room id")),
responses(
(status = 200, description = "recording stopped", body = AppResponseData<VoiceRecordingDto>),
(status = 400, description = "recording is not enabled", body = AppResponseData<NoData>),
(status = 403, description = "forbidden", body = AppResponseData<NoData>)
)
)]
pub async fn stop_recording(
State(service): State<ChatService>,
AuthedUser(uid): AuthedUser,
Path(room_id): Path<Uuid>,
) -> Result<AppResponseData<VoiceRecordingDto>, ApiError> {
Ok(AppResponseData::ok(
service.stop_recording(uid, room_id).await?,
))
}
/// LiveKit webhook:无会话,用 api_key/api_secret 验签 Authorization 与 body sha256。
#[utoipa::path(
post,
path = "/chat/livekit/webhook",
tag = "chat",
description = "LiveKit 事件回调(签名校验,无会话)。",
responses(
(status = 200, description = "ok", body = AppResponseData<NoData>),
(status = 401, description = "invalid signature", body = AppResponseData<NoData>)
)
)]
pub async fn livekit_webhook(
State(service): State<ChatService>,
headers: HeaderMap,
body: String,
) -> Result<AppResponseData<NoData>, ApiError> {
let cfg = service.settings.livekit.clone();
if !cfg.enabled {
return Ok(AppResponseData::empty());
}
let token = headers
.get("authorization")
.and_then(|value| value.to_str().ok())
.ok_or_else(ApiError::unauthorized)?;
let verifier = TokenVerifier::with_api_key(cfg.api_key.trim(), cfg.api_secret.trim());
let receiver = WebhookReceiver::new(verifier);
let event = receiver.receive(&body, token).map_err(|error| {
tracing::warn!(%error, "livekit webhook rejected");
ApiError::unauthorized()
})?;
let Some(room) = event.room.as_ref() else {
return Ok(AppResponseData::empty());
};
let Ok(room_id) = Uuid::parse_str(&room.name) else {
return Ok(AppResponseData::empty());
};
match event.event.as_str() {
"room_started" => {
service
.audience
.realtime
.publish_room(&crate::share::realtime::RoomEvent::VoicePresence { room_id })
.await;
}
"room_finished" => {
if let Err(error) = crate::services::voice::clear_room(&service, room_id).await {
tracing::warn!(%error, %room_id, "voice room clear failed");
}
service
.audience
.realtime
.publish_room(&crate::share::realtime::RoomEvent::VoicePresence { room_id })
.await;
}
"participant_joined" => {
if let Some(participant) = event.participant.as_ref()
&& let Some(uid) = crate::services::voice::identity_to_uid(&participant.identity)
{
let names = crate::services::voice::display_names(&service, &[uid]).await;
let (display_name, avatar_url) = names
.get(&uid)
.cloned()
.unwrap_or_else(|| (participant.name.clone(), None));
let member = crate::services::voice::VoiceMember {
user_id: uid,
display_name,
avatar_url,
joined_at: participant.joined_at,
can_publish: participant
.permission
.as_ref()
.map(|p| p.can_publish)
.unwrap_or(true),
moderator_muted: false,
tracks: Vec::new(),
livekit_sid: Some(participant.sid.clone()),
};
if let Err(error) =
crate::services::voice::write_member(&service, room_id, &member).await
{
tracing::warn!(%error, %room_id, "voice member write failed");
}
}
service
.audience
.realtime
.publish_room(&crate::share::realtime::RoomEvent::VoicePresence { room_id })
.await;
}
"participant_left" => {
if let Some(participant) = event.participant.as_ref()
&& let Some(uid) = crate::services::voice::identity_to_uid(&participant.identity)
&& let Err(error) =
crate::services::voice::remove_member(&service, room_id, uid).await
{
tracing::warn!(%error, %room_id, "voice member remove failed");
}
service
.audience
.realtime
.publish_room(&crate::share::realtime::RoomEvent::VoicePresence { room_id })
.await;
service
.audience
.realtime
.publish_room(&crate::share::realtime::RoomEvent::VoiceHands { room_id })
.await;
}
"track_published" | "track_unpublished" => {
if let Some(participant) = event.participant.as_ref()
&& let Some(uid) = crate::services::voice::identity_to_uid(&participant.identity)
{
let tracks = participant
.tracks
.iter()
.map(|t| crate::services::voice::VoiceTrackDto {
sid: t.sid.clone(),
kind: livekit_protocol::TrackType::try_from(t.r#type)
.map(|k| k.as_str_name().to_string())
.unwrap_or_else(|_| "UNKNOWN".to_string()),
source: livekit_protocol::TrackSource::try_from(t.source)
.map(|s| s.as_str_name().to_string())
.unwrap_or_else(|_| "UNKNOWN".to_string()),
muted: t.muted,
})
.collect();
if let Some(mut member) = crate::services::voice::read_members(&service, room_id)
.await
.unwrap_or_default()
.into_iter()
.find(|m| m.user_id == uid)
{
member.tracks = tracks;
let _ = crate::services::voice::write_member(&service, room_id, &member).await;
}
}
service
.audience
.realtime
.publish_room(&crate::share::realtime::RoomEvent::VoicePresence { room_id })
.await;
}
_ => {}
}
Ok(AppResponseData::empty())
}