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

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

View File

@@ -486,13 +486,27 @@ npm run check:server-rs-ddd
- Rust view`puzzle_gallery_view`
- 返回类型:`Vec<PuzzleWorkProfile>`
- 源码:`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<PuzzleGalleryCardViewRow>`
- 源码:`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`

View File

@@ -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;
}

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

View File

@@ -6,6 +6,21 @@ use crate::puzzle_works::{PuzzleWorkProfileResponse, PuzzleWorkSummaryResponse};
#[serde(rename_all = "camelCase")]
pub struct PuzzleGalleryResponse {
pub items: Vec<PuzzleWorkSummaryResponse>,
#[serde(default)]
pub preview_refs: Vec<PuzzleGalleryWorkRefResponse>,
#[serde(default)]
pub has_more: bool,
#[serde(default)]
pub next_cursor: Option<String>,
#[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)]

View File

@@ -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<Vec<SubscriptionHandle>, 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",

View File

@@ -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<PuzzleDraftLevelRecord>,
}
#[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<String>,
pub author_display_name: String,
pub work_title: String,
pub work_description: String,
pub level_name: String,
pub summary: String,
pub theme_tags: Vec<String>,
pub cover_image_src: Option<String>,
pub cover_asset_id: Option<String>,
pub publication_status: String,
pub updated_at: String,
pub published_at: Option<String>,
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<String>,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct PuzzleWorkPointIncentiveClaimRecordInput {
pub profile_id: String,

View File

@@ -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<PuzzleAgentMessageRow>,
puzzle_agent_session: __sdk::TableUpdate<PuzzleAgentSessionRow>,
puzzle_event: __sdk::TableUpdate<PuzzleEvent>,
puzzle_gallery_card_view: __sdk::TableUpdate<PuzzleGalleryCardViewRow>,
puzzle_gallery_view: __sdk::TableUpdate<PuzzleWorkProfile>,
puzzle_leaderboard_entry: __sdk::TableUpdate<PuzzleLeaderboardEntryRow>,
puzzle_runtime_run: __sdk::TableUpdate<PuzzleRuntimeRunRow>,
@@ -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::<PuzzleGalleryCardViewRow>(
"puzzle_gallery_card_view",
&self.puzzle_gallery_card_view,
);
diff.puzzle_gallery_view = cache.apply_diff_to_table::<PuzzleWorkProfile>(
"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::<PuzzleGalleryCardViewRow>(
"puzzle_gallery_card_view",
&self.puzzle_gallery_card_view,
event,
);
callbacks.invoke_table_row_callbacks::<PuzzleWorkProfile>(
"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",

View File

@@ -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<String>,
pub author_display_name: String,
pub work_title: String,
pub work_description: String,
pub level_name: String,
pub summary: String,
pub theme_tags: Vec<String>,
pub cover_image_src: Option<String>,
pub cover_asset_id: Option<String>,
pub publication_status: PuzzlePublicationStatus,
pub updated_at_micros: i64,
pub published_at_micros: Option<i64>,
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<String>,
}
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<PuzzleGalleryCardViewRow, String>,
pub profile_id: __sdk::__query_builder::Col<PuzzleGalleryCardViewRow, String>,
pub owner_user_id: __sdk::__query_builder::Col<PuzzleGalleryCardViewRow, String>,
pub source_session_id: __sdk::__query_builder::Col<PuzzleGalleryCardViewRow, Option<String>>,
pub author_display_name: __sdk::__query_builder::Col<PuzzleGalleryCardViewRow, String>,
pub work_title: __sdk::__query_builder::Col<PuzzleGalleryCardViewRow, String>,
pub work_description: __sdk::__query_builder::Col<PuzzleGalleryCardViewRow, String>,
pub level_name: __sdk::__query_builder::Col<PuzzleGalleryCardViewRow, String>,
pub summary: __sdk::__query_builder::Col<PuzzleGalleryCardViewRow, String>,
pub theme_tags: __sdk::__query_builder::Col<PuzzleGalleryCardViewRow, Vec<String>>,
pub cover_image_src: __sdk::__query_builder::Col<PuzzleGalleryCardViewRow, Option<String>>,
pub cover_asset_id: __sdk::__query_builder::Col<PuzzleGalleryCardViewRow, Option<String>>,
pub publication_status:
__sdk::__query_builder::Col<PuzzleGalleryCardViewRow, PuzzlePublicationStatus>,
pub updated_at_micros: __sdk::__query_builder::Col<PuzzleGalleryCardViewRow, i64>,
pub published_at_micros: __sdk::__query_builder::Col<PuzzleGalleryCardViewRow, Option<i64>>,
pub play_count: __sdk::__query_builder::Col<PuzzleGalleryCardViewRow, u32>,
pub remix_count: __sdk::__query_builder::Col<PuzzleGalleryCardViewRow, u32>,
pub like_count: __sdk::__query_builder::Col<PuzzleGalleryCardViewRow, u32>,
pub point_incentive_total_half_points:
__sdk::__query_builder::Col<PuzzleGalleryCardViewRow, u64>,
pub point_incentive_claimed_points: __sdk::__query_builder::Col<PuzzleGalleryCardViewRow, u64>,
pub publish_ready: __sdk::__query_builder::Col<PuzzleGalleryCardViewRow, bool>,
pub generation_status: __sdk::__query_builder::Col<PuzzleGalleryCardViewRow, Option<String>>,
}
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"),
}
}
}

View File

@@ -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<PuzzleGalleryCardViewRow>,
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::<PuzzleGalleryCardViewRow>("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<Item = PuzzleGalleryCardViewRow> + '_ {
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<super::RemoteModule>) {
let _table =
client_cache.get_or_make_table::<PuzzleGalleryCardViewRow>("puzzle_gallery_card_view");
}
#[doc(hidden)]
pub(super) fn parse_table_update(
raw_updates: __ws::v2::TableUpdate,
) -> __sdk::Result<__sdk::TableUpdate<PuzzleGalleryCardViewRow>> {
__sdk::TableUpdate::parse_table_update(raw_updates).map_err(|e| {
__sdk::InternalError::failed_parse("TableUpdate<PuzzleGalleryCardViewRow>", "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<PuzzleGalleryCardViewRow>;
}
impl puzzle_gallery_card_viewQueryTableAccess for __sdk::QueryTableAccessor {
fn puzzle_gallery_card_view(&self) -> __sdk::__query_builder::Table<PuzzleGalleryCardViewRow> {
__sdk::__query_builder::Table::new("puzzle_gallery_card_view")
}
}

View File

@@ -402,24 +402,28 @@ impl SpacetimeClient {
pub async fn list_puzzle_gallery(
&self,
) -> Result<Vec<PuzzleWorkProfileRecord>, SpacetimeClientError> {
) -> Result<Vec<PuzzleGalleryCardRecord>, SpacetimeClientError> {
self.read_after_connect(move |connection| {
let mut items = connection
.db()
.puzzle_gallery_view()
.puzzle_gallery_card_view()
.iter()
.collect::<Vec<_>>();
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())
})

View File

@@ -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<PuzzleWorkProfile> {
let mut items = ctx
@@ -125,11 +125,40 @@ pub fn puzzle_gallery_view(ctx: &AnonymousViewContext) -> Vec<PuzzleWorkProfile>
.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::<Vec<_>>();
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<PuzzleGalleryCardViewRow> {
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<PuzzleWorkProfile>
}
})
.collect::<Vec<_>>();
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<String>,
pub author_display_name: String,
pub work_title: String,
pub work_description: String,
pub level_name: String,
pub summary: String,
pub theme_tags: Vec<String>,
pub cover_image_src: Option<String>,
pub cover_asset_id: Option<String>,
pub publication_status: PuzzlePublicationStatus,
pub updated_at_micros: i64,
pub published_at_micros: Option<i64>,
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<String>,
}
/// 拼图创作事件类型。
///
/// 事件表只广播跨层订阅需要的轻量事实,作品真相仍以
@@ -2444,6 +2504,68 @@ fn build_puzzle_work_profile_from_row_without_recent_count(
})
}
fn build_puzzle_gallery_card_view_row(
row: &PuzzleWorkProfileRow,
) -> Result<PuzzleGalleryCardViewRow, String> {
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<String> {
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<Vec<module_puzzle::PuzzleDraftLevel>, String> {

View File

@@ -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<PuzzleWorksResponse>(
return requestJson<PuzzleGalleryResponse>(
PUZZLE_GALLERY_API_BASE,
{
method: 'GET',