Skip to main content

tdm_server_rust/service/
manga_service.rs

1//! 漫画业务服务 (Manga Service)
2//!
3//! 漫画管理的核心业务逻辑:
4//! - 漫画 CRUD 与分页查询
5//! - 收藏管理
6//! - 术语表增删改查
7//! - 常驻组员管理
8//! - 文件下载代理
9
10use crate::{
11    app::AppState,
12    cache::{
13        collect_list_cache_key, get_collect_list_json_cached, get_list_page_json_cached,
14        get_manga_ori_names_cached, get_manga_tran_names_cached, invalidate_collect_list,
15        invalidate_stationed_mangas, manga_page_cache_key, on_manga_mutated, on_station_mutated,
16        set_collect_list_json_cached, set_list_page_json_cached, set_manga_ori_names_cached,
17        set_manga_tran_names_cached,
18    },
19    common::PageBean,
20    entity::{
21        manga::{
22            AddStationRequest, CollectedMembersVo, GlossaryRequest, GlossaryVo, Manga,
23            MangaCollect, MangaDetailVo, MangaListVo, MangaSimpleVo, MangaUpdateRequest,
24        },
25        member::Member,
26    },
27    error::{ApiResult, AppError},
28    repository::{
29        author_repo::AuthorRepository, manga_list_assemble::MangaListSort,
30        manga_repo::MangaRepository, member_repo::MemberRepository,
31    },
32    service::rss_service::{RssRefreshScope, RssService},
33    utils::page::{paginate, slice_rows},
34};
35use axum::body::Bytes;
36use sea_orm::TransactionTrait;
37use serde::Deserialize;
38use std::path::PathBuf;
39use std::sync::Arc;
40
41/// 常驻组员操作请求体
42#[derive(Debug, Deserialize)]
43#[serde(rename_all = "camelCase")]
44pub struct StationMemberBody {
45    /// 组员 ID(申请常驻时使用)
46    #[serde(default, alias = "Id")]
47    pub id: i32,
48    /// 常驻记录 ID(更新/删除状态时使用)
49    pub station_id: Option<i32>,
50    /// 常驻状态
51    pub status: Option<i16>,
52    /// 漫画 ID
53    pub manga_id: Option<i32>,
54    /// 岗位
55    pub post: Option<i32>,
56}
57
58/// 漫画服务
59///
60/// 提供漫画及关联数据(收藏、术语表、常驻)的完整业务逻辑。
61pub struct MangaService;
62
63impl MangaService {
64    /// 多条件分页查询漫画列表。
65    ///
66    /// 先查总数再查分页数据,对齐 Java `PageHelper` 行为。
67    ///
68    /// # 返回值
69    ///
70    /// 返回 [`PageBean<MangaListVo>`],`total` 为符合条件的漫画总数。
71    #[tracing::instrument(skip_all, level = "debug")]
72    pub async fn page(
73        state: &AppState,
74        page: i32,
75        page_size: i32,
76        manga_tran_name: Option<String>,
77        manga_ori_name: Option<String>,
78        category: Option<i16>,
79        manga_status: Option<i16>,
80        _author_id: Option<i16>,
81        author_name: Option<String>,
82        magazine_name: Option<String>,
83        sort_prop: Option<String>,
84        sort_order: Option<String>,
85    ) -> ApiResult<PageBean<MangaListVo>> {
86        let page = page.max(1);
87        let page_size = page_size.max(1);
88        let offset = (page - 1) * page_size;
89        let pool_count = state.db.clone();
90        let pool_page = state.db.clone();
91        let repo_count = MangaRepository::new(pool_count);
92        let repo_page = MangaRepository::new(pool_page);
93        let tran = manga_tran_name.as_deref();
94        let ori = manga_ori_name.as_deref();
95        let author = author_name.as_deref();
96        let magazine = magazine_name.as_deref();
97        let sort = MangaListSort::from_query(sort_prop.as_deref(), sort_order.as_deref());
98        let (total, rows) = tokio::try_join!(
99            repo_count.count_list(tran, ori, category, manga_status, author, magazine),
100            repo_page.list_page_sorted(
101                tran,
102                ori,
103                category,
104                manga_status,
105                author,
106                magazine,
107                sort,
108                page_size,
109                offset,
110            ),
111        )?;
112        Ok(slice_rows(rows, total))
113    }
114
115    /// 漫画列表分页预序列化 JSON(短 TTL 缓存)
116    #[tracing::instrument(skip_all, level = "debug")]
117    pub async fn page_json(
118        state: &AppState,
119        page: i32,
120        page_size: i32,
121        manga_tran_name: Option<String>,
122        manga_ori_name: Option<String>,
123        category: Option<i16>,
124        manga_status: Option<i16>,
125        author_id: Option<i16>,
126        author_name: Option<String>,
127        magazine_name: Option<String>,
128        sort_prop: Option<String>,
129        sort_order: Option<String>,
130    ) -> ApiResult<Arc<Vec<u8>>> {
131        let sort = MangaListSort::from_query(sort_prop.as_deref(), sort_order.as_deref());
132        let key = manga_page_cache_key(
133            state,
134            page,
135            page_size,
136            manga_tran_name.as_deref(),
137            manga_ori_name.as_deref(),
138            category,
139            manga_status,
140            author_name.as_deref(),
141            magazine_name.as_deref(),
142            sort.cache_field(),
143            sort.cache_order(),
144        );
145        if let Some(json) = get_list_page_json_cached(state, &key).await {
146            return Ok(json);
147        }
148        let page_data = Self::page(
149            state,
150            page,
151            page_size,
152            manga_tran_name,
153            manga_ori_name,
154            category,
155            manga_status,
156            author_id,
157            author_name,
158            magazine_name,
159            sort_prop,
160            sort_order,
161        )
162        .await?;
163        set_list_page_json_cached(state, key.clone(), page_data).await;
164        get_list_page_json_cached(state, &key)
165            .await
166            .ok_or_else(|| AppError::business("漫画列表缓存写入失败"))
167    }
168
169    /// 查询全部译名
170    #[tracing::instrument(skip_all, level = "debug")]
171    pub async fn get_manga_tran_name(state: &AppState) -> ApiResult<Vec<MangaSimpleVo>> {
172        if let Some(cached) = get_manga_tran_names_cached(state).await {
173            return Ok(cached);
174        }
175        let data = MangaRepository::new(state.db.clone())
176            .get_manga_tran_names()
177            .await?;
178        set_manga_tran_names_cached(state, &data).await;
179        Ok(data)
180    }
181
182    /// 查询全部原名
183    #[tracing::instrument(skip_all, level = "debug")]
184    pub async fn get_manga_ori_name(state: &AppState) -> ApiResult<Vec<MangaSimpleVo>> {
185        if let Some(cached) = get_manga_ori_names_cached(state).await {
186            return Ok(cached);
187        }
188        let data = MangaRepository::new(state.db.clone())
189            .list_ori_names()
190            .await?;
191        set_manga_ori_names_cached(state, &data).await;
192        Ok(data)
193    }
194
195    /// 删除漫画
196    #[tracing::instrument(skip_all, level = "debug")]
197    pub async fn delete_manga(state: &AppState, id: i32) -> ApiResult<()> {
198        let repo = MangaRepository::new(state.db.clone());
199        repo.delete_manga_author(id).await?;
200        repo.delete_manga_author2(id).await?;
201        repo.delete_manga_magazine(id).await?;
202        repo.delete_manga_episode(id).await?;
203        repo.delete_by_id(id).await?;
204        on_manga_mutated(state, Some(id)).await;
205        Ok(())
206    }
207
208    /// 新增漫画
209    #[tracing::instrument(skip_all, level = "debug")]
210    pub async fn add_new_manga(
211        state: &AppState,
212        req: MangaUpdateRequest,
213        image: Option<Bytes>,
214        member_id: i32,
215    ) -> ApiResult<()> {
216        let repo = MangaRepository::new(state.db.clone());
217        let author_repo = AuthorRepository::new(state.db.clone());
218        validate_manga_name_unique(&repo, None, &req).await?;
219
220        let mut manga = request_to_manga(&req);
221        apply_manga_fields(&mut manga, &req);
222        if let Some(img) = resolve_image_path(state, &req, image, None, member_id).await? {
223            manga.img_url = Some(img);
224        }
225
226        let author1_id = resolve_author_for_add(&author_repo, req.author_name.as_deref()).await?;
227        let author2_id = resolve_author2_for_add(&author_repo, req.author_name2.as_deref()).await?;
228
229        let manga_id = repo.insert(&manga).await?;
230        repo.insert_manga_author(manga_id, author1_id).await?;
231        if let Some(aid2) = author2_id {
232            repo.insert_manga_author2(manga_id, aid2).await?;
233        }
234        if let Some(mid) = normalize_magazine_id(req.magazine_id) {
235            repo.insert_manga_magazine(manga_id, mid).await?;
236        }
237        on_manga_mutated(state, None).await;
238        RssService::refresh(state, RssRefreshScope::NewManga);
239        Ok(())
240    }
241
242    /// 按 ID 查询漫画
243    #[tracing::instrument(skip_all, level = "debug")]
244    pub async fn get_manga_by_id(state: &AppState, id: i32) -> ApiResult<MangaDetailVo> {
245        let repo = MangaRepository::new(state.db.clone());
246        repo.get_manga_detail_by_id(id)
247            .await?
248            .ok_or_else(|| AppError::business("漫画不存在喵"))
249    }
250
251    /// 更新漫画
252    #[tracing::instrument(skip_all, level = "debug")]
253    pub async fn update_manga(
254        state: &AppState,
255        req: MangaUpdateRequest,
256        image: Option<Bytes>,
257        member_id: i32,
258    ) -> ApiResult<()> {
259        let repo = MangaRepository::new(state.db.clone());
260        let author_repo = AuthorRepository::new(state.db.clone());
261        let id = req.id.ok_or_else(|| AppError::business("缺少漫画 ID"))?;
262        validate_manga_name_unique(&repo, Some(id), &req).await?;
263
264        let old = repo
265            .get_manga_by_id(id)
266            .await?
267            .ok_or_else(|| AppError::business("漫画不存在喵"))?;
268
269        let mut manga = old;
270        apply_manga_fields(&mut manga, &req);
271        if let Some(img) =
272            resolve_image_path(state, &req, image, manga.img_url.as_deref(), member_id).await?
273        {
274            manga.img_url = Some(img);
275        }
276
277        let author1_id = resolve_author_for_update(
278            &author_repo,
279            req.author_name.as_deref(),
280            "原作作者不在档案里喵!先添加作者吧",
281        )
282        .await?;
283        let author2_id = resolve_author_for_update(
284            &author_repo,
285            req.author_name2.as_deref(),
286            "作画作者不在档案里喵!先添加作者吧",
287        )
288        .await?;
289
290        repo.update_manga(&manga).await?;
291        if let Some(aid) = author1_id {
292            if repo.test_manga_author1(id).await? {
293                repo.update_manga_author(id, aid).await?;
294            } else {
295                repo.insert_manga_author(id, aid).await?;
296            }
297        }
298        sync_manga_author2(&repo, id, req.author_name2.as_deref(), author2_id).await?;
299        sync_manga_magazine(&repo, id, normalize_magazine_id(req.magazine_id)).await?;
300        on_manga_mutated(state, Some(id)).await;
301        RssService::refresh(state, RssRefreshScope::MangaUpdated);
302        Ok(())
303    }
304
305    /// 查询收藏详情
306    #[tracing::instrument(skip_all, level = "debug")]
307    pub async fn get_collect_detail(
308        state: &AppState,
309        collect: MangaCollect,
310    ) -> ApiResult<MangaCollect> {
311        let detail = MangaRepository::new(state.db.clone())
312            .get_collect_detail(collect.manga_id, collect.member_id)
313            .await?;
314        Ok(detail.unwrap_or(collect))
315    }
316
317    /// 分页查询组员收藏
318    #[tracing::instrument(skip_all, level = "debug")]
319    pub async fn get_collect_list(
320        state: &AppState,
321        page: i32,
322        page_size: i32,
323        member_id: i32,
324    ) -> ApiResult<PageBean<MangaListVo>> {
325        let (total, rows) = MangaRepository::new(state.db.clone())
326            .get_collect_list_page(member_id, page, page_size)
327            .await?;
328        Ok(slice_rows(rows, total))
329    }
330
331    /// 收藏列表预序列化 JSON(短 TTL 缓存)
332    #[tracing::instrument(skip_all, level = "debug")]
333    pub async fn get_collect_list_json(
334        state: &AppState,
335        page: i32,
336        page_size: i32,
337        member_id: i32,
338    ) -> ApiResult<Arc<Vec<u8>>> {
339        let key = collect_list_cache_key(member_id, page, page_size);
340        if let Some(json) = get_collect_list_json_cached(state, &key).await {
341            return Ok(json);
342        }
343        let page_data = Self::get_collect_list(state, page, page_size, member_id).await?;
344        set_collect_list_json_cached(state, key.clone(), page_data).await;
345        get_collect_list_json_cached(state, &key)
346            .await
347            .ok_or_else(|| AppError::business("collectList 缓存写入失败"))
348    }
349
350    /// 分页查询收藏该漫画的组员
351    #[tracing::instrument(skip_all, level = "debug")]
352    pub async fn get_collected_members(
353        state: &AppState,
354        page: i32,
355        page_size: i32,
356        manga_id: i32,
357    ) -> ApiResult<PageBean<CollectedMembersVo>> {
358        let manga_repo = MangaRepository::new(state.db.clone());
359        let member_repo = MemberRepository::new(state.db.clone());
360        let mut all = manga_repo.get_collected_members(manga_id).await?;
361        let ids: Vec<i32> = all.iter().map(|m| m.id).collect();
362        let posts_map = member_repo.get_posts_map(&ids).await?;
363        for m in &mut all {
364            let post_ids = posts_map.get(&m.id).cloned().unwrap_or_default();
365            m.posts = post_ids
366                .iter()
367                .map(|p| crate::entity::member::Post { post: *p })
368                .collect();
369        }
370        Ok(paginate(all, page, page_size))
371    }
372
373    /// 删除收藏
374    #[tracing::instrument(skip_all, level = "debug")]
375    pub async fn del_collect(state: &AppState, collect: MangaCollect) -> ApiResult<()> {
376        MangaRepository::new(state.db.clone())
377            .del_collect(collect.manga_id, collect.member_id)
378            .await?;
379        invalidate_collect_list(state, collect.member_id).await;
380        Ok(())
381    }
382
383    /// 新增收藏
384    #[tracing::instrument(skip_all, level = "debug")]
385    pub async fn add_collect(state: &AppState, collect: MangaCollect) -> ApiResult<()> {
386        MangaRepository::new(state.db.clone())
387            .add_collect(collect.manga_id, collect.member_id)
388            .await?;
389        invalidate_collect_list(state, collect.member_id).await;
390        Ok(())
391    }
392
393    /// 分页查询术语
394    #[tracing::instrument(skip_all, level = "debug")]
395    pub async fn page_glossary(
396        state: &AppState,
397        page: i32,
398        page_size: i32,
399        r#type: Option<i16>,
400        manga_id: i16,
401    ) -> ApiResult<PageBean<GlossaryVo>> {
402        let repo = MangaRepository::new(state.db.clone());
403        let all = repo.list_glossary(manga_id as i32, r#type).await?;
404        Ok(paginate(all, page, page_size))
405    }
406
407    /// 删除术语
408    #[tracing::instrument(skip_all, level = "debug")]
409    pub async fn delete_glossary(state: &AppState, id: i32) -> ApiResult<()> {
410        MangaRepository::new(state.db.clone())
411            .delete_glossary_by_id(id)
412            .await
413    }
414
415    /// 新增术语
416    #[tracing::instrument(skip_all, level = "debug")]
417    pub async fn add_glossary(
418        state: &AppState,
419        req: GlossaryRequest,
420        member_id: i32,
421    ) -> ApiResult<()> {
422        MangaRepository::new(state.db.clone())
423            .insert_glossary(&req, member_id)
424            .await?;
425        Ok(())
426    }
427
428    /// 按 ID 查询术语
429    #[tracing::instrument(skip_all, level = "debug")]
430    pub async fn get_glossary_by_id(state: &AppState, id: i32) -> ApiResult<GlossaryVo> {
431        MangaRepository::new(state.db.clone())
432            .get_glossary_by_id(id)
433            .await?
434            .ok_or_else(|| AppError::business("术语不存在喵"))
435    }
436
437    /// 更新术语
438    #[tracing::instrument(skip_all, level = "debug")]
439    pub async fn update_glossary(
440        state: &AppState,
441        req: GlossaryRequest,
442        member_id: i32,
443    ) -> ApiResult<()> {
444        MangaRepository::new(state.db.clone())
445            .update_glossary(&req, member_id)
446            .await
447    }
448
449    /// RSS 漫画列表
450    #[tracing::instrument(skip_all, level = "debug")]
451    pub async fn get_manga_rss(
452        state: &AppState,
453    ) -> ApiResult<Vec<crate::entity::rss::RssMangaRow>> {
454        MangaRepository::new(state.db.clone()).get_manga_rss().await
455    }
456
457    /// RSS 话数列表
458    #[tracing::instrument(skip_all, level = "debug")]
459    pub async fn get_episode_rss(
460        state: &AppState,
461    ) -> ApiResult<Vec<crate::entity::rss::EpisodeRssRow>> {
462        MangaRepository::new(state.db.clone())
463            .get_episode_rss()
464            .await
465    }
466
467    /// RSS 输出写入文件
468    #[tracing::instrument(skip_all, level = "debug")]
469    pub async fn rss_output(state: &AppState, xml: String) -> ApiResult<()> {
470        RssService::refresh(state, RssRefreshScope::ExternalOutput { xml });
471        Ok(())
472    }
473
474    /// 分页查询常驻组员
475    #[tracing::instrument(skip_all, level = "debug")]
476    pub async fn get_stationed_members(
477        state: &AppState,
478        page: i32,
479        page_size: i32,
480        manga_id: i32,
481    ) -> ApiResult<PageBean<Member>> {
482        let manga_repo = MangaRepository::new(state.db.clone());
483        let member_repo = MemberRepository::new(state.db.clone());
484        let mut all = manga_repo.get_stationed_members(manga_id).await?;
485        let ids: Vec<i32> = all.iter().map(|m| m.id).collect();
486        let posts_map = member_repo.get_posts_map(&ids).await?;
487        for m in &mut all {
488            let post_ids = posts_map.get(&m.id).cloned().unwrap_or_default();
489            m.post_ids = post_ids.clone();
490            m.posts = post_ids
491                .iter()
492                .map(|p| crate::entity::member::Post { post: *p })
493                .collect();
494        }
495        Ok(paginate(all, page, page_size))
496    }
497
498    /// 删除常驻(先清空该组员未交稿单话岗位,再删记录)
499    #[tracing::instrument(skip_all, level = "debug")]
500    pub async fn del_station(state: &AppState, station_id: i32) -> ApiResult<()> {
501        let db = crate::db::from_sqlx_pool(state.db.clone());
502        let txn = db.begin().await?;
503        let station = MangaRepository::get_station_by_id_with(&txn, station_id)
504            .await?
505            .ok_or_else(|| AppError::business("常驻记录不存在喵"))?;
506        MangaRepository::clear_member_unsubmitted_episodes_with(
507            &txn,
508            station.manga_id,
509            station.member_id,
510            station.post,
511        )
512        .await?;
513        MangaRepository::del_station_with(&txn, station_id).await?;
514        txn.commit().await?;
515        on_station_mutated(state, station.manga_id, station.member_id).await;
516        Ok(())
517    }
518
519    /// 申请常驻
520    #[tracing::instrument(skip_all, level = "debug")]
521    pub async fn add_station(state: &AppState, body: StationMemberBody) -> ApiResult<()> {
522        let manga_id = body
523            .manga_id
524            .ok_or_else(|| AppError::business("缺少漫画 ID"))?;
525        let post = body.post.unwrap_or(5);
526        MangaRepository::new(state.db.clone())
527            .add_station(manga_id, body.id, post)
528            .await?;
529        invalidate_stationed_mangas(state, body.id).await;
530        Ok(())
531    }
532
533    /// 管理员添加常驻(可选填充未交稿空位)
534    #[tracing::instrument(skip_all, level = "debug")]
535    pub async fn add_station_by_admin(state: &AppState, req: AddStationRequest) -> ApiResult<()> {
536        let db = crate::db::from_sqlx_pool(state.db.clone());
537        let txn = db.begin().await?;
538        MangaRepository::add_station_by_admin_with(&txn, &req).await?;
539        if req.fill_episodes == Some(true) {
540            MangaRepository::fill_empty_episodes_with(&txn, &req).await?;
541            MangaRepository::fill_episode_detail_with(&txn, &req).await?;
542        }
543        txn.commit().await?;
544        on_station_mutated(state, req.manga_id, req.member_id).await;
545        Ok(())
546    }
547
548    /// 更新常驻状态
549    #[tracing::instrument(skip_all, level = "debug")]
550    pub async fn update_station(state: &AppState, body: StationMemberBody) -> ApiResult<()> {
551        let station_id = body
552            .station_id
553            .ok_or_else(|| AppError::business("缺少常驻记录 ID(stationId)"))?;
554        let repo = MangaRepository::new(state.db.clone());
555        let station = repo
556            .get_station_by_id(station_id)
557            .await?
558            .ok_or_else(|| AppError::business("常驻记录不存在喵"))?;
559        let status = body.status.unwrap_or(0);
560        let new_status = match status {
561            1 => 2,
562            0 => 1,
563            2 => 1,
564            other => other,
565        };
566        repo.update_station_status(station_id, new_status).await?;
567        invalidate_stationed_mangas(state, station.member_id).await;
568        Ok(())
569    }
570}
571
572fn request_to_manga(req: &MangaUpdateRequest) -> Manga {
573    Manga {
574        id: req.id,
575        manga_tran_name: req.manga_tran_name.clone(),
576        manga_ori_name: req.manga_ori_name.clone(),
577        category: req.category,
578        manga_status: req.manga_status,
579        img_url: req.image.clone(),
580        link: req.link.clone(),
581        introduction: req.introduction.clone(),
582        update_time: None,
583    }
584}
585
586/// 将请求字段合并到漫画 POJO
587fn apply_manga_fields(manga: &mut Manga, req: &MangaUpdateRequest) {
588    if let Some(v) = req.manga_tran_name.clone() {
589        manga.manga_tran_name = Some(v);
590    }
591    if let Some(v) = req.manga_ori_name.clone() {
592        manga.manga_ori_name = Some(v);
593    }
594    if let Some(v) = req.category {
595        manga.category = Some(v);
596    }
597    if let Some(v) = req.manga_status {
598        manga.manga_status = Some(v);
599    }
600    if let Some(v) = req.link.clone() {
601        manga.link = Some(v);
602    }
603    if let Some(v) = req.introduction.clone() {
604        manga.introduction = Some(v);
605    }
606}
607
608/// 校验译名/原名唯一(更新时排除自身)
609#[tracing::instrument(skip_all, level = "debug")]
610async fn validate_manga_name_unique(
611    repo: &MangaRepository,
612    exclude_id: Option<i32>,
613    req: &MangaUpdateRequest,
614) -> ApiResult<()> {
615    if let Some(name) = req.manga_ori_name.as_deref().filter(|s| !s.is_empty()) {
616        if repo.exists_ori_name_for_other(name, exclude_id).await? {
617            return Err(AppError::unique("漫画原名"));
618        }
619    }
620    if let Some(name) = req.manga_tran_name.as_deref().filter(|s| !s.is_empty()) {
621        if repo.exists_tran_name_for_other(name, exclude_id).await? {
622            return Err(AppError::unique("漫画译名"));
623        }
624    }
625    Ok(())
626}
627
628/// 解析封面路径:multipart 二进制优先,否则 metadata.image(OSS objectKey)
629#[tracing::instrument(skip_all, level = "debug")]
630async fn resolve_image_path(
631    state: &AppState,
632    req: &MangaUpdateRequest,
633    image: Option<Bytes>,
634    _old_image: Option<&str>,
635    member_id: i32,
636) -> ApiResult<Option<String>> {
637    if let Some(bytes) = image {
638        return Ok(Some(save_image(state, &bytes, member_id).await?));
639    }
640    if let Some(ref path) = req.image {
641        if !path.trim().is_empty() {
642            return Ok(Some(path.clone()));
643        }
644    }
645    Ok(None)
646}
647
648/// 新增时解析原作作者(不存在则自动建档)
649#[tracing::instrument(skip_all, level = "debug")]
650async fn resolve_author_for_add(
651    author_repo: &AuthorRepository,
652    name: Option<&str>,
653) -> ApiResult<i32> {
654    let name = name
655        .filter(|s| !s.trim().is_empty())
656        .ok_or_else(|| AppError::business("还没输入作者呢喵"))?;
657    if let Some(author) = author_repo.test_author_name(name).await? {
658        return author.id.ok_or_else(|| AppError::business("作者 ID 无效"));
659    }
660    Ok(author_repo
661        .insert(&crate::entity::author::Author {
662            id: None,
663            author_name: Some(name.to_string()),
664        })
665        .await?)
666}
667
668/// 新增时解析作画作者(不存在且非空则自动建档)
669#[tracing::instrument(skip_all, level = "debug")]
670async fn resolve_author2_for_add(
671    author_repo: &AuthorRepository,
672    name: Option<&str>,
673) -> ApiResult<Option<i32>> {
674    let Some(name) = name.filter(|s| !s.trim().is_empty()) else {
675        return Ok(None);
676    };
677    if let Some(author) = author_repo.test_author_name(name).await? {
678        return Ok(Some(
679            author
680                .id
681                .ok_or_else(|| AppError::business("作者 ID 无效"))?,
682        ));
683    }
684    Ok(Some(
685        author_repo
686            .insert(&crate::entity::author::Author {
687                id: None,
688                author_name: Some(name.to_string()),
689            })
690            .await?,
691    ))
692}
693
694/// 更新时解析作者(必须在档案中)
695#[tracing::instrument(skip_all, level = "debug")]
696async fn resolve_author_for_update(
697    author_repo: &AuthorRepository,
698    name: Option<&str>,
699    err_msg: &str,
700) -> ApiResult<Option<i32>> {
701    let Some(name) = name.filter(|s| !s.trim().is_empty()) else {
702        return Ok(None);
703    };
704    match author_repo.test_author_name(name).await? {
705        Some(a) => a.id.ok_or_else(|| AppError::business(err_msg)).map(Some),
706        None => Err(AppError::business(err_msg)),
707    }
708}
709
710/// 同步作画作者关联
711#[tracing::instrument(skip_all, level = "debug")]
712async fn sync_manga_author2(
713    repo: &MangaRepository,
714    manga_id: i32,
715    author2_name: Option<&str>,
716    author2_id: Option<i32>,
717) -> ApiResult<()> {
718    let has_name = author2_name.is_some_and(|s| !s.trim().is_empty());
719    let has_row = repo.test_author2(manga_id).await?;
720    if has_name {
721        if let Some(aid) = author2_id {
722            if has_row {
723                repo.update_manga_author2(manga_id, aid).await?;
724            } else {
725                repo.insert_manga_author2(manga_id, aid).await?;
726            }
727        }
728    } else if has_row {
729        repo.delete_manga_author2(manga_id).await?;
730    }
731    Ok(())
732}
733
734/// 同步杂志关联
735#[tracing::instrument(skip_all, level = "debug")]
736async fn sync_manga_magazine(
737    repo: &MangaRepository,
738    manga_id: i32,
739    magazine_id: Option<i32>,
740) -> ApiResult<()> {
741    match magazine_id {
742        None => repo.delete_manga_magazine(manga_id).await?,
743        Some(mid) => {
744            if repo.test_manga_magazine(manga_id).await? {
745                repo.update_manga_magazine(manga_id, mid).await?;
746            } else {
747                repo.insert_manga_magazine(manga_id, mid).await?;
748            }
749        }
750    }
751    Ok(())
752}
753
754/// 规范化杂志 ID(0/负数视为未选)
755fn normalize_magazine_id(magazine_id: Option<i32>) -> Option<i32> {
756    magazine_id.filter(|id| *id > 0)
757}
758
759#[cfg(test)]
760mod tests {
761    use super::normalize_magazine_id;
762
763    #[test]
764    fn normalize_magazine_id_filters_invalid() {
765        assert_eq!(normalize_magazine_id(None), None);
766        assert_eq!(normalize_magazine_id(Some(0)), None);
767        assert_eq!(normalize_magazine_id(Some(3)), Some(3));
768    }
769}
770
771#[tracing::instrument(skip_all, level = "debug")]
772async fn save_image(state: &AppState, bytes: &Bytes, member_id: i32) -> ApiResult<String> {
773    let name = format!("manga_{member_id}_{}.jpg", uuid::Uuid::new_v4());
774    let path = PathBuf::from(&state.config.folder.base2)
775        .join("images")
776        .join(&name);
777    if let Some(parent) = path.parent() {
778        tokio::fs::create_dir_all(parent)
779            .await
780            .map_err(|e| AppError::Internal(e.to_string()))?;
781    }
782    tokio::fs::write(&path, bytes)
783        .await
784        .map_err(|e| AppError::Internal(e.to_string()))?;
785    Ok(format!("images/{name}"))
786}