Skip to main content

tdm_server_rust/service/
oss_service.rs

1//! OSS 对象存储服务 (OSS Service)
2//!
3//! 文件上传/下载凭证管理,支持 COS STS 预签名和 CDN 代理下载。
4
5use crate::{
6    app::AppState,
7    entity::oss::{OssCredential, OssDto},
8    error::{ApiResult, AppError},
9    repository::oss_repo::OssRepository,
10};
11use bytes::Bytes;
12
13/// OSS 服务
14pub struct OssService;
15
16impl OssService {
17    /// 新增或更新 OSS 文件记录并绑定到话数岗位。
18    ///
19    /// # Errors
20    ///
21    /// - `AppError::Database` — 数据库写入失败
22    /// - `AppError::Internal` — COS STS 凭证获取失败
23    #[tracing::instrument(skip_all, level = "debug")]
24    pub async fn upsert_oss(
25        state: &AppState,
26        dto: OssDto,
27        member_id: i32,
28    ) -> ApiResult<()> {
29        let cfg = (*state.config).clone();
30        OssRepository::new(state.db.clone(), cfg)
31            .upsert_oss(&dto, member_id)
32            .await?;
33        Ok(())
34    }
35
36    /// 获取 COS 文件上传预签名凭证。
37    ///
38    /// # 返回值
39    ///
40    /// 返回 [`OssCredential`],包含预签名上传 URL 和对象 Key。
41    ///
42    /// # Errors
43    ///
44    /// - `AppError::business("文件后缀不允许上传喵")` — 后缀不在白名单
45    /// - `AppError::business("文件超出XXMB限制喵")` — 文件超过大小限制
46    #[tracing::instrument(skip_all, level = "debug")]
47    pub async fn get_upload_credential(
48        state: &AppState,
49        episode_id: i32,
50        post_name: String,
51        filename: String,
52    ) -> ApiResult<OssCredential> {
53        let cfg = (*state.config).clone();
54        OssRepository::new(state.db.clone(), cfg)
55            .get_upload_credential(episode_id, &post_name, &filename)
56            .await
57    }
58
59    /// 获取图片上传凭证
60    #[tracing::instrument(skip_all, level = "debug")]
61    pub async fn get_image_upload_credential(
62        state: &AppState,
63        image_type: String,
64        filename: String,
65    ) -> ApiResult<OssCredential> {
66        let cfg = (*state.config).clone();
67        OssRepository::new(state.db.clone(), cfg)
68            .get_image_upload_credential(&image_type, &filename)
69            .await
70    }
71
72    /// 获取文件下载预签名 URL。
73    ///
74    /// # 返回值
75    ///
76    /// 返回 [`OssCredential`],`presigned_url` 为 CDN 鉴权链接。
77    ///
78    /// # Errors
79    ///
80    /// - `AppError::DownloadUnAuth` — 无下载权限
81    #[tracing::instrument(skip_all, level = "debug")]
82    pub async fn get_download_credential(
83        state: &AppState,
84        episode_id: i32,
85        post_name: String,
86    ) -> ApiResult<OssCredential> {
87        let cfg = (*state.config).clone();
88        OssRepository::new(state.db.clone(), cfg)
89            .get_download_credential(episode_id, &post_name)
90            .await
91    }
92
93    /// 鉴权后返回 CDN 直链与文件名(用于 302 跳转)。
94    ///
95    /// # 返回值
96    ///
97    /// `(presigned_url, filename)` — CDN 鉴权链接和原始文件名。
98    ///
99    /// # Errors
100    ///
101    /// - `AppError::DownloadUnAuth` — 无下载权限(由 `get_download_credential` 传播)
102    #[tracing::instrument(skip_all, level = "debug")]
103    pub async fn download_redirect_target(
104        state: &AppState,
105        episode_id: i32,
106        post_name: String,
107    ) -> ApiResult<(String, String)> {
108        let cred = Self::get_download_credential(state, episode_id, post_name).await?;
109        let filename = filename_from_credential(&cred);
110        Ok((cred.presigned_url, filename))
111    }
112
113    /// 服务端代理拉取 OSS/CDN 文件。
114    ///
115    /// CORS 不可用时的回退路径:服务器下载后返回给客户端。
116    ///
117    /// # 返回值
118    ///
119    /// `(filename, bytes)` — 原始文件名和文件内容。
120    ///
121    /// # Errors
122    ///
123    /// - `AppError::Internal("拉取 OSS 文件失败: ...")` — HTTP 请求失败
124    /// - `AppError::Oss { msg: "OSS 下载失败: HTTP XXX" }` — OSS 返回非 2xx
125    /// - `AppError::Internal("读取 OSS 文件失败: ...")` — 响应体读取失败
126    #[tracing::instrument(skip_all, level = "debug")]
127    pub async fn download_file_proxy(
128        state: &AppState,
129        episode_id: i32,
130        post_name: String,
131    ) -> ApiResult<(String, Bytes)> {
132        let cred = Self::get_download_credential(state, episode_id, post_name.clone()).await?;
133        let filename = filename_from_credential(&cred);
134
135        let resp = crate::telemetry::traced_http_execute(
136            &state.http_client,
137            state.http_client.get(&cred.presigned_url),
138        )
139        .await
140            .map_err(|e| AppError::Internal(format!("拉取 OSS 文件失败: {e}")))?;
141        if !resp.status().is_success() {
142            return Err(AppError::Oss {
143                code: None,
144                msg: format!("OSS 下载失败: HTTP {}", resp.status()),
145            });
146        }
147        let bytes = resp
148            .bytes()
149            .await
150            .map_err(|e| AppError::Internal(format!("读取 OSS 文件失败: {e}")))?;
151        crate::utils::agent_debug::log(
152            "H1",
153            "oss_service.rs:download_file_proxy",
154            "oss_proxy_ok",
155            serde_json::json!({
156                "episodeId": episode_id,
157                "postName": post_name,
158                "filename": filename,
159                "bytes": bytes.len()
160            }),
161        );
162        Ok((filename, bytes))
163    }
164}
165
166/// 从凭证解析下载文件名
167fn filename_from_credential(cred: &OssCredential) -> String {
168    cred.original_filename
169        .as_ref()
170        .filter(|s| !s.trim().is_empty())
171        .cloned()
172        .or_else(|| {
173            cred.object_key
174                .as_ref()
175                .and_then(|k| k.split('/').next_back())
176                .map(|s| s.to_string())
177        })
178        .unwrap_or_else(|| "download.bin".to_string())
179}