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