gitdataai/libs/service/search/service.rs
2026-04-14 19:02:01 +08:00

469 lines
16 KiB
Rust

use crate::AppService;
use crate::error::AppError;
use chrono::{DateTime, Utc};
use db::database::AppDatabase;
use models::issues::issue;
use models::projects::{project, project_members};
use models::repos::repo;
use models::users::user;
use sea_orm::*;
use sea_query::{Expr as SqExpr, extension::postgres::PgExpr};
use serde::{Deserialize, Serialize};
use session::Session;
use utoipa::ToSchema;
use uuid::Uuid;
// ─── Request / Response types ────────────────────────────────────────────────
#[derive(Debug, Clone, Deserialize, utoipa::IntoParams)]
pub struct SearchQuery {
/// Search keyword (matches against name, title, description, etc.)
#[param(min_length = 1, max_length = 200)]
pub q: String,
/// Comma-separated list of entity types to search.
/// Supported: projects, repos, issues, users.
/// Default: all types.
pub r#type: Option<String>,
/// Page number (1-indexed). Default: 1.
pub page: Option<u32>,
/// Results per page per type. Default: 20, max: 100.
pub per_page: Option<u32>,
}
fn parse_types(types: Option<String>) -> Vec<String> {
match types {
None => vec![
"projects".into(),
"repos".into(),
"issues".into(),
"users".into(),
],
Some(s) => {
let s = s.to_lowercase();
let mut out = vec![];
for t in s.split(',') {
let t = t.trim();
if t == "projects" || t == "repos" || t == "issues" || t == "users" {
out.push(t.into());
}
}
if out.is_empty() {
vec![
"projects".into(),
"repos".into(),
"issues".into(),
"users".into(),
]
} else {
out
}
}
}
}
fn build_like_pattern(q: &str) -> String {
format!("%{}%", q.trim())
}
// ─── Per-type result items ───────────────────────────────────────────────────
#[derive(Debug, Clone, Serialize, ToSchema)]
pub struct ProjectSearchItem {
pub uid: Uuid,
pub name: String,
pub display_name: String,
pub description: Option<String>,
pub avatar_url: Option<String>,
pub is_public: bool,
pub created_at: DateTime<Utc>,
pub updated_at: DateTime<Utc>,
}
#[derive(Debug, Clone, Serialize, ToSchema)]
pub struct RepoSearchItem {
pub uid: Uuid,
pub name: String,
pub description: Option<String>,
pub project_uid: Uuid,
pub project_name: String,
pub is_private: bool,
pub created_at: DateTime<Utc>,
}
#[derive(Debug, Clone, Serialize, ToSchema)]
pub struct IssueSearchItem {
pub uid: Uuid,
pub number: i64,
pub title: String,
pub body: Option<String>,
pub state: String,
pub project_uid: Uuid,
pub project_name: String,
pub created_at: DateTime<Utc>,
pub updated_at: DateTime<Utc>,
}
#[derive(Debug, Clone, Serialize, ToSchema)]
pub struct UserSearchItem {
pub uid: Uuid,
pub username: String,
pub display_name: Option<String>,
pub avatar_url: Option<String>,
pub organization: Option<String>,
pub created_at: DateTime<Utc>,
}
// ─── Per-type result set ─────────────────────────────────────────────────────
#[derive(Debug, Clone, Serialize, ToSchema)]
pub struct SearchResultSet<T> {
pub items: Vec<T>,
pub total: i64,
pub page: u32,
pub per_page: u32,
}
impl<T> SearchResultSet<T> {
fn new(items: Vec<T>, total: i64, page: u32, per_page: u32) -> Self {
Self {
items,
total,
page,
per_page,
}
}
}
// ─── Aggregated response ──────────────────────────────────────────────────────
#[derive(Debug, Clone, Serialize, ToSchema)]
pub struct SearchResponse {
pub query: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub projects: Option<SearchResultSet<ProjectSearchItem>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub repos: Option<SearchResultSet<RepoSearchItem>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub issues: Option<SearchResultSet<IssueSearchItem>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub users: Option<SearchResultSet<UserSearchItem>>,
}
// ─── Permission helpers ───────────────────────────────────────────────────────
/// Returns the set of project IDs the current user can access (for filtering).
/// If user is None (anonymous), returns only public project IDs.
async fn accessible_project_ids(
db: &AppDatabase,
user_id: Option<Uuid>,
) -> Result<Vec<Uuid>, AppError> {
let public_projects: Vec<Uuid> = project::Entity::find()
.filter(project::Column::IsPublic.eq(true))
.select_only()
.column(project::Column::Id)
.into_tuple::<Uuid>()
.all(db)
.await
.map_err(|_| AppError::InternalError)?;
let Some(user_id) = user_id else {
// Anonymous: only public projects
return Ok(public_projects);
};
let memberships: Vec<Uuid> = project_members::Entity::find()
.filter(project_members::Column::User.eq(user_id))
.select_only()
.column(project_members::Column::Project)
.into_tuple::<Uuid>()
.all(db)
.await
.map_err(|_| AppError::InternalError)?;
let mut all: Vec<Uuid> = public_projects;
for id in memberships {
if !all.iter().any(|x| x == &id) {
all.push(id);
}
}
Ok(all)
}
impl AppService {
pub async fn search(
&self,
ctx: &Session,
params: SearchQuery,
) -> Result<SearchResponse, AppError> {
let page = Ord::max(params.page.unwrap_or(1), 1u32);
let per_page = Ord::min(params.per_page.unwrap_or(20), 100u32);
let query = params.q.trim();
if query.is_empty() {
return Err(AppError::BadRequest("q is required".to_string()));
}
let types = parse_types(params.r#type);
let user_id = ctx.user();
let accessible = accessible_project_ids(&self.db, user_id).await?;
let mut resp = SearchResponse {
query: query.to_string(),
projects: None,
repos: None,
issues: None,
users: None,
};
if types.iter().any(|t| t == "projects") {
match self
.search_projects(query, &accessible, page, per_page)
.await
{
Ok(set) => resp.projects = Some(set),
Err(_) => {}
}
}
if types.iter().any(|t| t == "repos") {
if let Ok(set) = self.search_repos(query, &accessible, page, per_page).await {
resp.repos = Some(set);
}
}
if types.iter().any(|t| t == "issues") {
if let Ok(set) = self.search_issues(query, &accessible, page, per_page).await {
resp.issues = Some(set);
}
}
if types.iter().any(|t| t == "users") {
if let Ok(users) = self.search_users(query, page, per_page).await {
resp.users = Some(users);
}
}
Ok(resp)
}
async fn search_projects(
&self,
query: &str,
accessible: &[Uuid],
page: u32,
per_page: u32,
) -> Result<SearchResultSet<ProjectSearchItem>, AppError> {
let offset = (page - 1) * per_page;
let pattern = build_like_pattern(query);
// OR filter: Name ILIKE q OR DisplayName ILIKE q OR Description ILIKE q
let or_filter = SqExpr::col(project::Column::Name)
.ilike(&pattern)
.or(SqExpr::col(project::Column::DisplayName).ilike(&pattern))
.or(SqExpr::col(project::Column::Description).ilike(&pattern));
// Count
let total: i64 = project::Entity::find()
.filter(project::Column::Id.is_in(accessible.iter().cloned().collect::<Vec<_>>()))
.filter(or_filter.clone())
.count(&self.db)
.await
.map_err(|_| AppError::InternalError)? as i64;
// Fetch
let items: Vec<ProjectSearchItem> = project::Entity::find()
.filter(project::Column::Id.is_in(accessible.iter().cloned().collect::<Vec<_>>()))
.filter(or_filter)
.order_by_desc(project::Column::UpdatedAt)
.offset(Some(offset as u64))
.limit(Some(per_page as u64))
.all(&self.db)
.await
.map_err(|_| AppError::InternalError)?
.into_iter()
.map(|p| ProjectSearchItem {
uid: p.id,
name: p.name,
display_name: p.display_name,
description: p.description,
avatar_url: p.avatar_url,
is_public: p.is_public,
created_at: p.created_at,
updated_at: p.updated_at,
})
.collect();
Ok(SearchResultSet::new(items, total, page, per_page))
}
async fn search_repos(
&self,
query: &str,
accessible: &[Uuid],
page: u32,
per_page: u32,
) -> Result<SearchResultSet<RepoSearchItem>, AppError> {
let offset = (page - 1) * per_page;
let pattern = build_like_pattern(query);
// OR filter: RepoName ILIKE q OR Description ILIKE q
let repo_or = SqExpr::col(repo::Column::RepoName)
.ilike(&pattern)
.or(SqExpr::col(repo::Column::Description).ilike(&pattern));
// Count
let total: i64 = repo::Entity::find()
.filter(repo::Column::Project.is_in(accessible.iter().cloned().collect::<Vec<_>>()))
.filter(repo_or.clone())
.count(&self.db)
.await
.map_err(|_| AppError::InternalError)? as i64;
// Fetch
let repos: Vec<repo::Model> = repo::Entity::find()
.filter(repo::Column::Project.is_in(accessible.iter().cloned().collect::<Vec<_>>()))
.filter(repo_or)
.order_by_desc(repo::Column::UpdatedAt)
.offset(Some(offset as u64))
.limit(Some(per_page as u64))
.all(&self.db)
.await
.map_err(|_| AppError::InternalError)?;
// Batch-fetch project names
let project_ids: Vec<Uuid> = repos.iter().map(|r| r.project).collect();
let project_names: std::collections::HashMap<Uuid, String> = project::Entity::find()
.filter(project::Column::Id.is_in(project_ids.clone()))
.into_tuple::<(Uuid, String)>()
.all(&self.db)
.await
.map_err(|_| AppError::InternalError)?
.into_iter()
.collect();
let items: Vec<RepoSearchItem> = repos
.into_iter()
.map(|r| RepoSearchItem {
uid: r.id,
name: r.repo_name,
description: r.description,
project_uid: r.project,
project_name: project_names
.get(&r.project)
.cloned()
.unwrap_or_else(|| r.project.to_string()),
is_private: r.is_private,
created_at: r.created_at,
})
.collect();
Ok(SearchResultSet::new(items, total, page, per_page))
}
async fn search_issues(
&self,
query: &str,
accessible: &[Uuid],
page: u32,
per_page: u32,
) -> Result<SearchResultSet<IssueSearchItem>, AppError> {
let offset = (page - 1) * per_page;
let pattern = build_like_pattern(query);
// OR filter: Title ILIKE q OR Body ILIKE q
let issue_or = SqExpr::col(issue::Column::Title)
.ilike(&pattern)
.or(SqExpr::col(issue::Column::Body).ilike(&pattern));
// Count
let total: i64 = issue::Entity::find()
.filter(issue::Column::Project.is_in(accessible.iter().cloned().collect::<Vec<_>>()))
.filter(issue_or.clone())
.count(&self.db)
.await
.map_err(|_| AppError::InternalError)? as i64;
// Fetch
let issues: Vec<issue::Model> = issue::Entity::find()
.filter(issue::Column::Project.is_in(accessible.iter().cloned().collect::<Vec<_>>()))
.filter(issue_or)
.order_by_desc(issue::Column::UpdatedAt)
.offset(Some(offset as u64))
.limit(Some(per_page as u64))
.all(&self.db)
.await
.map_err(|_| AppError::InternalError)?;
// Batch-fetch project names
let project_ids: Vec<Uuid> = issues.iter().map(|i| i.project).collect();
let project_names: std::collections::HashMap<Uuid, String> = project::Entity::find()
.filter(project::Column::Id.is_in(project_ids.clone()))
.into_tuple::<(Uuid, String)>()
.all(&self.db)
.await
.map_err(|_| AppError::InternalError)?
.into_iter()
.collect();
let items: Vec<IssueSearchItem> = issues
.into_iter()
.map(|i| IssueSearchItem {
uid: i.id,
number: i.number,
title: i.title,
body: i.body,
state: i.state,
project_uid: i.project,
project_name: project_names
.get(&i.project)
.cloned()
.unwrap_or_else(|| i.project.to_string()),
created_at: i.created_at,
updated_at: i.updated_at,
})
.collect();
Ok(SearchResultSet::new(items, total, page, per_page))
}
async fn search_users(
&self,
query: &str,
page: u32,
per_page: u32,
) -> Result<SearchResultSet<UserSearchItem>, AppError> {
let offset = (page - 1) * per_page;
let pattern = build_like_pattern(query);
// OR filter: Username ILIKE q OR DisplayName ILIKE q
let user_or = SqExpr::col(user::Column::Username)
.ilike(&pattern)
.or(SqExpr::col(user::Column::DisplayName).ilike(&pattern));
// Count
let total: i64 = user::Entity::find()
.filter(user_or.clone())
.count(&self.db)
.await
.map_err(|_| AppError::InternalError)? as i64;
// Fetch
let items: Vec<UserSearchItem> = user::Entity::find()
.filter(user_or)
.order_by_desc(user::Column::LastSignInAt)
.offset(Some(offset as u64))
.limit(Some(per_page as u64))
.all(&self.db)
.await
.map_err(|_| AppError::InternalError)?
.into_iter()
.map(|u| UserSearchItem {
uid: u.uid,
username: u.username,
display_name: u.display_name,
avatar_url: u.avatar_url,
organization: u.organization,
created_at: u.created_at,
})
.collect();
Ok(SearchResultSet::new(items, total, page, per_page))
}
}