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:
boos
2026-05-17 17:15:03 +08:00
parent 7a3a88f738
commit 78d46eeddb
2 changed files with 214 additions and 0 deletions
+2
View File
@@ -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())
+212
View File
@@ -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,
})))
}