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