forked from boos/BoosAPI
feat: add workflow studio and postgres support
This commit is contained in:
+371
-93
@@ -1,5 +1,6 @@
|
||||
use crate::{
|
||||
auth::jwt::validate_jwt,
|
||||
db::models::background_task::BackgroundTask,
|
||||
db::models::{downstream_api_key::DownstreamApiKey, user::User},
|
||||
error::{AppError, AppResult},
|
||||
AppState,
|
||||
@@ -12,6 +13,7 @@ use axum::{
|
||||
};
|
||||
use serde::Deserialize;
|
||||
use serde_json::{json, Value};
|
||||
use sqlx::Row;
|
||||
|
||||
pub fn routes() -> Router<AppState> {
|
||||
Router::new()
|
||||
@@ -20,6 +22,305 @@ pub fn routes() -> Router<AppState> {
|
||||
.route("/v1/user/api-keys/{id}", delete(delete_api_key))
|
||||
.route("/api/user/logs", get(get_user_logs))
|
||||
.route("/api/user/summary", get(get_user_summary))
|
||||
.route(
|
||||
"/api/user/models/marketplace",
|
||||
get(get_user_models_marketplace),
|
||||
)
|
||||
}
|
||||
|
||||
async fn user_picture_log_items(state: &AppState, user_id: i64) -> AppResult<Vec<Value>> {
|
||||
let tasks: Vec<BackgroundTask> = sqlx::query_as(
|
||||
"SELECT *
|
||||
FROM background_tasks
|
||||
WHERE task_type = ?
|
||||
AND json_extract(result_json, '$.userId') = ?
|
||||
ORDER BY created_at DESC, id DESC",
|
||||
)
|
||||
.bind("picture-agent-generation")
|
||||
.bind(user_id)
|
||||
.fetch_all(&*state.db)
|
||||
.await?;
|
||||
|
||||
Ok(tasks
|
||||
.into_iter()
|
||||
.map(|task| {
|
||||
let result = task
|
||||
.result_json
|
||||
.as_deref()
|
||||
.and_then(|raw| serde_json::from_str::<Value>(raw).ok())
|
||||
.unwrap_or_else(|| json!({}));
|
||||
let status = match task.status.as_str() {
|
||||
"succeeded" => "success",
|
||||
"failed" => "failed",
|
||||
"cancelled" => "cancelled",
|
||||
"pending" => "pending",
|
||||
_ => "running",
|
||||
};
|
||||
json!({
|
||||
"id": task.id,
|
||||
"route_id": Value::Null,
|
||||
"channel_id": Value::Null,
|
||||
"account_id": Value::Null,
|
||||
"downstream_api_key_id": Value::Null,
|
||||
"site_id": Value::Null,
|
||||
"username": Value::Null,
|
||||
"site_name": Value::Null,
|
||||
"site_url": Value::Null,
|
||||
"downstream_key_name": result.get("keyName").cloned().unwrap_or(Value::Null),
|
||||
"downstream_key_group_name": Value::Null,
|
||||
"downstream_key_tags": Value::Null,
|
||||
"model_requested": result.get("model").cloned().unwrap_or(Value::Null),
|
||||
"model_actual": result.get("model").cloned().unwrap_or(Value::Null),
|
||||
"status": status,
|
||||
"http_status": if status == "success" { 200 } else if status == "running" || status == "pending" { 202 } else { 500 },
|
||||
"latency_ms": Value::Null,
|
||||
"first_byte_latency_ms": Value::Null,
|
||||
"prompt_tokens": Value::Null,
|
||||
"completion_tokens": Value::Null,
|
||||
"total_tokens": Value::Null,
|
||||
"estimated_cost": 0.0,
|
||||
"error_message": task.error.clone(),
|
||||
"retry_count": 0,
|
||||
"is_stream": false,
|
||||
"client_family": "picture-agent",
|
||||
"client_app_id": "picture-agent",
|
||||
"client_app_name": "Picture Agent",
|
||||
"client_confidence": "high",
|
||||
"created_at": task.created_at,
|
||||
"source": "picture-agent",
|
||||
"task_type": task.task_type,
|
||||
"title": task.title,
|
||||
"message": task.message,
|
||||
"error": task.error,
|
||||
"progress": task.progress,
|
||||
"progress_total": task.progress_total,
|
||||
"status_label": status,
|
||||
"result": result,
|
||||
})
|
||||
})
|
||||
.collect())
|
||||
}
|
||||
|
||||
async fn user_usage_log_items(state: &AppState, user_id: i64) -> AppResult<Vec<Value>> {
|
||||
let rows = sqlx::query(
|
||||
r#"
|
||||
SELECT
|
||||
l.id,
|
||||
l.model,
|
||||
l.cost,
|
||||
l.prompt_tokens,
|
||||
l.completion_tokens,
|
||||
l.total_tokens,
|
||||
l.created_at,
|
||||
k.name AS downstream_key_name
|
||||
FROM usage_logs l
|
||||
JOIN downstream_api_keys k ON k.id = l.downstream_key_id
|
||||
WHERE k.user_id = ?
|
||||
ORDER BY l.created_at DESC, l.id DESC
|
||||
LIMIT 5000
|
||||
"#,
|
||||
)
|
||||
.bind(user_id)
|
||||
.fetch_all(&*state.db)
|
||||
.await?;
|
||||
|
||||
Ok(rows
|
||||
.into_iter()
|
||||
.map(|row| {
|
||||
let model = row
|
||||
.try_get::<Option<String>, _>("model")
|
||||
.ok()
|
||||
.flatten()
|
||||
.unwrap_or_default();
|
||||
let model_trimmed = model.trim().to_string();
|
||||
let source_model = model_trimmed
|
||||
.split(':')
|
||||
.next_back()
|
||||
.unwrap_or(&model_trimmed)
|
||||
.trim()
|
||||
.to_string();
|
||||
json!({
|
||||
"id": format!("usage-{}", row.try_get::<i64, _>("id").unwrap_or_default()),
|
||||
"route_id": Value::Null,
|
||||
"channel_id": Value::Null,
|
||||
"account_id": Value::Null,
|
||||
"downstream_api_key_id": Value::Null,
|
||||
"site_id": Value::Null,
|
||||
"username": Value::Null,
|
||||
"site_name": Value::Null,
|
||||
"site_url": Value::Null,
|
||||
"downstream_key_name": row.try_get::<Option<String>, _>("downstream_key_name").ok().flatten(),
|
||||
"downstream_key_group_name": Value::Null,
|
||||
"downstream_key_tags": Value::Null,
|
||||
"model_requested": source_model,
|
||||
"model_actual": source_model,
|
||||
"status": "success",
|
||||
"http_status": 200,
|
||||
"latency_ms": Value::Null,
|
||||
"first_byte_latency_ms": Value::Null,
|
||||
"prompt_tokens": row.try_get::<Option<i64>, _>("prompt_tokens").ok().flatten(),
|
||||
"completion_tokens": row.try_get::<Option<i64>, _>("completion_tokens").ok().flatten(),
|
||||
"total_tokens": row.try_get::<Option<i64>, _>("total_tokens").ok().flatten(),
|
||||
"estimated_cost": row.try_get::<Option<f64>, _>("cost").ok().flatten().unwrap_or(0.0),
|
||||
"error_message": Value::Null,
|
||||
"retry_count": 0,
|
||||
"is_stream": false,
|
||||
"client_family": "picture-agent",
|
||||
"client_app_id": "picture-agent",
|
||||
"client_app_name": "Picture Agent",
|
||||
"client_confidence": "high",
|
||||
"created_at": row.try_get::<Option<String>, _>("created_at").ok().flatten(),
|
||||
"source": "usage-logs",
|
||||
"task_type": "usage-log",
|
||||
"title": "生图助手",
|
||||
"message": model_trimmed,
|
||||
"error": Value::Null,
|
||||
"progress": Value::Null,
|
||||
"progress_total": Value::Null,
|
||||
})
|
||||
})
|
||||
.collect())
|
||||
}
|
||||
|
||||
fn user_log_text(item: &Value) -> String {
|
||||
[
|
||||
item.get("model_requested").and_then(Value::as_str),
|
||||
item.get("model_actual").and_then(Value::as_str),
|
||||
item.get("downstream_key_name").and_then(Value::as_str),
|
||||
item.get("site_name").and_then(Value::as_str),
|
||||
item.get("site_url").and_then(Value::as_str),
|
||||
item.get("error_message").and_then(Value::as_str),
|
||||
item.get("message").and_then(Value::as_str),
|
||||
item.get("title").and_then(Value::as_str),
|
||||
item.get("source").and_then(Value::as_str),
|
||||
]
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.collect::<Vec<_>>()
|
||||
.join(" ")
|
||||
.to_ascii_lowercase()
|
||||
}
|
||||
|
||||
fn user_log_status(item: &Value) -> String {
|
||||
item.get("status")
|
||||
.and_then(Value::as_str)
|
||||
.unwrap_or_default()
|
||||
.to_ascii_lowercase()
|
||||
}
|
||||
|
||||
fn user_log_created_at(item: &Value) -> String {
|
||||
item.get("created_at")
|
||||
.and_then(Value::as_str)
|
||||
.unwrap_or_default()
|
||||
.to_string()
|
||||
}
|
||||
|
||||
fn user_log_cost(item: &Value) -> f64 {
|
||||
item.get("estimated_cost")
|
||||
.and_then(Value::as_f64)
|
||||
.unwrap_or(0.0)
|
||||
}
|
||||
|
||||
fn user_log_tokens(item: &Value) -> i64 {
|
||||
item.get("total_tokens")
|
||||
.and_then(Value::as_i64)
|
||||
.unwrap_or(0)
|
||||
}
|
||||
|
||||
fn matches_user_log_filters(
|
||||
item: &Value,
|
||||
q: Option<&str>,
|
||||
status: Option<&str>,
|
||||
date_from: Option<&str>,
|
||||
date_to: Option<&str>,
|
||||
) -> bool {
|
||||
if let Some(q) = q {
|
||||
let q = q.to_ascii_lowercase();
|
||||
if !user_log_text(item).contains(&q) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(status) = status {
|
||||
let item_status = user_log_status(item);
|
||||
let normalized = status.to_ascii_lowercase();
|
||||
if normalized == "success" {
|
||||
if item_status != "success" {
|
||||
return false;
|
||||
}
|
||||
} else if normalized == "failed" {
|
||||
if !matches!(item_status.as_str(), "failed" | "cancelled") {
|
||||
return false;
|
||||
}
|
||||
} else if item_status != normalized {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
let created_at = user_log_created_at(item);
|
||||
if let Some(date_from) = date_from {
|
||||
if !created_at.is_empty() && created_at.as_str() < date_from {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
if let Some(date_to) = date_to {
|
||||
if !created_at.is_empty() && created_at.as_str() > date_to {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
true
|
||||
}
|
||||
|
||||
fn sort_user_logs_desc(items: &mut [Value]) {
|
||||
items.sort_by(|a, b| {
|
||||
let created_cmp = user_log_created_at(b).cmp(&user_log_created_at(a));
|
||||
if created_cmp != std::cmp::Ordering::Equal {
|
||||
return created_cmp;
|
||||
}
|
||||
let id_a = a.get("id").map(Value::to_string).unwrap_or_default();
|
||||
let id_b = b.get("id").map(Value::to_string).unwrap_or_default();
|
||||
id_b.cmp(&id_a)
|
||||
});
|
||||
}
|
||||
|
||||
async fn load_user_log_items(state: &AppState, user_id: i64) -> AppResult<Vec<Value>> {
|
||||
let mut items: Vec<Value> = sqlx::query_as::<_, crate::routes::stats::ProxyLogRow>(
|
||||
r#"
|
||||
SELECT
|
||||
l.id, l.route_id, l.channel_id, l.account_id, l.downstream_api_key_id,
|
||||
l.model_requested, l.model_actual, l.status, l.http_status,
|
||||
l.latency_ms, l.first_byte_latency_ms,
|
||||
l.prompt_tokens, l.completion_tokens, l.total_tokens, l.estimated_cost,
|
||||
l.error_message, l.retry_count, l.is_stream,
|
||||
l.client_family, l.client_app_id, l.client_app_name, l.client_confidence,
|
||||
l.created_at,
|
||||
a.site_id AS site_id,
|
||||
COALESCE(NULLIF(a.username, ''), CASE WHEN a.id IS NULL THEN NULL ELSE '账号 #' || a.id END) AS username,
|
||||
s.name AS site_name,
|
||||
s.url AS site_url,
|
||||
k.name AS downstream_key_name,
|
||||
k.group_name AS downstream_key_group_name,
|
||||
k.tags AS downstream_key_tags
|
||||
FROM proxy_logs l
|
||||
JOIN downstream_api_keys k ON l.downstream_api_key_id = k.id
|
||||
LEFT JOIN accounts a ON a.id = l.account_id
|
||||
LEFT JOIN sites s ON s.id = a.site_id
|
||||
WHERE k.user_id = ?
|
||||
ORDER BY l.created_at DESC
|
||||
LIMIT 5000
|
||||
"#,
|
||||
)
|
||||
.bind(user_id)
|
||||
.fetch_all(&*state.db)
|
||||
.await?
|
||||
.into_iter()
|
||||
.map(|row| serde_json::to_value(row).unwrap_or(json!({})))
|
||||
.collect();
|
||||
|
||||
items.extend(user_usage_log_items(state, user_id).await?);
|
||||
items.extend(user_picture_log_items(state, user_id).await?);
|
||||
sort_user_logs_desc(&mut items);
|
||||
Ok(items)
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
@@ -190,98 +491,40 @@ async fn get_user_logs(
|
||||
let offset = params.offset.unwrap_or((page - 1) * page_size).max(0);
|
||||
|
||||
let q = params.q.as_deref().map(str::trim).filter(|s| !s.is_empty());
|
||||
let q_like = q.map(|s| format!("%{s}%"));
|
||||
let status = params
|
||||
.status
|
||||
.as_deref()
|
||||
.filter(|s| !s.is_empty() && *s != "all");
|
||||
let date_from = params.date_from.as_deref().filter(|s| !s.is_empty());
|
||||
let date_to = params.date_to.as_deref().filter(|s| !s.is_empty());
|
||||
|
||||
let items: Vec<Value> = sqlx::query_as::<_, crate::routes::stats::ProxyLogRow>(
|
||||
r#"
|
||||
SELECT
|
||||
l.id, l.route_id, l.channel_id, l.account_id, l.downstream_api_key_id,
|
||||
l.model_requested, l.model_actual, l.status, l.http_status,
|
||||
l.latency_ms, l.first_byte_latency_ms,
|
||||
l.prompt_tokens, l.completion_tokens, l.total_tokens, l.estimated_cost,
|
||||
l.error_message, l.retry_count, l.is_stream,
|
||||
l.client_family, l.client_app_id, l.client_app_name, l.client_confidence,
|
||||
l.created_at,
|
||||
a.site_id AS site_id,
|
||||
COALESCE(NULLIF(a.username, ''), CASE WHEN a.id IS NULL THEN NULL ELSE '账号 #' || a.id END) AS username,
|
||||
s.name AS site_name,
|
||||
s.url AS site_url,
|
||||
k.name AS downstream_key_name,
|
||||
k.group_name AS downstream_key_group_name,
|
||||
k.tags AS downstream_key_tags
|
||||
FROM proxy_logs l
|
||||
JOIN downstream_api_keys k ON l.downstream_api_key_id = k.id
|
||||
LEFT JOIN accounts a ON a.id = l.account_id
|
||||
LEFT JOIN sites s ON s.id = a.site_id
|
||||
WHERE k.user_id = ?
|
||||
AND (? IS NULL OR l.model_requested LIKE ?)
|
||||
AND (? IS NULL OR l.status = ?)
|
||||
AND (? IS NULL OR l.created_at >= ?)
|
||||
AND (? IS NULL OR l.created_at <= ?)
|
||||
ORDER BY l.created_at DESC
|
||||
LIMIT ? OFFSET ?
|
||||
"#,
|
||||
)
|
||||
.bind(user_id)
|
||||
.bind(q_like.as_deref())
|
||||
.bind(q_like.as_deref())
|
||||
.bind(status)
|
||||
.bind(status)
|
||||
.bind(date_from)
|
||||
.bind(date_from)
|
||||
.bind(date_to)
|
||||
.bind(date_to)
|
||||
.bind(page_size)
|
||||
.bind(offset)
|
||||
.fetch_all(&*state.db)
|
||||
.await?
|
||||
.into_iter()
|
||||
.map(|row| serde_json::to_value(row).unwrap_or(json!({})))
|
||||
.collect();
|
||||
|
||||
let (total, success_count, total_cost, total_tokens): (i64, i64, f64, i64) = sqlx::query_as(
|
||||
r#"
|
||||
SELECT
|
||||
COUNT(*),
|
||||
SUM(CASE WHEN l.status = 'success' THEN 1 ELSE 0 END),
|
||||
COALESCE(SUM(l.estimated_cost), 0.0),
|
||||
COALESCE(SUM(l.total_tokens), 0)
|
||||
FROM proxy_logs l
|
||||
JOIN downstream_api_keys k ON l.downstream_api_key_id = k.id
|
||||
WHERE k.user_id = ?
|
||||
AND (? IS NULL OR l.model_requested LIKE ?)
|
||||
AND (? IS NULL OR l.status = ?)
|
||||
AND (? IS NULL OR l.created_at >= ?)
|
||||
AND (? IS NULL OR l.created_at <= ?)
|
||||
"#,
|
||||
)
|
||||
.bind(user_id)
|
||||
.bind(q_like.as_deref())
|
||||
.bind(q_like.as_deref())
|
||||
.bind(status)
|
||||
.bind(status)
|
||||
.bind(date_from)
|
||||
.bind(date_from)
|
||||
.bind(date_to)
|
||||
.bind(date_to)
|
||||
.fetch_one(&*state.db)
|
||||
.await?;
|
||||
let items = load_user_log_items(&state, user_id).await?;
|
||||
let filtered_items: Vec<Value> = items
|
||||
.into_iter()
|
||||
.filter(|item| matches_user_log_filters(item, q, status, date_from, date_to))
|
||||
.collect();
|
||||
let total = filtered_items.len() as i64;
|
||||
let success_count = filtered_items
|
||||
.iter()
|
||||
.filter(|item| user_log_status(item).as_str() == "success")
|
||||
.count() as i64;
|
||||
let failed_count = total - success_count;
|
||||
let total_cost = filtered_items.iter().map(user_log_cost).sum::<f64>();
|
||||
let total_tokens = filtered_items.iter().map(user_log_tokens).sum::<i64>();
|
||||
let paged_items = filtered_items
|
||||
.into_iter()
|
||||
.skip(offset as usize)
|
||||
.take(page_size as usize)
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
Ok(Json(json!({
|
||||
"items": items,
|
||||
"items": paged_items,
|
||||
"total": total,
|
||||
"page": page,
|
||||
"page_size": page_size,
|
||||
"summary": {
|
||||
"total": total,
|
||||
"success": success_count,
|
||||
"failed": total - success_count,
|
||||
"failed": failed_count,
|
||||
"total_cost": total_cost,
|
||||
"total_tokens": total_tokens,
|
||||
}
|
||||
@@ -293,22 +536,57 @@ async fn get_user_summary(
|
||||
headers: HeaderMap,
|
||||
) -> AppResult<Json<Value>> {
|
||||
let user_id = current_user_id(&headers, &state)?;
|
||||
|
||||
let stats: (i64, i64, f64) = sqlx::query_as(
|
||||
"SELECT COUNT(*),
|
||||
SUM(CASE WHEN l.status = 'success' THEN 1 ELSE 0 END),
|
||||
COALESCE(SUM(l.estimated_cost), 0.0)
|
||||
FROM proxy_logs l
|
||||
JOIN downstream_api_keys k ON l.downstream_api_key_id = k.id
|
||||
WHERE k.user_id = ?",
|
||||
)
|
||||
.bind(user_id)
|
||||
.fetch_one(&*state.db)
|
||||
.await?;
|
||||
let items = load_user_log_items(&state, user_id).await?;
|
||||
let total = items.len() as i64;
|
||||
let success_count = items
|
||||
.iter()
|
||||
.filter(|item| user_log_status(item).as_str() == "success")
|
||||
.count() as i64;
|
||||
let total_cost = items.iter().map(user_log_cost).sum::<f64>();
|
||||
|
||||
Ok(Json(json!({
|
||||
"totalRequests": stats.0,
|
||||
"successRequests": stats.1,
|
||||
"totalCost": stats.2,
|
||||
"totalRequests": total,
|
||||
"successRequests": success_count,
|
||||
"totalCost": total_cost,
|
||||
})))
|
||||
}
|
||||
|
||||
async fn get_user_models_marketplace(
|
||||
State(state): State<AppState>,
|
||||
headers: HeaderMap,
|
||||
Query(query): Query<crate::routes::admin::models_marketplace::MarketplaceQuery>,
|
||||
) -> AppResult<Json<Value>> {
|
||||
let _user_id = current_user_id(&headers, &state)?;
|
||||
let value = crate::routes::admin::models_marketplace::marketplace_inner(&state.db).await?;
|
||||
let models = value
|
||||
.get("models")
|
||||
.and_then(Value::as_array)
|
||||
.map(|items| {
|
||||
items
|
||||
.iter()
|
||||
.map(|model| {
|
||||
let brand = model.get("name").and_then(Value::as_str).unwrap_or_default();
|
||||
json!({
|
||||
"name": brand,
|
||||
"accountCount": 0,
|
||||
"tokenCount": 0,
|
||||
"avgLatency": model.get("avgLatency").cloned().unwrap_or(Value::Null),
|
||||
"successRate": model.get("successRate").cloned().unwrap_or(Value::Null),
|
||||
"description": model.get("description").cloned().unwrap_or(Value::Null),
|
||||
"tags": model.get("tags").cloned().unwrap_or_else(|| json!([])),
|
||||
"supportedEndpointTypes": model.get("supportedEndpointTypes").cloned().unwrap_or_else(|| json!([])),
|
||||
"pricingSources": [],
|
||||
"accounts": [],
|
||||
})
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
})
|
||||
.unwrap_or_default();
|
||||
|
||||
Ok(Json(json!({
|
||||
"models": models,
|
||||
"meta": value.get("meta").cloned().unwrap_or_else(|| json!({})),
|
||||
"userView": true,
|
||||
"includePricing": query.include_pricing.is_some(),
|
||||
})))
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user