Implement compression quota refunds and admin manual subscription
This commit is contained in:
912
src/api/user.rs
Normal file
912
src/api/user.rs
Normal file
@@ -0,0 +1,912 @@
|
||||
use crate::api::context;
|
||||
use crate::api::envelope::Envelope;
|
||||
use crate::error::{AppError, ErrorCode};
|
||||
use crate::services::billing;
|
||||
use crate::services::mail;
|
||||
use crate::state::AppState;
|
||||
|
||||
use argon2::{Argon2, PasswordHash, PasswordHasher, PasswordVerifier};
|
||||
use axum::extract::{ConnectInfo, Path, Query, State};
|
||||
use axum::http::HeaderMap;
|
||||
use axum::routing::{delete, get, post, put};
|
||||
use axum::{Json, Router};
|
||||
use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine as _};
|
||||
use chrono::{DateTime, Duration, Utc};
|
||||
use rand::RngCore;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use sha2::{Digest, Sha256};
|
||||
use sqlx::FromRow;
|
||||
use std::net::SocketAddr;
|
||||
use uuid::Uuid;
|
||||
|
||||
pub fn router() -> Router<AppState> {
|
||||
Router::new()
|
||||
.route("/user/profile", get(get_profile))
|
||||
.route("/user/profile", put(update_profile))
|
||||
.route("/user/password", put(update_password))
|
||||
.route("/user/history", get(list_history))
|
||||
.route("/user/api-keys", get(list_api_keys))
|
||||
.route("/user/api-keys", post(create_api_key))
|
||||
.route("/user/api-keys/{key_id}/rotate", post(rotate_api_key))
|
||||
.route("/user/api-keys/{key_id}", delete(disable_api_key))
|
||||
}
|
||||
|
||||
#[derive(Debug, FromRow, Serialize)]
|
||||
struct ApiKeyView {
|
||||
id: Uuid,
|
||||
name: String,
|
||||
key_prefix: String,
|
||||
permissions: serde_json::Value,
|
||||
rate_limit: i32,
|
||||
is_active: bool,
|
||||
last_used_at: Option<chrono::DateTime<chrono::Utc>>,
|
||||
last_used_ip: Option<String>,
|
||||
created_at: chrono::DateTime<chrono::Utc>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
struct ApiKeyListResponse {
|
||||
api_keys: Vec<ApiKeyView>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
struct UserView {
|
||||
id: Uuid,
|
||||
email: String,
|
||||
username: String,
|
||||
role: String,
|
||||
email_verified: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
struct MessageResponse {
|
||||
message: String,
|
||||
}
|
||||
|
||||
async fn get_profile(
|
||||
State(state): State<AppState>,
|
||||
jar: axum_extra::extract::cookie::CookieJar,
|
||||
ConnectInfo(addr): ConnectInfo<SocketAddr>,
|
||||
headers: HeaderMap,
|
||||
) -> Result<Json<Envelope<UserView>>, AppError> {
|
||||
let ip = context::client_ip(&headers, addr.ip());
|
||||
let (_jar, principal) = context::authenticate(&state, jar, &headers, ip).await?;
|
||||
|
||||
let user_id = match principal {
|
||||
context::Principal::User { user_id, .. } => user_id,
|
||||
_ => return Err(AppError::new(ErrorCode::Unauthorized, "未登录")),
|
||||
};
|
||||
|
||||
#[derive(Debug, FromRow)]
|
||||
struct UserRow {
|
||||
id: Uuid,
|
||||
email: String,
|
||||
username: String,
|
||||
role: String,
|
||||
email_verified_at: Option<DateTime<Utc>>,
|
||||
}
|
||||
|
||||
let user = sqlx::query_as::<_, UserRow>(
|
||||
r#"
|
||||
SELECT id, email, username, role::text AS role, email_verified_at
|
||||
FROM users
|
||||
WHERE id = $1
|
||||
"#,
|
||||
)
|
||||
.bind(user_id)
|
||||
.fetch_one(&state.db)
|
||||
.await
|
||||
.map_err(|err| AppError::new(ErrorCode::Internal, "查询用户失败").with_source(err))?;
|
||||
|
||||
Ok(Json(Envelope {
|
||||
success: true,
|
||||
data: UserView {
|
||||
id: user.id,
|
||||
email: user.email,
|
||||
username: user.username,
|
||||
role: user.role,
|
||||
email_verified: user.email_verified_at.is_some(),
|
||||
},
|
||||
}))
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct UpdateProfileRequest {
|
||||
email: Option<String>,
|
||||
username: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
struct UpdateProfileResponse {
|
||||
user: UserView,
|
||||
message: String,
|
||||
}
|
||||
|
||||
async fn update_profile(
|
||||
State(state): State<AppState>,
|
||||
jar: axum_extra::extract::cookie::CookieJar,
|
||||
ConnectInfo(addr): ConnectInfo<SocketAddr>,
|
||||
headers: HeaderMap,
|
||||
Json(req): Json<UpdateProfileRequest>,
|
||||
) -> Result<Json<Envelope<UpdateProfileResponse>>, AppError> {
|
||||
let ip = context::client_ip(&headers, addr.ip());
|
||||
let (_jar, principal) = context::authenticate(&state, jar, &headers, ip).await?;
|
||||
|
||||
let user_id = match principal {
|
||||
context::Principal::User { user_id, .. } => user_id,
|
||||
_ => return Err(AppError::new(ErrorCode::Unauthorized, "未登录")),
|
||||
};
|
||||
|
||||
if req.email.is_none() && req.username.is_none() {
|
||||
return Err(AppError::new(ErrorCode::InvalidRequest, "未提供可更新字段"));
|
||||
}
|
||||
|
||||
#[derive(Debug, FromRow)]
|
||||
struct UserRow {
|
||||
id: Uuid,
|
||||
email: String,
|
||||
username: String,
|
||||
role: String,
|
||||
email_verified_at: Option<DateTime<Utc>>,
|
||||
}
|
||||
|
||||
let user = sqlx::query_as::<_, UserRow>(
|
||||
r#"
|
||||
SELECT id, email, username, role::text AS role, email_verified_at
|
||||
FROM users
|
||||
WHERE id = $1
|
||||
"#,
|
||||
)
|
||||
.bind(user_id)
|
||||
.fetch_one(&state.db)
|
||||
.await
|
||||
.map_err(|err| AppError::new(ErrorCode::Internal, "查询用户失败").with_source(err))?;
|
||||
|
||||
let mut next_email = user.email.clone();
|
||||
let mut next_username = user.username.clone();
|
||||
let mut email_changed = false;
|
||||
|
||||
if let Some(email) = req.email.as_ref() {
|
||||
let email = email.trim().to_lowercase();
|
||||
validate_email(&email)?;
|
||||
if email != user.email {
|
||||
next_email = email;
|
||||
email_changed = true;
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(username) = req.username.as_ref() {
|
||||
let username = username.trim().to_string();
|
||||
validate_username(&username)?;
|
||||
if username != user.username {
|
||||
next_username = username;
|
||||
}
|
||||
}
|
||||
|
||||
if next_email == user.email && next_username == user.username {
|
||||
return Ok(Json(Envelope {
|
||||
success: true,
|
||||
data: UpdateProfileResponse {
|
||||
user: UserView {
|
||||
id: user.id,
|
||||
email: user.email,
|
||||
username: user.username,
|
||||
role: user.role,
|
||||
email_verified: user.email_verified_at.is_some(),
|
||||
},
|
||||
message: "暂无更新".to_string(),
|
||||
},
|
||||
}));
|
||||
}
|
||||
|
||||
let mut tx = state
|
||||
.db
|
||||
.begin()
|
||||
.await
|
||||
.map_err(|err| AppError::new(ErrorCode::Internal, "开启事务失败").with_source(err))?;
|
||||
|
||||
let email_verified_at = if email_changed { None } else { user.email_verified_at };
|
||||
|
||||
let updated = sqlx::query_as::<_, UserRow>(
|
||||
r#"
|
||||
UPDATE users
|
||||
SET email = $2,
|
||||
username = $3,
|
||||
email_verified_at = $4,
|
||||
updated_at = NOW()
|
||||
WHERE id = $1
|
||||
RETURNING id, email, username, role::text AS role, email_verified_at
|
||||
"#,
|
||||
)
|
||||
.bind(user_id)
|
||||
.bind(&next_email)
|
||||
.bind(&next_username)
|
||||
.bind(email_verified_at)
|
||||
.fetch_one(&mut *tx)
|
||||
.await
|
||||
.map_err(map_unique_violation)?;
|
||||
|
||||
let mut verification_link: Option<String> = None;
|
||||
if email_changed {
|
||||
let token = generate_token();
|
||||
let token_hash = sha256_hex(&token);
|
||||
let expires_at = Utc::now() + Duration::hours(24);
|
||||
|
||||
sqlx::query(
|
||||
r#"
|
||||
INSERT INTO email_verifications (user_id, token_hash, expires_at)
|
||||
VALUES ($1, $2, $3)
|
||||
"#,
|
||||
)
|
||||
.bind(user_id)
|
||||
.bind(token_hash)
|
||||
.bind(expires_at)
|
||||
.execute(&mut *tx)
|
||||
.await
|
||||
.map_err(|err| AppError::new(ErrorCode::Internal, "创建邮箱验证记录失败").with_source(err))?;
|
||||
|
||||
verification_link = Some(format!(
|
||||
"{}/verify-email?token={}",
|
||||
state.config.public_base_url, token
|
||||
));
|
||||
}
|
||||
|
||||
tx.commit()
|
||||
.await
|
||||
.map_err(|err| AppError::new(ErrorCode::Internal, "提交事务失败").with_source(err))?;
|
||||
|
||||
if let Some(link) = verification_link.as_deref() {
|
||||
mail::send_verification_email(&state, &updated.email, &updated.username, link)
|
||||
.await
|
||||
.map_err(|err| AppError::new(ErrorCode::MailSendFailed, "验证邮件发送失败").with_source(err))?;
|
||||
}
|
||||
|
||||
let message = if email_changed {
|
||||
"资料已更新,请验证新邮箱".to_string()
|
||||
} else {
|
||||
"资料已更新".to_string()
|
||||
};
|
||||
|
||||
Ok(Json(Envelope {
|
||||
success: true,
|
||||
data: UpdateProfileResponse {
|
||||
user: UserView {
|
||||
id: updated.id,
|
||||
email: updated.email,
|
||||
username: updated.username,
|
||||
role: updated.role,
|
||||
email_verified: updated.email_verified_at.is_some(),
|
||||
},
|
||||
message,
|
||||
},
|
||||
}))
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct UpdatePasswordRequest {
|
||||
current_password: String,
|
||||
new_password: String,
|
||||
}
|
||||
|
||||
async fn update_password(
|
||||
State(state): State<AppState>,
|
||||
jar: axum_extra::extract::cookie::CookieJar,
|
||||
ConnectInfo(addr): ConnectInfo<SocketAddr>,
|
||||
headers: HeaderMap,
|
||||
Json(req): Json<UpdatePasswordRequest>,
|
||||
) -> Result<Json<Envelope<MessageResponse>>, AppError> {
|
||||
let ip = context::client_ip(&headers, addr.ip());
|
||||
let (_jar, principal) = context::authenticate(&state, jar, &headers, ip).await?;
|
||||
|
||||
let user_id = match principal {
|
||||
context::Principal::User { user_id, .. } => user_id,
|
||||
_ => return Err(AppError::new(ErrorCode::Unauthorized, "未登录")),
|
||||
};
|
||||
|
||||
validate_password(&req.new_password)?;
|
||||
|
||||
#[derive(Debug, FromRow)]
|
||||
struct PasswordRow {
|
||||
password_hash: String,
|
||||
}
|
||||
|
||||
let row = sqlx::query_as::<_, PasswordRow>("SELECT password_hash FROM users WHERE id = $1")
|
||||
.bind(user_id)
|
||||
.fetch_one(&state.db)
|
||||
.await
|
||||
.map_err(|err| AppError::new(ErrorCode::Internal, "查询用户失败").with_source(err))?;
|
||||
|
||||
verify_password(&req.current_password, &row.password_hash)?;
|
||||
|
||||
let new_hash = hash_password(&req.new_password)?;
|
||||
sqlx::query("UPDATE users SET password_hash = $2, updated_at = NOW() WHERE id = $1")
|
||||
.bind(user_id)
|
||||
.bind(new_hash)
|
||||
.execute(&state.db)
|
||||
.await
|
||||
.map_err(|err| AppError::new(ErrorCode::Internal, "更新密码失败").with_source(err))?;
|
||||
|
||||
Ok(Json(Envelope {
|
||||
success: true,
|
||||
data: MessageResponse {
|
||||
message: "密码已更新,请重新登录以确保安全".to_string(),
|
||||
},
|
||||
}))
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct HistoryQuery {
|
||||
page: Option<u32>,
|
||||
limit: Option<u32>,
|
||||
status: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
struct HistoryFileView {
|
||||
file_id: Uuid,
|
||||
original_name: String,
|
||||
original_size: i64,
|
||||
compressed_size: Option<i64>,
|
||||
saved_percent: Option<f64>,
|
||||
status: String,
|
||||
output_format: String,
|
||||
error_message: Option<String>,
|
||||
download_url: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
struct HistoryTaskView {
|
||||
task_id: Uuid,
|
||||
status: String,
|
||||
source: String,
|
||||
progress: i32,
|
||||
total_files: i32,
|
||||
completed_files: i32,
|
||||
failed_files: i32,
|
||||
created_at: DateTime<Utc>,
|
||||
completed_at: Option<DateTime<Utc>>,
|
||||
expires_at: DateTime<Utc>,
|
||||
download_all_url: Option<String>,
|
||||
files: Vec<HistoryFileView>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
struct HistoryResponse {
|
||||
tasks: Vec<HistoryTaskView>,
|
||||
page: u32,
|
||||
limit: u32,
|
||||
total: i64,
|
||||
}
|
||||
|
||||
async fn list_history(
|
||||
State(state): State<AppState>,
|
||||
jar: axum_extra::extract::cookie::CookieJar,
|
||||
ConnectInfo(addr): ConnectInfo<SocketAddr>,
|
||||
headers: HeaderMap,
|
||||
Query(query): Query<HistoryQuery>,
|
||||
) -> Result<Json<Envelope<HistoryResponse>>, AppError> {
|
||||
let ip = context::client_ip(&headers, addr.ip());
|
||||
let (_jar, principal) = context::authenticate(&state, jar, &headers, ip).await?;
|
||||
|
||||
let user_id = match principal {
|
||||
context::Principal::User { user_id, .. } => user_id,
|
||||
_ => return Err(AppError::new(ErrorCode::Unauthorized, "未登录")),
|
||||
};
|
||||
|
||||
let limit = query.limit.unwrap_or(20).clamp(1, 100);
|
||||
let page = query.page.unwrap_or(1).max(1);
|
||||
let offset = (page - 1) * limit;
|
||||
let status = query.status.map(|s| s.trim().to_string()).filter(|s| !s.is_empty());
|
||||
|
||||
let total: i64 = if let Some(status) = &status {
|
||||
sqlx::query_scalar(
|
||||
"SELECT COUNT(*) FROM tasks WHERE user_id = $1 AND status::text = $2",
|
||||
)
|
||||
.bind(user_id)
|
||||
.bind(status)
|
||||
.fetch_one(&state.db)
|
||||
.await
|
||||
.map_err(|err| AppError::new(ErrorCode::Internal, "查询历史失败").with_source(err))?
|
||||
} else {
|
||||
sqlx::query_scalar("SELECT COUNT(*) FROM tasks WHERE user_id = $1")
|
||||
.bind(user_id)
|
||||
.fetch_one(&state.db)
|
||||
.await
|
||||
.map_err(|err| AppError::new(ErrorCode::Internal, "查询历史失败").with_source(err))?
|
||||
};
|
||||
|
||||
#[derive(Debug, FromRow)]
|
||||
struct TaskRow {
|
||||
id: Uuid,
|
||||
status: String,
|
||||
source: String,
|
||||
total_files: i32,
|
||||
completed_files: i32,
|
||||
failed_files: i32,
|
||||
created_at: DateTime<Utc>,
|
||||
completed_at: Option<DateTime<Utc>>,
|
||||
expires_at: DateTime<Utc>,
|
||||
}
|
||||
|
||||
let tasks: Vec<TaskRow> = if let Some(status) = &status {
|
||||
sqlx::query_as::<_, TaskRow>(
|
||||
r#"
|
||||
SELECT
|
||||
id,
|
||||
status::text AS status,
|
||||
source::text AS source,
|
||||
total_files,
|
||||
completed_files,
|
||||
failed_files,
|
||||
created_at,
|
||||
completed_at,
|
||||
expires_at
|
||||
FROM tasks
|
||||
WHERE user_id = $1 AND status::text = $2
|
||||
ORDER BY created_at DESC
|
||||
LIMIT $3 OFFSET $4
|
||||
"#,
|
||||
)
|
||||
.bind(user_id)
|
||||
.bind(status)
|
||||
.bind(limit as i64)
|
||||
.bind(offset as i64)
|
||||
.fetch_all(&state.db)
|
||||
.await
|
||||
.map_err(|err| AppError::new(ErrorCode::Internal, "查询历史失败").with_source(err))?
|
||||
} else {
|
||||
sqlx::query_as::<_, TaskRow>(
|
||||
r#"
|
||||
SELECT
|
||||
id,
|
||||
status::text AS status,
|
||||
source::text AS source,
|
||||
total_files,
|
||||
completed_files,
|
||||
failed_files,
|
||||
created_at,
|
||||
completed_at,
|
||||
expires_at
|
||||
FROM tasks
|
||||
WHERE user_id = $1
|
||||
ORDER BY created_at DESC
|
||||
LIMIT $2 OFFSET $3
|
||||
"#,
|
||||
)
|
||||
.bind(user_id)
|
||||
.bind(limit as i64)
|
||||
.bind(offset as i64)
|
||||
.fetch_all(&state.db)
|
||||
.await
|
||||
.map_err(|err| AppError::new(ErrorCode::Internal, "查询历史失败").with_source(err))?
|
||||
};
|
||||
|
||||
#[derive(Debug, FromRow)]
|
||||
struct FileRow {
|
||||
id: Uuid,
|
||||
original_name: String,
|
||||
original_size: i64,
|
||||
compressed_size: Option<i64>,
|
||||
saved_percent: Option<f64>,
|
||||
status: String,
|
||||
output_format: String,
|
||||
error_message: Option<String>,
|
||||
storage_path: Option<String>,
|
||||
}
|
||||
|
||||
let now = Utc::now();
|
||||
let mut result_tasks = Vec::with_capacity(tasks.len());
|
||||
for task in tasks {
|
||||
let files: Vec<FileRow> = sqlx::query_as::<_, FileRow>(
|
||||
r#"
|
||||
SELECT
|
||||
id,
|
||||
original_name,
|
||||
original_size,
|
||||
compressed_size,
|
||||
saved_percent::float8 AS saved_percent,
|
||||
status::text AS status,
|
||||
output_format,
|
||||
error_message,
|
||||
storage_path
|
||||
FROM task_files
|
||||
WHERE task_id = $1
|
||||
ORDER BY created_at ASC
|
||||
"#,
|
||||
)
|
||||
.bind(task.id)
|
||||
.fetch_all(&state.db)
|
||||
.await
|
||||
.map_err(|err| AppError::new(ErrorCode::Internal, "查询任务文件失败").with_source(err))?;
|
||||
|
||||
let file_views = files
|
||||
.into_iter()
|
||||
.map(|file| HistoryFileView {
|
||||
file_id: file.id,
|
||||
original_name: file.original_name,
|
||||
original_size: file.original_size,
|
||||
compressed_size: file.compressed_size,
|
||||
saved_percent: file.saved_percent,
|
||||
status: file.status.clone(),
|
||||
output_format: file.output_format,
|
||||
error_message: file.error_message,
|
||||
download_url: if file.status == "completed" && file.storage_path.is_some() && task.expires_at > now {
|
||||
Some(format!("/downloads/{}", file.id))
|
||||
} else {
|
||||
None
|
||||
},
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
let progress = if task.total_files > 0 {
|
||||
((task.completed_files + task.failed_files) * 100 / task.total_files).clamp(0, 100)
|
||||
} else {
|
||||
0
|
||||
};
|
||||
|
||||
let download_all_url = if task.status == "completed" && task.expires_at > now {
|
||||
Some(format!("/downloads/tasks/{}", task.id))
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
result_tasks.push(HistoryTaskView {
|
||||
task_id: task.id,
|
||||
status: task.status,
|
||||
source: task.source,
|
||||
progress,
|
||||
total_files: task.total_files,
|
||||
completed_files: task.completed_files,
|
||||
failed_files: task.failed_files,
|
||||
created_at: task.created_at,
|
||||
completed_at: task.completed_at,
|
||||
expires_at: task.expires_at,
|
||||
download_all_url,
|
||||
files: file_views,
|
||||
});
|
||||
}
|
||||
|
||||
Ok(Json(Envelope {
|
||||
success: true,
|
||||
data: HistoryResponse {
|
||||
tasks: result_tasks,
|
||||
page,
|
||||
limit,
|
||||
total,
|
||||
},
|
||||
}))
|
||||
}
|
||||
|
||||
async fn list_api_keys(
|
||||
State(state): State<AppState>,
|
||||
jar: axum_extra::extract::cookie::CookieJar,
|
||||
ConnectInfo(addr): ConnectInfo<SocketAddr>,
|
||||
headers: HeaderMap,
|
||||
) -> Result<Json<Envelope<ApiKeyListResponse>>, AppError> {
|
||||
let ip = context::client_ip(&headers, addr.ip());
|
||||
let (_jar, principal) = context::authenticate(&state, jar, &headers, ip).await?;
|
||||
|
||||
let (user_id, _email_verified) = match principal {
|
||||
context::Principal::User {
|
||||
user_id,
|
||||
email_verified,
|
||||
..
|
||||
} => (user_id, email_verified),
|
||||
_ => return Err(AppError::new(ErrorCode::Unauthorized, "未登录")),
|
||||
};
|
||||
|
||||
let rows = sqlx::query_as::<_, ApiKeyView>(
|
||||
r#"
|
||||
SELECT
|
||||
id,
|
||||
name,
|
||||
key_prefix,
|
||||
permissions,
|
||||
rate_limit,
|
||||
is_active,
|
||||
last_used_at,
|
||||
last_used_ip::text AS last_used_ip,
|
||||
created_at
|
||||
FROM api_keys
|
||||
WHERE user_id = $1
|
||||
ORDER BY created_at DESC
|
||||
"#,
|
||||
)
|
||||
.bind(user_id)
|
||||
.fetch_all(&state.db)
|
||||
.await
|
||||
.map_err(|err| AppError::new(ErrorCode::Internal, "查询 API Key 失败").with_source(err))?;
|
||||
|
||||
Ok(Json(Envelope {
|
||||
success: true,
|
||||
data: ApiKeyListResponse { api_keys: rows },
|
||||
}))
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct CreateApiKeyRequest {
|
||||
name: String,
|
||||
permissions: Option<Vec<String>>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
struct CreateApiKeyResponse {
|
||||
id: Uuid,
|
||||
name: String,
|
||||
key_prefix: String,
|
||||
key: String,
|
||||
message: String,
|
||||
}
|
||||
|
||||
async fn create_api_key(
|
||||
State(state): State<AppState>,
|
||||
jar: axum_extra::extract::cookie::CookieJar,
|
||||
ConnectInfo(addr): ConnectInfo<SocketAddr>,
|
||||
headers: HeaderMap,
|
||||
Json(req): Json<CreateApiKeyRequest>,
|
||||
) -> Result<Json<Envelope<CreateApiKeyResponse>>, AppError> {
|
||||
if req.name.trim().is_empty() || req.name.len() > 100 {
|
||||
return Err(AppError::new(ErrorCode::InvalidRequest, "name 不合法"));
|
||||
}
|
||||
|
||||
let ip = context::client_ip(&headers, addr.ip());
|
||||
let (_jar, principal) = context::authenticate(&state, jar, &headers, ip).await?;
|
||||
|
||||
let (user_id, email_verified) = match principal {
|
||||
context::Principal::User {
|
||||
user_id,
|
||||
email_verified,
|
||||
..
|
||||
} => (user_id, email_verified),
|
||||
_ => return Err(AppError::new(ErrorCode::Unauthorized, "未登录")),
|
||||
};
|
||||
|
||||
if !email_verified {
|
||||
return Err(AppError::new(ErrorCode::EmailNotVerified, "请先验证邮箱"));
|
||||
}
|
||||
|
||||
let billing = billing::get_user_billing(&state, user_id).await?;
|
||||
if !billing.plan.feature_api_enabled {
|
||||
return Err(AppError::new(ErrorCode::Forbidden, "当前套餐未开通 API Key"));
|
||||
}
|
||||
|
||||
let permissions = normalize_permissions(req.permissions)?;
|
||||
|
||||
let (full_key, key_prefix) = generate_api_key();
|
||||
let key_hash = context::api_key_hash(&full_key, &state.config.api_key_pepper)?;
|
||||
|
||||
let row_id: Uuid = sqlx::query_scalar(
|
||||
r#"
|
||||
INSERT INTO api_keys (user_id, name, key_prefix, key_hash, permissions, rate_limit)
|
||||
VALUES ($1, $2, $3, $4, $5, 100)
|
||||
RETURNING id
|
||||
"#,
|
||||
)
|
||||
.bind(user_id)
|
||||
.bind(req.name.trim())
|
||||
.bind(&key_prefix)
|
||||
.bind(key_hash)
|
||||
.bind(&permissions)
|
||||
.fetch_one(&state.db)
|
||||
.await
|
||||
.map_err(|err| AppError::new(ErrorCode::Internal, "创建 API Key 失败").with_source(err))?;
|
||||
|
||||
Ok(Json(Envelope {
|
||||
success: true,
|
||||
data: CreateApiKeyResponse {
|
||||
id: row_id,
|
||||
name: req.name.trim().to_string(),
|
||||
key_prefix,
|
||||
key: full_key,
|
||||
message: "请保存此 Key,它只会显示一次".to_string(),
|
||||
},
|
||||
}))
|
||||
}
|
||||
|
||||
async fn disable_api_key(
|
||||
State(state): State<AppState>,
|
||||
jar: axum_extra::extract::cookie::CookieJar,
|
||||
ConnectInfo(addr): ConnectInfo<SocketAddr>,
|
||||
headers: HeaderMap,
|
||||
Path(key_id): Path<Uuid>,
|
||||
) -> Result<Json<Envelope<serde_json::Value>>, AppError> {
|
||||
let ip = context::client_ip(&headers, addr.ip());
|
||||
let (_jar, principal) = context::authenticate(&state, jar, &headers, ip).await?;
|
||||
|
||||
let user_id = match principal {
|
||||
context::Principal::User { user_id, .. } => user_id,
|
||||
_ => return Err(AppError::new(ErrorCode::Unauthorized, "未登录")),
|
||||
};
|
||||
|
||||
let result = sqlx::query("UPDATE api_keys SET is_active = false WHERE id = $1 AND user_id = $2")
|
||||
.bind(key_id)
|
||||
.bind(user_id)
|
||||
.execute(&state.db)
|
||||
.await
|
||||
.map_err(|err| AppError::new(ErrorCode::Internal, "更新 API Key 失败").with_source(err))?;
|
||||
|
||||
if result.rows_affected() == 0 {
|
||||
return Err(AppError::new(ErrorCode::NotFound, "API Key 不存在"));
|
||||
}
|
||||
|
||||
Ok(Json(Envelope {
|
||||
success: true,
|
||||
data: serde_json::json!({ "message": "已禁用" }),
|
||||
}))
|
||||
}
|
||||
|
||||
async fn rotate_api_key(
|
||||
State(state): State<AppState>,
|
||||
jar: axum_extra::extract::cookie::CookieJar,
|
||||
ConnectInfo(addr): ConnectInfo<SocketAddr>,
|
||||
headers: HeaderMap,
|
||||
Path(key_id): Path<Uuid>,
|
||||
) -> Result<Json<Envelope<CreateApiKeyResponse>>, AppError> {
|
||||
let ip = context::client_ip(&headers, addr.ip());
|
||||
let (_jar, principal) = context::authenticate(&state, jar, &headers, ip).await?;
|
||||
|
||||
let (user_id, email_verified) = match principal {
|
||||
context::Principal::User {
|
||||
user_id,
|
||||
email_verified,
|
||||
..
|
||||
} => (user_id, email_verified),
|
||||
_ => return Err(AppError::new(ErrorCode::Unauthorized, "未登录")),
|
||||
};
|
||||
|
||||
if !email_verified {
|
||||
return Err(AppError::new(ErrorCode::EmailNotVerified, "请先验证邮箱"));
|
||||
}
|
||||
|
||||
let (full_key, key_prefix) = generate_api_key();
|
||||
let key_hash = context::api_key_hash(&full_key, &state.config.api_key_pepper)?;
|
||||
|
||||
#[derive(Debug, FromRow)]
|
||||
struct RotateRow {
|
||||
id: Uuid,
|
||||
name: String,
|
||||
}
|
||||
|
||||
let row = sqlx::query_as::<_, RotateRow>(
|
||||
r#"
|
||||
UPDATE api_keys
|
||||
SET key_prefix = $1,
|
||||
key_hash = $2,
|
||||
is_active = true
|
||||
WHERE id = $3 AND user_id = $4
|
||||
RETURNING id, name
|
||||
"#,
|
||||
)
|
||||
.bind(&key_prefix)
|
||||
.bind(key_hash)
|
||||
.bind(key_id)
|
||||
.bind(user_id)
|
||||
.fetch_optional(&state.db)
|
||||
.await
|
||||
.map_err(|err| AppError::new(ErrorCode::Internal, "更新 API Key 失败").with_source(err))?
|
||||
.ok_or_else(|| AppError::new(ErrorCode::NotFound, "API Key 不存在"))?;
|
||||
|
||||
Ok(Json(Envelope {
|
||||
success: true,
|
||||
data: CreateApiKeyResponse {
|
||||
id: row.id,
|
||||
name: row.name,
|
||||
key_prefix,
|
||||
key: full_key,
|
||||
message: "请保存此 Key,它只会显示一次".to_string(),
|
||||
},
|
||||
}))
|
||||
}
|
||||
|
||||
fn generate_api_key() -> (String, String) {
|
||||
let mut prefix_bytes = [0u8; 4];
|
||||
rand::rngs::OsRng.fill_bytes(&mut prefix_bytes);
|
||||
let prefix = hex::encode(prefix_bytes);
|
||||
let key_prefix = format!("if_live_{prefix}");
|
||||
|
||||
let mut secret_bytes = [0u8; 32];
|
||||
rand::rngs::OsRng.fill_bytes(&mut secret_bytes);
|
||||
let secret = URL_SAFE_NO_PAD.encode(secret_bytes);
|
||||
|
||||
let full = format!("{key_prefix}_{secret}");
|
||||
(full, key_prefix)
|
||||
}
|
||||
|
||||
fn normalize_permissions(input: Option<Vec<String>>) -> Result<serde_json::Value, AppError> {
|
||||
let allowed = ["compress", "batch_compress", "read_stats", "billing_read", "webhook_manage"];
|
||||
|
||||
let mut perms = Vec::<String>::new();
|
||||
if let Some(values) = input {
|
||||
for value in values {
|
||||
let v = value.trim().to_ascii_lowercase();
|
||||
if v.is_empty() {
|
||||
continue;
|
||||
}
|
||||
if !allowed.contains(&v.as_str()) {
|
||||
return Err(AppError::new(
|
||||
ErrorCode::InvalidRequest,
|
||||
format!("不支持的权限: {v}"),
|
||||
));
|
||||
}
|
||||
if !perms.contains(&v) {
|
||||
perms.push(v);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if perms.is_empty() {
|
||||
perms.push("compress".to_string());
|
||||
}
|
||||
|
||||
Ok(serde_json::json!(perms))
|
||||
}
|
||||
|
||||
fn validate_email(email: &str) -> Result<(), AppError> {
|
||||
if email.trim().is_empty() || !email.contains('@') {
|
||||
return Err(AppError::new(ErrorCode::InvalidRequest, "邮箱格式不正确"));
|
||||
}
|
||||
if email.len() > 255 {
|
||||
return Err(AppError::new(ErrorCode::InvalidRequest, "邮箱过长"));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn validate_username(username: &str) -> Result<(), AppError> {
|
||||
if username.trim().is_empty() {
|
||||
return Err(AppError::new(ErrorCode::InvalidRequest, "用户名不能为空"));
|
||||
}
|
||||
if username.len() > 50 {
|
||||
return Err(AppError::new(ErrorCode::InvalidRequest, "用户名过长"));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn validate_password(password: &str) -> Result<(), AppError> {
|
||||
if password.len() < 8 {
|
||||
return Err(AppError::new(ErrorCode::InvalidRequest, "密码至少 8 位"));
|
||||
}
|
||||
if password.len() > 128 {
|
||||
return Err(AppError::new(ErrorCode::InvalidRequest, "密码过长"));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn hash_password(password: &str) -> Result<String, AppError> {
|
||||
let salt = argon2::password_hash::SaltString::generate(&mut rand::rngs::OsRng);
|
||||
let hashed = Argon2::default()
|
||||
.hash_password(password.as_bytes(), &salt)
|
||||
.map_err(|err| AppError::new(ErrorCode::Internal, "密码哈希失败").with_source(err))?;
|
||||
Ok(hashed.to_string())
|
||||
}
|
||||
|
||||
fn verify_password(password: &str, password_hash: &str) -> Result<(), AppError> {
|
||||
let parsed = PasswordHash::new(password_hash)
|
||||
.map_err(|err| AppError::new(ErrorCode::Internal, "密码哈希格式错误").with_source(err))?;
|
||||
Argon2::default()
|
||||
.verify_password(password.as_bytes(), &parsed)
|
||||
.map_err(|_| AppError::new(ErrorCode::Unauthorized, "密码错误"))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn generate_token() -> String {
|
||||
let mut bytes = [0u8; 32];
|
||||
rand::rngs::OsRng.fill_bytes(&mut bytes);
|
||||
URL_SAFE_NO_PAD.encode(bytes)
|
||||
}
|
||||
|
||||
fn sha256_hex(token: &str) -> String {
|
||||
let mut hasher = Sha256::new();
|
||||
hasher.update(token.as_bytes());
|
||||
hex::encode(hasher.finalize())
|
||||
}
|
||||
|
||||
fn map_unique_violation(err: sqlx::Error) -> AppError {
|
||||
if let sqlx::Error::Database(db_err) = &err {
|
||||
if let Some(code) = db_err.code() {
|
||||
if code == "23505" {
|
||||
return AppError::new(ErrorCode::InvalidRequest, "邮箱或用户名已存在");
|
||||
}
|
||||
}
|
||||
}
|
||||
AppError::new(ErrorCode::Internal, "数据库操作失败").with_source(err)
|
||||
}
|
||||
Reference in New Issue
Block a user