2026-07-02 05:38:01 +08:00

281 lines
8.1 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

//! 下载中心 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)
})))
}