Skip to main content

helix_core/ports/
upload.rs

1//! FileUploader port — 本地文件上传抽象。
2//!
3//! 请求中的 `upload_url` 仅供 adapter 执行写入;成功结果只允许回传长期可读的
4//! `public_url`,不得从预签名 PUT URL 推导或复制。
5
6use std::sync::Arc;
7
8use crate::effect::{FileUploadRequest, FileUploadResponse};
9use crate::error::PortError;
10use crate::platform::{MaybeSend, MaybeSync};
11
12/// 上传进度采样。driver 只报告已读出的字节事实;百分比节流由 host adapter 兑现。
13#[derive(Debug, Clone, Copy, PartialEq, Eq)]
14pub struct FileUploadProgress {
15    pub completed_bytes: u64,
16    pub total_bytes: u64,
17}
18
19/// 上传进度策略。`Disabled` 是默认值,避免图片等无需百分比的上传制造逐块 Tick。
20#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
21pub enum FileUploadProgressPolicy {
22    #[default]
23    Disabled,
24    /// 每跨过一个百分比步长才回灌。adapter 必须忽略 0 或大于 100 的非法步长。
25    PercentStep(u8),
26}
27
28/// runtime-neutral 的上传进度回报口。
29///
30/// 具体 channel / atomic / task 均留在 driver;core 只定义同步、无 I/O 的字节事实回调。
31pub trait FileUploadProgressReporter: MaybeSend + MaybeSync + 'static {
32    fn report(&self, progress: FileUploadProgress);
33}
34
35/// 文件上传的短期写地址与长期读地址,二者必须同时存在。
36#[derive(Debug, Clone, PartialEq, Eq)]
37pub struct FileUploadUrls {
38    upload_url: String,
39    public_url: String,
40    // 保持 FileUploadRequest 的公开字段兼容:策略随其已有私有封装携带。
41    progress_policy: FileUploadProgressPolicy,
42}
43
44impl FileUploadUrls {
45    pub fn new(upload_url: String, public_url: String) -> Result<Self, &'static str> {
46        if upload_url.trim().is_empty() || public_url.trim().is_empty() {
47            return Err("upload_url and public_url must not be empty");
48        }
49        Ok(Self {
50            upload_url,
51            public_url,
52            progress_policy: FileUploadProgressPolicy::Disabled,
53        })
54    }
55
56    pub fn with_progress_policy(mut self, policy: FileUploadProgressPolicy) -> Self {
57        self.progress_policy = policy;
58        self
59    }
60
61    pub fn progress_policy(&self) -> FileUploadProgressPolicy {
62        self.progress_policy
63    }
64
65    pub fn upload_url(&self) -> &str {
66        &self.upload_url
67    }
68
69    pub fn public_url(&self) -> &str {
70        &self.public_url
71    }
72
73    pub fn into_parts(self) -> (String, String) {
74        (self.upload_url, self.public_url)
75    }
76}
77
78#[cfg_attr(not(target_arch = "wasm32"), async_trait::async_trait)]
79#[cfg_attr(target_arch = "wasm32", async_trait::async_trait(?Send))]
80pub trait FileUploader: MaybeSend + MaybeSync + 'static {
81    async fn upload(&self, req: FileUploadRequest) -> Result<FileUploadResponse, PortError>;
82
83    /// 带可选进度回报的上传入口。既有 adapter 无需改动即可沿用终态-only 行为。
84    async fn upload_with_progress(
85        &self,
86        req: FileUploadRequest,
87        progress: Option<Arc<dyn FileUploadProgressReporter>>,
88    ) -> Result<FileUploadResponse, PortError> {
89        let _ = progress;
90        self.upload(req).await
91    }
92}
93
94#[cfg(test)]
95mod tests {
96    use super::*;
97    use crate::effect::{Correlation, Effect, FileUploadRequest};
98
99    #[test]
100    fn upload_effect_keeps_distinct_validated_urls() {
101        let req = FileUploadRequest {
102            local_path: "/tmp/a.png".into(),
103            object_key: "im/a.png".into(),
104            method: "PUT".into(),
105            urls: FileUploadUrls::new(
106                "https://upload/a?signature=secret".into(),
107                "https://cdn/a.png".into(),
108            )
109            .expect("valid urls")
110            .with_progress_policy(FileUploadProgressPolicy::PercentStep(5)),
111            headers: Vec::new(),
112            content_type: Some("image/png".into()),
113            size: Some(123),
114        };
115        let Effect::UploadFile { corr, req } = (Effect::UploadFile {
116            corr: Correlation::from_raw(9),
117            req,
118        }) else {
119            panic!("expected upload effect");
120        };
121        assert_eq!(corr.raw(), 9);
122        assert!(req.urls.upload_url().contains("signature=secret"));
123        assert_eq!(req.urls.public_url(), "https://cdn/a.png");
124        assert_eq!(
125            req.progress_policy(),
126            FileUploadProgressPolicy::PercentStep(5)
127        );
128    }
129
130    #[test]
131    fn upload_urls_require_write_and_public_addresses() {
132        assert!(FileUploadUrls::new(String::new(), "https://cdn/a".into()).is_err());
133        assert!(FileUploadUrls::new("https://upload/a".into(), String::new()).is_err());
134    }
135}