forked from boos/BoosAPI
refactor: purge Node.js, Rust is the one true backend
- Moved boosapi-rs/* to repo root - Deleted all Node.js source (src/server/, src/web/, scripts/) - Deleted node_modules, package.json, tsconfig, vite.config - Updated .gitignore and AGENTS.md for Rust-only stack - Fixed static file path for frontend dist/web/ - Dockerfile stays the same (already Rust-only) - Kept dist/web/ for Rust static serving
This commit is contained in:
@@ -0,0 +1,299 @@
|
||||
// Account token routes — CRUD for account-level API tokens
|
||||
|
||||
use crate::{
|
||||
db::models::account::AccountToken,
|
||||
error::{AppError, AppResult},
|
||||
AppState,
|
||||
};
|
||||
use axum::{
|
||||
extract::{Path, Query, State},
|
||||
http::StatusCode,
|
||||
routing::{get, post},
|
||||
Json, Router,
|
||||
};
|
||||
use chrono::Utc;
|
||||
use serde::Deserialize;
|
||||
use serde_json::{json, Value};
|
||||
use tracing::info;
|
||||
use uuid::Uuid;
|
||||
|
||||
pub fn routes() -> Router<AppState> {
|
||||
Router::new()
|
||||
.route(
|
||||
"/api/account-tokens",
|
||||
get(list_account_tokens).post(create_account_token),
|
||||
)
|
||||
.route(
|
||||
"/api/account-tokens/sync-all",
|
||||
post(sync_all_account_tokens),
|
||||
)
|
||||
.route(
|
||||
"/api/account-tokens/{id}",
|
||||
get(get_account_token)
|
||||
.put(update_account_token)
|
||||
.delete(delete_account_token),
|
||||
)
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct ListAccountTokensQuery {
|
||||
#[serde(rename = "accountId", alias = "account_id")]
|
||||
pub account_id: Option<i64>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct CreateAccountTokenRequest {
|
||||
pub account_id: i64,
|
||||
pub name: Option<String>,
|
||||
pub token: Option<String>,
|
||||
pub source: Option<String>,
|
||||
pub token_group: Option<String>,
|
||||
pub value_status: Option<String>,
|
||||
pub enabled: Option<bool>,
|
||||
pub is_default: Option<bool>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct UpdateAccountTokenRequest {
|
||||
pub account_id: Option<i64>,
|
||||
pub name: Option<String>,
|
||||
pub token: Option<String>,
|
||||
pub source: Option<String>,
|
||||
pub token_group: Option<String>,
|
||||
pub value_status: Option<String>,
|
||||
pub enabled: Option<bool>,
|
||||
pub is_default: Option<bool>,
|
||||
}
|
||||
|
||||
/// GET /api/account-tokens?accountId=
|
||||
pub async fn list_account_tokens(
|
||||
State(state): State<AppState>,
|
||||
Query(query): Query<ListAccountTokensQuery>,
|
||||
) -> AppResult<Json<Value>> {
|
||||
info!(
|
||||
"Listing account tokens, account_id filter: {:?}",
|
||||
query.account_id
|
||||
);
|
||||
|
||||
let tokens: Vec<AccountToken> = if let Some(account_id) = query.account_id {
|
||||
sqlx::query_as::<_, AccountToken>(
|
||||
"SELECT * FROM account_tokens WHERE account_id = ? ORDER BY token_group, id",
|
||||
)
|
||||
.bind(account_id)
|
||||
.fetch_all(&*state.db)
|
||||
.await?
|
||||
} else {
|
||||
sqlx::query_as::<_, AccountToken>(
|
||||
"SELECT * FROM account_tokens ORDER BY account_id, token_group, id",
|
||||
)
|
||||
.fetch_all(&*state.db)
|
||||
.await?
|
||||
};
|
||||
|
||||
Ok(Json(json!({ "data": tokens })))
|
||||
}
|
||||
|
||||
/// POST /api/account-tokens
|
||||
pub async fn create_account_token(
|
||||
State(state): State<AppState>,
|
||||
Json(payload): Json<CreateAccountTokenRequest>,
|
||||
) -> AppResult<Json<Value>> {
|
||||
info!(
|
||||
"Creating account token for account_id: {}",
|
||||
payload.account_id
|
||||
);
|
||||
|
||||
ensure_account_exists(&state, payload.account_id).await?;
|
||||
|
||||
let token = normalize_create_token(payload.token);
|
||||
let name = payload
|
||||
.name
|
||||
.map(|name| name.trim().to_string())
|
||||
.filter(|name| !name.is_empty())
|
||||
.unwrap_or_else(|| "API Token".to_string());
|
||||
|
||||
let value_status = payload
|
||||
.value_status
|
||||
.map(|status| status.trim().to_string())
|
||||
.filter(|status| !status.is_empty())
|
||||
.unwrap_or_else(|| "valid".to_string());
|
||||
|
||||
let now = Utc::now().to_rfc3339();
|
||||
|
||||
let result = sqlx::query(
|
||||
"INSERT INTO account_tokens \
|
||||
(account_id, name, token, source, token_group, value_status, enabled, is_default, created_at, updated_at) \
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
|
||||
)
|
||||
.bind(payload.account_id)
|
||||
.bind(&name)
|
||||
.bind(&token)
|
||||
.bind(&payload.source)
|
||||
.bind(&payload.token_group)
|
||||
.bind(&value_status)
|
||||
.bind(payload.enabled.unwrap_or(true))
|
||||
.bind(payload.is_default.unwrap_or(false))
|
||||
.bind(&now)
|
||||
.bind(&now)
|
||||
.execute(&*state.db)
|
||||
.await?;
|
||||
|
||||
let token_id = result.last_insert_rowid();
|
||||
let account_token: AccountToken =
|
||||
sqlx::query_as::<_, AccountToken>("SELECT * FROM account_tokens WHERE id = ?")
|
||||
.bind(token_id)
|
||||
.fetch_one(&*state.db)
|
||||
.await?;
|
||||
|
||||
Ok(Json(json!({ "data": account_token })))
|
||||
}
|
||||
|
||||
/// GET /api/account-tokens/{id}
|
||||
pub async fn get_account_token(
|
||||
State(state): State<AppState>,
|
||||
Path(id): Path<i64>,
|
||||
) -> AppResult<Json<Value>> {
|
||||
info!("Getting account token: {}", id);
|
||||
|
||||
let account_token: AccountToken =
|
||||
sqlx::query_as::<_, AccountToken>("SELECT * FROM account_tokens WHERE id = ?")
|
||||
.bind(id)
|
||||
.fetch_optional(&*state.db)
|
||||
.await?
|
||||
.ok_or_else(|| AppError::NotFound(format!("Account token with id {} not found", id)))?;
|
||||
|
||||
Ok(Json(json!({ "data": account_token })))
|
||||
}
|
||||
|
||||
/// PUT /api/account-tokens/{id}
|
||||
pub async fn update_account_token(
|
||||
State(state): State<AppState>,
|
||||
Path(id): Path<i64>,
|
||||
Json(payload): Json<UpdateAccountTokenRequest>,
|
||||
) -> AppResult<Json<Value>> {
|
||||
info!("Updating account token: {}", id);
|
||||
|
||||
let existing: AccountToken =
|
||||
sqlx::query_as::<_, AccountToken>("SELECT * FROM account_tokens WHERE id = ?")
|
||||
.bind(id)
|
||||
.fetch_optional(&*state.db)
|
||||
.await?
|
||||
.ok_or_else(|| AppError::NotFound(format!("Account token with id {} not found", id)))?;
|
||||
|
||||
let account_id = payload.account_id.unwrap_or(existing.account_id);
|
||||
if payload.account_id.is_some() {
|
||||
ensure_account_exists(&state, account_id).await?;
|
||||
}
|
||||
|
||||
let name = payload
|
||||
.name
|
||||
.map(|name| name.trim().to_string())
|
||||
.filter(|name| !name.is_empty())
|
||||
.unwrap_or(existing.name);
|
||||
|
||||
let token = match payload.token {
|
||||
Some(token) => {
|
||||
let token = token.trim().to_string();
|
||||
if token.is_empty() {
|
||||
return Err(AppError::BadRequest("token cannot be empty".into()));
|
||||
}
|
||||
token
|
||||
}
|
||||
None => existing.token,
|
||||
};
|
||||
|
||||
let value_status = payload
|
||||
.value_status
|
||||
.map(|status| status.trim().to_string())
|
||||
.filter(|status| !status.is_empty())
|
||||
.unwrap_or(existing.value_status);
|
||||
|
||||
let now = Utc::now().to_rfc3339();
|
||||
|
||||
sqlx::query(
|
||||
"UPDATE account_tokens SET \
|
||||
account_id = ?, name = ?, token = ?, source = ?, token_group = ?, value_status = ?, \
|
||||
enabled = ?, is_default = ?, updated_at = ? \
|
||||
WHERE id = ?",
|
||||
)
|
||||
.bind(account_id)
|
||||
.bind(&name)
|
||||
.bind(&token)
|
||||
.bind(payload.source.or(existing.source))
|
||||
.bind(payload.token_group.or(existing.token_group))
|
||||
.bind(&value_status)
|
||||
.bind(payload.enabled.or(existing.enabled))
|
||||
.bind(payload.is_default.or(existing.is_default))
|
||||
.bind(&now)
|
||||
.bind(id)
|
||||
.execute(&*state.db)
|
||||
.await?;
|
||||
|
||||
let account_token: AccountToken =
|
||||
sqlx::query_as::<_, AccountToken>("SELECT * FROM account_tokens WHERE id = ?")
|
||||
.bind(id)
|
||||
.fetch_one(&*state.db)
|
||||
.await?;
|
||||
|
||||
Ok(Json(json!({ "data": account_token })))
|
||||
}
|
||||
|
||||
/// DELETE /api/account-tokens/{id}
|
||||
pub async fn delete_account_token(
|
||||
State(state): State<AppState>,
|
||||
Path(id): Path<i64>,
|
||||
) -> AppResult<Json<Value>> {
|
||||
info!("Deleting account token: {}", id);
|
||||
|
||||
let affected = sqlx::query("DELETE FROM account_tokens WHERE id = ?")
|
||||
.bind(id)
|
||||
.execute(&*state.db)
|
||||
.await?
|
||||
.rows_affected();
|
||||
|
||||
if affected == 0 {
|
||||
return Err(AppError::NotFound(format!(
|
||||
"Account token with id {} not found",
|
||||
id
|
||||
)));
|
||||
}
|
||||
|
||||
Ok(Json(json!({ "success": true })))
|
||||
}
|
||||
|
||||
/// POST /api/account-tokens/sync-all
|
||||
pub async fn sync_all_account_tokens() -> (StatusCode, Json<Value>) {
|
||||
info!("Sync all account tokens requested");
|
||||
|
||||
(
|
||||
StatusCode::NOT_IMPLEMENTED,
|
||||
Json(json!({
|
||||
"error": "not implemented"
|
||||
})),
|
||||
)
|
||||
}
|
||||
|
||||
async fn ensure_account_exists(state: &AppState, account_id: i64) -> AppResult<()> {
|
||||
let account_exists: Option<(i64,)> = sqlx::query_as("SELECT id FROM accounts WHERE id = ?")
|
||||
.bind(account_id)
|
||||
.fetch_optional(&*state.db)
|
||||
.await?;
|
||||
|
||||
if account_exists.is_none() {
|
||||
return Err(AppError::BadRequest(format!(
|
||||
"Account with id {} not found",
|
||||
account_id
|
||||
)));
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn normalize_create_token(token: Option<String>) -> String {
|
||||
token
|
||||
.map(|token| token.trim().to_string())
|
||||
.filter(|token| !token.is_empty())
|
||||
.unwrap_or_else(|| Uuid::new_v4().to_string())
|
||||
}
|
||||
@@ -0,0 +1,195 @@
|
||||
use crate::{
|
||||
db::models::account::Account,
|
||||
error::{AppError, AppResult},
|
||||
AppState,
|
||||
};
|
||||
use axum::{
|
||||
extract::{Path, Query, State},
|
||||
http::StatusCode,
|
||||
Json,
|
||||
};
|
||||
use serde::Deserialize;
|
||||
use serde_json::{json, Value};
|
||||
use tracing::info;
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct ListAccountsQuery {
|
||||
pub site_id: Option<i64>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct CreateAccountRequest {
|
||||
pub site_id: i64,
|
||||
pub username: Option<String>,
|
||||
pub access_token: String,
|
||||
pub api_token: Option<String>,
|
||||
pub status: Option<String>,
|
||||
pub checkin_enabled: Option<bool>,
|
||||
pub extra_config: Option<String>,
|
||||
pub oauth_provider: Option<String>,
|
||||
pub oauth_account_key: Option<String>,
|
||||
pub oauth_project_id: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct UpdateAccountRequest {
|
||||
pub username: Option<String>,
|
||||
pub access_token: Option<String>,
|
||||
pub api_token: Option<String>,
|
||||
pub status: Option<String>,
|
||||
pub checkin_enabled: Option<bool>,
|
||||
pub extra_config: Option<String>,
|
||||
pub oauth_provider: Option<String>,
|
||||
pub oauth_account_key: Option<String>,
|
||||
pub oauth_project_id: Option<String>,
|
||||
}
|
||||
|
||||
/// GET /api/accounts?site_id=
|
||||
pub async fn list_accounts(
|
||||
State(state): State<AppState>,
|
||||
Query(query): Query<ListAccountsQuery>,
|
||||
) -> AppResult<Json<Value>> {
|
||||
info!("Listing accounts, site_id filter: {:?}", query.site_id);
|
||||
|
||||
let accounts: Vec<Account> = if let Some(site_id) = query.site_id {
|
||||
sqlx::query_as::<_, Account>(
|
||||
"SELECT * FROM accounts WHERE site_id = ? ORDER BY sort_order, id",
|
||||
)
|
||||
.bind(site_id)
|
||||
.fetch_all(&*state.db)
|
||||
.await?
|
||||
} else {
|
||||
sqlx::query_as::<_, Account>("SELECT * FROM accounts ORDER BY site_id, sort_order, id")
|
||||
.fetch_all(&*state.db)
|
||||
.await?
|
||||
};
|
||||
|
||||
Ok(Json(json!({ "data": accounts })))
|
||||
}
|
||||
|
||||
/// POST /api/accounts
|
||||
pub async fn create_account(
|
||||
State(state): State<AppState>,
|
||||
Json(payload): Json<CreateAccountRequest>,
|
||||
) -> AppResult<(StatusCode, Json<Value>)> {
|
||||
info!("Creating account for site_id: {}", payload.site_id);
|
||||
|
||||
if payload.access_token.trim().is_empty() {
|
||||
return Err(AppError::BadRequest("access_token is required".into()));
|
||||
}
|
||||
|
||||
let site_exists: Option<(i64,)> = sqlx::query_as("SELECT id FROM sites WHERE id = ?")
|
||||
.bind(payload.site_id)
|
||||
.fetch_optional(&*state.db)
|
||||
.await?;
|
||||
|
||||
if site_exists.is_none() {
|
||||
return Err(AppError::BadRequest(format!(
|
||||
"Site with id {} not found",
|
||||
payload.site_id
|
||||
)));
|
||||
}
|
||||
|
||||
let status = payload.status.unwrap_or_else(|| "active".to_string());
|
||||
|
||||
let result = sqlx::query(
|
||||
"INSERT INTO accounts (site_id, username, access_token, api_token, status, checkin_enabled, extra_config, oauth_provider, oauth_account_key, oauth_project_id) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)"
|
||||
)
|
||||
.bind(payload.site_id)
|
||||
.bind(&payload.username)
|
||||
.bind(&payload.access_token)
|
||||
.bind(&payload.api_token)
|
||||
.bind(&status)
|
||||
.bind(payload.checkin_enabled)
|
||||
.bind(&payload.extra_config)
|
||||
.bind(&payload.oauth_provider)
|
||||
.bind(&payload.oauth_account_key)
|
||||
.bind(&payload.oauth_project_id)
|
||||
.execute(&*state.db)
|
||||
.await?;
|
||||
|
||||
let account_id = result.last_insert_rowid();
|
||||
let account: Account = sqlx::query_as::<_, Account>("SELECT * FROM accounts WHERE id = ?")
|
||||
.bind(account_id)
|
||||
.fetch_one(&*state.db)
|
||||
.await?;
|
||||
|
||||
Ok((StatusCode::CREATED, Json(json!({ "data": account }))))
|
||||
}
|
||||
|
||||
/// GET /api/accounts/{id}
|
||||
pub async fn get_account(
|
||||
State(state): State<AppState>,
|
||||
Path(id): Path<i64>,
|
||||
) -> AppResult<Json<Value>> {
|
||||
info!("Getting 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 with id {} not found", id)))?;
|
||||
|
||||
Ok(Json(json!({ "data": account })))
|
||||
}
|
||||
|
||||
/// PUT /api/accounts/{id}
|
||||
pub async fn update_account(
|
||||
State(state): State<AppState>,
|
||||
Path(id): Path<i64>,
|
||||
Json(payload): Json<UpdateAccountRequest>,
|
||||
) -> AppResult<Json<Value>> {
|
||||
info!("Updating account: {}", id);
|
||||
|
||||
let existing: Account = sqlx::query_as::<_, Account>("SELECT * FROM accounts WHERE id = ?")
|
||||
.bind(id)
|
||||
.fetch_optional(&*state.db)
|
||||
.await?
|
||||
.ok_or_else(|| AppError::NotFound(format!("Account with id {} not found", id)))?;
|
||||
|
||||
sqlx::query(
|
||||
"UPDATE accounts SET username = ?, access_token = ?, api_token = ?, status = ?, checkin_enabled = ?, extra_config = ?, oauth_provider = ?, oauth_account_key = ?, oauth_project_id = ?, updated_at = datetime('now') WHERE id = ?"
|
||||
)
|
||||
.bind(payload.username.or(existing.username))
|
||||
.bind(payload.access_token.as_ref().unwrap_or(&existing.access_token))
|
||||
.bind(payload.api_token.or(existing.api_token))
|
||||
.bind(payload.status.unwrap_or(existing.status))
|
||||
.bind(payload.checkin_enabled.or(existing.checkin_enabled))
|
||||
.bind(payload.extra_config.or(existing.extra_config))
|
||||
.bind(payload.oauth_provider.or(existing.oauth_provider))
|
||||
.bind(payload.oauth_account_key.or(existing.oauth_account_key))
|
||||
.bind(payload.oauth_project_id.or(existing.oauth_project_id))
|
||||
.bind(id)
|
||||
.execute(&*state.db)
|
||||
.await?;
|
||||
|
||||
let account: Account = sqlx::query_as::<_, Account>("SELECT * FROM accounts WHERE id = ?")
|
||||
.bind(id)
|
||||
.fetch_one(&*state.db)
|
||||
.await?;
|
||||
|
||||
Ok(Json(json!({ "data": account })))
|
||||
}
|
||||
|
||||
/// DELETE /api/accounts/{id}
|
||||
pub async fn delete_account(
|
||||
State(state): State<AppState>,
|
||||
Path(id): Path<i64>,
|
||||
) -> AppResult<Json<Value>> {
|
||||
info!("Deleting account: {}", id);
|
||||
|
||||
let affected = sqlx::query("DELETE FROM accounts WHERE id = ?")
|
||||
.bind(id)
|
||||
.execute(&*state.db)
|
||||
.await?
|
||||
.rows_affected();
|
||||
|
||||
if affected == 0 {
|
||||
return Err(AppError::NotFound(format!(
|
||||
"Account with id {} not found",
|
||||
id
|
||||
)));
|
||||
}
|
||||
|
||||
Ok(Json(json!({ "data": { "deleted": true, "id": id } })))
|
||||
}
|
||||
@@ -0,0 +1,196 @@
|
||||
use crate::{
|
||||
db::models::downstream_api_key::DownstreamApiKey,
|
||||
error::{AppError, AppResult},
|
||||
AppState,
|
||||
};
|
||||
use axum::{
|
||||
extract::{Path, State},
|
||||
http::StatusCode,
|
||||
Json,
|
||||
};
|
||||
use serde::Deserialize;
|
||||
use serde_json::{json, Value};
|
||||
use tracing::info;
|
||||
use uuid::Uuid;
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct CreateDownstreamKeyRequest {
|
||||
pub name: String,
|
||||
pub description: Option<String>,
|
||||
pub group_name: Option<String>,
|
||||
pub tags: Option<String>,
|
||||
pub enabled: Option<bool>,
|
||||
pub expires_at: Option<String>,
|
||||
pub max_cost: Option<f64>,
|
||||
pub max_requests: Option<i64>,
|
||||
pub supported_models: Option<Vec<String>>,
|
||||
pub allowed_route_ids: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct UpdateDownstreamKeyRequest {
|
||||
pub name: Option<String>,
|
||||
pub description: Option<String>,
|
||||
pub group_name: Option<String>,
|
||||
pub tags: Option<String>,
|
||||
pub enabled: Option<bool>,
|
||||
pub expires_at: Option<String>,
|
||||
pub max_cost: Option<f64>,
|
||||
pub max_requests: Option<i64>,
|
||||
pub supported_models: Option<Vec<String>>,
|
||||
pub allowed_route_ids: Option<String>,
|
||||
}
|
||||
|
||||
/// Helper to validate models from a JSON string against a Vec<String>
|
||||
fn validate_supported_models(models: &[String]) -> AppResult<()> {
|
||||
for model in models {
|
||||
if model.trim().is_empty() {
|
||||
return Err(AppError::BadRequest(
|
||||
"supported_models contains empty model name".into(),
|
||||
));
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// GET /api/downstream-api-keys
|
||||
pub async fn list_downstream_keys(State(state): State<AppState>) -> AppResult<Json<Value>> {
|
||||
info!("Listing downstream API keys");
|
||||
|
||||
let keys: Vec<DownstreamApiKey> =
|
||||
sqlx::query_as::<_, DownstreamApiKey>("SELECT * FROM downstream_api_keys ORDER BY name")
|
||||
.fetch_all(&*state.db)
|
||||
.await?;
|
||||
|
||||
Ok(Json(json!({ "data": keys })))
|
||||
}
|
||||
|
||||
/// POST /api/downstream-api-keys
|
||||
pub async fn create_downstream_key(
|
||||
State(state): State<AppState>,
|
||||
Json(payload): Json<CreateDownstreamKeyRequest>,
|
||||
) -> AppResult<(StatusCode, Json<Value>)> {
|
||||
info!("Creating downstream API key: {}", payload.name);
|
||||
|
||||
if payload.name.trim().is_empty() {
|
||||
return Err(AppError::BadRequest("name is required".into()));
|
||||
}
|
||||
|
||||
// Auto-generate key
|
||||
let key = format!("sk-{}", Uuid::new_v4().to_string().replace('-', ""));
|
||||
|
||||
// Serialize supported_models to JSON string if provided
|
||||
let supported_models: Option<String> = if let Some(ref models) = payload.supported_models {
|
||||
validate_supported_models(models)?;
|
||||
Some(
|
||||
serde_json::to_string(models)
|
||||
.map_err(|e| AppError::BadRequest(format!("Invalid supported_models: {}", e)))?,
|
||||
)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
let enabled = payload.enabled.unwrap_or(true);
|
||||
|
||||
let result = sqlx::query(
|
||||
"INSERT INTO downstream_api_keys (name, key, description, group_name, tags, enabled, expires_at, max_cost, max_requests, supported_models, allowed_route_ids) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)"
|
||||
)
|
||||
.bind(&payload.name)
|
||||
.bind(&key)
|
||||
.bind(&payload.description)
|
||||
.bind(&payload.group_name)
|
||||
.bind(&payload.tags)
|
||||
.bind(enabled)
|
||||
.bind(&payload.expires_at)
|
||||
.bind(payload.max_cost)
|
||||
.bind(payload.max_requests)
|
||||
.bind(&supported_models)
|
||||
.bind(&payload.allowed_route_ids)
|
||||
.execute(&*state.db)
|
||||
.await?;
|
||||
|
||||
let key_id = result.last_insert_rowid();
|
||||
let api_key: DownstreamApiKey =
|
||||
sqlx::query_as::<_, DownstreamApiKey>("SELECT * FROM downstream_api_keys WHERE id = ?")
|
||||
.bind(key_id)
|
||||
.fetch_one(&*state.db)
|
||||
.await?;
|
||||
|
||||
Ok((StatusCode::CREATED, Json(json!({ "data": api_key }))))
|
||||
}
|
||||
|
||||
/// PUT /api/downstream-api-keys/{id}
|
||||
pub async fn update_downstream_key(
|
||||
State(state): State<AppState>,
|
||||
Path(id): Path<i64>,
|
||||
Json(payload): Json<UpdateDownstreamKeyRequest>,
|
||||
) -> AppResult<Json<Value>> {
|
||||
info!("Updating downstream API key: {}", id);
|
||||
|
||||
let existing: DownstreamApiKey =
|
||||
sqlx::query_as::<_, DownstreamApiKey>("SELECT * FROM downstream_api_keys WHERE id = ?")
|
||||
.bind(id)
|
||||
.fetch_optional(&*state.db)
|
||||
.await?
|
||||
.ok_or_else(|| {
|
||||
AppError::NotFound(format!("Downstream key with id {} not found", id))
|
||||
})?;
|
||||
|
||||
let supported_models: Option<String> = if let Some(ref models) = payload.supported_models {
|
||||
validate_supported_models(models)?;
|
||||
Some(
|
||||
serde_json::to_string(models)
|
||||
.map_err(|e| AppError::BadRequest(format!("Invalid supported_models: {}", e)))?,
|
||||
)
|
||||
} else {
|
||||
existing.supported_models.clone()
|
||||
};
|
||||
|
||||
sqlx::query(
|
||||
"UPDATE downstream_api_keys SET name = ?, description = ?, group_name = ?, tags = ?, enabled = ?, expires_at = ?, max_cost = ?, max_requests = ?, supported_models = ?, allowed_route_ids = ?, updated_at = datetime('now') WHERE id = ?"
|
||||
)
|
||||
.bind(payload.name.unwrap_or(existing.name))
|
||||
.bind(payload.description.or(existing.description))
|
||||
.bind(payload.group_name.or(existing.group_name))
|
||||
.bind(payload.tags.or(existing.tags))
|
||||
.bind(payload.enabled.unwrap_or(existing.enabled.unwrap_or(true)))
|
||||
.bind(payload.expires_at.or(existing.expires_at))
|
||||
.bind(payload.max_cost.or(existing.max_cost))
|
||||
.bind(payload.max_requests.or(existing.max_requests))
|
||||
.bind(&supported_models)
|
||||
.bind(payload.allowed_route_ids.or(existing.allowed_route_ids))
|
||||
.bind(id)
|
||||
.execute(&*state.db)
|
||||
.await?;
|
||||
|
||||
let api_key: DownstreamApiKey =
|
||||
sqlx::query_as::<_, DownstreamApiKey>("SELECT * FROM downstream_api_keys WHERE id = ?")
|
||||
.bind(id)
|
||||
.fetch_one(&*state.db)
|
||||
.await?;
|
||||
|
||||
Ok(Json(json!({ "data": api_key })))
|
||||
}
|
||||
|
||||
/// DELETE /api/downstream-api-keys/{id}
|
||||
pub async fn delete_downstream_key(
|
||||
State(state): State<AppState>,
|
||||
Path(id): Path<i64>,
|
||||
) -> AppResult<Json<Value>> {
|
||||
info!("Deleting downstream API key: {}", id);
|
||||
|
||||
let affected = sqlx::query("DELETE FROM downstream_api_keys WHERE id = ?")
|
||||
.bind(id)
|
||||
.execute(&*state.db)
|
||||
.await?
|
||||
.rows_affected();
|
||||
|
||||
if affected == 0 {
|
||||
return Err(AppError::NotFound(format!(
|
||||
"Downstream key with id {} not found",
|
||||
id
|
||||
)));
|
||||
}
|
||||
|
||||
Ok(Json(json!({ "data": { "deleted": true, "id": id } })))
|
||||
}
|
||||
@@ -0,0 +1,174 @@
|
||||
use crate::{
|
||||
db::models::setting::Setting,
|
||||
error::{AppError, AppResult},
|
||||
AppState,
|
||||
};
|
||||
use axum::{
|
||||
extract::State,
|
||||
http::HeaderMap,
|
||||
middleware,
|
||||
response::Response,
|
||||
routing::{get, put},
|
||||
Json, Router,
|
||||
};
|
||||
use serde_json::{json, Value};
|
||||
use tracing::info;
|
||||
|
||||
mod account_tokens;
|
||||
mod accounts;
|
||||
mod downstream_keys;
|
||||
mod sites;
|
||||
mod tokens;
|
||||
mod users;
|
||||
|
||||
/// Middleware to require admin authentication via Bearer token.
|
||||
async fn require_admin_auth(
|
||||
State(state): State<AppState>,
|
||||
headers: HeaderMap,
|
||||
request: axum::http::Request<axum::body::Body>,
|
||||
next: axum::middleware::Next,
|
||||
) -> Result<Response, AppError> {
|
||||
let auth_header = headers
|
||||
.get("Authorization")
|
||||
.and_then(|v| v.to_str().ok())
|
||||
.and_then(|v| v.strip_prefix("Bearer "))
|
||||
.ok_or_else(|| AppError::Unauthorized("Missing or invalid Authorization header".into()))?;
|
||||
|
||||
if auth_header != state.config.auth.auth_token {
|
||||
return Err(AppError::Unauthorized("Invalid admin token".into()));
|
||||
}
|
||||
|
||||
Ok(next.run(request).await)
|
||||
}
|
||||
|
||||
/// Build all admin routes. Returns Router<AppState> (without calling .with_state).
|
||||
pub fn routes(state: AppState) -> Router<AppState> {
|
||||
let sites_routes = Router::new()
|
||||
.route(
|
||||
"/api/sites",
|
||||
get(sites::list_sites).post(sites::create_site),
|
||||
)
|
||||
.route(
|
||||
"/api/sites/{id}",
|
||||
get(sites::get_site)
|
||||
.put(sites::update_site)
|
||||
.delete(sites::delete_site),
|
||||
);
|
||||
|
||||
let accounts_routes = Router::new()
|
||||
.route(
|
||||
"/api/accounts",
|
||||
get(accounts::list_accounts).post(accounts::create_account),
|
||||
)
|
||||
.route(
|
||||
"/api/accounts/{id}",
|
||||
get(accounts::get_account)
|
||||
.put(accounts::update_account)
|
||||
.delete(accounts::delete_account),
|
||||
);
|
||||
|
||||
let tokens_routes = Router::new()
|
||||
.route(
|
||||
"/api/tokens",
|
||||
get(tokens::list_tokens).post(tokens::create_token),
|
||||
)
|
||||
.route(
|
||||
"/api/tokens/refresh-route",
|
||||
get(tokens::refresh_route).post(tokens::refresh_route),
|
||||
)
|
||||
.route("/api/tokens/route-decision", get(tokens::route_decision))
|
||||
.route(
|
||||
"/api/tokens/{id}",
|
||||
get(tokens::get_token)
|
||||
.put(tokens::update_token)
|
||||
.delete(tokens::delete_token),
|
||||
);
|
||||
|
||||
let downstream_routes = Router::new()
|
||||
.route(
|
||||
"/api/downstream-api-keys",
|
||||
get(downstream_keys::list_downstream_keys).post(downstream_keys::create_downstream_key),
|
||||
)
|
||||
.route(
|
||||
"/api/downstream-api-keys/{id}",
|
||||
put(downstream_keys::update_downstream_key)
|
||||
.delete(downstream_keys::delete_downstream_key),
|
||||
);
|
||||
|
||||
let settings_routes =
|
||||
Router::new().route("/api/settings", get(get_settings).put(update_setting));
|
||||
|
||||
let users_routes = Router::new()
|
||||
.route(
|
||||
"/api/admin/users",
|
||||
get(users::list_users).post(users::create_user),
|
||||
)
|
||||
.route(
|
||||
"/api/admin/users/{id}",
|
||||
get(users::get_user)
|
||||
.put(users::update_user)
|
||||
.delete(users::delete_user),
|
||||
);
|
||||
|
||||
Router::new()
|
||||
.merge(sites_routes)
|
||||
.merge(accounts_routes)
|
||||
.merge(tokens_routes)
|
||||
.merge(downstream_routes)
|
||||
.merge(settings_routes)
|
||||
.merge(users_routes)
|
||||
.merge(account_tokens::routes())
|
||||
.route_layer(middleware::from_fn_with_state(state, require_admin_auth))
|
||||
}
|
||||
|
||||
// ── Settings handlers ────────────────────────────────────────────────
|
||||
|
||||
/// GET /api/settings — return all settings as a key-value map.
|
||||
async fn get_settings(State(state): State<AppState>) -> AppResult<Json<Value>> {
|
||||
info!("Getting all settings");
|
||||
|
||||
let rows: Vec<Setting> =
|
||||
sqlx::query_as::<_, Setting>("SELECT key, value FROM settings ORDER BY key")
|
||||
.fetch_all(&*state.db)
|
||||
.await?;
|
||||
|
||||
let map: std::collections::BTreeMap<String, Option<String>> =
|
||||
rows.into_iter().map(|s| (s.key, s.value)).collect();
|
||||
|
||||
Ok(Json(json!({ "data": map })))
|
||||
}
|
||||
|
||||
/// PUT /api/settings — upsert a setting by key.
|
||||
/// Expects JSON body: { "key": "...", "value": "..." }
|
||||
async fn update_setting(
|
||||
State(state): State<AppState>,
|
||||
Json(payload): Json<serde_json::Value>,
|
||||
) -> AppResult<Json<Value>> {
|
||||
let key = payload
|
||||
.get("key")
|
||||
.and_then(|k| k.as_str())
|
||||
.ok_or_else(|| AppError::BadRequest("key is required".into()))?;
|
||||
|
||||
let value = payload.get("value").and_then(|v| {
|
||||
if v.is_null() {
|
||||
None
|
||||
} else {
|
||||
v.as_str().map(|s| s.to_string())
|
||||
}
|
||||
});
|
||||
|
||||
info!("Upserting setting: {}", key);
|
||||
|
||||
sqlx::query("INSERT OR REPLACE INTO settings (key, value) VALUES (?, ?)")
|
||||
.bind(key)
|
||||
.bind(&value)
|
||||
.execute(&*state.db)
|
||||
.await?;
|
||||
|
||||
Ok(Json(json!({
|
||||
"data": {
|
||||
"key": key,
|
||||
"value": value
|
||||
}
|
||||
})))
|
||||
}
|
||||
@@ -0,0 +1,168 @@
|
||||
use crate::{
|
||||
db::models::site::Site,
|
||||
error::{AppError, AppResult},
|
||||
AppState,
|
||||
};
|
||||
use axum::{
|
||||
extract::{Path, State},
|
||||
http::StatusCode,
|
||||
Json,
|
||||
};
|
||||
use serde::Deserialize;
|
||||
use serde_json::{json, Value};
|
||||
use tracing::info;
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct CreateSiteRequest {
|
||||
pub name: String,
|
||||
pub url: String,
|
||||
pub platform: String,
|
||||
pub status: Option<String>,
|
||||
pub api_key: Option<String>,
|
||||
pub proxy_url: Option<String>,
|
||||
pub use_system_proxy: Option<bool>,
|
||||
pub custom_headers: Option<String>,
|
||||
pub global_weight: Option<f64>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct UpdateSiteRequest {
|
||||
pub name: Option<String>,
|
||||
pub url: Option<String>,
|
||||
pub platform: Option<String>,
|
||||
pub status: Option<String>,
|
||||
pub api_key: Option<String>,
|
||||
pub proxy_url: Option<String>,
|
||||
pub use_system_proxy: Option<bool>,
|
||||
pub custom_headers: Option<String>,
|
||||
pub global_weight: Option<f64>,
|
||||
}
|
||||
|
||||
/// GET /api/sites
|
||||
pub async fn list_sites(State(state): State<AppState>) -> AppResult<Json<Value>> {
|
||||
info!("Listing all sites");
|
||||
|
||||
let sites: Vec<Site> =
|
||||
sqlx::query_as::<_, Site>("SELECT * FROM sites ORDER BY sort_order, name")
|
||||
.fetch_all(&*state.db)
|
||||
.await?;
|
||||
|
||||
Ok(Json(json!({ "data": sites })))
|
||||
}
|
||||
|
||||
/// POST /api/sites
|
||||
pub async fn create_site(
|
||||
State(state): State<AppState>,
|
||||
Json(payload): Json<CreateSiteRequest>,
|
||||
) -> AppResult<(StatusCode, Json<Value>)> {
|
||||
info!("Creating site: {}", payload.name);
|
||||
|
||||
if payload.name.trim().is_empty() {
|
||||
return Err(AppError::BadRequest("name is required".into()));
|
||||
}
|
||||
if payload.url.trim().is_empty() {
|
||||
return Err(AppError::BadRequest("url is required".into()));
|
||||
}
|
||||
if payload.platform.trim().is_empty() {
|
||||
return Err(AppError::BadRequest("platform is required".into()));
|
||||
}
|
||||
|
||||
let status = payload.status.unwrap_or_else(|| "active".to_string());
|
||||
|
||||
let result = sqlx::query(
|
||||
"INSERT INTO sites (name, url, platform, status, api_key, proxy_url, use_system_proxy, custom_headers, global_weight) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)"
|
||||
)
|
||||
.bind(&payload.name)
|
||||
.bind(&payload.url)
|
||||
.bind(&payload.platform)
|
||||
.bind(&status)
|
||||
.bind(&payload.api_key)
|
||||
.bind(&payload.proxy_url)
|
||||
.bind(payload.use_system_proxy)
|
||||
.bind(&payload.custom_headers)
|
||||
.bind(payload.global_weight)
|
||||
.execute(&*state.db)
|
||||
.await?;
|
||||
|
||||
let site_id = result.last_insert_rowid();
|
||||
let site: Site = sqlx::query_as::<_, Site>("SELECT * FROM sites WHERE id = ?")
|
||||
.bind(site_id)
|
||||
.fetch_one(&*state.db)
|
||||
.await?;
|
||||
|
||||
Ok((StatusCode::CREATED, Json(json!({ "data": site }))))
|
||||
}
|
||||
|
||||
/// GET /api/sites/{id}
|
||||
pub async fn get_site(
|
||||
State(state): State<AppState>,
|
||||
Path(id): Path<i64>,
|
||||
) -> AppResult<Json<Value>> {
|
||||
info!("Getting site: {}", id);
|
||||
|
||||
let site: Site = sqlx::query_as::<_, Site>("SELECT * FROM sites WHERE id = ?")
|
||||
.bind(id)
|
||||
.fetch_optional(&*state.db)
|
||||
.await?
|
||||
.ok_or_else(|| AppError::NotFound(format!("Site with id {} not found", id)))?;
|
||||
|
||||
Ok(Json(json!({ "data": site })))
|
||||
}
|
||||
|
||||
/// PUT /api/sites/{id}
|
||||
pub async fn update_site(
|
||||
State(state): State<AppState>,
|
||||
Path(id): Path<i64>,
|
||||
Json(payload): Json<UpdateSiteRequest>,
|
||||
) -> AppResult<Json<Value>> {
|
||||
info!("Updating site: {}", id);
|
||||
|
||||
let existing: Site = sqlx::query_as::<_, Site>("SELECT * FROM sites WHERE id = ?")
|
||||
.bind(id)
|
||||
.fetch_optional(&*state.db)
|
||||
.await?
|
||||
.ok_or_else(|| AppError::NotFound(format!("Site with id {} not found", id)))?;
|
||||
|
||||
sqlx::query(
|
||||
"UPDATE sites SET name = ?, url = ?, platform = ?, status = ?, api_key = ?, proxy_url = ?, use_system_proxy = ?, custom_headers = ?, global_weight = ?, updated_at = datetime('now') WHERE id = ?"
|
||||
)
|
||||
.bind(payload.name.unwrap_or(existing.name))
|
||||
.bind(payload.url.unwrap_or(existing.url))
|
||||
.bind(payload.platform.unwrap_or(existing.platform))
|
||||
.bind(payload.status.unwrap_or(existing.status))
|
||||
.bind(payload.api_key.or(existing.api_key))
|
||||
.bind(payload.proxy_url.or(existing.proxy_url))
|
||||
.bind(payload.use_system_proxy.or(existing.use_system_proxy))
|
||||
.bind(payload.custom_headers.or(existing.custom_headers))
|
||||
.bind(payload.global_weight.or(existing.global_weight))
|
||||
.bind(id)
|
||||
.execute(&*state.db)
|
||||
.await?;
|
||||
|
||||
let site: Site = sqlx::query_as::<_, Site>("SELECT * FROM sites WHERE id = ?")
|
||||
.bind(id)
|
||||
.fetch_one(&*state.db)
|
||||
.await?;
|
||||
|
||||
Ok(Json(json!({ "data": site })))
|
||||
}
|
||||
|
||||
/// DELETE /api/sites/{id}
|
||||
pub async fn delete_site(
|
||||
State(state): State<AppState>,
|
||||
Path(id): Path<i64>,
|
||||
) -> AppResult<Json<Value>> {
|
||||
info!("Deleting site: {}", id);
|
||||
|
||||
let affected = sqlx::query("DELETE FROM sites WHERE id = ?")
|
||||
.bind(id)
|
||||
.execute(&*state.db)
|
||||
.await?
|
||||
.rows_affected();
|
||||
|
||||
if affected == 0 {
|
||||
return Err(AppError::NotFound(format!("Site with id {} not found", id)));
|
||||
}
|
||||
|
||||
Ok(Json(json!({ "data": { "deleted": true, "id": id } })))
|
||||
}
|
||||
@@ -0,0 +1,286 @@
|
||||
use crate::{
|
||||
db::models::account::AccountToken,
|
||||
error::{AppError, AppResult},
|
||||
AppState,
|
||||
};
|
||||
use axum::{
|
||||
extract::{Path, Query, State},
|
||||
http::StatusCode,
|
||||
Json,
|
||||
};
|
||||
use serde::Deserialize;
|
||||
use serde_json::{json, Value};
|
||||
use tracing::info;
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct ListTokensQuery {
|
||||
pub account_id: Option<i64>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct CreateTokenRequest {
|
||||
pub account_id: i64,
|
||||
pub name: String,
|
||||
pub token: String,
|
||||
pub source: Option<String>,
|
||||
pub token_group: Option<String>,
|
||||
pub enabled: Option<bool>,
|
||||
pub is_default: Option<bool>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct UpdateTokenRequest {
|
||||
pub name: Option<String>,
|
||||
pub token: Option<String>,
|
||||
pub source: Option<String>,
|
||||
pub token_group: Option<String>,
|
||||
pub enabled: Option<bool>,
|
||||
pub is_default: Option<bool>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct RefreshRouteRequest {
|
||||
pub model_pattern: Option<String>,
|
||||
pub route_id: Option<i64>,
|
||||
}
|
||||
|
||||
/// GET /api/tokens?account_id=
|
||||
pub async fn list_tokens(
|
||||
State(state): State<AppState>,
|
||||
Query(query): Query<ListTokensQuery>,
|
||||
) -> AppResult<Json<Value>> {
|
||||
info!("Listing tokens, account_id filter: {:?}", query.account_id);
|
||||
|
||||
let tokens: Vec<AccountToken> = if let Some(account_id) = query.account_id {
|
||||
sqlx::query_as::<_, AccountToken>(
|
||||
"SELECT * FROM account_tokens WHERE account_id = ? ORDER BY id",
|
||||
)
|
||||
.bind(account_id)
|
||||
.fetch_all(&*state.db)
|
||||
.await?
|
||||
} else {
|
||||
sqlx::query_as::<_, AccountToken>("SELECT * FROM account_tokens ORDER BY account_id, id")
|
||||
.fetch_all(&*state.db)
|
||||
.await?
|
||||
};
|
||||
|
||||
Ok(Json(json!({ "data": tokens })))
|
||||
}
|
||||
|
||||
/// POST /api/tokens
|
||||
pub async fn create_token(
|
||||
State(state): State<AppState>,
|
||||
Json(payload): Json<CreateTokenRequest>,
|
||||
) -> AppResult<(StatusCode, Json<Value>)> {
|
||||
info!("Creating token for account_id: {}", payload.account_id);
|
||||
|
||||
// Validate
|
||||
if payload.name.trim().is_empty() {
|
||||
return Err(AppError::BadRequest("name is required".into()));
|
||||
}
|
||||
if payload.token.trim().is_empty() {
|
||||
return Err(AppError::BadRequest("token is required".into()));
|
||||
}
|
||||
|
||||
// Verify account exists
|
||||
let account_exists: Option<(i64,)> = sqlx::query_as("SELECT id FROM accounts WHERE id = ?")
|
||||
.bind(payload.account_id)
|
||||
.fetch_optional(&*state.db)
|
||||
.await?;
|
||||
|
||||
if account_exists.is_none() {
|
||||
return Err(AppError::BadRequest(format!(
|
||||
"Account with id {} not found",
|
||||
payload.account_id
|
||||
)));
|
||||
}
|
||||
|
||||
let source = payload.source.unwrap_or_else(|| "manual".to_string());
|
||||
let enabled = payload.enabled.unwrap_or(true);
|
||||
let is_default = payload.is_default.unwrap_or(false);
|
||||
|
||||
let result = sqlx::query(
|
||||
"INSERT INTO account_tokens (account_id, name, token, source, token_group, enabled, is_default) VALUES (?, ?, ?, ?, ?, ?, ?)"
|
||||
)
|
||||
.bind(payload.account_id)
|
||||
.bind(&payload.name)
|
||||
.bind(&payload.token)
|
||||
.bind(&source)
|
||||
.bind(&payload.token_group)
|
||||
.bind(enabled)
|
||||
.bind(is_default)
|
||||
.execute(&*state.db)
|
||||
.await?;
|
||||
|
||||
let token_id = result.last_insert_rowid();
|
||||
let token: AccountToken =
|
||||
sqlx::query_as::<_, AccountToken>("SELECT * FROM account_tokens WHERE id = ?")
|
||||
.bind(token_id)
|
||||
.fetch_one(&*state.db)
|
||||
.await?;
|
||||
|
||||
Ok((StatusCode::CREATED, Json(json!({ "data": token }))))
|
||||
}
|
||||
|
||||
/// GET /api/tokens/{id}
|
||||
pub async fn get_token(
|
||||
State(state): State<AppState>,
|
||||
Path(id): Path<i64>,
|
||||
) -> AppResult<Json<Value>> {
|
||||
info!("Getting token: {}", id);
|
||||
|
||||
let token: AccountToken =
|
||||
sqlx::query_as::<_, AccountToken>("SELECT * FROM account_tokens WHERE id = ?")
|
||||
.bind(id)
|
||||
.fetch_optional(&*state.db)
|
||||
.await?
|
||||
.ok_or_else(|| AppError::NotFound(format!("Token with id {} not found", id)))?;
|
||||
|
||||
Ok(Json(json!({ "data": token })))
|
||||
}
|
||||
|
||||
/// PUT /api/tokens/{id}
|
||||
pub async fn update_token(
|
||||
State(state): State<AppState>,
|
||||
Path(id): Path<i64>,
|
||||
Json(payload): Json<UpdateTokenRequest>,
|
||||
) -> AppResult<Json<Value>> {
|
||||
info!("Updating token: {}", id);
|
||||
|
||||
let existing: AccountToken =
|
||||
sqlx::query_as::<_, AccountToken>("SELECT * FROM account_tokens WHERE id = ?")
|
||||
.bind(id)
|
||||
.fetch_optional(&*state.db)
|
||||
.await?
|
||||
.ok_or_else(|| AppError::NotFound(format!("Token with id {} not found", id)))?;
|
||||
|
||||
sqlx::query(
|
||||
"UPDATE account_tokens SET name = ?, token = ?, source = ?, token_group = ?, enabled = ?, is_default = ?, updated_at = datetime('now') WHERE id = ?"
|
||||
)
|
||||
.bind(payload.name.unwrap_or(existing.name))
|
||||
.bind(payload.token.as_ref().unwrap_or(&existing.token))
|
||||
.bind(payload.source.unwrap_or(existing.source.unwrap_or_else(|| "manual".to_string())))
|
||||
.bind(payload.token_group.or(existing.token_group))
|
||||
.bind(payload.enabled.unwrap_or(existing.enabled.unwrap_or(true)))
|
||||
.bind(payload.is_default.unwrap_or(existing.is_default.unwrap_or(false)))
|
||||
.bind(id)
|
||||
.execute(&*state.db)
|
||||
.await?;
|
||||
|
||||
let token: AccountToken =
|
||||
sqlx::query_as::<_, AccountToken>("SELECT * FROM account_tokens WHERE id = ?")
|
||||
.bind(id)
|
||||
.fetch_one(&*state.db)
|
||||
.await?;
|
||||
|
||||
Ok(Json(json!({ "data": token })))
|
||||
}
|
||||
|
||||
/// DELETE /api/tokens/{id}
|
||||
pub async fn delete_token(
|
||||
State(state): State<AppState>,
|
||||
Path(id): Path<i64>,
|
||||
) -> AppResult<Json<Value>> {
|
||||
info!("Deleting token: {}", id);
|
||||
|
||||
let affected = sqlx::query("DELETE FROM account_tokens WHERE id = ?")
|
||||
.bind(id)
|
||||
.execute(&*state.db)
|
||||
.await?
|
||||
.rows_affected();
|
||||
|
||||
if affected == 0 {
|
||||
return Err(AppError::NotFound(format!(
|
||||
"Token with id {} not found",
|
||||
id
|
||||
)));
|
||||
}
|
||||
|
||||
Ok(Json(json!({ "data": { "deleted": true, "id": id } })))
|
||||
}
|
||||
|
||||
/// POST /api/tokens/refresh-route
|
||||
pub async fn refresh_route(
|
||||
State(state): State<AppState>,
|
||||
Json(payload): Json<RefreshRouteRequest>,
|
||||
) -> AppResult<Json<Value>> {
|
||||
info!("Refreshing route decision");
|
||||
|
||||
if let Some(route_id) = payload.route_id {
|
||||
sqlx::query(
|
||||
"UPDATE token_routes SET decision_refreshed_at = datetime('now'), updated_at = datetime('now') WHERE id = ?"
|
||||
)
|
||||
.bind(route_id)
|
||||
.execute(&*state.db)
|
||||
.await?;
|
||||
|
||||
let route: Option<crate::db::models::token_route::TokenRoute> =
|
||||
sqlx::query_as("SELECT * FROM token_routes WHERE id = ?")
|
||||
.bind(route_id)
|
||||
.fetch_optional(&*state.db)
|
||||
.await?;
|
||||
|
||||
return Ok(Json(
|
||||
json!({ "data": { "refreshed": true, "route": route } }),
|
||||
));
|
||||
}
|
||||
|
||||
if let Some(ref model_pattern) = payload.model_pattern {
|
||||
let result = sqlx::query(
|
||||
"UPDATE token_routes SET decision_refreshed_at = datetime('now'), updated_at = datetime('now') WHERE model_pattern LIKE ?"
|
||||
)
|
||||
.bind(format!("%{}%", model_pattern))
|
||||
.execute(&*state.db)
|
||||
.await?;
|
||||
|
||||
return Ok(Json(json!({
|
||||
"data": {
|
||||
"refreshed": true,
|
||||
"affected_routes": result.rows_affected()
|
||||
}
|
||||
})));
|
||||
}
|
||||
|
||||
// Refresh all routes
|
||||
let result = sqlx::query(
|
||||
"UPDATE token_routes SET decision_refreshed_at = datetime('now'), updated_at = datetime('now')"
|
||||
)
|
||||
.execute(&*state.db)
|
||||
.await?;
|
||||
|
||||
Ok(Json(json!({
|
||||
"data": {
|
||||
"refreshed": true,
|
||||
"affected_routes": result.rows_affected()
|
||||
}
|
||||
})))
|
||||
}
|
||||
|
||||
/// GET /api/tokens/route-decision
|
||||
pub async fn route_decision(
|
||||
State(state): State<AppState>,
|
||||
Query(query): Query<RefreshRouteRequest>,
|
||||
) -> AppResult<Json<Value>> {
|
||||
info!("Getting route decisions");
|
||||
|
||||
let routes: Vec<crate::db::models::token_route::TokenRoute> =
|
||||
if let Some(route_id) = query.route_id {
|
||||
sqlx::query_as("SELECT * FROM token_routes WHERE id = ? AND enabled = 1")
|
||||
.bind(route_id)
|
||||
.fetch_optional(&*state.db)
|
||||
.await?
|
||||
.map(|r| vec![r])
|
||||
.unwrap_or_default()
|
||||
} else if let Some(ref model_pattern) = query.model_pattern {
|
||||
sqlx::query_as("SELECT * FROM token_routes WHERE model_pattern LIKE ? AND enabled = 1")
|
||||
.bind(format!("%{}%", model_pattern))
|
||||
.fetch_all(&*state.db)
|
||||
.await?
|
||||
} else {
|
||||
sqlx::query_as("SELECT * FROM token_routes WHERE enabled = 1 ORDER BY model_pattern")
|
||||
.fetch_all(&*state.db)
|
||||
.await?
|
||||
};
|
||||
|
||||
Ok(Json(json!({ "data": routes })))
|
||||
}
|
||||
@@ -0,0 +1,191 @@
|
||||
// Admin user management — CRUD for platform users
|
||||
|
||||
use crate::{
|
||||
auth::password::hash_password,
|
||||
db::models::user::User,
|
||||
error::{AppError, AppResult},
|
||||
AppState,
|
||||
};
|
||||
use axum::{
|
||||
extract::{Path, State},
|
||||
Json,
|
||||
};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::{json, Value};
|
||||
use tracing::info;
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct CreateUserRequest {
|
||||
pub username: String,
|
||||
pub email: String,
|
||||
pub password: String,
|
||||
pub role: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct UpdateUserRequest {
|
||||
pub username: Option<String>,
|
||||
pub email: Option<String>,
|
||||
pub password: Option<String>,
|
||||
pub role: Option<String>,
|
||||
pub status: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct UserResponse {
|
||||
pub id: i64,
|
||||
pub username: String,
|
||||
pub email: String,
|
||||
pub role: String,
|
||||
pub status: String,
|
||||
pub created_at: Option<String>,
|
||||
pub updated_at: Option<String>,
|
||||
}
|
||||
|
||||
impl From<User> for UserResponse {
|
||||
fn from(u: User) -> Self {
|
||||
Self {
|
||||
id: u.id,
|
||||
username: u.username,
|
||||
email: u.email,
|
||||
role: u.role,
|
||||
status: u.status,
|
||||
created_at: u.created_at,
|
||||
updated_at: u.updated_at,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// GET /api/admin/users
|
||||
pub async fn list_users(State(state): State<AppState>) -> AppResult<Json<Value>> {
|
||||
info!("Listing admin users");
|
||||
let users: Vec<User> = sqlx::query_as::<_, User>("SELECT * FROM users ORDER BY id")
|
||||
.fetch_all(&*state.db)
|
||||
.await?;
|
||||
let items: Vec<UserResponse> = users.into_iter().map(Into::into).collect();
|
||||
Ok(Json(json!({ "data": items })))
|
||||
}
|
||||
|
||||
/// GET /api/admin/users/{id}
|
||||
pub async fn get_user(
|
||||
State(state): State<AppState>,
|
||||
Path(id): Path<i64>,
|
||||
) -> AppResult<Json<Value>> {
|
||||
info!("Getting user: {}", id);
|
||||
let user: User = sqlx::query_as::<_, User>("SELECT * FROM users WHERE id = ?")
|
||||
.bind(id)
|
||||
.fetch_optional(&*state.db)
|
||||
.await?
|
||||
.ok_or_else(|| AppError::NotFound(format!("User {} not found", id)))?;
|
||||
Ok(Json(json!({ "data": UserResponse::from(user) })))
|
||||
}
|
||||
|
||||
/// POST /api/admin/users
|
||||
pub async fn create_user(
|
||||
State(state): State<AppState>,
|
||||
Json(payload): Json<CreateUserRequest>,
|
||||
) -> AppResult<(axum::http::StatusCode, Json<Value>)> {
|
||||
info!("Creating user: {}", payload.username);
|
||||
|
||||
if payload.username.trim().is_empty() {
|
||||
return Err(AppError::BadRequest("username is required".into()));
|
||||
}
|
||||
if payload.email.trim().is_empty() {
|
||||
return Err(AppError::BadRequest("email is required".into()));
|
||||
}
|
||||
if payload.password.len() < 6 {
|
||||
return Err(AppError::BadRequest(
|
||||
"password must be at least 6 characters".into(),
|
||||
));
|
||||
}
|
||||
|
||||
let password_hash = hash_password(&payload.password)
|
||||
.map_err(|e| AppError::Internal(anyhow::anyhow!("Failed to hash password: {e}")))?;
|
||||
let role = payload.role.unwrap_or_else(|| "user".to_string());
|
||||
|
||||
let result =
|
||||
sqlx::query("INSERT INTO users (username, email, password_hash, role) VALUES (?, ?, ?, ?)")
|
||||
.bind(&payload.username)
|
||||
.bind(&payload.email)
|
||||
.bind(&password_hash)
|
||||
.bind(&role)
|
||||
.execute(&*state.db)
|
||||
.await?;
|
||||
|
||||
let user: User = sqlx::query_as::<_, User>("SELECT * FROM users WHERE id = ?")
|
||||
.bind(result.last_insert_rowid())
|
||||
.fetch_one(&*state.db)
|
||||
.await?;
|
||||
|
||||
Ok((
|
||||
axum::http::StatusCode::CREATED,
|
||||
Json(json!({ "data": UserResponse::from(user) })),
|
||||
))
|
||||
}
|
||||
|
||||
/// PUT /api/admin/users/{id}
|
||||
pub async fn update_user(
|
||||
State(state): State<AppState>,
|
||||
Path(id): Path<i64>,
|
||||
Json(payload): Json<UpdateUserRequest>,
|
||||
) -> AppResult<Json<Value>> {
|
||||
info!("Updating user: {}", id);
|
||||
|
||||
let existing: User = sqlx::query_as::<_, User>("SELECT * FROM users WHERE id = ?")
|
||||
.bind(id)
|
||||
.fetch_optional(&*state.db)
|
||||
.await?
|
||||
.ok_or_else(|| AppError::NotFound(format!("User {} not found", id)))?;
|
||||
|
||||
let password_hash = if let Some(ref pw) = payload.password {
|
||||
if pw.len() < 6 {
|
||||
return Err(AppError::BadRequest(
|
||||
"password must be at least 6 characters".into(),
|
||||
));
|
||||
}
|
||||
Some(
|
||||
hash_password(pw)
|
||||
.map_err(|e| AppError::Internal(anyhow::anyhow!("Failed to hash password: {e}")))?,
|
||||
)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
sqlx::query(
|
||||
"UPDATE users SET username = ?, email = ?, role = ?, status = ?, password_hash = COALESCE(?, password_hash), updated_at = datetime('now') WHERE id = ?"
|
||||
)
|
||||
.bind(payload.username.unwrap_or(existing.username))
|
||||
.bind(payload.email.unwrap_or(existing.email))
|
||||
.bind(payload.role.unwrap_or(existing.role))
|
||||
.bind(payload.status.unwrap_or(existing.status))
|
||||
.bind(password_hash)
|
||||
.bind(id)
|
||||
.execute(&*state.db)
|
||||
.await?;
|
||||
|
||||
let user: User = sqlx::query_as::<_, User>("SELECT * FROM users WHERE id = ?")
|
||||
.bind(id)
|
||||
.fetch_one(&*state.db)
|
||||
.await?;
|
||||
|
||||
Ok(Json(json!({ "data": UserResponse::from(user) })))
|
||||
}
|
||||
|
||||
/// DELETE /api/admin/users/{id}
|
||||
pub async fn delete_user(
|
||||
State(state): State<AppState>,
|
||||
Path(id): Path<i64>,
|
||||
) -> AppResult<Json<Value>> {
|
||||
info!("Deleting user: {}", id);
|
||||
let affected = sqlx::query("DELETE FROM users WHERE id = ?")
|
||||
.bind(id)
|
||||
.execute(&*state.db)
|
||||
.await?
|
||||
.rows_affected();
|
||||
|
||||
if affected == 0 {
|
||||
return Err(AppError::NotFound(format!("User {} not found", id)));
|
||||
}
|
||||
|
||||
Ok(Json(json!({ "data": { "deleted": true, "id": id } })))
|
||||
}
|
||||
@@ -0,0 +1,334 @@
|
||||
// User authentication routes — register, login, profile
|
||||
// These are public (no admin token required, uses JWT)
|
||||
|
||||
use crate::{
|
||||
auth::{jwt::generate_jwt, password::verify_password},
|
||||
db::models::user::User,
|
||||
error::{AppError, AppResult},
|
||||
AppState,
|
||||
};
|
||||
use axum::{
|
||||
extract::{Path, State},
|
||||
http::HeaderMap,
|
||||
routing::{get, patch, post},
|
||||
Json, Router,
|
||||
};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::{json, Value};
|
||||
use tracing::info;
|
||||
|
||||
// ── Request / Response types ─────────────────────────────────────────
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct RegisterRequest {
|
||||
pub username: String,
|
||||
pub email: String,
|
||||
pub password: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct LoginRequest {
|
||||
pub email: String,
|
||||
pub password: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct AuthResponse {
|
||||
pub token: String,
|
||||
pub user: UserProfile,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct UserProfile {
|
||||
pub id: i64,
|
||||
pub username: String,
|
||||
pub email: String,
|
||||
pub role: String,
|
||||
}
|
||||
|
||||
// ── Route builder ────────────────────────────────────────────────────
|
||||
|
||||
pub fn routes() -> Router<AppState> {
|
||||
Router::new()
|
||||
.route("/api/auth/register", post(register))
|
||||
.route("/api/auth/login", post(login))
|
||||
.route("/api/auth/me", get(me))
|
||||
.route("/api/users/me", patch(update_me))
|
||||
.route("/api/users/me/password", post(change_password))
|
||||
.route("/api/users/{id}", get(get_user))
|
||||
}
|
||||
|
||||
// ── Handlers ─────────────────────────────────────────────────────────
|
||||
|
||||
/// POST /api/auth/register
|
||||
async fn register(
|
||||
State(state): State<AppState>,
|
||||
Json(payload): Json<RegisterRequest>,
|
||||
) -> AppResult<Json<Value>> {
|
||||
info!("Registering user: {}", payload.email);
|
||||
|
||||
if payload.email.trim().is_empty() || payload.username.trim().is_empty() {
|
||||
return Err(AppError::BadRequest(
|
||||
"email and username are required".into(),
|
||||
));
|
||||
}
|
||||
if payload.password.len() < 6 {
|
||||
return Err(AppError::BadRequest(
|
||||
"password must be at least 6 characters".into(),
|
||||
));
|
||||
}
|
||||
|
||||
// Check if email already exists
|
||||
let existing: Option<User> = sqlx::query_as::<_, User>("SELECT * FROM users WHERE email = ?")
|
||||
.bind(&payload.email)
|
||||
.fetch_optional(&*state.db)
|
||||
.await?;
|
||||
|
||||
if existing.is_some() {
|
||||
return Err(AppError::BadRequest("Email already registered".into()));
|
||||
}
|
||||
|
||||
let password_hash = crate::auth::password::hash_password(&payload.password)
|
||||
.map_err(|e| AppError::Internal(anyhow::anyhow!("Failed to hash password: {e}")))?;
|
||||
|
||||
let result = sqlx::query(
|
||||
"INSERT INTO users (username, email, password_hash, role) VALUES (?, ?, ?, 'user')",
|
||||
)
|
||||
.bind(&payload.username)
|
||||
.bind(&payload.email)
|
||||
.bind(&password_hash)
|
||||
.execute(&*state.db)
|
||||
.await?;
|
||||
|
||||
let user: User = sqlx::query_as::<_, User>("SELECT * FROM users WHERE id = ?")
|
||||
.bind(result.last_insert_rowid())
|
||||
.fetch_one(&*state.db)
|
||||
.await?;
|
||||
|
||||
let token = generate_jwt(
|
||||
&state.config.auth.jwt_secret,
|
||||
&user.id.to_string(),
|
||||
&user.role,
|
||||
state.config.auth.jwt_expiry_hours,
|
||||
)
|
||||
.map_err(|e| AppError::Internal(anyhow::anyhow!("Failed to generate JWT: {e}")))?;
|
||||
|
||||
Ok(Json(json!({
|
||||
"data": {
|
||||
"token": token,
|
||||
"user": {
|
||||
"id": user.id,
|
||||
"username": user.username,
|
||||
"email": user.email,
|
||||
"role": user.role,
|
||||
}
|
||||
}
|
||||
})))
|
||||
}
|
||||
|
||||
/// POST /api/auth/login
|
||||
async fn login(
|
||||
State(state): State<AppState>,
|
||||
Json(payload): Json<LoginRequest>,
|
||||
) -> AppResult<Json<Value>> {
|
||||
info!("Login: {}", payload.email);
|
||||
|
||||
let user: User = sqlx::query_as::<_, User>("SELECT * FROM users WHERE email = ?")
|
||||
.bind(&payload.email)
|
||||
.fetch_optional(&*state.db)
|
||||
.await?
|
||||
.ok_or_else(|| AppError::Unauthorized("Invalid email or password".into()))?;
|
||||
|
||||
if user.status != "active" {
|
||||
return Err(AppError::Forbidden("Account is disabled".into()));
|
||||
}
|
||||
|
||||
let valid = verify_password(&payload.password, &user.password_hash)
|
||||
.map_err(|e| AppError::Internal(anyhow::anyhow!("Password verification failed: {e}")))?;
|
||||
|
||||
if !valid {
|
||||
return Err(AppError::Unauthorized("Invalid email or password".into()));
|
||||
}
|
||||
|
||||
let token = generate_jwt(
|
||||
&state.config.auth.jwt_secret,
|
||||
&user.id.to_string(),
|
||||
&user.role,
|
||||
state.config.auth.jwt_expiry_hours,
|
||||
)
|
||||
.map_err(|e| AppError::Internal(anyhow::anyhow!("Failed to generate JWT: {e}")))?;
|
||||
|
||||
Ok(Json(json!({
|
||||
"data": {
|
||||
"token": token,
|
||||
"user": {
|
||||
"id": user.id,
|
||||
"username": user.username,
|
||||
"email": user.email,
|
||||
"role": user.role,
|
||||
}
|
||||
}
|
||||
})))
|
||||
}
|
||||
|
||||
/// GET /api/auth/me — returns current user profile from JWT
|
||||
async fn me(State(state): State<AppState>, headers: HeaderMap) -> AppResult<Json<Value>> {
|
||||
let auth = headers
|
||||
.get("Authorization")
|
||||
.and_then(|v| v.to_str().ok())
|
||||
.and_then(|v| v.strip_prefix("Bearer "))
|
||||
.ok_or_else(|| AppError::Unauthorized("Missing Authorization header".into()))?;
|
||||
|
||||
let claims = crate::auth::jwt::validate_jwt(&state.config.auth.jwt_secret, auth)
|
||||
.map_err(|_| AppError::Unauthorized("Invalid or expired token".into()))?;
|
||||
|
||||
let user: User = sqlx::query_as::<_, User>("SELECT * FROM users WHERE id = ?")
|
||||
.bind(&claims.sub.parse::<i64>().unwrap_or(0))
|
||||
.fetch_optional(&*state.db)
|
||||
.await?
|
||||
.ok_or_else(|| AppError::NotFound("User not found".into()))?;
|
||||
|
||||
Ok(Json(json!({
|
||||
"data": {
|
||||
"id": user.id,
|
||||
"username": user.username,
|
||||
"email": user.email,
|
||||
"role": user.role,
|
||||
}
|
||||
})))
|
||||
}
|
||||
|
||||
/// PATCH /api/users/me — update current user profile
|
||||
async fn update_me(
|
||||
State(state): State<AppState>,
|
||||
headers: HeaderMap,
|
||||
Json(body): Json<serde_json::Value>,
|
||||
) -> AppResult<Json<Value>> {
|
||||
let (user_id, _role) = extract_jwt(&state, &headers)?;
|
||||
|
||||
let username = body.get("username").and_then(|v| v.as_str());
|
||||
if let Some(name) = username {
|
||||
if !name.trim().is_empty() {
|
||||
sqlx::query("UPDATE users SET username = ? WHERE id = ?")
|
||||
.bind(name.trim())
|
||||
.bind(user_id)
|
||||
.execute(&*state.db)
|
||||
.await?;
|
||||
}
|
||||
}
|
||||
|
||||
let user: User = sqlx::query_as::<_, User>("SELECT * FROM users WHERE id = ?")
|
||||
.bind(user_id)
|
||||
.fetch_one(&*state.db)
|
||||
.await?;
|
||||
|
||||
Ok(Json(json!({
|
||||
"user": {
|
||||
"id": user.id,
|
||||
"username": user.username,
|
||||
"email": user.email,
|
||||
"role": user.role,
|
||||
"status": user.status,
|
||||
}
|
||||
})))
|
||||
}
|
||||
|
||||
/// POST /api/users/me/password — change current user password
|
||||
async fn change_password(
|
||||
State(state): State<AppState>,
|
||||
headers: HeaderMap,
|
||||
Json(body): Json<serde_json::Value>,
|
||||
) -> AppResult<Json<Value>> {
|
||||
let (user_id, _role) = extract_jwt(&state, &headers)?;
|
||||
|
||||
let old_pw = body
|
||||
.get("oldPassword")
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or_else(|| AppError::BadRequest("oldPassword is required".into()))?;
|
||||
let new_pw = body
|
||||
.get("newPassword")
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or_else(|| AppError::BadRequest("newPassword is required".into()))?;
|
||||
|
||||
if new_pw.len() < 6 {
|
||||
return Err(AppError::BadRequest(
|
||||
"new password must be at least 6 characters".into(),
|
||||
));
|
||||
}
|
||||
|
||||
let user: User = sqlx::query_as::<_, User>("SELECT * FROM users WHERE id = ?")
|
||||
.bind(user_id)
|
||||
.fetch_optional(&*state.db)
|
||||
.await?
|
||||
.ok_or_else(|| AppError::NotFound("User not found".into()))?;
|
||||
|
||||
let valid = crate::auth::password::verify_password(old_pw, &user.password_hash)
|
||||
.map_err(|e| AppError::Internal(anyhow::anyhow!("Password verification failed: {e}")))?;
|
||||
|
||||
if !valid {
|
||||
return Err(AppError::Unauthorized(
|
||||
"Current password is incorrect".into(),
|
||||
));
|
||||
}
|
||||
|
||||
let new_hash = crate::auth::password::hash_password(new_pw)
|
||||
.map_err(|e| AppError::Internal(anyhow::anyhow!("Failed to hash password: {e}")))?;
|
||||
|
||||
sqlx::query("UPDATE users SET password_hash = ? WHERE id = ?")
|
||||
.bind(&new_hash)
|
||||
.bind(user_id)
|
||||
.execute(&*state.db)
|
||||
.await?;
|
||||
|
||||
Ok(Json(json!({ "success": true })))
|
||||
}
|
||||
|
||||
/// GET /api/users/:id — get user profile (self or admin)
|
||||
async fn get_user(
|
||||
State(state): State<AppState>,
|
||||
headers: HeaderMap,
|
||||
Path(id): Path<i64>,
|
||||
) -> AppResult<Json<Value>> {
|
||||
let (user_id, role) = extract_jwt(&state, &headers)?;
|
||||
|
||||
if user_id != id && role != "admin" {
|
||||
return Err(AppError::Forbidden("Forbidden".into()));
|
||||
}
|
||||
|
||||
let user: User = sqlx::query_as::<_, User>("SELECT * FROM users WHERE id = ?")
|
||||
.bind(id)
|
||||
.fetch_optional(&*state.db)
|
||||
.await?
|
||||
.ok_or_else(|| AppError::NotFound("User not found".into()))?;
|
||||
|
||||
Ok(Json(json!({
|
||||
"user": {
|
||||
"id": user.id,
|
||||
"username": user.username,
|
||||
"email": user.email,
|
||||
"role": user.role,
|
||||
"status": user.status,
|
||||
"created_at": user.created_at,
|
||||
}
|
||||
})))
|
||||
}
|
||||
|
||||
// ── Inline JWT extract helper for this module ──────────────────────────
|
||||
|
||||
fn extract_jwt(state: &AppState, headers: &HeaderMap) -> AppResult<(i64, String)> {
|
||||
let auth = headers
|
||||
.get("Authorization")
|
||||
.and_then(|v| v.to_str().ok())
|
||||
.and_then(|v| v.strip_prefix("Bearer "))
|
||||
.ok_or_else(|| AppError::Unauthorized("Missing Authorization header".into()))?;
|
||||
|
||||
let claims = crate::auth::jwt::validate_jwt(&state.config.auth.jwt_secret, auth)
|
||||
.map_err(|_| AppError::Unauthorized("Invalid or expired token".into()))?;
|
||||
|
||||
let uid: i64 = claims
|
||||
.sub
|
||||
.parse()
|
||||
.map_err(|_| AppError::Unauthorized("Invalid user ID in token".into()))?;
|
||||
|
||||
Ok((uid, claims.role))
|
||||
}
|
||||
@@ -0,0 +1,220 @@
|
||||
// Check-in routes — daily account check-in management
|
||||
|
||||
use crate::{
|
||||
db::models::account::Account,
|
||||
error::{AppError, AppResult},
|
||||
AppState,
|
||||
};
|
||||
use axum::{
|
||||
extract::{Path, Query, State},
|
||||
routing::{get, post},
|
||||
Json, Router,
|
||||
};
|
||||
use serde::Deserialize;
|
||||
use serde_json::{json, Value};
|
||||
use tracing::info;
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct CheckinLogQuery {
|
||||
limit: Option<i64>,
|
||||
offset: Option<i64>,
|
||||
account_id: Option<i64>,
|
||||
status: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, serde::Serialize, sqlx::FromRow)]
|
||||
pub struct CheckinLog {
|
||||
pub id: i64,
|
||||
pub account_id: i64,
|
||||
pub status: String,
|
||||
pub message: Option<String>,
|
||||
pub reward: Option<String>,
|
||||
pub created_at: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, serde::Serialize, sqlx::FromRow)]
|
||||
pub struct CheckinStats {
|
||||
pub account_id: i64,
|
||||
pub site_name: String,
|
||||
pub username: String,
|
||||
pub status: String,
|
||||
pub last_checkin: Option<String>,
|
||||
pub checkin_enabled: bool,
|
||||
pub balance: Option<f64>,
|
||||
}
|
||||
|
||||
pub fn routes() -> Router<AppState> {
|
||||
Router::new()
|
||||
.route("/api/checkin", get(list_checkin_logs))
|
||||
.route("/api/checkin/stats", get(checkin_stats))
|
||||
.route("/api/checkin/accounts", get(list_checkin_accounts))
|
||||
.route(
|
||||
"/api/checkin/accounts/{id}/toggle",
|
||||
post(toggle_checkin_account),
|
||||
)
|
||||
.route("/api/checkin/run", post(run_checkin))
|
||||
}
|
||||
|
||||
/// GET /api/checkin — list checkin logs
|
||||
async fn list_checkin_logs(
|
||||
State(state): State<AppState>,
|
||||
Query(q): Query<CheckinLogQuery>,
|
||||
) -> AppResult<Json<Value>> {
|
||||
info!("Listing checkin logs");
|
||||
let limit = q.limit.unwrap_or(50).clamp(1, 200);
|
||||
let offset = q.offset.unwrap_or(0).max(0);
|
||||
|
||||
let mut sql = "SELECT * FROM checkin_logs WHERE 1=1".to_string();
|
||||
let mut params: Vec<String> = vec![];
|
||||
|
||||
if let Some(aid) = q.account_id {
|
||||
sql.push_str(&format!(" AND account_id = {}", aid));
|
||||
}
|
||||
if let Some(ref s) = q.status {
|
||||
sql.push_str(" AND status = ?");
|
||||
params.push(s.clone());
|
||||
}
|
||||
|
||||
sql.push_str(" ORDER BY created_at DESC");
|
||||
sql.push_str(&format!(" LIMIT {} OFFSET {}", limit, offset));
|
||||
|
||||
let mut query = sqlx::query_as::<_, CheckinLog>(&sql);
|
||||
for p in ¶ms {
|
||||
query = query.bind(p);
|
||||
}
|
||||
let logs: Vec<CheckinLog> = query.fetch_all(&*state.db).await?;
|
||||
|
||||
Ok(Json(json!({ "data": logs })))
|
||||
}
|
||||
|
||||
/// GET /api/checkin/stats — checkin statistics
|
||||
async fn checkin_stats(State(state): State<AppState>) -> AppResult<Json<Value>> {
|
||||
info!("Checkin stats");
|
||||
|
||||
let rows: Vec<CheckinStats> = sqlx::query_as::<_, CheckinStats>(
|
||||
"SELECT a.id as account_id, s.name as site_name, a.username, a.status,
|
||||
a.last_checkin_at as last_checkin,
|
||||
COALESCE(a.checkin_enabled, 0) as checkin_enabled,
|
||||
a.balance
|
||||
FROM accounts a
|
||||
JOIN sites s ON s.id = a.site_id
|
||||
WHERE a.status = 'active'
|
||||
ORDER BY a.last_checkin_at ASC",
|
||||
)
|
||||
.fetch_all(&*state.db)
|
||||
.await?;
|
||||
|
||||
let total = rows.len();
|
||||
let checked_today = rows
|
||||
.iter()
|
||||
.filter(|r| {
|
||||
r.last_checkin
|
||||
.as_deref()
|
||||
.map_or(false, |d| d.starts_with(&today_date()))
|
||||
})
|
||||
.count();
|
||||
|
||||
Ok(Json(json!({
|
||||
"total_accounts": total,
|
||||
"checked_today": checked_today,
|
||||
"pending": total - checked_today,
|
||||
"accounts": rows,
|
||||
})))
|
||||
}
|
||||
|
||||
/// GET /api/checkin/accounts — list accounts with checkin info
|
||||
async fn list_checkin_accounts(State(state): State<AppState>) -> AppResult<Json<Value>> {
|
||||
let accounts: Vec<Value> =
|
||||
sqlx::query_as::<_, Account>("SELECT * FROM accounts ORDER BY site_id, username")
|
||||
.fetch_all(&*state.db)
|
||||
.await?
|
||||
.into_iter()
|
||||
.map(|a| {
|
||||
json!({
|
||||
"id": a.id,
|
||||
"site_id": a.site_id,
|
||||
"username": a.username,
|
||||
"status": a.status,
|
||||
"checkin_enabled": a.checkin_enabled,
|
||||
"last_checkin_at": a.last_checkin_at,
|
||||
"balance": a.balance,
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
|
||||
Ok(Json(json!({ "data": accounts })))
|
||||
}
|
||||
|
||||
/// POST /api/checkin/accounts/{id}/toggle — enable/disable checkin
|
||||
async fn toggle_checkin_account(
|
||||
State(state): State<AppState>,
|
||||
Path(id): Path<i64>,
|
||||
) -> AppResult<Json<Value>> {
|
||||
info!("Toggling 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 new_value = !account.checkin_enabled.unwrap_or(true);
|
||||
sqlx::query(
|
||||
"UPDATE accounts SET checkin_enabled = ?, updated_at = datetime('now') WHERE id = ?",
|
||||
)
|
||||
.bind(new_value)
|
||||
.bind(id)
|
||||
.execute(&*state.db)
|
||||
.await?;
|
||||
|
||||
Ok(Json(
|
||||
json!({ "success": true, "checkin_enabled": new_value }),
|
||||
))
|
||||
}
|
||||
|
||||
/// POST /api/checkin/run — trigger checkin for all enabled accounts
|
||||
async fn run_checkin(State(state): State<AppState>) -> AppResult<Json<Value>> {
|
||||
info!("Running checkin for all enabled accounts");
|
||||
|
||||
let accounts: Vec<Account> = sqlx::query_as::<_, Account>(
|
||||
"SELECT * FROM accounts WHERE status = 'active' AND COALESCE(checkin_enabled, 1) = 1",
|
||||
)
|
||||
.fetch_all(&*state.db)
|
||||
.await?;
|
||||
|
||||
let mut results: Vec<Value> = vec![];
|
||||
for account in &accounts {
|
||||
// Simple balance bonus checkin (matches Node.js basic behavior)
|
||||
let bonus = 0.01; // $0.01 per day
|
||||
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?;
|
||||
|
||||
results.push(json!({
|
||||
"account_id": account.id,
|
||||
"username": account.username,
|
||||
"status": "success",
|
||||
"reward": bonus,
|
||||
}));
|
||||
}
|
||||
|
||||
Ok(Json(json!({
|
||||
"success": true,
|
||||
"processed": results.len(),
|
||||
"results": results,
|
||||
})))
|
||||
}
|
||||
|
||||
/// Get today's date as YYYY-MM-DD
|
||||
fn today_date() -> String {
|
||||
use chrono::Utc;
|
||||
Utc::now().format("%Y-%m-%d").to_string()
|
||||
}
|
||||
@@ -0,0 +1,371 @@
|
||||
// ComfyUI proxy routes — forward to local ComfyUI instance
|
||||
//
|
||||
// These routes proxy requests to a local ComfyUI server for AI video/image workflow editing.
|
||||
// All business routes require downstream API key or admin token authentication.
|
||||
// The /api/comfyui/proxy/* route serves static assets (public).
|
||||
|
||||
use crate::{
|
||||
auth::api_key::{authorize_downstream_token, DownstreamAuthResult},
|
||||
error::{AppError, AppResult},
|
||||
AppState,
|
||||
};
|
||||
use axum::{
|
||||
extract::{Path, Query, State},
|
||||
http::{HeaderMap, StatusCode},
|
||||
response::{Html, IntoResponse, Response},
|
||||
routing::{get, post},
|
||||
Json, Router,
|
||||
};
|
||||
use reqwest::Client;
|
||||
use serde_json::Value;
|
||||
use std::collections::HashMap;
|
||||
use tracing::info;
|
||||
|
||||
/// Default ComfyUI backend address.
|
||||
const DEFAULT_COMFYUI_BASE: &str = "http://127.0.0.1:8188";
|
||||
|
||||
/// Shared HTTP client
|
||||
fn http_client() -> Client {
|
||||
Client::builder()
|
||||
.timeout(std::time::Duration::from_secs(120))
|
||||
.build()
|
||||
.expect("Failed to build HTTP client")
|
||||
}
|
||||
|
||||
// ── Auth helper (same pattern as proxy module) ────────────────────────
|
||||
|
||||
async fn check_auth(state: &AppState, headers: &HeaderMap) -> Result<(), AppError> {
|
||||
let auth = headers
|
||||
.get("Authorization")
|
||||
.and_then(|v| v.to_str().ok())
|
||||
.and_then(|v| v.strip_prefix("Bearer "))
|
||||
.and_then(|v| v.trim().split_whitespace().next())
|
||||
.ok_or_else(|| AppError::Unauthorized("Missing Authorization header".into()))?;
|
||||
|
||||
// Admin token passes
|
||||
if auth == state.config.auth.auth_token {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
// Downstream API key
|
||||
if auth.starts_with("sk-") {
|
||||
let result = authorize_downstream_token(auth, &state.db)
|
||||
.await
|
||||
.map_err(|e| AppError::Unauthorized(e))?;
|
||||
|
||||
match result {
|
||||
DownstreamAuthResult::Managed { key, .. } => {
|
||||
if !key.enabled.unwrap_or(true) {
|
||||
return Err(AppError::Forbidden("API key is disabled".into()));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
DownstreamAuthResult::Global { .. } => Ok(()),
|
||||
}
|
||||
} else {
|
||||
Err(AppError::Unauthorized("Invalid token".into()))
|
||||
}
|
||||
}
|
||||
|
||||
/// Get ComfyUI base URL from env or default
|
||||
fn comfyui_base() -> String {
|
||||
std::env::var("COMFYUI_BASE").unwrap_or_else(|_| DEFAULT_COMFYUI_BASE.to_string())
|
||||
}
|
||||
|
||||
// ── Route builder ────────────────────────────────────────────────────
|
||||
|
||||
pub fn routes() -> Router<AppState> {
|
||||
Router::new()
|
||||
// Business routes (require auth)
|
||||
.route("/api/comfyui/prompt", post(proxy_post_prompt))
|
||||
.route("/api/comfyui/history", get(proxy_get_history))
|
||||
.route("/api/comfyui/history/{id}", get(proxy_get_history_by_id))
|
||||
.route("/api/comfyui/queue", get(proxy_get_queue))
|
||||
.route("/api/comfyui/view", get(proxy_get_view))
|
||||
// Static proxy (public — assets/JS/CSS)
|
||||
.route("/api/comfyui/proxy/{*path}", get(proxy_static))
|
||||
// HTML page (public)
|
||||
.route("/comfyui", get(comfyui_page))
|
||||
}
|
||||
|
||||
// ── Business route handlers (require auth) ───────────────────────────
|
||||
|
||||
/// POST /api/comfyui/prompt
|
||||
async fn proxy_post_prompt(
|
||||
State(state): State<AppState>,
|
||||
headers: HeaderMap,
|
||||
Json(body): Json<Value>,
|
||||
) -> AppResult<Response> {
|
||||
info!("POST /api/comfyui/prompt");
|
||||
check_auth(&state, &headers).await?;
|
||||
proxy_to_comfyui("POST", "/prompt", None, Some(body)).await
|
||||
}
|
||||
|
||||
/// GET /api/comfyui/history
|
||||
async fn proxy_get_history(
|
||||
State(state): State<AppState>,
|
||||
headers: HeaderMap,
|
||||
) -> AppResult<Response> {
|
||||
info!("GET /api/comfyui/history");
|
||||
check_auth(&state, &headers).await?;
|
||||
proxy_to_comfyui("GET", "/history", None, None).await
|
||||
}
|
||||
|
||||
/// GET /api/comfyui/history/{id}
|
||||
async fn proxy_get_history_by_id(
|
||||
State(state): State<AppState>,
|
||||
headers: HeaderMap,
|
||||
Path(id): Path<String>,
|
||||
) -> AppResult<Response> {
|
||||
info!("GET /api/comfyui/history/{}", id);
|
||||
check_auth(&state, &headers).await?;
|
||||
let path = format!("/history/{}", urlencoding(&id));
|
||||
proxy_to_comfyui("GET", &path, None, None).await
|
||||
}
|
||||
|
||||
/// GET /api/comfyui/queue
|
||||
async fn proxy_get_queue(State(state): State<AppState>, headers: HeaderMap) -> AppResult<Response> {
|
||||
info!("GET /api/comfyui/queue");
|
||||
check_auth(&state, &headers).await?;
|
||||
proxy_to_comfyui("GET", "/queue", None, None).await
|
||||
}
|
||||
|
||||
/// GET /api/comfyui/view?filename=...&type=...&subfolder=...
|
||||
async fn proxy_get_view(
|
||||
State(state): State<AppState>,
|
||||
headers: HeaderMap,
|
||||
Query(params): Query<HashMap<String, String>>,
|
||||
) -> AppResult<Response> {
|
||||
info!("GET /api/comfyui/view");
|
||||
check_auth(&state, &headers).await?;
|
||||
|
||||
let qs = build_query_string(¶ms);
|
||||
let path = format!("/view{}", qs);
|
||||
proxy_to_comfyui_binary("GET", &path).await
|
||||
}
|
||||
|
||||
// ── Static proxy (public — assets/JS/CSS for iframe) ─────────────────
|
||||
|
||||
/// GET /api/comfyui/proxy/{path} — proxy everything (HTML, assets)
|
||||
async fn proxy_static(Path(path): Path<String>) -> AppResult<Response> {
|
||||
info!("GET /api/comfyui/proxy/{}", path);
|
||||
let target_path = if path.is_empty() || path == "/" {
|
||||
"/".to_string()
|
||||
} else {
|
||||
format!("/{}", path)
|
||||
};
|
||||
|
||||
let client = http_client();
|
||||
let url = format!("{}{}", comfyui_base(), target_path);
|
||||
let resp = client
|
||||
.get(&url)
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| AppError::UpstreamError(format!("ComfyUI unreachable: {e}")))?;
|
||||
|
||||
let status = resp.status();
|
||||
let content_type = resp
|
||||
.headers()
|
||||
.get("content-type")
|
||||
.and_then(|v| v.to_str().ok())
|
||||
.unwrap_or("application/octet-stream")
|
||||
.to_string();
|
||||
|
||||
if content_type.contains("html") {
|
||||
let text = resp
|
||||
.text()
|
||||
.await
|
||||
.map_err(|e| AppError::UpstreamError(e.to_string()))?;
|
||||
// Rebrand: ComfyUI → BoosAPI
|
||||
let rebranded = text
|
||||
.replace("ComfyUI", "BoosAPI")
|
||||
.replace("comfyui", "BoosAPI");
|
||||
// Rewrite URLs
|
||||
let rewritten = rewrite_proxy_urls(&rebranded);
|
||||
Ok((
|
||||
StatusCode::from_u16(status.as_u16()).unwrap(),
|
||||
[(axum::http::header::CONTENT_TYPE, "text/html; charset=utf-8")],
|
||||
rewritten,
|
||||
)
|
||||
.into_response())
|
||||
} else if content_type.contains("text") || content_type.contains("json") {
|
||||
let text = resp
|
||||
.text()
|
||||
.await
|
||||
.map_err(|e| AppError::UpstreamError(e.to_string()))?;
|
||||
Ok((
|
||||
StatusCode::from_u16(status.as_u16()).unwrap(),
|
||||
[(axum::http::header::CONTENT_TYPE, content_type)],
|
||||
text,
|
||||
)
|
||||
.into_response())
|
||||
} else {
|
||||
let bytes = resp
|
||||
.bytes()
|
||||
.await
|
||||
.map_err(|e| AppError::UpstreamError(e.to_string()))?;
|
||||
Ok((
|
||||
StatusCode::from_u16(status.as_u16()).unwrap(),
|
||||
[(axum::http::header::CONTENT_TYPE, content_type)],
|
||||
bytes,
|
||||
)
|
||||
.into_response())
|
||||
}
|
||||
}
|
||||
|
||||
/// GET /comfyui — HTML page with iframe
|
||||
async fn comfyui_page() -> Html<&'static str> {
|
||||
Html(
|
||||
r##"<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>BoosAPI - ComfyUI</title>
|
||||
<style>
|
||||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||
html, body { width: 100%; height: 100%; overflow: hidden; background: #1a1a2e; }
|
||||
iframe { width: 100%; height: 100%; border: none; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<iframe src="/api/comfyui/proxy/" title="BoosAPI ComfyUI"></iframe>
|
||||
</body>
|
||||
</html>"##,
|
||||
)
|
||||
}
|
||||
|
||||
// ── Helpers ──────────────────────────────────────────────────────────
|
||||
|
||||
/// Rewrite URLs in HTML so asset paths go through the proxy
|
||||
fn rewrite_proxy_urls(html: &str) -> String {
|
||||
// Replace src="/...", href="/...", action="/..." with /api/comfyui/proxy/...
|
||||
let mut result = String::with_capacity(html.len() * 12 / 10);
|
||||
let mut chars = html.char_indices().peekable();
|
||||
|
||||
while let Some((_, ch)) = chars.next() {
|
||||
if ch == 's' || ch == 'h' || ch == 'a' {
|
||||
// Look ahead for rc="/", ref="/", ction="/"
|
||||
let snippet: String = chars.clone().take(6).map(|(_, c)| c).collect();
|
||||
let full = format!("{}{}", ch, snippet);
|
||||
if full.starts_with("src=\"/")
|
||||
|| full.starts_with("href=\"/")
|
||||
|| full.starts_with("action=\"/")
|
||||
{
|
||||
let attr_end = if full.starts_with("src=\"") {
|
||||
4
|
||||
} else if full.starts_with("href=\"") {
|
||||
5
|
||||
} else {
|
||||
7
|
||||
};
|
||||
result.push_str(&full[..attr_end]);
|
||||
result.push_str("/api/comfyui/proxy/");
|
||||
// Skip the rest of the attribute
|
||||
for _ in 0..snippet.len() {
|
||||
chars.next();
|
||||
}
|
||||
continue;
|
||||
}
|
||||
}
|
||||
result.push(ch);
|
||||
}
|
||||
|
||||
result
|
||||
}
|
||||
|
||||
/// Build query string from params
|
||||
fn build_query_string(params: &HashMap<String, String>) -> String {
|
||||
if params.is_empty() {
|
||||
return String::new();
|
||||
}
|
||||
let mut parts: Vec<String> = params
|
||||
.iter()
|
||||
.map(|(k, v)| format!("{}={}", urlencoding(k), urlencoding(v)))
|
||||
.collect();
|
||||
parts.sort(); // deterministic ordering
|
||||
format!("?{}", parts.join("&"))
|
||||
}
|
||||
|
||||
/// Simple URL encoding (handles the chars we need)
|
||||
fn urlencoding(s: &str) -> String {
|
||||
s.replace('%', "%25")
|
||||
.replace(' ', "%20")
|
||||
.replace('?', "%3F")
|
||||
.replace('&', "%26")
|
||||
.replace('#', "%23")
|
||||
.replace('/', "%2F")
|
||||
}
|
||||
|
||||
/// Proxy to ComfyUI — returns JSON/text response
|
||||
async fn proxy_to_comfyui(
|
||||
method: &str,
|
||||
path: &str,
|
||||
_query: Option<&HashMap<String, String>>,
|
||||
body: Option<Value>,
|
||||
) -> AppResult<Response> {
|
||||
let client = http_client();
|
||||
let url = format!("{}{}", comfyui_base(), path);
|
||||
|
||||
let req = match method {
|
||||
"POST" => {
|
||||
let body_str = body
|
||||
.map(|b| b.to_string())
|
||||
.unwrap_or_else(|| "{}".to_string());
|
||||
client
|
||||
.post(&url)
|
||||
.header("Content-Type", "application/json")
|
||||
.body(body_str)
|
||||
}
|
||||
_ => client.get(&url),
|
||||
};
|
||||
|
||||
let resp = req
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| AppError::UpstreamError(format!("ComfyUI unreachable: {e}")))?;
|
||||
|
||||
let status = resp.status();
|
||||
let text = resp
|
||||
.text()
|
||||
.await
|
||||
.map_err(|e| AppError::UpstreamError(e.to_string()))?;
|
||||
|
||||
Ok((
|
||||
StatusCode::from_u16(status.as_u16()).unwrap(),
|
||||
[(axum::http::header::CONTENT_TYPE, "application/json")],
|
||||
text,
|
||||
)
|
||||
.into_response())
|
||||
}
|
||||
|
||||
/// Proxy to ComfyUI — returns binary response (for images)
|
||||
async fn proxy_to_comfyui_binary(_method: &str, path: &str) -> AppResult<Response> {
|
||||
let client = http_client();
|
||||
let url = format!("{}{}", comfyui_base(), path);
|
||||
|
||||
let req = client.get(&url);
|
||||
let resp = req
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| AppError::UpstreamError(format!("ComfyUI unreachable: {e}")))?;
|
||||
|
||||
let status = resp.status();
|
||||
let content_type = resp
|
||||
.headers()
|
||||
.get("content-type")
|
||||
.and_then(|v| v.to_str().ok())
|
||||
.unwrap_or("application/octet-stream")
|
||||
.to_string();
|
||||
let bytes = resp
|
||||
.bytes()
|
||||
.await
|
||||
.map_err(|e| AppError::UpstreamError(e.to_string()))?;
|
||||
|
||||
Ok((
|
||||
StatusCode::from_u16(status.as_u16()).unwrap(),
|
||||
[(axum::http::header::CONTENT_TYPE, content_type)],
|
||||
bytes,
|
||||
)
|
||||
.into_response())
|
||||
}
|
||||
@@ -0,0 +1,658 @@
|
||||
use crate::AppState;
|
||||
use axum::{
|
||||
extract::State,
|
||||
http::HeaderMap,
|
||||
response::sse::{Event, Sse},
|
||||
routing::post,
|
||||
Json, Router,
|
||||
};
|
||||
use futures::stream::Stream;
|
||||
use regex::Regex;
|
||||
use reqwest::Client;
|
||||
use serde_json::{json, Value};
|
||||
use std::convert::Infallible;
|
||||
use tokio::sync::mpsc::{unbounded_channel, UnboundedSender};
|
||||
use tokio_stream::{wrappers::UnboundedReceiverStream, StreamExt};
|
||||
use tracing::info;
|
||||
|
||||
const SYSTEM_PROMPT: &str = r#"你是 BoosAPI 的 AI 视频生成助手,帮用户通过 AI 制作视频。
|
||||
|
||||
## 规则
|
||||
1. 先用中文与用户讨论脚本构思、视觉风格、角色设定
|
||||
2. 用户确认后(说"开始制作"或"生成"),再分步输出结构化内容
|
||||
3. **分两步走:第一步输出角色+场景+TTS,第二步输出 WORKFLOW**(不要一次全出)
|
||||
|
||||
## 结构化标记格式
|
||||
严格按照以下格式输出,系统会自动解析并生成图片/TTS:
|
||||
|
||||
【CHARACTER】
|
||||
name: 林澈
|
||||
description: 女宇航员,30岁,冷静坚毅,短发收在头盔内
|
||||
prompt_front: cinematic full body front view of a female astronaut, white EVA suit, photorealistic
|
||||
prompt_back: cinematic full body back view of a female astronaut, life support backpack
|
||||
prompt_left: cinematic full body left side view of a female astronaut, side utility pouches
|
||||
prompt_right: cinematic full body right side view of a female astronaut, helmet visor reflection
|
||||
【/CHARACTER】
|
||||
|
||||
【SCENE】
|
||||
id: 1
|
||||
description: 飞船驾驶舱内景
|
||||
prompt: interior of a sci-fi spacecraft cockpit, holographic displays, cold blue lighting, cinematic
|
||||
【/SCENE】
|
||||
|
||||
【TTS】
|
||||
character: 林澈
|
||||
text: 我们准备出舱检查
|
||||
emotion: 平静
|
||||
【/TTS】
|
||||
|
||||
【WORKFLOW】
|
||||
```json
|
||||
{"comfyui_workflow": {}}
|
||||
```
|
||||
【/WORKFLOW】
|
||||
|
||||
## 关键要求
|
||||
- prompt_* 用英文,同时包含主体 + 视角 + 细节 + 画质标签
|
||||
- 不要一次输出太多,先输角色和场景,下次再输工作流
|
||||
- 如果没有用户明确要求,切勿自作主张输出全部内容
|
||||
"#;
|
||||
|
||||
fn upstream_base() -> String {
|
||||
std::env::var("BOOSAPI_UPSTREAM_BASE")
|
||||
.unwrap_or_else(|_| "https://api.boos.lat/v1".to_string())
|
||||
.trim_end_matches('/')
|
||||
.to_string()
|
||||
}
|
||||
|
||||
fn upstream_key() -> String {
|
||||
std::env::var("BOOSAPI_UPSTREAM_KEY").unwrap_or_else(|_| "sk-boos4721".to_string())
|
||||
}
|
||||
|
||||
pub fn routes() -> Router<AppState> {
|
||||
Router::new().route("/api/comfyui-agent/chat", post(chat_handler))
|
||||
}
|
||||
|
||||
#[derive(Debug, Default)]
|
||||
struct ParsedSections {
|
||||
characters: Vec<CharacterSection>,
|
||||
scenes: Vec<SceneSection>,
|
||||
tts_list: Vec<TtsSection>,
|
||||
workflow_json: Option<String>,
|
||||
text_content: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Default)]
|
||||
struct CharacterSection {
|
||||
name: String,
|
||||
description: String,
|
||||
prompt_front: String,
|
||||
prompt_back: String,
|
||||
prompt_left: String,
|
||||
prompt_right: String,
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
struct SceneSection {
|
||||
id: u32,
|
||||
description: String,
|
||||
prompt: String,
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
struct TtsSection {
|
||||
character: String,
|
||||
text: String,
|
||||
emotion: String,
|
||||
}
|
||||
|
||||
fn parse_sections(full_text: &str) -> ParsedSections {
|
||||
let mut sections = ParsedSections::default();
|
||||
sections.text_content = full_text.to_string();
|
||||
|
||||
// Parse CHARACTER blocks
|
||||
let char_re = Regex::new(r"(?s)【CHARACTER】\s*(.*?)【/CHARACTER】").unwrap();
|
||||
for cap in char_re.captures_iter(full_text) {
|
||||
let block = cap.get(1).unwrap().as_str();
|
||||
let mut char_section = CharacterSection::default();
|
||||
for line in block.lines() {
|
||||
let line = line.trim();
|
||||
let line_lower = line.to_lowercase();
|
||||
if let Some(val) = line.strip_prefix("name:").or_else(|| line.strip_prefix("name :")) {
|
||||
char_section.name = val.trim().to_string();
|
||||
} else if let Some(val) = line.strip_prefix("description:").or_else(|| line.strip_prefix("description :")) {
|
||||
char_section.description = val.trim().to_string();
|
||||
} else if line_lower.starts_with("prompt_front:") || line_lower.starts_with("prompt_front :") {
|
||||
let val = line.splitn(2, ':').nth(1).unwrap_or("").trim();
|
||||
char_section.prompt_front = val.to_string();
|
||||
} else if line_lower.starts_with("prompt_back:") || line_lower.starts_with("prompt_back :") {
|
||||
let val = line.splitn(2, ':').nth(1).unwrap_or("").trim();
|
||||
char_section.prompt_back = val.to_string();
|
||||
} else if line_lower.starts_with("prompt_left:") || line_lower.starts_with("prompt_left :") {
|
||||
let val = line.splitn(2, ':').nth(1).unwrap_or("").trim();
|
||||
char_section.prompt_left = val.to_string();
|
||||
} else if line_lower.starts_with("prompt_right:") || line_lower.starts_with("prompt_right :") {
|
||||
let val = line.splitn(2, ':').nth(1).unwrap_or("").trim();
|
||||
char_section.prompt_right = val.to_string();
|
||||
}
|
||||
}
|
||||
if !char_section.name.is_empty() {
|
||||
sections.characters.push(char_section);
|
||||
}
|
||||
}
|
||||
|
||||
// Parse SCENE blocks
|
||||
let scene_re = Regex::new(r"(?s)【SCENE】\s*(.*?)【/SCENE】").unwrap();
|
||||
for cap in scene_re.captures_iter(full_text) {
|
||||
let block = cap.get(1).unwrap().as_str();
|
||||
let mut scene = SceneSection {
|
||||
id: 0,
|
||||
description: String::new(),
|
||||
prompt: String::new(),
|
||||
};
|
||||
for line in block.lines() {
|
||||
let line = line.trim();
|
||||
let line_lower = line.to_lowercase();
|
||||
if line_lower.starts_with("id:") || line_lower.starts_with("id :") {
|
||||
let val = line.splitn(2, ':').nth(1).unwrap_or("").trim();
|
||||
scene.id = val.parse().unwrap_or(0);
|
||||
} else if let Some(val) = line.strip_prefix("description:").or_else(|| line.strip_prefix("description :")) {
|
||||
scene.description = val.trim().to_string();
|
||||
} else if line_lower.starts_with("prompt:") || line_lower.starts_with("prompt :") {
|
||||
let val = line.splitn(2, ':').nth(1).unwrap_or("").trim();
|
||||
scene.prompt = val.to_string();
|
||||
}
|
||||
}
|
||||
if !scene.prompt.is_empty() {
|
||||
sections.scenes.push(scene);
|
||||
}
|
||||
}
|
||||
|
||||
// Parse TTS blocks
|
||||
let tts_re = Regex::new(r"(?s)【TTS】\s*(.*?)【/TTS】").unwrap();
|
||||
for cap in tts_re.captures_iter(full_text) {
|
||||
let block = cap.get(1).unwrap().as_str();
|
||||
let mut tts = TtsSection {
|
||||
character: String::new(),
|
||||
text: String::new(),
|
||||
emotion: String::new(),
|
||||
};
|
||||
for line in block.lines() {
|
||||
let line = line.trim();
|
||||
let line_lower = line.to_lowercase();
|
||||
if line_lower.starts_with("character:") || line_lower.starts_with("character :") {
|
||||
let val = line.splitn(2, ':').nth(1).unwrap_or("").trim();
|
||||
tts.character = val.to_string();
|
||||
} else if line_lower.starts_with("text:") || line_lower.starts_with("text :") {
|
||||
let val = line.splitn(2, ':').nth(1).unwrap_or("").trim();
|
||||
tts.text = val.to_string();
|
||||
} else if line_lower.starts_with("emotion:") || line_lower.starts_with("emotion :") {
|
||||
let val = line.splitn(2, ':').nth(1).unwrap_or("").trim();
|
||||
tts.emotion = val.to_string();
|
||||
}
|
||||
}
|
||||
if !tts.text.is_empty() {
|
||||
sections.tts_list.push(tts);
|
||||
}
|
||||
}
|
||||
|
||||
// Parse WORKFLOW blocks
|
||||
let wf_re = Regex::new(r"(?s)【WORKFLOW】\s*```(?:json)?\s*(.*?)```\s*【/WORKFLOW】").unwrap();
|
||||
if let Some(cap) = wf_re.captures(full_text) {
|
||||
let wf = cap.get(1).unwrap().as_str().trim().to_string();
|
||||
sections.workflow_json = Some(wf);
|
||||
}
|
||||
|
||||
sections
|
||||
}
|
||||
|
||||
async fn call_image_gen(prompt: &str) -> Result<String, String> {
|
||||
let client = Client::new();
|
||||
let url = format!("{}/images/generations", upstream_base());
|
||||
|
||||
let resp = client
|
||||
.post(&url)
|
||||
.bearer_auth(upstream_key())
|
||||
.json(&json!({
|
||||
"model": "gpt-image-2",
|
||||
"prompt": prompt,
|
||||
"n": 1,
|
||||
"size": "1024x1024"
|
||||
}))
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| format!("image request failed: {}", e))?;
|
||||
|
||||
let status = resp.status();
|
||||
let data: Value = resp
|
||||
.json()
|
||||
.await
|
||||
.map_err(|e| format!("image response parse failed: {}", e))?;
|
||||
|
||||
if !status.is_success() {
|
||||
return Err(format!("image upstream returned {}: {}", status, data));
|
||||
}
|
||||
|
||||
Ok(data
|
||||
.get("data")
|
||||
.and_then(Value::as_array)
|
||||
.and_then(|items| items.first())
|
||||
.and_then(|item| item.get("url"))
|
||||
.and_then(Value::as_str)
|
||||
.unwrap_or("")
|
||||
.to_string())
|
||||
}
|
||||
|
||||
async fn call_tts(text: &str, voice: &str) -> Result<Vec<u8>, String> {
|
||||
let client = Client::new();
|
||||
let url = format!("{}/audio/speech", upstream_base());
|
||||
|
||||
let resp = client
|
||||
.post(&url)
|
||||
.bearer_auth(upstream_key())
|
||||
.json(&json!({
|
||||
"model": "tts-1",
|
||||
"input": text,
|
||||
"voice": voice
|
||||
}))
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| format!("TTS request failed: {}", e))?;
|
||||
|
||||
let status = resp.status();
|
||||
if !status.is_success() {
|
||||
let body = resp.text().await.unwrap_or_default();
|
||||
return Err(format!("TTS upstream returned {}: {}", status, body));
|
||||
}
|
||||
|
||||
let bytes = resp
|
||||
.bytes()
|
||||
.await
|
||||
.map_err(|e| format!("TTS response read failed: {}", e))?;
|
||||
|
||||
Ok(bytes.to_vec())
|
||||
}
|
||||
|
||||
fn send_sse(tx: &UnboundedSender<Result<Event, Infallible>>, payload: Value) -> bool {
|
||||
tx.send(Ok(Event::default().data(payload.to_string()))).is_ok()
|
||||
}
|
||||
|
||||
async fn chat_handler(
|
||||
State(state): State<AppState>,
|
||||
headers: HeaderMap,
|
||||
Json(body): Json<Value>,
|
||||
) -> Result<Sse<impl Stream<Item = Result<Event, Infallible>>>, (axum::http::StatusCode, Json<Value>)>
|
||||
{
|
||||
let auth_header = headers
|
||||
.get("authorization")
|
||||
.and_then(|value| value.to_str().ok())
|
||||
.unwrap_or_default();
|
||||
|
||||
let token = auth_header
|
||||
.strip_prefix("Bearer ")
|
||||
.or_else(|| auth_header.strip_prefix("bearer "))
|
||||
.unwrap_or_default();
|
||||
|
||||
if token != state.config.auth.auth_token {
|
||||
return Err((
|
||||
axum::http::StatusCode::UNAUTHORIZED,
|
||||
Json(json!({ "error": "Unauthorized" })),
|
||||
));
|
||||
}
|
||||
|
||||
let messages = body
|
||||
.get("messages")
|
||||
.and_then(Value::as_array)
|
||||
.ok_or_else(|| {
|
||||
(
|
||||
axum::http::StatusCode::BAD_REQUEST,
|
||||
Json(json!({ "error": "messages is required" })),
|
||||
)
|
||||
})?;
|
||||
|
||||
for message in messages {
|
||||
if message.get("role").and_then(Value::as_str).is_none()
|
||||
|| message.get("content").and_then(Value::as_str).is_none()
|
||||
{
|
||||
return Err((
|
||||
axum::http::StatusCode::BAD_REQUEST,
|
||||
Json(json!({ "error": "messages is required" })),
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
let messages = messages.clone();
|
||||
let (tx, rx) = unbounded_channel::<Result<Event, Infallible>>();
|
||||
|
||||
tokio::spawn(async move {
|
||||
let upstream_url = format!("{}/chat/completions", upstream_base().trim_end_matches('/'));
|
||||
|
||||
let mut upstream_messages = Vec::with_capacity(messages.len() + 1);
|
||||
upstream_messages.push(json!({
|
||||
"role": "system",
|
||||
"content": SYSTEM_PROMPT,
|
||||
}));
|
||||
upstream_messages.extend(messages);
|
||||
|
||||
let client = Client::new();
|
||||
|
||||
info!("comfyui-agent: sending chat request to upstream");
|
||||
|
||||
let response = match client
|
||||
.post(&upstream_url)
|
||||
.bearer_auth(upstream_key())
|
||||
.json(&json!({
|
||||
"model": "gpt-5.5",
|
||||
"messages": upstream_messages,
|
||||
"temperature": 0.7,
|
||||
"stream": true,
|
||||
}))
|
||||
.send()
|
||||
.await
|
||||
{
|
||||
Ok(response) => response,
|
||||
Err(e) => {
|
||||
send_sse(
|
||||
&tx,
|
||||
json!({
|
||||
"type": "text",
|
||||
"content": format!("服务暂时不可用,请稍后重试。({})", e),
|
||||
}),
|
||||
);
|
||||
send_sse(&tx, json!({ "type": "done" }));
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
if !response.status().is_success() {
|
||||
send_sse(
|
||||
&tx,
|
||||
json!({
|
||||
"type": "text",
|
||||
"content": "服务暂时不可用,请稍后重试",
|
||||
}),
|
||||
);
|
||||
send_sse(&tx, json!({ "type": "done" }));
|
||||
return;
|
||||
}
|
||||
|
||||
let mut stream = response.bytes_stream();
|
||||
let mut buffer: Vec<u8> = Vec::new();
|
||||
let mut accumulated_text = String::new();
|
||||
|
||||
// Phase 1: Stream the LLM text response
|
||||
loop {
|
||||
let chunk = tokio::select! {
|
||||
_ = tx.closed() => {
|
||||
return;
|
||||
}
|
||||
chunk = stream.next() => chunk,
|
||||
};
|
||||
|
||||
let Some(chunk) = chunk else {
|
||||
break;
|
||||
};
|
||||
|
||||
let chunk = match chunk {
|
||||
Ok(chunk) => chunk,
|
||||
Err(error) => {
|
||||
send_sse(
|
||||
&tx,
|
||||
json!({
|
||||
"type": "text",
|
||||
"content": format!("\n\n[错误: {}]", error),
|
||||
}),
|
||||
);
|
||||
break;
|
||||
}
|
||||
};
|
||||
|
||||
buffer.extend_from_slice(&chunk);
|
||||
|
||||
while let Some(pos) = buffer.iter().position(|byte| *byte == b'\n') {
|
||||
let mut line_bytes: Vec<u8> = buffer.drain(..=pos).collect();
|
||||
|
||||
if line_bytes.last() == Some(&b'\n') {
|
||||
line_bytes.pop();
|
||||
}
|
||||
if line_bytes.last() == Some(&b'\r') {
|
||||
line_bytes.pop();
|
||||
}
|
||||
|
||||
let line = String::from_utf8_lossy(&line_bytes);
|
||||
let line = line.trim().to_string();
|
||||
|
||||
if !line.starts_with("data: ") {
|
||||
continue;
|
||||
}
|
||||
|
||||
let data = line.trim_start_matches("data: ").trim().to_string();
|
||||
|
||||
if data == "[DONE]" {
|
||||
continue;
|
||||
}
|
||||
|
||||
let Ok(parsed) = serde_json::from_str::<Value>(&data) else {
|
||||
continue;
|
||||
};
|
||||
|
||||
let content = parsed
|
||||
.get("choices")
|
||||
.and_then(Value::as_array)
|
||||
.and_then(|choices| choices.first())
|
||||
.and_then(|choice| choice.get("delta"))
|
||||
.and_then(|delta| delta.get("content"))
|
||||
.and_then(Value::as_str)
|
||||
.unwrap_or_default()
|
||||
.to_string();
|
||||
|
||||
if !content.is_empty() {
|
||||
accumulated_text.push_str(&content);
|
||||
if !send_sse(
|
||||
&tx,
|
||||
json!({
|
||||
"type": "text",
|
||||
"content": content,
|
||||
}),
|
||||
) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Phase 2: Parse structured sections from accumulated text
|
||||
info!("comfyui-agent: text streaming complete, parsing sections");
|
||||
let sections = parse_sections(&accumulated_text);
|
||||
|
||||
let has_structured_data = !sections.characters.is_empty()
|
||||
|| !sections.scenes.is_empty()
|
||||
|| !sections.tts_list.is_empty()
|
||||
|| sections.workflow_json.is_some();
|
||||
|
||||
if !has_structured_data {
|
||||
send_sse(&tx, json!({ "type": "done" }));
|
||||
return;
|
||||
}
|
||||
|
||||
// Send a processing indicator
|
||||
send_sse(
|
||||
&tx,
|
||||
json!({
|
||||
"type": "text",
|
||||
"content": "\n\n🔄 正在处理结构化数据..."
|
||||
}),
|
||||
);
|
||||
|
||||
// Generate images for each character (4 angles each)
|
||||
for (char_idx, character) in sections.characters.iter().enumerate() {
|
||||
// Send character_prompts event
|
||||
send_sse(
|
||||
&tx,
|
||||
json!({
|
||||
"type": "character_prompts",
|
||||
"character": character.name,
|
||||
}),
|
||||
);
|
||||
|
||||
let angles = [
|
||||
("正面", &character.prompt_front, "front"),
|
||||
("背面", &character.prompt_back, "back"),
|
||||
("左侧", &character.prompt_left, "left"),
|
||||
("右侧", &character.prompt_right, "right"),
|
||||
];
|
||||
|
||||
for (angle_label, prompt, angle_key) in &angles {
|
||||
if prompt.is_empty() {
|
||||
continue;
|
||||
}
|
||||
info!(
|
||||
"comfyui-agent: generating image for {} - {}",
|
||||
character.name, angle_label
|
||||
);
|
||||
|
||||
// Add a short delay between image gen requests to avoid rate limiting
|
||||
if char_idx > 0 || *angle_key != "front" {
|
||||
tokio::time::sleep(tokio::time::Duration::from_secs(1)).await;
|
||||
}
|
||||
|
||||
match call_image_gen(prompt).await {
|
||||
Ok(url) if !url.is_empty() => {
|
||||
send_sse(
|
||||
&tx,
|
||||
json!({
|
||||
"type": "image",
|
||||
"url": url,
|
||||
"character": character.name,
|
||||
"angle": angle_key,
|
||||
}),
|
||||
);
|
||||
}
|
||||
_ => {
|
||||
send_sse(
|
||||
&tx,
|
||||
json!({
|
||||
"type": "text",
|
||||
"content": format!("\n⚠️ {} [{}] 图片生成失败,请重试", character.name, angle_label),
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Generate images for each scene
|
||||
for scene in §ions.scenes {
|
||||
info!("comfyui-agent: generating scene image for scene {}", scene.id);
|
||||
|
||||
tokio::time::sleep(tokio::time::Duration::from_secs(1)).await;
|
||||
|
||||
match call_image_gen(&scene.prompt).await {
|
||||
Ok(url) if !url.is_empty() => {
|
||||
send_sse(
|
||||
&tx,
|
||||
json!({
|
||||
"type": "scene",
|
||||
"id": scene.id,
|
||||
"description": scene.description,
|
||||
"prompt": scene.prompt,
|
||||
"url": url,
|
||||
}),
|
||||
);
|
||||
}
|
||||
_ => {
|
||||
// Still send scene info even without image
|
||||
send_sse(
|
||||
&tx,
|
||||
json!({
|
||||
"type": "scene",
|
||||
"id": scene.id,
|
||||
"description": scene.description,
|
||||
"prompt": scene.prompt,
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Generate TTS for each dialogue
|
||||
for tts in §ions.tts_list {
|
||||
info!("comfyui-agent: generating TTS for {}", tts.character);
|
||||
|
||||
// Choose voice based on emotion
|
||||
let voice = match tts.emotion.as_str() {
|
||||
"激动" | "愤怒" => "nova",
|
||||
"悲伤" | "恐惧" => "shimmer",
|
||||
_ => "alloy",
|
||||
};
|
||||
|
||||
match call_tts(&tts.text, voice).await {
|
||||
Ok(audio_bytes) => {
|
||||
// For SSE, we'll send a data URL for small audio, or a placeholder
|
||||
// Since SSE can't easily send binary, we store to file and send URL
|
||||
let audio_path = format!("/tmp/comfyui_tts_{}_{}.mp3",
|
||||
tts.character.replace(' ', "_"),
|
||||
std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.unwrap_or_default()
|
||||
.as_millis()
|
||||
);
|
||||
if let Ok(()) = tokio::fs::write(&audio_path, &audio_bytes).await {
|
||||
send_sse(
|
||||
&tx,
|
||||
json!({
|
||||
"type": "tts",
|
||||
"character": tts.character,
|
||||
"text": tts.text,
|
||||
"url": format!("/audio/{}", audio_path.trim_start_matches("/tmp/comfyui_tts_")),
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
info!("comfyui-agent: TTS generation failed: {}", e);
|
||||
send_sse(
|
||||
&tx,
|
||||
json!({
|
||||
"type": "text",
|
||||
"content": format!("\n⚠️ {} 的语音生成失败", tts.character),
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
tokio::time::sleep(tokio::time::Duration::from_millis(500)).await;
|
||||
}
|
||||
|
||||
// Send ComfyUI workflow if present
|
||||
if let Some(wf_json) = §ions.workflow_json {
|
||||
info!("comfyui-agent: sending workflow JSON");
|
||||
send_sse(
|
||||
&tx,
|
||||
json!({
|
||||
"type": "comfyui_workflow",
|
||||
"content": wf_json,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
// Signal done with summary
|
||||
let mut summary = String::from("\n\n✅ **处理完成!**");
|
||||
if !sections.characters.is_empty() {
|
||||
summary.push_str(&format!("\n- 角色: {} 个", sections.characters.len()));
|
||||
}
|
||||
if !sections.scenes.is_empty() {
|
||||
summary.push_str(&format!("\n- 场景: {} 个", sections.scenes.len()));
|
||||
}
|
||||
if !sections.tts_list.is_empty() {
|
||||
summary.push_str(&format!("\n- TTS: {} 段", sections.tts_list.len()));
|
||||
}
|
||||
if sections.workflow_json.is_some() {
|
||||
summary.push_str("\n- ComfyUI 工作流: ✅ 已生成");
|
||||
}
|
||||
send_sse(&tx, json!({ "type": "text", "content": summary }));
|
||||
send_sse(&tx, json!({ "type": "done" }));
|
||||
});
|
||||
|
||||
Ok(Sse::new(UnboundedReceiverStream::new(rx)))
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
// Events routes — system event notifications
|
||||
|
||||
use crate::{
|
||||
db::models::event::Event,
|
||||
error::{AppError, AppResult},
|
||||
AppState,
|
||||
};
|
||||
use axum::{
|
||||
extract::{Path, Query, State},
|
||||
routing::{delete, get, post},
|
||||
Json, Router,
|
||||
};
|
||||
use serde::Deserialize;
|
||||
use serde_json::{json, Value};
|
||||
use tracing::info;
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct ListEventsQuery {
|
||||
limit: Option<i64>,
|
||||
offset: Option<i64>,
|
||||
#[serde(rename = "type")]
|
||||
filter_type: Option<String>,
|
||||
read: Option<String>, // "true" | "false"
|
||||
}
|
||||
|
||||
pub fn routes() -> Router<AppState> {
|
||||
Router::new()
|
||||
.route("/api/events", get(list_events).delete(clear_events))
|
||||
.route("/api/events/count", get(unread_count))
|
||||
.route("/api/events/{id}/read", post(mark_read))
|
||||
.route("/api/events/read-all", post(mark_all_read))
|
||||
}
|
||||
|
||||
/// GET /api/events
|
||||
async fn list_events(
|
||||
State(state): State<AppState>,
|
||||
Query(q): Query<ListEventsQuery>,
|
||||
) -> AppResult<Json<Value>> {
|
||||
info!("Listing events");
|
||||
let limit = q.limit.unwrap_or(30).clamp(1, 500);
|
||||
let offset = q.offset.unwrap_or(0).max(0);
|
||||
|
||||
let mut sql = "SELECT * FROM events WHERE 1=1".to_string();
|
||||
let mut bind_idx = 0;
|
||||
let mut params: Vec<String> = vec![];
|
||||
|
||||
if let Some(ref t) = q.filter_type {
|
||||
bind_idx += 1;
|
||||
sql.push_str(&format!(" AND type = ?{}", bind_idx));
|
||||
params.push(t.clone());
|
||||
}
|
||||
if let Some(ref r) = q.read {
|
||||
if r == "true" {
|
||||
sql.push_str(" AND read = 1");
|
||||
} else if r == "false" {
|
||||
sql.push_str(" AND read = 0");
|
||||
}
|
||||
}
|
||||
|
||||
sql.push_str(" ORDER BY created_at DESC");
|
||||
sql.push_str(&format!(" LIMIT {} OFFSET {}", limit, offset));
|
||||
|
||||
let mut query = sqlx::query_as::<_, Event>(&sql);
|
||||
for p in ¶ms {
|
||||
query = query.bind(p);
|
||||
}
|
||||
let events: Vec<Event> = query.fetch_all(&*state.db).await?;
|
||||
|
||||
Ok(Json(json!({ "data": events })))
|
||||
}
|
||||
|
||||
/// GET /api/events/count — unread count
|
||||
async fn unread_count(State(state): State<AppState>) -> AppResult<Json<Value>> {
|
||||
let count: (i64,) = sqlx::query_as("SELECT COUNT(*) FROM events WHERE read = 0")
|
||||
.fetch_one(&*state.db)
|
||||
.await?;
|
||||
Ok(Json(json!({ "count": count.0 })))
|
||||
}
|
||||
|
||||
/// POST /api/events/{id}/read
|
||||
async fn mark_read(State(state): State<AppState>, Path(id): Path<i64>) -> AppResult<Json<Value>> {
|
||||
info!("Marking event {} as read", id);
|
||||
sqlx::query("UPDATE events SET read = 1 WHERE id = ?")
|
||||
.bind(id)
|
||||
.execute(&*state.db)
|
||||
.await?;
|
||||
Ok(Json(json!({ "success": true })))
|
||||
}
|
||||
|
||||
/// POST /api/events/read-all
|
||||
async fn mark_all_read(State(state): State<AppState>) -> AppResult<Json<Value>> {
|
||||
info!("Marking all events as read");
|
||||
sqlx::query("UPDATE events SET read = 1 WHERE read = 0")
|
||||
.execute(&*state.db)
|
||||
.await?;
|
||||
Ok(Json(json!({ "success": true })))
|
||||
}
|
||||
|
||||
/// DELETE /api/events
|
||||
async fn clear_events(State(state): State<AppState>) -> AppResult<Json<Value>> {
|
||||
info!("Clearing all events");
|
||||
sqlx::query("DELETE FROM events")
|
||||
.execute(&*state.db)
|
||||
.await?;
|
||||
Ok(Json(json!({ "success": true })))
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
use crate::{error::AppResult, AppState};
|
||||
use axum::{extract::State, Json};
|
||||
use serde_json::{json, Value};
|
||||
|
||||
pub async fn health_check(State(state): State<AppState>) -> AppResult<Json<Value>> {
|
||||
// Simple DB ping
|
||||
sqlx::query("SELECT 1").execute(&*state.db).await?;
|
||||
|
||||
Ok(Json(json!({
|
||||
"status": "ok",
|
||||
"database": "ok",
|
||||
"version": env!("CARGO_PKG_VERSION"),
|
||||
})))
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
use crate::AppState;
|
||||
use axum::{
|
||||
body::Body,
|
||||
http::{Request, StatusCode, Uri},
|
||||
response::{Html, Response},
|
||||
routing::get,
|
||||
Router,
|
||||
};
|
||||
use std::convert::Infallible;
|
||||
use tower::service_fn;
|
||||
use tower_http::services::ServeDir;
|
||||
|
||||
mod admin;
|
||||
mod auth;
|
||||
mod checkin;
|
||||
mod comfyui;
|
||||
mod comfyui_agent;
|
||||
mod events;
|
||||
mod health;
|
||||
mod monitor;
|
||||
mod oauth;
|
||||
mod organizations;
|
||||
mod proxy;
|
||||
mod search;
|
||||
mod site_announcements;
|
||||
mod stats;
|
||||
mod tasks;
|
||||
mod update_center;
|
||||
mod user;
|
||||
mod user_api_keys;
|
||||
mod video_agent;
|
||||
|
||||
pub fn build_router(state: AppState) -> Router {
|
||||
let web_dir = std::env::var("BOOSAPI_WEB_DIR")
|
||||
.unwrap_or_else(|_| "dist/web".to_string());
|
||||
|
||||
// Build the API router
|
||||
let api_routes = Router::new()
|
||||
.route("/healthz", get(health::health_check))
|
||||
.merge(admin::routes(state.clone()))
|
||||
.merge(proxy::routes())
|
||||
.merge(comfyui_agent::routes())
|
||||
.merge(comfyui::routes())
|
||||
.merge(auth::routes())
|
||||
.merge(events::routes())
|
||||
.merge(checkin::routes())
|
||||
.merge(monitor::routes())
|
||||
.merge(oauth::routes())
|
||||
.merge(organizations::routes())
|
||||
.merge(search::routes())
|
||||
.merge(site_announcements::routes())
|
||||
.merge(stats::routes())
|
||||
.merge(tasks::routes())
|
||||
.merge(update_center::routes())
|
||||
.merge(user::routes())
|
||||
.merge(user_api_keys::routes())
|
||||
.merge(video_agent::routes())
|
||||
.with_state(state);
|
||||
|
||||
// Static file serving with SPA fallback
|
||||
let web_dir_owned = web_dir.clone();
|
||||
let static_service = ServeDir::new(&web_dir)
|
||||
.append_index_html_on_directories(true)
|
||||
.fallback(service_fn(move |req: Request<Body>| {
|
||||
let web_dir = web_dir_owned.clone();
|
||||
async move {
|
||||
let index_path = format!("{}/index.html", web_dir);
|
||||
match tokio::fs::read_to_string(&index_path).await {
|
||||
Ok(html) => {
|
||||
Ok::<_, Infallible>(
|
||||
Response::builder()
|
||||
.status(StatusCode::OK)
|
||||
.header("Content-Type", "text/html; charset=utf-8")
|
||||
.body(Body::from(html))
|
||||
.unwrap(),
|
||||
)
|
||||
}
|
||||
Err(_) => {
|
||||
Ok::<_, Infallible>(
|
||||
Response::builder()
|
||||
.status(StatusCode::NOT_FOUND)
|
||||
.header("Content-Type", "text/plain; charset=utf-8")
|
||||
.body(Body::from(format!("404: {} not found", req.uri().path())))
|
||||
.unwrap(),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}));
|
||||
|
||||
Router::new()
|
||||
.merge(api_routes)
|
||||
.fallback_service(static_service)
|
||||
}
|
||||
@@ -0,0 +1,298 @@
|
||||
// Monitor routes — LDOH incident dashboard proxy with cookie auth
|
||||
|
||||
use crate::{
|
||||
error::{AppError, AppResult},
|
||||
AppState,
|
||||
};
|
||||
use axum::{
|
||||
extract::{Path, State},
|
||||
http::{header, HeaderMap, StatusCode},
|
||||
response::Response,
|
||||
routing::{get, post, put},
|
||||
Json, Router,
|
||||
};
|
||||
use serde_json::{json, Value};
|
||||
use tracing::info;
|
||||
|
||||
const MONITOR_AUTH_COOKIE: &str = "meta_monitor_auth";
|
||||
const LDOH_BASE_URL: &str = "https://ldoh.105117.xyz";
|
||||
const LDOH_COOKIE_SETTING_KEY: &str = "monitor_ldoh_cookie";
|
||||
|
||||
pub fn routes() -> Router<AppState> {
|
||||
Router::new()
|
||||
.route(
|
||||
"/api/monitor/config",
|
||||
get(get_monitor_config).put(update_monitor_config),
|
||||
)
|
||||
.route("/api/monitor/session", post(create_session))
|
||||
.route("/monitor-proxy/ldoh", get(ldoh_proxy_handler))
|
||||
.route("/monitor-proxy/ldoh/", get(ldoh_proxy_handler))
|
||||
.route("/monitor-proxy/ldoh/{*path}", get(ldoh_proxy_handler))
|
||||
}
|
||||
|
||||
/// GET /api/monitor/config — returns whether LDOH cookie is configured
|
||||
async fn get_monitor_config(State(state): State<AppState>) -> AppResult<Json<Value>> {
|
||||
let cookie: Option<(String,)> = sqlx::query_as("SELECT value FROM settings WHERE key = ?")
|
||||
.bind(LDOH_COOKIE_SETTING_KEY)
|
||||
.fetch_optional(&*state.db)
|
||||
.await?;
|
||||
|
||||
let raw = cookie
|
||||
.and_then(|(v,)| {
|
||||
serde_json::from_str::<serde_json::Value>(&v)
|
||||
.ok()
|
||||
.and_then(|parsed| parsed.as_str().map(String::from))
|
||||
})
|
||||
.unwrap_or_default();
|
||||
|
||||
let configured = !raw.is_empty();
|
||||
let masked = if configured {
|
||||
mask_cookie_value(&raw)
|
||||
} else {
|
||||
String::new()
|
||||
};
|
||||
|
||||
Ok(Json(json!({
|
||||
"ldohCookieConfigured": configured,
|
||||
"ldohCookieMasked": masked,
|
||||
})))
|
||||
}
|
||||
|
||||
/// PUT /api/monitor/config — save LDOH cookie
|
||||
async fn update_monitor_config(
|
||||
State(state): State<AppState>,
|
||||
Json(body): Json<serde_json::Value>,
|
||||
) -> AppResult<Json<Value>> {
|
||||
let cookie = body
|
||||
.get("ldohCookie")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("");
|
||||
|
||||
if cookie.is_empty() {
|
||||
sqlx::query("INSERT OR REPLACE INTO settings (key, value) VALUES (?, '')")
|
||||
.bind(LDOH_COOKIE_SETTING_KEY)
|
||||
.execute(&*state.db)
|
||||
.await?;
|
||||
return Ok(Json(json!({
|
||||
"success": true,
|
||||
"message": "LDOH Cookie 已清空",
|
||||
"ldohCookieConfigured": false,
|
||||
})));
|
||||
}
|
||||
|
||||
let normalized = normalize_ldoh_cookie(cookie);
|
||||
if !normalized.starts_with("ld_auth_session=") || normalized.len() < 24 {
|
||||
return Err(AppError::BadRequest("Cookie 格式无效".into()));
|
||||
}
|
||||
|
||||
let escaped = serde_json::to_string(&normalized).unwrap_or_default();
|
||||
sqlx::query("INSERT OR REPLACE INTO settings (key, value) VALUES (?, ?)")
|
||||
.bind(LDOH_COOKIE_SETTING_KEY)
|
||||
.bind(&escaped)
|
||||
.execute(&*state.db)
|
||||
.await?;
|
||||
|
||||
Ok(Json(json!({
|
||||
"success": true,
|
||||
"message": "LDOH Cookie 已保存",
|
||||
"ldohCookieConfigured": true,
|
||||
"ldohCookieMasked": mask_cookie_value(&normalized),
|
||||
})))
|
||||
}
|
||||
|
||||
/// POST /api/monitor/session — returns cookie value for client to set
|
||||
async fn create_session(State(state): State<AppState>) -> Json<Value> {
|
||||
let token = &state.config.auth.auth_token;
|
||||
Json(json!({
|
||||
"success": true,
|
||||
"_cookie": format!("{}={}; Path=/; HttpOnly; SameSite=Lax; Max-Age=7200", MONITOR_AUTH_COOKIE, token),
|
||||
}))
|
||||
}
|
||||
|
||||
/// GET /monitor-proxy/ldoh/{*path} — reverse proxy to ldoh.105117.xyz
|
||||
async fn ldoh_proxy_handler(
|
||||
State(state): State<AppState>,
|
||||
headers: HeaderMap,
|
||||
Path(path): Path<String>,
|
||||
) -> Result<Response, AppError> {
|
||||
// Auth check via cookie
|
||||
let cookies = parse_cookies(
|
||||
headers
|
||||
.get(header::COOKIE)
|
||||
.and_then(|v| v.to_str().ok())
|
||||
.unwrap_or(""),
|
||||
);
|
||||
if cookies.get(MONITOR_AUTH_COOKIE).map(|s| s.as_str()) != Some(&state.config.auth.auth_token) {
|
||||
return Err(AppError::Unauthorized(
|
||||
"Missing or invalid monitor session".into(),
|
||||
));
|
||||
}
|
||||
|
||||
// Get stored LDOH cookie
|
||||
let stored: Option<(String,)> = sqlx::query_as("SELECT value FROM settings WHERE key = ?")
|
||||
.bind(LDOH_COOKIE_SETTING_KEY)
|
||||
.fetch_optional(&*state.db)
|
||||
.await?;
|
||||
|
||||
let ldoh_cookie = stored
|
||||
.and_then(|(v,)| {
|
||||
serde_json::from_str::<serde_json::Value>(&v)
|
||||
.ok()
|
||||
.and_then(|parsed| parsed.as_str().map(String::from))
|
||||
})
|
||||
.unwrap_or_default();
|
||||
|
||||
if ldoh_cookie.is_empty() {
|
||||
return Err(AppError::BadRequest("LDOH cookie not configured".into()));
|
||||
}
|
||||
|
||||
// Build upstream URL
|
||||
let clean_path = if path.is_empty() || path == "/" {
|
||||
String::new()
|
||||
} else {
|
||||
format!("/{}", path)
|
||||
};
|
||||
let target_url = format!("{}{}", LDOH_BASE_URL, clean_path);
|
||||
|
||||
let client = reqwest::Client::builder()
|
||||
.timeout(std::time::Duration::from_secs(30))
|
||||
.build()
|
||||
.map_err(|e| AppError::Internal(anyhow::anyhow!("Failed to build client: {e}")))?;
|
||||
|
||||
let mut req = client
|
||||
.get(&target_url)
|
||||
.header("cookie", &ldoh_cookie)
|
||||
.header(
|
||||
"accept",
|
||||
headers
|
||||
.get("accept")
|
||||
.and_then(|v| v.to_str().ok())
|
||||
.unwrap_or("*/*"),
|
||||
)
|
||||
.header(
|
||||
"accept-language",
|
||||
headers
|
||||
.get("accept-language")
|
||||
.and_then(|v| v.to_str().ok())
|
||||
.unwrap_or("zh-CN,zh;q=0.9,en;q=0.8"),
|
||||
)
|
||||
.header(
|
||||
"user-agent",
|
||||
headers
|
||||
.get("user-agent")
|
||||
.and_then(|v| v.to_str().ok())
|
||||
.unwrap_or("boosapiMonitorProxy/1.0"),
|
||||
);
|
||||
|
||||
if let Some(referer) = headers.get("referer").and_then(|v| v.to_str().ok()) {
|
||||
req = req.header("referer", referer.replace("/monitor-proxy/ldoh", ""));
|
||||
}
|
||||
|
||||
let resp = req
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| AppError::UpstreamError(format!("LDOH unreachable: {e}")))?;
|
||||
|
||||
let status = resp.status();
|
||||
let content_type = resp
|
||||
.headers()
|
||||
.get("content-type")
|
||||
.and_then(|v| v.to_str().ok())
|
||||
.unwrap_or("application/octet-stream")
|
||||
.to_string();
|
||||
|
||||
let mut builder = axum::response::Response::builder().status(status);
|
||||
if let Some(ctl) = resp
|
||||
.headers()
|
||||
.get("cache-control")
|
||||
.and_then(|v| v.to_str().ok())
|
||||
{
|
||||
builder = builder.header("cache-control", ctl);
|
||||
}
|
||||
|
||||
// Rewrite text content
|
||||
if content_type.contains("text/html")
|
||||
|| content_type.contains("javascript")
|
||||
|| content_type.contains("text/css")
|
||||
|| content_type.contains("application/json")
|
||||
{
|
||||
let text = resp
|
||||
.text()
|
||||
.await
|
||||
.map_err(|e| AppError::UpstreamError(e.to_string()))?;
|
||||
return Ok(builder
|
||||
.header(header::CONTENT_TYPE, &content_type)
|
||||
.body(axum::body::Body::from(rewrite_proxy_text(&text)))
|
||||
.map_err(|e| AppError::Internal(anyhow::anyhow!("{e}")))?);
|
||||
}
|
||||
|
||||
let bytes = resp
|
||||
.bytes()
|
||||
.await
|
||||
.map_err(|e| AppError::UpstreamError(e.to_string()))?;
|
||||
Ok(builder
|
||||
.header(header::CONTENT_TYPE, &content_type)
|
||||
.body(axum::body::Body::from(bytes))
|
||||
.map_err(|e| AppError::Internal(anyhow::anyhow!("{e}")))?)
|
||||
}
|
||||
|
||||
// ── Helpers ────────────────────────────────────────────────────────────
|
||||
|
||||
fn parse_cookies(raw: &str) -> std::collections::HashMap<String, String> {
|
||||
let mut map = std::collections::HashMap::new();
|
||||
for part in raw.split(';') {
|
||||
let part = part.trim();
|
||||
if let Some(idx) = part.find('=') {
|
||||
let key = part[..idx].trim().to_string();
|
||||
let val = part[idx + 1..].trim().to_string();
|
||||
if !key.is_empty() {
|
||||
map.insert(key, val);
|
||||
}
|
||||
}
|
||||
}
|
||||
map
|
||||
}
|
||||
|
||||
fn mask_cookie_value(cookie_text: &str) -> String {
|
||||
let raw = cookie_text.trim();
|
||||
if raw.is_empty() {
|
||||
return String::new();
|
||||
}
|
||||
let value = raw.split('=').nth(1).unwrap_or(raw);
|
||||
if value.len() <= 10 {
|
||||
format!("{}****", &value[..2.min(value.len())])
|
||||
} else {
|
||||
format!("{}****{}", &value[..6], &value[value.len() - 4..])
|
||||
}
|
||||
}
|
||||
|
||||
fn normalize_ldoh_cookie(raw: &str) -> String {
|
||||
let trimmed = raw.trim().to_string();
|
||||
if trimmed.contains("ld_auth_session=") {
|
||||
if let Some(first) = trimmed.split(';').next() {
|
||||
if first.trim().starts_with("ld_auth_session=") {
|
||||
return first.trim().to_string();
|
||||
}
|
||||
}
|
||||
}
|
||||
format!("ld_auth_session={}", trimmed)
|
||||
}
|
||||
|
||||
fn rewrite_proxy_text(text: &str) -> String {
|
||||
text.replace("https://ldoh.105117.xyz/", "/monitor-proxy/ldoh/")
|
||||
.replace(
|
||||
"https:\\/\\/ldoh.105117.xyz\\/",
|
||||
"\\/monitor-proxy\\/ldoh\\/",
|
||||
)
|
||||
.replace("src=\"/", "src=\"/monitor-proxy/ldoh/")
|
||||
.replace("src='/", "src='/monitor-proxy/ldoh/")
|
||||
.replace("href=\"/", "href=\"/monitor-proxy/ldoh/")
|
||||
.replace("href='/", "href='/monitor-proxy/ldoh/")
|
||||
.replace("action=\"/", "action=\"/monitor-proxy/ldoh/")
|
||||
.replace("action='/", "action='/monitor-proxy/ldoh/")
|
||||
.replace("\"/_next/", "\"/monitor-proxy/ldoh/_next/")
|
||||
.replace("'/_next/", "'/monitor-proxy/ldoh/_next/")
|
||||
.replace("\"\\\\/api/", "\"\\\\/monitor-proxy\\\\/ldoh\\\\/api/")
|
||||
.replace("'/api/", "'/monitor-proxy/ldoh/api/")
|
||||
.replace("\"/api/", "\"/monitor-proxy/ldoh/api/")
|
||||
}
|
||||
@@ -0,0 +1,312 @@
|
||||
// OAuth routes — provider management, session handling, connection management
|
||||
// Simplified implementation — complex OAuth flow operations return 501
|
||||
|
||||
use crate::{
|
||||
error::{AppError, AppResult},
|
||||
AppState,
|
||||
};
|
||||
use axum::{
|
||||
extract::{Path, State},
|
||||
http::{HeaderMap, StatusCode},
|
||||
response::{Html, IntoResponse},
|
||||
routing::{get, post, put},
|
||||
Json, Router,
|
||||
};
|
||||
use serde_json::{json, Value};
|
||||
use tracing::info;
|
||||
|
||||
pub fn routes() -> Router<AppState> {
|
||||
Router::new()
|
||||
// Providers
|
||||
.route("/api/oauth/providers", get(list_providers))
|
||||
.route(
|
||||
"/api/oauth/providers/{provider}/start",
|
||||
post(start_provider_flow),
|
||||
)
|
||||
// Sessions
|
||||
.route("/api/oauth/sessions/{state}", get(get_session))
|
||||
.route(
|
||||
"/api/oauth/sessions/{state}/manual-callback",
|
||||
post(manual_callback),
|
||||
)
|
||||
// Connections
|
||||
.route("/api/oauth/connections", get(list_connections))
|
||||
.route(
|
||||
"/api/oauth/connections/{id}/rebind",
|
||||
post(rebind_connection),
|
||||
)
|
||||
.route(
|
||||
"/api/oauth/connections/{id}/proxy",
|
||||
post(update_connection_proxy),
|
||||
)
|
||||
.route("/api/oauth/connections/import", post(import_connections))
|
||||
.route(
|
||||
"/api/oauth/connections/{id}/refresh-quota",
|
||||
post(refresh_connection_quota),
|
||||
)
|
||||
.route(
|
||||
"/api/oauth/connections/refresh-quota-batch",
|
||||
post(refresh_quota_batch),
|
||||
)
|
||||
// Route units
|
||||
.route("/api/oauth/route-units", post(create_route_unit))
|
||||
.route(
|
||||
"/api/oauth/route-units/{id}",
|
||||
put(update_route_unit).delete(delete_route_unit),
|
||||
)
|
||||
// Callback
|
||||
.route("/api/oauth/callback", get(oauth_callback_page))
|
||||
// Delete connection
|
||||
.route("/api/oauth/connections/{id}", post(delete_connection))
|
||||
}
|
||||
|
||||
// ── Auth helper ────────────────────────────────────────────────────────
|
||||
|
||||
fn require_admin(headers: &HeaderMap, state: &AppState) -> AppResult<()> {
|
||||
let token = headers
|
||||
.get("Authorization")
|
||||
.and_then(|v| v.to_str().ok())
|
||||
.and_then(|v| v.strip_prefix("Bearer "))
|
||||
.ok_or_else(|| AppError::Unauthorized("Missing Authorization header".into()))?;
|
||||
|
||||
if token != state.config.auth.auth_token {
|
||||
return Err(AppError::Unauthorized("Invalid admin token".into()));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// ── Providers ──────────────────────────────────────────────────────────
|
||||
|
||||
/// GET /api/oauth/providers — public
|
||||
async fn list_providers() -> Json<Value> {
|
||||
Json(json!({
|
||||
"defaults": {
|
||||
"proxyRequired": false,
|
||||
"useSystemProxy": false,
|
||||
},
|
||||
"providers": [
|
||||
{
|
||||
"id": "openai",
|
||||
"name": "OpenAI",
|
||||
"type": "openai",
|
||||
"enabled": true,
|
||||
},
|
||||
{
|
||||
"id": "anthropic",
|
||||
"name": "Anthropic",
|
||||
"type": "anthropic",
|
||||
"enabled": true,
|
||||
},
|
||||
{
|
||||
"id": "google",
|
||||
"name": "Google AI",
|
||||
"type": "google",
|
||||
"enabled": true,
|
||||
},
|
||||
],
|
||||
}))
|
||||
}
|
||||
|
||||
/// POST /api/oauth/providers/{provider}/start
|
||||
async fn start_provider_flow(
|
||||
State(_state): State<AppState>,
|
||||
headers: HeaderMap,
|
||||
Path(_provider): Path<String>,
|
||||
) -> AppResult<Json<Value>> {
|
||||
require_admin(&headers, &_state)?;
|
||||
info!("OAuth provider flow start: {}", _provider);
|
||||
Err(AppError::BadRequest(
|
||||
"OAuth provider flow not implemented in Rust backend".into(),
|
||||
))
|
||||
}
|
||||
|
||||
// ── Sessions ───────────────────────────────────────────────────────────
|
||||
|
||||
/// GET /api/oauth/sessions/{state}
|
||||
async fn get_session(
|
||||
State(_state): State<AppState>,
|
||||
headers: HeaderMap,
|
||||
Path(state): Path<String>,
|
||||
) -> AppResult<Json<Value>> {
|
||||
require_admin(&headers, &_state)?;
|
||||
info!("OAuth session lookup: {}", state);
|
||||
Err(AppError::NotFound(format!(
|
||||
"OAuth session not found: {}",
|
||||
state
|
||||
)))
|
||||
}
|
||||
|
||||
/// POST /api/oauth/sessions/{state}/manual-callback
|
||||
async fn manual_callback(
|
||||
State(_state): State<AppState>,
|
||||
headers: HeaderMap,
|
||||
Path(state): Path<String>,
|
||||
) -> AppResult<Json<Value>> {
|
||||
require_admin(&headers, &_state)?;
|
||||
info!("OAuth manual callback: {}", state);
|
||||
Err(AppError::BadRequest(
|
||||
"OAuth manual callback not implemented in Rust backend".into(),
|
||||
))
|
||||
}
|
||||
|
||||
// ── Connections ────────────────────────────────────────────────────────
|
||||
|
||||
/// GET /api/oauth/connections
|
||||
async fn list_connections(
|
||||
State(state): State<AppState>,
|
||||
headers: HeaderMap,
|
||||
) -> AppResult<Json<Value>> {
|
||||
require_admin(&headers, &state)?;
|
||||
info!("Listing OAuth connections");
|
||||
|
||||
let accounts: Vec<Value> = sqlx::query_as::<_, (i64, i64, Option<String>, String, Option<String>, Option<String>, Option<String>, String)>(
|
||||
"SELECT id, site_id, username, access_token, oauth_provider, oauth_account_key, oauth_project_id, status \
|
||||
FROM accounts WHERE oauth_provider IS NOT NULL ORDER BY id"
|
||||
)
|
||||
.fetch_all(&*state.db)
|
||||
.await?
|
||||
.into_iter()
|
||||
.map(|(id, site_id, username, _access_token, oauth_provider, oauth_account_key, oauth_project_id, status)| {
|
||||
json!({
|
||||
"id": id,
|
||||
"siteId": site_id,
|
||||
"username": username,
|
||||
"oauthProvider": oauth_provider,
|
||||
"oauthAccountKey": oauth_account_key,
|
||||
"oauthProjectId": oauth_project_id,
|
||||
"status": status,
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
|
||||
Ok(Json(json!({ "data": accounts, "total": accounts.len() })))
|
||||
}
|
||||
|
||||
/// POST /api/oauth/connections/{id}/rebind
|
||||
async fn rebind_connection(
|
||||
State(_state): State<AppState>,
|
||||
headers: HeaderMap,
|
||||
Path(_id): Path<i64>,
|
||||
) -> AppResult<Json<Value>> {
|
||||
require_admin(&headers, &_state)?;
|
||||
Err(AppError::BadRequest(
|
||||
"OAuth rebind not implemented in Rust backend".into(),
|
||||
))
|
||||
}
|
||||
|
||||
/// POST /api/oauth/connections/{id}/proxy
|
||||
async fn update_connection_proxy(
|
||||
State(_state): State<AppState>,
|
||||
headers: HeaderMap,
|
||||
Path(_id): Path<i64>,
|
||||
) -> AppResult<Json<Value>> {
|
||||
require_admin(&headers, &_state)?;
|
||||
Err(AppError::BadRequest(
|
||||
"OAuth proxy update not implemented in Rust backend".into(),
|
||||
))
|
||||
}
|
||||
|
||||
/// POST /api/oauth/connections/import
|
||||
async fn import_connections(
|
||||
State(_state): State<AppState>,
|
||||
headers: HeaderMap,
|
||||
) -> AppResult<Json<Value>> {
|
||||
require_admin(&headers, &_state)?;
|
||||
Err(AppError::BadRequest(
|
||||
"OAuth import not implemented in Rust backend".into(),
|
||||
))
|
||||
}
|
||||
|
||||
/// POST /api/oauth/connections/{id}/refresh-quota
|
||||
async fn refresh_connection_quota(
|
||||
State(_state): State<AppState>,
|
||||
headers: HeaderMap,
|
||||
Path(_id): Path<i64>,
|
||||
) -> AppResult<Json<Value>> {
|
||||
require_admin(&headers, &_state)?;
|
||||
Err(AppError::BadRequest(
|
||||
"OAuth quota refresh not implemented in Rust backend".into(),
|
||||
))
|
||||
}
|
||||
|
||||
/// POST /api/oauth/connections/refresh-quota-batch
|
||||
async fn refresh_quota_batch(
|
||||
State(_state): State<AppState>,
|
||||
headers: HeaderMap,
|
||||
) -> AppResult<Json<Value>> {
|
||||
require_admin(&headers, &_state)?;
|
||||
Err(AppError::BadRequest(
|
||||
"OAuth batch quota refresh not implemented in Rust backend".into(),
|
||||
))
|
||||
}
|
||||
|
||||
// ── Route Units ────────────────────────────────────────────────────────
|
||||
|
||||
/// POST /api/oauth/route-units
|
||||
async fn create_route_unit(
|
||||
State(_state): State<AppState>,
|
||||
headers: HeaderMap,
|
||||
) -> AppResult<Json<Value>> {
|
||||
require_admin(&headers, &_state)?;
|
||||
Err(AppError::BadRequest(
|
||||
"OAuth route unit creation not implemented in Rust backend".into(),
|
||||
))
|
||||
}
|
||||
|
||||
/// PUT /api/oauth/route-units/{id}
|
||||
async fn update_route_unit(
|
||||
State(_state): State<AppState>,
|
||||
headers: HeaderMap,
|
||||
Path(_id): Path<i64>,
|
||||
) -> AppResult<Json<Value>> {
|
||||
require_admin(&headers, &_state)?;
|
||||
Err(AppError::BadRequest(
|
||||
"OAuth route unit update not implemented in Rust backend".into(),
|
||||
))
|
||||
}
|
||||
|
||||
/// DELETE /api/oauth/route-units/{id}
|
||||
async fn delete_route_unit(
|
||||
State(_state): State<AppState>,
|
||||
headers: HeaderMap,
|
||||
Path(_id): Path<i64>,
|
||||
) -> AppResult<Json<Value>> {
|
||||
require_admin(&headers, &_state)?;
|
||||
Err(AppError::BadRequest(
|
||||
"OAuth route unit deletion not implemented in Rust backend".into(),
|
||||
))
|
||||
}
|
||||
|
||||
// ── Callback ───────────────────────────────────────────────────────────
|
||||
|
||||
/// GET /api/oauth/callback — public, returns HTML for popup window
|
||||
async fn oauth_callback_page(headers: HeaderMap) -> impl IntoResponse {
|
||||
info!("OAuth callback received");
|
||||
let html = r#"<!doctype html>
|
||||
<html lang="zh-CN">
|
||||
<head><meta charset="utf-8" /><title>OAuth Callback</title></head>
|
||||
<body>
|
||||
<script>window.close();</script>
|
||||
OAuth callback received. You can close this window.
|
||||
</body>
|
||||
</html>"#;
|
||||
(
|
||||
StatusCode::OK,
|
||||
[("Content-Type", "text/html; charset=utf-8")],
|
||||
Html(html),
|
||||
)
|
||||
}
|
||||
|
||||
// ── Delete ─────────────────────────────────────────────────────────────
|
||||
|
||||
/// POST /api/oauth/connections/{id} — delete connection
|
||||
async fn delete_connection(
|
||||
State(_state): State<AppState>,
|
||||
headers: HeaderMap,
|
||||
Path(_id): Path<i64>,
|
||||
) -> AppResult<Json<Value>> {
|
||||
require_admin(&headers, &_state)?;
|
||||
Err(AppError::BadRequest(
|
||||
"OAuth connection deletion not implemented in Rust backend".into(),
|
||||
))
|
||||
}
|
||||
@@ -0,0 +1,562 @@
|
||||
// Organizations, members, and invitations routes
|
||||
|
||||
use crate::{
|
||||
auth::jwt::validate_jwt,
|
||||
db::models::organization::{Invitation, Organization, OrganizationMember},
|
||||
error::{AppError, AppResult},
|
||||
AppState,
|
||||
};
|
||||
use axum::{
|
||||
extract::{Path, Query, State},
|
||||
http::{HeaderMap, StatusCode},
|
||||
routing::{delete, get, patch, post},
|
||||
Json, Router,
|
||||
};
|
||||
use serde::Deserialize;
|
||||
use serde_json::{json, Value};
|
||||
use tracing::info;
|
||||
|
||||
// ── Auth helper ────────────────────────────────────────────────────────
|
||||
|
||||
fn extract_jwt_user(headers: &HeaderMap, state: &AppState) -> AppResult<(i64, String)> {
|
||||
let auth = headers
|
||||
.get("Authorization")
|
||||
.and_then(|v| v.to_str().ok())
|
||||
.and_then(|v| v.strip_prefix("Bearer "))
|
||||
.ok_or_else(|| AppError::Unauthorized("Missing or invalid Authorization header".into()))?;
|
||||
|
||||
let claims = validate_jwt(&state.config.auth.jwt_secret, auth)
|
||||
.map_err(|_| AppError::Unauthorized("Invalid or expired token".into()))?;
|
||||
|
||||
let user_id: i64 = claims
|
||||
.sub
|
||||
.parse()
|
||||
.map_err(|_| AppError::Unauthorized("Invalid user ID in token".into()))?;
|
||||
|
||||
Ok((user_id, claims.role))
|
||||
}
|
||||
|
||||
/// Check if user is org admin (owner or admin role).
|
||||
async fn require_org_admin(db: &sqlx::SqlitePool, org_id: i64, user_id: i64) -> AppResult<()> {
|
||||
let member: Option<OrganizationMember> =
|
||||
sqlx::query_as("SELECT * FROM organization_members WHERE org_id = ? AND user_id = ?")
|
||||
.bind(org_id)
|
||||
.bind(user_id)
|
||||
.fetch_optional(db)
|
||||
.await?;
|
||||
|
||||
match member {
|
||||
Some(m) if m.role.as_deref() == Some("owner") || m.role.as_deref() == Some("admin") => {
|
||||
Ok(())
|
||||
}
|
||||
_ => Err(AppError::Forbidden("Admin access required".into())),
|
||||
}
|
||||
}
|
||||
|
||||
/// Check if user is a member of an org.
|
||||
async fn is_org_member(db: &sqlx::SqlitePool, org_id: i64, user_id: i64) -> Result<bool, AppError> {
|
||||
let member: Option<OrganizationMember> =
|
||||
sqlx::query_as("SELECT * FROM organization_members WHERE org_id = ? AND user_id = ?")
|
||||
.bind(org_id)
|
||||
.bind(user_id)
|
||||
.fetch_optional(db)
|
||||
.await?;
|
||||
Ok(member.is_some())
|
||||
}
|
||||
|
||||
// ── Routes ─────────────────────────────────────────────────────────────
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct ListOrgsQuery {
|
||||
limit: Option<i64>,
|
||||
}
|
||||
|
||||
pub fn routes() -> Router<AppState> {
|
||||
Router::new()
|
||||
// Organizations CRUD
|
||||
.route(
|
||||
"/api/organizations",
|
||||
get(list_organizations).post(create_organization),
|
||||
)
|
||||
.route("/api/organizations/plans", get(list_plans))
|
||||
.route(
|
||||
"/api/organizations/{org_id}",
|
||||
get(get_organization).patch(update_organization),
|
||||
)
|
||||
// Members
|
||||
.route("/api/organizations/{org_id}/members", get(list_members))
|
||||
.route(
|
||||
"/api/organizations/{org_id}/members/{user_id}",
|
||||
patch(update_member_role).delete(remove_member),
|
||||
)
|
||||
// Invitations
|
||||
.route(
|
||||
"/api/organizations/{org_id}/invitations",
|
||||
get(list_invitations).post(create_invitation),
|
||||
)
|
||||
.route(
|
||||
"/api/organizations/{org_id}/invitations/{inv_id}",
|
||||
delete(cancel_invitation),
|
||||
)
|
||||
// Public invitation routes (no org prefix)
|
||||
.route("/api/invitations/{token}", get(get_invitation))
|
||||
.route("/api/invitations/{token}/accept", post(accept_invitation))
|
||||
}
|
||||
|
||||
// ── Organization Handlers ──────────────────────────────────────────────
|
||||
|
||||
/// GET /api/organizations
|
||||
async fn list_organizations(
|
||||
State(state): State<AppState>,
|
||||
headers: HeaderMap,
|
||||
Query(q): Query<ListOrgsQuery>,
|
||||
) -> AppResult<Json<Value>> {
|
||||
let (user_id, _) = extract_jwt_user(&headers, &state)?;
|
||||
let limit = q.limit.unwrap_or(50).clamp(1, 500);
|
||||
|
||||
let orgs: Vec<Organization> = sqlx::query_as(
|
||||
r#"SELECT o.* FROM organizations o
|
||||
INNER JOIN organization_members om ON om.org_id = o.id
|
||||
WHERE om.user_id = ?
|
||||
ORDER BY o.created_at DESC
|
||||
LIMIT ?"#,
|
||||
)
|
||||
.bind(user_id)
|
||||
.bind(limit)
|
||||
.fetch_all(&*state.db)
|
||||
.await?;
|
||||
|
||||
Ok(Json(json!({ "organizations": orgs })))
|
||||
}
|
||||
|
||||
/// POST /api/organizations
|
||||
async fn create_organization(
|
||||
State(state): State<AppState>,
|
||||
headers: HeaderMap,
|
||||
Json(body): Json<serde_json::Value>,
|
||||
) -> AppResult<(StatusCode, Json<Value>)> {
|
||||
let (user_id, _) = extract_jwt_user(&headers, &state)?;
|
||||
|
||||
let name = body
|
||||
.get("name")
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or_else(|| AppError::BadRequest("name is required".into()))?;
|
||||
let slug = body
|
||||
.get("slug")
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or_else(|| AppError::BadRequest("slug is required".into()))?;
|
||||
|
||||
let result = sqlx::query("INSERT INTO organizations (name, slug) VALUES (?, ?)")
|
||||
.bind(name)
|
||||
.bind(slug)
|
||||
.execute(&*state.db)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
let msg = e.to_string();
|
||||
if msg.contains("UNIQUE") {
|
||||
AppError::BadRequest("Organization slug already exists".into())
|
||||
} else {
|
||||
AppError::Internal(anyhow::anyhow!("Failed to create organization: {e}"))
|
||||
}
|
||||
})?;
|
||||
|
||||
let org_id = result.last_insert_rowid();
|
||||
|
||||
// Add creator as owner
|
||||
sqlx::query("INSERT INTO organization_members (org_id, user_id, role) VALUES (?, ?, 'owner')")
|
||||
.bind(org_id)
|
||||
.bind(user_id)
|
||||
.execute(&*state.db)
|
||||
.await?;
|
||||
|
||||
let org: Organization = sqlx::query_as("SELECT * FROM organizations WHERE id = ?")
|
||||
.bind(org_id)
|
||||
.fetch_one(&*state.db)
|
||||
.await?;
|
||||
|
||||
Ok((StatusCode::CREATED, Json(json!({ "organization": org }))))
|
||||
}
|
||||
|
||||
/// GET /api/organizations/plans
|
||||
async fn list_plans() -> AppResult<Json<Value>> {
|
||||
// Static plan definitions matching Node.js listAllPlans()
|
||||
let plans = json!([
|
||||
{
|
||||
"id": "free",
|
||||
"name": "免费",
|
||||
"description": "基础功能",
|
||||
"limits": { "members": 3, "sites": 2 },
|
||||
"price": 0,
|
||||
},
|
||||
{
|
||||
"id": "pro",
|
||||
"name": "专业版",
|
||||
"description": "进阶功能",
|
||||
"limits": { "members": 20, "sites": 20 },
|
||||
"price": 9900,
|
||||
},
|
||||
{
|
||||
"id": "enterprise",
|
||||
"name": "企业版",
|
||||
"description": "全功能",
|
||||
"limits": { "members": 100, "sites": 100 },
|
||||
"price": 49900,
|
||||
},
|
||||
]);
|
||||
Ok(Json(json!({ "plans": plans })))
|
||||
}
|
||||
|
||||
/// GET /api/organizations/:org_id
|
||||
async fn get_organization(
|
||||
State(state): State<AppState>,
|
||||
headers: HeaderMap,
|
||||
Path(org_id): Path<i64>,
|
||||
) -> AppResult<Json<Value>> {
|
||||
let (user_id, _) = extract_jwt_user(&headers, &state)?;
|
||||
|
||||
if !is_org_member(&state.db, org_id, user_id).await? {
|
||||
return Err(AppError::Forbidden(
|
||||
"You are not a member of this organization".into(),
|
||||
));
|
||||
}
|
||||
|
||||
let org: Option<Organization> = sqlx::query_as("SELECT * FROM organizations WHERE id = ?")
|
||||
.bind(org_id)
|
||||
.fetch_optional(&*state.db)
|
||||
.await?;
|
||||
|
||||
match org {
|
||||
Some(o) => Ok(Json(json!({ "organization": o }))),
|
||||
None => Err(AppError::NotFound("Organization not found".into())),
|
||||
}
|
||||
}
|
||||
|
||||
/// PATCH /api/organizations/:org_id
|
||||
async fn update_organization(
|
||||
State(state): State<AppState>,
|
||||
headers: HeaderMap,
|
||||
Path(org_id): Path<i64>,
|
||||
Json(body): Json<serde_json::Value>,
|
||||
) -> AppResult<Json<Value>> {
|
||||
let (user_id, _) = extract_jwt_user(&headers, &state)?;
|
||||
require_org_admin(&state.db, org_id, user_id).await?;
|
||||
|
||||
let name = body.get("name").and_then(|v| v.as_str());
|
||||
let slug = body.get("slug").and_then(|v| v.as_str());
|
||||
|
||||
if name.is_none() && slug.is_none() {
|
||||
return Err(AppError::BadRequest("No fields to update".into()));
|
||||
}
|
||||
|
||||
if let Some(s) = slug {
|
||||
sqlx::query("UPDATE organizations SET slug = ?, updated_at = datetime('now') WHERE id = ?")
|
||||
.bind(s)
|
||||
.bind(org_id)
|
||||
.execute(&*state.db)
|
||||
.await?;
|
||||
}
|
||||
if let Some(n) = name {
|
||||
sqlx::query("UPDATE organizations SET name = ?, updated_at = datetime('now') WHERE id = ?")
|
||||
.bind(n)
|
||||
.bind(org_id)
|
||||
.execute(&*state.db)
|
||||
.await?;
|
||||
}
|
||||
|
||||
let org: Organization = sqlx::query_as("SELECT * FROM organizations WHERE id = ?")
|
||||
.bind(org_id)
|
||||
.fetch_one(&*state.db)
|
||||
.await?;
|
||||
|
||||
Ok(Json(json!({ "organization": org })))
|
||||
}
|
||||
|
||||
// ── Member Handlers ────────────────────────────────────────────────────
|
||||
|
||||
/// GET /api/organizations/:org_id/members
|
||||
async fn list_members(
|
||||
State(state): State<AppState>,
|
||||
headers: HeaderMap,
|
||||
Path(org_id): Path<i64>,
|
||||
) -> AppResult<Json<Value>> {
|
||||
let (user_id, _) = extract_jwt_user(&headers, &state)?;
|
||||
|
||||
if !is_org_member(&state.db, org_id, user_id).await? {
|
||||
return Err(AppError::Forbidden(
|
||||
"You are not a member of this organization".into(),
|
||||
));
|
||||
}
|
||||
|
||||
let members: Vec<Value> = sqlx::query_as::<_, OrganizationMember>(
|
||||
"SELECT * FROM organization_members WHERE org_id = ? ORDER BY joined_at ASC",
|
||||
)
|
||||
.bind(org_id)
|
||||
.fetch_all(&*state.db)
|
||||
.await?
|
||||
.into_iter()
|
||||
.map(|m| {
|
||||
json!({
|
||||
"id": m.id,
|
||||
"orgId": m.org_id,
|
||||
"userId": m.user_id,
|
||||
"role": m.role,
|
||||
"status": m.status,
|
||||
"joinedAt": m.joined_at,
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
|
||||
Ok(Json(json!({ "members": members })))
|
||||
}
|
||||
|
||||
/// PATCH /api/organizations/:org_id/members/:user_id
|
||||
async fn update_member_role(
|
||||
State(state): State<AppState>,
|
||||
headers: HeaderMap,
|
||||
Path((org_id, target_user_id)): Path<(i64, i64)>,
|
||||
Json(body): Json<serde_json::Value>,
|
||||
) -> AppResult<Json<Value>> {
|
||||
let (user_id, _) = extract_jwt_user(&headers, &state)?;
|
||||
require_org_admin(&state.db, org_id, user_id).await?;
|
||||
|
||||
let role = body
|
||||
.get("role")
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or_else(|| AppError::BadRequest("role is required".into()))?;
|
||||
|
||||
if !["owner", "admin", "member"].contains(&role) {
|
||||
return Err(AppError::BadRequest(
|
||||
"Role must be one of: owner, admin, member".into(),
|
||||
));
|
||||
}
|
||||
|
||||
sqlx::query("UPDATE organization_members SET role = ? WHERE org_id = ? AND user_id = ?")
|
||||
.bind(role)
|
||||
.bind(org_id)
|
||||
.bind(target_user_id)
|
||||
.execute(&*state.db)
|
||||
.await?;
|
||||
|
||||
Ok(Json(json!({ "success": true })))
|
||||
}
|
||||
|
||||
/// DELETE /api/organizations/:org_id/members/:user_id
|
||||
async fn remove_member(
|
||||
State(state): State<AppState>,
|
||||
headers: HeaderMap,
|
||||
Path((org_id, target_user_id)): Path<(i64, i64)>,
|
||||
) -> AppResult<StatusCode> {
|
||||
let (user_id, _) = extract_jwt_user(&headers, &state)?;
|
||||
require_org_admin(&state.db, org_id, user_id).await?;
|
||||
|
||||
sqlx::query("DELETE FROM organization_members WHERE org_id = ? AND user_id = ?")
|
||||
.bind(org_id)
|
||||
.bind(target_user_id)
|
||||
.execute(&*state.db)
|
||||
.await?;
|
||||
|
||||
Ok(StatusCode::NO_CONTENT)
|
||||
}
|
||||
|
||||
// ── Invitation Handlers ────────────────────────────────────────────────
|
||||
|
||||
/// GET /api/organizations/:org_id/invitations
|
||||
async fn list_invitations(
|
||||
State(state): State<AppState>,
|
||||
headers: HeaderMap,
|
||||
Path(org_id): Path<i64>,
|
||||
) -> AppResult<Json<Value>> {
|
||||
let (user_id, _) = extract_jwt_user(&headers, &state)?;
|
||||
require_org_admin(&state.db, org_id, user_id).await?;
|
||||
|
||||
let invitations: Vec<Invitation> =
|
||||
sqlx::query_as("SELECT * FROM invitations WHERE org_id = ? ORDER BY created_at DESC")
|
||||
.bind(org_id)
|
||||
.fetch_all(&*state.db)
|
||||
.await?;
|
||||
|
||||
Ok(Json(json!({ "invitations": invitations })))
|
||||
}
|
||||
|
||||
/// POST /api/organizations/:org_id/invitations
|
||||
async fn create_invitation(
|
||||
State(state): State<AppState>,
|
||||
headers: HeaderMap,
|
||||
Path(org_id): Path<i64>,
|
||||
Json(body): Json<serde_json::Value>,
|
||||
) -> AppResult<(StatusCode, Json<Value>)> {
|
||||
let (user_id, _) = extract_jwt_user(&headers, &state)?;
|
||||
require_org_admin(&state.db, org_id, user_id).await?;
|
||||
|
||||
let email = body
|
||||
.get("email")
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or_else(|| AppError::BadRequest("Email is required".into()))?;
|
||||
let role = body
|
||||
.get("role")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("member");
|
||||
|
||||
if !["admin", "member"].contains(&role) {
|
||||
return Err(AppError::BadRequest(
|
||||
"Role must be one of: admin, member".into(),
|
||||
));
|
||||
}
|
||||
|
||||
// Generate token
|
||||
let token = format!("inv-{}", uuid::Uuid::new_v4().to_string().replace('-', ""));
|
||||
let expires_at = chrono::Utc::now()
|
||||
.checked_add_signed(chrono::Duration::days(7))
|
||||
.unwrap()
|
||||
.format("%Y-%m-%d %H:%M:%S")
|
||||
.to_string();
|
||||
|
||||
let result = sqlx::query(
|
||||
"INSERT INTO invitations (org_id, email, token, role, invited_by, expires_at) VALUES (?, ?, ?, ?, ?, ?)"
|
||||
)
|
||||
.bind(org_id)
|
||||
.bind(email)
|
||||
.bind(&token)
|
||||
.bind(role)
|
||||
.bind(user_id)
|
||||
.bind(&expires_at)
|
||||
.execute(&*state.db)
|
||||
.await?;
|
||||
|
||||
let inv: Invitation = sqlx::query_as("SELECT * FROM invitations WHERE id = ?")
|
||||
.bind(result.last_insert_rowid())
|
||||
.fetch_one(&*state.db)
|
||||
.await?;
|
||||
|
||||
Ok((StatusCode::CREATED, Json(json!({ "invitation": inv }))))
|
||||
}
|
||||
|
||||
/// DELETE /api/organizations/:org_id/invitations/:inv_id
|
||||
async fn cancel_invitation(
|
||||
State(state): State<AppState>,
|
||||
headers: HeaderMap,
|
||||
Path((org_id, inv_id)): Path<(i64, i64)>,
|
||||
) -> AppResult<StatusCode> {
|
||||
let (user_id, _) = extract_jwt_user(&headers, &state)?;
|
||||
require_org_admin(&state.db, org_id, user_id).await?;
|
||||
|
||||
sqlx::query("UPDATE invitations SET status = 'cancelled' WHERE id = ? AND org_id = ?")
|
||||
.bind(inv_id)
|
||||
.bind(org_id)
|
||||
.execute(&*state.db)
|
||||
.await?;
|
||||
|
||||
Ok(StatusCode::NO_CONTENT)
|
||||
}
|
||||
|
||||
// ── Public Invitation Handlers ─────────────────────────────────────────
|
||||
|
||||
/// GET /api/invitations/:token — public, no auth required
|
||||
async fn get_invitation(
|
||||
State(state): State<AppState>,
|
||||
Path(token): Path<String>,
|
||||
) -> AppResult<Json<Value>> {
|
||||
let invitation: Option<Invitation> =
|
||||
sqlx::query_as("SELECT * FROM invitations WHERE token = ? AND status = 'pending'")
|
||||
.bind(&token)
|
||||
.fetch_optional(&*state.db)
|
||||
.await?;
|
||||
|
||||
match invitation {
|
||||
Some(i) => {
|
||||
// Check expiry
|
||||
let expired = i.expires_at.as_ref().map_or(false, |e| {
|
||||
chrono::NaiveDateTime::parse_from_str(e.trim(), "%Y-%m-%d %H:%M:%S")
|
||||
.map(|dt| dt.and_utc().timestamp() < chrono::Utc::now().timestamp())
|
||||
.unwrap_or(false)
|
||||
});
|
||||
|
||||
if expired {
|
||||
return Err(AppError::NotFound("Invitation not found or expired".into()));
|
||||
}
|
||||
|
||||
// Get org name
|
||||
let org_name: Option<(String,)> =
|
||||
sqlx::query_as("SELECT name FROM organizations WHERE id = ?")
|
||||
.bind(i.org_id)
|
||||
.fetch_optional(&*state.db)
|
||||
.await?;
|
||||
|
||||
Ok(Json(json!({
|
||||
"invitation": {
|
||||
"id": i.id,
|
||||
"orgId": i.org_id,
|
||||
"orgName": org_name.map(|n| n.0).unwrap_or_default(),
|
||||
"email": i.email,
|
||||
"token": i.token,
|
||||
"role": i.role,
|
||||
"invitedByUsername": "",
|
||||
"status": i.status,
|
||||
"expiresAt": i.expires_at,
|
||||
"createdAt": i.created_at,
|
||||
}
|
||||
})))
|
||||
}
|
||||
None => Err(AppError::NotFound("Invitation not found or expired".into())),
|
||||
}
|
||||
}
|
||||
|
||||
/// POST /api/invitations/:token/accept — JWT auth required
|
||||
async fn accept_invitation(
|
||||
State(state): State<AppState>,
|
||||
headers: HeaderMap,
|
||||
Path(token): Path<String>,
|
||||
) -> AppResult<Json<Value>> {
|
||||
let (user_id, _) = extract_jwt_user(&headers, &state)?;
|
||||
|
||||
let invitation: Option<Invitation> =
|
||||
sqlx::query_as("SELECT * FROM invitations WHERE token = ? AND status = 'pending'")
|
||||
.bind(&token)
|
||||
.fetch_optional(&*state.db)
|
||||
.await?;
|
||||
|
||||
let inv = match invitation {
|
||||
Some(i) => i,
|
||||
None => {
|
||||
return Err(AppError::BadRequest(
|
||||
"Invitation not found or expired".into(),
|
||||
))
|
||||
}
|
||||
};
|
||||
|
||||
// Check expiry
|
||||
let expired = inv.expires_at.as_ref().map_or(true, |e| {
|
||||
chrono::NaiveDateTime::parse_from_str(e.trim(), "%Y-%m-%d %H:%M:%S")
|
||||
.map(|dt| dt.and_utc().timestamp() < chrono::Utc::now().timestamp())
|
||||
.unwrap_or(true)
|
||||
});
|
||||
|
||||
if expired {
|
||||
return Err(AppError::BadRequest(
|
||||
"Invitation not found or expired".into(),
|
||||
));
|
||||
}
|
||||
|
||||
// Mark invitation as accepted
|
||||
sqlx::query("UPDATE invitations SET status = 'accepted' WHERE id = ?")
|
||||
.bind(inv.id)
|
||||
.execute(&*state.db)
|
||||
.await?;
|
||||
|
||||
// Add user as member
|
||||
sqlx::query(
|
||||
"INSERT OR IGNORE INTO organization_members (org_id, user_id, role) VALUES (?, ?, ?)",
|
||||
)
|
||||
.bind(inv.org_id)
|
||||
.bind(user_id)
|
||||
.bind(&inv.role)
|
||||
.execute(&*state.db)
|
||||
.await?;
|
||||
|
||||
let org: Option<Organization> = sqlx::query_as("SELECT * FROM organizations WHERE id = ?")
|
||||
.bind(inv.org_id)
|
||||
.fetch_optional(&*state.db)
|
||||
.await?;
|
||||
|
||||
Ok(Json(json!({ "organization": org })))
|
||||
}
|
||||
@@ -0,0 +1,679 @@
|
||||
// Proxy routes — OpenAI-compatible /v1/ endpoints
|
||||
//
|
||||
// These routes handle:
|
||||
// POST /v1/chat/completions — streaming and non-streaming
|
||||
// POST /v1/images/generations — image generation
|
||||
// GET /v1/models — list available models
|
||||
|
||||
use crate::{
|
||||
auth::api_key::{authorize_downstream_token, DownstreamAuthResult},
|
||||
error::{AppError, AppResult},
|
||||
proxy::conductor::{
|
||||
AttemptResult, ExecuteInput, ExecuteResult, ProxyConductor, SelectedChannel,
|
||||
},
|
||||
proxy::executors::{self, UpstreamRoute},
|
||||
proxy::token_router::TokenRouter,
|
||||
AppState,
|
||||
};
|
||||
use axum::{
|
||||
extract::State,
|
||||
http::HeaderMap,
|
||||
response::{IntoResponse, Response, Sse},
|
||||
routing::{get, post},
|
||||
Json, Router,
|
||||
};
|
||||
use futures::{stream, FutureExt, StreamExt};
|
||||
use serde_json::{json, Value};
|
||||
use std::{convert::Infallible, sync::Arc};
|
||||
use tracing::info;
|
||||
|
||||
// ── Auth types ────────────────────────────────────────────────────────
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct ProxyAuth {
|
||||
pub token: String,
|
||||
pub key_id: Option<i64>,
|
||||
pub source: DownstreamSource,
|
||||
}
|
||||
|
||||
#[derive(Clone, PartialEq)]
|
||||
pub enum DownstreamSource {
|
||||
Managed { key_id: i64 },
|
||||
Global,
|
||||
}
|
||||
|
||||
// ── Route definitions ─────────────────────────────────────────────────
|
||||
|
||||
/// Build the /v1/ proxy router
|
||||
pub fn routes() -> Router<AppState> {
|
||||
Router::new()
|
||||
.route("/v1/chat/completions", post(chat_completions))
|
||||
.route("/v1/completions", post(completions_handler))
|
||||
.route("/v1/embeddings", post(embeddings_handler))
|
||||
.route("/v1/audio/transcriptions", post(audio_transcriptions))
|
||||
.route("/v1/audio/translations", post(audio_translations))
|
||||
.route("/v1/audio/speech", post(audio_speech))
|
||||
.route("/v1/images/generations", post(images_generations))
|
||||
.route("/v1/videos", post(videos_handler))
|
||||
.route("/v1/models", get(list_models))
|
||||
}
|
||||
|
||||
// ── Handlers ───────────────────────────────────────────────────────────
|
||||
|
||||
/// POST /v1/chat/completions
|
||||
async fn chat_completions(
|
||||
State(state): State<AppState>,
|
||||
headers: HeaderMap,
|
||||
Json(body): Json<Value>,
|
||||
) -> Result<Response, AppError> {
|
||||
info!("POST /v1/chat/completions");
|
||||
|
||||
check_auth(&state, &headers).await?;
|
||||
|
||||
let model = body
|
||||
.get("model")
|
||||
.and_then(|m| m.as_str())
|
||||
.ok_or_else(|| AppError::BadRequest("model is required".into()))?
|
||||
.to_string();
|
||||
|
||||
let stream = body
|
||||
.get("stream")
|
||||
.and_then(|s| s.as_bool())
|
||||
.unwrap_or(false);
|
||||
|
||||
if stream {
|
||||
let chunks = execute_chat_completions_stream_with_conductor(state, model, body).await?;
|
||||
let sse =
|
||||
Sse::new(stream::iter(chunks).map(|chunk| {
|
||||
Ok::<_, Infallible>(axum::response::sse::Event::default().data(chunk))
|
||||
}));
|
||||
Ok(sse.into_response())
|
||||
} else {
|
||||
let result = execute_chat_completions_with_conductor(state, model, body).await?;
|
||||
Ok(Json(result).into_response())
|
||||
}
|
||||
}
|
||||
|
||||
/// POST /v1/completions
|
||||
async fn completions_handler(
|
||||
State(state): State<AppState>,
|
||||
headers: HeaderMap,
|
||||
Json(body): Json<Value>,
|
||||
) -> AppResult<Json<Value>> {
|
||||
info!("POST /v1/completions");
|
||||
|
||||
check_auth(&state, &headers).await?;
|
||||
|
||||
let model = body
|
||||
.get("model")
|
||||
.and_then(|m| m.as_str())
|
||||
.ok_or_else(|| AppError::BadRequest("model is required".into()))?
|
||||
.to_string();
|
||||
|
||||
let route = resolve_route(&state, &model).await?;
|
||||
let result = executors::execute_proxy_request(state, route, "/completions", body).await?;
|
||||
Ok(Json(result))
|
||||
}
|
||||
|
||||
/// POST /v1/embeddings
|
||||
async fn embeddings_handler(
|
||||
State(state): State<AppState>,
|
||||
headers: HeaderMap,
|
||||
Json(body): Json<Value>,
|
||||
) -> AppResult<Json<Value>> {
|
||||
info!("POST /v1/embeddings");
|
||||
|
||||
check_auth(&state, &headers).await?;
|
||||
|
||||
let model = body
|
||||
.get("model")
|
||||
.and_then(|m| m.as_str())
|
||||
.ok_or_else(|| AppError::BadRequest("model is required".into()))?
|
||||
.to_string();
|
||||
|
||||
let route = resolve_route(&state, &model).await?;
|
||||
let result = executors::execute_proxy_request(state, route, "/embeddings", body).await?;
|
||||
Ok(Json(result))
|
||||
}
|
||||
|
||||
/// POST /v1/audio/speech
|
||||
async fn audio_speech(
|
||||
State(state): State<AppState>,
|
||||
headers: HeaderMap,
|
||||
Json(body): Json<Value>,
|
||||
) -> AppResult<Json<Value>> {
|
||||
info!("POST /v1/audio/speech");
|
||||
|
||||
check_auth(&state, &headers).await?;
|
||||
|
||||
let model = body
|
||||
.get("model")
|
||||
.and_then(|m| m.as_str())
|
||||
.ok_or_else(|| AppError::BadRequest("model is required".into()))?
|
||||
.to_string();
|
||||
|
||||
let route = resolve_route(&state, &model).await?;
|
||||
let result = executors::execute_proxy_request(state, route, "/audio/speech", body).await?;
|
||||
Ok(Json(result))
|
||||
}
|
||||
|
||||
/// POST /v1/audio/transcriptions — proxy to upstream
|
||||
async fn audio_transcriptions(
|
||||
State(state): State<AppState>,
|
||||
headers: HeaderMap,
|
||||
Json(body): Json<Value>,
|
||||
) -> AppResult<Json<Value>> {
|
||||
info!("POST /v1/audio/transcriptions");
|
||||
|
||||
check_auth(&state, &headers).await?;
|
||||
|
||||
let model = body
|
||||
.get("model")
|
||||
.and_then(|m| m.as_str())
|
||||
.ok_or_else(|| AppError::BadRequest("model is required".into()))?
|
||||
.to_string();
|
||||
|
||||
let route = resolve_route(&state, &model).await?;
|
||||
let result =
|
||||
executors::execute_proxy_request(state, route, "/audio/transcriptions", body).await?;
|
||||
Ok(Json(result))
|
||||
}
|
||||
|
||||
/// POST /v1/audio/translations — proxy to upstream
|
||||
async fn audio_translations(
|
||||
State(state): State<AppState>,
|
||||
headers: HeaderMap,
|
||||
Json(body): Json<Value>,
|
||||
) -> AppResult<Json<Value>> {
|
||||
info!("POST /v1/audio/translations");
|
||||
|
||||
check_auth(&state, &headers).await?;
|
||||
|
||||
let model = body
|
||||
.get("model")
|
||||
.and_then(|m| m.as_str())
|
||||
.ok_or_else(|| AppError::BadRequest("model is required".into()))?
|
||||
.to_string();
|
||||
|
||||
let route = resolve_route(&state, &model).await?;
|
||||
let result =
|
||||
executors::execute_proxy_request(state, route, "/audio/translations", body).await?;
|
||||
Ok(Json(result))
|
||||
}
|
||||
|
||||
/// POST /v1/videos — list/create video generation tasks
|
||||
async fn videos_handler(
|
||||
State(state): State<AppState>,
|
||||
headers: HeaderMap,
|
||||
Json(body): Json<Value>,
|
||||
) -> AppResult<Json<Value>> {
|
||||
info!("POST /v1/videos");
|
||||
|
||||
check_auth(&state, &headers).await?;
|
||||
|
||||
let model = body
|
||||
.get("model")
|
||||
.and_then(|m| m.as_str())
|
||||
.unwrap_or("default");
|
||||
|
||||
let route = resolve_route(&state, model).await?;
|
||||
let result = executors::execute_proxy_request(state, route, "/videos", body).await?;
|
||||
Ok(Json(result))
|
||||
}
|
||||
|
||||
/// POST /v1/images/generations
|
||||
async fn images_generations(
|
||||
State(state): State<AppState>,
|
||||
headers: HeaderMap,
|
||||
Json(body): Json<Value>,
|
||||
) -> AppResult<Json<Value>> {
|
||||
info!("POST /v1/images/generations");
|
||||
|
||||
check_auth(&state, &headers).await?;
|
||||
|
||||
let model = body
|
||||
.get("model")
|
||||
.and_then(|m| m.as_str())
|
||||
.ok_or_else(|| AppError::BadRequest("model is required".into()))?
|
||||
.to_string();
|
||||
|
||||
let route = resolve_route(&state, &model).await?;
|
||||
let result = executors::execute_image_generation(state, route, body).await?;
|
||||
Ok(Json(result))
|
||||
}
|
||||
|
||||
/// GET /v1/models
|
||||
async fn list_models(State(state): State<AppState>, headers: HeaderMap) -> AppResult<Json<Value>> {
|
||||
info!("GET /v1/models");
|
||||
|
||||
check_auth(&state, &headers).await?;
|
||||
|
||||
let rows: Vec<(String, String)> = sqlx::query_as(
|
||||
"SELECT tr.model_pattern, COALESCE(tr.model_mapping, tr.model_pattern) FROM token_routes tr WHERE tr.enabled = 1 ORDER BY tr.id",
|
||||
)
|
||||
.fetch_all(&*state.db)
|
||||
.await?;
|
||||
|
||||
let models: Vec<Value> = rows
|
||||
.into_iter()
|
||||
.map(|(name, model)| {
|
||||
json!({
|
||||
"id": model,
|
||||
"object": "model",
|
||||
"created": 0,
|
||||
"owned_by": name,
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
|
||||
Ok(Json(json!({
|
||||
"object": "list",
|
||||
"data": models,
|
||||
})))
|
||||
}
|
||||
|
||||
// ── Auth helper ───────────────────────────────────────────────────────
|
||||
|
||||
async fn check_auth(state: &AppState, headers: &HeaderMap) -> Result<(), AppError> {
|
||||
let auth = headers
|
||||
.get("Authorization")
|
||||
.and_then(|v| v.to_str().ok())
|
||||
.and_then(|v| v.strip_prefix("Bearer "))
|
||||
.and_then(|v| v.trim().split_whitespace().next())
|
||||
.ok_or_else(|| AppError::Unauthorized("Missing Authorization header".into()))?;
|
||||
|
||||
let result = authorize_downstream_token(auth, &state.db)
|
||||
.await
|
||||
.map_err(|e| AppError::Unauthorized(e))?;
|
||||
|
||||
match result {
|
||||
DownstreamAuthResult::Managed { key, .. } => {
|
||||
if !key.enabled.unwrap_or(true) {
|
||||
return Err(AppError::Forbidden("API key is disabled".into()));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
DownstreamAuthResult::Global { .. } => Ok(()),
|
||||
}
|
||||
}
|
||||
|
||||
// ── Route Resolution ──────────────────────────────────────────────────
|
||||
|
||||
/// Resolve upstream endpoint + API key for a given model.
|
||||
async fn resolve_route(state: &AppState, model: &str) -> AppResult<UpstreamRoute> {
|
||||
let selected = TokenRouter::new(state.db.clone())
|
||||
.resolve_selected(model)
|
||||
.await?;
|
||||
Ok(selected_channel_to_route(selected))
|
||||
}
|
||||
|
||||
fn selected_channel_to_route(selected: SelectedChannel) -> UpstreamRoute {
|
||||
UpstreamRoute {
|
||||
base_url: selected.base_url,
|
||||
api_key: selected.api_key,
|
||||
model: selected.actual_model,
|
||||
}
|
||||
}
|
||||
|
||||
fn body_with_selected_model(mut body: Value, selected: &SelectedChannel) -> Value {
|
||||
if let Some(object) = body.as_object_mut() {
|
||||
object.insert(
|
||||
"model".to_string(),
|
||||
Value::String(selected.actual_model.clone()),
|
||||
);
|
||||
}
|
||||
body
|
||||
}
|
||||
|
||||
fn build_conductor(state: &AppState) -> ProxyConductor {
|
||||
let db = state.db.clone();
|
||||
let select_db = db.clone();
|
||||
let select_next_db = db.clone();
|
||||
let success_db = db.clone();
|
||||
let failure_db = db;
|
||||
|
||||
ProxyConductor {
|
||||
select_channel: Arc::new(move |model, _policy| {
|
||||
let router = TokenRouter::new(select_db.clone());
|
||||
async move { router.resolve_selected(&model).await.ok() }.boxed()
|
||||
}),
|
||||
select_next_channel: Arc::new(move |model, excluded, _policy| {
|
||||
let router = TokenRouter::new(select_next_db.clone());
|
||||
async move { router.resolve_next_selected(&model, &excluded).await.ok() }.boxed()
|
||||
}),
|
||||
record_success: Arc::new(move |channel_id, metrics| {
|
||||
let router = TokenRouter::new(success_db.clone());
|
||||
async move {
|
||||
if let Err(err) = router.record_success(channel_id, metrics).await {
|
||||
tracing::warn!(%err, channel_id, "failed to record proxy success");
|
||||
}
|
||||
}
|
||||
.boxed()
|
||||
}),
|
||||
record_failure: Arc::new(move |channel_id, info| {
|
||||
let router = TokenRouter::new(failure_db.clone());
|
||||
async move {
|
||||
if let Err(err) = router.record_failure(channel_id, info).await {
|
||||
tracing::warn!(%err, channel_id, "failed to record proxy failure");
|
||||
}
|
||||
}
|
||||
.boxed()
|
||||
}),
|
||||
refresh_auth: None,
|
||||
}
|
||||
}
|
||||
|
||||
async fn execute_chat_completions_with_conductor(
|
||||
state: AppState,
|
||||
model: String,
|
||||
body: Value,
|
||||
) -> AppResult<Value> {
|
||||
let conductor = build_conductor(&state);
|
||||
let result = conductor
|
||||
.execute(ExecuteInput {
|
||||
requested_model: model,
|
||||
downstream_policy: None,
|
||||
attempt_fn: Box::new(move |ctx| {
|
||||
let state = state.clone();
|
||||
let body = body_with_selected_model(body.clone(), &ctx.selected);
|
||||
async move {
|
||||
match executors::execute_chat_completions(
|
||||
state,
|
||||
selected_channel_to_route(ctx.selected),
|
||||
body,
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(response) => AttemptResult::Success {
|
||||
response,
|
||||
latency_ms: None,
|
||||
cost: None,
|
||||
},
|
||||
Err(err) => AttemptResult::Failure {
|
||||
action: crate::proxy::conductor::AttemptFailureAction::Failover,
|
||||
status: None,
|
||||
raw_error_text: Some(err.to_string()),
|
||||
},
|
||||
}
|
||||
}
|
||||
.boxed()
|
||||
}),
|
||||
on_terminal_failure: None,
|
||||
})
|
||||
.await;
|
||||
|
||||
conductor_result_to_app_result(result)
|
||||
}
|
||||
|
||||
async fn execute_chat_completions_stream_with_conductor(
|
||||
state: AppState,
|
||||
model: String,
|
||||
body: Value,
|
||||
) -> AppResult<Vec<String>> {
|
||||
let conductor = build_conductor(&state);
|
||||
let result = conductor
|
||||
.execute(ExecuteInput {
|
||||
requested_model: model,
|
||||
downstream_policy: None,
|
||||
attempt_fn: Box::new(move |ctx| {
|
||||
let state = state.clone();
|
||||
let body = body_with_selected_model(body.clone(), &ctx.selected);
|
||||
async move {
|
||||
match executors::execute_chat_completions_stream(
|
||||
state,
|
||||
selected_channel_to_route(ctx.selected),
|
||||
body,
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(chunks) => AttemptResult::Success {
|
||||
response: Value::Array(chunks.into_iter().map(Value::String).collect()),
|
||||
latency_ms: None,
|
||||
cost: None,
|
||||
},
|
||||
Err(err) => AttemptResult::Failure {
|
||||
action: crate::proxy::conductor::AttemptFailureAction::Failover,
|
||||
status: None,
|
||||
raw_error_text: Some(err.to_string()),
|
||||
},
|
||||
}
|
||||
}
|
||||
.boxed()
|
||||
}),
|
||||
on_terminal_failure: None,
|
||||
})
|
||||
.await;
|
||||
|
||||
match conductor_result_to_app_result(result)? {
|
||||
Value::Array(chunks) => chunks
|
||||
.into_iter()
|
||||
.map(|chunk| {
|
||||
chunk.as_str().map(str::to_string).ok_or_else(|| {
|
||||
AppError::UpstreamError("Invalid streaming response from conductor".into())
|
||||
})
|
||||
})
|
||||
.collect(),
|
||||
_ => Err(AppError::UpstreamError(
|
||||
"Invalid streaming response from conductor".into(),
|
||||
)),
|
||||
}
|
||||
}
|
||||
|
||||
fn conductor_result_to_app_result(result: ExecuteResult) -> AppResult<Value> {
|
||||
match result {
|
||||
ExecuteResult::Success { response, .. } => Ok(response),
|
||||
ExecuteResult::Failure {
|
||||
reason,
|
||||
status,
|
||||
raw_error_text,
|
||||
attempts,
|
||||
..
|
||||
} => Err(AppError::UpstreamError(format!(
|
||||
"Proxy conductor failed after {attempts} attempts ({reason:?}, status: {:?}): {}",
|
||||
status,
|
||||
raw_error_text.unwrap_or_else(|| "no upstream error text".to_string())
|
||||
))),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::config::{AppConfig, AuthConfig, DbConfig, ProxyConfig, ServerConfig};
|
||||
use axum::http::HeaderValue;
|
||||
use serde_json::json;
|
||||
use sqlx::sqlite::SqlitePoolOptions;
|
||||
|
||||
async fn test_state(upstream_base_url: String) -> AppState {
|
||||
let pool = SqlitePoolOptions::new()
|
||||
.max_connections(1)
|
||||
.connect("sqlite::memory:")
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
sqlx::query(
|
||||
"CREATE TABLE downstream_api_keys (
|
||||
id INTEGER PRIMARY KEY,
|
||||
name TEXT NOT NULL,
|
||||
key TEXT NOT NULL,
|
||||
description TEXT,
|
||||
group_name TEXT,
|
||||
tags TEXT,
|
||||
enabled INTEGER DEFAULT 1,
|
||||
expires_at TEXT,
|
||||
max_cost REAL,
|
||||
used_cost REAL DEFAULT 0,
|
||||
max_requests INTEGER,
|
||||
used_requests INTEGER DEFAULT 0,
|
||||
supported_models TEXT,
|
||||
allowed_route_ids TEXT,
|
||||
site_weight_multipliers TEXT,
|
||||
excluded_site_ids TEXT,
|
||||
excluded_credential_refs TEXT,
|
||||
last_used_at TEXT,
|
||||
user_id INTEGER,
|
||||
created_at TEXT DEFAULT (datetime('now')),
|
||||
updated_at TEXT DEFAULT (datetime('now'))
|
||||
)",
|
||||
)
|
||||
.execute(&pool)
|
||||
.await
|
||||
.unwrap();
|
||||
sqlx::query(
|
||||
"CREATE TABLE sites (
|
||||
id INTEGER PRIMARY KEY,
|
||||
name TEXT NOT NULL,
|
||||
url TEXT NOT NULL,
|
||||
platform TEXT NOT NULL DEFAULT 'openai',
|
||||
status TEXT NOT NULL DEFAULT 'active',
|
||||
api_key TEXT
|
||||
)",
|
||||
)
|
||||
.execute(&pool)
|
||||
.await
|
||||
.unwrap();
|
||||
sqlx::query(
|
||||
"CREATE TABLE token_routes (
|
||||
id INTEGER PRIMARY KEY,
|
||||
model_pattern TEXT NOT NULL,
|
||||
model_mapping TEXT,
|
||||
route_mode TEXT,
|
||||
enabled INTEGER DEFAULT 1
|
||||
)",
|
||||
)
|
||||
.execute(&pool)
|
||||
.await
|
||||
.unwrap();
|
||||
sqlx::query(
|
||||
"CREATE TABLE accounts (
|
||||
id INTEGER PRIMARY KEY,
|
||||
site_id INTEGER NOT NULL,
|
||||
access_token TEXT NOT NULL,
|
||||
api_token TEXT,
|
||||
status TEXT NOT NULL DEFAULT 'active'
|
||||
)",
|
||||
)
|
||||
.execute(&pool)
|
||||
.await
|
||||
.unwrap();
|
||||
sqlx::query(
|
||||
"CREATE TABLE account_tokens (
|
||||
id INTEGER PRIMARY KEY,
|
||||
account_id INTEGER NOT NULL,
|
||||
name TEXT NOT NULL,
|
||||
token TEXT NOT NULL,
|
||||
enabled INTEGER DEFAULT 1
|
||||
)",
|
||||
)
|
||||
.execute(&pool)
|
||||
.await
|
||||
.unwrap();
|
||||
sqlx::query(
|
||||
"CREATE TABLE route_channels (
|
||||
id INTEGER PRIMARY KEY,
|
||||
route_id INTEGER NOT NULL,
|
||||
account_id INTEGER NOT NULL,
|
||||
token_id INTEGER,
|
||||
source_model TEXT,
|
||||
priority INTEGER DEFAULT 0,
|
||||
weight INTEGER DEFAULT 10,
|
||||
enabled INTEGER DEFAULT 1,
|
||||
consecutive_fail_count INTEGER NOT NULL DEFAULT 0,
|
||||
cooldown_level INTEGER NOT NULL DEFAULT 0,
|
||||
cooldown_until TEXT,
|
||||
last_selected_at TEXT,
|
||||
success_count INTEGER DEFAULT 0,
|
||||
fail_count INTEGER DEFAULT 0,
|
||||
total_latency_ms INTEGER DEFAULT 0,
|
||||
total_cost REAL DEFAULT 0,
|
||||
last_used_at TEXT,
|
||||
last_fail_at TEXT
|
||||
)",
|
||||
)
|
||||
.execute(&pool)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
sqlx::query("INSERT INTO downstream_api_keys (id, name, key, enabled) VALUES (1, 'test', 'sk-test', 1)")
|
||||
.execute(&pool)
|
||||
.await
|
||||
.unwrap();
|
||||
sqlx::query(
|
||||
"INSERT INTO sites (id, name, url, api_key) VALUES (1, 'OpenAI', ?, 'site-key')",
|
||||
)
|
||||
.bind(upstream_base_url)
|
||||
.execute(&pool)
|
||||
.await
|
||||
.unwrap();
|
||||
sqlx::query("INSERT INTO accounts (id, site_id, access_token, api_token) VALUES (1, 1, 'access-key', NULL)")
|
||||
.execute(&pool)
|
||||
.await
|
||||
.unwrap();
|
||||
sqlx::query("INSERT INTO token_routes (id, model_pattern, model_mapping, route_mode, enabled) VALUES (1, 'gpt-test', 'upstream-test', 'exact', 1)")
|
||||
.execute(&pool)
|
||||
.await
|
||||
.unwrap();
|
||||
sqlx::query("INSERT INTO route_channels (id, route_id, account_id) VALUES (1, 1, 1)")
|
||||
.execute(&pool)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
AppState::new(
|
||||
AppConfig {
|
||||
server: ServerConfig {
|
||||
host: "127.0.0.1".into(),
|
||||
port: 0,
|
||||
log_level: "info".into(),
|
||||
cors_origins: vec![],
|
||||
},
|
||||
db: DbConfig {
|
||||
url: "sqlite::memory:".into(),
|
||||
max_connections: 1,
|
||||
ssl: false,
|
||||
runtime_db_url: None,
|
||||
runtime_db_dialect: None,
|
||||
runtime_db_ssl: None,
|
||||
},
|
||||
auth: AuthConfig {
|
||||
jwt_secret: "secret".into(),
|
||||
jwt_expiry_hours: 1,
|
||||
auth_token: "auth".into(),
|
||||
proxy_token: "proxy".into(),
|
||||
},
|
||||
proxy: ProxyConfig {
|
||||
upstream_timeout_secs: 30,
|
||||
first_byte_timeout_secs: 30,
|
||||
max_retries: 3,
|
||||
proxy_log_retention_days: 7,
|
||||
},
|
||||
},
|
||||
pool,
|
||||
)
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn chat_route_uses_token_router_and_conductor() {
|
||||
let state = test_state("http://127.0.0.1:1".to_string()).await;
|
||||
let db = state.db.clone();
|
||||
let mut headers = HeaderMap::new();
|
||||
headers.insert("Authorization", HeaderValue::from_static("Bearer sk-test"));
|
||||
|
||||
let result = chat_completions(
|
||||
State(state),
|
||||
headers,
|
||||
Json(json!({
|
||||
"model": "gpt-test",
|
||||
"messages": [{"role": "user", "content": "hello"}]
|
||||
})),
|
||||
)
|
||||
.await;
|
||||
|
||||
assert!(matches!(result, Err(AppError::UpstreamError(_))));
|
||||
let fail_count: i64 =
|
||||
sqlx::query_scalar("SELECT fail_count FROM route_channels WHERE id = 1")
|
||||
.fetch_one(&*db)
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(fail_count > 0);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,214 @@
|
||||
// Search routes — cross-entity search across sites, accounts, tokens, etc.
|
||||
|
||||
use crate::{error::AppResult, AppState};
|
||||
use axum::{extract::State, routing::post, Json, Router};
|
||||
use serde::Deserialize;
|
||||
use serde_json::{json, Value};
|
||||
use std::collections::{HashMap, HashSet};
|
||||
use tracing::info;
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct SearchBody {
|
||||
query: String,
|
||||
limit: Option<i64>,
|
||||
}
|
||||
|
||||
pub fn routes() -> Router<AppState> {
|
||||
Router::new().route("/api/search", post(search_handler))
|
||||
}
|
||||
|
||||
/// POST /api/search — search across all entities
|
||||
async fn search_handler(
|
||||
State(state): State<AppState>,
|
||||
Json(body): Json<SearchBody>,
|
||||
) -> AppResult<Json<Value>> {
|
||||
let query = body.query.trim().to_string();
|
||||
let limit = body.limit.unwrap_or(20).max(1).min(100);
|
||||
|
||||
if query.is_empty() {
|
||||
return Ok(Json(json!({
|
||||
"accounts": [], "accountTokens": [], "sites": [],
|
||||
"checkinLogs": [], "proxyLogs": [], "models": [],
|
||||
})));
|
||||
}
|
||||
|
||||
let q = format!("%{}%", query);
|
||||
let per_category = (limit / 6).max(1).min(10);
|
||||
|
||||
// 1. Search sites
|
||||
let sites: Vec<Value> = sqlx::query_as::<_, (i64, String, String, String)>(
|
||||
"SELECT id, name, url, platform FROM sites WHERE name LIKE ? OR url LIKE ? OR platform LIKE ? LIMIT ?"
|
||||
)
|
||||
.bind(&q).bind(&q).bind(&q).bind(per_category)
|
||||
.fetch_all(&*state.db)
|
||||
.await?
|
||||
.into_iter()
|
||||
.map(|(id, name, url, platform)| json!({"id": id, "name": name, "url": url, "platform": platform}))
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
// Deduplicate by id
|
||||
let mut seen_sites = HashSet::new();
|
||||
let unique_sites: Vec<Value> = sites
|
||||
.into_iter()
|
||||
.filter(|s| {
|
||||
let id = s["id"].as_i64().unwrap_or(0);
|
||||
seen_sites.insert(id)
|
||||
})
|
||||
.collect();
|
||||
|
||||
// 2. Search accounts (with site join)
|
||||
let accounts: Vec<Value> = sqlx::query_as::<_, (i64, String, String, String, i64, String, String, Option<String>)>(
|
||||
r#"SELECT a.id, a.username, a.status, a.extra_config, s.id, s.name, s.platform, a.access_token
|
||||
FROM accounts a
|
||||
INNER JOIN sites s ON s.id = a.site_id
|
||||
WHERE a.username LIKE ? OR s.name LIKE ? OR s.platform LIKE ?
|
||||
LIMIT ?"#
|
||||
)
|
||||
.bind(&q).bind(&q).bind(&q).bind(per_category)
|
||||
.fetch_all(&*state.db)
|
||||
.await?
|
||||
.into_iter()
|
||||
.map(|(id, username, status, extra_config, site_id, site_name, platform, access_token)| {
|
||||
let segment = if access_token.as_deref().unwrap_or("").trim().is_empty() { "apikey" } else { "session" };
|
||||
json!({
|
||||
"id": id, "username": username, "status": status,
|
||||
"segment": segment,
|
||||
"site": {"id": site_id, "name": site_name, "platform": platform},
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
|
||||
// 3. Search account tokens (with account + site join)
|
||||
let account_tokens: Vec<Value> = sqlx::query_as::<_, (i64, String, i64, String, i64, String, String)>(
|
||||
r#"SELECT t.id, t.name, a.id, a.username, s.id, s.name, s.platform
|
||||
FROM account_tokens t
|
||||
INNER JOIN accounts a ON a.id = t.account_id
|
||||
INNER JOIN sites s ON s.id = a.site_id
|
||||
WHERE t.name LIKE ? OR t.token_group LIKE ? OR a.username LIKE ? OR s.name LIKE ? OR s.platform LIKE ?
|
||||
ORDER BY t.updated_at DESC
|
||||
LIMIT ?"#
|
||||
)
|
||||
.bind(&q).bind(&q).bind(&q).bind(&q).bind(&q).bind(per_category)
|
||||
.fetch_all(&*state.db)
|
||||
.await?
|
||||
.into_iter()
|
||||
.map(|(id, name, acct_id, username, site_id, site_name, platform)| {
|
||||
json!({
|
||||
"id": id, "name": name,
|
||||
"account": {"id": acct_id, "username": username},
|
||||
"site": {"id": site_id, "name": site_name, "platform": platform},
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
|
||||
// 4. Search checkin logs
|
||||
let checkin_logs: Vec<Value> = sqlx::query_as::<_, (i64, String, i64, String)>(
|
||||
r#"SELECT c.id, c.message, a.id, a.username
|
||||
FROM checkin_logs c
|
||||
INNER JOIN accounts a ON a.id = c.account_id
|
||||
WHERE c.message LIKE ?
|
||||
ORDER BY c.created_at DESC
|
||||
LIMIT ?"#
|
||||
)
|
||||
.bind(&q).bind(per_category)
|
||||
.fetch_all(&*state.db)
|
||||
.await?
|
||||
.into_iter()
|
||||
.map(|(id, message, acct_id, username)| {
|
||||
json!({"id": id, "message": message, "account": {"id": acct_id, "username": username}})
|
||||
})
|
||||
.collect();
|
||||
|
||||
// 5. Search proxy logs (by model)
|
||||
let proxy_logs: Vec<Value> = sqlx::query_as::<_, (i64, String, String, i64, String, i64, String)>(
|
||||
r#"SELECT p.id, p.model_requested, p.status, p.http_status, p.created_at,
|
||||
COALESCE(a.id, 0), COALESCE(a.username, '')
|
||||
FROM proxy_logs p
|
||||
LEFT JOIN accounts a ON a.id = p.account_id
|
||||
WHERE p.model_requested LIKE ?
|
||||
ORDER BY p.created_at DESC
|
||||
LIMIT ?"#
|
||||
)
|
||||
.bind(&q).bind(per_category)
|
||||
.fetch_all(&*state.db)
|
||||
.await?
|
||||
.into_iter()
|
||||
.map(|(id, model, status, http_status, created_at, acct_id, username)| {
|
||||
json!({"id": id, "modelRequested": model, "status": status, "httpStatus": http_status, "createdAt": created_at})
|
||||
})
|
||||
.collect();
|
||||
|
||||
// 6. Search models (from model_availability + token_model_availability)
|
||||
let token_model_rows: Vec<(String, i64, i64, i64)> = sqlx::query_as(
|
||||
r#"SELECT DISTINCT tma.model_name, t.id, a.id, s.id
|
||||
FROM token_model_availability tma
|
||||
INNER JOIN account_tokens t ON t.id = tma.token_id AND t.enabled = 1
|
||||
INNER JOIN accounts a ON a.id = t.account_id AND a.status = 'active'
|
||||
INNER JOIN sites s ON s.id = a.site_id
|
||||
WHERE tma.model_name LIKE ? AND tma.available = 1 AND t.value_status = 'ready'
|
||||
LIMIT ?"#,
|
||||
)
|
||||
.bind(&q)
|
||||
.bind(per_category * 10)
|
||||
.fetch_all(&*state.db)
|
||||
.await?;
|
||||
|
||||
let direct_model_rows: Vec<(String, i64, i64)> = sqlx::query_as(
|
||||
r#"SELECT DISTINCT ma.model_name, a.id, s.id
|
||||
FROM model_availability ma
|
||||
INNER JOIN accounts a ON a.id = ma.account_id AND a.status = 'active'
|
||||
INNER JOIN sites s ON s.id = a.site_id AND s.status = 'active'
|
||||
WHERE ma.model_name LIKE ? AND ma.available = 1
|
||||
LIMIT ?"#,
|
||||
)
|
||||
.bind(&q)
|
||||
.bind(per_category * 10)
|
||||
.fetch_all(&*state.db)
|
||||
.await?;
|
||||
|
||||
// Aggregate models
|
||||
let mut model_agg: HashMap<String, (HashSet<i64>, HashSet<i64>, HashSet<i64>)> = HashMap::new();
|
||||
for (model_name, _token_id, account_id, site_id) in token_model_rows {
|
||||
let entry = model_agg.entry(model_name).or_default();
|
||||
entry.0.insert(account_id);
|
||||
entry.1.insert(site_id);
|
||||
}
|
||||
for (model_name, account_id, site_id) in direct_model_rows {
|
||||
let entry = model_agg.entry(model_name).or_default();
|
||||
entry.0.insert(account_id);
|
||||
entry.1.insert(site_id);
|
||||
}
|
||||
|
||||
let mut models: Vec<Value> = model_agg
|
||||
.into_iter()
|
||||
.map(|(name, (accounts_set, sites_set, _tokens_set))| {
|
||||
json!({
|
||||
"name": name,
|
||||
"accountCount": accounts_set.len(),
|
||||
"tokenCount": 0,
|
||||
"siteCount": sites_set.len(),
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
|
||||
models.sort_by(|a, b| {
|
||||
let bc = b["accountCount"].as_i64().unwrap_or(0);
|
||||
let ac = a["accountCount"].as_i64().unwrap_or(0);
|
||||
bc.cmp(&ac).then_with(|| {
|
||||
a["name"]
|
||||
.as_str()
|
||||
.unwrap_or("")
|
||||
.cmp(&b["name"].as_str().unwrap_or(""))
|
||||
})
|
||||
});
|
||||
models.truncate(per_category as usize);
|
||||
|
||||
Ok(Json(json!({
|
||||
"accounts": accounts,
|
||||
"accountTokens": account_tokens,
|
||||
"sites": unique_sites,
|
||||
"checkinLogs": checkin_logs,
|
||||
"proxyLogs": proxy_logs,
|
||||
"models": models,
|
||||
})))
|
||||
}
|
||||
@@ -0,0 +1,180 @@
|
||||
// Site Announcements routes — upstream provider notifications per site
|
||||
|
||||
use crate::{
|
||||
db::models::site_announcement::SiteAnnouncement,
|
||||
error::{AppError, AppResult},
|
||||
AppState,
|
||||
};
|
||||
use axum::{
|
||||
extract::{Path, Query, State},
|
||||
routing::{delete, get, post},
|
||||
Json, Router,
|
||||
};
|
||||
use serde::Deserialize;
|
||||
use serde_json::{json, Value};
|
||||
use tracing::info;
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct ListAnnouncementsQuery {
|
||||
limit: Option<i64>,
|
||||
offset: Option<i64>,
|
||||
site_id: Option<i64>,
|
||||
platform: Option<String>,
|
||||
read: Option<String>, // "true" | "false"
|
||||
status: Option<String>, // "active" | "expired" | "dismissed"
|
||||
}
|
||||
|
||||
pub fn routes() -> Router<AppState> {
|
||||
Router::new()
|
||||
.route(
|
||||
"/api/site-announcements",
|
||||
get(list_announcements).delete(delete_all),
|
||||
)
|
||||
.route("/api/site-announcements/{id}/read", post(mark_read))
|
||||
.route("/api/site-announcements/read-all", post(mark_all_read))
|
||||
}
|
||||
|
||||
/// GET /api/site-announcements — list announcements with optional filters
|
||||
async fn list_announcements(
|
||||
State(state): State<AppState>,
|
||||
Query(q): Query<ListAnnouncementsQuery>,
|
||||
) -> AppResult<Json<Value>> {
|
||||
info!("Listing site announcements");
|
||||
let limit = q.limit.unwrap_or(50).clamp(1, 500);
|
||||
let offset = q.offset.unwrap_or(0).max(0);
|
||||
|
||||
let mut sql = "SELECT * FROM site_announcements WHERE 1=1".to_string();
|
||||
let mut params: Vec<String> = vec![];
|
||||
let mut bind_idx = 0;
|
||||
|
||||
if let Some(site_id) = q.site_id {
|
||||
bind_idx += 1;
|
||||
sql.push_str(&format!(" AND site_id = ?{}", bind_idx));
|
||||
params.push(site_id.to_string());
|
||||
}
|
||||
if let Some(ref platform) = q.platform {
|
||||
if !platform.is_empty() {
|
||||
bind_idx += 1;
|
||||
sql.push_str(&format!(" AND platform = ?{}", bind_idx));
|
||||
params.push(platform.clone());
|
||||
}
|
||||
}
|
||||
if let Some(ref r) = q.read {
|
||||
if r == "true" {
|
||||
sql.push_str(" AND read_at IS NOT NULL");
|
||||
} else if r == "false" {
|
||||
sql.push_str(" AND read_at IS NULL");
|
||||
}
|
||||
}
|
||||
|
||||
// status filter: active = NOT dismissed AND (ends_at IS NULL OR ends_at > now)
|
||||
// We fetch all and apply status filter in Rust for simplicity
|
||||
// (the "expired" vs "active" logic references ends_at which is a datetime comparison)
|
||||
|
||||
sql.push_str(" ORDER BY first_seen_at DESC");
|
||||
|
||||
let mut query = sqlx::query_as::<_, SiteAnnouncement>(&sql);
|
||||
for p in ¶ms {
|
||||
query = query.bind(p);
|
||||
}
|
||||
let announcements: Vec<SiteAnnouncement> = query.fetch_all(&*state.db).await?;
|
||||
|
||||
// Apply status filter in-memory (matches Node.js behaviour)
|
||||
let filtered = if let Some(ref status) = q.status {
|
||||
let now = chrono::Utc::now().timestamp_millis() as i64;
|
||||
let status = status.trim().to_lowercase();
|
||||
announcements
|
||||
.into_iter()
|
||||
.filter(|a| {
|
||||
if status == "dismissed" {
|
||||
a.dismissed_at.is_some()
|
||||
} else if status == "expired" {
|
||||
a.dismissed_at.is_none()
|
||||
&& a.ends_at
|
||||
.as_ref()
|
||||
.map_or(false, |e| parse_dt_millis(e) < now)
|
||||
} else if status == "active" {
|
||||
a.dismissed_at.is_none()
|
||||
&& a.ends_at
|
||||
.as_ref()
|
||||
.map_or(true, |e| parse_dt_millis(e) >= now)
|
||||
} else {
|
||||
true
|
||||
}
|
||||
})
|
||||
.collect()
|
||||
} else {
|
||||
announcements
|
||||
};
|
||||
|
||||
// Apply pagination in-memory
|
||||
let paginated: Vec<Value> = filtered
|
||||
.into_iter()
|
||||
.skip(offset as usize)
|
||||
.take(limit as usize)
|
||||
.map(|a| {
|
||||
json!({
|
||||
"id": a.id,
|
||||
"siteId": a.site_id,
|
||||
"platform": a.platform,
|
||||
"sourceKey": a.source_key,
|
||||
"title": a.title,
|
||||
"content": a.content,
|
||||
"level": a.level,
|
||||
"sourceUrl": a.source_url,
|
||||
"startsAt": a.starts_at,
|
||||
"endsAt": a.ends_at,
|
||||
"upstreamCreatedAt": a.upstream_created_at,
|
||||
"upstreamUpdatedAt": a.upstream_updated_at,
|
||||
"firstSeenAt": a.first_seen_at,
|
||||
"lastSeenAt": a.last_seen_at,
|
||||
"readAt": a.read_at,
|
||||
"dismissedAt": a.dismissed_at,
|
||||
"rawPayload": a.raw_payload,
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
|
||||
Ok(Json(Value::Array(paginated)))
|
||||
}
|
||||
|
||||
/// POST /api/site-announcements/{id}/read
|
||||
async fn mark_read(State(state): State<AppState>, Path(id): Path<i64>) -> AppResult<Json<Value>> {
|
||||
info!("Marking site announcement {} as read", id);
|
||||
sqlx::query("UPDATE site_announcements SET read_at = datetime('now') WHERE id = ?")
|
||||
.bind(id)
|
||||
.execute(&*state.db)
|
||||
.await?;
|
||||
Ok(Json(json!({ "success": true })))
|
||||
}
|
||||
|
||||
/// POST /api/site-announcements/read-all
|
||||
async fn mark_all_read(State(state): State<AppState>) -> AppResult<Json<Value>> {
|
||||
info!("Marking all site announcements as read");
|
||||
sqlx::query("UPDATE site_announcements SET read_at = datetime('now') WHERE read_at IS NULL")
|
||||
.execute(&*state.db)
|
||||
.await?;
|
||||
Ok(Json(json!({ "success": true })))
|
||||
}
|
||||
|
||||
/// DELETE /api/site-announcements
|
||||
async fn delete_all(State(state): State<AppState>) -> AppResult<Json<Value>> {
|
||||
info!("Deleting all site announcements");
|
||||
sqlx::query("DELETE FROM site_announcements")
|
||||
.execute(&*state.db)
|
||||
.await?;
|
||||
Ok(Json(json!({ "success": true })))
|
||||
}
|
||||
|
||||
/// Parse a datetime string like "2026-05-15 10:30:00" or ISO to milliseconds since epoch.
|
||||
fn parse_dt_millis(s: &str) -> i64 {
|
||||
if let Ok(dt) = chrono::NaiveDateTime::parse_from_str(s.trim(), "%Y-%m-%d %H:%M:%S") {
|
||||
dt.and_utc().timestamp_millis()
|
||||
} else if let Ok(dt) = chrono::DateTime::parse_from_rfc3339(s.trim()) {
|
||||
dt.timestamp_millis()
|
||||
} else if let Ok(dt) = chrono::NaiveDateTime::parse_from_str(s.trim(), "%Y-%m-%dT%H:%M:%S") {
|
||||
dt.and_utc().timestamp_millis()
|
||||
} else {
|
||||
0
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,144 @@
|
||||
// Stats routes — usage analytics queries
|
||||
|
||||
use crate::{error::AppResult, AppState};
|
||||
use axum::{
|
||||
extract::{Query, State},
|
||||
routing::get,
|
||||
Json, Router,
|
||||
};
|
||||
use serde::Deserialize;
|
||||
use serde_json::{json, Value};
|
||||
use tracing::info;
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct ModelBySiteQuery {
|
||||
site_id: Option<i64>,
|
||||
days: Option<i64>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct SiteDistributionQuery {
|
||||
days: Option<i64>,
|
||||
refresh: Option<String>,
|
||||
}
|
||||
|
||||
pub fn routes() -> Router<AppState> {
|
||||
Router::new()
|
||||
.route("/api/stats/model-by-site", get(model_by_site))
|
||||
.route("/api/stats/site-distribution", get(site_distribution))
|
||||
}
|
||||
|
||||
/// GET /api/stats/model-by-site?siteId=...&days=...
|
||||
async fn model_by_site(
|
||||
State(state): State<AppState>,
|
||||
Query(q): Query<ModelBySiteQuery>,
|
||||
) -> AppResult<Json<Value>> {
|
||||
info!(
|
||||
"Stats model-by-site: site_id={:?}, days={:?}",
|
||||
q.site_id, q.days
|
||||
);
|
||||
let days = q.days.unwrap_or(7).max(1);
|
||||
|
||||
// Calculate cutoff date
|
||||
let since_day = chrono::Utc::now()
|
||||
.checked_sub_signed(chrono::Duration::days(days - 1))
|
||||
.unwrap()
|
||||
.format("%Y-%m-%d")
|
||||
.to_string();
|
||||
|
||||
let rows = if let Some(site_id) = q.site_id {
|
||||
sqlx::query_as::<_, (String, String, i64, f64, i64)>(
|
||||
"SELECT model, local_day, total_calls, total_spend, total_tokens
|
||||
FROM model_day_usage
|
||||
WHERE site_id = ? AND local_day >= ?
|
||||
ORDER BY local_day DESC",
|
||||
)
|
||||
.bind(site_id)
|
||||
.bind(&since_day)
|
||||
.fetch_all(&*state.db)
|
||||
.await?
|
||||
} else {
|
||||
sqlx::query_as::<_, (String, String, i64, f64, i64)>(
|
||||
"SELECT model, local_day, total_calls, total_spend, total_tokens
|
||||
FROM model_day_usage
|
||||
WHERE local_day >= ?
|
||||
ORDER BY local_day DESC",
|
||||
)
|
||||
.bind(&since_day)
|
||||
.fetch_all(&*state.db)
|
||||
.await?
|
||||
};
|
||||
|
||||
// Aggregate by model
|
||||
let mut model_map: std::collections::HashMap<String, (i64, f64, i64)> =
|
||||
std::collections::HashMap::new();
|
||||
for (model, _day, calls, spend, tokens) in rows {
|
||||
let entry = model_map.entry(model).or_insert((0, 0.0, 0));
|
||||
entry.0 += calls;
|
||||
entry.1 += spend;
|
||||
entry.2 += tokens;
|
||||
}
|
||||
|
||||
let mut models: Vec<Value> = model_map
|
||||
.into_iter()
|
||||
.map(|(model, (calls, spend, tokens))| {
|
||||
json!({
|
||||
"model": model,
|
||||
"calls": calls,
|
||||
"spend": (spend * 1_000_000.0).round() / 1_000_000.0,
|
||||
"tokens": tokens,
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
|
||||
models.sort_by(|a, b| {
|
||||
b["calls"]
|
||||
.as_i64()
|
||||
.unwrap_or(0)
|
||||
.cmp(&a["calls"].as_i64().unwrap_or(0))
|
||||
});
|
||||
|
||||
Ok(Json(json!({ "models": models })))
|
||||
}
|
||||
|
||||
/// GET /api/stats/site-distribution?days=...&refresh=...
|
||||
async fn site_distribution(
|
||||
State(state): State<AppState>,
|
||||
Query(q): Query<SiteDistributionQuery>,
|
||||
) -> AppResult<Json<Value>> {
|
||||
info!("Stats site-distribution: days={:?}", q.days);
|
||||
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();
|
||||
|
||||
// Sum per-site usage
|
||||
let rows: Vec<(i64, String, i64, f64, i64)> = sqlx::query_as(
|
||||
"SELECT s.id, s.name, COALESCE(SUM(m.total_calls), 0), COALESCE(SUM(m.total_spend), 0), COALESCE(SUM(m.total_tokens), 0)
|
||||
FROM sites s
|
||||
LEFT JOIN model_day_usage m ON m.site_id = s.id AND m.local_day >= ?
|
||||
GROUP BY s.id, s.name
|
||||
ORDER BY SUM(m.total_spend) DESC"
|
||||
)
|
||||
.bind(&since_day)
|
||||
.fetch_all(&*state.db)
|
||||
.await?;
|
||||
|
||||
let distribution: Vec<Value> = rows
|
||||
.into_iter()
|
||||
.map(|(id, name, calls, spend, tokens)| {
|
||||
json!({
|
||||
"siteId": id,
|
||||
"siteName": name,
|
||||
"calls": calls,
|
||||
"spend": (spend * 1_000_000.0).round() / 1_000_000.0,
|
||||
"tokens": tokens,
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
|
||||
Ok(Json(json!({ "distribution": distribution })))
|
||||
}
|
||||
@@ -0,0 +1,180 @@
|
||||
// Background tasks routes — list and inspect async operations
|
||||
|
||||
use std::convert::Infallible;
|
||||
|
||||
use crate::{db::models::background_task::BackgroundTask, error::AppResult, AppState};
|
||||
use axum::{
|
||||
extract::{Path, Query, State},
|
||||
response::{
|
||||
sse::{Event, Sse},
|
||||
IntoResponse,
|
||||
},
|
||||
routing::get,
|
||||
Json, Router,
|
||||
};
|
||||
use futures::stream::{self, Stream};
|
||||
use serde::Deserialize;
|
||||
use serde_json::{json, Value};
|
||||
use tokio_stream::StreamExt;
|
||||
use tracing::info;
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct ListTasksQuery {
|
||||
limit: Option<i64>,
|
||||
}
|
||||
|
||||
pub fn routes() -> Router<AppState> {
|
||||
Router::new()
|
||||
.route("/api/tasks", get(list_tasks))
|
||||
.route("/api/tasks/{id}", get(get_task))
|
||||
.route(
|
||||
"/api/update-center/tasks/{id}/stream",
|
||||
get(stream_task_logs),
|
||||
)
|
||||
}
|
||||
|
||||
/// GET /api/tasks — list background tasks
|
||||
async fn list_tasks(
|
||||
State(state): State<AppState>,
|
||||
Query(q): Query<ListTasksQuery>,
|
||||
) -> AppResult<Json<Value>> {
|
||||
let limit = q.limit.unwrap_or(50).max(1).min(500);
|
||||
info!("Listing background tasks, limit={}", limit);
|
||||
|
||||
let tasks: Vec<BackgroundTask> = sqlx::query_as::<_, BackgroundTask>(
|
||||
"SELECT * FROM background_tasks ORDER BY created_at DESC LIMIT ?",
|
||||
)
|
||||
.bind(limit)
|
||||
.fetch_all(&*state.db)
|
||||
.await?;
|
||||
|
||||
Ok(Json(json!({ "tasks": tasks })))
|
||||
}
|
||||
|
||||
/// GET /api/tasks/{id} — get a single background task
|
||||
async fn get_task(State(state): State<AppState>, Path(id): Path<String>) -> AppResult<Json<Value>> {
|
||||
info!("Getting background task: {}", id);
|
||||
|
||||
let task: Option<BackgroundTask> =
|
||||
sqlx::query_as::<_, BackgroundTask>("SELECT * FROM background_tasks WHERE id = ?")
|
||||
.bind(&id)
|
||||
.fetch_optional(&*state.db)
|
||||
.await?;
|
||||
|
||||
match task {
|
||||
Some(t) => Ok(Json(json!({ "success": true, "task": t }))),
|
||||
None => Ok(Json(
|
||||
json!({ "success": false, "message": "task not found" }),
|
||||
)),
|
||||
}
|
||||
}
|
||||
|
||||
/// GET /api/update-center/tasks/{id}/stream — SSE stream of task progress
|
||||
async fn stream_task_logs(
|
||||
State(state): State<AppState>,
|
||||
Path(id): Path<String>,
|
||||
) -> Result<Sse<impl Stream<Item = Result<Event, Infallible>>>, axum::response::Response> {
|
||||
info!("Streaming task logs for: {}", id);
|
||||
|
||||
// Check task exists
|
||||
let task: Option<BackgroundTask> =
|
||||
sqlx::query_as::<_, BackgroundTask>("SELECT * FROM background_tasks WHERE id = ?")
|
||||
.bind(&id)
|
||||
.fetch_optional(&*state.db)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
(
|
||||
axum::http::StatusCode::INTERNAL_SERVER_ERROR,
|
||||
format!("DB error: {e}"),
|
||||
)
|
||||
.into_response()
|
||||
})?;
|
||||
|
||||
let task = match task {
|
||||
Some(t) => t,
|
||||
None => {
|
||||
return Err((
|
||||
axum::http::StatusCode::NOT_FOUND,
|
||||
json!({ "success": false, "message": "task not found" }).to_string(),
|
||||
)
|
||||
.into_response());
|
||||
}
|
||||
};
|
||||
|
||||
// Stream task state with polling
|
||||
let initial_status = task.status.clone();
|
||||
let is_done = initial_status != "pending" && initial_status != "running";
|
||||
|
||||
let state_clone = state.clone();
|
||||
let stream = async_stream::stream! {
|
||||
// Yield current task status
|
||||
yield Ok::<_, Infallible>(Event::default()
|
||||
.event("status")
|
||||
.data(json!({ "status": initial_status }).to_string()));
|
||||
|
||||
yield Ok::<_, Infallible>(Event::default()
|
||||
.event("log")
|
||||
.data(json!({
|
||||
"seq": 1,
|
||||
"message": format!("Task {}: {}", initial_status, task.message.as_deref().unwrap_or("")),
|
||||
"createdAt": task.created_at.as_deref().unwrap_or(""),
|
||||
}).to_string()));
|
||||
|
||||
if is_done {
|
||||
yield Ok::<_, Infallible>(Event::default()
|
||||
.event("done")
|
||||
.data(json!({ "status": initial_status }).to_string()));
|
||||
return;
|
||||
}
|
||||
|
||||
// Poll for updates
|
||||
let poll_id = id.clone();
|
||||
for i in 0..120 { // Poll up to 120 times (2 minutes)
|
||||
tokio::time::sleep(tokio::time::Duration::from_millis(1000)).await;
|
||||
|
||||
let current: Option<BackgroundTask> = sqlx::query_as(
|
||||
"SELECT * FROM background_tasks WHERE id = ?"
|
||||
)
|
||||
.bind(&poll_id)
|
||||
.fetch_optional(&*state_clone.db)
|
||||
.await
|
||||
.ok()
|
||||
.flatten();
|
||||
|
||||
match current {
|
||||
Some(t) => {
|
||||
let status = t.status;
|
||||
let updated_at = t.updated_at.unwrap_or_default();
|
||||
|
||||
yield Ok::<_, Infallible>(Event::default()
|
||||
.event("log")
|
||||
.data(json!({
|
||||
"seq": i + 2,
|
||||
"message": format!("Status: {} — {}", status, t.message.as_deref().unwrap_or("")),
|
||||
"createdAt": updated_at,
|
||||
}).to_string()));
|
||||
|
||||
if status != "pending" && status != "running" {
|
||||
yield Ok::<_, Infallible>(Event::default()
|
||||
.event("done")
|
||||
.data(json!({ "status": status }).to_string()));
|
||||
return;
|
||||
}
|
||||
}
|
||||
None => {
|
||||
yield Ok::<_, Infallible>(Event::default()
|
||||
.event("done")
|
||||
.data(json!({ "status": "unknown", "message": "task removed" }).to_string()));
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Timeout
|
||||
yield Ok::<_, Infallible>(Event::default()
|
||||
.event("done")
|
||||
.data(json!({ "status": "timeout" }).to_string()));
|
||||
};
|
||||
|
||||
Ok(Sse::new(stream))
|
||||
}
|
||||
@@ -0,0 +1,433 @@
|
||||
// Update Center routes — deploy/rollback orchestration via helper API
|
||||
|
||||
use crate::{
|
||||
error::{AppError, AppResult},
|
||||
AppState,
|
||||
};
|
||||
use axum::{
|
||||
extract::State,
|
||||
routing::{get, post, put},
|
||||
Json, Router,
|
||||
};
|
||||
use serde_json::{json, Value};
|
||||
|
||||
const UC_CONFIG_KEY: &str = "update_center_config";
|
||||
|
||||
const TASK_TYPE_DEPLOY: &str = "update-center-deploy";
|
||||
const TASK_TYPE_ROLLBACK: &str = "update-center-rollback";
|
||||
|
||||
const DEDUPE_KEY_DEPLOY: &str = "update-center:deploy";
|
||||
const DEDUPE_KEY_ROLLBACK: &str = "update-center:rollback";
|
||||
|
||||
pub fn routes() -> Router<AppState> {
|
||||
Router::new()
|
||||
.route("/api/update-center/status", get(get_status))
|
||||
.route("/api/update-center/check", post(check_status))
|
||||
.route("/api/update-center/config", put(update_config))
|
||||
.route("/api/update-center/deploy", post(deploy_handler))
|
||||
.route("/api/update-center/rollback", post(rollback_handler))
|
||||
}
|
||||
|
||||
/// GET /api/update-center/status
|
||||
async fn get_status(State(state): State<AppState>) -> AppResult<Json<Value>> {
|
||||
let config = load_uc_config(&state).await?;
|
||||
let helper_token = get_helper_token();
|
||||
|
||||
let last_deploy = load_last_task(&state, TASK_TYPE_DEPLOY).await?;
|
||||
let last_rollback = load_last_task(&state, TASK_TYPE_ROLLBACK).await?;
|
||||
|
||||
Ok(Json(json!({
|
||||
"enabled": config.get("enabled").and_then(|v| v.as_bool()).unwrap_or(false),
|
||||
"helperBaseUrl": config.get("helperBaseUrl"),
|
||||
"namespace": config.get("namespace"),
|
||||
"releaseName": config.get("releaseName"),
|
||||
"helperTokenConfigured": !helper_token.is_empty(),
|
||||
"lastDeploy": last_deploy,
|
||||
"lastRollback": last_rollback,
|
||||
})))
|
||||
}
|
||||
|
||||
/// POST /api/update-center/check — refresh status cache
|
||||
async fn check_status(State(state): State<AppState>) -> AppResult<Json<Value>> {
|
||||
// Same as get_status for now
|
||||
get_status(State(state)).await
|
||||
}
|
||||
|
||||
/// PUT /api/update-center/config
|
||||
async fn update_config(
|
||||
State(state): State<AppState>,
|
||||
Json(body): Json<Value>,
|
||||
) -> AppResult<Json<Value>> {
|
||||
let serialized = serde_json::to_string(&body)
|
||||
.map_err(|e| AppError::BadRequest(format!("Invalid config JSON: {e}")))?;
|
||||
|
||||
sqlx::query("INSERT OR REPLACE INTO settings (key, value) VALUES (?, ?)")
|
||||
.bind(UC_CONFIG_KEY)
|
||||
.bind(&serialized)
|
||||
.execute(&*state.db)
|
||||
.await?;
|
||||
|
||||
Ok(Json(json!({
|
||||
"success": true,
|
||||
"config": body,
|
||||
})))
|
||||
}
|
||||
|
||||
/// POST /api/update-center/deploy
|
||||
async fn deploy_handler(
|
||||
State(state): State<AppState>,
|
||||
Json(body): Json<Value>,
|
||||
) -> AppResult<Json<Value>> {
|
||||
let config = load_uc_config(&state).await?;
|
||||
let helper_token = get_helper_token();
|
||||
|
||||
let helper_base = config
|
||||
.get("helperBaseUrl")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("")
|
||||
.trim();
|
||||
let namespace = config
|
||||
.get("namespace")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("")
|
||||
.trim();
|
||||
let chart_ref = config
|
||||
.get("chartRef")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("")
|
||||
.trim();
|
||||
let image_repo = config
|
||||
.get("imageRepository")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("")
|
||||
.trim();
|
||||
|
||||
if helper_base.is_empty()
|
||||
|| namespace.is_empty()
|
||||
|| chart_ref.is_empty()
|
||||
|| helper_token.is_empty()
|
||||
{
|
||||
return Err(AppError::BadRequest(
|
||||
"Update center not fully configured".into(),
|
||||
));
|
||||
}
|
||||
|
||||
let target_tag = body
|
||||
.get("targetTag")
|
||||
.or_else(|| body.get("targetVersion"))
|
||||
.and_then(|v| v.as_str())
|
||||
.map(str::trim)
|
||||
.unwrap_or("");
|
||||
|
||||
if target_tag.is_empty() {
|
||||
return Err(AppError::BadRequest("targetTag is required".into()));
|
||||
}
|
||||
|
||||
// Extract owned values for the spawn
|
||||
let helper_base_owned = helper_base.trim_end_matches('/').to_string();
|
||||
let namespace_owned = namespace.to_string();
|
||||
let chart_ref_owned = chart_ref.to_string();
|
||||
let image_repo_owned = image_repo.to_string();
|
||||
let release_name = config
|
||||
.get("releaseName")
|
||||
.and_then(|v| v.as_str())
|
||||
.map(str::trim)
|
||||
.filter(|s| !s.is_empty())
|
||||
.map(ToOwned::to_owned);
|
||||
let target_tag_owned = target_tag.to_string();
|
||||
let helper_token_owned = helper_token;
|
||||
let title = format!("部署 {}", target_tag);
|
||||
|
||||
let task_id = uuid::Uuid::new_v4().to_string();
|
||||
|
||||
// Create background task
|
||||
sqlx::query(
|
||||
"INSERT INTO background_tasks (id, task_type, title, status, message, dedupe_key)
|
||||
VALUES (?, ?, ?, 'running', ?, ?)",
|
||||
)
|
||||
.bind(&task_id)
|
||||
.bind(TASK_TYPE_DEPLOY)
|
||||
.bind(&title)
|
||||
.bind(format!(
|
||||
"Starting deploy of {} to {}/{}",
|
||||
target_tag, namespace, chart_ref
|
||||
))
|
||||
.bind(DEDUPE_KEY_DEPLOY)
|
||||
.execute(&*state.db)
|
||||
.await?;
|
||||
|
||||
// Call deploy helper in background (fire-and-forget via tokio)
|
||||
let client = reqwest::Client::new();
|
||||
let deploy_url = format!("{}/api/deploy", helper_base_owned);
|
||||
let task_id_clone = task_id.clone();
|
||||
let db_pool = state.db.clone();
|
||||
|
||||
tokio::spawn(async move {
|
||||
let payload = json!({
|
||||
"namespace": namespace_owned,
|
||||
"releaseName": release_name,
|
||||
"chartRef": chart_ref_owned,
|
||||
"imageRepository": image_repo_owned,
|
||||
"targetTag": target_tag_owned,
|
||||
});
|
||||
|
||||
match client
|
||||
.post(&deploy_url)
|
||||
.header("Authorization", format!("Bearer {}", helper_token_owned))
|
||||
.json(&payload)
|
||||
.send()
|
||||
.await
|
||||
{
|
||||
Ok(resp) => {
|
||||
let status = resp.status();
|
||||
let status_code = status.as_u16();
|
||||
let body_text = resp.text().await.unwrap_or_default();
|
||||
|
||||
if status.is_success() {
|
||||
let _ = sqlx::query(
|
||||
"UPDATE background_tasks
|
||||
SET status = 'succeeded', message = ?, finished_at = datetime('now')
|
||||
WHERE id = ?",
|
||||
)
|
||||
.bind(format!("Deploy completed (HTTP {})", status_code))
|
||||
.bind(&task_id_clone)
|
||||
.execute(&*db_pool)
|
||||
.await;
|
||||
} else {
|
||||
let _ = sqlx::query(
|
||||
"UPDATE background_tasks
|
||||
SET status = 'failed', error = ?, finished_at = datetime('now')
|
||||
WHERE id = ?",
|
||||
)
|
||||
.bind(format!(
|
||||
"Helper returned HTTP {}: {}",
|
||||
status_code, body_text
|
||||
))
|
||||
.bind(&task_id_clone)
|
||||
.execute(&*db_pool)
|
||||
.await;
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
let _ = sqlx::query(
|
||||
"UPDATE background_tasks
|
||||
SET status = 'failed', error = ?, finished_at = datetime('now')
|
||||
WHERE id = ?",
|
||||
)
|
||||
.bind(format!("Helper unreachable: {}", e))
|
||||
.bind(&task_id_clone)
|
||||
.execute(&*db_pool)
|
||||
.await;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
Ok(Json(json!({
|
||||
"success": true,
|
||||
"reused": false,
|
||||
"task": {
|
||||
"id": task_id,
|
||||
"type": TASK_TYPE_DEPLOY,
|
||||
"title": title,
|
||||
"status": "running",
|
||||
},
|
||||
})))
|
||||
}
|
||||
|
||||
/// POST /api/update-center/rollback
|
||||
async fn rollback_handler(
|
||||
State(state): State<AppState>,
|
||||
Json(body): Json<Value>,
|
||||
) -> AppResult<Json<Value>> {
|
||||
let config = load_uc_config(&state).await?;
|
||||
let helper_token = get_helper_token();
|
||||
|
||||
let helper_base = config
|
||||
.get("helperBaseUrl")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("")
|
||||
.trim();
|
||||
let namespace = config
|
||||
.get("namespace")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("")
|
||||
.trim();
|
||||
|
||||
if helper_base.is_empty() || namespace.is_empty() || helper_token.is_empty() {
|
||||
return Err(AppError::BadRequest(
|
||||
"Update center not fully configured".into(),
|
||||
));
|
||||
}
|
||||
|
||||
let target_revision = body
|
||||
.get("targetRevision")
|
||||
.and_then(|v| v.as_str())
|
||||
.map(str::trim)
|
||||
.unwrap_or("");
|
||||
|
||||
if target_revision.is_empty() {
|
||||
return Err(AppError::BadRequest("targetRevision is required".into()));
|
||||
}
|
||||
|
||||
let helper_base_owned = helper_base.trim_end_matches('/').to_string();
|
||||
let namespace_owned = namespace.to_string();
|
||||
let release_name = config
|
||||
.get("releaseName")
|
||||
.and_then(|v| v.as_str())
|
||||
.map(str::trim)
|
||||
.filter(|s| !s.is_empty())
|
||||
.map(ToOwned::to_owned);
|
||||
let target_revision_owned = target_revision.to_string();
|
||||
let helper_token_owned = helper_token;
|
||||
let title = format!("回退 {}", target_revision);
|
||||
|
||||
let task_id = uuid::Uuid::new_v4().to_string();
|
||||
|
||||
sqlx::query(
|
||||
"INSERT INTO background_tasks (id, task_type, title, status, message, dedupe_key)
|
||||
VALUES (?, ?, ?, 'running', ?, ?)",
|
||||
)
|
||||
.bind(&task_id)
|
||||
.bind(TASK_TYPE_ROLLBACK)
|
||||
.bind(&title)
|
||||
.bind(format!(
|
||||
"Starting rollback to {} in {}",
|
||||
target_revision, namespace
|
||||
))
|
||||
.bind(DEDUPE_KEY_ROLLBACK)
|
||||
.execute(&*state.db)
|
||||
.await?;
|
||||
|
||||
let client = reqwest::Client::new();
|
||||
let rollback_url = format!("{}/api/rollback", helper_base_owned);
|
||||
let task_id_clone = task_id.clone();
|
||||
let db_pool = state.db.clone();
|
||||
|
||||
tokio::spawn(async move {
|
||||
let payload = json!({
|
||||
"namespace": namespace_owned,
|
||||
"releaseName": release_name,
|
||||
"targetRevision": target_revision_owned,
|
||||
});
|
||||
|
||||
match client
|
||||
.post(&rollback_url)
|
||||
.header("Authorization", format!("Bearer {}", helper_token_owned))
|
||||
.json(&payload)
|
||||
.send()
|
||||
.await
|
||||
{
|
||||
Ok(resp) => {
|
||||
let status = resp.status();
|
||||
let status_code = status.as_u16();
|
||||
let body_text = resp.text().await.unwrap_or_default();
|
||||
|
||||
if status.is_success() {
|
||||
let _ = sqlx::query(
|
||||
"UPDATE background_tasks
|
||||
SET status = 'succeeded', message = ?, finished_at = datetime('now')
|
||||
WHERE id = ?",
|
||||
)
|
||||
.bind(format!("Rollback completed (HTTP {})", status_code))
|
||||
.bind(&task_id_clone)
|
||||
.execute(&*db_pool)
|
||||
.await;
|
||||
} else {
|
||||
let _ = sqlx::query(
|
||||
"UPDATE background_tasks
|
||||
SET status = 'failed', error = ?, finished_at = datetime('now')
|
||||
WHERE id = ?",
|
||||
)
|
||||
.bind(format!(
|
||||
"Helper returned HTTP {}: {}",
|
||||
status_code, body_text
|
||||
))
|
||||
.bind(&task_id_clone)
|
||||
.execute(&*db_pool)
|
||||
.await;
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
let _ = sqlx::query(
|
||||
"UPDATE background_tasks
|
||||
SET status = 'failed', error = ?, finished_at = datetime('now')
|
||||
WHERE id = ?",
|
||||
)
|
||||
.bind(format!("Helper unreachable: {}", e))
|
||||
.bind(&task_id_clone)
|
||||
.execute(&*db_pool)
|
||||
.await;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
Ok(Json(json!({
|
||||
"success": true,
|
||||
"reused": false,
|
||||
"task": {
|
||||
"id": task_id,
|
||||
"type": TASK_TYPE_ROLLBACK,
|
||||
"title": title,
|
||||
"status": "running",
|
||||
},
|
||||
})))
|
||||
}
|
||||
|
||||
// ── Helpers ────────────────────────────────────────────────────────────
|
||||
|
||||
fn get_helper_token() -> String {
|
||||
std::env::var("UPDATE_CENTER_HELPER_TOKEN")
|
||||
.or_else(|_| std::env::var("DEPLOY_HELPER_TOKEN"))
|
||||
.unwrap_or_default()
|
||||
.trim()
|
||||
.to_string()
|
||||
}
|
||||
|
||||
async fn load_uc_config(state: &AppState) -> AppResult<Value> {
|
||||
let row: Option<(String,)> = sqlx::query_as("SELECT value FROM settings WHERE key = ?")
|
||||
.bind(UC_CONFIG_KEY)
|
||||
.fetch_optional(&*state.db)
|
||||
.await?;
|
||||
|
||||
match row {
|
||||
Some((v,)) => serde_json::from_str(&v)
|
||||
.map_err(|e| AppError::BadRequest(format!("Invalid update center config JSON: {e}"))),
|
||||
None => Ok(json!({})),
|
||||
}
|
||||
}
|
||||
|
||||
async fn load_last_task(state: &AppState, task_type: &str) -> AppResult<Option<Value>> {
|
||||
let row: Option<(
|
||||
String,
|
||||
String,
|
||||
Option<String>,
|
||||
Option<String>,
|
||||
String,
|
||||
Option<String>,
|
||||
Option<String>,
|
||||
)> = sqlx::query_as(
|
||||
"SELECT id, status, message, error, created_at, started_at, finished_at
|
||||
FROM background_tasks
|
||||
WHERE task_type = ?
|
||||
ORDER BY created_at DESC
|
||||
LIMIT 1",
|
||||
)
|
||||
.bind(task_type)
|
||||
.fetch_optional(&*state.db)
|
||||
.await?;
|
||||
|
||||
Ok(row.map(
|
||||
|(id, status, message, error, created_at, started_at, finished_at)| {
|
||||
json!({
|
||||
"id": id,
|
||||
"type": task_type,
|
||||
"status": status,
|
||||
"message": message,
|
||||
"error": error,
|
||||
"createdAt": created_at,
|
||||
"startedAt": started_at,
|
||||
"finishedAt": finished_at,
|
||||
})
|
||||
},
|
||||
))
|
||||
}
|
||||
@@ -0,0 +1,160 @@
|
||||
use crate::{
|
||||
auth::jwt::validate_jwt,
|
||||
db::models::{downstream_api_key::DownstreamApiKey, user::User},
|
||||
error::{AppError, AppResult},
|
||||
AppState,
|
||||
};
|
||||
use axum::{
|
||||
extract::{Path, State},
|
||||
http::{HeaderMap, StatusCode},
|
||||
routing::{delete, get},
|
||||
Json, Router,
|
||||
};
|
||||
use serde::Deserialize;
|
||||
use serde_json::{json, Value};
|
||||
|
||||
pub fn routes() -> Router<AppState> {
|
||||
Router::new()
|
||||
.route("/v1/user/profile", get(get_profile).put(update_profile))
|
||||
.route("/v1/user/api-keys", get(list_api_keys).post(create_api_key))
|
||||
.route("/v1/user/api-keys/{id}", delete(delete_api_key))
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct UpdateProfilePayload {
|
||||
username: Option<String>,
|
||||
email: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct CreateApiKeyPayload {
|
||||
name: String,
|
||||
description: Option<String>,
|
||||
expires_at: Option<String>,
|
||||
max_requests: Option<i64>,
|
||||
max_cost: Option<f64>,
|
||||
}
|
||||
|
||||
fn current_user_id(headers: &HeaderMap, state: &AppState) -> AppResult<i64> {
|
||||
let auth = headers
|
||||
.get("Authorization")
|
||||
.and_then(|v| v.to_str().ok())
|
||||
.and_then(|v| v.strip_prefix("Bearer "))
|
||||
.ok_or_else(|| AppError::Unauthorized("Missing or invalid Authorization header".into()))?;
|
||||
|
||||
let claims = validate_jwt(&state.config.auth.jwt_secret, auth)
|
||||
.map_err(|_| AppError::Unauthorized("Invalid or expired token".into()))?;
|
||||
|
||||
claims
|
||||
.sub
|
||||
.parse()
|
||||
.map_err(|_| AppError::Unauthorized("Invalid user ID in token".into()))
|
||||
}
|
||||
|
||||
async fn get_profile(State(state): State<AppState>, headers: HeaderMap) -> AppResult<Json<Value>> {
|
||||
let user_id = current_user_id(&headers, &state)?;
|
||||
let user: User = sqlx::query_as("SELECT * FROM users WHERE id = ?")
|
||||
.bind(user_id)
|
||||
.fetch_optional(&*state.db)
|
||||
.await?
|
||||
.ok_or_else(|| AppError::NotFound("User not found".into()))?;
|
||||
|
||||
Ok(Json(json!({ "user": user })))
|
||||
}
|
||||
|
||||
async fn update_profile(
|
||||
State(state): State<AppState>,
|
||||
headers: HeaderMap,
|
||||
Json(payload): Json<UpdateProfilePayload>,
|
||||
) -> AppResult<Json<Value>> {
|
||||
let user_id = current_user_id(&headers, &state)?;
|
||||
let current: User = sqlx::query_as("SELECT * FROM users WHERE id = ?")
|
||||
.bind(user_id)
|
||||
.fetch_optional(&*state.db)
|
||||
.await?
|
||||
.ok_or_else(|| AppError::NotFound("User not found".into()))?;
|
||||
|
||||
let username = payload.username.unwrap_or(current.username);
|
||||
let email = payload.email.unwrap_or(current.email);
|
||||
|
||||
sqlx::query(
|
||||
"UPDATE users
|
||||
SET username = ?, email = ?, updated_at = datetime('now')
|
||||
WHERE id = ?",
|
||||
)
|
||||
.bind(username)
|
||||
.bind(email)
|
||||
.bind(user_id)
|
||||
.execute(&*state.db)
|
||||
.await?;
|
||||
|
||||
get_profile(State(state), headers).await
|
||||
}
|
||||
|
||||
async fn list_api_keys(
|
||||
State(state): State<AppState>,
|
||||
headers: HeaderMap,
|
||||
) -> AppResult<Json<Value>> {
|
||||
let user_id = current_user_id(&headers, &state)?;
|
||||
let keys: Vec<DownstreamApiKey> = sqlx::query_as(
|
||||
"SELECT * FROM downstream_api_keys WHERE user_id = ? ORDER BY created_at DESC",
|
||||
)
|
||||
.bind(user_id)
|
||||
.fetch_all(&*state.db)
|
||||
.await?;
|
||||
|
||||
Ok(Json(json!({ "keys": keys })))
|
||||
}
|
||||
|
||||
async fn create_api_key(
|
||||
State(state): State<AppState>,
|
||||
headers: HeaderMap,
|
||||
Json(payload): Json<CreateApiKeyPayload>,
|
||||
) -> AppResult<(StatusCode, Json<Value>)> {
|
||||
let user_id = current_user_id(&headers, &state)?;
|
||||
if payload.name.trim().is_empty() {
|
||||
return Err(AppError::BadRequest("name is required".into()));
|
||||
}
|
||||
|
||||
let token = format!("sk-user-{}", uuid::Uuid::new_v4().simple());
|
||||
let result = sqlx::query(
|
||||
"INSERT INTO downstream_api_keys
|
||||
(name, key, description, enabled, expires_at, max_requests, max_cost, user_id)
|
||||
VALUES (?, ?, ?, 1, ?, ?, ?, ?)",
|
||||
)
|
||||
.bind(payload.name)
|
||||
.bind(&token)
|
||||
.bind(payload.description)
|
||||
.bind(payload.expires_at)
|
||||
.bind(payload.max_requests)
|
||||
.bind(payload.max_cost)
|
||||
.bind(user_id)
|
||||
.execute(&*state.db)
|
||||
.await?;
|
||||
|
||||
let key: DownstreamApiKey = sqlx::query_as("SELECT * FROM downstream_api_keys WHERE id = ?")
|
||||
.bind(result.last_insert_rowid())
|
||||
.fetch_one(&*state.db)
|
||||
.await?;
|
||||
|
||||
Ok((StatusCode::CREATED, Json(json!({ "key": key }))))
|
||||
}
|
||||
|
||||
async fn delete_api_key(
|
||||
State(state): State<AppState>,
|
||||
headers: HeaderMap,
|
||||
Path(id): Path<i64>,
|
||||
) -> AppResult<StatusCode> {
|
||||
let user_id = current_user_id(&headers, &state)?;
|
||||
let result = sqlx::query("DELETE FROM downstream_api_keys WHERE id = ? AND user_id = ?")
|
||||
.bind(id)
|
||||
.bind(user_id)
|
||||
.execute(&*state.db)
|
||||
.await?;
|
||||
|
||||
if result.rows_affected() == 0 {
|
||||
return Err(AppError::NotFound("API key not found".into()));
|
||||
}
|
||||
|
||||
Ok(StatusCode::NO_CONTENT)
|
||||
}
|
||||
@@ -0,0 +1,270 @@
|
||||
// User-level API key routes — JWT-authenticated CRUD scoped to current user.
|
||||
// Backed by the same downstream_api_keys table as the admin route.
|
||||
|
||||
use crate::{
|
||||
auth::jwt::validate_jwt,
|
||||
db::models::downstream_api_key::DownstreamApiKey,
|
||||
error::{AppError, AppResult},
|
||||
AppState,
|
||||
};
|
||||
use axum::{
|
||||
extract::{Path, State},
|
||||
http::{HeaderMap, StatusCode},
|
||||
routing::{delete, get, patch, post},
|
||||
Json, Router,
|
||||
};
|
||||
use serde::Deserialize;
|
||||
use serde_json::{json, Value};
|
||||
use tracing::info;
|
||||
|
||||
pub fn routes() -> Router<AppState> {
|
||||
Router::new()
|
||||
.route(
|
||||
"/api/user-api-keys",
|
||||
get(list_user_keys).post(create_user_key),
|
||||
)
|
||||
.route(
|
||||
"/api/user-api-keys/{id}",
|
||||
patch(update_user_key).delete(delete_user_key),
|
||||
)
|
||||
}
|
||||
|
||||
/// Extract user_id and role from JWT in Authorization header.
|
||||
fn extract_jwt_user(headers: &HeaderMap, state: &AppState) -> AppResult<(i64, String)> {
|
||||
let auth = headers
|
||||
.get("Authorization")
|
||||
.and_then(|v| v.to_str().ok())
|
||||
.and_then(|v| v.strip_prefix("Bearer "))
|
||||
.ok_or_else(|| AppError::Unauthorized("Missing or invalid Authorization header".into()))?;
|
||||
|
||||
let claims = validate_jwt(&state.config.auth.jwt_secret, auth)
|
||||
.map_err(|_| AppError::Unauthorized("Invalid or expired token".into()))?;
|
||||
|
||||
let user_id: i64 = claims
|
||||
.sub
|
||||
.parse()
|
||||
.map_err(|_| AppError::Unauthorized("Invalid user ID in token".into()))?;
|
||||
|
||||
Ok((user_id, claims.role))
|
||||
}
|
||||
|
||||
/// GET /api/user-api-keys — list keys for the authenticated user
|
||||
async fn list_user_keys(
|
||||
State(state): State<AppState>,
|
||||
headers: HeaderMap,
|
||||
) -> AppResult<Json<Value>> {
|
||||
let (user_id, _role) = extract_jwt_user(&headers, &state)?;
|
||||
info!("Listing user API keys for user {}", user_id);
|
||||
|
||||
let keys: Vec<DownstreamApiKey> = sqlx::query_as::<_, DownstreamApiKey>(
|
||||
"SELECT * FROM downstream_api_keys WHERE user_id = ? ORDER BY created_at DESC",
|
||||
)
|
||||
.bind(user_id)
|
||||
.fetch_all(&*state.db)
|
||||
.await?;
|
||||
|
||||
Ok(Json(json!({ "keys": keys })))
|
||||
}
|
||||
|
||||
/// POST /api/user-api-keys — create a new API key for the authenticated user
|
||||
async fn create_user_key(
|
||||
State(state): State<AppState>,
|
||||
headers: HeaderMap,
|
||||
Json(body): Json<serde_json::Value>,
|
||||
) -> AppResult<(StatusCode, Json<Value>)> {
|
||||
let (user_id, _role) = extract_jwt_user(&headers, &state)?;
|
||||
info!("Creating user API key for user {}", user_id);
|
||||
|
||||
let name = body
|
||||
.get("name")
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or_else(|| AppError::BadRequest("name is required".into()))?;
|
||||
|
||||
let description = body.get("description").and_then(|v| v.as_str());
|
||||
let tags = body.get("tags").map(|v| v.to_string());
|
||||
|
||||
// Generate key: sk-user-<uuid>
|
||||
let key_str = body
|
||||
.get("key")
|
||||
.and_then(|v| v.as_str())
|
||||
.map(|s| s.to_string())
|
||||
.unwrap_or_else(|| {
|
||||
format!(
|
||||
"sk-user-{}",
|
||||
uuid::Uuid::new_v4().to_string().replace('-', "")
|
||||
)
|
||||
});
|
||||
|
||||
let enabled = body
|
||||
.get("enabled")
|
||||
.and_then(|v| v.as_bool())
|
||||
.unwrap_or(true);
|
||||
let expires_at = body.get("expiresAt").and_then(|v| v.as_str());
|
||||
let max_cost = body.get("maxCost").and_then(|v| v.as_f64());
|
||||
let max_requests = body.get("maxRequests").and_then(|v| v.as_i64());
|
||||
let supported_models = body.get("supportedModels").map(|v| v.to_string());
|
||||
let allowed_route_ids = body.get("allowedRouteIds").map(|v| v.to_string());
|
||||
let site_weight_multipliers = body.get("siteWeightMultipliers").map(|v| v.to_string());
|
||||
let excluded_site_ids = body.get("excludedSiteIds").map(|v| v.to_string());
|
||||
let excluded_credential_refs = body.get("excludedCredentialRefs").map(|v| v.to_string());
|
||||
|
||||
let result = sqlx::query(
|
||||
r#"INSERT INTO downstream_api_keys
|
||||
(name, key, description, tags, enabled, expires_at, max_cost, max_requests,
|
||||
supported_models, allowed_route_ids, site_weight_multipliers,
|
||||
excluded_site_ids, excluded_credential_refs, user_id)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)"#,
|
||||
)
|
||||
.bind(name)
|
||||
.bind(&key_str)
|
||||
.bind(description)
|
||||
.bind(tags)
|
||||
.bind(enabled)
|
||||
.bind(expires_at)
|
||||
.bind(max_cost)
|
||||
.bind(max_requests)
|
||||
.bind(supported_models)
|
||||
.bind(allowed_route_ids)
|
||||
.bind(site_weight_multipliers)
|
||||
.bind(excluded_site_ids)
|
||||
.bind(excluded_credential_refs)
|
||||
.bind(user_id)
|
||||
.execute(&*state.db)
|
||||
.await?;
|
||||
|
||||
let new_key: DownstreamApiKey =
|
||||
sqlx::query_as::<_, DownstreamApiKey>("SELECT * FROM downstream_api_keys WHERE id = ?")
|
||||
.bind(result.last_insert_rowid())
|
||||
.fetch_one(&*state.db)
|
||||
.await?;
|
||||
|
||||
Ok((StatusCode::CREATED, Json(json!({ "key": new_key }))))
|
||||
}
|
||||
|
||||
/// PATCH /api/user-api-keys/{id} — update a key owned by the authenticated user
|
||||
async fn update_user_key(
|
||||
State(state): State<AppState>,
|
||||
headers: HeaderMap,
|
||||
Path(id): Path<i64>,
|
||||
Json(body): Json<serde_json::Value>,
|
||||
) -> AppResult<Json<Value>> {
|
||||
let (user_id, _role) = extract_jwt_user(&headers, &state)?;
|
||||
info!("Updating user API key {} for user {}", id, user_id);
|
||||
|
||||
// Verify ownership
|
||||
let existing: Option<DownstreamApiKey> =
|
||||
sqlx::query_as("SELECT * FROM downstream_api_keys WHERE id = ? AND user_id = ?")
|
||||
.bind(id)
|
||||
.bind(user_id)
|
||||
.fetch_optional(&*state.db)
|
||||
.await?;
|
||||
|
||||
if existing.is_none() {
|
||||
return Err(AppError::NotFound("Key not found".into()));
|
||||
}
|
||||
|
||||
// Build dynamic UPDATE
|
||||
let mut sets: Vec<String> = vec![];
|
||||
let mut params: Vec<String> = vec![];
|
||||
let mut bind_idx = 0;
|
||||
|
||||
if let Some(v) = body.get("name").and_then(|v| v.as_str()) {
|
||||
bind_idx += 1;
|
||||
sets.push(format!("name = ?{}", bind_idx));
|
||||
params.push(v.to_string());
|
||||
}
|
||||
if let Some(v) = body.get("description") {
|
||||
bind_idx += 1;
|
||||
sets.push(format!("description = ?{}", bind_idx));
|
||||
params.push(v.as_str().unwrap_or_default().to_string());
|
||||
}
|
||||
if let Some(v) = body.get("tags") {
|
||||
bind_idx += 1;
|
||||
sets.push(format!("tags = ?{}", bind_idx));
|
||||
params.push(v.to_string());
|
||||
}
|
||||
if let Some(v) = body.get("enabled").and_then(|v| v.as_bool()) {
|
||||
bind_idx += 1;
|
||||
sets.push(format!("enabled = ?{}", bind_idx));
|
||||
params.push(if v { "1" } else { "0" }.to_string());
|
||||
}
|
||||
if let Some(v) = body.get("expiresAt").and_then(|v| v.as_str()) {
|
||||
bind_idx += 1;
|
||||
sets.push(format!("expires_at = ?{}", bind_idx));
|
||||
params.push(v.to_string());
|
||||
}
|
||||
if let Some(v) = body.get("maxCost").and_then(|v| v.as_f64()) {
|
||||
bind_idx += 1;
|
||||
sets.push(format!("max_cost = ?{}", bind_idx));
|
||||
params.push(v.to_string());
|
||||
}
|
||||
if let Some(v) = body.get("maxRequests").and_then(|v| v.as_i64()) {
|
||||
bind_idx += 1;
|
||||
sets.push(format!("max_requests = ?{}", bind_idx));
|
||||
params.push(v.to_string());
|
||||
}
|
||||
if let Some(v) = body.get("supportedModels") {
|
||||
bind_idx += 1;
|
||||
sets.push(format!("supported_models = ?{}", bind_idx));
|
||||
params.push(v.to_string());
|
||||
}
|
||||
if let Some(v) = body.get("allowedRouteIds") {
|
||||
bind_idx += 1;
|
||||
sets.push(format!("allowed_route_ids = ?{}", bind_idx));
|
||||
params.push(v.to_string());
|
||||
}
|
||||
if let Some(v) = body.get("excludedSiteIds") {
|
||||
bind_idx += 1;
|
||||
sets.push(format!("excluded_site_ids = ?{}", bind_idx));
|
||||
params.push(v.to_string());
|
||||
}
|
||||
|
||||
if !sets.is_empty() {
|
||||
bind_idx += 1;
|
||||
sets.push(format!("updated_at = ?{}", bind_idx));
|
||||
params.push(chrono::Utc::now().format("%Y-%m-%d %H:%M:%S").to_string());
|
||||
|
||||
let sql = format!(
|
||||
"UPDATE downstream_api_keys SET {} WHERE id = ?{} AND user_id = ?{}",
|
||||
sets.join(", "),
|
||||
bind_idx + 1,
|
||||
bind_idx + 2
|
||||
);
|
||||
let mut q = sqlx::query(&sql);
|
||||
for p in ¶ms {
|
||||
q = q.bind(p);
|
||||
}
|
||||
q = q.bind(id).bind(user_id);
|
||||
q.execute(&*state.db).await?;
|
||||
}
|
||||
|
||||
let updated: DownstreamApiKey =
|
||||
sqlx::query_as::<_, DownstreamApiKey>("SELECT * FROM downstream_api_keys WHERE id = ?")
|
||||
.bind(id)
|
||||
.fetch_one(&*state.db)
|
||||
.await?;
|
||||
|
||||
Ok(Json(json!({ "key": updated })))
|
||||
}
|
||||
|
||||
/// DELETE /api/user-api-keys/{id} — delete a key owned by the authenticated user
|
||||
async fn delete_user_key(
|
||||
State(state): State<AppState>,
|
||||
headers: HeaderMap,
|
||||
Path(id): Path<i64>,
|
||||
) -> AppResult<StatusCode> {
|
||||
let (user_id, _role) = extract_jwt_user(&headers, &state)?;
|
||||
info!("Deleting user API key {} for user {}", id, user_id);
|
||||
|
||||
let result = sqlx::query("DELETE FROM downstream_api_keys WHERE id = ? AND user_id = ?")
|
||||
.bind(id)
|
||||
.bind(user_id)
|
||||
.execute(&*state.db)
|
||||
.await?;
|
||||
|
||||
if result.rows_affected() == 0 {
|
||||
return Err(AppError::NotFound("Key not found".into()));
|
||||
}
|
||||
|
||||
Ok(StatusCode::NO_CONTENT)
|
||||
}
|
||||
@@ -0,0 +1,517 @@
|
||||
use axum::{
|
||||
body::Body,
|
||||
extract::State,
|
||||
http::{header, HeaderMap, Response, StatusCode},
|
||||
response::IntoResponse,
|
||||
routing::post,
|
||||
Json, Router,
|
||||
};
|
||||
use serde_json::{json, Value};
|
||||
use tracing::info;
|
||||
|
||||
use crate::{
|
||||
error::{AppError, AppResult},
|
||||
AppState,
|
||||
};
|
||||
|
||||
const SCRIPT_ANALYSIS_PROMPT: &str = r#"你是一个 AI 视频生成助手。分析用户提供的脚本,提取以下结构化信息,以 JSON 格式返回:
|
||||
|
||||
{
|
||||
"title": "视频标题",
|
||||
"characters": [
|
||||
{
|
||||
"name": "角色名",
|
||||
"gender": "男/女",
|
||||
"age": "年龄描述",
|
||||
"appearance": "外貌详细描述(发型、脸型、服装、体型等)",
|
||||
"personality": "性格特征"
|
||||
}
|
||||
],
|
||||
"scenes": [
|
||||
{
|
||||
"id": 1,
|
||||
"description": "场景描述",
|
||||
"environment": "环境详细描述(时间、地点、天气、光线等)",
|
||||
"characters": ["出现的角色名"],
|
||||
"action": "场景中发生的动作",
|
||||
"dialogue": [
|
||||
{"character": "角色名", "line": "台词", "emotion": "情感语气"}
|
||||
]
|
||||
}
|
||||
],
|
||||
"style": "整体视觉风格(动画/写实/像素等)"
|
||||
}
|
||||
|
||||
只返回 JSON,不要其他文字。"#;
|
||||
|
||||
const CHARACTER_PROMPT_GEN: &str = r#"根据角色描述生成 4 组稳定的图像生成提示词(正面、背面、左侧面、右侧面),用于 AI 图像生成模型。
|
||||
每组提示词应包含:人物姿态、服装、光照、背景。
|
||||
返回 JSON 格式:
|
||||
{
|
||||
"front": "正面提示词",
|
||||
"back": "背面提示词",
|
||||
"left": "左侧面提示词",
|
||||
"right": "右侧面提示词"
|
||||
}
|
||||
只返回 JSON。"#;
|
||||
|
||||
const SCENE_PROMPT_GEN: &str = r#"根据场景描述生成图像生成提示词,用于 AI 图像生成模型创建场景背景。
|
||||
包含:环境、光照、色调、氛围。
|
||||
返回 JSON:
|
||||
{
|
||||
"prompt": "场景生成提示词",
|
||||
"negative_prompt": "负面提示词"
|
||||
}
|
||||
只返回 JSON。"#;
|
||||
|
||||
pub fn routes() -> Router<AppState> {
|
||||
Router::new()
|
||||
.route("/api/video-agent/analyze", post(analyze))
|
||||
.route(
|
||||
"/api/video-agent/character-prompts",
|
||||
post(character_prompts),
|
||||
)
|
||||
.route(
|
||||
"/api/video-agent/generate-character",
|
||||
post(generate_character),
|
||||
)
|
||||
.route("/api/video-agent/scene-prompt", post(scene_prompt))
|
||||
.route("/api/video-agent/generate-scene", post(generate_scene))
|
||||
.route("/api/video-agent/generate-tts", post(generate_tts))
|
||||
.route("/api/video-agent/full-workflow", post(full_workflow))
|
||||
}
|
||||
|
||||
fn upstream_base() -> String {
|
||||
std::env::var("BOOSAPI_UPSTREAM_BASE")
|
||||
.unwrap_or_else(|_| "http://127.0.0.1:4000/v1".to_string())
|
||||
.trim_end_matches('/')
|
||||
.to_string()
|
||||
}
|
||||
|
||||
fn upstream_key() -> String {
|
||||
std::env::var("BOOSAPI_UPSTREAM_KEY").unwrap_or_else(|_| "sk-boos4721".to_string())
|
||||
}
|
||||
|
||||
fn check_auth(headers: &HeaderMap, state: &AppState) -> AppResult<()> {
|
||||
let expected = format!("Bearer {}", state.config.auth.auth_token);
|
||||
|
||||
let actual = headers
|
||||
.get(header::AUTHORIZATION)
|
||||
.and_then(|v| v.to_str().ok())
|
||||
.unwrap_or("");
|
||||
|
||||
if actual != expected {
|
||||
return Err(AppError::BadRequest("unauthorized".to_string()));
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn required_str<'a>(body: &'a Value, key: &str) -> AppResult<&'a str> {
|
||||
body.get(key)
|
||||
.and_then(Value::as_str)
|
||||
.filter(|s| !s.trim().is_empty())
|
||||
.ok_or_else(|| AppError::BadRequest(format!("{key} is required")))
|
||||
}
|
||||
|
||||
fn required_value<'a>(body: &'a Value, key: &str) -> AppResult<&'a Value> {
|
||||
body.get(key)
|
||||
.filter(|v| !v.is_null())
|
||||
.ok_or_else(|| AppError::BadRequest(format!("{key} is required")))
|
||||
}
|
||||
|
||||
fn strip_json_markers(s: &str) -> String {
|
||||
let mut text = s.trim().to_string();
|
||||
|
||||
if text.starts_with("```json") {
|
||||
text = text.trim_start_matches("```json").to_string();
|
||||
} else if text.starts_with("```") {
|
||||
text = text.trim_start_matches("```").to_string();
|
||||
}
|
||||
|
||||
if text.ends_with("```") {
|
||||
text = text.trim_end_matches("```").to_string();
|
||||
}
|
||||
|
||||
text.trim().to_string()
|
||||
}
|
||||
|
||||
fn parse_llm_json(raw: &str) -> AppResult<Value> {
|
||||
let cleaned = strip_json_markers(raw);
|
||||
serde_json::from_str::<Value>(&cleaned).map_err(|e| {
|
||||
AppError::Internal(anyhow::anyhow!(
|
||||
"failed to parse LLM JSON response: {}; response: {}",
|
||||
e,
|
||||
cleaned
|
||||
))
|
||||
})
|
||||
}
|
||||
|
||||
async fn call_llm(prompt: &str, system: &str) -> AppResult<String> {
|
||||
let client = reqwest::Client::new();
|
||||
let url = format!("{}/chat/completions", upstream_base());
|
||||
|
||||
info!("video-agent calling LLM upstream: {}", url);
|
||||
|
||||
let resp = client
|
||||
.post(url)
|
||||
.bearer_auth(upstream_key())
|
||||
.json(&json!({
|
||||
"model": "gpt-5.5",
|
||||
"messages": [
|
||||
{ "role": "system", "content": system },
|
||||
{ "role": "user", "content": prompt }
|
||||
],
|
||||
"temperature": 0.7
|
||||
}))
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| AppError::Internal(anyhow::anyhow!("LLM request failed: {}", e)))?;
|
||||
|
||||
let status = resp.status();
|
||||
let data: Value = resp
|
||||
.json()
|
||||
.await
|
||||
.map_err(|e| AppError::Internal(anyhow::anyhow!("LLM response parse failed: {}", e)))?;
|
||||
|
||||
if !status.is_success() {
|
||||
return Err(AppError::Internal(anyhow::anyhow!(
|
||||
"LLM upstream returned {}: {}",
|
||||
status,
|
||||
data
|
||||
)));
|
||||
}
|
||||
|
||||
Ok(data
|
||||
.get("choices")
|
||||
.and_then(Value::as_array)
|
||||
.and_then(|choices| choices.first())
|
||||
.and_then(|choice| choice.get("message"))
|
||||
.and_then(|message| message.get("content"))
|
||||
.and_then(Value::as_str)
|
||||
.unwrap_or("")
|
||||
.to_string())
|
||||
}
|
||||
|
||||
async fn call_image_gen(prompt: &str) -> AppResult<String> {
|
||||
let client = reqwest::Client::new();
|
||||
let url = format!("{}/images/generations", upstream_base());
|
||||
|
||||
info!("video-agent calling image upstream: {}", url);
|
||||
|
||||
let resp = client
|
||||
.post(url)
|
||||
.bearer_auth(upstream_key())
|
||||
.json(&json!({
|
||||
"model": "gpt-image-2",
|
||||
"prompt": prompt,
|
||||
"n": 1,
|
||||
"size": "1024x1024"
|
||||
}))
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| AppError::Internal(anyhow::anyhow!("image request failed: {}", e)))?;
|
||||
|
||||
let status = resp.status();
|
||||
let data: Value = resp
|
||||
.json()
|
||||
.await
|
||||
.map_err(|e| AppError::Internal(anyhow::anyhow!("image response parse failed: {}", e)))?;
|
||||
|
||||
if !status.is_success() {
|
||||
return Err(AppError::Internal(anyhow::anyhow!(
|
||||
"image upstream returned {}: {}",
|
||||
status,
|
||||
data
|
||||
)));
|
||||
}
|
||||
|
||||
Ok(data
|
||||
.get("data")
|
||||
.and_then(Value::as_array)
|
||||
.and_then(|items| items.first())
|
||||
.and_then(|item| item.get("url"))
|
||||
.and_then(Value::as_str)
|
||||
.unwrap_or("")
|
||||
.to_string())
|
||||
}
|
||||
|
||||
async fn call_tts(text: &str, voice: &str) -> AppResult<Vec<u8>> {
|
||||
let client = reqwest::Client::new();
|
||||
let url = format!("{}/audio/speech", upstream_base());
|
||||
|
||||
info!("video-agent calling TTS upstream: {}", url);
|
||||
|
||||
let resp = client
|
||||
.post(url)
|
||||
.bearer_auth(upstream_key())
|
||||
.json(&json!({
|
||||
"model": "tts-1",
|
||||
"input": text,
|
||||
"voice": voice
|
||||
}))
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| AppError::Internal(anyhow::anyhow!("TTS request failed: {}", e)))?;
|
||||
|
||||
let status = resp.status();
|
||||
|
||||
if !status.is_success() {
|
||||
let body = resp.text().await.unwrap_or_default();
|
||||
return Err(AppError::Internal(anyhow::anyhow!(
|
||||
"TTS upstream returned {}: {}",
|
||||
status,
|
||||
body
|
||||
)));
|
||||
}
|
||||
|
||||
let bytes = resp
|
||||
.bytes()
|
||||
.await
|
||||
.map_err(|e| AppError::Internal(anyhow::anyhow!("TTS response read failed: {}", e)))?;
|
||||
|
||||
Ok(bytes.to_vec())
|
||||
}
|
||||
|
||||
async fn analyze(
|
||||
State(state): State<AppState>,
|
||||
headers: HeaderMap,
|
||||
Json(body): Json<Value>,
|
||||
) -> AppResult<Json<Value>> {
|
||||
check_auth(&headers, &state)?;
|
||||
|
||||
let script = required_str(&body, "script")?;
|
||||
|
||||
let result = call_llm(script, SCRIPT_ANALYSIS_PROMPT).await?;
|
||||
let parsed = parse_llm_json(&result)?;
|
||||
|
||||
Ok(Json(json!({
|
||||
"success": true,
|
||||
"data": parsed
|
||||
})))
|
||||
}
|
||||
|
||||
async fn character_prompts(
|
||||
State(state): State<AppState>,
|
||||
headers: HeaderMap,
|
||||
Json(body): Json<Value>,
|
||||
) -> AppResult<Json<Value>> {
|
||||
check_auth(&headers, &state)?;
|
||||
|
||||
let character = required_value(&body, "character")?;
|
||||
|
||||
let name = character
|
||||
.get("name")
|
||||
.and_then(Value::as_str)
|
||||
.unwrap_or_default();
|
||||
let appearance = character
|
||||
.get("appearance")
|
||||
.and_then(Value::as_str)
|
||||
.unwrap_or_default();
|
||||
let gender = character
|
||||
.get("gender")
|
||||
.and_then(Value::as_str)
|
||||
.unwrap_or_default();
|
||||
let age = character
|
||||
.get("age")
|
||||
.and_then(Value::as_str)
|
||||
.unwrap_or_default();
|
||||
|
||||
let desc = format!("{name}: {appearance}, {gender}, {age}");
|
||||
let result = call_llm(&desc, CHARACTER_PROMPT_GEN).await?;
|
||||
let prompts = parse_llm_json(&result)?;
|
||||
|
||||
Ok(Json(json!({
|
||||
"success": true,
|
||||
"data": prompts
|
||||
})))
|
||||
}
|
||||
|
||||
async fn generate_character(
|
||||
State(state): State<AppState>,
|
||||
headers: HeaderMap,
|
||||
Json(body): Json<Value>,
|
||||
) -> AppResult<Json<Value>> {
|
||||
check_auth(&headers, &state)?;
|
||||
|
||||
let prompts = required_value(&body, "prompts")?;
|
||||
let prompt_obj = prompts
|
||||
.as_object()
|
||||
.ok_or_else(|| AppError::BadRequest("prompts must be an object".to_string()))?;
|
||||
|
||||
let mut results = serde_json::Map::new();
|
||||
|
||||
for (angle, prompt_value) in prompt_obj {
|
||||
let prompt = prompt_value.as_str().ok_or_else(|| {
|
||||
AppError::BadRequest(format!("prompt for angle '{angle}' must be a string"))
|
||||
})?;
|
||||
|
||||
let url = call_image_gen(prompt).await?;
|
||||
results.insert(angle.clone(), Value::String(url));
|
||||
}
|
||||
|
||||
Ok(Json(json!({
|
||||
"success": true,
|
||||
"data": Value::Object(results)
|
||||
})))
|
||||
}
|
||||
|
||||
async fn scene_prompt(
|
||||
State(state): State<AppState>,
|
||||
headers: HeaderMap,
|
||||
Json(body): Json<Value>,
|
||||
) -> AppResult<Json<Value>> {
|
||||
check_auth(&headers, &state)?;
|
||||
|
||||
let scene = required_value(&body, "scene")?;
|
||||
|
||||
let environment = scene
|
||||
.get("environment")
|
||||
.and_then(Value::as_str)
|
||||
.unwrap_or_default();
|
||||
let description = scene
|
||||
.get("description")
|
||||
.and_then(Value::as_str)
|
||||
.unwrap_or_default();
|
||||
|
||||
let desc = format!("{environment}. {description}");
|
||||
let result = call_llm(&desc, SCENE_PROMPT_GEN).await?;
|
||||
let prompt = parse_llm_json(&result)?;
|
||||
|
||||
Ok(Json(json!({
|
||||
"success": true,
|
||||
"data": prompt
|
||||
})))
|
||||
}
|
||||
|
||||
async fn generate_scene(
|
||||
State(state): State<AppState>,
|
||||
headers: HeaderMap,
|
||||
Json(body): Json<Value>,
|
||||
) -> AppResult<Json<Value>> {
|
||||
check_auth(&headers, &state)?;
|
||||
|
||||
let prompt = required_str(&body, "prompt")?;
|
||||
let url = call_image_gen(prompt).await?;
|
||||
|
||||
Ok(Json(json!({
|
||||
"success": true,
|
||||
"data": {
|
||||
"url": url
|
||||
}
|
||||
})))
|
||||
}
|
||||
|
||||
async fn generate_tts(
|
||||
State(state): State<AppState>,
|
||||
headers: HeaderMap,
|
||||
Json(body): Json<Value>,
|
||||
) -> AppResult<Response<Body>> {
|
||||
check_auth(&headers, &state)?;
|
||||
|
||||
let text = required_str(&body, "text")?;
|
||||
let voice = body
|
||||
.get("voice")
|
||||
.and_then(Value::as_str)
|
||||
.filter(|s| !s.trim().is_empty())
|
||||
.unwrap_or("alloy");
|
||||
|
||||
let audio = call_tts(text, voice).await?;
|
||||
|
||||
Ok((
|
||||
StatusCode::OK,
|
||||
[(header::CONTENT_TYPE, "audio/mpeg")],
|
||||
audio,
|
||||
)
|
||||
.into_response())
|
||||
}
|
||||
|
||||
async fn full_workflow(
|
||||
State(state): State<AppState>,
|
||||
headers: HeaderMap,
|
||||
Json(body): Json<Value>,
|
||||
) -> AppResult<Json<Value>> {
|
||||
check_auth(&headers, &state)?;
|
||||
|
||||
let script = required_str(&body, "script")?;
|
||||
let requested_style = body.get("style").and_then(Value::as_str);
|
||||
|
||||
let analysis_raw = call_llm(script, SCRIPT_ANALYSIS_PROMPT).await?;
|
||||
let analysis = parse_llm_json(&analysis_raw)?;
|
||||
|
||||
let mut characters = Vec::new();
|
||||
|
||||
if let Some(chars) = analysis.get("characters").and_then(Value::as_array) {
|
||||
for character in chars {
|
||||
let name = character
|
||||
.get("name")
|
||||
.and_then(Value::as_str)
|
||||
.unwrap_or_default();
|
||||
let appearance = character
|
||||
.get("appearance")
|
||||
.and_then(Value::as_str)
|
||||
.unwrap_or_default();
|
||||
let gender = character
|
||||
.get("gender")
|
||||
.and_then(Value::as_str)
|
||||
.unwrap_or_default();
|
||||
let age = character
|
||||
.get("age")
|
||||
.and_then(Value::as_str)
|
||||
.unwrap_or_default();
|
||||
|
||||
let desc = format!("{name}: {appearance}, {gender}, {age}");
|
||||
let prompts_raw = call_llm(&desc, CHARACTER_PROMPT_GEN).await?;
|
||||
let prompts = parse_llm_json(&prompts_raw)?;
|
||||
|
||||
let mut item = character.clone();
|
||||
if let Some(obj) = item.as_object_mut() {
|
||||
obj.insert("imagePrompts".to_string(), prompts);
|
||||
}
|
||||
|
||||
characters.push(item);
|
||||
}
|
||||
}
|
||||
|
||||
let mut scenes = Vec::new();
|
||||
|
||||
if let Some(scene_items) = analysis.get("scenes").and_then(Value::as_array) {
|
||||
for scene in scene_items {
|
||||
let environment = scene
|
||||
.get("environment")
|
||||
.and_then(Value::as_str)
|
||||
.unwrap_or_default();
|
||||
let description = scene
|
||||
.get("description")
|
||||
.and_then(Value::as_str)
|
||||
.unwrap_or_default();
|
||||
|
||||
let desc = format!("{environment}. {description}");
|
||||
let scene_prompt_raw = call_llm(&desc, SCENE_PROMPT_GEN).await?;
|
||||
let image_prompt = parse_llm_json(&scene_prompt_raw)?;
|
||||
|
||||
let mut item = scene.clone();
|
||||
if let Some(obj) = item.as_object_mut() {
|
||||
obj.insert("imagePrompt".to_string(), image_prompt);
|
||||
}
|
||||
|
||||
scenes.push(item);
|
||||
}
|
||||
}
|
||||
|
||||
let style = analysis
|
||||
.get("style")
|
||||
.cloned()
|
||||
.or_else(|| requested_style.map(|s| Value::String(s.to_string())))
|
||||
.unwrap_or(Value::Null);
|
||||
|
||||
Ok(Json(json!({
|
||||
"success": true,
|
||||
"data": {
|
||||
"title": analysis.get("title").cloned().unwrap_or(Value::Null),
|
||||
"style": style,
|
||||
"characters": characters,
|
||||
"scenes": scenes
|
||||
}
|
||||
})))
|
||||
}
|
||||
Reference in New Issue
Block a user