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:
@@ -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 }
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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" {
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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> {
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
|
||||
@@ -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";
|
||||
|
||||
Reference in New Issue
Block a user