feat(api-server): cache puzzle gallery card view
This commit is contained in:
@@ -59,6 +59,7 @@ mod profile_identity;
|
||||
mod prompt;
|
||||
mod puzzle;
|
||||
mod puzzle_agent_turn;
|
||||
mod puzzle_gallery_cache;
|
||||
mod refresh_session;
|
||||
mod registration_reward;
|
||||
mod request_context;
|
||||
@@ -149,6 +150,7 @@ async fn run_server(config: AppConfig) -> Result<(), io::Error> {
|
||||
let state = restore_app_state_for_startup(config)
|
||||
.await
|
||||
.map_err(|error| std::io::Error::other(format!("初始化应用状态失败:{error}")))?;
|
||||
state.puzzle_gallery_cache().spawn_cleanup_task();
|
||||
let router = build_router(state);
|
||||
|
||||
info!(
|
||||
|
||||
@@ -38,7 +38,7 @@ use shared_contracts::{
|
||||
PuzzleResultPreviewBlockerResponse, PuzzleResultPreviewEnvelopeResponse,
|
||||
PuzzleResultPreviewFindingResponse, SendPuzzleAgentMessageRequest,
|
||||
},
|
||||
puzzle_gallery::{PuzzleGalleryDetailResponse, PuzzleGalleryResponse},
|
||||
puzzle_gallery::PuzzleGalleryDetailResponse,
|
||||
puzzle_runtime::{
|
||||
AdvancePuzzleNextLevelRequest, DragPuzzlePieceRequest, PuzzleBoardSnapshotResponse,
|
||||
PuzzleCellPositionResponse, PuzzleLeaderboardEntryResponse, PuzzleMergedGroupStateResponse,
|
||||
@@ -59,16 +59,16 @@ use spacetime_client::{
|
||||
PuzzleAgentSessionCreateRecordInput, PuzzleAgentSessionRecord,
|
||||
PuzzleAgentSuggestedActionRecord, PuzzleAnchorItemRecord, PuzzleAnchorPackRecord,
|
||||
PuzzleAudioAssetRecord, PuzzleCreatorIntentRecord, PuzzleDraftLevelRecord,
|
||||
PuzzleFormDraftRecord, PuzzleFormDraftSaveRecordInput, PuzzleGeneratedImageCandidateRecord,
|
||||
PuzzleGeneratedImagesSaveRecordInput, PuzzleLeaderboardEntryRecord,
|
||||
PuzzleLeaderboardSubmitRecordInput, PuzzlePublishRecordInput, PuzzleRecommendedNextWorkRecord,
|
||||
PuzzleResultDraftRecord, PuzzleResultPreviewBlockerRecord, PuzzleResultPreviewFindingRecord,
|
||||
PuzzleResultPreviewRecord, PuzzleRunDragRecordInput, PuzzleRunPauseRecordInput,
|
||||
PuzzleRunPropRecordInput, PuzzleRunRecord, PuzzleRunStartRecordInput, PuzzleRunSwapRecordInput,
|
||||
PuzzleSelectCoverImageRecordInput, PuzzleUiBackgroundSaveRecordInput,
|
||||
PuzzleWorkLikeReportRecordInput, PuzzleWorkPointIncentiveClaimRecordInput,
|
||||
PuzzleWorkProfileRecord, PuzzleWorkRemixRecordInput, PuzzleWorkUpsertRecordInput,
|
||||
SpacetimeClientError,
|
||||
PuzzleFormDraftRecord, PuzzleFormDraftSaveRecordInput, PuzzleGalleryCardRecord,
|
||||
PuzzleGeneratedImageCandidateRecord, PuzzleGeneratedImagesSaveRecordInput,
|
||||
PuzzleLeaderboardEntryRecord, PuzzleLeaderboardSubmitRecordInput, PuzzlePublishRecordInput,
|
||||
PuzzleRecommendedNextWorkRecord, PuzzleResultDraftRecord, PuzzleResultPreviewBlockerRecord,
|
||||
PuzzleResultPreviewFindingRecord, PuzzleResultPreviewRecord, PuzzleRunDragRecordInput,
|
||||
PuzzleRunPauseRecordInput, PuzzleRunPropRecordInput, PuzzleRunRecord,
|
||||
PuzzleRunStartRecordInput, PuzzleRunSwapRecordInput, PuzzleSelectCoverImageRecordInput,
|
||||
PuzzleUiBackgroundSaveRecordInput, PuzzleWorkLikeReportRecordInput,
|
||||
PuzzleWorkPointIncentiveClaimRecordInput, PuzzleWorkProfileRecord, PuzzleWorkRemixRecordInput,
|
||||
PuzzleWorkUpsertRecordInput, SpacetimeClientError,
|
||||
};
|
||||
use std::convert::Infallible;
|
||||
|
||||
@@ -103,6 +103,7 @@ use crate::{
|
||||
PuzzleAgentTurnRequest, build_failed_finalize_record_input, build_finalize_record_input,
|
||||
run_puzzle_agent_turn,
|
||||
},
|
||||
puzzle_gallery_cache::{build_puzzle_gallery_window_response, puzzle_gallery_cached_json},
|
||||
request_context::RequestContext,
|
||||
state::AppState,
|
||||
vector_engine_audio_generation::{
|
||||
@@ -1529,6 +1530,14 @@ pub async fn list_puzzle_gallery(
|
||||
State(state): State<AppState>,
|
||||
Extension(request_context): Extension<RequestContext>,
|
||||
) -> Result<Json<Value>, Response> {
|
||||
if let Some(response) = state.puzzle_gallery_cache().read_fresh_response().await {
|
||||
return Ok(puzzle_gallery_cached_json(&request_context, response));
|
||||
}
|
||||
let _rebuild_guard = state.puzzle_gallery_cache().acquire_rebuild_guard().await;
|
||||
if let Some(response) = state.puzzle_gallery_cache().read_fresh_response().await {
|
||||
return Ok(puzzle_gallery_cached_json(&request_context, response));
|
||||
}
|
||||
|
||||
let items = state
|
||||
.spacetime_client()
|
||||
.list_puzzle_gallery()
|
||||
@@ -1541,15 +1550,18 @@ pub async fn list_puzzle_gallery(
|
||||
)
|
||||
})?;
|
||||
|
||||
Ok(json_success_body(
|
||||
Some(&request_context),
|
||||
PuzzleGalleryResponse {
|
||||
items: items
|
||||
.into_iter()
|
||||
.map(|item| map_puzzle_work_summary_response(&state, item))
|
||||
.collect(),
|
||||
},
|
||||
))
|
||||
let response = build_puzzle_gallery_window_response(
|
||||
items
|
||||
.into_iter()
|
||||
.map(|item| map_puzzle_gallery_card_response(&state, item))
|
||||
.collect(),
|
||||
);
|
||||
state
|
||||
.puzzle_gallery_cache()
|
||||
.store_response(response.clone())
|
||||
.await;
|
||||
|
||||
Ok(json_success_body(Some(&request_context), response))
|
||||
}
|
||||
|
||||
pub async fn get_puzzle_gallery_detail(
|
||||
|
||||
@@ -342,6 +342,49 @@ pub(super) fn map_puzzle_work_summary_response(
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn map_puzzle_gallery_card_response(
|
||||
state: &AppState,
|
||||
item: PuzzleGalleryCardRecord,
|
||||
) -> PuzzleWorkSummaryResponse {
|
||||
let author = resolve_work_author_by_user_id(
|
||||
state,
|
||||
&item.owner_user_id,
|
||||
Some(&item.author_display_name),
|
||||
None,
|
||||
);
|
||||
PuzzleWorkSummaryResponse {
|
||||
work_id: item.work_id,
|
||||
profile_id: item.profile_id,
|
||||
owner_user_id: item.owner_user_id,
|
||||
source_session_id: item.source_session_id,
|
||||
author_display_name: author.display_name,
|
||||
work_title: item.work_title,
|
||||
work_description: item.work_description,
|
||||
level_name: item.level_name,
|
||||
summary: item.summary,
|
||||
theme_tags: item.theme_tags,
|
||||
cover_image_src: item.cover_image_src,
|
||||
cover_asset_id: item.cover_asset_id,
|
||||
publication_status: item.publication_status,
|
||||
updated_at: item.updated_at,
|
||||
published_at: item.published_at,
|
||||
play_count: item.play_count,
|
||||
remix_count: item.remix_count,
|
||||
like_count: item.like_count,
|
||||
recent_play_count_7d: item.recent_play_count_7d,
|
||||
point_incentive_total_half_points: item.point_incentive_total_half_points,
|
||||
point_incentive_claimed_points: item.point_incentive_claimed_points,
|
||||
point_incentive_total_points: item.point_incentive_total_half_points as f64 / 2.0,
|
||||
point_incentive_claimable_points: item
|
||||
.point_incentive_total_half_points
|
||||
.saturating_div(2)
|
||||
.saturating_sub(item.point_incentive_claimed_points),
|
||||
publish_ready: item.publish_ready,
|
||||
generation_status: item.generation_status,
|
||||
levels: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn map_puzzle_work_profile_response(
|
||||
state: &AppState,
|
||||
item: PuzzleWorkProfileRecord,
|
||||
|
||||
188
server-rs/crates/api-server/src/puzzle_gallery_cache.rs
Normal file
188
server-rs/crates/api-server/src/puzzle_gallery_cache.rs
Normal file
@@ -0,0 +1,188 @@
|
||||
use std::{
|
||||
sync::Arc,
|
||||
time::{Duration, Instant},
|
||||
};
|
||||
|
||||
use axum::Json;
|
||||
use serde_json::Value;
|
||||
use shared_contracts::{
|
||||
puzzle_gallery::{PuzzleGalleryResponse, PuzzleGalleryWorkRefResponse},
|
||||
puzzle_works::PuzzleWorkSummaryResponse,
|
||||
};
|
||||
use tokio::{
|
||||
sync::{Mutex, MutexGuard, RwLock},
|
||||
time,
|
||||
};
|
||||
|
||||
use crate::{api_response::json_success_body, 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 {
|
||||
response: PuzzleGalleryResponse,
|
||||
built_at: Instant,
|
||||
}
|
||||
|
||||
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<PuzzleGalleryResponse> {
|
||||
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(entry.response.clone())
|
||||
}
|
||||
|
||||
pub async fn store_response(&self, response: PuzzleGalleryResponse) {
|
||||
let now = Instant::now();
|
||||
*self.inner.write().await = Some(PuzzleGalleryCacheEntry {
|
||||
response,
|
||||
built_at: now,
|
||||
});
|
||||
}
|
||||
|
||||
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: PuzzleGalleryResponse,
|
||||
) -> Json<Value> {
|
||||
json_success_body(Some(request_context), response)
|
||||
}
|
||||
|
||||
#[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);
|
||||
}
|
||||
}
|
||||
@@ -31,6 +31,7 @@ use tokio::sync::Semaphore;
|
||||
use tracing::{info, warn};
|
||||
|
||||
use crate::config::AppConfig;
|
||||
use crate::puzzle_gallery_cache::PuzzleGalleryCache;
|
||||
use crate::wechat_pay::{WechatPayClient, map_wechat_pay_init_error};
|
||||
use crate::wechat_provider::build_wechat_provider;
|
||||
|
||||
@@ -64,6 +65,7 @@ pub struct AppState {
|
||||
#[cfg_attr(not(test), allow(dead_code))]
|
||||
ai_task_service: AiTaskService,
|
||||
spacetime_client: SpacetimeClient,
|
||||
puzzle_gallery_cache: PuzzleGalleryCache,
|
||||
llm_client: Option<LlmClient>,
|
||||
creative_agent_gpt5_client: Option<LlmClient>,
|
||||
creative_agent_executor: Arc<MockLangChainRustAgentExecutor>,
|
||||
@@ -223,6 +225,7 @@ impl AppState {
|
||||
wechat_pay_client,
|
||||
ai_task_service,
|
||||
spacetime_client,
|
||||
puzzle_gallery_cache: PuzzleGalleryCache::new(),
|
||||
llm_client,
|
||||
creative_agent_gpt5_client,
|
||||
creative_agent_executor: Arc::new(MockLangChainRustAgentExecutor),
|
||||
@@ -477,6 +480,10 @@ impl AppState {
|
||||
&self.spacetime_client
|
||||
}
|
||||
|
||||
pub fn puzzle_gallery_cache(&self) -> &PuzzleGalleryCache {
|
||||
&self.puzzle_gallery_cache
|
||||
}
|
||||
|
||||
pub fn llm_client(&self) -> Option<&LlmClient> {
|
||||
self.llm_client.as_ref()
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user