Update BossAI workflow and workspace experience

This commit is contained in:
boos
2026-05-28 10:32:20 +08:00
parent e0bd9b4194
commit 931bcf3fb9
39 changed files with 7791 additions and 1328 deletions
+455 -60
View File
@@ -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,
+68 -4
View File
@@ -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
View File
@@ -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)
+1
View File
@@ -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
View File
@@ -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();
+48
View File
@@ -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
View File
File diff suppressed because it is too large Load Diff
+98 -2
View File
@@ -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
}