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(())
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user