//! 下载中心 API — 任务列表、下载链接、文件下载、过期清理 use axum::body::Body; use axum::extract::{Path, Query, State}; use axum::http::header; use axum::response::Response; use axum::Json; use chrono::NaiveDateTime; use serde::{Deserialize, Serialize}; use serde_json::{json, Value}; use crate::error::AppError; use crate::middleware::auth::{self, CurrentUser}; /// 下载任务列表查询参数 #[derive(Debug, Deserialize)] pub struct DownloadListQuery { #[serde(default = "default_page")] pub page: i64, #[serde(default = "default_page_size")] pub page_size: i64, } /// 清理请求参数 #[derive(Debug, Deserialize)] pub struct CleanupRequest { /// 保留天数,默认7天 #[serde(default = "default_retain_days")] pub retain_days: i32, } /// 下载任务列表项 #[derive(Debug, Serialize)] pub struct DownloadTaskItem { pub id: i64, pub task_type: String, pub status: i8, pub status_text: String, pub file_name: Option, pub progress: i32, pub error_message: Option, pub created_at: Option, pub completed_at: Option, } fn default_page() -> i64 { 1 } fn default_page_size() -> i64 { 20 } fn default_retain_days() -> i32 { 7 } /// 状态码转文本 fn download_status_text(status: i8) -> &'static str { match status { 0 => "排队中", 1 => "处理中", 2 => "已完成", 3 => "失败", _ => "未知", } } /// 数据库行映射 #[derive(Debug, sqlx::FromRow)] struct DownloadTaskRow { id: i64, task_type: String, status: i8, file_name: Option, file_path: Option, progress: i32, error_message: Option, created_at: Option, completed_at: Option, } /// 将数据库行转为响应对象 fn row_to_item(row: DownloadTaskRow) -> DownloadTaskItem { DownloadTaskItem { id: row.id, task_type: row.task_type, status: row.status, status_text: download_status_text(row.status).to_string(), file_name: row.file_name, progress: row.progress, error_message: if row.status == 3 { row.error_message } else { None }, created_at: row.created_at.map(|t| t.to_string()), completed_at: row.completed_at.map(|t| t.to_string()), } } /// GET /api/downloads — 下载任务列表 pub async fn list_downloads( user: CurrentUser, State(state): State, Query(params): Query, ) -> Result, AppError> { auth::check_permission(&user, "charge_record:export")?; let user_id = user.user_id; let offset = (params.page - 1) * params.page_size; // 查询总数 let total: i64 = sqlx::query_scalar( "SELECT COUNT(*) FROM download_tasks WHERE user_id = ?" ) .bind(user_id) .fetch_one(&state.mysql) .await?; // 查询列表 let rows = sqlx::query_as::<_, DownloadTaskRow>( "SELECT id, task_type, status, file_name, file_path, progress, \ error_message, created_at, completed_at \ FROM download_tasks WHERE user_id = ? \ ORDER BY created_at DESC LIMIT ? OFFSET ?" ) .bind(user_id) .bind(params.page_size) .bind(offset) .fetch_all(&state.mysql) .await?; let list: Vec = rows.into_iter().map(row_to_item).collect(); Ok(Json(json!({ "code": 0, "data": { "list": list, "total": total, "page": params.page, "page_size": params.page_size, }, "message": "ok" }))) } /// GET /api/downloads/:id — 获取下载链接 pub async fn get_download( user: CurrentUser, State(state): State, Path(id): Path, ) -> Result, AppError> { auth::check_permission(&user, "charge_record:export")?; let row: Option = sqlx::query_as( "SELECT id, task_type, status, file_name, file_path, progress, \ error_message, created_at, completed_at \ FROM download_tasks WHERE id = ?" ) .bind(id) .fetch_optional(&state.mysql) .await?; let row = row.ok_or_else(|| AppError::NotFound("下载任务不存在".into()))?; if row.status != 2 { return Err(AppError::BadRequest("任务尚未完成".into())); } let file_path = row.file_path.unwrap_or_default(); // 检查文件是否存在 if !std::path::Path::new(&file_path).exists() { return Err(AppError::NotFound("文件不存在或已清理".into())); } // 返回文件下载路径(后续可改为签名URL) let download_url = format!("/api/downloads/{}/file", id); Ok(Json(json!({ "code": 0, "data": { "id": row.id, "file_name": row.file_name, "download_url": download_url, }, "message": "ok" }))) } /// GET /api/downloads/:id/file — 下载文件(二进制流) pub async fn download_file( user: CurrentUser, State(state): State, Path(id): Path, ) -> Result, AppError> { auth::check_permission(&user, "charge_record:export")?; let db = &state.mysql; // 查询下载任务(限定本人) let task: Option = sqlx::query_as( "SELECT id, task_type, status, file_name, file_path, progress, \ error_message, created_at, completed_at \ FROM download_tasks WHERE id = ? AND user_id = ?", ) .bind(id) .bind(user.user_id) .fetch_optional(db) .await?; let task = task.ok_or_else(|| AppError::NotFound("下载任务不存在".into()))?; if task.status != 2 { return Err(AppError::BadRequest("文件未生成完成".into())); } let file_path = task .file_path .ok_or_else(|| AppError::NotFound("文件路径不存在".into()))?; // 读取文件内容 let file_content = tokio::fs::read(&file_path).await.map_err(|e| { tracing::error!("读取下载文件失败 path={} err={}", file_path, e); AppError::Internal(format!("读取文件失败: {}", e)) })?; // 推断文件名 let file_name = task.file_name.unwrap_or_else(|| "download.xlsx".to_string()); // 构建文件下载响应 let response = Response::builder() .status(200) .header( header::CONTENT_TYPE, "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", ) .header( header::CONTENT_DISPOSITION, format!("attachment; filename=\"{}\"", file_name), ) .body(Body::from(file_content)) .map_err(|e| AppError::Internal(format!("构建响应失败: {}", e)))?; Ok(response) } /// POST /api/downloads/cleanup — 清理过期文件 pub async fn cleanup_downloads( user: CurrentUser, State(state): State, Json(req): Json, ) -> Result, AppError> { auth::check_permission(&user, "charge_record:export")?; // 查询过期任务(状态为完成且超过保留天数) let expired: Vec = sqlx::query_as( "SELECT id, task_type, status, file_name, file_path, progress, \ error_message, created_at, completed_at \ FROM download_tasks \ WHERE status = 2 AND completed_at < DATE_SUB(NOW(), INTERVAL ? DAY)" ) .bind(req.retain_days) .fetch_all(&state.mysql) .await?; let mut cleaned = 0u32; for task in &expired { // 删除物理文件 if let Some(ref path) = task.file_path { let _ = tokio::fs::remove_file(path).await; } // 更新数据库记录 let _ = sqlx::query( "UPDATE download_tasks SET file_path = NULL, status = 3, \ error_message = '文件已过期清理' WHERE id = ?" ) .bind(task.id) .execute(&state.mysql) .await; cleaned += 1; } tracing::info!("清理过期下载文件 {} 个", cleaned); Ok(Json(json!({ "code": 0, "data": { "cleaned": cleaned }, "message": format!("已清理 {} 个过期文件", cleaned) }))) }