修复隐藏拼图作品进入通关推荐

收口拼图公开消费路径的 Published + visible 判断

拦截隐藏拼图的公开详情、互动和正式运行态入口

补充隐藏拼图推荐候选回归测试

更新后端契约文档和团队踩坑记录
This commit is contained in:
2026-06-15 22:42:38 +08:00
parent a51e63415f
commit 767da0164a
3 changed files with 90 additions and 10 deletions

View File

@@ -103,6 +103,14 @@
- 验证:`cargo test -p spacetime-module custom_world_public_interactions_accept_legacy_missing_published_at --manifest-path server-rs/Cargo.toml`
- 关联:`server-rs/crates/spacetime-module/src/custom_world.rs``docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md``docs/technical/【后端架构】统一公开作品ReadModel设计-2026-05-26.md`
## 拼图公开推荐不要只按 Published 判断
- 现象:后台把拼图作品隐藏后,作品不在公开列表里显示,但玩家通关其它拼图后的推荐下一作品仍可能出现这条隐藏作品。
- 原因:拼图隐藏只把 `puzzle_work_profile.visible` 置为 `false`,不会把 `publication_status``Published` 改走;通关推荐候选曾只通过 `by_puzzle_work_publication_status().filter(Published)` 取数,漏掉可见性判断。
- 处理:拼图公开消费路径统一使用 `Published + visible=true`,范围包括 `puzzle_gallery_view``puzzle_gallery_card_view`、兼容 gallery/detail procedure、公开点赞 / Remix、正式公开 runtime 启动和通关后的 `recommended_next_works` 候选。
- 验证:`cargo test -p spacetime-module hidden_published_puzzle_work_is_not_public_visible_candidate --manifest-path server-rs/Cargo.toml`,并在需要时用后台隐藏一个已发布拼图后重试通关推荐。
- 关联:`server-rs/crates/spacetime-module/src/puzzle.rs``docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md`
## 推荐页 WF 点赞不要落到 RPG / custom-world
- 现象:推荐页里给 `WF-*` 敲木鱼作品点赞时,平台错误弹窗显示 `custom_world 已发布作品不存在,无法点赞`

View File

@@ -675,6 +675,8 @@ npm run check:server-rs-ddd
- Rust 结构体:`PuzzleWorkProfileRow`
- 源码:`server-rs/crates/spacetime-module/src/puzzle.rs`
- 说明:拼图作品 profile 表,保存草稿 / 已发布作品的标题、作者、关卡、封面、发布状态、可见性、基础游玩数、点赞数、改造数和积分激励领取状态。
- 字段变更:`visible` 控制是否进入公开列表 / 详情、通关后的推荐下一作品候选、公开点赞 / Remix 和正式公开 runtime默认 `true`。后台隐藏后作品可保留 `publication_status = Published`,但公开消费路径必须按 `Published + visible=true` 判断。
### `puzzle_clear_agent_session`
@@ -720,14 +722,14 @@ npm run check:server-rs-ddd
- Rust view`puzzle_gallery_view`
- 返回类型:`Vec<PuzzleWorkProfile>`
- 源码:`server-rs/crates/spacetime-module/src/puzzle.rs`
- 说明:拼图广场公开详情 source / 兼容投影,只暴露 `publication_status = Published` 的作品,但返回完整 `PuzzleWorkProfile`,包含 levels / anchor_pack 等详情级字段;统一公开详情主路径通过 `public_work_detail_entry` 消费该 view只保留平台详情页展示摘要。
- 说明:拼图广场公开详情 source / 兼容投影,只暴露 `publication_status = Published``visible = true` 的作品,但返回完整 `PuzzleWorkProfile`,包含 levels / anchor_pack 等详情级字段;统一公开详情主路径通过 `public_work_detail_entry` 消费该 view只保留平台详情页展示摘要。
### SpacetimeDB view`puzzle_gallery_card_view`
- Rust view`puzzle_gallery_card_view`
- 返回类型:`Vec<PuzzleGalleryCardViewRow>`
- 源码:`server-rs/crates/spacetime-module/src/puzzle.rs`
- 说明:拼图公开列表 source 投影,只暴露前端列表卡片需要的公开字段,不携带 levels / anchor_pack 等详情级载荷;统一公开列表主路径通过 `public_work_gallery_entry` 消费该 view`/api/runtime/puzzle/gallery` 保留旧 HTTP shape并从统一 public cache 映射回 `PuzzleGalleryResponse`
- 说明:拼图公开列表 source 投影,只暴露 `publication_status = Published``visible = true` 的公开字段,不携带 levels / anchor_pack 等详情级载荷;统一公开列表主路径通过 `public_work_gallery_entry` 消费该 view`/api/runtime/puzzle/gallery` 保留旧 HTTP shape并从统一 public cache 映射回 `PuzzleGalleryResponse`
### 拼图公开列表 HTTP 窗口缓存

View File

@@ -153,7 +153,7 @@ pub fn puzzle_gallery_view(ctx: &AnonymousViewContext) -> Vec<PuzzleWorkProfile>
.puzzle_work_profile()
.by_puzzle_work_publication_status()
.filter(PuzzlePublicationStatus::Published)
.filter(|row| row.visible)
.filter(is_public_visible_puzzle_work)
.filter_map(
|row| match build_puzzle_work_profile_from_row_without_recent_count(&row) {
Ok(profile) => Some(profile),
@@ -183,7 +183,7 @@ pub fn puzzle_gallery_card_view(ctx: &AnonymousViewContext) -> Vec<PuzzleGallery
.puzzle_work_profile()
.by_puzzle_work_publication_status()
.filter(PuzzlePublicationStatus::Published)
.filter(|row| row.visible)
.filter(is_public_visible_puzzle_work)
.filter_map(|row| match build_puzzle_gallery_card_view_row(&row) {
Ok(item) => Some(item),
Err(error) => {
@@ -2069,6 +2069,7 @@ fn list_puzzle_gallery_tx(ctx: &TxContext) -> Result<Vec<PuzzleWorkProfile>, Str
.puzzle_work_profile()
.by_puzzle_work_publication_status()
.filter(PuzzlePublicationStatus::Published)
.filter(is_public_visible_puzzle_work)
.collect::<Vec<_>>();
let profile_ids = rows
.iter()
@@ -2094,8 +2095,8 @@ fn get_puzzle_gallery_detail_tx(
.profile_id()
.find(&input.profile_id)
.ok_or_else(|| "拼图作品不存在".to_string())?;
if row.publication_status != PuzzlePublicationStatus::Published {
return Err("拼图作品尚未发布".to_string());
if !is_public_visible_puzzle_work(&row) {
return Err("拼图作品不可公开访问".to_string());
}
build_puzzle_work_profile_from_row_with_recent_count(
ctx,
@@ -2118,7 +2119,7 @@ fn record_puzzle_work_like_tx(
.puzzle_work_profile()
.profile_id()
.find(&profile_id.to_string())
.filter(|row| row.publication_status == PuzzlePublicationStatus::Published)
.filter(is_public_visible_puzzle_work)
.ok_or_else(|| "拼图已发布作品不存在,无法点赞".to_string())?;
let inserted_like = record_public_work_like(
ctx,
@@ -2214,7 +2215,7 @@ fn remix_puzzle_work_tx(
.puzzle_work_profile()
.profile_id()
.find(&source_profile_id.to_string())
.filter(|row| row.publication_status == PuzzlePublicationStatus::Published)
.filter(is_public_visible_puzzle_work)
.ok_or_else(|| "拼图已发布源作品不存在".to_string())?;
let source_profile = build_puzzle_work_profile_from_row(&source)?;
let remixed_at = Timestamp::from_micros_since_unix_epoch(input.remixed_at_micros);
@@ -2355,6 +2356,11 @@ fn start_puzzle_run_tx(
{
return Err("入口拼图作品未发布".to_string());
}
if entry_profile_row.publication_status == PuzzlePublicationStatus::Published
&& !entry_profile_row.visible
{
return Err("入口拼图作品不可公开访问".to_string());
}
let mut entry_profile = build_puzzle_work_profile_from_row(&entry_profile_row)?;
if entry_profile.cover_image_src.is_none() {
return Err("入口拼图作品缺少正式图片".to_string());
@@ -2387,7 +2393,7 @@ fn start_puzzle_run_tx(
);
refresh_next_level_handoff(ctx, &mut run)?;
if entry_profile_row.publication_status == PuzzlePublicationStatus::Published {
if is_public_visible_puzzle_work(&entry_profile_row) {
record_public_work_play(
ctx,
PublicWorkPlayRecordInput {
@@ -2595,6 +2601,7 @@ fn advance_puzzle_next_level_tx(
.puzzle_work_profile()
.profile_id()
.find(&next_profile.profile_id)
.filter(is_public_visible_puzzle_work)
{
record_public_work_play(
ctx,
@@ -2822,7 +2829,7 @@ fn submit_puzzle_leaderboard_entry_tx(
if !matches_service_level && !is_frontend_puzzle_level_candidate(&run, &input.profile_id) {
return Err("提交成绩的拼图作品与当前关卡不匹配".to_string());
}
if current_profile_row.publication_status != PuzzlePublicationStatus::Published {
if !is_public_visible_puzzle_work(&current_profile_row) {
hydrate_puzzle_leaderboard_entries(
ctx,
&mut run,
@@ -3832,10 +3839,15 @@ fn list_published_puzzle_profiles(ctx: &TxContext) -> Result<Vec<PuzzleWorkProfi
.puzzle_work_profile()
.by_puzzle_work_publication_status()
.filter(PuzzlePublicationStatus::Published)
.filter(is_public_visible_puzzle_work)
.map(|row| build_puzzle_work_profile_from_row(&row))
.collect()
}
fn is_public_visible_puzzle_work(row: &PuzzleWorkProfileRow) -> bool {
row.publication_status == PuzzlePublicationStatus::Published && row.visible
}
fn reset_next_level_handoff(run: &mut PuzzleRunSnapshot) {
run.recommended_next_profile_id = None;
run.next_level_mode = PUZZLE_NEXT_LEVEL_MODE_NONE.to_string();
@@ -4250,6 +4262,29 @@ mod tests {
);
}
#[test]
fn hidden_published_puzzle_work_is_not_public_visible_candidate() {
let visible_published = puzzle_work_profile_row_for_visibility(
"visible-published",
PuzzlePublicationStatus::Published,
true,
);
let hidden_published = puzzle_work_profile_row_for_visibility(
"hidden-published",
PuzzlePublicationStatus::Published,
false,
);
let visible_draft = puzzle_work_profile_row_for_visibility(
"visible-draft",
PuzzlePublicationStatus::Draft,
true,
);
assert!(is_public_visible_puzzle_work(&visible_published));
assert!(!is_public_visible_puzzle_work(&hidden_published));
assert!(!is_public_visible_puzzle_work(&visible_draft));
}
#[test]
fn level_generation_failure_only_marks_target_level_failed() {
let anchor_pack = infer_anchor_pack("画面描述:一只猫在雨夜灯牌下回头。", None);
@@ -4371,4 +4406,39 @@ mod tests {
assert_eq!(entries[1].nickname, "玩家 B");
assert_eq!(entries[1].rank, 2);
}
fn puzzle_work_profile_row_for_visibility(
profile_id: &str,
publication_status: PuzzlePublicationStatus,
visible: bool,
) -> PuzzleWorkProfileRow {
let timestamp = Timestamp::from_micros_since_unix_epoch(1);
PuzzleWorkProfileRow {
profile_id: profile_id.to_string(),
work_id: format!("work-{profile_id}"),
owner_user_id: "owner".to_string(),
source_session_id: None,
author_display_name: "作者".to_string(),
work_title: "作品".to_string(),
work_description: String::new(),
level_name: "第一关".to_string(),
summary: "摘要".to_string(),
theme_tags_json: "[]".to_string(),
cover_image_src: Some("/cover.png".to_string()),
cover_asset_id: Some("asset-cover".to_string()),
levels_json: "[]".to_string(),
publication_status,
play_count: 0,
anchor_pack_json: serialize_json(&empty_anchor_pack()),
publish_ready: true,
created_at: timestamp,
updated_at: timestamp,
published_at: Some(timestamp),
remix_count: 0,
like_count: 0,
point_incentive_total_half_points: 0,
point_incentive_claimed_points: 0,
visible,
}
}
}