tdm_server_rust/service/
manga_benefit_service.rs1use 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
16pub struct MangaBenefitService;
18
19impl MangaBenefitService {
20 #[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 #[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 #[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 #[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
119fn 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#[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}