1use crate::{
8 app::AppState,
9 config::AppConfig,
10 entity::{
11 enums::{MemberInternEnum, PostEnum},
12 manga::CollectedMembersVo,
13 rss::{EpisodeRssRow, RssItem, RssMangaRow, WorkReminderRssRow, RSS_POST_CONFIGS},
14 },
15 error::{ApiResult, AppError},
16 repository::{
17 episode_repo::EpisodeRepository, manga_repo::MangaRepository, member_repo::MemberRepository,
18 },
19 utils::rss_labels::{
20 current_post_label, format_members, format_rss_time, intern_label, next_post_label,
21 rss_category,
22 },
23};
24use std::{collections::HashSet, future::Future, path::PathBuf};
25
26#[derive(Debug, Clone)]
28pub enum RssRefreshScope {
29 EpisodePipeline,
31 WorkflowSubmit {
33 post_name: String,
35 },
36 NewManga,
38 MangaUpdated,
40 PublishLink {
42 episode_id: i32,
44 },
45 MemberReminder,
47 ExternalOutput {
49 xml: String,
51 },
52}
53
54pub struct RssService;
59
60impl RssService {
61 pub fn refresh(state: &AppState, scope: RssRefreshScope) {
63 let st = state.clone();
64 let label = refresh_log_label(&scope);
65 Self::schedule(
66 label,
67 async move { Self::execute_refresh(&st, scope).await },
68 );
69 }
70
71 #[tracing::instrument(skip_all, level = "info")]
73 pub async fn refresh_now(state: &AppState, scope: RssRefreshScope) -> ApiResult<()> {
74 Self::execute_refresh(state, scope).await
75 }
76
77 #[tracing::instrument(skip_all, level = "info")]
79 pub async fn run_startup_refresh(state: &AppState) -> ApiResult<()> {
80 Self::execute_refresh(state, RssRefreshScope::EpisodePipeline).await?;
81 Self::execute_refresh(state, RssRefreshScope::MemberReminder).await
82 }
83
84 #[tracing::instrument(skip_all, level = "debug")]
85 async fn execute_refresh(state: &AppState, scope: RssRefreshScope) -> ApiResult<()> {
86 match scope {
87 RssRefreshScope::EpisodePipeline => {
88 Self::rss_manga(state).await?;
89 Self::rss_episode_new(state).await?;
90 Self::rss_episode_all_posts(state).await
91 }
92 RssRefreshScope::WorkflowSubmit { post_name } => {
93 Self::rss_episode(state, &post_name).await?;
94 Self::rss_reminder(state).await
95 }
96 RssRefreshScope::NewManga | RssRefreshScope::MangaUpdated => {
97 Self::rss_manga(state).await
98 }
99 RssRefreshScope::PublishLink { episode_id } => {
100 Self::rss_publish_link(state, episode_id).await
101 }
102 RssRefreshScope::MemberReminder => Self::rss_reminder(state).await,
103 RssRefreshScope::ExternalOutput { xml } => Self::rss_output(state, xml).await,
104 }
105 }
106
107 pub fn schedule<F>(label: &'static str, fut: F)
109 where
110 F: Future<Output = ApiResult<()>> + Send + 'static,
111 {
112 tokio::spawn(async move {
113 if let Err(e) = fut.await {
114 tracing::error!(rss = label, error = ?e, "RSS 生成失败");
115 }
116 });
117 }
118
119 #[tracing::instrument(skip_all, level = "debug")]
121 pub async fn rss_manga(state: &AppState) -> ApiResult<()> {
122 let _guard = state.rss_file_lock.lock("rssManga.xml").await;
123 let manga_repo = MangaRepository::new(state.db.clone());
124 let new_mangas = manga_repo.get_manga_rss().await?;
125 let updated_mangas = manga_repo.get_manga_updated_rss().await?;
126 let episodes = manga_repo.get_episode_rss().await?;
127 let link_base = manga_link_base(&state.config);
128 let site = state.config.rss.site_base_url.trim_end_matches('/');
129 let items = build_rss_manga_items(
130 &new_mangas,
131 &updated_mangas,
132 &episodes,
133 &manga_repo,
134 &link_base,
135 site,
136 &state.config,
137 )
138 .await?;
139 write_rss_file(state, "rssManga.xml", &generate_rss_xml(&items, site)).await
140 }
141
142 #[tracing::instrument(skip_all, level = "debug")]
144 pub async fn rss_episode_new(state: &AppState) -> ApiResult<()> {
145 let _guard = state.rss_file_lock.lock("rssEpisode.xml").await;
146 let manga_repo = MangaRepository::new(state.db.clone());
147 let episodes = manga_repo.get_episode_rss().await?;
148 let link_base = manga_link_base(&state.config);
149 let site = state.config.rss.site_base_url.trim_end_matches('/');
150 let mut items = Vec::new();
151 for episode in &episodes {
152 let members = manga_repo.get_collected_members(episode.manga_id).await?;
153 items.push(build_episode_item(
154 episode,
155 &members,
156 &link_base,
157 &state.config,
158 )?);
159 }
160 write_rss_file(state, "rssEpisode.xml", &generate_rss_xml(&items, site)).await
161 }
162
163 #[tracing::instrument(skip_all, level = "debug")]
165 pub async fn rss_episode_all_posts(state: &AppState) -> ApiResult<()> {
166 for cfg in RSS_POST_CONFIGS {
167 Self::rss_episode(state, cfg.post_name).await?;
168 }
169 Ok(())
170 }
171
172 #[tracing::instrument(skip_all, level = "debug")]
174 pub async fn rss_episode(state: &AppState, post_name: &str) -> ApiResult<()> {
175 let file_name = RSS_POST_CONFIGS
176 .iter()
177 .find(|c| c.post_name == post_name)
178 .map(|c| c.file_name)
179 .ok_or_else(|| AppError::business(format!("未知 RSS 岗位: {post_name}")))?;
180 let _guard = state.rss_file_lock.lock(file_name).await;
181 let rows = MangaRepository::new(state.db.clone())
182 .get_work_reminder_rss(post_name)
183 .await?;
184 let link_base = manga_link_base(&state.config);
185 let site = state.config.rss.site_base_url.trim_end_matches('/');
186 let items: Vec<RssItem> = rows
187 .iter()
188 .map(|r| build_work_reminder_item(r, &link_base, &state.config))
189 .collect();
190 write_rss_file(state, file_name, &generate_rss_xml(&items, site)).await
191 }
192
193 #[tracing::instrument(skip_all, level = "debug")]
195 pub async fn rss_publish_link(state: &AppState, episode_id: i32) -> ApiResult<()> {
196 let _guard = state.rss_file_lock.lock("rssPublishLink.xml").await;
197 let ep_repo = EpisodeRepository::new(state.db.clone());
198 let manga_repo = MangaRepository::new(state.db.clone());
199 let ep = ep_repo
200 .get_by_id(episode_id)
201 .await?
202 .ok_or_else(|| AppError::business("话数不存在喵"))?;
203 let publish_link = ep.publish_link.as_deref().unwrap_or("").trim();
204 if publish_link.is_empty() {
205 tracing::error!("发布链接为空!episode_id={episode_id}");
206 return Ok(());
207 }
208 let manga_id = ep.manga_id;
209 let detail = manga_repo.get_manga_detail_by_id(manga_id).await?;
210 let manga = detail.ok_or_else(|| AppError::business("漫画不存在喵"))?;
211 let link_base = manga_link_base(&state.config);
212 let site = state.config.rss.site_base_url.trim_end_matches('/');
213 let tran = manga.manga_tran_name.unwrap_or_default();
214 let ori = manga.manga_ori_name.unwrap_or_default();
215 let ep_num = ep.manga_episode.clone().unwrap_or_default();
216 let ep_name = ep.manga_episode_name.as_deref().unwrap_or("");
217 let title = build_publish_link_title(&tran, &ori, &ep_num, ep_name);
218 let item = RssItem {
219 title,
220 link: format!("{link_base}{manga_id}"),
221 author: manga.author_name.unwrap_or_default(),
222 pub_date: format_rss_time(ep.setup_time),
223 category: rss_category(manga.category, manga.manga_status),
224 description: format!(
225 "- - - - -☆漫画发布完成,大家快去看吧☆- - - - -\n\
226 本话创立时间:{} 漫画创立时间:{}\n\
227 本话已发布完成,可以开始阅读啦!大家辛苦了(*^▽^*)欢迎大家去{}观看此话!\n",
228 format_rss_time(ep.setup_time),
229 format_rss_time(manga.setup_time),
230 publish_link
231 ),
232 enclosure_url: build_image_url(&state.config, manga.image.as_deref().unwrap_or("")),
233 enclosure_type: guess_mime(manga.image.as_deref().unwrap_or("")),
234 guid: format!("publish-{episode_id}"),
235 };
236 write_rss_file(
237 state,
238 "rssPublishLink.xml",
239 &generate_rss_xml(&[item], site),
240 )
241 .await
242 }
243
244 #[tracing::instrument(skip_all, level = "debug")]
246 pub async fn rss_reminder(state: &AppState) -> ApiResult<()> {
247 let _guard = state.rss_file_lock.lock("rssMember.xml").await;
248 let member_repo = MemberRepository::new(state.db.clone());
249 let members = member_repo.get_member_reminder_rss().await?;
250 let site = state.config.rss.site_base_url.trim_end_matches('/');
251 let mut items = Vec::new();
252 for member in members {
253 if !MemberInternEnum::is_working_member(member.intern) {
254 continue;
255 }
256 let posts = member_repo.get_post_ids_by_member(member.id).await?;
257 if !posts.iter().any(|p| {
258 *p == PostEnum::TRANSLATOR
259 || *p == PostEnum::PROOFREADER
260 || *p == PostEnum::LETTERER
261 }) {
262 continue;
263 }
264 let desc = member_reminder_description(member.intern);
265 items.push(RssItem {
266 title: "长期未交稿提醒".to_string(),
267 link: member.email.clone().unwrap_or_default(),
268 author: member.username.clone().unwrap_or_default(),
269 pub_date: String::new(),
270 category: String::new(),
271 description: desc,
272 enclosure_url: String::new(),
273 enclosure_type: String::new(),
274 guid: format!("member-reminder-{}", member.id),
275 });
276 }
277 write_rss_file(state, "rssMember.xml", &generate_rss_xml(&items, site)).await
278 }
279
280 #[tracing::instrument(skip_all, level = "debug")]
282 pub async fn rss_output(state: &AppState, xml: String) -> ApiResult<()> {
283 let _guard = state.rss_file_lock.lock("rss.xml").await;
284 write_rss_file(state, "rss.xml", &xml).await
285 }
286}
287
288fn refresh_log_label(scope: &RssRefreshScope) -> &'static str {
290 match scope {
291 RssRefreshScope::EpisodePipeline => "rss_episode_pipeline",
292 RssRefreshScope::WorkflowSubmit { .. } => "rss_workflow_submit",
293 RssRefreshScope::NewManga | RssRefreshScope::MangaUpdated => "rss_manga",
294 RssRefreshScope::PublishLink { .. } => "rss_publish_link",
295 RssRefreshScope::MemberReminder => "rss_reminder",
296 RssRefreshScope::ExternalOutput { .. } => "rss_output",
297 }
298}
299
300fn manga_link_base(config: &AppConfig) -> String {
302 let site = config.rss.site_base_url.trim_end_matches('/');
303 format!("{site}/manga/")
304}
305
306pub fn resolve_rss_src_dir(config: &AppConfig) -> PathBuf {
308 let base = PathBuf::from(&config.folder.base);
309 if base.is_absolute() {
310 base.join("src")
311 } else {
312 std::env::current_dir()
313 .unwrap_or_else(|_| PathBuf::from("."))
314 .join(base)
315 .join("src")
316 }
317}
318
319fn build_manga_update_item(
320 manga: &RssMangaRow,
321 link_base: &str,
322 _site: &str,
323 config: &AppConfig,
324) -> ApiResult<RssItem> {
325 let tran = manga.manga_tran_name.as_deref().unwrap_or("");
326 let ori = manga.manga_ori_name.as_deref().unwrap_or("");
327 let author = if manga.author_id2.is_some() {
328 format!(
329 "原作:{} 作画:{}",
330 manga.author_name.as_deref().unwrap_or(""),
331 manga.author_name2.as_deref().unwrap_or("")
332 )
333 } else {
334 format!("作者:{}", manga.author_name.as_deref().unwrap_or(""))
335 };
336 let image = manga.image.as_deref().unwrap_or("");
337 Ok(RssItem {
338 title: format!("☆漫画信息更新☆{tran}({ori})"),
339 link: format!("{link_base}{}", manga.id),
340 author,
341 pub_date: format_rss_time(manga.update_time.or(manga.setup_time)),
342 category: rss_category(manga.category, manga.manga_status),
343 description: format!(
344 "- - - - -☆漫画信息更新☆- - - - -\n漫画《{tran}》的信息已更新,请查看最新资料喵!\n"
345 ),
346 enclosure_url: build_image_url(config, image),
347 enclosure_type: guess_mime(image),
348 guid: format!("manga-update-{}", manga.id),
349 })
350}
351
352#[tracing::instrument(skip_all, level = "debug")]
354async fn build_rss_manga_items(
355 new_mangas: &[RssMangaRow],
356 updated_mangas: &[RssMangaRow],
357 episodes: &[EpisodeRssRow],
358 manga_repo: &MangaRepository,
359 link_base: &str,
360 site: &str,
361 config: &AppConfig,
362) -> ApiResult<Vec<RssItem>> {
363 let updated_ids: HashSet<i32> = updated_mangas.iter().map(|m| m.id).collect();
364 let mut items = Vec::new();
365 for manga in new_mangas {
366 if !updated_ids.contains(&manga.id) {
367 items.push(build_manga_item(manga, link_base, site, config)?);
368 }
369 }
370 for manga in updated_mangas {
371 items.push(build_manga_update_item(manga, link_base, site, config)?);
372 }
373 for episode in episodes {
374 let members = manga_repo.get_collected_members(episode.manga_id).await?;
375 items.push(build_episode_item(episode, &members, link_base, config)?);
376 }
377 Ok(items)
378}
379
380#[cfg(test)]
382fn merge_manga_rss_ids(new_mangas: &[RssMangaRow], updated_mangas: &[RssMangaRow]) -> Vec<i32> {
383 let updated_ids: HashSet<i32> = updated_mangas.iter().map(|m| m.id).collect();
384 let mut ids = Vec::new();
385 for manga in new_mangas {
386 if !updated_ids.contains(&manga.id) {
387 ids.push(manga.id);
388 }
389 }
390 for manga in updated_mangas {
391 ids.push(manga.id);
392 }
393 ids
394}
395
396fn build_manga_item(
397 manga: &RssMangaRow,
398 link_base: &str,
399 _site: &str,
400 config: &AppConfig,
401) -> ApiResult<RssItem> {
402 let tran = manga.manga_tran_name.as_deref().unwrap_or("");
403 let ori = manga.manga_ori_name.as_deref().unwrap_or("");
404 let author = if manga.author_id2.is_some() {
405 format!(
406 "原作:{} 作画:{}",
407 manga.author_name.as_deref().unwrap_or(""),
408 manga.author_name2.as_deref().unwrap_or("")
409 )
410 } else {
411 format!("作者:{}", manga.author_name.as_deref().unwrap_or(""))
412 };
413 let image = manga.image.as_deref().unwrap_or("");
414 Ok(RssItem {
415 title: format!("☆新增漫画☆{tran}({ori})"),
416 link: format!("{link_base}{}", manga.id),
417 author,
418 pub_date: format_rss_time(manga.setup_time),
419 category: rss_category(manga.category, manga.manga_status),
420 description: format!(
421 "- - - - -☆新增漫画☆- - - - -\n漫画《{tran}》已加入漫画库,欢迎大家关注喵!\n"
422 ),
423 enclosure_url: build_image_url(config, image),
424 enclosure_type: guess_mime(image),
425 guid: format!("manga-{}", manga.id),
426 })
427}
428
429fn build_episode_item(
430 episode: &EpisodeRssRow,
431 members: &[CollectedMembersVo],
432 link_base: &str,
433 config: &AppConfig,
434) -> ApiResult<RssItem> {
435 let tran = episode.manga_name.as_deref().unwrap_or("");
436 let ori = episode.manga_ori_name.as_deref().unwrap_or("");
437 let ep_num = episode.manga_episode.as_deref().unwrap_or("");
438 let ep_name = episode.manga_episode_name.as_deref().unwrap_or("");
439 let title = if ep_name.is_empty() {
440 format!("※新增话数※{tran}({ori}) 第{ep_num}话")
441 } else {
442 format!("※新增话数※{tran}({ori}) 第{ep_num}话:{ep_name}")
443 };
444 let collect_block = {
445 let collect = format_members(members);
446 if collect.is_empty() {
447 String::new()
448 } else {
449 format!("所有收藏了本漫画的组员:\n{collect}\n\n")
450 }
451 };
452 let description = if episode
453 .translator_name
454 .as_deref()
455 .map(|s| !s.is_empty())
456 .unwrap_or(false)
457 {
458 format!(
459 "- - - - -☆新增话数,快来接稿吧☆- - - - -\n\
460 新增话数时间:{} 漫画创立时间:{}\n\
461 {collect_block}加缇酱和灯酱为好友,缇酱和灯酱还可以私聊提醒信息哦喵!\n\
462 请{}\n\n→{}({})←\n\n尽快开始翻译哦喵!\n",
463 format_rss_time(episode.setup_time),
464 format_rss_time(episode.manga_setup_time),
465 intern_label(episode.intern.unwrap_or(0)),
466 episode.translator_name.as_deref().unwrap_or(""),
467 episode.email.as_deref().unwrap_or("")
468 )
469 } else {
470 format!(
471 "- - - - -☆新增话数,快来接稿吧☆- - - - -\n\
472 新增话数时间:{} 漫画创立时间:{}\n\
473 {collect_block}目前还没有人接稿翻译……〒▽〒 快来人接稿吧喵!\n",
474 format_rss_time(episode.setup_time),
475 format_rss_time(episode.manga_setup_time),
476 )
477 };
478 let image = episode.image.as_deref().unwrap_or("");
479 Ok(RssItem {
480 title,
481 link: format!("{link_base}{}", episode.manga_id),
482 author: format!("图源:{}", episode.provider_name.as_deref().unwrap_or("")),
483 pub_date: format_rss_time(episode.setup_time),
484 category: rss_category(episode.category, episode.manga_status),
485 description,
486 enclosure_url: build_image_url(config, image),
487 enclosure_type: guess_mime(image),
488 guid: format!("episode-{}-{}", episode.id, episode.manga_id),
489 })
490}
491
492fn build_work_reminder_item(
493 episode: &WorkReminderRssRow,
494 link_base: &str,
495 config: &AppConfig,
496) -> RssItem {
497 let tran = episode.manga_name.as_deref().unwrap_or("");
498 let ori = episode.manga_ori_name.as_deref().unwrap_or("");
499 let ep_num = episode.manga_episode.as_deref().unwrap_or("");
500 let ep_name = episode.manga_episode_name.as_deref().unwrap_or("");
501 let title = if ep_name.is_empty() {
502 format!("!漫画交稿!{tran}({ori}) 第{ep_num}话")
503 } else {
504 format!("!漫画交稿!{tran}({ori}) 第{ep_num}话:{ep_name}")
505 };
506 let author = format!(
507 "{}{}({})已经完成{}",
508 intern_label(episode.intern_now.unwrap_or(0)),
509 episode.username_now.as_deref().unwrap_or(""),
510 episode.email_now.as_deref().unwrap_or(""),
511 current_post_label(&episode.my_name)
512 );
513 let description = if episode.my_name == "reviewer" {
514 format!(
515 "- - - - -☆漫画交稿,审稿已经完成啦☆- - - - -\n\
516 本话创立时间:{} 漫画创立时间:{}\n\
517 审稿已经完成啦!可以开始发布咯(๑•̀ㅂ•́)و✧!\n",
518 format_rss_time(episode.setup_time),
519 format_rss_time(episode.manga_setup_time),
520 )
521 } else if episode
522 .username
523 .as_deref()
524 .map(|s| !s.is_empty())
525 .unwrap_or(false)
526 {
527 format!(
528 "- - - - -☆漫画交稿,下一工序已指派☆- - - - -\n\
529 本话创立时间:{} 漫画创立时间:{}\n\
530 加缇酱和灯酱为好友,缇酱和灯酱还可以私聊提醒信息哦喵!\n\
531 请{}\n\n→{}({})←\n\n尽快开始{}哦喵!\n",
532 format_rss_time(episode.setup_time),
533 format_rss_time(episode.manga_setup_time),
534 intern_label(episode.intern.unwrap_or(0)),
535 episode.username.as_deref().unwrap_or(""),
536 episode.email.as_deref().unwrap_or(""),
537 next_post_label(&episode.my_name)
538 )
539 } else {
540 format!(
541 "- - - - -☆漫画交稿,下一工序待接稿☆- - - - -\n\
542 本话创立时间:{} 漫画创立时间:{}\n\
543 目前还没有人接稿{}……〒▽〒 快来人接稿吧喵!\n",
544 format_rss_time(episode.setup_time),
545 format_rss_time(episode.manga_setup_time),
546 next_post_label(&episode.my_name)
547 )
548 };
549 let image = episode.image.as_deref().unwrap_or("");
550 RssItem {
551 title,
552 link: format!("{link_base}{}", episode.manga_id),
553 author,
554 pub_date: format_rss_time(episode.setup_time),
555 category: rss_category(episode.category, episode.manga_status),
556 description,
557 enclosure_url: build_image_url(config, image),
558 enclosure_type: guess_mime(image),
559 guid: format!("reminder-{}-{}", episode.episode_id, episode.my_name),
560 }
561}
562
563fn build_publish_link_title(tran: &str, ori: &str, ep_num: &str, ep_name: &str) -> String {
567 if ep_name.is_empty() {
568 format!("!漫画发布完成!{tran}({ori}) 第{ep_num}话")
569 } else {
570 format!("!漫画发布完成!{tran}({ori}) 第{ep_num}话:{ep_name}")
571 }
572}
573
574fn member_reminder_description(intern: i16) -> String {
578 if intern == MemberInternEnum::REGULAR {
579 "- - - - -☆长期未交稿提醒☆- - - - -\n你已经超三个月都没有在任何岗位上有进展了,请尽快确认当前任务状态喵!\n".to_string()
580 } else {
581 "- - - - -☆长期未交稿提醒☆- - - - -\n你还没有在三个月内转正哦,请尽快推进任务进展,加油喵!\n".to_string()
582 }
583}
584
585fn generate_rss_xml(items: &[RssItem], site: &str) -> String {
586 let mut xml = String::from("<?xml version=\"1.0\" encoding=\"UTF-8\" ?>\n");
587 xml.push_str("<rss version=\"2.0\">\n");
588 xml.push_str(" <channel>\n");
589 xml.push_str(" <title>提灯喵汉化组</title>\n");
590 xml.push_str(&format!(" <link>{site}</link>\n"));
591 xml.push_str(" <description>提灯喵汉化组,醉心于百合和人外萌娘的汉化组</description>\n");
592 for item in items {
593 xml.push_str(" <item>\n");
594 xml.push_str(&format!(
595 " <title>{}</title>\n",
596 escape_xml(&item.title)
597 ));
598 xml.push_str(&format!(" <link>{}</link>\n", escape_xml(&item.link)));
599 xml.push_str(&format!(
600 " <author>{}</author>\n",
601 escape_xml(&item.author)
602 ));
603 xml.push_str(&format!(
604 " <pubDate>{}</pubDate>\n",
605 escape_xml(&item.pub_date)
606 ));
607 xml.push_str(&format!(
608 " <category>{}</category>\n",
609 escape_xml(&item.category)
610 ));
611 xml.push_str(&format!(
612 " <description>{}</description>\n",
613 escape_xml(&item.description)
614 ));
615 xml.push_str(&format!(
616 " <enclosure url=\"{}\" type=\"{}\" />\n",
617 escape_xml(&item.enclosure_url),
618 escape_xml(&item.enclosure_type)
619 ));
620 if !item.guid.is_empty() {
621 xml.push_str(&format!(" <guid>{}</guid>\n", escape_xml(&item.guid)));
622 }
623 xml.push_str(" </item>\n");
624 }
625 xml.push_str(" </channel>\n");
626 xml.push_str("</rss>");
627 xml
628}
629
630fn escape_xml(s: &str) -> String {
631 s.replace('&', "&")
632 .replace('<', "<")
633 .replace('>', ">")
634 .replace('"', """)
635}
636
637#[tracing::instrument(skip_all, level = "debug")]
638async fn write_rss_file(state: &AppState, file_name: &str, xml: &str) -> ApiResult<()> {
639 let dir = resolve_rss_src_dir(&state.config);
640 tokio::fs::create_dir_all(&dir)
641 .await
642 .map_err(|e| AppError::Internal(format!("创建 RSS 目录失败: {e}")))?;
643 let path = dir.join(file_name);
644 tokio::fs::write(&path, xml)
645 .await
646 .map_err(|e| AppError::Internal(format!("RSS 写入失败 {file_name}: {e}")))?;
647 tracing::info!(path = %path.display(), "RSS XML 写入成功");
648 Ok(())
649}
650
651fn build_image_url(config: &AppConfig, image: &str) -> String {
652 if image.is_empty() {
653 return String::new();
654 }
655 if image.starts_with("http://") || image.starts_with("https://") {
656 return image.to_string();
657 }
658 let mut object_key = image.to_string();
659 const LOCAL_PREFIX: &str = "/src/assets/images/";
660 if object_key.starts_with(LOCAL_PREFIX) {
661 object_key = object_key[LOCAL_PREFIX.len()..].to_string();
662 } else if object_key.starts_with('/') {
663 object_key = object_key[1..].to_string();
664 }
665 let cdn = config.tencent.image_cdn_domain.trim();
666 if !cdn.is_empty() {
667 let domain = cdn.trim_end_matches('/');
668 let protocol = if domain.starts_with("http://") || domain.starts_with("https://") {
669 String::new()
670 } else {
671 "https://".to_string()
672 };
673 return format!("{protocol}{domain}/{object_key}");
674 }
675 format!(
676 "https://{}.cos.{}.myqcloud.com/{}",
677 config.tencent.image_bucket, config.tencent.region, object_key
678 )
679}
680
681fn guess_mime(filename: &str) -> String {
682 mime_guess::from_path(filename)
683 .first_or_octet_stream()
684 .to_string()
685}
686
687#[tracing::instrument(skip_all, level = "info")]
689pub async fn ensure_rss_dir(config: &AppConfig) -> anyhow::Result<()> {
690 let dir = resolve_rss_src_dir(config);
691 tokio::fs::create_dir_all(&dir).await?;
692 Ok(())
693}
694
695pub fn duration_until_noon_shanghai() -> std::time::Duration {
697 use crate::utils::shanghai_time::shanghai_offset;
698 use chrono::{Duration, Timelike, Utc};
699 let now = Utc::now().with_timezone(&shanghai_offset());
700 let mut target = now.date_naive().and_hms_opt(12, 0, 0).unwrap();
701 if now.hour() >= 12 {
702 target += Duration::days(1);
703 }
704 let wait = (target - now.naive_local()).num_seconds();
705 if wait <= 0 {
706 std::time::Duration::from_secs(86400)
707 } else {
708 std::time::Duration::from_secs(wait as u64)
709 }
710}
711
712#[cfg(test)]
713mod tests {
714 use super::*;
715
716 #[test]
718 fn test_build_manga_update_item_title() {
719 let cfg = crate::config::load("dev").unwrap();
720 let manga = RssMangaRow {
721 id: 42,
722 manga_tran_name: Some("译名".into()),
723 manga_ori_name: Some("原名".into()),
724 category: Some(1),
725 manga_status: Some(1),
726 image: Some("cover.jpg".into()),
727 setup_time: None,
728 update_time: None,
729 author_id: None,
730 author_name: Some("作者A".into()),
731 author_id2: None,
732 author_name2: None,
733 };
734 let item = build_manga_update_item(
735 &manga,
736 "https://dev.yuriful.top/manga/",
737 "https://dev.yuriful.top",
738 &cfg,
739 )
740 .unwrap();
741 assert!(item.title.contains("☆漫画信息更新☆"));
742 }
743
744 #[test]
746 fn test_rss_message_labels_are_specific() {
747 let cfg = crate::config::load("dev").unwrap();
748 let site = "https://dev.yuriful.top";
749 let manga = RssMangaRow {
750 id: 1,
751 manga_tran_name: Some("译".into()),
752 manga_ori_name: Some("ori".into()),
753 author_name: Some("作者".into()),
754 ..Default::default()
755 };
756
757 let new_item =
758 build_manga_item(&manga, "https://dev.yuriful.top/manga/", site, &cfg).unwrap();
759 assert!(new_item.description.contains("已加入漫画库"));
760
761 let update_item =
762 build_manga_update_item(&manga, "https://dev.yuriful.top/manga/", site, &cfg).unwrap();
763 assert!(update_item.description.contains("漫画《译》的信息已更新"));
764
765 let publish_title = build_publish_link_title("译", "ori", "1", "标题");
766 assert!(publish_title.contains("!漫画发布完成!"));
767 }
768
769 #[test]
771 fn test_rss_item_guids_are_stable() {
772 let cfg = crate::config::load("dev").unwrap();
773 let site = "https://dev.yuriful.top";
774
775 let manga = RssMangaRow {
776 id: 1,
777 manga_tran_name: Some("A".into()),
778 manga_ori_name: Some("B".into()),
779 image: Some("c.jpg".into()),
780 ..Default::default()
781 };
782 let ep = dummy_episode_row();
783 let members: Vec<CollectedMembersVo> = vec![];
784 let wr = dummy_work_reminder_row();
785
786 let a1 = build_manga_item(&manga, "https://dev.yuriful.top/manga/", site, &cfg).unwrap();
787 let a2 = build_manga_item(&manga, "https://dev.yuriful.top/manga/", site, &cfg).unwrap();
788 assert_eq!(a1.guid, a2.guid, "build_manga_item guid 必须稳定");
789 assert_eq!(a1.guid, "manga-1");
790
791 let b1 =
792 build_manga_update_item(&manga, "https://dev.yuriful.top/manga/", site, &cfg).unwrap();
793 let b2 =
794 build_manga_update_item(&manga, "https://dev.yuriful.top/manga/", site, &cfg).unwrap();
795 assert_eq!(b1.guid, b2.guid, "build_manga_update_item guid 必须稳定");
796 assert_eq!(b1.guid, "manga-update-1");
797
798 let c1 = build_episode_item(&ep, &members, "https://dev.yuriful.top/manga/", &cfg).unwrap();
799 let c2 = build_episode_item(&ep, &members, "https://dev.yuriful.top/manga/", &cfg).unwrap();
800 assert_eq!(c1.guid, c2.guid, "build_episode_item guid 必须稳定");
801 assert_eq!(c1.guid, "episode-1-10");
802
803 let d1 = build_work_reminder_item(&wr, "https://dev.yuriful.top/manga/", &cfg);
804 let d2 = build_work_reminder_item(&wr, "https://dev.yuriful.top/manga/", &cfg);
805 assert_eq!(d1.guid, d2.guid, "build_work_reminder_item guid 必须稳定");
806 assert_eq!(d1.guid, "reminder-1-translator");
807 }
808
809 #[test]
811 fn test_build_episode_item_no_leading_space() {
812 let cfg = crate::config::load("dev").unwrap();
813 let ep = dummy_episode_row();
814 let members: Vec<CollectedMembersVo> = vec![];
815 let item =
816 build_episode_item(&ep, &members, "https://dev.yuriful.top/manga/", &cfg).unwrap();
817 assert!(item.title.contains("※新增话数※"));
818 assert!(item.description.contains("☆新增话数,快来接稿吧☆"));
819 assert!(
820 item.description.contains("\n新增话数时间:"),
821 "含翻译时 description 应无行首空格,实际:\n{}",
822 item.description
823 );
824
825 let ep2 = EpisodeRssRow {
827 translator_name: None,
828 ..dummy_episode_row()
829 };
830 let item2 =
831 build_episode_item(&ep2, &members, "https://dev.yuriful.top/manga/", &cfg).unwrap();
832 assert!(
833 item2.description.contains("\n新增话数时间:"),
834 "无翻译时 description 应无行首空格,实际:\n{}",
835 item2.description
836 );
837 }
838
839 #[test]
841 fn test_build_work_reminder_item_no_leading_space() {
842 let cfg = crate::config::load("dev").unwrap();
843 let wr = dummy_work_reminder_row();
844 let item = build_work_reminder_item(&wr, "https://dev.yuriful.top/manga/", &cfg);
845 assert!(
846 item.description.contains("\n本话创立时间:"),
847 "交稿提醒 description 应无行首空格,实际:\n{}",
848 item.description
849 );
850 assert!(item.description.contains("☆漫画交稿,下一工序已指派☆"));
851
852 let waiting = WorkReminderRssRow {
853 username: None,
854 ..dummy_work_reminder_row()
855 };
856 let waiting_item =
857 build_work_reminder_item(&waiting, "https://dev.yuriful.top/manga/", &cfg);
858 assert!(waiting_item
859 .description
860 .contains("☆漫画交稿,下一工序待接稿☆"));
861
862 let reviewer = WorkReminderRssRow {
863 my_name: "reviewer".into(),
864 ..dummy_work_reminder_row()
865 };
866 let reviewer_item =
867 build_work_reminder_item(&reviewer, "https://dev.yuriful.top/manga/", &cfg);
868 assert!(reviewer_item
869 .description
870 .contains("☆漫画交稿,审稿已经完成啦☆"));
871 }
872
873 fn dummy_episode_row() -> EpisodeRssRow {
874 EpisodeRssRow {
875 id: 1,
876 manga_id: 10,
877 manga_episode: Some("1".into()),
878 manga_episode_name: Some("标题".into()),
879 setup_time: None,
880 manga_name: Some("译".into()),
881 manga_ori_name: Some("ori".into()),
882 category: Some(1),
883 manga_status: Some(1),
884 manga_setup_time: None,
885 image: None,
886 provider_name: Some("图源".into()),
887 translator_name: Some("翻译".into()),
888 intern: Some(0),
889 email: Some("a@b.com".into()),
890 publish_link: None,
891 }
892 }
893
894 fn dummy_work_reminder_row() -> WorkReminderRssRow {
895 WorkReminderRssRow {
896 episode_id: 1,
897 manga_id: 10,
898 manga_episode: Some("1".into()),
899 manga_episode_name: Some("标题".into()),
900 setup_time: None,
901 manga_name: Some("译".into()),
902 manga_ori_name: Some("ori".into()),
903 category: Some(1),
904 manga_status: Some(1),
905 manga_setup_time: None,
906 image: None,
907 my_name: "translator".into(),
908 username_now: Some("翻译".into()),
909 intern_now: Some(0),
910 email_now: Some("a@b.com".into()),
911 username: Some("校对".into()),
912 intern: Some(0),
913 email: Some("c@d.com".into()),
914 }
915 }
916
917 #[test]
919 fn test_merge_manga_rss_ids_dedup() {
920 let new_mangas = vec![
921 RssMangaRow {
922 id: 1,
923 ..Default::default()
924 },
925 RssMangaRow {
926 id: 2,
927 ..Default::default()
928 },
929 ];
930 let updated = vec![RssMangaRow {
931 id: 1,
932 ..Default::default()
933 }];
934 let ids = merge_manga_rss_ids(&new_mangas, &updated);
935 assert_eq!(ids, vec![2, 1]);
936 }
937
938 #[test]
940 fn test_generate_rss_xml_escapes_special_chars() {
941 let items = vec![RssItem {
942 title: "a & b <c> \"d\"".into(),
943 link: "https://example.com/manga/1".into(),
944 author: "作者".into(),
945 pub_date: "2026年05月23日 12:00:00".into(),
946 category: "长短篇:漫画".into(),
947 description: "desc & more".into(),
948 enclosure_url: String::new(),
949 enclosure_type: String::new(),
950 guid: "stable-test-guid".into(),
951 }];
952 let xml = generate_rss_xml(&items, "https://yuriful.top");
953 assert!(xml.contains("&"));
954 assert!(xml.contains("<c>"));
955 assert!(xml.contains(""d""));
956 }
957
958 #[test]
960 fn test_resolve_rss_src_dir_relative_base() {
961 let mut cfg = crate::config::load("dev").unwrap();
962 cfg.folder.base = "./local/tdm".into();
963 let dir = resolve_rss_src_dir(&cfg);
964 let s = dir.to_string_lossy();
965 assert!(s.contains("local"), "path={s}");
966 assert!(s.ends_with("src"), "path={s}");
967 }
968}