perf: cache public gallery views

This commit is contained in:
kdletters
2026-05-17 01:19:12 +08:00
parent d9c8473504
commit 81fe3dcf28
39 changed files with 2124 additions and 298 deletions

View File

@@ -8,9 +8,42 @@ use crate::runtime::{
};
use crate::*;
use module_big_fish::{EvaluateBigFishPublishReadinessCommand, evaluate_publish_readiness};
use spacetimedb::AnonymousViewContext;
const INITIAL_BIG_FISH_CREATION_PROGRESS_PERCENT: u32 = 0;
/// 大鱼吃小鱼公开广场列表投影。
///
/// 公开列表从已发布 creation session 生成卡片字段7 日播放数由
/// `api-server` 订阅 `public_work_play_daily_stat` 后在本地聚合。
#[spacetimedb::view(accessor = big_fish_gallery_view, public)]
pub fn big_fish_gallery_view(ctx: &AnonymousViewContext) -> Vec<BigFishWorkSummarySnapshot> {
let mut items = ctx
.db
.big_fish_creation_session()
.by_big_fish_session_stage()
.filter(BigFishCreationStage::Published)
.filter_map(|row| match build_big_fish_gallery_view_row(ctx, &row) {
Ok(snapshot) => Some(snapshot),
Err(error) => {
log::warn!(
"大鱼吃小鱼公开广场 view 跳过损坏的作品投影 session_id={}: {}",
row.session_id,
error
);
None
}
})
.collect::<Vec<_>>();
items.sort_by(|left, right| {
right
.updated_at_micros
.cmp(&left.updated_at_micros)
.then_with(|| left.source_session_id.cmp(&right.source_session_id))
});
items
}
#[spacetimedb::procedure]
pub fn create_big_fish_session(
ctx: &mut ProcedureContext,
@@ -988,6 +1021,16 @@ pub(crate) fn build_big_fish_work_summary(
ctx: &ReducerContext,
row: &BigFishCreationSession,
now_micros: i64,
) -> Result<BigFishWorkSummarySnapshot, String> {
let mut summary = build_big_fish_work_summary_without_recent_count(ctx, row)?;
summary.recent_play_count_7d =
count_recent_public_work_plays(ctx, "big-fish", &row.session_id, now_micros);
Ok(summary)
}
fn build_big_fish_work_summary_without_recent_count(
ctx: &ReducerContext,
row: &BigFishCreationSession,
) -> Result<BigFishWorkSummarySnapshot, String> {
let draft = row
.draft_json
@@ -1052,12 +1095,7 @@ pub(crate) fn build_big_fish_work_summary(
play_count: row.play_count,
remix_count: row.remix_count,
like_count: row.like_count,
recent_play_count_7d: count_recent_public_work_plays(
ctx,
"big-fish",
&row.session_id,
now_micros,
),
recent_play_count_7d: 0,
published_at_micros: row
.published_at
.or_else(|| (row.stage == BigFishCreationStage::Published).then_some(row.updated_at))
@@ -1065,6 +1103,113 @@ pub(crate) fn build_big_fish_work_summary(
})
}
fn build_big_fish_gallery_view_row(
ctx: &AnonymousViewContext,
row: &BigFishCreationSession,
) -> Result<BigFishWorkSummarySnapshot, String> {
let draft = row
.draft_json
.as_deref()
.map(deserialize_draft)
.transpose()
.map_err(|error| format!("big_fish.draft_json 非法: {error}"))?;
let asset_slots = list_big_fish_asset_slots_for_view(ctx, &row.session_id);
let coverage = build_asset_coverage(draft.as_ref(), &asset_slots);
let cover_image_src = asset_slots
.iter()
.find(|slot| slot.asset_kind == BigFishAssetKind::StageBackground)
.and_then(|slot| slot.asset_url.clone())
.or_else(|| {
asset_slots
.iter()
.find(|slot| slot.asset_kind == BigFishAssetKind::LevelMainImage)
.and_then(|slot| slot.asset_url.clone())
});
let title = draft
.as_ref()
.map(|value| value.title.clone())
.filter(|value| !value.trim().is_empty())
.unwrap_or_else(|| "未命名大鱼草稿".to_string());
let subtitle = draft
.as_ref()
.map(|value| value.subtitle.clone())
.filter(|value| !value.trim().is_empty())
.unwrap_or_else(|| "等待整理玩法草稿".to_string());
let summary = draft
.as_ref()
.map(|value| value.core_fun.clone())
.filter(|value| !value.trim().is_empty())
.unwrap_or_else(|| {
row.last_assistant_reply
.clone()
.unwrap_or_else(|| "继续补齐锚点后即可生成玩法草稿。".to_string())
});
Ok(BigFishWorkSummarySnapshot {
work_id: format!("big-fish-work-{}", row.session_id),
source_session_id: row.session_id.clone(),
owner_user_id: row.owner_user_id.clone(),
title,
subtitle,
summary,
cover_image_src,
status: if row.stage == BigFishCreationStage::Published {
"published".to_string()
} else {
"draft".to_string()
},
updated_at_micros: row.updated_at.to_micros_since_unix_epoch(),
publish_ready: coverage.publish_ready,
level_count: draft
.as_ref()
.map(|value| value.runtime_params.level_count)
.unwrap_or(BIG_FISH_DEFAULT_LEVEL_COUNT),
level_main_image_ready_count: coverage.level_main_image_ready_count,
level_motion_ready_count: coverage.level_motion_ready_count,
background_ready: coverage.background_ready,
play_count: row.play_count,
remix_count: row.remix_count,
like_count: row.like_count,
recent_play_count_7d: 0,
published_at_micros: row
.published_at
.or_else(|| (row.stage == BigFishCreationStage::Published).then_some(row.updated_at))
.map(|value| value.to_micros_since_unix_epoch()),
})
}
fn list_big_fish_asset_slots_for_view(
ctx: &AnonymousViewContext,
session_id: &str,
) -> Vec<BigFishAssetSlotSnapshot> {
let mut slots = ctx
.db
.big_fish_asset_slot()
.by_big_fish_asset_session_id()
.filter(session_id)
.map(|slot| BigFishAssetSlotSnapshot {
slot_id: slot.slot_id,
session_id: slot.session_id,
asset_kind: slot.asset_kind,
level: slot.level,
motion_key: slot.motion_key,
status: slot.status,
asset_url: slot.asset_url,
prompt_snapshot: slot.prompt_snapshot,
updated_at_micros: slot.updated_at.to_micros_since_unix_epoch(),
})
.collect::<Vec<_>>();
slots.sort_by_key(|slot| {
(
slot.level.unwrap_or(0),
slot.asset_kind.as_str().to_string(),
slot.motion_key.clone().unwrap_or_default(),
slot.slot_id.clone(),
)
});
slots
}
fn build_public_big_fish_gallery_list_input() -> BigFishWorksListInput {
BigFishWorksListInput {
// 中文注释published_only 分支不会按 owner 过滤;非空占位用于兼容旧部署模块的前置校验。

View File

@@ -2,7 +2,8 @@ use crate::*;
#[spacetimedb::table(
accessor = big_fish_creation_session,
index(accessor = by_big_fish_session_owner_user_id, btree(columns = [owner_user_id]))
index(accessor = by_big_fish_session_owner_user_id, btree(columns = [owner_user_id])),
index(accessor = by_big_fish_session_stage, btree(columns = [stage]))
)]
pub struct BigFishCreationSession {
#[primary_key]

View File

@@ -19,6 +19,62 @@ use module_match3d::{
use serde::Serialize;
use serde::de::DeserializeOwned;
use serde_json::Value;
use spacetimedb::AnonymousViewContext;
/// 抓大鹅公开广场列表投影。
///
/// `match3d_work_profile` 是玩法源表HTTP gallery 只订阅这个轻量 view
/// 避免每个公开列表请求重新调用 procedure 扫描和组装全量列表。
#[spacetimedb::view(accessor = match3d_gallery_view, public)]
pub fn match3d_gallery_view(ctx: &AnonymousViewContext) -> Vec<Match3DGalleryViewRow> {
let mut items = ctx
.db
.match3d_work_profile()
.by_match3d_work_publication_status()
.filter(MATCH3D_PUBLICATION_PUBLISHED)
.filter_map(|row| match build_gallery_view_row(&row) {
Ok(item) => Some(item),
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)
.then_with(|| left.profile_id.cmp(&right.profile_id))
});
items
}
#[derive(Clone, Debug, PartialEq, Eq, SpacetimeType)]
pub struct Match3DGalleryViewRow {
pub profile_id: String,
pub owner_user_id: String,
pub source_session_id: String,
pub author_display_name: String,
pub game_name: String,
pub theme_text: String,
pub summary_text: String,
pub tags: Vec<String>,
pub cover_image_src: String,
pub cover_asset_id: String,
pub reference_image_src: Option<String>,
pub clear_count: u32,
pub difficulty: u32,
pub publication_status: String,
pub publish_ready: bool,
pub play_count: u32,
pub updated_at_micros: i64,
pub published_at_micros: Option<i64>,
pub generated_item_assets_json: Option<String>,
}
#[spacetimedb::procedure]
pub fn create_match3d_agent_session(
@@ -1004,6 +1060,35 @@ fn build_work_snapshot(row: &Match3DWorkProfileRow) -> Result<Match3DWorkSnapsho
})
}
fn build_gallery_view_row(row: &Match3DWorkProfileRow) -> Result<Match3DGalleryViewRow, String> {
let config = parse_config(&row.config_json)?;
Ok(Match3DGalleryViewRow {
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(),
game_name: row.game_name.clone(),
theme_text: row.theme_text.clone(),
summary_text: row.summary_text.clone(),
tags: parse_tags(&row.tags_json)?,
cover_image_src: row.cover_image_src.clone(),
cover_asset_id: row.cover_asset_id.clone(),
reference_image_src: config.reference_image_src,
clear_count: row.clear_count,
difficulty: row.difficulty,
publication_status: row.publication_status.clone(),
publish_ready: is_work_publish_ready(row),
play_count: row.play_count,
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()),
generated_item_assets_json: normalize_generated_item_assets_json(
row.generated_item_assets_json.as_deref(),
)?,
})
}
fn build_initial_run_snapshot(
run_id: &str,
work: &Match3DWorkProfileRow,
@@ -1908,8 +1993,7 @@ mod tests {
};
let row_json = to_json_string(&draft);
let restored =
parse_json::<Match3DDraftSnapshot>(&row_json, "match3d draft_json").unwrap();
let restored = parse_json::<Match3DDraftSnapshot>(&row_json, "match3d draft_json").unwrap();
assert_eq!(
restored.generated_item_assets_json.as_deref(),

View File

@@ -125,17 +125,19 @@ 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),
Err(error) => {
log::warn!(
"拼图广场 view 跳过损坏的作品投影 profile_id={}: {}",
row.profile_id,
error
);
None
}
})
.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 File

@@ -26,6 +26,65 @@ use module_square_hole::{
};
use serde::Serialize;
use serde::de::DeserializeOwned;
use spacetimedb::AnonymousViewContext;
/// 方洞挑战公开广场列表投影。
///
/// HTTP gallery 通过 `spacetime-client` 订阅该 view 后读本地 cache
/// 不再在每个公开列表请求里调用 `list_square_hole_works` procedure。
#[spacetimedb::view(accessor = square_hole_gallery_view, public)]
pub fn square_hole_gallery_view(ctx: &AnonymousViewContext) -> Vec<SquareHoleGalleryViewRow> {
let mut items = ctx
.db
.square_hole_work_profile()
.by_square_hole_work_publication_status()
.filter(SQUARE_HOLE_PUBLICATION_PUBLISHED)
.filter_map(|row| match build_gallery_view_row(&row) {
Ok(item) => Some(item),
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)
.then_with(|| left.profile_id.cmp(&right.profile_id))
});
items
}
#[derive(Clone, Debug, PartialEq, Eq, SpacetimeType)]
pub struct SquareHoleGalleryViewRow {
pub work_id: String,
pub profile_id: String,
pub owner_user_id: String,
pub source_session_id: String,
pub author_display_name: String,
pub game_name: String,
pub theme_text: String,
pub twist_rule: String,
pub summary_text: String,
pub tags: Vec<String>,
pub cover_image_src: String,
pub background_prompt: String,
pub background_image_src: String,
pub shape_options: Vec<SquareHoleShapeOptionSnapshot>,
pub hole_options: Vec<SquareHoleHoleOptionSnapshot>,
pub shape_count: u32,
pub difficulty: u32,
pub publication_status: String,
pub publish_ready: bool,
pub play_count: u32,
pub updated_at_micros: i64,
pub published_at_micros: Option<i64>,
}
#[spacetimedb::procedure]
pub fn create_square_hole_agent_session(
@@ -880,6 +939,38 @@ fn build_work_snapshot(row: &SquareHoleWorkProfileRow) -> Result<SquareHoleWorkS
})
}
fn build_gallery_view_row(
row: &SquareHoleWorkProfileRow,
) -> Result<SquareHoleGalleryViewRow, String> {
let config = parse_config(&row.config_json)?;
Ok(SquareHoleGalleryViewRow {
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(),
game_name: row.game_name.clone(),
theme_text: row.theme_text.clone(),
twist_rule: row.twist_rule.clone(),
summary_text: row.summary_text.clone(),
tags: parse_tags(&row.tags_json)?,
cover_image_src: row.cover_image_src.clone(),
background_prompt: config.background_prompt,
background_image_src: config.background_image_src,
shape_options: config.shape_options,
hole_options: config.hole_options,
shape_count: row.shape_count,
difficulty: row.difficulty,
publication_status: row.publication_status.clone(),
publish_ready: is_work_publish_ready(row),
play_count: row.play_count,
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()),
})
}
fn refresh_run_row(
ctx: &ReducerContext,
row: SquareHoleRuntimeRunRow,

View File

@@ -222,7 +222,7 @@ pub struct SquareHoleCreatorConfigSnapshot {
pub background_image_src: String,
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[derive(Clone, Debug, PartialEq, Eq, SpacetimeType, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct SquareHoleShapeOptionSnapshot {
pub option_id: String,
@@ -235,7 +235,7 @@ pub struct SquareHoleShapeOptionSnapshot {
pub image_src: String,
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[derive(Clone, Debug, PartialEq, Eq, SpacetimeType, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct SquareHoleHoleOptionSnapshot {
pub hole_id: String,

View File

@@ -1,6 +1,7 @@
use crate::*;
use serde::Serialize;
use serde::de::DeserializeOwned;
use spacetimedb::AnonymousViewContext;
pub const VISUAL_NOVEL_SOURCE_IDEA: &str = "idea";
pub const VISUAL_NOVEL_SOURCE_DOCUMENT: &str = "document";
@@ -166,6 +167,58 @@ pub struct VisualNovelRuntimeEvent {
pub(crate) occurred_at: Timestamp,
}
/// 视觉小说公开广场列表投影。
///
/// 该 view 只暴露已发布作品卡片需要的公开字段HTTP gallery 订阅后
/// 从本地 cache 读取,避免每个列表请求调用 `list_visual_novel_works` procedure。
#[spacetimedb::view(accessor = visual_novel_gallery_view, public)]
pub fn visual_novel_gallery_view(ctx: &AnonymousViewContext) -> Vec<VisualNovelGalleryViewRow> {
let mut items = ctx
.db
.visual_novel_work_profile()
.by_visual_novel_work_publication_status()
.filter(VISUAL_NOVEL_PUBLICATION_PUBLISHED)
.filter_map(|row| match build_gallery_view_row(&row) {
Ok(item) => Some(item),
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)
.then_with(|| left.profile_id.cmp(&right.profile_id))
});
items
}
#[derive(Clone, Debug, PartialEq, Eq, SpacetimeType)]
pub struct VisualNovelGalleryViewRow {
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 tags: Vec<String>,
pub cover_image_src: Option<String>,
pub source_asset_ids: Vec<String>,
pub publication_status: String,
pub publish_ready: bool,
pub play_count: u32,
pub created_at_micros: i64,
pub updated_at_micros: i64,
pub published_at_micros: Option<i64>,
}
#[derive(Clone, Debug, PartialEq, Eq, SpacetimeType)]
pub struct VisualNovelAgentSessionCreateInput {
pub session_id: String,
@@ -1445,6 +1498,31 @@ fn build_work_snapshot(row: &VisualNovelWorkProfileRow) -> Result<VisualNovelWor
})
}
fn build_gallery_view_row(
row: &VisualNovelWorkProfileRow,
) -> Result<VisualNovelGalleryViewRow, String> {
Ok(VisualNovelGalleryViewRow {
work_id: row.work_id.clone(),
profile_id: row.profile_id.clone(),
owner_user_id: row.owner_user_id.clone(),
source_session_id: empty_to_none(&row.source_session_id),
author_display_name: row.author_display_name.clone(),
work_title: row.work_title.clone(),
work_description: row.work_description.clone(),
tags: parse_string_vec_or_empty(&row.tags_json)?,
cover_image_src: empty_to_none(&row.cover_image_src),
source_asset_ids: parse_string_vec_or_empty(&row.source_asset_ids_json)?,
publication_status: row.publication_status.clone(),
publish_ready: row.publish_ready,
play_count: row.play_count,
created_at_micros: row.created_at.to_micros_since_unix_epoch(),
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()),
})
}
fn build_run_snapshot(
ctx: &ReducerContext,
row: &VisualNovelRuntimeRunRow,