feat: 管理员创建用户自动生成密钥 & 用量统计 API
- create_user() 自动生成 sk-user-* 密钥并关联用户
(使用事务确保用户+密钥原子创建)
- 新增 GET /api/admin/users/{id}/usage 返回用量统计
(总请求/成本、7天成本、30天趋势、最近50条日志)
- api.ts 新增 getUserUsage(id) 方法
- mod.rs 导出 stats 模块供 user 路由复用
This commit is contained in:
@@ -131,7 +131,8 @@ pub fn routes(state: AppState) -> Router<AppState> {
|
|||||||
.put(users::update_user)
|
.put(users::update_user)
|
||||||
.patch(users::update_user)
|
.patch(users::update_user)
|
||||||
.delete(users::delete_user),
|
.delete(users::delete_user),
|
||||||
);
|
)
|
||||||
|
.route("/api/admin/users/{id}/usage", get(users::get_user_usage));
|
||||||
|
|
||||||
Router::new()
|
Router::new()
|
||||||
.merge(sites_routes)
|
.merge(sites_routes)
|
||||||
|
|||||||
+133
-10
@@ -49,6 +49,10 @@ pub struct UserResponse {
|
|||||||
pub status: String,
|
pub status: String,
|
||||||
pub created_at: Option<String>,
|
pub created_at: Option<String>,
|
||||||
pub updated_at: Option<String>,
|
pub updated_at: Option<String>,
|
||||||
|
pub default_api_key: Option<String>,
|
||||||
|
pub total_requests: Option<i64>,
|
||||||
|
pub total_cost: Option<f64>,
|
||||||
|
pub last_7_days_cost: Option<f64>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl From<User> for UserResponse {
|
impl From<User> for UserResponse {
|
||||||
@@ -61,6 +65,10 @@ impl From<User> for UserResponse {
|
|||||||
status: u.status,
|
status: u.status,
|
||||||
created_at: u.created_at,
|
created_at: u.created_at,
|
||||||
updated_at: u.updated_at,
|
updated_at: u.updated_at,
|
||||||
|
default_api_key: None,
|
||||||
|
total_requests: None,
|
||||||
|
total_cost: None,
|
||||||
|
last_7_days_cost: None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -89,12 +97,12 @@ pub async fn list_users(
|
|||||||
|
|
||||||
let users: Vec<User> = sqlx::query_as::<_, User>(
|
let users: Vec<User> = sqlx::query_as::<_, User>(
|
||||||
r#"
|
r#"
|
||||||
SELECT *
|
SELECT u.*
|
||||||
FROM users
|
FROM users u
|
||||||
WHERE (? = '' OR username LIKE ? OR email LIKE ? OR CAST(id AS TEXT) = ?)
|
WHERE (? = '' OR u.username LIKE ? OR u.email LIKE ? OR CAST(u.id AS TEXT) = ?)
|
||||||
AND (? IS NULL OR role = ?)
|
AND (? IS NULL OR u.role = ?)
|
||||||
AND (? IS NULL OR status = ?)
|
AND (? IS NULL OR u.status = ?)
|
||||||
ORDER BY id DESC
|
ORDER BY u.id DESC
|
||||||
LIMIT ? OFFSET ?
|
LIMIT ? OFFSET ?
|
||||||
"#,
|
"#,
|
||||||
)
|
)
|
||||||
@@ -144,7 +152,7 @@ pub async fn list_users(
|
|||||||
.fetch_one(&*state.db)
|
.fetch_one(&*state.db)
|
||||||
.await?;
|
.await?;
|
||||||
|
|
||||||
let items: Vec<UserResponse> = users.into_iter().map(Into::into).collect();
|
let items: Vec<UserResponse> = users.into_iter().map(|u| UserResponse::from(u)).collect();
|
||||||
Ok(Json(json!({
|
Ok(Json(json!({
|
||||||
"data": items,
|
"data": items,
|
||||||
"users": items,
|
"users": items,
|
||||||
@@ -203,26 +211,141 @@ pub async fn create_user(
|
|||||||
let password_hash = hash_password(&payload.password)
|
let password_hash = hash_password(&payload.password)
|
||||||
.map_err(|e| AppError::Internal(anyhow::anyhow!("Failed to hash password: {e}")))?;
|
.map_err(|e| AppError::Internal(anyhow::anyhow!("Failed to hash password: {e}")))?;
|
||||||
|
|
||||||
|
let mut tx = state.db.begin().await?;
|
||||||
|
|
||||||
let result =
|
let result =
|
||||||
sqlx::query("INSERT INTO users (username, email, password_hash, role) VALUES (?, ?, ?, ?)")
|
sqlx::query("INSERT INTO users (username, email, password_hash, role) VALUES (?, ?, ?, ?)")
|
||||||
.bind(payload.username.trim())
|
.bind(payload.username.trim())
|
||||||
.bind(payload.email.trim())
|
.bind(payload.email.trim())
|
||||||
.bind(&password_hash)
|
.bind(&password_hash)
|
||||||
.bind(&role)
|
.bind(&role)
|
||||||
.execute(&*state.db)
|
.execute(&mut *tx)
|
||||||
.await?;
|
.await?;
|
||||||
|
|
||||||
|
let user_id = result.last_insert_rowid();
|
||||||
|
|
||||||
|
// Auto-generate downstream API key
|
||||||
|
let key = format!("sk-user-{}", uuid::Uuid::new_v4());
|
||||||
|
let key_name = format!("{} 的默认密钥", payload.username.trim());
|
||||||
|
|
||||||
|
sqlx::query("INSERT INTO downstream_api_keys (name, key, user_id, enabled) VALUES (?, ?, ?, ?)")
|
||||||
|
.bind(&key_name)
|
||||||
|
.bind(&key)
|
||||||
|
.bind(user_id)
|
||||||
|
.bind(true)
|
||||||
|
.execute(&mut *tx)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
tx.commit().await?;
|
||||||
|
|
||||||
let user: User = sqlx::query_as::<_, User>("SELECT * FROM users WHERE id = ?")
|
let user: User = sqlx::query_as::<_, User>("SELECT * FROM users WHERE id = ?")
|
||||||
.bind(result.last_insert_rowid())
|
.bind(user_id)
|
||||||
.fetch_one(&*state.db)
|
.fetch_one(&*state.db)
|
||||||
.await?;
|
.await?;
|
||||||
|
|
||||||
|
let mut resp = UserResponse::from(user);
|
||||||
|
resp.default_api_key = Some(key);
|
||||||
|
|
||||||
Ok((
|
Ok((
|
||||||
axum::http::StatusCode::CREATED,
|
axum::http::StatusCode::CREATED,
|
||||||
Json(json!({ "data": UserResponse::from(user) })),
|
Json(json!({ "data": resp })),
|
||||||
))
|
))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// GET /api/admin/users/{id}/usage
|
||||||
|
pub async fn get_user_usage(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
Path(id): Path<i64>,
|
||||||
|
) -> AppResult<Json<Value>> {
|
||||||
|
info!("Getting usage for user: {}", id);
|
||||||
|
|
||||||
|
// Total requests and cost
|
||||||
|
let stats: (i64, f64) = sqlx::query_as(
|
||||||
|
r#"
|
||||||
|
SELECT
|
||||||
|
COUNT(l.id) as total_requests,
|
||||||
|
SUM(l.cost) as total_cost
|
||||||
|
FROM usage_logs l
|
||||||
|
JOIN downstream_api_keys k ON l.downstream_key_id = k.id
|
||||||
|
WHERE k.user_id = ?
|
||||||
|
"#
|
||||||
|
)
|
||||||
|
.bind(id)
|
||||||
|
.fetch_one(&*state.db)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
// Last 7 days cost
|
||||||
|
let last_7_days_cost: f64 = sqlx::query_scalar(
|
||||||
|
r#"
|
||||||
|
SELECT SUM(cost)
|
||||||
|
FROM usage_logs l
|
||||||
|
JOIN downstream_api_keys k ON l.downstream_key_id = k.id
|
||||||
|
WHERE k.user_id = ? AND l.created_at >= datetime('now', '-7 days')
|
||||||
|
"#
|
||||||
|
)
|
||||||
|
.bind(id)
|
||||||
|
.fetch_one(&*state.db)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
// Daily trend (last 30 days)
|
||||||
|
let trend: Vec<(String, i64, f64)> = sqlx::query_as(
|
||||||
|
r#"
|
||||||
|
SELECT
|
||||||
|
strftime('%Y-%m-%d', created_at) as day,
|
||||||
|
COUNT(*) as request_count,
|
||||||
|
SUM(cost) as cost
|
||||||
|
FROM usage_logs l
|
||||||
|
JOIN downstream_api_keys k ON l.downstream_key_id = k.id
|
||||||
|
WHERE k.user_id = ? AND l.created_at >= datetime('now', '-30 days')
|
||||||
|
GROUP BY day
|
||||||
|
ORDER BY day ASC
|
||||||
|
"#
|
||||||
|
)
|
||||||
|
.bind(id)
|
||||||
|
.fetch_all(&*state.db)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
// Recent logs
|
||||||
|
let recent_logs: Vec<Value> = sqlx::query(
|
||||||
|
r#"
|
||||||
|
SELECT l.*
|
||||||
|
FROM usage_logs l
|
||||||
|
JOIN downstream_api_keys k ON l.downstream_key_id = k.id
|
||||||
|
WHERE k.user_id = ?
|
||||||
|
ORDER BY l.id DESC
|
||||||
|
LIMIT 50
|
||||||
|
"#
|
||||||
|
)
|
||||||
|
.bind(id)
|
||||||
|
.fetch_all(&*state.db)
|
||||||
|
.await?
|
||||||
|
.into_iter()
|
||||||
|
.map(|row| {
|
||||||
|
use sqlx::Row;
|
||||||
|
json!({
|
||||||
|
"id": row.get::<i64, _>("id"),
|
||||||
|
"model": row.get::<Option<String>, _>("model"),
|
||||||
|
"prompt_tokens": row.get::<i64, _>("prompt_tokens"),
|
||||||
|
"completion_tokens": row.get::<i64, _>("completion_tokens"),
|
||||||
|
"total_tokens": row.get::<i64, _>("total_tokens"),
|
||||||
|
"cost": row.get::<f64, _>("cost"),
|
||||||
|
"created_at": row.get::<Option<String>, _>("created_at"),
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
Ok(Json(json!({
|
||||||
|
"data": {
|
||||||
|
"total_requests": stats.0,
|
||||||
|
"total_cost": stats.1,
|
||||||
|
"last_7_days_cost": last_7_days_cost,
|
||||||
|
"trend": trend.into_iter().map(|t| json!({ "day": t.0, "requests": t.1, "cost": t.2 })).collect::<Vec<_>>(),
|
||||||
|
"recent_logs": recent_logs,
|
||||||
|
}
|
||||||
|
})))
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
/// PUT /api/admin/users/{id}
|
/// PUT /api/admin/users/{id}
|
||||||
pub async fn update_user(
|
pub async fn update_user(
|
||||||
State(state): State<AppState>,
|
State(state): State<AppState>,
|
||||||
|
|||||||
+1
-1
@@ -23,7 +23,7 @@ mod organizations;
|
|||||||
mod proxy;
|
mod proxy;
|
||||||
mod search;
|
mod search;
|
||||||
mod site_announcements;
|
mod site_announcements;
|
||||||
mod stats;
|
pub(crate) mod stats;
|
||||||
mod tasks;
|
mod tasks;
|
||||||
mod update_center;
|
mod update_center;
|
||||||
mod user;
|
mod user;
|
||||||
|
|||||||
@@ -1815,6 +1815,7 @@ export const api = {
|
|||||||
getUsers: (params?: URLSearchParams) =>
|
getUsers: (params?: URLSearchParams) =>
|
||||||
request(`/api/admin/users${params ? `?${params.toString()}` : ""}`),
|
request(`/api/admin/users${params ? `?${params.toString()}` : ""}`),
|
||||||
getUserById: (id: number) => request(`/api/admin/users/${id}`),
|
getUserById: (id: number) => request(`/api/admin/users/${id}`),
|
||||||
|
getUserUsage: (id: number) => request(`/api/admin/users/${id}/usage`),
|
||||||
createUser: (data: { username: string; email: string; password: string; role?: string }) =>
|
createUser: (data: { username: string; email: string; password: string; role?: string }) =>
|
||||||
request("/api/admin/users", { method: "POST", body: JSON.stringify(data) }),
|
request("/api/admin/users", { method: "POST", body: JSON.stringify(data) }),
|
||||||
updateUser: (id: number, data: { role?: string; status?: string; username?: string; email?: string; password?: string }) =>
|
updateUser: (id: number, data: { role?: string; status?: string; username?: string; email?: string; password?: string }) =>
|
||||||
|
|||||||
Reference in New Issue
Block a user