feat: complete Rust backend UI compatibility

This commit is contained in:
boos
2026-05-18 16:52:54 +08:00
parent f2434e725d
commit f986fe0377
41 changed files with 3585 additions and 811 deletions
+682
View File
@@ -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" }))
}
+3 -1
View File
@@ -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
+12
View File
@@ -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
View File
@@ -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(())
}