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