281 lines
8.1 KiB
Rust
281 lines
8.1 KiB
Rust
//! 下载中心 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)
|
||
})))
|
||
}
|