Merge branch 'master' into codex/puzzle-clear-template-runtime-fixes

# Conflicts:
#	.hermes/shared-memory/decision-log.md
#	.hermes/shared-memory/project-overview.md
#	docs/【开发运维】本地开发验证与生产运维-2026-05-15.md
#	scripts/dev.test.ts
#	server-rs/crates/api-server/src/creation_entry_config.rs
#	server-rs/crates/api-server/src/wooden_fish.rs
#	server-rs/crates/module-auth/src/lib.rs
#	server-rs/crates/spacetime-client/src/wooden_fish.rs
#	server-rs/crates/spacetime-module/src/auth/procedures.rs
#	src/components/custom-world-home/creationWorkShelf.ts
#	src/components/platform-entry/PlatformEntryFlowShellImpl.tsx
#	src/components/rpg-entry/rpgEntryWorldPresentation.ts
#	src/services/miniGameDraftGenerationProgress.test.ts
#	src/services/miniGameDraftGenerationProgress.ts
This commit is contained in:
2026-06-04 11:24:14 +08:00
451 changed files with 18452 additions and 5266 deletions

View File

@@ -11,6 +11,7 @@ crate-type = ["cdylib"]
log = { workspace = true }
serde = { workspace = true }
serde_json = { workspace = true }
shared-contracts = { workspace = true }
module-ai = { workspace = true, features = ["spacetime-types"] }
module-assets = { workspace = true, features = ["spacetime-types"] }
module-bark-battle = { workspace = true }

View File

@@ -454,7 +454,6 @@ fn export_auth_store_snapshot_from_tables_tx(
.meta_id()
.find(&AUTH_STORE_PROJECTION_META_ID.to_string())
.map(|row| row.updated_at.to_micros_since_unix_epoch());
let snapshot = build_auth_store_snapshot_from_rows(users, identities, sessions)?;
if let Some(updated_at_micros) = updated_at_micros {
upsert_auth_store_snapshot_rows(ctx, &snapshot, updated_at_micros)?;
@@ -485,6 +484,7 @@ fn build_auth_store_snapshot_from_rows(
if !valid_user_ids.contains(&identity.user_id) {
continue;
}
match identity.provider.as_str() {
"phone" => {
let phone_number = identity
@@ -558,6 +558,10 @@ fn build_auth_store_snapshot_from_rows(
let mut sessions_by_id = std::collections::HashMap::new();
let mut session_id_by_refresh_token_hash = std::collections::HashMap::new();
for session in sessions {
if !valid_user_ids.contains(&session.user_id) {
continue;
}
let client_info = serde_json::from_str::<serde_json::Value>(&session.client_info_json)
.map_err(|error| format!("refresh session 客户端信息 JSON 解析失败:{error}"))?;
session_id_by_refresh_token_hash.insert(
@@ -712,10 +716,9 @@ mod tests {
#[test]
fn auth_store_snapshot_user_row_key_is_stable_after_username_change() {
let mut before = sample_snapshot();
let before = sample_snapshot();
let mut after = sample_snapshot();
after.users_by_username.clear();
let mut renamed_user = before
let mut renamed_user = after
.users_by_username
.remove("phone_42")
.expect("sample user exists");

View File

@@ -1188,11 +1188,14 @@ fn normalize_migration_row(table_name: &str, value: &serde_json::Value) -> serde
object
.entry("event_ends_at_text".to_string())
.or_insert(serde_json::Value::Null);
object
.entry("event_banners_json".to_string())
.or_insert(serde_json::Value::Null);
}
}
if table_name == "creation_entry_type_config" {
if let Some(object) = next_value.as_object_mut() {
// 中文注释:入口分类字段晚于入口类型配置表加入,旧迁移包按未分类兼容。
// 中文注释:入口分类和统一创作契约字段晚于入口类型配置表加入,旧迁移包按空配置兼容。
object
.entry("category_id".to_string())
.or_insert(serde_json::Value::Null);
@@ -1202,6 +1205,9 @@ fn normalize_migration_row(table_name: &str, value: &serde_json::Value) -> serde
object
.entry("category_sort_order".to_string())
.or_insert_with(|| serde_json::Value::from(0));
object
.entry("unified_creation_spec_json".to_string())
.or_insert(serde_json::Value::Null);
}
}
if table_name == "user_account" {

View File

@@ -23,6 +23,9 @@ pub struct CreationEntryConfig {
pub(crate) event_starts_at_text: Option<String>,
#[default(None::<String>)]
pub(crate) event_ends_at_text: Option<String>,
/// 底部加号创作入口页的多 banner JSON 配置,旧单条字段仅用于兼容。
#[default(None::<String>)]
pub(crate) event_banners_json: Option<String>,
}
#[spacetimedb::table(
@@ -46,6 +49,8 @@ pub struct CreationEntryTypeConfig {
pub(crate) category_label: Option<String>,
#[default(0)]
pub(crate) category_sort_order: i32,
#[default(None::<String>)]
pub(crate) unified_creation_spec_json: Option<String>,
}
#[spacetimedb::procedure]
@@ -83,6 +88,27 @@ pub fn upsert_creation_entry_type_config(
}
}
#[spacetimedb::procedure]
/// 后台保存底部加号创作入口页多 banner 配置的过程入口。
pub fn upsert_creation_entry_event_banners_config(
ctx: &mut ProcedureContext,
input: CreationEntryEventBannersAdminUpsertInput,
) -> CreationEntryConfigProcedureResult {
match ctx.try_with_tx(|tx| upsert_creation_entry_event_banners_config_in_tx(tx, input.clone()))
{
Ok(record) => CreationEntryConfigProcedureResult {
ok: true,
record: Some(record),
error_message: None,
},
Err(message) => CreationEntryConfigProcedureResult {
ok: false,
record: None,
error_message: Some(message),
},
}
}
fn upsert_creation_entry_type_config_in_tx(
ctx: &ReducerContext,
input: CreationEntryTypeAdminUpsertInput,
@@ -96,6 +122,7 @@ fn upsert_creation_entry_type_config_in_tx(
if input.title.trim().is_empty() {
return Err("入口标题不能为空".to_string());
}
let unified_creation_spec_json = normalize_unified_creation_spec_json(&id, &input)?;
let row = CreationEntryTypeConfig {
id: id.clone(),
title: input.title.trim().to_string(),
@@ -109,6 +136,7 @@ fn upsert_creation_entry_type_config_in_tx(
category_id: Some(normalize_category_id(&input.category_id)),
category_label: Some(normalize_category_label(&input.category_label)),
category_sort_order: input.category_sort_order,
unified_creation_spec_json,
};
if ctx.db.creation_entry_type_config().id().find(&id).is_some() {
ctx.db.creation_entry_type_config().id().update(row);
@@ -118,6 +146,31 @@ fn upsert_creation_entry_type_config_in_tx(
get_or_seed_creation_entry_config_snapshot(ctx)
}
/// 在事务内归一化 banner JSON 并更新全局入口配置表头。
fn upsert_creation_entry_event_banners_config_in_tx(
ctx: &ReducerContext,
input: CreationEntryEventBannersAdminUpsertInput,
) -> Result<CreationEntryConfigSnapshot, String> {
seed_creation_entry_config_if_missing(ctx);
let now = ctx.timestamp;
let config_id = CREATION_ENTRY_CONFIG_GLOBAL_ID.to_string();
let Some(header) = ctx.db.creation_entry_config().config_id().find(&config_id) else {
return Err("创作入口配置初始化失败".to_string());
};
let event_banners_json =
module_runtime::normalize_creation_entry_event_banners_json(&input.event_banners_json)?;
ctx.db
.creation_entry_config()
.config_id()
.update(CreationEntryConfig {
updated_at: now,
event_banners_json: Some(event_banners_json),
..header
});
get_or_seed_creation_entry_config_snapshot(ctx)
}
fn get_or_seed_creation_entry_config_snapshot(
ctx: &ReducerContext,
) -> Result<CreationEntryConfigSnapshot, String> {
@@ -145,6 +198,7 @@ fn get_or_seed_creation_entry_config_snapshot(
category_label: normalize_optional_category_label(row.category_label.as_deref()),
category_sort_order: row.category_sort_order,
updated_at_micros: row.updated_at.to_micros_since_unix_epoch(),
unified_creation_spec_json: row.unified_creation_spec_json,
})
.collect::<Vec<_>>();
creation_types.sort_by(|left, right| {
@@ -187,12 +241,16 @@ fn get_or_seed_creation_entry_config_snapshot(
header.event_ends_at_text.as_deref(),
DEFAULT_CREATION_ENTRY_EVENT_ENDS_AT_TEXT,
),
render_mode: "structured".to_string(),
html_code: None,
},
event_banners_json: header.event_banners_json,
creation_types,
updated_at_micros: header.updated_at.to_micros_since_unix_epoch(),
})
}
/// 初始化创作入口全局配置,并为旧库补齐默认多 banner JSON。
fn seed_creation_entry_config_if_missing(ctx: &ReducerContext) {
let now = ctx.timestamp;
if ctx
@@ -217,6 +275,7 @@ fn seed_creation_entry_config_if_missing(ctx: &ReducerContext) {
event_prize_pool_mud_points: DEFAULT_CREATION_ENTRY_EVENT_PRIZE_POOL_MUD_POINTS,
event_starts_at_text: Some(DEFAULT_CREATION_ENTRY_EVENT_STARTS_AT_TEXT.to_string()),
event_ends_at_text: Some(DEFAULT_CREATION_ENTRY_EVENT_ENDS_AT_TEXT.to_string()),
event_banners_json: Some(module_runtime::default_creation_entry_event_banners_json()),
});
}
@@ -404,10 +463,29 @@ fn default_creation_entry_type_configs(now: Timestamp) -> Vec<CreationEntryTypeC
category_id: Some(snapshot.category_id),
category_label: Some(snapshot.category_label),
category_sort_order: snapshot.category_sort_order,
unified_creation_spec_json: snapshot.unified_creation_spec_json,
})
.collect()
}
fn normalize_unified_creation_spec_json(
id: &str,
input: &CreationEntryTypeAdminUpsertInput,
) -> Result<Option<String>, String> {
let Some(spec_json) = input.unified_creation_spec_json.as_deref() else {
return Ok(None);
};
let normalized = spec_json.trim();
if normalized.is_empty() {
return Ok(None);
}
let spec =
shared_contracts::creation_entry_config::decode_unified_creation_spec_response(normalized)?;
shared_contracts::creation_entry_config::validate_unified_creation_spec_for_play(id, &spec)?;
shared_contracts::creation_entry_config::encode_unified_creation_spec_response(&spec).map(Some)
}
fn normalize_category_id(value: &str) -> String {
let normalized = value.trim();
if normalized.is_empty() {

View File

@@ -95,7 +95,7 @@ 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,
}
@@ -171,9 +171,9 @@ pub struct VisualNovelRuntimeEvent {
pub(crate) occurred_at: Timestamp,
}
/// 视觉小说公开广场列表投影。
/// 视觉小说广场列表投影。
///
/// 该 view 只暴露已发布作品卡片需要的公开字段HTTP gallery 订阅
/// 该 view 只暴露已发布作品卡片需要的展示字段HTTP gallery 缓存刷新
/// 从本地 cache 读取,避免每个列表请求调用 `list_visual_novel_works` procedure。
#[spacetimedb::view(accessor = visual_novel_gallery_view, public)]
pub fn visual_novel_gallery_view(ctx: &AnonymousViewContext) -> Vec<VisualNovelGalleryViewRow> {

View File

@@ -534,6 +534,9 @@ fn publish_wooden_fish_work_tx(
input: WoodenFishWorkPublishInput,
) -> Result<WoodenFishWorkSnapshot, String> {
let row = find_owned_work(ctx, &input.profile_id, &input.owner_user_id)?;
if row.generation_status == WOODEN_FISH_GENERATION_FAILED {
return Err("生成失败的敲木鱼作品需要重新生成后才能发布".to_string());
}
if !is_publish_ready(&row) {
return Err("发布需要完整的敲击物图案、背景、返回按钮、敲击音效和飘字配置".to_string());
}

View File

@@ -12,6 +12,7 @@ pub const WOODEN_FISH_PUBLICATION_PUBLISHED: &str = "Published";
pub const WOODEN_FISH_GENERATION_DRAFT: &str = "draft";
pub const WOODEN_FISH_GENERATION_GENERATING: &str = "generating";
pub const WOODEN_FISH_GENERATION_READY: &str = "ready";
pub const WOODEN_FISH_GENERATION_FAILED: &str = "failed";
pub const WOODEN_FISH_EVENT_RUN_STARTED: &str = "run-started";
pub const WOODEN_FISH_EVENT_RUN_CHECKPOINT: &str = "checkpoint";
pub const WOODEN_FISH_EVENT_RUN_FINISHED: &str = "finish";