Skip to content
Clone

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
parent 17d1f1a commit 11f67b5 Browse files

22 files changed

+447 −20

This change is very large, so some diffs are not shown. Clone the repository to see the full change.

File tree 22
Modified lib/chat/src/services/messages.rs 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 View web/openapi.json

Diff too large to display.

Modified Cargo.lock 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 View openapi.json

Diff too large to display.