Skip to main content

tdm_server_rust/repository/
manga_list_assemble.rs

1//! 漫画列表/卡片/RSS/收藏组装(SeaORM 多表批量加载,对齐 MangaMapper 联查)
2
3use crate::entity::episode::EpisodeType;
4use crate::entity::manga::{CollectedMembersVo, GlossaryVo, MangaCardVo, MangaDetailVo};
5use crate::entity::rss::{EpisodeRssRow, RssMangaRow, WorkReminderRssRow, RSS_MAX_ITEMS};
6use crate::sea_entity::{
7    authortb, glossary, magazine, mangaauthor, mangaepisodedetail, mangaepisodetb, mangamagazine,
8    mangatb, membertb, workreminder,
9};
10use chrono::{DateTime, Utc};
11use sea_orm::{
12    ColumnTrait, Condition, ConnectionTrait, DatabaseConnection, DbBackend, EntityTrait,
13    PaginatorTrait, QueryFilter, QueryOrder, QuerySelect, Statement, Value,
14};
15use std::collections::HashMap;
16
17/// 漫画列表排序字段
18#[derive(Debug, Clone, Copy, PartialEq, Eq)]
19pub enum MangaListSortField {
20    /// 最近更新时间
21    UpdateTime,
22    /// 添加/开坑时间
23    SetupTime,
24}
25
26/// 漫画列表排序方向
27#[derive(Debug, Clone, Copy, PartialEq, Eq)]
28pub enum MangaListSortOrder {
29    /// 升序
30    Asc,
31    /// 降序
32    Desc,
33}
34
35/// 漫画列表排序配置
36#[derive(Debug, Clone, Copy, PartialEq, Eq)]
37pub struct MangaListSort {
38    field: MangaListSortField,
39    order: MangaListSortOrder,
40}
41
42impl Default for MangaListSort {
43    fn default() -> Self {
44        Self {
45            field: MangaListSortField::UpdateTime,
46            order: MangaListSortOrder::Desc,
47        }
48    }
49}
50
51impl MangaListSort {
52    /// 从前端查询参数构造排序;未知字段/方向回退到更新时间倒序。
53    pub fn from_query(sort_prop: Option<&str>, sort_order: Option<&str>) -> Self {
54        let field = match sort_prop.map(normalize_sort_token).as_deref() {
55            Some(
56                "setuptime" | "createtime" | "createdat" | "addtime" | "addedtime" | "created"
57                | "添加时间",
58            ) => MangaListSortField::SetupTime,
59            Some("updatetime" | "updatedat" | "updated" | "更新时间") | None => {
60                MangaListSortField::UpdateTime
61            }
62            Some(_) => MangaListSortField::UpdateTime,
63        };
64        let order = match sort_order.map(normalize_sort_token).as_deref() {
65            Some("ascending" | "asc") => MangaListSortOrder::Asc,
66            Some("descending" | "desc") | None => MangaListSortOrder::Desc,
67            Some(_) => MangaListSortOrder::Desc,
68        };
69        Self { field, order }
70    }
71
72    /// 规范化后的缓存字段名。
73    pub fn cache_field(self) -> &'static str {
74        match self.field {
75            MangaListSortField::UpdateTime => "updateTime",
76            MangaListSortField::SetupTime => "setupTime",
77        }
78    }
79
80    /// 规范化后的缓存方向名。
81    pub fn cache_order(self) -> &'static str {
82        match self.order {
83            MangaListSortOrder::Asc => "ascending",
84            MangaListSortOrder::Desc => "descending",
85        }
86    }
87}
88
89fn normalize_sort_token(value: &str) -> String {
90    value
91        .trim()
92        .replace(['_', '-', ' '], "")
93        .to_ascii_lowercase()
94}
95
96/// 列表关联数据缓存
97struct MangaListContext {
98    /// mangaId -> (type1 authorId, authorName)
99    author1: HashMap<i32, (i32, String)>,
100    /// mangaId -> (type2 authorId, authorName)
101    author2: HashMap<i32, (i32, String)>,
102    /// mangaId -> (magazineId, magazineName)
103    magazines: HashMap<i32, (i32, String)>,
104    /// mangaId -> 最新话详情
105    latest_details: HashMap<i32, mangaepisodedetail::Model>,
106}
107
108/// 统计符合条件的漫画总数
109pub async fn count_list(
110    db: &DatabaseConnection,
111    manga_tran_name: Option<&str>,
112    manga_ori_name: Option<&str>,
113    category: Option<i16>,
114    manga_status: Option<i16>,
115    author_name: Option<&str>,
116    magazine_name: Option<&str>,
117) -> crate::error::ApiResult<i64> {
118    let filter = list_filter(
119        db,
120        manga_tran_name,
121        manga_ori_name,
122        category,
123        manga_status,
124        author_name,
125        magazine_name,
126    )
127    .await?;
128    let n = mangatb::Entity::find().filter(filter).count(db).await?;
129    Ok(n as i64)
130}
131
132/// 条件分页查询漫画卡片
133pub async fn list_page(
134    db: &DatabaseConnection,
135    manga_tran_name: Option<&str>,
136    manga_ori_name: Option<&str>,
137    category: Option<i16>,
138    manga_status: Option<i16>,
139    author_name: Option<&str>,
140    magazine_name: Option<&str>,
141    limit: i32,
142    offset: i32,
143) -> crate::error::ApiResult<Vec<MangaCardVo>> {
144    list_page_sorted(
145        db,
146        manga_tran_name,
147        manga_ori_name,
148        category,
149        manga_status,
150        author_name,
151        magazine_name,
152        MangaListSort::default(),
153        limit,
154        offset,
155    )
156    .await
157}
158
159/// 条件分页查询漫画卡片(支持后端排序,避免前端只排当前页)
160pub async fn list_page_sorted(
161    db: &DatabaseConnection,
162    manga_tran_name: Option<&str>,
163    manga_ori_name: Option<&str>,
164    category: Option<i16>,
165    manga_status: Option<i16>,
166    author_name: Option<&str>,
167    magazine_name: Option<&str>,
168    sort: MangaListSort,
169    limit: i32,
170    offset: i32,
171) -> crate::error::ApiResult<Vec<MangaCardVo>> {
172    let filter = list_filter(
173        db,
174        manga_tran_name,
175        manga_ori_name,
176        category,
177        manga_status,
178        author_name,
179        magazine_name,
180    )
181    .await?;
182    let query = mangatb::Entity::find().filter(filter);
183    let query = match (sort.field, sort.order) {
184        (MangaListSortField::UpdateTime, MangaListSortOrder::Desc) => query
185            .order_by_desc(mangatb::Column::UpdateTime)
186            .order_by_desc(mangatb::Column::Id),
187        (MangaListSortField::UpdateTime, MangaListSortOrder::Asc) => query
188            .order_by_asc(mangatb::Column::UpdateTime)
189            .order_by_asc(mangatb::Column::Id),
190        (MangaListSortField::SetupTime, MangaListSortOrder::Desc) => query
191            .order_by_desc(mangatb::Column::SetupTime)
192            .order_by_desc(mangatb::Column::Id),
193        (MangaListSortField::SetupTime, MangaListSortOrder::Asc) => query
194            .order_by_asc(mangatb::Column::SetupTime)
195            .order_by_asc(mangatb::Column::Id),
196    };
197    let rows = query
198        .limit(limit.max(0) as u64)
199        .offset(offset.max(0) as u64)
200        .all(db)
201        .await?;
202    assemble_manga_cards(db, &rows, true).await
203}
204
205/// 按作者 ID 查询漫画卡片
206pub async fn list_by_author(
207    db: &DatabaseConnection,
208    author_id: i32,
209) -> crate::error::ApiResult<Vec<MangaCardVo>> {
210    let (_, cards) = list_by_author_page(db, author_id, 1, i32::MAX).await?;
211    Ok(cards)
212}
213
214/// 按作者 ID SQL 真分页(COUNT 与当前页并行)
215pub async fn list_by_author_page(
216    db: &DatabaseConnection,
217    author_id: i32,
218    page: i32,
219    page_size: i32,
220) -> crate::error::ApiResult<(i64, Vec<MangaCardVo>)> {
221    let page = page.max(1);
222    let page_size = page_size.max(1);
223    let offset = ((page - 1) * page_size) as i64;
224    let (total, rows) = tokio::try_join!(
225        count_by_author(db, author_id),
226        fetch_by_author_page(db, author_id, page_size, offset),
227    )?;
228    if rows.is_empty() {
229        return Ok((total, vec![]));
230    }
231    let cards = assemble_manga_cards(db, &rows, true).await?;
232    Ok((total, cards))
233}
234
235/// 统计作者关联漫画总数
236async fn count_by_author(db: &DatabaseConnection, author_id: i32) -> crate::error::ApiResult<i64> {
237    let sql = r#"SELECT COUNT(DISTINCT m."Id")::bigint AS cnt
238        FROM mangaauthor ma
239        INNER JOIN mangatb m ON m."Id" = ma."mangaId"
240        WHERE ma."authorId" = $1"#;
241    let row = db
242        .query_one(Statement::from_sql_and_values(
243            DbBackend::Postgres,
244            sql,
245            [Value::from(author_id)],
246        ))
247        .await?
248        .ok_or_else(|| crate::error::AppError::business("统计作者漫画失败喵"))?;
249    Ok(row.try_get("", "cnt")?)
250}
251
252/// 按 updateTime 倒序拉取作者漫画当前页
253async fn fetch_by_author_page(
254    db: &DatabaseConnection,
255    author_id: i32,
256    page_size: i32,
257    offset: i64,
258) -> crate::error::ApiResult<Vec<mangatb::Model>> {
259    let sql = r#"SELECT DISTINCT m."Id", m."mangaTranName", m."mangaOriName", m.image, m.category,
260        m."mangaStatus", m."setupTime", m."updateTime", m.link, m.introduction
261        FROM mangaauthor ma
262        INNER JOIN mangatb m ON m."Id" = ma."mangaId"
263        WHERE ma."authorId" = $1
264        ORDER BY m."updateTime" DESC
265        LIMIT $2 OFFSET $3"#;
266    let rows = db
267        .query_all(Statement::from_sql_and_values(
268            DbBackend::Postgres,
269            sql,
270            [
271                Value::from(author_id),
272                Value::from(page_size),
273                Value::from(offset),
274            ],
275        ))
276        .await?;
277    rows.into_iter()
278        .map(row_to_mangatb_model)
279        .collect::<crate::error::ApiResult<Vec<_>>>()
280}
281
282/// 按杂志 ID 查询漫画卡片
283pub async fn list_by_magazine(
284    db: &DatabaseConnection,
285    magazine_id: i32,
286) -> crate::error::ApiResult<Vec<MangaCardVo>> {
287    let (_, cards) = list_by_magazine_page(db, magazine_id, 1, i32::MAX).await?;
288    Ok(cards)
289}
290
291/// 按杂志 ID SQL 真分页(COUNT 与当前页并行)
292pub async fn list_by_magazine_page(
293    db: &DatabaseConnection,
294    magazine_id: i32,
295    page: i32,
296    page_size: i32,
297) -> crate::error::ApiResult<(i64, Vec<MangaCardVo>)> {
298    let page = page.max(1);
299    let page_size = page_size.max(1);
300    let offset = ((page - 1) * page_size) as i64;
301    let (total, rows) = tokio::try_join!(
302        count_by_magazine(db, magazine_id),
303        fetch_by_magazine_page(db, magazine_id, page_size, offset),
304    )?;
305    if rows.is_empty() {
306        return Ok((total, vec![]));
307    }
308    let cards = assemble_manga_cards(db, &rows, true).await?;
309    Ok((total, cards))
310}
311
312/// 统计杂志关联漫画总数
313async fn count_by_magazine(
314    db: &DatabaseConnection,
315    magazine_id: i32,
316) -> crate::error::ApiResult<i64> {
317    let sql = r#"SELECT COUNT(DISTINCT m."Id")::bigint AS cnt
318        FROM mangamagazine mm
319        INNER JOIN mangatb m ON m."Id" = mm."mangaId"
320        WHERE mm."magazineId" = $1 AND mm."mangaId" IS NOT NULL"#;
321    let row = db
322        .query_one(Statement::from_sql_and_values(
323            DbBackend::Postgres,
324            sql,
325            [Value::from(magazine_id)],
326        ))
327        .await?
328        .ok_or_else(|| crate::error::AppError::business("统计杂志漫画失败喵"))?;
329    Ok(row.try_get("", "cnt")?)
330}
331
332/// 按 updateTime 倒序拉取杂志漫画当前页
333async fn fetch_by_magazine_page(
334    db: &DatabaseConnection,
335    magazine_id: i32,
336    page_size: i32,
337    offset: i64,
338) -> crate::error::ApiResult<Vec<mangatb::Model>> {
339    let sql = r#"SELECT DISTINCT m."Id", m."mangaTranName", m."mangaOriName", m.image, m.category,
340        m."mangaStatus", m."setupTime", m."updateTime", m.link, m.introduction
341        FROM mangamagazine mm
342        INNER JOIN mangatb m ON m."Id" = mm."mangaId"
343        WHERE mm."magazineId" = $1 AND mm."mangaId" IS NOT NULL
344        ORDER BY m."updateTime" DESC
345        LIMIT $2 OFFSET $3"#;
346    let rows = db
347        .query_all(Statement::from_sql_and_values(
348            DbBackend::Postgres,
349            sql,
350            [
351                Value::from(magazine_id),
352                Value::from(page_size),
353                Value::from(offset),
354            ],
355        ))
356        .await?;
357    rows.into_iter()
358        .map(row_to_mangatb_model)
359        .collect::<crate::error::ApiResult<Vec<_>>>()
360}
361
362/// 按 ID 查询漫画卡片
363pub async fn get_manga_card_by_id(
364    db: &DatabaseConnection,
365    id: i32,
366) -> crate::error::ApiResult<Option<MangaCardVo>> {
367    let row = mangatb::Entity::find_by_id(id).one(db).await?;
368    let Some(m) = row else {
369        return Ok(None);
370    };
371    let cards = assemble_manga_cards(db, std::slice::from_ref(&m), true).await?;
372    Ok(cards.into_iter().next())
373}
374
375/// 按 ID 查询漫画详情
376pub async fn get_manga_detail_by_id(
377    db: &DatabaseConnection,
378    id: i32,
379) -> crate::error::ApiResult<Option<MangaDetailVo>> {
380    let row = mangatb::Entity::find_by_id(id).one(db).await?;
381    let Some(m) = row else {
382        return Ok(None);
383    };
384    let ctx = load_list_context(db, std::slice::from_ref(&m), true, false).await?;
385    Ok(Some(to_manga_detail(&m, &ctx)))
386}
387
388/// 按 ID 查询漫画响应
389pub async fn get_manga_response_by_id(
390    db: &DatabaseConnection,
391    id: i32,
392) -> crate::error::ApiResult<Option<crate::entity::manga::MangaResponse>> {
393    let row = mangatb::Entity::find_by_id(id).one(db).await?;
394    let Some(m) = row else {
395        return Ok(None);
396    };
397    let ctx = load_list_context(db, std::slice::from_ref(&m), true, true).await?;
398    Ok(Some(to_manga_response(&m, &ctx)))
399}
400
401/// 批量按 ID 查询漫画响应
402pub async fn get_manga_responses_by_ids(
403    db: &DatabaseConnection,
404    ids: &[i32],
405) -> crate::error::ApiResult<HashMap<i32, crate::entity::manga::MangaResponse>> {
406    if ids.is_empty() {
407        return Ok(HashMap::new());
408    }
409    let rows = mangatb::Entity::find()
410        .filter(mangatb::Column::Id.is_in(ids.to_vec()))
411        .all(db)
412        .await?;
413    let ctx = load_list_context(db, &rows, true, true).await?;
414    Ok(rows
415        .into_iter()
416        .map(|m| (m.id, to_manga_response(&m, &ctx)))
417        .collect())
418}
419
420/// 组员收藏列表(全量,供 parity 对照)
421pub async fn get_collect_list(
422    db: &DatabaseConnection,
423    member_id: i32,
424) -> crate::error::ApiResult<Vec<MangaCardVo>> {
425    let (_, cards) = get_collect_list_page(db, member_id, 1, i32::MAX).await?;
426    Ok(cards)
427}
428
429/// 组员收藏列表 SQL 真分页
430///
431/// COUNT 与当前页漫画行并行查询,仅对当前页调用 `assemble_manga_cards`。
432pub async fn get_collect_list_page(
433    db: &DatabaseConnection,
434    member_id: i32,
435    page: i32,
436    page_size: i32,
437) -> crate::error::ApiResult<(i64, Vec<MangaCardVo>)> {
438    let page = page.max(1);
439    let page_size = page_size.max(1);
440    let offset = ((page - 1) * page_size) as i64;
441    let (total, rows) = tokio::try_join!(
442        count_collect_list(db, member_id),
443        fetch_collect_list_page(db, member_id, page_size, offset),
444    )?;
445    if rows.is_empty() {
446        return Ok((total, vec![]));
447    }
448    let cards = assemble_manga_cards(db, &rows, false).await?;
449    Ok((total, cards))
450}
451
452/// 统计组员收藏漫画总数
453async fn count_collect_list(
454    db: &DatabaseConnection,
455    member_id: i32,
456) -> crate::error::ApiResult<i64> {
457    let sql = r#"SELECT COUNT(*)::bigint AS cnt
458        FROM mangacollect c
459        INNER JOIN mangatb m ON m."Id" = c."mangaId"
460        WHERE c."memberId" = $1"#;
461    let row = db
462        .query_one(Statement::from_sql_and_values(
463            DbBackend::Postgres,
464            sql,
465            [Value::from(member_id)],
466        ))
467        .await?
468        .ok_or_else(|| crate::error::AppError::business("统计收藏失败喵"))?;
469    Ok(row.try_get("", "cnt")?)
470}
471
472/// 按 updateTime 倒序拉取收藏漫画当前页行
473async fn fetch_collect_list_page(
474    db: &DatabaseConnection,
475    member_id: i32,
476    page_size: i32,
477    offset: i64,
478) -> crate::error::ApiResult<Vec<mangatb::Model>> {
479    let sql = r#"SELECT m."Id", m."mangaTranName", m."mangaOriName", m.image, m.category,
480        m."mangaStatus", m."setupTime", m."updateTime", m.link, m.introduction
481        FROM mangacollect c
482        INNER JOIN mangatb m ON m."Id" = c."mangaId"
483        WHERE c."memberId" = $1
484        ORDER BY m."updateTime" DESC
485        LIMIT $2 OFFSET $3"#;
486    let rows = db
487        .query_all(Statement::from_sql_and_values(
488            DbBackend::Postgres,
489            sql,
490            [
491                Value::from(member_id),
492                Value::from(page_size),
493                Value::from(offset),
494            ],
495        ))
496        .await?;
497    rows.into_iter().map(row_to_mangatb_model).collect()
498}
499
500/// 将收藏分页查询行映射为 mangatb::Model
501fn row_to_mangatb_model(row: sea_orm::QueryResult) -> crate::error::ApiResult<mangatb::Model> {
502    Ok(mangatb::Model {
503        id: row.try_get("", "Id")?,
504        manga_tran_name: row.try_get("", "mangaTranName")?,
505        manga_ori_name: row.try_get("", "mangaOriName")?,
506        image: row.try_get("", "image")?,
507        category: row.try_get("", "category")?,
508        manga_status: row.try_get("", "mangaStatus")?,
509        setup_time: row.try_get("", "setupTime")?,
510        update_time: row.try_get("", "updateTime")?,
511        link: row.try_get("", "link").ok(),
512        introduction: row.try_get("", "introduction").ok(),
513    })
514}
515
516/// 查询收藏该漫画的组员
517pub async fn get_collected_members(
518    db: &DatabaseConnection,
519    manga_id: i32,
520) -> crate::error::ApiResult<Vec<CollectedMembersVo>> {
521    let sql = r#"SELECT m."Id" AS id,
522            m.username,
523            m.intern,
524            m."registrationTime" AS registration_time,
525            m."lastSubmitTime" AS last_submit_time,
526            COUNT(ms."Id")::int AS station_count,
527            MIN(c."Id") AS first_collect_id
528        FROM mangacollect c
529        INNER JOIN membertb m ON m."Id" = c."memberId"
530        LEFT JOIN mangastationed ms ON ms."memberId" = m."Id"
531        WHERE c."mangaId" = $1
532        GROUP BY m."Id", m.username, m.intern, m."registrationTime", m."lastSubmitTime"
533        ORDER BY first_collect_id"#;
534    let rows = db
535        .query_all(Statement::from_sql_and_values(
536            DbBackend::Postgres,
537            sql,
538            [Value::from(manga_id)],
539        ))
540        .await?;
541    rows.into_iter()
542        .map(|row| {
543            let id: i32 = row.try_get("", "id")?;
544            let username: String = row.try_get("", "username")?;
545            let intern: i32 = row.try_get("", "intern")?;
546            let registration_time: chrono::NaiveDateTime = row.try_get("", "registration_time")?;
547            let last_submit_time: chrono::NaiveDateTime = row.try_get("", "last_submit_time")?;
548            let station_count: i32 = row.try_get("", "station_count")?;
549            Ok(CollectedMembersVo {
550                id,
551                username: Some(username),
552                intern: intern as i16,
553                posts: vec![],
554                station_count: (station_count > 0).then_some(station_count),
555                registration_time: dt_legacy(registration_time),
556                last_submit_time: dt_legacy(last_submit_time),
557            })
558        })
559        .collect()
560}
561
562/// 最新开坑漫画 RSS
563pub async fn get_manga_rss(db: &DatabaseConnection) -> crate::error::ApiResult<Vec<RssMangaRow>> {
564    let rows = mangatb::Entity::find()
565        .order_by_desc(mangatb::Column::SetupTime)
566        .limit(RSS_MAX_ITEMS)
567        .all(db)
568        .await?;
569    assemble_rss_manga(db, &rows, false).await
570}
571
572/// 最近更新漫画 RSS
573pub async fn get_manga_updated_rss(
574    db: &DatabaseConnection,
575) -> crate::error::ApiResult<Vec<RssMangaRow>> {
576    let rows = mangatb::Entity::find()
577        .order_by_desc(mangatb::Column::UpdateTime)
578        .limit(RSS_MAX_ITEMS)
579        .all(db)
580        .await?;
581    assemble_rss_manga(db, &rows, true).await
582}
583
584/// 将数据库话数分类转换为 RSS 强类型;未知旧值按正篇兼容处理。
585fn rss_episode_type(value: &str) -> EpisodeType {
586    if value == EpisodeType::Extra.as_str() {
587        EpisodeType::Extra
588    } else {
589        EpisodeType::Main
590    }
591}
592
593/// 话数 RSS
594pub async fn get_episode_rss(
595    db: &DatabaseConnection,
596) -> crate::error::ApiResult<Vec<EpisodeRssRow>> {
597    let episodes = mangaepisodetb::Entity::find()
598        .order_by_desc(mangaepisodetb::Column::Id)
599        .limit(RSS_MAX_ITEMS)
600        .all(db)
601        .await?;
602    if episodes.is_empty() {
603        return Ok(vec![]);
604    }
605    let manga_ids: Vec<i32> = episodes.iter().map(|e| e.manga_id).collect();
606    let mangas: HashMap<i32, mangatb::Model> = mangatb::Entity::find()
607        .filter(mangatb::Column::Id.is_in(manga_ids))
608        .all(db)
609        .await?
610        .into_iter()
611        .map(|m| (m.id, m))
612        .collect();
613    let mut member_ids = Vec::new();
614    for ep in &episodes {
615        if let Some(id) = ep.provider_id {
616            member_ids.push(id);
617        }
618        if let Some(id) = ep.translator_id {
619            member_ids.push(id);
620        }
621    }
622    member_ids.sort_unstable();
623    member_ids.dedup();
624    let members: HashMap<i32, membertb::Model> = if member_ids.is_empty() {
625        HashMap::new()
626    } else {
627        membertb::Entity::find()
628            .filter(membertb::Column::Id.is_in(member_ids))
629            .all(db)
630            .await?
631            .into_iter()
632            .map(|m| (m.id, m))
633            .collect()
634    };
635    Ok(episodes
636        .iter()
637        .map(|ep| {
638            let manga = mangas.get(&ep.manga_id);
639            let provider = ep.provider_id.and_then(|id| members.get(&id));
640            let translator = ep.translator_id.and_then(|id| members.get(&id));
641            EpisodeRssRow {
642                id: ep.id,
643                manga_id: ep.manga_id,
644                manga_episode: Some(ep.manga_episode.clone()),
645                manga_episode_name: ep.manga_episode_name.clone(),
646                episode_type: rss_episode_type(&ep.episode_type),
647                setup_time: dt_legacy(ep.setup_time),
648                manga_name: manga.map(|m| m.manga_tran_name.clone()),
649                manga_ori_name: manga.map(|m| m.manga_ori_name.clone()),
650                category: manga.map(|m| m.category as i16),
651                manga_status: manga.map(|m| m.manga_status as i16),
652                manga_setup_time: manga.and_then(|m| dt_legacy(m.setup_time)),
653                image: manga.map(|m| m.image.clone()),
654                provider_name: provider.map(|m| m.username.clone()),
655                translator_name: translator.map(|m| m.username.clone()),
656                intern: translator.map(|m| m.intern as i16),
657                email: translator.and_then(|m| m.email.clone()),
658                publish_link: ep.publish_link.clone(),
659            }
660        })
661        .collect())
662}
663
664/// 交稿提醒 RSS
665pub async fn get_work_reminder_rss(
666    db: &DatabaseConnection,
667    post_name: &str,
668) -> crate::error::ApiResult<Vec<WorkReminderRssRow>> {
669    let reminders = workreminder::Entity::find()
670        .filter(workreminder::Column::MyName.eq(post_name))
671        .order_by_desc(workreminder::Column::Id)
672        .limit(RSS_MAX_ITEMS)
673        .all(db)
674        .await?;
675    if reminders.is_empty() {
676        return Ok(vec![]);
677    }
678    let ep_ids: Vec<i32> = reminders.iter().filter_map(|r| r.episode_id).collect();
679    let episodes: HashMap<i32, mangaepisodetb::Model> = if ep_ids.is_empty() {
680        HashMap::new()
681    } else {
682        mangaepisodetb::Entity::find()
683            .filter(mangaepisodetb::Column::Id.is_in(ep_ids))
684            .all(db)
685            .await?
686            .into_iter()
687            .map(|e| (e.id, e))
688            .collect()
689    };
690    let manga_ids: Vec<i32> = episodes.values().map(|e| e.manga_id).collect();
691    let mangas: HashMap<i32, mangatb::Model> = if manga_ids.is_empty() {
692        HashMap::new()
693    } else {
694        mangatb::Entity::find()
695            .filter(mangatb::Column::Id.is_in(manga_ids))
696            .all(db)
697            .await?
698            .into_iter()
699            .map(|m| (m.id, m))
700            .collect()
701    };
702    let mut member_ids = Vec::new();
703    for r in &reminders {
704        if let Some(id) = r.member_id {
705            member_ids.push(id);
706        }
707        if let Some(ep_id) = r.episode_id {
708            if let Some(ep) = episodes.get(&ep_id) {
709                if let Some(my_name) = r.my_name.as_deref() {
710                    if let Some(id) = next_post_member_id(ep, my_name) {
711                        member_ids.push(id);
712                    }
713                }
714            }
715        }
716    }
717    member_ids.sort_unstable();
718    member_ids.dedup();
719    let members: HashMap<i32, membertb::Model> = if member_ids.is_empty() {
720        HashMap::new()
721    } else {
722        membertb::Entity::find()
723            .filter(membertb::Column::Id.is_in(member_ids))
724            .all(db)
725            .await?
726            .into_iter()
727            .map(|m| (m.id, m))
728            .collect()
729    };
730    Ok(reminders
731        .into_iter()
732        .filter_map(|r| {
733            let ep_id = r.episode_id?;
734            let ep = episodes.get(&ep_id);
735            let manga = ep.and_then(|e| mangas.get(&e.manga_id));
736            let mem_now = r.member_id.and_then(|id| members.get(&id));
737            let my_name = r.my_name.clone().unwrap_or_default();
738            let next_id = ep.and_then(|e| next_post_member_id(e, &my_name));
739            let mem_next = next_id.and_then(|id| members.get(&id));
740            Some(WorkReminderRssRow {
741                episode_id: ep_id,
742                manga_id: manga.map(|m| m.id).unwrap_or(0),
743                manga_episode: ep.map(|e| e.manga_episode.clone()),
744                manga_episode_name: ep.and_then(|e| e.manga_episode_name.clone()),
745                episode_type: ep
746                    .map(|e| rss_episode_type(&e.episode_type))
747                    .unwrap_or_default(),
748                setup_time: ep.and_then(|e| dt_legacy(e.setup_time)),
749                manga_name: manga.map(|m| m.manga_tran_name.clone()),
750                manga_ori_name: manga.map(|m| m.manga_ori_name.clone()),
751                category: manga.map(|m| m.category as i16),
752                manga_status: manga.map(|m| m.manga_status as i16),
753                manga_setup_time: manga.and_then(|m| dt_legacy(m.setup_time)),
754                image: manga.map(|m| m.image.clone()),
755                my_name,
756                username_now: mem_now.map(|m| m.username.clone()),
757                intern_now: mem_now.map(|m| m.intern as i16),
758                email_now: mem_now.and_then(|m| m.email.clone()),
759                username: mem_next.map(|m| m.username.clone()),
760                intern: mem_next.map(|m| m.intern as i16),
761                email: mem_next.and_then(|m| m.email.clone()),
762            })
763        })
764        .collect())
765}
766
767/// 术语行映射(含更新人名)
768pub fn glossary_to_vo(g: glossary::Model, update_member_name: Option<String>) -> GlossaryVo {
769    GlossaryVo {
770        id: g.id,
771        title: g.title,
772        content: g.content,
773        image: g.image,
774        r#type: g.r#type.map(|v| v as i16),
775        manga_id: g.manga_id.map(|v| v as i16),
776        first: g.first,
777        update_time: dt_legacy_opt(g.update_time),
778        update_by: g.update_by,
779        update_member_name,
780    }
781}
782
783/// 构建列表筛选条件
784async fn list_filter(
785    db: &DatabaseConnection,
786    manga_tran_name: Option<&str>,
787    manga_ori_name: Option<&str>,
788    category: Option<i16>,
789    manga_status: Option<i16>,
790    author_name: Option<&str>,
791    magazine_name: Option<&str>,
792) -> crate::error::ApiResult<Condition> {
793    let mut cond = Condition::all();
794    if let Some(v) = manga_tran_name {
795        cond = cond.add(mangatb::Column::MangaTranName.like(format!("%{v}%")));
796    }
797    if let Some(v) = manga_ori_name {
798        cond = cond.add(mangatb::Column::MangaOriName.eq(v));
799    }
800    if let Some(v) = category {
801        cond = cond.add(mangatb::Column::Category.eq(v as i32));
802    }
803    if let Some(v) = manga_status {
804        cond = cond.add(mangatb::Column::MangaStatus.eq(v as i32));
805    }
806    if let Some(name) = author_name {
807        let authors = authortb::Entity::find()
808            .filter(authortb::Column::AuthorName.eq(name))
809            .all(db)
810            .await?;
811        if authors.is_empty() {
812            return Ok(Condition::all().add(mangatb::Column::Id.eq(-1)));
813        }
814        let author_ids: Vec<i32> = authors.iter().map(|a| a.id).collect();
815        let links = mangaauthor::Entity::find()
816            .filter(mangaauthor::Column::AuthorId.is_in(author_ids))
817            .all(db)
818            .await?;
819        if links.is_empty() {
820            return Ok(Condition::all().add(mangatb::Column::Id.eq(-1)));
821        }
822        let manga_ids: Vec<i32> = links.iter().map(|l| l.manga_id).collect();
823        cond = cond.add(mangatb::Column::Id.is_in(manga_ids));
824    }
825    if let Some(name) = magazine_name {
826        let mags = magazine::Entity::find()
827            .filter(magazine::Column::MagazineName.eq(name))
828            .all(db)
829            .await?;
830        if mags.is_empty() {
831            return Ok(Condition::all().add(mangatb::Column::Id.eq(-1)));
832        }
833        let mag_ids: Vec<i32> = mags.iter().map(|m| m.id).collect();
834        let links = mangamagazine::Entity::find()
835            .filter(mangamagazine::Column::MagazineId.is_in(mag_ids))
836            .all(db)
837            .await?;
838        if links.is_empty() {
839            return Ok(Condition::all().add(mangatb::Column::Id.eq(-1)));
840        }
841        let manga_ids: Vec<i32> = links.iter().filter_map(|l| l.manga_id).collect();
842        cond = cond.add(mangatb::Column::Id.is_in(manga_ids));
843    }
844    Ok(cond)
845}
846
847/// 批量组装漫画卡片
848async fn assemble_manga_cards(
849    db: &DatabaseConnection,
850    mangas: &[mangatb::Model],
851    with_magazine: bool,
852) -> crate::error::ApiResult<Vec<MangaCardVo>> {
853    if mangas.is_empty() {
854        return Ok(vec![]);
855    }
856    let ctx = load_list_context(db, mangas, with_magazine, true).await?;
857    Ok(mangas.iter().map(|m| to_manga_card(m, &ctx)).collect())
858}
859
860/// 批量组装 RSS 漫画行
861async fn assemble_rss_manga(
862    db: &DatabaseConnection,
863    mangas: &[mangatb::Model],
864    include_update_time: bool,
865) -> crate::error::ApiResult<Vec<RssMangaRow>> {
866    if mangas.is_empty() {
867        return Ok(vec![]);
868    }
869    let ctx = load_list_context(db, mangas, false, false).await?;
870    Ok(mangas
871        .iter()
872        .map(|m| to_rss_manga(m, &ctx, include_update_time))
873        .collect())
874}
875
876/// 加载列表关联数据
877async fn load_list_context(
878    db: &DatabaseConnection,
879    mangas: &[mangatb::Model],
880    with_magazine: bool,
881    with_latest_detail: bool,
882) -> crate::error::ApiResult<MangaListContext> {
883    if mangas.is_empty() {
884        return Ok(MangaListContext {
885            author1: HashMap::new(),
886            author2: HashMap::new(),
887            magazines: HashMap::new(),
888            latest_details: HashMap::new(),
889        });
890    }
891    let manga_ids: Vec<i32> = mangas.iter().map(|m| m.id).collect();
892    let author_links = mangaauthor::Entity::find()
893        .filter(mangaauthor::Column::MangaId.is_in(manga_ids.clone()))
894        .all(db)
895        .await?;
896    let author_ids: Vec<i32> = author_links.iter().map(|l| l.author_id).collect();
897    let authors: HashMap<i32, authortb::Model> = if author_ids.is_empty() {
898        HashMap::new()
899    } else {
900        authortb::Entity::find()
901            .filter(authortb::Column::Id.is_in(author_ids))
902            .all(db)
903            .await?
904            .into_iter()
905            .map(|a| (a.id, a))
906            .collect()
907    };
908    let mut author1 = HashMap::new();
909    let mut author2 = HashMap::new();
910    for link in author_links {
911        let Some(author) = authors.get(&link.author_id) else {
912            continue;
913        };
914        let entry = (author.id, author.author_name.clone());
915        if link.r#type == 1 {
916            author1.insert(link.manga_id, entry);
917        } else if link.r#type == 2 {
918            author2.insert(link.manga_id, entry);
919        }
920    }
921    let magazines = if with_magazine {
922        load_magazines(db, &manga_ids).await?
923    } else {
924        HashMap::new()
925    };
926    let latest_details = if with_latest_detail {
927        load_latest_details(db, &manga_ids).await?
928    } else {
929        HashMap::new()
930    };
931    Ok(MangaListContext {
932        author1,
933        author2,
934        magazines,
935        latest_details,
936    })
937}
938
939/// 批量加载杂志关联
940async fn load_magazines(
941    db: &DatabaseConnection,
942    manga_ids: &[i32],
943) -> crate::error::ApiResult<HashMap<i32, (i32, String)>> {
944    let links = mangamagazine::Entity::find()
945        .filter(mangamagazine::Column::MangaId.is_in(manga_ids.to_vec()))
946        .all(db)
947        .await?;
948    let mag_ids: Vec<i32> = links.iter().filter_map(|l| l.magazine_id).collect();
949    let mags: HashMap<i32, magazine::Model> = if mag_ids.is_empty() {
950        HashMap::new()
951    } else {
952        magazine::Entity::find()
953            .filter(magazine::Column::Id.is_in(mag_ids))
954            .all(db)
955            .await?
956            .into_iter()
957            .map(|m| (m.id, m))
958            .collect()
959    };
960    let mut out = HashMap::new();
961    for link in links {
962        let (Some(manga_id), Some(mag_id)) = (link.manga_id, link.magazine_id) else {
963            continue;
964        };
965        if let Some(mag) = mags.get(&mag_id) {
966            out.insert(manga_id, (mag.id, mag.magazine_name.clone()));
967        }
968    }
969    Ok(out)
970}
971
972/// 批量加载各漫画最新话详情(DISTINCT ON 每漫画一条)
973async fn load_latest_details(
974    db: &DatabaseConnection,
975    manga_ids: &[i32],
976) -> crate::error::ApiResult<HashMap<i32, mangaepisodedetail::Model>> {
977    if manga_ids.is_empty() {
978        return Ok(HashMap::new());
979    }
980    let placeholders: String = (1..=manga_ids.len())
981        .map(|i| format!("${i}"))
982        .collect::<Vec<_>>()
983        .join(", ");
984    let sql = format!(
985        r#"SELECT DISTINCT ON (me."mangaId") me."mangaId", me."Id" AS "episodeId"
986        FROM mangaepisodetb me
987        WHERE me."mangaId" IN ({placeholders})
988        ORDER BY me."mangaId", me."Id" DESC"#
989    );
990    let values: Vec<Value> = manga_ids.iter().copied().map(Value::from).collect();
991    let rows = db
992        .query_all(Statement::from_sql_and_values(
993            DbBackend::Postgres,
994            &sql,
995            values,
996        ))
997        .await?;
998    let mut latest_ep: HashMap<i32, i32> = HashMap::new();
999    for row in rows {
1000        let manga_id: i32 = row.try_get("", "mangaId")?;
1001        let episode_id: i32 = row.try_get("", "episodeId")?;
1002        latest_ep.insert(manga_id, episode_id);
1003    }
1004    let ep_ids: Vec<i32> = latest_ep.values().copied().collect();
1005    if ep_ids.is_empty() {
1006        return Ok(HashMap::new());
1007    }
1008    let details: HashMap<i32, mangaepisodedetail::Model> = mangaepisodedetail::Entity::find()
1009        .filter(mangaepisodedetail::Column::EpisodeId.is_in(ep_ids))
1010        .all(db)
1011        .await?
1012        .into_iter()
1013        .map(|d| (d.episode_id, d))
1014        .collect();
1015    let mut out = HashMap::new();
1016    for (manga_id, ep_id) in latest_ep {
1017        if let Some(d) = details.get(&ep_id) {
1018            out.insert(manga_id, d.clone());
1019        }
1020    }
1021    Ok(out)
1022}
1023
1024/// 组装 MangaCardVo
1025fn to_manga_card(m: &mangatb::Model, ctx: &MangaListContext) -> MangaCardVo {
1026    let med = ctx.latest_details.get(&m.id);
1027    let a1 = ctx.author1.get(&m.id);
1028    let a2 = ctx.author2.get(&m.id);
1029    MangaCardVo {
1030        id: m.id,
1031        manga_tran_name: Some(m.manga_tran_name.clone()),
1032        manga_ori_name: Some(m.manga_ori_name.clone()),
1033        image: Some(m.image.clone()),
1034        category: Some(m.category as i16),
1035        manga_status: Some(m.manga_status as i16),
1036        setup_time: dt_legacy(m.setup_time),
1037        update_time: dt_legacy(m.update_time),
1038        author_name: a1.map(|(_, n)| n.clone()),
1039        author_name2: a2.map(|(_, n)| n.clone()),
1040        author_id2: a2.map(|(id, _)| *id),
1041        magazine_name: ctx.magazines.get(&m.id).map(|(_, n)| n.clone()),
1042        translator_setup_time: med.and_then(|d| dt_legacy_opt(d.translator_setup_time)),
1043        translator_update_time: med.and_then(|d| dt_legacy_opt(d.translator_update_time)),
1044        proofreader_setup_time: med.and_then(|d| dt_legacy_opt(d.proofreader_setup_time)),
1045        proofreader_update_time: med.and_then(|d| dt_legacy_opt(d.proofreader_update_time)),
1046        letterer_setup_time: med.and_then(|d| dt_legacy_opt(d.letterer_setup_time)),
1047        letterer_update_time: med.and_then(|d| dt_legacy_opt(d.letterer_update_time)),
1048        reviewer_setup_time: med.and_then(|d| dt_legacy_opt(d.reviewer_setup_time)),
1049        reviewer_update_time: med.and_then(|d| dt_legacy_opt(d.reviewer_update_time)),
1050        timer_setup_time: med.and_then(|d| dt_legacy_opt(d.timer_setup_time)),
1051        timer_update_time: med.and_then(|d| dt_legacy_opt(d.timer_update_time)),
1052    }
1053}
1054
1055/// 组装 MangaDetailVo
1056fn to_manga_detail(m: &mangatb::Model, ctx: &MangaListContext) -> MangaDetailVo {
1057    let a1 = ctx.author1.get(&m.id);
1058    let a2 = ctx.author2.get(&m.id);
1059    let mag = ctx.magazines.get(&m.id);
1060    MangaDetailVo {
1061        id: m.id,
1062        manga_tran_name: Some(m.manga_tran_name.clone()),
1063        manga_ori_name: Some(m.manga_ori_name.clone()),
1064        image: Some(m.image.clone()),
1065        category: Some(m.category as i16),
1066        manga_status: Some(m.manga_status as i16),
1067        setup_time: dt_legacy(m.setup_time),
1068        update_time: dt_legacy(m.update_time),
1069        link: m.link.clone(),
1070        introduction: m.introduction.clone(),
1071        author_name: a1.map(|(_, n)| n.clone()),
1072        author_id: a1.map(|(id, _)| *id),
1073        author_name2: a2.map(|(_, n)| n.clone()),
1074        author_id2: a2.map(|(id, _)| *id),
1075        magazine_name: mag.map(|(_, n)| n.clone()),
1076        magazine_id: mag.map(|(id, _)| *id),
1077    }
1078}
1079
1080/// 组装 MangaResponse
1081fn to_manga_response(
1082    m: &mangatb::Model,
1083    ctx: &MangaListContext,
1084) -> crate::entity::manga::MangaResponse {
1085    let med = ctx.latest_details.get(&m.id);
1086    let a1 = ctx.author1.get(&m.id);
1087    let a2 = ctx.author2.get(&m.id);
1088    let mag = ctx.magazines.get(&m.id);
1089    crate::entity::manga::MangaResponse {
1090        id: Some(m.id),
1091        manga_tran_name: Some(m.manga_tran_name.clone()),
1092        manga_ori_name: Some(m.manga_ori_name.clone()),
1093        image: Some(m.image.clone()),
1094        category: Some(m.category as i16),
1095        manga_status: Some(m.manga_status as i16),
1096        setup_time: dt_legacy(m.setup_time),
1097        update_time: dt_legacy(m.update_time),
1098        link: m.link.clone(),
1099        introduction: m.introduction.clone(),
1100        author_name: a1.map(|(_, n)| n.clone()),
1101        author_id: a1.map(|(id, _)| *id),
1102        author_name2: a2.map(|(_, n)| n.clone()),
1103        author_id2: a2.map(|(id, _)| *id),
1104        magazine_name: mag.map(|(_, n)| n.clone()),
1105        magazine_id: mag.map(|(id, _)| *id),
1106        translator_setup_time: med.and_then(|d| dt_legacy_opt(d.translator_setup_time)),
1107        translator_update_time: med.and_then(|d| dt_legacy_opt(d.translator_update_time)),
1108        proofreader_setup_time: med.and_then(|d| dt_legacy_opt(d.proofreader_setup_time)),
1109        proofreader_update_time: med.and_then(|d| dt_legacy_opt(d.proofreader_update_time)),
1110        letterer_setup_time: med.and_then(|d| dt_legacy_opt(d.letterer_setup_time)),
1111        letterer_update_time: med.and_then(|d| dt_legacy_opt(d.letterer_update_time)),
1112        reviewer_setup_time: med.and_then(|d| dt_legacy_opt(d.reviewer_setup_time)),
1113        reviewer_update_time: med.and_then(|d| dt_legacy_opt(d.reviewer_update_time)),
1114    }
1115}
1116
1117/// 组装 RssMangaRow
1118fn to_rss_manga(m: &mangatb::Model, ctx: &MangaListContext, include_update: bool) -> RssMangaRow {
1119    let a1 = ctx.author1.get(&m.id);
1120    let a2 = ctx.author2.get(&m.id);
1121    RssMangaRow {
1122        id: m.id,
1123        manga_tran_name: Some(m.manga_tran_name.clone()),
1124        manga_ori_name: Some(m.manga_ori_name.clone()),
1125        category: Some(m.category as i16),
1126        manga_status: Some(m.manga_status as i16),
1127        image: Some(m.image.clone()),
1128        setup_time: dt_legacy(m.setup_time),
1129        update_time: if include_update {
1130            dt_legacy(m.update_time)
1131        } else {
1132            None
1133        },
1134        author_id: a1.map(|(id, _)| *id),
1135        author_name: a1.map(|(_, n)| n.clone()),
1136        author_id2: a2.map(|(id, _)| *id),
1137        author_name2: a2.map(|(_, n)| n.clone()),
1138    }
1139}
1140
1141/// 工序提醒下一岗位组员 ID
1142fn next_post_member_id(ep: &mangaepisodetb::Model, my_name: &str) -> Option<i32> {
1143    match my_name {
1144        "translator" => ep.proofreader_id,
1145        "proofreader" => ep.letterer_id,
1146        "letterer" => ep.reviewer_id,
1147        "timer" => ep.timer_id,
1148        _ => None,
1149    }
1150}
1151
1152/// naive 时间转 API UTC
1153fn dt_legacy(n: chrono::NaiveDateTime) -> Option<DateTime<Utc>> {
1154    Some(crate::utils::legacy_time::naive_db_to_api_utc(n))
1155}
1156
1157/// 可空 naive 时间转 API UTC
1158fn dt_legacy_opt(n: Option<chrono::NaiveDateTime>) -> Option<DateTime<Utc>> {
1159    n.map(crate::utils::legacy_time::naive_db_to_api_utc)
1160}
1161
1162#[cfg(test)]
1163mod tests {
1164    use super::{rss_episode_type, MangaListSort, MangaListSortField, MangaListSortOrder};
1165    use crate::entity::episode::EpisodeType;
1166
1167    /// RSS 话数分类映射应识别番外,并将未知旧值兼容为正篇。
1168    #[test]
1169    fn rss_episode_type_defaults_unknown_values_to_main() {
1170        assert_eq!(rss_episode_type("EXTRA"), EpisodeType::Extra);
1171        assert_eq!(rss_episode_type("MAIN"), EpisodeType::Main);
1172        assert_eq!(rss_episode_type(""), EpisodeType::Main);
1173    }
1174
1175    #[test]
1176    fn manga_list_sort_accepts_time_aliases() {
1177        let sort = MangaListSort::from_query(Some("createTime"), Some("ascending"));
1178        assert_eq!(sort.field, MangaListSortField::SetupTime);
1179        assert_eq!(sort.order, MangaListSortOrder::Asc);
1180        assert_eq!(sort.cache_field(), "setupTime");
1181        assert_eq!(sort.cache_order(), "ascending");
1182
1183        let sort = MangaListSort::from_query(Some("updated_at"), Some("desc"));
1184        assert_eq!(sort.field, MangaListSortField::UpdateTime);
1185        assert_eq!(sort.order, MangaListSortOrder::Desc);
1186    }
1187
1188    #[test]
1189    fn manga_list_sort_defaults_to_update_time_desc() {
1190        let sort = MangaListSort::from_query(Some("unknown"), Some("sideways"));
1191        assert_eq!(sort.field, MangaListSortField::UpdateTime);
1192        assert_eq!(sort.order, MangaListSortOrder::Desc);
1193        assert_eq!(sort.cache_field(), "updateTime");
1194        assert_eq!(sort.cache_order(), "descending");
1195    }
1196}