Update BossAI workflow and workspace experience
This commit is contained in:
+455
-60
@@ -8,8 +8,10 @@ use axum::{
|
||||
routing::{get, post, put},
|
||||
Json, Router,
|
||||
};
|
||||
use chrono::{DateTime, Duration, Utc};
|
||||
use serde::Deserialize;
|
||||
use serde_json::{json, Value};
|
||||
use sqlx::Row;
|
||||
|
||||
pub fn routes() -> Router<AppState> {
|
||||
Router::new()
|
||||
@@ -100,6 +102,62 @@ struct IdPath {
|
||||
id: i64,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct DownstreamUsageQuery {
|
||||
range: Option<String>,
|
||||
#[serde(rename = "timeZone")]
|
||||
time_zone: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, sqlx::FromRow)]
|
||||
struct DownstreamKeyUsageRow {
|
||||
id: i64,
|
||||
name: String,
|
||||
key: String,
|
||||
description: Option<String>,
|
||||
group_name: Option<String>,
|
||||
tags: Option<String>,
|
||||
enabled: Option<bool>,
|
||||
expires_at: Option<String>,
|
||||
max_cost: Option<f64>,
|
||||
used_cost: Option<f64>,
|
||||
max_requests: Option<i64>,
|
||||
used_requests: Option<i64>,
|
||||
supported_models: Option<String>,
|
||||
allowed_route_ids: Option<String>,
|
||||
site_weight_multipliers: Option<String>,
|
||||
excluded_site_ids: Option<String>,
|
||||
excluded_credential_refs: Option<String>,
|
||||
last_used_at: Option<String>,
|
||||
user_id: Option<i64>,
|
||||
created_at: Option<String>,
|
||||
updated_at: Option<String>,
|
||||
range_requests: i64,
|
||||
range_success_requests: i64,
|
||||
range_failed_requests: i64,
|
||||
range_tokens: i64,
|
||||
range_cost: f64,
|
||||
}
|
||||
|
||||
#[derive(Debug, sqlx::FromRow)]
|
||||
struct DownstreamAggregateRow {
|
||||
total_requests: i64,
|
||||
success_requests: i64,
|
||||
failed_requests: i64,
|
||||
total_tokens: i64,
|
||||
total_cost: f64,
|
||||
}
|
||||
|
||||
#[derive(Debug, sqlx::FromRow)]
|
||||
struct DownstreamTrendBucketRow {
|
||||
bucket_start_utc: String,
|
||||
total_requests: i64,
|
||||
success_requests: i64,
|
||||
failed_requests: i64,
|
||||
total_tokens: i64,
|
||||
total_cost: f64,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct DatabaseConnectionRequest {
|
||||
@@ -128,42 +186,73 @@ async fn list_routes_lite(State(state): State<AppState>) -> AppResult<Json<Value
|
||||
}
|
||||
|
||||
async fn routes_summary(State(state): State<AppState>) -> AppResult<Json<Value>> {
|
||||
let routes: Vec<TokenRoute> = sqlx::query_as("SELECT * FROM token_routes ORDER BY id DESC")
|
||||
let routes: Vec<sqlx::sqlite::SqliteRow> = sqlx::query(
|
||||
r#"
|
||||
SELECT
|
||||
tr.id,
|
||||
tr.model_pattern,
|
||||
tr.model_mapping,
|
||||
tr.display_name,
|
||||
tr.display_icon,
|
||||
tr.decision_snapshot,
|
||||
tr.decision_refreshed_at,
|
||||
tr.routing_strategy,
|
||||
tr.route_mode,
|
||||
tr.enabled,
|
||||
COUNT(rc.id) AS channel_count,
|
||||
COALESCE(SUM(CASE WHEN COALESCE(rc.enabled, 1) = 1 THEN 1 ELSE 0 END), 0) AS enabled_channel_count,
|
||||
COALESCE(GROUP_CONCAT(DISTINCT s.name), '') AS site_names
|
||||
FROM token_routes tr
|
||||
LEFT JOIN route_channels rc ON rc.route_id = tr.id
|
||||
LEFT JOIN accounts a ON a.id = rc.account_id
|
||||
LEFT JOIN sites s ON s.id = a.site_id
|
||||
GROUP BY tr.id
|
||||
ORDER BY tr.id DESC
|
||||
"#,
|
||||
)
|
||||
.fetch_all(&*state.db)
|
||||
.await?;
|
||||
let mut summaries = Vec::new();
|
||||
for route in routes {
|
||||
let channel_count: i64 =
|
||||
sqlx::query_scalar("SELECT COUNT(*) FROM route_channels WHERE route_id = ?")
|
||||
.bind(route.id)
|
||||
.fetch_one(&*state.db)
|
||||
.await?;
|
||||
let enabled_channel_count: i64 = sqlx::query_scalar(
|
||||
"SELECT COUNT(*) FROM route_channels WHERE route_id = ? AND COALESCE(enabled, 1) = 1",
|
||||
)
|
||||
.bind(route.id)
|
||||
.fetch_one(&*state.db)
|
||||
.await?;
|
||||
summaries.push(json!({
|
||||
"id": route.id,
|
||||
"modelPattern": route.model_pattern,
|
||||
"displayName": route.display_name,
|
||||
"displayIcon": route.display_icon,
|
||||
"routeMode": route.route_mode,
|
||||
|
||||
let summaries: Vec<Value> = routes
|
||||
.into_iter()
|
||||
.map(|row| {
|
||||
let site_names_raw: String = row.try_get("site_names").unwrap_or_default();
|
||||
let site_names: Vec<String> = site_names_raw
|
||||
.split(',')
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
.map(str::to_string)
|
||||
.collect();
|
||||
let decision_snapshot_raw: Option<String> = row.try_get("decision_snapshot").ok();
|
||||
let decision_snapshot = decision_snapshot_raw
|
||||
.as_deref()
|
||||
.and_then(|raw| serde_json::from_str::<Value>(raw).ok())
|
||||
.unwrap_or(Value::Null);
|
||||
json!({
|
||||
"id": row.try_get::<i64, _>("id").unwrap_or_default(),
|
||||
"modelPattern": row.try_get::<String, _>("model_pattern").unwrap_or_default(),
|
||||
"displayName": row.try_get::<Option<String>, _>("display_name").ok().flatten(),
|
||||
"displayIcon": row.try_get::<Option<String>, _>("display_icon").ok().flatten(),
|
||||
"routeMode": row.try_get::<Option<String>, _>("route_mode").ok().flatten(),
|
||||
"sourceRouteIds": [],
|
||||
"modelMapping": route.model_mapping,
|
||||
"routingStrategy": route.routing_strategy,
|
||||
"enabled": route.enabled.unwrap_or(true),
|
||||
"channelCount": channel_count,
|
||||
"enabledChannelCount": enabled_channel_count,
|
||||
"siteNames": [],
|
||||
"decisionSnapshot": Value::Null,
|
||||
"decisionRefreshedAt": Value::Null,
|
||||
}));
|
||||
}
|
||||
Ok(Json(
|
||||
json!({ "success": true, "items": summaries, "data": summaries }),
|
||||
))
|
||||
"modelMapping": row.try_get::<Option<String>, _>("model_mapping").ok().flatten(),
|
||||
"routingStrategy": row.try_get::<Option<String>, _>("routing_strategy").ok().flatten(),
|
||||
"enabled": row.try_get::<Option<bool>, _>("enabled").ok().flatten().unwrap_or(true),
|
||||
"channelCount": row.try_get::<i64, _>("channel_count").unwrap_or_default(),
|
||||
"enabledChannelCount": row.try_get::<i64, _>("enabled_channel_count").unwrap_or_default(),
|
||||
"siteNames": site_names,
|
||||
"decisionSnapshot": decision_snapshot,
|
||||
"decisionRefreshedAt": row.try_get::<Option<String>, _>("decision_refreshed_at").ok().flatten(),
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
Ok(Json(json!({
|
||||
"success": true,
|
||||
"routes": summaries,
|
||||
"items": summaries,
|
||||
"data": summaries,
|
||||
"total": summaries.len(),
|
||||
})))
|
||||
}
|
||||
|
||||
async fn create_route(
|
||||
@@ -375,9 +464,17 @@ async fn clear_route_cooldown(
|
||||
|
||||
async fn rebuild_routes(State(state): State<AppState>) -> AppResult<Json<Value>> {
|
||||
let summary = crate::services::route_rebuild::rebuild_routes(&state).await?;
|
||||
let total_routes: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM token_routes")
|
||||
.fetch_one(&*state.db)
|
||||
.await?;
|
||||
let total_channels: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM route_channels")
|
||||
.fetch_one(&*state.db)
|
||||
.await?;
|
||||
Ok(Json(json!({
|
||||
"success": true,
|
||||
"rebuild": summary,
|
||||
"totalRoutes": total_routes,
|
||||
"totalChannels": total_channels,
|
||||
})))
|
||||
}
|
||||
|
||||
@@ -494,16 +591,13 @@ async fn reset_downstream_key_usage(
|
||||
Ok(Json(json!({ "success": true })))
|
||||
}
|
||||
|
||||
async fn downstream_keys_summary(State(state): State<AppState>) -> AppResult<Json<Value>> {
|
||||
let items: Vec<Value> = sqlx::query_as::<
|
||||
_,
|
||||
crate::db::models::downstream_api_key::DownstreamApiKey,
|
||||
>("SELECT * FROM downstream_api_keys ORDER BY name")
|
||||
.fetch_all(&*state.db)
|
||||
.await?
|
||||
.into_iter()
|
||||
.map(|key| serde_json::to_value(key).unwrap_or_else(|_| json!({})))
|
||||
.collect();
|
||||
async fn downstream_keys_summary(
|
||||
State(state): State<AppState>,
|
||||
Query(query): Query<DownstreamUsageQuery>,
|
||||
) -> AppResult<Json<Value>> {
|
||||
let (range, since, _bucket_seconds) = resolve_downstream_usage_window(query.range.as_deref());
|
||||
let rows = fetch_downstream_key_usage_rows(&state, since.as_deref()).await?;
|
||||
let items: Vec<Value> = rows.into_iter().map(downstream_key_usage_json).collect();
|
||||
let total: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM downstream_api_keys")
|
||||
.fetch_one(&*state.db)
|
||||
.await?;
|
||||
@@ -522,6 +616,7 @@ async fn downstream_keys_summary(State(state): State<AppState>) -> AppResult<Jso
|
||||
.await?;
|
||||
Ok(Json(json!({
|
||||
"success": true,
|
||||
"range": range,
|
||||
"items": items,
|
||||
"data": items,
|
||||
"summary": {
|
||||
@@ -538,28 +633,327 @@ async fn downstream_key_overview(
|
||||
State(state): State<AppState>,
|
||||
Path(IdPath { id }): Path<IdPath>,
|
||||
) -> AppResult<Json<Value>> {
|
||||
let key: Option<Value> = sqlx::query_as::<
|
||||
_,
|
||||
crate::db::models::downstream_api_key::DownstreamApiKey,
|
||||
>("SELECT * FROM downstream_api_keys WHERE id = ?")
|
||||
.bind(id)
|
||||
.fetch_optional(&*state.db)
|
||||
.await?
|
||||
.map(|key| serde_json::to_value(key).unwrap_or_else(|_| json!({})));
|
||||
Ok(Json(json!({ "success": true, "item": key })))
|
||||
let key = fetch_downstream_key_usage_row(&state, id, None).await?;
|
||||
let item = downstream_key_usage_json(key);
|
||||
let usage = json!({
|
||||
"last24h": downstream_usage_aggregate(&state, id, Some(sqlite_utc_since(Duration::hours(24)))).await?,
|
||||
"last7d": downstream_usage_aggregate(&state, id, Some(sqlite_utc_since(Duration::days(7)))).await?,
|
||||
"all": downstream_usage_aggregate(&state, id, None).await?,
|
||||
});
|
||||
Ok(Json(json!({ "success": true, "item": item, "usage": usage })))
|
||||
}
|
||||
|
||||
async fn downstream_key_trend(
|
||||
State(state): State<AppState>,
|
||||
Path(IdPath { id }): Path<IdPath>,
|
||||
Query(q): Query<std::collections::HashMap<String, String>>,
|
||||
) -> Json<Value> {
|
||||
Json(json!({
|
||||
Query(q): Query<DownstreamUsageQuery>,
|
||||
) -> AppResult<Json<Value>> {
|
||||
let key: Option<(i64, String)> =
|
||||
sqlx::query_as("SELECT id, name FROM downstream_api_keys WHERE id = ?")
|
||||
.bind(id)
|
||||
.fetch_optional(&*state.db)
|
||||
.await?;
|
||||
let Some((key_id, key_name)) = key else {
|
||||
return Err(AppError::NotFound(format!("Downstream key {id} not found")));
|
||||
};
|
||||
|
||||
let (range, since, bucket_seconds) = resolve_downstream_usage_window(q.range.as_deref());
|
||||
let buckets = downstream_usage_trend(&state, key_id, since.as_deref(), bucket_seconds).await?;
|
||||
Ok(Json(json!({
|
||||
"success": true,
|
||||
"range": q.get("range").map(String::as_str).unwrap_or("24h"),
|
||||
"item": { "id": id, "name": "" },
|
||||
"bucketSeconds": 3600,
|
||||
"buckets": [],
|
||||
}))
|
||||
"range": range,
|
||||
"item": { "id": key_id, "name": key_name },
|
||||
"bucketSeconds": bucket_seconds,
|
||||
"timeZone": q.time_zone,
|
||||
"buckets": buckets,
|
||||
})))
|
||||
}
|
||||
|
||||
fn resolve_downstream_usage_window(range: Option<&str>) -> (&'static str, Option<String>, i64) {
|
||||
match range.unwrap_or("24h") {
|
||||
"7d" => ("7d", Some(sqlite_utc_since(Duration::days(7))), 86_400),
|
||||
"all" => ("all", None, 86_400),
|
||||
_ => ("24h", Some(sqlite_utc_since(Duration::hours(24))), 3_600),
|
||||
}
|
||||
}
|
||||
|
||||
fn sqlite_utc_since(duration: Duration) -> String {
|
||||
(Utc::now() - duration)
|
||||
.naive_utc()
|
||||
.format("%Y-%m-%d %H:%M:%S")
|
||||
.to_string()
|
||||
}
|
||||
|
||||
async fn fetch_downstream_key_usage_rows(
|
||||
state: &AppState,
|
||||
since: Option<&str>,
|
||||
) -> AppResult<Vec<DownstreamKeyUsageRow>> {
|
||||
let rows = sqlx::query_as::<_, DownstreamKeyUsageRow>(
|
||||
r#"
|
||||
SELECT
|
||||
k.id, k.name, k.key, k.description, k.group_name, k.tags, k.enabled,
|
||||
k.expires_at, k.max_cost, k.used_cost, k.max_requests, k.used_requests,
|
||||
k.supported_models, k.allowed_route_ids, k.site_weight_multipliers,
|
||||
k.excluded_site_ids, k.excluded_credential_refs, k.last_used_at,
|
||||
k.user_id, k.created_at, k.updated_at,
|
||||
COUNT(l.id) AS range_requests,
|
||||
COALESCE(SUM(CASE WHEN l.status = 'success' THEN 1 ELSE 0 END), 0) AS range_success_requests,
|
||||
COALESCE(SUM(CASE WHEN l.id IS NOT NULL AND l.status != 'success' THEN 1 ELSE 0 END), 0) AS range_failed_requests,
|
||||
COALESCE(SUM(l.total_tokens), 0) AS range_tokens,
|
||||
COALESCE(SUM(l.estimated_cost), 0.0) AS range_cost
|
||||
FROM downstream_api_keys k
|
||||
LEFT JOIN proxy_logs l
|
||||
ON l.downstream_api_key_id = k.id
|
||||
AND (? IS NULL OR l.created_at >= ?)
|
||||
GROUP BY k.id
|
||||
ORDER BY k.name
|
||||
"#,
|
||||
)
|
||||
.bind(since)
|
||||
.bind(since)
|
||||
.fetch_all(&*state.db)
|
||||
.await?;
|
||||
Ok(rows)
|
||||
}
|
||||
|
||||
async fn fetch_downstream_key_usage_row(
|
||||
state: &AppState,
|
||||
key_id: i64,
|
||||
since: Option<&str>,
|
||||
) -> AppResult<DownstreamKeyUsageRow> {
|
||||
sqlx::query_as::<_, DownstreamKeyUsageRow>(
|
||||
r#"
|
||||
SELECT
|
||||
k.id, k.name, k.key, k.description, k.group_name, k.tags, k.enabled,
|
||||
k.expires_at, k.max_cost, k.used_cost, k.max_requests, k.used_requests,
|
||||
k.supported_models, k.allowed_route_ids, k.site_weight_multipliers,
|
||||
k.excluded_site_ids, k.excluded_credential_refs, k.last_used_at,
|
||||
k.user_id, k.created_at, k.updated_at,
|
||||
COUNT(l.id) AS range_requests,
|
||||
COALESCE(SUM(CASE WHEN l.status = 'success' THEN 1 ELSE 0 END), 0) AS range_success_requests,
|
||||
COALESCE(SUM(CASE WHEN l.id IS NOT NULL AND l.status != 'success' THEN 1 ELSE 0 END), 0) AS range_failed_requests,
|
||||
COALESCE(SUM(l.total_tokens), 0) AS range_tokens,
|
||||
COALESCE(SUM(l.estimated_cost), 0.0) AS range_cost
|
||||
FROM downstream_api_keys k
|
||||
LEFT JOIN proxy_logs l
|
||||
ON l.downstream_api_key_id = k.id
|
||||
AND (? IS NULL OR l.created_at >= ?)
|
||||
WHERE k.id = ?
|
||||
GROUP BY k.id
|
||||
"#,
|
||||
)
|
||||
.bind(since)
|
||||
.bind(since)
|
||||
.bind(key_id)
|
||||
.fetch_optional(&*state.db)
|
||||
.await?
|
||||
.ok_or_else(|| AppError::NotFound(format!("Downstream key {key_id} not found")))
|
||||
}
|
||||
|
||||
async fn downstream_usage_aggregate(
|
||||
state: &AppState,
|
||||
key_id: i64,
|
||||
since: Option<String>,
|
||||
) -> AppResult<Value> {
|
||||
let row: DownstreamAggregateRow = sqlx::query_as(
|
||||
r#"
|
||||
SELECT
|
||||
COUNT(id) AS total_requests,
|
||||
COALESCE(SUM(CASE WHEN status = 'success' THEN 1 ELSE 0 END), 0) AS success_requests,
|
||||
COALESCE(SUM(CASE WHEN status != 'success' THEN 1 ELSE 0 END), 0) AS failed_requests,
|
||||
COALESCE(SUM(total_tokens), 0) AS total_tokens,
|
||||
COALESCE(SUM(estimated_cost), 0.0) AS total_cost
|
||||
FROM proxy_logs
|
||||
WHERE downstream_api_key_id = ?
|
||||
AND (? IS NULL OR created_at >= ?)
|
||||
"#,
|
||||
)
|
||||
.bind(key_id)
|
||||
.bind(since.as_deref())
|
||||
.bind(since.as_deref())
|
||||
.fetch_one(&*state.db)
|
||||
.await?;
|
||||
Ok(aggregate_usage_json(
|
||||
row.total_requests,
|
||||
row.success_requests,
|
||||
row.failed_requests,
|
||||
row.total_tokens,
|
||||
row.total_cost,
|
||||
))
|
||||
}
|
||||
|
||||
async fn downstream_usage_trend(
|
||||
state: &AppState,
|
||||
key_id: i64,
|
||||
since: Option<&str>,
|
||||
bucket_seconds: i64,
|
||||
) -> AppResult<Vec<Value>> {
|
||||
let rows: Vec<DownstreamTrendBucketRow> = sqlx::query_as(
|
||||
r#"
|
||||
SELECT
|
||||
datetime((CAST(strftime('%s', created_at) AS INTEGER) / ?) * ?, 'unixepoch') AS bucket_start_utc,
|
||||
COUNT(id) AS total_requests,
|
||||
COALESCE(SUM(CASE WHEN status = 'success' THEN 1 ELSE 0 END), 0) AS success_requests,
|
||||
COALESCE(SUM(CASE WHEN status != 'success' THEN 1 ELSE 0 END), 0) AS failed_requests,
|
||||
COALESCE(SUM(total_tokens), 0) AS total_tokens,
|
||||
COALESCE(SUM(estimated_cost), 0.0) AS total_cost
|
||||
FROM proxy_logs
|
||||
WHERE downstream_api_key_id = ?
|
||||
AND (? IS NULL OR created_at >= ?)
|
||||
GROUP BY bucket_start_utc
|
||||
ORDER BY bucket_start_utc
|
||||
"#,
|
||||
)
|
||||
.bind(bucket_seconds)
|
||||
.bind(bucket_seconds)
|
||||
.bind(key_id)
|
||||
.bind(since)
|
||||
.bind(since)
|
||||
.fetch_all(&*state.db)
|
||||
.await?;
|
||||
|
||||
Ok(rows
|
||||
.into_iter()
|
||||
.map(|row| {
|
||||
json!({
|
||||
"startUtc": normalize_sqlite_datetime_utc(&row.bucket_start_utc),
|
||||
"totalRequests": row.total_requests,
|
||||
"successRequests": row.success_requests,
|
||||
"failedRequests": row.failed_requests,
|
||||
"successRate": success_rate(row.success_requests, row.total_requests),
|
||||
"totalTokens": row.total_tokens,
|
||||
"totalCost": row.total_cost,
|
||||
})
|
||||
})
|
||||
.collect())
|
||||
}
|
||||
|
||||
fn downstream_key_usage_json(row: DownstreamKeyUsageRow) -> Value {
|
||||
let success_rate = success_rate(row.range_success_requests, row.range_requests);
|
||||
json!({
|
||||
"id": row.id,
|
||||
"name": row.name,
|
||||
"key": row.key,
|
||||
"keyMasked": mask_downstream_key(&row.key),
|
||||
"description": row.description,
|
||||
"groupName": row.group_name,
|
||||
"tags": json_text_array(row.tags.as_deref()),
|
||||
"enabled": row.enabled.unwrap_or(true),
|
||||
"expiresAt": row.expires_at,
|
||||
"maxCost": row.max_cost,
|
||||
"usedCost": row.used_cost.unwrap_or(0.0),
|
||||
"maxRequests": row.max_requests,
|
||||
"usedRequests": row.used_requests.unwrap_or(0),
|
||||
"supportedModels": json_text_array(row.supported_models.as_deref()),
|
||||
"allowedRouteIds": json_i64_array(row.allowed_route_ids.as_deref()),
|
||||
"siteWeightMultipliers": json_text_object(row.site_weight_multipliers.as_deref()),
|
||||
"excludedSiteIds": json_i64_array(row.excluded_site_ids.as_deref()),
|
||||
"excludedCredentialRefs": json_text_array(row.excluded_credential_refs.as_deref()),
|
||||
"lastUsedAt": row.last_used_at,
|
||||
"userId": row.user_id,
|
||||
"createdAt": row.created_at,
|
||||
"updatedAt": row.updated_at,
|
||||
"billing": {
|
||||
"requestLimit": limit_json(row.used_requests.unwrap_or(0) as f64, row.max_requests.map(|v| v as f64)),
|
||||
"costLimit": limit_json(row.used_cost.unwrap_or(0.0), row.max_cost),
|
||||
},
|
||||
"rangeUsage": {
|
||||
"totalRequests": row.range_requests,
|
||||
"successRequests": row.range_success_requests,
|
||||
"failedRequests": row.range_failed_requests,
|
||||
"successRate": success_rate,
|
||||
"totalTokens": row.range_tokens,
|
||||
"totalCost": row.range_cost,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
fn aggregate_usage_json(
|
||||
total_requests: i64,
|
||||
success_requests: i64,
|
||||
failed_requests: i64,
|
||||
total_tokens: i64,
|
||||
total_cost: f64,
|
||||
) -> Value {
|
||||
json!({
|
||||
"totalRequests": total_requests,
|
||||
"successRequests": success_requests,
|
||||
"failedRequests": failed_requests,
|
||||
"successRate": success_rate(success_requests, total_requests),
|
||||
"totalTokens": total_tokens,
|
||||
"totalCost": total_cost,
|
||||
})
|
||||
}
|
||||
|
||||
fn success_rate(success: i64, total: i64) -> Option<f64> {
|
||||
if total <= 0 {
|
||||
None
|
||||
} else {
|
||||
Some(((success as f64 / total as f64) * 10_000.0).round() / 100.0)
|
||||
}
|
||||
}
|
||||
|
||||
fn limit_json(used: f64, max: Option<f64>) -> Value {
|
||||
let percent = max
|
||||
.filter(|value| *value > 0.0)
|
||||
.map(|limit| ((used / limit) * 10_000.0).round() / 100.0);
|
||||
let remaining = max.map(|limit| (limit - used).max(0.0));
|
||||
let exhausted = max.map(|limit| used >= limit).unwrap_or(false);
|
||||
json!({
|
||||
"used": used,
|
||||
"max": max,
|
||||
"remaining": remaining,
|
||||
"percent": percent,
|
||||
"exhausted": exhausted,
|
||||
})
|
||||
}
|
||||
|
||||
fn mask_downstream_key(key: &str) -> String {
|
||||
let chars: Vec<char> = key.chars().collect();
|
||||
let last4: String = chars[chars.len().saturating_sub(4)..].iter().collect();
|
||||
format!("••••••••{}", last4)
|
||||
}
|
||||
|
||||
fn json_text_array(raw: Option<&str>) -> Vec<Value> {
|
||||
let Some(raw) = raw.map(str::trim).filter(|value| !value.is_empty()) else {
|
||||
return Vec::new();
|
||||
};
|
||||
if let Ok(Value::Array(items)) = serde_json::from_str::<Value>(raw) {
|
||||
return items;
|
||||
}
|
||||
raw.split(',')
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
.map(|value| Value::String(value.to_string()))
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn json_i64_array(raw: Option<&str>) -> Vec<i64> {
|
||||
json_text_array(raw)
|
||||
.into_iter()
|
||||
.filter_map(|value| match value {
|
||||
Value::Number(number) => number.as_i64(),
|
||||
Value::String(text) => text.parse::<i64>().ok(),
|
||||
_ => None,
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn json_text_object(raw: Option<&str>) -> Value {
|
||||
let Some(raw) = raw.map(str::trim).filter(|value| !value.is_empty()) else {
|
||||
return json!({});
|
||||
};
|
||||
serde_json::from_str::<Value>(raw).unwrap_or_else(|_| json!({}))
|
||||
}
|
||||
|
||||
fn normalize_sqlite_datetime_utc(value: &str) -> String {
|
||||
if value.ends_with('Z') || value.contains('+') {
|
||||
return value.to_string();
|
||||
}
|
||||
if DateTime::parse_from_rfc3339(value).is_ok() {
|
||||
return value.to_string();
|
||||
}
|
||||
format!("{}Z", value.replace(' ', "T"))
|
||||
}
|
||||
|
||||
async fn auth_info() -> Json<Value> {
|
||||
@@ -586,6 +980,7 @@ async fn runtime_settings(State(state): State<AppState>) -> Json<Value> {
|
||||
},
|
||||
"comfyui": {
|
||||
"enabled": state.config.comfyui.enabled,
|
||||
"backend": format!("{:?}", state.config.comfyui.backend).to_ascii_lowercase(),
|
||||
"port": state.config.comfyui.port,
|
||||
"baseUrl": state.config.comfyui.base_url,
|
||||
"externalUrl": state.config.comfyui.external_url,
|
||||
|
||||
@@ -140,6 +140,10 @@ pub async fn create_site(
|
||||
return Err(AppError::BadRequest("platform is required".into()));
|
||||
}
|
||||
|
||||
let site_url = normalize_site_url(&payload.url)?;
|
||||
let platform = payload.platform.trim().to_string();
|
||||
ensure_site_unique(&state, &platform, &site_url, None).await?;
|
||||
|
||||
let status = payload.status.unwrap_or_else(|| "active".to_string());
|
||||
|
||||
let mut tx = state.db.begin().await?;
|
||||
@@ -154,8 +158,8 @@ pub async fn create_site(
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
|
||||
)
|
||||
.bind(&payload.name)
|
||||
.bind(&payload.url)
|
||||
.bind(&payload.platform)
|
||||
.bind(&site_url)
|
||||
.bind(&platform)
|
||||
.bind(&status)
|
||||
.bind(&payload.api_key)
|
||||
.bind(&payload.proxy_url)
|
||||
@@ -217,6 +221,27 @@ pub async fn update_site(
|
||||
.await?
|
||||
.ok_or_else(|| AppError::NotFound(format!("Site with id {} not found", id)))?;
|
||||
|
||||
if payload
|
||||
.platform
|
||||
.as_deref()
|
||||
.is_some_and(|platform| platform.trim().is_empty())
|
||||
{
|
||||
return Err(AppError::BadRequest("platform is required".into()));
|
||||
}
|
||||
|
||||
let next_url = match payload.url.as_deref() {
|
||||
Some(url) => normalize_site_url(url)?,
|
||||
None => existing.url.clone(),
|
||||
};
|
||||
let next_platform = payload
|
||||
.platform
|
||||
.as_deref()
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
.unwrap_or(existing.platform.as_str())
|
||||
.to_string();
|
||||
ensure_site_unique(&state, &next_platform, &next_url, Some(id)).await?;
|
||||
|
||||
let mut tx = state.db.begin().await?;
|
||||
|
||||
sqlx::query(
|
||||
@@ -230,8 +255,8 @@ pub async fn update_site(
|
||||
WHERE id = ?",
|
||||
)
|
||||
.bind(payload.name.unwrap_or(existing.name))
|
||||
.bind(payload.url.unwrap_or(existing.url))
|
||||
.bind(payload.platform.unwrap_or(existing.platform))
|
||||
.bind(next_url)
|
||||
.bind(next_platform)
|
||||
.bind(payload.status.unwrap_or(existing.status))
|
||||
.bind(payload.api_key.or(existing.api_key))
|
||||
.bind(payload.proxy_url.or(existing.proxy_url))
|
||||
@@ -466,6 +491,45 @@ fn normalize_site_url(raw: &str) -> AppResult<String> {
|
||||
Ok(trimmed)
|
||||
}
|
||||
|
||||
async fn ensure_site_unique(
|
||||
state: &AppState,
|
||||
platform: &str,
|
||||
url: &str,
|
||||
except_id: Option<i64>,
|
||||
) -> AppResult<()> {
|
||||
let existing: Option<(i64, String)> = if let Some(id) = except_id {
|
||||
sqlx::query_as(
|
||||
"SELECT id, name FROM sites WHERE platform = ? AND url = ? AND id != ? LIMIT 1",
|
||||
)
|
||||
.bind(platform)
|
||||
.bind(url)
|
||||
.bind(id)
|
||||
.fetch_optional(&*state.db)
|
||||
.await?
|
||||
} else {
|
||||
sqlx::query_as("SELECT id, name FROM sites WHERE platform = ? AND url = ? LIMIT 1")
|
||||
.bind(platform)
|
||||
.bind(url)
|
||||
.fetch_optional(&*state.db)
|
||||
.await?
|
||||
};
|
||||
|
||||
if let Some((id, name)) = existing {
|
||||
let display_name = name.trim();
|
||||
let display_name = if display_name.is_empty() {
|
||||
"未命名站点"
|
||||
} else {
|
||||
display_name
|
||||
};
|
||||
return Err(AppError::BadRequest(format!(
|
||||
"该平台下已存在相同 URL 的站点:{}(ID {})",
|
||||
display_name, id
|
||||
)));
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn detect_site_platform(url: &str) -> (&'static str, Option<&'static str>) {
|
||||
let lower = url.to_ascii_lowercase();
|
||||
|
||||
|
||||
+225
-2
@@ -10,7 +10,7 @@ use axum::{
|
||||
routing::{get, post},
|
||||
Json, Router,
|
||||
};
|
||||
use serde_json::{json, Value};
|
||||
use serde_json::{json, Map, Value};
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
struct DigitalHumanAuth {
|
||||
@@ -101,7 +101,11 @@ async fn status(State(state): State<AppState>, headers: HeaderMap) -> AppResult<
|
||||
let _ = resolve_auth(&headers, &state).await?;
|
||||
Ok(Json(json!({
|
||||
"success": true,
|
||||
"mode": "boosapi-video-route",
|
||||
"mode": if state.config.comfyui.uses_runninghub_backend() { "runninghub-ai-app" } else { "boosapi-video-route" },
|
||||
"runninghub": {
|
||||
"configured": state.config.comfyui.api_key.as_deref().map(str::trim).is_some_and(|value| !value.is_empty()),
|
||||
"endpoint": "https://www.runninghub.cn/task/openapi/ai-app/run"
|
||||
},
|
||||
"capabilities": [
|
||||
"text_to_avatar_video",
|
||||
"image_to_avatar_video",
|
||||
@@ -112,12 +116,231 @@ async fn status(State(state): State<AppState>, headers: HeaderMap) -> AppResult<
|
||||
})))
|
||||
}
|
||||
|
||||
fn truthy(value: Option<&Value>) -> bool {
|
||||
match value {
|
||||
Some(Value::Bool(value)) => *value,
|
||||
Some(Value::Number(value)) => value.as_i64().unwrap_or_default() != 0,
|
||||
Some(Value::String(value)) => matches!(
|
||||
value.trim().to_ascii_lowercase().as_str(),
|
||||
"1" | "true" | "yes" | "runninghub"
|
||||
),
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
|
||||
fn runninghub_api_key(state: &AppState, body: &Value) -> AppResult<String> {
|
||||
body.get("runninghub_api_key")
|
||||
.and_then(Value::as_str)
|
||||
.or_else(|| state.config.comfyui.api_key.as_deref())
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
.map(ToOwned::to_owned)
|
||||
.ok_or_else(|| {
|
||||
AppError::BadRequest(
|
||||
"RunningHub api_key is required in config.toml [comfyui].api_key".into(),
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
fn runninghub_webapp_id(body: &Value) -> AppResult<Value> {
|
||||
let raw = body
|
||||
.get("webapp_id")
|
||||
.or_else(|| body.get("webappId"))
|
||||
.or_else(|| body.get("model"))
|
||||
.and_then(|value| match value {
|
||||
Value::String(value) => Some(value.trim().to_string()),
|
||||
Value::Number(value) => Some(value.to_string()),
|
||||
_ => None,
|
||||
})
|
||||
.filter(|value| !value.is_empty())
|
||||
.ok_or_else(|| AppError::BadRequest("RunningHub webappId is required".into()))?;
|
||||
if let Ok(value) = raw.parse::<i64>() {
|
||||
Ok(json!(value))
|
||||
} else {
|
||||
Ok(json!(raw))
|
||||
}
|
||||
}
|
||||
|
||||
fn push_node_info(
|
||||
items: &mut Vec<Value>,
|
||||
body: &Value,
|
||||
id_key: &str,
|
||||
field_name: &str,
|
||||
field_value: Option<String>,
|
||||
) {
|
||||
let Some(node_id) = body
|
||||
.get(id_key)
|
||||
.and_then(Value::as_str)
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
else {
|
||||
return;
|
||||
};
|
||||
let Some(field_value) = field_value
|
||||
.map(|value| value.trim().to_string())
|
||||
.filter(|value| !value.is_empty())
|
||||
else {
|
||||
return;
|
||||
};
|
||||
items.push(json!({
|
||||
"nodeId": node_id,
|
||||
"fieldName": field_name,
|
||||
"fieldValue": field_value
|
||||
}));
|
||||
}
|
||||
|
||||
fn runninghub_node_info_list(body: &Value) -> AppResult<Vec<Value>> {
|
||||
if let Some(list) = body
|
||||
.get("node_info_list")
|
||||
.or_else(|| body.get("nodeInfoList"))
|
||||
.and_then(Value::as_array)
|
||||
{
|
||||
let items = list
|
||||
.iter()
|
||||
.filter(|item| item.is_object())
|
||||
.cloned()
|
||||
.collect::<Vec<_>>();
|
||||
if !items.is_empty() {
|
||||
return Ok(items);
|
||||
}
|
||||
}
|
||||
|
||||
let mut items = Vec::new();
|
||||
let prompt = body
|
||||
.get("prompt")
|
||||
.and_then(Value::as_str)
|
||||
.map(ToOwned::to_owned);
|
||||
let script = body
|
||||
.get("script")
|
||||
.and_then(Value::as_str)
|
||||
.map(ToOwned::to_owned)
|
||||
.or_else(|| {
|
||||
body.get("prompt")
|
||||
.and_then(Value::as_str)
|
||||
.and_then(|value| {
|
||||
value
|
||||
.split_once("台词:")
|
||||
.map(|(_, script)| script.trim().to_string())
|
||||
})
|
||||
});
|
||||
|
||||
push_node_info(&mut items, body, "prompt_node_id", "prompt", prompt);
|
||||
push_node_info(&mut items, body, "script_node_id", "text", script);
|
||||
push_node_info(
|
||||
&mut items,
|
||||
body,
|
||||
"image_node_id",
|
||||
"image",
|
||||
body.get("image_url")
|
||||
.and_then(Value::as_str)
|
||||
.map(ToOwned::to_owned),
|
||||
);
|
||||
push_node_info(
|
||||
&mut items,
|
||||
body,
|
||||
"audio_node_id",
|
||||
"audio",
|
||||
body.get("audio_url")
|
||||
.and_then(Value::as_str)
|
||||
.map(ToOwned::to_owned),
|
||||
);
|
||||
|
||||
if items.is_empty() {
|
||||
return Err(AppError::BadRequest(
|
||||
"RunningHub nodeInfoList is required; set node_info_list or node id mappings".into(),
|
||||
));
|
||||
}
|
||||
Ok(items)
|
||||
}
|
||||
|
||||
fn runninghub_request_body(body: &Value, api_key: &str) -> AppResult<Value> {
|
||||
let mut payload = Map::new();
|
||||
payload.insert("webappId".to_string(), runninghub_webapp_id(body)?);
|
||||
payload.insert("apiKey".to_string(), json!(api_key));
|
||||
payload.insert(
|
||||
"nodeInfoList".to_string(),
|
||||
Value::Array(runninghub_node_info_list(body)?),
|
||||
);
|
||||
if let Some(value) = body
|
||||
.get("access_password")
|
||||
.or_else(|| body.get("accessPassword"))
|
||||
.and_then(Value::as_str)
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
{
|
||||
payload.insert("accessPassword".to_string(), json!(value));
|
||||
}
|
||||
if let Some(value) = body
|
||||
.get("instance_type")
|
||||
.or_else(|| body.get("instanceType"))
|
||||
.and_then(Value::as_str)
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
{
|
||||
payload.insert("instanceType".to_string(), json!(value));
|
||||
}
|
||||
if let Some(value) = body
|
||||
.get("webhook_url")
|
||||
.or_else(|| body.get("webhookUrl"))
|
||||
.and_then(Value::as_str)
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
{
|
||||
payload.insert("webhookUrl".to_string(), json!(value));
|
||||
}
|
||||
Ok(Value::Object(payload))
|
||||
}
|
||||
|
||||
async fn generate_runninghub(state: &AppState, body: Value) -> AppResult<Json<Value>> {
|
||||
let api_key = runninghub_api_key(state, &body)?;
|
||||
let request_body = runninghub_request_body(&body, &api_key)?;
|
||||
let response = reqwest::Client::builder()
|
||||
.timeout(std::time::Duration::from_secs(300))
|
||||
.build()
|
||||
.unwrap_or_else(|_| reqwest::Client::new())
|
||||
.post("https://www.runninghub.cn/task/openapi/ai-app/run")
|
||||
.bearer_auth(&api_key)
|
||||
.header("Content-Type", "application/json")
|
||||
.json(&request_body)
|
||||
.send()
|
||||
.await
|
||||
.map_err(|err| AppError::UpstreamError(format!("RunningHub request failed: {err}")))?;
|
||||
let status = response.status();
|
||||
let text = response
|
||||
.text()
|
||||
.await
|
||||
.map_err(|err| AppError::UpstreamError(err.to_string()))?;
|
||||
let value = serde_json::from_str::<Value>(&text).unwrap_or_else(|_| json!({ "body": text }));
|
||||
if !status.is_success() {
|
||||
return Err(AppError::UpstreamError(value.to_string()));
|
||||
}
|
||||
Ok(Json(json!({
|
||||
"success": true,
|
||||
"provider": "runninghub",
|
||||
"request": request_body,
|
||||
"response": value
|
||||
})))
|
||||
}
|
||||
|
||||
async fn generate(
|
||||
State(state): State<AppState>,
|
||||
headers: HeaderMap,
|
||||
Json(mut body): Json<Value>,
|
||||
) -> AppResult<Json<Value>> {
|
||||
let auth = resolve_auth(&headers, &state).await?;
|
||||
let provider = body
|
||||
.get("provider")
|
||||
.or_else(|| body.get("backend"))
|
||||
.and_then(Value::as_str)
|
||||
.map(str::trim)
|
||||
.unwrap_or("");
|
||||
if provider.eq_ignore_ascii_case("runninghub")
|
||||
|| truthy(body.get("use_runninghub"))
|
||||
|| (state.config.comfyui.uses_runninghub_backend()
|
||||
&& truthy(body.get("use_config_backend")))
|
||||
{
|
||||
return generate_runninghub(&state, body).await;
|
||||
}
|
||||
if body
|
||||
.get("model")
|
||||
.and_then(Value::as_str)
|
||||
|
||||
@@ -624,6 +624,7 @@ async fn check_auth(state: &AppState, headers: &HeaderMap) -> Result<Option<i64>
|
||||
if !key.enabled.unwrap_or(true) {
|
||||
return Err(AppError::Forbidden("API key is disabled".into()));
|
||||
}
|
||||
UsageTracker::new(state.db.clone()).check_rate_limit(key.id).await?;
|
||||
Ok(Some(key.id))
|
||||
}
|
||||
DownstreamAuthResult::Global { .. } => Ok(None),
|
||||
|
||||
+233
-23
@@ -8,6 +8,8 @@ use axum::{
|
||||
};
|
||||
use serde::Deserialize;
|
||||
use serde_json::{json, Value};
|
||||
use sqlx::Row;
|
||||
use std::collections::{BTreeMap, HashMap};
|
||||
use tracing::info;
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
@@ -124,6 +126,7 @@ async fn dashboard(
|
||||
.fetch_one(&*state.db)
|
||||
.await?;
|
||||
let site_availability = build_site_availability(&state).await?;
|
||||
let model_analysis = build_model_analysis(&state, 7).await?;
|
||||
|
||||
let payload = json!({
|
||||
"generatedAt": chrono::Utc::now().to_rfc3339(),
|
||||
@@ -148,6 +151,7 @@ async fn dashboard(
|
||||
"tokensPerMinute": perf_tokens as f64 * 60.0 / perf_window_secs as f64,
|
||||
},
|
||||
"siteAvailability": site_availability,
|
||||
"modelAnalysis": model_analysis,
|
||||
"summary": {
|
||||
"sites": sites,
|
||||
"accounts": accounts,
|
||||
@@ -186,11 +190,139 @@ async fn dashboard(
|
||||
"generatedAt": payload["generatedAt"],
|
||||
"insights": payload["insights"],
|
||||
"siteAvailability": payload["siteAvailability"],
|
||||
"modelAnalysis": payload["modelAnalysis"],
|
||||
}))),
|
||||
_ => Ok(Json(payload)),
|
||||
}
|
||||
}
|
||||
|
||||
async fn build_model_analysis(state: &AppState, days: i64) -> AppResult<Value> {
|
||||
let days = days.max(1);
|
||||
let rows = sqlx::query(
|
||||
r#"
|
||||
SELECT
|
||||
COALESCE(NULLIF(model_actual, ''), NULLIF(model_requested, ''), 'unknown') AS model,
|
||||
date(created_at) AS day,
|
||||
COUNT(*) AS calls,
|
||||
COALESCE(SUM(CASE WHEN status = 'success' THEN 1 ELSE 0 END), 0) AS success_calls,
|
||||
COALESCE(SUM(CASE WHEN status != 'success' OR status IS NULL THEN 1 ELSE 0 END), 0) AS failed_calls,
|
||||
COALESCE(SUM(estimated_cost), 0.0) AS spend,
|
||||
COALESCE(SUM(total_tokens), 0) AS tokens,
|
||||
AVG(latency_ms) AS avg_latency_ms
|
||||
FROM proxy_logs
|
||||
WHERE created_at >= datetime('now', ?)
|
||||
GROUP BY model, day
|
||||
ORDER BY day ASC
|
||||
"#,
|
||||
)
|
||||
.bind(format!("-{} days", days - 1))
|
||||
.fetch_all(&*state.db)
|
||||
.await?;
|
||||
|
||||
let mut by_model: HashMap<String, (i64, i64, i64, f64, i64, f64, i64)> = HashMap::new();
|
||||
let mut by_day: BTreeMap<String, f64> = BTreeMap::new();
|
||||
|
||||
for row in rows {
|
||||
let model: String = row
|
||||
.try_get("model")
|
||||
.unwrap_or_else(|_| "unknown".to_string());
|
||||
let day: String = row.try_get("day").unwrap_or_default();
|
||||
let calls: i64 = row.try_get("calls").unwrap_or_default();
|
||||
let success_calls: i64 = row.try_get("success_calls").unwrap_or_default();
|
||||
let failed_calls: i64 = row.try_get("failed_calls").unwrap_or_default();
|
||||
let spend: f64 = row.try_get("spend").unwrap_or_default();
|
||||
let tokens: i64 = row.try_get("tokens").unwrap_or_default();
|
||||
let avg_latency: Option<f64> = row.try_get("avg_latency_ms").ok().flatten();
|
||||
|
||||
let entry = by_model.entry(model).or_insert((0, 0, 0, 0.0, 0, 0.0, 0));
|
||||
entry.0 += calls;
|
||||
entry.1 += success_calls;
|
||||
entry.2 += failed_calls;
|
||||
entry.3 += spend;
|
||||
entry.4 += tokens;
|
||||
if let Some(latency) = avg_latency {
|
||||
entry.5 += latency * calls.max(1) as f64;
|
||||
entry.6 += calls.max(1);
|
||||
}
|
||||
if !day.is_empty() {
|
||||
*by_day.entry(day).or_insert(0.0) += spend;
|
||||
}
|
||||
}
|
||||
|
||||
let mut models: Vec<(String, (i64, i64, i64, f64, i64, f64, i64))> =
|
||||
by_model.into_iter().collect();
|
||||
models.sort_by(|a, b| b.1 .3.total_cmp(&a.1 .3).then_with(|| b.1 .0.cmp(&a.1 .0)));
|
||||
|
||||
let total_calls: i64 = models.iter().map(|(_, item)| item.0).sum();
|
||||
let total_spend: f64 = models.iter().map(|(_, item)| item.3).sum();
|
||||
let total_tokens: i64 = models.iter().map(|(_, item)| item.4).sum();
|
||||
|
||||
let spend_distribution: Vec<Value> = models
|
||||
.iter()
|
||||
.map(|(model, item)| {
|
||||
json!({
|
||||
"model": model,
|
||||
"calls": item.0,
|
||||
"spend": (item.3 * 1_000_000.0).round() / 1_000_000.0,
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
let calls_distribution: Vec<Value> = models
|
||||
.iter()
|
||||
.map(|(model, item)| {
|
||||
let share = if total_calls > 0 {
|
||||
item.0 as f64 * 100.0 / total_calls as f64
|
||||
} else {
|
||||
0.0
|
||||
};
|
||||
json!({
|
||||
"model": model,
|
||||
"calls": item.0,
|
||||
"share": share,
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
let call_ranking: Vec<Value> = models
|
||||
.iter()
|
||||
.map(|(model, item)| {
|
||||
let success_rate = if item.0 > 0 {
|
||||
item.1 as f64 * 100.0 / item.0 as f64
|
||||
} else {
|
||||
0.0
|
||||
};
|
||||
let avg_latency = if item.6 > 0 {
|
||||
(item.5 / item.6 as f64).round() as i64
|
||||
} else {
|
||||
0
|
||||
};
|
||||
json!({
|
||||
"model": model,
|
||||
"calls": item.0,
|
||||
"successRate": success_rate,
|
||||
"avgLatencyMs": avg_latency,
|
||||
"spend": (item.3 * 1_000_000.0).round() / 1_000_000.0,
|
||||
"tokens": item.4,
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
let spend_trend: Vec<Value> = by_day
|
||||
.into_iter()
|
||||
.map(|(day, spend)| json!({ "day": day, "spend": spend }))
|
||||
.collect();
|
||||
|
||||
Ok(json!({
|
||||
"totals": {
|
||||
"spend": (total_spend * 1_000_000.0).round() / 1_000_000.0,
|
||||
"calls": total_calls,
|
||||
"tokens": total_tokens,
|
||||
},
|
||||
"spendDistribution": spend_distribution,
|
||||
"spendTrend": spend_trend,
|
||||
"callsDistribution": calls_distribution,
|
||||
"callRanking": call_ranking,
|
||||
}))
|
||||
}
|
||||
|
||||
async fn build_site_availability(state: &AppState) -> AppResult<Vec<Value>> {
|
||||
let rows: Vec<(
|
||||
i64,
|
||||
@@ -489,28 +621,70 @@ async fn site_distribution(
|
||||
.to_string();
|
||||
|
||||
// Sum per-site usage
|
||||
let rows: Vec<(i64, String, i64, f64, i64)> = sqlx::query_as(
|
||||
"SELECT s.id, s.name, COALESCE(SUM(m.total_calls), 0), COALESCE(SUM(m.total_spend), 0.0), COALESCE(SUM(m.total_tokens), 0)
|
||||
let mut rows: Vec<(i64, String, Option<String>, i64, f64, i64, i64, f64)> = sqlx::query_as(
|
||||
"SELECT
|
||||
s.id,
|
||||
s.name,
|
||||
s.platform,
|
||||
COALESCE(SUM(m.total_calls), 0),
|
||||
COALESCE(SUM(m.total_spend), 0.0),
|
||||
COALESCE(SUM(m.total_tokens), 0),
|
||||
COUNT(DISTINCT a.id),
|
||||
COALESCE(SUM(a.balance), 0.0)
|
||||
FROM sites s
|
||||
LEFT JOIN accounts a ON a.site_id = s.id
|
||||
LEFT JOIN model_day_usage m ON m.site_id = s.id AND m.local_day >= ?
|
||||
GROUP BY s.id, s.name
|
||||
ORDER BY SUM(m.total_spend) DESC"
|
||||
GROUP BY s.id, s.name, s.platform
|
||||
ORDER BY SUM(m.total_spend) DESC",
|
||||
)
|
||||
.bind(&since_day)
|
||||
.fetch_all(&*state.db)
|
||||
.await?;
|
||||
|
||||
if rows
|
||||
.iter()
|
||||
.all(|(_, _, _, calls, spend, tokens, _, _)| *calls == 0 && *spend == 0.0 && *tokens == 0)
|
||||
{
|
||||
rows = sqlx::query_as(
|
||||
"SELECT
|
||||
s.id,
|
||||
s.name,
|
||||
s.platform,
|
||||
COUNT(l.id),
|
||||
COALESCE(SUM(l.estimated_cost), 0.0),
|
||||
COALESCE(SUM(l.total_tokens), 0),
|
||||
COUNT(DISTINCT a.id),
|
||||
COALESCE(SUM(DISTINCT a.balance), 0.0)
|
||||
FROM sites s
|
||||
LEFT JOIN accounts a ON a.site_id = s.id
|
||||
LEFT JOIN proxy_logs l
|
||||
ON l.account_id = a.id
|
||||
AND date(l.created_at) >= ?
|
||||
GROUP BY s.id, s.name, s.platform
|
||||
ORDER BY COALESCE(SUM(l.estimated_cost), 0.0) DESC, COUNT(l.id) DESC",
|
||||
)
|
||||
.bind(&since_day)
|
||||
.fetch_all(&*state.db)
|
||||
.await?;
|
||||
}
|
||||
|
||||
let distribution: Vec<Value> = rows
|
||||
.into_iter()
|
||||
.map(|(id, name, calls, spend, tokens)| {
|
||||
json!({
|
||||
"siteId": id,
|
||||
"siteName": name,
|
||||
"calls": calls,
|
||||
"spend": (spend * 1_000_000.0).round() / 1_000_000.0,
|
||||
"tokens": tokens,
|
||||
})
|
||||
})
|
||||
.map(
|
||||
|(id, name, platform, calls, spend, tokens, account_count, balance)| {
|
||||
json!({
|
||||
"siteId": id,
|
||||
"siteName": name,
|
||||
"platform": platform,
|
||||
"calls": calls,
|
||||
"spend": (spend * 1_000_000.0).round() / 1_000_000.0,
|
||||
"totalSpend": (spend * 1_000_000.0).round() / 1_000_000.0,
|
||||
"totalBalance": balance,
|
||||
"accountCount": account_count,
|
||||
"tokens": tokens,
|
||||
})
|
||||
},
|
||||
)
|
||||
.collect();
|
||||
|
||||
Ok(Json(json!({ "distribution": distribution })))
|
||||
@@ -528,25 +702,61 @@ async fn site_trend(
|
||||
.format("%Y-%m-%d")
|
||||
.to_string();
|
||||
|
||||
let rows: Vec<(String, i64, f64, i64)> = sqlx::query_as(
|
||||
"SELECT local_day, COALESCE(SUM(total_calls), 0), COALESCE(SUM(total_spend), 0.0), COALESCE(SUM(total_tokens), 0)
|
||||
FROM model_day_usage
|
||||
WHERE local_day >= ?
|
||||
GROUP BY local_day
|
||||
ORDER BY local_day ASC",
|
||||
let mut rows: Vec<(String, String, i64, f64, i64)> = sqlx::query_as(
|
||||
"SELECT
|
||||
m.local_day,
|
||||
s.name,
|
||||
COALESCE(SUM(m.total_calls), 0),
|
||||
COALESCE(SUM(m.total_spend), 0.0),
|
||||
COALESCE(SUM(m.total_tokens), 0)
|
||||
FROM model_day_usage m
|
||||
JOIN sites s ON s.id = m.site_id
|
||||
WHERE m.local_day >= ?
|
||||
GROUP BY m.local_day, s.id, s.name
|
||||
ORDER BY m.local_day ASC, s.name ASC",
|
||||
)
|
||||
.bind(&since_day)
|
||||
.fetch_all(&*state.db)
|
||||
.await?;
|
||||
|
||||
let trend: Vec<Value> = rows
|
||||
.into_iter()
|
||||
.map(|(day, calls, spend, tokens)| {
|
||||
if rows.is_empty() {
|
||||
rows = sqlx::query_as(
|
||||
"SELECT
|
||||
date(l.created_at),
|
||||
COALESCE(s.name, '未知站点'),
|
||||
COUNT(l.id),
|
||||
COALESCE(SUM(l.estimated_cost), 0.0),
|
||||
COALESCE(SUM(l.total_tokens), 0)
|
||||
FROM proxy_logs l
|
||||
LEFT JOIN accounts a ON a.id = l.account_id
|
||||
LEFT JOIN sites s ON s.id = a.site_id
|
||||
WHERE date(l.created_at) >= ?
|
||||
GROUP BY date(l.created_at), s.id, s.name
|
||||
ORDER BY date(l.created_at) ASC, s.name ASC",
|
||||
)
|
||||
.bind(&since_day)
|
||||
.fetch_all(&*state.db)
|
||||
.await?;
|
||||
}
|
||||
|
||||
let mut by_day: BTreeMap<String, serde_json::Map<String, Value>> = BTreeMap::new();
|
||||
for (day, site, calls, spend, tokens) in rows {
|
||||
by_day.entry(day).or_default().insert(
|
||||
site,
|
||||
json!({
|
||||
"date": day,
|
||||
"calls": calls,
|
||||
"spend": spend,
|
||||
"tokens": tokens,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
let trend: Vec<Value> = by_day
|
||||
.into_iter()
|
||||
.map(|(day, sites)| {
|
||||
json!({
|
||||
"date": day,
|
||||
"sites": sites,
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
|
||||
@@ -22,6 +22,7 @@ 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/dashboard", get(get_user_dashboard))
|
||||
.route(
|
||||
"/api/user/models/marketplace",
|
||||
get(get_user_models_marketplace),
|
||||
@@ -551,6 +552,53 @@ async fn get_user_summary(
|
||||
})))
|
||||
}
|
||||
|
||||
async fn get_user_dashboard(
|
||||
State(state): State<AppState>,
|
||||
headers: HeaderMap,
|
||||
) -> AppResult<Json<Value>> {
|
||||
let user_id = current_user_id(&headers, &state)?;
|
||||
|
||||
let user: User = sqlx::query_as("SELECT * FROM users WHERE id = ?")
|
||||
.bind(user_id)
|
||||
.fetch_optional(&*state.db)
|
||||
.await?
|
||||
.ok_or_else(|| AppError::NotFound("User not found".into()))?;
|
||||
|
||||
let keys: Vec<DownstreamApiKey> = sqlx::query_as(
|
||||
"SELECT * FROM downstream_api_keys WHERE user_id = ? ORDER BY created_at DESC",
|
||||
)
|
||||
.bind(user_id)
|
||||
.fetch_all(&*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>();
|
||||
let recent_logs = items.into_iter().take(5).collect::<Vec<_>>();
|
||||
|
||||
Ok(Json(json!({
|
||||
"user": {
|
||||
"id": user.id,
|
||||
"username": user.username,
|
||||
"email": user.email,
|
||||
"role": user.role,
|
||||
"status": user.status,
|
||||
"createdAt": user.created_at,
|
||||
},
|
||||
"keys": keys,
|
||||
"summary": {
|
||||
"totalRequests": total,
|
||||
"successRequests": success_count,
|
||||
"totalCost": total_cost,
|
||||
},
|
||||
"recentLogs": recent_logs,
|
||||
})))
|
||||
}
|
||||
|
||||
async fn get_user_models_marketplace(
|
||||
State(state): State<AppState>,
|
||||
headers: HeaderMap,
|
||||
|
||||
+1998
-107
File diff suppressed because it is too large
Load Diff
+98
-2
@@ -7,12 +7,12 @@ use axum::{
|
||||
body::Body,
|
||||
extract::{Path, Query, State},
|
||||
http::{header, HeaderMap, Response, StatusCode},
|
||||
routing::get,
|
||||
routing::{get, post},
|
||||
Json, Router,
|
||||
};
|
||||
use serde::Deserialize;
|
||||
use serde_json::{json, Value};
|
||||
use std::path::PathBuf;
|
||||
use std::{collections::HashSet, path::PathBuf};
|
||||
use tokio_util::io::ReaderStream;
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
@@ -34,9 +34,19 @@ struct AssetQuery {
|
||||
page_size: Option<i64>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct BatchDeleteAssetsRequest {
|
||||
#[serde(default, alias = "publicIds")]
|
||||
public_ids: Vec<String>,
|
||||
}
|
||||
|
||||
pub fn routes() -> Router<AppState> {
|
||||
Router::new()
|
||||
.route("/api/workspace/assets", get(list_admin_assets))
|
||||
.route(
|
||||
"/api/workspace/assets/batch-delete",
|
||||
post(batch_delete_admin_assets),
|
||||
)
|
||||
.route(
|
||||
"/api/workspace/assets/{public_id}",
|
||||
get(get_admin_asset).delete(delete_admin_asset),
|
||||
@@ -46,6 +56,10 @@ pub fn routes() -> Router<AppState> {
|
||||
get(get_admin_asset_file),
|
||||
)
|
||||
.route("/api/user/workspace/assets", get(list_user_assets))
|
||||
.route(
|
||||
"/api/user/workspace/assets/batch-delete",
|
||||
post(batch_delete_user_assets),
|
||||
)
|
||||
.route(
|
||||
"/api/user/workspace/assets/{public_id}",
|
||||
get(get_user_asset).delete(delete_user_asset),
|
||||
@@ -175,6 +189,10 @@ async fn list_assets(
|
||||
FROM workspace_assets
|
||||
WHERE deleted_at IS NULL
|
||||
AND ((? = 1 AND (? IS NULL OR user_id = ?)) OR (? = 0 AND user_id = ?))
|
||||
AND (
|
||||
source != 'workflow-ui'
|
||||
OR json_extract(metadata, '$.workflowUi.type') = 'output'
|
||||
)
|
||||
AND (? IS NULL OR source = ?)
|
||||
AND (? IS NULL OR kind = ?)
|
||||
AND (
|
||||
@@ -212,6 +230,10 @@ async fn list_assets(
|
||||
FROM workspace_assets
|
||||
WHERE deleted_at IS NULL
|
||||
AND ((? = 1 AND (? IS NULL OR user_id = ?)) OR (? = 0 AND user_id = ?))
|
||||
AND (
|
||||
source != 'workflow-ui'
|
||||
OR json_extract(metadata, '$.workflowUi.type') = 'output'
|
||||
)
|
||||
AND (? IS NULL OR source = ?)
|
||||
AND (? IS NULL OR kind = ?)
|
||||
AND (
|
||||
@@ -320,6 +342,62 @@ async fn delete_asset(
|
||||
Ok(StatusCode::NO_CONTENT)
|
||||
}
|
||||
|
||||
async fn delete_assets_batch(
|
||||
state: AppState,
|
||||
caller: WorkspaceCaller,
|
||||
public_ids: Vec<String>,
|
||||
) -> AppResult<Json<Value>> {
|
||||
let mut seen = HashSet::new();
|
||||
let public_ids = public_ids
|
||||
.into_iter()
|
||||
.map(|value| value.trim().to_string())
|
||||
.filter(|value| !value.is_empty())
|
||||
.filter(|value| seen.insert(value.clone()))
|
||||
.take(200)
|
||||
.collect::<Vec<_>>();
|
||||
if public_ids.is_empty() {
|
||||
return Err(AppError::BadRequest("No workspace assets selected".into()));
|
||||
}
|
||||
|
||||
let scoped_user_id = match caller {
|
||||
WorkspaceCaller::Admin => None,
|
||||
WorkspaceCaller::User { user_id } => Some(user_id),
|
||||
};
|
||||
let is_admin = matches!(caller, WorkspaceCaller::Admin);
|
||||
let mut deleted_ids = Vec::new();
|
||||
let mut failed_ids = Vec::new();
|
||||
|
||||
for public_id in public_ids {
|
||||
let result = sqlx::query(
|
||||
"UPDATE workspace_assets
|
||||
SET deleted_at = datetime('now'), updated_at = datetime('now')
|
||||
WHERE public_id = ?
|
||||
AND deleted_at IS NULL
|
||||
AND ((? = 1 AND (? IS NULL OR user_id = ?)) OR (? = 0 AND user_id = ?))",
|
||||
)
|
||||
.bind(&public_id)
|
||||
.bind(is_admin)
|
||||
.bind(scoped_user_id)
|
||||
.bind(scoped_user_id)
|
||||
.bind(is_admin)
|
||||
.bind(scoped_user_id)
|
||||
.execute(&*state.db)
|
||||
.await?;
|
||||
if result.rows_affected() > 0 {
|
||||
deleted_ids.push(public_id);
|
||||
} else {
|
||||
failed_ids.push(public_id);
|
||||
}
|
||||
}
|
||||
|
||||
Ok(Json(json!({
|
||||
"success": true,
|
||||
"deletedCount": deleted_ids.len(),
|
||||
"deletedIds": deleted_ids,
|
||||
"failedIds": failed_ids,
|
||||
})))
|
||||
}
|
||||
|
||||
async fn get_asset_file(
|
||||
state: AppState,
|
||||
caller: WorkspaceCaller,
|
||||
@@ -400,6 +478,15 @@ async fn delete_admin_asset(
|
||||
delete_asset(state, caller, public_id).await
|
||||
}
|
||||
|
||||
async fn batch_delete_admin_assets(
|
||||
State(state): State<AppState>,
|
||||
headers: HeaderMap,
|
||||
Json(payload): Json<BatchDeleteAssetsRequest>,
|
||||
) -> AppResult<Json<Value>> {
|
||||
let caller = resolve_caller(&headers, &state, true)?;
|
||||
delete_assets_batch(state, caller, payload.public_ids).await
|
||||
}
|
||||
|
||||
async fn list_user_assets(
|
||||
State(state): State<AppState>,
|
||||
headers: HeaderMap,
|
||||
@@ -435,3 +522,12 @@ async fn delete_user_asset(
|
||||
let caller = resolve_caller(&headers, &state, false)?;
|
||||
delete_asset(state, caller, public_id).await
|
||||
}
|
||||
|
||||
async fn batch_delete_user_assets(
|
||||
State(state): State<AppState>,
|
||||
headers: HeaderMap,
|
||||
Json(payload): Json<BatchDeleteAssetsRequest>,
|
||||
) -> AppResult<Json<Value>> {
|
||||
let caller = resolve_caller(&headers, &state, false)?;
|
||||
delete_assets_batch(state, caller, payload.public_ids).await
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user