use std::{ sync::Arc, time::{Duration, Instant}, }; use axum::response::Response; use bytes::Bytes; use shared_contracts::{ puzzle_gallery::{PuzzleGalleryResponse, PuzzleGalleryWorkRefResponse}, puzzle_works::PuzzleWorkSummaryResponse, }; use tokio::{ sync::{Mutex, MutexGuard, RwLock}, time, }; use crate::{api_response::json_success_data_bytes_response, request_context::RequestContext}; const PUZZLE_GALLERY_PRIMARY_ITEM_COUNT: usize = 10; const PUZZLE_GALLERY_PREVIEW_REF_COUNT: usize = 10; const PUZZLE_GALLERY_CACHE_TTL: Duration = Duration::from_secs(5); const PUZZLE_GALLERY_CACHE_MAX_IDLE: Duration = Duration::from_secs(300); const PUZZLE_GALLERY_CACHE_CLEANUP_INTERVAL: Duration = Duration::from_secs(60); #[derive(Clone, Debug)] pub struct PuzzleGalleryCache { inner: Arc>>, rebuild_lock: Arc>, } #[derive(Clone, Debug)] struct PuzzleGalleryCacheEntry { data_json: Bytes, built_at: Instant, } #[derive(Clone, Debug)] pub struct PuzzleGalleryCachedResponse { data_json: Bytes, } impl PuzzleGalleryCachedResponse { pub fn data_json_len(&self) -> usize { self.data_json.len() } } impl PuzzleGalleryCache { pub fn new() -> Self { Self { inner: Arc::new(RwLock::new(None)), rebuild_lock: Arc::new(Mutex::new(())), } } pub async fn acquire_rebuild_guard(&self) -> MutexGuard<'_, ()> { self.rebuild_lock.lock().await } pub async fn read_fresh_response(&self) -> Option { let guard = self.inner.read().await; let entry = guard.as_ref()?; let now = Instant::now(); if now.duration_since(entry.built_at) > PUZZLE_GALLERY_CACHE_TTL { return None; } Some(PuzzleGalleryCachedResponse { data_json: entry.data_json.clone(), }) } pub async fn store_response( &self, response: PuzzleGalleryResponse, ) -> Result { let now = Instant::now(); let cached = PuzzleGalleryCachedResponse { data_json: Bytes::from(serde_json::to_vec(&response)?), }; *self.inner.write().await = Some(PuzzleGalleryCacheEntry { data_json: cached.data_json.clone(), built_at: now, }); Ok(cached) } pub fn spawn_cleanup_task(&self) { let cache = self.clone(); tokio::spawn(async move { let mut interval = time::interval(PUZZLE_GALLERY_CACHE_CLEANUP_INTERVAL); loop { interval.tick().await; cache.cleanup_idle_entry().await; } }); } async fn cleanup_idle_entry(&self) { let mut guard = self.inner.write().await; if let Some(entry) = guard.as_ref() && Instant::now().duration_since(entry.built_at) > PUZZLE_GALLERY_CACHE_MAX_IDLE { *guard = None; } } } pub fn build_puzzle_gallery_window_response( items: Vec, ) -> PuzzleGalleryResponse { let total_count = items.len().min(u32::MAX as usize) as u32; let preview_refs = items .iter() .skip(PUZZLE_GALLERY_PRIMARY_ITEM_COUNT) .take(PUZZLE_GALLERY_PREVIEW_REF_COUNT) .map(|item| PuzzleGalleryWorkRefResponse { work_id: item.work_id.clone(), profile_id: item.profile_id.clone(), }) .collect::>(); let next_cursor = items .get(PUZZLE_GALLERY_PRIMARY_ITEM_COUNT + PUZZLE_GALLERY_PREVIEW_REF_COUNT) .map(|item| item.profile_id.clone()); let has_more = items.len() > PUZZLE_GALLERY_PRIMARY_ITEM_COUNT + PUZZLE_GALLERY_PREVIEW_REF_COUNT; PuzzleGalleryResponse { items: items .into_iter() .take(PUZZLE_GALLERY_PRIMARY_ITEM_COUNT) .collect(), preview_refs, has_more, next_cursor, total_count, } } pub fn puzzle_gallery_cached_json( request_context: &RequestContext, response: PuzzleGalleryCachedResponse, ) -> Response { json_success_data_bytes_response(Some(request_context), response.data_json) } #[cfg(test)] mod tests { use super::*; fn build_summary(index: usize) -> PuzzleWorkSummaryResponse { PuzzleWorkSummaryResponse { work_id: format!("work-{index}"), profile_id: format!("profile-{index}"), owner_user_id: "user-1".to_string(), source_session_id: None, author_display_name: "作者".to_string(), work_title: format!("作品 {index}"), work_description: "描述".to_string(), level_name: "第一关".to_string(), summary: "摘要".to_string(), theme_tags: Vec::new(), cover_image_src: None, cover_asset_id: None, publication_status: "published".to_string(), updated_at: "2026-05-01T00:00:00Z".to_string(), published_at: None, play_count: 0, remix_count: 0, like_count: 0, recent_play_count_7d: 0, point_incentive_total_half_points: 0, point_incentive_claimed_points: 0, point_incentive_total_points: 0.0, point_incentive_claimable_points: 0, publish_ready: true, generation_status: Some("ready".to_string()), levels: Vec::new(), } } #[test] fn build_window_returns_primary_cards_preview_refs_and_cursor() { let response = build_puzzle_gallery_window_response((0..25).map(build_summary).collect::>()); assert_eq!(response.total_count, 25); assert_eq!(response.items.len(), 10); assert_eq!(response.preview_refs.len(), 10); assert_eq!(response.items[0].profile_id, "profile-0"); assert_eq!(response.items[9].profile_id, "profile-9"); assert_eq!(response.preview_refs[0].profile_id, "profile-10"); assert_eq!(response.preview_refs[9].profile_id, "profile-19"); assert!(response.has_more); assert_eq!(response.next_cursor.as_deref(), Some("profile-20")); } #[test] fn build_window_handles_short_gallery_without_more_cursor() { let response = build_puzzle_gallery_window_response((0..8).map(build_summary).collect::>()); assert_eq!(response.total_count, 8); assert_eq!(response.items.len(), 8); assert!(response.preview_refs.is_empty()); assert!(!response.has_more); assert_eq!(response.next_cursor, None); } }