Skip to main content

alun_fs/
local.rs

1use std::path::{Path, PathBuf};
2use tokio::fs;
3use tokio::io::{AsyncReadExt, AsyncWriteExt};
4use tracing::{info, error};
5use uuid::Uuid;
6use chrono::Utc;
7
8use super::plugin::{FileMeta, StoreResult};
9
10/// 本地文件系统存储后端
11///
12/// 支持按相对路径读写/删除文件,自动创建目录,自动推算 MIME 类型。
13pub struct LocalFs {
14    /// 存储根目录(绝对路径)
15    root_dir: PathBuf,
16}
17
18impl LocalFs {
19    /// 创建本地文件存储实例
20    ///
21    /// `root_dir` 相对路径会被展开为基于 `current_dir` 的绝对路径。
22    pub fn new(root_dir: &str) -> Self {
23        let path = PathBuf::from(root_dir);
24        let path = if path.is_absolute() {
25            path
26        } else {
27            std::env::current_dir()
28                .unwrap_or_else(|_| PathBuf::from("."))
29                .join(path)
30        };
31
32        info!("LocalFs 初始化, root={}", path.display());
33        Self { root_dir: path }
34    }
35
36    /// 获取存储根目录路径
37    pub fn root_dir(&self) -> &Path { &self.root_dir }
38
39    /// 写入文件(relative_path 保留原始名称,UUID 作为 file_id)
40    pub async fn write(&self, relative_path: &str, data: &[u8]) -> StoreResult<FileMeta> {
41        let full_path = self.resolve(relative_path);
42        self.ensure_parent(&full_path).await?;
43
44        let mut file = fs::File::create(&full_path).await.map_err(|e| {
45            error!("文件创建失败 {}: {}", full_path.display(), e);
46            format!("文件创建失败: {}", e)
47        })?;
48
49        file.write_all(data).await.map_err(|e| {
50            error!("文件写入失败 {}: {}", full_path.display(), e);
51            format!("文件写入失败: {}", e)
52        })?;
53
54        let metadata = fs::metadata(&full_path).await.map_err(|e| {
55            format!("获取元数据失败: {}", e)
56        })?;
57
58        let meta = FileMeta {
59            file_id: Uuid::new_v4().to_string(),
60            original_name: relative_path.to_string(),
61            stored_path: relative_path.to_string(),
62            size: metadata.len(),
63            content_type: mime_guess(relative_path),
64            created_at: Utc::now(),
65        };
66
67        info!("文件存储成功: {} ({} bytes)", relative_path, meta.size);
68        Ok(meta)
69    }
70
71    /// 写入文件(自动按日期分目录:`YYYY/MM/DD/uuid.ext`)
72    ///
73    /// 比 `write` 更安全——上层无需关心路径命名。
74    pub async fn write_with_name(&self, original_name: &str, data: &[u8]) -> StoreResult<FileMeta> {
75        let ext = Path::new(original_name)
76            .extension()
77            .and_then(|e| e.to_str())
78            .unwrap_or("bin");
79
80        let file_id = Uuid::new_v4().to_string();
81        let stored_name = format!("{}.{}", file_id, ext);
82        let date_path = Utc::now().format("%Y/%m/%d").to_string();
83        let relative_path = format!("{}/{}", date_path, stored_name);
84
85        let full_path = self.resolve(&relative_path);
86        self.ensure_parent(&full_path).await?;
87
88        let mut file = fs::File::create(&full_path).await.map_err(|e| {
89            format!("文件创建失败: {}", e)
90        })?;
91
92        file.write_all(data).await.map_err(|e| {
93            format!("文件写入失败: {}", e)
94        })?;
95
96        let meta = FileMeta {
97            file_id,
98            original_name: original_name.to_string(),
99            stored_path: relative_path,
100            size: data.len() as u64,
101            content_type: mime_guess(original_name),
102            created_at: Utc::now(),
103        };
104
105        info!("文件存储成功: {} -> {} ({} bytes)", original_name, meta.stored_path, meta.size);
106        Ok(meta)
107    }
108
109    /// 读取文件全部内容
110    ///
111    /// 文件不存在返回 `Err("文件不存在: ...")`。
112    pub async fn read(&self, relative_path: &str) -> StoreResult<Vec<u8>> {
113        let full_path = self.resolve(relative_path);
114
115        if !full_path.exists() {
116            return Err(format!("文件不存在: {}", relative_path));
117        }
118
119        let mut file = fs::File::open(&full_path).await.map_err(|e| {
120            format!("文件打开失败: {}", e)
121        })?;
122
123        let mut data = Vec::new();
124        file.read_to_end(&mut data).await.map_err(|e| {
125            format!("文件读取失败: {}", e)
126        })?;
127
128        Ok(data)
129    }
130
131    /// 删除文件(不存在不报错)
132    pub async fn delete(&self, relative_path: &str) -> StoreResult<()> {
133        let full_path = self.resolve(relative_path);
134
135        if !full_path.exists() {
136            return Ok(());
137        }
138
139        fs::remove_file(&full_path).await.map_err(|e| {
140            format!("文件删除失败: {}", e)
141        })?;
142
143        info!("文件已删除: {}", relative_path);
144        Ok(())
145    }
146
147    /// 检查文件是否存在
148    pub async fn exists(&self, relative_path: &str) -> bool {
149        self.resolve(relative_path).exists()
150    }
151
152    fn resolve(&self, relative_path: &str) -> PathBuf {
153        let path = Path::new(relative_path);
154        let path = if path.is_absolute() {
155            path.strip_prefix("/").unwrap_or(path).to_path_buf()
156        } else {
157            path.to_path_buf()
158        };
159        self.root_dir.join(path)
160    }
161
162    async fn ensure_parent(&self, full_path: &Path) -> StoreResult<()> {
163        if let Some(parent) = full_path.parent() {
164            if !parent.exists() {
165                fs::create_dir_all(parent).await.map_err(|e| {
166                    format!("目录创建失败: {}", e)
167                })?;
168            }
169        }
170        Ok(())
171    }
172}
173
174fn mime_guess(filename: &str) -> String {
175    let ext = Path::new(filename)
176        .extension()
177        .and_then(|e| e.to_str())
178        .unwrap_or("bin");
179
180    match ext.to_lowercase().as_str() {
181        "jpg" | "jpeg" => "image/jpeg",
182        "png" => "image/png",
183        "gif" => "image/gif",
184        "webp" => "image/webp",
185        "svg" => "image/svg+xml",
186        "pdf" => "application/pdf",
187        "json" => "application/json",
188        "xml" => "application/xml",
189        "html" | "htm" => "text/html",
190        "css" => "text/css",
191        "js" => "application/javascript",
192        "txt" => "text/plain",
193        "csv" => "text/csv",
194        "zip" => "application/zip",
195        "mp4" => "video/mp4",
196        "mp3" => "audio/mpeg",
197        "doc" => "application/msword",
198        "docx" => "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
199        "xls" => "application/vnd.ms-excel",
200        "xlsx" => "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
201        _ => "application/octet-stream",
202    }.into()
203}