feat: complete Rust backend UI compatibility
This commit is contained in:
+21
-20
@@ -8,7 +8,7 @@ use crate::db::models::downstream_api_key::DownstreamApiKey;
|
|||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone)]
|
||||||
pub enum DownstreamAuthResult {
|
pub enum DownstreamAuthResult {
|
||||||
/// Found in downstream_api_keys table.
|
/// Found in downstream_api_keys table.
|
||||||
Managed { key: DownstreamApiKey },
|
Managed { key: Box<DownstreamApiKey> },
|
||||||
/// Matched the global proxy token from config.
|
/// Matched the global proxy token from config.
|
||||||
Global { token: String },
|
Global { token: String },
|
||||||
}
|
}
|
||||||
@@ -23,6 +23,15 @@ pub async fn authorize_downstream_token(
|
|||||||
pool: &SqlitePool,
|
pool: &SqlitePool,
|
||||||
global_proxy_token: Option<&str>,
|
global_proxy_token: Option<&str>,
|
||||||
) -> Result<DownstreamAuthResult, String> {
|
) -> Result<DownstreamAuthResult, String> {
|
||||||
|
if global_proxy_token
|
||||||
|
.map(|global| !global.is_empty() && token == global)
|
||||||
|
.unwrap_or(false)
|
||||||
|
{
|
||||||
|
return Ok(DownstreamAuthResult::Global {
|
||||||
|
token: token.to_string(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
// Try managed keys first
|
// Try managed keys first
|
||||||
if token.starts_with("sk-") {
|
if token.starts_with("sk-") {
|
||||||
let key: Option<DownstreamApiKey> = sqlx::query_as::<_, DownstreamApiKey>(
|
let key: Option<DownstreamApiKey> = sqlx::query_as::<_, DownstreamApiKey>(
|
||||||
@@ -34,30 +43,12 @@ pub async fn authorize_downstream_token(
|
|||||||
.map_err(|e| format!("Database error: {e}"))?;
|
.map_err(|e| format!("Database error: {e}"))?;
|
||||||
|
|
||||||
if let Some(k) = key {
|
if let Some(k) = key {
|
||||||
return Ok(DownstreamAuthResult::Managed { key: k });
|
return Ok(DownstreamAuthResult::Managed { key: Box::new(k) });
|
||||||
}
|
|
||||||
|
|
||||||
if global_proxy_token
|
|
||||||
.map(|global| !global.is_empty() && token == global)
|
|
||||||
.unwrap_or(false)
|
|
||||||
{
|
|
||||||
return Ok(DownstreamAuthResult::Global {
|
|
||||||
token: token.to_string(),
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return Err("Invalid API key".to_string());
|
return Err("Invalid API key".to_string());
|
||||||
}
|
}
|
||||||
|
|
||||||
if global_proxy_token
|
|
||||||
.map(|global| !global.is_empty() && token == global)
|
|
||||||
.unwrap_or(false)
|
|
||||||
{
|
|
||||||
return Ok(DownstreamAuthResult::Global {
|
|
||||||
token: token.to_string(),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
Err("Invalid API key format".to_string())
|
Err("Invalid API key format".to_string())
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -118,4 +109,14 @@ mod tests {
|
|||||||
assert!(verify_api_key(key, &hash1).unwrap());
|
assert!(verify_api_key(key, &hash1).unwrap());
|
||||||
assert!(verify_api_key(key, &hash2).unwrap());
|
assert!(verify_api_key(key, &hash2).unwrap());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_global_proxy_token_authorizes_without_db_lookup() {
|
||||||
|
let pool = SqlitePool::connect("sqlite::memory:").await.unwrap();
|
||||||
|
let result = authorize_downstream_token("sk-global", &pool, Some("sk-global"))
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
assert!(matches!(result, DownstreamAuthResult::Global { .. }));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+46
-1
@@ -3,6 +3,7 @@ use figment::{
|
|||||||
Figment,
|
Figment,
|
||||||
};
|
};
|
||||||
use serde::Deserialize;
|
use serde::Deserialize;
|
||||||
|
use std::path::PathBuf;
|
||||||
|
|
||||||
#[derive(Debug, Deserialize, Clone)]
|
#[derive(Debug, Deserialize, Clone)]
|
||||||
pub struct AppConfig {
|
pub struct AppConfig {
|
||||||
@@ -10,6 +11,8 @@ pub struct AppConfig {
|
|||||||
pub db: DbConfig,
|
pub db: DbConfig,
|
||||||
pub auth: AuthConfig,
|
pub auth: AuthConfig,
|
||||||
pub proxy: ProxyConfig,
|
pub proxy: ProxyConfig,
|
||||||
|
#[serde(default)]
|
||||||
|
pub comfyui: ComfyUIConfig,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Deserialize, Clone)]
|
#[derive(Debug, Deserialize, Clone)]
|
||||||
@@ -46,11 +49,53 @@ pub struct ProxyConfig {
|
|||||||
pub proxy_log_retention_days: u32,
|
pub proxy_log_retention_days: u32,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize, Clone)]
|
||||||
|
pub struct ComfyUIConfig {
|
||||||
|
pub enabled: bool,
|
||||||
|
pub install_dir: Option<PathBuf>,
|
||||||
|
pub python_bin: String,
|
||||||
|
pub port: u16,
|
||||||
|
pub extra_args: Vec<String>,
|
||||||
|
pub max_restarts: u32,
|
||||||
|
pub external_url: Option<String>,
|
||||||
|
pub base_url: Option<String>,
|
||||||
|
pub agent_model: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for ComfyUIConfig {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self {
|
||||||
|
enabled: false,
|
||||||
|
install_dir: None,
|
||||||
|
python_bin: "python3".to_string(),
|
||||||
|
port: 8188,
|
||||||
|
extra_args: Vec::new(),
|
||||||
|
max_restarts: 5,
|
||||||
|
external_url: None,
|
||||||
|
base_url: Some("http://127.0.0.1:8188".to_string()),
|
||||||
|
agent_model: "gpt-4o".to_string(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ComfyUIConfig {
|
||||||
|
pub fn target_base_url(&self) -> String {
|
||||||
|
self.external_url
|
||||||
|
.as_deref()
|
||||||
|
.or(self.base_url.as_deref())
|
||||||
|
.map(str::trim)
|
||||||
|
.filter(|url| !url.is_empty())
|
||||||
|
.unwrap_or("http://127.0.0.1:8188")
|
||||||
|
.trim_end_matches('/')
|
||||||
|
.to_string()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
impl AppConfig {
|
impl AppConfig {
|
||||||
pub fn load() -> anyhow::Result<Self> {
|
pub fn load() -> anyhow::Result<Self> {
|
||||||
dotenvy::dotenv().ok();
|
dotenvy::dotenv().ok();
|
||||||
Ok(Figment::new()
|
Ok(Figment::new()
|
||||||
.merge(Toml::file("config.toml").nested())
|
.merge(Toml::file("config.toml"))
|
||||||
.merge(Env::prefixed("BOOSAPI_").split("__"))
|
.merge(Env::prefixed("BOOSAPI_").split("__"))
|
||||||
.extract()?)
|
.extract()?)
|
||||||
}
|
}
|
||||||
|
|||||||
+14
@@ -15,13 +15,27 @@ use std::sync::Arc;
|
|||||||
pub struct AppState {
|
pub struct AppState {
|
||||||
pub config: config::AppConfig,
|
pub config: config::AppConfig,
|
||||||
pub db: Arc<SqlitePool>,
|
pub db: Arc<SqlitePool>,
|
||||||
|
pub comfyui: Arc<services::comfyui_process::ComfyUIProcess>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl AppState {
|
impl AppState {
|
||||||
pub fn new(config: config::AppConfig, db: SqlitePool) -> Self {
|
pub fn new(config: config::AppConfig, db: SqlitePool) -> Self {
|
||||||
|
let boosapi_host = if config.server.host == "0.0.0.0" || config.server.host == "::" {
|
||||||
|
"127.0.0.1".to_string()
|
||||||
|
} else {
|
||||||
|
config.server.host.clone()
|
||||||
|
};
|
||||||
|
let boosapi_url = format!("http://{}:{}", boosapi_host, config.server.port);
|
||||||
|
let comfyui = services::comfyui_process::ComfyUIProcess::new(
|
||||||
|
config.comfyui.clone(),
|
||||||
|
boosapi_url,
|
||||||
|
config.auth.proxy_token.clone(),
|
||||||
|
);
|
||||||
|
|
||||||
Self {
|
Self {
|
||||||
config,
|
config,
|
||||||
db: Arc::new(db),
|
db: Arc::new(db),
|
||||||
|
comfyui,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -25,6 +25,10 @@ async fn main() -> anyhow::Result<()> {
|
|||||||
|
|
||||||
// Build app state
|
// Build app state
|
||||||
let state = AppState::new(config.clone(), pool);
|
let state = AppState::new(config.clone(), pool);
|
||||||
|
if let Err(err) = state.comfyui.start().await {
|
||||||
|
tracing::warn!(%err, "ComfyUI embedded process did not start");
|
||||||
|
}
|
||||||
|
let comfyui_process = state.comfyui.clone();
|
||||||
|
|
||||||
// Build router
|
// Build router
|
||||||
let app = boosapi::routes::build_router(state);
|
let app = boosapi::routes::build_router(state);
|
||||||
@@ -40,6 +44,10 @@ async fn main() -> anyhow::Result<()> {
|
|||||||
.with_graceful_shutdown(shutdown_signal())
|
.with_graceful_shutdown(shutdown_signal())
|
||||||
.await?;
|
.await?;
|
||||||
|
|
||||||
|
if let Err(err) = comfyui_process.stop().await {
|
||||||
|
tracing::warn!(%err, "ComfyUI embedded process did not stop cleanly");
|
||||||
|
}
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
#![allow(clippy::type_complexity)]
|
||||||
|
|
||||||
use super::retry_policy::classify_failure;
|
use super::retry_policy::classify_failure;
|
||||||
use super::types::{
|
use super::types::{
|
||||||
AttemptFailure, AttemptFailureAction, AttemptResult, ExecuteAttemptContext, ExecuteInput,
|
AttemptFailure, AttemptFailureAction, AttemptResult, ExecuteAttemptContext, ExecuteInput,
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
#![allow(clippy::module_inception, clippy::type_complexity)]
|
||||||
|
|
||||||
pub mod conductor;
|
pub mod conductor;
|
||||||
pub mod retry_policy;
|
pub mod retry_policy;
|
||||||
pub mod types;
|
pub mod types;
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
#![allow(clippy::type_complexity)]
|
||||||
|
|
||||||
use futures::future::BoxFuture;
|
use futures::future::BoxFuture;
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use serde_json::Value;
|
use serde_json::Value;
|
||||||
|
|||||||
@@ -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)
|
.fetch_all(&*state.db)
|
||||||
.await?;
|
.await?;
|
||||||
|
|
||||||
Ok(Json(json!({ "data": keys })))
|
Ok(Json(
|
||||||
|
json!({ "success": true, "items": keys, "data": keys }),
|
||||||
|
))
|
||||||
}
|
}
|
||||||
|
|
||||||
/// POST /api/downstream-api-keys
|
/// POST /api/downstream-api-keys
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ use tracing::info;
|
|||||||
|
|
||||||
mod account_tokens;
|
mod account_tokens;
|
||||||
mod accounts;
|
mod accounts;
|
||||||
|
mod compat;
|
||||||
mod downstream_keys;
|
mod downstream_keys;
|
||||||
mod sites;
|
mod sites;
|
||||||
mod tokens;
|
mod tokens;
|
||||||
@@ -89,10 +90,19 @@ pub fn routes(state: AppState) -> Router<AppState> {
|
|||||||
"/api/downstream-api-keys",
|
"/api/downstream-api-keys",
|
||||||
get(downstream_keys::list_downstream_keys).post(downstream_keys::create_downstream_key),
|
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(
|
.route(
|
||||||
"/api/downstream-api-keys/{id}",
|
"/api/downstream-api-keys/{id}",
|
||||||
put(downstream_keys::update_downstream_key)
|
put(downstream_keys::update_downstream_key)
|
||||||
.delete(downstream_keys::delete_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 =
|
let settings_routes =
|
||||||
@@ -107,6 +117,7 @@ pub fn routes(state: AppState) -> Router<AppState> {
|
|||||||
"/api/admin/users/{id}",
|
"/api/admin/users/{id}",
|
||||||
get(users::get_user)
|
get(users::get_user)
|
||||||
.put(users::update_user)
|
.put(users::update_user)
|
||||||
|
.patch(users::update_user)
|
||||||
.delete(users::delete_user),
|
.delete(users::delete_user),
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -118,6 +129,7 @@ pub fn routes(state: AppState) -> Router<AppState> {
|
|||||||
.merge(settings_routes)
|
.merge(settings_routes)
|
||||||
.merge(users_routes)
|
.merge(users_routes)
|
||||||
.merge(account_tokens::routes())
|
.merge(account_tokens::routes())
|
||||||
|
.merge(compat::routes())
|
||||||
.route_layer(middleware::from_fn_with_state(state, require_admin_auth))
|
.route_layer(middleware::from_fn_with_state(state, require_admin_auth))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+165
-9
@@ -7,7 +7,7 @@ use crate::{
|
|||||||
AppState,
|
AppState,
|
||||||
};
|
};
|
||||||
use axum::{
|
use axum::{
|
||||||
extract::{Path, State},
|
extract::{Path, Query, State},
|
||||||
Json,
|
Json,
|
||||||
};
|
};
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
@@ -31,6 +31,15 @@ pub struct UpdateUserRequest {
|
|||||||
pub status: Option<String>,
|
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)]
|
#[derive(Debug, Serialize)]
|
||||||
pub struct UserResponse {
|
pub struct UserResponse {
|
||||||
pub id: i64,
|
pub id: i64,
|
||||||
@@ -57,13 +66,101 @@ impl From<User> for UserResponse {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// GET /api/admin/users
|
/// 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");
|
info!("Listing admin users");
|
||||||
let users: Vec<User> = sqlx::query_as::<_, User>("SELECT * FROM users ORDER BY id")
|
|
||||||
|
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)
|
.fetch_all(&*state.db)
|
||||||
.await?;
|
.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();
|
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}
|
/// 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)
|
let password_hash = hash_password(&payload.password)
|
||||||
.map_err(|e| AppError::Internal(anyhow::anyhow!("Failed to hash password: {e}")))?;
|
.map_err(|e| AppError::Internal(anyhow::anyhow!("Failed to hash password: {e}")))?;
|
||||||
let role = payload.role.unwrap_or_else(|| "user".to_string());
|
|
||||||
|
|
||||||
let result =
|
let result =
|
||||||
sqlx::query("INSERT INTO users (username, email, password_hash, role) VALUES (?, ?, ?, ?)")
|
sqlx::query("INSERT INTO users (username, email, password_hash, role) VALUES (?, ?, ?, ?)")
|
||||||
.bind(&payload.username)
|
.bind(payload.username.trim())
|
||||||
.bind(&payload.email)
|
.bind(payload.email.trim())
|
||||||
.bind(&password_hash)
|
.bind(&password_hash)
|
||||||
.bind(&role)
|
.bind(&role)
|
||||||
.execute(&*state.db)
|
.execute(&*state.db)
|
||||||
@@ -137,6 +237,24 @@ pub async fn update_user(
|
|||||||
.await?
|
.await?
|
||||||
.ok_or_else(|| AppError::NotFound(format!("User {} not found", id)))?;
|
.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 {
|
let password_hash = if let Some(ref pw) = payload.password {
|
||||||
if pw.len() < 6 {
|
if pw.len() < 6 {
|
||||||
return Err(AppError::BadRequest(
|
return Err(AppError::BadRequest(
|
||||||
@@ -154,8 +272,8 @@ pub async fn update_user(
|
|||||||
sqlx::query(
|
sqlx::query(
|
||||||
"UPDATE users SET username = ?, email = ?, role = ?, status = ?, password_hash = COALESCE(?, password_hash), updated_at = datetime('now') WHERE id = ?"
|
"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.username.map(|v| v.trim().to_string()).unwrap_or(existing.username))
|
||||||
.bind(payload.email.unwrap_or(existing.email))
|
.bind(payload.email.map(|v| v.trim().to_string()).unwrap_or(existing.email))
|
||||||
.bind(payload.role.unwrap_or(existing.role))
|
.bind(payload.role.unwrap_or(existing.role))
|
||||||
.bind(payload.status.unwrap_or(existing.status))
|
.bind(payload.status.unwrap_or(existing.status))
|
||||||
.bind(password_hash)
|
.bind(password_hash)
|
||||||
@@ -189,3 +307,41 @@ pub async fn delete_user(
|
|||||||
|
|
||||||
Ok(Json(json!({ "data": { "deleted": true, "id": id } })))
|
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::{
|
use axum::{
|
||||||
extract::{Path, State},
|
extract::{Path, State},
|
||||||
http::HeaderMap,
|
http::HeaderMap,
|
||||||
routing::{get, patch, post},
|
routing::{get, post},
|
||||||
Json, Router,
|
Json, Router,
|
||||||
};
|
};
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
@@ -32,12 +32,14 @@ pub struct LoginRequest {
|
|||||||
pub password: String,
|
pub password: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[allow(dead_code)]
|
||||||
#[derive(Debug, Serialize)]
|
#[derive(Debug, Serialize)]
|
||||||
pub struct AuthResponse {
|
pub struct AuthResponse {
|
||||||
pub token: String,
|
pub token: String,
|
||||||
pub user: UserProfile,
|
pub user: UserProfile,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[allow(dead_code)]
|
||||||
#[derive(Debug, Serialize)]
|
#[derive(Debug, Serialize)]
|
||||||
pub struct UserProfile {
|
pub struct UserProfile {
|
||||||
pub id: i64,
|
pub id: i64,
|
||||||
@@ -51,10 +53,11 @@ pub struct UserProfile {
|
|||||||
pub fn routes() -> Router<AppState> {
|
pub fn routes() -> Router<AppState> {
|
||||||
Router::new()
|
Router::new()
|
||||||
.route("/api/auth/register", post(register))
|
.route("/api/auth/register", post(register))
|
||||||
|
.route("/api/users/register", post(register))
|
||||||
.route("/api/auth/login", post(login))
|
.route("/api/auth/login", post(login))
|
||||||
.route("/api/users/login", post(login))
|
.route("/api/users/login", post(login))
|
||||||
.route("/api/auth/me", get(me))
|
.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/me/password", post(change_password))
|
||||||
.route("/api/users/{id}", get(get_user))
|
.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}")))?;
|
.map_err(|e| AppError::Internal(anyhow::anyhow!("Failed to generate JWT: {e}")))?;
|
||||||
|
|
||||||
Ok(Json(json!({
|
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": {
|
"data": {
|
||||||
"token": token,
|
"token": token,
|
||||||
"user": {
|
"user": {
|
||||||
@@ -122,6 +134,8 @@ async fn register(
|
|||||||
"username": user.username,
|
"username": user.username,
|
||||||
"email": user.email,
|
"email": user.email,
|
||||||
"role": user.role,
|
"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}")))?;
|
.map_err(|e| AppError::Internal(anyhow::anyhow!("Failed to generate JWT: {e}")))?;
|
||||||
|
|
||||||
Ok(Json(json!({
|
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": {
|
"data": {
|
||||||
"token": token,
|
"token": token,
|
||||||
"user": {
|
"user": {
|
||||||
@@ -167,6 +190,8 @@ async fn login(
|
|||||||
"username": user.username,
|
"username": user.username,
|
||||||
"email": user.email,
|
"email": user.email,
|
||||||
"role": user.role,
|
"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()))?;
|
.map_err(|_| AppError::Unauthorized("Invalid or expired token".into()))?;
|
||||||
|
|
||||||
let user: User = sqlx::query_as::<_, User>("SELECT * FROM users WHERE id = ?")
|
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)
|
.fetch_optional(&*state.db)
|
||||||
.await?
|
.await?
|
||||||
.ok_or_else(|| AppError::NotFound("User not found".into()))?;
|
.ok_or_else(|| AppError::NotFound("User not found".into()))?;
|
||||||
|
|
||||||
Ok(Json(json!({
|
Ok(Json(json!({
|
||||||
|
"user": {
|
||||||
|
"id": user.id,
|
||||||
|
"username": user.username,
|
||||||
|
"email": user.email,
|
||||||
|
"role": user.role,
|
||||||
|
"status": user.status,
|
||||||
|
"createdAt": user.created_at,
|
||||||
|
},
|
||||||
"data": {
|
"data": {
|
||||||
"id": user.id,
|
"id": user.id,
|
||||||
"username": user.username,
|
"username": user.username,
|
||||||
"email": user.email,
|
"email": user.email,
|
||||||
"role": user.role,
|
"role": user.role,
|
||||||
|
"status": user.status,
|
||||||
|
"createdAt": user.created_at,
|
||||||
}
|
}
|
||||||
})))
|
})))
|
||||||
}
|
}
|
||||||
|
|||||||
+49
-1
@@ -46,6 +46,7 @@ pub struct CheckinStats {
|
|||||||
pub fn routes() -> Router<AppState> {
|
pub fn routes() -> Router<AppState> {
|
||||||
Router::new()
|
Router::new()
|
||||||
.route("/api/checkin", get(list_checkin_logs))
|
.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/stats", get(checkin_stats))
|
||||||
.route("/api/checkin/accounts", get(list_checkin_accounts))
|
.route("/api/checkin/accounts", get(list_checkin_accounts))
|
||||||
.route(
|
.route(
|
||||||
@@ -53,6 +54,12 @@ pub fn routes() -> Router<AppState> {
|
|||||||
post(toggle_checkin_account),
|
post(toggle_checkin_account),
|
||||||
)
|
)
|
||||||
.route("/api/checkin/run", post(run_checkin))
|
.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
|
/// GET /api/checkin — list checkin logs
|
||||||
@@ -110,7 +117,7 @@ async fn checkin_stats(State(state): State<AppState>) -> AppResult<Json<Value>>
|
|||||||
.filter(|r| {
|
.filter(|r| {
|
||||||
r.last_checkin
|
r.last_checkin
|
||||||
.as_deref()
|
.as_deref()
|
||||||
.map_or(false, |d| d.starts_with(&today_date()))
|
.is_some_and(|d| d.starts_with(&today_date()))
|
||||||
})
|
})
|
||||||
.count();
|
.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
|
/// Get today's date as YYYY-MM-DD
|
||||||
fn today_date() -> String {
|
fn today_date() -> String {
|
||||||
use chrono::Utc;
|
use chrono::Utc;
|
||||||
|
|||||||
+92
-33
@@ -10,10 +10,11 @@ use crate::{
|
|||||||
AppState,
|
AppState,
|
||||||
};
|
};
|
||||||
use axum::{
|
use axum::{
|
||||||
|
body::Bytes,
|
||||||
extract::{Path, Query, State},
|
extract::{Path, Query, State},
|
||||||
http::{HeaderMap, StatusCode},
|
http::{HeaderMap, Method, StatusCode, Uri},
|
||||||
response::{Html, IntoResponse, Response},
|
response::{Html, IntoResponse, Response},
|
||||||
routing::{get, post},
|
routing::{any, get, post},
|
||||||
Json, Router,
|
Json, Router,
|
||||||
};
|
};
|
||||||
use reqwest::Client;
|
use reqwest::Client;
|
||||||
@@ -21,9 +22,6 @@ use serde_json::Value;
|
|||||||
use std::collections::HashMap;
|
use std::collections::HashMap;
|
||||||
use tracing::info;
|
use tracing::info;
|
||||||
|
|
||||||
/// Default ComfyUI backend address.
|
|
||||||
const DEFAULT_COMFYUI_BASE: &str = "http://127.0.0.1:8188";
|
|
||||||
|
|
||||||
/// Shared HTTP client
|
/// Shared HTTP client
|
||||||
fn http_client() -> Client {
|
fn http_client() -> Client {
|
||||||
Client::builder()
|
Client::builder()
|
||||||
@@ -39,7 +37,7 @@ async fn check_auth(state: &AppState, headers: &HeaderMap) -> Result<(), AppErro
|
|||||||
.get("Authorization")
|
.get("Authorization")
|
||||||
.and_then(|v| v.to_str().ok())
|
.and_then(|v| v.to_str().ok())
|
||||||
.and_then(|v| v.strip_prefix("Bearer "))
|
.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()))?;
|
.ok_or_else(|| AppError::Unauthorized("Missing Authorization header".into()))?;
|
||||||
|
|
||||||
// Admin token passes
|
// Admin token passes
|
||||||
@@ -49,9 +47,10 @@ async fn check_auth(state: &AppState, headers: &HeaderMap) -> Result<(), AppErro
|
|||||||
|
|
||||||
// Downstream API key
|
// Downstream API key
|
||||||
if auth.starts_with("sk-") {
|
if auth.starts_with("sk-") {
|
||||||
let result = authorize_downstream_token(auth, &state.db)
|
let result =
|
||||||
|
authorize_downstream_token(auth, &state.db, Some(&state.config.auth.proxy_token))
|
||||||
.await
|
.await
|
||||||
.map_err(|e| AppError::Unauthorized(e))?;
|
.map_err(AppError::Unauthorized)?;
|
||||||
|
|
||||||
match result {
|
match result {
|
||||||
DownstreamAuthResult::Managed { key, .. } => {
|
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
|
/// Get ComfyUI base URL from embedded/external runtime configuration.
|
||||||
fn comfyui_base() -> String {
|
fn comfyui_base(state: &AppState) -> String {
|
||||||
std::env::var("COMFYUI_BASE").unwrap_or_else(|_| DEFAULT_COMFYUI_BASE.to_string())
|
state.comfyui.internal_base_url()
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Route builder ────────────────────────────────────────────────────
|
// ── 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/history/{id}", get(proxy_get_history_by_id))
|
||||||
.route("/api/comfyui/queue", get(proxy_get_queue))
|
.route("/api/comfyui/queue", get(proxy_get_queue))
|
||||||
.route("/api/comfyui/view", get(proxy_get_view))
|
.route("/api/comfyui/view", get(proxy_get_view))
|
||||||
// Static proxy (public — assets/JS/CSS)
|
.route("/api/comfyui/object_info", get(proxy_get_object_info))
|
||||||
.route("/api/comfyui/proxy", get(proxy_static_root))
|
// Public ComfyUI reverse proxy for the embedded iframe UI.
|
||||||
.route("/api/comfyui/proxy/", get(proxy_static_root))
|
.route("/api/comfyui/proxy", any(proxy_static_root))
|
||||||
.route("/api/comfyui/proxy/{*path}", get(proxy_static))
|
.route("/api/comfyui/proxy/", any(proxy_static_root))
|
||||||
|
.route("/api/comfyui/proxy/{*path}", any(proxy_static))
|
||||||
// HTML page (public)
|
// HTML page (public)
|
||||||
.route("/comfyui", get(comfyui_page))
|
.route("/comfyui", get(comfyui_page))
|
||||||
}
|
}
|
||||||
@@ -100,7 +100,7 @@ async fn proxy_post_prompt(
|
|||||||
) -> AppResult<Response> {
|
) -> AppResult<Response> {
|
||||||
info!("POST /api/comfyui/prompt");
|
info!("POST /api/comfyui/prompt");
|
||||||
check_auth(&state, &headers).await?;
|
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
|
/// GET /api/comfyui/history
|
||||||
@@ -110,7 +110,7 @@ async fn proxy_get_history(
|
|||||||
) -> AppResult<Response> {
|
) -> AppResult<Response> {
|
||||||
info!("GET /api/comfyui/history");
|
info!("GET /api/comfyui/history");
|
||||||
check_auth(&state, &headers).await?;
|
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}
|
/// GET /api/comfyui/history/{id}
|
||||||
@@ -122,14 +122,24 @@ async fn proxy_get_history_by_id(
|
|||||||
info!("GET /api/comfyui/history/{}", id);
|
info!("GET /api/comfyui/history/{}", id);
|
||||||
check_auth(&state, &headers).await?;
|
check_auth(&state, &headers).await?;
|
||||||
let path = format!("/history/{}", urlencoding(&id));
|
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
|
/// GET /api/comfyui/queue
|
||||||
async fn proxy_get_queue(State(state): State<AppState>, headers: HeaderMap) -> AppResult<Response> {
|
async fn proxy_get_queue(State(state): State<AppState>, headers: HeaderMap) -> AppResult<Response> {
|
||||||
info!("GET /api/comfyui/queue");
|
info!("GET /api/comfyui/queue");
|
||||||
check_auth(&state, &headers).await?;
|
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=...
|
/// GET /api/comfyui/view?filename=...&type=...&subfolder=...
|
||||||
@@ -143,29 +153,73 @@ async fn proxy_get_view(
|
|||||||
|
|
||||||
let qs = build_query_string(¶ms);
|
let qs = build_query_string(¶ms);
|
||||||
let path = format!("/view{}", qs);
|
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
|
/// /api/comfyui/proxy (root) — proxy to ComfyUI root
|
||||||
async fn proxy_static_root() -> AppResult<Response> {
|
async fn proxy_static_root(
|
||||||
proxy_static(Path("/".to_string())).await
|
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)
|
/// /api/comfyui/proxy/{path} — proxy ComfyUI UI assets and API calls.
|
||||||
async fn proxy_static(Path(path): Path<String>) -> AppResult<Response> {
|
async fn proxy_static(
|
||||||
info!("GET /api/comfyui/proxy/{}", path);
|
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 == "/" {
|
let target_path = if path.is_empty() || path == "/" {
|
||||||
"/".to_string()
|
"/".to_string()
|
||||||
} else {
|
} else {
|
||||||
format!("/{}", path)
|
format!("/{}", path)
|
||||||
};
|
};
|
||||||
|
let target_path = match uri.query() {
|
||||||
|
Some(query) => format!("{target_path}?{query}"),
|
||||||
|
None => target_path,
|
||||||
|
};
|
||||||
|
|
||||||
let client = http_client();
|
let client = http_client();
|
||||||
let url = format!("{}{}", comfyui_base(), target_path);
|
let url = format!("{}{}", comfyui_base(&state), target_path);
|
||||||
let resp = client
|
info!("{} /api/comfyui/proxy{} -> {}", method, uri.path(), url);
|
||||||
.get(&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()
|
.send()
|
||||||
.await
|
.await
|
||||||
.map_err(|e| AppError::UpstreamError(format!("ComfyUI unreachable: {e}")))?;
|
.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
|
/// Proxy to ComfyUI — returns JSON/text response
|
||||||
async fn proxy_to_comfyui(
|
async fn proxy_to_comfyui(
|
||||||
|
state: &AppState,
|
||||||
method: &str,
|
method: &str,
|
||||||
path: &str,
|
path: &str,
|
||||||
_query: Option<&HashMap<String, String>>,
|
_query: Option<&HashMap<String, String>>,
|
||||||
body: Option<Value>,
|
body: Option<Value>,
|
||||||
) -> AppResult<Response> {
|
) -> AppResult<Response> {
|
||||||
let client = http_client();
|
let client = http_client();
|
||||||
let url = format!("{}{}", comfyui_base(), path);
|
let url = format!("{}{}", comfyui_base(state), path);
|
||||||
|
|
||||||
let req = match method {
|
let req = match method {
|
||||||
"POST" => {
|
"POST" => {
|
||||||
@@ -347,9 +402,13 @@ async fn proxy_to_comfyui(
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Proxy to ComfyUI — returns binary response (for images)
|
/// 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 client = http_client();
|
||||||
let url = format!("{}{}", comfyui_base(), path);
|
let url = format!("{}{}", comfyui_base(state), path);
|
||||||
|
|
||||||
let req = client.get(&url);
|
let req = client.get(&url);
|
||||||
let resp = req
|
let resp = req
|
||||||
|
|||||||
+140
-73
@@ -3,12 +3,13 @@ use axum::{
|
|||||||
extract::State,
|
extract::State,
|
||||||
http::HeaderMap,
|
http::HeaderMap,
|
||||||
response::sse::{Event, Sse},
|
response::sse::{Event, Sse},
|
||||||
routing::post,
|
routing::{get, post},
|
||||||
Json, Router,
|
Json, Router,
|
||||||
};
|
};
|
||||||
use futures::stream::Stream;
|
use futures::stream::Stream;
|
||||||
use regex::Regex;
|
use regex::Regex;
|
||||||
use reqwest::Client;
|
use reqwest::Client;
|
||||||
|
use serde::Deserialize;
|
||||||
use serde_json::{json, Value};
|
use serde_json::{json, Value};
|
||||||
use std::convert::Infallible;
|
use std::convert::Infallible;
|
||||||
use tokio::sync::mpsc::{unbounded_channel, UnboundedSender};
|
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> {
|
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)]
|
#[derive(Debug, Default)]
|
||||||
@@ -79,7 +129,6 @@ struct ParsedSections {
|
|||||||
scenes: Vec<SceneSection>,
|
scenes: Vec<SceneSection>,
|
||||||
tts_list: Vec<TtsSection>,
|
tts_list: Vec<TtsSection>,
|
||||||
workflow_json: Option<String>,
|
workflow_json: Option<String>,
|
||||||
text_content: String,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Default)]
|
#[derive(Debug, Default)]
|
||||||
@@ -108,7 +157,6 @@ struct TtsSection {
|
|||||||
|
|
||||||
fn parse_sections(full_text: &str) -> ParsedSections {
|
fn parse_sections(full_text: &str) -> ParsedSections {
|
||||||
let mut sections = ParsedSections::default();
|
let mut sections = ParsedSections::default();
|
||||||
sections.text_content = full_text.to_string();
|
|
||||||
|
|
||||||
// Parse CHARACTER blocks
|
// Parse CHARACTER blocks
|
||||||
let char_re = Regex::new(r"(?s)【CHARACTER】\s*(.*?)【/CHARACTER】").unwrap();
|
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() {
|
for line in block.lines() {
|
||||||
let line = line.trim();
|
let line = line.trim();
|
||||||
let line_lower = line.to_lowercase();
|
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();
|
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();
|
char_section.description = val.trim().to_string();
|
||||||
} else if line_lower.starts_with("prompt_front:") || line_lower.starts_with("prompt_front :") {
|
} else if line_lower.starts_with("prompt_front:")
|
||||||
let val = line.splitn(2, ':').nth(1).unwrap_or("").trim();
|
|| 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();
|
char_section.prompt_front = val.to_string();
|
||||||
} else if line_lower.starts_with("prompt_back:") || line_lower.starts_with("prompt_back :") {
|
} else if line_lower.starts_with("prompt_back:")
|
||||||
let val = line.splitn(2, ':').nth(1).unwrap_or("").trim();
|
|| 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();
|
char_section.prompt_back = val.to_string();
|
||||||
} else if line_lower.starts_with("prompt_left:") || line_lower.starts_with("prompt_left :") {
|
} else if line_lower.starts_with("prompt_left:")
|
||||||
let val = line.splitn(2, ':').nth(1).unwrap_or("").trim();
|
|| 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();
|
char_section.prompt_left = val.to_string();
|
||||||
} else if line_lower.starts_with("prompt_right:") || line_lower.starts_with("prompt_right :") {
|
} else if line_lower.starts_with("prompt_right:")
|
||||||
let val = line.splitn(2, ':').nth(1).unwrap_or("").trim();
|
|| 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();
|
char_section.prompt_right = val.to_string();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -154,12 +216,15 @@ fn parse_sections(full_text: &str) -> ParsedSections {
|
|||||||
let line = line.trim();
|
let line = line.trim();
|
||||||
let line_lower = line.to_lowercase();
|
let line_lower = line.to_lowercase();
|
||||||
if line_lower.starts_with("id:") || line_lower.starts_with("id :") {
|
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);
|
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();
|
scene.description = val.trim().to_string();
|
||||||
} else if line_lower.starts_with("prompt:") || line_lower.starts_with("prompt :") {
|
} 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();
|
scene.prompt = val.to_string();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -181,13 +246,13 @@ fn parse_sections(full_text: &str) -> ParsedSections {
|
|||||||
let line = line.trim();
|
let line = line.trim();
|
||||||
let line_lower = line.to_lowercase();
|
let line_lower = line.to_lowercase();
|
||||||
if line_lower.starts_with("character:") || line_lower.starts_with("character :") {
|
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();
|
tts.character = val.to_string();
|
||||||
} else if line_lower.starts_with("text:") || line_lower.starts_with("text :") {
|
} 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();
|
tts.text = val.to_string();
|
||||||
} else if line_lower.starts_with("emotion:") || line_lower.starts_with("emotion :") {
|
} 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();
|
tts.emotion = val.to_string();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -206,13 +271,13 @@ fn parse_sections(full_text: &str) -> ParsedSections {
|
|||||||
sections
|
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 client = Client::new();
|
||||||
let url = format!("{}/images/generations", upstream_base());
|
let url = format!("{}/images/generations", api_base.trim_end_matches('/'));
|
||||||
|
|
||||||
let resp = client
|
let resp = client
|
||||||
.post(&url)
|
.post(&url)
|
||||||
.bearer_auth(upstream_key())
|
.bearer_auth(api_key)
|
||||||
.json(&json!({
|
.json(&json!({
|
||||||
"model": "gpt-image-2",
|
"model": "gpt-image-2",
|
||||||
"prompt": prompt,
|
"prompt": prompt,
|
||||||
@@ -243,13 +308,18 @@ async fn call_image_gen(prompt: &str) -> Result<String, String> {
|
|||||||
.to_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 client = Client::new();
|
||||||
let url = format!("{}/audio/speech", upstream_base());
|
let url = format!("{}/audio/speech", api_base.trim_end_matches('/'));
|
||||||
|
|
||||||
let resp = client
|
let resp = client
|
||||||
.post(&url)
|
.post(&url)
|
||||||
.bearer_auth(upstream_key())
|
.bearer_auth(api_key)
|
||||||
.json(&json!({
|
.json(&json!({
|
||||||
"model": "tts-1",
|
"model": "tts-1",
|
||||||
"input": text,
|
"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 {
|
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(
|
async fn chat_handler(
|
||||||
State(state): State<AppState>,
|
State(state): State<AppState>,
|
||||||
headers: HeaderMap,
|
headers: HeaderMap,
|
||||||
Json(body): Json<Value>,
|
Json(body): Json<AgentChatRequest>,
|
||||||
) -> Result<Sse<impl Stream<Item = Result<Event, Infallible>>>, (axum::http::StatusCode, Json<Value>)>
|
) -> Result<Sse<impl Stream<Item = Result<Event, Infallible>>>, (axum::http::StatusCode, Json<Value>)>
|
||||||
{
|
{
|
||||||
let auth_header = headers
|
if !authorize(&headers, &state) {
|
||||||
.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 {
|
|
||||||
return Err((
|
return Err((
|
||||||
axum::http::StatusCode::UNAUTHORIZED,
|
axum::http::StatusCode::UNAUTHORIZED,
|
||||||
Json(json!({ "error": "Unauthorized" })),
|
Json(json!({ "error": "Unauthorized" })),
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
let messages = body
|
if body.messages.is_empty()
|
||||||
.get("messages")
|
|| body
|
||||||
.and_then(Value::as_array)
|
.messages
|
||||||
.ok_or_else(|| {
|
.iter()
|
||||||
(
|
.any(|message| message.role.trim().is_empty() || message.content.trim().is_empty())
|
||||||
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((
|
return Err((
|
||||||
axum::http::StatusCode::BAD_REQUEST,
|
axum::http::StatusCode::BAD_REQUEST,
|
||||||
Json(json!({ "error": "messages is required" })),
|
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>>();
|
let (tx, rx) = unbounded_channel::<Result<Event, Infallible>>();
|
||||||
|
|
||||||
tokio::spawn(async move {
|
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);
|
let mut upstream_messages = Vec::with_capacity(messages.len() + 1);
|
||||||
upstream_messages.push(json!({
|
upstream_messages.push(json!({
|
||||||
@@ -340,9 +403,9 @@ async fn chat_handler(
|
|||||||
|
|
||||||
let response = match client
|
let response = match client
|
||||||
.post(&upstream_url)
|
.post(&upstream_url)
|
||||||
.bearer_auth(upstream_key())
|
.bearer_auth(&api_key)
|
||||||
.json(&json!({
|
.json(&json!({
|
||||||
"model": "gpt-5.5",
|
"model": agent_model,
|
||||||
"messages": upstream_messages,
|
"messages": upstream_messages,
|
||||||
"temperature": 0.7,
|
"temperature": 0.7,
|
||||||
"stream": true,
|
"stream": true,
|
||||||
@@ -516,7 +579,7 @@ async fn chat_handler(
|
|||||||
tokio::time::sleep(tokio::time::Duration::from_secs(1)).await;
|
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() => {
|
Ok(url) if !url.is_empty() => {
|
||||||
send_sse(
|
send_sse(
|
||||||
&tx,
|
&tx,
|
||||||
@@ -543,11 +606,14 @@ async fn chat_handler(
|
|||||||
|
|
||||||
// Generate images for each scene
|
// Generate images for each scene
|
||||||
for scene in §ions.scenes {
|
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;
|
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() => {
|
Ok(url) if !url.is_empty() => {
|
||||||
send_sse(
|
send_sse(
|
||||||
&tx,
|
&tx,
|
||||||
@@ -586,11 +652,12 @@ async fn chat_handler(
|
|||||||
_ => "alloy",
|
_ => "alloy",
|
||||||
};
|
};
|
||||||
|
|
||||||
match call_tts(&tts.text, voice).await {
|
match call_tts(&api_base, &api_key, &tts.text, voice).await {
|
||||||
Ok(audio_bytes) => {
|
Ok(audio_bytes) => {
|
||||||
// For SSE, we'll send a data URL for small audio, or a placeholder
|
// 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
|
// 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(' ', "_"),
|
tts.character.replace(' ', "_"),
|
||||||
std::time::SystemTime::now()
|
std::time::SystemTime::now()
|
||||||
.duration_since(std::time::UNIX_EPOCH)
|
.duration_since(std::time::UNIX_EPOCH)
|
||||||
|
|||||||
@@ -1,13 +1,9 @@
|
|||||||
// Events routes — system event notifications
|
// Events routes — system event notifications
|
||||||
|
|
||||||
use crate::{
|
use crate::{db::models::event::Event, error::AppResult, AppState};
|
||||||
db::models::event::Event,
|
|
||||||
error::{AppError, AppResult},
|
|
||||||
AppState,
|
|
||||||
};
|
|
||||||
use axum::{
|
use axum::{
|
||||||
extract::{Path, Query, State},
|
extract::{Path, Query, State},
|
||||||
routing::{delete, get, post},
|
routing::{get, post},
|
||||||
Json, Router,
|
Json, Router,
|
||||||
};
|
};
|
||||||
use serde::Deserialize;
|
use serde::Deserialize;
|
||||||
|
|||||||
+7
-12
@@ -1,8 +1,8 @@
|
|||||||
use crate::AppState;
|
use crate::AppState;
|
||||||
use axum::{
|
use axum::{
|
||||||
body::Body,
|
body::Body,
|
||||||
http::{Request, StatusCode, Uri},
|
http::{Request, StatusCode},
|
||||||
response::{Html, Response},
|
response::Response,
|
||||||
routing::get,
|
routing::get,
|
||||||
Router,
|
Router,
|
||||||
};
|
};
|
||||||
@@ -31,8 +31,7 @@ mod user_api_keys;
|
|||||||
mod video_agent;
|
mod video_agent;
|
||||||
|
|
||||||
pub fn build_router(state: AppState) -> Router {
|
pub fn build_router(state: AppState) -> Router {
|
||||||
let web_dir = std::env::var("BOOSAPI_WEB_DIR")
|
let web_dir = std::env::var("BOOSAPI_WEB_DIR").unwrap_or_else(|_| "dist/web".to_string());
|
||||||
.unwrap_or_else(|_| "dist/web".to_string());
|
|
||||||
|
|
||||||
// Build the API router
|
// Build the API router
|
||||||
let api_routes = Router::new()
|
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
|
// SPA fallback: serve index.html for client-side routing
|
||||||
let index_path = format!("{}/index.html", web_dir);
|
let index_path = format!("{}/index.html", web_dir);
|
||||||
match tokio::fs::read_to_string(&index_path).await {
|
match tokio::fs::read_to_string(&index_path).await {
|
||||||
Ok(html) => {
|
Ok(html) => Ok::<_, Infallible>(
|
||||||
Ok::<_, Infallible>(
|
|
||||||
Response::builder()
|
Response::builder()
|
||||||
.status(StatusCode::OK)
|
.status(StatusCode::OK)
|
||||||
.header("Content-Type", "text/html; charset=utf-8")
|
.header("Content-Type", "text/html; charset=utf-8")
|
||||||
.body(Body::from(html))
|
.body(Body::from(html))
|
||||||
.unwrap(),
|
.unwrap(),
|
||||||
)
|
),
|
||||||
}
|
Err(_) => Ok::<_, Infallible>(
|
||||||
Err(_) => {
|
|
||||||
Ok::<_, Infallible>(
|
|
||||||
Response::builder()
|
Response::builder()
|
||||||
.status(StatusCode::NOT_FOUND)
|
.status(StatusCode::NOT_FOUND)
|
||||||
.header("Content-Type", "text/plain; charset=utf-8")
|
.header("Content-Type", "text/plain; charset=utf-8")
|
||||||
.body(Body::from(format!("404: {} not found", path)))
|
.body(Body::from(format!("404: {} not found", path)))
|
||||||
.unwrap(),
|
.unwrap(),
|
||||||
)
|
),
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}));
|
}));
|
||||||
|
|||||||
@@ -6,13 +6,12 @@ use crate::{
|
|||||||
};
|
};
|
||||||
use axum::{
|
use axum::{
|
||||||
extract::{Path, State},
|
extract::{Path, State},
|
||||||
http::{header, HeaderMap, StatusCode},
|
http::{header, HeaderMap},
|
||||||
response::Response,
|
response::Response,
|
||||||
routing::{get, post, put},
|
routing::{get, post},
|
||||||
Json, Router,
|
Json, Router,
|
||||||
};
|
};
|
||||||
use serde_json::{json, Value};
|
use serde_json::{json, Value};
|
||||||
use tracing::info;
|
|
||||||
|
|
||||||
const MONITOR_AUTH_COOKIE: &str = "meta_monitor_auth";
|
const MONITOR_AUTH_COOKIE: &str = "meta_monitor_auth";
|
||||||
const LDOH_BASE_URL: &str = "https://ldoh.105117.xyz";
|
const LDOH_BASE_URL: &str = "https://ldoh.105117.xyz";
|
||||||
@@ -220,20 +219,20 @@ async fn ldoh_proxy_handler(
|
|||||||
.text()
|
.text()
|
||||||
.await
|
.await
|
||||||
.map_err(|e| AppError::UpstreamError(e.to_string()))?;
|
.map_err(|e| AppError::UpstreamError(e.to_string()))?;
|
||||||
return Ok(builder
|
return builder
|
||||||
.header(header::CONTENT_TYPE, &content_type)
|
.header(header::CONTENT_TYPE, &content_type)
|
||||||
.body(axum::body::Body::from(rewrite_proxy_text(&text)))
|
.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
|
let bytes = resp
|
||||||
.bytes()
|
.bytes()
|
||||||
.await
|
.await
|
||||||
.map_err(|e| AppError::UpstreamError(e.to_string()))?;
|
.map_err(|e| AppError::UpstreamError(e.to_string()))?;
|
||||||
Ok(builder
|
builder
|
||||||
.header(header::CONTENT_TYPE, &content_type)
|
.header(header::CONTENT_TYPE, &content_type)
|
||||||
.body(axum::body::Body::from(bytes))
|
.body(axum::body::Body::from(bytes))
|
||||||
.map_err(|e| AppError::Internal(anyhow::anyhow!("{e}")))?)
|
.map_err(|e| AppError::Internal(anyhow::anyhow!("{e}")))
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Helpers ────────────────────────────────────────────────────────────
|
// ── Helpers ────────────────────────────────────────────────────────────
|
||||||
|
|||||||
+1
-1
@@ -280,7 +280,7 @@ async fn delete_route_unit(
|
|||||||
// ── Callback ───────────────────────────────────────────────────────────
|
// ── Callback ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
/// GET /api/oauth/callback — public, returns HTML for popup window
|
/// 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");
|
info!("OAuth callback received");
|
||||||
let html = r#"<!doctype html>
|
let html = r#"<!doctype html>
|
||||||
<html lang="zh-CN">
|
<html lang="zh-CN">
|
||||||
|
|||||||
@@ -14,7 +14,6 @@ use axum::{
|
|||||||
};
|
};
|
||||||
use serde::Deserialize;
|
use serde::Deserialize;
|
||||||
use serde_json::{json, Value};
|
use serde_json::{json, Value};
|
||||||
use tracing::info;
|
|
||||||
|
|
||||||
// ── Auth helper ────────────────────────────────────────────────────────
|
// ── Auth helper ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
@@ -465,7 +464,7 @@ async fn get_invitation(
|
|||||||
match invitation {
|
match invitation {
|
||||||
Some(i) => {
|
Some(i) => {
|
||||||
// Check expiry
|
// 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")
|
chrono::NaiveDateTime::parse_from_str(e.trim(), "%Y-%m-%d %H:%M:%S")
|
||||||
.map(|dt| dt.and_utc().timestamp() < chrono::Utc::now().timestamp())
|
.map(|dt| dt.and_utc().timestamp() < chrono::Utc::now().timestamp())
|
||||||
.unwrap_or(false)
|
.unwrap_or(false)
|
||||||
@@ -525,7 +524,7 @@ async fn accept_invitation(
|
|||||||
};
|
};
|
||||||
|
|
||||||
// Check expiry
|
// 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")
|
chrono::NaiveDateTime::parse_from_str(e.trim(), "%Y-%m-%d %H:%M:%S")
|
||||||
.map(|dt| dt.and_utc().timestamp() < chrono::Utc::now().timestamp())
|
.map(|dt| dt.and_utc().timestamp() < chrono::Utc::now().timestamp())
|
||||||
.unwrap_or(true)
|
.unwrap_or(true)
|
||||||
|
|||||||
@@ -29,6 +29,7 @@ use tracing::info;
|
|||||||
|
|
||||||
// ── Auth types ────────────────────────────────────────────────────────
|
// ── Auth types ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
#[allow(dead_code)]
|
||||||
#[derive(Clone)]
|
#[derive(Clone)]
|
||||||
pub struct ProxyAuth {
|
pub struct ProxyAuth {
|
||||||
pub token: String,
|
pub token: String,
|
||||||
@@ -36,6 +37,7 @@ pub struct ProxyAuth {
|
|||||||
pub source: DownstreamSource,
|
pub source: DownstreamSource,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[allow(dead_code)]
|
||||||
#[derive(Clone, PartialEq)]
|
#[derive(Clone, PartialEq)]
|
||||||
pub enum DownstreamSource {
|
pub enum DownstreamSource {
|
||||||
Managed { key_id: i64 },
|
Managed { key_id: i64 },
|
||||||
@@ -279,12 +281,12 @@ async fn check_auth(state: &AppState, headers: &HeaderMap) -> Result<(), AppErro
|
|||||||
.get("Authorization")
|
.get("Authorization")
|
||||||
.and_then(|v| v.to_str().ok())
|
.and_then(|v| v.to_str().ok())
|
||||||
.and_then(|v| v.strip_prefix("Bearer "))
|
.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()))?;
|
.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
|
.await
|
||||||
.map_err(|e| AppError::Unauthorized(e))?;
|
.map_err(AppError::Unauthorized)?;
|
||||||
|
|
||||||
match result {
|
match result {
|
||||||
DownstreamAuthResult::Managed { key, .. } => {
|
DownstreamAuthResult::Managed { key, .. } => {
|
||||||
@@ -646,6 +648,7 @@ mod tests {
|
|||||||
max_retries: 3,
|
max_retries: 3,
|
||||||
proxy_log_retention_days: 7,
|
proxy_log_retention_days: 7,
|
||||||
},
|
},
|
||||||
|
comfyui: Default::default(),
|
||||||
},
|
},
|
||||||
pool,
|
pool,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -5,7 +5,8 @@ use axum::{extract::State, routing::post, Json, Router};
|
|||||||
use serde::Deserialize;
|
use serde::Deserialize;
|
||||||
use serde_json::{json, Value};
|
use serde_json::{json, Value};
|
||||||
use std::collections::{HashMap, HashSet};
|
use std::collections::{HashMap, HashSet};
|
||||||
use tracing::info;
|
|
||||||
|
type ModelAggregate = HashMap<String, (HashSet<i64>, HashSet<i64>, HashSet<i64>)>;
|
||||||
|
|
||||||
#[derive(Debug, Deserialize)]
|
#[derive(Debug, Deserialize)]
|
||||||
pub struct SearchBody {
|
pub struct SearchBody {
|
||||||
@@ -23,7 +24,7 @@ async fn search_handler(
|
|||||||
Json(body): Json<SearchBody>,
|
Json(body): Json<SearchBody>,
|
||||||
) -> AppResult<Json<Value>> {
|
) -> AppResult<Json<Value>> {
|
||||||
let query = body.query.trim().to_string();
|
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() {
|
if query.is_empty() {
|
||||||
return Ok(Json(json!({
|
return Ok(Json(json!({
|
||||||
@@ -33,7 +34,7 @@ async fn search_handler(
|
|||||||
}
|
}
|
||||||
|
|
||||||
let q = format!("%{}%", query);
|
let q = format!("%{}%", query);
|
||||||
let per_category = (limit / 6).max(1).min(10);
|
let per_category = (limit / 6).clamp(1, 10);
|
||||||
|
|
||||||
// 1. Search sites
|
// 1. Search sites
|
||||||
let sites: Vec<Value> = sqlx::query_as::<_, (i64, String, String, String)>(
|
let sites: Vec<Value> = sqlx::query_as::<_, (i64, String, String, String)>(
|
||||||
@@ -68,7 +69,7 @@ async fn search_handler(
|
|||||||
.fetch_all(&*state.db)
|
.fetch_all(&*state.db)
|
||||||
.await?
|
.await?
|
||||||
.into_iter()
|
.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" };
|
let segment = if access_token.as_deref().unwrap_or("").trim().is_empty() { "apikey" } else { "session" };
|
||||||
json!({
|
json!({
|
||||||
"id": id, "username": username, "status": status,
|
"id": id, "username": username, "status": status,
|
||||||
@@ -133,7 +134,7 @@ async fn search_handler(
|
|||||||
.fetch_all(&*state.db)
|
.fetch_all(&*state.db)
|
||||||
.await?
|
.await?
|
||||||
.into_iter()
|
.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})
|
json!({"id": id, "modelRequested": model, "status": status, "httpStatus": http_status, "createdAt": created_at})
|
||||||
})
|
})
|
||||||
.collect();
|
.collect();
|
||||||
@@ -167,7 +168,7 @@ async fn search_handler(
|
|||||||
.await?;
|
.await?;
|
||||||
|
|
||||||
// Aggregate models
|
// 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 {
|
for (model_name, _token_id, account_id, site_id) in token_model_rows {
|
||||||
let entry = model_agg.entry(model_name).or_default();
|
let entry = model_agg.entry(model_name).or_default();
|
||||||
entry.0.insert(account_id);
|
entry.0.insert(account_id);
|
||||||
@@ -198,7 +199,7 @@ async fn search_handler(
|
|||||||
a["name"]
|
a["name"]
|
||||||
.as_str()
|
.as_str()
|
||||||
.unwrap_or("")
|
.unwrap_or("")
|
||||||
.cmp(&b["name"].as_str().unwrap_or(""))
|
.cmp(b["name"].as_str().unwrap_or(""))
|
||||||
})
|
})
|
||||||
});
|
});
|
||||||
models.truncate(per_category as usize);
|
models.truncate(per_category as usize);
|
||||||
|
|||||||
@@ -1,13 +1,9 @@
|
|||||||
// Site Announcements routes — upstream provider notifications per site
|
// Site Announcements routes — upstream provider notifications per site
|
||||||
|
|
||||||
use crate::{
|
use crate::{db::models::site_announcement::SiteAnnouncement, error::AppResult, AppState};
|
||||||
db::models::site_announcement::SiteAnnouncement,
|
|
||||||
error::{AppError, AppResult},
|
|
||||||
AppState,
|
|
||||||
};
|
|
||||||
use axum::{
|
use axum::{
|
||||||
extract::{Path, Query, State},
|
extract::{Path, Query, State},
|
||||||
routing::{delete, get, post},
|
routing::{get, post},
|
||||||
Json, Router,
|
Json, Router,
|
||||||
};
|
};
|
||||||
use serde::Deserialize;
|
use serde::Deserialize;
|
||||||
@@ -81,7 +77,7 @@ async fn list_announcements(
|
|||||||
|
|
||||||
// Apply status filter in-memory (matches Node.js behaviour)
|
// Apply status filter in-memory (matches Node.js behaviour)
|
||||||
let filtered = if let Some(ref status) = q.status {
|
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();
|
let status = status.trim().to_lowercase();
|
||||||
announcements
|
announcements
|
||||||
.into_iter()
|
.into_iter()
|
||||||
@@ -90,14 +86,10 @@ async fn list_announcements(
|
|||||||
a.dismissed_at.is_some()
|
a.dismissed_at.is_some()
|
||||||
} else if status == "expired" {
|
} else if status == "expired" {
|
||||||
a.dismissed_at.is_none()
|
a.dismissed_at.is_none()
|
||||||
&& a.ends_at
|
&& a.ends_at.as_ref().is_some_and(|e| parse_dt_millis(e) < now)
|
||||||
.as_ref()
|
|
||||||
.map_or(false, |e| parse_dt_millis(e) < now)
|
|
||||||
} else if status == "active" {
|
} else if status == "active" {
|
||||||
a.dismissed_at.is_none()
|
a.dismissed_at.is_none()
|
||||||
&& a.ends_at
|
&& a.ends_at.as_ref().is_none_or(|e| parse_dt_millis(e) >= now)
|
||||||
.as_ref()
|
|
||||||
.map_or(true, |e| parse_dt_millis(e) >= now)
|
|
||||||
} else {
|
} else {
|
||||||
true
|
true
|
||||||
}
|
}
|
||||||
|
|||||||
+217
-2
@@ -19,13 +19,190 @@ pub struct ModelBySiteQuery {
|
|||||||
#[derive(Debug, Deserialize)]
|
#[derive(Debug, Deserialize)]
|
||||||
pub struct SiteDistributionQuery {
|
pub struct SiteDistributionQuery {
|
||||||
days: Option<i64>,
|
days: Option<i64>,
|
||||||
refresh: Option<String>,
|
#[serde(rename = "refresh")]
|
||||||
|
_refresh: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn routes() -> Router<AppState> {
|
pub fn routes() -> Router<AppState> {
|
||||||
Router::new()
|
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/model-by-site", get(model_by_site))
|
||||||
.route("/api/stats/site-distribution", get(site_distribution))
|
.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=...
|
/// GET /api/stats/model-by-site?siteId=...&days=...
|
||||||
@@ -117,7 +294,7 @@ async fn site_distribution(
|
|||||||
|
|
||||||
// Sum per-site usage
|
// Sum per-site usage
|
||||||
let rows: Vec<(i64, String, i64, f64, i64)> = sqlx::query_as(
|
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
|
FROM sites s
|
||||||
LEFT JOIN model_day_usage m ON m.site_id = s.id AND m.local_day >= ?
|
LEFT JOIN model_day_usage m ON m.site_id = s.id AND m.local_day >= ?
|
||||||
GROUP BY s.id, s.name
|
GROUP BY s.id, s.name
|
||||||
@@ -142,3 +319,41 @@ async fn site_distribution(
|
|||||||
|
|
||||||
Ok(Json(json!({ "distribution": 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,
|
routing::get,
|
||||||
Json, Router,
|
Json, Router,
|
||||||
};
|
};
|
||||||
use futures::stream::{self, Stream};
|
use futures::stream::Stream;
|
||||||
use serde::Deserialize;
|
use serde::Deserialize;
|
||||||
use serde_json::{json, Value};
|
use serde_json::{json, Value};
|
||||||
use tokio_stream::StreamExt;
|
|
||||||
use tracing::info;
|
use tracing::info;
|
||||||
|
|
||||||
#[derive(Debug, Deserialize)]
|
#[derive(Debug, Deserialize)]
|
||||||
@@ -38,7 +37,7 @@ async fn list_tasks(
|
|||||||
State(state): State<AppState>,
|
State(state): State<AppState>,
|
||||||
Query(q): Query<ListTasksQuery>,
|
Query(q): Query<ListTasksQuery>,
|
||||||
) -> AppResult<Json<Value>> {
|
) -> 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);
|
info!("Listing background tasks, limit={}", limit);
|
||||||
|
|
||||||
let tasks: Vec<BackgroundTask> = sqlx::query_as::<_, BackgroundTask>(
|
let tasks: Vec<BackgroundTask> = sqlx::query_as::<_, BackgroundTask>(
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
// Update Center routes — deploy/rollback orchestration via helper API
|
// Update Center routes — deploy/rollback orchestration via helper API
|
||||||
|
|
||||||
|
#![allow(clippy::type_complexity)]
|
||||||
|
|
||||||
use crate::{
|
use crate::{
|
||||||
error::{AppError, AppResult},
|
error::{AppError, AppResult},
|
||||||
AppState,
|
AppState,
|
||||||
|
|||||||
@@ -10,10 +10,9 @@ use crate::{
|
|||||||
use axum::{
|
use axum::{
|
||||||
extract::{Path, State},
|
extract::{Path, State},
|
||||||
http::{HeaderMap, StatusCode},
|
http::{HeaderMap, StatusCode},
|
||||||
routing::{delete, get, patch, post},
|
routing::{get, patch},
|
||||||
Json, Router,
|
Json, Router,
|
||||||
};
|
};
|
||||||
use serde::Deserialize;
|
|
||||||
use serde_json::{json, Value};
|
use serde_json::{json, Value};
|
||||||
use tracing::info;
|
use tracing::info;
|
||||||
|
|
||||||
|
|||||||
+137
-31
@@ -2,11 +2,16 @@ use axum::{
|
|||||||
body::Body,
|
body::Body,
|
||||||
extract::State,
|
extract::State,
|
||||||
http::{header, HeaderMap, Response, StatusCode},
|
http::{header, HeaderMap, Response, StatusCode},
|
||||||
response::IntoResponse,
|
response::{
|
||||||
routing::post,
|
sse::{Event, Sse},
|
||||||
|
IntoResponse,
|
||||||
|
},
|
||||||
|
routing::{get, post},
|
||||||
Json, Router,
|
Json, Router,
|
||||||
};
|
};
|
||||||
|
use futures::stream;
|
||||||
use serde_json::{json, Value};
|
use serde_json::{json, Value};
|
||||||
|
use std::convert::Infallible;
|
||||||
use tracing::info;
|
use tracing::info;
|
||||||
|
|
||||||
use crate::{
|
use crate::{
|
||||||
@@ -66,6 +71,11 @@ const SCENE_PROMPT_GEN: &str = r#"根据场景描述生成图像生成提示词
|
|||||||
|
|
||||||
pub fn routes() -> Router<AppState> {
|
pub fn routes() -> Router<AppState> {
|
||||||
Router::new()
|
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/analyze", post(analyze))
|
||||||
.route(
|
.route(
|
||||||
"/api/video-agent/character-prompts",
|
"/api/video-agent/character-prompts",
|
||||||
@@ -81,15 +91,100 @@ pub fn routes() -> Router<AppState> {
|
|||||||
.route("/api/video-agent/full-workflow", post(full_workflow))
|
.route("/api/video-agent/full-workflow", post(full_workflow))
|
||||||
}
|
}
|
||||||
|
|
||||||
fn upstream_base() -> String {
|
async fn status(State(state): State<AppState>, headers: HeaderMap) -> AppResult<Json<Value>> {
|
||||||
std::env::var("BOOSAPI_UPSTREAM_BASE")
|
check_auth(&headers, &state)?;
|
||||||
.unwrap_or_else(|_| "http://127.0.0.1:4000/v1".to_string())
|
Ok(Json(
|
||||||
.trim_end_matches('/')
|
serde_json::to_value(state.comfyui.status_response().await)
|
||||||
.to_string()
|
.unwrap_or_else(|_| json!({ "status": "unknown" })),
|
||||||
|
))
|
||||||
}
|
}
|
||||||
|
|
||||||
fn upstream_key() -> String {
|
async fn start_comfyui(
|
||||||
std::env::var("BOOSAPI_UPSTREAM_KEY").unwrap_or_else(|_| "sk-boos4721".to_string())
|
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<()> {
|
fn check_auth(headers: &HeaderMap, state: &AppState) -> AppResult<()> {
|
||||||
@@ -107,6 +202,17 @@ fn check_auth(headers: &HeaderMap, state: &AppState) -> AppResult<()> {
|
|||||||
Ok(())
|
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> {
|
fn required_str<'a>(body: &'a Value, key: &str) -> AppResult<&'a str> {
|
||||||
body.get(key)
|
body.get(key)
|
||||||
.and_then(Value::as_str)
|
.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 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
|
let resp = client
|
||||||
.post(url)
|
.post(url)
|
||||||
.bearer_auth(upstream_key())
|
.bearer_auth(&state.config.auth.proxy_token)
|
||||||
.json(&json!({
|
.json(&json!({
|
||||||
"model": "gpt-5.5",
|
"model": &state.config.comfyui.agent_model,
|
||||||
"messages": [
|
"messages": [
|
||||||
{ "role": "system", "content": system },
|
{ "role": "system", "content": system },
|
||||||
{ "role": "user", "content": prompt }
|
{ "role": "user", "content": prompt }
|
||||||
@@ -193,15 +299,15 @@ async fn call_llm(prompt: &str, system: &str) -> AppResult<String> {
|
|||||||
.to_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 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
|
let resp = client
|
||||||
.post(url)
|
.post(url)
|
||||||
.bearer_auth(upstream_key())
|
.bearer_auth(&state.config.auth.proxy_token)
|
||||||
.json(&json!({
|
.json(&json!({
|
||||||
"model": "gpt-image-2",
|
"model": "gpt-image-2",
|
||||||
"prompt": prompt,
|
"prompt": prompt,
|
||||||
@@ -236,15 +342,15 @@ async fn call_image_gen(prompt: &str) -> AppResult<String> {
|
|||||||
.to_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 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
|
let resp = client
|
||||||
.post(url)
|
.post(url)
|
||||||
.bearer_auth(upstream_key())
|
.bearer_auth(&state.config.auth.proxy_token)
|
||||||
.json(&json!({
|
.json(&json!({
|
||||||
"model": "tts-1",
|
"model": "tts-1",
|
||||||
"input": text,
|
"input": text,
|
||||||
@@ -282,7 +388,7 @@ async fn analyze(
|
|||||||
|
|
||||||
let script = required_str(&body, "script")?;
|
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)?;
|
let parsed = parse_llm_json(&result)?;
|
||||||
|
|
||||||
Ok(Json(json!({
|
Ok(Json(json!({
|
||||||
@@ -318,7 +424,7 @@ async fn character_prompts(
|
|||||||
.unwrap_or_default();
|
.unwrap_or_default();
|
||||||
|
|
||||||
let desc = format!("{name}: {appearance}, {gender}, {age}");
|
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)?;
|
let prompts = parse_llm_json(&result)?;
|
||||||
|
|
||||||
Ok(Json(json!({
|
Ok(Json(json!({
|
||||||
@@ -346,7 +452,7 @@ async fn generate_character(
|
|||||||
AppError::BadRequest(format!("prompt for angle '{angle}' must be a string"))
|
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));
|
results.insert(angle.clone(), Value::String(url));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -375,7 +481,7 @@ async fn scene_prompt(
|
|||||||
.unwrap_or_default();
|
.unwrap_or_default();
|
||||||
|
|
||||||
let desc = format!("{environment}. {description}");
|
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)?;
|
let prompt = parse_llm_json(&result)?;
|
||||||
|
|
||||||
Ok(Json(json!({
|
Ok(Json(json!({
|
||||||
@@ -392,7 +498,7 @@ async fn generate_scene(
|
|||||||
check_auth(&headers, &state)?;
|
check_auth(&headers, &state)?;
|
||||||
|
|
||||||
let prompt = required_str(&body, "prompt")?;
|
let prompt = required_str(&body, "prompt")?;
|
||||||
let url = call_image_gen(prompt).await?;
|
let url = call_image_gen(&state, prompt).await?;
|
||||||
|
|
||||||
Ok(Json(json!({
|
Ok(Json(json!({
|
||||||
"success": true,
|
"success": true,
|
||||||
@@ -416,7 +522,7 @@ async fn generate_tts(
|
|||||||
.filter(|s| !s.trim().is_empty())
|
.filter(|s| !s.trim().is_empty())
|
||||||
.unwrap_or("alloy");
|
.unwrap_or("alloy");
|
||||||
|
|
||||||
let audio = call_tts(text, voice).await?;
|
let audio = call_tts(&state, text, voice).await?;
|
||||||
|
|
||||||
Ok((
|
Ok((
|
||||||
StatusCode::OK,
|
StatusCode::OK,
|
||||||
@@ -436,7 +542,7 @@ async fn full_workflow(
|
|||||||
let script = required_str(&body, "script")?;
|
let script = required_str(&body, "script")?;
|
||||||
let requested_style = body.get("style").and_then(Value::as_str);
|
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 analysis = parse_llm_json(&analysis_raw)?;
|
||||||
|
|
||||||
let mut characters = Vec::new();
|
let mut characters = Vec::new();
|
||||||
@@ -461,7 +567,7 @@ async fn full_workflow(
|
|||||||
.unwrap_or_default();
|
.unwrap_or_default();
|
||||||
|
|
||||||
let desc = format!("{name}: {appearance}, {gender}, {age}");
|
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 prompts = parse_llm_json(&prompts_raw)?;
|
||||||
|
|
||||||
let mut item = character.clone();
|
let mut item = character.clone();
|
||||||
@@ -487,7 +593,7 @@ async fn full_workflow(
|
|||||||
.unwrap_or_default();
|
.unwrap_or_default();
|
||||||
|
|
||||||
let desc = format!("{environment}. {description}");
|
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 image_prompt = parse_llm_json(&scene_prompt_raw)?;
|
||||||
|
|
||||||
let mut item = scene.clone();
|
let mut item = scene.clone();
|
||||||
|
|||||||
@@ -0,0 +1,262 @@
|
|||||||
|
use crate::config::ComfyUIConfig;
|
||||||
|
use serde::Serialize;
|
||||||
|
use std::{path::Path, sync::Arc, time::Instant};
|
||||||
|
use tokio::{
|
||||||
|
process::{Child, Command},
|
||||||
|
sync::{Mutex, RwLock},
|
||||||
|
};
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize)]
|
||||||
|
#[serde(tag = "status", rename_all = "snake_case")]
|
||||||
|
pub enum ComfyUIStatus {
|
||||||
|
Disabled,
|
||||||
|
Starting,
|
||||||
|
Running { pid: Option<u32>, uptime_secs: u64 },
|
||||||
|
Unhealthy { last_error: String },
|
||||||
|
Stopped,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize)]
|
||||||
|
pub struct ComfyUIStatusResponse {
|
||||||
|
pub status: String,
|
||||||
|
pub pid: Option<u32>,
|
||||||
|
pub uptime_secs: u64,
|
||||||
|
pub mode: String,
|
||||||
|
pub model_backend: String,
|
||||||
|
pub base_url: String,
|
||||||
|
pub version: Option<String>,
|
||||||
|
pub last_error: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct ComfyUIProcess {
|
||||||
|
config: ComfyUIConfig,
|
||||||
|
child: Mutex<Option<Child>>,
|
||||||
|
status: RwLock<ComfyUIStatus>,
|
||||||
|
started_at: Mutex<Option<Instant>>,
|
||||||
|
boosapi_url: String,
|
||||||
|
proxy_token: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ComfyUIProcess {
|
||||||
|
pub fn new(config: ComfyUIConfig, boosapi_url: String, proxy_token: String) -> Arc<Self> {
|
||||||
|
let initial_status = if config.enabled {
|
||||||
|
ComfyUIStatus::Stopped
|
||||||
|
} else {
|
||||||
|
ComfyUIStatus::Disabled
|
||||||
|
};
|
||||||
|
|
||||||
|
Arc::new(Self {
|
||||||
|
config,
|
||||||
|
child: Mutex::new(None),
|
||||||
|
status: RwLock::new(initial_status),
|
||||||
|
started_at: Mutex::new(None),
|
||||||
|
boosapi_url,
|
||||||
|
proxy_token,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn config(&self) -> &ComfyUIConfig {
|
||||||
|
&self.config
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn internal_base_url(&self) -> String {
|
||||||
|
if self.config.enabled {
|
||||||
|
format!("http://127.0.0.1:{}", self.config.port)
|
||||||
|
} else {
|
||||||
|
self.config.target_base_url()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn model_backend_url(&self) -> String {
|
||||||
|
format!("{}/v1", self.boosapi_url.trim_end_matches('/'))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn status(&self) -> ComfyUIStatus {
|
||||||
|
let status = self.status.read().await.clone();
|
||||||
|
match status {
|
||||||
|
ComfyUIStatus::Running { pid, .. } => ComfyUIStatus::Running {
|
||||||
|
pid,
|
||||||
|
uptime_secs: self.uptime_secs().await,
|
||||||
|
},
|
||||||
|
other => other,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn status_response(&self) -> ComfyUIStatusResponse {
|
||||||
|
let status = self.status().await;
|
||||||
|
let base_url = self.internal_base_url();
|
||||||
|
let mode = if self.config.enabled {
|
||||||
|
"embedded"
|
||||||
|
} else {
|
||||||
|
"external"
|
||||||
|
}
|
||||||
|
.to_string();
|
||||||
|
|
||||||
|
match status {
|
||||||
|
ComfyUIStatus::Disabled => ComfyUIStatusResponse {
|
||||||
|
status: "disabled".to_string(),
|
||||||
|
pid: None,
|
||||||
|
uptime_secs: 0,
|
||||||
|
mode,
|
||||||
|
model_backend: self.model_backend_url(),
|
||||||
|
base_url,
|
||||||
|
version: None,
|
||||||
|
last_error: None,
|
||||||
|
},
|
||||||
|
ComfyUIStatus::Starting => ComfyUIStatusResponse {
|
||||||
|
status: "starting".to_string(),
|
||||||
|
pid: None,
|
||||||
|
uptime_secs: 0,
|
||||||
|
mode,
|
||||||
|
model_backend: self.model_backend_url(),
|
||||||
|
base_url,
|
||||||
|
version: None,
|
||||||
|
last_error: None,
|
||||||
|
},
|
||||||
|
ComfyUIStatus::Running { pid, uptime_secs } => ComfyUIStatusResponse {
|
||||||
|
status: "running".to_string(),
|
||||||
|
pid,
|
||||||
|
uptime_secs,
|
||||||
|
mode,
|
||||||
|
model_backend: self.model_backend_url(),
|
||||||
|
base_url,
|
||||||
|
version: None,
|
||||||
|
last_error: None,
|
||||||
|
},
|
||||||
|
ComfyUIStatus::Unhealthy { last_error } => ComfyUIStatusResponse {
|
||||||
|
status: "unhealthy".to_string(),
|
||||||
|
pid: None,
|
||||||
|
uptime_secs: self.uptime_secs().await,
|
||||||
|
mode,
|
||||||
|
model_backend: self.model_backend_url(),
|
||||||
|
base_url,
|
||||||
|
version: None,
|
||||||
|
last_error: Some(last_error),
|
||||||
|
},
|
||||||
|
ComfyUIStatus::Stopped => ComfyUIStatusResponse {
|
||||||
|
status: "stopped".to_string(),
|
||||||
|
pid: None,
|
||||||
|
uptime_secs: 0,
|
||||||
|
mode,
|
||||||
|
model_backend: self.model_backend_url(),
|
||||||
|
base_url,
|
||||||
|
version: None,
|
||||||
|
last_error: None,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn start(&self) -> anyhow::Result<()> {
|
||||||
|
if !self.config.enabled {
|
||||||
|
*self.status.write().await = ComfyUIStatus::Disabled;
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
|
||||||
|
if self.child.lock().await.is_some() {
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
|
||||||
|
*self.status.write().await = ComfyUIStatus::Starting;
|
||||||
|
|
||||||
|
let install_dir = self
|
||||||
|
.config
|
||||||
|
.install_dir
|
||||||
|
.as_ref()
|
||||||
|
.ok_or_else(|| anyhow::anyhow!("BOOSAPI_COMFYUI__INSTALL_DIR is required"))?;
|
||||||
|
let main_py = install_dir.join("main.py");
|
||||||
|
if !main_py.exists() {
|
||||||
|
let error = format!("ComfyUI main.py not found at {}", main_py.display());
|
||||||
|
*self.status.write().await = ComfyUIStatus::Unhealthy {
|
||||||
|
last_error: error.clone(),
|
||||||
|
};
|
||||||
|
anyhow::bail!(error);
|
||||||
|
}
|
||||||
|
|
||||||
|
write_boosapi_config(install_dir, &self.model_backend_url(), &self.proxy_token)?;
|
||||||
|
|
||||||
|
let mut command = Command::new(&self.config.python_bin);
|
||||||
|
command
|
||||||
|
.arg("main.py")
|
||||||
|
.arg("--listen")
|
||||||
|
.arg("127.0.0.1")
|
||||||
|
.arg("--port")
|
||||||
|
.arg(self.config.port.to_string())
|
||||||
|
.current_dir(install_dir);
|
||||||
|
|
||||||
|
for arg in &self.config.extra_args {
|
||||||
|
if !arg.trim().is_empty() {
|
||||||
|
command.arg(arg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let child = command.spawn()?;
|
||||||
|
let pid = child.id();
|
||||||
|
|
||||||
|
*self.child.lock().await = Some(child);
|
||||||
|
*self.started_at.lock().await = Some(Instant::now());
|
||||||
|
*self.status.write().await = ComfyUIStatus::Running {
|
||||||
|
pid,
|
||||||
|
uptime_secs: 0,
|
||||||
|
};
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn stop(&self) -> anyhow::Result<()> {
|
||||||
|
let mut child_slot = self.child.lock().await;
|
||||||
|
if let Some(child) = child_slot.as_mut() {
|
||||||
|
if let Err(err) = child.start_kill() {
|
||||||
|
*self.status.write().await = ComfyUIStatus::Unhealthy {
|
||||||
|
last_error: err.to_string(),
|
||||||
|
};
|
||||||
|
return Err(err.into());
|
||||||
|
}
|
||||||
|
let _ = child.wait().await;
|
||||||
|
}
|
||||||
|
|
||||||
|
*child_slot = None;
|
||||||
|
*self.started_at.lock().await = None;
|
||||||
|
*self.status.write().await = if self.config.enabled {
|
||||||
|
ComfyUIStatus::Stopped
|
||||||
|
} else {
|
||||||
|
ComfyUIStatus::Disabled
|
||||||
|
};
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn restart(&self) -> anyhow::Result<()> {
|
||||||
|
self.stop().await?;
|
||||||
|
self.start().await
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn health_check(&self) -> bool {
|
||||||
|
let url = format!("{}/system_stats", self.internal_base_url());
|
||||||
|
match reqwest::Client::new().get(url).send().await {
|
||||||
|
Ok(response) => response.status().is_success(),
|
||||||
|
Err(_) => false,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn uptime_secs(&self) -> u64 {
|
||||||
|
self.started_at
|
||||||
|
.lock()
|
||||||
|
.await
|
||||||
|
.map(|started| started.elapsed().as_secs())
|
||||||
|
.unwrap_or(0)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn write_boosapi_config(
|
||||||
|
install_dir: &Path,
|
||||||
|
api_base: &str,
|
||||||
|
proxy_token: &str,
|
||||||
|
) -> anyhow::Result<()> {
|
||||||
|
let config = format!(
|
||||||
|
"boosapi:\n api_base: \"{}\"\n api_key: \"{}\"\n",
|
||||||
|
api_base.trim_end_matches('/'),
|
||||||
|
proxy_token
|
||||||
|
);
|
||||||
|
std::fs::write(install_dir.join("boosapi_config.yaml"), config)?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
pub mod announcement;
|
pub mod announcement;
|
||||||
pub mod billing;
|
pub mod billing;
|
||||||
|
pub mod comfyui_process;
|
||||||
pub mod cron;
|
pub mod cron;
|
||||||
pub mod notification;
|
pub mod notification;
|
||||||
pub mod oauth;
|
pub mod oauth;
|
||||||
|
|||||||
@@ -0,0 +1,9 @@
|
|||||||
|
// BoosAPI custom headers. Do not introduce x-metapi-* headers.
|
||||||
|
|
||||||
|
pub const HEADER_SITE_ID: &str = "x-boosapi-site-id";
|
||||||
|
pub const HEADER_TOKEN_ID: &str = "x-boosapi-token-id";
|
||||||
|
pub const HEADER_CHANNEL_ID: &str = "x-boosapi-channel-id";
|
||||||
|
pub const HEADER_REQUEST_ID: &str = "x-boosapi-request-id";
|
||||||
|
pub const HEADER_UPSTREAM_MODEL: &str = "x-boosapi-upstream-model";
|
||||||
|
pub const HEADER_TRUNCATED: &str = "x-boosapi-truncated";
|
||||||
|
pub const HEADER_PROXIED: &str = "x-boosapi-proxied";
|
||||||
@@ -1,10 +1,12 @@
|
|||||||
// Shared utilities
|
// Shared utilities
|
||||||
|
|
||||||
|
pub mod headers;
|
||||||
pub mod json;
|
pub mod json;
|
||||||
pub mod pagination;
|
pub mod pagination;
|
||||||
pub mod sse;
|
pub mod sse;
|
||||||
pub mod time;
|
pub mod time;
|
||||||
|
|
||||||
|
pub use headers::*;
|
||||||
pub use json::*;
|
pub use json::*;
|
||||||
pub use pagination::*;
|
pub use pagination::*;
|
||||||
pub use sse::*;
|
pub use sse::*;
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
// Canonical response — unified output format for all providers
|
// Canonical response — unified output format for all providers
|
||||||
use crate::transformers::canonical::request::CanonicalMessage;
|
use crate::transformers::canonical::request::CanonicalMessage;
|
||||||
use crate::transformers::canonical::types::{StreamDelta, ToolCall, Usage};
|
use crate::transformers::canonical::types::{StreamDelta, Usage};
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
/// Unified internal response format
|
/// Unified internal response format
|
||||||
|
|||||||
@@ -1,4 +1,6 @@
|
|||||||
// OpenAI chat/completions request → CanonicalRequest parser
|
// OpenAI chat/completions request → CanonicalRequest parser
|
||||||
|
#![allow(clippy::bind_instead_of_map, clippy::manual_map)]
|
||||||
|
|
||||||
use crate::transformers::canonical::{
|
use crate::transformers::canonical::{
|
||||||
CanonicalMessage, CanonicalRequest, ContentPart, ReasoningConfig, Role, ToolCall, ToolChoice,
|
CanonicalMessage, CanonicalRequest, ContentPart, ReasoningConfig, Role, ToolCall, ToolChoice,
|
||||||
ToolDefinition,
|
ToolDefinition,
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
// CanonicalResponse → OpenAI /v1/chat/completions JSON formatter
|
// CanonicalResponse → OpenAI /v1/chat/completions JSON formatter
|
||||||
use crate::transformers::canonical::{
|
use crate::transformers::canonical::{
|
||||||
CanonicalChoice, CanonicalMessage, CanonicalResponse, ContentPart, Role, Usage,
|
CanonicalChoice, CanonicalMessage, CanonicalResponse, ContentPart, Role,
|
||||||
};
|
};
|
||||||
use serde_json::{json, Value};
|
use serde_json::{json, Value};
|
||||||
|
|
||||||
@@ -115,7 +115,7 @@ fn format_content_parts(parts: &[ContentPart]) -> Value {
|
|||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
use crate::transformers::canonical::{CanonicalMessage, CanonicalResponse};
|
use crate::transformers::canonical::CanonicalResponse;
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_format_simple_response() {
|
fn test_format_simple_response() {
|
||||||
|
|||||||
@@ -1,4 +1,6 @@
|
|||||||
// OpenAI SSE stream parser and formatter
|
// OpenAI SSE stream parser and formatter
|
||||||
|
#![allow(clippy::unnecessary_filter_map)]
|
||||||
|
|
||||||
use crate::transformers::canonical::{
|
use crate::transformers::canonical::{
|
||||||
CanonicalStreamChoice, CanonicalStreamEvent, Role, StreamDelta, Usage,
|
CanonicalStreamChoice, CanonicalStreamEvent, Role, StreamDelta, Usage,
|
||||||
};
|
};
|
||||||
|
|||||||
+1
-1
@@ -416,7 +416,7 @@ export const sidebarGroups = [
|
|||||||
const topNavItems = [
|
const topNavItems = [
|
||||||
{ label: '控制台', to: '/' },
|
{ label: '控制台', to: '/' },
|
||||||
{ label: '模型广场', to: '/models' },
|
{ label: '模型广场', to: '/models' },
|
||||||
{ label: 'ComfyUI', to: '/video-agent' },
|
{ label: '视频工作台', to: '/video-agent' },
|
||||||
{ label: '视频助手', to: '/comfyui-agent' },
|
{ label: '视频助手', to: '/comfyui-agent' },
|
||||||
{ label: '关于', to: '/about' },
|
{ label: '关于', to: '/about' },
|
||||||
];
|
];
|
||||||
|
|||||||
+260
-31
@@ -243,6 +243,184 @@ async function request<T = any>(
|
|||||||
return res.json() as Promise<T>;
|
return res.json() as Promise<T>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function unwrapArrayResponse(value: any, keys: string[] = ["items", "data"]): any[] {
|
||||||
|
if (Array.isArray(value)) return value;
|
||||||
|
for (const key of keys) {
|
||||||
|
if (Array.isArray(value?.[key])) return value[key];
|
||||||
|
}
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
function wrapItemsResponse(value: any): any {
|
||||||
|
if (Array.isArray(value)) return { success: true, items: value };
|
||||||
|
if (Array.isArray(value?.items)) return value;
|
||||||
|
if (Array.isArray(value?.data)) return { ...value, success: value.success ?? true, items: value.data };
|
||||||
|
return { ...(value || {}), success: value?.success ?? true, items: [] };
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseJsonArrayField(value: any): any[] {
|
||||||
|
if (Array.isArray(value)) return value;
|
||||||
|
if (typeof value !== "string" || !value.trim()) return [];
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(value);
|
||||||
|
return Array.isArray(parsed) ? parsed : [];
|
||||||
|
} catch {
|
||||||
|
return value
|
||||||
|
.split(",")
|
||||||
|
.map((item) => item.trim())
|
||||||
|
.filter(Boolean);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeRouteRow(row: any): any {
|
||||||
|
if (!row || typeof row !== "object") return row;
|
||||||
|
return {
|
||||||
|
...row,
|
||||||
|
modelPattern: row.modelPattern ?? row.model_pattern ?? "",
|
||||||
|
modelMapping: row.modelMapping ?? row.model_mapping ?? null,
|
||||||
|
displayName: row.displayName ?? row.display_name ?? null,
|
||||||
|
displayIcon: row.displayIcon ?? row.display_icon ?? null,
|
||||||
|
routingStrategy: row.routingStrategy ?? row.routing_strategy ?? "weighted",
|
||||||
|
routeMode: row.routeMode ?? row.route_mode ?? "pattern",
|
||||||
|
updatedAt: row.updatedAt ?? row.updated_at ?? null,
|
||||||
|
createdAt: row.createdAt ?? row.created_at ?? null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeDownstreamKeyRow(row: any): any {
|
||||||
|
if (!row || typeof row !== "object") return row;
|
||||||
|
return {
|
||||||
|
...row,
|
||||||
|
keyMasked: row.keyMasked ?? row.key_masked ?? (row.key ? `${String(row.key).slice(0, 8)}...` : ""),
|
||||||
|
groupName: row.groupName ?? row.group_name ?? null,
|
||||||
|
usedCost: Number(row.usedCost ?? row.used_cost ?? 0),
|
||||||
|
usedRequests: Number(row.usedRequests ?? row.used_requests ?? 0),
|
||||||
|
maxCost: row.maxCost ?? row.max_cost ?? null,
|
||||||
|
maxRequests: row.maxRequests ?? row.max_requests ?? null,
|
||||||
|
supportedModels: row.supportedModels ?? parseJsonArrayField(row.supported_models),
|
||||||
|
allowedRouteIds: (row.allowedRouteIds ?? parseJsonArrayField(row.allowed_route_ids))
|
||||||
|
.map((id: any) => Number(id))
|
||||||
|
.filter((id: number) => Number.isFinite(id)),
|
||||||
|
expiresAt: row.expiresAt ?? row.expires_at ?? null,
|
||||||
|
lastUsedAt: row.lastUsedAt ?? row.last_used_at ?? null,
|
||||||
|
createdAt: row.createdAt ?? row.created_at ?? null,
|
||||||
|
updatedAt: row.updatedAt ?? row.updated_at ?? null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeProxyLogSummary(summary: any): ProxyLogsSummary {
|
||||||
|
return {
|
||||||
|
totalCount: Number(summary?.totalCount ?? summary?.total ?? 0),
|
||||||
|
successCount: Number(summary?.successCount ?? summary?.success ?? 0),
|
||||||
|
failedCount: Number(summary?.failedCount ?? summary?.failed ?? 0),
|
||||||
|
totalCost: Number(summary?.totalCost ?? summary?.cost ?? 0),
|
||||||
|
totalTokensAll: Number(summary?.totalTokensAll ?? summary?.totalTokens ?? 0),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeProxyLogRow(row: any): any {
|
||||||
|
if (!row || typeof row !== "object") return row;
|
||||||
|
return {
|
||||||
|
...row,
|
||||||
|
routeId: row.routeId ?? row.route_id ?? null,
|
||||||
|
channelId: row.channelId ?? row.channel_id ?? null,
|
||||||
|
accountId: row.accountId ?? row.account_id ?? null,
|
||||||
|
downstreamApiKeyId:
|
||||||
|
row.downstreamApiKeyId ?? row.downstream_api_key_id ?? null,
|
||||||
|
modelRequested: row.modelRequested ?? row.model_requested ?? "",
|
||||||
|
modelActual: row.modelActual ?? row.model_actual ?? null,
|
||||||
|
httpStatus: row.httpStatus ?? row.http_status ?? null,
|
||||||
|
latencyMs: row.latencyMs ?? row.latency_ms ?? null,
|
||||||
|
firstByteLatencyMs:
|
||||||
|
row.firstByteLatencyMs ?? row.first_byte_latency_ms ?? null,
|
||||||
|
promptTokens: row.promptTokens ?? row.prompt_tokens ?? null,
|
||||||
|
completionTokens: row.completionTokens ?? row.completion_tokens ?? null,
|
||||||
|
totalTokens: row.totalTokens ?? row.total_tokens ?? null,
|
||||||
|
estimatedCost: Number(row.estimatedCost ?? row.estimated_cost ?? 0),
|
||||||
|
errorMessage: row.errorMessage ?? row.error_message ?? null,
|
||||||
|
retryCount: row.retryCount ?? row.retry_count ?? 0,
|
||||||
|
isStream: row.isStream ?? row.is_stream ?? null,
|
||||||
|
clientFamily: row.clientFamily ?? row.client_family ?? null,
|
||||||
|
clientAppId: row.clientAppId ?? row.client_app_id ?? null,
|
||||||
|
clientAppName: row.clientAppName ?? row.client_app_name ?? null,
|
||||||
|
clientConfidence: row.clientConfidence ?? row.client_confidence ?? null,
|
||||||
|
createdAt: row.createdAt ?? row.created_at ?? null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeProxyLogsResponse(response: any): ProxyLogsResponse {
|
||||||
|
const items = unwrapArrayResponse(response, ["items", "data"]).map(
|
||||||
|
normalizeProxyLogRow,
|
||||||
|
);
|
||||||
|
return {
|
||||||
|
...(response || {}),
|
||||||
|
items,
|
||||||
|
total: Number(response?.total ?? items.length),
|
||||||
|
page: Number(response?.page ?? 1),
|
||||||
|
pageSize: Number(response?.pageSize ?? response?.page_size ?? items.length),
|
||||||
|
clientOptions: Array.isArray(response?.clientOptions)
|
||||||
|
? response.clientOptions
|
||||||
|
: [],
|
||||||
|
summary: normalizeProxyLogSummary(response?.summary),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeProxyDebugTraceRow(row: any): ProxyDebugTraceListItem {
|
||||||
|
return {
|
||||||
|
...row,
|
||||||
|
id: Number(row?.id ?? 0),
|
||||||
|
createdAt: row?.createdAt ?? row?.created_at ?? "",
|
||||||
|
downstreamPath: row?.downstreamPath ?? row?.downstream_path ?? "",
|
||||||
|
clientKind: row?.clientKind ?? row?.client_kind ?? null,
|
||||||
|
sessionId: row?.sessionId ?? row?.session_id ?? null,
|
||||||
|
requestedModel: row?.requestedModel ?? row?.requested_model ?? null,
|
||||||
|
selectedChannelId:
|
||||||
|
row?.selectedChannelId ?? row?.selected_channel_id ?? null,
|
||||||
|
finalStatus: row?.finalStatus ?? row?.final_status ?? null,
|
||||||
|
finalHttpStatus: row?.finalHttpStatus ?? row?.final_http_status ?? null,
|
||||||
|
finalUpstreamPath: row?.finalUpstreamPath ?? row?.final_upstream_path ?? null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeProxyDebugTraceDetail(response: any): ProxyDebugTraceDetail {
|
||||||
|
const trace = response?.trace ?? response?.data?.trace ?? response ?? {};
|
||||||
|
const attempts = Array.isArray(response?.attempts)
|
||||||
|
? response.attempts
|
||||||
|
: Array.isArray(response?.data?.attempts)
|
||||||
|
? response.data.attempts
|
||||||
|
: [];
|
||||||
|
return {
|
||||||
|
trace: {
|
||||||
|
...trace,
|
||||||
|
id: Number(trace?.id ?? response?.id ?? 0),
|
||||||
|
createdAt: trace?.createdAt ?? trace?.created_at ?? null,
|
||||||
|
updatedAt: trace?.updatedAt ?? trace?.updated_at ?? null,
|
||||||
|
downstreamPath: trace?.downstreamPath ?? trace?.downstream_path ?? null,
|
||||||
|
clientKind: trace?.clientKind ?? trace?.client_kind ?? null,
|
||||||
|
sessionId: trace?.sessionId ?? trace?.session_id ?? null,
|
||||||
|
traceHint: trace?.traceHint ?? trace?.trace_hint ?? null,
|
||||||
|
requestedModel: trace?.requestedModel ?? trace?.requested_model ?? null,
|
||||||
|
stickySessionKey:
|
||||||
|
trace?.stickySessionKey ?? trace?.sticky_session_key ?? null,
|
||||||
|
stickyHitChannelId:
|
||||||
|
trace?.stickyHitChannelId ?? trace?.sticky_hit_channel_id ?? null,
|
||||||
|
selectedChannelId:
|
||||||
|
trace?.selectedChannelId ?? trace?.selected_channel_id ?? null,
|
||||||
|
selectedRouteId: trace?.selectedRouteId ?? trace?.selected_route_id ?? null,
|
||||||
|
selectedAccountId:
|
||||||
|
trace?.selectedAccountId ?? trace?.selected_account_id ?? null,
|
||||||
|
selectedSiteId: trace?.selectedSiteId ?? trace?.selected_site_id ?? null,
|
||||||
|
selectedSitePlatform:
|
||||||
|
trace?.selectedSitePlatform ?? trace?.selected_site_platform ?? null,
|
||||||
|
finalStatus: trace?.finalStatus ?? trace?.final_status ?? null,
|
||||||
|
finalHttpStatus: trace?.finalHttpStatus ?? trace?.final_http_status ?? null,
|
||||||
|
finalUpstreamPath:
|
||||||
|
trace?.finalUpstreamPath ?? trace?.final_upstream_path ?? null,
|
||||||
|
},
|
||||||
|
attempts,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
async function streamSse(
|
async function streamSse(
|
||||||
url: string,
|
url: string,
|
||||||
handlers: {
|
handlers: {
|
||||||
@@ -840,11 +1018,16 @@ export type DownstreamApiKeyTrendResponse = {
|
|||||||
|
|
||||||
export const api = {
|
export const api = {
|
||||||
// Sites
|
// Sites
|
||||||
getSites: () => request("/api/sites"),
|
getSites: async () => {
|
||||||
|
const result = await request<any>("/api/sites");
|
||||||
|
return Array.isArray(result?.data) ? result.data : result;
|
||||||
|
},
|
||||||
addSite: (data: any) =>
|
addSite: (data: any) =>
|
||||||
request("/api/sites", { method: "POST", body: JSON.stringify(data) }),
|
request("/api/sites", { method: "POST", body: JSON.stringify(data) })
|
||||||
|
.then((result: any) => result?.data ?? result),
|
||||||
updateSite: (id: number, data: any) =>
|
updateSite: (id: number, data: any) =>
|
||||||
request(`/api/sites/${id}`, { method: "PUT", body: JSON.stringify(data) }),
|
request(`/api/sites/${id}`, { method: "PUT", body: JSON.stringify(data) })
|
||||||
|
.then((result: any) => result?.data ?? result),
|
||||||
deleteSite: (id: number) => request(`/api/sites/${id}`, { method: "DELETE" }),
|
deleteSite: (id: number) => request(`/api/sites/${id}`, { method: "DELETE" }),
|
||||||
batchUpdateSites: (data: any) =>
|
batchUpdateSites: (data: any) =>
|
||||||
request("/api/sites/batch", { method: "POST", body: JSON.stringify(data) }),
|
request("/api/sites/batch", { method: "POST", body: JSON.stringify(data) }),
|
||||||
@@ -985,8 +1168,10 @@ export const api = {
|
|||||||
triggerCheckinAll: () => request("/api/checkin/trigger", { method: "POST" }),
|
triggerCheckinAll: () => request("/api/checkin/trigger", { method: "POST" }),
|
||||||
triggerCheckin: (id: number) =>
|
triggerCheckin: (id: number) =>
|
||||||
request(`/api/checkin/trigger/${id}`, { method: "POST" }),
|
request(`/api/checkin/trigger/${id}`, { method: "POST" }),
|
||||||
getCheckinLogs: (params?: string) =>
|
getCheckinLogs: async (params?: string) =>
|
||||||
request(`/api/checkin/logs${params ? "?" + params : ""}`),
|
unwrapArrayResponse(
|
||||||
|
await request(`/api/checkin/logs${params ? "?" + params : ""}`),
|
||||||
|
),
|
||||||
updateCheckinSchedule: (cron: string) =>
|
updateCheckinSchedule: (cron: string) =>
|
||||||
request("/api/checkin/schedule", {
|
request("/api/checkin/schedule", {
|
||||||
method: "PUT",
|
method: "PUT",
|
||||||
@@ -994,11 +1179,28 @@ export const api = {
|
|||||||
}),
|
}),
|
||||||
|
|
||||||
// Routes
|
// Routes
|
||||||
getRoutes: () => request("/api/routes"),
|
getRoutes: async () =>
|
||||||
getRoutesLite: () => request("/api/routes/lite"),
|
unwrapArrayResponse(await request("/api/routes"), [
|
||||||
getRoutesSummary: () => request("/api/routes/summary"),
|
"routes",
|
||||||
|
"items",
|
||||||
|
"data",
|
||||||
|
]).map(normalizeRouteRow),
|
||||||
|
getRoutesLite: async () =>
|
||||||
|
unwrapArrayResponse(await request("/api/routes/lite"), [
|
||||||
|
"routes",
|
||||||
|
"items",
|
||||||
|
"data",
|
||||||
|
]).map(normalizeRouteRow),
|
||||||
|
getRoutesSummary: async () =>
|
||||||
|
unwrapArrayResponse(await request("/api/routes/summary"), [
|
||||||
|
"routes",
|
||||||
|
"items",
|
||||||
|
"data",
|
||||||
|
]).map(normalizeRouteRow),
|
||||||
getRouteChannels: (routeId: number) =>
|
getRouteChannels: (routeId: number) =>
|
||||||
request(`/api/routes/${routeId}/channels`),
|
request(`/api/routes/${routeId}/channels`).then((value) =>
|
||||||
|
unwrapArrayResponse(value, ["channels", "items", "data"]),
|
||||||
|
),
|
||||||
batchAddChannels: (
|
batchAddChannels: (
|
||||||
routeId: number,
|
routeId: number,
|
||||||
channels: Array<{
|
channels: Array<{
|
||||||
@@ -1113,22 +1315,24 @@ export const api = {
|
|||||||
...(options?.refresh ? { refresh: 1 } : {}),
|
...(options?.refresh ? { refresh: 1 } : {}),
|
||||||
})}`,
|
})}`,
|
||||||
),
|
),
|
||||||
getProxyLogs: (params?: ProxyLogsQuery) =>
|
getProxyLogs: async (params?: ProxyLogsQuery) =>
|
||||||
request(
|
normalizeProxyLogsResponse(
|
||||||
`/api/stats/proxy-logs${buildQueryString(params)}`,
|
await request(`/api/stats/proxy-logs${buildQueryString(params)}`),
|
||||||
) as Promise<ProxyLogsResponse>,
|
),
|
||||||
getProxyLogsQuery: (params?: ProxyLogsQuery) =>
|
getProxyLogsQuery: async (params?: ProxyLogsQuery) =>
|
||||||
request(
|
normalizeProxyLogsResponse(
|
||||||
|
await request(
|
||||||
`/api/stats/proxy-logs${buildQueryString({
|
`/api/stats/proxy-logs${buildQueryString({
|
||||||
...params,
|
...params,
|
||||||
view: "query",
|
view: "query",
|
||||||
})}`,
|
})}`,
|
||||||
) as Promise<{
|
),
|
||||||
|
) as {
|
||||||
items: ProxyLogsResponse["items"];
|
items: ProxyLogsResponse["items"];
|
||||||
total: number;
|
total: number;
|
||||||
page: number;
|
page: number;
|
||||||
pageSize: number;
|
pageSize: number;
|
||||||
}>,
|
},
|
||||||
getProxyLogsMeta: (
|
getProxyLogsMeta: (
|
||||||
params?: Omit<ProxyLogsQuery, "limit" | "offset"> & {
|
params?: Omit<ProxyLogsQuery, "limit" | "offset"> & {
|
||||||
refresh?: number | boolean;
|
refresh?: number | boolean;
|
||||||
@@ -1146,8 +1350,14 @@ export const api = {
|
|||||||
...(refresh !== undefined ? { refresh } : {}),
|
...(refresh !== undefined ? { refresh } : {}),
|
||||||
} as Record<string, string | number | boolean | null | undefined>;
|
} as Record<string, string | number | boolean | null | undefined>;
|
||||||
if (refresh === undefined) delete queryParams.refresh;
|
if (refresh === undefined) delete queryParams.refresh;
|
||||||
return request(
|
return request(`/api/stats/proxy-logs${buildQueryString(queryParams)}`).then(
|
||||||
`/api/stats/proxy-logs${buildQueryString(queryParams)}`,
|
(response: any) => ({
|
||||||
|
clientOptions: Array.isArray(response?.clientOptions)
|
||||||
|
? response.clientOptions
|
||||||
|
: [],
|
||||||
|
summary: normalizeProxyLogSummary(response?.summary),
|
||||||
|
sites: Array.isArray(response?.sites) ? response.sites : [],
|
||||||
|
}),
|
||||||
) as Promise<{
|
) as Promise<{
|
||||||
clientOptions: ProxyLogsResponse["clientOptions"];
|
clientOptions: ProxyLogsResponse["clientOptions"];
|
||||||
summary: ProxyLogsResponse["summary"];
|
summary: ProxyLogsResponse["summary"];
|
||||||
@@ -1155,14 +1365,20 @@ export const api = {
|
|||||||
}>;
|
}>;
|
||||||
},
|
},
|
||||||
getProxyLogDetail: (id: number) =>
|
getProxyLogDetail: (id: number) =>
|
||||||
request(`/api/stats/proxy-logs/${id}`) as Promise<ProxyLogDetail>,
|
request(`/api/stats/proxy-logs/${id}`).then((response: any) =>
|
||||||
|
normalizeProxyLogRow(response?.item ?? response?.data ?? response),
|
||||||
|
) as Promise<ProxyLogDetail>,
|
||||||
getProxyDebugTraces: (params?: { limit?: number }) =>
|
getProxyDebugTraces: (params?: { limit?: number }) =>
|
||||||
request(
|
request(`/api/stats/proxy-debug/traces${buildQueryString(params)}`).then(
|
||||||
`/api/stats/proxy-debug/traces${buildQueryString(params)}`,
|
(response: any) => ({
|
||||||
|
items: unwrapArrayResponse(response, ["items", "data"]).map(
|
||||||
|
normalizeProxyDebugTraceRow,
|
||||||
|
),
|
||||||
|
}),
|
||||||
) as Promise<ProxyDebugTracesResponse>,
|
) as Promise<ProxyDebugTracesResponse>,
|
||||||
getProxyDebugTraceDetail: (id: number) =>
|
getProxyDebugTraceDetail: (id: number) =>
|
||||||
request(
|
request(`/api/stats/proxy-debug/traces/${id}`).then(
|
||||||
`/api/stats/proxy-debug/traces/${id}`,
|
normalizeProxyDebugTraceDetail,
|
||||||
) as Promise<ProxyDebugTraceDetail>,
|
) as Promise<ProxyDebugTraceDetail>,
|
||||||
checkModels: (accountId: number) =>
|
checkModels: (accountId: number) =>
|
||||||
request(`/api/models/check/${accountId}`, { method: "POST" }),
|
request(`/api/models/check/${accountId}`, { method: "POST" }),
|
||||||
@@ -1281,8 +1497,10 @@ export const api = {
|
|||||||
}) as Promise<{ success: true }>,
|
}) as Promise<{ success: true }>,
|
||||||
|
|
||||||
// Events
|
// Events
|
||||||
getEvents: (params?: string) =>
|
getEvents: async (params?: string) =>
|
||||||
request(`/api/events${params ? "?" + params : ""}`),
|
unwrapArrayResponse(
|
||||||
|
await request(`/api/events${params ? "?" + params : ""}`),
|
||||||
|
),
|
||||||
getEventCount: () => request("/api/events/count"),
|
getEventCount: () => request("/api/events/count"),
|
||||||
markEventRead: (id: number) =>
|
markEventRead: (id: number) =>
|
||||||
request(`/api/events/${id}/read`, { method: "POST" }),
|
request(`/api/events/${id}/read`, { method: "POST" }),
|
||||||
@@ -1394,7 +1612,10 @@ export const api = {
|
|||||||
body: JSON.stringify(data),
|
body: JSON.stringify(data),
|
||||||
timeoutMs: 120_000,
|
timeoutMs: 120_000,
|
||||||
}),
|
}),
|
||||||
getDownstreamApiKeys: () => request("/api/downstream-keys"),
|
getDownstreamApiKeys: async () => {
|
||||||
|
const response = wrapItemsResponse(await request("/api/downstream-keys"));
|
||||||
|
return { ...response, items: response.items.map(normalizeDownstreamKeyRow) };
|
||||||
|
},
|
||||||
createDownstreamApiKey: (data: any) =>
|
createDownstreamApiKey: (data: any) =>
|
||||||
request("/api/downstream-keys", {
|
request("/api/downstream-keys", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
@@ -1425,11 +1646,16 @@ export const api = {
|
|||||||
request(`/api/downstream-keys/${id}/reset-usage`, {
|
request(`/api/downstream-keys/${id}/reset-usage`, {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
}),
|
}),
|
||||||
getDownstreamApiKeysSummary: (params?: {
|
getDownstreamApiKeysSummary: async (params?: {
|
||||||
range?: "24h" | "7d" | "all";
|
range?: "24h" | "7d" | "all";
|
||||||
status?: "all" | "enabled" | "disabled";
|
status?: "all" | "enabled" | "disabled";
|
||||||
search?: string;
|
search?: string;
|
||||||
}) => request(`/api/downstream-keys/summary${buildQueryString(params)}`),
|
}) => {
|
||||||
|
const response = wrapItemsResponse(
|
||||||
|
await request(`/api/downstream-keys/summary${buildQueryString(params)}`),
|
||||||
|
);
|
||||||
|
return { ...response, items: response.items.map(normalizeDownstreamKeyRow) };
|
||||||
|
},
|
||||||
getDownstreamApiKeyOverview: (id: number) =>
|
getDownstreamApiKeyOverview: (id: number) =>
|
||||||
request(`/api/downstream-keys/${id}/overview`),
|
request(`/api/downstream-keys/${id}/overview`),
|
||||||
getDownstreamApiKeyTrend: (
|
getDownstreamApiKeyTrend: (
|
||||||
@@ -1586,9 +1812,12 @@ export const api = {
|
|||||||
},
|
},
|
||||||
|
|
||||||
// Admin user management
|
// Admin user management
|
||||||
getUsers: () => request("/api/admin/users"),
|
getUsers: (params?: URLSearchParams) =>
|
||||||
|
request(`/api/admin/users${params ? `?${params.toString()}` : ""}`),
|
||||||
getUserById: (id: number) => request(`/api/admin/users/${id}`),
|
getUserById: (id: number) => request(`/api/admin/users/${id}`),
|
||||||
updateUser: (id: number, data: { role?: string; status?: string; username?: string }) =>
|
createUser: (data: { username: string; email: string; password: string; role?: string }) =>
|
||||||
|
request("/api/admin/users", { method: "POST", body: JSON.stringify(data) }),
|
||||||
|
updateUser: (id: number, data: { role?: string; status?: string; username?: string; email?: string; password?: string }) =>
|
||||||
request(`/api/admin/users/${id}`, { method: "PATCH", body: JSON.stringify(data) }),
|
request(`/api/admin/users/${id}`, { method: "PATCH", body: JSON.stringify(data) }),
|
||||||
deleteUser: (id: number) =>
|
deleteUser: (id: number) =>
|
||||||
request(`/api/admin/users/${id}`, { method: "DELETE" }),
|
request(`/api/admin/users/${id}`, { method: "DELETE" }),
|
||||||
|
|||||||
@@ -5479,6 +5479,274 @@ textarea {
|
|||||||
color: var(--color-text-muted);
|
color: var(--color-text-muted);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ===== User Management ===== */
|
||||||
|
.user-management-page {
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-management-alert {
|
||||||
|
margin-bottom: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-management-stats {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||||
|
gap: 12px;
|
||||||
|
margin-bottom: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-stat-card {
|
||||||
|
min-height: 82px;
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
border: 1px solid var(--color-border-light);
|
||||||
|
background: var(--color-bg-card);
|
||||||
|
box-shadow: var(--shadow-card);
|
||||||
|
padding: 14px 16px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
justify-content: space-between;
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-stat-label {
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-stat-value {
|
||||||
|
font-size: 24px;
|
||||||
|
line-height: 1;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--color-text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-stat-primary .user-stat-value {
|
||||||
|
color: var(--color-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-stat-success .user-stat-value {
|
||||||
|
color: var(--color-success);
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-stat-info .user-stat-value {
|
||||||
|
color: var(--color-info);
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-stat-danger .user-stat-value {
|
||||||
|
color: var(--color-danger);
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-management-toolbar-card {
|
||||||
|
padding: 14px;
|
||||||
|
margin-bottom: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-management-toolbar {
|
||||||
|
margin-bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-management-search {
|
||||||
|
min-width: 260px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-management-select {
|
||||||
|
height: 36px;
|
||||||
|
min-width: 120px;
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
background: var(--color-bg-card);
|
||||||
|
color: var(--color-text-primary);
|
||||||
|
padding: 0 10px;
|
||||||
|
font-size: 13px;
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-management-select:focus {
|
||||||
|
border-color: var(--color-primary);
|
||||||
|
box-shadow: 0 0 0 3px rgba(79, 70, 229, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-management-batchbar {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
margin-top: 12px;
|
||||||
|
padding-top: 12px;
|
||||||
|
border-top: 1px solid var(--color-border-light);
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--color-text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-management-table-card {
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-management-table-wrap {
|
||||||
|
overflow-x: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-management-table {
|
||||||
|
min-width: 900px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-management-check-col {
|
||||||
|
width: 42px;
|
||||||
|
text-align: center !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-management-actions-col {
|
||||||
|
width: 188px;
|
||||||
|
text-align: right !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-management-row-actions {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: flex-end;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-identity-cell {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-avatar {
|
||||||
|
width: 32px;
|
||||||
|
height: 32px;
|
||||||
|
border-radius: 8px;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
flex: 0 0 auto;
|
||||||
|
background: color-mix(in srgb, var(--color-primary) 14%, var(--color-bg-card));
|
||||||
|
color: var(--color-primary);
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-identity-text {
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-name-line {
|
||||||
|
display: flex;
|
||||||
|
align-items: baseline;
|
||||||
|
gap: 8px;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-name {
|
||||||
|
color: var(--color-text-primary);
|
||||||
|
font-weight: 600;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
max-width: 220px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-id {
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-email {
|
||||||
|
margin-top: 2px;
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
font-size: 12px;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
max-width: 280px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-management-pagination {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
padding: 12px 14px;
|
||||||
|
border-top: 1px solid var(--color-border-light);
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
font-size: 13px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-management-page-controls {
|
||||||
|
display: inline-flex;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-management-form {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
|
gap: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-management-form label {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 6px;
|
||||||
|
min-width: 0;
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-management-form input,
|
||||||
|
.user-management-form select {
|
||||||
|
width: 100%;
|
||||||
|
height: 38px;
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
background: var(--color-bg-card);
|
||||||
|
color: var(--color-text-primary);
|
||||||
|
padding: 0 10px;
|
||||||
|
font-size: 13px;
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-management-form input:focus,
|
||||||
|
.user-management-form select:focus {
|
||||||
|
border-color: var(--color-primary);
|
||||||
|
box-shadow: 0 0 0 3px rgba(79, 70, 229, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 900px) {
|
||||||
|
.user-management-stats {
|
||||||
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-management-search {
|
||||||
|
min-width: 100%;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 640px) {
|
||||||
|
.user-management-stats {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-management-select {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-management-form {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-management-pagination {
|
||||||
|
align-items: stretch;
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-management-page-controls {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-management-page-controls .btn {
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
.route-batch-bar {
|
.route-batch-bar {
|
||||||
position: sticky;
|
position: sticky;
|
||||||
top: calc(var(--topbar-height) + 8px);
|
top: calc(var(--topbar-height) + 8px);
|
||||||
|
|||||||
+583
-355
File diff suppressed because it is too large
Load Diff
+489
-159
@@ -1,204 +1,534 @@
|
|||||||
import React, { useState, useEffect, useCallback } from 'react';
|
import React, { useCallback, useEffect, useMemo, useState } from 'react';
|
||||||
import { api } from '../api.js';
|
import { api } from '../api.js';
|
||||||
|
import CenteredModal from '../components/CenteredModal.js';
|
||||||
|
import DeleteConfirmModal from '../components/DeleteConfirmModal.js';
|
||||||
|
|
||||||
|
type UserRole = 'admin' | 'user';
|
||||||
|
type UserStatus = 'active' | 'disabled';
|
||||||
|
|
||||||
interface User {
|
interface User {
|
||||||
id: number;
|
id: number;
|
||||||
username: string;
|
username: string;
|
||||||
email: string;
|
email: string;
|
||||||
role: string;
|
role: UserRole;
|
||||||
status: string;
|
status: UserStatus;
|
||||||
createdAt: string;
|
createdAt?: string;
|
||||||
|
created_at?: string;
|
||||||
|
updatedAt?: string;
|
||||||
|
updated_at?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface UserStats {
|
||||||
|
total: number;
|
||||||
|
active: number;
|
||||||
|
disabled: number;
|
||||||
|
admins: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface UserListMeta {
|
||||||
|
page: number;
|
||||||
|
pageSize: number;
|
||||||
|
total: number;
|
||||||
|
totalPages: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
type UserFormMode = 'create' | 'edit';
|
||||||
|
|
||||||
|
interface UserFormState {
|
||||||
|
username: string;
|
||||||
|
email: string;
|
||||||
|
password: string;
|
||||||
|
role: UserRole;
|
||||||
|
status: UserStatus;
|
||||||
|
}
|
||||||
|
|
||||||
|
const emptyForm: UserFormState = {
|
||||||
|
username: '',
|
||||||
|
email: '',
|
||||||
|
password: '',
|
||||||
|
role: 'user',
|
||||||
|
status: 'active',
|
||||||
|
};
|
||||||
|
|
||||||
|
function normalizeUsers(result: any): User[] {
|
||||||
|
const rawUsers = Array.isArray(result?.data)
|
||||||
|
? result.data
|
||||||
|
: Array.isArray(result?.users)
|
||||||
|
? result.users
|
||||||
|
: [];
|
||||||
|
return rawUsers.map((user: any) => ({
|
||||||
|
id: Number(user.id),
|
||||||
|
username: String(user.username || ''),
|
||||||
|
email: String(user.email || ''),
|
||||||
|
role: user.role === 'admin' ? 'admin' : 'user',
|
||||||
|
status: user.status === 'disabled' ? 'disabled' : 'active',
|
||||||
|
createdAt: user.createdAt ?? user.created_at,
|
||||||
|
created_at: user.created_at,
|
||||||
|
updatedAt: user.updatedAt ?? user.updated_at,
|
||||||
|
updated_at: user.updated_at,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeStats(result: any, users: User[]): UserStats {
|
||||||
|
const stats = result?.stats;
|
||||||
|
if (stats) {
|
||||||
|
return {
|
||||||
|
total: Number(stats.total || 0),
|
||||||
|
active: Number(stats.active || 0),
|
||||||
|
disabled: Number(stats.disabled || 0),
|
||||||
|
admins: Number(stats.admins || 0),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
total: users.length,
|
||||||
|
active: users.filter((user) => user.status === 'active').length,
|
||||||
|
disabled: users.filter((user) => user.status === 'disabled').length,
|
||||||
|
admins: users.filter((user) => user.role === 'admin').length,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeMeta(result: any, users: User[], page: number, pageSize: number): UserListMeta {
|
||||||
|
const meta = result?.meta || {};
|
||||||
|
const total = Number(meta.total ?? users.length);
|
||||||
|
return {
|
||||||
|
page: Number(meta.page || page),
|
||||||
|
pageSize: Number(meta.pageSize || pageSize),
|
||||||
|
total,
|
||||||
|
totalPages: Math.max(1, Number(meta.totalPages || Math.ceil(total / pageSize) || 1)),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDate(value?: string): string {
|
||||||
|
if (!value) return '-';
|
||||||
|
const normalized = value.includes('T') ? value : value.replace(' ', 'T');
|
||||||
|
const date = new Date(normalized);
|
||||||
|
if (Number.isNaN(date.getTime())) return value.slice(0, 16);
|
||||||
|
return date.toLocaleString('zh-CN', {
|
||||||
|
year: 'numeric',
|
||||||
|
month: '2-digit',
|
||||||
|
day: '2-digit',
|
||||||
|
hour: '2-digit',
|
||||||
|
minute: '2-digit',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function roleLabel(role: UserRole): string {
|
||||||
|
return role === 'admin' ? '管理员' : '普通用户';
|
||||||
|
}
|
||||||
|
|
||||||
|
function statusLabel(status: UserStatus): string {
|
||||||
|
return status === 'active' ? '正常' : '已禁用';
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function UserManagement() {
|
export default function UserManagement() {
|
||||||
const [users, setUsers] = useState<User[]>([]);
|
const [users, setUsers] = useState<User[]>([]);
|
||||||
|
const [stats, setStats] = useState<UserStats>({ total: 0, active: 0, disabled: 0, admins: 0 });
|
||||||
|
const [meta, setMeta] = useState<UserListMeta>({ page: 1, pageSize: 25, total: 0, totalPages: 1 });
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [saving, setSaving] = useState(false);
|
||||||
const [error, setError] = useState('');
|
const [error, setError] = useState('');
|
||||||
const [editingUser, setEditingUser] = useState<number | null>(null);
|
const [search, setSearch] = useState('');
|
||||||
const [editRole, setEditRole] = useState('');
|
const [roleFilter, setRoleFilter] = useState('');
|
||||||
const [editStatus, setEditStatus] = useState('');
|
const [statusFilter, setStatusFilter] = useState('');
|
||||||
|
const [page, setPage] = useState(1);
|
||||||
|
const [pageSize, setPageSize] = useState(25);
|
||||||
|
const [selectedIds, setSelectedIds] = useState<number[]>([]);
|
||||||
|
const [formMode, setFormMode] = useState<UserFormMode>('create');
|
||||||
|
const [formOpen, setFormOpen] = useState(false);
|
||||||
|
const [editingUser, setEditingUser] = useState<User | null>(null);
|
||||||
|
const [form, setForm] = useState<UserFormState>(emptyForm);
|
||||||
|
const [deleteTarget, setDeleteTarget] = useState<User | 'selected' | null>(null);
|
||||||
|
|
||||||
const loadUsers = useCallback(async () => {
|
const loadUsers = useCallback(async () => {
|
||||||
|
setLoading(true);
|
||||||
|
setError('');
|
||||||
try {
|
try {
|
||||||
const result = await api.getUsers();
|
const params = new URLSearchParams();
|
||||||
setUsers(Array.isArray(result.users) ? result.users : []);
|
if (search.trim()) params.set('q', search.trim());
|
||||||
|
if (roleFilter) params.set('role', roleFilter);
|
||||||
|
if (statusFilter) params.set('status', statusFilter);
|
||||||
|
params.set('page', String(page));
|
||||||
|
params.set('page_size', String(pageSize));
|
||||||
|
|
||||||
|
const result = await api.getUsers(params);
|
||||||
|
const nextUsers = normalizeUsers(result);
|
||||||
|
setUsers(nextUsers);
|
||||||
|
setStats(normalizeStats(result, nextUsers));
|
||||||
|
setMeta(normalizeMeta(result, nextUsers, page, pageSize));
|
||||||
|
setSelectedIds((ids) => ids.filter((id) => nextUsers.some((user) => user.id === id)));
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
setError(err?.message || '加载用户列表失败');
|
setError(err?.message || '加载用户列表失败');
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
}, []);
|
}, [page, pageSize, roleFilter, search, statusFilter]);
|
||||||
|
|
||||||
useEffect(() => { loadUsers(); }, [loadUsers]);
|
useEffect(() => {
|
||||||
|
const handle = window.setTimeout(() => {
|
||||||
|
loadUsers();
|
||||||
|
}, 180);
|
||||||
|
return () => window.clearTimeout(handle);
|
||||||
|
}, [loadUsers]);
|
||||||
|
|
||||||
const handleUpdateUser = async (id: number) => {
|
const selectedUsers = useMemo(
|
||||||
try {
|
() => users.filter((user) => selectedIds.includes(user.id)),
|
||||||
await api.updateUser(id, { role: editRole, status: editStatus });
|
[selectedIds, users],
|
||||||
setEditingUser(null);
|
|
||||||
await loadUsers();
|
|
||||||
} catch (err: any) {
|
|
||||||
setError(err?.message || '更新失败');
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleDisableUser = async (id: number) => {
|
|
||||||
try {
|
|
||||||
await api.deleteUser(id);
|
|
||||||
await loadUsers();
|
|
||||||
} catch (err: any) {
|
|
||||||
setError(err?.message || '操作失败');
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
if (loading) {
|
|
||||||
return (
|
|
||||||
<div className="animate-fade-in" style={{ padding: 16 }}>
|
|
||||||
<div className="skeleton" style={{ width: 220, height: 24, marginBottom: 16 }} />
|
|
||||||
<div className="skeleton" style={{ width: '100%', height: 120, borderRadius: 12 }} />
|
|
||||||
</div>
|
|
||||||
);
|
);
|
||||||
|
const allVisibleSelected = users.length > 0 && users.every((user) => selectedIds.includes(user.id));
|
||||||
|
|
||||||
|
const openCreateModal = () => {
|
||||||
|
setFormMode('create');
|
||||||
|
setEditingUser(null);
|
||||||
|
setForm(emptyForm);
|
||||||
|
setFormOpen(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const openEditModal = (user: User) => {
|
||||||
|
setFormMode('edit');
|
||||||
|
setEditingUser(user);
|
||||||
|
setForm({
|
||||||
|
username: user.username,
|
||||||
|
email: user.email,
|
||||||
|
password: '',
|
||||||
|
role: user.role,
|
||||||
|
status: user.status,
|
||||||
|
});
|
||||||
|
setFormOpen(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const updateSelection = (id: number, checked: boolean) => {
|
||||||
|
setSelectedIds((ids) => (
|
||||||
|
checked ? Array.from(new Set([...ids, id])) : ids.filter((item) => item !== id)
|
||||||
|
));
|
||||||
|
};
|
||||||
|
|
||||||
|
const toggleVisibleSelection = (checked: boolean) => {
|
||||||
|
if (checked) {
|
||||||
|
setSelectedIds((ids) => Array.from(new Set([...ids, ...users.map((user) => user.id)])));
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
|
setSelectedIds((ids) => ids.filter((id) => !users.some((user) => user.id === id)));
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSubmit = async () => {
|
||||||
|
const username = form.username.trim();
|
||||||
|
const email = form.email.trim();
|
||||||
|
const password = form.password.trim();
|
||||||
|
if (!username || !email) {
|
||||||
|
setError('用户名和邮箱不能为空');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (formMode === 'create' && password.length < 6) {
|
||||||
|
setError('新用户密码至少 6 位');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setSaving(true);
|
||||||
|
setError('');
|
||||||
|
try {
|
||||||
|
if (formMode === 'create') {
|
||||||
|
await api.createUser({
|
||||||
|
username,
|
||||||
|
email,
|
||||||
|
password,
|
||||||
|
role: form.role,
|
||||||
|
});
|
||||||
|
} else if (editingUser) {
|
||||||
|
await api.updateUser(editingUser.id, {
|
||||||
|
username,
|
||||||
|
email,
|
||||||
|
role: form.role,
|
||||||
|
status: form.status,
|
||||||
|
...(password ? { password } : {}),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
setFormOpen(false);
|
||||||
|
await loadUsers();
|
||||||
|
} catch (err: any) {
|
||||||
|
setError(err?.message || '保存用户失败');
|
||||||
|
} finally {
|
||||||
|
setSaving(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const updateUserStatus = async (user: User, status: UserStatus) => {
|
||||||
|
setSaving(true);
|
||||||
|
setError('');
|
||||||
|
try {
|
||||||
|
await api.updateUser(user.id, { status });
|
||||||
|
await loadUsers();
|
||||||
|
} catch (err: any) {
|
||||||
|
setError(err?.message || '更新用户状态失败');
|
||||||
|
} finally {
|
||||||
|
setSaving(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const updateSelectedStatus = async (status: UserStatus) => {
|
||||||
|
if (selectedUsers.length === 0) return;
|
||||||
|
setSaving(true);
|
||||||
|
setError('');
|
||||||
|
try {
|
||||||
|
await Promise.all(selectedUsers.map((user) => api.updateUser(user.id, { status })));
|
||||||
|
setSelectedIds([]);
|
||||||
|
await loadUsers();
|
||||||
|
} catch (err: any) {
|
||||||
|
setError(err?.message || '批量更新失败');
|
||||||
|
} finally {
|
||||||
|
setSaving(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const confirmDelete = async () => {
|
||||||
|
if (!deleteTarget) return;
|
||||||
|
setSaving(true);
|
||||||
|
setError('');
|
||||||
|
try {
|
||||||
|
const targets = deleteTarget === 'selected' ? selectedUsers : [deleteTarget];
|
||||||
|
await Promise.all(targets.map((user) => api.deleteUser(user.id)));
|
||||||
|
setDeleteTarget(null);
|
||||||
|
setSelectedIds([]);
|
||||||
|
await loadUsers();
|
||||||
|
} catch (err: any) {
|
||||||
|
setError(err?.message || '删除用户失败');
|
||||||
|
} finally {
|
||||||
|
setSaving(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const statCards = [
|
||||||
|
{ label: '总用户', value: stats.total, tone: 'primary' },
|
||||||
|
{ label: '正常用户', value: stats.active, tone: 'success' },
|
||||||
|
{ label: '管理员', value: stats.admins, tone: 'info' },
|
||||||
|
{ label: '已禁用', value: stats.disabled, tone: 'danger' },
|
||||||
|
];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="animate-fade-in">
|
<div className="page-container animate-fade-in user-management-page">
|
||||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 16 }}>
|
<div className="page-header">
|
||||||
<h1 style={{ fontSize: 18, fontWeight: 600, margin: 0 }}>用户管理</h1>
|
<div>
|
||||||
<span style={{ fontSize: 13, color: 'var(--color-text-muted)' }}>
|
<h2 className="page-title">用户管理</h2>
|
||||||
共 {users.length} 个用户
|
<div className="page-subtitle">维护平台用户、角色与登录状态。禁用不会删除用户数据,可随时恢复。</div>
|
||||||
</span>
|
</div>
|
||||||
|
<div className="page-actions">
|
||||||
|
<button type="button" className="btn btn-ghost" onClick={loadUsers} disabled={loading || saving}>
|
||||||
|
刷新
|
||||||
|
</button>
|
||||||
|
<button type="button" className="btn btn-primary" onClick={openCreateModal}>
|
||||||
|
新建用户
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{error && <div className="alert alert-error" style={{ marginBottom: 12 }}>{error}</div>}
|
{error ? <div className="alert alert-error user-management-alert">{error}</div> : null}
|
||||||
|
|
||||||
{/* Stats cards */}
|
<div className="user-management-stats">
|
||||||
<div style={{ display: 'flex', gap: 12, marginBottom: 16 }}>
|
{statCards.map((stat) => (
|
||||||
{[
|
<div className={`user-stat-card user-stat-${stat.tone}`} key={stat.label}>
|
||||||
{ label: '总用户', value: users.length, color: '#818cf8' },
|
<div className="user-stat-label">{stat.label}</div>
|
||||||
{ label: '管理员', value: users.filter((u) => u.role === 'admin').length, color: '#4ade80' },
|
<div className="user-stat-value">{loading ? '-' : stat.value}</div>
|
||||||
{ label: '已禁用', value: users.filter((u) => u.status === 'disabled').length, color: '#f87171' },
|
|
||||||
].map((stat) => (
|
|
||||||
<div key={stat.label} style={{
|
|
||||||
flex: 1, padding: '14px 16px', borderRadius: 10,
|
|
||||||
background: 'var(--color-surface, #1a1a2e)',
|
|
||||||
border: '1px solid var(--color-border, #2a2a3e)',
|
|
||||||
}}>
|
|
||||||
<div style={{ fontSize: 12, color: 'var(--color-text-muted)', marginBottom: 4 }}>{stat.label}</div>
|
|
||||||
<div style={{ fontSize: 22, fontWeight: 700, color: stat.color }}>{stat.value}</div>
|
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Users table */}
|
<div className="card user-management-toolbar-card">
|
||||||
<div style={{
|
<div className="toolbar user-management-toolbar">
|
||||||
background: 'var(--color-surface, #1a1a2e)',
|
<div className="toolbar-search user-management-search">
|
||||||
borderRadius: 12, border: '1px solid var(--color-border, #2a2a3e)',
|
<svg width="16" height="16" fill="none" viewBox="0 0 24 24" stroke="currentColor" aria-hidden="true">
|
||||||
overflow: 'hidden',
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.8} d="m21 21-4.35-4.35M10.5 18a7.5 7.5 0 1 1 0-15 7.5 7.5 0 0 1 0 15Z" />
|
||||||
}}>
|
</svg>
|
||||||
<table style={{ width: '100%', borderCollapse: 'collapse', fontSize: 13 }}>
|
<input
|
||||||
|
value={search}
|
||||||
|
onChange={(event) => { setSearch(event.target.value); setPage(1); }}
|
||||||
|
placeholder="搜索 ID / 用户名 / 邮箱"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<select className="user-management-select" value={roleFilter} onChange={(event) => { setRoleFilter(event.target.value); setPage(1); }}>
|
||||||
|
<option value="">全部角色</option>
|
||||||
|
<option value="admin">管理员</option>
|
||||||
|
<option value="user">普通用户</option>
|
||||||
|
</select>
|
||||||
|
<select className="user-management-select" value={statusFilter} onChange={(event) => { setStatusFilter(event.target.value); setPage(1); }}>
|
||||||
|
<option value="">全部状态</option>
|
||||||
|
<option value="active">正常</option>
|
||||||
|
<option value="disabled">已禁用</option>
|
||||||
|
</select>
|
||||||
|
<select className="user-management-select" value={pageSize} onChange={(event) => { setPageSize(Number(event.target.value)); setPage(1); }}>
|
||||||
|
<option value={25}>25 / 页</option>
|
||||||
|
<option value={50}>50 / 页</option>
|
||||||
|
<option value={100}>100 / 页</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
{selectedUsers.length > 0 ? (
|
||||||
|
<div className="user-management-batchbar">
|
||||||
|
<span>已选 {selectedUsers.length} 个用户</span>
|
||||||
|
<button type="button" className="btn btn-ghost" onClick={() => updateSelectedStatus('active')} disabled={saving}>批量启用</button>
|
||||||
|
<button type="button" className="btn btn-ghost" onClick={() => updateSelectedStatus('disabled')} disabled={saving}>批量禁用</button>
|
||||||
|
<button type="button" className="btn btn-link btn-link-danger" onClick={() => setDeleteTarget('selected')} disabled={saving}>批量删除</button>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="card user-management-table-card">
|
||||||
|
{loading ? (
|
||||||
|
<div className="empty-state">
|
||||||
|
<div className="empty-state-title">加载中...</div>
|
||||||
|
<div className="empty-state-desc">正在读取用户列表。</div>
|
||||||
|
</div>
|
||||||
|
) : users.length === 0 ? (
|
||||||
|
<div className="empty-state">
|
||||||
|
<svg className="empty-state-icon" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1} d="M18 18.72a9.1 9.1 0 0 0 3.74-1.22 5.25 5.25 0 0 0-7.49-3.82M15 11.25a3.75 3.75 0 1 0-7.5 0 3.75 3.75 0 0 0 7.5 0ZM4.5 20.1a7.5 7.5 0 0 1 15 0" />
|
||||||
|
</svg>
|
||||||
|
<div className="empty-state-title">没有匹配的用户</div>
|
||||||
|
<div className="empty-state-desc">调整筛选条件,或使用右上角新建用户。</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="user-management-table-wrap">
|
||||||
|
<table className="data-table user-management-table">
|
||||||
<thead>
|
<thead>
|
||||||
<tr style={{ borderBottom: '1px solid var(--color-border, #2a2a3e)' }}>
|
<tr>
|
||||||
<th style={{ padding: '12px 16px', textAlign: 'left', fontWeight: 600, color: 'var(--color-text-muted)' }}>ID</th>
|
<th className="user-management-check-col">
|
||||||
<th style={{ padding: '12px 16px', textAlign: 'left', fontWeight: 600, color: 'var(--color-text-muted)' }}>用户名</th>
|
<input
|
||||||
<th style={{ padding: '12px 16px', textAlign: 'left', fontWeight: 600, color: 'var(--color-text-muted)' }}>邮箱</th>
|
type="checkbox"
|
||||||
<th style={{ padding: '12px 16px', textAlign: 'left', fontWeight: 600, color: 'var(--color-text-muted)' }}>角色</th>
|
checked={allVisibleSelected}
|
||||||
<th style={{ padding: '12px 16px', textAlign: 'left', fontWeight: 600, color: 'var(--color-text-muted)' }}>状态</th>
|
onChange={(event) => toggleVisibleSelection(event.target.checked)}
|
||||||
<th style={{ padding: '12px 16px', textAlign: 'left', fontWeight: 600, color: 'var(--color-text-muted)' }}>注册时间</th>
|
aria-label="选择当前页用户"
|
||||||
<th style={{ padding: '12px 16px', textAlign: 'right', fontWeight: 600, color: 'var(--color-text-muted)' }}>操作</th>
|
/>
|
||||||
|
</th>
|
||||||
|
<th>用户</th>
|
||||||
|
<th>角色</th>
|
||||||
|
<th>状态</th>
|
||||||
|
<th>注册时间</th>
|
||||||
|
<th>最近更新</th>
|
||||||
|
<th className="user-management-actions-col">操作</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{users.length === 0 ? (
|
{users.map((user) => (
|
||||||
<tr>
|
<tr key={user.id} className={selectedIds.includes(user.id) ? 'row-selected' : ''}>
|
||||||
<td colSpan={7} style={{ padding: 24, textAlign: 'center', color: 'var(--color-text-muted)' }}>
|
<td className="user-management-check-col">
|
||||||
暂无用户
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={selectedIds.includes(user.id)}
|
||||||
|
onChange={(event) => updateSelection(user.id, event.target.checked)}
|
||||||
|
aria-label={`选择 ${user.username}`}
|
||||||
|
/>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<div className="user-identity-cell">
|
||||||
|
<div className="user-avatar">{user.username.slice(0, 1).toUpperCase() || 'U'}</div>
|
||||||
|
<div className="user-identity-text">
|
||||||
|
<div className="user-name-line">
|
||||||
|
<span className="user-name">{user.username}</span>
|
||||||
|
<span className="user-id">#{user.id}</span>
|
||||||
|
</div>
|
||||||
|
<div className="user-email">{user.email}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<span className={`badge ${user.role === 'admin' ? 'badge-info' : 'badge-muted'}`}>
|
||||||
|
{roleLabel(user.role)}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<span className={`badge ${user.status === 'active' ? 'badge-success' : 'badge-error'}`}>
|
||||||
|
{statusLabel(user.status)}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td>{formatDate(user.createdAt || user.created_at)}</td>
|
||||||
|
<td>{formatDate(user.updatedAt || user.updated_at)}</td>
|
||||||
|
<td className="user-management-actions-col">
|
||||||
|
<div className="user-management-row-actions">
|
||||||
|
<button type="button" className="btn btn-link btn-link-primary" onClick={() => openEditModal(user)}>编辑</button>
|
||||||
|
{user.status === 'active' ? (
|
||||||
|
<button type="button" className="btn btn-link btn-link-warning" onClick={() => updateUserStatus(user, 'disabled')} disabled={saving}>禁用</button>
|
||||||
|
) : (
|
||||||
|
<button type="button" className="btn btn-link btn-link-info" onClick={() => updateUserStatus(user, 'active')} disabled={saving}>启用</button>
|
||||||
|
)}
|
||||||
|
<button type="button" className="btn btn-link btn-link-danger" onClick={() => setDeleteTarget(user)} disabled={saving}>删除</button>
|
||||||
|
</div>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
) : (
|
))}
|
||||||
users.map((user) => (
|
|
||||||
<tr key={user.id} style={{ borderBottom: '1px solid var(--color-border, #2a2a3e)' }}>
|
|
||||||
<td style={{ padding: '12px 16px', fontFamily: 'monospace', fontSize: 12 }}>{user.id}</td>
|
|
||||||
<td style={{ padding: '12px 16px' }}>{user.username}</td>
|
|
||||||
<td style={{ padding: '12px 16px', color: 'var(--color-text-secondary)' }}>{user.email}</td>
|
|
||||||
<td style={{ padding: '12px 16px' }}>
|
|
||||||
{editingUser === user.id ? (
|
|
||||||
<select
|
|
||||||
value={editRole}
|
|
||||||
onChange={(e) => setEditRole(e.target.value)}
|
|
||||||
style={{
|
|
||||||
padding: '4px 8px', borderRadius: 4, fontSize: 12,
|
|
||||||
border: '1px solid var(--color-border)',
|
|
||||||
background: 'var(--color-bg)', color: 'var(--color-text-primary)',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<option value="user">user</option>
|
|
||||||
<option value="admin">admin</option>
|
|
||||||
</select>
|
|
||||||
) : (
|
|
||||||
<span style={{
|
|
||||||
fontSize: 12, padding: '2px 8px', borderRadius: 4,
|
|
||||||
background: user.role === 'admin' ? 'rgba(99,102,241,0.15)' : 'rgba(34,197,94,0.15)',
|
|
||||||
color: user.role === 'admin' ? '#818cf8' : '#4ade80',
|
|
||||||
}}>
|
|
||||||
{user.role === 'admin' ? '管理员' : '用户'}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</td>
|
|
||||||
<td style={{ padding: '12px 16px' }}>
|
|
||||||
{editingUser === user.id ? (
|
|
||||||
<select
|
|
||||||
value={editStatus}
|
|
||||||
onChange={(e) => setEditStatus(e.target.value)}
|
|
||||||
style={{
|
|
||||||
padding: '4px 8px', borderRadius: 4, fontSize: 12,
|
|
||||||
border: '1px solid var(--color-border)',
|
|
||||||
background: 'var(--color-bg)', color: 'var(--color-text-primary)',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<option value="active">active</option>
|
|
||||||
<option value="disabled">disabled</option>
|
|
||||||
</select>
|
|
||||||
) : (
|
|
||||||
<span style={{
|
|
||||||
fontSize: 12, padding: '2px 8px', borderRadius: 4,
|
|
||||||
background: user.status === 'active' ? 'rgba(34,197,94,0.15)' : 'rgba(239,68,68,0.15)',
|
|
||||||
color: user.status === 'active' ? '#4ade80' : '#f87171',
|
|
||||||
}}>
|
|
||||||
{user.status === 'active' ? '正常' : '禁用'}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</td>
|
|
||||||
<td style={{ padding: '12px 16px', fontSize: 12, color: 'var(--color-text-muted)' }}>
|
|
||||||
{user.createdAt?.slice(0, 10) || '-'}
|
|
||||||
</td>
|
|
||||||
<td style={{ padding: '12px 16px', textAlign: 'right' }}>
|
|
||||||
{editingUser === user.id ? (
|
|
||||||
<div style={{ display: 'flex', gap: 6, justifyContent: 'flex-end' }}>
|
|
||||||
<button onClick={() => handleUpdateUser(user.id)} className="btn btn-primary" style={{ fontSize: 12, padding: '4px 12px' }}>保存</button>
|
|
||||||
<button onClick={() => setEditingUser(null)} className="btn btn-ghost" style={{ fontSize: 12, padding: '4px 12px' }}>取消</button>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div style={{ display: 'flex', gap: 6, justifyContent: 'flex-end' }}>
|
|
||||||
<button
|
|
||||||
onClick={() => { setEditingUser(user.id); setEditRole(user.role); setEditStatus(user.status); }}
|
|
||||||
className="btn btn-ghost"
|
|
||||||
style={{ fontSize: 12, padding: '4px 12px' }}
|
|
||||||
>
|
|
||||||
编辑
|
|
||||||
</button>
|
|
||||||
{user.status !== 'disabled' && (
|
|
||||||
<button onClick={() => handleDisableUser(user.id)} className="btn btn-ghost danger" style={{ fontSize: 12, padding: '4px 12px' }}>
|
|
||||||
禁用
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
))
|
|
||||||
)}
|
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="user-management-pagination">
|
||||||
|
<div>共 {meta.total} 个用户,第 {meta.page} / {meta.totalPages} 页</div>
|
||||||
|
<div className="user-management-page-controls">
|
||||||
|
<button type="button" className="btn btn-ghost" disabled={page <= 1 || loading} onClick={() => setPage((value) => Math.max(1, value - 1))}>
|
||||||
|
上一页
|
||||||
|
</button>
|
||||||
|
<button type="button" className="btn btn-ghost" disabled={page >= meta.totalPages || loading} onClick={() => setPage((value) => value + 1)}>
|
||||||
|
下一页
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<CenteredModal
|
||||||
|
open={formOpen}
|
||||||
|
onClose={() => setFormOpen(false)}
|
||||||
|
title={formMode === 'create' ? '新建用户' : `编辑用户 #${editingUser?.id ?? ''}`}
|
||||||
|
maxWidth={620}
|
||||||
|
closeOnEscape
|
||||||
|
footer={(
|
||||||
|
<>
|
||||||
|
<button type="button" className="btn btn-ghost" onClick={() => setFormOpen(false)} disabled={saving}>取消</button>
|
||||||
|
<button type="button" className="btn btn-primary" onClick={handleSubmit} disabled={saving}>
|
||||||
|
{saving ? '保存中...' : '保存'}
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<div className="user-management-form">
|
||||||
|
<label>
|
||||||
|
<span>用户名</span>
|
||||||
|
<input value={form.username} onChange={(event) => setForm((value) => ({ ...value, username: event.target.value }))} />
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
<span>邮箱</span>
|
||||||
|
<input value={form.email} onChange={(event) => setForm((value) => ({ ...value, email: event.target.value }))} />
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
<span>{formMode === 'create' ? '密码' : '重置密码'}</span>
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
value={form.password}
|
||||||
|
onChange={(event) => setForm((value) => ({ ...value, password: event.target.value }))}
|
||||||
|
placeholder={formMode === 'create' ? '至少 6 位' : '留空则不修改'}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
<span>角色</span>
|
||||||
|
<select value={form.role} onChange={(event) => setForm((value) => ({ ...value, role: event.target.value as UserRole }))}>
|
||||||
|
<option value="user">普通用户</option>
|
||||||
|
<option value="admin">管理员</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
<span>状态</span>
|
||||||
|
<select value={form.status} onChange={(event) => setForm((value) => ({ ...value, status: event.target.value as UserStatus }))} disabled={formMode === 'create'}>
|
||||||
|
<option value="active">正常</option>
|
||||||
|
<option value="disabled">已禁用</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</CenteredModal>
|
||||||
|
|
||||||
|
<DeleteConfirmModal
|
||||||
|
open={deleteTarget !== null}
|
||||||
|
title="确认删除用户"
|
||||||
|
confirmText="确认删除"
|
||||||
|
loading={saving}
|
||||||
|
onClose={() => setDeleteTarget(null)}
|
||||||
|
onConfirm={confirmDelete}
|
||||||
|
description={deleteTarget === 'selected'
|
||||||
|
? `将永久删除选中的 ${selectedUsers.length} 个用户。`
|
||||||
|
: `将永久删除用户 ${deleteTarget?.username || ''}。`}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user