Skip to main content

tdm_server_rust/service/
episode_service.rs

1//! 话数业务服务 (Episode Service)
2//!
3//! 话数管理的核心业务逻辑:
4//! - 话数 CRUD(创建、批量创建、更新、删除)
5//! - 发布链接管理
6//! - 上传页数据查询
7//! - 统计信息(漫画统计、组员统计)
8//! - 最新话查询
9
10use crate::{
11    app::AppState,
12    cache::invalidate_task_tracking,
13    common::PageBean,
14    entity::enums::{MemberInternEnum, PostEnum},
15    entity::episode::{
16        EpisodeDetailVo, EpisodeEditDto, EpisodeListVo, EpisodeSimpleListVo, MemberStatistics,
17        NewestEpisodeVo, PublishLinkRequest, Statistics, UploadPageVo,
18    },
19    entity::manga::EpisodeDownloadRequest,
20    error::{ApiResult, AppError},
21    repository::{
22        episode_repo::EpisodeRepository, member_repo::MemberRepository, oss_repo::OssRepository,
23    },
24    service::{
25        oss_service::OssService,
26        rss_service::{RssRefreshScope, RssService},
27    },
28    utils::page::{paginate, slice_rows},
29};
30use axum::body::Bytes;
31use chrono::{DateTime, Utc};
32use std::path::{Path, PathBuf};
33
34/// 话数服务
35///
36/// 提供话数及关联数据(统计、发布)的完整业务逻辑。
37pub struct EpisodeService;
38
39impl EpisodeService {
40    /// 按漫画 ID 查询话数简单列表。
41    ///
42    /// 返回包含文件信息的精简话数列表,用于上传页展示。
43    /// 结果按 `manga_episode` 升序排列,仅包含未删除的话数记录。
44    ///
45    /// # 返回值
46    ///
47    /// 返回漫画下所有未删除的话数列表,按 `manga_episode` 排序。
48    ///
49    /// # Errors
50    ///
51    /// - `AppError::Database` — 数据库查询失败
52    ///
53    /// # Examples
54    ///
55    /// ```ignore
56    /// use tdm_server::service::episode_service::EpisodeService;
57    ///
58    /// let episodes = EpisodeService::select_list_by_manga_id(&state, manga_id).await?;
59    /// for ep in &episodes {
60    ///     println!("话数 {}: {}", ep.manga_episode, ep.manga_episode_name);
61    /// }
62    /// ```
63    #[tracing::instrument(skip_all, level = "debug")]
64    pub async fn select_list_by_manga_id(
65        state: &AppState,
66        manga_id: i32,
67    ) -> ApiResult<Vec<EpisodeSimpleListVo>> {
68        EpisodeRepository::new(state.db.clone())
69            .list_by_manga_id(manga_id)
70            .await
71    }
72
73    /// 批量更新话数发布链接。
74    ///
75    /// 接收一组发布链接请求,批量更新数据库中的链接字段。
76    /// 更新完成后会刷新任务追踪缓存,并为每个话数触发 RSS 订阅源刷新。
77    ///
78    /// # Errors
79    ///
80    /// - `AppError::Database` — 数据库更新失败
81    ///
82    /// # Examples
83    ///
84    /// ```ignore
85    /// let requests = vec![
86    ///     PublishLinkRequest { id: 1, publish_link: Some("https://example.com/1".into()) },
87    ///     PublishLinkRequest { id: 2, publish_link: Some("https://example.com/2".into()) },
88    /// ];
89    /// EpisodeService::update_manga_episodes(&state, requests).await?;
90    /// ```
91    #[tracing::instrument(skip_all, level = "debug")]
92    pub async fn update_manga_episodes(
93        state: &AppState,
94        requests: Vec<PublishLinkRequest>,
95    ) -> ApiResult<()> {
96        EpisodeRepository::new(state.db.clone())
97            .update_publish_links_batch(&requests)
98            .await?;
99        invalidate_task_tracking(state).await;
100        for id in requests.iter().map(|r| r.id) {
101            RssService::refresh(state, RssRefreshScope::PublishLink { episode_id: id });
102        }
103        Ok(())
104    }
105
106    /// 更新单条话数的发布链接。
107    ///
108    /// 在写入前会检查新链接是否与其他话数冲突。更新成功后同样
109    /// 会刷新任务追踪缓存和 RSS 订阅源。
110    ///
111    /// # Errors
112    ///
113    /// - `AppError::business("发布链接已存在喵")` — 链接与其他话数冲突
114    /// - `AppError::Database` — 数据库操作失败
115    ///
116    /// # Examples
117    ///
118    /// ```ignore
119    /// EpisodeService::update_publish_link(
120    ///     &state,
121    ///     42,
122    ///     Some("https://example.com/ch42".to_string()),
123    /// ).await?;
124    /// ```
125    #[tracing::instrument(skip_all, level = "debug")]
126    pub async fn update_publish_link(
127        state: &AppState,
128        id: i32,
129        publish_link: Option<String>,
130    ) -> ApiResult<()> {
131        let repo = EpisodeRepository::new(state.db.clone());
132        if let Some(ref link) = publish_link {
133            if !link.is_empty() && repo.count_publish_link(link, Some(id)).await? > 0 {
134                return Err(AppError::business("发布链接已存在喵"));
135            }
136        }
137        let dto = EpisodeEditDto {
138            id: Some(id),
139            manga_id: None,
140            manga_episode: None,
141            manga_episode_end: None,
142            manga_episode_name: None,
143            provider_id: None,
144            translator_id: None,
145            proofreader_id: None,
146            letterer_id: None,
147            timer_id: None,
148            reviewer_id: None,
149            publish_link,
150        };
151        repo.update_publish_link_only(id, dto.publish_link).await?;
152        invalidate_task_tracking(state).await;
153        RssService::refresh(state, RssRefreshScope::PublishLink { episode_id: id });
154        Ok(())
155    }
156
157    /// 分页查询漫画话数。
158    ///
159    /// 获取指定漫画下的全部话数列表,然后在内存中进行分页截取。
160    /// 返回标准分页对象 `PageBean`,包含当前页数据、总条数和总页数。
161    ///
162    /// # Errors
163    ///
164    /// - `AppError::Database` — 数据库查询失败
165    ///
166    /// # Examples
167    ///
168    /// ```ignore
169    /// // 查询第 1 页,每页 20 条
170    /// let page = EpisodeService::page_episode(&state, 1, 20, manga_id).await?;
171    /// println!("共 {} 条,当前页 {} 条", page.total, page.rows.len());
172    /// ```
173    #[tracing::instrument(skip_all, level = "debug")]
174    pub async fn page_episode(
175        state: &AppState,
176        page: i32,
177        page_size: i32,
178        manga_id: i32,
179    ) -> ApiResult<PageBean<EpisodeListVo>> {
180        let repo = EpisodeRepository::new(state.db.clone());
181        let all = repo.list_full_by_manga_id(manga_id).await?;
182        Ok(paginate(all, page, page_size))
183    }
184
185    /// 删除指定话数(软删除)。
186    ///
187    /// 将话数标记为删除状态,同时刷新任务追踪缓存并触发 RSS 订阅源
188    /// 全量刷新。注意:此操作不会立即从数据库中物理删除数据。
189    ///
190    /// # Errors
191    ///
192    /// - `AppError::Database` — 数据库操作失败
193    ///
194    /// # Examples
195    ///
196    /// ```ignore
197    /// EpisodeService::delete_episode(&state, episode_id).await?;
198    /// ```
199    #[tracing::instrument(skip_all, level = "debug")]
200    pub async fn delete_episode(state: &AppState, id: i32) -> ApiResult<()> {
201        EpisodeRepository::new(state.db.clone())
202            .delete_by_id(id)
203            .await?;
204        invalidate_task_tracking(state).await;
205        RssService::refresh(state, RssRefreshScope::EpisodePipeline);
206        Ok(())
207    }
208
209    /// 新增话数(对齐 Java `addEpisodes`)。
210    ///
211    /// 支持两种模式:
212    /// - **批量模式**:指定 `manga_episode` 起始值和 `manga_episode_end` 结束值,
213    ///   在区间内逐条创建话数。
214    /// - **单话模式**:仅指定 `manga_episode`,创建一条话数记录。
215    ///
216    /// 创建完成后会刷新任务追踪缓存并触发 RSS 全量刷新。
217    ///
218    /// # Errors
219    ///
220    /// - `AppError::business("缺少漫画 ID")` — DTO 中未提供 `manga_id`
221    /// - `AppError::business("起始话数格式不正确")` — 话数序号不是有效整数
222    /// - `AppError::business("结束话数格式不正确")` — 结束序号不是有效整数
223    /// - `AppError::business("该漫画单话已存在喵!")` — 话数序号重复
224    /// - `AppError::business("发布链接已存在喵")` — 链接与其他话数冲突
225    /// - `AppError::Database` — 数据库操作失败
226    ///
227    /// # Examples
228    ///
229    /// ```ignore
230    /// // 批量创建话数 1~10
231    /// let dto = EpisodeEditDto {
232    ///     manga_id: Some(100),
233    ///     manga_episode: Some("1".into()),
234    ///     manga_episode_end: Some("10".into()),
235    ///     ..Default::default()
236    /// };
237    /// EpisodeService::add_episodes(&state, dto).await?;
238    ///
239    /// // 创建单话
240    /// let dto = EpisodeEditDto {
241    ///     manga_id: Some(100),
242    ///     manga_episode: Some("11".into()),
243    ///     ..Default::default()
244    /// };
245    /// EpisodeService::add_episodes(&state, dto).await?;
246    /// ```
247    #[tracing::instrument(skip_all, level = "debug")]
248    pub async fn add_episodes(state: &AppState, dto: EpisodeEditDto) -> ApiResult<()> {
249        let manga_id = dto
250            .manga_id
251            .ok_or_else(|| AppError::business("缺少漫画 ID"))?;
252        let repo = EpisodeRepository::new(state.db.clone());
253
254        let end = dto
255            .manga_episode_end
256            .as_deref()
257            .map(str::trim)
258            .filter(|s| !s.is_empty());
259
260        if let Some(end_str) = end {
261            let start_str = dto.manga_episode.as_deref().unwrap_or("0");
262            let start: i32 = start_str
263                .parse()
264                .map_err(|_| AppError::business("起始话数格式不正确"))?;
265            let end_num: i32 = end_str
266                .parse()
267                .map_err(|_| AppError::business("结束话数格式不正确"))?;
268            for i in start..=end_num {
269                let mut one = dto.clone();
270                one.manga_episode = Some(i.to_string());
271                one.manga_episode_end = None;
272                Self::add_single_episode(&repo, &one, manga_id).await?;
273            }
274        } else {
275            Self::add_single_episode(&repo, &dto, manga_id).await?;
276        }
277        invalidate_task_tracking(state).await;
278        RssService::refresh(state, RssRefreshScope::EpisodePipeline);
279        Ok(())
280    }
281
282    /// 创建单条话数记录(内部方法)。
283    ///
284    /// 执行话数新增的核心逻辑:
285    /// 1. 校验话数序号是否与已有记录重复
286    /// 2. 校验发布链接是否与其他话数冲突
287    /// 3. 插入主表记录和详情记录
288    /// 4. 更新漫画的 `update_time` 时间戳
289    ///
290    /// # Errors
291    ///
292    /// - `AppError::business("缺少话数序号")` — `manga_episode` 为空
293    /// - `AppError::business("该漫画单话已存在喵!")` — 序号重复
294    /// - `AppError::business("发布链接已存在喵")` — 链接冲突
295    /// - `AppError::Database` — 数据库操作失败
296    #[tracing::instrument(skip_all, level = "debug")]
297    async fn add_single_episode(
298        repo: &EpisodeRepository,
299        dto: &EpisodeEditDto,
300        manga_id: i32,
301    ) -> ApiResult<()> {
302        let episode_label = dto
303            .manga_episode
304            .as_deref()
305            .ok_or_else(|| AppError::business("缺少话数序号"))?;
306        if repo
307            .count_episode_by_number(manga_id, episode_label)
308            .await?
309            > 0
310        {
311            return Err(AppError::business("该漫画单话已存在喵!"));
312        }
313        if let Some(ref link) = dto.publish_link {
314            if !link.is_empty() && repo.count_publish_link(link, None).await? > 0 {
315                return Err(AppError::business("发布链接已存在喵"));
316            }
317        }
318        let episode_id = repo.insert(dto).await?;
319        repo.insert_detail(episode_id, dto).await?;
320        repo.touch_manga_update_time(manga_id).await?;
321        Ok(())
322    }
323
324    /// 按 ID 查询单条话数详情。
325    ///
326    /// 返回话数的完整信息,包括主表字段和详情字段。
327    /// 如果话数已被软删除或不存在,返回业务错误。
328    ///
329    /// # Errors
330    ///
331    /// - `AppError::business("话数不存在喵")` — 指定 ID 的话数不存在
332    /// - `AppError::Database` — 数据库查询失败
333    ///
334    /// # Examples
335    ///
336    /// ```ignore
337    /// let episode = EpisodeService::get_manga_episode_by_id(&state, 42).await?;
338    /// println!("话数名称: {}", episode.manga_episode_name);
339    /// ```
340    #[tracing::instrument(skip_all, level = "debug")]
341    pub async fn get_manga_episode_by_id(state: &AppState, id: i32) -> ApiResult<EpisodeDetailVo> {
342        EpisodeRepository::new(state.db.clone())
343            .get_by_id(id)
344            .await?
345            .ok_or_else(|| AppError::business("话数不存在喵"))
346    }
347
348    /// 查询漫画最新话。
349    ///
350    /// 获取指定漫画下序号最大的话数记录。常用于展示连载漫画的
351    /// 最近更新情况。
352    ///
353    /// # Errors
354    ///
355    /// - `AppError::business("该漫画尚无单话喵")` — 漫画下没有话数记录
356    /// - `AppError::Database` — 数据库查询失败
357    ///
358    /// # Examples
359    ///
360    /// ```ignore
361    /// let newest = EpisodeService::get_newest_manga_episode_by_id(&state, manga_id).await?;
362    /// println!("最新话: {}", newest.manga_episode);
363    /// ```
364    #[tracing::instrument(skip_all, level = "debug")]
365    pub async fn get_newest_manga_episode_by_id(
366        state: &AppState,
367        manga_id: i32,
368    ) -> ApiResult<NewestEpisodeVo> {
369        EpisodeRepository::new(state.db.clone())
370            .get_newest_by_manga_id(manga_id)
371            .await?
372            .ok_or_else(|| AppError::business("该漫画尚无单话喵"))
373    }
374
375    /// 更新话数信息。
376    ///
377    /// 根据 DTO 中提供的字段更新话数主表。更新完成后刷新任务追踪
378    /// 缓存并触发 RSS 全量刷新。
379    ///
380    /// # Errors
381    ///
382    /// - `AppError::Database` — 数据库更新失败
383    ///
384    /// # Examples
385    ///
386    /// ```ignore
387    /// let dto = EpisodeEditDto {
388    ///     id: Some(42),
389    ///     manga_episode_name: Some("新标题".into()),
390    ///     ..Default::default()
391    /// };
392    /// EpisodeService::update_manga_episode(&state, dto).await?;
393    /// ```
394    #[tracing::instrument(skip_all, level = "debug")]
395    pub async fn update_manga_episode(state: &AppState, dto: EpisodeEditDto) -> ApiResult<()> {
396        let should_refresh_rss = should_refresh_rss_for_episode_edit(&dto);
397        EpisodeRepository::new(state.db.clone())
398            .update(&dto)
399            .await?;
400        invalidate_task_tracking(state).await;
401        if should_refresh_rss {
402            RssService::refresh(state, RssRefreshScope::EpisodePipeline);
403        }
404        Ok(())
405    }
406
407    /// 分页查询已上传稿件列表。
408    ///
409    /// 在数据库层面执行分页查询和计数,支持按漫画译名和用户名筛选。
410    /// 返回标准分页对象 `PageBean<UploadPageVo>`。
411    ///
412    /// # Errors
413    ///
414    /// - `AppError::Database` — 数据库查询失败
415    ///
416    /// # Examples
417    ///
418    /// ```ignore
419    /// // 按译名筛选,查询第 1 页
420    /// let page = EpisodeService::get_uploaded_submit(
421    ///     &state, 1, 20,
422    ///     Some("进击的巨人".into()),
423    ///     None,
424    /// ).await?;
425    /// ```
426    #[tracing::instrument(skip_all, level = "debug")]
427    pub async fn get_uploaded_submit(
428        state: &AppState,
429        page: i32,
430        page_size: i32,
431        manga_tran_name: Option<String>,
432        username: Option<String>,
433    ) -> ApiResult<PageBean<UploadPageVo>> {
434        let (total, rows) = EpisodeRepository::new(state.db.clone())
435            .page_uploaded_submit(
436                page,
437                page_size,
438                manga_tran_name.as_deref(),
439                username.as_deref(),
440            )
441            .await?;
442        Ok(slice_rows(rows, total))
443    }
444
445    /// 任意时间段统计
446    #[tracing::instrument(skip_all, level = "debug")]
447    pub async fn get_statistics(state: &AppState, start: &str, end: &str) -> ApiResult<Statistics> {
448        let start = crate::utils::shanghai_time::parse_shanghai_iso(start)
449            .map_err(|e| AppError::business(format!("时间格式错误: {e}")))?;
450        let end = crate::utils::shanghai_time::parse_shanghai_iso(end)
451            .map_err(|e| AppError::business(format!("时间格式错误: {e}")))?;
452        let repo = EpisodeRepository::new(state.db.clone());
453        let stats = repo.get_statistic_count(start, end).await?;
454        let result = EpisodeRepository::stats_from_post(&stats);
455        // #region agent log
456        crate::utils::agent_debug::log(
457            "H1",
458            "episode_service.rs:get_statistics",
459            "statistics_any",
460            serde_json::json!({
461                "translatorCount": result.translator_count,
462                "proofreaderCount": result.proofreader_count
463            }),
464        );
465        // #endregion
466        Ok(result)
467    }
468
469    /// 预设时间段统计列表(6 项:日/月/年 + 上期对比)
470    #[tracing::instrument(skip_all, level = "debug")]
471    pub async fn get_statistics_list(state: &AppState) -> ApiResult<Vec<Statistics>> {
472        use crate::utils::shanghai_time::{shanghai_day_start, shanghai_now};
473        use chrono::Datelike;
474        let now = shanghai_now();
475        let today_start = shanghai_day_start(now.year(), now.month(), now.day());
476        let yesterday_start = today_start - chrono::Duration::days(1);
477        let first_day_of_month = shanghai_day_start(now.year(), now.month(), 1);
478        let first_day_of_last_month = if now.month() == 1 {
479            shanghai_day_start(now.year() - 1, 12, 1)
480        } else {
481            shanghai_day_start(now.year(), now.month() - 1, 1)
482        };
483        let first_day_of_year = shanghai_day_start(now.year(), 1, 1);
484        let first_day_of_last_year = shanghai_day_start(now.year() - 1, 1, 1);
485
486        let repo = EpisodeRepository::new(state.db.clone());
487        let day = repo.get_statistic_count(today_start, now).await?;
488        let month = repo.get_statistic_count(first_day_of_month, now).await?;
489        let year = repo.get_statistic_count(first_day_of_year, now).await?;
490        let day_last = repo
491            .get_statistic_count(yesterday_start, today_start)
492            .await?;
493        let month_last = repo
494            .get_statistic_count(first_day_of_last_month, first_day_of_month)
495            .await?;
496        let year_last = repo
497            .get_statistic_count(first_day_of_last_year, first_day_of_year)
498            .await?;
499
500        let list = vec![
501            EpisodeRepository::stats_from_post(&day),
502            EpisodeRepository::stats_from_post(&month),
503            EpisodeRepository::stats_from_post(&year),
504            EpisodeRepository::stats_from_post(&day_last),
505            EpisodeRepository::stats_from_post(&month_last),
506            EpisodeRepository::stats_from_post(&year_last),
507        ];
508        // #region agent log
509        crate::utils::agent_debug::log(
510            "H1",
511            "episode_service.rs:get_statistics_list",
512            "statistics_list",
513            serde_json::json!({
514                "len": list.len(),
515                "dayTranslatorCount": list.first().map(|s| s.translator_count)
516            }),
517        );
518        // #endregion
519        Ok(list)
520    }
521
522    /// 组员完成统计
523    #[tracing::instrument(skip_all, level = "debug")]
524    pub async fn get_member_statistics(
525        state: &AppState,
526        start: DateTime<Utc>,
527        end: DateTime<Utc>,
528    ) -> ApiResult<Vec<MemberStatistics>> {
529        EpisodeRepository::new(state.db.clone())
530            .get_member_statistics(start, end, None)
531            .await
532    }
533
534    /// 回退流程
535    #[tracing::instrument(skip_all, level = "debug")]
536    pub async fn rollback_episode(
537        state: &AppState,
538        episode_id: i32,
539        workflow_type: &str,
540    ) -> ApiResult<()> {
541        let repo = EpisodeRepository::new(state.db.clone());
542        if let Some(oss_id) = repo.rollback_episode(episode_id, workflow_type).await? {
543            let cfg = (*state.config).clone();
544            let _ = OssRepository::new(state.db.clone(), cfg)
545                .delete_oss(oss_id)
546                .await;
547        }
548        invalidate_task_tracking(state).await;
549        Ok(())
550    }
551
552    /// 上传话数工作文件(对齐 Java MangaFileUtils + 更新 DB 路径)
553    #[tracing::instrument(skip_all, level = "debug")]
554    pub async fn upload_manga_file(
555        state: &AppState,
556        data: Bytes,
557        episode_id: i32,
558        my_name: &str,
559        manga_id: i32,
560        original_filename: &str,
561    ) -> ApiResult<()> {
562        let post_id = legacy_post_id(my_name)
563            .ok_or_else(|| AppError::business(format!("当前岗位文件不存在喵:{my_name}")))?;
564        if data.len() > 1024 * 1024 {
565            return Err(AppError::business("上传的文件不能大于1MB喵!"));
566        }
567        let filename = sanitize_filename(original_filename);
568        let repo = EpisodeRepository::new(state.db.clone());
569        if repo.get_by_id(episode_id).await?.is_none() {
570            return Err(AppError::business(format!("漫画单话 {episode_id} 不存在")));
571        }
572        let folder = PathBuf::from(&state.config.folder.base2)
573            .join(manga_id.to_string())
574            .join(episode_id.to_string())
575            .join(post_id.to_string());
576        tokio::fs::create_dir_all(&folder)
577            .await
578            .map_err(|e| AppError::Internal(e.to_string()))?;
579        if let Some(old) = repo.get_legacy_file_path(episode_id, my_name).await? {
580            let _ = tokio::fs::remove_file(old).await;
581        }
582        let file_path = folder.join(&filename);
583        tokio::fs::write(&file_path, data)
584            .await
585            .map_err(|e| AppError::business(format!("文件上传失败喵: {e}")))?;
586        let stored = file_path.to_string_lossy().replace('\\', "/");
587        repo.set_legacy_file_path(episode_id, my_name, &stored)
588            .await?;
589        invalidate_task_tracking(state).await;
590        Ok(())
591    }
592
593    /// 下载话数工作文件(legacy 路径 → 规范目录 → OSS 回退)
594    #[tracing::instrument(skip_all, level = "debug")]
595    pub async fn download_episode_file(
596        state: &AppState,
597        req: EpisodeDownloadRequest,
598    ) -> ApiResult<(String, Bytes)> {
599        let member = MemberRepository::new(state.db.clone())
600            .get_by_id(req.member_id)
601            .await?;
602        if !MemberInternEnum::is_working_member(member.intern) {
603            return Err(AppError::download_unauth(
604                "目前的职阶无法下载喵!先联系管理员改职阶喵!",
605            ));
606        }
607        if req.my_name.eq_ignore_ascii_case("translator") {
608            let post_ids = &member.post_ids;
609            let is_letterer = post_ids.contains(&PostEnum::LETTERER);
610            let has_translation_access = post_ids.contains(&PostEnum::TRANSLATOR)
611                || post_ids.contains(&PostEnum::PROOFREADER)
612                || post_ids.contains(&PostEnum::REVIEWER);
613            if is_letterer && !has_translation_access {
614                return Err(AppError::download_unauth(
615                    "不!要!下载翻译稿!嵌字要下载校对稿!如果真的需要下载翻译稿请找Gum979",
616                ));
617            }
618        }
619
620        let repo = EpisodeRepository::new(state.db.clone());
621        if repo.get_by_id(req.id).await?.is_none() {
622            return Err(AppError::business(format!("漫画单话 {} 不存在", req.id)));
623        }
624
625        let legacy_path = repo.get_legacy_file_path(req.id, &req.my_name).await?;
626        if let Some(stored_path) = legacy_path.as_deref() {
627            if let Some((filename, bytes)) =
628                read_stored_legacy(&state.config.folder.base2, stored_path).await
629            {
630                return Ok((filename, bytes));
631            }
632        }
633
634        if let Some((filename, bytes)) = read_canonical_legacy(
635            &state.config.folder.base2,
636            req.manga_id,
637            req.id,
638            &req.my_name,
639        )
640        .await
641        {
642            return Ok((filename, bytes));
643        }
644
645        if legacy_path.is_some() {
646            let name = legacy_path
647                .as_deref()
648                .and_then(|p| Path::new(p).file_name())
649                .and_then(|s| s.to_str())
650                .unwrap_or("download.dat");
651            return Err(AppError::download_failed(format!(
652                "文件不存在或无法读取: {name}"
653            )));
654        }
655
656        OssService::download_file_proxy(state, req.id, req.my_name.clone())
657            .await
658            .map_err(|e| match e {
659                AppError::DownloadUnAuth { .. } => e,
660                _ => AppError::download_unauth("文件不存在或无权限喵"),
661            })
662    }
663}
664
665/// 判断话数编辑是否需要触发 RSS 通知刷新。
666///
667/// 只有话号、话数标题、发布链接等非岗位字段变更请求会触发 RSS。
668/// 各岗位 ID 字段仅表示指派/改派,不触发群通知。
669fn should_refresh_rss_for_episode_edit(dto: &EpisodeEditDto) -> bool {
670    dto.manga_episode
671        .as_deref()
672        .is_some_and(|s| !s.trim().is_empty())
673        || dto
674            .manga_episode_name
675            .as_deref()
676            .is_some_and(|s| !s.trim().is_empty())
677        || dto
678            .publish_link
679            .as_deref()
680            .is_some_and(|s| !s.trim().is_empty())
681}
682
683/// 岗位名 → legacy 目录 postId
684fn legacy_post_id(post_name: &str) -> Option<i32> {
685    match post_name.to_lowercase().as_str() {
686        "translator" => Some(PostEnum::TRANSLATOR),
687        "proofreader" => Some(PostEnum::PROOFREADER),
688        "timer" => Some(PostEnum::TIMER),
689        _ => None,
690    }
691}
692
693/// 清理上传文件名
694fn sanitize_filename(name: &str) -> String {
695    let base = Path::new(name)
696        .file_name()
697        .and_then(|s| s.to_str())
698        .unwrap_or("upload.dat");
699    if base.is_empty() || base.contains("..") {
700        "upload.dat".to_string()
701    } else {
702        base.to_string()
703    }
704}
705
706/// 生产环境 legacy 根目录前缀(DB 中存绝对路径,本地 dev 需映射到 folder.base2)
707const LEGACY_PROD_PREFIXES: &[&str] = &["/www/wwwroot/data", "/www/wwwroot/tdm/data"];
708
709/// 将 DB 中的 legacy 路径展开为候选本地路径(原路径 + base2 映射)
710fn legacy_path_candidates(base2: &str, stored: &str) -> Vec<PathBuf> {
711    let mut out = Vec::new();
712    let mut push = |p: PathBuf| {
713        if !out.iter().any(|x| x == &p) {
714            out.push(p);
715        }
716    };
717    push(PathBuf::from(stored));
718    for prefix in LEGACY_PROD_PREFIXES {
719        if let Some(suffix) = stored.strip_prefix(prefix) {
720            push(PathBuf::from(base2).join(suffix.trim_start_matches('/')));
721        }
722    }
723    out
724}
725
726/// 将 folder.base2 解析为可读绝对路径
727fn resolve_folder_base2(base2: &str) -> PathBuf {
728    let path = PathBuf::from(base2);
729    if path.is_absolute() {
730        path
731    } else {
732        PathBuf::from(env!("CARGO_MANIFEST_DIR")).join(path)
733    }
734}
735
736/// 按候选路径读取 DB 记录的 legacy 稿件
737#[tracing::instrument(skip_all, level = "debug")]
738async fn read_stored_legacy(base2: &str, stored: &str) -> Option<(String, Bytes)> {
739    let base2 = resolve_folder_base2(base2);
740    for path in legacy_path_candidates(&base2.to_string_lossy(), stored) {
741        let Ok(bytes) = tokio::fs::read(&path).await else {
742            continue;
743        };
744        let filename = path
745            .file_name()
746            .and_then(|s| s.to_str())
747            .unwrap_or("download.dat")
748            .to_string();
749        return Some((filename, Bytes::from(bytes)));
750    }
751    None
752}
753
754/// 读取 Java 规范目录下的首个稿件文件
755#[tracing::instrument(skip_all, level = "debug")]
756async fn read_canonical_legacy(
757    base2: &str,
758    manga_id: i32,
759    episode_id: i32,
760    post_name: &str,
761) -> Option<(String, Bytes)> {
762    let post_id = legacy_post_id(post_name)?;
763    let dir = resolve_folder_base2(base2)
764        .join(manga_id.to_string())
765        .join(episode_id.to_string())
766        .join(post_id.to_string());
767    let mut entries = tokio::fs::read_dir(&dir).await.ok()?;
768    while let Ok(Some(entry)) = entries.next_entry().await {
769        let path = entry.path();
770        if !path.is_file() {
771            continue;
772        }
773        let bytes = tokio::fs::read(&path).await.ok()?;
774        let filename = path.file_name()?.to_str()?.to_string();
775        return Some((filename, Bytes::from(bytes)));
776    }
777    None
778}
779
780#[cfg(test)]
781mod rss_trigger_tests {
782    use super::*;
783
784    /// 构造仅包含 ID 的话数编辑 DTO。
785    fn edit_dto() -> EpisodeEditDto {
786        EpisodeEditDto {
787            id: Some(1),
788            manga_id: Some(10),
789            manga_episode: None,
790            manga_episode_end: None,
791            manga_episode_name: None,
792            provider_id: None,
793            translator_id: None,
794            proofreader_id: None,
795            letterer_id: None,
796            timer_id: None,
797            reviewer_id: None,
798            publish_link: None,
799        }
800    }
801
802    /// 岗位字段编辑属于指派/改派,不触发 RSS 通知。
803    #[test]
804    fn post_assignment_edit_does_not_refresh_rss() {
805        let dto = EpisodeEditDto {
806            provider_id: Some(1),
807            translator_id: Some(2),
808            proofreader_id: Some(3),
809            letterer_id: Some(4),
810            timer_id: Some(5),
811            reviewer_id: Some(6),
812            ..edit_dto()
813        };
814
815        assert!(!should_refresh_rss_for_episode_edit(&dto));
816    }
817
818    /// 非岗位字段编辑需要保留 RSS 通知。
819    #[test]
820    fn non_post_edit_refreshes_rss() {
821        let episode = EpisodeEditDto {
822            manga_episode: Some("12".into()),
823            ..edit_dto()
824        };
825        let name = EpisodeEditDto {
826            manga_episode_name: Some("新标题".into()),
827            ..edit_dto()
828        };
829        let link = EpisodeEditDto {
830            publish_link: Some("https://example.com/1".into()),
831            ..edit_dto()
832        };
833
834        assert!(should_refresh_rss_for_episode_edit(&episode));
835        assert!(should_refresh_rss_for_episode_edit(&name));
836        assert!(should_refresh_rss_for_episode_edit(&link));
837    }
838}
839
840#[cfg(test)]
841mod download_legacy_tests {
842    use super::*;
843
844    /// 校验生产 legacy 路径可映射到 fixture 目录并读取
845    #[tokio::test]
846    async fn read_remapped_proofreader_file() {
847        let base2 = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures/legacy/data");
848        let stored = "/www/wwwroot/data/33/肚子咕噜咕噜子她和肉肉在同居!04 翻译:Gum979 校对:萝莉控之魂.txt";
849        assert!(
850            read_stored_legacy(base2.to_string_lossy().as_ref(), stored)
851                .await
852                .is_some(),
853            "remapped legacy file should be readable under tests/fixtures/legacy/data"
854        );
855    }
856}