feat: full clean architecture refactor — domain → application → infrastructure → presentation
- Restructure from Modular MVC to Clean Architecture with 4 strict layers - Domain: entities (anime/komik/proxy), Repository traits, typed errors - Application: use case classes with proper error propagation - Infrastructure: repository impls, parsers, Redis cache, HTTP/scraping, browser - Presentation: Axum handlers, DTOs, AppState, router, AppError+IntoResponse - Migrate all parsers (otakudesu, alqanime, komik) to native infra implementations - Replace once_cell::sync::Lazy/OnceCell with std::sync::LazyLock/OnceLock - Remove 150+ old files in modules/ and shared/ directories - Remove once_cell from Cargo.toml dependencies - Fix test/debug binaries to use new import paths - Zero new clippy warnings Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.8
parent
80c96eaa42
commit
776fa828d8
@@ -0,0 +1 @@
|
||||
pub mod use_cases;
|
||||
@@ -0,0 +1,244 @@
|
||||
//! Anime (Otakudesu) application use cases.
|
||||
//!
|
||||
//! Orchestrates repository fetching, caching, and image poster processing.
|
||||
//! Returns pure domain types — no DTOs.
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use deadpool_redis::Pool;
|
||||
use sea_orm::DatabaseConnection;
|
||||
|
||||
use crate::domain::entity::anime::*;
|
||||
use crate::domain::error::*;
|
||||
use crate::infrastructure::cache::redis::Cache;
|
||||
use crate::infrastructure::repository::OtakudesuRepository;
|
||||
use crate::infrastructure::services::images::cache::{
|
||||
cache_image_urls_batch_lazy, get_cached_or_original,
|
||||
};
|
||||
|
||||
const INDEX_CACHE_TTL: u64 = 10;
|
||||
const GENRE_LIST_CACHE_TTL: u64 = 3600;
|
||||
const DEFAULT_CACHE_TTL: u64 = 300;
|
||||
|
||||
pub struct AnimeUseCases {
|
||||
repository: OtakudesuRepository,
|
||||
redis_pool: Pool,
|
||||
db: Arc<DatabaseConnection>,
|
||||
semaphore: Option<Arc<tokio::sync::Semaphore>>,
|
||||
}
|
||||
|
||||
impl AnimeUseCases {
|
||||
pub fn new(
|
||||
repository: OtakudesuRepository,
|
||||
redis_pool: Pool,
|
||||
db: Arc<DatabaseConnection>,
|
||||
semaphore: Option<Arc<tokio::sync::Semaphore>>,
|
||||
) -> Self {
|
||||
Self {
|
||||
repository,
|
||||
redis_pool,
|
||||
db,
|
||||
semaphore,
|
||||
}
|
||||
}
|
||||
|
||||
fn cache(&self) -> Cache<'_> {
|
||||
Cache::new(&self.redis_pool)
|
||||
}
|
||||
|
||||
pub async fn get_anime_index(&self) -> Result<AnimeData, DomainError> {
|
||||
self.cache()
|
||||
.get_or_set("anime:index:v2", INDEX_CACHE_TTL, || async {
|
||||
let mut data = self
|
||||
.repository
|
||||
.fetch_anime_index()
|
||||
.await
|
||||
.map_err(|e| e.to_string())?;
|
||||
|
||||
if data.ongoing_anime.is_empty() && data.complete_anime.is_empty() {
|
||||
return Err("Empty anime index — refusing to cache".to_string());
|
||||
}
|
||||
|
||||
let mut posters: Vec<String> = data
|
||||
.ongoing_anime
|
||||
.iter()
|
||||
.map(|item| item.poster.clone())
|
||||
.collect();
|
||||
posters.extend(data.complete_anime.iter().map(|item| item.poster.clone()));
|
||||
|
||||
let cached_posters = cache_image_urls_batch_lazy(
|
||||
self.db.clone(),
|
||||
&self.redis_pool,
|
||||
posters,
|
||||
self.semaphore.clone(),
|
||||
)
|
||||
.await;
|
||||
|
||||
let ongoing_len = data.ongoing_anime.len();
|
||||
for (i, item) in data.ongoing_anime.iter_mut().enumerate() {
|
||||
if let Some(url) = cached_posters.get(i) {
|
||||
item.poster = url.clone();
|
||||
}
|
||||
}
|
||||
for (i, item) in data.complete_anime.iter_mut().enumerate() {
|
||||
if let Some(url) = cached_posters.get(ongoing_len + i) {
|
||||
item.poster = url.clone();
|
||||
}
|
||||
}
|
||||
|
||||
Ok(data)
|
||||
})
|
||||
.await
|
||||
.map_err(|e| DomainError::Scraping(ScrapingError::Http(e)))
|
||||
}
|
||||
|
||||
pub async fn get_genres(&self) -> Result<Vec<Genre>, DomainError> {
|
||||
self.cache()
|
||||
.get_or_set("anime:genres:list", GENRE_LIST_CACHE_TTL, || async {
|
||||
self.repository
|
||||
.fetch_genres()
|
||||
.await
|
||||
.map_err(|e| e.to_string())
|
||||
})
|
||||
.await
|
||||
.map_err(|e| DomainError::Scraping(ScrapingError::Http(e)))
|
||||
}
|
||||
|
||||
pub async fn get_anime_detail(&self, slug: String) -> Result<AnimeDetailData, DomainError> {
|
||||
let cache_key = format!("anime:detail:{}", slug);
|
||||
self.cache()
|
||||
.get_or_set(&cache_key, DEFAULT_CACHE_TTL, || async {
|
||||
let mut data = self
|
||||
.repository
|
||||
.fetch_anime_detail(&slug)
|
||||
.await
|
||||
.map_err(|e| e.to_string())?;
|
||||
|
||||
data.poster = get_cached_or_original(
|
||||
self.db.clone(),
|
||||
&self.redis_pool,
|
||||
&data.poster,
|
||||
self.semaphore.clone(),
|
||||
)
|
||||
.await;
|
||||
|
||||
let rec_posters: Vec<String> = data
|
||||
.recommendations
|
||||
.iter()
|
||||
.map(|r| r.poster.clone())
|
||||
.collect();
|
||||
let cached_rec_posters = cache_image_urls_batch_lazy(
|
||||
self.db.clone(),
|
||||
&self.redis_pool,
|
||||
rec_posters,
|
||||
self.semaphore.clone(),
|
||||
)
|
||||
.await;
|
||||
|
||||
for (i, rec) in data.recommendations.iter_mut().enumerate() {
|
||||
if let Some(url) = cached_rec_posters.get(i) {
|
||||
rec.poster = url.clone();
|
||||
}
|
||||
}
|
||||
|
||||
Ok(data)
|
||||
})
|
||||
.await
|
||||
.map_err(|e| DomainError::Scraping(ScrapingError::Http(e)))
|
||||
}
|
||||
|
||||
pub async fn get_complete_anime_page(
|
||||
&self,
|
||||
slug: String,
|
||||
) -> Result<(Vec<CompleteAnimeListItem>, Pagination), DomainError> {
|
||||
let cache_key = format!("anime:complete:{}", slug);
|
||||
self.cache()
|
||||
.get_or_set(&cache_key, DEFAULT_CACHE_TTL, || async {
|
||||
self.repository
|
||||
.fetch_complete_anime_page(&slug)
|
||||
.await
|
||||
.map_err(|e| e.to_string())
|
||||
})
|
||||
.await
|
||||
.map_err(|e| DomainError::Scraping(ScrapingError::Http(e)))
|
||||
}
|
||||
|
||||
pub async fn get_ongoing_anime_page(
|
||||
&self,
|
||||
slug: String,
|
||||
) -> Result<(Vec<OngoingAnimeListItem>, Pagination), DomainError> {
|
||||
let cache_key = format!("anime:ongoing:{}", slug);
|
||||
self.cache()
|
||||
.get_or_set(&cache_key, DEFAULT_CACHE_TTL, || async {
|
||||
self.repository
|
||||
.fetch_ongoing_anime_page(&slug)
|
||||
.await
|
||||
.map_err(|e| e.to_string())
|
||||
})
|
||||
.await
|
||||
.map_err(|e| DomainError::Scraping(ScrapingError::Http(e)))
|
||||
}
|
||||
|
||||
pub async fn get_latest_anime_page(
|
||||
&self,
|
||||
slug: String,
|
||||
) -> Result<(Vec<LatestAnimeItem>, Pagination), DomainError> {
|
||||
let cache_key = format!("anime:latest:{}", slug);
|
||||
self.cache()
|
||||
.get_or_set(&cache_key, DEFAULT_CACHE_TTL, || async {
|
||||
self.repository
|
||||
.fetch_latest_anime_page(&slug)
|
||||
.await
|
||||
.map_err(|e| e.to_string())
|
||||
})
|
||||
.await
|
||||
.map_err(|e| DomainError::Scraping(ScrapingError::Http(e)))
|
||||
}
|
||||
|
||||
pub async fn get_search_anime_page(
|
||||
&self,
|
||||
slug: String,
|
||||
page: String,
|
||||
) -> Result<(Vec<SearchAnimeItem>, Pagination), DomainError> {
|
||||
let cache_key = format!("anime:search:{}:{}", slug, page);
|
||||
self.cache()
|
||||
.get_or_set(&cache_key, DEFAULT_CACHE_TTL, || async {
|
||||
self.repository
|
||||
.fetch_search_anime_page(&slug, &page)
|
||||
.await
|
||||
.map_err(|e| e.to_string())
|
||||
})
|
||||
.await
|
||||
.map_err(|e| DomainError::Scraping(ScrapingError::Http(e)))
|
||||
}
|
||||
|
||||
pub async fn get_genre_anime_page(
|
||||
&self,
|
||||
genre_slug: String,
|
||||
page: String,
|
||||
) -> Result<(Vec<GenreAnimeItem>, Pagination), DomainError> {
|
||||
let cache_key = format!("anime:genre:{}:{}", genre_slug, page);
|
||||
self.cache()
|
||||
.get_or_set(&cache_key, DEFAULT_CACHE_TTL, || async {
|
||||
self.repository
|
||||
.fetch_genre_anime_page(&genre_slug, &page)
|
||||
.await
|
||||
.map_err(|e| e.to_string())
|
||||
})
|
||||
.await
|
||||
.map_err(|e| DomainError::Scraping(ScrapingError::Http(e)))
|
||||
}
|
||||
|
||||
pub async fn get_anime_full(&self, slug: String) -> Result<AnimeFullData, DomainError> {
|
||||
let cache_key = format!("anime:full:{}", slug);
|
||||
self.cache()
|
||||
.get_or_set(&cache_key, DEFAULT_CACHE_TTL, || async {
|
||||
self.repository
|
||||
.fetch_anime_full(&slug)
|
||||
.await
|
||||
.map_err(|e| e.to_string())
|
||||
})
|
||||
.await
|
||||
.map_err(|e| DomainError::Scraping(ScrapingError::Http(e)))
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user