194c44c
use sea_orm::{DatabaseBackend, FromQueryResult, Statement};
use serde::{Deserialize, Serialize};
use time::OffsetDateTime;
use utoipa::{IntoParams, ToSchema};
use uuid::Uuid;
use crate::errors::ChatError;
use crate::services::service::ChatService;
use crate::share::lookup::{lookup_space_by_name, require_space_member};
#[derive(Debug, Clone, Serialize, ToSchema)]
pub struct SearchHitDto {
pub message_id: Uuid,
pub room_id: Uuid,
pub author_id: i64,
pub author: Option<space::services::spaces::UserDto>,
pub seq: i64,
pub content: String,
pub created_at: OffsetDateTime,
}
#[derive(Debug, Clone, Serialize, ToSchema)]
pub struct SearchPage {
pub items: Vec<SearchHitDto>,
pub total: i64,
}
#[derive(Debug, Clone, Deserialize, IntoParams, ToSchema)]
pub struct SearchQuery {
pub q: String,
pub room_id: Option<Uuid>,
pub author_id: Option<i64>,
pub limit: Option<u64>,
pub offset: Option<u64>,
}
#[derive(Debug, FromQueryResult)]
struct SearchRow {
message_id: Uuid,
room_id: Uuid,
author_id: i64,
seq: i64,
content: String,
created_at: OffsetDateTime,
}
#[derive(Debug, FromQueryResult)]
struct CountRow {
total: i64,
}
impl ChatService {
pub async fn search_messages(
&self,
caller: i64,
space_name: &str,
q: SearchQuery,
) -> Result<SearchPage, ChatError> {
let space = lookup_space_by_name(self, space_name).await?;
require_space_member(self, &space, caller).await?;
let query = q.q.trim();
if query.is_empty() {
return Err(ChatError::BadRequest("query is empty".to_string()));
}
let limit = q
.limit
.unwrap_or(self.config().chat.default_pagination)
.clamp(1, self.config().chat.max_pagination);
let offset = q
.offset
.unwrap_or(0)
.clamp(0, export::pagination::MAX_OFFSET);
// Private rooms must be restricted to their members; public space
// rooms are readable by every space member. CJK text is not tokenized
// by Postgres FTS ('simple' config), so CJK queries fall back to
// substring ILIKE matching.
let cjk = query.chars().any(is_cjk_char);
let text_predicate = if cjk {
"AND m.content ILIKE $8"
} else {
"AND m.search_vector @@ plainto_tsquery('simple', $2)"
};
let order_clause = if cjk {
"ORDER BY m.created_at DESC"
} else {
"ORDER BY ts_rank(m.search_vector, plainto_tsquery('simple', $2)) DESC, m.created_at DESC"
};
let like_pattern = format!("%{}%", escape_like(query));
let filters = format!(
r#"
AND r.space_id = $1
AND m.deleted_at IS NULL
{text_predicate}
AND ($5::uuid IS NULL OR m.room_id = $5)
AND ($6::uuid IS NULL OR m.author_id = $6)
AND (r.is_private = false OR r.owner_id = $7
OR EXISTS (SELECT 1 FROM room_members rm
WHERE rm.room_id = m.room_id AND rm.user_id = $7
AND rm.left_at IS NULL))"#
);
let count_sql = format!(
"SELECT count(*)::bigint AS total FROM chat_message m JOIN rooms r ON r.id = m.room_id WHERE 1=1{filters}"
);
let count_row = CountRow::find_by_statement(Statement::from_sql_and_values(
DatabaseBackend::Postgres,
&count_sql,
[
space.id.into(),
query.into(),
0i64.into(),
0i64.into(),
q.room_id.into(),
q.author_id.into(),
caller.into(),
like_pattern.clone().into(),
],
))
.one(self.db().read())
.await?
.ok_or(ChatError::Internal("count query returned no row".into()))?;
let sql = format!(
r#"SELECT m.id AS message_id, m.room_id, m.author_id, m.seq, m.content, m.created_at
FROM chat_message m
JOIN rooms r ON r.id = m.room_id
WHERE 1=1{filters}
{order_clause}
LIMIT $3 OFFSET $4"#
);
let rows = SearchRow::find_by_statement(Statement::from_sql_and_values(
DatabaseBackend::Postgres,
&sql,
[
space.id.into(),
query.into(),
(limit as i64).into(),
(offset as i64).into(),
q.room_id.into(),
q.author_id.into(),
caller.into(),
like_pattern.into(),
],
))
.all(self.db().read())
.await?;
let author_ids: Vec<i64> = rows.iter().map(|r| r.author_id).collect();
let author_map = self.load_user_briefs(&author_ids).await?;
let items = rows
.into_iter()
.map(|r| SearchHitDto {
author: author_map.get(&r.author_id).cloned(),
message_id: r.message_id,
room_id: r.room_id,
author_id: r.author_id,
seq: r.seq,
content: r.content,
created_at: r.created_at,
})
.collect();
Ok(SearchPage {
items,
total: count_row.total,
})
}
}
fn is_cjk_char(c: char) -> bool {
matches!(c as u32,
0x4E00..=0x9FFF
| 0x3400..=0x4DBF
| 0x20000..=0x2A6DF
| 0xF900..=0xFAFF
| 0x3040..=0x30FF
| 0x31F0..=0x31FF
| 0xAC00..=0xD7AF
| 0x1100..=0x11FF
| 0x3130..=0x318F)
}
fn escape_like(q: &str) -> String {
let mut out = String::with_capacity(q.len());
for c in q.chars() {
if matches!(c, '%' | '_' | '\\') {
out.push('\\');
}
out.push(c);
}
out
}