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};
11use axum::body::Bytes;
12use chrono::{DateTime, Utc};
13use sqlx::{FromRow, Row};
14use std::collections::BTreeMap;
15use std::path::PathBuf;
16
17/// 特典服务
18pub struct MangaBenefitService;
19
20/// 数据库行
21#[derive(Debug, FromRow)]
22struct BenefitRow {
23    /// 主键
24    id: i32,
25    /// 卷号
26    volume_number: i32,
27    /// 卷标题
28    volume_title: Option<String>,
29    /// 店铺
30    store_name: Option<String>,
31    /// 特典名
32    benefit_name: Option<String>,
33    /// 标签
34    benefit_tag: Option<String>,
35    /// 图片
36    img_url: Option<String>,
37    /// 类型
38    r#type: Option<i16>,
39    /// 发布时间
40    publish_time: Option<DateTime<Utc>>,
41}
42
43impl MangaBenefitService {
44    /// 按漫画 ID 查询特典(按卷分组,对齐 Java getMangaBenefitsByMangaId)
45    #[tracing::instrument(skip_all, level = "debug")]
46    pub async fn get_manga_benefits_by_manga_id(
47        state: &AppState,
48        manga_id: i32,
49    ) -> ApiResult<Vec<VolumeVo>> {
50        let rows: Vec<BenefitRow> = sqlx::query_as(
51            "SELECT id, volume_number, volume_title, store_name, benefit_name, benefit_tag, \
52             type, img_url, publish_time FROM manga_benefit \
53             WHERE manga_id = ? AND deleted_at IS NULL \
54             ORDER BY volume_number, id",
55        )
56        .bind(manga_id)
57        .fetch_all(&state.db)
58        .await?;
59
60        let mut volume_map: BTreeMap<i32, Vec<BenefitRow>> = BTreeMap::new();
61        for row in rows {
62            volume_map.entry(row.volume_number).or_default().push(row);
63        }
64
65        Ok(volume_map
66            .into_iter()
67            .map(|(volume_number, list)| build_volume_vo(volume_number, list))
68            .collect())
69    }
70
71    /// 新增特典
72    #[tracing::instrument(skip_all, level = "debug")]
73    pub async fn add_manga_benefit(
74        state: &AppState,
75        file: Option<Bytes>,
76        dto: MangaBenefitDto,
77    ) -> ApiResult<()> {
78        let img_url = resolve_img_url(state, file, dto.img_url.as_deref()).await?;
79        if img_url.as_deref().unwrap_or("").is_empty() {
80            return Err(AppError::business("请先上传图片"));
81        }
82        sqlx::query(
83            "INSERT INTO manga_benefit(manga_id, volume_number, volume_title, store_name, \
84             benefit_name, benefit_tag, type, img_url, publish_time) \
85             VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)",
86        )
87        .bind(dto.manga_id)
88        .bind(dto.volume_number.unwrap_or(1))
89        .bind(&dto.volume_title)
90        .bind(&dto.store_name)
91        .bind(&dto.benefit_name)
92        .bind(&dto.benefit_tag)
93        .bind(dto.r#type)
94        .bind(&img_url)
95        .bind(dto.publish_time)
96        .execute(&state.db)
97        .await?;
98        Ok(())
99    }
100
101    /// 更新特典
102    #[tracing::instrument(skip_all, level = "debug")]
103    pub async fn update_manga_benefit(
104        state: &AppState,
105        file: Option<Bytes>,
106        dto: MangaBenefitDto,
107    ) -> ApiResult<()> {
108        let id = dto.id.ok_or_else(|| AppError::business("缺少特典 ID"))?;
109        let existing = sqlx::query(
110            "SELECT id, manga_id, volume_number, volume_title, store_name, benefit_name, \
111             benefit_tag, type, img_url, publish_time FROM manga_benefit WHERE id = ? AND deleted_at IS NULL",
112        )
113        .bind(id)
114        .fetch_optional(&state.db)
115        .await?
116        .ok_or_else(|| AppError::business("要更新的特典不存在"))?;
117
118        if file.is_some() {
119            return Err(AppError::business("请先上传图片"));
120        }
121
122        let mut img_url: Option<String> = existing.try_get("img_url").ok();
123        if let Some(new_url) = dto.img_url.filter(|s| !s.is_empty()) {
124            img_url = Some(new_url);
125        }
126
127        let volume_number: i32 = dto.volume_number.unwrap_or_else(|| existing.get("volume_number"));
128        let volume_title: Option<String> = dto
129            .volume_title
130            .or_else(|| existing.try_get("volume_title").ok());
131        let store_name: Option<String> = dto
132            .store_name
133            .or_else(|| existing.try_get("store_name").ok());
134        let benefit_name: Option<String> = dto
135            .benefit_name
136            .or_else(|| existing.try_get("benefit_name").ok());
137        let benefit_tag: Option<String> = dto
138            .benefit_tag
139            .or_else(|| existing.try_get("benefit_tag").ok());
140        let benefit_type: Option<i16> = dto.r#type.or_else(|| existing.try_get("type").ok());
141        let publish_time: Option<DateTime<Utc>> = dto
142            .publish_time
143            .or_else(|| existing.try_get("publish_time").ok());
144
145        let updated = sqlx::query(
146            "UPDATE manga_benefit SET manga_id = ?, volume_number = ?, volume_title = ?, \
147             store_name = ?, benefit_name = ?, benefit_tag = ?, type = ?, img_url = ?, \
148             publish_time = ? WHERE id = ?",
149        )
150        .bind(dto.manga_id)
151        .bind(volume_number)
152        .bind(volume_title)
153        .bind(store_name)
154        .bind(benefit_name)
155        .bind(benefit_tag)
156        .bind(benefit_type)
157        .bind(img_url)
158        .bind(publish_time)
159        .bind(id)
160        .execute(&state.db)
161        .await?
162        .rows_affected();
163
164        if updated == 0 {
165            return Err(AppError::business("更新特典失败"));
166        }
167        Ok(())
168    }
169
170    /// 软删除特典
171    #[tracing::instrument(skip_all, level = "debug")]
172    pub async fn delete_manga_benefit_by_id(state: &AppState, id: i32) -> ApiResult<()> {
173        sqlx::query("UPDATE manga_benefit SET deleted_at = NOW() WHERE id = ?")
174            .bind(id)
175            .execute(&state.db)
176            .await?;
177        Ok(())
178    }
179}
180
181/// 组装卷 VO
182fn build_volume_vo(volume_number: i32, list: Vec<BenefitRow>) -> VolumeVo {
183    let first = &list[0];
184    let cover_url = list
185        .iter()
186        .find(|r| r.r#type == Some(1))
187        .and_then(|r| r.img_url.clone())
188        .or_else(|| list.first().and_then(|r| r.img_url.clone()));
189
190    let mut store_map: BTreeMap<String, Vec<&BenefitRow>> = BTreeMap::new();
191    for row in &list {
192        if let Some(store) = row.store_name.as_deref().filter(|s| !s.is_empty()) {
193            store_map.entry(store.to_string()).or_default().push(row);
194        }
195    }
196
197    let benefits = store_map
198        .into_iter()
199        .map(|(store_name, items)| StoreBenefitVo {
200            store_name: Some(store_name),
201            items: items
202                .into_iter()
203                .map(|r| MangaBenefitItemVo {
204                    id: r.id,
205                    benefit_name: r.benefit_name.clone(),
206                    benefit_tag: r.benefit_tag.clone(),
207                    thumbnail_url: None,
208                    image_url: r.img_url.clone(),
209                })
210                .collect(),
211        })
212        .collect();
213
214    VolumeVo {
215        id: first.id,
216        volume_number,
217        volume_title: first.volume_title.clone(),
218        publish_time: first.publish_time,
219        cover_url,
220        benefits,
221    }
222}
223
224/// 解析图片 URL:优先 multipart 文件,否则用 DTO 中的 OSS 路径
225#[tracing::instrument(skip_all, level = "debug")]
226async fn resolve_img_url(
227    state: &AppState,
228    file: Option<Bytes>,
229    dto_url: Option<&str>,
230) -> ApiResult<Option<String>> {
231    if let Some(bytes) = file {
232        return Ok(Some(save_benefit_image(state, &bytes).await?));
233    }
234    Ok(dto_url.map(str::to_string))
235}
236
237#[tracing::instrument(skip_all, level = "debug")]
238async fn save_benefit_image(state: &AppState, bytes: &Bytes) -> ApiResult<String> {
239    let name = format!("benefit_{}.jpg", uuid::Uuid::new_v4());
240    let path = PathBuf::from(&state.config.folder.base2)
241        .join("benefits")
242        .join(&name);
243    if let Some(parent) = path.parent() {
244        tokio::fs::create_dir_all(parent)
245            .await
246            .map_err(|e| AppError::Internal(e.to_string()))?;
247    }
248    tokio::fs::write(&path, bytes)
249        .await
250        .map_err(|e| AppError::Internal(e.to_string()))?;
251    Ok(format!("benefits/{name}"))
252}