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

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