This commit is contained in:
2026-04-29 11:51:04 +08:00
parent e191619ab3
commit 412279ae11
89 changed files with 3966 additions and 491 deletions

View File

@@ -337,6 +337,10 @@ pub struct CustomWorldProfile {
profile_payload_json: String,
playable_npc_count: u32,
landmark_count: u32,
// 公开消费计数随 profile 真相持久化,发布、编辑和取消发布都不能重置。
play_count: u32,
remix_count: u32,
like_count: u32,
author_display_name: String,
published_at: Option<Timestamp>,
// 软删除后保留 profile 真相,供审计与幂等删除使用。
@@ -484,6 +488,10 @@ pub struct CustomWorldGalleryEntry {
theme_mode: CustomWorldThemeMode,
playable_npc_count: u32,
landmark_count: u32,
// 画廊读模型直接同步互动计数,避免前端临时把评分或游玩数改名成点赞。
play_count: u32,
remix_count: u32,
like_count: u32,
published_at: Timestamp,
updated_at: Timestamp,
}
@@ -2161,6 +2169,48 @@ pub fn get_custom_world_gallery_detail_by_code(
}
}
#[spacetimedb::procedure]
pub fn remix_custom_world_profile(
ctx: &mut ProcedureContext,
input: module_custom_world::CustomWorldProfileRemixInput,
) -> CustomWorldLibraryMutationResult {
match ctx.try_with_tx(|tx| remix_custom_world_profile_record(tx, input.clone())) {
Ok((entry, gallery_entry)) => CustomWorldLibraryMutationResult {
ok: true,
entry: Some(entry),
gallery_entry,
error_message: None,
},
Err(message) => CustomWorldLibraryMutationResult {
ok: false,
entry: None,
gallery_entry: None,
error_message: Some(message),
},
}
}
#[spacetimedb::procedure]
pub fn record_custom_world_profile_play(
ctx: &mut ProcedureContext,
input: module_custom_world::CustomWorldProfilePlayRecordInput,
) -> CustomWorldLibraryMutationResult {
match ctx.try_with_tx(|tx| record_custom_world_profile_play_record(tx, input.clone())) {
Ok((entry, gallery_entry)) => CustomWorldLibraryMutationResult {
ok: true,
entry: Some(entry),
gallery_entry: Some(gallery_entry),
error_message: None,
},
Err(message) => CustomWorldLibraryMutationResult {
ok: false,
entry: None,
gallery_entry: None,
error_message: Some(message),
},
}
}
#[spacetimedb::procedure]
pub fn list_custom_world_works(
ctx: &mut ProcedureContext,
@@ -2466,6 +2516,9 @@ fn upsert_custom_world_profile_record(
profile_payload_json: input.profile_payload_json.clone(),
playable_npc_count: input.playable_npc_count,
landmark_count: input.landmark_count,
play_count: existing.play_count,
remix_count: existing.remix_count,
like_count: existing.like_count,
author_display_name: input.author_display_name.clone(),
published_at: existing.published_at,
deleted_at: None,
@@ -2488,6 +2541,9 @@ fn upsert_custom_world_profile_record(
profile_payload_json: input.profile_payload_json.clone(),
playable_npc_count: input.playable_npc_count,
landmark_count: input.landmark_count,
play_count: 0,
remix_count: 0,
like_count: 0,
author_display_name: input.author_display_name.clone(),
published_at: None,
deleted_at: None,
@@ -2632,6 +2688,9 @@ fn publish_custom_world_profile_record(
profile_payload_json: existing.profile_payload_json.clone(),
playable_npc_count: existing.playable_npc_count,
landmark_count: existing.landmark_count,
play_count: existing.play_count,
remix_count: existing.remix_count,
like_count: existing.like_count,
author_display_name: input.author_display_name.clone(),
published_at: Some(published_at),
deleted_at: None,
@@ -2695,6 +2754,9 @@ fn unpublish_custom_world_profile_record(
profile_payload_json: existing.profile_payload_json.clone(),
playable_npc_count: existing.playable_npc_count,
landmark_count: existing.landmark_count,
play_count: existing.play_count,
remix_count: existing.remix_count,
like_count: existing.like_count,
author_display_name: input.author_display_name.clone(),
published_at: None,
deleted_at: None,
@@ -2754,6 +2816,9 @@ fn delete_custom_world_profile_record(
profile_payload_json: existing.profile_payload_json.clone(),
playable_npc_count: existing.playable_npc_count,
landmark_count: existing.landmark_count,
play_count: existing.play_count,
remix_count: existing.remix_count,
like_count: existing.like_count,
author_display_name: existing.author_display_name.clone(),
published_at: None,
deleted_at: Some(deleted_at),
@@ -2924,6 +2989,177 @@ fn get_custom_world_gallery_detail_record_by_code(
))
}
fn remix_custom_world_profile_record(
ctx: &ReducerContext,
input: module_custom_world::CustomWorldProfileRemixInput,
) -> Result<
(
CustomWorldProfileSnapshot,
Option<CustomWorldGalleryEntrySnapshot>,
),
String,
> {
let source_owner_user_id = input.source_owner_user_id.trim();
let source_profile_id = input.source_profile_id.trim();
let target_owner_user_id = input.target_owner_user_id.trim();
let target_profile_id = input.target_profile_id.trim();
if source_owner_user_id.is_empty()
|| source_profile_id.is_empty()
|| target_owner_user_id.is_empty()
|| target_profile_id.is_empty()
{
return Err("custom_world remix 参数不能为空".to_string());
}
if input.author_display_name.trim().is_empty() {
return Err("custom_world remix 作者名不能为空".to_string());
}
// Remix 只允许从已发布源作品派生草稿,同时把源作品的公开 remix 计数同步到画廊。
let source = ctx
.db
.custom_world_profile()
.profile_id()
.find(&source_profile_id.to_string())
.filter(|row| row.owner_user_id == source_owner_user_id)
.filter(|row| {
row.publication_status == CustomWorldPublicationStatus::Published
&& row.deleted_at.is_none()
&& row.published_at.is_some()
})
.ok_or_else(|| "custom_world 已发布源作品不存在".to_string())?;
if ctx
.db
.custom_world_profile()
.profile_id()
.find(&target_profile_id.to_string())
.is_some()
{
return Err("custom_world remix 目标 profile 已存在".to_string());
}
let remixed_at = Timestamp::from_micros_since_unix_epoch(input.remixed_at_micros);
let next_source = CustomWorldProfile {
profile_id: source.profile_id.clone(),
owner_user_id: source.owner_user_id.clone(),
public_work_code: source.public_work_code.clone(),
author_public_user_code: source.author_public_user_code.clone(),
source_agent_session_id: source.source_agent_session_id.clone(),
publication_status: source.publication_status,
world_name: source.world_name.clone(),
subtitle: source.subtitle.clone(),
summary_text: source.summary_text.clone(),
theme_mode: source.theme_mode,
cover_image_src: source.cover_image_src.clone(),
profile_payload_json: source.profile_payload_json.clone(),
playable_npc_count: source.playable_npc_count,
landmark_count: source.landmark_count,
play_count: source.play_count,
remix_count: source.remix_count.saturating_add(1),
like_count: source.like_count,
author_display_name: source.author_display_name.clone(),
published_at: source.published_at,
deleted_at: source.deleted_at,
created_at: source.created_at,
updated_at: remixed_at,
};
ctx.db
.custom_world_profile()
.profile_id()
.delete(&source.profile_id);
let updated_source = ctx.db.custom_world_profile().insert(next_source);
let source_gallery = sync_custom_world_gallery_entry_from_profile(ctx, &updated_source)?;
// 新草稿继承作品内容,但互动计数从 0 开始,避免把源作品热度复制成用户资产。
let draft = CustomWorldProfile {
profile_id: target_profile_id.to_string(),
owner_user_id: target_owner_user_id.to_string(),
public_work_code: None,
author_public_user_code: None,
source_agent_session_id: None,
publication_status: CustomWorldPublicationStatus::Draft,
world_name: source.world_name.clone(),
subtitle: source.subtitle.clone(),
summary_text: source.summary_text.clone(),
theme_mode: source.theme_mode,
cover_image_src: source.cover_image_src.clone(),
profile_payload_json: source.profile_payload_json.clone(),
playable_npc_count: source.playable_npc_count,
landmark_count: source.landmark_count,
play_count: 0,
remix_count: 0,
like_count: 0,
author_display_name: input.author_display_name.trim().to_string(),
published_at: None,
deleted_at: None,
created_at: remixed_at,
updated_at: remixed_at,
};
let inserted_draft = ctx.db.custom_world_profile().insert(draft);
Ok((
build_custom_world_profile_snapshot(&inserted_draft),
Some(source_gallery),
))
}
fn record_custom_world_profile_play_record(
ctx: &ReducerContext,
input: module_custom_world::CustomWorldProfilePlayRecordInput,
) -> Result<(CustomWorldProfileSnapshot, CustomWorldGalleryEntrySnapshot), String> {
let owner_user_id = input.owner_user_id.trim();
let profile_id = input.profile_id.trim();
if owner_user_id.is_empty() || profile_id.is_empty() {
return Err("custom_world play 参数不能为空".to_string());
}
let existing = ctx
.db
.custom_world_profile()
.profile_id()
.find(&profile_id.to_string())
.filter(|row| row.owner_user_id == owner_user_id)
.filter(|row| {
row.publication_status == CustomWorldPublicationStatus::Published
&& row.deleted_at.is_none()
&& row.published_at.is_some()
})
.ok_or_else(|| "custom_world 已发布作品不存在,无法记录游玩".to_string())?;
let played_at = Timestamp::from_micros_since_unix_epoch(input.played_at_micros);
// 游玩计数是公开广场消费数据,只增加计数并保持作品内容不变。
let next_row = CustomWorldProfile {
profile_id: existing.profile_id.clone(),
owner_user_id: existing.owner_user_id.clone(),
public_work_code: existing.public_work_code.clone(),
author_public_user_code: existing.author_public_user_code.clone(),
source_agent_session_id: existing.source_agent_session_id.clone(),
publication_status: existing.publication_status,
world_name: existing.world_name.clone(),
subtitle: existing.subtitle.clone(),
summary_text: existing.summary_text.clone(),
theme_mode: existing.theme_mode,
cover_image_src: existing.cover_image_src.clone(),
profile_payload_json: existing.profile_payload_json.clone(),
playable_npc_count: existing.playable_npc_count,
landmark_count: existing.landmark_count,
play_count: existing.play_count.saturating_add(1),
remix_count: existing.remix_count,
like_count: existing.like_count,
author_display_name: existing.author_display_name.clone(),
published_at: existing.published_at,
deleted_at: existing.deleted_at,
created_at: existing.created_at,
updated_at: played_at,
};
ctx.db
.custom_world_profile()
.profile_id()
.delete(&existing.profile_id);
let inserted = ctx.db.custom_world_profile().insert(next_row);
let gallery_entry = sync_custom_world_gallery_entry_from_profile(ctx, &inserted)?;
Ok((
build_custom_world_profile_snapshot(&inserted),
gallery_entry,
))
}
fn list_custom_world_work_snapshots(
ctx: &ReducerContext,
input: CustomWorldWorksListInput,
@@ -5054,6 +5290,9 @@ fn sync_custom_world_gallery_entry_from_profile(
theme_mode: profile.theme_mode,
playable_npc_count: profile.playable_npc_count,
landmark_count: profile.landmark_count,
play_count: profile.play_count,
remix_count: profile.remix_count,
like_count: profile.like_count,
published_at,
updated_at: profile.updated_at,
};
@@ -5135,6 +5374,9 @@ fn ensure_custom_world_profile_public_fields(
profile_payload_json: profile.profile_payload_json.clone(),
playable_npc_count: profile.playable_npc_count,
landmark_count: profile.landmark_count,
play_count: profile.play_count,
remix_count: profile.remix_count,
like_count: profile.like_count,
author_display_name: profile.author_display_name.clone(),
published_at: profile.published_at,
deleted_at: profile.deleted_at,
@@ -5161,6 +5403,9 @@ fn build_custom_world_profile_row_copy(profile: &CustomWorldProfile) -> CustomWo
profile_payload_json: profile.profile_payload_json.clone(),
playable_npc_count: profile.playable_npc_count,
landmark_count: profile.landmark_count,
play_count: profile.play_count,
remix_count: profile.remix_count,
like_count: profile.like_count,
author_display_name: profile.author_display_name.clone(),
published_at: profile.published_at,
deleted_at: profile.deleted_at,
@@ -5185,6 +5430,9 @@ fn build_custom_world_profile_snapshot(row: &CustomWorldProfile) -> CustomWorldP
profile_payload_json: row.profile_payload_json.clone(),
playable_npc_count: row.playable_npc_count,
landmark_count: row.landmark_count,
play_count: row.play_count,
remix_count: row.remix_count,
like_count: row.like_count,
author_display_name: row.author_display_name.clone(),
published_at_micros: row
.published_at
@@ -5337,6 +5585,9 @@ fn build_custom_world_gallery_entry_snapshot(
theme_mode: row.theme_mode,
playable_npc_count: row.playable_npc_count,
landmark_count: row.landmark_count,
play_count: row.play_count,
remix_count: row.remix_count,
like_count: row.like_count,
published_at_micros: row.published_at.to_micros_since_unix_epoch(),
updated_at_micros: row.updated_at.to_micros_since_unix_epoch(),
}
@@ -6405,6 +6656,9 @@ mod tests {
profile_payload_json: "{}".to_string(),
playable_npc_count: 0,
landmark_count: 0,
play_count: 0,
remix_count: 0,
like_count: 0,
author_display_name: "玩家".to_string(),
published_at: None,
deleted_at: None,
@@ -6426,6 +6680,9 @@ mod tests {
profile_payload_json: "{}".to_string(),
playable_npc_count: 0,
landmark_count: 0,
play_count: 0,
remix_count: 0,
like_count: 0,
author_display_name: "玩家".to_string(),
published_at: None,
deleted_at: Some(Timestamp::from_micros_since_unix_epoch(2)),
@@ -6447,6 +6704,9 @@ mod tests {
profile_payload_json: "{}".to_string(),
playable_npc_count: 0,
landmark_count: 0,
play_count: 0,
remix_count: 0,
like_count: 0,
author_display_name: "玩家".to_string(),
published_at: None,
deleted_at: None,
@@ -6507,6 +6767,9 @@ mod tests {
profile_payload_json: "{}".to_string(),
playable_npc_count: 0,
landmark_count: 0,
play_count: 0,
remix_count: 0,
like_count: 0,
author_display_name: "玩家".to_string(),
published_at: if publication_status == CustomWorldPublicationStatus::Published {
Some(Timestamp::from_micros_since_unix_epoch(2))
@@ -6568,6 +6831,9 @@ mod tests {
profile_payload_json: "{}".to_string(),
playable_npc_count: 0,
landmark_count: 0,
play_count: 0,
remix_count: 0,
like_count: 0,
author_display_name: "玩家".to_string(),
published_at: None,
deleted_at: None,