Skip to main content

tdm_server_rust/repository/
episode_workflow_assemble.rs

1//! 话数 workflow 列表组装(SeaORM 多表批量加载,对齐 episode_mapper 联查)
2
3use crate::entity::episode::{
4    EpisodeDetailVo, EpisodeListVo, EpisodeSimpleListVo, EpisodeType, NewestEpisodeVo, RoleTakerVo,
5    UploadPageVo,
6};
7use crate::repository::episode_sort;
8use crate::sea_entity::{
9    editor_page, episode_role_taker, mangaepisodedetail, mangaepisodetb, mangatb, membertb, oss,
10};
11use chrono::{DateTime, Utc};
12use sea_orm::{
13    sea_query::{NullOrdering, Order},
14    ColumnTrait, Condition, DatabaseConnection, EntityTrait, PaginatorTrait, QueryFilter,
15    QueryOrder, QuerySelect,
16};
17use std::collections::HashMap;
18
19/// workflow 关联数据缓存
20struct WorkflowContext {
21    /// episodeId -> detail
22    details: HashMap<i32, mangaepisodedetail::Model>,
23    /// memberId -> username
24    members: HashMap<i32, String>,
25    /// memberId -> email
26    member_emails: HashMap<i32, String>,
27    /// ossId -> filename
28    oss_names: HashMap<i32, String>,
29    /// episodeId -> 编辑器图源页面数量
30    editor_page_counts: HashMap<i32, i64>,
31    /// (episodeId, role) -> 共同接稿人
32    role_takers: HashMap<(i32, String), Vec<RoleTakerVo>>,
33}
34
35/// 按漫画 ID 查询话数简单列表
36pub async fn list_simple_by_manga_id(
37    db: &DatabaseConnection,
38    manga_id: i32,
39) -> crate::error::ApiResult<Vec<EpisodeSimpleListVo>> {
40    let episodes = episodes_by_manga_ordered(db, manga_id).await?;
41    let ctx = load_workflow_context(db, &episodes).await?;
42    Ok(episodes
43        .iter()
44        .map(|ep| to_simple_list_vo(ep, &ctx))
45        .collect())
46}
47
48/// 按漫画 ID 分页查询话数完整列表(COUNT 与当前页并行,仅组装当前页)
49pub async fn page_episodes_by_manga(
50    db: &DatabaseConnection,
51    manga_id: i32,
52    page: i32,
53    page_size: i32,
54    episode_type: EpisodeType,
55) -> crate::error::ApiResult<(i64, Vec<EpisodeListVo>)> {
56    let page = page.max(1);
57    let page_size = page_size.max(1);
58    let offset = ((page - 1) * page_size) as u64;
59    let filter = Condition::all()
60        .add(mangaepisodetb::Column::MangaId.eq(manga_id))
61        .add(mangaepisodetb::Column::EpisodeType.eq(episode_type.as_str()));
62    let order = episode_sort::episode_label_sort_expr(r#""mangaEpisode""#);
63    let filter_count = filter.clone();
64    let filter_page = filter.clone();
65    let (total, episodes) = tokio::try_join!(
66        async move {
67            Ok::<_, crate::error::AppError>(
68                mangaepisodetb::Entity::find()
69                    .filter(filter_count)
70                    .count(db)
71                    .await? as i64,
72            )
73        },
74        async move {
75            Ok(mangaepisodetb::Entity::find()
76                .filter(filter_page)
77                .order_by_with_nulls(order, Order::Desc, NullOrdering::Last)
78                .limit(page_size as u64)
79                .offset(offset)
80                .all(db)
81                .await?)
82        },
83    )?;
84    if episodes.is_empty() {
85        return Ok((total, vec![]));
86    }
87    let ctx = load_workflow_context(db, &episodes).await?;
88    Ok((
89        total,
90        episodes.iter().map(|ep| to_list_vo(ep, &ctx)).collect(),
91    ))
92}
93
94/// 按漫画 ID 查询话数完整列表
95pub async fn list_full_by_manga_id(
96    db: &DatabaseConnection,
97    manga_id: i32,
98) -> crate::error::ApiResult<Vec<EpisodeListVo>> {
99    let episodes = episodes_by_manga_ordered(db, manga_id).await?;
100    let ctx = load_workflow_context(db, &episodes).await?;
101    Ok(episodes.iter().map(|ep| to_list_vo(ep, &ctx)).collect())
102}
103
104/// 按 ID 查询话数详情
105pub async fn get_detail_by_id(
106    db: &DatabaseConnection,
107    id: i32,
108) -> crate::error::ApiResult<Option<EpisodeDetailVo>> {
109    let ep = mangaepisodetb::Entity::find_by_id(id).one(db).await?;
110    let Some(ep) = ep else {
111        return Ok(None);
112    };
113    let ctx = load_workflow_context(db, std::slice::from_ref(&ep)).await?;
114    Ok(Some(to_detail_vo(&ep, &ctx)))
115}
116
117/// 查询漫画最新话
118pub async fn get_newest_by_manga_id(
119    db: &DatabaseConnection,
120    manga_id: i32,
121) -> crate::error::ApiResult<Option<NewestEpisodeVo>> {
122    let ep = mangaepisodetb::Entity::find()
123        .filter(mangaepisodetb::Column::MangaId.eq(manga_id))
124        .filter(mangaepisodetb::Column::EpisodeType.eq(EpisodeType::Main.as_str()))
125        .order_by_desc(mangaepisodetb::Column::Id)
126        .one(db)
127        .await?;
128    Ok(ep.map(|e| NewestEpisodeVo {
129        manga_episode: Some(e.manga_episode),
130        translator_id: e.translator_id,
131        proofreader_id: e.proofreader_id,
132        letterer_id: e.letterer_id,
133        reviewer_id: e.reviewer_id,
134    }))
135}
136
137/// 藏宝处分页:已上传稿件列表(COUNT 与当前页并行)
138pub async fn page_uploaded_submit(
139    db: &DatabaseConnection,
140    page: i32,
141    page_size: i32,
142    manga_tran_name: Option<&str>,
143    username: Option<&str>,
144) -> crate::error::ApiResult<(i64, Vec<UploadPageVo>)> {
145    let filter = uploaded_submit_filter(db, manga_tran_name, username).await?;
146    let page = page.max(1);
147    let page_size = page_size.max(1);
148    let offset = ((page - 1) * page_size) as u64;
149    let filter_count = filter.clone();
150    let filter_page = filter.clone();
151    let (total, episodes) = tokio::try_join!(
152        async move {
153            Ok::<_, crate::error::AppError>(
154                mangaepisodetb::Entity::find()
155                    .filter(filter_count)
156                    .count(db)
157                    .await? as i64,
158            )
159        },
160        async move {
161            Ok(mangaepisodetb::Entity::find()
162                .filter(filter_page)
163                .order_by_desc(mangaepisodetb::Column::UpdateTime)
164                .limit(page_size as u64)
165                .offset(offset)
166                .all(db)
167                .await?)
168        },
169    )?;
170    if total == 0 {
171        return Ok((0, vec![]));
172    }
173    let ctx = load_upload_context(db, &episodes).await?;
174    Ok((
175        total,
176        episodes.iter().map(|ep| to_upload_vo(ep, &ctx)).collect(),
177    ))
178}
179
180/// 按话数标签降序取漫画下话数(非数字标签如 e32103 排最后)
181async fn episodes_by_manga_ordered(
182    db: &DatabaseConnection,
183    manga_id: i32,
184) -> crate::error::ApiResult<Vec<mangaepisodetb::Model>> {
185    let rows = mangaepisodetb::Entity::find()
186        .filter(mangaepisodetb::Column::MangaId.eq(manga_id))
187        .order_by_with_nulls(
188            episode_sort::episode_label_sort_expr(r#""mangaEpisode""#),
189            Order::Desc,
190            NullOrdering::Last,
191        )
192        .all(db)
193        .await?;
194    Ok(rows)
195}
196
197/// 藏宝处筛选条件
198async fn uploaded_submit_filter(
199    db: &DatabaseConnection,
200    manga_tran_name: Option<&str>,
201    username: Option<&str>,
202) -> crate::error::ApiResult<Condition> {
203    use mangaepisodetb::Column as C;
204    let file_cond = Condition::any()
205        .add(C::TranslatorFile.is_not_null())
206        .add(C::ProofreaderFile.is_not_null())
207        .add(C::TimerFile.is_not_null())
208        .add(C::TranslatorFileOssId.is_not_null())
209        .add(C::ProofreaderFileOssId.is_not_null())
210        .add(C::TimerFileOssId.is_not_null());
211    let mut all = Condition::all().add(file_cond);
212    if let Some(name) = manga_tran_name {
213        let manga = mangatb::Entity::find()
214            .filter(mangatb::Column::MangaTranName.eq(name))
215            .one(db)
216            .await?;
217        let Some(m) = manga else {
218            return Ok(Condition::all().add(C::Id.eq(-1)));
219        };
220        all = all.add(C::MangaId.eq(m.id));
221    }
222    if let Some(u) = username {
223        let members = membertb::Entity::find()
224            .filter(membertb::Column::Username.eq(u))
225            .all(db)
226            .await?;
227        if members.is_empty() {
228            return Ok(Condition::all().add(C::Id.eq(-1)));
229        }
230        let ids: Vec<i32> = members.iter().map(|m| m.id).collect();
231        all = all.add(
232            Condition::any()
233                .add(C::TranslatorId.is_in(ids.clone()))
234                .add(C::ProofreaderId.is_in(ids)),
235        );
236    }
237    Ok(all)
238}
239
240/// 加载 workflow 关联数据
241async fn load_workflow_context(
242    db: &DatabaseConnection,
243    episodes: &[mangaepisodetb::Model],
244) -> crate::error::ApiResult<WorkflowContext> {
245    if episodes.is_empty() {
246        return Ok(WorkflowContext {
247            details: HashMap::new(),
248            members: HashMap::new(),
249            member_emails: HashMap::new(),
250            oss_names: HashMap::new(),
251            editor_page_counts: HashMap::new(),
252            role_takers: HashMap::new(),
253        });
254    }
255    let ep_ids: Vec<i32> = episodes.iter().map(|e| e.id).collect();
256    let details: HashMap<i32, mangaepisodedetail::Model> = mangaepisodedetail::Entity::find()
257        .filter(mangaepisodedetail::Column::EpisodeId.is_in(ep_ids.clone()))
258        .all(db)
259        .await?
260        .into_iter()
261        .map(|d| (d.episode_id, d))
262        .collect();
263    let editor_page_counts = load_editor_page_counts(db, &ep_ids).await?;
264
265    let mut member_ids = Vec::new();
266    let mut oss_ids = Vec::new();
267    let role_taker_rows = episode_role_taker::Entity::find()
268        .filter(episode_role_taker::Column::EpisodeId.is_in(ep_ids.clone()))
269        .order_by_asc(episode_role_taker::Column::SetupTime)
270        .all(db)
271        .await?;
272    for ep in episodes {
273        for mid in [
274            ep.provider_id,
275            ep.translator_id,
276            ep.proofreader_id,
277            ep.letterer_id,
278            ep.timer_id,
279            ep.reviewer_id,
280        ] {
281            if let Some(id) = mid {
282                member_ids.push(id);
283            }
284        }
285        for oid in [
286            ep.provider_file_oss_id,
287            ep.translator_file_oss_id,
288            ep.proofreader_file_oss_id,
289            ep.letterer_file_oss_id,
290            ep.timer_file_oss_id,
291        ] {
292            if let Some(id) = oid {
293                oss_ids.push(id);
294            }
295        }
296    }
297    for taker in &role_taker_rows {
298        member_ids.push(taker.member_id);
299        if let Some(oss_id) = taker.file_oss_id {
300            oss_ids.push(oss_id);
301        }
302    }
303    member_ids.sort_unstable();
304    member_ids.dedup();
305    oss_ids.sort_unstable();
306    oss_ids.dedup();
307
308    let (members, member_emails, member_avatars) = load_members(db, &member_ids).await?;
309    let oss_names = load_oss_names(db, &oss_ids).await?;
310    let role_takers = group_role_takers(role_taker_rows, &members, &member_avatars, &oss_names);
311    Ok(WorkflowContext {
312        details,
313        members,
314        member_emails,
315        oss_names,
316        editor_page_counts,
317        role_takers,
318    })
319}
320
321/// 批量统计编辑器图源页面数量
322async fn load_editor_page_counts(
323    db: &DatabaseConnection,
324    episode_ids: &[i32],
325) -> crate::error::ApiResult<HashMap<i32, i64>> {
326    if episode_ids.is_empty() {
327        return Ok(HashMap::new());
328    }
329    let rows: Vec<(i32, i64)> = editor_page::Entity::find()
330        .select_only()
331        .column(editor_page::Column::EpisodeId)
332        .column_as(editor_page::Column::Id.count(), "page_count")
333        .filter(editor_page::Column::EpisodeId.is_in(episode_ids.to_vec()))
334        .group_by(editor_page::Column::EpisodeId)
335        .into_tuple()
336        .all(db)
337        .await?;
338    Ok(rows.into_iter().collect())
339}
340
341/// 藏宝处扩展上下文(含漫画名)
342struct UploadContext {
343    /// workflow 基础上下文
344    workflow: WorkflowContext,
345    /// mangaId -> 译名
346    manga_names: HashMap<i32, String>,
347}
348
349/// 加载藏宝处关联数据
350async fn load_upload_context(
351    db: &DatabaseConnection,
352    episodes: &[mangaepisodetb::Model],
353) -> crate::error::ApiResult<UploadContext> {
354    let workflow = load_workflow_context(db, episodes).await?;
355    let manga_ids: Vec<i32> = episodes.iter().map(|e| e.manga_id).collect();
356    let mangas: HashMap<i32, String> = if manga_ids.is_empty() {
357        HashMap::new()
358    } else {
359        mangatb::Entity::find()
360            .filter(mangatb::Column::Id.is_in(manga_ids))
361            .all(db)
362            .await?
363            .into_iter()
364            .map(|m| (m.id, m.manga_tran_name))
365            .collect()
366    };
367    Ok(UploadContext {
368        workflow,
369        manga_names: mangas,
370    })
371}
372
373/// 批量加载组员名与邮箱
374async fn load_members(
375    db: &DatabaseConnection,
376    ids: &[i32],
377) -> crate::error::ApiResult<(
378    HashMap<i32, String>,
379    HashMap<i32, String>,
380    HashMap<i32, String>,
381)> {
382    if ids.is_empty() {
383        return Ok((HashMap::new(), HashMap::new(), HashMap::new()));
384    }
385    let rows = membertb::Entity::find()
386        .filter(membertb::Column::Id.is_in(ids.to_vec()))
387        .all(db)
388        .await?;
389    let mut names = HashMap::new();
390    let mut emails = HashMap::new();
391    let mut avatars = HashMap::new();
392    for m in rows {
393        let id = m.id;
394        names.insert(m.id, m.username);
395        if let Some(e) = m.email {
396            emails.insert(id, e);
397        }
398        if let Some(a) = m.avatar_url {
399            avatars.insert(id, a);
400        }
401    }
402    Ok((names, emails, avatars))
403}
404
405/// 批量加载 OSS 文件名
406async fn load_oss_names(
407    db: &DatabaseConnection,
408    ids: &[i32],
409) -> crate::error::ApiResult<HashMap<i32, String>> {
410    if ids.is_empty() {
411        return Ok(HashMap::new());
412    }
413    Ok(oss::Entity::find()
414        .filter(oss::Column::Id.is_in(ids.to_vec()))
415        .all(db)
416        .await?
417        .into_iter()
418        .map(|o| (o.id, o.filename))
419        .collect())
420}
421
422/// 将共同接稿记录按话数与岗位分组。
423fn group_role_takers(
424    rows: Vec<episode_role_taker::Model>,
425    members: &HashMap<i32, String>,
426    avatars: &HashMap<i32, String>,
427    oss_names: &HashMap<i32, String>,
428) -> HashMap<(i32, String), Vec<RoleTakerVo>> {
429    let mut grouped: HashMap<(i32, String), Vec<RoleTakerVo>> = HashMap::new();
430    for row in rows {
431        let role = row.role.clone();
432        grouped
433            .entry((row.episode_id, role))
434            .or_default()
435            .push(RoleTakerVo {
436                member_id: row.member_id,
437                member_name: members.get(&row.member_id).cloned(),
438                avatar_url: avatars.get(&row.member_id).cloned(),
439                setup_time: dt_legacy(row.setup_time),
440                update_time: dt_legacy_opt(row.update_time),
441                file_oss_id: row.file_oss_id,
442                filename: row.file_oss_id.and_then(|id| oss_names.get(&id).cloned()),
443            });
444    }
445    grouped
446}
447
448/// 组装 EpisodeListVo
449fn to_list_vo(ep: &mangaepisodetb::Model, ctx: &WorkflowContext) -> EpisodeListVo {
450    let med = ctx.details.get(&ep.id);
451    EpisodeListVo {
452        id: ep.id,
453        manga_id: ep.manga_id,
454        manga_episode: Some(ep.manga_episode.clone()),
455        manga_episode_name: ep.manga_episode_name.clone(),
456        episode_type: if ep.episode_type == EpisodeType::Extra.as_str() {
457            EpisodeType::Extra
458        } else {
459            EpisodeType::Main
460        },
461        provider_id: ep.provider_id,
462        translator_id: ep.translator_id,
463        proofreader_id: ep.proofreader_id,
464        letterer_id: ep.letterer_id,
465        timer_id: ep.timer_id,
466        reviewer_id: ep.reviewer_id,
467        setup_time: dt_legacy(ep.setup_time),
468        update_time: dt_legacy(ep.update_time),
469        translator_file: ep.translator_file.clone(),
470        proofreader_file: ep.proofreader_file.clone(),
471        timer_file: ep.timer_file.clone(),
472        publish_link: ep.publish_link.clone(),
473        provider_file_oss_id: ep.provider_file_oss_id,
474        editor_page_count: ctx.editor_page_counts.get(&ep.id).copied().unwrap_or(0),
475        translator_file_oss_id: ep.translator_file_oss_id,
476        proofreader_file_oss_id: ep.proofreader_file_oss_id,
477        letterer_file_oss_id: ep.letterer_file_oss_id,
478        timer_file_oss_id: ep.timer_file_oss_id,
479        translator_setup_time: med.and_then(|d| dt_legacy_opt(d.translator_setup_time)),
480        translator_update_time: med.and_then(|d| dt_legacy_opt(d.translator_update_time)),
481        proofreader_setup_time: med.and_then(|d| dt_legacy_opt(d.proofreader_setup_time)),
482        proofreader_update_time: med.and_then(|d| dt_legacy_opt(d.proofreader_update_time)),
483        letterer_setup_time: med.and_then(|d| dt_legacy_opt(d.letterer_setup_time)),
484        letterer_update_time: med.and_then(|d| dt_legacy_opt(d.letterer_update_time)),
485        timer_setup_time: med.and_then(|d| dt_legacy_opt(d.timer_setup_time)),
486        timer_update_time: med.and_then(|d| dt_legacy_opt(d.timer_update_time)),
487        reviewer_setup_time: med.and_then(|d| dt_legacy_opt(d.reviewer_setup_time)),
488        reviewer_update_time: med.and_then(|d| dt_legacy_opt(d.reviewer_update_time)),
489        provider_name: ep.provider_id.and_then(|id| ctx.members.get(&id).cloned()),
490        translator_name: ep
491            .translator_id
492            .and_then(|id| ctx.members.get(&id).cloned()),
493        proofreader_name: ep
494            .proofreader_id
495            .and_then(|id| ctx.members.get(&id).cloned()),
496        letterer_name: ep.letterer_id.and_then(|id| ctx.members.get(&id).cloned()),
497        timer_name: ep.timer_id.and_then(|id| ctx.members.get(&id).cloned()),
498        reviewer_name: ep.reviewer_id.and_then(|id| ctx.members.get(&id).cloned()),
499        provider_filename: ep
500            .provider_file_oss_id
501            .and_then(|id| ctx.oss_names.get(&id).cloned()),
502        translator_filename: ep
503            .translator_file_oss_id
504            .and_then(|id| ctx.oss_names.get(&id).cloned()),
505        proofreader_filename: ep
506            .proofreader_file_oss_id
507            .and_then(|id| ctx.oss_names.get(&id).cloned()),
508        letterer_filename: ep
509            .letterer_file_oss_id
510            .and_then(|id| ctx.oss_names.get(&id).cloned()),
511        timer_filename: ep
512            .timer_file_oss_id
513            .and_then(|id| ctx.oss_names.get(&id).cloned()),
514        translator_co_takers: ctx
515            .role_takers
516            .get(&(ep.id, "translator".to_string()))
517            .cloned()
518            .unwrap_or_default(),
519        proofreader_co_takers: ctx
520            .role_takers
521            .get(&(ep.id, "proofreader".to_string()))
522            .cloned()
523            .unwrap_or_default(),
524    }
525}
526
527/// 组装 EpisodeSimpleListVo
528fn to_simple_list_vo(ep: &mangaepisodetb::Model, ctx: &WorkflowContext) -> EpisodeSimpleListVo {
529    let full = to_list_vo(ep, ctx);
530    EpisodeSimpleListVo {
531        id: full.id,
532        manga_episode: full.manga_episode,
533        manga_episode_name: full.manga_episode_name,
534        episode_type: full.episode_type,
535        translator_file: full.translator_file,
536        proofreader_file: full.proofreader_file,
537        timer_file: full.timer_file,
538        provider_file_oss_id: full.provider_file_oss_id,
539        editor_page_count: full.editor_page_count,
540        translator_file_oss_id: full.translator_file_oss_id,
541        proofreader_file_oss_id: full.proofreader_file_oss_id,
542        letterer_file_oss_id: full.letterer_file_oss_id,
543        timer_file_oss_id: full.timer_file_oss_id,
544        publish_link: full.publish_link,
545    }
546}
547
548/// 组装 EpisodeDetailVo
549fn to_detail_vo(ep: &mangaepisodetb::Model, ctx: &WorkflowContext) -> EpisodeDetailVo {
550    let full = to_list_vo(ep, ctx);
551    EpisodeDetailVo {
552        id: full.id,
553        manga_id: full.manga_id,
554        manga_episode: full.manga_episode,
555        manga_episode_name: full.manga_episode_name,
556        episode_type: full.episode_type,
557        provider_id: full.provider_id,
558        translator_id: full.translator_id,
559        proofreader_id: full.proofreader_id,
560        letterer_id: full.letterer_id,
561        timer_id: full.timer_id,
562        reviewer_id: full.reviewer_id,
563        setup_time: full.setup_time,
564        update_time: full.update_time,
565        publish_link: full.publish_link,
566    }
567}
568
569/// 组装 UploadPageVo
570fn to_upload_vo(ep: &mangaepisodetb::Model, ctx: &UploadContext) -> UploadPageVo {
571    let w = &ctx.workflow;
572    UploadPageVo {
573        id: ep.id,
574        manga_id: ep.manga_id,
575        manga_name: ctx.manga_names.get(&ep.manga_id).cloned(),
576        manga_episode: Some(ep.manga_episode.clone()),
577        update_time: dt_legacy(ep.update_time),
578        translator_file: ep.translator_file.clone(),
579        proofreader_file: ep.proofreader_file.clone(),
580        timer_file: ep.timer_file.clone(),
581        provider_id: ep.provider_id,
582        translator_id: ep.translator_id,
583        proofreader_id: ep.proofreader_id,
584        letterer_id: ep.letterer_id,
585        timer_id: ep.timer_id,
586        reviewer_id: ep.reviewer_id,
587        provider_name: None,
588        translator_name: ep.translator_id.and_then(|id| w.members.get(&id).cloned()),
589        proofreader_name: ep.proofreader_id.and_then(|id| w.members.get(&id).cloned()),
590        letterer_name: ep.letterer_id.and_then(|id| w.members.get(&id).cloned()),
591        timer_name: ep.timer_id.and_then(|id| w.members.get(&id).cloned()),
592        reviewer_name: ep.reviewer_id.and_then(|id| w.members.get(&id).cloned()),
593        provider_file_oss_id: ep.provider_file_oss_id,
594        translator_file_oss_id: ep.translator_file_oss_id,
595        proofreader_file_oss_id: ep.proofreader_file_oss_id,
596        letterer_file_oss_id: ep.letterer_file_oss_id,
597        timer_file_oss_id: ep.timer_file_oss_id,
598        provider_filename: ep
599            .provider_file_oss_id
600            .and_then(|id| w.oss_names.get(&id).cloned()),
601        translator_filename: ep
602            .translator_file_oss_id
603            .and_then(|id| w.oss_names.get(&id).cloned()),
604        proofreader_filename: ep
605            .proofreader_file_oss_id
606            .and_then(|id| w.oss_names.get(&id).cloned()),
607        letterer_filename: ep
608            .letterer_file_oss_id
609            .and_then(|id| w.oss_names.get(&id).cloned()),
610        timer_filename: ep
611            .timer_file_oss_id
612            .and_then(|id| w.oss_names.get(&id).cloned()),
613        email: ep
614            .translator_id
615            .and_then(|id| w.member_emails.get(&id).cloned()),
616        email_now: ep
617            .proofreader_id
618            .and_then(|id| w.member_emails.get(&id).cloned()),
619    }
620}
621
622/// naive 时间转 API UTC
623fn dt_legacy(n: chrono::NaiveDateTime) -> Option<DateTime<Utc>> {
624    Some(crate::utils::legacy_time::naive_db_to_api_utc(n))
625}
626
627/// 可空 naive 时间转 API UTC
628fn dt_legacy_opt(n: Option<chrono::NaiveDateTime>) -> Option<DateTime<Utc>> {
629    n.map(crate::utils::legacy_time::naive_db_to_api_utc)
630}