use crate::api::context; use crate::api::envelope::Envelope; use crate::error::{AppError, ErrorCode}; use crate::services::billing; use crate::services::{credentials, mail, settings}; use crate::state::AppState; 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 sqlx::FromRow; use std::collections::HashMap; use std::net::SocketAddr; use uuid::Uuid; pub fn router() -> Router { 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>, last_used_ip: Option, created_at: chrono::DateTime, } #[derive(Debug, Serialize)] struct ApiKeyListResponse { api_keys: Vec, } #[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, jar: axum_extra::extract::cookie::CookieJar, ConnectInfo(addr): ConnectInfo, headers: HeaderMap, ) -> Result>, 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>, } 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 verification_required = settings::email_verification_required(&state).await?; 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() || !verification_required, }, })) } #[derive(Debug, Deserialize)] struct UpdateProfileRequest { email: Option, username: Option, } #[derive(Debug, Serialize)] struct UpdateProfileResponse { user: UserView, message: String, } async fn update_profile( State(state): State, jar: axum_extra::extract::cookie::CookieJar, ConnectInfo(addr): ConnectInfo, headers: HeaderMap, Json(req): Json, ) -> Result>, 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>, } 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; let verification_required = settings::email_verification_required(&state).await?; if let Some(email) = req.email.as_ref() { let email = email.trim().to_lowercase(); credentials::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(); credentials::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() || !verification_required, }, 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 && verification_required { None } else if email_changed { Some(Utc::now()) } 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 = None; if email_changed && verification_required { let token = credentials::generate_token(); let token_hash = credentials::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 && verification_required { "资料已更新,请验证新邮箱".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() || !verification_required, }, message, }, })) } #[derive(Debug, Deserialize)] struct UpdatePasswordRequest { current_password: String, new_password: String, } async fn update_password( State(state): State, jar: axum_extra::extract::cookie::CookieJar, ConnectInfo(addr): ConnectInfo, headers: HeaderMap, Json(req): Json, ) -> Result>, 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, "未登录")), }; credentials::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))?; if !credentials::verify_password(&req.current_password, &row.password_hash).await? { return Err(AppError::new(ErrorCode::Unauthorized, "密码错误")); } let new_hash = credentials::hash_password(&req.new_password).await?; sqlx::query( "UPDATE users SET password_hash = $2, token_version = token_version + 1, 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, limit: Option, status: Option, } #[derive(Debug, Serialize)] struct HistoryFileView { file_id: Uuid, original_name: String, original_size: i64, compressed_size: Option, saved_percent: Option, status: String, output_format: String, error_message: Option, download_url: Option, } #[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, completed_at: Option>, expires_at: DateTime, download_all_url: Option, files: Vec, } #[derive(Debug, Serialize)] struct HistoryResponse { tasks: Vec, page: u32, limit: u32, total: i64, } async fn list_history( State(state): State, jar: axum_extra::extract::cookie::CookieJar, ConnectInfo(addr): ConnectInfo, headers: HeaderMap, Query(query): Query, ) -> Result>, 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 = super::normalize_task_status(query.status.as_deref())?; let total: i64 = if let Some(status) = status { sqlx::query_scalar( "SELECT COUNT(*) FROM tasks WHERE user_id = $1 AND status = $2::task_status", ) .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, completed_at: Option>, expires_at: DateTime, } let tasks: Vec = 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 = $2::task_status 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 { task_id: Uuid, id: Uuid, original_name: String, original_size: i64, compressed_size: Option, saved_percent: Option, status: String, output_format: String, error_message: Option, has_storage: bool, } let now = Utc::now(); let task_ids = tasks.iter().map(|task| task.id).collect::>(); let files = if task_ids.is_empty() { Vec::new() } else { sqlx::query_as::<_, FileRow>( r#" SELECT task_id, id, original_name, original_size, compressed_size, saved_percent::float8 AS saved_percent, status::text AS status, output_format, error_message, COALESCE(storage_key, storage_path) IS NOT NULL AS has_storage FROM task_files WHERE task_id = ANY($1) ORDER BY task_id, created_at ASC "#, ) .bind(&task_ids) .fetch_all(&state.db) .await .map_err(|err| AppError::new(ErrorCode::Internal, "查询任务文件失败").with_source(err))? }; let mut files_by_task = HashMap::>::new(); for file in files { files_by_task.entry(file.task_id).or_default().push(file); } let mut result_tasks = Vec::with_capacity(tasks.len()); for task in tasks { let file_views = files_by_task .remove(&task.id) .unwrap_or_default() .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.has_storage && task.expires_at > now { Some(format!("/downloads/{}", file.id)) } else { None }, }) .collect::>(); 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, jar: axum_extra::extract::cookie::CookieJar, ConnectInfo(addr): ConnectInfo, headers: HeaderMap, ) -> Result>, 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>, } #[derive(Debug, Serialize)] struct CreateApiKeyResponse { id: Uuid, name: String, key_prefix: String, key: String, message: String, } async fn create_api_key( State(state): State, jar: axum_extra::extract::cookie::CookieJar, ConnectInfo(addr): ConnectInfo, headers: HeaderMap, Json(req): Json, ) -> Result>, 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, "请先验证邮箱")); } if !settings::runtime_policy(&state) .await? .features .api_key_enabled { return Err(AppError::new( ErrorCode::Forbidden, "API Key 功能当前已关闭", )); } 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, jar: axum_extra::extract::cookie::CookieJar, ConnectInfo(addr): ConnectInfo, headers: HeaderMap, Path(key_id): Path, ) -> Result>, 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, jar: axum_extra::extract::cookie::CookieJar, ConnectInfo(addr): ConnectInfo, headers: HeaderMap, Path(key_id): Path, ) -> Result>, 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, "请先验证邮箱")); } if !settings::runtime_policy(&state) .await? .features .api_key_enabled { return Err(AppError::new( ErrorCode::Forbidden, "API Key 功能当前已关闭", )); } 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>) -> Result { let allowed = ["compress", "batch_compress"]; let mut perms = Vec::::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 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) }