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 organizations;
|
||||
mod proxy;
|
||||
mod search;
|
||||
mod site_announcements;
|
||||
mod stats;
|
||||
mod tasks;
|
||||
@@ -26,6 +27,7 @@ pub fn build_router(state: AppState) -> Router {
|
||||
.merge(checkin::routes())
|
||||
.merge(monitor::routes())
|
||||
.merge(organizations::routes())
|
||||
.merge(search::routes())
|
||||
.merge(site_announcements::routes())
|
||||
.merge(stats::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