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        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/// RSS 刷新范围(业务事件 → 写哪些 feed)
32#[derive(Debug, Clone)]
33pub enum RssRefreshScope {
34    /// 增删改话、接稿(影响接稿提醒与人员字段)
35    EpisodePipeline,
36    /// 交稿(workreminder + lastSubmitTime)
37    WorkflowSubmit {
38        /// 交稿岗位英文名
39        post_name: String,
40    },
41    /// 新发漫画
42    NewManga,
43    /// 更新漫画元数据
44    MangaUpdated,
45    /// 发布链接
46    PublishLink {
47        /// 话数 ID
48        episode_id: i32,
49    },
50    /// 三月未交稿组员
51    MemberReminder,
52    /// 外部 rssOutput API
53    ExternalOutput {
54        /// 原始 XML
55        xml: String,
56    },
57}
58
59/// RSS 服务
60///
61/// 负责生成和管理 RSS 2.0 订阅源 XML 文件。
62/// 支持按业务事件异步增量刷新,避免全量重建。
63pub struct RssService;
64
65impl RssService {
66    /// 按业务事件异步刷新 RSS(失败仅打日志)
67    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    /// 同步刷新(集成测试或需 await 时使用)
77    #[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    /// 启动时全量刷新(非轮询)
83    #[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    /// 异步调度 RSS 任务(失败仅打日志,不影响主流程)
113    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    /// 生成 rssManga.xml(新开坑 + 最近更新 + 最新话,合并后保留最新 40 条)
125    #[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    /// 生成 rssEpisode.xml(最新话新单话提醒,最多 40 条)
148    #[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    /// 生成全部岗位的 rssEpisode_*.xml(对齐 Java rssEpisodeAllPosts)
169    #[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    /// 生成指定岗位的 rssEpisode_{post}.xml
178    #[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    /// 生成 rssPublishLink.xml
199    #[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    /// 生成 rssMember.xml(三月未交稿 / 实习未转正提醒)
250    #[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    /// 写入外部 RSS XML(rssOutput API)
286    #[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
293/// schedule 日志标签
294fn 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
305/// 漫画详情页链接前缀
306fn manga_link_base(config: &AppConfig) -> String {
307    let site = config.rss.site_base_url.trim_end_matches('/');
308    format!("{site}/manga/")
309}
310
311/// 解析 RSS 输出目录 `{folder.base}/src`
312pub 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/// 合并新开坑、最近更新、新单话条目(同一漫画 id 仅保留更新条目;超出 40 条时保留事件时间最新的)
358#[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
393/// RSS 条目事件时间(缺失时视为最旧)
394fn 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/// 合并新开坑与最近更新 id 集合(单元测试用)
399#[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
447/// 构造新增话数 RSS 条目,并按话数分类生成标题标签。
448fn 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
512/// 构造交稿提醒 RSS 条目,并按话数分类生成标题标签。
513fn 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
585/// 按话数分类生成 RSS 标题中的话数标签。
586fn 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
593/// 构造发布完成 RSS 标题,并复用统一的话数分类标签。
594///
595/// 标题固定使用“漫画发布完成”事件名,避免与交稿提醒混淆。
596fn 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
611/// 构造长期未交稿提醒描述。
612///
613/// 正式组员提示长期无进展,实习组员提示三个月内未转正。
614fn 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('&', "&amp;")
669        .replace('<', "&lt;")
670        .replace('>', "&gt;")
671        .replace('"', "&quot;")
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/// 启动时确保 RSS 目录存在
725#[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
732/// 计算距上海时区下一 12:00 的等待时长
733pub 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    /// build_manga_update_item 标题含漫画信息更新标记
755    #[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    /// RSS 文案应明确区分新增漫画、漫画信息更新与发布完成。
783    #[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    /// 新增话数 RSS 标题应区分正篇与番外/特典。
808    #[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    /// 交稿提醒 RSS 标题应区分正篇与番外/特典。
834    #[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    /// 发布完成 RSS 标题应区分正篇与番外/特典。
857    #[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    /// GUID 必须基于条目身份确定性生成(同一内容跨次重建 guid 不变,避免 RSS 阅读器重复推送)
870    #[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    /// build_episode_item 文案不含行首多余空格(对齐 Java 原版)
910    #[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        // 无翻译版本(译者名为空)
926        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    /// build_work_reminder_item 文案不含行首多余空格(对齐 Java 原版)
940    #[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    /// 交稿 RSS:本话创立时间应晚于漫画创立时间
974    #[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    /// 构造正篇 RSS 话数测试行。
1001    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    /// 构造正篇交稿提醒测试行。
1024    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    /// rssManga 合并后超出 RSS_MAX_ITEMS 时保留事件时间最新的条目
1049    #[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    /// 同一漫画 id 在新开坑与最近更新中只保留更新侧
1083    #[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    /// XML 应转义特殊字符
1104    #[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("&amp;"));
1119        assert!(xml.contains("&lt;c&gt;"));
1120        assert!(xml.contains("&quot;d&quot;"));
1121    }
1122
1123    /// RSS 输出目录应为 `{folder.base}/src`
1124    #[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}