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:
asepharyana
2026-07-21 12:54:23 +07:00
co-authored by Claude Opus 4.8
parent 80c96eaa42
commit 776fa828d8
205 changed files with 4241 additions and 14569 deletions
+1
View File
@@ -0,0 +1 @@
pub mod use_cases;
+244
View File
@@ -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)))
}
}