From 73f937d78ab8f967f40654a07e746221e3b2c05d Mon Sep 17 00:00:00 2001 From: kdletters <61648117+kdletters@users.noreply.github.com> Date: Sun, 17 May 2026 05:50:33 +0800 Subject: [PATCH] feat(api-server): cache puzzle gallery card view --- .hermes/shared-memory/pitfalls.md | 6 +- ...】server-rs与SpacetimeDB数据契约-2026-05-15.md | 18 +- .../shared/src/contracts/puzzleWorkSummary.ts | 13 ++ server-rs/crates/api-server/src/main.rs | 2 + server-rs/crates/api-server/src/puzzle.rs | 52 +++-- .../crates/api-server/src/puzzle/mappers.rs | 43 ++++ .../api-server/src/puzzle_gallery_cache.rs | 188 ++++++++++++++++++ server-rs/crates/api-server/src/state.rs | 7 + .../shared-contracts/src/puzzle_gallery.rs | 15 ++ server-rs/crates/spacetime-client/src/lib.rs | 4 +- .../crates/spacetime-client/src/mapper.rs | 59 +++++- .../src/module_bindings/mod.rs | 26 +++ .../puzzle_gallery_card_view_row_type.rs | 110 ++++++++++ .../puzzle_gallery_card_view_table.rs | 115 +++++++++++ .../crates/spacetime-client/src/puzzle.rs | 18 +- .../crates/spacetime-module/src/puzzle.rs | 136 ++++++++++++- .../puzzle-gallery/puzzleGalleryClient.ts | 3 +- 17 files changed, 771 insertions(+), 44 deletions(-) create mode 100644 server-rs/crates/api-server/src/puzzle_gallery_cache.rs create mode 100644 server-rs/crates/spacetime-client/src/module_bindings/puzzle_gallery_card_view_row_type.rs create mode 100644 server-rs/crates/spacetime-client/src/module_bindings/puzzle_gallery_card_view_table.rs diff --git a/.hermes/shared-memory/pitfalls.md b/.hermes/shared-memory/pitfalls.md index 6258385c..1069ff7a 100644 --- a/.hermes/shared-memory/pitfalls.md +++ b/.hermes/shared-memory/pitfalls.md @@ -95,9 +95,9 @@ - 现象:`/api/runtime/puzzle/gallery` 每个请求都走 `spacetime-client.list_puzzle_gallery()` 调用 SpacetimeDB procedure,导致 SpacetimeDB WASM 侧重复组装全量列表,客户端再映射一遍;历史实现还出现过 procedure JSON 字符串往返。 - 原因:`api-server` 的服务器端 `spacetime-client` 没有订阅可公开读取的 gallery 投影,虽然 SDK 支持 client cache,但请求路径仍把列表读取当作 procedure 调用。 -- 处理:`spacetime-module` 中用 public view `puzzle_gallery_view` 暴露已发布拼图作品;`spacetime-client` 建连接后订阅 `SELECT * FROM puzzle_gallery_view` 和 `SELECT * FROM public_work_play_daily_stat WHERE source_type = 'puzzle'` 并等待 `on_applied`,HTTP gallery 只从 `connection.db().puzzle_gallery_view().iter()` 本地 cache 读取和排序,再用已同步的 `public_work_play_daily_stat` 在本地聚合 7 日播放数。旧 `list_puzzle_gallery` procedure 只作兼容,不再作为 HTTP gallery 主路径。 -- 验证:搜索 `server-rs/crates/spacetime-client/src/puzzle.rs` 不应再出现 gallery 主路径调用 `list_puzzle_gallery_then`;执行 `cargo check --manifest-path server-rs/Cargo.toml -p spacetime-client`、`cargo check --manifest-path server-rs/Cargo.toml -p api-server` 和 schema/runtime access 检查。 -- 关联:`server-rs/crates/spacetime-module/src/puzzle.rs`、`server-rs/crates/spacetime-client/src/lib.rs`、`server-rs/crates/spacetime-client/src/puzzle.rs`、`/api/runtime/puzzle/gallery`。 +- 处理:`spacetime-module` 中用 public view `puzzle_gallery_card_view` 暴露已发布拼图作品的列表卡片字段,不携带 `levels` / `anchor_pack` 等详情级载荷;`spacetime-client` 建连接后订阅 `SELECT * FROM puzzle_gallery_card_view` 和 `SELECT * FROM public_work_play_daily_stat WHERE source_type = 'puzzle'` 并等待 `on_applied`。HTTP gallery 通过 `PuzzleGalleryCache` 缓存最终 `PuzzleGalleryResponse` DTO:`items` 返回前 10 个完整卡片,`previewRefs` 返回后 10 个作品号引用,cache miss / TTL 过期时单飞重建,后台 cleanup task 周期清理旧响应。旧 `list_puzzle_gallery` procedure 只作兼容,不再作为 HTTP gallery 主路径。 +- 验证:搜索 `server-rs/crates/spacetime-client/src/puzzle.rs` 不应再出现 gallery 主路径调用 `list_puzzle_gallery_then`;搜索 `server-rs/crates/spacetime-client/src/lib.rs` 应订阅 `puzzle_gallery_card_view`;执行 `npm run spacetime:generate`、`cargo check --manifest-path server-rs/Cargo.toml -p spacetime-client`、`cargo check --manifest-path server-rs/Cargo.toml -p api-server` 和 schema/runtime access 检查。 +- 关联:`server-rs/crates/spacetime-module/src/puzzle.rs`、`server-rs/crates/spacetime-client/src/lib.rs`、`server-rs/crates/spacetime-client/src/puzzle.rs`、`server-rs/crates/api-server/src/puzzle_gallery_cache.rs`、`/api/runtime/puzzle/gallery`。 ## 多玩法公开广场列表优先订阅 public view / read model diff --git a/docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md b/docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md index 51f21b1b..478bf090 100644 --- a/docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md +++ b/docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md @@ -486,13 +486,27 @@ npm run check:server-rs-ddd - Rust view:`puzzle_gallery_view` - 返回类型:`Vec` - 源码:`server-rs/crates/spacetime-module/src/puzzle.rs` -- 说明:拼图广场公开列表投影,只暴露 `publication_status = Published` 的作品;`api-server` 的 `spacetime-client` 长期订阅 `SELECT * FROM puzzle_gallery_view` 与 `SELECT * FROM public_work_play_daily_stat WHERE source_type = 'puzzle'` 后,从本地 cache 构造 `/api/runtime/puzzle/gallery` 响应,并在本地按当前请求时间聚合 `recentPlayCount7d`,不再每个 HTTP 请求调用 `list_puzzle_gallery` procedure。 +- 说明:拼图广场公开详情兼容投影,只暴露 `publication_status = Published` 的作品,但返回完整 `PuzzleWorkProfile`,包含 levels / anchor_pack 等详情级字段;公开列表主路径不再订阅该 view。 + +### SpacetimeDB view:`puzzle_gallery_card_view` + +- Rust view:`puzzle_gallery_card_view` +- 返回类型:`Vec` +- 源码:`server-rs/crates/spacetime-module/src/puzzle.rs` +- 说明:拼图广场公开列表卡片投影,只暴露前端列表卡片需要的公开字段,不携带 levels / anchor_pack 等详情级载荷;`api-server` 的 `spacetime-client` 长期订阅 `SELECT * FROM puzzle_gallery_card_view` 与 `SELECT * FROM public_work_play_daily_stat WHERE source_type = 'puzzle'` 后,从本地 cache 构造 `/api/runtime/puzzle/gallery` 响应,并在本地按当前请求时间聚合 `recentPlayCount7d`,不再每个 HTTP 请求调用 `list_puzzle_gallery` procedure。 + +### 拼图公开列表 HTTP 窗口缓存 + +- 接口:`GET /api/runtime/puzzle/gallery` +- 响应契约:保留 `items` 字段兼容旧前端;当前 `items` 只返回前 10 个完整卡片,新增 `previewRefs` 返回后 10 个 `workId/profileId` 引用,并返回 `hasMore`、`nextCursor` 与 `totalCount`。 +- 缓存策略:`api-server` 在 `PuzzleGalleryCache` 中缓存最终 `PuzzleGalleryResponse` DTO。缓存 miss / 过期时单飞重建,避免并发请求重复排序、映射和 JSON DTO 构造;缓存短 TTL 刷新 `recentPlayCount7d`,后台 cleanup task 周期清理超过最大空闲窗口的旧响应。 +- 详情路径:公开详情、点赞、游玩记录和 Remix 仍按原有 procedure / reducer 路径处理;前端拿到 `previewRefs` 后如果需要展开更多内容,应优先使用后续列表窗口能力或详情 cache,不要把自动详情预取变成新的 procedure 热点。 ### api-server 长期订阅读模型 `spacetime-client` 建立每个池连接时会等待下列订阅初始同步: -- `SELECT * FROM puzzle_gallery_view` +- `SELECT * FROM puzzle_gallery_card_view` - `SELECT * FROM custom_world_gallery_entry` - `SELECT * FROM match_3_d_gallery_view` - `SELECT * FROM square_hole_gallery_view` diff --git a/packages/shared/src/contracts/puzzleWorkSummary.ts b/packages/shared/src/contracts/puzzleWorkSummary.ts index b1e69499..64678bb4 100644 --- a/packages/shared/src/contracts/puzzleWorkSummary.ts +++ b/packages/shared/src/contracts/puzzleWorkSummary.ts @@ -42,6 +42,19 @@ export interface PuzzleWorksResponse { items: PuzzleWorkSummary[]; } +export interface PuzzleGalleryWorkRef { + workId: string; + profileId: string; +} + +export interface PuzzleGalleryResponse { + items: PuzzleWorkSummary[]; + previewRefs?: PuzzleGalleryWorkRef[]; + hasMore?: boolean; + nextCursor?: string | null; + totalCount?: number; +} + export interface PuzzleWorkDetailResponse { item: PuzzleWorkProfile; } diff --git a/server-rs/crates/api-server/src/main.rs b/server-rs/crates/api-server/src/main.rs index 40e880b3..4c758721 100644 --- a/server-rs/crates/api-server/src/main.rs +++ b/server-rs/crates/api-server/src/main.rs @@ -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!( diff --git a/server-rs/crates/api-server/src/puzzle.rs b/server-rs/crates/api-server/src/puzzle.rs index 24999dff..cdf24f7e 100644 --- a/server-rs/crates/api-server/src/puzzle.rs +++ b/server-rs/crates/api-server/src/puzzle.rs @@ -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, Extension(request_context): Extension, ) -> Result, 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( diff --git a/server-rs/crates/api-server/src/puzzle/mappers.rs b/server-rs/crates/api-server/src/puzzle/mappers.rs index daefe7d3..e988809c 100644 --- a/server-rs/crates/api-server/src/puzzle/mappers.rs +++ b/server-rs/crates/api-server/src/puzzle/mappers.rs @@ -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, diff --git a/server-rs/crates/api-server/src/puzzle_gallery_cache.rs b/server-rs/crates/api-server/src/puzzle_gallery_cache.rs new file mode 100644 index 00000000..179cb091 --- /dev/null +++ b/server-rs/crates/api-server/src/puzzle_gallery_cache.rs @@ -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>>, + rebuild_lock: Arc>, +} + +#[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 { + 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, +) -> 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: PuzzleGalleryResponse, +) -> Json { + 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::>()); + + 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); + } +} diff --git a/server-rs/crates/api-server/src/state.rs b/server-rs/crates/api-server/src/state.rs index 64cc88b4..2e2e690e 100644 --- a/server-rs/crates/api-server/src/state.rs +++ b/server-rs/crates/api-server/src/state.rs @@ -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, creative_agent_gpt5_client: Option, creative_agent_executor: Arc, @@ -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() } diff --git a/server-rs/crates/shared-contracts/src/puzzle_gallery.rs b/server-rs/crates/shared-contracts/src/puzzle_gallery.rs index daed2603..ecf89149 100644 --- a/server-rs/crates/shared-contracts/src/puzzle_gallery.rs +++ b/server-rs/crates/shared-contracts/src/puzzle_gallery.rs @@ -6,6 +6,21 @@ use crate::puzzle_works::{PuzzleWorkProfileResponse, PuzzleWorkSummaryResponse}; #[serde(rename_all = "camelCase")] pub struct PuzzleGalleryResponse { pub items: Vec, + #[serde(default)] + pub preview_refs: Vec, + #[serde(default)] + pub has_more: bool, + #[serde(default)] + pub next_cursor: Option, + #[serde(default)] + pub total_count: u32, +} + +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct PuzzleGalleryWorkRefResponse { + pub work_id: String, + pub profile_id: String, } #[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] diff --git a/server-rs/crates/spacetime-client/src/lib.rs b/server-rs/crates/spacetime-client/src/lib.rs index 08a17274..be1f7e99 100644 --- a/server-rs/crates/spacetime-client/src/lib.rs +++ b/server-rs/crates/spacetime-client/src/lib.rs @@ -44,7 +44,7 @@ pub use mapper::{ PuzzleAgentSessionRecord, PuzzleAgentSuggestedActionRecord, PuzzleAnchorItemRecord, PuzzleAnchorPackRecord, PuzzleAudioAssetRecord, PuzzleBoardRecord, PuzzleCellPositionRecord, PuzzleCreatorIntentRecord, PuzzleDraftLevelRecord, PuzzleFormDraftRecord, - PuzzleFormDraftSaveRecordInput, PuzzleGeneratedImageCandidateRecord, + PuzzleFormDraftSaveRecordInput, PuzzleGalleryCardRecord, PuzzleGeneratedImageCandidateRecord, PuzzleGeneratedImagesSaveRecordInput, PuzzleLeaderboardEntryRecord, PuzzleLeaderboardSubmitRecordInput, PuzzleMergedGroupRecord, PuzzlePieceStateRecord, PuzzlePublishRecordInput, PuzzleRecommendedNextWorkRecord, PuzzleResultDraftRecord, @@ -540,7 +540,7 @@ impl SpacetimeClient { ) -> Result, SpacetimeClientError> { let mut subscriptions = Vec::new(); for query in [ - "SELECT * FROM puzzle_gallery_view", + "SELECT * FROM puzzle_gallery_card_view", "SELECT * FROM custom_world_gallery_entry", "SELECT * FROM match_3_d_gallery_view", "SELECT * FROM square_hole_gallery_view", diff --git a/server-rs/crates/spacetime-client/src/mapper.rs b/server-rs/crates/spacetime-client/src/mapper.rs index 8ee6a7c5..6ee3b1ca 100644 --- a/server-rs/crates/spacetime-client/src/mapper.rs +++ b/server-rs/crates/spacetime-client/src/mapper.rs @@ -4027,8 +4027,36 @@ pub(crate) fn map_puzzle_work_profile(snapshot: PuzzleWorkProfile) -> PuzzleWork } } -pub(crate) fn map_puzzle_work_profile_row(snapshot: PuzzleWorkProfile) -> PuzzleWorkProfileRecord { - map_puzzle_work_profile(snapshot) +pub(crate) fn map_puzzle_gallery_card_view_row( + snapshot: PuzzleGalleryCardViewRow, + recent_play_count_7d: u32, +) -> PuzzleGalleryCardRecord { + PuzzleGalleryCardRecord { + work_id: snapshot.work_id, + profile_id: snapshot.profile_id, + owner_user_id: snapshot.owner_user_id, + source_session_id: snapshot.source_session_id, + author_display_name: snapshot.author_display_name, + work_title: snapshot.work_title, + work_description: snapshot.work_description, + level_name: snapshot.level_name, + summary: snapshot.summary, + theme_tags: snapshot.theme_tags, + cover_image_src: snapshot.cover_image_src, + cover_asset_id: snapshot.cover_asset_id, + publication_status: format_puzzle_publication_status(snapshot.publication_status) + .to_string(), + updated_at: format_timestamp_micros(snapshot.updated_at_micros), + published_at: snapshot.published_at_micros.map(format_timestamp_micros), + play_count: snapshot.play_count, + remix_count: snapshot.remix_count, + like_count: snapshot.like_count, + recent_play_count_7d, + point_incentive_total_half_points: snapshot.point_incentive_total_half_points, + point_incentive_claimed_points: snapshot.point_incentive_claimed_points, + publish_ready: snapshot.publish_ready, + generation_status: snapshot.generation_status, + } } pub(crate) fn map_puzzle_run_snapshot(snapshot: PuzzleRunSnapshot) -> PuzzleRunRecord { @@ -7412,6 +7440,33 @@ pub struct PuzzleWorkProfileRecord { pub levels: Vec, } +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct PuzzleGalleryCardRecord { + pub work_id: String, + pub profile_id: String, + pub owner_user_id: String, + pub source_session_id: Option, + pub author_display_name: String, + pub work_title: String, + pub work_description: String, + pub level_name: String, + pub summary: String, + pub theme_tags: Vec, + pub cover_image_src: Option, + pub cover_asset_id: Option, + pub publication_status: String, + pub updated_at: String, + pub published_at: Option, + pub play_count: u32, + pub remix_count: u32, + pub like_count: u32, + pub recent_play_count_7d: u32, + pub point_incentive_total_half_points: u64, + pub point_incentive_claimed_points: u64, + pub publish_ready: bool, + pub generation_status: Option, +} + #[derive(Clone, Debug, PartialEq, Eq)] pub struct PuzzleWorkPointIncentiveClaimRecordInput { pub profile_id: String, diff --git a/server-rs/crates/spacetime-client/src/module_bindings/mod.rs b/server-rs/crates/spacetime-client/src/module_bindings/mod.rs index b828cfa5..984ccd36 100644 --- a/server-rs/crates/spacetime-client/src/module_bindings/mod.rs +++ b/server-rs/crates/spacetime-client/src/module_bindings/mod.rs @@ -542,6 +542,8 @@ pub mod puzzle_event_table; pub mod puzzle_event_type; pub mod puzzle_form_draft_save_input_type; pub mod puzzle_form_draft_type; +pub mod puzzle_gallery_card_view_row_type; +pub mod puzzle_gallery_card_view_table; pub mod puzzle_gallery_view_table; pub mod puzzle_generated_image_candidate_type; pub mod puzzle_generated_images_save_input_type; @@ -1464,6 +1466,8 @@ pub use puzzle_event_table::*; pub use puzzle_event_type::PuzzleEvent; pub use puzzle_form_draft_save_input_type::PuzzleFormDraftSaveInput; pub use puzzle_form_draft_type::PuzzleFormDraft; +pub use puzzle_gallery_card_view_row_type::PuzzleGalleryCardViewRow; +pub use puzzle_gallery_card_view_table::*; pub use puzzle_gallery_view_table::*; pub use puzzle_generated_image_candidate_type::PuzzleGeneratedImageCandidate; pub use puzzle_generated_images_save_input_type::PuzzleGeneratedImagesSaveInput; @@ -2188,6 +2192,7 @@ pub struct DbUpdate { puzzle_agent_message: __sdk::TableUpdate, puzzle_agent_session: __sdk::TableUpdate, puzzle_event: __sdk::TableUpdate, + puzzle_gallery_card_view: __sdk::TableUpdate, puzzle_gallery_view: __sdk::TableUpdate, puzzle_leaderboard_entry: __sdk::TableUpdate, puzzle_runtime_run: __sdk::TableUpdate, @@ -2432,6 +2437,9 @@ impl TryFrom<__ws::v2::TransactionUpdate> for DbUpdate { "puzzle_event" => db_update .puzzle_event .append(puzzle_event_table::parse_table_update(table_update)?), + "puzzle_gallery_card_view" => db_update.puzzle_gallery_card_view.append( + puzzle_gallery_card_view_table::parse_table_update(table_update)?, + ), "puzzle_gallery_view" => db_update .puzzle_gallery_view .append(puzzle_gallery_view_table::parse_table_update(table_update)?), @@ -3004,6 +3012,10 @@ impl __sdk::DbUpdate for DbUpdate { "match_3_d_gallery_view", &self.match_3_d_gallery_view, ); + diff.puzzle_gallery_card_view = cache.apply_diff_to_table::( + "puzzle_gallery_card_view", + &self.puzzle_gallery_card_view, + ); diff.puzzle_gallery_view = cache.apply_diff_to_table::( "puzzle_gallery_view", &self.puzzle_gallery_view, @@ -3221,6 +3233,9 @@ impl __sdk::DbUpdate for DbUpdate { "puzzle_event" => db_update .puzzle_event .append(__sdk::parse_row_list_as_inserts(table_rows.rows)?), + "puzzle_gallery_card_view" => db_update + .puzzle_gallery_card_view + .append(__sdk::parse_row_list_as_inserts(table_rows.rows)?), "puzzle_gallery_view" => db_update .puzzle_gallery_view .append(__sdk::parse_row_list_as_inserts(table_rows.rows)?), @@ -3516,6 +3531,9 @@ impl __sdk::DbUpdate for DbUpdate { "puzzle_event" => db_update .puzzle_event .append(__sdk::parse_row_list_as_deletes(table_rows.rows)?), + "puzzle_gallery_card_view" => db_update + .puzzle_gallery_card_view + .append(__sdk::parse_row_list_as_deletes(table_rows.rows)?), "puzzle_gallery_view" => db_update .puzzle_gallery_view .append(__sdk::parse_row_list_as_deletes(table_rows.rows)?), @@ -3683,6 +3701,7 @@ pub struct AppliedDiff<'r> { puzzle_agent_message: __sdk::TableAppliedDiff<'r, PuzzleAgentMessageRow>, puzzle_agent_session: __sdk::TableAppliedDiff<'r, PuzzleAgentSessionRow>, puzzle_event: __sdk::TableAppliedDiff<'r, PuzzleEvent>, + puzzle_gallery_card_view: __sdk::TableAppliedDiff<'r, PuzzleGalleryCardViewRow>, puzzle_gallery_view: __sdk::TableAppliedDiff<'r, PuzzleWorkProfile>, puzzle_leaderboard_entry: __sdk::TableAppliedDiff<'r, PuzzleLeaderboardEntryRow>, puzzle_runtime_run: __sdk::TableAppliedDiff<'r, PuzzleRuntimeRunRow>, @@ -4043,6 +4062,11 @@ impl<'r> __sdk::AppliedDiff<'r> for AppliedDiff<'r> { &self.puzzle_event, event, ); + callbacks.invoke_table_row_callbacks::( + "puzzle_gallery_card_view", + &self.puzzle_gallery_card_view, + event, + ); callbacks.invoke_table_row_callbacks::( "puzzle_gallery_view", &self.puzzle_gallery_view, @@ -4901,6 +4925,7 @@ impl __sdk::SpacetimeModule for RemoteModule { puzzle_agent_message_table::register_table(client_cache); puzzle_agent_session_table::register_table(client_cache); puzzle_event_table::register_table(client_cache); + puzzle_gallery_card_view_table::register_table(client_cache); puzzle_gallery_view_table::register_table(client_cache); puzzle_leaderboard_entry_table::register_table(client_cache); puzzle_runtime_run_table::register_table(client_cache); @@ -4997,6 +5022,7 @@ impl __sdk::SpacetimeModule for RemoteModule { "puzzle_agent_message", "puzzle_agent_session", "puzzle_event", + "puzzle_gallery_card_view", "puzzle_gallery_view", "puzzle_leaderboard_entry", "puzzle_runtime_run", diff --git a/server-rs/crates/spacetime-client/src/module_bindings/puzzle_gallery_card_view_row_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/puzzle_gallery_card_view_row_type.rs new file mode 100644 index 00000000..3828a2c2 --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/puzzle_gallery_card_view_row_type.rs @@ -0,0 +1,110 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +use super::puzzle_publication_status_type::PuzzlePublicationStatus; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +pub struct PuzzleGalleryCardViewRow { + pub work_id: String, + pub profile_id: String, + pub owner_user_id: String, + pub source_session_id: Option, + pub author_display_name: String, + pub work_title: String, + pub work_description: String, + pub level_name: String, + pub summary: String, + pub theme_tags: Vec, + pub cover_image_src: Option, + pub cover_asset_id: Option, + pub publication_status: PuzzlePublicationStatus, + pub updated_at_micros: i64, + pub published_at_micros: Option, + pub play_count: u32, + pub remix_count: u32, + pub like_count: u32, + pub point_incentive_total_half_points: u64, + pub point_incentive_claimed_points: u64, + pub publish_ready: bool, + pub generation_status: Option, +} + +impl __sdk::InModule for PuzzleGalleryCardViewRow { + type Module = super::RemoteModule; +} + +/// Column accessor struct for the table `PuzzleGalleryCardViewRow`. +/// +/// Provides typed access to columns for query building. +pub struct PuzzleGalleryCardViewRowCols { + pub work_id: __sdk::__query_builder::Col, + pub profile_id: __sdk::__query_builder::Col, + pub owner_user_id: __sdk::__query_builder::Col, + pub source_session_id: __sdk::__query_builder::Col>, + pub author_display_name: __sdk::__query_builder::Col, + pub work_title: __sdk::__query_builder::Col, + pub work_description: __sdk::__query_builder::Col, + pub level_name: __sdk::__query_builder::Col, + pub summary: __sdk::__query_builder::Col, + pub theme_tags: __sdk::__query_builder::Col>, + pub cover_image_src: __sdk::__query_builder::Col>, + pub cover_asset_id: __sdk::__query_builder::Col>, + pub publication_status: + __sdk::__query_builder::Col, + pub updated_at_micros: __sdk::__query_builder::Col, + pub published_at_micros: __sdk::__query_builder::Col>, + pub play_count: __sdk::__query_builder::Col, + pub remix_count: __sdk::__query_builder::Col, + pub like_count: __sdk::__query_builder::Col, + pub point_incentive_total_half_points: + __sdk::__query_builder::Col, + pub point_incentive_claimed_points: __sdk::__query_builder::Col, + pub publish_ready: __sdk::__query_builder::Col, + pub generation_status: __sdk::__query_builder::Col>, +} + +impl __sdk::__query_builder::HasCols for PuzzleGalleryCardViewRow { + type Cols = PuzzleGalleryCardViewRowCols; + fn cols(table_name: &'static str) -> Self::Cols { + PuzzleGalleryCardViewRowCols { + work_id: __sdk::__query_builder::Col::new(table_name, "work_id"), + profile_id: __sdk::__query_builder::Col::new(table_name, "profile_id"), + owner_user_id: __sdk::__query_builder::Col::new(table_name, "owner_user_id"), + source_session_id: __sdk::__query_builder::Col::new(table_name, "source_session_id"), + author_display_name: __sdk::__query_builder::Col::new( + table_name, + "author_display_name", + ), + work_title: __sdk::__query_builder::Col::new(table_name, "work_title"), + work_description: __sdk::__query_builder::Col::new(table_name, "work_description"), + level_name: __sdk::__query_builder::Col::new(table_name, "level_name"), + summary: __sdk::__query_builder::Col::new(table_name, "summary"), + theme_tags: __sdk::__query_builder::Col::new(table_name, "theme_tags"), + cover_image_src: __sdk::__query_builder::Col::new(table_name, "cover_image_src"), + cover_asset_id: __sdk::__query_builder::Col::new(table_name, "cover_asset_id"), + publication_status: __sdk::__query_builder::Col::new(table_name, "publication_status"), + updated_at_micros: __sdk::__query_builder::Col::new(table_name, "updated_at_micros"), + published_at_micros: __sdk::__query_builder::Col::new( + table_name, + "published_at_micros", + ), + play_count: __sdk::__query_builder::Col::new(table_name, "play_count"), + remix_count: __sdk::__query_builder::Col::new(table_name, "remix_count"), + like_count: __sdk::__query_builder::Col::new(table_name, "like_count"), + point_incentive_total_half_points: __sdk::__query_builder::Col::new( + table_name, + "point_incentive_total_half_points", + ), + point_incentive_claimed_points: __sdk::__query_builder::Col::new( + table_name, + "point_incentive_claimed_points", + ), + publish_ready: __sdk::__query_builder::Col::new(table_name, "publish_ready"), + generation_status: __sdk::__query_builder::Col::new(table_name, "generation_status"), + } + } +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/puzzle_gallery_card_view_table.rs b/server-rs/crates/spacetime-client/src/module_bindings/puzzle_gallery_card_view_table.rs new file mode 100644 index 00000000..58c1659b --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/puzzle_gallery_card_view_table.rs @@ -0,0 +1,115 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use super::puzzle_gallery_card_view_row_type::PuzzleGalleryCardViewRow; +use super::puzzle_publication_status_type::PuzzlePublicationStatus; +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +/// Table handle for the table `puzzle_gallery_card_view`. +/// +/// Obtain a handle from the [`PuzzleGalleryCardViewTableAccess::puzzle_gallery_card_view`] method on [`super::RemoteTables`], +/// like `ctx.db.puzzle_gallery_card_view()`. +/// +/// Users are encouraged not to explicitly reference this type, +/// but to directly chain method calls, +/// like `ctx.db.puzzle_gallery_card_view().on_insert(...)`. +pub struct PuzzleGalleryCardViewTableHandle<'ctx> { + imp: __sdk::TableHandle, + ctx: std::marker::PhantomData<&'ctx super::RemoteTables>, +} + +#[allow(non_camel_case_types)] +/// Extension trait for access to the table `puzzle_gallery_card_view`. +/// +/// Implemented for [`super::RemoteTables`]. +pub trait PuzzleGalleryCardViewTableAccess { + #[allow(non_snake_case)] + /// Obtain a [`PuzzleGalleryCardViewTableHandle`], which mediates access to the table `puzzle_gallery_card_view`. + fn puzzle_gallery_card_view(&self) -> PuzzleGalleryCardViewTableHandle<'_>; +} + +impl PuzzleGalleryCardViewTableAccess for super::RemoteTables { + fn puzzle_gallery_card_view(&self) -> PuzzleGalleryCardViewTableHandle<'_> { + PuzzleGalleryCardViewTableHandle { + imp: self + .imp + .get_table::("puzzle_gallery_card_view"), + ctx: std::marker::PhantomData, + } + } +} + +pub struct PuzzleGalleryCardViewInsertCallbackId(__sdk::CallbackId); +pub struct PuzzleGalleryCardViewDeleteCallbackId(__sdk::CallbackId); + +impl<'ctx> __sdk::Table for PuzzleGalleryCardViewTableHandle<'ctx> { + type Row = PuzzleGalleryCardViewRow; + type EventContext = super::EventContext; + + fn count(&self) -> u64 { + self.imp.count() + } + fn iter(&self) -> impl Iterator + '_ { + self.imp.iter() + } + + type InsertCallbackId = PuzzleGalleryCardViewInsertCallbackId; + + fn on_insert( + &self, + callback: impl FnMut(&Self::EventContext, &Self::Row) + Send + 'static, + ) -> PuzzleGalleryCardViewInsertCallbackId { + PuzzleGalleryCardViewInsertCallbackId(self.imp.on_insert(Box::new(callback))) + } + + fn remove_on_insert(&self, callback: PuzzleGalleryCardViewInsertCallbackId) { + self.imp.remove_on_insert(callback.0) + } + + type DeleteCallbackId = PuzzleGalleryCardViewDeleteCallbackId; + + fn on_delete( + &self, + callback: impl FnMut(&Self::EventContext, &Self::Row) + Send + 'static, + ) -> PuzzleGalleryCardViewDeleteCallbackId { + PuzzleGalleryCardViewDeleteCallbackId(self.imp.on_delete(Box::new(callback))) + } + + fn remove_on_delete(&self, callback: PuzzleGalleryCardViewDeleteCallbackId) { + self.imp.remove_on_delete(callback.0) + } +} + +#[doc(hidden)] +pub(super) fn register_table(client_cache: &mut __sdk::ClientCache) { + let _table = + client_cache.get_or_make_table::("puzzle_gallery_card_view"); +} + +#[doc(hidden)] +pub(super) fn parse_table_update( + raw_updates: __ws::v2::TableUpdate, +) -> __sdk::Result<__sdk::TableUpdate> { + __sdk::TableUpdate::parse_table_update(raw_updates).map_err(|e| { + __sdk::InternalError::failed_parse("TableUpdate", "TableUpdate") + .with_cause(e) + .into() + }) +} + +#[allow(non_camel_case_types)] +/// Extension trait for query builder access to the table `PuzzleGalleryCardViewRow`. +/// +/// Implemented for [`__sdk::QueryTableAccessor`]. +pub trait puzzle_gallery_card_viewQueryTableAccess { + #[allow(non_snake_case)] + /// Get a query builder for the table `PuzzleGalleryCardViewRow`. + fn puzzle_gallery_card_view(&self) -> __sdk::__query_builder::Table; +} + +impl puzzle_gallery_card_viewQueryTableAccess for __sdk::QueryTableAccessor { + fn puzzle_gallery_card_view(&self) -> __sdk::__query_builder::Table { + __sdk::__query_builder::Table::new("puzzle_gallery_card_view") + } +} diff --git a/server-rs/crates/spacetime-client/src/puzzle.rs b/server-rs/crates/spacetime-client/src/puzzle.rs index 7bb69899..517d62d6 100644 --- a/server-rs/crates/spacetime-client/src/puzzle.rs +++ b/server-rs/crates/spacetime-client/src/puzzle.rs @@ -402,24 +402,28 @@ impl SpacetimeClient { pub async fn list_puzzle_gallery( &self, - ) -> Result, SpacetimeClientError> { + ) -> Result, SpacetimeClientError> { self.read_after_connect(move |connection| { let mut items = connection .db() - .puzzle_gallery_view() + .puzzle_gallery_card_view() .iter() .collect::>(); - items.sort_by(|left, right| right.updated_at_micros.cmp(&left.updated_at_micros)); + items.sort_by(|left, right| { + right + .updated_at_micros + .cmp(&left.updated_at_micros) + .then_with(|| left.profile_id.cmp(&right.profile_id)) + }); let recent_play_counts = public_work_recent_play_counts(connection, "puzzle"); Ok(items .into_iter() .map(|item| { - let mut record = map_puzzle_work_profile_row(item); - record.recent_play_count_7d = recent_play_counts - .get(&record.profile_id) + let recent_play_count_7d = recent_play_counts + .get(&item.profile_id) .copied() .unwrap_or(0); - record + map_puzzle_gallery_card_view_row(item, recent_play_count_7d) }) .collect()) }) diff --git a/server-rs/crates/spacetime-module/src/puzzle.rs b/server-rs/crates/spacetime-module/src/puzzle.rs index 28f75c1e..7b027bd4 100644 --- a/server-rs/crates/spacetime-module/src/puzzle.rs +++ b/server-rs/crates/spacetime-module/src/puzzle.rs @@ -114,10 +114,10 @@ pub struct PuzzleWorkProfileRow { point_incentive_claimed_points: u64, } -/// 拼图广场公开列表投影。 +/// 拼图广场公开详情兼容投影。 /// -/// `puzzle_work_profile` 是私有真相表,HTTP gallery 只订阅这个 view, -/// 避免每次请求回到 procedure 重新扫表、组装列表和跨层 JSON 往返。 +/// 该 view 返回完整 `PuzzleWorkProfile`,包含 levels / anchor_pack 等详情级字段。 +/// 公开列表主路径应订阅更轻量的 `puzzle_gallery_card_view`。 #[spacetimedb::view(accessor = puzzle_gallery_view, public)] pub fn puzzle_gallery_view(ctx: &AnonymousViewContext) -> Vec { let mut items = ctx @@ -125,11 +125,40 @@ pub fn puzzle_gallery_view(ctx: &AnonymousViewContext) -> Vec .puzzle_work_profile() .by_puzzle_work_publication_status() .filter(PuzzlePublicationStatus::Published) - .filter_map(|row| match build_puzzle_work_profile_from_row_without_recent_count(&row) { - Ok(profile) => Some(profile), + .filter_map( + |row| match build_puzzle_work_profile_from_row_without_recent_count(&row) { + Ok(profile) => Some(profile), + Err(error) => { + log::warn!( + "拼图广场 view 跳过损坏的作品投影 profile_id={}: {}", + row.profile_id, + error + ); + None + } + }, + ) + .collect::>(); + items.sort_by(|left, right| right.updated_at_micros.cmp(&left.updated_at_micros)); + items +} + +/// 拼图广场公开列表卡片投影。 +/// +/// 该 view 只暴露前端列表首屏需要的公开卡片字段,不携带 levels / anchor_pack +/// 等详情级载荷,供 api-server 热点缓存订阅和组装列表窗口。 +#[spacetimedb::view(accessor = puzzle_gallery_card_view, public)] +pub fn puzzle_gallery_card_view(ctx: &AnonymousViewContext) -> Vec { + let mut items = ctx + .db + .puzzle_work_profile() + .by_puzzle_work_publication_status() + .filter(PuzzlePublicationStatus::Published) + .filter_map(|row| match build_puzzle_gallery_card_view_row(&row) { + Ok(item) => Some(item), Err(error) => { log::warn!( - "拼图广场 view 跳过损坏的作品投影 profile_id={}: {}", + "拼图广场卡片 view 跳过损坏的作品投影 profile_id={}: {}", row.profile_id, error ); @@ -137,10 +166,41 @@ pub fn puzzle_gallery_view(ctx: &AnonymousViewContext) -> Vec } }) .collect::>(); - items.sort_by(|left, right| right.updated_at_micros.cmp(&left.updated_at_micros)); + items.sort_by(|left, right| { + right + .updated_at_micros + .cmp(&left.updated_at_micros) + .then_with(|| left.profile_id.cmp(&right.profile_id)) + }); items } +#[derive(Clone, Debug, PartialEq, Eq, SpacetimeType)] +pub struct PuzzleGalleryCardViewRow { + pub work_id: String, + pub profile_id: String, + pub owner_user_id: String, + pub source_session_id: Option, + pub author_display_name: String, + pub work_title: String, + pub work_description: String, + pub level_name: String, + pub summary: String, + pub theme_tags: Vec, + pub cover_image_src: Option, + pub cover_asset_id: Option, + pub publication_status: PuzzlePublicationStatus, + pub updated_at_micros: i64, + pub published_at_micros: Option, + pub play_count: u32, + pub remix_count: u32, + pub like_count: u32, + pub point_incentive_total_half_points: u64, + pub point_incentive_claimed_points: u64, + pub publish_ready: bool, + pub generation_status: Option, +} + /// 拼图创作事件类型。 /// /// 事件表只广播跨层订阅需要的轻量事实,作品真相仍以 @@ -2444,6 +2504,68 @@ fn build_puzzle_work_profile_from_row_without_recent_count( }) } +fn build_puzzle_gallery_card_view_row( + row: &PuzzleWorkProfileRow, +) -> Result { + let levels = build_profile_levels_from_row(row)?; + Ok(PuzzleGalleryCardViewRow { + work_id: row.work_id.clone(), + profile_id: row.profile_id.clone(), + owner_user_id: row.owner_user_id.clone(), + source_session_id: row.source_session_id.clone(), + author_display_name: row.author_display_name.clone(), + work_title: if row.work_title.trim().is_empty() { + row.level_name.clone() + } else { + row.work_title.clone() + }, + work_description: if row.work_description.trim().is_empty() { + row.summary.clone() + } else { + row.work_description.clone() + }, + level_name: row.level_name.clone(), + summary: row.summary.clone(), + theme_tags: deserialize_theme_tags(&row.theme_tags_json)?, + cover_image_src: row.cover_image_src.clone(), + cover_asset_id: row.cover_asset_id.clone(), + publication_status: row.publication_status, + updated_at_micros: row.updated_at.to_micros_since_unix_epoch(), + published_at_micros: row + .published_at + .map(|value| value.to_micros_since_unix_epoch()), + play_count: row.play_count, + remix_count: row.remix_count, + like_count: row.like_count, + point_incentive_total_half_points: row.point_incentive_total_half_points, + point_incentive_claimed_points: row.point_incentive_claimed_points, + publish_ready: row.publish_ready, + generation_status: resolve_puzzle_gallery_generation_status(&levels), + }) +} + +fn resolve_puzzle_gallery_generation_status( + levels: &[module_puzzle::PuzzleDraftLevel], +) -> Option { + levels + .iter() + .map(|level| level.generation_status.trim()) + .find(|status| *status == "generating") + .or_else(|| { + levels + .iter() + .map(|level| level.generation_status.trim()) + .find(|status| *status == "ready") + }) + .or_else(|| { + levels + .iter() + .map(|level| level.generation_status.trim()) + .find(|status| !status.is_empty()) + }) + .map(str::to_string) +} + fn build_profile_levels_from_row( row: &PuzzleWorkProfileRow, ) -> Result, String> { diff --git a/src/services/puzzle-gallery/puzzleGalleryClient.ts b/src/services/puzzle-gallery/puzzleGalleryClient.ts index d57ec95a..2ee76b62 100644 --- a/src/services/puzzle-gallery/puzzleGalleryClient.ts +++ b/src/services/puzzle-gallery/puzzleGalleryClient.ts @@ -2,6 +2,7 @@ import type { PuzzleAgentSessionSnapshot, } from '../../../packages/shared/src/contracts/puzzleAgentSession'; import type { + PuzzleGalleryResponse, PuzzleWorksResponse, PuzzleWorkSummary, } from '../../../packages/shared/src/contracts/puzzleWorkSummary'; @@ -18,7 +19,7 @@ const PUZZLE_GALLERY_READ_RETRY: ApiRetryOptions = { * 读取拼图广场列表。 */ export async function listPuzzleGallery() { - return requestJson( + return requestJson( PUZZLE_GALLERY_API_BASE, { method: 'GET',