merge: admin work visibility controls

This commit is contained in:
kdletters
2026-05-28 01:01:25 +08:00
55 changed files with 1988 additions and 84 deletions

View File

@@ -20,6 +20,7 @@ pub fn bark_battle_gallery_view(ctx: &AnonymousViewContext) -> Vec<BarkBattleGal
.bark_battle_published_config()
.by_bark_battle_published_owner_user_id()
.filter(""..)
.filter(|row| row.visible)
.filter_map(|row| match build_bark_battle_gallery_view_row(ctx, &row) {
Ok(item) => Some(item),
Err(error) => {
@@ -260,6 +261,7 @@ fn publish_bark_battle_work_tx(
created_at: published_at,
updated_at: published_at,
published_at,
visible: true,
};
let mut published = published;
match ctx

View File

@@ -1,5 +1,7 @@
use crate::*;
const WORK_VISIBLE_DEFAULT: bool = true;
#[spacetimedb::table(
accessor = bark_battle_draft_config,
index(accessor = by_bark_battle_draft_owner_user_id, btree(columns = [owner_user_id])),
@@ -40,6 +42,9 @@ pub struct BarkBattlePublishedConfigRow {
pub(crate) created_at: Timestamp,
pub(crate) updated_at: Timestamp,
pub(crate) published_at: Timestamp,
// 后台可见性开关;默认显示,隐藏后不进入公开列表。
#[default(WORK_VISIBLE_DEFAULT)]
pub(crate) visible: bool,
}
#[spacetimedb::table(

View File

@@ -129,6 +129,7 @@ pub(crate) fn generate_big_fish_asset_tx(
published_at: session.published_at,
created_at: session.created_at,
updated_at,
visible: session.visible,
};
replace_big_fish_session(ctx, &session, next_session);
for event in readiness.events {
@@ -200,6 +201,7 @@ pub(crate) fn publish_big_fish_game_tx(
published_at: Some(published_at),
created_at: session.created_at,
updated_at: published_at,
visible: session.visible,
};
replace_big_fish_session(ctx, &session, next_session);
for event in readiness.events {

View File

@@ -23,6 +23,7 @@ pub fn big_fish_gallery_view(ctx: &AnonymousViewContext) -> Vec<BigFishWorkSumma
.big_fish_creation_session()
.by_big_fish_session_stage()
.filter(BigFishCreationStage::Published)
.filter(|row| row.visible)
.filter_map(|row| match build_big_fish_gallery_view_row(ctx, &row) {
Ok(snapshot) => Some(snapshot),
Err(error) => {
@@ -284,6 +285,7 @@ pub(crate) fn create_big_fish_session_tx(
published_at: None,
created_at,
updated_at: created_at,
visible: true,
});
ctx.db.big_fish_agent_message().insert(BigFishAgentMessage {
message_id: input.welcome_message_id,
@@ -492,6 +494,7 @@ pub(crate) fn submit_big_fish_message_tx(
published_at: session.published_at,
created_at: session.created_at,
updated_at: submitted_at,
visible: session.visible,
};
replace_big_fish_session(ctx, &session, next_session);
@@ -542,6 +545,7 @@ pub(crate) fn finalize_big_fish_agent_message_turn_tx(
published_at: session.published_at,
created_at: session.created_at,
updated_at,
visible: session.visible,
};
replace_big_fish_session(ctx, &session, next_session);
return Err(error_message.to_string());
@@ -600,6 +604,7 @@ pub(crate) fn finalize_big_fish_agent_message_turn_tx(
published_at: session.published_at,
created_at: session.created_at,
updated_at,
visible: session.visible,
};
replace_big_fish_session(ctx, &session, next_session);
@@ -667,6 +672,7 @@ pub(crate) fn compile_big_fish_draft_tx(
published_at: session.published_at,
created_at: session.created_at,
updated_at: compiled_at,
visible: session.visible,
};
replace_big_fish_session(ctx, &session, next_session);
for event in readiness.events {
@@ -768,6 +774,7 @@ pub(crate) fn record_big_fish_play_tx(
published_at: session.published_at,
created_at: session.created_at,
updated_at: played_at,
visible: session.visible,
};
replace_big_fish_session(ctx, &session, next_session);
@@ -821,6 +828,7 @@ pub(crate) fn record_big_fish_like_tx(
published_at: session.published_at,
created_at: session.created_at,
updated_at: liked_at,
visible: session.visible,
};
replace_big_fish_session(ctx, &session, next_session);
}
@@ -888,6 +896,7 @@ fn remix_big_fish_work_tx(
published_at: source.published_at,
created_at: source.created_at,
updated_at: remixed_at,
visible: source.visible,
};
replace_big_fish_session(ctx, &source, next_source);
@@ -909,6 +918,7 @@ fn remix_big_fish_work_tx(
published_at: None,
created_at: remixed_at,
updated_at: remixed_at,
visible: true,
};
ctx.db.big_fish_creation_session().insert(target_session);
ctx.db.big_fish_agent_message().insert(BigFishAgentMessage {
@@ -1238,6 +1248,7 @@ mod tests {
},
created_at: Timestamp::from_micros_since_unix_epoch(1),
updated_at: Timestamp::from_micros_since_unix_epoch(1),
visible: true,
}
}

View File

@@ -1,5 +1,7 @@
use crate::*;
const WORK_VISIBLE_DEFAULT: bool = true;
#[spacetimedb::table(
accessor = big_fish_creation_session,
index(accessor = by_big_fish_session_owner_user_id, btree(columns = [owner_user_id])),
@@ -28,6 +30,9 @@ pub struct BigFishCreationSession {
pub(crate) like_count: u32,
#[default(None::<Timestamp>)]
pub(crate) published_at: Option<Timestamp>,
// 后台可见性开关;默认显示,隐藏后不进入公开列表。
#[default(WORK_VISIBLE_DEFAULT)]
pub(crate) visible: bool,
}
#[spacetimedb::table(

View File

@@ -2,6 +2,8 @@ use crate::*;
use spacetimedb::AnonymousViewContext;
use std::collections::{HashMap, HashSet};
const WORK_VISIBLE_DEFAULT: bool = true;
#[spacetimedb::table(
accessor = custom_world_profile,
index(accessor = by_custom_world_profile_owner_user_id, btree(columns = [owner_user_id])),
@@ -12,36 +14,39 @@ use std::collections::{HashMap, HashSet};
)]
pub struct CustomWorldProfile {
#[primary_key]
profile_id: String,
pub(crate) profile_id: String,
// 当前 profile 承接 library / publish / enter-world 的正式世界工件真相。
owner_user_id: String,
pub(crate) owner_user_id: String,
// 作品公开编号是稳定分享键,第一次发布时分配,后续重复发布沿用。
public_work_code: Option<String>,
pub(crate) public_work_code: Option<String>,
// 作者公开陶泥号在发布时固化到作品真相,供广场读模型与搜索结果直接展示。
author_public_user_code: Option<String>,
source_agent_session_id: Option<String>,
publication_status: CustomWorldPublicationStatus,
world_name: String,
subtitle: String,
summary_text: String,
theme_mode: CustomWorldThemeMode,
cover_image_src: Option<String>,
profile_payload_json: String,
playable_npc_count: u32,
landmark_count: u32,
pub(crate) author_public_user_code: Option<String>,
pub(crate) source_agent_session_id: Option<String>,
pub(crate) publication_status: CustomWorldPublicationStatus,
pub(crate) world_name: String,
pub(crate) subtitle: String,
pub(crate) summary_text: String,
pub(crate) theme_mode: CustomWorldThemeMode,
pub(crate) cover_image_src: Option<String>,
pub(crate) profile_payload_json: String,
pub(crate) playable_npc_count: u32,
pub(crate) landmark_count: u32,
// 公开消费计数随 profile 真相持久化,发布、编辑和取消发布都不能重置。
#[default(0)]
play_count: u32,
pub(crate) play_count: u32,
#[default(0)]
remix_count: u32,
pub(crate) remix_count: u32,
#[default(0)]
like_count: u32,
author_display_name: String,
published_at: Option<Timestamp>,
pub(crate) like_count: u32,
pub(crate) author_display_name: String,
pub(crate) published_at: Option<Timestamp>,
// 软删除后保留 profile 真相,供审计与幂等删除使用。
deleted_at: Option<Timestamp>,
created_at: Timestamp,
updated_at: Timestamp,
pub(crate) deleted_at: Option<Timestamp>,
pub(crate) created_at: Timestamp,
pub(crate) updated_at: Timestamp,
// 后台可见性开关;默认显示,隐藏后不进入公开列表。
#[default(WORK_VISIBLE_DEFAULT)]
pub(crate) visible: bool,
}
#[spacetimedb::table(
@@ -170,28 +175,31 @@ pub struct CustomWorldDraftCard {
)]
pub struct CustomWorldGalleryEntry {
#[primary_key]
profile_id: String,
pub(crate) profile_id: String,
// 画廊是公开订阅读模型,不再运行时从 profile 即席拼装。
owner_user_id: String,
public_work_code: String,
author_public_user_code: String,
author_display_name: String,
world_name: String,
subtitle: String,
summary_text: String,
cover_image_src: Option<String>,
theme_mode: CustomWorldThemeMode,
playable_npc_count: u32,
landmark_count: u32,
pub(crate) owner_user_id: String,
pub(crate) public_work_code: String,
pub(crate) author_public_user_code: String,
pub(crate) author_display_name: String,
pub(crate) world_name: String,
pub(crate) subtitle: String,
pub(crate) summary_text: String,
pub(crate) cover_image_src: Option<String>,
pub(crate) theme_mode: CustomWorldThemeMode,
pub(crate) playable_npc_count: u32,
pub(crate) landmark_count: u32,
// 画廊读模型直接同步互动计数,避免前端临时把评分或游玩数改名成点赞。
#[default(0)]
play_count: u32,
pub(crate) play_count: u32,
#[default(0)]
remix_count: u32,
pub(crate) remix_count: u32,
#[default(0)]
like_count: u32,
published_at: Timestamp,
updated_at: Timestamp,
pub(crate) like_count: u32,
pub(crate) published_at: Timestamp,
pub(crate) updated_at: Timestamp,
// 后台可见性开关;默认显示,隐藏后不进入公开列表。
#[default(WORK_VISIBLE_DEFAULT)]
pub(crate) visible: bool,
}
// Agent 会话首版只负责把可持久化创作状态落进 SpacetimeDBLLM 采集与卡片生成后续再接入。
#[spacetimedb::procedure]
@@ -1229,6 +1237,7 @@ fn upsert_custom_world_profile_record(
deleted_at: None,
created_at: existing.created_at,
updated_at,
visible: existing.visible,
}
}
None => CustomWorldProfile {
@@ -1254,6 +1263,7 @@ fn upsert_custom_world_profile_record(
deleted_at: None,
created_at: updated_at,
updated_at,
visible: true,
},
};
@@ -1401,6 +1411,7 @@ fn publish_custom_world_profile_record(
deleted_at: None,
created_at: existing.created_at,
updated_at: published_at,
visible: existing.visible,
};
let inserted = ctx.db.custom_world_profile().insert(next_row);
@@ -1467,6 +1478,7 @@ fn unpublish_custom_world_profile_record(
deleted_at: None,
created_at: existing.created_at,
updated_at,
visible: existing.visible,
};
let inserted = ctx.db.custom_world_profile().insert(next_row);
@@ -1529,6 +1541,7 @@ fn delete_custom_world_profile_record(
deleted_at: Some(deleted_at),
created_at: existing.created_at,
updated_at: deleted_at,
visible: existing.visible,
};
let _ = ctx.db.custom_world_profile().insert(next_row);
@@ -1651,6 +1664,7 @@ fn get_custom_world_gallery_detail_record(
row.owner_user_id == input.owner_user_id
&& row.publication_status == CustomWorldPublicationStatus::Published
&& row.deleted_at.is_none()
&& row.visible
});
let gallery_entry = ctx
@@ -1745,6 +1759,7 @@ fn remix_custom_world_profile_record(
.filter(|row| {
row.publication_status == CustomWorldPublicationStatus::Published
&& row.deleted_at.is_none()
&& row.visible
&& row.published_at.is_some()
})
.ok_or_else(|| "custom_world 已发布源作品不存在,无法改编".to_string())?;
@@ -1777,6 +1792,7 @@ fn remix_custom_world_profile_record(
deleted_at: source.deleted_at,
created_at: source.created_at,
updated_at: remixed_at,
visible: source.visible,
};
let updated_source = ctx.db.custom_world_profile().insert(next_source);
let source_gallery = sync_custom_world_gallery_entry_from_profile(ctx, &updated_source)?;
@@ -1805,6 +1821,7 @@ fn remix_custom_world_profile_record(
deleted_at: None,
created_at: remixed_at,
updated_at: remixed_at,
visible: true,
};
if let Some(existing_target) = ctx
@@ -1845,6 +1862,7 @@ fn record_custom_world_profile_play_record(
.filter(|row| {
row.publication_status == CustomWorldPublicationStatus::Published
&& row.deleted_at.is_none()
&& row.visible
&& row.published_at.is_some()
})
.ok_or_else(|| "custom_world 已发布作品不存在,无法记录游玩".to_string())?;
@@ -1887,6 +1905,7 @@ fn record_custom_world_profile_play_record(
deleted_at: existing.deleted_at,
created_at: existing.created_at,
updated_at: played_at,
visible: existing.visible,
};
let inserted = ctx.db.custom_world_profile().insert(next_row);
let gallery_entry = sync_custom_world_gallery_entry_from_profile(ctx, &inserted)?;
@@ -1916,6 +1935,7 @@ fn record_custom_world_profile_like_record(
.filter(|row| {
row.publication_status == CustomWorldPublicationStatus::Published
&& row.deleted_at.is_none()
&& row.visible
&& row.published_at.is_some()
})
.ok_or_else(|| "custom_world 已发布作品不存在,无法点赞".to_string())?;
@@ -1967,6 +1987,7 @@ fn record_custom_world_profile_like_record(
deleted_at: existing.deleted_at,
created_at: existing.created_at,
updated_at: liked_at,
visible: existing.visible,
};
let inserted = ctx.db.custom_world_profile().insert(next_row);
let gallery_entry = sync_custom_world_gallery_entry_from_profile(ctx, &inserted)?;
@@ -2582,6 +2603,7 @@ fn is_same_agent_draft_profile_candidate(
) -> bool {
row.owner_user_id == owner_user_id
&& row.deleted_at.is_none()
&& row.visible
&& row.publication_status == CustomWorldPublicationStatus::Draft
&& row.source_agent_session_id.as_deref() == Some(source_agent_session_id)
}
@@ -4841,6 +4863,7 @@ fn sync_custom_world_gallery_entry_from_profile(
like_count: profile.like_count,
published_at,
updated_at: profile.updated_at,
visible: profile.visible,
};
let inserted = ctx.db.custom_world_gallery_entry().insert(row);
@@ -4854,7 +4877,7 @@ fn sync_missing_custom_world_gallery_entries(ctx: &ReducerContext) -> Result<(),
.custom_world_profile()
.by_custom_world_profile_publication_status()
.filter(CustomWorldPublicationStatus::Published)
.filter(|profile| profile.deleted_at.is_none())
.filter(|profile| profile.deleted_at.is_none() && profile.visible)
.collect::<Vec<_>>();
for profile in published_profiles {
@@ -4926,6 +4949,7 @@ fn ensure_custom_world_profile_public_fields(
deleted_at: profile.deleted_at,
created_at: profile.created_at,
updated_at: profile.updated_at,
visible: profile.visible,
};
ctx.db.custom_world_profile().insert(next_row)
@@ -4955,6 +4979,7 @@ fn build_custom_world_profile_row_copy(profile: &CustomWorldProfile) -> CustomWo
deleted_at: profile.deleted_at,
created_at: profile.created_at,
updated_at: profile.updated_at,
visible: profile.visible,
}
}
@@ -4997,7 +5022,7 @@ pub(crate) fn custom_world_public_profile_snapshots(
.custom_world_profile()
.by_custom_world_profile_publication_status()
.filter(CustomWorldPublicationStatus::Published)
.filter(|row| row.deleted_at.is_none())
.filter(|row| row.deleted_at.is_none() && row.visible)
.map(|row| build_custom_world_profile_snapshot(&row))
.collect::<Vec<_>>();
@@ -5156,6 +5181,7 @@ pub(crate) fn custom_world_public_gallery_snapshots(
.custom_world_gallery_entry()
.by_custom_world_gallery_owner_user_id()
.filter(""..)
.filter(|row| row.visible)
.map(|row| {
build_custom_world_gallery_entry_snapshot_with_recent_counts(&row, &HashMap::new())
})
@@ -5377,6 +5403,7 @@ mod tests {
deleted_at: None,
created_at: Timestamp::from_micros_since_unix_epoch(1),
updated_at: Timestamp::from_micros_since_unix_epoch(1),
visible: true,
};
let deleted = CustomWorldProfile {
profile_id: "profile-1".to_string(),
@@ -5401,6 +5428,7 @@ mod tests {
deleted_at: Some(Timestamp::from_micros_since_unix_epoch(2)),
created_at: Timestamp::from_micros_since_unix_epoch(1),
updated_at: Timestamp::from_micros_since_unix_epoch(1),
visible: true,
};
let published = CustomWorldProfile {
profile_id: "profile-1".to_string(),
@@ -5425,6 +5453,7 @@ mod tests {
deleted_at: None,
created_at: Timestamp::from_micros_since_unix_epoch(1),
updated_at: Timestamp::from_micros_since_unix_epoch(1),
visible: true,
};
assert!(is_same_agent_draft_profile_candidate(
@@ -5552,6 +5581,7 @@ mod tests {
deleted_at: None,
created_at: Timestamp::from_micros_since_unix_epoch(1),
updated_at: Timestamp::from_micros_since_unix_epoch(1),
visible: true,
};
let mut active_agent_session_ids = HashSet::new();

View File

@@ -20,6 +20,7 @@ pub fn jump_hop_gallery_view(ctx: &AnonymousViewContext) -> Vec<JumpHopGalleryVi
.jump_hop_work_profile()
.by_jump_hop_work_publication_status()
.filter(JUMP_HOP_PUBLICATION_PUBLISHED)
.filter(|row| row.visible)
.filter_map(|row| match build_gallery_view_row(&row) {
Ok(item) => Some(item),
Err(error) => {
@@ -401,6 +402,7 @@ fn compile_jump_hop_draft_tx(
play_count: 0,
updated_at: compiled_at,
published_at: None,
visible: true,
};
upsert_work(ctx, row);
replace_session(
@@ -1163,6 +1165,7 @@ fn clone_work(row: &JumpHopWorkProfileRow) -> JumpHopWorkProfileRow {
play_count: row.play_count,
updated_at: row.updated_at,
published_at: row.published_at,
visible: row.visible,
}
}

View File

@@ -1,5 +1,7 @@
use crate::*;
const WORK_VISIBLE_DEFAULT: bool = true;
#[spacetimedb::table(
accessor = jump_hop_agent_session,
index(accessor = by_jump_hop_agent_session_owner_user_id, btree(columns = [owner_user_id]))
@@ -51,6 +53,9 @@ pub struct JumpHopWorkProfileRow {
pub(crate) play_count: u32,
pub(crate) updated_at: Timestamp,
pub(crate) published_at: Option<Timestamp>,
// 后台可见性开关;默认显示,隐藏后不进入公开列表。
#[default(WORK_VISIBLE_DEFAULT)]
pub(crate) visible: bool,
}
#[spacetimedb::table(

View File

@@ -32,6 +32,7 @@ pub fn match3d_gallery_view(ctx: &AnonymousViewContext) -> Vec<Match3DGalleryVie
.match3d_work_profile()
.by_match3d_work_publication_status()
.filter(MATCH3D_PUBLICATION_PUBLISHED)
.filter(|row| row.visible)
.filter_map(|row| match build_gallery_view_row(&row) {
Ok(item) => Some(item),
Err(error) => {
@@ -571,6 +572,7 @@ fn compile_match3d_draft_tx(
updated_at: compiled_at,
published_at: previous_published_at,
generated_item_assets_json,
visible: true,
};
upsert_work(ctx, work);
replace_session(
@@ -643,6 +645,7 @@ fn build_updated_match3d_work_row(
updated_at,
published_at: current.published_at,
generated_item_assets_json: current.generated_item_assets_json.clone(),
visible: current.visible,
};
Ok(next)
}
@@ -1330,6 +1333,7 @@ fn clone_work(row: &Match3DWorkProfileRow) -> Match3DWorkProfileRow {
updated_at: row.updated_at,
published_at: row.published_at,
generated_item_assets_json: row.generated_item_assets_json.clone(),
visible: row.visible,
}
}
@@ -1885,6 +1889,7 @@ mod tests {
updated_at: Timestamp::from_micros_since_unix_epoch(1),
published_at: None,
generated_item_assets_json: None,
visible: true,
};
let snapshot = build_initial_run_snapshot("run-1", &work, 10, None);
assert_eq!(snapshot.total_item_count, 12);
@@ -1924,6 +1929,7 @@ mod tests {
r#"[{"itemId":"match3d-item-1","itemName":"草莓","imageSrc":"/generated-match3d-assets/session/profile/items/item/image.png","status":"image_ready"}]"#
.to_string(),
),
visible: true,
};
let snapshot = build_work_snapshot(&work).expect("work snapshot should build");
@@ -1969,6 +1975,7 @@ mod tests {
r#"[{"itemId":"match3d-item-1","itemName":"草莓","imageSrc":"/generated-match3d-assets/session/profile/items/item/image.png","status":"image_ready"}]"#
.to_string(),
),
visible: true,
};
let preserved =
@@ -2038,6 +2045,7 @@ mod tests {
r#"[{"itemId":"match3d-item-1","itemName":"草莓","imageSrc":"/generated-match3d-assets/session/profile/items/item/image.png","status":"image_ready"}]"#
.to_string(),
),
visible: true,
};
let input = Match3DWorkUpdateInput {
profile_id: existing.profile_id.clone(),
@@ -2097,6 +2105,7 @@ mod tests {
r#"[{"itemId":"match3d-item-1","itemName":"草莓","imageSrc":"/generated-match3d-assets/session/profile/items/item/image.png","status":"image_ready"},{"itemId":"match3d-item-2","itemName":"苹果","imageViews":[{"imageSrc":"/v1.png"},{"imageSrc":"/v2.png"},{"imageSrc":"/v3.png"},{"imageSrc":"/v4.png"},{"imageSrc":"/v5.png"}],"status":"model_ready"},{"itemId":"match3d-item-3","itemName":"香蕉","imageViews":[{"imageSrc":"/v1.png"},{"imageSrc":"/v2.png"},{"imageSrc":"/v3.png"},{"imageSrc":"/v4.png"}],"status":"image_ready"}]"#
.to_string(),
),
visible: true,
};
let error = validate_publishable_work(&base_work).unwrap_err();
@@ -2156,6 +2165,7 @@ mod tests {
updated_at: Timestamp::from_micros_since_unix_epoch(1),
published_at: None,
generated_item_assets_json: None,
visible: true,
};
let input_game_name = None;

View File

@@ -1,5 +1,7 @@
use crate::*;
const WORK_VISIBLE_DEFAULT: bool = true;
#[spacetimedb::table(
accessor = match3d_agent_session,
index(accessor = by_match3d_agent_session_owner_user_id, btree(columns = [owner_user_id]))
@@ -60,6 +62,9 @@ pub struct Match3DWorkProfileRow {
pub(crate) published_at: Option<Timestamp>,
#[default(None::<String>)]
pub(crate) generated_item_assets_json: Option<String>,
// 后台可见性开关;默认显示,隐藏后不进入公开列表。
#[default(WORK_VISIBLE_DEFAULT)]
pub(crate) visible: bool,
}
#[spacetimedb::table(

View File

@@ -1243,6 +1243,10 @@ fn normalize_migration_row(table_name: &str, value: &serde_json::Value) -> serde
}
if table_name == "custom_world_profile" || table_name == "custom_world_gallery_entry" {
if let Some(object) = next_value.as_object_mut() {
// 中文注释:作品可见性字段晚于首版作品表加入,旧迁移包按默认显示兼容。
object
.entry("visible".to_string())
.or_insert_with(|| serde_json::Value::Bool(true));
// 中文注释:自定义世界公开互动计数字段晚于基础作品表加入,旧迁移包按 0 兼容。
object
.entry("play_count".to_string())
@@ -1257,6 +1261,10 @@ fn normalize_migration_row(table_name: &str, value: &serde_json::Value) -> serde
}
if table_name == "puzzle_work_profile" {
if let Some(object) = next_value.as_object_mut() {
// 中文注释:作品可见性字段晚于首版作品表加入,旧迁移包按默认显示兼容。
object
.entry("visible".to_string())
.or_insert_with(|| serde_json::Value::Bool(true));
// 中文注释:拼图公开互动计数晚于基础作品表加入,旧迁移包按 0 兼容。
object
.entry("play_count".to_string())
@@ -1294,8 +1302,34 @@ fn normalize_migration_row(table_name: &str, value: &serde_json::Value) -> serde
.or_insert(fallback_description);
}
}
if table_name == "big_fish_creation_session" {
if let Some(object) = next_value.as_object_mut() {
// 中文注释:作品可见性字段晚于大鱼吃小鱼创作会话表加入,旧迁移包按默认显示兼容。
object
.entry("visible".to_string())
.or_insert_with(|| serde_json::Value::Bool(true));
}
}
if matches!(
table_name,
"jump_hop_work_profile"
| "square_hole_work_profile"
| "visual_novel_work_profile"
| "bark_battle_published_config"
) {
if let Some(object) = next_value.as_object_mut() {
// 中文注释:作品可见性字段晚于首版作品表加入,旧迁移包按默认显示兼容。
object
.entry("visible".to_string())
.or_insert_with(|| serde_json::Value::Bool(true));
}
}
if table_name == "match3d_work_profile" {
if let Some(object) = next_value.as_object_mut() {
// 中文注释:作品可见性字段晚于首版作品表加入,旧迁移包按默认显示兼容。
object
.entry("visible".to_string())
.or_insert_with(|| serde_json::Value::Bool(true));
// 中文注释:抓大鹅生成素材字段晚于基础作品表加入,旧迁移包按未生成素材兼容。
object
.entry("generated_item_assets_json".to_string())
@@ -1304,6 +1338,10 @@ fn normalize_migration_row(table_name: &str, value: &serde_json::Value) -> serde
}
if table_name == "wooden_fish_work_profile" {
if let Some(object) = next_value.as_object_mut() {
// 中文注释:作品可见性字段晚于首版作品表加入,旧迁移包按默认显示兼容。
object
.entry("visible".to_string())
.or_insert_with(|| serde_json::Value::Bool(true));
// 中文注释:敲木鱼背景环境图晚于首版作品表加入,旧迁移包按未生成背景兼容。
object
.entry("background_asset_json".to_string())

View File

@@ -38,6 +38,7 @@ use spacetimedb::{
use crate::auth::user_account;
const PUZZLE_POINT_INCENTIVE_DEFAULT_U64: u64 = 0;
const WORK_VISIBLE_DEFAULT: bool = true;
/// 拼图 Agent session 真相表。
/// 当前只保存结构化字段与 JSON 草稿,不提前拆出更多编辑态子表。
@@ -84,34 +85,37 @@ pub struct PuzzleAgentMessageRow {
)]
pub struct PuzzleWorkProfileRow {
#[primary_key]
profile_id: String,
work_id: String,
owner_user_id: String,
source_session_id: Option<String>,
author_display_name: String,
work_title: String,
work_description: String,
level_name: String,
summary: String,
theme_tags_json: String,
cover_image_src: Option<String>,
cover_asset_id: Option<String>,
levels_json: String,
publication_status: PuzzlePublicationStatus,
play_count: u32,
anchor_pack_json: String,
publish_ready: bool,
created_at: Timestamp,
updated_at: Timestamp,
published_at: Option<Timestamp>,
pub(crate) profile_id: String,
pub(crate) work_id: String,
pub(crate) owner_user_id: String,
pub(crate) source_session_id: Option<String>,
pub(crate) author_display_name: String,
pub(crate) work_title: String,
pub(crate) work_description: String,
pub(crate) level_name: String,
pub(crate) summary: String,
pub(crate) theme_tags_json: String,
pub(crate) cover_image_src: Option<String>,
pub(crate) cover_asset_id: Option<String>,
pub(crate) levels_json: String,
pub(crate) publication_status: PuzzlePublicationStatus,
pub(crate) play_count: u32,
pub(crate) anchor_pack_json: String,
pub(crate) publish_ready: bool,
pub(crate) created_at: Timestamp,
pub(crate) updated_at: Timestamp,
pub(crate) published_at: Option<Timestamp>,
#[default(0)]
remix_count: u32,
pub(crate) remix_count: u32,
#[default(0)]
like_count: u32,
pub(crate) like_count: u32,
#[default(PUZZLE_POINT_INCENTIVE_DEFAULT_U64)]
point_incentive_total_half_points: u64,
pub(crate) point_incentive_total_half_points: u64,
#[default(PUZZLE_POINT_INCENTIVE_DEFAULT_U64)]
point_incentive_claimed_points: u64,
pub(crate) point_incentive_claimed_points: u64,
// 后台可见性开关;默认显示,隐藏后不进入公开列表。
#[default(WORK_VISIBLE_DEFAULT)]
pub(crate) visible: bool,
}
/// 拼图广场公开详情兼容投影。
@@ -125,6 +129,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_map(
|row| match build_puzzle_work_profile_from_row_without_recent_count(&row) {
Ok(profile) => Some(profile),
@@ -154,6 +159,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_map(|row| match build_puzzle_gallery_card_view_row(&row) {
Ok(item) => Some(item),
Err(error) => {
@@ -1578,6 +1584,7 @@ fn update_puzzle_work_tx(
created_at: row.created_at,
updated_at: Timestamp::from_micros_since_unix_epoch(input.updated_at_micros),
published_at: row.published_at,
visible: row.visible,
};
replace_puzzle_work_profile(ctx, &row, next_row);
sync_puzzle_source_session_draft_from_work(ctx, &row, &preview_draft, input.updated_at_micros)?;
@@ -1790,6 +1797,7 @@ fn record_puzzle_work_like_tx(
created_at: row.created_at,
updated_at: liked_at,
published_at: row.published_at,
visible: row.visible,
};
replace_puzzle_work_profile(ctx, &row, next_row);
ctx.db
@@ -1878,6 +1886,7 @@ fn remix_puzzle_work_tx(
created_at: source.created_at,
updated_at: remixed_at,
published_at: source.published_at,
visible: source.visible,
},
);
@@ -1945,6 +1954,7 @@ fn remix_puzzle_work_tx(
created_at: remixed_at,
updated_at: remixed_at,
published_at: None,
visible: true,
});
get_puzzle_agent_session_tx(
@@ -2396,6 +2406,7 @@ fn claim_puzzle_work_point_incentive_tx(
created_at: row.created_at,
updated_at: claimed_at,
published_at: row.published_at,
visible: row.visible,
};
replace_puzzle_work_profile(ctx, &row, next_row);
@@ -3008,6 +3019,7 @@ fn upsert_puzzle_work_profile(ctx: &TxContext, profile: PuzzleWorkProfile) -> Re
published_at: profile
.published_at_micros
.map(Timestamp::from_micros_since_unix_epoch),
visible: existing.visible,
},
);
return Ok(());
@@ -3040,6 +3052,7 @@ fn upsert_puzzle_work_profile(ctx: &TxContext, profile: PuzzleWorkProfile) -> Re
published_at: profile
.published_at_micros
.map(Timestamp::from_micros_since_unix_epoch),
visible: true,
});
Ok(())
}
@@ -3364,6 +3377,7 @@ fn accrue_puzzle_point_incentive(
created_at: row.created_at,
updated_at: Timestamp::from_micros_since_unix_epoch(updated_at_micros),
published_at: row.published_at,
visible: row.visible,
},
);
Ok(())
@@ -3402,6 +3416,7 @@ fn increment_puzzle_profile_play_count(
created_at: row.created_at,
updated_at: Timestamp::from_micros_since_unix_epoch(updated_at_micros),
published_at: row.published_at,
visible: row.visible,
},
);
}

View File

@@ -1,4 +1,5 @@
pub mod analytics_date_dimension;
mod admin_work_visibility;
mod browse_history;
pub mod creation_entry_config;
mod profile;
@@ -6,6 +7,7 @@ mod settings;
mod snapshots;
pub use analytics_date_dimension::*;
pub use admin_work_visibility::*;
pub use browse_history::*;
pub use creation_entry_config::*;
pub use profile::*;

View File

@@ -0,0 +1,708 @@
use crate::*;
use crate::puzzle::{PuzzleWorkProfileRow, puzzle_work_profile};
use module_custom_world::CustomWorldPublicationStatus;
use module_puzzle::PuzzlePublicationStatus;
const SOURCE_TYPE_PUZZLE: &str = "puzzle";
const SOURCE_TYPE_CUSTOM_WORLD: &str = "custom-world";
const SOURCE_TYPE_JUMP_HOP: &str = "jump-hop";
const SOURCE_TYPE_WOODEN_FISH: &str = "wooden-fish";
const SOURCE_TYPE_MATCH3D: &str = "match3d";
const SOURCE_TYPE_SQUARE_HOLE: &str = "square-hole";
const SOURCE_TYPE_VISUAL_NOVEL: &str = "visual-novel";
const SOURCE_TYPE_BIG_FISH: &str = "big-fish";
const SOURCE_TYPE_BARK_BATTLE: &str = "bark-battle";
/// 后台作品可见性列表。
///
/// 中文注释:后台必须能看到 hidden 作品,不能复用 public_work_* view否则隐藏后无法恢复。
#[spacetimedb::procedure]
pub fn admin_list_work_visibility(
ctx: &mut ProcedureContext,
input: AdminWorkVisibilityListInput,
) -> AdminWorkVisibilityListProcedureResult {
match ctx.try_with_tx(|tx| list_work_visibility_tx(tx, input.clone())) {
Ok(entries) => AdminWorkVisibilityListProcedureResult {
ok: true,
entries,
error_message: None,
},
Err(message) => AdminWorkVisibilityListProcedureResult {
ok: false,
entries: Vec::new(),
error_message: Some(message),
},
}
}
/// 后台修改单个作品可见性。
#[spacetimedb::procedure]
pub fn admin_update_work_visibility(
ctx: &mut ProcedureContext,
input: AdminWorkVisibilityUpdateInput,
) -> AdminWorkVisibilityProcedureResult {
match ctx.try_with_tx(|tx| update_work_visibility_tx(tx, input.clone())) {
Ok(record) => AdminWorkVisibilityProcedureResult {
ok: true,
record: Some(record),
error_message: None,
},
Err(message) => AdminWorkVisibilityProcedureResult {
ok: false,
record: None,
error_message: Some(message),
},
}
}
fn list_work_visibility_tx(
ctx: &ReducerContext,
input: AdminWorkVisibilityListInput,
) -> Result<Vec<AdminWorkVisibilitySnapshot>, String> {
require_admin_user_id(&input.admin_user_id)?;
let mut entries = Vec::new();
entries.extend(list_puzzle_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));
entries.extend(list_match3d_work_visibility(ctx));
entries.extend(list_square_hole_work_visibility(ctx));
entries.extend(list_visual_novel_work_visibility(ctx));
entries.extend(list_big_fish_work_visibility(ctx));
entries.extend(list_bark_battle_work_visibility(ctx));
sort_work_visibility_entries(&mut entries);
Ok(entries)
}
fn update_work_visibility_tx(
ctx: &ReducerContext,
input: AdminWorkVisibilityUpdateInput,
) -> Result<AdminWorkVisibilitySnapshot, String> {
require_admin_user_id(&input.admin_user_id)?;
let source_type = normalize_source_type(&input.source_type)?;
let profile_id = normalize_required_text(&input.profile_id, "profileId")?;
match source_type.as_str() {
SOURCE_TYPE_PUZZLE => update_puzzle_work_visibility(ctx, &profile_id, input.visible),
SOURCE_TYPE_CUSTOM_WORLD => {
update_custom_world_work_visibility(ctx, &profile_id, input.visible)
}
SOURCE_TYPE_JUMP_HOP => update_jump_hop_work_visibility(ctx, &profile_id, input.visible),
SOURCE_TYPE_WOODEN_FISH => {
update_wooden_fish_work_visibility(ctx, &profile_id, input.visible)
}
SOURCE_TYPE_MATCH3D => update_match3d_work_visibility(ctx, &profile_id, input.visible),
SOURCE_TYPE_SQUARE_HOLE => update_square_hole_work_visibility(ctx, &profile_id, input.visible),
SOURCE_TYPE_VISUAL_NOVEL => {
update_visual_novel_work_visibility(ctx, &profile_id, input.visible)
}
SOURCE_TYPE_BIG_FISH => update_big_fish_work_visibility(ctx, &profile_id, input.visible),
SOURCE_TYPE_BARK_BATTLE => {
update_bark_battle_work_visibility(ctx, &profile_id, input.visible)
}
_ => Err(format!("不支持的作品类型:{source_type}")),
}
}
fn list_puzzle_work_visibility(ctx: &ReducerContext) -> Vec<AdminWorkVisibilitySnapshot> {
ctx.db
.puzzle_work_profile()
.by_puzzle_work_publication_status()
.filter(PuzzlePublicationStatus::Published)
.map(|row| puzzle_work_visibility_snapshot(&row))
.collect()
}
fn update_puzzle_work_visibility(
ctx: &ReducerContext,
profile_id: &str,
visible: bool,
) -> Result<AdminWorkVisibilitySnapshot, String> {
let row = ctx
.db
.puzzle_work_profile()
.profile_id()
.find(&profile_id.to_string())
.ok_or_else(|| "拼图作品不存在".to_string())?;
if row.publication_status != PuzzlePublicationStatus::Published {
return Err("只能修改已发布拼图作品可见性".to_string());
}
let next = PuzzleWorkProfileRow { visible, ..row };
ctx.db
.puzzle_work_profile()
.profile_id()
.delete(&next.profile_id);
ctx.db.puzzle_work_profile().insert(next);
let updated = ctx
.db
.puzzle_work_profile()
.profile_id()
.find(&profile_id.to_string())
.ok_or_else(|| "拼图作品可见性更新失败".to_string())?;
Ok(puzzle_work_visibility_snapshot(&updated))
}
fn puzzle_work_visibility_snapshot(row: &PuzzleWorkProfileRow) -> AdminWorkVisibilitySnapshot {
let sort_time = timestamp_sort_micros(row.published_at, row.updated_at);
AdminWorkVisibilitySnapshot {
source_type: SOURCE_TYPE_PUZZLE.to_string(),
work_id: row.work_id.clone(),
profile_id: row.profile_id.clone(),
source_session_id: row.source_session_id.clone(),
public_work_code: build_prefixed_public_work_code("PZ", &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.level_name.as_str()]),
subtitle: "拼图关卡".to_string(),
cover_image_src: row.cover_image_src.clone(),
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<AdminWorkVisibilitySnapshot> {
ctx.db
.custom_world_profile()
.by_custom_world_profile_publication_status()
.filter(CustomWorldPublicationStatus::Published)
.filter(|row| row.deleted_at.is_none())
.map(|row| custom_world_work_visibility_snapshot(&row))
.collect()
}
fn update_custom_world_work_visibility(
ctx: &ReducerContext,
profile_id: &str,
visible: bool,
) -> Result<AdminWorkVisibilitySnapshot, String> {
let row = ctx
.db
.custom_world_profile()
.profile_id()
.find(&profile_id.to_string())
.ok_or_else(|| "自定义世界作品不存在".to_string())?;
if row.publication_status != CustomWorldPublicationStatus::Published || row.deleted_at.is_some()
{
return Err("只能修改已发布自定义世界作品可见性".to_string());
}
let next = CustomWorldProfile { visible, ..row };
let snapshot = custom_world_work_visibility_snapshot(&next);
let profile_id = next.profile_id.clone();
ctx.db
.custom_world_profile()
.profile_id()
.delete(&profile_id);
ctx.db.custom_world_profile().insert(next);
if let Some(gallery) = ctx
.db
.custom_world_gallery_entry()
.profile_id()
.find(&profile_id)
{
let next_gallery = CustomWorldGalleryEntry { visible, ..gallery };
let gallery_profile_id = next_gallery.profile_id.clone();
ctx.db
.custom_world_gallery_entry()
.profile_id()
.delete(&gallery_profile_id);
ctx.db.custom_world_gallery_entry().insert(next_gallery);
}
Ok(snapshot)
}
fn custom_world_work_visibility_snapshot(row: &CustomWorldProfile) -> AdminWorkVisibilitySnapshot {
let public_work_code = row
.public_work_code
.clone()
.unwrap_or_else(|| build_custom_world_public_work_code(&row.profile_id));
let sort_time = timestamp_sort_micros(row.published_at, row.updated_at);
AdminWorkVisibilitySnapshot {
source_type: SOURCE_TYPE_CUSTOM_WORLD.to_string(),
work_id: format!("custom-world:{}", row.profile_id),
profile_id: row.profile_id.clone(),
source_session_id: row.source_agent_session_id.clone(),
public_work_code,
owner_user_id: row.owner_user_id.clone(),
author_display_name: row.author_display_name.clone(),
title: row.world_name.clone(),
subtitle: row.subtitle.clone(),
cover_image_src: row.cover_image_src.clone(),
visible: row.visible,
published_at_micros: row.published_at.map(|value| value.to_micros_since_unix_epoch()),
updated_at_micros: sort_time,
}
}
fn list_jump_hop_work_visibility(ctx: &ReducerContext) -> Vec<AdminWorkVisibilitySnapshot> {
ctx.db
.jump_hop_work_profile()
.by_jump_hop_work_publication_status()
.filter(JUMP_HOP_PUBLICATION_PUBLISHED)
.map(|row| jump_hop_work_visibility_snapshot(&row))
.collect()
}
fn update_jump_hop_work_visibility(
ctx: &ReducerContext,
profile_id: &str,
visible: bool,
) -> Result<AdminWorkVisibilitySnapshot, String> {
let row = ctx
.db
.jump_hop_work_profile()
.profile_id()
.find(&profile_id.to_string())
.ok_or_else(|| "跳一跳作品不存在".to_string())?;
if row.publication_status != JUMP_HOP_PUBLICATION_PUBLISHED {
return Err("只能修改已发布跳一跳作品可见性".to_string());
}
let next = JumpHopWorkProfileRow { visible, ..row };
let snapshot = jump_hop_work_visibility_snapshot(&next);
let profile_id = next.profile_id.clone();
ctx.db
.jump_hop_work_profile()
.profile_id()
.delete(&profile_id);
ctx.db.jump_hop_work_profile().insert(next);
Ok(snapshot)
}
fn jump_hop_work_visibility_snapshot(row: &JumpHopWorkProfileRow) -> AdminWorkVisibilitySnapshot {
let sort_time = timestamp_sort_micros(row.published_at, row.updated_at);
AdminWorkVisibilitySnapshot {
source_type: SOURCE_TYPE_JUMP_HOP.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("JH", &row.profile_id),
owner_user_id: row.owner_user_id.clone(),
author_display_name: row.author_display_name.clone(),
title: row.work_title.clone(),
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_wooden_fish_work_visibility(ctx: &ReducerContext) -> Vec<AdminWorkVisibilitySnapshot> {
ctx.db
.wooden_fish_work_profile()
.by_wooden_fish_work_publication_status()
.filter(WOODEN_FISH_PUBLICATION_PUBLISHED)
.map(|row| wooden_fish_work_visibility_snapshot(&row))
.collect()
}
fn update_wooden_fish_work_visibility(
ctx: &ReducerContext,
profile_id: &str,
visible: bool,
) -> Result<AdminWorkVisibilitySnapshot, String> {
let row = ctx
.db
.wooden_fish_work_profile()
.profile_id()
.find(&profile_id.to_string())
.ok_or_else(|| "敲木鱼作品不存在".to_string())?;
if row.publication_status != WOODEN_FISH_PUBLICATION_PUBLISHED {
return Err("只能修改已发布敲木鱼作品可见性".to_string());
}
let next = WoodenFishWorkProfileRow { visible, ..row };
let snapshot = wooden_fish_work_visibility_snapshot(&next);
let profile_id = next.profile_id.clone();
ctx.db
.wooden_fish_work_profile()
.profile_id()
.delete(&profile_id);
ctx.db.wooden_fish_work_profile().insert(next);
Ok(snapshot)
}
fn wooden_fish_work_visibility_snapshot(
row: &WoodenFishWorkProfileRow,
) -> AdminWorkVisibilitySnapshot {
let sort_time = timestamp_sort_micros(row.published_at, row.updated_at);
AdminWorkVisibilitySnapshot {
source_type: SOURCE_TYPE_WOODEN_FISH.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("WF", &row.profile_id),
owner_user_id: row.owner_user_id.clone(),
author_display_name: row.author_display_name.clone(),
title: row.work_title.clone(),
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_match3d_work_visibility(ctx: &ReducerContext) -> Vec<AdminWorkVisibilitySnapshot> {
ctx.db
.match3d_work_profile()
.by_match3d_work_publication_status()
.filter(MATCH3D_PUBLICATION_PUBLISHED)
.map(|row| match3d_work_visibility_snapshot(&row))
.collect()
}
fn update_match3d_work_visibility(
ctx: &ReducerContext,
profile_id: &str,
visible: bool,
) -> Result<AdminWorkVisibilitySnapshot, String> {
let row = ctx
.db
.match3d_work_profile()
.profile_id()
.find(&profile_id.to_string())
.ok_or_else(|| "抓大鹅作品不存在".to_string())?;
if row.publication_status != MATCH3D_PUBLICATION_PUBLISHED {
return Err("只能修改已发布抓大鹅作品可见性".to_string());
}
let next = Match3DWorkProfileRow { visible, ..row };
let snapshot = match3d_work_visibility_snapshot(&next);
let profile_id = next.profile_id.clone();
ctx.db
.match3d_work_profile()
.profile_id()
.delete(&profile_id);
ctx.db.match3d_work_profile().insert(next);
Ok(snapshot)
}
fn match3d_work_visibility_snapshot(row: &Match3DWorkProfileRow) -> AdminWorkVisibilitySnapshot {
let sort_time = timestamp_sort_micros(row.published_at, row.updated_at);
AdminWorkVisibilitySnapshot {
source_type: SOURCE_TYPE_MATCH3D.to_string(),
work_id: row.profile_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("M3", &row.profile_id),
owner_user_id: row.owner_user_id.clone(),
author_display_name: row.author_display_name.clone(),
title: row.game_name.clone(),
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_square_hole_work_visibility(ctx: &ReducerContext) -> Vec<AdminWorkVisibilitySnapshot> {
ctx.db
.square_hole_work_profile()
.by_square_hole_work_publication_status()
.filter(SQUARE_HOLE_PUBLICATION_PUBLISHED)
.map(|row| square_hole_work_visibility_snapshot(&row))
.collect()
}
fn update_square_hole_work_visibility(
ctx: &ReducerContext,
profile_id: &str,
visible: bool,
) -> Result<AdminWorkVisibilitySnapshot, String> {
let row = ctx
.db
.square_hole_work_profile()
.profile_id()
.find(&profile_id.to_string())
.ok_or_else(|| "方洞挑战作品不存在".to_string())?;
if row.publication_status != SQUARE_HOLE_PUBLICATION_PUBLISHED {
return Err("只能修改已发布方洞挑战作品可见性".to_string());
}
let next = SquareHoleWorkProfileRow { visible, ..row };
let snapshot = square_hole_work_visibility_snapshot(&next);
let profile_id = next.profile_id.clone();
ctx.db
.square_hole_work_profile()
.profile_id()
.delete(&profile_id);
ctx.db.square_hole_work_profile().insert(next);
Ok(snapshot)
}
fn square_hole_work_visibility_snapshot(
row: &SquareHoleWorkProfileRow,
) -> AdminWorkVisibilitySnapshot {
let sort_time = timestamp_sort_micros(row.published_at, row.updated_at);
AdminWorkVisibilitySnapshot {
source_type: SOURCE_TYPE_SQUARE_HOLE.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("SH", &row.profile_id),
owner_user_id: row.owner_user_id.clone(),
author_display_name: row.author_display_name.clone(),
title: row.game_name.clone(),
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_visual_novel_work_visibility(ctx: &ReducerContext) -> Vec<AdminWorkVisibilitySnapshot> {
ctx.db
.visual_novel_work_profile()
.by_visual_novel_work_publication_status()
.filter(VISUAL_NOVEL_PUBLICATION_PUBLISHED)
.map(|row| visual_novel_work_visibility_snapshot(&row))
.collect()
}
fn update_visual_novel_work_visibility(
ctx: &ReducerContext,
profile_id: &str,
visible: bool,
) -> Result<AdminWorkVisibilitySnapshot, String> {
let row = ctx
.db
.visual_novel_work_profile()
.profile_id()
.find(&profile_id.to_string())
.ok_or_else(|| "视觉小说作品不存在".to_string())?;
if row.publication_status != VISUAL_NOVEL_PUBLICATION_PUBLISHED {
return Err("只能修改已发布视觉小说作品可见性".to_string());
}
let next = VisualNovelWorkProfileRow { visible, ..row };
let snapshot = visual_novel_work_visibility_snapshot(&next);
let profile_id = next.profile_id.clone();
ctx.db
.visual_novel_work_profile()
.profile_id()
.delete(&profile_id);
ctx.db.visual_novel_work_profile().insert(next);
Ok(snapshot)
}
fn visual_novel_work_visibility_snapshot(
row: &VisualNovelWorkProfileRow,
) -> AdminWorkVisibilitySnapshot {
let sort_time = timestamp_sort_micros(row.published_at, row.updated_at);
AdminWorkVisibilitySnapshot {
source_type: SOURCE_TYPE_VISUAL_NOVEL.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("VN", &row.profile_id),
owner_user_id: row.owner_user_id.clone(),
author_display_name: row.author_display_name.clone(),
title: row.work_title.clone(),
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_big_fish_work_visibility(ctx: &ReducerContext) -> Vec<AdminWorkVisibilitySnapshot> {
ctx.db
.big_fish_creation_session()
.by_big_fish_session_stage()
.filter(BigFishCreationStage::Published)
.map(|row| big_fish_work_visibility_snapshot(&row))
.collect()
}
fn update_big_fish_work_visibility(
ctx: &ReducerContext,
profile_id: &str,
visible: bool,
) -> Result<AdminWorkVisibilitySnapshot, String> {
let row = ctx
.db
.big_fish_creation_session()
.session_id()
.find(&profile_id.to_string())
.ok_or_else(|| "大鱼吃小鱼作品不存在".to_string())?;
if row.stage != BigFishCreationStage::Published {
return Err("只能修改已发布大鱼吃小鱼作品可见性".to_string());
}
let next = BigFishCreationSession { visible, ..row };
let snapshot = big_fish_work_visibility_snapshot(&next);
let session_id = next.session_id.clone();
ctx.db
.big_fish_creation_session()
.session_id()
.delete(&session_id);
ctx.db.big_fish_creation_session().insert(next);
Ok(snapshot)
}
fn big_fish_work_visibility_snapshot(
row: &BigFishCreationSession,
) -> AdminWorkVisibilitySnapshot {
let published_at = row.published_at.map(|value| value.to_micros_since_unix_epoch());
let updated_at = timestamp_sort_micros(row.published_at, row.updated_at);
AdminWorkVisibilitySnapshot {
source_type: SOURCE_TYPE_BIG_FISH.to_string(),
work_id: row.session_id.clone(),
profile_id: row.session_id.clone(),
source_session_id: Some(row.session_id.clone()),
public_work_code: build_prefixed_public_work_code("BF", &row.session_id),
owner_user_id: row.owner_user_id.clone(),
author_display_name: "玩家".to_string(),
title: "大鱼吃小鱼".to_string(),
subtitle: "成长挑战".to_string(),
cover_image_src: None,
visible: row.visible,
published_at_micros: published_at,
updated_at_micros: updated_at,
}
}
fn list_bark_battle_work_visibility(ctx: &ReducerContext) -> Vec<AdminWorkVisibilitySnapshot> {
ctx.db
.bark_battle_published_config()
.iter()
.map(|row| bark_battle_work_visibility_snapshot(&row))
.collect()
}
fn update_bark_battle_work_visibility(
ctx: &ReducerContext,
profile_id: &str,
visible: bool,
) -> Result<AdminWorkVisibilitySnapshot, String> {
let row = ctx
.db
.bark_battle_published_config()
.work_id()
.find(&profile_id.to_string())
.ok_or_else(|| "汪汪声浪作品不存在".to_string())?;
let next = BarkBattlePublishedConfigRow { visible, ..row };
ctx.db.bark_battle_published_config().delete(next.clone());
ctx.db.bark_battle_published_config().insert(next.clone());
Ok(bark_battle_work_visibility_snapshot(&next))
}
fn bark_battle_work_visibility_snapshot(
row: &BarkBattlePublishedConfigRow,
) -> AdminWorkVisibilitySnapshot {
AdminWorkVisibilitySnapshot {
source_type: SOURCE_TYPE_BARK_BATTLE.to_string(),
work_id: row.work_id.clone(),
profile_id: row.work_id.clone(),
source_session_id: row.source_draft_id.clone(),
public_work_code: build_bark_battle_public_work_code(&row.work_id),
owner_user_id: row.owner_user_id.clone(),
author_display_name: "玩家".to_string(),
title: "汪汪声浪".to_string(),
subtitle: row.difficulty_preset.clone(),
cover_image_src: None,
visible: row.visible,
published_at_micros: Some(row.published_at.to_micros_since_unix_epoch()),
updated_at_micros: timestamp_sort_micros(Some(row.published_at), row.updated_at),
}
}
fn require_admin_user_id(value: &str) -> Result<(), String> {
normalize_required_text(value, "adminUserId").map(|_| ())
}
fn normalize_source_type(value: &str) -> Result<String, String> {
let normalized = normalize_required_text(value, "sourceType")?
.to_ascii_lowercase()
.replace('_', "-");
let source_type = match normalized.as_str() {
"match-3-d" | "match-3d" | "match3-d" => SOURCE_TYPE_MATCH3D,
other => other,
};
Ok(source_type.to_string())
}
fn normalize_required_text(value: &str, field_name: &str) -> Result<String, String> {
let normalized = value.trim();
if normalized.is_empty() {
return Err(format!("{field_name} 不能为空"));
}
Ok(normalized.to_string())
}
fn sort_work_visibility_entries(entries: &mut [AdminWorkVisibilitySnapshot]) {
entries.sort_by(|left, right| {
right
.updated_at_micros
.cmp(&left.updated_at_micros)
.then_with(|| left.source_type.cmp(&right.source_type))
.then_with(|| left.profile_id.cmp(&right.profile_id))
});
}
fn timestamp_sort_micros(published_at: Option<Timestamp>, updated_at: Timestamp) -> i64 {
published_at
.unwrap_or(updated_at)
.to_micros_since_unix_epoch()
}
fn build_prefixed_public_work_code(prefix: &str, value: &str) -> String {
let normalized = normalize_public_code_text(value);
let fallback = if normalized.is_empty() {
"00000000".to_string()
} else {
normalized
};
let suffix = last_eight_padded(&fallback);
format!("{prefix}-{suffix}")
}
fn build_bark_battle_public_work_code(work_id: &str) -> String {
let normalized = normalize_public_code_text(work_id);
let without_prefix = normalized
.strip_prefix("BB")
.map(ToString::to_string)
.unwrap_or_else(|| normalized.clone());
let fallback = if without_prefix.is_empty() {
if normalized.is_empty() {
"00000000".to_string()
} else {
normalized
}
} else {
without_prefix
};
format!("BB-{}", last_eight_padded(&fallback))
}
fn normalize_public_code_text(value: &str) -> String {
value
.trim()
.chars()
.filter(|character| character.is_ascii_alphanumeric())
.flat_map(char::to_uppercase)
.collect()
}
fn last_eight_padded(value: &str) -> String {
let suffix = value
.chars()
.rev()
.take(8)
.collect::<Vec<_>>()
.into_iter()
.rev()
.collect::<String>();
format!("{suffix:0>8}")
}
fn choose_non_empty(values: &[&str]) -> String {
values
.iter()
.map(|value| value.trim())
.find(|value| !value.is_empty())
.unwrap_or_default()
.to_string()
}

View File

@@ -39,6 +39,7 @@ pub fn square_hole_gallery_view(ctx: &AnonymousViewContext) -> Vec<SquareHoleGal
.square_hole_work_profile()
.by_square_hole_work_publication_status()
.filter(SQUARE_HOLE_PUBLICATION_PUBLISHED)
.filter(|row| row.visible)
.filter_map(|row| match build_gallery_view_row(&row) {
Ok(item) => Some(item),
Err(error) => {
@@ -537,6 +538,7 @@ fn compile_square_hole_draft_tx(
play_count: 0,
updated_at: compiled_at,
published_at: None,
visible: true,
};
upsert_work(ctx, work);
replace_session(
@@ -614,6 +616,7 @@ fn update_square_hole_work_tx(
play_count: current.play_count,
updated_at,
published_at: current.published_at,
visible: current.visible,
};
let snapshot = build_work_snapshot(&next)?;
replace_work(ctx, &current, next);
@@ -1141,6 +1144,7 @@ fn clone_work(row: &SquareHoleWorkProfileRow) -> SquareHoleWorkProfileRow {
play_count: row.play_count,
updated_at: row.updated_at,
published_at: row.published_at,
visible: row.visible,
}
}

View File

@@ -1,5 +1,7 @@
use crate::*;
const WORK_VISIBLE_DEFAULT: bool = true;
#[spacetimedb::table(
accessor = square_hole_agent_session,
index(accessor = by_square_hole_agent_session_owner_user_id, btree(columns = [owner_user_id]))
@@ -59,6 +61,9 @@ pub struct SquareHoleWorkProfileRow {
pub(crate) play_count: u32,
pub(crate) updated_at: Timestamp,
pub(crate) published_at: Option<Timestamp>,
// 后台可见性开关;默认显示,隐藏后不进入公开列表。
#[default(WORK_VISIBLE_DEFAULT)]
pub(crate) visible: bool,
}
#[spacetimedb::table(

View File

@@ -1,8 +1,9 @@
use crate::*;
use serde::Serialize;
use serde::de::DeserializeOwned;
use spacetimedb::AnonymousViewContext;
const WORK_VISIBLE_DEFAULT: bool = true;
pub const VISUAL_NOVEL_SOURCE_IDEA: &str = "idea";
pub const VISUAL_NOVEL_SOURCE_DOCUMENT: &str = "document";
pub const VISUAL_NOVEL_SOURCE_BLANK: &str = "blank";
@@ -94,6 +95,9 @@ pub struct VisualNovelWorkProfileRow {
pub(crate) created_at: Timestamp,
pub(crate) updated_at: Timestamp,
pub(crate) published_at: Option<Timestamp>,
// 后台可见性开关;默认显示,隐藏后不进入公开列表。
#[default(WORK_VISIBLE_DEFAULT)]
pub(crate) visible: bool,
}
/// 视觉小说运行态 run 表。
@@ -178,6 +182,7 @@ pub fn visual_novel_gallery_view(ctx: &AnonymousViewContext) -> Vec<VisualNovelG
.visual_novel_work_profile()
.by_visual_novel_work_publication_status()
.filter(VISUAL_NOVEL_PUBLICATION_PUBLISHED)
.filter(|row| row.visible)
.filter_map(|row| match build_gallery_view_row(&row) {
Ok(item) => Some(item),
Err(error) => {
@@ -421,13 +426,13 @@ pub struct VisualNovelRuntimeEventProcedureResult {
pub error_message: Option<String>,
}
#[derive(Clone, Debug, PartialEq, Serialize, SpacetimeType)]
#[derive(Clone, Debug, PartialEq, serde::Serialize, SpacetimeType)]
pub struct VisualNovelJsonField {
pub key: String,
pub value: VisualNovelJsonValue,
}
#[derive(Clone, Debug, PartialEq, Serialize, SpacetimeType)]
#[derive(Clone, Debug, PartialEq, serde::Serialize, SpacetimeType)]
pub enum VisualNovelJsonValue {
Null,
Bool(bool),
@@ -437,7 +442,7 @@ pub enum VisualNovelJsonValue {
Object(Vec<VisualNovelJsonField>),
}
#[derive(Clone, Debug, PartialEq, Serialize, SpacetimeType)]
#[derive(Clone, Debug, PartialEq, serde::Serialize, SpacetimeType)]
#[serde(rename_all = "camelCase")]
pub struct VisualNovelAgentMessageSnapshot {
pub message_id: String,
@@ -448,7 +453,7 @@ pub struct VisualNovelAgentMessageSnapshot {
pub created_at_micros: i64,
}
#[derive(Clone, Debug, PartialEq, Serialize, SpacetimeType)]
#[derive(Clone, Debug, PartialEq, serde::Serialize, SpacetimeType)]
#[serde(rename_all = "camelCase")]
pub struct VisualNovelAgentSessionSnapshot {
pub session_id: String,
@@ -468,7 +473,7 @@ pub struct VisualNovelAgentSessionSnapshot {
pub updated_at_micros: i64,
}
#[derive(Clone, Debug, PartialEq, Serialize, SpacetimeType)]
#[derive(Clone, Debug, PartialEq, serde::Serialize, SpacetimeType)]
#[serde(rename_all = "camelCase")]
pub struct VisualNovelWorkSnapshot {
pub work_id: String,
@@ -490,7 +495,7 @@ pub struct VisualNovelWorkSnapshot {
pub published_at_micros: Option<i64>,
}
#[derive(Clone, Debug, PartialEq, Serialize, SpacetimeType)]
#[derive(Clone, Debug, PartialEq, serde::Serialize, SpacetimeType)]
#[serde(rename_all = "camelCase")]
pub struct VisualNovelRuntimeHistoryEntrySnapshot {
pub entry_id: String,
@@ -506,7 +511,7 @@ pub struct VisualNovelRuntimeHistoryEntrySnapshot {
pub created_at_micros: i64,
}
#[derive(Clone, Debug, PartialEq, Serialize, SpacetimeType)]
#[derive(Clone, Debug, PartialEq, serde::Serialize, SpacetimeType)]
#[serde(rename_all = "camelCase")]
pub struct VisualNovelRunSnapshot {
pub run_id: String,
@@ -526,7 +531,7 @@ pub struct VisualNovelRunSnapshot {
pub updated_at_micros: i64,
}
#[derive(Clone, Debug, PartialEq, Serialize, SpacetimeType)]
#[derive(Clone, Debug, PartialEq, serde::Serialize, SpacetimeType)]
#[serde(rename_all = "camelCase")]
pub struct VisualNovelRuntimeEventSnapshot {
pub event_id: String,
@@ -1029,6 +1034,7 @@ fn compile_visual_novel_work_profile_tx(
created_at: compiled_at,
updated_at: compiled_at,
published_at: None,
visible: true,
};
upsert_work(ctx, work);
replace_session(
@@ -1731,6 +1737,7 @@ fn clone_work(row: &VisualNovelWorkProfileRow) -> VisualNovelWorkProfileRow {
created_at: row.created_at,
updated_at: row.updated_at,
published_at: row.published_at,
visible: row.visible,
}
}
@@ -1971,7 +1978,7 @@ fn parse_json<T: DeserializeOwned>(value: &str, label: &str) -> Result<T, String
serde_json::from_str(value).map_err(|error| format!("{label} 非法: {error}"))
}
fn to_json_string<T: Serialize>(value: &T) -> String {
fn to_json_string<T: serde::Serialize>(value: &T) -> String {
serde_json::to_string(value).unwrap_or_else(|_| "{}".to_string())
}

View File

@@ -24,6 +24,7 @@ pub fn wooden_fish_gallery_view(ctx: &AnonymousViewContext) -> Vec<WoodenFishGal
.wooden_fish_work_profile()
.by_wooden_fish_work_publication_status()
.filter(WOODEN_FISH_PUBLICATION_PUBLISHED)
.filter(|row| row.visible)
.filter_map(|row| match build_gallery_view_row(&row) {
Ok(item) => Some(item),
Err(error) => {
@@ -412,6 +413,7 @@ fn compile_wooden_fish_draft_tx(
published_at: None,
background_asset_json: background_asset.as_ref().map(to_json_string),
back_button_asset_json: back_button_asset.as_ref().map(to_json_string),
visible: true,
};
upsert_work(ctx, row);
let config = config_from_draft(&draft);
@@ -1313,6 +1315,7 @@ fn clone_work(row: &WoodenFishWorkProfileRow) -> WoodenFishWorkProfileRow {
play_count: row.play_count,
updated_at: row.updated_at,
published_at: row.published_at,
visible: row.visible,
}
}

View File

@@ -1,5 +1,7 @@
use crate::*;
const WORK_VISIBLE_DEFAULT: bool = true;
#[spacetimedb::table(
accessor = wooden_fish_agent_session,
index(accessor = by_wooden_fish_agent_session_owner_user_id, btree(columns = [owner_user_id]))
@@ -49,6 +51,9 @@ pub struct WoodenFishWorkProfileRow {
pub(crate) background_asset_json: Option<String>,
#[default(None::<String>)]
pub(crate) back_button_asset_json: Option<String>,
// 后台可见性开关;默认显示,隐藏后不进入公开列表。
#[default(WORK_VISIBLE_DEFAULT)]
pub(crate) visible: bool,
}
#[spacetimedb::table(