1use 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
34pub struct EpisodeService;
38
39impl EpisodeService {
40 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 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 Ok(result)
467 }
468
469 #[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 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 Ok(list)
520 }
521
522 #[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 #[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 #[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 #[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
665fn 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
683fn 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
693fn 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
706const LEGACY_PROD_PREFIXES: &[&str] = &["/www/wwwroot/data", "/www/wwwroot/tdm/data"];
708
709fn 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
726fn 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#[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#[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 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 #[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 #[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 #[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}