1use 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
24const ARCHIVE_EXISTS_TIMEOUT: Duration = Duration::from_secs(3);
26
27pub struct EditorSourceArchiveService;
29
30impl EditorSourceArchiveService {
31 #[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 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 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 let zip_bytes = build_zip(state, &pages).await?;
66 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
91async 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
147fn 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
166fn 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
176pub 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
195pub fn archive_object_key(episode_id: i32, hash: String) -> String {
197 format!("editor/episode_{episode_id}/source_{hash}.zip")
198}
199
200async 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
215async 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 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
242fn 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
256async 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
280async 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}