Skip to content
Clone

refactor(chat): merge chat-room-store back into the chat crate

- move the channel command layer (SpaceContext/CreateRoomCmd/RoomDto plus
  create/deactivate/room helpers) into chat services next to the room DTOs
- move the create/delete channel endpoints and services from space into
  ChatService; ChatService now holds SpaceService for the room-creation quota
  check, and bootstrap builds the space service first
- simplify routing: channel admin routes share ChatService state instead of a
  separately-mounted SpaceService router
- delete the bridge crate and its dependencies, drop the unused room_commands
  alias, and delete channels from the cleanup admin through ChatService

openapi.json is unchanged.
ZhenYi committed
parent 99b1f99 commit 179d50d Browse files

22 files changed

+370 −484

File tree 22
Modified lib/chat/src/handlers/rooms.rs View lib/chat/src/handlers/rooms.rs
@@ -5,7 +5,9 @@
55use uuid::Uuid;
66
77use crate::errors::ApiError;
8use crate::services::rooms::{RoomBriefDto, UpdateRoomCmd};
8use space::services::dto::SpacePath;
9
10use crate::services::rooms::{CreateRoomCmd, RoomBriefDto, RoomDto, UpdateRoomCmd};
911use crate::services::service::ChatService;
1012
1113#[utoipa::path(
@@ -76,3 +78,56 @@
7678 let room = service.update_room(uid, room_id, cmd).await?;
7779 Ok(AppResponseData::ok(room))
7880}
81
82#[utoipa::path(
83 post,
84 path = "/chat/spaces/{space_name}/rooms",
85 tag = "chat",
86 description = "在空间下创建聊天频道;公开频道会自动加入全部活跃成员。",
87 params(SpacePath),
88 request_body = CreateRoomCmd,
89 responses(
90 (status = 200, description = "room created", body = AppResponseData<RoomDto>),
91 (status = 429, description = "creation quota exceeded", body = AppResponseData<NoData>),
92 (status = 400, description = "bad request", body = AppResponseData<NoData>),
93 (status = 403, description = "forbidden", body = AppResponseData<NoData>),
94 (status = 404, description = "space not found", body = AppResponseData<NoData>),
95 (status = 409, description = "room name conflict", body = AppResponseData<NoData>),
96 (status = 401, description = "authentication required", body = AppResponseData<NoData>)
97 )
98)]
99pub async fn create_room(
100 State(service): State<ChatService>,
101 session: Session,
102 Path(path): Path<SpacePath>,
103 Json(cmd): Json<CreateRoomCmd>,
104) -> Result<AppResponseData<RoomDto>, ApiError> {
105 let uid = session::require_uid(&session)?;
106 let room = service.create_room(uid, &path.space_name, cmd).await?;
107 Ok(AppResponseData::ok(room))
108}
109
110#[utoipa::path(
111 delete,
112 path = "/chat/rooms/{room_id}",
113 tag = "chat",
114 description = "软删除聊天频道并停止其对外可见。",
115 params(
116 ("room_id" = Uuid, Path, description = "room id")
117 ),
118 responses(
119 (status = 200, description = "room deleted", body = AppResponseData<NoData>),
120 (status = 403, description = "forbidden", body = AppResponseData<NoData>),
121 (status = 404, description = "room or space not found", body = AppResponseData<NoData>),
122 (status = 401, description = "authentication required", body = AppResponseData<NoData>)
123 )
124)]
125pub async fn delete_room(
126 State(service): State<ChatService>,
127 session: Session,
128 Path(room_id): Path<Uuid>,
129) -> Result<AppResponseData<NoData>, ApiError> {
130 let uid = session::require_uid(&session)?;
131 service.delete_room(uid, room_id).await?;
132 Ok(AppResponseData::empty())
133}
Modified lib/chat/src/services/rooms.rs View lib/chat/src/services/rooms.rs
@@ -1,12 +1,12 @@
11use export::users::UserDto;
22use models::chat::messages::{self, ChatMessageType};
33use models::chat::{read_states, room_seq, room_user_settings};
4use models::dir::dirs;
4use models::dir::dirs::{self, DirKind};
55use models::room::rooms::{self, RoomKind};
66use sea_orm::sea_query::Expr;
77use sea_orm::{
8 ActiveModelTrait, ColumnTrait, EntityTrait, QueryFilter, QueryOrder, QuerySelect, Set,
9 TransactionTrait,
8 ActiveModelTrait, ColumnTrait, ConnectionTrait, EntityTrait, QueryFilter, QueryOrder,
9 QuerySelect, Set, TransactionTrait,
1010};
1111use serde::{Deserialize, Serialize};
1212use time::OffsetDateTime;
@@ -488,3 +488,273 @@
488488 dir::set_user_overwrite(tx, dir_id, owner_id, dir::PERM_VISIBLE, 0, None).await?;
489489 Ok(())
490490}
491
492#[derive(Clone, Serialize)]
493pub struct SpaceContext {
494 pub id: Uuid,
495 pub lower_name: String,
496 pub name: String,
497 pub avatar_url: Option<String>,
498}
499
500#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
501pub struct CreateRoomCmd {
502 pub name: String,
503 #[serde(default)]
504 pub kind: RoomKind,
505 #[serde(default)]
506 pub topic: Option<String>,
507 #[serde(default)]
508 pub is_private: bool,
509 #[serde(default)]
510 pub nsfw: bool,
511 #[serde(default)]
512 pub user_limit: Option<i32>,
513 #[serde(default)]
514 pub rate_limit_per_user: Option<i32>,
515 #[serde(default)]
516 pub parent_id: Option<Uuid>,
517}
518
519#[derive(Debug, Clone, Serialize, ToSchema)]
520pub struct RoomDto {
521 pub id: Uuid,
522 pub space_id: Option<Uuid>,
523 pub dir_id: Option<Uuid>,
524 pub kind: RoomKind,
525 pub name: String,
526 pub topic: Option<String>,
527 pub position: i64,
528 pub parent_id: Option<Uuid>,
529 pub owner_id: i64,
530 pub is_private: bool,
531 pub nsfw: bool,
532 pub user_limit: Option<i32>,
533 pub rate_limit_per_user: Option<i32>,
534 pub is_active: bool,
535 pub created_at: OffsetDateTime,
536 pub updated_at: OffsetDateTime,
537}
538
539impl From<rooms::Model> for RoomDto {
540 fn from(r: rooms::Model) -> Self {
541 Self {
542 id: r.id,
543 space_id: r.space_id,
544 dir_id: r.dir_id,
545 kind: r.kind,
546 name: r.name,
547 topic: r.topic,
548 position: r.position,
549 parent_id: r.parent_id,
550 owner_id: r.owner_id,
551 is_private: r.is_private,
552 nsfw: r.nsfw,
553 user_limit: r.user_limit,
554 rate_limit_per_user: r.rate_limit_per_user,
555 is_active: r.is_active,
556 created_at: r.created_at,
557 updated_at: r.updated_at,
558 }
559 }
560}
561
562async fn room(tx: &impl ConnectionTrait, id: Uuid) -> Result<RoomDto, ChatError> {
563 rooms::Entity::find_by_id(id)
564 .one(tx)
565 .await?
566 .filter(|room| room.is_active)
567 .map(RoomDto::from)
568 .ok_or(ChatError::NotFound)
569}
570
571async fn create_space_room(
572 tx: &impl ConnectionTrait,
573 space: &SpaceContext,
574 actor: i64,
575 cmd: CreateRoomCmd,
576) -> Result<RoomDto, ChatError> {
577 if !matches!(
578 cmd.kind,
579 RoomKind::GuildRoom
580 | RoomKind::Text
581 | RoomKind::Category
582 | RoomKind::News
583 | RoomKind::Voice
584 | RoomKind::Stage
585 ) {
586 return Err(ChatError::BadRequest("unsupported room kind: only guild_room, text, category, news, voice and stage can be created".to_owned()));
587 }
588 let name = cmd.name.trim();
589 if name.is_empty() || name.chars().count() > 100 {
590 return Err(ChatError::BadRequest(
591 "room name is empty or too long".to_owned(),
592 ));
593 }
594 let lower = name.to_lowercase();
595 if !lower
596 .chars()
597 .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-')
598 {
599 return Err(ChatError::BadRequest(
600 "invalid room name: must contain only lowercase letters, digits, and hyphens"
601 .to_owned(),
602 ));
603 }
604 if rooms::Entity::find()
605 .filter(rooms::Column::SpaceId.eq(space.id))
606 .filter(rooms::Column::LowerName.eq(&lower))
607 .one(tx)
608 .await?
609 .is_some()
610 {
611 return Err(ChatError::Conflict(format!(
612 "room name {lower} already exists in this space"
613 )));
614 }
615 let dir_kind = match cmd.kind {
616 RoomKind::Voice | RoomKind::Stage => DirKind::VoiceChannel,
617 _ => DirKind::ChatChannel,
618 };
619 let node = dir::create_object(
620 tx,
621 actor,
622 dir::NewDir {
623 space_id: space.id,
624 kind: dir_kind,
625 name: name.to_owned(),
626 parent_dir_id: cmd.parent_id,
627 position: None,
628 topic: cmd.topic.clone(),
629 purpose: None,
630 icon: None,
631 color: None,
632 },
633 )
634 .await?;
635 let now = OffsetDateTime::now_utc();
636 let created = rooms::ActiveModel {
637 id: Set(Uuid::now_v7()),
638 space_id: Set(Some(space.id)),
639 dir_id: Set(Some(node.id)),
640 kind: Set(cmd.kind),
641 lower_name: Set(lower),
642 name: Set(name.to_owned()),
643 topic: Set(cmd.topic),
644 position: Set(node.position),
645 parent_id: Set(cmd.parent_id),
646 owner_id: Set(actor),
647 is_private: Set(cmd.is_private),
648 nsfw: Set(cmd.nsfw),
649 user_limit: Set(cmd.user_limit),
650 rate_limit_per_user: Set(cmd.rate_limit_per_user),
651 bitrate: Set(None),
652 last_message_id: Set(None),
653 is_active: Set(true),
654 created_at: Set(now),
655 updated_at: Set(now),
656 }
657 .insert(tx)
658 .await?;
659 if created.is_private
660 && let Some(dir_id) = created.dir_id
661 {
662 dir::set_user_overwrite(tx, dir_id, actor, dir::PERM_VISIBLE, 0, None).await?;
663 }
664 Ok(created.into())
665}
666
667async fn deactivate_space_room(
668 tx: &impl ConnectionTrait,
669 space: &SpaceContext,
670 actor: i64,
671 room_id: Uuid,
672) -> Result<(), ChatError> {
673 let row = rooms::Entity::find_by_id(room_id)
674 .one(tx)
675 .await?
676 .filter(|row| row.is_active && row.space_id == Some(space.id))
677 .ok_or(ChatError::NotFound)?;
678 let detail = room_detail(&row);
679 let dir_id = row.dir_id;
680 let mut active: rooms::ActiveModel = row.into();
681 active.is_active = Set(false);
682 active.updated_at = Set(OffsetDateTime::now_utc());
683 active.update(tx).await?;
684 if let Some(dir_id) = dir_id {
685 dir::archive(tx, actor, dir_id).await?;
686 }
687 events::emit(
688 tx,
689 "chat.room.update",
690 events::Labels::new()
691 .with(events::label::ROOM_ID, room_id)
692 .with(events::label::ACTOR_USER_ID, actor),
693 serde_json::json!({
694 "room_id": room_id, "deleted": true, "room": detail, "space": space,
695 }),
696 )
697 .await?;
698 Ok(())
699}
700
701fn room_detail(room: &rooms::Model) -> serde_json::Value {
702 serde_json::json!({ "id": room.id, "space_id": room.space_id, "dir_id": room.dir_id, "kind": room.kind, "name": room.name, "topic": room.topic })
703}
704
705impl ChatService {
706 pub async fn create_room(
707 &self,
708 caller: i64,
709 space_name: &str,
710 cmd: CreateRoomCmd,
711 ) -> Result<RoomDto, ChatError> {
712 self.space
713 .allow_room_creation(caller)
714 .await
715 .map_err(|error| match error {
716 space::errors::SpaceError::RateLimited(message) => ChatError::RateLimited(message),
717 other => ChatError::Internal(other.to_string()),
718 })?;
719 let space = lookup_space_by_name(self, space_name).await?;
720 if !authz::can_manage_space(self, &space, caller).await? {
721 return Err(ChatError::Forbidden);
722 }
723 let context = SpaceContext {
724 id: space.id(),
725 lower_name: space.slug().to_owned(),
726 name: space.name().to_owned(),
727 avatar_url: space.avatar_url().await?,
728 };
729 let created = self
730 .database
731 .write()
732 .transaction::<_, RoomDto, ChatError>(move |tx| {
733 Box::pin(async move { create_space_room(tx, &context, caller, cmd).await })
734 })
735 .await?;
736 Ok(created)
737 }
738
739 pub async fn delete_room(&self, caller: i64, room_id: Uuid) -> Result<(), ChatError> {
740 let room = room(self.database.read(), room_id).await?;
741 let space_id = room.space_id.ok_or(ChatError::NotFound)?;
742 let space = self.participants.access.by_id(space_id).await?;
743 if !authz::can_manage_space(self, &space, caller).await? {
744 return Err(ChatError::Forbidden);
745 }
746 let context = SpaceContext {
747 id: space.id(),
748 lower_name: space.slug().to_owned(),
749 name: space.name().to_owned(),
750 avatar_url: space.avatar_url().await?,
751 };
752 self.database
753 .write()
754 .transaction::<_, (), ChatError>(move |tx| {
755 Box::pin(async move { deactivate_space_room(tx, &context, caller, room_id).await })
756 })
757 .await?;
758 Ok(())
759 }
760}
Modified lib/chat/src/services/service.rs View lib/chat/src/services/service.rs
@@ -10,6 +10,7 @@
1010 pub(crate) audience: crate::services::audience::Audience,
1111 pub(crate) settings: ChatSettings,
1212 pub(crate) participants: crate::services::presence::Participants,
13 pub(crate) space: space::services::service::SpaceService,
1314}
1415
1516impl ChatService {
@@ -19,6 +20,7 @@
1920 audience: crate::services::audience::Audience,
2021 settings: ChatSettings,
2122 participants: crate::services::presence::Participants,
23 space: space::services::service::SpaceService,
2224 ) -> Self {
2325 Self {
2426 database,
@@ -26,6 +28,7 @@
2628 audience,
2729 settings,
2830 participants,
31 space,
2932 }
3033 }
3134}
Modified lib/chat/src/lib.rs View lib/chat/src/lib.rs
@@ -1,4 +1,3 @@
1pub use chat_room_store as room_commands;
21pub mod errors;
32pub mod handlers;
43pub mod openapi;
Modified lib/chat/src/openapi.rs View lib/chat/src/openapi.rs
@@ -4,8 +4,8 @@
44#[derive(OpenApi)]
55#[openapi(
66 paths(
7 space::handlers::create_room::create_room,
8 space::handlers::delete_room::delete_room,
7 crate::handlers::rooms::create_room,
8 crate::handlers::rooms::delete_room,
99 crate::handlers::rooms::list_space_rooms,
1010 crate::handlers::rooms::get_room,
1111 crate::handlers::rooms::update_room,
Modified lib/chat/src/routes.rs View lib/chat/src/routes.rs
@@ -20,7 +20,7 @@
2020 get_read_state, list_space_read_states, mark_read, mark_thread_read,
2121};
2222use crate::handlers::reminders::{cancel_reminder, create_reminder, list_reminders};
23use crate::handlers::rooms::{get_room, list_space_rooms, update_room};
23use crate::handlers::rooms::{create_room, delete_room, get_room, list_space_rooms, update_room};
2424use crate::handlers::settings::{get_room_setting, update_room_setting};
2525use crate::handlers::threads::{
2626 create_thread, follow_thread, get_thread, list_my_threads, list_threads, unfollow_thread,
@@ -34,25 +34,16 @@
3434use crate::handlers::webhooks::{create_webhook, delete_webhook, list_webhooks, post_via_webhook};
3535use crate::services::service::ChatService;
3636
37pub fn router(
38 _service: ChatService,
39 space_service: space::services::service::SpaceService,
40) -> Router<ChatService> {
41 let space_room_routes = Router::<ChatService>::new()
37pub fn router(_service: ChatService) -> Router<ChatService> {
38 let guarded = Router::<ChatService>::new()
4239 .route(
4340 "/chat/spaces/{space_name}/rooms",
44 export::http::routing::post(space::handlers::create_room::create_room),
41 get(list_space_rooms).post(create_room),
4542 )
4643 .route(
4744 "/chat/rooms/{room_id}",
48 delete(space::handlers::delete_room::delete_room),
45 get(get_room).patch(update_room).delete(delete_room),
4946 )
50 .with_state(space_service)
51 .wrap(export::http::Guard::Session);
52
53 let guarded = Router::<ChatService>::new()
54 .route("/chat/spaces/{space_name}/rooms", get(list_space_rooms))
55 .route("/chat/rooms/{room_id}", get(get_room).patch(update_room))
5647 .route(
5748 "/chat/rooms/{room_id}/messages",
5849 get(list_messages).post(send_message),
@@ -248,7 +239,7 @@
248239 .route("/chat/hooks/{token}", post(post_via_webhook))
249240 .route("/chat/livekit/webhook", post(livekit_webhook));
250241
251 guarded.merge(open).merge(space_room_routes)
242 guarded.merge(open)
252243}
253244
254245/// 内部路由:只由服务间调用(`x-gitdata-service-key` 校验),不暴露给浏览器。
Modified lib/chat/Cargo.toml View lib/chat/Cargo.toml
@@ -6,7 +6,6 @@
66[dependencies]
77auth = { workspace = true }
88dir = { workspace = true }
9chat-room-store = { path = "../chat-room-store" }
109email = { workspace = true }
1110async-trait = { workspace = true }
1211config = { workspace = true }
Deleted lib/chat-room-store/src/lib.rs
@@ -1,242 +0,0 @@
1use models::dir::dirs::DirKind;
2use models::room::rooms::{self, RoomKind};
3use sea_orm::{ActiveModelTrait, ColumnTrait, ConnectionTrait, EntityTrait, QueryFilter, Set};
4use serde::{Deserialize, Serialize};
5use time::OffsetDateTime;
6use utoipa::ToSchema;
7use uuid::Uuid;
8
9#[derive(Debug, thiserror::Error)]
10pub enum Error {
11 #[error("{0}")]
12 InvalidInput(String),
13 #[error("{0}")]
14 Conflict(String),
15 #[error("room not found")]
16 NotFound,
17 #[error(transparent)]
18 Database(#[from] sea_orm::DbErr),
19}
20
21impl From<dir::Error> for Error {
22 fn from(error: dir::Error) -> Self {
23 match error {
24 dir::Error::InvalidInput(message) => Self::InvalidInput(message),
25 dir::Error::NotFound => Self::NotFound,
26 dir::Error::Database(error) => Self::Database(error),
27 }
28 }
29}
30
31#[derive(Clone, Serialize)]
32pub struct SpaceContext {
33 pub id: Uuid,
34 pub lower_name: String,
35 pub name: String,
36 pub avatar_url: Option<String>,
37}
38
39#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
40pub struct CreateRoomCmd {
41 pub name: String,
42 #[serde(default)]
43 pub kind: RoomKind,
44 #[serde(default)]
45 pub topic: Option<String>,
46 #[serde(default)]
47 pub is_private: bool,
48 #[serde(default)]
49 pub nsfw: bool,
50 #[serde(default)]
51 pub user_limit: Option<i32>,
52 #[serde(default)]
53 pub rate_limit_per_user: Option<i32>,
54 #[serde(default)]
55 pub parent_id: Option<Uuid>,
56}
57
58#[derive(Debug, Clone, Serialize, ToSchema)]
59pub struct RoomDto {
60 pub id: Uuid,
61 pub space_id: Option<Uuid>,
62 pub dir_id: Option<Uuid>,
63 pub kind: RoomKind,
64 pub name: String,
65 pub topic: Option<String>,
66 pub position: i64,
67 pub parent_id: Option<Uuid>,
68 pub owner_id: i64,
69 pub is_private: bool,
70 pub nsfw: bool,
71 pub user_limit: Option<i32>,
72 pub rate_limit_per_user: Option<i32>,
73 pub is_active: bool,
74 pub created_at: OffsetDateTime,
75 pub updated_at: OffsetDateTime,
76}
77
78impl From<rooms::Model> for RoomDto {
79 fn from(r: rooms::Model) -> Self {
80 Self {
81 id: r.id,
82 space_id: r.space_id,
83 dir_id: r.dir_id,
84 kind: r.kind,
85 name: r.name,
86 topic: r.topic,
87 position: r.position,
88 parent_id: r.parent_id,
89 owner_id: r.owner_id,
90 is_private: r.is_private,
91 nsfw: r.nsfw,
92 user_limit: r.user_limit,
93 rate_limit_per_user: r.rate_limit_per_user,
94 is_active: r.is_active,
95 created_at: r.created_at,
96 updated_at: r.updated_at,
97 }
98 }
99}
100
101pub async fn room(tx: &impl ConnectionTrait, id: Uuid) -> Result<RoomDto, Error> {
102 rooms::Entity::find_by_id(id)
103 .one(tx)
104 .await?
105 .filter(|room| room.is_active)
106 .map(RoomDto::from)
107 .ok_or(Error::NotFound)
108}
109
110pub async fn create_space_room(
111 tx: &impl ConnectionTrait,
112 space: &SpaceContext,
113 actor: i64,
114 cmd: CreateRoomCmd,
115) -> Result<RoomDto, Error> {
116 if !matches!(
117 cmd.kind,
118 RoomKind::GuildRoom
119 | RoomKind::Text
120 | RoomKind::Category
121 | RoomKind::News
122 | RoomKind::Voice
123 | RoomKind::Stage
124 ) {
125 return Err(Error::InvalidInput("unsupported room kind: only guild_room, text, category, news, voice and stage can be created".to_owned()));
126 }
127 let name = cmd.name.trim();
128 if name.is_empty() || name.chars().count() > 100 {
129 return Err(Error::InvalidInput(
130 "room name is empty or too long".to_owned(),
131 ));
132 }
133 let lower = name.to_lowercase();
134 if !lower
135 .chars()
136 .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-')
137 {
138 return Err(Error::InvalidInput(
139 "invalid room name: must contain only lowercase letters, digits, and hyphens"
140 .to_owned(),
141 ));
142 }
143 if rooms::Entity::find()
144 .filter(rooms::Column::SpaceId.eq(space.id))
145 .filter(rooms::Column::LowerName.eq(&lower))
146 .one(tx)
147 .await?
148 .is_some()
149 {
150 return Err(Error::Conflict(format!(
151 "room name {lower} already exists in this space"
152 )));
153 }
154 let dir_kind = match cmd.kind {
155 RoomKind::Voice | RoomKind::Stage => DirKind::VoiceChannel,
156 _ => DirKind::ChatChannel,
157 };
158 let node = dir::create_object(
159 tx,
160 actor,
161 dir::NewDir {
162 space_id: space.id,
163 kind: dir_kind,
164 name: name.to_owned(),
165 parent_dir_id: cmd.parent_id,
166 position: None,
167 topic: cmd.topic.clone(),
168 purpose: None,
169 icon: None,
170 color: None,
171 },
172 )
173 .await?;
174 let now = OffsetDateTime::now_utc();
175 let created = rooms::ActiveModel {
176 id: Set(Uuid::now_v7()),
177 space_id: Set(Some(space.id)),
178 dir_id: Set(Some(node.id)),
179 kind: Set(cmd.kind),
180 lower_name: Set(lower),
181 name: Set(name.to_owned()),
182 topic: Set(cmd.topic),
183 position: Set(node.position),
184 parent_id: Set(cmd.parent_id),
185 owner_id: Set(actor),
186 is_private: Set(cmd.is_private),
187 nsfw: Set(cmd.nsfw),
188 user_limit: Set(cmd.user_limit),
189 rate_limit_per_user: Set(cmd.rate_limit_per_user),
190 bitrate: Set(None),
191 last_message_id: Set(None),
192 is_active: Set(true),
193 created_at: Set(now),
194 updated_at: Set(now),
195 }
196 .insert(tx)
197 .await?;
198 if created.is_private
199 && let Some(dir_id) = created.dir_id
200 {
201 dir::set_user_overwrite(tx, dir_id, actor, dir::PERM_VISIBLE, 0, None).await?;
202 }
203 Ok(created.into())
204}
205
206pub async fn deactivate_space_room(
207 tx: &impl ConnectionTrait,
208 space: &SpaceContext,
209 actor: i64,
210 room_id: Uuid,
211) -> Result<(), Error> {
212 let row = rooms::Entity::find_by_id(room_id)
213 .one(tx)
214 .await?
215 .filter(|row| row.is_active && row.space_id == Some(space.id))
216 .ok_or(Error::NotFound)?;
217 let detail = room_detail(&row);
218 let dir_id = row.dir_id;
219 let mut active: rooms::ActiveModel = row.into();
220 active.is_active = Set(false);
221 active.updated_at = Set(OffsetDateTime::now_utc());
222 active.update(tx).await?;
223 if let Some(dir_id) = dir_id {
224 dir::archive(tx, actor, dir_id).await?;
225 }
226 events::emit(
227 tx,
228 "chat.room.update",
229 events::Labels::new()
230 .with(events::label::ROOM_ID, room_id)
231 .with(events::label::ACTOR_USER_ID, actor),
232 serde_json::json!({
233 "room_id": room_id, "deleted": true, "room": detail, "space": space,
234 }),
235 )
236 .await?;
237 Ok(())
238}
239
240fn room_detail(room: &rooms::Model) -> serde_json::Value {
241 serde_json::json!({ "id": room.id, "space_id": room.space_id, "dir_id": room.dir_id, "kind": room.kind, "name": room.name, "topic": room.topic })
242}
Deleted lib/chat-room-store/Cargo.toml
@@ -1,16 +0,0 @@
1[package]
2name = "chat-room-store"
3version.workspace = true
4edition.workspace = true
5
6[dependencies]
7events = { workspace = true }
8dir = { workspace = true }
9models = { workspace = true }
10sea-orm = { workspace = true }
11serde = { workspace = true, features = ["derive"] }
12serde_json = { workspace = true }
13thiserror = { workspace = true }
14time = { workspace = true }
15utoipa = { workspace = true }
16uuid = { workspace = true }
Deleted lib/space/src/handlers/create_room.rs
@@ -1,37 +0,0 @@
1use ::session::Session;
2use export::http::{Json, Path, State};
3use export::rest::{AppResponseData, NoData};
4
5use crate::errors::ApiError;
6use crate::services::dto::SpacePath;
7use crate::services::rooms::{CreateRoomCmd, RoomDto};
8use crate::services::service::SpaceService;
9use ::session::share::session;
10
11#[utoipa::path(
12 post,
13 path = "/chat/spaces/{space_name}/rooms",
14 tag = "chat",
15 description = "在空间下创建聊天频道;公开频道会自动加入全部活跃成员。",
16 params(SpacePath),
17 request_body = CreateRoomCmd,
18 responses(
19 (status = 200, description = "room created", body = AppResponseData<RoomDto>),
20 (status = 429, description = "creation quota exceeded", body = AppResponseData<NoData>),
21 (status = 400, description = "bad request", body = AppResponseData<NoData>),
22 (status = 403, description = "forbidden", body = AppResponseData<NoData>),
23 (status = 404, description = "space not found", body = AppResponseData<NoData>),
24 (status = 409, description = "room name conflict", body = AppResponseData<NoData>),
25 (status = 401, description = "authentication required", body = AppResponseData<NoData>)
26 )
27)]
28pub async fn create_room(
29 State(service): State<SpaceService>,
30 session: Session,
31 Path(path): Path<SpacePath>,
32 Json(cmd): Json<CreateRoomCmd>,
33) -> Result<AppResponseData<RoomDto>, ApiError> {
34 let uid = session::require_uid(&session)?;
35 let room = service.create_room(uid, &path.space_name, cmd).await?;
36 Ok(AppResponseData::ok(room))
37}
Deleted lib/space/src/handlers/delete_room.rs
@@ -1,33 +0,0 @@
1use ::session::Session;
2use export::http::{Path, State};
3use export::rest::{AppResponseData, NoData};
4use uuid::Uuid;
5
6use crate::errors::ApiError;
7use crate::services::service::SpaceService;
8use ::session::share::session;
9
10#[utoipa::path(
11 delete,
12 path = "/chat/rooms/{room_id}",
13 tag = "chat",
14 description = "软删除聊天频道并停止其对外可见。",
15 params(
16 ("room_id" = Uuid, Path, description = "room id")
17 ),
18 responses(
19 (status = 200, description = "room deleted", body = AppResponseData<NoData>),
20 (status = 403, description = "forbidden", body = AppResponseData<NoData>),
21 (status = 404, description = "room or space not found", body = AppResponseData<NoData>),
22 (status = 401, description = "authentication required", body = AppResponseData<NoData>)
23 )
24)]
25pub async fn delete_room(
26 State(service): State<SpaceService>,
27 session: Session,
28 Path(room_id): Path<Uuid>,
29) -> Result<AppResponseData<NoData>, ApiError> {
30 let uid = session::require_uid(&session)?;
31 service.delete_room(uid, room_id).await?;
32 Ok(AppResponseData::empty())
33}
Modified lib/space/src/handlers/mod.rs View lib/space/src/handlers/mod.rs
@@ -2,10 +2,8 @@
22pub mod create_dir_node;
33pub mod create_group;
44pub mod create_join_rule;
5pub mod create_room;
65pub mod delete_group;
76pub mod delete_join_rule;
8pub mod delete_room;
97pub mod delete_space_image;
108pub mod explore_spaces;
119pub mod get_group;
Modified lib/space/src/services/mod.rs View lib/space/src/services/mod.rs
@@ -10,7 +10,6 @@
1010pub mod member;
1111pub mod permission;
1212pub mod role;
13pub mod rooms;
1413pub mod service;
1514pub mod types;
1615
Deleted lib/space/src/services/rooms.rs
@@ -1,86 +0,0 @@
1use sea_orm::TransactionTrait;
2use uuid::Uuid;
3
4use chat_room_store::SpaceContext;
5pub use chat_room_store::{CreateRoomCmd, RoomDto};
6
7use crate::authority;
8use crate::errors::SpaceError;
9use crate::services::service::SpaceService;
10use crate::share::authz::{MANAGE_CHANNELS, require_perm};
11use crate::share::lookup;
12
13impl From<chat_room_store::Error> for SpaceError {
14 fn from(error: chat_room_store::Error) -> Self {
15 match error {
16 chat_room_store::Error::InvalidInput(message) => Self::BadRequest(message),
17 chat_room_store::Error::Conflict(message) => Self::Conflict(message),
18 chat_room_store::Error::NotFound => Self::NotFound,
19 chat_room_store::Error::Database(error) => Self::from(error),
20 }
21 }
22}
23
24impl SpaceService {
25 #[tracing::instrument(name = "space.create_room", skip_all, fields(caller = %caller, space = %space_name))]
26 pub async fn create_room(
27 &self,
28 caller: i64,
29 space_name: &str,
30 cmd: CreateRoomCmd,
31 ) -> Result<RoomDto, SpaceError> {
32 self.policy.allow_room_creation(caller).await?;
33 let space = lookup::lookup_space_by_name(self, space_name).await?;
34 require_perm(self, &space, caller, MANAGE_CHANNELS).await?;
35 if !authority::space_is_active(&space) {
36 return Err(SpaceError::NotFound);
37 }
38 let context = SpaceContext {
39 id: space.id,
40 lower_name: space.slug.clone(),
41 name: space.name.clone(),
42 avatar_url: authority::space_avatar(self, &space).await?,
43 };
44 let created = self
45 .access
46 .database
47 .write()
48 .transaction::<_, RoomDto, SpaceError>(move |tx| {
49 Box::pin(async move {
50 Ok(chat_room_store::create_space_room(tx, &context, caller, cmd).await?)
51 })
52 })
53 .await?;
54 Ok(created)
55 }
56
57 #[tracing::instrument(name = "space.delete_room", skip_all, fields(caller = %caller, room_id = %room_id))]
58 pub async fn delete_room(&self, caller: i64, room_id: Uuid) -> Result<(), SpaceError> {
59 let room = chat_room_store::room(self.access.database.read(), room_id).await?;
60 let space_id = room.space_id.ok_or(SpaceError::NotFound)?;
61 let space = authority::space_by_id(self, space_id).await?;
62 require_perm(self, &space, caller, MANAGE_CHANNELS).await?;
63 if !authority::space_is_active(&space) {
64 return Err(SpaceError::NotFound);
65 }
66 let context = SpaceContext {
67 id: space.id,
68 lower_name: space.slug.clone(),
69 name: space.name.clone(),
70 avatar_url: authority::space_avatar(self, &space).await?,
71 };
72 self.access
73 .database
74 .write()
75 .transaction::<_, (), SpaceError>(move |tx| {
76 Box::pin(async move {
77 Ok(
78 chat_room_store::deactivate_space_room(tx, &context, caller, room_id)
79 .await?,
80 )
81 })
82 })
83 .await?;
84 Ok(())
85 }
86}
Modified lib/space/src/services/service.rs View lib/space/src/services/service.rs
@@ -1103,3 +1103,9 @@
11031103 }
11041104 }
11051105}
1106
1107impl SpaceService {
1108 pub async fn allow_room_creation(&self, caller: i64) -> Result<(), crate::errors::SpaceError> {
1109 self.policy.allow_room_creation(caller).await
1110 }
1111}
Modified lib/space/src/authority.rs View lib/space/src/authority.rs
@@ -33,16 +33,6 @@
3333 Ok(SpaceExtras::find_by_id(space_id).one(db).await?)
3434}
3535
36pub async fn space_avatar(
37 state: &SpaceService,
38 space: &spaces::Model,
39) -> Result<Option<String>, SpaceError> {
40 Ok(extras(state.access.database.read(), space.id)
41 .await?
42 .and_then(|row| row.avatar_url)
43 .or_else(|| space.avatar_url.clone()))
44}
45
4636pub async fn member_count(state: &SpaceService, space_id: Uuid) -> Result<i64, SpaceError> {
4737 use sea_orm::PaginatorTrait;
4838
@@ -53,17 +43,6 @@
5343 .count(state.access.database.read())
5444 .await?;
5545 Ok(count as i64)
56}
57
58pub async fn space_by_id(
59 state: &SpaceService,
60 space_id: Uuid,
61) -> Result<spaces::Model, SpaceError> {
62 Spaces::find_by_id(space_id)
63 .filter(spaces::Column::DeletedAt.is_null())
64 .one(state.access.database.read())
65 .await?
66 .ok_or(SpaceError::NotFound)
6746}
6847
6948pub async fn space_by_slug(state: &SpaceService, slug: &str) -> Result<spaces::Model, SpaceError> {
Modified lib/space/Cargo.toml View lib/space/Cargo.toml
@@ -5,7 +5,6 @@
55
66[dependencies]
77auth = { workspace = true }
8chat-room-store = { path = "../chat-room-store" }
98dir = { workspace = true }
109async-trait = { workspace = true }
1110base64 = { workspace = true }
Modified lib/webapi/src/domains/chat.rs View lib/webapi/src/domains/chat.rs
@@ -3,7 +3,7 @@
33use crate::bootstrap::Deps;
44
55pub fn router(deps: &Deps) -> Router<()> {
6 chat::routes::router(deps.chat.clone(), deps.space.clone()).with_state(deps.chat.clone())
6 chat::routes::router(deps.chat.clone()).with_state(deps.chat.clone())
77}
88
99pub fn internal(deps: &Deps) -> Router<()> {
Modified lib/webapi/src/bootstrap.rs View lib/webapi/src/bootstrap.rs
@@ -34,7 +34,6 @@
3434 auth::services::AuthSettings::from_config(&state.config),
3535 state.mail.clone(),
3636 );
37 let chat = build_chat_service(&state, &accounts, &notifications, &access);
3837 let issue_audience = issues::services::audience::Audience::new(
3938 accounts.clone(),
4039 notifications.clone(),
@@ -66,6 +65,7 @@
6665 ),
6766 notifications.clone(),
6867 );
68 let chat = build_chat_service(&state, &accounts, &notifications, &access, &space);
6969 let user = user::services::service::UserService::new(
7070 state.database.clone(),
7171 accounts.clone(),
@@ -128,6 +128,7 @@
128128 accounts: &auth::Accounts,
129129 notifications: &export::notify::Notifications,
130130 access: &space::Access,
131 space: &space::services::service::SpaceService,
131132) -> chat::services::service::ChatService {
132133 let audience = chat::services::audience::Audience::new(
133134 accounts.clone(),
@@ -153,5 +154,6 @@
153154 audience,
154155 chat::services::service::ChatSettings::from_config(&state.config),
155156 participants,
157 space.clone(),
156158 )
157159}
Modified lib/webapi/src/cleanup.rs View lib/webapi/src/cleanup.rs
@@ -46,6 +46,7 @@
4646pub struct CleanupState {
4747 pub database: std::sync::Arc<export::AppDatabase>,
4848 pub space: SpaceService,
49 pub chat: chat::services::service::ChatService,
4950 pub issues: IssueService,
5051 pub git: GitService,
5152}
@@ -81,12 +82,14 @@
8182pub fn router(
8283 database: std::sync::Arc<export::AppDatabase>,
8384 space: SpaceService,
85 chat: chat::services::service::ChatService,
8486 issues: IssueService,
8587 git: GitService,
8688) -> Router {
8789 let state = CleanupState {
8890 database,
8991 space,
92 chat,
9093 issues,
9194 git,
9295 };
@@ -147,6 +150,20 @@
147150 }
148151}
149152
153fn map_chat(error: chat::errors::ChatError) -> ApiError {
154 use chat::errors::ChatError;
155 match error {
156 ChatError::BadRequest(message) | ChatError::Conflict(message) => {
157 ApiError::bad_request(message)
158 }
159 ChatError::Unauthorized => ApiError::unauthorized(),
160 ChatError::Forbidden => ApiError::forbidden(),
161 ChatError::NotFound => ApiError::not_found("resource not found"),
162 ChatError::RateLimited(message) => ApiError::bad_request(message),
163 other => ApiError::internal(other.to_string()),
164 }
165}
166
150167fn dir_internal(error: dir::Error) -> ApiError {
151168 match error {
152169 dir::Error::InvalidInput(message) => ApiError::bad_request(message),
@@ -580,10 +597,10 @@
580597 )));
581598 }
582599 state
583 .space
600 .chat
584601 .delete_room(uid, room.id)
585602 .await
586 .map_err(map_space)?;
603 .map_err(map_chat)?;
587604 }
588605 "dir" | "object" => {
589606 let node = dirs::Entity::find_by_id(object_id)
Modified lib/webapi/src/lib.rs View lib/webapi/src/lib.rs
@@ -62,6 +62,7 @@
6262 .merge(cleanup::router(
6363 state.database.clone(),
6464 deps.space.clone(),
65 deps.chat.clone(),
6566 deps.issues.clone(),
6667 deps.git.clone(),
6768 ));
Modified Cargo.lock View Cargo.lock
@@ -1286,7 +1286,6 @@
12861286 "auth",
12871287 "base64 0.23.1",
12881288 "bytes",
1289 "chat-room-store",
12901289 "config 1.0.0",
12911290 "dir",
12921291 "email",
@@ -1324,22 +1323,6 @@
13241323]
13251324
13261325[[package]]
1327name = "chat-room-store"
1328version = "1.0.0"
1329dependencies = [
1330 "dir",
1331 "events",
1332 "models",
1333 "sea-orm",
1334 "serde",
1335 "serde_json",
1336 "thiserror 2.0.21",
1337 "time 0.3.55",
1338 "utoipa 5.5.0",
1339 "uuid",
1340]
1341
1342[[package]]
13431326name = "cheetah-string"
13441327version = "1.2.0"
13451328source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -7218,7 +7201,6 @@
72187201 "auth",
72197202 "base64 0.23.1",
72207203 "cache",
7221 "chat-room-store",
72227204 "chrono",
72237205 "config 1.0.0",
72247206 "db",