Skip to main content

tdm_server_rust/service/
editor_source_archive_service.rs

1//! 编辑器图源现打包服务 (Editor Source Archive)
2//!
3//! 新模型下图源不再以压缩包形式存储——编辑器页面图片已由前端解压后逐张直传到图片 OSS。
4//! 「下载图源」时本服务按当前页面集计算内容哈希:
5//!
6//! 1. 哈希 = `episode_id` + 各页 `page_index` + `image_object_key`,页面集不变则哈希不变
7//! 2. 若图片 OSS 已存在同哈希压缩包(`editor/episode_{id}/source_{hash}.zip`)→ 直接复用
8//! 3. 否则现拉取各页图片打成 zip,上传到图片 OSS 缓存后返回下载地址
9//!
10//! 这样「内容未改变时」始终复用同一个打包 hash 压缩包,避免重复打包。
11
12use crate::app::AppState;
13use crate::entity::oss::OssCredential;
14use crate::error::{ApiResult, AppError};
15use crate::repository::editor_page_repo::EditorPageRepository;
16use crate::repository::oss_repo::OssRepository;
17use crate::sea_entity::editor_page;
18use reqwest::header::CONTENT_TYPE;
19use sha1::{Digest, Sha1};
20use std::io::{Cursor, Write};
21use std::time::Duration;
22use zip::write::SimpleFileOptions;
23
24/// 图源压缩包缓存探测超时。
25const ARCHIVE_EXISTS_TIMEOUT: Duration = Duration::from_secs(3);
26
27/// 图源现打包服务
28pub struct EditorSourceArchiveService;
29
30impl EditorSourceArchiveService {
31    /// 获取或现打包某话数图源压缩包,返回下载凭证(指向图片 OSS 中的 zip)。
32    ///
33    /// ## 错误
34    /// - 话数尚无图源页面(既无 provider 压缩包也未注册单图)
35    /// - 图片拉取、打包或上传失败
36    #[tracing::instrument(skip_all, level = "debug")]
37    pub async fn get_or_build_archive(
38        state: &AppState,
39        episode_id: i32,
40    ) -> ApiResult<OssCredential> {
41        let page_repo = EditorPageRepository::new(state.db.clone());
42        let pages = page_repo.list_by_episode(episode_id).await?;
43        if pages.is_empty() {
44            return Err(AppError::business("当前话数尚无图源页面喵"));
45        }
46
47        // 内容哈希:页面集(页序 + 对象键)不变则哈希不变,命中缓存
48        let archive_key = archive_object_key(episode_id, compute_pages_hash(episode_id, &pages));
49
50        let oss_repo = OssRepository::new(state.db.clone(), (*state.config).clone());
51        let archive_url = oss_repo.image_display_url(&archive_key);
52        let filename = build_archive_filename(state, episode_id).await?;
53
54        // 命中缓存:图片 OSS 已存在同哈希压缩包,直接返回
55        if archive_exists(state, &archive_url).await {
56            tracing::info!("图源压缩包命中缓存 episode_id={episode_id} key={archive_key}");
57            return Ok(OssCredential {
58                presigned_url: archive_url,
59                object_key: Some(archive_key),
60                original_filename: Some(filename),
61            });
62        }
63
64        // 未命中:现拉各页图片打包并上传
65        let zip_bytes = build_zip(state, &pages).await?;
66        // 压缩包体积可能超过单图上限,放宽到压缩包上限
67        let archive_max = (state
68            .config
69            .tencent
70            .max_file_size
71            .max(state.config.tencent.image_max_file_size))
72            * 1024
73            * 1024;
74        let put_url = oss_repo
75            .presigned_image_put_url(&archive_key, archive_max)
76            .await?;
77        upload_bytes(state, &put_url, "application/zip", zip_bytes).await?;
78        tracing::info!(
79            "图源压缩包现打包完成 episode_id={episode_id} key={archive_key} pages={}",
80            pages.len()
81        );
82
83        Ok(OssCredential {
84            presigned_url: archive_url,
85            object_key: Some(archive_key),
86            original_filename: Some(filename),
87        })
88    }
89}
90
91/// 构建图源压缩包对外下载文件名(与前端 SpecificationFileName 的图源规则保持一致)
92async fn build_archive_filename(state: &AppState, episode_id: i32) -> ApiResult<String> {
93    let Some((manga_id, manga_episode, provider_id)) =
94        sqlx::query_as::<_, (i32, String, Option<i32>)>(
95            r#"SELECT "mangaId", "mangaEpisode", "providerId" FROM mangaepisodetb WHERE "Id" = $1"#,
96        )
97        .bind(episode_id)
98        .fetch_optional(&state.db)
99        .await?
100    else {
101        return Ok(format!("episode_{episode_id}_source.zip"));
102    };
103
104    let manga_name =
105        sqlx::query_as::<_, (String,)>(r#"SELECT "mangaTranName" FROM mangatb WHERE "Id" = $1"#)
106            .bind(manga_id)
107            .fetch_optional(&state.db)
108            .await?
109            .map(|row| row.0)
110            .unwrap_or_default();
111
112    let provider_name = match provider_id {
113        Some(provider_id) => {
114            sqlx::query_as::<_, (String,)>(r#"SELECT username FROM membertb WHERE "Id" = $1"#)
115                .bind(provider_id)
116                .fetch_optional(&state.db)
117                .await?
118                .map(|row| row.0)
119                .unwrap_or_default()
120        }
121        None => String::new(),
122    };
123
124    let manga_segment = sanitize_filename_segment(&manga_name);
125    let episode_segment = manga_episode
126        .split('+')
127        .filter(|ep| !ep.is_empty())
128        .map(|ep| format!("第{ep}话"))
129        .collect::<Vec<_>>()
130        .join("、");
131    let provider_segment = sanitize_filename_segment(&provider_name);
132
133    let mut parts = vec![manga_segment, episode_segment]
134        .into_iter()
135        .filter(|part| !part.is_empty())
136        .collect::<Vec<_>>();
137    parts.push(format!(" 图源:{provider_segment}"));
138
139    let filename = parts.join("");
140    if filename.trim().is_empty() {
141        Ok(format!("episode_{episode_id}_source.zip"))
142    } else {
143        Ok(format!("{filename}.zip"))
144    }
145}
146
147/// 清理文件名片段中的系统非法字符(替换为前端使用的全角字符)
148fn sanitize_filename_segment(segment: &str) -> String {
149    segment
150        .chars()
151        .map(|ch| match ch {
152            '\\' => '\',
153            '/' => '/',
154            ':' => ':',
155            '*' => '*',
156            '?' => '?',
157            '"' => '"',
158            '<' => '<',
159            '>' => '>',
160            '|' => '|',
161            other => other,
162        })
163        .collect()
164}
165
166/// 计算页面集内容哈希(SHA1 十六进制)
167fn compute_pages_hash(episode_id: i32, pages: &[editor_page::Model]) -> String {
168    compute_archive_hash(
169        episode_id,
170        pages
171            .iter()
172            .map(|p| (p.page_index, p.image_object_key.as_str())),
173    )
174}
175
176/// 计算图源页面集压缩包哈希。
177pub fn compute_archive_hash<'a>(
178    episode_id: i32,
179    pages: impl IntoIterator<Item = (i32, &'a str)>,
180) -> String {
181    let mut hasher = Sha1::new();
182    hasher.update(episode_id.to_le_bytes());
183    for (page_index, object_key) in pages {
184        hasher.update(page_index.to_le_bytes());
185        hasher.update(object_key.as_bytes());
186        hasher.update(b"\n");
187    }
188    hasher
189        .finalize()
190        .iter()
191        .map(|b| format!("{b:02x}"))
192        .collect()
193}
194
195/// 生成图源压缩包对象键。
196pub fn archive_object_key(episode_id: i32, hash: String) -> String {
197    format!("editor/episode_{episode_id}/source_{hash}.zip")
198}
199
200/// 判断图片 OSS 中是否已存在该压缩包(HEAD 返回 2xx 视为命中)
201async fn archive_exists(state: &AppState, url: &str) -> bool {
202    match tokio::time::timeout(ARCHIVE_EXISTS_TIMEOUT, state.http_client.head(url).send()).await {
203        Ok(Ok(resp)) => resp.status().is_success(),
204        Ok(Err(err)) => {
205            tracing::warn!("图源压缩包缓存探测失败 url={url} error={err}");
206            false
207        }
208        Err(_) => {
209            tracing::warn!("图源压缩包缓存探测超时 url={url}");
210            false
211        }
212    }
213}
214
215/// 拉取各页图片并打成 zip 字节
216async fn build_zip(state: &AppState, pages: &[editor_page::Model]) -> ApiResult<Vec<u8>> {
217    let fetched =
218        futures::future::try_join_all(pages.iter().enumerate().map(|(idx, page)| async move {
219            let bytes = fetch_image(state, &page.image_url).await?;
220            Ok::<_, AppError>((idx, page, bytes))
221        }))
222        .await?;
223    let mut buf = Vec::new();
224    {
225        let mut zip = zip::ZipWriter::new(Cursor::new(&mut buf));
226        // 图片通常已是 JPG/PNG/WebP 压缩格式;zip 存储模式可避免首下载时重复压缩拖慢响应。
227        let options =
228            SimpleFileOptions::default().compression_method(zip::CompressionMethod::Stored);
229        for (idx, page, bytes) in fetched {
230            let name = entry_name(idx, page);
231            zip.start_file(name, options)
232                .map_err(|e| AppError::Internal(format!("zip 写入失败:{e}")))?;
233            zip.write_all(&bytes)
234                .map_err(|e| AppError::Internal(format!("zip 写入失败:{e}")))?;
235        }
236        zip.finish()
237            .map_err(|e| AppError::Internal(format!("zip 收尾失败:{e}")))?;
238    }
239    Ok(buf)
240}
241
242/// 压缩包内单页文件名(页序前缀 + 原始文件名,保证有序且唯一)
243fn entry_name(idx: usize, page: &editor_page::Model) -> String {
244    let base = page.source_file_name.clone().unwrap_or_else(|| {
245        let ext = page
246            .image_object_key
247            .rsplit('.')
248            .next()
249            .filter(|e| e.len() <= 5 && !e.contains('/'))
250            .unwrap_or("jpg");
251        format!("page.{ext}")
252    });
253    format!("{idx:04}_{base}")
254}
255
256/// 拉取单张图片字节(图片 URL 为 CDN/直链,公网可读)
257async fn fetch_image(state: &AppState, url: &str) -> ApiResult<Vec<u8>> {
258    let resp = state
259        .http_client
260        .get(url)
261        .send()
262        .await
263        .map_err(|e| AppError::Oss {
264            code: None,
265            msg: format!("下载图片失败:{e}"),
266        })?;
267    if !resp.status().is_success() {
268        return Err(AppError::Oss {
269            code: None,
270            msg: format!("下载图片返回非 2xx:HTTP {}", resp.status()),
271        });
272    }
273    let bytes = resp
274        .bytes()
275        .await
276        .map_err(|e| AppError::Internal(format!("读取图片字节失败:{e}")))?;
277    Ok(bytes.to_vec())
278}
279
280/// 通过预签名 PUT URL 上传字节
281async fn upload_bytes(
282    state: &AppState,
283    put_url: &str,
284    content_type: &str,
285    bytes: Vec<u8>,
286) -> ApiResult<()> {
287    let resp = state
288        .http_client
289        .put(put_url)
290        .header(CONTENT_TYPE, content_type)
291        .body(bytes)
292        .send()
293        .await
294        .map_err(|e| AppError::Oss {
295            code: None,
296            msg: format!("上传压缩包到 OSS 失败:{e}"),
297        })?;
298    if !resp.status().is_success() {
299        return Err(AppError::Oss {
300            code: None,
301            msg: format!("上传压缩包到 OSS 返回非 2xx:HTTP {}", resp.status()),
302        });
303    }
304    Ok(())
305}