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)
|
|||
|
|
})))
|
|||
|
|
}
|