forked from boos/BoosAPI
feat: Phase 5-6 - Proxy Conductor, Token Router, Services, Dockerfile
- Proxy Conductor: retry/fallback/auth-refresh orchestration loop - Token Router: exact/wildcard/regex model routing with channel selection - Services: Cron scheduler, OAuth token refresh, Update center, Announcements - User routes: profile + API key management - DB queries module - Dockerfile: multi-stage build - Proxy routes integrated with Conductor + TokenRouter
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())
|
||||
}
|
||||
@@ -14,6 +14,7 @@ use axum::{
|
||||
use serde_json::{json, Value};
|
||||
use tracing::info;
|
||||
|
||||
mod account_tokens;
|
||||
mod accounts;
|
||||
mod downstream_keys;
|
||||
mod sites;
|
||||
@@ -116,6 +117,7 @@ pub fn routes(state: AppState) -> Router<AppState> {
|
||||
.merge(downstream_routes)
|
||||
.merge(settings_routes)
|
||||
.merge(users_routes)
|
||||
.merge(account_tokens::routes())
|
||||
.route_layer(middleware::from_fn_with_state(state, require_admin_auth))
|
||||
}
|
||||
|
||||
|
||||
@@ -59,10 +59,9 @@ impl From<User> for UserResponse {
|
||||
/// 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 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 })))
|
||||
}
|
||||
@@ -95,29 +94,33 @@ pub async fn create_user(
|
||||
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()));
|
||||
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 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) }))))
|
||||
Ok((
|
||||
axum::http::StatusCode::CREATED,
|
||||
Json(json!({ "data": UserResponse::from(user) })),
|
||||
))
|
||||
}
|
||||
|
||||
/// PUT /api/admin/users/{id}
|
||||
@@ -136,9 +139,14 @@ pub async fn update_user(
|
||||
|
||||
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()));
|
||||
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}")))?)
|
||||
Some(
|
||||
hash_password(pw)
|
||||
.map_err(|e| AppError::Internal(anyhow::anyhow!("Failed to hash password: {e}")))?,
|
||||
)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
@@ -68,10 +68,14 @@ async fn register(
|
||||
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()));
|
||||
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()));
|
||||
return Err(AppError::BadRequest(
|
||||
"password must be at least 6 characters".into(),
|
||||
));
|
||||
}
|
||||
|
||||
// Check if email already exists
|
||||
@@ -88,13 +92,13 @@ async fn register(
|
||||
.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')"
|
||||
"INSERT INTO users (username, email, password_hash, role) VALUES (?, ?, ?, 'user')",
|
||||
)
|
||||
.bind(&payload.username)
|
||||
.bind(&payload.email)
|
||||
.bind(&password_hash)
|
||||
.execute(&*state.db)
|
||||
.await?;
|
||||
.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())
|
||||
@@ -106,7 +110,8 @@ async fn register(
|
||||
&user.id.to_string(),
|
||||
&user.role,
|
||||
state.config.auth.jwt_expiry_hours,
|
||||
).map_err(|e| AppError::Internal(anyhow::anyhow!("Failed to generate JWT: {e}")))?;
|
||||
)
|
||||
.map_err(|e| AppError::Internal(anyhow::anyhow!("Failed to generate JWT: {e}")))?;
|
||||
|
||||
Ok(Json(json!({
|
||||
"data": {
|
||||
@@ -150,7 +155,8 @@ async fn login(
|
||||
&user.id.to_string(),
|
||||
&user.role,
|
||||
state.config.auth.jwt_expiry_hours,
|
||||
).map_err(|e| AppError::Internal(anyhow::anyhow!("Failed to generate JWT: {e}")))?;
|
||||
)
|
||||
.map_err(|e| AppError::Internal(anyhow::anyhow!("Failed to generate JWT: {e}")))?;
|
||||
|
||||
Ok(Json(json!({
|
||||
"data": {
|
||||
@@ -166,10 +172,7 @@ async fn login(
|
||||
}
|
||||
|
||||
/// GET /api/auth/me — returns current user profile from JWT
|
||||
async fn me(
|
||||
State(state): State<AppState>,
|
||||
headers: HeaderMap,
|
||||
) -> AppResult<Json<Value>> {
|
||||
async fn me(State(state): State<AppState>, headers: HeaderMap) -> AppResult<Json<Value>> {
|
||||
let auth = headers
|
||||
.get("Authorization")
|
||||
.and_then(|v| v.to_str().ok())
|
||||
@@ -238,13 +241,19 @@ async fn change_password(
|
||||
) -> AppResult<Json<Value>> {
|
||||
let (user_id, _role) = extract_jwt(&state, &headers)?;
|
||||
|
||||
let old_pw = body.get("oldPassword").and_then(|v| v.as_str())
|
||||
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())
|
||||
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()));
|
||||
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 = ?")
|
||||
@@ -257,7 +266,9 @@ async fn change_password(
|
||||
.map_err(|e| AppError::Internal(anyhow::anyhow!("Password verification failed: {e}")))?;
|
||||
|
||||
if !valid {
|
||||
return Err(AppError::Unauthorized("Current password is incorrect".into()));
|
||||
return Err(AppError::Unauthorized(
|
||||
"Current password is incorrect".into(),
|
||||
));
|
||||
}
|
||||
|
||||
let new_hash = crate::auth::password::hash_password(new_pw)
|
||||
@@ -314,7 +325,9 @@ fn extract_jwt(state: &AppState, headers: &HeaderMap) -> AppResult<(i64, String)
|
||||
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()
|
||||
let uid: i64 = claims
|
||||
.sub
|
||||
.parse()
|
||||
.map_err(|_| AppError::Unauthorized("Invalid user ID in token".into()))?;
|
||||
|
||||
Ok((uid, claims.role))
|
||||
|
||||
@@ -48,7 +48,10 @@ pub fn routes() -> Router<AppState> {
|
||||
.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/accounts/{id}/toggle",
|
||||
post(toggle_checkin_account),
|
||||
)
|
||||
.route("/api/checkin/run", post(run_checkin))
|
||||
}
|
||||
|
||||
@@ -96,15 +99,20 @@ async fn checkin_stats(State(state): State<AppState>) -> AppResult<Json<Value>>
|
||||
FROM accounts a
|
||||
JOIN sites s ON s.id = a.site_id
|
||||
WHERE a.status = 'active'
|
||||
ORDER BY a.last_checkin_at ASC"
|
||||
ORDER BY a.last_checkin_at ASC",
|
||||
)
|
||||
.fetch_all(&*state.db)
|
||||
.await?;
|
||||
.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();
|
||||
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,
|
||||
@@ -116,22 +124,23 @@ async fn checkin_stats(State(state): State<AppState>) -> AppResult<Json<Value>>
|
||||
|
||||
/// 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();
|
||||
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 })))
|
||||
}
|
||||
@@ -150,13 +159,17 @@ async fn toggle_checkin_account(
|
||||
.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?;
|
||||
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 })))
|
||||
Ok(Json(
|
||||
json!({ "success": true, "checkin_enabled": new_value }),
|
||||
))
|
||||
}
|
||||
|
||||
/// POST /api/checkin/run — trigger checkin for all enabled accounts
|
||||
@@ -164,10 +177,10 @@ 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"
|
||||
"SELECT * FROM accounts WHERE status = 'active' AND COALESCE(checkin_enabled, 1) = 1",
|
||||
)
|
||||
.fetch_all(&*state.db)
|
||||
.await?;
|
||||
.fetch_all(&*state.db)
|
||||
.await?;
|
||||
|
||||
let mut results: Vec<Value> = vec![];
|
||||
for account in &accounts {
|
||||
|
||||
@@ -5,15 +5,15 @@
|
||||
// The /api/comfyui/proxy/* route serves static assets (public).
|
||||
|
||||
use crate::{
|
||||
auth::api_key::{ authorize_downstream_token, DownstreamAuthResult },
|
||||
error::{ AppError, AppResult },
|
||||
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 },
|
||||
extract::{Path, Query, State},
|
||||
http::{HeaderMap, StatusCode},
|
||||
response::{Html, IntoResponse, Response},
|
||||
routing::{get, post},
|
||||
Json, Router,
|
||||
};
|
||||
use reqwest::Client;
|
||||
@@ -49,7 +49,8 @@ async fn check_auth(state: &AppState, headers: &HeaderMap) -> Result<(), AppErro
|
||||
|
||||
// Downstream API key
|
||||
if auth.starts_with("sk-") {
|
||||
let result = authorize_downstream_token(auth, &state.db).await
|
||||
let result = authorize_downstream_token(auth, &state.db)
|
||||
.await
|
||||
.map_err(|e| AppError::Unauthorized(e))?;
|
||||
|
||||
match result {
|
||||
@@ -123,10 +124,7 @@ async fn proxy_get_history_by_id(
|
||||
}
|
||||
|
||||
/// GET /api/comfyui/queue
|
||||
async fn proxy_get_queue(
|
||||
State(state): State<AppState>,
|
||||
headers: HeaderMap,
|
||||
) -> AppResult<Response> {
|
||||
async fn proxy_get_queue(State(state): State<AppState>, headers: HeaderMap) -> AppResult<Response> {
|
||||
info!("GET /api/comfyui/queue");
|
||||
check_auth(&state, &headers).await?;
|
||||
proxy_to_comfyui("GET", "/queue", None, None).await
|
||||
@@ -149,9 +147,7 @@ async fn proxy_get_view(
|
||||
// ── 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> {
|
||||
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()
|
||||
@@ -161,18 +157,24 @@ async fn proxy_static(
|
||||
|
||||
let client = http_client();
|
||||
let url = format!("{}{}", comfyui_base(), target_path);
|
||||
let resp = client.get(&url).send().await
|
||||
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()
|
||||
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
|
||||
let text = resp
|
||||
.text()
|
||||
.await
|
||||
.map_err(|e| AppError::UpstreamError(e.to_string()))?;
|
||||
// Rebrand: ComfyUI → BoosAPI
|
||||
let rebranded = text
|
||||
@@ -180,21 +182,41 @@ async fn proxy_static(
|
||||
.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())
|
||||
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
|
||||
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())
|
||||
Ok((
|
||||
StatusCode::from_u16(status.as_u16()).unwrap(),
|
||||
[(axum::http::header::CONTENT_TYPE, content_type)],
|
||||
text,
|
||||
)
|
||||
.into_response())
|
||||
} else {
|
||||
let bytes = resp.bytes().await
|
||||
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())
|
||||
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(
|
||||
r##"<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
@@ -209,7 +231,8 @@ async fn comfyui_page() -> Html<&'static str> {
|
||||
<body>
|
||||
<iframe src="/api/comfyui/proxy/" title="BoosAPI ComfyUI"></iframe>
|
||||
</body>
|
||||
</html>"##)
|
||||
</html>"##,
|
||||
)
|
||||
}
|
||||
|
||||
// ── Helpers ──────────────────────────────────────────────────────────
|
||||
@@ -225,12 +248,23 @@ fn rewrite_proxy_urls(html: &str) -> String {
|
||||
// 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 };
|
||||
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(); }
|
||||
for _ in 0..snippet.len() {
|
||||
chars.next();
|
||||
}
|
||||
continue;
|
||||
}
|
||||
}
|
||||
@@ -275,22 +309,34 @@ async fn proxy_to_comfyui(
|
||||
|
||||
let req = match method {
|
||||
"POST" => {
|
||||
let body_str = body.map(|b| b.to_string()).unwrap_or_else(|| "{}".to_string());
|
||||
client.post(&url)
|
||||
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
|
||||
let resp = req
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| AppError::UpstreamError(format!("ComfyUI unreachable: {e}")))?;
|
||||
|
||||
let status = resp.status();
|
||||
let text = resp.text().await
|
||||
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())
|
||||
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)
|
||||
@@ -299,17 +345,27 @@ async fn proxy_to_comfyui_binary(_method: &str, path: &str) -> AppResult<Respons
|
||||
let url = format!("{}{}", comfyui_base(), path);
|
||||
|
||||
let req = client.get(&url);
|
||||
let resp = req.send().await
|
||||
let resp = req
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| AppError::UpstreamError(format!("ComfyUI unreachable: {e}")))?;
|
||||
|
||||
let status = resp.status();
|
||||
let content_type = resp.headers()
|
||||
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
|
||||
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())
|
||||
Ok((
|
||||
StatusCode::from_u16(status.as_u16()).unwrap(),
|
||||
[(axum::http::header::CONTENT_TYPE, content_type)],
|
||||
bytes,
|
||||
)
|
||||
.into_response())
|
||||
}
|
||||
|
||||
@@ -0,0 +1,242 @@
|
||||
use crate::AppState;
|
||||
use axum::{
|
||||
extract::State,
|
||||
http::HeaderMap,
|
||||
response::sse::{Event, Sse},
|
||||
routing::post,
|
||||
Json, Router,
|
||||
};
|
||||
use futures::stream::Stream;
|
||||
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 视频生成助手,帮助用户通过 ComfyUI 制作 AI 视频。
|
||||
|
||||
## 能力
|
||||
1. **分析脚本** — 提取角色、场景、对话
|
||||
2. **生成角色提示词** — 为每个角色生成 4 个角度(正面/背面/左侧面/右侧面)
|
||||
3. **生成场景提示词** — 描述场景的稳定图像生成提示词
|
||||
4. **生成语音(TTS)** — 将对话台词转为语音描述
|
||||
5. **导出 ComfyUI 工作流** — 生成 workflow JSON
|
||||
|
||||
## 输出格式
|
||||
直接输出普通文本,用 emoji 标记不同内容类型:
|
||||
- 🎭 角色名 — 角色描述和相关提示词
|
||||
- 🌄 场景描述 — 场景提示词
|
||||
- 🔊 角色名: "台词" — TTS 语音
|
||||
- 📦 ComfyUI 工作流 — 最终的 workflow JSON(放在 ```json 代码块内)
|
||||
|
||||
## 工作流程
|
||||
1. 用户提供脚本 → 你分析脚本结构
|
||||
2. 针对每个角色 → 生成 4 角度提示词
|
||||
3. 针对每个场景 → 生成场景提示词
|
||||
4. 针对每段对话 → 生成 TTS 语音描述
|
||||
5. 最终导出 ComfyUI 工作流 JSON
|
||||
|
||||
始终用中文回复,文字简洁清晰。"#;
|
||||
|
||||
pub fn routes() -> Router<AppState> {
|
||||
Router::new().route("/api/comfyui-agent/chat", post(chat_handler))
|
||||
}
|
||||
|
||||
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_base = std::env::var("BOOSAPI_UPSTREAM_BASE")
|
||||
.unwrap_or_else(|_| "https://api.boos.lat/v1".to_string());
|
||||
let upstream_key =
|
||||
std::env::var("BOOSAPI_UPSTREAM_KEY").unwrap_or_else(|_| "sk-boos4721".to_string());
|
||||
|
||||
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 chat streaming 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(_) => {
|
||||
send_sse(
|
||||
&tx,
|
||||
json!({
|
||||
"type": "text",
|
||||
"content": "服务暂时不可用,请稍后重试",
|
||||
}),
|
||||
);
|
||||
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();
|
||||
|
||||
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) => {
|
||||
if !send_sse(
|
||||
&tx,
|
||||
json!({
|
||||
"type": "text",
|
||||
"content": format!("\n\n[错误: {}]", error),
|
||||
}),
|
||||
) {
|
||||
return;
|
||||
}
|
||||
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();
|
||||
|
||||
if !line.starts_with("data: ") {
|
||||
continue;
|
||||
}
|
||||
|
||||
let data = line.trim_start_matches("data: ").trim();
|
||||
|
||||
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();
|
||||
|
||||
if !content.is_empty()
|
||||
&& !send_sse(
|
||||
&tx,
|
||||
json!({
|
||||
"type": "text",
|
||||
"content": content,
|
||||
}),
|
||||
)
|
||||
{
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
send_sse(&tx, json!({ "type": "done" }));
|
||||
});
|
||||
|
||||
Ok(Sse::new(UnboundedReceiverStream::new(rx)))
|
||||
}
|
||||
|
||||
fn send_sse(tx: &UnboundedSender<Result<Event, Infallible>>, payload: Value) -> bool {
|
||||
tx.send(Ok(Event::default().data(payload.to_string())))
|
||||
.is_ok()
|
||||
}
|
||||
@@ -78,10 +78,7 @@ async fn unread_count(State(state): State<AppState>) -> AppResult<Json<Value>> {
|
||||
}
|
||||
|
||||
/// POST /api/events/{id}/read
|
||||
async fn mark_read(
|
||||
State(state): State<AppState>,
|
||||
Path(id): Path<i64>,
|
||||
) -> AppResult<Json<Value>> {
|
||||
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)
|
||||
|
||||
@@ -5,32 +5,42 @@ 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 {
|
||||
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)
|
||||
}
|
||||
|
||||
@@ -20,7 +20,10 @@ 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/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))
|
||||
@@ -28,23 +31,26 @@ pub fn routes() -> Router<AppState> {
|
||||
}
|
||||
|
||||
/// 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 = ?"
|
||||
)
|
||||
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 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() };
|
||||
let masked = if configured {
|
||||
mask_cookie_value(&raw)
|
||||
} else {
|
||||
String::new()
|
||||
};
|
||||
|
||||
Ok(Json(json!({
|
||||
"ldohCookieConfigured": configured,
|
||||
@@ -57,7 +63,10 @@ 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("");
|
||||
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 (?, '')")
|
||||
@@ -92,9 +101,7 @@ async fn update_monitor_config(
|
||||
}
|
||||
|
||||
/// POST /api/monitor/session — returns cookie value for client to set
|
||||
async fn create_session(
|
||||
State(state): State<AppState>,
|
||||
) -> Json<Value> {
|
||||
async fn create_session(State(state): State<AppState>) -> Json<Value> {
|
||||
let token = &state.config.auth.auth_token;
|
||||
Json(json!({
|
||||
"success": true,
|
||||
@@ -110,24 +117,30 @@ async fn ldoh_proxy_handler(
|
||||
) -> Result<Response, AppError> {
|
||||
// Auth check via cookie
|
||||
let cookies = parse_cookies(
|
||||
headers.get(header::COOKIE).and_then(|v| v.to_str().ok()).unwrap_or("")
|
||||
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()));
|
||||
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 = ?"
|
||||
)
|
||||
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();
|
||||
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()));
|
||||
@@ -146,28 +159,54 @@ async fn ldoh_proxy_handler(
|
||||
.build()
|
||||
.map_err(|e| AppError::Internal(anyhow::anyhow!("Failed to build client: {e}")))?;
|
||||
|
||||
let mut req = client.get(&target_url)
|
||||
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"));
|
||||
.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
|
||||
let resp = req
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| AppError::UpstreamError(format!("LDOH unreachable: {e}")))?;
|
||||
|
||||
let status = resp.status();
|
||||
let content_type = resp.headers()
|
||||
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()) {
|
||||
if let Some(ctl) = resp
|
||||
.headers()
|
||||
.get("cache-control")
|
||||
.and_then(|v| v.to_str().ok())
|
||||
{
|
||||
builder = builder.header("cache-control", ctl);
|
||||
}
|
||||
|
||||
@@ -177,14 +216,20 @@ async fn ldoh_proxy_handler(
|
||||
|| content_type.contains("text/css")
|
||||
|| content_type.contains("application/json")
|
||||
{
|
||||
let text = resp.text().await.map_err(|e| AppError::UpstreamError(e.to_string()))?;
|
||||
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()))?;
|
||||
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))
|
||||
@@ -200,7 +245,9 @@ fn parse_cookies(raw: &str) -> std::collections::HashMap<String, String> {
|
||||
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); }
|
||||
if !key.is_empty() {
|
||||
map.insert(key, val);
|
||||
}
|
||||
}
|
||||
}
|
||||
map
|
||||
@@ -208,12 +255,14 @@ fn parse_cookies(raw: &str) -> std::collections::HashMap<String, String> {
|
||||
|
||||
fn mask_cookie_value(cookie_text: &str) -> String {
|
||||
let raw = cookie_text.trim();
|
||||
if raw.is_empty() { return String::new(); }
|
||||
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..])
|
||||
format!("{}****{}", &value[..6], &value[value.len() - 4..])
|
||||
}
|
||||
}
|
||||
|
||||
@@ -230,15 +279,17 @@ fn normalize_ldoh_cookie(raw: &str) -> String {
|
||||
}
|
||||
|
||||
fn rewrite_proxy_text(text: &str) -> String {
|
||||
text
|
||||
.replace("https://ldoh.105117.xyz/", "/monitor-proxy/ldoh/")
|
||||
.replace("https:\\/\\/ldoh.105117.xyz\\/", "\\/monitor-proxy\\/ldoh\\/")
|
||||
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("src='/", "src='/monitor-proxy/ldoh/")
|
||||
.replace("href=\"/", "href=\"/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("action='/", "action='/monitor-proxy/ldoh/")
|
||||
.replace("\"/_next/", "\"/monitor-proxy/ldoh/_next/")
|
||||
.replace("'/_next/", "'/monitor-proxy/ldoh/_next/")
|
||||
.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(),
|
||||
))
|
||||
}
|
||||
@@ -37,34 +37,30 @@ fn extract_jwt_user(headers: &HeaderMap, state: &AppState) -> AppResult<(i64, St
|
||||
}
|
||||
|
||||
/// 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?;
|
||||
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(()),
|
||||
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?;
|
||||
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())
|
||||
}
|
||||
|
||||
@@ -78,15 +74,30 @@ pub struct ListOrgsQuery {
|
||||
pub fn routes() -> Router<AppState> {
|
||||
Router::new()
|
||||
// Organizations CRUD
|
||||
.route("/api/organizations", get(list_organizations).post(create_organization))
|
||||
.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))
|
||||
.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))
|
||||
.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))
|
||||
.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))
|
||||
@@ -108,12 +119,12 @@ async fn list_organizations(
|
||||
INNER JOIN organization_members om ON om.org_id = o.id
|
||||
WHERE om.user_id = ?
|
||||
ORDER BY o.created_at DESC
|
||||
LIMIT ?"#
|
||||
LIMIT ?"#,
|
||||
)
|
||||
.bind(user_id)
|
||||
.bind(limit)
|
||||
.fetch_all(&*state.db)
|
||||
.await?;
|
||||
.bind(user_id)
|
||||
.bind(limit)
|
||||
.fetch_all(&*state.db)
|
||||
.await?;
|
||||
|
||||
Ok(Json(json!({ "organizations": orgs })))
|
||||
}
|
||||
@@ -126,14 +137,16 @@ async fn create_organization(
|
||||
) -> AppResult<(StatusCode, Json<Value>)> {
|
||||
let (user_id, _) = extract_jwt_user(&headers, &state)?;
|
||||
|
||||
let name = body.get("name").and_then(|v| v.as_str())
|
||||
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())
|
||||
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 (?, ?)"
|
||||
)
|
||||
let result = sqlx::query("INSERT INTO organizations (name, slug) VALUES (?, ?)")
|
||||
.bind(name)
|
||||
.bind(slug)
|
||||
.execute(&*state.db)
|
||||
@@ -150,9 +163,7 @@ async fn create_organization(
|
||||
let org_id = result.last_insert_rowid();
|
||||
|
||||
// Add creator as owner
|
||||
sqlx::query(
|
||||
"INSERT INTO organization_members (org_id, user_id, role) VALUES (?, ?, 'owner')"
|
||||
)
|
||||
sqlx::query("INSERT INTO organization_members (org_id, user_id, role) VALUES (?, ?, 'owner')")
|
||||
.bind(org_id)
|
||||
.bind(user_id)
|
||||
.execute(&*state.db)
|
||||
@@ -204,7 +215,9 @@ async fn get_organization(
|
||||
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()));
|
||||
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 = ?")
|
||||
@@ -269,25 +282,29 @@ async fn list_members(
|
||||
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()));
|
||||
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"
|
||||
"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!({
|
||||
.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();
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
|
||||
Ok(Json(json!({ "members": members })))
|
||||
}
|
||||
@@ -302,11 +319,15 @@ async fn update_member_role(
|
||||
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())
|
||||
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()));
|
||||
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 = ?")
|
||||
@@ -348,12 +369,11 @@ async fn list_invitations(
|
||||
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?;
|
||||
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 })))
|
||||
}
|
||||
@@ -368,12 +388,19 @@ async fn create_invitation(
|
||||
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())
|
||||
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");
|
||||
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()));
|
||||
return Err(AppError::BadRequest(
|
||||
"Role must be one of: admin, member".into(),
|
||||
));
|
||||
}
|
||||
|
||||
// Generate token
|
||||
@@ -429,12 +456,11 @@ 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?;
|
||||
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) => {
|
||||
@@ -450,12 +476,11 @@ async fn get_invitation(
|
||||
}
|
||||
|
||||
// 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?;
|
||||
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": {
|
||||
@@ -484,16 +509,19 @@ async fn accept_invitation(
|
||||
) -> 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 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())),
|
||||
None => {
|
||||
return Err(AppError::BadRequest(
|
||||
"Invitation not found or expired".into(),
|
||||
))
|
||||
}
|
||||
};
|
||||
|
||||
// Check expiry
|
||||
@@ -504,7 +532,9 @@ async fn accept_invitation(
|
||||
});
|
||||
|
||||
if expired {
|
||||
return Err(AppError::BadRequest("Invitation not found or expired".into()));
|
||||
return Err(AppError::BadRequest(
|
||||
"Invitation not found or expired".into(),
|
||||
));
|
||||
}
|
||||
|
||||
// Mark invitation as accepted
|
||||
@@ -515,13 +545,13 @@ async fn accept_invitation(
|
||||
|
||||
// Add user as member
|
||||
sqlx::query(
|
||||
"INSERT OR IGNORE INTO organization_members (org_id, user_id, role) VALUES (?, ?, ?)"
|
||||
"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?;
|
||||
.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)
|
||||
|
||||
@@ -6,22 +6,25 @@
|
||||
// GET /v1/models — list available models
|
||||
|
||||
use crate::{
|
||||
auth::api_key::{ authorize_downstream_token, DownstreamAuthResult },
|
||||
error::{ AppError, AppResult },
|
||||
proxy::executors::{ self, UpstreamRoute },
|
||||
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 },
|
||||
response::{IntoResponse, Response, Sse},
|
||||
routing::{get, post},
|
||||
Json, Router,
|
||||
};
|
||||
use futures::stream;
|
||||
use futures::StreamExt;
|
||||
use serde_json::{ json, Value };
|
||||
use std::convert::Infallible;
|
||||
use futures::{stream, FutureExt, StreamExt};
|
||||
use serde_json::{json, Value};
|
||||
use std::{convert::Infallible, sync::Arc};
|
||||
use tracing::info;
|
||||
|
||||
// ── Auth types ────────────────────────────────────────────────────────
|
||||
@@ -73,17 +76,20 @@ async fn chat_completions(
|
||||
.ok_or_else(|| AppError::BadRequest("model is required".into()))?
|
||||
.to_string();
|
||||
|
||||
let route = resolve_route(&state, &model).await?;
|
||||
let stream = body.get("stream").and_then(|s| s.as_bool()).unwrap_or(false);
|
||||
let stream = body
|
||||
.get("stream")
|
||||
.and_then(|s| s.as_bool())
|
||||
.unwrap_or(false);
|
||||
|
||||
if stream {
|
||||
let chunks = executors::execute_chat_completions_stream(state, route, body).await?;
|
||||
let sse = Sse::new(stream::iter(chunks).map(|chunk| {
|
||||
Ok::<_, Infallible>(axum::response::sse::Event::default().data(chunk))
|
||||
}));
|
||||
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 = executors::execute_chat_completions(state, route, body).await?;
|
||||
let result = execute_chat_completions_with_conductor(state, model, body).await?;
|
||||
Ok(Json(result).into_response())
|
||||
}
|
||||
}
|
||||
@@ -168,7 +174,8 @@ async fn audio_transcriptions(
|
||||
.to_string();
|
||||
|
||||
let route = resolve_route(&state, &model).await?;
|
||||
let result = executors::execute_proxy_request(state, route, "/audio/transcriptions", body).await?;
|
||||
let result =
|
||||
executors::execute_proxy_request(state, route, "/audio/transcriptions", body).await?;
|
||||
Ok(Json(result))
|
||||
}
|
||||
|
||||
@@ -189,7 +196,8 @@ async fn audio_translations(
|
||||
.to_string();
|
||||
|
||||
let route = resolve_route(&state, &model).await?;
|
||||
let result = executors::execute_proxy_request(state, route, "/audio/translations", body).await?;
|
||||
let result =
|
||||
executors::execute_proxy_request(state, route, "/audio/translations", body).await?;
|
||||
Ok(Json(result))
|
||||
}
|
||||
|
||||
@@ -235,19 +243,16 @@ async fn images_generations(
|
||||
}
|
||||
|
||||
/// GET /v1/models
|
||||
async fn list_models(
|
||||
State(state): State<AppState>,
|
||||
headers: HeaderMap,
|
||||
) -> AppResult<Json<Value>> {
|
||||
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.name, tr.model FROM token_routes tr WHERE tr.enabled = 1 ORDER BY tr.name"
|
||||
"SELECT tr.name, tr.model FROM token_routes tr WHERE tr.enabled = 1 ORDER BY tr.name",
|
||||
)
|
||||
.fetch_all(&*state.db)
|
||||
.await?;
|
||||
.fetch_all(&*state.db)
|
||||
.await?;
|
||||
|
||||
let models: Vec<Value> = rows
|
||||
.into_iter()
|
||||
@@ -277,7 +282,8 @@ async fn check_auth(state: &AppState, headers: &HeaderMap) -> Result<(), AppErro
|
||||
.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
|
||||
let result = authorize_downstream_token(auth, &state.db)
|
||||
.await
|
||||
.map_err(|e| AppError::Unauthorized(e))?;
|
||||
|
||||
match result {
|
||||
@@ -295,27 +301,379 @@ async fn check_auth(state: &AppState, headers: &HeaderMap) -> Result<(), AppErro
|
||||
|
||||
/// Resolve upstream endpoint + API key for a given model.
|
||||
async fn resolve_route(state: &AppState, model: &str) -> AppResult<UpstreamRoute> {
|
||||
let row: Option<(String, String)> = sqlx::query_as(
|
||||
"SELECT s.base_url, COALESCE(a.api_key, s.default_key, '') as api_key
|
||||
FROM token_routes tr
|
||||
JOIN sites s ON s.id = tr.site_id
|
||||
LEFT JOIN accounts a ON a.id = tr.account_id AND a.site_id = s.id
|
||||
WHERE tr.enabled = 1 AND tr.model = ?
|
||||
LIMIT 1"
|
||||
)
|
||||
.bind(model)
|
||||
.fetch_optional(&*state.db)
|
||||
let selected = TokenRouter::new(state.db.clone())
|
||||
.resolve_selected(model)
|
||||
.await?;
|
||||
Ok(selected_channel_to_route(selected))
|
||||
}
|
||||
|
||||
match row {
|
||||
Some((base_url, api_key)) => Ok(UpstreamRoute {
|
||||
base_url,
|
||||
api_key,
|
||||
model: model.to_string(),
|
||||
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()
|
||||
}),
|
||||
None => Err(AppError::BadRequest(format!(
|
||||
"No route configured for model '{}'",
|
||||
model
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,14 +1,7 @@
|
||||
// Search routes — cross-entity search across sites, accounts, tokens, etc.
|
||||
|
||||
use crate::{
|
||||
error::AppResult,
|
||||
AppState,
|
||||
};
|
||||
use axum::{
|
||||
extract::State,
|
||||
routing::post,
|
||||
Json, Router,
|
||||
};
|
||||
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};
|
||||
@@ -21,8 +14,7 @@ pub struct SearchBody {
|
||||
}
|
||||
|
||||
pub fn routes() -> Router<AppState> {
|
||||
Router::new()
|
||||
.route("/api/search", post(search_handler))
|
||||
Router::new().route("/api/search", post(search_handler))
|
||||
}
|
||||
|
||||
/// POST /api/search — search across all entities
|
||||
@@ -56,10 +48,13 @@ async fn search_handler(
|
||||
|
||||
// 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();
|
||||
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>)>(
|
||||
@@ -151,11 +146,12 @@ async fn search_handler(
|
||||
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 ?"#
|
||||
LIMIT ?"#,
|
||||
)
|
||||
.bind(&q).bind(per_category * 10)
|
||||
.fetch_all(&*state.db)
|
||||
.await?;
|
||||
.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
|
||||
@@ -163,11 +159,12 @@ async fn search_handler(
|
||||
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 ?"#
|
||||
LIMIT ?"#,
|
||||
)
|
||||
.bind(&q).bind(per_category * 10)
|
||||
.fetch_all(&*state.db)
|
||||
.await?;
|
||||
.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();
|
||||
@@ -197,7 +194,12 @@ async fn search_handler(
|
||||
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("")))
|
||||
bc.cmp(&ac).then_with(|| {
|
||||
a["name"]
|
||||
.as_str()
|
||||
.unwrap_or("")
|
||||
.cmp(&b["name"].as_str().unwrap_or(""))
|
||||
})
|
||||
});
|
||||
models.truncate(per_category as usize);
|
||||
|
||||
|
||||
@@ -20,13 +20,16 @@ pub struct ListAnnouncementsQuery {
|
||||
offset: Option<i64>,
|
||||
site_id: Option<i64>,
|
||||
platform: Option<String>,
|
||||
read: Option<String>, // "true" | "false"
|
||||
status: Option<String>, // "active" | "expired" | "dismissed"
|
||||
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",
|
||||
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))
|
||||
}
|
||||
@@ -80,21 +83,26 @@ async fn list_announcements(
|
||||
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()
|
||||
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
|
||||
};
|
||||
@@ -131,10 +139,7 @@ async fn list_announcements(
|
||||
}
|
||||
|
||||
/// POST /api/site-announcements/{id}/read
|
||||
async fn mark_read(
|
||||
State(state): State<AppState>,
|
||||
Path(id): Path<i64>,
|
||||
) -> AppResult<Json<Value>> {
|
||||
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)
|
||||
|
||||
@@ -1,9 +1,6 @@
|
||||
// Stats routes — usage analytics queries
|
||||
|
||||
use crate::{
|
||||
error::AppResult,
|
||||
AppState,
|
||||
};
|
||||
use crate::{error::AppResult, AppState};
|
||||
use axum::{
|
||||
extract::{Query, State},
|
||||
routing::get,
|
||||
@@ -36,7 +33,10 @@ 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);
|
||||
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
|
||||
@@ -51,26 +51,27 @@ async fn model_by_site(
|
||||
"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"
|
||||
ORDER BY local_day DESC",
|
||||
)
|
||||
.bind(site_id)
|
||||
.bind(&since_day)
|
||||
.fetch_all(&*state.db)
|
||||
.await?
|
||||
.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"
|
||||
ORDER BY local_day DESC",
|
||||
)
|
||||
.bind(&since_day)
|
||||
.fetch_all(&*state.db)
|
||||
.await?
|
||||
.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();
|
||||
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;
|
||||
@@ -90,7 +91,12 @@ async fn model_by_site(
|
||||
})
|
||||
.collect();
|
||||
|
||||
models.sort_by(|a, b| b["calls"].as_i64().unwrap_or(0).cmp(&a["calls"].as_i64().unwrap_or(0)));
|
||||
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 })))
|
||||
}
|
||||
|
||||
@@ -2,11 +2,7 @@
|
||||
|
||||
use std::convert::Infallible;
|
||||
|
||||
use crate::{
|
||||
db::models::background_task::BackgroundTask,
|
||||
error::AppResult,
|
||||
AppState,
|
||||
};
|
||||
use crate::{db::models::background_task::BackgroundTask, error::AppResult, AppState};
|
||||
use axum::{
|
||||
extract::{Path, Query, State},
|
||||
response::{
|
||||
@@ -31,7 +27,10 @@ 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))
|
||||
.route(
|
||||
"/api/update-center/tasks/{id}/stream",
|
||||
get(stream_task_logs),
|
||||
)
|
||||
}
|
||||
|
||||
/// GET /api/tasks — list background tasks
|
||||
@@ -43,32 +42,30 @@ async fn list_tasks(
|
||||
info!("Listing background tasks, limit={}", limit);
|
||||
|
||||
let tasks: Vec<BackgroundTask> = sqlx::query_as::<_, BackgroundTask>(
|
||||
"SELECT * FROM background_tasks ORDER BY created_at DESC LIMIT ?"
|
||||
"SELECT * FROM background_tasks ORDER BY created_at DESC LIMIT ?",
|
||||
)
|
||||
.bind(limit)
|
||||
.fetch_all(&*state.db)
|
||||
.await?;
|
||||
.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>> {
|
||||
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?;
|
||||
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" }))),
|
||||
None => Ok(Json(
|
||||
json!({ "success": false, "message": "task not found" }),
|
||||
)),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -80,15 +77,18 @@ async fn stream_task_logs(
|
||||
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: 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,
|
||||
@@ -96,7 +96,8 @@ async fn stream_task_logs(
|
||||
return Err((
|
||||
axum::http::StatusCode::NOT_FOUND,
|
||||
json!({ "success": false, "message": "task not found" }).to_string(),
|
||||
).into_response());
|
||||
)
|
||||
.into_response());
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
},
|
||||
))
|
||||
}
|
||||
@@ -1 +1,160 @@
|
||||
// Routes - user
|
||||
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, post, put},
|
||||
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)
|
||||
}
|
||||
|
||||
@@ -19,8 +19,14 @@ 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))
|
||||
.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.
|
||||
@@ -51,11 +57,11 @@ async fn list_user_keys(
|
||||
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"
|
||||
"SELECT * FROM downstream_api_keys WHERE user_id = ? ORDER BY created_at DESC",
|
||||
)
|
||||
.bind(user_id)
|
||||
.fetch_all(&*state.db)
|
||||
.await?;
|
||||
.bind(user_id)
|
||||
.fetch_all(&*state.db)
|
||||
.await?;
|
||||
|
||||
Ok(Json(json!({ "keys": keys })))
|
||||
}
|
||||
@@ -82,9 +88,17 @@ async fn create_user_key(
|
||||
.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('-', "")));
|
||||
.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 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());
|
||||
@@ -99,31 +113,30 @@ async fn create_user_key(
|
||||
(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 (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)"#
|
||||
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?;
|
||||
.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?;
|
||||
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 }))))
|
||||
}
|
||||
@@ -139,13 +152,12 @@ async fn update_user_key(
|
||||
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?;
|
||||
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()));
|
||||
@@ -157,35 +169,54 @@ async fn update_user_key(
|
||||
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());
|
||||
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));
|
||||
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());
|
||||
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());
|
||||
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());
|
||||
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());
|
||||
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());
|
||||
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());
|
||||
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());
|
||||
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());
|
||||
bind_idx += 1;
|
||||
sets.push(format!("excluded_site_ids = ?{}", bind_idx));
|
||||
params.push(v.to_string());
|
||||
}
|
||||
|
||||
if !sets.is_empty() {
|
||||
@@ -207,12 +238,11 @@ async fn update_user_key(
|
||||
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?;
|
||||
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 })))
|
||||
}
|
||||
|
||||
@@ -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