This commit is contained in:
2026-04-29 20:56:59 +08:00
parent fb6f455530
commit 730f485f48
200 changed files with 9881 additions and 2221 deletions

View File

@@ -325,7 +325,7 @@ pub struct CustomWorldProfile {
owner_user_id: String,
// 作品公开编号是稳定分享键,第一次发布时分配,后续重复发布沿用。
public_work_code: Option<String>,
// 作者公开叙世号在发布时固化到作品真相,供广场读模型与搜索结果直接展示。
// 作者公开陶泥号在发布时固化到作品真相,供广场读模型与搜索结果直接展示。
author_public_user_code: Option<String>,
source_agent_session_id: Option<String>,
publication_status: CustomWorldPublicationStatus,
@@ -337,16 +337,19 @@ 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 真相,供审计与幂等删除使用。
deleted_at: Option<Timestamp>,
created_at: Timestamp,
updated_at: Timestamp,
// 公开消费计数随 profile 真相持久化,发布、编辑和取消发布都不能重置。
#[default(0)]
play_count: u32,
#[default(0)]
remix_count: u32,
#[default(0)]
like_count: u32,
}
#[spacetimedb::table(
@@ -488,12 +491,15 @@ 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,
// 画廊读模型直接同步互动计数,避免前端临时把评分或游玩数改名成点赞。
#[default(0)]
play_count: u32,
#[default(0)]
remix_count: u32,
#[default(0)]
like_count: u32,
}
// 成长状态默认按 user_id 单行持久化;若尚未存在记录则返回 Lv.1 / 0 XP 的兼容初始值。
@@ -2839,9 +2845,10 @@ fn list_custom_world_profile_snapshots(
let mut entries = ctx
.db
.custom_world_profile()
.iter()
.filter(|row| row.owner_user_id == input.owner_user_id && row.deleted_at.is_none())
.map(|row| build_custom_world_profile_snapshot(&row))
.by_custom_world_profile_owner_user_id()
.filter(&input.owner_user_id)
.filter(|row| row.deleted_at.is_none())
.map(|row| build_custom_world_profile_list_snapshot(&row))
.collect::<Vec<_>>();
entries.sort_by(|left, right| right.updated_at_micros.cmp(&left.updated_at_micros));
@@ -2849,6 +2856,86 @@ fn list_custom_world_profile_snapshots(
Ok(entries)
}
fn build_custom_world_profile_list_snapshot(row: &CustomWorldProfile) -> CustomWorldProfileSnapshot {
let mut snapshot = build_custom_world_profile_snapshot(row);
snapshot.profile_payload_json = build_custom_world_profile_list_payload_json(row);
snapshot
}
fn build_custom_world_profile_list_payload_json(row: &CustomWorldProfile) -> String {
let source_profile = serde_json::from_str::<JsonValue>(&row.profile_payload_json).ok();
let source_object = source_profile.as_ref().and_then(JsonValue::as_object);
let empty_roles = JsonValue::Array(Vec::new());
let empty_landmarks = JsonValue::Array(Vec::new());
// 中文注释:首屏作品列表只需要卡片摘要,不能继续把完整 profile 大 JSON 随列表搬回 Axum。
let payload = json!({
"id": row.profile_id,
"name": row.world_name,
"subtitle": row.subtitle,
"summary": row.summary_text,
"tone": source_object
.and_then(|object| object.get("tone"))
.and_then(JsonValue::as_str)
.unwrap_or_default(),
"playerGoal": source_object
.and_then(|object| object.get("playerGoal"))
.and_then(JsonValue::as_str)
.unwrap_or_default(),
"settingText": source_object
.and_then(|object| object.get("settingText"))
.and_then(JsonValue::as_str)
.unwrap_or_default(),
"themeMode": row.theme_mode.as_str(),
"templateWorldType": source_object
.and_then(|object| object.get("templateWorldType"))
.and_then(JsonValue::as_str)
.unwrap_or("WUXIA"),
"compatibilityTemplateWorldType": source_object
.and_then(|object| object.get("compatibilityTemplateWorldType"))
.cloned()
.unwrap_or(JsonValue::Null),
"cover": row.cover_image_src.as_ref().map(|image_src| json!({
"sourceType": "generated",
"imageSrc": image_src,
})),
"majorFactions": source_object
.and_then(|object| object.get("majorFactions"))
.cloned()
.unwrap_or_else(|| JsonValue::Array(Vec::new())),
"coreConflicts": source_object
.and_then(|object| object.get("coreConflicts"))
.cloned()
.unwrap_or_else(|| JsonValue::Array(Vec::new())),
"playableNpcs": source_object
.and_then(|object| object.get("playableNpcs"))
.cloned()
.unwrap_or_else(|| empty_roles.clone()),
"storyNpcs": source_object
.and_then(|object| object.get("storyNpcs"))
.cloned()
.unwrap_or_else(|| JsonValue::Array(Vec::new())),
"items": source_object
.and_then(|object| object.get("items"))
.cloned()
.unwrap_or_else(|| JsonValue::Array(Vec::new())),
"camp": source_object
.and_then(|object| object.get("camp"))
.cloned()
.unwrap_or(JsonValue::Null),
"landmarks": source_object
.and_then(|object| object.get("landmarks"))
.cloned()
.unwrap_or_else(|| empty_landmarks.clone()),
"ownedSettingLayers": source_object
.and_then(|object| object.get("ownedSettingLayers"))
.cloned()
.unwrap_or(JsonValue::Null),
});
serde_json::to_string(&payload).unwrap_or_else(|_| "{}".to_string())
}
fn list_custom_world_gallery_snapshots(
ctx: &ReducerContext,
) -> Result<Vec<CustomWorldGalleryEntrySnapshot>, String> {
@@ -2858,7 +2945,7 @@ fn list_custom_world_gallery_snapshots(
.db
.custom_world_gallery_entry()
.iter()
.map(|row| build_custom_world_gallery_entry_snapshot(&row))
.map(|row| build_custom_world_gallery_entry_snapshot(ctx, &row))
.collect::<Vec<_>>();
entries.sort_by(|left, right| {
@@ -2905,7 +2992,7 @@ fn get_custom_world_library_detail_record(
profile.as_ref().map(build_custom_world_profile_snapshot),
gallery_entry
.as_ref()
.map(build_custom_world_gallery_entry_snapshot),
.map(|row| build_custom_world_gallery_entry_snapshot(ctx, row)),
))
}
@@ -2943,7 +3030,7 @@ fn get_custom_world_gallery_detail_record(
profile.as_ref().map(build_custom_world_profile_snapshot),
gallery_entry
.as_ref()
.map(build_custom_world_gallery_entry_snapshot),
.map(|row| build_custom_world_gallery_entry_snapshot(ctx, row)),
))
}
@@ -2985,7 +3072,7 @@ fn get_custom_world_gallery_detail_record_by_code(
profile.as_ref().map(build_custom_world_profile_snapshot),
gallery_entry
.as_ref()
.map(build_custom_world_gallery_entry_snapshot),
.map(|row| build_custom_world_gallery_entry_snapshot(ctx, row)),
))
}
@@ -3123,6 +3210,15 @@ fn record_custom_world_profile_play_record(
})
.ok_or_else(|| "custom_world 已发布作品不存在,无法记录游玩".to_string())?;
let played_at = Timestamp::from_micros_since_unix_epoch(input.played_at_micros);
record_public_work_play(
ctx,
crate::runtime::PublicWorkPlayRecordInput {
source_type: "custom-world".to_string(),
owner_user_id: existing.owner_user_id.clone(),
profile_id: existing.profile_id.clone(),
played_at_micros: input.played_at_micros,
},
)?;
// 游玩计数是公开广场消费数据,只增加计数并保持作品内容不变。
let next_row = CustomWorldProfile {
profile_id: existing.profile_id.clone(),
@@ -3790,7 +3886,7 @@ fn execute_publish_world_action(
let author_public_user_code = read_optional_text_field(payload, &["authorPublicUserCode"])
.unwrap_or_else(|| build_public_user_code_from_owner_user_id(&session.owner_user_id));
let author_display_name = read_optional_text_field(payload, &["authorDisplayName"])
.unwrap_or_else(|| "创作者".to_string());
.unwrap_or_else(|| "陶泥主".to_string());
let publish_result = publish_custom_world_world_record(
ctx,
CustomWorldPublishWorldInput {
@@ -5299,7 +5395,7 @@ fn sync_custom_world_gallery_entry_from_profile(
let inserted = ctx.db.custom_world_gallery_entry().insert(row);
Ok(build_custom_world_gallery_entry_snapshot(&inserted))
Ok(build_custom_world_gallery_entry_snapshot(ctx, &inserted))
}
fn sync_missing_custom_world_gallery_entries(ctx: &ReducerContext) -> Result<(), String> {
@@ -5570,6 +5666,7 @@ fn build_custom_world_draft_card_snapshot(
}
fn build_custom_world_gallery_entry_snapshot(
ctx: &ReducerContext,
row: &CustomWorldGalleryEntry,
) -> CustomWorldGalleryEntrySnapshot {
CustomWorldGalleryEntrySnapshot {
@@ -5588,6 +5685,12 @@ fn build_custom_world_gallery_entry_snapshot(
play_count: row.play_count,
remix_count: row.remix_count,
like_count: row.like_count,
recent_play_count_7d: count_recent_public_work_plays(
ctx,
"custom-world",
&row.profile_id,
ctx.timestamp.to_micros_since_unix_epoch(),
),
published_at_micros: row.published_at.to_micros_since_unix_epoch(),
updated_at_micros: row.updated_at.to_micros_since_unix_epoch(),
}