feat: 实现文件上传功能并完善测试覆盖
- 新增 upload.rs 模块,支持 multipart/form-data 文件上传 - 文件按日期存储在 storage/YYYY-MM-DD/ 目录下 - 使用 UUID 生成唯一文件名,保留原始扩展名 - 添加 axum-extra, uuid, chrono 依赖 新增测试用例: - config_test.rs: 6 个测试 (配置结构验证) - router_test.rs: 11 个测试 (路由匹配逻辑) - handler_test.rs: 8 个测试 (请求处理) - upload_test.rs: 13 个测试 (文件上传功能) 其他改进: - 优化 handler.rs 代码注释 - 更新 .gitignore 忽略 storage/ 和 .claude/ - 添加 CLAUDE.md 项目指南文档 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
150
src/upload.rs
Normal file
150
src/upload.rs
Normal file
@@ -0,0 +1,150 @@
|
||||
use axum::{
|
||||
body::Body,
|
||||
extract::State,
|
||||
http::StatusCode,
|
||||
response::{IntoResponse, Response},
|
||||
};
|
||||
use axum_extra::extract::Multipart;
|
||||
use chrono::Local;
|
||||
use serde_json::json;
|
||||
use std::path::PathBuf;
|
||||
use std::sync::Arc;
|
||||
use tokio::fs;
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::handler::AppState;
|
||||
|
||||
/// 文件上传结果
|
||||
#[derive(Debug, serde::Serialize)]
|
||||
pub struct UploadResult {
|
||||
pub success: bool,
|
||||
pub files: Vec<FileInfo>,
|
||||
pub message: String,
|
||||
}
|
||||
|
||||
/// 单个文件信息
|
||||
#[derive(Debug, serde::Serialize)]
|
||||
pub struct FileInfo {
|
||||
pub original_name: String,
|
||||
pub stored_name: String,
|
||||
pub path: String,
|
||||
pub size: usize,
|
||||
pub content_type: Option<String>,
|
||||
}
|
||||
|
||||
impl UploadResult {
|
||||
pub fn success(files: Vec<FileInfo>) -> Self {
|
||||
UploadResult {
|
||||
success: true,
|
||||
files,
|
||||
message: "Files uploaded successfully".to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn error(message: String) -> Self {
|
||||
UploadResult {
|
||||
success: false,
|
||||
files: vec![],
|
||||
message,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl IntoResponse for UploadResult {
|
||||
fn into_response(self) -> Response {
|
||||
let status = if self.success {
|
||||
StatusCode::OK
|
||||
} else {
|
||||
StatusCode::BAD_REQUEST
|
||||
};
|
||||
|
||||
let body = serde_json::to_string(&self).unwrap_or_else(|_| {
|
||||
json!({
|
||||
"success": false,
|
||||
"files": [],
|
||||
"message": "Failed to serialize response"
|
||||
})
|
||||
.to_string()
|
||||
});
|
||||
|
||||
Response::builder()
|
||||
.status(status)
|
||||
.header("content-type", "application/json")
|
||||
.body(Body::from(body))
|
||||
.unwrap()
|
||||
}
|
||||
}
|
||||
|
||||
/// 处理文件上传
|
||||
pub async fn upload_handler(
|
||||
State(_state): State<Arc<AppState>>,
|
||||
mut multipart: Multipart,
|
||||
) -> impl IntoResponse {
|
||||
let mut uploaded_files = Vec::new();
|
||||
|
||||
// 确保 storage 目录存在
|
||||
let storage_root = PathBuf::from("storage");
|
||||
if let Err(e) = fs::create_dir_all(&storage_root).await {
|
||||
return UploadResult::error(format!("Failed to create storage directory: {}", e));
|
||||
}
|
||||
|
||||
// 创建按日期分类的子目录
|
||||
let date_dir = Local::now().format("%Y-%m-%d").to_string();
|
||||
let upload_dir = storage_root.join(&date_dir);
|
||||
if let Err(e) = fs::create_dir_all(&upload_dir).await {
|
||||
return UploadResult::error(format!("Failed to create upload directory: {}", e));
|
||||
}
|
||||
|
||||
// 处理每个上传的字段
|
||||
while let Some(field) = multipart.next_field().await.unwrap_or(None) {
|
||||
// 获取文件名
|
||||
let original_name = field
|
||||
.file_name()
|
||||
.map(|s| s.to_string())
|
||||
.unwrap_or_else(|| "unknown".to_string());
|
||||
|
||||
// 获取内容类型
|
||||
let content_type = field.content_type().map(|s| s.to_string());
|
||||
|
||||
// 读取文件数据
|
||||
let data = match field.bytes().await {
|
||||
Ok(d) => d,
|
||||
Err(e) => {
|
||||
eprintln!("Failed to read file data: {}", e);
|
||||
continue;
|
||||
}
|
||||
};
|
||||
|
||||
// 生成唯一文件名,保留原始扩展名
|
||||
let extension = std::path::Path::new(&original_name)
|
||||
.extension()
|
||||
.and_then(|s| s.to_str())
|
||||
.map(|s| format!(".{}", s))
|
||||
.unwrap_or_default();
|
||||
|
||||
let stored_name = format!("{}{}", Uuid::new_v4(), extension);
|
||||
let file_path = upload_dir.join(&stored_name);
|
||||
|
||||
// 保存文件
|
||||
match fs::write(&file_path, &data).await {
|
||||
Ok(_) => {
|
||||
uploaded_files.push(FileInfo {
|
||||
original_name: original_name.clone(),
|
||||
stored_name: stored_name.clone(),
|
||||
path: format!("storage/{}/{}", date_dir, stored_name),
|
||||
size: data.len(),
|
||||
content_type,
|
||||
});
|
||||
}
|
||||
Err(e) => {
|
||||
eprintln!("Failed to save file {}: {}", original_name, e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if uploaded_files.is_empty() {
|
||||
UploadResult::error("No files were uploaded".to_string())
|
||||
} else {
|
||||
UploadResult::success(uploaded_files)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user