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:
boos
2026-05-19 02:47:15 +08:00
parent a0d3743564
commit 1cef28169c
4 changed files with 137 additions and 12 deletions
+2 -1
View File
@@ -131,7 +131,8 @@ pub fn routes(state: AppState) -> Router<AppState> {
.put(users::update_user)
.patch(users::update_user)
.delete(users::delete_user),
);
)
.route("/api/admin/users/{id}/usage", get(users::get_user_usage));
Router::new()
.merge(sites_routes)
+133 -10
View File
@@ -49,6 +49,10 @@ pub struct UserResponse {
pub status: String,
pub created_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 {
@@ -61,6 +65,10 @@ impl From<User> for UserResponse {
status: u.status,
created_at: u.created_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>(
r#"
SELECT *
FROM users
WHERE (? = '' OR username LIKE ? OR email LIKE ? OR CAST(id AS TEXT) = ?)
AND (? IS NULL OR role = ?)
AND (? IS NULL OR status = ?)
ORDER BY id DESC
SELECT u.*
FROM users u
WHERE (? = '' OR u.username LIKE ? OR u.email LIKE ? OR CAST(u.id AS TEXT) = ?)
AND (? IS NULL OR u.role = ?)
AND (? IS NULL OR u.status = ?)
ORDER BY u.id DESC
LIMIT ? OFFSET ?
"#,
)
@@ -144,7 +152,7 @@ pub async fn list_users(
.fetch_one(&*state.db)
.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!({
"data": items,
"users": items,
@@ -203,26 +211,141 @@ pub async fn create_user(
let password_hash = hash_password(&payload.password)
.map_err(|e| AppError::Internal(anyhow::anyhow!("Failed to hash password: {e}")))?;
let mut tx = state.db.begin().await?;
let result =
sqlx::query("INSERT INTO users (username, email, password_hash, role) VALUES (?, ?, ?, ?)")
.bind(payload.username.trim())
.bind(payload.email.trim())
.bind(&password_hash)
.bind(&role)
.execute(&*state.db)
.execute(&mut *tx)
.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 = ?")
.bind(result.last_insert_rowid())
.bind(user_id)
.fetch_one(&*state.db)
.await?;
let mut resp = UserResponse::from(user);
resp.default_api_key = Some(key);
Ok((
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}
pub async fn update_user(
State(state): State<AppState>,
+1 -1
View File
@@ -23,7 +23,7 @@ mod organizations;
mod proxy;
mod search;
mod site_announcements;
mod stats;
pub(crate) mod stats;
mod tasks;
mod update_center;
mod user;
+1
View File
@@ -1815,6 +1815,7 @@ export const api = {
getUsers: (params?: URLSearchParams) =>
request(`/api/admin/users${params ? `?${params.toString()}` : ""}`),
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 }) =>
request("/api/admin/users", { method: "POST", body: JSON.stringify(data) }),
updateUser: (id: number, data: { role?: string; status?: string; username?: string; email?: string; password?: string }) =>