feat(search): index chat messages for global search
Build message search documents from the chat/space services, wire incremental indexing through the search outbox and worker, backfill per room with a seq-bounded page, and surface message hits in global search.
ZhenYi committed
This change is very large, so some diffs are not shown. Clone the repository to see the full change.
Modified lib/chat/src/services/messages.rs lib/chat/src/services/messages.rs +7 −2 View lib/chat/src/services/messages.rs
@@ -350,8 +350,8 @@
350350 // notification rows are written inside the transaction and each one has
351351 // to carry the recipient's own copy.
352352 let fallback_locale = i18n::config_fallback(self.config().email.default_locale());
353 let mention_locales = i18n::resolve_user_locales(self.db().read(), &mention_uids, fallback_locale)
354 .await;
353 let mention_locales =
354 i18n::resolve_user_locales(self.db().read(), &mention_uids, fallback_locale).await;
355355
356356 if !cmd.attachments.is_empty() {
357357 let owned = attachment_rows::Entity::find()
@@ -567,6 +567,7 @@
567567 txn_result?;
568568 pending.flush(&self.state.redis).await;
569569
570 self.queue_search_message(message_id).await;
570571 let row = messages::Entity::find_by_id(message_id)
571572 .one(self.db().read())
572573 .await?
@@ -892,6 +893,7 @@
892893 .await?;
893894 pending.flush(&self.state.redis).await;
894895
896 self.queue_search_message(message_id).await;
895897 let row = messages::Entity::find_by_id(message_id)
896898 .one(self.db().read())
897899 .await?
@@ -1018,6 +1020,9 @@
10181020 })
10191021 .await?;
10201022
1023 for &deleted_id in &row_ids {
1024 self.queue_search_message(deleted_id).await;
1025 }
10211026 let attached = attachment_rows::Entity::find()
10221027 .filter(attachment_rows::Column::MessageId.is_in(row_ids.clone()))
10231028 .all(self.db().read())
Modified lib/chat/src/services/rooms.rs lib/chat/src/services/rooms.rs +6 −0 View lib/chat/src/services/rooms.rs
@@ -589,6 +589,12 @@
589589 room_am.is_active = Set(false);
590590 room_am.updated_at = Set(now);
591591 room_am.update(self.db().write()).await?;
592 // 房间没有成员后停用,房间级索引任务会先清空它的消息文档。
593 if let Err(error) =
594 search::outbox::enqueue_room_messages(self.db().write(), room.id).await
595 {
596 tracing::warn!(%room_id, error = %error, "search room purge enqueue failed");
597 }
592598 }
593599 }
594600 }
Modified lib/chat/src/services/service.rs lib/chat/src/services/service.rs +8 −0 View lib/chat/src/services/service.rs
@@ -83,6 +83,14 @@
8383 .map_err(ChatError::from)
8484 }
8585
86 /// 消息落库后的搜索增量同步。检索组件不可用只是"搜不到",绝不回滚消息写入,
87 /// 所以这里只记日志。
88 pub(crate) async fn queue_search_message(&self, message_id: Uuid) {
89 if let Err(error) = search::outbox::enqueue_message(self.db().write(), message_id).await {
90 tracing::warn!(%message_id, error = %error, "search message enqueue failed");
91 }
92 }
93
8694 pub(crate) async fn send_email(
8795 &self,
8896 to: &str,
Modified lib/chat/src/services/webhooks.rs lib/chat/src/services/webhooks.rs +1 −0 View lib/chat/src/services/webhooks.rs
@@ -404,6 +404,7 @@
404404 })
405405 .await?;
406406
407 self.queue_search_message(message_id).await;
407408 let row = chat_messages::Entity::find_by_id(message_id)
408409 .one(self.db().read())
409410 .await?
Modified lib/chat/Cargo.toml lib/chat/Cargo.toml +1 −0 View lib/chat/Cargo.toml
@@ -9,6 +9,7 @@
99export = { workspace = true }
1010i18n = { workspace = true }
1111models = { workspace = true }
12search = { workspace = true }
1213session = { workspace = true }
1314space = { workspace = true }
1415storage = { workspace = true }
Modified lib/search/src/outbox.rs lib/search/src/outbox.rs +15 −0 View lib/search/src/outbox.rs
@@ -47,6 +47,21 @@
4747 enqueue(db, "pull", Some(pull_id), Some(repo_id)).await
4848}
4949
50/// 单条聊天消息的增量索引。
51pub async fn enqueue_message<C: ConnectionTrait>(
52 db: &C,
53 message_id: Uuid,
54) -> Result<(), sea_orm::DbErr> {
55 enqueue(db, "message", Some(message_id), None).await
56}
57
58pub async fn enqueue_room_messages<C: ConnectionTrait>(
59 db: &C,
60 room_id: Uuid,
61) -> Result<(), sea_orm::DbErr> {
62 enqueue(db, "room_messages", Some(room_id), None).await
63}
64
5065pub async fn enqueue_all<C: ConnectionTrait>(db: &C) -> Result<(), sea_orm::DbErr> {
5166 enqueue(db, "all", None, None).await
5267}
Modified lib/search/src/service.rs lib/search/src/service.rs +297 −3 View lib/search/src/service.rs
@@ -4,16 +4,18 @@
44
55use config::search::SearchConfig;
66use export::AppDatabase;
7use models::chat::messages::{self, ChatMessageType, FLAG_EPHEMERAL};
78use models::dir::dirs;
89use models::issue::{comment, issue_item, issue_label, issue_relation_label, issues};
910use models::pull_request::{
1011 pull_request_comment, pull_request_label, pull_request_review, pull_requests,
1112};
1213use models::repo::{repo_collaborations, repos};
14use models::room::{room_bans, room_members, rooms};
1315use models::space::{space_members, spaces};
1416use models::users::user;
1517use reqwest::{Client, StatusCode};
16use sea_orm::{ColumnTrait, EntityTrait, QueryFilter, QueryOrder};
18use sea_orm::{ColumnTrait, EntityTrait, QueryFilter, QueryOrder, QuerySelect};
1719use serde::{Deserialize, Serialize};
1820use serde_json::{Value, json};
1921use tracing::warn;
@@ -57,12 +59,19 @@
5759 Symbol,
5860 Issue,
5961 Pull,
62 Message,
6063}
6164
6265impl SearchKind {
6366 fn parse_list(value: Option<&str>) -> Result<Vec<Self>, SearchError> {
6467 let Some(value) = value.filter(|v| !v.trim().is_empty()) else {
65 return Ok(vec![Self::Code, Self::Symbol, Self::Issue, Self::Pull]);
68 return Ok(vec![
69 Self::Code,
70 Self::Symbol,
71 Self::Issue,
72 Self::Pull,
73 Self::Message,
74 ]);
6675 };
6776 let mut kinds = Vec::new();
6877 for value in value.split(',').map(str::trim).filter(|v| !v.is_empty()) {
@@ -71,6 +80,7 @@
7180 "symbol" | "symbols" => Self::Symbol,
7281 "issue" | "issues" => Self::Issue,
7382 "pull" | "pulls" | "pr" => Self::Pull,
83 "message" | "messages" => Self::Message,
7484 other => return Err(SearchError::Invalid(format!("unknown kind `{other}`"))),
7585 };
7686 if !kinds.contains(&kind) {
@@ -89,6 +99,7 @@
8999 Self::Symbol => "symbols",
90100 Self::Issue => "issues",
91101 Self::Pull => "pulls",
102 Self::Message => "messages",
92103 }
93104 }
94105
@@ -98,6 +109,7 @@
98109 Self::Symbol => "symbol",
99110 Self::Issue => "issue",
100111 Self::Pull => "pull",
112 Self::Message => "message",
101113 }
102114 }
103115}
@@ -134,6 +146,9 @@
134146 pub symbol_name: Option<String>,
135147 pub symbol_kind: Option<String>,
136148 pub definition: Option<bool>,
149 pub room_id: Option<Uuid>,
150 pub room_name: Option<String>,
151 pub thread_id: Option<Uuid>,
137152}
138153
139154#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
@@ -164,6 +179,9 @@
164179 symbol_name: Option<String>,
165180 symbol_kind: Option<String>,
166181 definition: Option<bool>,
182 room_id: Option<String>,
183 room_name: Option<String>,
184 thread_id: Option<String>,
167185 title: String,
168186 content: String,
169187 #[serde(default, skip_serializing, rename = "_matchesPosition")]
@@ -246,6 +264,7 @@
246264impl SearchDocument {
247265 fn hit(self) -> SearchHit {
248266 let kind = match self.kind.as_str() {
267 "message" => SearchKind::Message,
249268 "symbol" => SearchKind::Symbol,
250269 "issue" => SearchKind::Issue,
251270 "pull" => SearchKind::Pull,
@@ -274,6 +293,9 @@
274293 symbol_name: self.symbol_name,
275294 symbol_kind: self.symbol_kind,
276295 definition: self.definition,
296 room_id: self.room_id.and_then(|id| Uuid::parse_str(&id).ok()),
297 room_name: self.room_name,
298 thread_id: self.thread_id.and_then(|id| Uuid::parse_str(&id).ok()),
277299 }
278300 }
279301}
@@ -361,7 +383,7 @@
361383 self.request(reqwest::Method::PATCH, &format!("/indexes/{index}/settings"))
362384 .json(&json!({
363385 "searchableAttributes": searchable,
364 "filterableAttributes": ["repo_id", "space_id", "space_name", "repo_name", "language", "state", "definition"],
386 "filterableAttributes": ["repo_id", "space_id", "space_name", "repo_name", "language", "state", "definition", "room_id"],
365387 "displayedAttributes": ["*"],
366388 })),
367389 )
@@ -543,6 +565,9 @@
543565 .await?;
544566 meili
545567 .ensure_index(&self.index(SearchKind::Pull), &["title", "content"])
568 .await?;
569 meili
570 .ensure_index(&self.index(SearchKind::Message), &["content"])
546571 .await
547572 }
548573
@@ -575,6 +600,12 @@
575600 .map(|ctx| ctx.repo.id.to_string())
576601 .collect();
577602 let visible_spaces = self.visible_space_ids(caller).await?;
603 // 只有真的要查消息时才去算房间可见性,代码/工单检索不必付这份开销。
604 let visible_rooms = if kinds.contains(&SearchKind::Message) {
605 self.visible_room_ids(caller).await?
606 } else {
607 Vec::new()
608 };
578609 let meili = self.meili()?;
579610 let mut buckets: Vec<Vec<SearchHit>> = Vec::new();
580611 let mut total = 0usize;
@@ -589,12 +620,16 @@
589620 if kind == SearchKind::Issue && visible_spaces.is_empty() {
590621 continue;
591622 }
623 if kind == SearchKind::Message && visible_rooms.is_empty() {
624 continue;
625 }
592626 let filter = build_filter(
593627 kind,
594628 &FilterOptions {
595629 definitions_only,
596630 repo_ids: &visible_repo_ids,
597631 space_ids: &visible_spaces,
632 room_ids: &visible_rooms,
598633 space_name: query.space_name.as_deref(),
599634 repo_name: query.repo_name.as_deref(),
600635 language: query.language.as_deref(),
@@ -949,6 +984,141 @@
949984 .await
950985 }
951986
987 pub async fn index_message(&self, message_id: Uuid) -> Result<(), SearchError> {
988 self.ensure_indexes().await?;
989 let meili = self.meili()?;
990 let index = self.index(SearchKind::Message);
991 let Some(message) = messages::Entity::find_by_id(message_id)
992 .one(self.db.read())
993 .await?
994 else {
995 return meili.delete_document(&index, &message_id.to_string()).await;
996 };
997 let Some(document) = self.message_document(&message).await? else {
998 return meili.delete_document(&index, &message_id.to_string()).await;
999 };
1000 meili.add_documents(&index, &[document]).await
1001 }
1002
1003 /// 房间级回填:先清掉该房间的旧文档再重建,避免编辑/删除历史残留。
1004 pub async fn index_room_messages(&self, room_id: Uuid) -> Result<(), SearchError> {
1005 self.ensure_indexes().await?;
1006 let meili = self.meili()?;
1007 let index = self.index(SearchKind::Message);
1008 let filter = format!("room_id = \"{room_id}\"");
1009 meili.delete_filter(&index, &filter).await?;
1010 let Some(room) = rooms::Entity::find_by_id(room_id)
1011 .one(self.db.read())
1012 .await?
1013 else {
1014 return Ok(());
1015 };
1016 if !room.is_active {
1017 return Ok(());
1018 }
1019 let space = match room.space_id {
1020 Some(space_id) => self.space_for_id(space_id).await?,
1021 None => None,
1022 };
1023 // 从最新一条往旧分页,避免一次把大房间整表读进内存;作者按页批量取,
1024 // 房间与空间只查一次,回填不会退化成逐条三查询。
1025 let mut documents = Vec::new();
1026 let mut capped = false;
1027 let mut before_seq: Option<i64> = None;
1028 loop {
1029 let mut select = messages::Entity::find()
1030 .filter(messages::Column::RoomId.eq(room_id))
1031 .filter(messages::Column::DeletedAt.is_null())
1032 .order_by_desc(messages::Column::Seq)
1033 .limit(MESSAGE_BACKFILL_PAGE);
1034 if let Some(seq) = before_seq {
1035 select = select.filter(messages::Column::Seq.lt(seq));
1036 }
1037 let rows = select.all(self.db.read()).await?;
1038 if rows.is_empty() {
1039 break;
1040 }
1041 before_seq = rows.last().map(|message| message.seq);
1042 let author_ids: Vec<Uuid> = rows.iter().map(|message| message.author_id).collect();
1043 let authors = self.author_titles(&author_ids).await?;
1044 for message in rows {
1045 if documents.len() >= MAX_ROOM_MESSAGE_DOCUMENTS {
1046 capped = true;
1047 break;
1048 }
1049 if let Some(document) =
1050 build_message_document(&message, &room, space.as_ref(), &authors)
1051 {
1052 documents.push(document);
1053 }
1054 }
1055 if capped || before_seq.is_none() {
1056 break;
1057 }
1058 }
1059 if capped {
1060 warn!(
1061 %room_id,
1062 limit = MAX_ROOM_MESSAGE_DOCUMENTS,
1063 "search message index reached room document cap"
1064 );
1065 }
1066 for batch in documents.chunks(self.config.batch_size) {
1067 meili.add_documents(&index, batch).await?;
1068 }
1069 Ok(())
1070 }
1071
1072 /// 单条消息的检索文档;房间/空间已失效或消息不可索引时返回 `None`。
1073 async fn message_document(
1074 &self,
1075 message: &messages::Model,
1076 ) -> Result<Option<SearchDocument>, SearchError> {
1077 let Some(room) = rooms::Entity::find_by_id(message.room_id)
1078 .one(self.db.read())
1079 .await?
1080 else {
1081 return Ok(None);
1082 };
1083 let space = match room.space_id {
1084 Some(space_id) => self.space_for_id(space_id).await?,
1085 None => None,
1086 };
1087 let authors = self.author_titles(&[message.author_id]).await?;
1088 Ok(build_message_document(
1089 message,
1090 &room,
1091 space.as_ref(),
1092 &authors,
1093 ))
1094 }
1095
1096 /// 消息作者在检索结果里的标题(显示名)。
1097 async fn author_titles(
1098 &self,
1099 author_ids: &[Uuid],
1100 ) -> Result<HashMap<Uuid, String>, SearchError> {
1101 if author_ids.is_empty() {
1102 return Ok(HashMap::new());
1103 }
1104 let mut ids: Vec<Uuid> = author_ids.to_vec();
1105 ids.sort();
1106 ids.dedup();
1107 Ok(user::Entity::find()
1108 .filter(user::Column::Uid.is_in(ids))
1109 .all(self.db.read())
1110 .await?
1111 .into_iter()
1112 .map(|row| (row.uid, row.display_name))
1113 .collect())
1114 }
1115
1116 async fn space_for_id(&self, space_id: Uuid) -> Result<Option<spaces::Model>, SearchError> {
1117 Ok(spaces::Entity::find_by_id(space_id)
1118 .one(self.db.read())
1119 .await?)
1120 }
1121
9521122 pub async fn queue_all(&self) -> Result<(), SearchError> {
9531123 crate::outbox::enqueue_all(self.db.write()).await?;
9541124 Ok(())
@@ -1060,8 +1230,122 @@
10601230 ids.extend(member_ids.into_iter().map(|id| id.to_string()));
10611231 Ok(ids.into_iter().collect())
10621232 }
1233
1234 /// 聊天消息的可见房间:来访者所在空间里的公共频道,加上他本人所在的
1235 /// 私密频道与 DM/群聊;空间封禁与房间封禁都会把房间摘掉。
1236 async fn visible_room_ids(&self, caller: Option<Uuid>) -> Result<Vec<String>, SearchError> {
1237 let Some(caller) = caller else {
1238 return Ok(Vec::new());
1239 };
1240 let spaces = spaces::Entity::find()
1241 .filter(spaces::Column::IsActive.eq(true))
1242 .all(self.db.read())
1243 .await?;
1244 let memberships = space_members::Entity::find()
1245 .filter(space_members::Column::UserId.eq(caller))
1246 .filter(space_members::Column::Status.eq(space_members::MemberStatus::Active))
1247 .all(self.db.read())
1248 .await?;
1249 let member_of: HashSet<Uuid> = memberships.into_iter().map(|row| row.space_id).collect();
1250 let visible_spaces: HashSet<Uuid> = spaces
1251 .into_iter()
1252 .filter(|space| space.owner_id == caller || member_of.contains(&space.id))
1253 .map(|space| space.id)
1254 .collect();
1255 let rooms = rooms::Entity::find()
1256 .filter(rooms::Column::IsActive.eq(true))
1257 .all(self.db.read())
1258 .await?;
1259 let room_ids: Vec<Uuid> = rooms.iter().map(|room| room.id).collect();
1260 let room_memberships = room_members::Entity::find()
1261 .filter(room_members::Column::RoomId.is_in(room_ids.clone()))
1262 .filter(room_members::Column::UserId.eq(caller))
1263 .filter(room_members::Column::LeftAt.is_null())
1264 .all(self.db.read())
1265 .await?;
1266 let joined_rooms: HashSet<Uuid> = room_memberships
1267 .into_iter()
1268 .map(|row| row.room_id)
1269 .collect();
1270 let bans = room_bans::Entity::find()
1271 .filter(room_bans::Column::RoomId.is_in(room_ids))
1272 .filter(room_bans::Column::UserId.eq(caller))
1273 .all(self.db.read())
1274 .await?;
1275 let now = time::OffsetDateTime::now_utc();
1276 let banned: HashSet<Uuid> = bans
1277 .into_iter()
1278 .filter(|row| row.expires_at.is_none_or(|expires| expires > now))
1279 .map(|row| row.room_id)
1280 .collect();
1281 let visible = rooms
1282 .into_iter()
1283 .filter(|room| {
1284 if banned.contains(&room.id) {
1285 return false;
1286 }
1287 match room.space_id {
1288 Some(space_id) => {
1289 visible_spaces.contains(&space_id)
1290 && (!room.is_private
1291 || room.owner_id == caller
1292 || joined_rooms.contains(&room.id))
1293 }
1294 None => joined_rooms.contains(&room.id),
1295 }
1296 })
1297 .map(|room| room.id.to_string())
1298 .collect();
1299 Ok(visible)
1300 }
10631301}
10641302
1303/// 组装一条消息的检索文档;房间/空间失效、系统/临时消息或空正文都不索引。
1304/// 返回 `None` 意味着该消息应当从消息索引里删除。
1305fn build_message_document(
1306 message: &messages::Model,
1307 room: &rooms::Model,
1308 space: Option<&spaces::Model>,
1309 authors: &HashMap<Uuid, String>,
1310) -> Option<SearchDocument> {
1311 if message.deleted_at.is_some() {
1312 return None;
1313 }
1314 if message.flags & FLAG_EPHEMERAL != 0 {
1315 return None;
1316 }
1317 if !matches!(
1318 message.message_type,
1319 ChatMessageType::Default | ChatMessageType::Reply | ChatMessageType::ThreadCreated
1320 ) {
1321 return None;
1322 }
1323 if message.content.trim().is_empty() {
1324 return None;
1325 }
1326 if !room.is_active {
1327 return None;
1328 }
1329 if room.space_id.is_some() && space.is_none_or(|space| !space.is_active) {
1330 return None;
1331 }
1332 Some(SearchDocument {
1333 id: message.id.to_string(),
1334 kind: SearchKind::Message.as_str().to_string(),
1335 space_id: space.map(|space| space.id.to_string()),
1336 space_name: space.map(|space| space.name.clone()),
1337 room_id: Some(room.id.to_string()),
1338 room_name: Some(room.name.clone()),
1339 thread_id: message.thread_id.map(|id| id.to_string()),
1340 title: authors
1341 .get(&message.author_id)
1342 .cloned()
1343 .unwrap_or_else(|| message.author_id.to_string()),
1344 content: message.content.clone(),
1345 ..Default::default()
1346 })
1347}
1348
10651349/// 各类型按相关度轮流出结果,避免符号命中把代码命中挤出结果页。
10661350fn merge_buckets(buckets: Vec<Vec<SearchHit>>, limit: usize) -> Vec<SearchHit> {
10671351 let mut merged: Vec<SearchHit> = Vec::with_capacity(limit);
@@ -1090,6 +1374,7 @@
10901374 definitions_only: bool,
10911375 repo_ids: &'a [String],
10921376 space_ids: &'a [String],
1377 room_ids: &'a [String],
10931378 space_name: Option<&'a str>,
10941379 repo_name: Option<&'a str>,
10951380 language: Option<&'a str>,
@@ -1103,6 +1388,8 @@
11031388 SearchKind::Code | SearchKind::Symbol | SearchKind::Pull
11041389 ) {
11051390 filters.push(in_filter("repo_id", options.repo_ids));
1391 } else if kind == SearchKind::Message {
1392 filters.push(in_filter("room_id", options.room_ids));
11061393 } else {
11071394 filters.push(in_filter("space_id", options.space_ids));
11081395 }
@@ -1162,6 +1449,13 @@
11621449/// 单个仓库代码分块的上限,防止超大仓库一次索引把检索服务打满。
11631450const MAX_CODE_DOCUMENTS: usize = 20_000;
11641451
1452/// 单个房间回填的消息上限(按 seq 取最新),防止超大频道把检索服务打满;
1453/// 增量消息不设上限,回填只是历史补齐。
1454const MAX_ROOM_MESSAGE_DOCUMENTS: usize = 50_000;
1455
1456/// 回填时每次从数据库翻多少行,避免整房间一次性读进内存。
1457const MESSAGE_BACKFILL_PAGE: u64 = 500;
1458
11651459fn tree_entries(value: &[u8]) -> Vec<(String, u64)> {
11661460 value
11671461 .split(|byte| *byte == 0)
Modified lib/search/src/worker.rs lib/search/src/worker.rs +18 −0 View lib/search/src/worker.rs
@@ -6,6 +6,7 @@
66use models::issue::issue_item;
77use models::pull_request::pull_requests;
88use models::repo::repos;
9use models::room::rooms;
910use models::search::index_job;
1011use sea_orm::{ColumnTrait, EntityTrait, QueryFilter, QueryOrder};
1112use time::OffsetDateTime;
@@ -206,6 +207,18 @@
206207 .ok_or_else(|| SearchError::Invalid("pull job has no target".to_string()))?;
207208 self.service.index_pull(pull_id).await
208209 }
210 "message" => {
211 let message_id = job
212 .target_id
213 .ok_or_else(|| SearchError::Invalid("message job has no target".to_string()))?;
214 self.service.index_message(message_id).await
215 }
216 "room_messages" => {
217 let room_id = job
218 .target_id
219 .ok_or_else(|| SearchError::Invalid("room job has no target".to_string()))?;
220 self.service.index_room_messages(room_id).await
221 }
209222 "all" => self.expand_all().await,
210223 other => Err(SearchError::Invalid(format!(
211224 "unknown index job kind `{other}`"
@@ -223,6 +236,11 @@
223236 for pull in pull_requests::Entity::find().all(self.db.read()).await? {
224237 outbox::enqueue_pull(self.db.write(), pull.id, pull.repo_id).await?;
225238 }
239 // 含已停用房间:房间级任务先按 room_id 清空再回填,停用房间因此
240 // 在每次全量重建时顺带清掉残留文档。
241 for room in rooms::Entity::find().all(self.db.read()).await? {
242 outbox::enqueue_room_messages(self.db.write(), room.id).await?;
243 }
226244 Ok(())
227245 }
228246}
Modified lib/space/src/services/rooms.rs lib/space/src/services/rooms.rs +7 −0 View lib/space/src/services/rooms.rs
@@ -255,10 +255,17 @@
255255 }
256256
257257 let now = OffsetDateTime::now_utc();
258 let room_id = room.id;
258259 let mut am: rooms::ActiveModel = room.into();
259260 am.is_active = Set(false);
260261 am.updated_at = Set(now);
261262 am.update(self.db().write()).await?;
263 // 频道停用后它的消息不该再出现在全局搜索里:房间级索引任务会先
264 // 按 room_id 清掉文档,再因为房间已停用而不回填。
265 if let Err(error) = search::outbox::enqueue_room_messages(self.db().write(), room_id).await
266 {
267 tracing::warn!(%room_id, error = %error, "search room purge enqueue failed");
268 }
262269
263270 Ok(())
264271 }
Modified lib/space/src/services/spaces.rs lib/space/src/services/spaces.rs +18 −0 View lib/space/src/services/spaces.rs
@@ -1,5 +1,6 @@
11use std::collections::HashMap;
22
3use models::room::rooms;
34use models::space::space_members::{self, MemberStatus, MessageNotifications};
45use models::space::spaces::{self, AllowJoinLevel};
56use models::users::user;
@@ -466,10 +467,27 @@
466467 }
467468
468469 let now = OffsetDateTime::now_utc();
470 let space_id = space.id;
469471 let mut am: spaces::ActiveModel = space.into();
470472 am.is_active = Set(false);
471473 am.updated_at = Set(now);
472474 am.update(self.db().write()).await?;
475 // 空间停用后它的频道消息必须退出全局搜索:没有单条消息会再触发
476 // 重建,所以这里逐频道排一次房间级清理。
477 let room_ids: Vec<Uuid> = rooms::Entity::find()
478 .filter(rooms::Column::SpaceId.eq(space_id))
479 .all(self.db().read())
480 .await?
481 .into_iter()
482 .map(|room| room.id)
483 .collect();
484 for room_id in room_ids {
485 if let Err(error) =
486 search::outbox::enqueue_room_messages(self.db().write(), room_id).await
487 {
488 tracing::warn!(%room_id, error = %error, "search room purge enqueue failed");
489 }
490 }
473491 Ok(())
474492 }
475493
Modified lib/space/Cargo.toml lib/space/Cargo.toml +1 −0 View lib/space/Cargo.toml
@@ -11,6 +11,7 @@
1111email = { workspace = true }
1212i18n = { workspace = true }
1313models = { workspace = true }
14search = { workspace = true }
1415session = { workspace = true }
1516storage = { workspace = true }
1617axum = { workspace = true }
Modified lib/webapi/src/search.rs lib/webapi/src/search.rs +1 −1 View lib/webapi/src/search.rs
@@ -70,7 +70,7 @@
7070 get,
7171 path = "/search",
7272 tag = "search",
73 description = "全局检索当前调用者可见的代码、符号、议题和合并请求。",
73 description = "全局检索当前调用者可见的代码、符号、议题、合并请求与聊天消息。",
7474 params(SearchQuery),
7575 responses((status = 200, description = "search results", body = AppResponseData<SearchPage>))
7676)]
Modified web/src/entities/search/model/search.ts web/src/entities/search/model/search.ts +4 −1 View web/src/entities/search/model/search.ts
@@ -1,4 +1,4 @@
1export type SearchKind = 'code' | 'symbol' | 'issue' | 'pull'
1export type SearchKind = 'code' | 'symbol' | 'issue' | 'pull' | 'message'
22
33export interface SearchHit {
44 kind: SearchKind
@@ -19,6 +19,9 @@
1919 symbol_name?: string | null
2020 symbol_kind?: string | null
2121 definition?: boolean | null
22 room_id?: string | null
23 room_name?: string | null
24 thread_id?: string | null
2225}
2326
2427export interface SearchPage {
Modified web/src/features/chat/ui/chat-room.tsx web/src/features/chat/ui/chat-room.tsx +8 −2 View web/src/features/chat/ui/chat-room.tsx
@@ -184,13 +184,18 @@
184184
185185 // A deep `?jump=` is applied once per id: the transcript fetches its own window
186186 // around the row, which is the only way to reach a message that was never
187 // loaded, and clearing the parameter would erase a link worth keeping.
187 // loaded, and clearing the parameter would erase a link worth keeping. A jump
188 // inside an open thread belongs to the thread panel's own transcript.
189 const jumpingInThread = state.panel === 'thread' && Boolean(state.threadId)
188190 useEffect(() => {
189191 const id = state.jumpId
190192 if (!id || jumped.current === id) return
193 // 话题内的跳转由 ThreadPanel 自己完成;这里只把它标记成已处理,
194 // 免得关掉话题面板后同一条深链又到主时间线里再跳一次。
191195 jumped.current = id
196 if (jumpingInThread) return
192197 stream.jumpTo(id)
193 }, [state.jumpId, stream])
198 }, [state.jumpId, jumpingInThread, stream])
194199
195200 /* ------------------------------------------------------------- search */
196201
@@ -642,6 +647,7 @@
642647 customEmojis={data.emojis.data ?? []}
643648 nameOf={nameOf}
644649 interactions={interactions}
650 jumpId={state.jumpId}
645651 onClose={() => onStateChange({ panel: null, threadId: undefined })}
646652 onChanged={() => setVersion(value => value + 1)}
647653 />
Modified web/src/features/chat/ui/side-panel.tsx web/src/features/chat/ui/side-panel.tsx +12 −1 View web/src/features/chat/ui/side-panel.tsx
@@ -9,7 +9,7 @@
99 * has none (a DM has no space to search) is explained rather than silently
1010 * returning nothing.
1111 */
12import { useMemo, useState } from 'react'
12import { useEffect, useMemo, useRef, useState } from 'react'
1313import {
1414 Crosshair,
1515 Hash,
@@ -90,6 +90,8 @@
9090 customEmojis?: CustomEmoji[]
9191 nameOf: (userId: string | null | undefined) => string
9292 interactions: MessageInteractions
93 /** A message to reveal inside this thread (deep link from global search). */
94 jumpId?: string
9395 onClose: () => void
9496 /** Thread list / reply counts changed: the room view refetches them. */
9597 onChanged: () => void
@@ -105,6 +107,7 @@
105107 customEmojis,
106108 nameOf,
107109 interactions,
110 jumpId,
108111 onClose,
109112 onChanged,
110113}: ThreadPanelProps) {
@@ -129,6 +132,14 @@
129132 onSubmitted: (content, attachments) => stream.send({ content, attachments }),
130133 onTyping: () => sendTypingSignal(roomId, thread.id),
131134 })
135 // Deep-linked reply: same one-shot rule as the room transcript, so a reload
136 // keeps the link and a second render does not re-fetch the window.
137 const jumped = useRef<string | null>(null)
138 useEffect(() => {
139 if (!jumpId || jumped.current === jumpId) return
140 jumped.current = jumpId
141 stream.jumpTo(jumpId)
142 }, [jumpId, stream])
132143 // Replying inside a thread must never open another thread or quote a reply.
133144 const threadInteractions = useMemo<MessageInteractions>(
134145 () => ({
Modified web/src/features/repo-browse/ui/repo-code-search.tsx web/src/features/repo-browse/ui/repo-code-search.tsx +2 −3 View web/src/features/repo-browse/ui/repo-code-search.tsx
@@ -23,11 +23,10 @@
2323 hits: SearchHit[]
2424}
2525
26const KIND_LABEL: Record<SearchHit['kind'], string> = {
26/** 仓库内搜索只问代码与符号,这里的标签只有这两类。 */
27const KIND_LABEL: Record<'code' | 'symbol', string> = {
2728 get code() { return t('repo.search.kind.code') },
2829 get symbol() { return t('repo.search.kind.symbol') },
29 get issue() { return t('repo.search.kind.issue') },
30 get pull() { return t('repo.search.kind.pull') },
3130}
3231
3332export function RepoCodeSearch({
Modified web/src/features/search/ui/global-search-command.tsx web/src/features/search/ui/global-search-command.tsx +35 −5 View web/src/features/search/ui/global-search-command.tsx
@@ -1,18 +1,22 @@
11import { useEffect, useMemo, useRef, useState } from 'react'
22import { useNavigate } from 'react-router-dom'
3import { FileCode2, GitPullRequest, Hash, Search, X } from 'lucide-react'
3import { FileCode2, GitPullRequest, Hash, MessageSquare, Search, X } from 'lucide-react'
44import { useT } from '@/shared/i18n'
55import { search, type SearchHit, type SearchKind } from '@/entities/search'
66
77/** Label keys resolved lazily so the tab strip follows the active locale. */
8const KIND_LABELS: Record<SearchKind, 'search.kind.code' | 'search.kind.symbol' | 'search.kind.issue' | 'search.kind.pull'> = {
8const KIND_LABELS: Record<
9 SearchKind,
10 'search.kind.code' | 'search.kind.symbol' | 'search.kind.issue' | 'search.kind.pull' | 'search.kind.message'
11> = {
912 code: 'search.kind.code',
1013 symbol: 'search.kind.symbol',
1114 issue: 'search.kind.issue',
1215 pull: 'search.kind.pull',
16 message: 'search.kind.message',
1317}
1418
15const KINDS: SearchKind[] = ['code', 'symbol', 'issue', 'pull']
19const KINDS: SearchKind[] = ['code', 'symbol', 'issue', 'pull', 'message']
1620
1721export function GlobalSearchCommand() {
1822 const { t } = useT()
@@ -79,6 +83,18 @@
7983 }
8084
8185 const openHit = (hit: SearchHit) => {
86 if (hit.kind === 'message' && hit.room_id) {
87 // A DM has no space to name; its room lives under the personal scope.
88 const scope = hit.space_name ? encodeURIComponent(hit.space_name) : '@me'
89 const params = new URLSearchParams({ jump: hit.id })
90 if (hit.thread_id) {
91 params.set('panel', 'thread')
92 params.set('thread', hit.thread_id)
93 }
94 navigate(`/app/${scope}/chat/${encodeURIComponent(hit.room_id)}?${params.toString()}`)
95 setOpen(false)
96 return
97 }
8298 if (hit.kind === 'issue' && hit.space_name && hit.board_id) {
8399 navigate(`/app/${encodeURIComponent(hit.space_name)}/board/${encodeURIComponent(hit.board_id)}?issue=${encodeURIComponent(hit.id)}`)
84100 } else if (hit.kind === 'pull' && hit.space_name && hit.repo_name && hit.number) {
@@ -153,8 +169,22 @@
153169}
154170
155171function SearchRow({ hit, onOpen }: { hit: SearchHit; onOpen: () => void }) {
156 const Icon = hit.kind === 'issue' ? Hash : hit.kind === 'pull' ? GitPullRequest : FileCode2
157 const location = [hit.space_name, hit.repo_name, hit.path, hit.line_start ? `L${hit.line_start}` : ''].filter(Boolean).join(' / ')
172 const Icon =
173 hit.kind === 'issue'
174 ? Hash
175 : hit.kind === 'pull'
176 ? GitPullRequest
177 : hit.kind === 'message'
178 ? MessageSquare
179 : FileCode2
180 const location = [
181 hit.space_name,
182 hit.kind === 'message' ? hit.room_name : hit.repo_name,
183 hit.kind === 'message' ? '' : hit.path,
184 hit.line_start ? `L${hit.line_start}` : '',
185 ]
186 .filter(Boolean)
187 .join(' / ')
158188 return (
159189 <button type="button" onClick={onOpen} className="flex w-full gap-3 rounded-lg px-3 py-2.5 text-left transition-colors hover:bg-shell-field focus:bg-shell-field focus:outline-none">
160190 <Icon className="mt-0.5 h-4 w-4 shrink-0 text-shell-ink-muted" aria-hidden />
Modified web/src/shared/i18n/messages/en/misc.ts web/src/shared/i18n/messages/en/misc.ts +2 −1 View web/src/shared/i18n/messages/en/misc.ts
@@ -42,7 +42,7 @@
4242 'notifications.bellUnread': 'Notifications, {count} unread',
4343 'notifications.region': 'System notifications',
4444 'notifications.closeToast': 'Dismiss notification',
45 'search.command.placeholder': 'Search code, symbols, issues and pull requests…',
45 'search.command.placeholder': 'Search code, symbols, issues, pull requests and messages…',
4646 'search.command.open': 'Open global search',
4747 'search.command.dialog': 'Global search',
4848 'search.command.close': 'Close search',
@@ -53,4 +53,5 @@
5353 'search.kind.symbol': 'Symbols',
5454 'search.kind.issue': 'Issues',
5555 'search.kind.pull': 'Pull requests',
56 'search.kind.message': 'Messages',
5657} as const
Modified web/src/shared/i18n/messages/zh-CN/misc.ts web/src/shared/i18n/messages/zh-CN/misc.ts +2 −1 View web/src/shared/i18n/messages/zh-CN/misc.ts
@@ -42,7 +42,7 @@
4242 'notifications.bellUnread': '通知,{count} 条未读',
4343 'notifications.region': '系统通知',
4444 'notifications.closeToast': '关闭通知',
45 'search.command.placeholder': '搜索代码、符号、工单与拉取请求…',
45 'search.command.placeholder': '搜索代码、符号、工单、拉取请求与消息…',
4646 'search.command.open': '打开全局搜索',
4747 'search.command.dialog': '全局搜索',
4848 'search.command.close': '关闭搜索',
@@ -53,4 +53,5 @@
5353 'search.kind.symbol': '符号',
5454 'search.kind.issue': '工单',
5555 'search.kind.pull': '拉取请求',
56 'search.kind.message': '消息',
5657} as const
Modified web/openapi.json web/openapi.json View web/openapi.json
Diff too large to display.
Modified Cargo.lock Cargo.lock +2 −0 View Cargo.lock
@@ -1214,6 +1214,7 @@
12141214 "redis",
12151215 "reqwest 0.12.28",
12161216 "sea-orm",
1217 "search",
12171218 "serde",
12181219 "serde_json",
12191220 "session",
@@ -8469,6 +8470,7 @@
84698470 "models",
84708471 "rand 0.8.7",
84718472 "sea-orm",
8473 "search",
84728474 "serde",
84738475 "serde_json",
84748476 "session",
Modified openapi.json openapi.json View openapi.json
Diff too large to display.