feat(api-server): cache puzzle gallery card view

This commit is contained in:
kdletters
2026-05-17 05:50:33 +08:00
parent 02271e6c73
commit 73f937d78a
17 changed files with 771 additions and 44 deletions

View File

@@ -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!(

View File

@@ -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(

View File

@@ -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,

View 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);
}
}

View File

@@ -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()
}