diff --git a/.hermes/shared-memory/pitfalls.md b/.hermes/shared-memory/pitfalls.md index 5d999557..b12df76f 100644 --- a/.hermes/shared-memory/pitfalls.md +++ b/.hermes/shared-memory/pitfalls.md @@ -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 已发布作品不存在,无法点赞`。 diff --git a/docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md b/docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md index f0b2eb9d..3909dc66 100644 --- a/docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md +++ b/docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md @@ -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` - 源码:`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` - 源码:`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 窗口缓存 diff --git a/server-rs/crates/spacetime-module/src/puzzle.rs b/server-rs/crates/spacetime-module/src/puzzle.rs index 6fded8c1..8b253e7e 100644 --- a/server-rs/crates/spacetime-module/src/puzzle.rs +++ b/server-rs/crates/spacetime-module/src/puzzle.rs @@ -153,7 +153,7 @@ pub fn puzzle_gallery_view(ctx: &AnonymousViewContext) -> Vec .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 Some(item), Err(error) => { @@ -2069,6 +2069,7 @@ fn list_puzzle_gallery_tx(ctx: &TxContext) -> Result, Str .puzzle_work_profile() .by_puzzle_work_publication_status() .filter(PuzzlePublicationStatus::Published) + .filter(is_public_visible_puzzle_work) .collect::>(); 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(¤t_profile_row) { hydrate_puzzle_leaderboard_entries( ctx, &mut run, @@ -3832,10 +3839,15 @@ fn list_published_puzzle_profiles(ctx: &TxContext) -> Result 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, + } + } }