Skip to main content

tdm_server_rust/service/
manga_benefit_service.rs

1//! 漫画特典业务服务 (Manga Benefit Service)
2//!
3//! 特典图片上传、按卷分组查询。
4//! 对齐 Java `MangaBenefitServiceImpl`。
5
6use crate::{
7    app::AppState,
8    entity::manga::{MangaBenefitDto, MangaBenefitItemVo, StoreBenefitVo, VolumeVo},
9    error::{ApiResult, AppError},
10    repository::manga_benefit_repo::{BenefitRow, BenefitUpsert, MangaBenefitRepository},
11};
12use axum::body::Bytes;
13use std::collections::BTreeMap;
14use std::path::PathBuf;
15
16/// 特典服务
17pub struct MangaBenefitService;
18
19impl MangaBenefitService {
20    /// 按漫画 ID 查询特典(按卷分组,对齐 Java getMangaBenefitsByMangaId)
21    #[tracing::instrument(skip_all, level = "debug")]
22    pub async fn get_manga_benefits_by_manga_id(
23        state: &AppState,
24        manga_id: i32,
25    ) -> ApiResult<Vec<VolumeVo>> {
26        let rows = MangaBenefitRepository::new(state.db.clone())
27            .list_by_manga_id(manga_id)
28            .await?;
29
30        let mut volume_map: BTreeMap<i32, Vec<BenefitRow>> = BTreeMap::new();
31        for row in rows {
32            volume_map.entry(row.volume_number).or_default().push(row);
33        }
34
35        Ok(volume_map
36            .into_iter()
37            .map(|(volume_number, list)| build_volume_vo(volume_number, list))
38            .collect())
39    }
40
41    /// 新增特典
42    #[tracing::instrument(skip_all, level = "debug")]
43    pub async fn add_manga_benefit(
44        state: &AppState,
45        file: Option<Bytes>,
46        dto: MangaBenefitDto,
47    ) -> ApiResult<()> {
48        let img_url = resolve_img_url(state, file, dto.img_url.as_deref()).await?;
49        if img_url.as_deref().unwrap_or("").is_empty() {
50            return Err(AppError::business("请先上传图片"));
51        }
52        MangaBenefitRepository::new(state.db.clone())
53            .insert(&BenefitUpsert {
54                manga_id: dto.manga_id,
55                volume_number: dto.volume_number.unwrap_or(1),
56                volume_title: dto.volume_title,
57                store_name: dto.store_name,
58                benefit_name: dto.benefit_name,
59                benefit_tag: dto.benefit_tag,
60                r#type: dto.r#type,
61                img_url,
62                publish_time: dto.publish_time,
63            })
64            .await?;
65        Ok(())
66    }
67
68    /// 更新特典
69    #[tracing::instrument(skip_all, level = "debug")]
70    pub async fn update_manga_benefit(
71        state: &AppState,
72        file: Option<Bytes>,
73        dto: MangaBenefitDto,
74    ) -> ApiResult<()> {
75        let id = dto.id.ok_or_else(|| AppError::business("缺少特典 ID"))?;
76        let repo = MangaBenefitRepository::new(state.db.clone());
77        let existing = repo
78            .get_by_id(id)
79            .await?
80            .ok_or_else(|| AppError::business("要更新的特典不存在"))?;
81
82        if file.is_some() {
83            return Err(AppError::business("请先上传图片"));
84        }
85
86        let mut img_url = existing.img_url;
87        if let Some(new_url) = dto.img_url.filter(|s| !s.is_empty()) {
88            img_url = Some(new_url);
89        }
90
91        repo.update(
92            id,
93            &BenefitUpsert {
94                manga_id: dto.manga_id,
95                volume_number: dto.volume_number.unwrap_or(existing.volume_number),
96                volume_title: dto.volume_title.or(existing.volume_title),
97                store_name: dto.store_name.or(existing.store_name),
98                benefit_name: dto.benefit_name.or(existing.benefit_name),
99                benefit_tag: dto.benefit_tag.or(existing.benefit_tag),
100                r#type: dto.r#type.or(existing.r#type),
101                img_url,
102                publish_time: dto.publish_time.or(existing.publish_time),
103            },
104        )
105        .await?;
106        Ok(())
107    }
108
109    /// 软删除特典
110    #[tracing::instrument(skip_all, level = "debug")]
111    pub async fn delete_manga_benefit_by_id(state: &AppState, id: i32) -> ApiResult<()> {
112        MangaBenefitRepository::new(state.db.clone())
113            .soft_delete(id)
114            .await?;
115        Ok(())
116    }
117}
118
119/// 组装卷 VO
120fn build_volume_vo(volume_number: i32, list: Vec<BenefitRow>) -> VolumeVo {
121    let first = &list[0];
122    let cover_url = list
123        .iter()
124        .find(|r| r.r#type == Some(1))
125        .and_then(|r| r.img_url.clone())
126        .or_else(|| list.first().and_then(|r| r.img_url.clone()));
127
128    let mut store_map: BTreeMap<String, Vec<&BenefitRow>> = BTreeMap::new();
129    for row in &list {
130        if let Some(store) = row.store_name.as_deref().filter(|s| !s.is_empty()) {
131            store_map.entry(store.to_string()).or_default().push(row);
132        }
133    }
134
135    let benefits = store_map
136        .into_iter()
137        .map(|(store_name, items)| StoreBenefitVo {
138            store_name: Some(store_name),
139            items: items
140                .into_iter()
141                .map(|r| MangaBenefitItemVo {
142                    id: r.id,
143                    benefit_name: r.benefit_name.clone(),
144                    benefit_tag: r.benefit_tag.clone(),
145                    thumbnail_url: None,
146                    image_url: r.img_url.clone(),
147                })
148                .collect(),
149        })
150        .collect();
151
152    VolumeVo {
153        id: first.id,
154        volume_number,
155        volume_title: first.volume_title.clone(),
156        publish_time: first.publish_time,
157        cover_url,
158        benefits,
159    }
160}
161
162/// 解析图片 URL:优先 multipart 文件,否则用 DTO 中的 OSS 路径
163#[tracing::instrument(skip_all, level = "debug")]
164async fn resolve_img_url(
165    state: &AppState,
166    file: Option<Bytes>,
167    dto_url: Option<&str>,
168) -> ApiResult<Option<String>> {
169    if let Some(bytes) = file {
170        return Ok(Some(save_benefit_image(state, &bytes).await?));
171    }
172    Ok(dto_url.map(str::to_string))
173}
174
175#[tracing::instrument(skip_all, level = "debug")]
176async fn save_benefit_image(state: &AppState, bytes: &Bytes) -> ApiResult<String> {
177    let name = format!("benefit_{}.jpg", uuid::Uuid::new_v4());
178    let path = PathBuf::from(&state.config.folder.base2)
179        .join("benefits")
180        .join(&name);
181    if let Some(parent) = path.parent() {
182        tokio::fs::create_dir_all(parent)
183            .await
184            .map_err(|e| AppError::Internal(e.to_string()))?;
185    }
186    tokio::fs::write(&path, bytes)
187        .await
188        .map_err(|e| AppError::Internal(e.to_string()))?;
189    Ok(format!("benefits/{name}"))
190}