1use 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
23pub struct EpisodeRepository {
25 db: DbConn,
27}
28
29impl EpisodeRepository {
30 pub fn new(pool: PgPool) -> Self {
32 Self {
33 db: crate::db::from_sqlx_pool(pool),
34 }
35 }
36
37 pub fn from_db(db: DatabaseConnection) -> Self {
39 Self { db }
40 }
41
42 #[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 #[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 #[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 #[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 #[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 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 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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 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 #[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 #[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 #[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 #[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 #[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 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 #[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 #[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 #[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
702async 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
728async 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
755fn 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
803fn 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#[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}