diff --git a/apps/admin-web/src/pages/AdminWorkVisibilityPage.tsx b/apps/admin-web/src/pages/AdminWorkVisibilityPage.tsx index 4e02a845..aea06873 100644 --- a/apps/admin-web/src/pages/AdminWorkVisibilityPage.tsx +++ b/apps/admin-web/src/pages/AdminWorkVisibilityPage.tsx @@ -16,6 +16,7 @@ interface AdminWorkVisibilityPageProps { const sourceLabels: Record = { puzzle: '拼图', + 'puzzle-clear': '拼消消', 'custom-world': '自定义世界', 'jump-hop': '跳一跳', 'wooden-fish': '敲木鱼', diff --git a/docs/technical/【后端架构】统一公开作品ReadModel设计-2026-05-26.md b/docs/technical/【后端架构】统一公开作品ReadModel设计-2026-05-26.md index bd116756..ac1c723d 100644 --- a/docs/technical/【后端架构】统一公开作品ReadModel设计-2026-05-26.md +++ b/docs/technical/【后端架构】统一公开作品ReadModel设计-2026-05-26.md @@ -50,7 +50,7 @@ - `GET /admin/api/works/visibility` - `POST /admin/api/works/visibility` -后台操作 key 使用统一的 `sourceType + profileId` 组合。`profileId` 在大多数玩法中对应作品 profile;特殊玩法维持既有源表身份:`big-fish` 对应 `session_id`,`bark-battle` 对应 `work_id`。`custom-world` 更新源表时必须同步 `custom_world_gallery_entry.visible`,避免兼容 gallery 缓存与统一公开 read model 出现可见性漂移。 +后台操作 key 使用统一的 `sourceType + profileId` 组合。当前后端统一可见性管理覆盖 `puzzle`、`puzzle-clear`、`custom-world`、`jump-hop`、`wooden-fish`、`match3d`、`square-hole`、`visual-novel`、`big-fish` 和 `bark-battle`;`edutainment` 当前没有后端统一作品源表,暂不接入该后台能力。`profileId` 在大多数玩法中对应作品 profile;特殊玩法维持既有源表身份:`big-fish` 对应 `session_id`,`bark-battle` 对应 `work_id`。`custom-world` 更新源表时必须同步 `custom_world_gallery_entry.visible`,避免兼容 gallery 缓存与统一公开 read model 出现可见性漂移。 该后台能力只修改源表 / source view 过滤事实,不把 `visible` 暴露到公开列表或公开详情契约。隐藏作品后,统一 `public_work_gallery_entry` 与 `public_work_detail_entry` 不再返回该作品;恢复显示后重新进入公开 read model。 diff --git a/server-rs/crates/spacetime-module/src/runtime/admin_work_visibility.rs b/server-rs/crates/spacetime-module/src/runtime/admin_work_visibility.rs index 13ec22a5..9dd603a6 100644 --- a/server-rs/crates/spacetime-module/src/runtime/admin_work_visibility.rs +++ b/server-rs/crates/spacetime-module/src/runtime/admin_work_visibility.rs @@ -4,6 +4,7 @@ use module_custom_world::CustomWorldPublicationStatus; use module_puzzle::PuzzlePublicationStatus; const SOURCE_TYPE_PUZZLE: &str = "puzzle"; +const SOURCE_TYPE_PUZZLE_CLEAR: &str = "puzzle-clear"; const SOURCE_TYPE_CUSTOM_WORLD: &str = "custom-world"; const SOURCE_TYPE_JUMP_HOP: &str = "jump-hop"; const SOURCE_TYPE_WOODEN_FISH: &str = "wooden-fish"; @@ -63,6 +64,7 @@ fn list_work_visibility_tx( let mut entries = Vec::new(); entries.extend(list_puzzle_work_visibility(ctx)); + entries.extend(list_puzzle_clear_work_visibility(ctx)); entries.extend(list_custom_world_work_visibility(ctx)); entries.extend(list_jump_hop_work_visibility(ctx)); entries.extend(list_wooden_fish_work_visibility(ctx)); @@ -85,6 +87,9 @@ fn update_work_visibility_tx( match source_type.as_str() { SOURCE_TYPE_PUZZLE => update_puzzle_work_visibility(ctx, &profile_id, input.visible), + SOURCE_TYPE_PUZZLE_CLEAR => { + update_puzzle_clear_work_visibility(ctx, &profile_id, input.visible) + } SOURCE_TYPE_CUSTOM_WORLD => { update_custom_world_work_visibility(ctx, &profile_id, input.visible) } @@ -167,6 +172,63 @@ fn puzzle_work_visibility_snapshot(row: &PuzzleWorkProfileRow) -> AdminWorkVisib } } +fn list_puzzle_clear_work_visibility(ctx: &ReducerContext) -> Vec { + ctx.db + .puzzle_clear_work_profile() + .by_puzzle_clear_work_publication_status() + .filter(PUZZLE_CLEAR_PUBLICATION_PUBLISHED) + .map(|row| puzzle_clear_work_visibility_snapshot(&row)) + .collect() +} + +fn update_puzzle_clear_work_visibility( + ctx: &ReducerContext, + profile_id: &str, + visible: bool, +) -> Result { + let row = ctx + .db + .puzzle_clear_work_profile() + .profile_id() + .find(&profile_id.to_string()) + .ok_or_else(|| "拼消消作品不存在".to_string())?; + if row.publication_status != PUZZLE_CLEAR_PUBLICATION_PUBLISHED { + return Err("只能修改已发布拼消消作品可见性".to_string()); + } + let next = PuzzleClearWorkProfileRow { visible, ..row }; + let snapshot = puzzle_clear_work_visibility_snapshot(&next); + let profile_id = next.profile_id.clone(); + ctx.db + .puzzle_clear_work_profile() + .profile_id() + .delete(&profile_id); + ctx.db.puzzle_clear_work_profile().insert(next); + Ok(snapshot) +} + +fn puzzle_clear_work_visibility_snapshot( + row: &PuzzleClearWorkProfileRow, +) -> AdminWorkVisibilitySnapshot { + let sort_time = timestamp_sort_micros(row.published_at, row.updated_at); + AdminWorkVisibilitySnapshot { + source_type: SOURCE_TYPE_PUZZLE_CLEAR.to_string(), + work_id: row.work_id.clone(), + profile_id: row.profile_id.clone(), + source_session_id: Some(row.source_session_id.clone()).filter(|value| !value.is_empty()), + public_work_code: build_prefixed_public_work_code("PC", &row.profile_id), + owner_user_id: row.owner_user_id.clone(), + author_display_name: row.author_display_name.clone(), + title: choose_non_empty(&[row.work_title.as_str(), row.theme_prompt.as_str(), "拼消消"]), + subtitle: "拼消消".to_string(), + cover_image_src: Some(row.cover_image_src.clone()).filter(|value| !value.is_empty()), + visible: row.visible, + published_at_micros: row + .published_at + .map(|value| value.to_micros_since_unix_epoch()), + updated_at_micros: sort_time, + } +} + fn list_custom_world_work_visibility(ctx: &ReducerContext) -> Vec { ctx.db .custom_world_profile()