Skip to main content

sz_orm_storage/
lib.rs

1//! # SZ-ORM Storage — 对象存储
2//!
3//! 提供统一的对象存储抽象,支持 S3、阿里云 OSS、七牛 Kodo、华为 OBS、
4//! 腾讯 COS、又拍云以及本地文件系统,可通过 `StorageBuilder` 配置多 provider。
5//!
6//! ## 生产可用性说明
7//!
8//! 本 crate 中各 provider 的**生产可用性分级**如下:
9//!
10//! | Provider | 模块 | 状态 |
11//! |---|---|---|
12//! | Local(本地文件系统) | [`local`] | ✅ 生产可用 |
13//! | S3(AWS S3 兼容) | `s3_sdk` 模块(feature = "s3-sdk") | ✅ 生产可用(基于 `rust-s3` crate) |
14//! | Aliyun OSS | `real`(feature = "real-cloud") | ✅ 生产可用(基于 OpenDAL) |
15//! | Tencent COS | `real`(feature = "real-cloud") | ✅ 生产可用(基于 OpenDAL) |
16//! | Huawei OBS | `real`(feature = "real-cloud") | ✅ 生产可用(基于 OpenDAL) |
17//! | UpYun | `real`(feature = "real-cloud") | ✅ 生产可用(基于 OpenDAL) |
18//! | Qiniu Kodo | `real`(feature = "real-cloud") | ✅ 生产可用(官方 REST API + HMAC-SHA1) |
19//! | 以上云(未启用 feature) | [`aliyun`] [`tencent`] [`qiniu`] [`huawei`] [`upyun`] [`s3`] | ⚠️ MOCK-ONLY(内存 HashMap) |
20//!
21//! ## 使用真实云存储
22//!
23//! ```toml
24//! [dependencies]
25//! sz-orm-storage = { version = "1.2", features = ["real-cloud"] }
26//! ```
27//!
28//! ```no_run
29//! use sz_orm_storage::{Storage, StorageBuilder, StorageProvider};
30//!
31//! # async fn demo() -> Result<(), sz_orm_storage::StorageError> {
32//! let storage = StorageBuilder::new(StorageProvider::AliyunOss(Default::default()))
33//!     .with_bucket("my-bucket")
34//!     .with_endpoint("oss-cn-hangzhou.aliyuncs.com")
35//!     .with_access_key("AK...")
36//!     .with_secret_key("SK...")
37//!     .build()?;
38//! let url = storage.put("a.txt", b"data", "text/plain").await?;
39//! # Ok(())
40//! # }
41//! ```
42//!
43//! 启用 `real-cloud` 后,`StorageBuilder::build()` 自动构造真实云客户端;
44//! 未配置凭据时返回 `StorageError::InvalidConfig`,不会静默降级为 Mock。
45//!
46//! ## 主要模块
47//!
48//! - [`storage`] — 统一 trait 与构建器
49//! - `real` — 真实云存储(feature = "real-cloud")
50//! - [`s3`] / [`aliyun`] / [`huawei`] / [`tencent`] / [`qiniu`] / [`upyun`] / [`local`] — 各 provider 实现
51
52pub mod advanced;
53pub mod error;
54pub mod storage;
55
56pub mod aliyun;
57pub mod huawei;
58pub mod local;
59pub mod qiniu;
60pub mod s3;
61pub mod tencent;
62pub mod upyun;
63
64#[cfg(feature = "real-cloud")]
65pub mod real;
66
67// 高级存储功能(分片上传、断点续传、生命周期管理、CDN 刷新)
68pub use advanced::{
69    estimate_remaining_seconds, format_size, object_age_days, BucketLifecycle, CdnRefresher,
70    LifecycleAction, LifecycleEvaluationResult, LifecycleRule, MultipartUpload,
71    MultipartUploadSnapshot, Part, RefreshRequest, RefreshStatus, RefreshType,
72    ResumableUploadManager, UploadStatus,
73};
74
75pub use error::StorageError;
76pub use storage::*;
77
78pub use storage::StorageBuilder;
79pub use storage::StorageConfig;
80pub use storage::StorageProvider;
81pub use storage::StorageWrapper;
82
83pub use aliyun::AliyunOssStorage;
84pub use huawei::HuaweiObsStorage;
85pub use local::LocalStorage;
86pub use qiniu::QiniuKodoStorage;
87pub use s3::S3Storage;
88pub use tencent::TencentCosStorage;
89pub use upyun::UpYunStorage;
90
91#[cfg(feature = "real-cloud")]
92pub use real::{OpendalStorage, RealCloudStorage, RealQiniuKodoStorage};
93
94#[cfg(feature = "s3-sdk")]
95pub mod s3_sdk;
96
97#[cfg(feature = "s3-sdk")]
98pub use s3_sdk::S3SdkStorage;
99
100#[cfg(test)]
101mod tests {
102    use super::*;
103
104    /// 测试数据目录:优先 F:\test\data(用户规范),回退到环境变量或系统 temp(CI/Linux)
105    ///
106    /// 注意:仅检查目录存在不足以保证可用——还需验证可写性,
107    /// 以避免在受限沙箱环境中因目录存在但不可写导致测试失败。
108    fn test_data_base() -> std::path::PathBuf {
109        let f_drive = std::path::Path::new("F:\\test\\data");
110        if is_dir_writable(f_drive) {
111            return f_drive.to_path_buf();
112        }
113        if let Ok(dir) = std::env::var("SZ_ORM_TEST_DATA_DIR") {
114            let p = std::path::PathBuf::from(&dir);
115            if is_dir_writable(&p) {
116                return p;
117            }
118        }
119        std::env::temp_dir()
120    }
121
122    /// 检查目录是否存在且可写:尝试在其中创建并删除一个探测文件
123    fn is_dir_writable(dir: &std::path::Path) -> bool {
124        if !dir.exists() {
125            return false;
126        }
127        let probe = dir.join(format!(".probe_{}", std::process::id()));
128        match std::fs::File::create(&probe) {
129            Ok(_) => {
130                let _ = std::fs::remove_file(&probe);
131                true
132            }
133            Err(_) => false,
134        }
135    }
136
137    #[tokio::test]
138    async fn test_local_storage_put_and_get() {
139        let temp_dir = test_data_base().join(format!("storage_test_{}", uuid_simple()));
140        let storage = LocalStorage::new(temp_dir.to_string_lossy());
141
142        let key = "test.txt";
143        let data = b"Hello, World!";
144        let content_type = "text/plain";
145
146        storage.put(key, data, content_type).await.unwrap();
147
148        let retrieved = storage.get(key).await.unwrap();
149        assert_eq!(retrieved, data);
150
151        tokio::fs::remove_dir_all(&temp_dir).await.ok();
152    }
153
154    #[tokio::test]
155    async fn test_local_storage_delete() {
156        let temp_dir = test_data_base().join(format!("storage_test_{}", uuid_simple()));
157        let storage = LocalStorage::new(temp_dir.to_string_lossy());
158
159        let key = "delete_me.txt";
160        storage.put(key, b"test", "text/plain").await.unwrap();
161
162        storage.delete(key).await.unwrap();
163
164        let exists = storage.exists(key).await.unwrap();
165        assert!(!exists);
166
167        tokio::fs::remove_dir_all(&temp_dir).await.ok();
168    }
169
170    #[tokio::test]
171    async fn test_local_storage_exists() {
172        let temp_dir = test_data_base().join(format!("storage_test_{}", uuid_simple()));
173        let storage = LocalStorage::new(temp_dir.to_string_lossy());
174
175        let key = "exists.txt";
176        assert!(!storage.exists(key).await.unwrap());
177
178        storage.put(key, b"test", "text/plain").await.unwrap();
179        assert!(storage.exists(key).await.unwrap());
180
181        tokio::fs::remove_dir_all(&temp_dir).await.ok();
182    }
183
184    #[tokio::test]
185    async fn test_local_storage_not_found() {
186        let temp_dir = test_data_base().join(format!("storage_test_{}", uuid_simple()));
187        let storage = LocalStorage::new(temp_dir.to_string_lossy());
188
189        let result = storage.get("nonexistent.txt").await;
190        assert!(result.is_err());
191        assert!(matches!(result.unwrap_err(), StorageError::NotFound(_)));
192    }
193
194    #[tokio::test]
195    async fn test_storage_builder_local() {
196        let builder = StorageBuilder::new(StorageProvider::Local)
197            .with_bucket("test-bucket")
198            .with_path_prefix("prefix/");
199
200        let wrapper = builder.build().unwrap();
201        assert!(matches!(wrapper, StorageWrapper::Local(_)));
202    }
203
204    #[tokio::test]
205    async fn test_storage_builder_s3() {
206        let builder = StorageBuilder::new(StorageProvider::S3(S3Config::default()))
207            .with_bucket("test-bucket")
208            .with_region("us-west-2")
209            .with_access_key("ak")
210            .with_secret_key("sk");
211
212        let wrapper = builder.build().unwrap();
213        assert!(matches!(wrapper, StorageWrapper::S3(_)));
214    }
215
216    #[cfg(not(feature = "real-cloud"))]
217    #[tokio::test]
218    async fn test_storage_builder_aliyun() {
219        let builder = StorageBuilder::new(StorageProvider::AliyunOss(AliyunConfig::default()))
220            .with_bucket("test-bucket")
221            .with_endpoint("oss-cn-hangzhou.aliyuncs.com")
222            .with_access_key("ak")
223            .with_secret_key("sk");
224
225        let wrapper = builder.build().unwrap();
226        assert!(matches!(wrapper, StorageWrapper::Aliyun(_)));
227    }
228
229    #[cfg(not(feature = "real-cloud"))]
230    #[tokio::test]
231    async fn test_storage_builder_tencent() {
232        let builder = StorageBuilder::new(StorageProvider::TencentCos(TencentConfig::default()))
233            .with_bucket("test-bucket")
234            .with_region("ap-guangzhou");
235
236        let wrapper = builder.build().unwrap();
237        assert!(matches!(wrapper, StorageWrapper::Tencent(_)));
238    }
239
240    #[cfg(not(feature = "real-cloud"))]
241    #[tokio::test]
242    async fn test_storage_builder_qiniu() {
243        let builder = StorageBuilder::new(StorageProvider::QiniuKodo(QiniuConfig::default()))
244            .with_bucket("test-bucket");
245
246        let wrapper = builder.build().unwrap();
247        assert!(matches!(wrapper, StorageWrapper::Qiniu(_)));
248    }
249
250    #[cfg(not(feature = "real-cloud"))]
251    #[tokio::test]
252    async fn test_storage_builder_huawei() {
253        let builder = StorageBuilder::new(StorageProvider::HuaweiObs(HuaweiConfig::default()))
254            .with_bucket("test-bucket")
255            .with_endpoint("obs.cn-north-4.myhuaweicloud.com");
256
257        let wrapper = builder.build().unwrap();
258        assert!(matches!(wrapper, StorageWrapper::Huawei(_)));
259    }
260
261    #[cfg(not(feature = "real-cloud"))]
262    #[tokio::test]
263    async fn test_storage_builder_upyun() {
264        let builder = StorageBuilder::new(StorageProvider::UpYun(UpYunConfig::default()))
265            .with_bucket("test-bucket");
266
267        let wrapper = builder.build().unwrap();
268        assert!(matches!(wrapper, StorageWrapper::Upyun(_)));
269    }
270
271    #[tokio::test]
272    async fn test_builder_s3_actually_works() {
273        let wrapper = StorageBuilder::new(StorageProvider::S3(S3Config::default()))
274            .with_bucket("test-bucket")
275            .with_region("us-west-2")
276            .build()
277            .unwrap();
278
279        let url = wrapper
280            .put("k.txt", b"s3-via-builder", "text/plain")
281            .await
282            .unwrap();
283        assert!(url.starts_with("s3://test-bucket.us-west-2/"));
284
285        let data = wrapper.get("k.txt").await.unwrap();
286        assert_eq!(data, b"s3-via-builder");
287
288        assert!(wrapper.exists("k.txt").await.unwrap());
289        wrapper.delete("k.txt").await.unwrap();
290        assert!(!wrapper.exists("k.txt").await.unwrap());
291    }
292
293    #[cfg(not(feature = "real-cloud"))]
294    #[tokio::test]
295    async fn test_builder_aliyun_actually_works() {
296        let wrapper = StorageBuilder::new(StorageProvider::AliyunOss(AliyunConfig::default()))
297            .with_bucket("bucket")
298            .with_endpoint("oss-cn-hangzhou.aliyuncs.com")
299            .build()
300            .unwrap();
301
302        wrapper
303            .put("k", b"aliyun-via-builder", "text/plain")
304            .await
305            .unwrap();
306        assert_eq!(wrapper.get("k").await.unwrap(), b"aliyun-via-builder");
307    }
308
309    #[cfg(not(feature = "real-cloud"))]
310    #[tokio::test]
311    async fn test_builder_tencent_actually_works() {
312        let wrapper = StorageBuilder::new(StorageProvider::TencentCos(TencentConfig::default()))
313            .with_bucket("bucket")
314            .with_region("ap-guangzhou")
315            .build()
316            .unwrap();
317
318        wrapper
319            .put("k", b"tencent-via-builder", "text/plain")
320            .await
321            .unwrap();
322        assert_eq!(wrapper.get("k").await.unwrap(), b"tencent-via-builder");
323        assert!(wrapper.exists("k").await.unwrap());
324    }
325
326    #[cfg(not(feature = "real-cloud"))]
327    #[tokio::test]
328    async fn test_builder_qiniu_actually_works() {
329        let wrapper = StorageBuilder::new(StorageProvider::QiniuKodo(QiniuConfig::default()))
330            .with_bucket("bucket")
331            .build()
332            .unwrap();
333
334        wrapper
335            .put("k", b"qiniu-via-builder", "text/plain")
336            .await
337            .unwrap();
338        assert_eq!(wrapper.get("k").await.unwrap(), b"qiniu-via-builder");
339    }
340
341    #[cfg(not(feature = "real-cloud"))]
342    #[tokio::test]
343    async fn test_builder_huawei_actually_works() {
344        let wrapper = StorageBuilder::new(StorageProvider::HuaweiObs(HuaweiConfig::default()))
345            .with_bucket("bucket")
346            .with_endpoint("obs.cn-north-4.myhuaweicloud.com")
347            .build()
348            .unwrap();
349
350        wrapper
351            .put("k", b"huawei-via-builder", "text/plain")
352            .await
353            .unwrap();
354        assert_eq!(wrapper.get("k").await.unwrap(), b"huawei-via-builder");
355    }
356
357    #[cfg(not(feature = "real-cloud"))]
358    #[tokio::test]
359    async fn test_builder_upyun_actually_works() {
360        let wrapper = StorageBuilder::new(StorageProvider::UpYun(UpYunConfig::default()))
361            .with_bucket("bucket")
362            .build()
363            .unwrap();
364
365        wrapper
366            .put("k", b"upyun-via-builder", "text/plain")
367            .await
368            .unwrap();
369        assert_eq!(wrapper.get("k").await.unwrap(), b"upyun-via-builder");
370    }
371
372    #[tokio::test]
373    async fn test_storage_wrapper_put() {
374        let temp_dir = test_data_base().join(format!("storage_test_{}", uuid_simple()));
375        let wrapper = StorageWrapper::Local(LocalStorage::new(temp_dir.to_string_lossy()));
376
377        let url = wrapper
378            .put("test.txt", b"data", "text/plain")
379            .await
380            .unwrap();
381        assert!(url.starts_with("local://"));
382
383        tokio::fs::remove_dir_all(&temp_dir).await.ok();
384    }
385
386    #[tokio::test]
387    async fn test_storage_wrapper_get() {
388        let temp_dir = test_data_base().join(format!("storage_test_{}", uuid_simple()));
389        let wrapper = StorageWrapper::Local(LocalStorage::new(temp_dir.to_string_lossy()));
390
391        wrapper
392            .put("test.txt", b"data", "text/plain")
393            .await
394            .unwrap();
395        let data = wrapper.get("test.txt").await.unwrap();
396        assert_eq!(data, b"data");
397
398        tokio::fs::remove_dir_all(&temp_dir).await.ok();
399    }
400
401    #[tokio::test]
402    async fn test_storage_config_default() {
403        let config = StorageConfig::default();
404        assert_eq!(config.bucket, "default-bucket");
405        assert_eq!(config.region, "us-east-1");
406    }
407
408    #[test]
409    fn test_storage_config_debug_masks_secrets() {
410        let config = StorageConfig {
411            bucket: "my-bucket".to_string(),
412            region: "us-east-1".to_string(),
413            endpoint: Some("https://example.com".to_string()),
414            access_key: Some("AKIAIOSFODNN7EXAMPLE".to_string()),
415            secret_key: Some("wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY".to_string()),
416            path_prefix: Some("prefix/".to_string()),
417            base_path: Some("/tmp".to_string()),
418        };
419
420        let debug_output = format!("{:?}", config);
421
422        // 非敏感字段应正常输出
423        assert!(debug_output.contains("my-bucket"));
424        assert!(debug_output.contains("us-east-1"));
425        // 敏感字段必须被遮掩
426        assert!(debug_output.contains("\"***\""));
427        assert!(!debug_output.contains("AKIAIOSFODNN7EXAMPLE"));
428        assert!(!debug_output.contains("wJalrXUtnFEMI"));
429    }
430
431    #[cfg(feature = "real-cloud")]
432    #[tokio::test]
433    async fn test_real_cloud_requires_credentials() {
434        // real-cloud 下未配置凭据时必须返回 InvalidConfig,禁止静默降级为 Mock
435        for provider in [
436            StorageProvider::AliyunOss(AliyunConfig::default()),
437            StorageProvider::TencentCos(TencentConfig::default()),
438            StorageProvider::QiniuKodo(QiniuConfig::default()),
439            StorageProvider::HuaweiObs(HuaweiConfig::default()),
440            StorageProvider::UpYun(UpYunConfig::default()),
441        ] {
442            let result = StorageBuilder::new(provider)
443                .with_bucket("bucket")
444                .with_region("ap-guangzhou")
445                .with_endpoint("oss-cn-hangzhou.aliyuncs.com")
446                .build();
447            assert!(
448                matches!(result, Err(StorageError::InvalidConfig(_))),
449                "缺少凭据时应返回 InvalidConfig"
450            );
451        }
452    }
453
454    fn uuid_simple() -> String {
455        use std::time::{SystemTime, UNIX_EPOCH};
456        let now = SystemTime::now()
457            .duration_since(UNIX_EPOCH)
458            .unwrap_or_default()
459            .as_nanos();
460        format!("{:x}", now)
461    }
462}