1use 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
17pub struct MangaBenefitService;
19
20#[derive(Debug, FromRow)]
22struct BenefitRow {
23 id: i32,
25 volume_number: i32,
27 volume_title: Option<String>,
29 store_name: Option<String>,
31 benefit_name: Option<String>,
33 benefit_tag: Option<String>,
35 img_url: Option<String>,
37 r#type: Option<i16>,
39 publish_time: Option<DateTime<Utc>>,
41}
42
43impl MangaBenefitService {
44 #[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 #[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 #[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 #[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
181fn 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#[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}