1use 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#[derive(Debug, Deserialize)]
35#[serde(rename_all = "camelCase")]
36pub struct StationMemberBody {
37 #[serde(default, alias = "Id")]
39 pub id: i32,
40 pub station_id: Option<i32>,
42 pub status: Option<i16>,
44 pub manga_id: Option<i32>,
46 pub post: Option<i32>,
48}
49
50pub struct MangaService;
54
55impl MangaService {
56 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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
494fn 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#[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#[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#[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#[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#[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#[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#[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
667fn 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}