209 lines
6.6 KiB
Rust
209 lines
6.6 KiB
Rust
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<RwLock<Option<PuzzleGalleryCacheEntry>>>,
|
|
rebuild_lock: Arc<Mutex<()>>,
|
|
}
|
|
|
|
#[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<PuzzleGalleryCachedResponse> {
|
|
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<PuzzleGalleryCachedResponse, serde_json::Error> {
|
|
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<PuzzleWorkSummaryResponse>,
|
|
) -> 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::<Vec<_>>();
|
|
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::<Vec<_>>());
|
|
|
|
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::<Vec<_>>());
|
|
|
|
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);
|
|
}
|
|
}
|