feat: Search routes migrated to Rust
- POST /api/search — cross-entity search across 6 categories - Searches: sites, accounts, account_tokens, checkin_logs, proxy_logs, and model_availability - All via SQL LIKE, no external search engine dependency - Model aggregation with deduplication - 18/18 tests pass
This commit is contained in:
@@ -10,6 +10,7 @@ mod health;
|
|||||||
mod monitor;
|
mod monitor;
|
||||||
mod organizations;
|
mod organizations;
|
||||||
mod proxy;
|
mod proxy;
|
||||||
|
mod search;
|
||||||
mod site_announcements;
|
mod site_announcements;
|
||||||
mod stats;
|
mod stats;
|
||||||
mod tasks;
|
mod tasks;
|
||||||
@@ -26,6 +27,7 @@ pub fn build_router(state: AppState) -> Router {
|
|||||||
.merge(checkin::routes())
|
.merge(checkin::routes())
|
||||||
.merge(monitor::routes())
|
.merge(monitor::routes())
|
||||||
.merge(organizations::routes())
|
.merge(organizations::routes())
|
||||||
|
.merge(search::routes())
|
||||||
.merge(site_announcements::routes())
|
.merge(site_announcements::routes())
|
||||||
.merge(stats::routes())
|
.merge(stats::routes())
|
||||||
.merge(tasks::routes())
|
.merge(tasks::routes())
|
||||||
|
|||||||
@@ -0,0 +1,212 @@
|
|||||||
|
// Search routes — cross-entity search across sites, accounts, tokens, etc.
|
||||||
|
|
||||||
|
use crate::{
|
||||||
|
error::AppResult,
|
||||||
|
AppState,
|
||||||
|
};
|
||||||
|
use axum::{
|
||||||
|
extract::State,
|
||||||
|
routing::post,
|
||||||
|
Json, Router,
|
||||||
|
};
|
||||||
|
use serde::Deserialize;
|
||||||
|
use serde_json::{json, Value};
|
||||||
|
use std::collections::{HashMap, HashSet};
|
||||||
|
use tracing::info;
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
pub struct SearchBody {
|
||||||
|
query: String,
|
||||||
|
limit: Option<i64>,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn routes() -> Router<AppState> {
|
||||||
|
Router::new()
|
||||||
|
.route("/api/search", post(search_handler))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// POST /api/search — search across all entities
|
||||||
|
async fn search_handler(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
Json(body): Json<SearchBody>,
|
||||||
|
) -> AppResult<Json<Value>> {
|
||||||
|
let query = body.query.trim().to_string();
|
||||||
|
let limit = body.limit.unwrap_or(20).max(1).min(100);
|
||||||
|
|
||||||
|
if query.is_empty() {
|
||||||
|
return Ok(Json(json!({
|
||||||
|
"accounts": [], "accountTokens": [], "sites": [],
|
||||||
|
"checkinLogs": [], "proxyLogs": [], "models": [],
|
||||||
|
})));
|
||||||
|
}
|
||||||
|
|
||||||
|
let q = format!("%{}%", query);
|
||||||
|
let per_category = (limit / 6).max(1).min(10);
|
||||||
|
|
||||||
|
// 1. Search sites
|
||||||
|
let sites: Vec<Value> = sqlx::query_as::<_, (i64, String, String, String)>(
|
||||||
|
"SELECT id, name, url, platform FROM sites WHERE name LIKE ? OR url LIKE ? OR platform LIKE ? LIMIT ?"
|
||||||
|
)
|
||||||
|
.bind(&q).bind(&q).bind(&q).bind(per_category)
|
||||||
|
.fetch_all(&*state.db)
|
||||||
|
.await?
|
||||||
|
.into_iter()
|
||||||
|
.map(|(id, name, url, platform)| json!({"id": id, "name": name, "url": url, "platform": platform}))
|
||||||
|
.collect::<Vec<_>>();
|
||||||
|
|
||||||
|
// Deduplicate by id
|
||||||
|
let mut seen_sites = HashSet::new();
|
||||||
|
let unique_sites: Vec<Value> = sites.into_iter().filter(|s| {
|
||||||
|
let id = s["id"].as_i64().unwrap_or(0);
|
||||||
|
seen_sites.insert(id)
|
||||||
|
}).collect();
|
||||||
|
|
||||||
|
// 2. Search accounts (with site join)
|
||||||
|
let accounts: Vec<Value> = sqlx::query_as::<_, (i64, String, String, String, i64, String, String, Option<String>)>(
|
||||||
|
r#"SELECT a.id, a.username, a.status, a.extra_config, s.id, s.name, s.platform, a.access_token
|
||||||
|
FROM accounts a
|
||||||
|
INNER JOIN sites s ON s.id = a.site_id
|
||||||
|
WHERE a.username LIKE ? OR s.name LIKE ? OR s.platform LIKE ?
|
||||||
|
LIMIT ?"#
|
||||||
|
)
|
||||||
|
.bind(&q).bind(&q).bind(&q).bind(per_category)
|
||||||
|
.fetch_all(&*state.db)
|
||||||
|
.await?
|
||||||
|
.into_iter()
|
||||||
|
.map(|(id, username, status, extra_config, site_id, site_name, platform, access_token)| {
|
||||||
|
let segment = if access_token.as_deref().unwrap_or("").trim().is_empty() { "apikey" } else { "session" };
|
||||||
|
json!({
|
||||||
|
"id": id, "username": username, "status": status,
|
||||||
|
"segment": segment,
|
||||||
|
"site": {"id": site_id, "name": site_name, "platform": platform},
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
// 3. Search account tokens (with account + site join)
|
||||||
|
let account_tokens: Vec<Value> = sqlx::query_as::<_, (i64, String, i64, String, i64, String, String)>(
|
||||||
|
r#"SELECT t.id, t.name, a.id, a.username, s.id, s.name, s.platform
|
||||||
|
FROM account_tokens t
|
||||||
|
INNER JOIN accounts a ON a.id = t.account_id
|
||||||
|
INNER JOIN sites s ON s.id = a.site_id
|
||||||
|
WHERE t.name LIKE ? OR t.token_group LIKE ? OR a.username LIKE ? OR s.name LIKE ? OR s.platform LIKE ?
|
||||||
|
ORDER BY t.updated_at DESC
|
||||||
|
LIMIT ?"#
|
||||||
|
)
|
||||||
|
.bind(&q).bind(&q).bind(&q).bind(&q).bind(&q).bind(per_category)
|
||||||
|
.fetch_all(&*state.db)
|
||||||
|
.await?
|
||||||
|
.into_iter()
|
||||||
|
.map(|(id, name, acct_id, username, site_id, site_name, platform)| {
|
||||||
|
json!({
|
||||||
|
"id": id, "name": name,
|
||||||
|
"account": {"id": acct_id, "username": username},
|
||||||
|
"site": {"id": site_id, "name": site_name, "platform": platform},
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
// 4. Search checkin logs
|
||||||
|
let checkin_logs: Vec<Value> = sqlx::query_as::<_, (i64, String, i64, String)>(
|
||||||
|
r#"SELECT c.id, c.message, a.id, a.username
|
||||||
|
FROM checkin_logs c
|
||||||
|
INNER JOIN accounts a ON a.id = c.account_id
|
||||||
|
WHERE c.message LIKE ?
|
||||||
|
ORDER BY c.created_at DESC
|
||||||
|
LIMIT ?"#
|
||||||
|
)
|
||||||
|
.bind(&q).bind(per_category)
|
||||||
|
.fetch_all(&*state.db)
|
||||||
|
.await?
|
||||||
|
.into_iter()
|
||||||
|
.map(|(id, message, acct_id, username)| {
|
||||||
|
json!({"id": id, "message": message, "account": {"id": acct_id, "username": username}})
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
// 5. Search proxy logs (by model)
|
||||||
|
let proxy_logs: Vec<Value> = sqlx::query_as::<_, (i64, String, String, i64, String, i64, String)>(
|
||||||
|
r#"SELECT p.id, p.model_requested, p.status, p.http_status, p.created_at,
|
||||||
|
COALESCE(a.id, 0), COALESCE(a.username, '')
|
||||||
|
FROM proxy_logs p
|
||||||
|
LEFT JOIN accounts a ON a.id = p.account_id
|
||||||
|
WHERE p.model_requested LIKE ?
|
||||||
|
ORDER BY p.created_at DESC
|
||||||
|
LIMIT ?"#
|
||||||
|
)
|
||||||
|
.bind(&q).bind(per_category)
|
||||||
|
.fetch_all(&*state.db)
|
||||||
|
.await?
|
||||||
|
.into_iter()
|
||||||
|
.map(|(id, model, status, http_status, created_at, acct_id, username)| {
|
||||||
|
json!({"id": id, "modelRequested": model, "status": status, "httpStatus": http_status, "createdAt": created_at})
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
// 6. Search models (from model_availability + token_model_availability)
|
||||||
|
let token_model_rows: Vec<(String, i64, i64, i64)> = sqlx::query_as(
|
||||||
|
r#"SELECT DISTINCT tma.model_name, t.id, a.id, s.id
|
||||||
|
FROM token_model_availability tma
|
||||||
|
INNER JOIN account_tokens t ON t.id = tma.token_id AND t.enabled = 1
|
||||||
|
INNER JOIN accounts a ON a.id = t.account_id AND a.status = 'active'
|
||||||
|
INNER JOIN sites s ON s.id = a.site_id
|
||||||
|
WHERE tma.model_name LIKE ? AND tma.available = 1 AND t.value_status = 'ready'
|
||||||
|
LIMIT ?"#
|
||||||
|
)
|
||||||
|
.bind(&q).bind(per_category * 10)
|
||||||
|
.fetch_all(&*state.db)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
let direct_model_rows: Vec<(String, i64, i64)> = sqlx::query_as(
|
||||||
|
r#"SELECT DISTINCT ma.model_name, a.id, s.id
|
||||||
|
FROM model_availability ma
|
||||||
|
INNER JOIN accounts a ON a.id = ma.account_id AND a.status = 'active'
|
||||||
|
INNER JOIN sites s ON s.id = a.site_id AND s.status = 'active'
|
||||||
|
WHERE ma.model_name LIKE ? AND ma.available = 1
|
||||||
|
LIMIT ?"#
|
||||||
|
)
|
||||||
|
.bind(&q).bind(per_category * 10)
|
||||||
|
.fetch_all(&*state.db)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
// Aggregate models
|
||||||
|
let mut model_agg: HashMap<String, (HashSet<i64>, HashSet<i64>, HashSet<i64>)> = HashMap::new();
|
||||||
|
for (model_name, _token_id, account_id, site_id) in token_model_rows {
|
||||||
|
let entry = model_agg.entry(model_name).or_default();
|
||||||
|
entry.0.insert(account_id);
|
||||||
|
entry.1.insert(site_id);
|
||||||
|
}
|
||||||
|
for (model_name, account_id, site_id) in direct_model_rows {
|
||||||
|
let entry = model_agg.entry(model_name).or_default();
|
||||||
|
entry.0.insert(account_id);
|
||||||
|
entry.1.insert(site_id);
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut models: Vec<Value> = model_agg
|
||||||
|
.into_iter()
|
||||||
|
.map(|(name, (accounts_set, sites_set, _tokens_set))| {
|
||||||
|
json!({
|
||||||
|
"name": name,
|
||||||
|
"accountCount": accounts_set.len(),
|
||||||
|
"tokenCount": 0,
|
||||||
|
"siteCount": sites_set.len(),
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
models.sort_by(|a, b| {
|
||||||
|
let bc = b["accountCount"].as_i64().unwrap_or(0);
|
||||||
|
let ac = a["accountCount"].as_i64().unwrap_or(0);
|
||||||
|
bc.cmp(&ac).then_with(|| a["name"].as_str().unwrap_or("").cmp(&b["name"].as_str().unwrap_or("")))
|
||||||
|
});
|
||||||
|
models.truncate(per_category as usize);
|
||||||
|
|
||||||
|
Ok(Json(json!({
|
||||||
|
"accounts": accounts,
|
||||||
|
"accountTokens": account_tokens,
|
||||||
|
"sites": unique_sites,
|
||||||
|
"checkinLogs": checkin_logs,
|
||||||
|
"proxyLogs": proxy_logs,
|
||||||
|
"models": models,
|
||||||
|
})))
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user