Skip to main content

tdm_server_rust/service/
rss_service.rs

1//! RSS 订阅生成服务 (RSS Service)
2//!
3//! 生成和管理 RSS 2.0 订阅源 XML 文件。
4//! 支持按业务事件异步增量刷新(接稿、交稿、发布链接变更等),避免全量重建。
5//! 对齐 Java `RssServiceImpl`。
6
7use 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/// RSS 刷新范围(业务事件 → 写哪些 feed)
27#[derive(Debug, Clone)]
28pub enum RssRefreshScope {
29    /// 增删改话、接稿(影响接稿提醒与人员字段)
30    EpisodePipeline,
31    /// 交稿(workreminder + lastSubmitTime)
32    WorkflowSubmit {
33        /// 交稿岗位英文名
34        post_name: String,
35    },
36    /// 新发漫画
37    NewManga,
38    /// 更新漫画元数据
39    MangaUpdated,
40    /// 发布链接
41    PublishLink {
42        /// 话数 ID
43        episode_id: i32,
44    },
45    /// 三月未交稿组员
46    MemberReminder,
47    /// 外部 rssOutput API
48    ExternalOutput {
49        /// 原始 XML
50        xml: String,
51    },
52}
53
54/// RSS 服务
55///
56/// 负责生成和管理 RSS 2.0 订阅源 XML 文件。
57/// 支持按业务事件异步增量刷新,避免全量重建。
58pub struct RssService;
59
60impl RssService {
61    /// 按业务事件异步刷新 RSS(失败仅打日志)
62    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    /// 同步刷新(集成测试或需 await 时使用)
72    #[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    /// 启动时全量刷新(非轮询)
78    #[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    /// 异步调度 RSS 任务(失败仅打日志,不影响主流程)
108    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    /// 生成 rssManga.xml(新开坑 + 最近更新 + 最新 5 话)
120    #[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    /// 生成 rssEpisode.xml(最新 5 话新单话提醒)
143    #[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    /// 生成全部岗位的 rssEpisode_*.xml(对齐 Java rssEpisodeAllPosts)
164    #[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    /// 生成指定岗位的 rssEpisode_{post}.xml
173    #[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    /// 生成 rssPublishLink.xml
194    #[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    /// 生成 rssMember.xml(三月未交稿提醒)
245    #[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    /// 写入外部 RSS XML(rssOutput API)
281    #[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
288/// schedule 日志标签
289fn 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
300/// 漫画详情页链接前缀
301fn manga_link_base(config: &AppConfig) -> String {
302    let site = config.rss.site_base_url.trim_end_matches('/');
303    format!("{site}/manga/")
304}
305
306/// 解析 RSS 输出目录 `{folder.base}/src`
307pub 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/// 合并新开坑、最近更新、新单话条目(同一漫画 id 仅保留更新条目)
353#[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/// 合并新开坑与最近更新 id 集合(单元测试用)
381#[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
563/// 构造发布完成 RSS 标题。
564///
565/// 标题固定使用“漫画发布完成”事件名,避免与交稿提醒混淆。
566fn 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
574/// 构造长期未交稿提醒描述。
575///
576/// 正式组员提示长期无进展,实习组员提示三个月内未转正。
577fn 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('&', "&amp;")
632        .replace('<', "&lt;")
633        .replace('>', "&gt;")
634        .replace('"', "&quot;")
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/// 启动时确保 RSS 目录存在
688#[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
695/// 计算距上海时区下一 12:00 的等待时长
696pub 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    /// build_manga_update_item 标题含漫画信息更新标记
717    #[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    /// RSS 文案应明确区分新增漫画、漫画信息更新与发布完成。
745    #[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    /// GUID 必须基于条目身份确定性生成(同一内容跨次重建 guid 不变,避免 RSS 阅读器重复推送)
770    #[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    /// build_episode_item 文案不含行首多余空格(对齐 Java 原版)
810    #[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        // 无翻译版本(译者名为空)
826        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    /// build_work_reminder_item 文案不含行首多余空格(对齐 Java 原版)
840    #[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    /// 同一漫画 id 在新开坑与最近更新中只保留更新侧
918    #[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    /// XML 应转义特殊字符
939    #[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("&amp;"));
954        assert!(xml.contains("&lt;c&gt;"));
955        assert!(xml.contains("&quot;d&quot;"));
956    }
957
958    /// RSS 输出目录应为 `{folder.base}/src`
959    #[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}