fix: tolerate null legacy custom-world profile

This commit is contained in:
kdletters
2026-05-21 17:49:07 +08:00
parent 41075e41a2
commit 224a26d318
3 changed files with 60 additions and 1 deletions

View File

@@ -398,6 +398,14 @@
- 验证:`npm run test -- src/data/customWorldLibrary.test.ts src/components/CustomWorldResultView.test.tsx`,确认生成后即使父层做一次归一化回写,开局 CG 仍继续显示。 - 验证:`npm run test -- src/data/customWorldLibrary.test.ts src/components/CustomWorldResultView.test.tsx`,确认生成后即使父层做一次归一化回写,开局 CG 仍继续显示。
- 关联:`src/data/customWorldLibrary.ts``src/components/rpg-creation-result/RpgCreationResultViewImpl.tsx``src/components/CustomWorldEntityCatalog.tsx` - 关联:`src/data/customWorldLibrary.ts``src/components/rpg-creation-result/RpgCreationResultViewImpl.tsx``src/components/CustomWorldEntityCatalog.tsx`
## RPG 发布报 legacy_result_profile_json 非法先查 null 兼容
- 现象RPG 结果页发布动作返回 `UPSTREAM_ERROR`SpacetimeDB details 里是 `custom_world.compile.legacy_result_profile_json 不是合法 JSON object`
- 原因:`publish_world` 前端契约只要求 `{ action: 'publish_world' }``ExecuteCustomWorldAgentActionRequest.legacy_result_profile` 是可选字段,经 HTTP / serde / SpacetimeDB payload 传递时可能显式成为 JSON `null`。旧的编译器只接受 object 或缺省,把 `Some("null")` 当成非法 legacy JSON。
- 处理:`module-custom-world` 的 optional JSON object 解析要把 `null` 视为未提供,仍拒绝数组、字符串、数字和坏 JSON正式发布继续以 session `draft_profile_json` 为草稿真相。
- 验证:`cargo test -p module-custom-world published_profile_compile --manifest-path server-rs/Cargo.toml`
- 关联:`server-rs/crates/module-custom-world/src/application.rs``server-rs/crates/spacetime-module/src/custom_world.rs``docs/【玩法创作】平台入口与玩法链路-2026-05-15.md`
## 本地脚本调 VectorEngine 生图卡住先区分 fetch 首部超时 ## 本地脚本调 VectorEngine 生图卡住先区分 fetch 首部超时
- 现象:用 Node `fetch` 直接请求 `POST /v1/images/generations`,已经设置较长的 AbortController 超时,但仍在约 180 到 300 秒后抛 `AbortError``TypeError: fetch failed``UND_ERR_HEADERS_TIMEOUT`;同一 prompt 改用原生 `https.request` 可以在较短时间内成功返回图片。 - 现象:用 Node `fetch` 直接请求 `POST /v1/images/generations`,已经设置较长的 AbortController 超时,但仍在约 180 到 300 秒后抛 `AbortError``TypeError: fetch failed``UND_ERR_HEADERS_TIMEOUT`;同一 prompt 改用原生 `https.request` 可以在较短时间内成功返回图片。

View File

@@ -50,6 +50,8 @@ RPG API 仍沿用历史命名空间:`/api/runtime/custom-world*`、`/api/story
RPG Agent 结果页发布动作的前端契约只保证提交 `{ action: 'publish_world' }`;后端发布时以当前 `custom_world_agent_session.draft_profile_json` 为草稿真相,从 `settingText``creatorIntent.rawSettingText``creatorIntent.worldHook``worldHook``anchorContent.worldPromise(.hook)``summary``name/title` 依次派生正式 `setting_text`,最后才回退 `seed_text`。不要把 `seed_text` 当作唯一设定来源,旧会话可能为空。 RPG Agent 结果页发布动作的前端契约只保证提交 `{ action: 'publish_world' }`;后端发布时以当前 `custom_world_agent_session.draft_profile_json` 为草稿真相,从 `settingText``creatorIntent.rawSettingText``creatorIntent.worldHook``worldHook``anchorContent.worldPromise(.hook)``summary``name/title` 依次派生正式 `setting_text`,最后才回退 `seed_text`。不要把 `seed_text` 当作唯一设定来源,旧会话可能为空。
`legacyResultProfile` 只作为历史结果页 profile 兼容兜底;`publish_world` 请求缺省或显式为 `null` 时等价于未提供,编译正式 profile 时不得因此报 `custom_world.compile.legacy_result_profile_json 不是合法 JSON object`。真正的数组、字符串、数字等非 object legacy 载荷仍应拒绝。
RPG 结果页开局 CG 是 `profile.openingCg` 资产槽位:`api-server` 负责 VectorEngine / OSS 副作用并返回故事板和视频引用,前端只把结果写回当前 profile`sync_result_profile`、作品库保存和 `normalizeCustomWorldProfileRecord` 都必须保留该槽位。若生成成功后画面短暂显示又变回空白,优先检查父层重新同步或 profile 归一化是否把 `openingCg` 丢掉,而不是先怀疑已生成资源本身失效。 RPG 结果页开局 CG 是 `profile.openingCg` 资产槽位:`api-server` 负责 VectorEngine / OSS 副作用并返回故事板和视频引用,前端只把结果写回当前 profile`sync_result_profile`、作品库保存和 `normalizeCustomWorldProfileRecord` 都必须保留该槽位。若生成成功后画面短暂显示又变回空白,优先检查父层重新同步或 profile 归一化是否把 `openingCg` 丢掉,而不是先怀疑已生成资源本身失效。
## 拼图 ## 拼图

View File

@@ -694,7 +694,13 @@ fn parse_optional_json_object(
error: CustomWorldFieldError, error: CustomWorldFieldError,
) -> Result<Map<String, Value>, CustomWorldFieldError> { ) -> Result<Map<String, Value>, CustomWorldFieldError> {
match normalize_optional_json_slice(value) { match normalize_optional_json_slice(value) {
Some(value) => parse_required_json_object(&value, error), Some(value) => match serde_json::from_str::<Value>(&value) {
Ok(Value::Object(object)) => Ok(object),
// 中文注释:跨层可选字段经 serde 结构体序列化后可能显式落成 null
// 对 optional JSON object 而言 null 等价于未提供,不能阻断发布链路。
Ok(Value::Null) => Ok(Map::new()),
_ => Err(error),
},
None => Ok(Map::new()), None => Ok(Map::new()),
} }
} }
@@ -1018,6 +1024,49 @@ mod tests {
use super::*; use super::*;
use serde_json::json; use serde_json::json;
fn build_test_compile_input(
legacy_result_profile_json: Option<String>,
) -> CustomWorldPublishedProfileCompileInput {
CustomWorldPublishedProfileCompileInput {
session_id: "session-1".to_string(),
profile_id: "cwprof_001".to_string(),
owner_user_id: "user-1".to_string(),
draft_profile_json: json!({
"name": "潮雾列岛",
"summary": "群岛与旧灯塔之间的沉船疑案。",
"playableNpcs": [],
"storyNpcs": [],
"landmarks": []
})
.to_string(),
legacy_result_profile_json,
setting_text: "海图会在午夜改写群岛航路。".to_string(),
author_display_name: "创作者".to_string(),
updated_at_micros: 1,
}
}
#[test]
fn published_profile_compile_treats_null_legacy_result_profile_as_absent() {
let snapshot = build_custom_world_published_profile_compile_snapshot(
build_test_compile_input(Some("null".to_string())),
)
.expect("null legacy result profile should be treated as absent");
assert_eq!(snapshot.profile_id, "cwprof_001");
assert_eq!(snapshot.world_name, "潮雾列岛");
}
#[test]
fn published_profile_compile_rejects_non_object_legacy_result_profile() {
let error = build_custom_world_published_profile_compile_snapshot(
build_test_compile_input(Some("[]".to_string())),
)
.expect_err("array legacy result profile should still be invalid");
assert_eq!(error, CustomWorldFieldError::InvalidLegacyResultProfileJson);
}
#[test] #[test]
fn publish_setting_text_falls_back_to_draft_profile_when_seed_is_empty() { fn publish_setting_text_falls_back_to_draft_profile_when_seed_is_empty() {
let payload = Map::new(); let payload = Map::new();