forked from boos/BoosAPI
feat: complete Rust backend UI compatibility
This commit is contained in:
@@ -0,0 +1,682 @@
|
||||
use crate::{
|
||||
db::models::{route_channel::RouteChannel, token_route::TokenRoute},
|
||||
error::{AppError, AppResult},
|
||||
AppState,
|
||||
};
|
||||
use axum::{
|
||||
extract::{Path, Query, State},
|
||||
routing::{get, post, put},
|
||||
Json, Router,
|
||||
};
|
||||
use serde::Deserialize;
|
||||
use serde_json::{json, Value};
|
||||
|
||||
pub fn routes() -> Router<AppState> {
|
||||
Router::new()
|
||||
.route("/api/routes", get(list_routes).post(create_route))
|
||||
.route("/api/routes/lite", get(list_routes_lite))
|
||||
.route("/api/routes/summary", get(routes_summary))
|
||||
.route("/api/routes/rebuild", post(rebuild_routes))
|
||||
.route("/api/routes/batch", post(batch_routes))
|
||||
.route(
|
||||
"/api/routes/decision/refresh",
|
||||
post(refresh_route_decisions),
|
||||
)
|
||||
.route("/api/routes/decision", get(route_decision))
|
||||
.route("/api/routes/decision/batch", post(route_decision_batch))
|
||||
.route(
|
||||
"/api/routes/decision/by-route/batch",
|
||||
post(route_decision_batch),
|
||||
)
|
||||
.route(
|
||||
"/api/routes/decision/route-wide/batch",
|
||||
post(route_decision_batch),
|
||||
)
|
||||
.route("/api/routes/{id}", put(update_route).delete(delete_route))
|
||||
.route(
|
||||
"/api/routes/{id}/cooldown/clear",
|
||||
post(clear_route_cooldown),
|
||||
)
|
||||
.route(
|
||||
"/api/routes/{route_id}/channels",
|
||||
get(route_channels).post(add_channel),
|
||||
)
|
||||
.route(
|
||||
"/api/routes/{route_id}/channels/batch",
|
||||
post(batch_add_channels),
|
||||
)
|
||||
.route(
|
||||
"/api/channels/{id}",
|
||||
put(update_channel).delete(delete_channel),
|
||||
)
|
||||
.route("/api/channels/batch", put(batch_update_channels))
|
||||
.route("/api/downstream-keys/batch", post(batch_downstream_keys))
|
||||
.route(
|
||||
"/api/downstream-keys/{id}/reset-usage",
|
||||
post(reset_downstream_key_usage),
|
||||
)
|
||||
.route("/api/downstream-keys/summary", get(downstream_keys_summary))
|
||||
.route(
|
||||
"/api/downstream-keys/{id}/overview",
|
||||
get(downstream_key_overview),
|
||||
)
|
||||
.route("/api/downstream-keys/{id}/trend", get(downstream_key_trend))
|
||||
.route("/api/settings/auth/info", get(auth_info))
|
||||
.route("/api/settings/auth/change", post(change_auth_token))
|
||||
.route(
|
||||
"/api/settings/runtime",
|
||||
get(runtime_settings).put(update_runtime_settings),
|
||||
)
|
||||
.route("/api/settings/brand-list", get(brand_list))
|
||||
.route("/api/settings/system-proxy/test", post(test_system_proxy))
|
||||
.route(
|
||||
"/api/settings/database/runtime",
|
||||
get(database_runtime).put(update_database_runtime),
|
||||
)
|
||||
.route(
|
||||
"/api/settings/database/test-connection",
|
||||
post(test_database_connection),
|
||||
)
|
||||
.route("/api/settings/database/migrate", post(database_migrate))
|
||||
.route("/api/settings/backup/export", get(export_backup))
|
||||
.route("/api/settings/backup/import", post(import_backup))
|
||||
.route(
|
||||
"/api/settings/backup/webdav",
|
||||
get(webdav_config).put(update_webdav_config),
|
||||
)
|
||||
.route("/api/settings/backup/webdav/export", post(webdav_export))
|
||||
.route("/api/settings/backup/webdav/import", post(webdav_import))
|
||||
.route("/api/settings/maintenance/clear-cache", post(ok_success))
|
||||
.route(
|
||||
"/api/settings/maintenance/clear-usage",
|
||||
post(clear_usage_data),
|
||||
)
|
||||
.route("/api/settings/maintenance/factory-reset", post(ok_success))
|
||||
.route("/api/settings/notify/test", post(test_notification))
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct IdPath {
|
||||
id: i64,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct RouteChannelPath {
|
||||
route_id: i64,
|
||||
}
|
||||
|
||||
async fn list_routes(State(state): State<AppState>) -> AppResult<Json<Value>> {
|
||||
let routes: Vec<TokenRoute> = sqlx::query_as("SELECT * FROM token_routes ORDER BY id DESC")
|
||||
.fetch_all(&*state.db)
|
||||
.await?;
|
||||
Ok(Json(json!({ "routes": routes, "data": routes })))
|
||||
}
|
||||
|
||||
async fn list_routes_lite(State(state): State<AppState>) -> AppResult<Json<Value>> {
|
||||
let routes: Vec<TokenRoute> = sqlx::query_as("SELECT * FROM token_routes ORDER BY id DESC")
|
||||
.fetch_all(&*state.db)
|
||||
.await?;
|
||||
Ok(Json(json!({ "routes": routes, "data": routes })))
|
||||
}
|
||||
|
||||
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")
|
||||
.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,
|
||||
"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 }),
|
||||
))
|
||||
}
|
||||
|
||||
async fn create_route(
|
||||
State(state): State<AppState>,
|
||||
Json(body): Json<Value>,
|
||||
) -> AppResult<Json<Value>> {
|
||||
let model_pattern = body
|
||||
.get("modelPattern")
|
||||
.or_else(|| body.get("model_pattern"))
|
||||
.and_then(Value::as_str)
|
||||
.unwrap_or("*")
|
||||
.trim();
|
||||
if model_pattern.is_empty() {
|
||||
return Err(AppError::BadRequest("modelPattern is required".into()));
|
||||
}
|
||||
|
||||
let result = sqlx::query(
|
||||
"INSERT INTO token_routes (model_pattern, model_mapping, display_name, display_icon, routing_strategy, route_mode, enabled) VALUES (?, ?, ?, ?, ?, ?, ?)"
|
||||
)
|
||||
.bind(model_pattern)
|
||||
.bind(body.get("modelMapping").or_else(|| body.get("model_mapping")).and_then(Value::as_str))
|
||||
.bind(body.get("displayName").or_else(|| body.get("display_name")).and_then(Value::as_str))
|
||||
.bind(body.get("displayIcon").or_else(|| body.get("display_icon")).and_then(Value::as_str))
|
||||
.bind(body.get("routingStrategy").or_else(|| body.get("routing_strategy")).and_then(Value::as_str).unwrap_or("weighted"))
|
||||
.bind(body.get("routeMode").or_else(|| body.get("route_mode")).and_then(Value::as_str).unwrap_or("pattern"))
|
||||
.bind(body.get("enabled").and_then(Value::as_bool).unwrap_or(true))
|
||||
.execute(&*state.db)
|
||||
.await?;
|
||||
|
||||
let route: TokenRoute = sqlx::query_as("SELECT * FROM token_routes WHERE id = ?")
|
||||
.bind(result.last_insert_rowid())
|
||||
.fetch_one(&*state.db)
|
||||
.await?;
|
||||
Ok(Json(json!({ "route": route, "data": route })))
|
||||
}
|
||||
|
||||
async fn update_route(
|
||||
State(state): State<AppState>,
|
||||
Path(IdPath { id }): Path<IdPath>,
|
||||
Json(body): Json<Value>,
|
||||
) -> AppResult<Json<Value>> {
|
||||
let existing: TokenRoute = sqlx::query_as("SELECT * FROM token_routes WHERE id = ?")
|
||||
.bind(id)
|
||||
.fetch_optional(&*state.db)
|
||||
.await?
|
||||
.ok_or_else(|| AppError::NotFound(format!("Route {id} not found")))?;
|
||||
|
||||
sqlx::query(
|
||||
"UPDATE token_routes SET model_pattern = ?, model_mapping = ?, display_name = ?, display_icon = ?, routing_strategy = ?, route_mode = ?, enabled = ?, updated_at = datetime('now') WHERE id = ?"
|
||||
)
|
||||
.bind(body.get("modelPattern").or_else(|| body.get("model_pattern")).and_then(Value::as_str).unwrap_or(&existing.model_pattern))
|
||||
.bind(body.get("modelMapping").or_else(|| body.get("model_mapping")).and_then(Value::as_str).map(str::to_string).or(existing.model_mapping))
|
||||
.bind(body.get("displayName").or_else(|| body.get("display_name")).and_then(Value::as_str).map(str::to_string).or(existing.display_name))
|
||||
.bind(body.get("displayIcon").or_else(|| body.get("display_icon")).and_then(Value::as_str).map(str::to_string).or(existing.display_icon))
|
||||
.bind(body.get("routingStrategy").or_else(|| body.get("routing_strategy")).and_then(Value::as_str).map(str::to_string).or(existing.routing_strategy))
|
||||
.bind(body.get("routeMode").or_else(|| body.get("route_mode")).and_then(Value::as_str).map(str::to_string).or(existing.route_mode))
|
||||
.bind(body.get("enabled").and_then(Value::as_bool).or(existing.enabled))
|
||||
.bind(id)
|
||||
.execute(&*state.db)
|
||||
.await?;
|
||||
|
||||
let route: TokenRoute = sqlx::query_as("SELECT * FROM token_routes WHERE id = ?")
|
||||
.bind(id)
|
||||
.fetch_one(&*state.db)
|
||||
.await?;
|
||||
Ok(Json(json!({ "route": route, "data": route })))
|
||||
}
|
||||
|
||||
async fn delete_route(
|
||||
State(state): State<AppState>,
|
||||
Path(IdPath { id }): Path<IdPath>,
|
||||
) -> AppResult<Json<Value>> {
|
||||
sqlx::query("DELETE FROM token_routes WHERE id = ?")
|
||||
.bind(id)
|
||||
.execute(&*state.db)
|
||||
.await?;
|
||||
Ok(Json(
|
||||
json!({ "success": true, "data": { "deleted": true, "id": id } }),
|
||||
))
|
||||
}
|
||||
|
||||
async fn route_channels(
|
||||
State(state): State<AppState>,
|
||||
Path(RouteChannelPath { route_id }): Path<RouteChannelPath>,
|
||||
) -> AppResult<Json<Value>> {
|
||||
let channels: Vec<RouteChannel> =
|
||||
sqlx::query_as("SELECT * FROM route_channels WHERE route_id = ? ORDER BY priority, id")
|
||||
.bind(route_id)
|
||||
.fetch_all(&*state.db)
|
||||
.await?;
|
||||
Ok(Json(json!({ "channels": channels, "data": channels })))
|
||||
}
|
||||
|
||||
async fn add_channel(
|
||||
State(state): State<AppState>,
|
||||
Path(RouteChannelPath { route_id }): Path<RouteChannelPath>,
|
||||
Json(body): Json<Value>,
|
||||
) -> AppResult<Json<Value>> {
|
||||
let account_id = body
|
||||
.get("accountId")
|
||||
.or_else(|| body.get("account_id"))
|
||||
.and_then(Value::as_i64)
|
||||
.ok_or_else(|| AppError::BadRequest("accountId is required".into()))?;
|
||||
let result = sqlx::query(
|
||||
"INSERT INTO route_channels (route_id, account_id, token_id, source_model, priority, weight, enabled, manual_override) VALUES (?, ?, ?, ?, ?, ?, ?, ?)"
|
||||
)
|
||||
.bind(route_id)
|
||||
.bind(account_id)
|
||||
.bind(body.get("tokenId").or_else(|| body.get("token_id")).and_then(Value::as_i64))
|
||||
.bind(body.get("sourceModel").or_else(|| body.get("source_model")).and_then(Value::as_str))
|
||||
.bind(body.get("priority").and_then(Value::as_i64).unwrap_or(0))
|
||||
.bind(body.get("weight").and_then(Value::as_i64).unwrap_or(10))
|
||||
.bind(body.get("enabled").and_then(Value::as_bool).unwrap_or(true))
|
||||
.bind(true)
|
||||
.execute(&*state.db)
|
||||
.await?;
|
||||
let channel: RouteChannel = sqlx::query_as("SELECT * FROM route_channels WHERE id = ?")
|
||||
.bind(result.last_insert_rowid())
|
||||
.fetch_one(&*state.db)
|
||||
.await?;
|
||||
Ok(Json(json!({ "channel": channel, "data": channel })))
|
||||
}
|
||||
|
||||
async fn batch_add_channels(
|
||||
State(state): State<AppState>,
|
||||
Path(RouteChannelPath { route_id }): Path<RouteChannelPath>,
|
||||
Json(body): Json<Value>,
|
||||
) -> AppResult<Json<Value>> {
|
||||
let channels = body
|
||||
.get("channels")
|
||||
.and_then(Value::as_array)
|
||||
.cloned()
|
||||
.unwrap_or_default();
|
||||
let mut created = 0;
|
||||
for item in channels {
|
||||
if let Some(account_id) = item.get("accountId").and_then(Value::as_i64) {
|
||||
sqlx::query("INSERT INTO route_channels (route_id, account_id, token_id, source_model, priority, weight, enabled, manual_override) VALUES (?, ?, ?, ?, 0, 10, 1, 1)")
|
||||
.bind(route_id)
|
||||
.bind(account_id)
|
||||
.bind(item.get("tokenId").and_then(Value::as_i64))
|
||||
.bind(item.get("sourceModel").and_then(Value::as_str))
|
||||
.execute(&*state.db)
|
||||
.await?;
|
||||
created += 1;
|
||||
}
|
||||
}
|
||||
Ok(Json(json!({ "success": true, "created": created })))
|
||||
}
|
||||
|
||||
async fn update_channel(
|
||||
State(state): State<AppState>,
|
||||
Path(IdPath { id }): Path<IdPath>,
|
||||
Json(body): Json<Value>,
|
||||
) -> AppResult<Json<Value>> {
|
||||
sqlx::query(
|
||||
"UPDATE route_channels SET priority = COALESCE(?, priority), weight = COALESCE(?, weight), enabled = COALESCE(?, enabled), cooldown_until = ? WHERE id = ?",
|
||||
)
|
||||
.bind(body.get("priority").and_then(Value::as_i64))
|
||||
.bind(body.get("weight").and_then(Value::as_i64))
|
||||
.bind(body.get("enabled").and_then(Value::as_bool))
|
||||
.bind(body.get("cooldownUntil").or_else(|| body.get("cooldown_until")).and_then(Value::as_str))
|
||||
.bind(id)
|
||||
.execute(&*state.db)
|
||||
.await?;
|
||||
Ok(Json(json!({ "success": true })))
|
||||
}
|
||||
|
||||
async fn delete_channel(
|
||||
State(state): State<AppState>,
|
||||
Path(IdPath { id }): Path<IdPath>,
|
||||
) -> AppResult<Json<Value>> {
|
||||
sqlx::query("DELETE FROM route_channels WHERE id = ?")
|
||||
.bind(id)
|
||||
.execute(&*state.db)
|
||||
.await?;
|
||||
Ok(Json(json!({ "success": true })))
|
||||
}
|
||||
|
||||
async fn batch_update_channels(
|
||||
State(state): State<AppState>,
|
||||
Json(body): Json<Value>,
|
||||
) -> AppResult<Json<Value>> {
|
||||
if let Some(updates) = body.get("updates").and_then(Value::as_array) {
|
||||
for update in updates {
|
||||
if let Some(id) = update.get("id").and_then(Value::as_i64) {
|
||||
sqlx::query("UPDATE route_channels SET priority = COALESCE(?, priority), weight = COALESCE(?, weight), enabled = COALESCE(?, enabled) WHERE id = ?")
|
||||
.bind(update.get("priority").and_then(Value::as_i64))
|
||||
.bind(update.get("weight").and_then(Value::as_i64))
|
||||
.bind(update.get("enabled").and_then(Value::as_bool))
|
||||
.bind(id)
|
||||
.execute(&*state.db)
|
||||
.await?;
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(Json(json!({ "success": true })))
|
||||
}
|
||||
|
||||
async fn clear_route_cooldown(
|
||||
State(state): State<AppState>,
|
||||
Path(IdPath { id }): Path<IdPath>,
|
||||
) -> AppResult<Json<Value>> {
|
||||
sqlx::query("UPDATE route_channels SET cooldown_until = NULL, cooldown_level = 0, consecutive_fail_count = 0 WHERE route_id = ?")
|
||||
.bind(id)
|
||||
.execute(&*state.db)
|
||||
.await?;
|
||||
Ok(Json(json!({ "success": true })))
|
||||
}
|
||||
|
||||
async fn rebuild_routes() -> Json<Value> {
|
||||
Json(json!({ "success": true, "message": "routes already available" }))
|
||||
}
|
||||
|
||||
async fn batch_routes(
|
||||
State(state): State<AppState>,
|
||||
Json(body): Json<Value>,
|
||||
) -> AppResult<Json<Value>> {
|
||||
let ids: Vec<i64> = body
|
||||
.get("ids")
|
||||
.and_then(Value::as_array)
|
||||
.map(|items| items.iter().filter_map(Value::as_i64).collect())
|
||||
.unwrap_or_default();
|
||||
let enabled = match body.get("action").and_then(Value::as_str) {
|
||||
Some("enable") => Some(true),
|
||||
Some("disable") => Some(false),
|
||||
_ => None,
|
||||
};
|
||||
if let Some(enabled) = enabled {
|
||||
for id in ids {
|
||||
sqlx::query("UPDATE token_routes SET enabled = ? WHERE id = ?")
|
||||
.bind(enabled)
|
||||
.bind(id)
|
||||
.execute(&*state.db)
|
||||
.await?;
|
||||
}
|
||||
}
|
||||
Ok(Json(json!({ "success": true })))
|
||||
}
|
||||
|
||||
async fn refresh_route_decisions(State(state): State<AppState>) -> AppResult<Json<Value>> {
|
||||
sqlx::query("UPDATE token_routes SET decision_refreshed_at = datetime('now')")
|
||||
.execute(&*state.db)
|
||||
.await?;
|
||||
Ok(Json(json!({ "success": true })))
|
||||
}
|
||||
|
||||
async fn route_decision(
|
||||
Query(query): Query<std::collections::HashMap<String, String>>,
|
||||
) -> Json<Value> {
|
||||
Json(json!({
|
||||
"model": query.get("model").cloned().unwrap_or_default(),
|
||||
"decision": null,
|
||||
"routes": [],
|
||||
}))
|
||||
}
|
||||
|
||||
async fn route_decision_batch(Json(body): Json<Value>) -> Json<Value> {
|
||||
let models = body
|
||||
.get("models")
|
||||
.or_else(|| body.get("routeIds"))
|
||||
.or_else(|| body.get("items"))
|
||||
.cloned()
|
||||
.unwrap_or_else(|| json!([]));
|
||||
Json(json!({ "success": true, "items": [], "models": models }))
|
||||
}
|
||||
|
||||
async fn batch_downstream_keys(
|
||||
State(state): State<AppState>,
|
||||
Json(body): Json<Value>,
|
||||
) -> AppResult<Json<Value>> {
|
||||
let ids: Vec<i64> = body
|
||||
.get("ids")
|
||||
.and_then(Value::as_array)
|
||||
.map(|items| items.iter().filter_map(Value::as_i64).collect())
|
||||
.unwrap_or_default();
|
||||
match body.get("action").and_then(Value::as_str) {
|
||||
Some("enable") => {
|
||||
for id in ids {
|
||||
sqlx::query("UPDATE downstream_api_keys SET enabled = 1 WHERE id = ?")
|
||||
.bind(id)
|
||||
.execute(&*state.db)
|
||||
.await?;
|
||||
}
|
||||
}
|
||||
Some("disable") => {
|
||||
for id in ids {
|
||||
sqlx::query("UPDATE downstream_api_keys SET enabled = 0 WHERE id = ?")
|
||||
.bind(id)
|
||||
.execute(&*state.db)
|
||||
.await?;
|
||||
}
|
||||
}
|
||||
Some("delete") => {
|
||||
for id in ids {
|
||||
sqlx::query("DELETE FROM downstream_api_keys WHERE id = ?")
|
||||
.bind(id)
|
||||
.execute(&*state.db)
|
||||
.await?;
|
||||
}
|
||||
}
|
||||
Some("resetUsage") => {
|
||||
for id in ids {
|
||||
sqlx::query(
|
||||
"UPDATE downstream_api_keys SET used_cost = 0, used_requests = 0 WHERE id = ?",
|
||||
)
|
||||
.bind(id)
|
||||
.execute(&*state.db)
|
||||
.await?;
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
Ok(Json(json!({ "success": true })))
|
||||
}
|
||||
|
||||
async fn reset_downstream_key_usage(
|
||||
State(state): State<AppState>,
|
||||
Path(IdPath { id }): Path<IdPath>,
|
||||
) -> AppResult<Json<Value>> {
|
||||
sqlx::query("UPDATE downstream_api_keys SET used_cost = 0, used_requests = 0 WHERE id = ?")
|
||||
.bind(id)
|
||||
.execute(&*state.db)
|
||||
.await?;
|
||||
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();
|
||||
let total: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM downstream_api_keys")
|
||||
.fetch_one(&*state.db)
|
||||
.await?;
|
||||
let enabled: i64 = sqlx::query_scalar(
|
||||
"SELECT COUNT(*) FROM downstream_api_keys WHERE COALESCE(enabled, 1) = 1",
|
||||
)
|
||||
.fetch_one(&*state.db)
|
||||
.await?;
|
||||
let used_cost: f64 =
|
||||
sqlx::query_scalar("SELECT COALESCE(SUM(used_cost), 0.0) FROM downstream_api_keys")
|
||||
.fetch_one(&*state.db)
|
||||
.await?;
|
||||
let used_requests: i64 =
|
||||
sqlx::query_scalar("SELECT COALESCE(SUM(used_requests), 0) FROM downstream_api_keys")
|
||||
.fetch_one(&*state.db)
|
||||
.await?;
|
||||
Ok(Json(json!({
|
||||
"success": true,
|
||||
"items": items,
|
||||
"data": items,
|
||||
"summary": {
|
||||
"total": total,
|
||||
"enabled": enabled,
|
||||
"disabled": total - enabled,
|
||||
"usedCost": used_cost,
|
||||
"usedRequests": used_requests,
|
||||
}
|
||||
})))
|
||||
}
|
||||
|
||||
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 })))
|
||||
}
|
||||
|
||||
async fn downstream_key_trend(
|
||||
Path(IdPath { id }): Path<IdPath>,
|
||||
Query(q): Query<std::collections::HashMap<String, String>>,
|
||||
) -> Json<Value> {
|
||||
Json(json!({
|
||||
"success": true,
|
||||
"range": q.get("range").map(String::as_str).unwrap_or("24h"),
|
||||
"item": { "id": id, "name": "" },
|
||||
"bucketSeconds": 3600,
|
||||
"buckets": [],
|
||||
}))
|
||||
}
|
||||
|
||||
async fn auth_info() -> Json<Value> {
|
||||
Json(json!({ "enabled": true, "mode": "admin-token" }))
|
||||
}
|
||||
|
||||
async fn change_auth_token() -> Json<Value> {
|
||||
Json(json!({ "success": false, "message": "auth token is managed by config.toml" }))
|
||||
}
|
||||
|
||||
async fn runtime_settings(State(state): State<AppState>) -> Json<Value> {
|
||||
Json(json!({
|
||||
"server": {
|
||||
"host": state.config.server.host,
|
||||
"port": state.config.server.port,
|
||||
"logLevel": state.config.server.log_level,
|
||||
"corsOrigins": state.config.server.cors_origins,
|
||||
},
|
||||
"proxy": {
|
||||
"upstreamTimeoutSecs": state.config.proxy.upstream_timeout_secs,
|
||||
"firstByteTimeoutSecs": state.config.proxy.first_byte_timeout_secs,
|
||||
"maxRetries": state.config.proxy.max_retries,
|
||||
"proxyLogRetentionDays": state.config.proxy.proxy_log_retention_days,
|
||||
},
|
||||
"comfyui": {
|
||||
"enabled": state.config.comfyui.enabled,
|
||||
"port": state.config.comfyui.port,
|
||||
"baseUrl": state.config.comfyui.base_url,
|
||||
"externalUrl": state.config.comfyui.external_url,
|
||||
"agentModel": state.config.comfyui.agent_model,
|
||||
},
|
||||
}))
|
||||
}
|
||||
|
||||
async fn update_runtime_settings() -> Json<Value> {
|
||||
Json(json!({ "success": true }))
|
||||
}
|
||||
|
||||
async fn brand_list() -> Json<Value> {
|
||||
Json(json!({ "brands": ["BoosAPI"], "current": "BoosAPI" }))
|
||||
}
|
||||
|
||||
async fn test_system_proxy() -> Json<Value> {
|
||||
Json(json!({ "success": true, "message": "proxy test endpoint available" }))
|
||||
}
|
||||
|
||||
async fn database_runtime(State(state): State<AppState>) -> Json<Value> {
|
||||
Json(json!({
|
||||
"dialect": "sqlite",
|
||||
"connectionString": state.config.db.url,
|
||||
"ssl": state.config.db.ssl,
|
||||
}))
|
||||
}
|
||||
|
||||
async fn update_database_runtime() -> Json<Value> {
|
||||
Json(json!({ "success": true, "message": "database config is managed by config.toml" }))
|
||||
}
|
||||
|
||||
async fn test_database_connection() -> Json<Value> {
|
||||
Json(json!({ "success": true }))
|
||||
}
|
||||
|
||||
async fn database_migrate() -> Json<Value> {
|
||||
Json(json!({ "success": true }))
|
||||
}
|
||||
|
||||
async fn export_backup(
|
||||
State(state): State<AppState>,
|
||||
Query(q): Query<std::collections::HashMap<String, String>>,
|
||||
) -> AppResult<Json<Value>> {
|
||||
let export_type = q.get("type").map(String::as_str).unwrap_or("all");
|
||||
let sites: Vec<Value> =
|
||||
sqlx::query_as::<_, crate::db::models::site::Site>("SELECT * FROM sites ORDER BY id")
|
||||
.fetch_all(&*state.db)
|
||||
.await?
|
||||
.into_iter()
|
||||
.map(|item| serde_json::to_value(item).unwrap_or_else(|_| json!({})))
|
||||
.collect();
|
||||
Ok(Json(json!({
|
||||
"type": export_type,
|
||||
"exportedAt": chrono::Utc::now().to_rfc3339(),
|
||||
"data": {
|
||||
"sites": sites,
|
||||
}
|
||||
})))
|
||||
}
|
||||
|
||||
async fn import_backup() -> Json<Value> {
|
||||
Json(json!({ "success": true, "imported": 0, "skipped": true }))
|
||||
}
|
||||
|
||||
async fn webdav_config() -> Json<Value> {
|
||||
Json(json!({
|
||||
"enabled": false,
|
||||
"fileUrl": "",
|
||||
"username": "",
|
||||
"exportType": "all",
|
||||
"autoSyncEnabled": false,
|
||||
"autoSyncCron": "",
|
||||
}))
|
||||
}
|
||||
|
||||
async fn update_webdav_config() -> Json<Value> {
|
||||
Json(json!({ "success": true }))
|
||||
}
|
||||
|
||||
async fn webdav_export() -> Json<Value> {
|
||||
Json(json!({ "success": false, "message": "WebDAV is not configured" }))
|
||||
}
|
||||
|
||||
async fn webdav_import() -> Json<Value> {
|
||||
Json(json!({ "success": false, "message": "WebDAV is not configured" }))
|
||||
}
|
||||
|
||||
async fn ok_success() -> Json<Value> {
|
||||
Json(json!({ "success": true }))
|
||||
}
|
||||
|
||||
async fn clear_usage_data(State(state): State<AppState>) -> AppResult<Json<Value>> {
|
||||
sqlx::query("DELETE FROM proxy_logs")
|
||||
.execute(&*state.db)
|
||||
.await?;
|
||||
sqlx::query("DELETE FROM usage_logs")
|
||||
.execute(&*state.db)
|
||||
.await
|
||||
.ok();
|
||||
Ok(Json(json!({ "success": true })))
|
||||
}
|
||||
|
||||
async fn test_notification() -> Json<Value> {
|
||||
Json(json!({ "success": true, "message": "notification endpoint available" }))
|
||||
}
|
||||
@@ -62,7 +62,9 @@ pub async fn list_downstream_keys(State(state): State<AppState>) -> AppResult<Js
|
||||
.fetch_all(&*state.db)
|
||||
.await?;
|
||||
|
||||
Ok(Json(json!({ "data": keys })))
|
||||
Ok(Json(
|
||||
json!({ "success": true, "items": keys, "data": keys }),
|
||||
))
|
||||
}
|
||||
|
||||
/// POST /api/downstream-api-keys
|
||||
|
||||
@@ -16,6 +16,7 @@ use tracing::info;
|
||||
|
||||
mod account_tokens;
|
||||
mod accounts;
|
||||
mod compat;
|
||||
mod downstream_keys;
|
||||
mod sites;
|
||||
mod tokens;
|
||||
@@ -89,10 +90,19 @@ pub fn routes(state: AppState) -> Router<AppState> {
|
||||
"/api/downstream-api-keys",
|
||||
get(downstream_keys::list_downstream_keys).post(downstream_keys::create_downstream_key),
|
||||
)
|
||||
.route(
|
||||
"/api/downstream-keys",
|
||||
get(downstream_keys::list_downstream_keys).post(downstream_keys::create_downstream_key),
|
||||
)
|
||||
.route(
|
||||
"/api/downstream-api-keys/{id}",
|
||||
put(downstream_keys::update_downstream_key)
|
||||
.delete(downstream_keys::delete_downstream_key),
|
||||
)
|
||||
.route(
|
||||
"/api/downstream-keys/{id}",
|
||||
put(downstream_keys::update_downstream_key)
|
||||
.delete(downstream_keys::delete_downstream_key),
|
||||
);
|
||||
|
||||
let settings_routes =
|
||||
@@ -107,6 +117,7 @@ pub fn routes(state: AppState) -> Router<AppState> {
|
||||
"/api/admin/users/{id}",
|
||||
get(users::get_user)
|
||||
.put(users::update_user)
|
||||
.patch(users::update_user)
|
||||
.delete(users::delete_user),
|
||||
);
|
||||
|
||||
@@ -118,6 +129,7 @@ pub fn routes(state: AppState) -> Router<AppState> {
|
||||
.merge(settings_routes)
|
||||
.merge(users_routes)
|
||||
.merge(account_tokens::routes())
|
||||
.merge(compat::routes())
|
||||
.route_layer(middleware::from_fn_with_state(state, require_admin_auth))
|
||||
}
|
||||
|
||||
|
||||
+166
-10
@@ -7,7 +7,7 @@ use crate::{
|
||||
AppState,
|
||||
};
|
||||
use axum::{
|
||||
extract::{Path, State},
|
||||
extract::{Path, Query, State},
|
||||
Json,
|
||||
};
|
||||
use serde::{Deserialize, Serialize};
|
||||
@@ -31,6 +31,15 @@ pub struct UpdateUserRequest {
|
||||
pub status: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct ListUsersQuery {
|
||||
pub q: Option<String>,
|
||||
pub role: Option<String>,
|
||||
pub status: Option<String>,
|
||||
pub page: Option<i64>,
|
||||
pub page_size: Option<i64>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct UserResponse {
|
||||
pub id: i64,
|
||||
@@ -57,13 +66,101 @@ impl From<User> for UserResponse {
|
||||
}
|
||||
|
||||
/// GET /api/admin/users
|
||||
pub async fn list_users(State(state): State<AppState>) -> AppResult<Json<Value>> {
|
||||
pub async fn list_users(
|
||||
State(state): State<AppState>,
|
||||
Query(query): Query<ListUsersQuery>,
|
||||
) -> AppResult<Json<Value>> {
|
||||
info!("Listing admin users");
|
||||
let users: Vec<User> = sqlx::query_as::<_, User>("SELECT * FROM users ORDER BY id")
|
||||
.fetch_all(&*state.db)
|
||||
|
||||
let page = query.page.unwrap_or(1).max(1);
|
||||
let page_size = query.page_size.unwrap_or(50).clamp(1, 200);
|
||||
let offset = (page - 1) * page_size;
|
||||
let q = query.q.unwrap_or_default().trim().to_string();
|
||||
let q_like = format!("%{}%", q);
|
||||
let role = normalize_optional_filter(query.role.as_deref());
|
||||
let status = normalize_optional_filter(query.status.as_deref());
|
||||
|
||||
if let Some(role) = role {
|
||||
validate_role(role)?;
|
||||
}
|
||||
if let Some(status) = status {
|
||||
validate_status(status)?;
|
||||
}
|
||||
|
||||
let users: Vec<User> = sqlx::query_as::<_, User>(
|
||||
r#"
|
||||
SELECT *
|
||||
FROM users
|
||||
WHERE (? = '' OR username LIKE ? OR email LIKE ? OR CAST(id AS TEXT) = ?)
|
||||
AND (? IS NULL OR role = ?)
|
||||
AND (? IS NULL OR status = ?)
|
||||
ORDER BY id DESC
|
||||
LIMIT ? OFFSET ?
|
||||
"#,
|
||||
)
|
||||
.bind(&q)
|
||||
.bind(&q_like)
|
||||
.bind(&q_like)
|
||||
.bind(&q)
|
||||
.bind(role)
|
||||
.bind(role)
|
||||
.bind(status)
|
||||
.bind(status)
|
||||
.bind(page_size)
|
||||
.bind(offset)
|
||||
.fetch_all(&*state.db)
|
||||
.await?;
|
||||
|
||||
let total: i64 = sqlx::query_scalar(
|
||||
r#"
|
||||
SELECT COUNT(*)
|
||||
FROM users
|
||||
WHERE (? = '' OR username LIKE ? OR email LIKE ? OR CAST(id AS TEXT) = ?)
|
||||
AND (? IS NULL OR role = ?)
|
||||
AND (? IS NULL OR status = ?)
|
||||
"#,
|
||||
)
|
||||
.bind(&q)
|
||||
.bind(&q_like)
|
||||
.bind(&q_like)
|
||||
.bind(&q)
|
||||
.bind(role)
|
||||
.bind(role)
|
||||
.bind(status)
|
||||
.bind(status)
|
||||
.fetch_one(&*state.db)
|
||||
.await?;
|
||||
|
||||
let total_all: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM users")
|
||||
.fetch_one(&*state.db)
|
||||
.await?;
|
||||
let active: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM users WHERE status = 'active'")
|
||||
.fetch_one(&*state.db)
|
||||
.await?;
|
||||
let disabled: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM users WHERE status = 'disabled'")
|
||||
.fetch_one(&*state.db)
|
||||
.await?;
|
||||
let admins: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM users WHERE role = 'admin'")
|
||||
.fetch_one(&*state.db)
|
||||
.await?;
|
||||
|
||||
let items: Vec<UserResponse> = users.into_iter().map(Into::into).collect();
|
||||
Ok(Json(json!({ "data": items })))
|
||||
Ok(Json(json!({
|
||||
"data": items,
|
||||
"users": items,
|
||||
"meta": {
|
||||
"page": page,
|
||||
"pageSize": page_size,
|
||||
"total": total,
|
||||
"totalPages": ((total + page_size - 1) / page_size).max(1),
|
||||
},
|
||||
"stats": {
|
||||
"total": total_all,
|
||||
"active": active,
|
||||
"disabled": disabled,
|
||||
"admins": admins,
|
||||
}
|
||||
})))
|
||||
}
|
||||
|
||||
/// GET /api/admin/users/{id}
|
||||
@@ -99,14 +196,17 @@ pub async fn create_user(
|
||||
));
|
||||
}
|
||||
|
||||
let role = payload.role.unwrap_or_else(|| "user".to_string());
|
||||
validate_role(&role)?;
|
||||
ensure_email_available(&state, payload.email.trim(), None).await?;
|
||||
|
||||
let password_hash = hash_password(&payload.password)
|
||||
.map_err(|e| AppError::Internal(anyhow::anyhow!("Failed to hash password: {e}")))?;
|
||||
let role = payload.role.unwrap_or_else(|| "user".to_string());
|
||||
|
||||
let result =
|
||||
sqlx::query("INSERT INTO users (username, email, password_hash, role) VALUES (?, ?, ?, ?)")
|
||||
.bind(&payload.username)
|
||||
.bind(&payload.email)
|
||||
.bind(payload.username.trim())
|
||||
.bind(payload.email.trim())
|
||||
.bind(&password_hash)
|
||||
.bind(&role)
|
||||
.execute(&*state.db)
|
||||
@@ -137,6 +237,24 @@ pub async fn update_user(
|
||||
.await?
|
||||
.ok_or_else(|| AppError::NotFound(format!("User {} not found", id)))?;
|
||||
|
||||
if let Some(ref role) = payload.role {
|
||||
validate_role(role)?;
|
||||
}
|
||||
if let Some(ref status) = payload.status {
|
||||
validate_status(status)?;
|
||||
}
|
||||
if let Some(ref username) = payload.username {
|
||||
if username.trim().is_empty() {
|
||||
return Err(AppError::BadRequest("username is required".into()));
|
||||
}
|
||||
}
|
||||
if let Some(ref email) = payload.email {
|
||||
if email.trim().is_empty() {
|
||||
return Err(AppError::BadRequest("email is required".into()));
|
||||
}
|
||||
ensure_email_available(&state, email.trim(), Some(id)).await?;
|
||||
}
|
||||
|
||||
let password_hash = if let Some(ref pw) = payload.password {
|
||||
if pw.len() < 6 {
|
||||
return Err(AppError::BadRequest(
|
||||
@@ -154,8 +272,8 @@ pub async fn update_user(
|
||||
sqlx::query(
|
||||
"UPDATE users SET username = ?, email = ?, role = ?, status = ?, password_hash = COALESCE(?, password_hash), updated_at = datetime('now') WHERE id = ?"
|
||||
)
|
||||
.bind(payload.username.unwrap_or(existing.username))
|
||||
.bind(payload.email.unwrap_or(existing.email))
|
||||
.bind(payload.username.map(|v| v.trim().to_string()).unwrap_or(existing.username))
|
||||
.bind(payload.email.map(|v| v.trim().to_string()).unwrap_or(existing.email))
|
||||
.bind(payload.role.unwrap_or(existing.role))
|
||||
.bind(payload.status.unwrap_or(existing.status))
|
||||
.bind(password_hash)
|
||||
@@ -189,3 +307,41 @@ pub async fn delete_user(
|
||||
|
||||
Ok(Json(json!({ "data": { "deleted": true, "id": id } })))
|
||||
}
|
||||
|
||||
fn normalize_optional_filter(value: Option<&str>) -> Option<&str> {
|
||||
match value.map(str::trim) {
|
||||
Some("") | Some("all") | None => None,
|
||||
Some(value) => Some(value),
|
||||
}
|
||||
}
|
||||
|
||||
fn validate_role(role: &str) -> AppResult<()> {
|
||||
match role {
|
||||
"admin" | "user" => Ok(()),
|
||||
_ => Err(AppError::BadRequest("role must be admin or user".into())),
|
||||
}
|
||||
}
|
||||
|
||||
fn validate_status(status: &str) -> AppResult<()> {
|
||||
match status {
|
||||
"active" | "disabled" => Ok(()),
|
||||
_ => Err(AppError::BadRequest(
|
||||
"status must be active or disabled".into(),
|
||||
)),
|
||||
}
|
||||
}
|
||||
|
||||
async fn ensure_email_available(
|
||||
state: &AppState,
|
||||
email: &str,
|
||||
except_user_id: Option<i64>,
|
||||
) -> AppResult<()> {
|
||||
let existing_id: Option<i64> = sqlx::query_scalar("SELECT id FROM users WHERE email = ?")
|
||||
.bind(email)
|
||||
.fetch_optional(&*state.db)
|
||||
.await?;
|
||||
if existing_id.is_some_and(|id| Some(id) != except_user_id) {
|
||||
return Err(AppError::BadRequest("email already exists".into()));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
+38
-3
@@ -10,7 +10,7 @@ use crate::{
|
||||
use axum::{
|
||||
extract::{Path, State},
|
||||
http::HeaderMap,
|
||||
routing::{get, patch, post},
|
||||
routing::{get, post},
|
||||
Json, Router,
|
||||
};
|
||||
use serde::{Deserialize, Serialize};
|
||||
@@ -32,12 +32,14 @@ pub struct LoginRequest {
|
||||
pub password: String,
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct AuthResponse {
|
||||
pub token: String,
|
||||
pub user: UserProfile,
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct UserProfile {
|
||||
pub id: i64,
|
||||
@@ -51,10 +53,11 @@ pub struct UserProfile {
|
||||
pub fn routes() -> Router<AppState> {
|
||||
Router::new()
|
||||
.route("/api/auth/register", post(register))
|
||||
.route("/api/users/register", post(register))
|
||||
.route("/api/auth/login", post(login))
|
||||
.route("/api/users/login", post(login))
|
||||
.route("/api/auth/me", get(me))
|
||||
.route("/api/users/me", patch(update_me))
|
||||
.route("/api/users/me", get(me).patch(update_me))
|
||||
.route("/api/users/me/password", post(change_password))
|
||||
.route("/api/users/{id}", get(get_user))
|
||||
}
|
||||
@@ -115,6 +118,15 @@ async fn register(
|
||||
.map_err(|e| AppError::Internal(anyhow::anyhow!("Failed to generate JWT: {e}")))?;
|
||||
|
||||
Ok(Json(json!({
|
||||
"token": token,
|
||||
"user": {
|
||||
"id": user.id,
|
||||
"username": user.username,
|
||||
"email": user.email,
|
||||
"role": user.role,
|
||||
"status": user.status,
|
||||
"createdAt": user.created_at,
|
||||
},
|
||||
"data": {
|
||||
"token": token,
|
||||
"user": {
|
||||
@@ -122,6 +134,8 @@ async fn register(
|
||||
"username": user.username,
|
||||
"email": user.email,
|
||||
"role": user.role,
|
||||
"status": user.status,
|
||||
"createdAt": user.created_at,
|
||||
}
|
||||
}
|
||||
})))
|
||||
@@ -160,6 +174,15 @@ async fn login(
|
||||
.map_err(|e| AppError::Internal(anyhow::anyhow!("Failed to generate JWT: {e}")))?;
|
||||
|
||||
Ok(Json(json!({
|
||||
"token": token,
|
||||
"user": {
|
||||
"id": user.id,
|
||||
"username": user.username,
|
||||
"email": user.email,
|
||||
"role": user.role,
|
||||
"status": user.status,
|
||||
"createdAt": user.created_at,
|
||||
},
|
||||
"data": {
|
||||
"token": token,
|
||||
"user": {
|
||||
@@ -167,6 +190,8 @@ async fn login(
|
||||
"username": user.username,
|
||||
"email": user.email,
|
||||
"role": user.role,
|
||||
"status": user.status,
|
||||
"createdAt": user.created_at,
|
||||
}
|
||||
}
|
||||
})))
|
||||
@@ -184,17 +209,27 @@ async fn me(State(state): State<AppState>, headers: HeaderMap) -> AppResult<Json
|
||||
.map_err(|_| AppError::Unauthorized("Invalid or expired token".into()))?;
|
||||
|
||||
let user: User = sqlx::query_as::<_, User>("SELECT * FROM users WHERE id = ?")
|
||||
.bind(&claims.sub.parse::<i64>().unwrap_or(0))
|
||||
.bind(claims.sub.parse::<i64>().unwrap_or(0))
|
||||
.fetch_optional(&*state.db)
|
||||
.await?
|
||||
.ok_or_else(|| AppError::NotFound("User not found".into()))?;
|
||||
|
||||
Ok(Json(json!({
|
||||
"user": {
|
||||
"id": user.id,
|
||||
"username": user.username,
|
||||
"email": user.email,
|
||||
"role": user.role,
|
||||
"status": user.status,
|
||||
"createdAt": user.created_at,
|
||||
},
|
||||
"data": {
|
||||
"id": user.id,
|
||||
"username": user.username,
|
||||
"email": user.email,
|
||||
"role": user.role,
|
||||
"status": user.status,
|
||||
"createdAt": user.created_at,
|
||||
}
|
||||
})))
|
||||
}
|
||||
|
||||
+49
-1
@@ -46,6 +46,7 @@ pub struct CheckinStats {
|
||||
pub fn routes() -> Router<AppState> {
|
||||
Router::new()
|
||||
.route("/api/checkin", get(list_checkin_logs))
|
||||
.route("/api/checkin/logs", get(list_checkin_logs))
|
||||
.route("/api/checkin/stats", get(checkin_stats))
|
||||
.route("/api/checkin/accounts", get(list_checkin_accounts))
|
||||
.route(
|
||||
@@ -53,6 +54,12 @@ pub fn routes() -> Router<AppState> {
|
||||
post(toggle_checkin_account),
|
||||
)
|
||||
.route("/api/checkin/run", post(run_checkin))
|
||||
.route("/api/checkin/trigger", post(run_checkin))
|
||||
.route("/api/checkin/trigger/{id}", post(run_checkin_account))
|
||||
.route(
|
||||
"/api/checkin/schedule",
|
||||
axum::routing::put(update_checkin_schedule),
|
||||
)
|
||||
}
|
||||
|
||||
/// GET /api/checkin — list checkin logs
|
||||
@@ -110,7 +117,7 @@ async fn checkin_stats(State(state): State<AppState>) -> AppResult<Json<Value>>
|
||||
.filter(|r| {
|
||||
r.last_checkin
|
||||
.as_deref()
|
||||
.map_or(false, |d| d.starts_with(&today_date()))
|
||||
.is_some_and(|d| d.starts_with(&today_date()))
|
||||
})
|
||||
.count();
|
||||
|
||||
@@ -213,6 +220,47 @@ async fn run_checkin(State(state): State<AppState>) -> AppResult<Json<Value>> {
|
||||
})))
|
||||
}
|
||||
|
||||
/// POST /api/checkin/trigger/{id} — trigger checkin for one account.
|
||||
async fn run_checkin_account(
|
||||
State(state): State<AppState>,
|
||||
Path(id): Path<i64>,
|
||||
) -> AppResult<Json<Value>> {
|
||||
info!("Running checkin for account: {}", id);
|
||||
|
||||
let account: Account = sqlx::query_as::<_, Account>("SELECT * FROM accounts WHERE id = ?")
|
||||
.bind(id)
|
||||
.fetch_optional(&*state.db)
|
||||
.await?
|
||||
.ok_or_else(|| AppError::NotFound(format!("Account {} not found", id)))?;
|
||||
|
||||
let bonus = 0.01;
|
||||
sqlx::query("UPDATE accounts SET balance = COALESCE(balance, 0) + ?, last_checkin_at = datetime('now'), updated_at = datetime('now') WHERE id = ?")
|
||||
.bind(bonus)
|
||||
.bind(account.id)
|
||||
.execute(&*state.db)
|
||||
.await?;
|
||||
|
||||
sqlx::query("INSERT INTO checkin_logs (account_id, status, message, reward) VALUES (?, 'success', 'Daily checkin completed', ?)")
|
||||
.bind(account.id)
|
||||
.bind(format!("+${:.4}", bonus))
|
||||
.execute(&*state.db)
|
||||
.await?;
|
||||
|
||||
Ok(Json(json!({
|
||||
"success": true,
|
||||
"account_id": account.id,
|
||||
"username": account.username,
|
||||
"reward": bonus,
|
||||
})))
|
||||
}
|
||||
|
||||
async fn update_checkin_schedule(Json(body): Json<Value>) -> AppResult<Json<Value>> {
|
||||
Ok(Json(json!({
|
||||
"success": true,
|
||||
"cron": body.get("cron").and_then(Value::as_str).unwrap_or(""),
|
||||
})))
|
||||
}
|
||||
|
||||
/// Get today's date as YYYY-MM-DD
|
||||
fn today_date() -> String {
|
||||
use chrono::Utc;
|
||||
|
||||
+93
-34
@@ -10,10 +10,11 @@ use crate::{
|
||||
AppState,
|
||||
};
|
||||
use axum::{
|
||||
body::Bytes,
|
||||
extract::{Path, Query, State},
|
||||
http::{HeaderMap, StatusCode},
|
||||
http::{HeaderMap, Method, StatusCode, Uri},
|
||||
response::{Html, IntoResponse, Response},
|
||||
routing::{get, post},
|
||||
routing::{any, get, post},
|
||||
Json, Router,
|
||||
};
|
||||
use reqwest::Client;
|
||||
@@ -21,9 +22,6 @@ use serde_json::Value;
|
||||
use std::collections::HashMap;
|
||||
use tracing::info;
|
||||
|
||||
/// Default ComfyUI backend address.
|
||||
const DEFAULT_COMFYUI_BASE: &str = "http://127.0.0.1:8188";
|
||||
|
||||
/// Shared HTTP client
|
||||
fn http_client() -> Client {
|
||||
Client::builder()
|
||||
@@ -39,7 +37,7 @@ async fn check_auth(state: &AppState, headers: &HeaderMap) -> Result<(), AppErro
|
||||
.get("Authorization")
|
||||
.and_then(|v| v.to_str().ok())
|
||||
.and_then(|v| v.strip_prefix("Bearer "))
|
||||
.and_then(|v| v.trim().split_whitespace().next())
|
||||
.and_then(|v| v.split_whitespace().next())
|
||||
.ok_or_else(|| AppError::Unauthorized("Missing Authorization header".into()))?;
|
||||
|
||||
// Admin token passes
|
||||
@@ -49,9 +47,10 @@ async fn check_auth(state: &AppState, headers: &HeaderMap) -> Result<(), AppErro
|
||||
|
||||
// Downstream API key
|
||||
if auth.starts_with("sk-") {
|
||||
let result = authorize_downstream_token(auth, &state.db)
|
||||
.await
|
||||
.map_err(|e| AppError::Unauthorized(e))?;
|
||||
let result =
|
||||
authorize_downstream_token(auth, &state.db, Some(&state.config.auth.proxy_token))
|
||||
.await
|
||||
.map_err(AppError::Unauthorized)?;
|
||||
|
||||
match result {
|
||||
DownstreamAuthResult::Managed { key, .. } => {
|
||||
@@ -67,9 +66,9 @@ async fn check_auth(state: &AppState, headers: &HeaderMap) -> Result<(), AppErro
|
||||
}
|
||||
}
|
||||
|
||||
/// Get ComfyUI base URL from env or default
|
||||
fn comfyui_base() -> String {
|
||||
std::env::var("COMFYUI_BASE").unwrap_or_else(|_| DEFAULT_COMFYUI_BASE.to_string())
|
||||
/// Get ComfyUI base URL from embedded/external runtime configuration.
|
||||
fn comfyui_base(state: &AppState) -> String {
|
||||
state.comfyui.internal_base_url()
|
||||
}
|
||||
|
||||
// ── Route builder ────────────────────────────────────────────────────
|
||||
@@ -82,10 +81,11 @@ pub fn routes() -> Router<AppState> {
|
||||
.route("/api/comfyui/history/{id}", get(proxy_get_history_by_id))
|
||||
.route("/api/comfyui/queue", get(proxy_get_queue))
|
||||
.route("/api/comfyui/view", get(proxy_get_view))
|
||||
// Static proxy (public — assets/JS/CSS)
|
||||
.route("/api/comfyui/proxy", get(proxy_static_root))
|
||||
.route("/api/comfyui/proxy/", get(proxy_static_root))
|
||||
.route("/api/comfyui/proxy/{*path}", get(proxy_static))
|
||||
.route("/api/comfyui/object_info", get(proxy_get_object_info))
|
||||
// Public ComfyUI reverse proxy for the embedded iframe UI.
|
||||
.route("/api/comfyui/proxy", any(proxy_static_root))
|
||||
.route("/api/comfyui/proxy/", any(proxy_static_root))
|
||||
.route("/api/comfyui/proxy/{*path}", any(proxy_static))
|
||||
// HTML page (public)
|
||||
.route("/comfyui", get(comfyui_page))
|
||||
}
|
||||
@@ -100,7 +100,7 @@ async fn proxy_post_prompt(
|
||||
) -> AppResult<Response> {
|
||||
info!("POST /api/comfyui/prompt");
|
||||
check_auth(&state, &headers).await?;
|
||||
proxy_to_comfyui("POST", "/prompt", None, Some(body)).await
|
||||
proxy_to_comfyui(&state, "POST", "/prompt", None, Some(body)).await
|
||||
}
|
||||
|
||||
/// GET /api/comfyui/history
|
||||
@@ -110,7 +110,7 @@ async fn proxy_get_history(
|
||||
) -> AppResult<Response> {
|
||||
info!("GET /api/comfyui/history");
|
||||
check_auth(&state, &headers).await?;
|
||||
proxy_to_comfyui("GET", "/history", None, None).await
|
||||
proxy_to_comfyui(&state, "GET", "/history", None, None).await
|
||||
}
|
||||
|
||||
/// GET /api/comfyui/history/{id}
|
||||
@@ -122,14 +122,24 @@ async fn proxy_get_history_by_id(
|
||||
info!("GET /api/comfyui/history/{}", id);
|
||||
check_auth(&state, &headers).await?;
|
||||
let path = format!("/history/{}", urlencoding(&id));
|
||||
proxy_to_comfyui("GET", &path, None, None).await
|
||||
proxy_to_comfyui(&state, "GET", &path, None, None).await
|
||||
}
|
||||
|
||||
/// GET /api/comfyui/queue
|
||||
async fn proxy_get_queue(State(state): State<AppState>, headers: HeaderMap) -> AppResult<Response> {
|
||||
info!("GET /api/comfyui/queue");
|
||||
check_auth(&state, &headers).await?;
|
||||
proxy_to_comfyui("GET", "/queue", None, None).await
|
||||
proxy_to_comfyui(&state, "GET", "/queue", None, None).await
|
||||
}
|
||||
|
||||
/// GET /api/comfyui/object_info
|
||||
async fn proxy_get_object_info(
|
||||
State(state): State<AppState>,
|
||||
headers: HeaderMap,
|
||||
) -> AppResult<Response> {
|
||||
info!("GET /api/comfyui/object_info");
|
||||
check_auth(&state, &headers).await?;
|
||||
proxy_to_comfyui(&state, "GET", "/object_info", None, None).await
|
||||
}
|
||||
|
||||
/// GET /api/comfyui/view?filename=...&type=...&subfolder=...
|
||||
@@ -143,29 +153,73 @@ async fn proxy_get_view(
|
||||
|
||||
let qs = build_query_string(¶ms);
|
||||
let path = format!("/view{}", qs);
|
||||
proxy_to_comfyui_binary("GET", &path).await
|
||||
proxy_to_comfyui_binary(&state, "GET", &path).await
|
||||
}
|
||||
|
||||
// ── Static proxy (public — assets/JS/CSS for iframe) ─────────────────
|
||||
// ── Public ComfyUI reverse proxy for iframe UI ───────────────────────
|
||||
|
||||
/// GET /api/comfyui/proxy (root) — redirect to proxied root
|
||||
async fn proxy_static_root() -> AppResult<Response> {
|
||||
proxy_static(Path("/".to_string())).await
|
||||
/// /api/comfyui/proxy (root) — proxy to ComfyUI root
|
||||
async fn proxy_static_root(
|
||||
State(state): State<AppState>,
|
||||
method: Method,
|
||||
uri: Uri,
|
||||
headers: HeaderMap,
|
||||
body: Bytes,
|
||||
) -> AppResult<Response> {
|
||||
proxy_static_request(state, method, uri, headers, "/".to_string(), body).await
|
||||
}
|
||||
|
||||
/// GET /api/comfyui/proxy/{path} — proxy everything (HTML, assets)
|
||||
async fn proxy_static(Path(path): Path<String>) -> AppResult<Response> {
|
||||
info!("GET /api/comfyui/proxy/{}", path);
|
||||
/// /api/comfyui/proxy/{path} — proxy ComfyUI UI assets and API calls.
|
||||
async fn proxy_static(
|
||||
State(state): State<AppState>,
|
||||
Path(path): Path<String>,
|
||||
method: Method,
|
||||
uri: Uri,
|
||||
headers: HeaderMap,
|
||||
body: Bytes,
|
||||
) -> AppResult<Response> {
|
||||
proxy_static_request(state, method, uri, headers, path, body).await
|
||||
}
|
||||
|
||||
async fn proxy_static_request(
|
||||
state: AppState,
|
||||
method: Method,
|
||||
uri: Uri,
|
||||
headers: HeaderMap,
|
||||
path: String,
|
||||
body: Bytes,
|
||||
) -> AppResult<Response> {
|
||||
let target_path = if path.is_empty() || path == "/" {
|
||||
"/".to_string()
|
||||
} else {
|
||||
format!("/{}", path)
|
||||
};
|
||||
let target_path = match uri.query() {
|
||||
Some(query) => format!("{target_path}?{query}"),
|
||||
None => target_path,
|
||||
};
|
||||
|
||||
let client = http_client();
|
||||
let url = format!("{}{}", comfyui_base(), target_path);
|
||||
let resp = client
|
||||
.get(&url)
|
||||
let url = format!("{}{}", comfyui_base(&state), target_path);
|
||||
info!("{} /api/comfyui/proxy{} -> {}", method, uri.path(), url);
|
||||
|
||||
let reqwest_method = method
|
||||
.as_str()
|
||||
.parse()
|
||||
.map_err(|e| AppError::BadRequest(format!("Invalid HTTP method: {e}")))?;
|
||||
let mut req = client.request(reqwest_method, &url);
|
||||
|
||||
if let Some(content_type) = headers.get(axum::http::header::CONTENT_TYPE) {
|
||||
req = req.header(reqwest::header::CONTENT_TYPE, content_type.as_bytes());
|
||||
}
|
||||
if let Some(accept) = headers.get(axum::http::header::ACCEPT) {
|
||||
req = req.header(reqwest::header::ACCEPT, accept.as_bytes());
|
||||
}
|
||||
if !body.is_empty() {
|
||||
req = req.body(body);
|
||||
}
|
||||
|
||||
let resp = req
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| AppError::UpstreamError(format!("ComfyUI unreachable: {e}")))?;
|
||||
@@ -306,13 +360,14 @@ fn urlencoding(s: &str) -> String {
|
||||
|
||||
/// Proxy to ComfyUI — returns JSON/text response
|
||||
async fn proxy_to_comfyui(
|
||||
state: &AppState,
|
||||
method: &str,
|
||||
path: &str,
|
||||
_query: Option<&HashMap<String, String>>,
|
||||
body: Option<Value>,
|
||||
) -> AppResult<Response> {
|
||||
let client = http_client();
|
||||
let url = format!("{}{}", comfyui_base(), path);
|
||||
let url = format!("{}{}", comfyui_base(state), path);
|
||||
|
||||
let req = match method {
|
||||
"POST" => {
|
||||
@@ -347,9 +402,13 @@ async fn proxy_to_comfyui(
|
||||
}
|
||||
|
||||
/// Proxy to ComfyUI — returns binary response (for images)
|
||||
async fn proxy_to_comfyui_binary(_method: &str, path: &str) -> AppResult<Response> {
|
||||
async fn proxy_to_comfyui_binary(
|
||||
state: &AppState,
|
||||
_method: &str,
|
||||
path: &str,
|
||||
) -> AppResult<Response> {
|
||||
let client = http_client();
|
||||
let url = format!("{}{}", comfyui_base(), path);
|
||||
let url = format!("{}{}", comfyui_base(state), path);
|
||||
|
||||
let req = client.get(&url);
|
||||
let resp = req
|
||||
|
||||
+145
-78
@@ -3,12 +3,13 @@ use axum::{
|
||||
extract::State,
|
||||
http::HeaderMap,
|
||||
response::sse::{Event, Sse},
|
||||
routing::post,
|
||||
routing::{get, post},
|
||||
Json, Router,
|
||||
};
|
||||
use futures::stream::Stream;
|
||||
use regex::Regex;
|
||||
use reqwest::Client;
|
||||
use serde::Deserialize;
|
||||
use serde_json::{json, Value};
|
||||
use std::convert::Infallible;
|
||||
use tokio::sync::mpsc::{unbounded_channel, UnboundedSender};
|
||||
@@ -58,19 +59,68 @@ emotion: 平静
|
||||
- 如果没有用户明确要求,切勿自作主张输出全部内容
|
||||
"#;
|
||||
|
||||
fn upstream_base() -> String {
|
||||
std::env::var("BOOSAPI_UPSTREAM_BASE")
|
||||
.unwrap_or_else(|_| "https://api.boos.lat/v1".to_string())
|
||||
.trim_end_matches('/')
|
||||
.to_string()
|
||||
}
|
||||
|
||||
fn upstream_key() -> String {
|
||||
std::env::var("BOOSAPI_UPSTREAM_KEY").unwrap_or_else(|_| "sk-boos4721".to_string())
|
||||
}
|
||||
|
||||
pub fn routes() -> Router<AppState> {
|
||||
Router::new().route("/api/comfyui-agent/chat", post(chat_handler))
|
||||
Router::new()
|
||||
.route("/api/comfyui-agent/status", get(status_handler))
|
||||
.route("/api/comfyui-agent/chat", post(chat_handler))
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct AgentChatRequest {
|
||||
messages: Vec<AgentMessage>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct AgentMessage {
|
||||
role: String,
|
||||
content: String,
|
||||
}
|
||||
|
||||
fn upstream_config(state: &AppState) -> (String, String) {
|
||||
let base = std::env::var("BOOSAPI_UPSTREAM_BASE")
|
||||
.ok()
|
||||
.map(|value| value.trim().trim_end_matches('/').to_string())
|
||||
.filter(|value| !value.is_empty())
|
||||
.unwrap_or_else(|| "https://api.boos.lat/v1".to_string());
|
||||
let key = std::env::var("BOOSAPI_UPSTREAM_KEY")
|
||||
.ok()
|
||||
.map(|value| value.trim().to_string())
|
||||
.filter(|value| !value.is_empty())
|
||||
.unwrap_or_else(|| state.config.auth.proxy_token.clone());
|
||||
(base, key)
|
||||
}
|
||||
|
||||
fn authorize(headers: &HeaderMap, state: &AppState) -> bool {
|
||||
let auth_header = headers
|
||||
.get("authorization")
|
||||
.and_then(|value| value.to_str().ok())
|
||||
.unwrap_or_default();
|
||||
|
||||
let token = auth_header
|
||||
.strip_prefix("Bearer ")
|
||||
.or_else(|| auth_header.strip_prefix("bearer "))
|
||||
.unwrap_or_default();
|
||||
|
||||
token == state.config.auth.auth_token
|
||||
}
|
||||
|
||||
async fn status_handler(
|
||||
State(state): State<AppState>,
|
||||
headers: HeaderMap,
|
||||
) -> Result<Json<Value>, (axum::http::StatusCode, Json<Value>)> {
|
||||
if !authorize(&headers, &state) {
|
||||
return Err((
|
||||
axum::http::StatusCode::UNAUTHORIZED,
|
||||
Json(json!({ "error": "Unauthorized" })),
|
||||
));
|
||||
}
|
||||
let (upstream_base, _) = upstream_config(&state);
|
||||
Ok(Json(json!({
|
||||
"success": true,
|
||||
"model": state.config.comfyui.agent_model,
|
||||
"upstreamBase": upstream_base,
|
||||
"comfyui": state.comfyui.status_response().await,
|
||||
})))
|
||||
}
|
||||
|
||||
#[derive(Debug, Default)]
|
||||
@@ -79,7 +129,6 @@ struct ParsedSections {
|
||||
scenes: Vec<SceneSection>,
|
||||
tts_list: Vec<TtsSection>,
|
||||
workflow_json: Option<String>,
|
||||
text_content: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Default)]
|
||||
@@ -108,7 +157,6 @@ struct TtsSection {
|
||||
|
||||
fn parse_sections(full_text: &str) -> ParsedSections {
|
||||
let mut sections = ParsedSections::default();
|
||||
sections.text_content = full_text.to_string();
|
||||
|
||||
// Parse CHARACTER blocks
|
||||
let char_re = Regex::new(r"(?s)【CHARACTER】\s*(.*?)【/CHARACTER】").unwrap();
|
||||
@@ -118,21 +166,35 @@ fn parse_sections(full_text: &str) -> ParsedSections {
|
||||
for line in block.lines() {
|
||||
let line = line.trim();
|
||||
let line_lower = line.to_lowercase();
|
||||
if let Some(val) = line.strip_prefix("name:").or_else(|| line.strip_prefix("name :")) {
|
||||
if let Some(val) = line
|
||||
.strip_prefix("name:")
|
||||
.or_else(|| line.strip_prefix("name :"))
|
||||
{
|
||||
char_section.name = val.trim().to_string();
|
||||
} else if let Some(val) = line.strip_prefix("description:").or_else(|| line.strip_prefix("description :")) {
|
||||
} else if let Some(val) = line
|
||||
.strip_prefix("description:")
|
||||
.or_else(|| line.strip_prefix("description :"))
|
||||
{
|
||||
char_section.description = val.trim().to_string();
|
||||
} else if line_lower.starts_with("prompt_front:") || line_lower.starts_with("prompt_front :") {
|
||||
let val = line.splitn(2, ':').nth(1).unwrap_or("").trim();
|
||||
} else if line_lower.starts_with("prompt_front:")
|
||||
|| line_lower.starts_with("prompt_front :")
|
||||
{
|
||||
let val = line.split_once(':').map(|x| x.1).unwrap_or("").trim();
|
||||
char_section.prompt_front = val.to_string();
|
||||
} else if line_lower.starts_with("prompt_back:") || line_lower.starts_with("prompt_back :") {
|
||||
let val = line.splitn(2, ':').nth(1).unwrap_or("").trim();
|
||||
} else if line_lower.starts_with("prompt_back:")
|
||||
|| line_lower.starts_with("prompt_back :")
|
||||
{
|
||||
let val = line.split_once(':').map(|x| x.1).unwrap_or("").trim();
|
||||
char_section.prompt_back = val.to_string();
|
||||
} else if line_lower.starts_with("prompt_left:") || line_lower.starts_with("prompt_left :") {
|
||||
let val = line.splitn(2, ':').nth(1).unwrap_or("").trim();
|
||||
} else if line_lower.starts_with("prompt_left:")
|
||||
|| line_lower.starts_with("prompt_left :")
|
||||
{
|
||||
let val = line.split_once(':').map(|x| x.1).unwrap_or("").trim();
|
||||
char_section.prompt_left = val.to_string();
|
||||
} else if line_lower.starts_with("prompt_right:") || line_lower.starts_with("prompt_right :") {
|
||||
let val = line.splitn(2, ':').nth(1).unwrap_or("").trim();
|
||||
} else if line_lower.starts_with("prompt_right:")
|
||||
|| line_lower.starts_with("prompt_right :")
|
||||
{
|
||||
let val = line.split_once(':').map(|x| x.1).unwrap_or("").trim();
|
||||
char_section.prompt_right = val.to_string();
|
||||
}
|
||||
}
|
||||
@@ -154,12 +216,15 @@ fn parse_sections(full_text: &str) -> ParsedSections {
|
||||
let line = line.trim();
|
||||
let line_lower = line.to_lowercase();
|
||||
if line_lower.starts_with("id:") || line_lower.starts_with("id :") {
|
||||
let val = line.splitn(2, ':').nth(1).unwrap_or("").trim();
|
||||
let val = line.split_once(':').map(|x| x.1).unwrap_or("").trim();
|
||||
scene.id = val.parse().unwrap_or(0);
|
||||
} else if let Some(val) = line.strip_prefix("description:").or_else(|| line.strip_prefix("description :")) {
|
||||
} else if let Some(val) = line
|
||||
.strip_prefix("description:")
|
||||
.or_else(|| line.strip_prefix("description :"))
|
||||
{
|
||||
scene.description = val.trim().to_string();
|
||||
} else if line_lower.starts_with("prompt:") || line_lower.starts_with("prompt :") {
|
||||
let val = line.splitn(2, ':').nth(1).unwrap_or("").trim();
|
||||
let val = line.split_once(':').map(|x| x.1).unwrap_or("").trim();
|
||||
scene.prompt = val.to_string();
|
||||
}
|
||||
}
|
||||
@@ -181,13 +246,13 @@ fn parse_sections(full_text: &str) -> ParsedSections {
|
||||
let line = line.trim();
|
||||
let line_lower = line.to_lowercase();
|
||||
if line_lower.starts_with("character:") || line_lower.starts_with("character :") {
|
||||
let val = line.splitn(2, ':').nth(1).unwrap_or("").trim();
|
||||
let val = line.split_once(':').map(|x| x.1).unwrap_or("").trim();
|
||||
tts.character = val.to_string();
|
||||
} else if line_lower.starts_with("text:") || line_lower.starts_with("text :") {
|
||||
let val = line.splitn(2, ':').nth(1).unwrap_or("").trim();
|
||||
let val = line.split_once(':').map(|x| x.1).unwrap_or("").trim();
|
||||
tts.text = val.to_string();
|
||||
} else if line_lower.starts_with("emotion:") || line_lower.starts_with("emotion :") {
|
||||
let val = line.splitn(2, ':').nth(1).unwrap_or("").trim();
|
||||
let val = line.split_once(':').map(|x| x.1).unwrap_or("").trim();
|
||||
tts.emotion = val.to_string();
|
||||
}
|
||||
}
|
||||
@@ -206,13 +271,13 @@ fn parse_sections(full_text: &str) -> ParsedSections {
|
||||
sections
|
||||
}
|
||||
|
||||
async fn call_image_gen(prompt: &str) -> Result<String, String> {
|
||||
async fn call_image_gen(api_base: &str, api_key: &str, prompt: &str) -> Result<String, String> {
|
||||
let client = Client::new();
|
||||
let url = format!("{}/images/generations", upstream_base());
|
||||
let url = format!("{}/images/generations", api_base.trim_end_matches('/'));
|
||||
|
||||
let resp = client
|
||||
.post(&url)
|
||||
.bearer_auth(upstream_key())
|
||||
.bearer_auth(api_key)
|
||||
.json(&json!({
|
||||
"model": "gpt-image-2",
|
||||
"prompt": prompt,
|
||||
@@ -243,13 +308,18 @@ async fn call_image_gen(prompt: &str) -> Result<String, String> {
|
||||
.to_string())
|
||||
}
|
||||
|
||||
async fn call_tts(text: &str, voice: &str) -> Result<Vec<u8>, String> {
|
||||
async fn call_tts(
|
||||
api_base: &str,
|
||||
api_key: &str,
|
||||
text: &str,
|
||||
voice: &str,
|
||||
) -> Result<Vec<u8>, String> {
|
||||
let client = Client::new();
|
||||
let url = format!("{}/audio/speech", upstream_base());
|
||||
let url = format!("{}/audio/speech", api_base.trim_end_matches('/'));
|
||||
|
||||
let resp = client
|
||||
.post(&url)
|
||||
.bearer_auth(upstream_key())
|
||||
.bearer_auth(api_key)
|
||||
.json(&json!({
|
||||
"model": "tts-1",
|
||||
"input": text,
|
||||
@@ -274,58 +344,51 @@ async fn call_tts(text: &str, voice: &str) -> Result<Vec<u8>, String> {
|
||||
}
|
||||
|
||||
fn send_sse(tx: &UnboundedSender<Result<Event, Infallible>>, payload: Value) -> bool {
|
||||
tx.send(Ok(Event::default().data(payload.to_string()))).is_ok()
|
||||
tx.send(Ok(Event::default().data(payload.to_string())))
|
||||
.is_ok()
|
||||
}
|
||||
|
||||
async fn chat_handler(
|
||||
State(state): State<AppState>,
|
||||
headers: HeaderMap,
|
||||
Json(body): Json<Value>,
|
||||
Json(body): Json<AgentChatRequest>,
|
||||
) -> Result<Sse<impl Stream<Item = Result<Event, Infallible>>>, (axum::http::StatusCode, Json<Value>)>
|
||||
{
|
||||
let auth_header = headers
|
||||
.get("authorization")
|
||||
.and_then(|value| value.to_str().ok())
|
||||
.unwrap_or_default();
|
||||
|
||||
let token = auth_header
|
||||
.strip_prefix("Bearer ")
|
||||
.or_else(|| auth_header.strip_prefix("bearer "))
|
||||
.unwrap_or_default();
|
||||
|
||||
if token != state.config.auth.auth_token {
|
||||
if !authorize(&headers, &state) {
|
||||
return Err((
|
||||
axum::http::StatusCode::UNAUTHORIZED,
|
||||
Json(json!({ "error": "Unauthorized" })),
|
||||
));
|
||||
}
|
||||
|
||||
let messages = body
|
||||
.get("messages")
|
||||
.and_then(Value::as_array)
|
||||
.ok_or_else(|| {
|
||||
(
|
||||
axum::http::StatusCode::BAD_REQUEST,
|
||||
Json(json!({ "error": "messages is required" })),
|
||||
)
|
||||
})?;
|
||||
|
||||
for message in messages {
|
||||
if message.get("role").and_then(Value::as_str).is_none()
|
||||
|| message.get("content").and_then(Value::as_str).is_none()
|
||||
{
|
||||
return Err((
|
||||
axum::http::StatusCode::BAD_REQUEST,
|
||||
Json(json!({ "error": "messages is required" })),
|
||||
));
|
||||
}
|
||||
if body.messages.is_empty()
|
||||
|| body
|
||||
.messages
|
||||
.iter()
|
||||
.any(|message| message.role.trim().is_empty() || message.content.trim().is_empty())
|
||||
{
|
||||
return Err((
|
||||
axum::http::StatusCode::BAD_REQUEST,
|
||||
Json(json!({ "error": "messages is required" })),
|
||||
));
|
||||
}
|
||||
|
||||
let messages = messages.clone();
|
||||
let messages: Vec<Value> = body
|
||||
.messages
|
||||
.into_iter()
|
||||
.map(|message| {
|
||||
json!({
|
||||
"role": message.role,
|
||||
"content": message.content,
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
let (api_base, api_key) = upstream_config(&state);
|
||||
let agent_model = state.config.comfyui.agent_model.clone();
|
||||
let (tx, rx) = unbounded_channel::<Result<Event, Infallible>>();
|
||||
|
||||
tokio::spawn(async move {
|
||||
let upstream_url = format!("{}/chat/completions", upstream_base().trim_end_matches('/'));
|
||||
let upstream_url = format!("{}/chat/completions", api_base.trim_end_matches('/'));
|
||||
|
||||
let mut upstream_messages = Vec::with_capacity(messages.len() + 1);
|
||||
upstream_messages.push(json!({
|
||||
@@ -340,9 +403,9 @@ async fn chat_handler(
|
||||
|
||||
let response = match client
|
||||
.post(&upstream_url)
|
||||
.bearer_auth(upstream_key())
|
||||
.bearer_auth(&api_key)
|
||||
.json(&json!({
|
||||
"model": "gpt-5.5",
|
||||
"model": agent_model,
|
||||
"messages": upstream_messages,
|
||||
"temperature": 0.7,
|
||||
"stream": true,
|
||||
@@ -516,7 +579,7 @@ async fn chat_handler(
|
||||
tokio::time::sleep(tokio::time::Duration::from_secs(1)).await;
|
||||
}
|
||||
|
||||
match call_image_gen(prompt).await {
|
||||
match call_image_gen(&api_base, &api_key, prompt).await {
|
||||
Ok(url) if !url.is_empty() => {
|
||||
send_sse(
|
||||
&tx,
|
||||
@@ -543,11 +606,14 @@ async fn chat_handler(
|
||||
|
||||
// Generate images for each scene
|
||||
for scene in §ions.scenes {
|
||||
info!("comfyui-agent: generating scene image for scene {}", scene.id);
|
||||
info!(
|
||||
"comfyui-agent: generating scene image for scene {}",
|
||||
scene.id
|
||||
);
|
||||
|
||||
tokio::time::sleep(tokio::time::Duration::from_secs(1)).await;
|
||||
|
||||
match call_image_gen(&scene.prompt).await {
|
||||
match call_image_gen(&api_base, &api_key, &scene.prompt).await {
|
||||
Ok(url) if !url.is_empty() => {
|
||||
send_sse(
|
||||
&tx,
|
||||
@@ -586,11 +652,12 @@ async fn chat_handler(
|
||||
_ => "alloy",
|
||||
};
|
||||
|
||||
match call_tts(&tts.text, voice).await {
|
||||
match call_tts(&api_base, &api_key, &tts.text, voice).await {
|
||||
Ok(audio_bytes) => {
|
||||
// For SSE, we'll send a data URL for small audio, or a placeholder
|
||||
// Since SSE can't easily send binary, we store to file and send URL
|
||||
let audio_path = format!("/tmp/comfyui_tts_{}_{}.mp3",
|
||||
let audio_path = format!(
|
||||
"/tmp/comfyui_tts_{}_{}.mp3",
|
||||
tts.character.replace(' ', "_"),
|
||||
std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
|
||||
@@ -1,13 +1,9 @@
|
||||
// Events routes — system event notifications
|
||||
|
||||
use crate::{
|
||||
db::models::event::Event,
|
||||
error::{AppError, AppResult},
|
||||
AppState,
|
||||
};
|
||||
use crate::{db::models::event::Event, error::AppResult, AppState};
|
||||
use axum::{
|
||||
extract::{Path, Query, State},
|
||||
routing::{delete, get, post},
|
||||
routing::{get, post},
|
||||
Json, Router,
|
||||
};
|
||||
use serde::Deserialize;
|
||||
|
||||
+17
-22
@@ -1,8 +1,8 @@
|
||||
use crate::AppState;
|
||||
use axum::{
|
||||
body::Body,
|
||||
http::{Request, StatusCode, Uri},
|
||||
response::{Html, Response},
|
||||
http::{Request, StatusCode},
|
||||
response::Response,
|
||||
routing::get,
|
||||
Router,
|
||||
};
|
||||
@@ -31,8 +31,7 @@ mod user_api_keys;
|
||||
mod video_agent;
|
||||
|
||||
pub fn build_router(state: AppState) -> Router {
|
||||
let web_dir = std::env::var("BOOSAPI_WEB_DIR")
|
||||
.unwrap_or_else(|_| "dist/web".to_string());
|
||||
let web_dir = std::env::var("BOOSAPI_WEB_DIR").unwrap_or_else(|_| "dist/web".to_string());
|
||||
|
||||
// Build the API router
|
||||
let api_routes = Router::new()
|
||||
@@ -82,24 +81,20 @@ pub fn build_router(state: AppState) -> Router {
|
||||
// SPA fallback: serve index.html for client-side routing
|
||||
let index_path = format!("{}/index.html", web_dir);
|
||||
match tokio::fs::read_to_string(&index_path).await {
|
||||
Ok(html) => {
|
||||
Ok::<_, Infallible>(
|
||||
Response::builder()
|
||||
.status(StatusCode::OK)
|
||||
.header("Content-Type", "text/html; charset=utf-8")
|
||||
.body(Body::from(html))
|
||||
.unwrap(),
|
||||
)
|
||||
}
|
||||
Err(_) => {
|
||||
Ok::<_, Infallible>(
|
||||
Response::builder()
|
||||
.status(StatusCode::NOT_FOUND)
|
||||
.header("Content-Type", "text/plain; charset=utf-8")
|
||||
.body(Body::from(format!("404: {} not found", path)))
|
||||
.unwrap(),
|
||||
)
|
||||
}
|
||||
Ok(html) => Ok::<_, Infallible>(
|
||||
Response::builder()
|
||||
.status(StatusCode::OK)
|
||||
.header("Content-Type", "text/html; charset=utf-8")
|
||||
.body(Body::from(html))
|
||||
.unwrap(),
|
||||
),
|
||||
Err(_) => Ok::<_, Infallible>(
|
||||
Response::builder()
|
||||
.status(StatusCode::NOT_FOUND)
|
||||
.header("Content-Type", "text/plain; charset=utf-8")
|
||||
.body(Body::from(format!("404: {} not found", path)))
|
||||
.unwrap(),
|
||||
),
|
||||
}
|
||||
}
|
||||
}));
|
||||
|
||||
@@ -6,13 +6,12 @@ use crate::{
|
||||
};
|
||||
use axum::{
|
||||
extract::{Path, State},
|
||||
http::{header, HeaderMap, StatusCode},
|
||||
http::{header, HeaderMap},
|
||||
response::Response,
|
||||
routing::{get, post, put},
|
||||
routing::{get, post},
|
||||
Json, Router,
|
||||
};
|
||||
use serde_json::{json, Value};
|
||||
use tracing::info;
|
||||
|
||||
const MONITOR_AUTH_COOKIE: &str = "meta_monitor_auth";
|
||||
const LDOH_BASE_URL: &str = "https://ldoh.105117.xyz";
|
||||
@@ -220,20 +219,20 @@ async fn ldoh_proxy_handler(
|
||||
.text()
|
||||
.await
|
||||
.map_err(|e| AppError::UpstreamError(e.to_string()))?;
|
||||
return Ok(builder
|
||||
return builder
|
||||
.header(header::CONTENT_TYPE, &content_type)
|
||||
.body(axum::body::Body::from(rewrite_proxy_text(&text)))
|
||||
.map_err(|e| AppError::Internal(anyhow::anyhow!("{e}")))?);
|
||||
.map_err(|e| AppError::Internal(anyhow::anyhow!("{e}")));
|
||||
}
|
||||
|
||||
let bytes = resp
|
||||
.bytes()
|
||||
.await
|
||||
.map_err(|e| AppError::UpstreamError(e.to_string()))?;
|
||||
Ok(builder
|
||||
builder
|
||||
.header(header::CONTENT_TYPE, &content_type)
|
||||
.body(axum::body::Body::from(bytes))
|
||||
.map_err(|e| AppError::Internal(anyhow::anyhow!("{e}")))?)
|
||||
.map_err(|e| AppError::Internal(anyhow::anyhow!("{e}")))
|
||||
}
|
||||
|
||||
// ── Helpers ────────────────────────────────────────────────────────────
|
||||
|
||||
+1
-1
@@ -280,7 +280,7 @@ async fn delete_route_unit(
|
||||
// ── Callback ───────────────────────────────────────────────────────────
|
||||
|
||||
/// GET /api/oauth/callback — public, returns HTML for popup window
|
||||
async fn oauth_callback_page(headers: HeaderMap) -> impl IntoResponse {
|
||||
async fn oauth_callback_page(_headers: HeaderMap) -> impl IntoResponse {
|
||||
info!("OAuth callback received");
|
||||
let html = r#"<!doctype html>
|
||||
<html lang="zh-CN">
|
||||
|
||||
@@ -14,7 +14,6 @@ use axum::{
|
||||
};
|
||||
use serde::Deserialize;
|
||||
use serde_json::{json, Value};
|
||||
use tracing::info;
|
||||
|
||||
// ── Auth helper ────────────────────────────────────────────────────────
|
||||
|
||||
@@ -465,7 +464,7 @@ async fn get_invitation(
|
||||
match invitation {
|
||||
Some(i) => {
|
||||
// Check expiry
|
||||
let expired = i.expires_at.as_ref().map_or(false, |e| {
|
||||
let expired = i.expires_at.as_ref().is_some_and(|e| {
|
||||
chrono::NaiveDateTime::parse_from_str(e.trim(), "%Y-%m-%d %H:%M:%S")
|
||||
.map(|dt| dt.and_utc().timestamp() < chrono::Utc::now().timestamp())
|
||||
.unwrap_or(false)
|
||||
@@ -525,7 +524,7 @@ async fn accept_invitation(
|
||||
};
|
||||
|
||||
// Check expiry
|
||||
let expired = inv.expires_at.as_ref().map_or(true, |e| {
|
||||
let expired = inv.expires_at.as_ref().is_none_or(|e| {
|
||||
chrono::NaiveDateTime::parse_from_str(e.trim(), "%Y-%m-%d %H:%M:%S")
|
||||
.map(|dt| dt.and_utc().timestamp() < chrono::Utc::now().timestamp())
|
||||
.unwrap_or(true)
|
||||
|
||||
@@ -29,6 +29,7 @@ use tracing::info;
|
||||
|
||||
// ── Auth types ────────────────────────────────────────────────────────
|
||||
|
||||
#[allow(dead_code)]
|
||||
#[derive(Clone)]
|
||||
pub struct ProxyAuth {
|
||||
pub token: String,
|
||||
@@ -36,6 +37,7 @@ pub struct ProxyAuth {
|
||||
pub source: DownstreamSource,
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
#[derive(Clone, PartialEq)]
|
||||
pub enum DownstreamSource {
|
||||
Managed { key_id: i64 },
|
||||
@@ -279,12 +281,12 @@ async fn check_auth(state: &AppState, headers: &HeaderMap) -> Result<(), AppErro
|
||||
.get("Authorization")
|
||||
.and_then(|v| v.to_str().ok())
|
||||
.and_then(|v| v.strip_prefix("Bearer "))
|
||||
.and_then(|v| v.trim().split_whitespace().next())
|
||||
.and_then(|v| v.split_whitespace().next())
|
||||
.ok_or_else(|| AppError::Unauthorized("Missing Authorization header".into()))?;
|
||||
|
||||
let result = authorize_downstream_token(auth, &state.db)
|
||||
let result = authorize_downstream_token(auth, &state.db, Some(&state.config.auth.proxy_token))
|
||||
.await
|
||||
.map_err(|e| AppError::Unauthorized(e))?;
|
||||
.map_err(AppError::Unauthorized)?;
|
||||
|
||||
match result {
|
||||
DownstreamAuthResult::Managed { key, .. } => {
|
||||
@@ -646,6 +648,7 @@ mod tests {
|
||||
max_retries: 3,
|
||||
proxy_log_retention_days: 7,
|
||||
},
|
||||
comfyui: Default::default(),
|
||||
},
|
||||
pool,
|
||||
)
|
||||
|
||||
@@ -5,7 +5,8 @@ use axum::{extract::State, routing::post, Json, Router};
|
||||
use serde::Deserialize;
|
||||
use serde_json::{json, Value};
|
||||
use std::collections::{HashMap, HashSet};
|
||||
use tracing::info;
|
||||
|
||||
type ModelAggregate = HashMap<String, (HashSet<i64>, HashSet<i64>, HashSet<i64>)>;
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct SearchBody {
|
||||
@@ -23,7 +24,7 @@ async fn search_handler(
|
||||
Json(body): Json<SearchBody>,
|
||||
) -> AppResult<Json<Value>> {
|
||||
let query = body.query.trim().to_string();
|
||||
let limit = body.limit.unwrap_or(20).max(1).min(100);
|
||||
let limit = body.limit.unwrap_or(20).clamp(1, 100);
|
||||
|
||||
if query.is_empty() {
|
||||
return Ok(Json(json!({
|
||||
@@ -33,7 +34,7 @@ async fn search_handler(
|
||||
}
|
||||
|
||||
let q = format!("%{}%", query);
|
||||
let per_category = (limit / 6).max(1).min(10);
|
||||
let per_category = (limit / 6).clamp(1, 10);
|
||||
|
||||
// 1. Search sites
|
||||
let sites: Vec<Value> = sqlx::query_as::<_, (i64, String, String, String)>(
|
||||
@@ -68,7 +69,7 @@ async fn search_handler(
|
||||
.fetch_all(&*state.db)
|
||||
.await?
|
||||
.into_iter()
|
||||
.map(|(id, username, status, extra_config, site_id, site_name, platform, access_token)| {
|
||||
.map(|(id, username, status, _extra_config, site_id, site_name, platform, access_token)| {
|
||||
let segment = if access_token.as_deref().unwrap_or("").trim().is_empty() { "apikey" } else { "session" };
|
||||
json!({
|
||||
"id": id, "username": username, "status": status,
|
||||
@@ -133,7 +134,7 @@ async fn search_handler(
|
||||
.fetch_all(&*state.db)
|
||||
.await?
|
||||
.into_iter()
|
||||
.map(|(id, model, status, http_status, created_at, acct_id, username)| {
|
||||
.map(|(id, model, status, http_status, created_at, _acct_id, _username)| {
|
||||
json!({"id": id, "modelRequested": model, "status": status, "httpStatus": http_status, "createdAt": created_at})
|
||||
})
|
||||
.collect();
|
||||
@@ -167,7 +168,7 @@ async fn search_handler(
|
||||
.await?;
|
||||
|
||||
// Aggregate models
|
||||
let mut model_agg: HashMap<String, (HashSet<i64>, HashSet<i64>, HashSet<i64>)> = HashMap::new();
|
||||
let mut model_agg: ModelAggregate = HashMap::new();
|
||||
for (model_name, _token_id, account_id, site_id) in token_model_rows {
|
||||
let entry = model_agg.entry(model_name).or_default();
|
||||
entry.0.insert(account_id);
|
||||
@@ -198,7 +199,7 @@ async fn search_handler(
|
||||
a["name"]
|
||||
.as_str()
|
||||
.unwrap_or("")
|
||||
.cmp(&b["name"].as_str().unwrap_or(""))
|
||||
.cmp(b["name"].as_str().unwrap_or(""))
|
||||
})
|
||||
});
|
||||
models.truncate(per_category as usize);
|
||||
|
||||
@@ -1,13 +1,9 @@
|
||||
// Site Announcements routes — upstream provider notifications per site
|
||||
|
||||
use crate::{
|
||||
db::models::site_announcement::SiteAnnouncement,
|
||||
error::{AppError, AppResult},
|
||||
AppState,
|
||||
};
|
||||
use crate::{db::models::site_announcement::SiteAnnouncement, error::AppResult, AppState};
|
||||
use axum::{
|
||||
extract::{Path, Query, State},
|
||||
routing::{delete, get, post},
|
||||
routing::{get, post},
|
||||
Json, Router,
|
||||
};
|
||||
use serde::Deserialize;
|
||||
@@ -81,7 +77,7 @@ async fn list_announcements(
|
||||
|
||||
// Apply status filter in-memory (matches Node.js behaviour)
|
||||
let filtered = if let Some(ref status) = q.status {
|
||||
let now = chrono::Utc::now().timestamp_millis() as i64;
|
||||
let now = chrono::Utc::now().timestamp_millis();
|
||||
let status = status.trim().to_lowercase();
|
||||
announcements
|
||||
.into_iter()
|
||||
@@ -90,14 +86,10 @@ async fn list_announcements(
|
||||
a.dismissed_at.is_some()
|
||||
} else if status == "expired" {
|
||||
a.dismissed_at.is_none()
|
||||
&& a.ends_at
|
||||
.as_ref()
|
||||
.map_or(false, |e| parse_dt_millis(e) < now)
|
||||
&& a.ends_at.as_ref().is_some_and(|e| parse_dt_millis(e) < now)
|
||||
} else if status == "active" {
|
||||
a.dismissed_at.is_none()
|
||||
&& a.ends_at
|
||||
.as_ref()
|
||||
.map_or(true, |e| parse_dt_millis(e) >= now)
|
||||
&& a.ends_at.as_ref().is_none_or(|e| parse_dt_millis(e) >= now)
|
||||
} else {
|
||||
true
|
||||
}
|
||||
|
||||
+217
-2
@@ -19,13 +19,190 @@ pub struct ModelBySiteQuery {
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct SiteDistributionQuery {
|
||||
days: Option<i64>,
|
||||
refresh: Option<String>,
|
||||
#[serde(rename = "refresh")]
|
||||
_refresh: Option<String>,
|
||||
}
|
||||
|
||||
pub fn routes() -> Router<AppState> {
|
||||
Router::new()
|
||||
.route("/api/stats/dashboard", get(dashboard))
|
||||
.route("/api/stats/proxy-logs", get(proxy_logs))
|
||||
.route("/api/stats/proxy-logs/{id}", get(proxy_log_detail))
|
||||
.route("/api/stats/proxy-debug/traces", get(proxy_debug_traces))
|
||||
.route(
|
||||
"/api/stats/proxy-debug/traces/{id}",
|
||||
get(proxy_debug_trace_detail),
|
||||
)
|
||||
.route("/api/stats/model-by-site", get(model_by_site))
|
||||
.route("/api/stats/site-distribution", get(site_distribution))
|
||||
.route("/api/stats/site-trend", get(site_trend))
|
||||
}
|
||||
|
||||
/// GET /api/stats/dashboard
|
||||
async fn dashboard(
|
||||
State(state): State<AppState>,
|
||||
Query(q): Query<std::collections::HashMap<String, String>>,
|
||||
) -> AppResult<Json<Value>> {
|
||||
let sites: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM sites")
|
||||
.fetch_one(&*state.db)
|
||||
.await?;
|
||||
let accounts: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM accounts")
|
||||
.fetch_one(&*state.db)
|
||||
.await?;
|
||||
let downstream_keys: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM downstream_api_keys")
|
||||
.fetch_one(&*state.db)
|
||||
.await?;
|
||||
let requests_24h: i64 = sqlx::query_scalar(
|
||||
"SELECT COUNT(*) FROM proxy_logs WHERE created_at >= datetime('now', '-1 day')",
|
||||
)
|
||||
.fetch_one(&*state.db)
|
||||
.await?;
|
||||
let success_24h: i64 = sqlx::query_scalar(
|
||||
"SELECT COUNT(*) FROM proxy_logs WHERE status = 'success' AND created_at >= datetime('now', '-1 day')",
|
||||
)
|
||||
.fetch_one(&*state.db)
|
||||
.await?;
|
||||
let total_cost_24h: f64 = sqlx::query_scalar(
|
||||
"SELECT COALESCE(SUM(estimated_cost), 0.0) FROM proxy_logs WHERE created_at >= datetime('now', '-1 day')",
|
||||
)
|
||||
.fetch_one(&*state.db)
|
||||
.await?;
|
||||
|
||||
let payload = json!({
|
||||
"generatedAt": chrono::Utc::now().to_rfc3339(),
|
||||
"summary": {
|
||||
"sites": sites,
|
||||
"accounts": accounts,
|
||||
"downstreamKeys": downstream_keys,
|
||||
"requests24h": requests_24h,
|
||||
"success24h": success_24h,
|
||||
"successRate24h": if requests_24h > 0 { success_24h as f64 / requests_24h as f64 } else { 0.0 },
|
||||
"cost24h": total_cost_24h,
|
||||
},
|
||||
"insights": [],
|
||||
"stats": {
|
||||
"totalSites": sites,
|
||||
"totalAccounts": accounts,
|
||||
"totalRequests": requests_24h,
|
||||
"successRequests": success_24h,
|
||||
"totalCost": total_cost_24h,
|
||||
},
|
||||
});
|
||||
|
||||
match q.get("view").map(String::as_str) {
|
||||
Some("summary") => Ok(Json(json!({
|
||||
"generatedAt": payload["generatedAt"],
|
||||
"summary": payload["summary"],
|
||||
"stats": payload["stats"],
|
||||
}))),
|
||||
Some("insights") => Ok(Json(json!({
|
||||
"generatedAt": payload["generatedAt"],
|
||||
"insights": payload["insights"],
|
||||
}))),
|
||||
_ => Ok(Json(payload)),
|
||||
}
|
||||
}
|
||||
|
||||
/// GET /api/stats/proxy-logs
|
||||
async fn proxy_logs(
|
||||
State(state): State<AppState>,
|
||||
Query(q): Query<std::collections::HashMap<String, String>>,
|
||||
) -> AppResult<Json<Value>> {
|
||||
let limit = q
|
||||
.get("limit")
|
||||
.or_else(|| q.get("pageSize"))
|
||||
.and_then(|v| v.parse::<i64>().ok())
|
||||
.unwrap_or(50)
|
||||
.clamp(1, 500);
|
||||
let offset = q
|
||||
.get("offset")
|
||||
.and_then(|v| v.parse::<i64>().ok())
|
||||
.unwrap_or(0)
|
||||
.max(0);
|
||||
|
||||
let items: Vec<Value> = sqlx::query_as::<_, ProxyLogRow>(
|
||||
"SELECT id, route_id, channel_id, account_id, downstream_api_key_id, model_requested, model_actual, status, http_status, latency_ms, first_byte_latency_ms, prompt_tokens, completion_tokens, total_tokens, estimated_cost, error_message, retry_count, is_stream, client_family, client_app_id, client_app_name, client_confidence, created_at FROM proxy_logs ORDER BY created_at DESC LIMIT ? OFFSET ?",
|
||||
)
|
||||
.bind(limit)
|
||||
.bind(offset)
|
||||
.fetch_all(&*state.db)
|
||||
.await?
|
||||
.into_iter()
|
||||
.map(|row| serde_json::to_value(row).unwrap_or_else(|_| json!({})))
|
||||
.collect();
|
||||
|
||||
let total: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM proxy_logs")
|
||||
.fetch_one(&*state.db)
|
||||
.await?;
|
||||
let success: i64 =
|
||||
sqlx::query_scalar("SELECT COUNT(*) FROM proxy_logs WHERE status = 'success'")
|
||||
.fetch_one(&*state.db)
|
||||
.await?;
|
||||
|
||||
Ok(Json(json!({
|
||||
"items": items,
|
||||
"data": items,
|
||||
"total": total,
|
||||
"page": (offset / limit) + 1,
|
||||
"pageSize": limit,
|
||||
"summary": {
|
||||
"total": total,
|
||||
"success": success,
|
||||
"failed": total - success,
|
||||
},
|
||||
"clientOptions": [],
|
||||
"sites": [],
|
||||
})))
|
||||
}
|
||||
|
||||
#[derive(Debug, serde::Serialize, sqlx::FromRow)]
|
||||
struct ProxyLogRow {
|
||||
id: i64,
|
||||
route_id: Option<i64>,
|
||||
channel_id: Option<i64>,
|
||||
account_id: Option<i64>,
|
||||
downstream_api_key_id: Option<i64>,
|
||||
model_requested: Option<String>,
|
||||
model_actual: Option<String>,
|
||||
status: Option<String>,
|
||||
http_status: Option<i64>,
|
||||
latency_ms: Option<i64>,
|
||||
first_byte_latency_ms: Option<i64>,
|
||||
prompt_tokens: Option<i64>,
|
||||
completion_tokens: Option<i64>,
|
||||
total_tokens: Option<i64>,
|
||||
estimated_cost: Option<f64>,
|
||||
error_message: Option<String>,
|
||||
retry_count: Option<i64>,
|
||||
is_stream: Option<bool>,
|
||||
client_family: Option<String>,
|
||||
client_app_id: Option<String>,
|
||||
client_app_name: Option<String>,
|
||||
client_confidence: Option<String>,
|
||||
created_at: Option<String>,
|
||||
}
|
||||
|
||||
async fn proxy_log_detail(
|
||||
State(state): State<AppState>,
|
||||
axum::extract::Path(id): axum::extract::Path<i64>,
|
||||
) -> AppResult<Json<Value>> {
|
||||
let item: Option<ProxyLogRow> = sqlx::query_as(
|
||||
"SELECT id, route_id, channel_id, account_id, downstream_api_key_id, model_requested, model_actual, status, http_status, latency_ms, first_byte_latency_ms, prompt_tokens, completion_tokens, total_tokens, estimated_cost, error_message, retry_count, is_stream, client_family, client_app_id, client_app_name, client_confidence, created_at FROM proxy_logs WHERE id = ?",
|
||||
)
|
||||
.bind(id)
|
||||
.fetch_optional(&*state.db)
|
||||
.await?;
|
||||
Ok(Json(json!({ "item": item, "data": item })))
|
||||
}
|
||||
|
||||
async fn proxy_debug_traces() -> Json<Value> {
|
||||
Json(json!({ "items": [], "data": [], "total": 0 }))
|
||||
}
|
||||
|
||||
async fn proxy_debug_trace_detail(
|
||||
axum::extract::Path(id): axum::extract::Path<i64>,
|
||||
) -> Json<Value> {
|
||||
Json(json!({ "id": id, "events": [], "data": null }))
|
||||
}
|
||||
|
||||
/// GET /api/stats/model-by-site?siteId=...&days=...
|
||||
@@ -117,7 +294,7 @@ async fn site_distribution(
|
||||
|
||||
// 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), COALESCE(SUM(m.total_tokens), 0)
|
||||
"SELECT s.id, s.name, COALESCE(SUM(m.total_calls), 0), COALESCE(SUM(m.total_spend), 0.0), COALESCE(SUM(m.total_tokens), 0)
|
||||
FROM sites s
|
||||
LEFT JOIN model_day_usage m ON m.site_id = s.id AND m.local_day >= ?
|
||||
GROUP BY s.id, s.name
|
||||
@@ -142,3 +319,41 @@ async fn site_distribution(
|
||||
|
||||
Ok(Json(json!({ "distribution": distribution })))
|
||||
}
|
||||
|
||||
/// GET /api/stats/site-trend?days=...
|
||||
async fn site_trend(
|
||||
State(state): State<AppState>,
|
||||
Query(q): Query<SiteDistributionQuery>,
|
||||
) -> AppResult<Json<Value>> {
|
||||
let days = q.days.unwrap_or(7).max(1);
|
||||
let since_day = chrono::Utc::now()
|
||||
.checked_sub_signed(chrono::Duration::days(days - 1))
|
||||
.unwrap()
|
||||
.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",
|
||||
)
|
||||
.bind(&since_day)
|
||||
.fetch_all(&*state.db)
|
||||
.await?;
|
||||
|
||||
let trend: Vec<Value> = rows
|
||||
.into_iter()
|
||||
.map(|(day, calls, spend, tokens)| {
|
||||
json!({
|
||||
"date": day,
|
||||
"calls": calls,
|
||||
"spend": spend,
|
||||
"tokens": tokens,
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
|
||||
Ok(Json(json!({ "trend": trend })))
|
||||
}
|
||||
|
||||
+2
-3
@@ -12,10 +12,9 @@ use axum::{
|
||||
routing::get,
|
||||
Json, Router,
|
||||
};
|
||||
use futures::stream::{self, Stream};
|
||||
use futures::stream::Stream;
|
||||
use serde::Deserialize;
|
||||
use serde_json::{json, Value};
|
||||
use tokio_stream::StreamExt;
|
||||
use tracing::info;
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
@@ -38,7 +37,7 @@ async fn list_tasks(
|
||||
State(state): State<AppState>,
|
||||
Query(q): Query<ListTasksQuery>,
|
||||
) -> AppResult<Json<Value>> {
|
||||
let limit = q.limit.unwrap_or(50).max(1).min(500);
|
||||
let limit = q.limit.unwrap_or(50).clamp(1, 500);
|
||||
info!("Listing background tasks, limit={}", limit);
|
||||
|
||||
let tasks: Vec<BackgroundTask> = sqlx::query_as::<_, BackgroundTask>(
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
// Update Center routes — deploy/rollback orchestration via helper API
|
||||
|
||||
#![allow(clippy::type_complexity)]
|
||||
|
||||
use crate::{
|
||||
error::{AppError, AppResult},
|
||||
AppState,
|
||||
|
||||
@@ -10,10 +10,9 @@ use crate::{
|
||||
use axum::{
|
||||
extract::{Path, State},
|
||||
http::{HeaderMap, StatusCode},
|
||||
routing::{delete, get, patch, post},
|
||||
routing::{get, patch},
|
||||
Json, Router,
|
||||
};
|
||||
use serde::Deserialize;
|
||||
use serde_json::{json, Value};
|
||||
use tracing::info;
|
||||
|
||||
|
||||
+137
-31
@@ -2,11 +2,16 @@ use axum::{
|
||||
body::Body,
|
||||
extract::State,
|
||||
http::{header, HeaderMap, Response, StatusCode},
|
||||
response::IntoResponse,
|
||||
routing::post,
|
||||
response::{
|
||||
sse::{Event, Sse},
|
||||
IntoResponse,
|
||||
},
|
||||
routing::{get, post},
|
||||
Json, Router,
|
||||
};
|
||||
use futures::stream;
|
||||
use serde_json::{json, Value};
|
||||
use std::convert::Infallible;
|
||||
use tracing::info;
|
||||
|
||||
use crate::{
|
||||
@@ -66,6 +71,11 @@ const SCENE_PROMPT_GEN: &str = r#"根据场景描述生成图像生成提示词
|
||||
|
||||
pub fn routes() -> Router<AppState> {
|
||||
Router::new()
|
||||
.route("/api/video-agent/status", get(status))
|
||||
.route("/api/video-agent/start", post(start_comfyui))
|
||||
.route("/api/video-agent/stop", post(stop_comfyui))
|
||||
.route("/api/video-agent/restart", post(restart_comfyui))
|
||||
.route("/api/video-agent/chat", post(chat))
|
||||
.route("/api/video-agent/analyze", post(analyze))
|
||||
.route(
|
||||
"/api/video-agent/character-prompts",
|
||||
@@ -81,15 +91,100 @@ pub fn routes() -> Router<AppState> {
|
||||
.route("/api/video-agent/full-workflow", post(full_workflow))
|
||||
}
|
||||
|
||||
fn upstream_base() -> String {
|
||||
std::env::var("BOOSAPI_UPSTREAM_BASE")
|
||||
.unwrap_or_else(|_| "http://127.0.0.1:4000/v1".to_string())
|
||||
.trim_end_matches('/')
|
||||
.to_string()
|
||||
async fn status(State(state): State<AppState>, headers: HeaderMap) -> AppResult<Json<Value>> {
|
||||
check_auth(&headers, &state)?;
|
||||
Ok(Json(
|
||||
serde_json::to_value(state.comfyui.status_response().await)
|
||||
.unwrap_or_else(|_| json!({ "status": "unknown" })),
|
||||
))
|
||||
}
|
||||
|
||||
fn upstream_key() -> String {
|
||||
std::env::var("BOOSAPI_UPSTREAM_KEY").unwrap_or_else(|_| "sk-boos4721".to_string())
|
||||
async fn start_comfyui(
|
||||
State(state): State<AppState>,
|
||||
headers: HeaderMap,
|
||||
) -> AppResult<Json<Value>> {
|
||||
check_auth(&headers, &state)?;
|
||||
state
|
||||
.comfyui
|
||||
.start()
|
||||
.await
|
||||
.map_err(|err| AppError::Internal(anyhow::anyhow!(err)))?;
|
||||
Ok(Json(json!({
|
||||
"success": true,
|
||||
"comfyui": state.comfyui.status_response().await
|
||||
})))
|
||||
}
|
||||
|
||||
async fn stop_comfyui(State(state): State<AppState>, headers: HeaderMap) -> AppResult<Json<Value>> {
|
||||
check_auth(&headers, &state)?;
|
||||
state
|
||||
.comfyui
|
||||
.stop()
|
||||
.await
|
||||
.map_err(|err| AppError::Internal(anyhow::anyhow!(err)))?;
|
||||
Ok(Json(json!({
|
||||
"success": true,
|
||||
"comfyui": state.comfyui.status_response().await
|
||||
})))
|
||||
}
|
||||
|
||||
async fn restart_comfyui(
|
||||
State(state): State<AppState>,
|
||||
headers: HeaderMap,
|
||||
) -> AppResult<Json<Value>> {
|
||||
check_auth(&headers, &state)?;
|
||||
state
|
||||
.comfyui
|
||||
.restart()
|
||||
.await
|
||||
.map_err(|err| AppError::Internal(anyhow::anyhow!(err)))?;
|
||||
Ok(Json(json!({
|
||||
"success": true,
|
||||
"comfyui": state.comfyui.status_response().await
|
||||
})))
|
||||
}
|
||||
|
||||
async fn chat(
|
||||
State(state): State<AppState>,
|
||||
headers: HeaderMap,
|
||||
Json(body): Json<Value>,
|
||||
) -> AppResult<Sse<impl futures::Stream<Item = Result<Event, Infallible>>>> {
|
||||
check_auth(&headers, &state)?;
|
||||
|
||||
let prompt = body
|
||||
.get("message")
|
||||
.and_then(Value::as_str)
|
||||
.or_else(|| {
|
||||
body.get("messages")
|
||||
.and_then(Value::as_array)
|
||||
.and_then(|messages| messages.last())
|
||||
.and_then(|message| message.get("content"))
|
||||
.and_then(Value::as_str)
|
||||
})
|
||||
.unwrap_or("生成一个短视频工作流");
|
||||
|
||||
let workflow = build_basic_workflow(prompt);
|
||||
let events = vec![
|
||||
json!({
|
||||
"type": "text",
|
||||
"content": "已收到需求,正在生成 ComfyUI workflow..."
|
||||
}),
|
||||
json!({
|
||||
"type": "workflow_generated",
|
||||
"workflow": workflow
|
||||
}),
|
||||
json!({
|
||||
"type": "done"
|
||||
}),
|
||||
];
|
||||
|
||||
Ok(Sse::new(stream::iter(events.into_iter().map(|event| {
|
||||
Ok(Event::default().data(event.to_string()))
|
||||
}))))
|
||||
}
|
||||
|
||||
fn local_api_base(state: &AppState) -> String {
|
||||
state.comfyui.model_backend_url()
|
||||
}
|
||||
|
||||
fn check_auth(headers: &HeaderMap, state: &AppState) -> AppResult<()> {
|
||||
@@ -107,6 +202,17 @@ fn check_auth(headers: &HeaderMap, state: &AppState) -> AppResult<()> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn build_basic_workflow(prompt: &str) -> Value {
|
||||
json!({
|
||||
"boosapi": {
|
||||
"kind": "video-agent-workflow",
|
||||
"version": 1,
|
||||
"prompt": prompt,
|
||||
},
|
||||
"nodes": {},
|
||||
})
|
||||
}
|
||||
|
||||
fn required_str<'a>(body: &'a Value, key: &str) -> AppResult<&'a str> {
|
||||
body.get(key)
|
||||
.and_then(Value::as_str)
|
||||
@@ -147,17 +253,17 @@ fn parse_llm_json(raw: &str) -> AppResult<Value> {
|
||||
})
|
||||
}
|
||||
|
||||
async fn call_llm(prompt: &str, system: &str) -> AppResult<String> {
|
||||
async fn call_llm(state: &AppState, prompt: &str, system: &str) -> AppResult<String> {
|
||||
let client = reqwest::Client::new();
|
||||
let url = format!("{}/chat/completions", upstream_base());
|
||||
let url = format!("{}/chat/completions", local_api_base(state));
|
||||
|
||||
info!("video-agent calling LLM upstream: {}", url);
|
||||
info!("video-agent calling BoosAPI LLM endpoint: {}", url);
|
||||
|
||||
let resp = client
|
||||
.post(url)
|
||||
.bearer_auth(upstream_key())
|
||||
.bearer_auth(&state.config.auth.proxy_token)
|
||||
.json(&json!({
|
||||
"model": "gpt-5.5",
|
||||
"model": &state.config.comfyui.agent_model,
|
||||
"messages": [
|
||||
{ "role": "system", "content": system },
|
||||
{ "role": "user", "content": prompt }
|
||||
@@ -193,15 +299,15 @@ async fn call_llm(prompt: &str, system: &str) -> AppResult<String> {
|
||||
.to_string())
|
||||
}
|
||||
|
||||
async fn call_image_gen(prompt: &str) -> AppResult<String> {
|
||||
async fn call_image_gen(state: &AppState, prompt: &str) -> AppResult<String> {
|
||||
let client = reqwest::Client::new();
|
||||
let url = format!("{}/images/generations", upstream_base());
|
||||
let url = format!("{}/images/generations", local_api_base(state));
|
||||
|
||||
info!("video-agent calling image upstream: {}", url);
|
||||
info!("video-agent calling BoosAPI image endpoint: {}", url);
|
||||
|
||||
let resp = client
|
||||
.post(url)
|
||||
.bearer_auth(upstream_key())
|
||||
.bearer_auth(&state.config.auth.proxy_token)
|
||||
.json(&json!({
|
||||
"model": "gpt-image-2",
|
||||
"prompt": prompt,
|
||||
@@ -236,15 +342,15 @@ async fn call_image_gen(prompt: &str) -> AppResult<String> {
|
||||
.to_string())
|
||||
}
|
||||
|
||||
async fn call_tts(text: &str, voice: &str) -> AppResult<Vec<u8>> {
|
||||
async fn call_tts(state: &AppState, text: &str, voice: &str) -> AppResult<Vec<u8>> {
|
||||
let client = reqwest::Client::new();
|
||||
let url = format!("{}/audio/speech", upstream_base());
|
||||
let url = format!("{}/audio/speech", local_api_base(state));
|
||||
|
||||
info!("video-agent calling TTS upstream: {}", url);
|
||||
info!("video-agent calling BoosAPI TTS endpoint: {}", url);
|
||||
|
||||
let resp = client
|
||||
.post(url)
|
||||
.bearer_auth(upstream_key())
|
||||
.bearer_auth(&state.config.auth.proxy_token)
|
||||
.json(&json!({
|
||||
"model": "tts-1",
|
||||
"input": text,
|
||||
@@ -282,7 +388,7 @@ async fn analyze(
|
||||
|
||||
let script = required_str(&body, "script")?;
|
||||
|
||||
let result = call_llm(script, SCRIPT_ANALYSIS_PROMPT).await?;
|
||||
let result = call_llm(&state, script, SCRIPT_ANALYSIS_PROMPT).await?;
|
||||
let parsed = parse_llm_json(&result)?;
|
||||
|
||||
Ok(Json(json!({
|
||||
@@ -318,7 +424,7 @@ async fn character_prompts(
|
||||
.unwrap_or_default();
|
||||
|
||||
let desc = format!("{name}: {appearance}, {gender}, {age}");
|
||||
let result = call_llm(&desc, CHARACTER_PROMPT_GEN).await?;
|
||||
let result = call_llm(&state, &desc, CHARACTER_PROMPT_GEN).await?;
|
||||
let prompts = parse_llm_json(&result)?;
|
||||
|
||||
Ok(Json(json!({
|
||||
@@ -346,7 +452,7 @@ async fn generate_character(
|
||||
AppError::BadRequest(format!("prompt for angle '{angle}' must be a string"))
|
||||
})?;
|
||||
|
||||
let url = call_image_gen(prompt).await?;
|
||||
let url = call_image_gen(&state, prompt).await?;
|
||||
results.insert(angle.clone(), Value::String(url));
|
||||
}
|
||||
|
||||
@@ -375,7 +481,7 @@ async fn scene_prompt(
|
||||
.unwrap_or_default();
|
||||
|
||||
let desc = format!("{environment}. {description}");
|
||||
let result = call_llm(&desc, SCENE_PROMPT_GEN).await?;
|
||||
let result = call_llm(&state, &desc, SCENE_PROMPT_GEN).await?;
|
||||
let prompt = parse_llm_json(&result)?;
|
||||
|
||||
Ok(Json(json!({
|
||||
@@ -392,7 +498,7 @@ async fn generate_scene(
|
||||
check_auth(&headers, &state)?;
|
||||
|
||||
let prompt = required_str(&body, "prompt")?;
|
||||
let url = call_image_gen(prompt).await?;
|
||||
let url = call_image_gen(&state, prompt).await?;
|
||||
|
||||
Ok(Json(json!({
|
||||
"success": true,
|
||||
@@ -416,7 +522,7 @@ async fn generate_tts(
|
||||
.filter(|s| !s.trim().is_empty())
|
||||
.unwrap_or("alloy");
|
||||
|
||||
let audio = call_tts(text, voice).await?;
|
||||
let audio = call_tts(&state, text, voice).await?;
|
||||
|
||||
Ok((
|
||||
StatusCode::OK,
|
||||
@@ -436,7 +542,7 @@ async fn full_workflow(
|
||||
let script = required_str(&body, "script")?;
|
||||
let requested_style = body.get("style").and_then(Value::as_str);
|
||||
|
||||
let analysis_raw = call_llm(script, SCRIPT_ANALYSIS_PROMPT).await?;
|
||||
let analysis_raw = call_llm(&state, script, SCRIPT_ANALYSIS_PROMPT).await?;
|
||||
let analysis = parse_llm_json(&analysis_raw)?;
|
||||
|
||||
let mut characters = Vec::new();
|
||||
@@ -461,7 +567,7 @@ async fn full_workflow(
|
||||
.unwrap_or_default();
|
||||
|
||||
let desc = format!("{name}: {appearance}, {gender}, {age}");
|
||||
let prompts_raw = call_llm(&desc, CHARACTER_PROMPT_GEN).await?;
|
||||
let prompts_raw = call_llm(&state, &desc, CHARACTER_PROMPT_GEN).await?;
|
||||
let prompts = parse_llm_json(&prompts_raw)?;
|
||||
|
||||
let mut item = character.clone();
|
||||
@@ -487,7 +593,7 @@ async fn full_workflow(
|
||||
.unwrap_or_default();
|
||||
|
||||
let desc = format!("{environment}. {description}");
|
||||
let scene_prompt_raw = call_llm(&desc, SCENE_PROMPT_GEN).await?;
|
||||
let scene_prompt_raw = call_llm(&state, &desc, SCENE_PROMPT_GEN).await?;
|
||||
let image_prompt = parse_llm_json(&scene_prompt_raw)?;
|
||||
|
||||
let mut item = scene.clone();
|
||||
|
||||
Reference in New Issue
Block a user