feat: complete user login admin keys and deployment docs
This commit is contained in:
@@ -8,7 +8,7 @@ use axum::{
|
||||
http::StatusCode,
|
||||
Json,
|
||||
};
|
||||
use serde::Deserialize;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::{json, Value};
|
||||
use std::time::Duration;
|
||||
use tracing::info;
|
||||
@@ -501,3 +501,132 @@ fn build_account_extra_config(
|
||||
.map_err(|err| AppError::Internal(err.into()))
|
||||
}
|
||||
}
|
||||
|
||||
// ── Account model availability ────────────────────────────────────────────────
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
struct ModelItem {
|
||||
name: String,
|
||||
#[serde(rename = "latencyMs")]
|
||||
latency_ms: Option<i64>,
|
||||
disabled: bool,
|
||||
#[serde(rename = "isManual")]
|
||||
is_manual: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct AddManualModelsRequest {
|
||||
pub models: Vec<String>,
|
||||
}
|
||||
|
||||
/// GET /api/accounts/{id}/models
|
||||
pub async fn get_account_models(
|
||||
State(state): State<AppState>,
|
||||
Path(id): Path<i64>,
|
||||
) -> AppResult<Json<Value>> {
|
||||
info!("Getting models for account: {}", id);
|
||||
|
||||
// Account + site info
|
||||
let row = sqlx::query(
|
||||
"SELECT a.id, a.site_id, s.name AS site_name
|
||||
FROM accounts a
|
||||
JOIN sites s ON s.id = a.site_id
|
||||
WHERE a.id = ?",
|
||||
)
|
||||
.bind(id)
|
||||
.fetch_optional(&*state.db)
|
||||
.await?
|
||||
.ok_or_else(|| AppError::NotFound(format!("Account {} not found", id)))?;
|
||||
|
||||
use sqlx::Row;
|
||||
let site_id: i64 = row.get("site_id");
|
||||
let site_name: String = row.get("site_name");
|
||||
|
||||
// Available models for this account
|
||||
let availability: Vec<(String, Option<i64>, bool)> = sqlx::query_as(
|
||||
"SELECT model_name, latency_ms, is_manual
|
||||
FROM model_availability
|
||||
WHERE account_id = ? AND available = 1
|
||||
ORDER BY model_name ASC",
|
||||
)
|
||||
.bind(id)
|
||||
.fetch_all(&*state.db)
|
||||
.await?;
|
||||
|
||||
// Disabled models for this site
|
||||
let disabled_names: std::collections::HashSet<String> =
|
||||
sqlx::query_scalar("SELECT model_name FROM site_disabled_models WHERE site_id = ?")
|
||||
.bind(site_id)
|
||||
.fetch_all(&*state.db)
|
||||
.await?
|
||||
.into_iter()
|
||||
.collect();
|
||||
|
||||
let models: Vec<ModelItem> = availability
|
||||
.into_iter()
|
||||
.map(|(name, latency_ms, is_manual)| {
|
||||
let disabled = disabled_names.contains(&name);
|
||||
ModelItem {
|
||||
name,
|
||||
latency_ms,
|
||||
disabled,
|
||||
is_manual,
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
|
||||
let total_count = models.len();
|
||||
let disabled_count = models.iter().filter(|m| m.disabled).count();
|
||||
|
||||
Ok(Json(json!({
|
||||
"siteId": site_id,
|
||||
"siteName": site_name,
|
||||
"models": models,
|
||||
"totalCount": total_count,
|
||||
"disabledCount": disabled_count,
|
||||
})))
|
||||
}
|
||||
|
||||
/// POST /api/accounts/{id}/models/manual
|
||||
pub async fn add_account_manual_models(
|
||||
State(state): State<AppState>,
|
||||
Path(id): Path<i64>,
|
||||
Json(payload): Json<AddManualModelsRequest>,
|
||||
) -> AppResult<Json<Value>> {
|
||||
info!(
|
||||
"Adding {} manual models for account: {}",
|
||||
payload.models.len(),
|
||||
id
|
||||
);
|
||||
|
||||
// Verify account exists
|
||||
sqlx::query_scalar::<_, i64>("SELECT 1 FROM accounts WHERE id = ?")
|
||||
.bind(id)
|
||||
.fetch_optional(&*state.db)
|
||||
.await?
|
||||
.ok_or_else(|| AppError::NotFound(format!("Account {} not found", id)))?;
|
||||
|
||||
let mut inserted = 0usize;
|
||||
for model_name in payload
|
||||
.models
|
||||
.iter()
|
||||
.map(|m| m.trim())
|
||||
.filter(|m| !m.is_empty())
|
||||
{
|
||||
sqlx::query(
|
||||
"INSERT INTO model_availability (account_id, model_name, available, is_manual, checked_at)
|
||||
VALUES (?, ?, 1, 1, datetime('now'))
|
||||
ON CONFLICT (account_id, model_name) DO UPDATE SET
|
||||
available = 1,
|
||||
is_manual = 1,
|
||||
checked_at = datetime('now')",
|
||||
)
|
||||
.bind(id)
|
||||
.bind(model_name)
|
||||
.execute(&*state.db)
|
||||
.await?;
|
||||
inserted += 1;
|
||||
}
|
||||
|
||||
Ok(Json(json!({ "inserted": inserted })))
|
||||
}
|
||||
|
||||
@@ -4,7 +4,7 @@ use crate::{
|
||||
AppState,
|
||||
};
|
||||
use axum::{
|
||||
extract::{Path, State},
|
||||
extract::{Path, Query, State},
|
||||
http::StatusCode,
|
||||
Json,
|
||||
};
|
||||
@@ -13,6 +13,12 @@ use serde_json::{json, Value};
|
||||
use tracing::info;
|
||||
use uuid::Uuid;
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct ListKeysQuery {
|
||||
pub q: Option<String>,
|
||||
pub user_id: Option<i64>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct CreateDownstreamKeyRequest {
|
||||
pub name: String,
|
||||
@@ -25,6 +31,7 @@ pub struct CreateDownstreamKeyRequest {
|
||||
pub max_requests: Option<i64>,
|
||||
pub supported_models: Option<Vec<String>>,
|
||||
pub allowed_route_ids: Option<String>,
|
||||
pub user_id: Option<i64>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
@@ -39,6 +46,7 @@ pub struct UpdateDownstreamKeyRequest {
|
||||
pub max_requests: Option<i64>,
|
||||
pub supported_models: Option<Vec<String>>,
|
||||
pub allowed_route_ids: Option<String>,
|
||||
pub user_id: Option<i64>,
|
||||
}
|
||||
|
||||
/// Helper to validate models from a JSON string against a Vec<String>
|
||||
@@ -54,13 +62,32 @@ fn validate_supported_models(models: &[String]) -> AppResult<()> {
|
||||
}
|
||||
|
||||
/// GET /api/downstream-api-keys
|
||||
pub async fn list_downstream_keys(State(state): State<AppState>) -> AppResult<Json<Value>> {
|
||||
info!("Listing downstream API keys");
|
||||
pub async fn list_downstream_keys(
|
||||
State(state): State<AppState>,
|
||||
Query(query): Query<ListKeysQuery>,
|
||||
) -> AppResult<Json<Value>> {
|
||||
info!("Listing downstream API keys with filter: {:?}", query);
|
||||
|
||||
let keys: Vec<DownstreamApiKey> =
|
||||
sqlx::query_as::<_, DownstreamApiKey>("SELECT * FROM downstream_api_keys ORDER BY name")
|
||||
.fetch_all(&*state.db)
|
||||
.await?;
|
||||
let q = query.q.unwrap_or_default().trim().to_string();
|
||||
let q_like = format!("%{}%", q);
|
||||
let user_id = query.user_id;
|
||||
|
||||
let keys: Vec<DownstreamApiKey> = sqlx::query_as::<_, DownstreamApiKey>(
|
||||
r#"
|
||||
SELECT * FROM downstream_api_keys
|
||||
WHERE (? = '' OR name LIKE ? OR key LIKE ? OR tags LIKE ?)
|
||||
AND (? IS NULL OR user_id = ?)
|
||||
ORDER BY id DESC
|
||||
"#,
|
||||
)
|
||||
.bind(&q)
|
||||
.bind(&q_like)
|
||||
.bind(&q_like)
|
||||
.bind(&q_like)
|
||||
.bind(user_id)
|
||||
.bind(user_id)
|
||||
.fetch_all(&*state.db)
|
||||
.await?;
|
||||
|
||||
Ok(Json(
|
||||
json!({ "success": true, "items": keys, "data": keys }),
|
||||
@@ -79,7 +106,11 @@ pub async fn create_downstream_key(
|
||||
}
|
||||
|
||||
// Auto-generate key
|
||||
let key = format!("sk-{}", Uuid::new_v4().to_string().replace('-', ""));
|
||||
let key = if payload.user_id.is_some() {
|
||||
format!("sk-user-{}", Uuid::new_v4().to_string().replace('-', ""))
|
||||
} else {
|
||||
format!("sk-{}", Uuid::new_v4().to_string().replace('-', ""))
|
||||
};
|
||||
|
||||
// Serialize supported_models to JSON string if provided
|
||||
let supported_models: Option<String> = if let Some(ref models) = payload.supported_models {
|
||||
@@ -95,7 +126,7 @@ pub async fn create_downstream_key(
|
||||
let enabled = payload.enabled.unwrap_or(true);
|
||||
|
||||
let result = sqlx::query(
|
||||
"INSERT INTO downstream_api_keys (name, key, description, group_name, tags, enabled, expires_at, max_cost, max_requests, supported_models, allowed_route_ids) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)"
|
||||
"INSERT INTO downstream_api_keys (name, key, description, group_name, tags, enabled, expires_at, max_cost, max_requests, supported_models, allowed_route_ids, user_id) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)"
|
||||
)
|
||||
.bind(&payload.name)
|
||||
.bind(&key)
|
||||
@@ -108,6 +139,7 @@ pub async fn create_downstream_key(
|
||||
.bind(payload.max_requests)
|
||||
.bind(&supported_models)
|
||||
.bind(&payload.allowed_route_ids)
|
||||
.bind(payload.user_id)
|
||||
.execute(&*state.db)
|
||||
.await?;
|
||||
|
||||
@@ -149,7 +181,7 @@ pub async fn update_downstream_key(
|
||||
};
|
||||
|
||||
sqlx::query(
|
||||
"UPDATE downstream_api_keys SET name = ?, description = ?, group_name = ?, tags = ?, enabled = ?, expires_at = ?, max_cost = ?, max_requests = ?, supported_models = ?, allowed_route_ids = ?, updated_at = datetime('now') WHERE id = ?"
|
||||
"UPDATE downstream_api_keys SET name = ?, description = ?, group_name = ?, tags = ?, enabled = ?, expires_at = ?, max_cost = ?, max_requests = ?, supported_models = ?, allowed_route_ids = ?, user_id = ?, updated_at = datetime('now') WHERE id = ?"
|
||||
)
|
||||
.bind(payload.name.unwrap_or(existing.name))
|
||||
.bind(payload.description.or(existing.description))
|
||||
@@ -161,6 +193,7 @@ pub async fn update_downstream_key(
|
||||
.bind(payload.max_requests.or(existing.max_requests))
|
||||
.bind(&supported_models)
|
||||
.bind(payload.allowed_route_ids.or(existing.allowed_route_ids))
|
||||
.bind(payload.user_id.or(existing.user_id))
|
||||
.bind(id)
|
||||
.execute(&*state.db)
|
||||
.await?;
|
||||
@@ -196,3 +229,40 @@ pub async fn delete_downstream_key(
|
||||
|
||||
Ok(Json(json!({ "data": { "deleted": true, "id": id } })))
|
||||
}
|
||||
|
||||
/// POST /api/downstream-api-keys/{id}/reset
|
||||
pub async fn reset_downstream_key(
|
||||
State(state): State<AppState>,
|
||||
Path(id): Path<i64>,
|
||||
) -> AppResult<Json<Value>> {
|
||||
info!("Resetting downstream API key: {}", id);
|
||||
|
||||
let existing: DownstreamApiKey =
|
||||
sqlx::query_as::<_, DownstreamApiKey>("SELECT * FROM downstream_api_keys WHERE id = ?")
|
||||
.bind(id)
|
||||
.fetch_optional(&*state.db)
|
||||
.await?
|
||||
.ok_or_else(|| AppError::NotFound(format!("Downstream key {} not found", id)))?;
|
||||
|
||||
let new_key = if existing.user_id.is_some() {
|
||||
format!("sk-user-{}", Uuid::new_v4().to_string().replace('-', ""))
|
||||
} else {
|
||||
format!("sk-{}", Uuid::new_v4().to_string().replace('-', ""))
|
||||
};
|
||||
|
||||
sqlx::query(
|
||||
"UPDATE downstream_api_keys SET key = ?, updated_at = datetime('now') WHERE id = ?",
|
||||
)
|
||||
.bind(&new_key)
|
||||
.bind(id)
|
||||
.execute(&*state.db)
|
||||
.await?;
|
||||
|
||||
let api_key: DownstreamApiKey =
|
||||
sqlx::query_as::<_, DownstreamApiKey>("SELECT * FROM downstream_api_keys WHERE id = ?")
|
||||
.bind(id)
|
||||
.fetch_one(&*state.db)
|
||||
.await?;
|
||||
|
||||
Ok(Json(json!({ "data": api_key })))
|
||||
}
|
||||
|
||||
+30
-2
@@ -8,7 +8,7 @@ use axum::{
|
||||
http::HeaderMap,
|
||||
middleware,
|
||||
response::Response,
|
||||
routing::{get, post, put},
|
||||
routing::{get, patch, post, put},
|
||||
Json, Router,
|
||||
};
|
||||
use serde_json::{json, Value};
|
||||
@@ -79,6 +79,14 @@ pub fn routes(state: AppState) -> Router<AppState> {
|
||||
get(accounts::get_account)
|
||||
.put(accounts::update_account)
|
||||
.delete(accounts::delete_account),
|
||||
)
|
||||
.route(
|
||||
"/api/accounts/{id}/models",
|
||||
get(accounts::get_account_models),
|
||||
)
|
||||
.route(
|
||||
"/api/accounts/{id}/models/manual",
|
||||
post(accounts::add_account_manual_models),
|
||||
);
|
||||
|
||||
let tokens_routes = Router::new()
|
||||
@@ -112,10 +120,18 @@ pub fn routes(state: AppState) -> Router<AppState> {
|
||||
put(downstream_keys::update_downstream_key)
|
||||
.delete(downstream_keys::delete_downstream_key),
|
||||
)
|
||||
.route(
|
||||
"/api/downstream-api-keys/{id}/reset",
|
||||
post(downstream_keys::reset_downstream_key),
|
||||
)
|
||||
.route(
|
||||
"/api/downstream-keys/{id}",
|
||||
put(downstream_keys::update_downstream_key)
|
||||
.delete(downstream_keys::delete_downstream_key),
|
||||
)
|
||||
.route(
|
||||
"/api/downstream-keys/{id}/reset",
|
||||
post(downstream_keys::reset_downstream_key),
|
||||
);
|
||||
|
||||
let settings_routes =
|
||||
@@ -133,7 +149,19 @@ pub fn routes(state: AppState) -> Router<AppState> {
|
||||
.patch(users::update_user)
|
||||
.delete(users::delete_user),
|
||||
)
|
||||
.route("/api/admin/users/{id}/usage", get(users::get_user_usage));
|
||||
.route("/api/admin/users/{id}/usage", get(users::get_user_usage))
|
||||
.route(
|
||||
"/api/admin/users/{id}/keys",
|
||||
get(users::list_user_keys).post(users::create_user_key),
|
||||
)
|
||||
.route(
|
||||
"/api/admin/users/{id}/keys/{key_id}",
|
||||
patch(users::update_user_key).delete(users::delete_user_key),
|
||||
)
|
||||
.route(
|
||||
"/api/admin/users/{id}/keys/{key_id}/reset",
|
||||
post(users::reset_user_key),
|
||||
);
|
||||
|
||||
Router::new()
|
||||
.merge(sites_routes)
|
||||
|
||||
+199
-1
@@ -2,17 +2,24 @@
|
||||
|
||||
use crate::{
|
||||
auth::password::hash_password,
|
||||
db::models::user::User,
|
||||
db::models::{downstream_api_key::DownstreamApiKey, user::User},
|
||||
error::{AppError, AppResult},
|
||||
AppState,
|
||||
};
|
||||
use axum::{
|
||||
extract::{Path, Query, State},
|
||||
http::StatusCode,
|
||||
Json,
|
||||
};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::{json, Value};
|
||||
use tracing::info;
|
||||
use uuid::Uuid;
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct CreateUserApiKeyRequest {
|
||||
pub name: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct CreateUserRequest {
|
||||
@@ -501,3 +508,194 @@ async fn attach_usage_summary(state: &AppState, item: &mut UserResponse) -> AppR
|
||||
item.last_7_days_cost = Some(last_7_days_cost);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// ── User API Key Management for Admin ──────────────────────────────────
|
||||
|
||||
/// GET /api/admin/users/{id}/keys
|
||||
pub async fn list_user_keys(
|
||||
State(state): State<AppState>,
|
||||
Path(id): Path<i64>,
|
||||
) -> AppResult<Json<Value>> {
|
||||
info!("Admin listing API keys for user: {}", id);
|
||||
|
||||
// Verify user exists
|
||||
let _exists = sqlx::query_scalar::<_, i64>("SELECT 1 FROM users WHERE id = ?")
|
||||
.bind(id)
|
||||
.fetch_optional(&*state.db)
|
||||
.await?
|
||||
.ok_or_else(|| AppError::NotFound(format!("User {} not found", id)))?;
|
||||
|
||||
let keys: Vec<DownstreamApiKey> = sqlx::query_as::<_, DownstreamApiKey>(
|
||||
"SELECT * FROM downstream_api_keys WHERE user_id = ? ORDER BY created_at DESC",
|
||||
)
|
||||
.bind(id)
|
||||
.fetch_all(&*state.db)
|
||||
.await?;
|
||||
|
||||
Ok(Json(json!({ "data": keys, "keys": keys })))
|
||||
}
|
||||
|
||||
/// POST /api/admin/users/{id}/keys
|
||||
pub async fn create_user_key(
|
||||
State(state): State<AppState>,
|
||||
Path(id): Path<i64>,
|
||||
Json(payload): Json<CreateUserApiKeyRequest>,
|
||||
) -> AppResult<(StatusCode, Json<Value>)> {
|
||||
info!("Admin creating API key for user: {}", id);
|
||||
|
||||
let key = format!("sk-user-{}", Uuid::new_v4().to_string().replace('-', ""));
|
||||
|
||||
let result = sqlx::query(
|
||||
"INSERT INTO downstream_api_keys (name, key, user_id, enabled) VALUES (?, ?, ?, ?)",
|
||||
)
|
||||
.bind(&payload.name)
|
||||
.bind(&key)
|
||||
.bind(id)
|
||||
.bind(true)
|
||||
.execute(&*state.db)
|
||||
.await?;
|
||||
|
||||
let key_id = result.last_insert_rowid();
|
||||
let api_key: DownstreamApiKey =
|
||||
sqlx::query_as::<_, DownstreamApiKey>("SELECT * FROM downstream_api_keys WHERE id = ?")
|
||||
.bind(key_id)
|
||||
.fetch_one(&*state.db)
|
||||
.await?;
|
||||
|
||||
Ok((StatusCode::CREATED, Json(json!({ "data": api_key }))))
|
||||
}
|
||||
|
||||
/// DELETE /api/admin/users/{id}/keys/{key_id}
|
||||
pub async fn delete_user_key(
|
||||
State(state): State<AppState>,
|
||||
Path((user_id, key_id)): Path<(i64, i64)>,
|
||||
) -> AppResult<Json<Value>> {
|
||||
info!("Admin deleting API key {} for user {}", key_id, user_id);
|
||||
|
||||
let affected = sqlx::query("DELETE FROM downstream_api_keys WHERE id = ? AND user_id = ?")
|
||||
.bind(key_id)
|
||||
.bind(user_id)
|
||||
.execute(&*state.db)
|
||||
.await?
|
||||
.rows_affected();
|
||||
|
||||
if affected == 0 {
|
||||
return Err(AppError::NotFound(format!(
|
||||
"Key {} for user {} not found",
|
||||
key_id, user_id
|
||||
)));
|
||||
}
|
||||
|
||||
Ok(Json(json!({ "data": { "deleted": true, "id": key_id } })))
|
||||
}
|
||||
|
||||
/// POST /api/admin/users/{id}/keys/{key_id}/reset
|
||||
pub async fn reset_user_key(
|
||||
State(state): State<AppState>,
|
||||
Path((user_id, key_id)): Path<(i64, i64)>,
|
||||
) -> AppResult<Json<Value>> {
|
||||
info!("Admin resetting API key {} for user {}", key_id, user_id);
|
||||
|
||||
let new_key = format!("sk-user-{}", Uuid::new_v4().to_string().replace('-', ""));
|
||||
|
||||
let affected = sqlx::query("UPDATE downstream_api_keys SET key = ?, updated_at = datetime('now') WHERE id = ? AND user_id = ?")
|
||||
.bind(&new_key)
|
||||
.bind(key_id)
|
||||
.bind(user_id)
|
||||
.execute(&*state.db)
|
||||
.await?
|
||||
.rows_affected();
|
||||
|
||||
if affected == 0 {
|
||||
return Err(AppError::NotFound(format!(
|
||||
"Key {} for user {} not found",
|
||||
key_id, user_id
|
||||
)));
|
||||
}
|
||||
|
||||
let api_key: DownstreamApiKey =
|
||||
sqlx::query_as::<_, DownstreamApiKey>("SELECT * FROM downstream_api_keys WHERE id = ?")
|
||||
.bind(key_id)
|
||||
.fetch_one(&*state.db)
|
||||
.await?;
|
||||
|
||||
Ok(Json(json!({ "data": api_key })))
|
||||
}
|
||||
|
||||
/// PATCH /api/admin/users/{id}/keys/{key_id}
|
||||
pub async fn update_user_key(
|
||||
State(state): State<AppState>,
|
||||
Path((user_id, key_id)): Path<(i64, i64)>,
|
||||
Json(payload): Json<serde_json::Value>,
|
||||
) -> AppResult<Json<Value>> {
|
||||
info!("Admin updating API key {} for user {}", key_id, user_id);
|
||||
|
||||
let _existing: DownstreamApiKey = sqlx::query_as::<_, DownstreamApiKey>(
|
||||
"SELECT * FROM downstream_api_keys WHERE id = ? AND user_id = ?",
|
||||
)
|
||||
.bind(key_id)
|
||||
.bind(user_id)
|
||||
.fetch_optional(&*state.db)
|
||||
.await?
|
||||
.ok_or_else(|| AppError::NotFound(format!("Key {} for user {} not found", key_id, user_id)))?;
|
||||
|
||||
// Build dynamic UPDATE
|
||||
let mut sets: Vec<String> = vec![];
|
||||
let mut params: Vec<serde_json::Value> = vec![];
|
||||
|
||||
if let Some(v) = payload.get("name").and_then(|v| v.as_str()) {
|
||||
sets.push("name = ?".to_string());
|
||||
params.push(json!(v));
|
||||
}
|
||||
if let Some(v) = payload.get("description") {
|
||||
sets.push("description = ?".to_string());
|
||||
params.push(v.clone());
|
||||
}
|
||||
if let Some(v) = payload.get("enabled").and_then(|v| v.as_bool()) {
|
||||
sets.push("enabled = ?".to_string());
|
||||
params.push(json!(v));
|
||||
}
|
||||
if let Some(v) = payload.get("expiresAt") {
|
||||
sets.push("expires_at = ?".to_string());
|
||||
params.push(v.clone());
|
||||
}
|
||||
if let Some(v) = payload.get("maxCost").and_then(|v| v.as_f64()) {
|
||||
sets.push("max_cost = ?".to_string());
|
||||
params.push(json!(v));
|
||||
}
|
||||
if let Some(v) = payload.get("maxRequests").and_then(|v| v.as_i64()) {
|
||||
sets.push("max_requests = ?".to_string());
|
||||
params.push(json!(v));
|
||||
}
|
||||
|
||||
if !sets.is_empty() {
|
||||
sets.push("updated_at = datetime('now')".to_string());
|
||||
let sql = format!(
|
||||
"UPDATE downstream_api_keys SET {} WHERE id = ? AND user_id = ?",
|
||||
sets.join(", ")
|
||||
);
|
||||
let mut q = sqlx::query(&sql);
|
||||
for p in ¶ms {
|
||||
if let Some(s) = p.as_str() {
|
||||
q = q.bind(s.to_owned());
|
||||
} else if let Some(b) = p.as_bool() {
|
||||
q = q.bind(b);
|
||||
} else if let Some(f) = p.as_f64() {
|
||||
q = q.bind(f);
|
||||
} else if let Some(i) = p.as_i64() {
|
||||
q = q.bind(i);
|
||||
} else if p.is_null() {
|
||||
q = q.bind(None::<String>);
|
||||
}
|
||||
}
|
||||
q.bind(key_id).bind(user_id).execute(&*state.db).await?;
|
||||
}
|
||||
|
||||
let updated: DownstreamApiKey =
|
||||
sqlx::query_as::<_, DownstreamApiKey>("SELECT * FROM downstream_api_keys WHERE id = ?")
|
||||
.bind(key_id)
|
||||
.fetch_one(&*state.db)
|
||||
.await?;
|
||||
|
||||
Ok(Json(json!({ "data": updated })))
|
||||
}
|
||||
|
||||
+81
-22
@@ -161,33 +161,66 @@ async fn delete_api_key(
|
||||
Ok(StatusCode::NO_CONTENT)
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct UserLogsQuery {
|
||||
q: Option<String>,
|
||||
status: Option<String>,
|
||||
date_from: Option<String>,
|
||||
date_to: Option<String>,
|
||||
page: Option<i64>,
|
||||
page_size: Option<i64>,
|
||||
// legacy compat
|
||||
limit: Option<i64>,
|
||||
offset: Option<i64>,
|
||||
}
|
||||
|
||||
async fn get_user_logs(
|
||||
State(state): State<AppState>,
|
||||
headers: HeaderMap,
|
||||
Query(params): Query<std::collections::HashMap<String, String>>,
|
||||
Query(params): Query<UserLogsQuery>,
|
||||
) -> AppResult<Json<Value>> {
|
||||
let user_id = current_user_id(&headers, &state)?;
|
||||
|
||||
let limit = params
|
||||
.get("limit")
|
||||
.and_then(|v| v.parse::<i64>().ok())
|
||||
let page_size = params
|
||||
.page_size
|
||||
.or(params.limit)
|
||||
.unwrap_or(20)
|
||||
.clamp(1, 100);
|
||||
let offset = params
|
||||
.get("offset")
|
||||
.and_then(|v| v.parse::<i64>().ok())
|
||||
.unwrap_or(0)
|
||||
.max(0);
|
||||
.clamp(1, 200);
|
||||
let page = params.page.unwrap_or(1).max(1);
|
||||
let offset = params.offset.unwrap_or((page - 1) * page_size).max(0);
|
||||
|
||||
let q = params.q.as_deref().map(str::trim).filter(|s| !s.is_empty());
|
||||
let q_like = q.map(|s| format!("%{s}%"));
|
||||
let status = params
|
||||
.status
|
||||
.as_deref()
|
||||
.filter(|s| !s.is_empty() && *s != "all");
|
||||
let date_from = params.date_from.as_deref().filter(|s| !s.is_empty());
|
||||
let date_to = params.date_to.as_deref().filter(|s| !s.is_empty());
|
||||
|
||||
let items: Vec<Value> = sqlx::query_as::<_, crate::routes::stats::ProxyLogRow>(
|
||||
"SELECT l.* FROM proxy_logs l
|
||||
JOIN downstream_api_keys k ON l.downstream_api_key_id = k.id
|
||||
WHERE k.user_id = ?
|
||||
ORDER BY l.created_at DESC
|
||||
LIMIT ? OFFSET ?",
|
||||
r#"
|
||||
SELECT l.* FROM proxy_logs l
|
||||
JOIN downstream_api_keys k ON l.downstream_api_key_id = k.id
|
||||
WHERE k.user_id = ?
|
||||
AND (? IS NULL OR l.model_requested LIKE ?)
|
||||
AND (? IS NULL OR l.status = ?)
|
||||
AND (? IS NULL OR l.created_at >= ?)
|
||||
AND (? IS NULL OR l.created_at <= ?)
|
||||
ORDER BY l.created_at DESC
|
||||
LIMIT ? OFFSET ?
|
||||
"#,
|
||||
)
|
||||
.bind(user_id)
|
||||
.bind(limit)
|
||||
.bind(q_like.as_deref())
|
||||
.bind(q_like.as_deref())
|
||||
.bind(status)
|
||||
.bind(status)
|
||||
.bind(date_from)
|
||||
.bind(date_from)
|
||||
.bind(date_to)
|
||||
.bind(date_to)
|
||||
.bind(page_size)
|
||||
.bind(offset)
|
||||
.fetch_all(&*state.db)
|
||||
.await?
|
||||
@@ -195,20 +228,46 @@ async fn get_user_logs(
|
||||
.map(|row| serde_json::to_value(row).unwrap_or(json!({})))
|
||||
.collect();
|
||||
|
||||
let total: i64 = sqlx::query_scalar(
|
||||
"SELECT COUNT(*) FROM proxy_logs l
|
||||
JOIN downstream_api_keys k ON l.downstream_api_key_id = k.id
|
||||
WHERE k.user_id = ?",
|
||||
let (total, success_count, total_cost, total_tokens): (i64, i64, f64, i64) = sqlx::query_as(
|
||||
r#"
|
||||
SELECT
|
||||
COUNT(*),
|
||||
SUM(CASE WHEN l.status = 'success' THEN 1 ELSE 0 END),
|
||||
COALESCE(SUM(l.estimated_cost), 0.0),
|
||||
COALESCE(SUM(l.total_tokens), 0)
|
||||
FROM proxy_logs l
|
||||
JOIN downstream_api_keys k ON l.downstream_api_key_id = k.id
|
||||
WHERE k.user_id = ?
|
||||
AND (? IS NULL OR l.model_requested LIKE ?)
|
||||
AND (? IS NULL OR l.status = ?)
|
||||
AND (? IS NULL OR l.created_at >= ?)
|
||||
AND (? IS NULL OR l.created_at <= ?)
|
||||
"#,
|
||||
)
|
||||
.bind(user_id)
|
||||
.bind(q_like.as_deref())
|
||||
.bind(q_like.as_deref())
|
||||
.bind(status)
|
||||
.bind(status)
|
||||
.bind(date_from)
|
||||
.bind(date_from)
|
||||
.bind(date_to)
|
||||
.bind(date_to)
|
||||
.fetch_one(&*state.db)
|
||||
.await?;
|
||||
|
||||
Ok(Json(json!({
|
||||
"items": items,
|
||||
"total": total,
|
||||
"limit": limit,
|
||||
"offset": offset
|
||||
"page": page,
|
||||
"page_size": page_size,
|
||||
"summary": {
|
||||
"total": total,
|
||||
"success": success_count,
|
||||
"failed": total - success_count,
|
||||
"total_cost": total_cost,
|
||||
"total_tokens": total_tokens,
|
||||
}
|
||||
})))
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user