Skip to main content

tdm_server_rust/repository/
episode_repo.rs

1//! 话数数据访问层 (Episode Repository)
2//!
3//! 封装 `mangaepisodetb` 表及 workflow 查询(SeaORM)。
4
5use crate::db::DbConn;
6use crate::entity::episode::{
7    EpisodeDetailVo, EpisodeEditDto, EpisodeListVo, EpisodeSimpleListVo, EpisodeType,
8    MemberStatistics, NewestEpisodeVo, PublishLinkRequest, Statistics, UploadPageVo,
9};
10use crate::repository::episode_statistics_assemble;
11use crate::repository::episode_workflow_assemble;
12
13use crate::sea_entity::{episode_role_taker, mangaepisodedetail, mangaepisodetb, mangatb};
14use chrono::{DateTime, Utc};
15pub use episode_statistics_assemble::PostCompletionStats;
16use sea_orm::{
17    sea_query::{Expr, LockType},
18    ActiveModelTrait, ColumnTrait, ConnectionTrait, DatabaseConnection, EntityTrait,
19    PaginatorTrait, QueryFilter, QueryOrder, QuerySelect, Set, TransactionTrait,
20};
21use sqlx::postgres::PgPool;
22
23/// 话数仓储
24pub struct EpisodeRepository {
25    /// SeaORM 数据库连接
26    db: DbConn,
27}
28
29impl EpisodeRepository {
30    /// 从 `PgPool` 构造
31    pub fn new(pool: PgPool) -> Self {
32        Self {
33            db: crate::db::from_sqlx_pool(pool),
34        }
35    }
36
37    /// 从 `DatabaseConnection` 构造
38    pub fn from_db(db: DatabaseConnection) -> Self {
39        Self { db }
40    }
41
42    /// 按漫画 ID 查询话数简单列表
43    #[tracing::instrument(skip_all, level = "debug")]
44    pub async fn list_by_manga_id(
45        &self,
46        manga_id: i32,
47    ) -> crate::error::ApiResult<Vec<EpisodeSimpleListVo>> {
48        episode_workflow_assemble::list_simple_by_manga_id(&self.db, manga_id).await
49    }
50
51    /// 按漫画 ID 查询话数完整列表
52    #[tracing::instrument(skip_all, level = "debug")]
53    pub async fn list_full_by_manga_id(
54        &self,
55        manga_id: i32,
56    ) -> crate::error::ApiResult<Vec<EpisodeListVo>> {
57        episode_workflow_assemble::list_full_by_manga_id(&self.db, manga_id).await
58    }
59
60    /// 按漫画 ID 分页查询话数列表
61    #[tracing::instrument(skip_all, level = "debug")]
62    pub async fn page_episodes_by_manga(
63        &self,
64        manga_id: i32,
65        page: i32,
66        page_size: i32,
67        episode_type: EpisodeType,
68    ) -> crate::error::ApiResult<(i64, Vec<EpisodeListVo>)> {
69        episode_workflow_assemble::page_episodes_by_manga(
70            &self.db,
71            manga_id,
72            page,
73            page_size,
74            episode_type,
75        )
76        .await
77    }
78
79    /// 按 ID 查询话数详情
80    #[tracing::instrument(skip_all, level = "debug")]
81    pub async fn get_by_id(&self, id: i32) -> crate::error::ApiResult<Option<EpisodeDetailVo>> {
82        episode_workflow_assemble::get_detail_by_id(&self.db, id).await
83    }
84
85    /// 按话数 ID 查询所属漫画 ID
86    #[tracing::instrument(skip_all, level = "debug")]
87    pub async fn get_manga_id_by_episode_id(
88        &self,
89        episode_id: i32,
90    ) -> crate::error::ApiResult<Option<i32>> {
91        let row = mangaepisodetb::Entity::find_by_id(episode_id)
92            .one(&self.db)
93            .await?;
94        Ok(row.map(|e| e.manga_id))
95    }
96
97    /// 在事务中锁定话数主记录,串行化图源页面重建。
98    pub async fn lock_by_id_with<C: ConnectionTrait>(
99        db: &C,
100        episode_id: i32,
101    ) -> crate::error::ApiResult<Option<mangaepisodetb::Model>> {
102        Ok(mangaepisodetb::Entity::find_by_id(episode_id)
103            .lock(LockType::Update)
104            .one(db)
105            .await?)
106    }
107
108    /// 判断话数是否已有下游岗位交稿,避免重建图源破坏已开始的工作。
109    pub async fn has_downstream_submission_with<C: ConnectionTrait>(
110        db: &C,
111        episode_id: i32,
112    ) -> crate::error::ApiResult<bool> {
113        let detail = mangaepisodedetail::Entity::find()
114            .filter(mangaepisodedetail::Column::EpisodeId.eq(episode_id))
115            .one(db)
116            .await?;
117        Ok(detail.is_some_and(|row| {
118            row.translator_update_time.is_some()
119                || row.proofreader_update_time.is_some()
120                || row.letterer_update_time.is_some()
121                || row.timer_update_time.is_some()
122                || row.reviewer_update_time.is_some()
123        }))
124    }
125
126    /// 回写话数交稿时间到 `mangaepisodedetail`(编辑器整话确认)
127    ///
128    /// `role` 为 `translator` 写 `translatorUpdateTime`,`proofreader` 写 `proofreaderUpdateTime`。
129    /// 详情行不存在时插入新行。
130    #[tracing::instrument(skip_all, level = "debug")]
131    pub async fn touch_detail_submit_time(
132        &self,
133        episode_id: i32,
134        role: &str,
135    ) -> crate::error::ApiResult<()> {
136        let now = chrono::Utc::now().naive_utc();
137        let existing = mangaepisodedetail::Entity::find()
138            .filter(mangaepisodedetail::Column::EpisodeId.eq(episode_id))
139            .one(&self.db)
140            .await?;
141        match existing {
142            Some(model) => {
143                let mut am: mangaepisodedetail::ActiveModel = model.into();
144                apply_submit_role(&mut am, role, now)?;
145                am.update(&self.db).await?;
146            }
147            None => {
148                let mut am = mangaepisodedetail::ActiveModel {
149                    episode_id: Set(episode_id),
150                    ..Default::default()
151                };
152                apply_submit_role(&mut am, role, now)?;
153                am.insert(&self.db).await?;
154            }
155        }
156        Ok(())
157    }
158
159    /// 查询话数图源 OSS 记录 ID(provider_file_oss_id)
160    #[tracing::instrument(skip_all, level = "debug")]
161    pub async fn get_provider_oss_id(
162        &self,
163        episode_id: i32,
164    ) -> crate::error::ApiResult<Option<i32>> {
165        let row = mangaepisodetb::Entity::find_by_id(episode_id)
166            .one(&self.db)
167            .await?;
168        Ok(row.and_then(|e| e.provider_file_oss_id))
169    }
170
171    /// 查询话数各岗位已指派组员 ID(去重)
172    #[tracing::instrument(skip_all, level = "debug")]
173    pub async fn get_post_member_ids(&self, episode_id: i32) -> crate::error::ApiResult<Vec<i32>> {
174        let row = mangaepisodetb::Entity::find_by_id(episode_id)
175            .one(&self.db)
176            .await?;
177        let Some(ep) = row else {
178            return Ok(vec![]);
179        };
180        let mut ids = Vec::new();
181        for mid in [
182            ep.provider_id,
183            ep.translator_id,
184            ep.proofreader_id,
185            ep.letterer_id,
186            ep.timer_id,
187            ep.reviewer_id,
188        ] {
189            if let Some(id) = mid {
190                ids.push(id);
191            }
192        }
193        ids.sort_unstable();
194        ids.dedup();
195        Ok(ids)
196    }
197
198    /// 查询话数本地稿件路径
199    #[tracing::instrument(skip_all, level = "debug")]
200    pub async fn get_legacy_file_path(
201        &self,
202        id: i32,
203        post_name: &str,
204    ) -> crate::error::ApiResult<Option<String>> {
205        let row = mangaepisodetb::Entity::find_by_id(id).one(&self.db).await?;
206        let Some(ep) = row else {
207            return Ok(None);
208        };
209        let path = match post_name.to_lowercase().as_str() {
210            "translator" => ep.translator_file,
211            "proofreader" => ep.proofreader_file,
212            "timer" => ep.timer_file,
213            _ => {
214                return Err(crate::error::AppError::business(format!(
215                    "当前岗位文件不存在喵:{post_name}"
216                )));
217            }
218        };
219        Ok(path)
220    }
221
222    /// 更新话数本地稿件路径
223    #[tracing::instrument(skip_all, level = "debug")]
224    pub async fn set_legacy_file_path(
225        &self,
226        id: i32,
227        post_name: &str,
228        path: &str,
229    ) -> crate::error::ApiResult<()> {
230        let existing = mangaepisodetb::Entity::find_by_id(id)
231            .one(&self.db)
232            .await?
233            .ok_or_else(|| crate::error::AppError::business("话数不存在"))?;
234        let mut am: mangaepisodetb::ActiveModel = existing.into();
235        match post_name.to_lowercase().as_str() {
236            "translator" => am.translator_file = Set(Some(path.to_string())),
237            "proofreader" => am.proofreader_file = Set(Some(path.to_string())),
238            "timer" => am.timer_file = Set(Some(path.to_string())),
239            _ => {
240                return Err(crate::error::AppError::business(format!(
241                    "当前岗位文件不存在喵:{post_name}"
242                )));
243            }
244        }
245        am.update_time = Set(chrono::Utc::now().naive_utc());
246        am.update(&self.db).await?;
247        Ok(())
248    }
249
250    /// 查询漫画最新话
251    #[tracing::instrument(skip_all, level = "debug")]
252    pub async fn get_newest_by_manga_id(
253        &self,
254        manga_id: i32,
255    ) -> crate::error::ApiResult<Option<NewestEpisodeVo>> {
256        episode_workflow_assemble::get_newest_by_manga_id(&self.db, manga_id).await
257    }
258
259    /// 分页查询已上传稿件
260    #[tracing::instrument(skip_all, level = "debug")]
261    pub async fn page_uploaded_submit(
262        &self,
263        page: i32,
264        page_size: i32,
265        manga_tran_name: Option<&str>,
266        username: Option<&str>,
267    ) -> crate::error::ApiResult<(i64, Vec<UploadPageVo>)> {
268        episode_workflow_assemble::page_uploaded_submit(
269            &self.db,
270            page,
271            page_size,
272            manga_tran_name,
273            username,
274        )
275        .await
276    }
277
278    /// 统计同漫画、同分类下相同话数标签数量
279    #[tracing::instrument(skip_all, level = "debug")]
280    pub async fn count_episode_by_number(
281        &self,
282        manga_id: i32,
283        manga_episode: &str,
284        episode_type: EpisodeType,
285    ) -> crate::error::ApiResult<i64> {
286        let n = mangaepisodetb::Entity::find()
287            .filter(mangaepisodetb::Column::MangaId.eq(manga_id))
288            .filter(mangaepisodetb::Column::MangaEpisode.eq(manga_episode))
289            .filter(mangaepisodetb::Column::EpisodeType.eq(episode_type.as_str()))
290            .count(&self.db)
291            .await?;
292        Ok(n as i64)
293    }
294
295    /// 刷新漫画更新时间
296    #[tracing::instrument(skip_all, level = "debug")]
297    pub async fn touch_manga_update_time(&self, manga_id: i32) -> crate::error::ApiResult<()> {
298        mangatb::Entity::update_many()
299            .col_expr(
300                mangatb::Column::UpdateTime,
301                Expr::current_timestamp().into(),
302            )
303            .filter(mangatb::Column::Id.eq(manga_id))
304            .exec(&self.db)
305            .await?;
306        Ok(())
307    }
308
309    /// 新增话数
310    #[tracing::instrument(skip_all, level = "debug")]
311    pub async fn insert(&self, dto: &EpisodeEditDto) -> crate::error::ApiResult<i32> {
312        Self::insert_on(&self.db, dto).await
313    }
314
315    async fn insert_on<C: ConnectionTrait>(
316        db: &C,
317        dto: &EpisodeEditDto,
318    ) -> crate::error::ApiResult<i32> {
319        let manga_id = dto
320            .manga_id
321            .ok_or_else(|| crate::error::AppError::business("缺少漫画 ID"))?;
322        let now = chrono::Utc::now().naive_utc();
323        let model = mangaepisodetb::ActiveModel {
324            manga_id: Set(manga_id),
325            manga_episode: Set(dto.manga_episode.clone().unwrap_or_default()),
326            episode_type: Set(dto.episode_type.unwrap_or_default().as_str().to_string()),
327            manga_episode_name: Set(dto.manga_episode_name.clone()),
328            provider_id: Set(dto.provider_id),
329            translator_id: Set(dto.translator_id),
330            proofreader_id: Set(dto.proofreader_id),
331            letterer_id: Set(dto.letterer_id),
332            timer_id: Set(dto.timer_id),
333            reviewer_id: Set(dto.reviewer_id),
334            setup_time: Set(now),
335            update_time: Set(now),
336            publish_link: Set(dto.publish_link.clone()),
337            ..Default::default()
338        };
339        let inserted = model.insert(db).await?;
340        crate::utils::agent_debug::log(
341            "E1",
342            "episode_repo.rs:insert",
343            "episode inserted",
344            serde_json::json!({
345                "episodeId": inserted.id,
346                "mangaId": manga_id,
347                "providerId": dto.provider_id,
348                "translatorId": dto.translator_id
349            }),
350        );
351        Ok(inserted.id)
352    }
353
354    /// 新增话数详情及岗位接稿时间
355    #[tracing::instrument(skip_all, level = "debug")]
356    pub async fn insert_detail(
357        &self,
358        episode_id: i32,
359        dto: &EpisodeEditDto,
360    ) -> crate::error::ApiResult<()> {
361        Self::insert_detail_on(&self.db, episode_id, dto).await
362    }
363
364    async fn insert_detail_on<C: ConnectionTrait>(
365        db: &C,
366        episode_id: i32,
367        dto: &EpisodeEditDto,
368    ) -> crate::error::ApiResult<()> {
369        let now = chrono::Utc::now().naive_utc();
370        mangaepisodedetail::ActiveModel {
371            episode_id: Set(episode_id),
372            translator_setup_time: Set(dto.translator_id.map(|_| now)),
373            proofreader_setup_time: Set(dto.proofreader_id.map(|_| now)),
374            letterer_setup_time: Set(dto.letterer_id.map(|_| now)),
375            timer_setup_time: Set(dto.timer_id.map(|_| now)),
376            reviewer_setup_time: Set(dto.reviewer_id.map(|_| now)),
377            ..Default::default()
378        }
379        .insert(db)
380        .await?;
381        Ok(())
382    }
383
384    /// 在单个事务中新增一批话数及详情。
385    #[tracing::instrument(skip_all, level = "debug")]
386    pub async fn insert_batch(
387        &self,
388        dtos: &[EpisodeEditDto],
389        manga_id: i32,
390    ) -> crate::error::ApiResult<()> {
391        let tx = self.db.begin().await?;
392        for dto in dtos {
393            let episode_id = Self::insert_on(&tx, dto).await?;
394            Self::insert_detail_on(&tx, episode_id, dto).await?;
395        }
396        mangatb::Entity::update_many()
397            .col_expr(
398                mangatb::Column::UpdateTime,
399                Expr::current_timestamp().into(),
400            )
401            .filter(mangatb::Column::Id.eq(manga_id))
402            .exec(&tx)
403            .await?;
404        tx.commit().await?;
405        Ok(())
406    }
407
408    /// 仅更新发布链接
409    #[tracing::instrument(skip_all, level = "debug")]
410    pub async fn update_publish_link_only(
411        &self,
412        id: i32,
413        publish_link: Option<String>,
414    ) -> crate::error::ApiResult<()> {
415        mangaepisodetb::Entity::update_many()
416            .col_expr(mangaepisodetb::Column::PublishLink, publish_link.into())
417            .col_expr(
418                mangaepisodetb::Column::UpdateTime,
419                Expr::current_timestamp().into(),
420            )
421            .filter(mangaepisodetb::Column::Id.eq(id))
422            .exec(&self.db)
423            .await?;
424        Ok(())
425    }
426
427    /// 更新话数
428    #[tracing::instrument(skip_all, level = "debug")]
429    pub async fn update(&self, dto: &EpisodeEditDto) -> crate::error::ApiResult<()> {
430        let id = dto
431            .id
432            .ok_or_else(|| crate::error::AppError::business("缺少话数 ID"))?;
433        let existing = mangaepisodetb::Entity::find_by_id(id)
434            .one(&self.db)
435            .await?
436            .ok_or_else(|| crate::error::AppError::business("话数不存在"))?;
437        let manga_episode_name = dto
438            .manga_episode_name
439            .as_deref()
440            .map(|s| {
441                if s.is_empty() || s == "null" {
442                    None
443                } else {
444                    Some(s.to_string())
445                }
446            })
447            .flatten();
448        let mut am: mangaepisodetb::ActiveModel = existing.into();
449        if let Some(ref ep) = dto.manga_episode {
450            am.manga_episode = Set(ep.clone());
451        }
452        if let Some(episode_type) = dto.episode_type {
453            am.episode_type = Set(episode_type.as_str().to_string());
454        }
455        am.manga_episode_name = Set(manga_episode_name);
456        am.provider_id = Set(dto.provider_id);
457        am.translator_id = Set(dto.translator_id);
458        am.proofreader_id = Set(dto.proofreader_id);
459        am.letterer_id = Set(dto.letterer_id);
460        am.timer_id = Set(dto.timer_id);
461        am.reviewer_id = Set(dto.reviewer_id);
462        am.publish_link = Set(dto.publish_link.clone());
463        am.update_time = Set(chrono::Utc::now().naive_utc());
464        am.update(&self.db).await?;
465        self.sync_assignment_setup_times(id, dto).await?;
466        if let Some(manga_id) = dto.manga_id {
467            self.touch_manga_update_time(manga_id).await?;
468        }
469        Ok(())
470    }
471
472    /// 同步岗位指派接稿时间
473    async fn sync_assignment_setup_times(
474        &self,
475        episode_id: i32,
476        dto: &EpisodeEditDto,
477    ) -> crate::error::ApiResult<()> {
478        let detail = mangaepisodedetail::Entity::find()
479            .filter(mangaepisodedetail::Column::EpisodeId.eq(episode_id))
480            .one(&self.db)
481            .await?;
482        let Some(row) = detail else {
483            return Ok(());
484        };
485        let now = chrono::Utc::now().naive_utc();
486        let ts = row.translator_setup_time;
487        let ps = row.proofreader_setup_time;
488        let ls = row.letterer_setup_time;
489        let tms = row.timer_setup_time;
490        let rs = row.reviewer_setup_time;
491        let mut am: mangaepisodedetail::ActiveModel = row.into();
492        set_setup_coalesce(&mut am, "translatorSetupTime", dto.translator_id, ts, now);
493        set_setup_coalesce(&mut am, "proofreaderSetupTime", dto.proofreader_id, ps, now);
494        set_setup_coalesce(&mut am, "lettererSetupTime", dto.letterer_id, ls, now);
495        set_setup_coalesce(&mut am, "timerSetupTime", dto.timer_id, tms, now);
496        set_setup_coalesce(&mut am, "reviewerSetupTime", dto.reviewer_id, rs, now);
497        am.update(&self.db).await?;
498        Ok(())
499    }
500
501    /// 删除话数及详情
502    #[tracing::instrument(skip_all, level = "debug")]
503    pub async fn delete_by_id(&self, id: i32) -> crate::error::ApiResult<()> {
504        mangaepisodedetail::Entity::delete_many()
505            .filter(mangaepisodedetail::Column::EpisodeId.eq(id))
506            .exec(&self.db)
507            .await?;
508        mangaepisodetb::Entity::delete_by_id(id)
509            .exec(&self.db)
510            .await?;
511        Ok(())
512    }
513
514    /// 批量更新发布链接
515    #[tracing::instrument(skip_all, level = "debug")]
516    pub async fn update_publish_links_batch(
517        &self,
518        requests: &[PublishLinkRequest],
519    ) -> crate::error::ApiResult<()> {
520        for req in requests {
521            mangaepisodetb::Entity::update_many()
522                .col_expr(
523                    mangaepisodetb::Column::PublishLink,
524                    req.publish_link.clone().into(),
525                )
526                .col_expr(
527                    mangaepisodetb::Column::UpdateTime,
528                    Expr::current_timestamp().into(),
529                )
530                .filter(mangaepisodetb::Column::Id.eq(req.id))
531                .exec(&self.db)
532                .await?;
533        }
534        Ok(())
535    }
536
537    /// 检测发布链接是否重复
538    #[tracing::instrument(skip_all, level = "debug")]
539    pub async fn count_publish_link(
540        &self,
541        publish_link: &str,
542        exclude_id: Option<i32>,
543    ) -> crate::error::ApiResult<i64> {
544        let mut q = mangaepisodetb::Entity::find()
545            .filter(mangaepisodetb::Column::PublishLink.eq(publish_link));
546        if let Some(id) = exclude_id {
547            q = q.filter(mangaepisodetb::Column::Id.ne(id));
548        }
549        let n = q.count(&self.db).await?;
550        Ok(n as i64)
551    }
552
553    /// 统计时间段内各岗位完成数
554    #[tracing::instrument(skip_all, level = "debug")]
555    pub async fn get_statistic_count(
556        &self,
557        start: DateTime<Utc>,
558        end: DateTime<Utc>,
559    ) -> crate::error::ApiResult<PostCompletionStats> {
560        episode_statistics_assemble::statistic_count_in_range(&self.db, start, end).await
561    }
562
563    /// 批量统计多时间段(单 SQL FILTER)
564    #[tracing::instrument(skip_all, level = "debug")]
565    pub async fn get_statistic_counts_batch(
566        &self,
567        ranges: &[(DateTime<Utc>, DateTime<Utc>)],
568    ) -> crate::error::ApiResult<Vec<PostCompletionStats>> {
569        let naive: Vec<(chrono::NaiveDateTime, chrono::NaiveDateTime)> = ranges
570            .iter()
571            .map(|(s, e)| (s.naive_utc(), e.naive_utc()))
572            .collect();
573        episode_statistics_assemble::statistic_counts_in_ranges(&self.db, &naive).await
574    }
575
576    /// 转为 Statistics
577    pub fn stats_from_post(stats: &PostCompletionStats) -> Statistics {
578        Statistics {
579            translator_count: stats.translator_count as i32,
580            proofreader_count: stats.proofreader_count as i32,
581            letterer_count: stats.letterer_count as i32,
582            reviewer_count: stats.reviewer_count as i32,
583        }
584    }
585
586    /// 查询组员完成统计
587    #[tracing::instrument(skip_all, level = "debug")]
588    pub async fn get_member_statistics(
589        &self,
590        start: DateTime<Utc>,
591        end: DateTime<Utc>,
592        member_id: Option<i32>,
593    ) -> crate::error::ApiResult<Vec<MemberStatistics>> {
594        episode_statistics_assemble::member_statistics_in_range(&self.db, start, end, member_id)
595            .await
596    }
597
598    /// 回退指定岗位流程
599    #[tracing::instrument(skip_all, level = "debug")]
600    pub async fn rollback_episode(
601        &self,
602        episode_id: i32,
603        workflow_type: &str,
604        member_id: Option<i32>,
605    ) -> crate::error::ApiResult<Option<i32>> {
606        let post = workflow_type.to_lowercase();
607        if let Some(mid) = member_id {
608            if let Some(oss_id) =
609                rollback_episode_role_taker(&self.db, episode_id, &post, mid).await?
610            {
611                return Ok(oss_id);
612            }
613        }
614        let ep = mangaepisodetb::Entity::find_by_id(episode_id)
615            .one(&self.db)
616            .await?
617            .ok_or_else(|| crate::error::AppError::business("话数不存在"))?;
618
619        let oss_id = match post.as_str() {
620            "provider" => {
621                let oid = ep.provider_file_oss_id;
622                let mut am: mangaepisodetb::ActiveModel = ep.into();
623                am.provider_file_oss_id = Set(None);
624                am.update_time = Set(chrono::Utc::now().naive_utc());
625                am.update(&self.db).await?;
626                oid
627            }
628            "translator" => {
629                let oid = ep.translator_file_oss_id;
630                let mut am: mangaepisodetb::ActiveModel = ep.into();
631                am.translator_file = Set(None);
632                am.translator_file_oss_id = Set(None);
633                am.update_time = Set(chrono::Utc::now().naive_utc());
634                am.update(&self.db).await?;
635                clear_detail_time(&self.db, episode_id, "translatorUpdateTime").await?;
636                oid
637            }
638            "proofreader" => {
639                let oid = ep.proofreader_file_oss_id;
640                let mut am: mangaepisodetb::ActiveModel = ep.into();
641                am.proofreader_file = Set(None);
642                am.proofreader_file_oss_id = Set(None);
643                am.update_time = Set(chrono::Utc::now().naive_utc());
644                am.update(&self.db).await?;
645                clear_detail_time(&self.db, episode_id, "proofreaderUpdateTime").await?;
646                oid
647            }
648            "letterer" => {
649                let oid = ep.letterer_file_oss_id;
650                let mut am: mangaepisodetb::ActiveModel = ep.into();
651                am.letterer_file_oss_id = Set(None);
652                am.update_time = Set(chrono::Utc::now().naive_utc());
653                am.update(&self.db).await?;
654                clear_detail_time(&self.db, episode_id, "lettererUpdateTime").await?;
655                oid
656            }
657            "timer" => {
658                let oid = ep.timer_file_oss_id;
659                let mut am: mangaepisodetb::ActiveModel = ep.into();
660                am.timer_file = Set(None);
661                am.timer_file_oss_id = Set(None);
662                am.update_time = Set(chrono::Utc::now().naive_utc());
663                am.update(&self.db).await?;
664                clear_detail_time(&self.db, episode_id, "timerUpdateTime").await?;
665                oid
666            }
667            "reviewer" => {
668                clear_detail_time(&self.db, episode_id, "reviewerUpdateTime").await?;
669                None
670            }
671            _ => return Err(crate::error::AppError::business("不支持的回退流程类型喵!")),
672        };
673        Ok(oss_id)
674    }
675
676    /// 查询未发布话数
677    #[tracing::instrument(skip_all, level = "debug")]
678    pub async fn list_unpublished_with_detail(
679        &self,
680    ) -> crate::error::ApiResult<Vec<EpisodeListVo>> {
681        let rows = mangaepisodetb::Entity::find()
682            .filter(
683                sea_orm::Condition::any()
684                    .add(mangaepisodetb::Column::PublishLink.is_null())
685                    .add(mangaepisodetb::Column::PublishLink.eq("")),
686            )
687            .order_by_desc(mangaepisodetb::Column::Id)
688            .all(&self.db)
689            .await?;
690        Ok(rows
691            .into_iter()
692            .map(|r| EpisodeListVo {
693                id: r.id,
694                manga_id: r.manga_id,
695                manga_episode: Some(r.manga_episode),
696                ..Default::default()
697            })
698            .collect())
699    }
700}
701
702/// 清空 detail 交稿时间列
703async fn clear_detail_time(
704    db: &DatabaseConnection,
705    episode_id: i32,
706    col: &str,
707) -> crate::error::ApiResult<()> {
708    let detail = mangaepisodedetail::Entity::find()
709        .filter(mangaepisodedetail::Column::EpisodeId.eq(episode_id))
710        .one(db)
711        .await?;
712    let Some(row) = detail else {
713        return Ok(());
714    };
715    let mut am: mangaepisodedetail::ActiveModel = row.into();
716    match col {
717        "translatorUpdateTime" => am.translator_update_time = Set(None),
718        "proofreaderUpdateTime" => am.proofreader_update_time = Set(None),
719        "lettererUpdateTime" => am.letterer_update_time = Set(None),
720        "timerUpdateTime" => am.timer_update_time = Set(None),
721        "reviewerUpdateTime" => am.reviewer_update_time = Set(None),
722        _ => {}
723    }
724    am.update(db).await?;
725    Ok(())
726}
727
728/// 撤回共同接稿人的个人交稿与个人稿件。
729async fn rollback_episode_role_taker(
730    db: &DatabaseConnection,
731    episode_id: i32,
732    role: &str,
733    member_id: i32,
734) -> crate::error::ApiResult<Option<Option<i32>>> {
735    if !matches!(role, "translator" | "proofreader") {
736        return Ok(None);
737    }
738    let row = episode_role_taker::Entity::find()
739        .filter(episode_role_taker::Column::EpisodeId.eq(episode_id))
740        .filter(episode_role_taker::Column::Role.eq(role))
741        .filter(episode_role_taker::Column::MemberId.eq(member_id))
742        .one(db)
743        .await?;
744    let Some(row) = row else {
745        return Ok(None);
746    };
747    let oss_id = row.file_oss_id;
748    let mut am: episode_role_taker::ActiveModel = row.into();
749    am.update_time = Set(None);
750    am.file_oss_id = Set(None);
751    am.update(db).await?;
752    Ok(Some(oss_id))
753}
754
755/// 按 COALESCE 语义更新接稿时间
756fn set_setup_coalesce(
757    am: &mut mangaepisodedetail::ActiveModel,
758    col: &str,
759    member_id: Option<i32>,
760    existing: Option<chrono::NaiveDateTime>,
761    now: chrono::NaiveDateTime,
762) {
763    match col {
764        "translatorSetupTime" => {
765            am.translator_setup_time = Set(if member_id.is_some() {
766                Some(existing.unwrap_or(now))
767            } else {
768                None
769            });
770        }
771        "proofreaderSetupTime" => {
772            am.proofreader_setup_time = Set(if member_id.is_some() {
773                Some(existing.unwrap_or(now))
774            } else {
775                None
776            });
777        }
778        "lettererSetupTime" => {
779            am.letterer_setup_time = Set(if member_id.is_some() {
780                Some(existing.unwrap_or(now))
781            } else {
782                None
783            });
784        }
785        "timerSetupTime" => {
786            am.timer_setup_time = Set(if member_id.is_some() {
787                Some(existing.unwrap_or(now))
788            } else {
789                None
790            });
791        }
792        "reviewerSetupTime" => {
793            am.reviewer_setup_time = Set(if member_id.is_some() {
794                Some(existing.unwrap_or(now))
795            } else {
796                None
797            });
798        }
799        _ => {}
800    }
801}
802
803/// 按角色写入交稿时间到详情 ActiveModel(编辑器整话确认)
804fn apply_submit_role(
805    am: &mut mangaepisodedetail::ActiveModel,
806    role: &str,
807    now: chrono::NaiveDateTime,
808) -> crate::error::ApiResult<()> {
809    match role.to_lowercase().as_str() {
810        "translator" => am.translator_update_time = Set(Some(now)),
811        "proofreader" => am.proofreader_update_time = Set(Some(now)),
812        _ => {
813            return Err(crate::error::AppError::business(format!(
814                "不支持的整话确认角色:{role}(仅 translator / proofreader)"
815            )));
816        }
817    }
818    Ok(())
819}
820
821/// 岗位 ID 对应接稿时间字段(单元测试用)
822#[cfg(test)]
823fn assignment_setup_columns(dto: &EpisodeEditDto) -> [(&'static str, Option<i32>); 5] {
824    [
825        ("translatorSetupTime", dto.translator_id),
826        ("proofreaderSetupTime", dto.proofreader_id),
827        ("lettererSetupTime", dto.letterer_id),
828        ("timerSetupTime", dto.timer_id),
829        ("reviewerSetupTime", dto.reviewer_id),
830    ]
831}
832
833#[cfg(test)]
834mod tests {
835    use super::*;
836
837    fn dto_with_assignments() -> EpisodeEditDto {
838        EpisodeEditDto {
839            id: Some(1),
840            manga_id: Some(2),
841            manga_episode: Some("11.1".to_string()),
842            manga_episode_end: None,
843            manga_episode_name: Some("测试话".to_string()),
844            episode_type: Some(EpisodeType::Main),
845            provider_id: Some(97),
846            translator_id: Some(10),
847            proofreader_id: Some(20),
848            letterer_id: Some(30),
849            timer_id: None,
850            reviewer_id: Some(40),
851            publish_link: None,
852        }
853    }
854
855    #[test]
856    fn assignment_setup_columns_maps_member_ids_to_setup_columns() {
857        let columns = assignment_setup_columns(&dto_with_assignments());
858        assert_eq!(
859            columns,
860            [
861                ("translatorSetupTime", Some(10)),
862                ("proofreaderSetupTime", Some(20)),
863                ("lettererSetupTime", Some(30)),
864                ("timerSetupTime", None),
865                ("reviewerSetupTime", Some(40)),
866            ]
867        );
868    }
869
870    #[test]
871    fn assignment_setup_columns_keeps_empty_assignment_as_none() {
872        let mut dto = dto_with_assignments();
873        dto.proofreader_id = None;
874        dto.letterer_id = None;
875        let columns = assignment_setup_columns(&dto);
876        assert_eq!(columns[1], ("proofreaderSetupTime", None));
877        assert_eq!(columns[2], ("lettererSetupTime", None));
878    }
879}