281 lines
8.1 KiB
Rust
Raw Normal View History

//! 下载中心 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<String>,
pub progress: i32,
pub error_message: Option<String>,
pub created_at: Option<String>,
pub completed_at: Option<String>,
}
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<String>,
file_path: Option<String>,
progress: i32,
error_message: Option<String>,
created_at: Option<NaiveDateTime>,
completed_at: Option<NaiveDateTime>,
}
/// 将数据库行转为响应对象
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<super::AppState>,
Query(params): Query<DownloadListQuery>,
) -> Result<Json<Value>, 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<DownloadTaskItem> = 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<super::AppState>,
Path(id): Path<i64>,
) -> Result<Json<Value>, AppError> {
auth::check_permission(&user, "charge_record:export")?;
let row: Option<DownloadTaskRow> = 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<super::AppState>,
Path(id): Path<i64>,
) -> Result<Response<Body>, AppError> {
auth::check_permission(&user, "charge_record:export")?;
let db = &state.mysql;
// 查询下载任务(限定本人)
let task: Option<DownloadTaskRow> = 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<super::AppState>,
Json(req): Json<CleanupRequest>,
) -> Result<Json<Value>, AppError> {
auth::check_permission(&user, "charge_record:export")?;
// 查询过期任务(状态为完成且超过保留天数)
let expired: Vec<DownloadTaskRow> = 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)
})))
}