From 3ab56e6cfafe8382320dc7477460e594a074fbfd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E5=BE=B7=E5=AE=87?= Date: Thu, 17 Sep 2026 13:41:41 +0800 Subject: [PATCH] =?UTF-8?q?=E9=9D=9E=20canonical=20=E8=B5=84=E6=BA=90=20ki?= =?UTF-8?q?nd=20=E4=B8=A5=E6=A0=BC=E6=94=B6=E5=8F=A3=E5=B9=B6=E7=95=99?= =?UTF-8?q?=E7=97=95=E5=8E=9F=E5=A7=8B=E4=B8=B2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - manifest.rs:读 manifest 时若存在 `unknown` kind,从原始 JSON 回捞 `assets[].kind` 原值,连同资产 ID、路径写进应用日志;同一原值每进程只记一次,避免刷屏。 - recovery_tests.rs:新增用例钉住回捞范围(只报非 canonical 值,跳过 canonical 与字面 `unknown`;结构异常返回空表)。 - commands.rs:`imported_platform_asset_kind` 收口平台 `assetKind` 时,非 canonical 原值一并留痕,供后续定位平台侧 legacy 写入点。 - shared-contracts/Cargo.toml:`ts-bindings` 不再顺带打开 `kind-observability`,改由 AGC 壳显式声明,日志开关不再随绑定需求被动启停。 - 口径:kind 保持严格解析(无 trim/lowercase、无别名表、无迁移),认不出的值一律收口 `unknown` 并留痕,不做 fallback。 --- .../src-tauri/Cargo.toml | 2 +- .../src-tauri/src/commands.rs | 16 ++++- .../src-tauri/src/project/manifest.rs | 69 +++++++++++++++++++ .../src/project/manifest/recovery_tests.rs | 36 ++++++++++ server-rs/crates/shared-contracts/Cargo.toml | 4 +- 5 files changed, 122 insertions(+), 5 deletions(-) diff --git a/apps/ai-game-creator-shell/src-tauri/Cargo.toml b/apps/ai-game-creator-shell/src-tauri/Cargo.toml index 68c131dd8..8ec462348 100644 --- a/apps/ai-game-creator-shell/src-tauri/Cargo.toml +++ b/apps/ai-game-creator-shell/src-tauri/Cargo.toml @@ -50,7 +50,7 @@ portable-pty = "0.9" percent-encoding = "2" reqwest = { version = "0.12", default-features = false, features = ["json", "multipart", "native-tls", "stream"] } regex = "1" -shared-contracts = { path = "../../../server-rs/crates/shared-contracts", default-features = false, features = ["ts-bindings"] } +shared-contracts = { path = "../../../server-rs/crates/shared-contracts", default-features = false, features = ["ts-bindings", "kind-observability"] } tauri = { version = "2.11.2", features = [] } tauri-plugin-dialog = "2.7.1" tauri-plugin-http = { version = "2.5.9", default-features = false, features = ["charset", "cookies", "http2", "rustls-tls"] } diff --git a/apps/ai-game-creator-shell/src-tauri/src/commands.rs b/apps/ai-game-creator-shell/src-tauri/src/commands.rs index 1fd1b3489..04278b797 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/commands.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/commands.rs @@ -4175,9 +4175,19 @@ fn imported_platform_asset_kind(platform_kind: Option<&str>) -> GameCreationAppA match platform_kind.map(str::trim).filter(|kind| !kind.is_empty()) { // 平台是外部系统,它的 `assetKind` 词汇(含大写 `UI` 这类变体)由共享契约统一收口到 // canonical 值;这里只把收口结果转成正式枚举,写入侧不再产出裸字符串。 - Some(kind) => GameCreationAppAssetKind::from_str_lossy( - shared_contracts::game_creation_app::canonical_game_creation_app_asset_kind(kind), - ), + Some(kind) => { + let canonical = + shared_contracts::game_creation_app::canonical_game_creation_app_asset_kind(kind); + if canonical != kind { + // 收口会抹掉原值,而 kind 本身是严格解析(不 fallback、不迁移), + // 所以非 canonical 的原值必须在这里留痕:这是后续定位"平台侧谁还在写 + // legacy kind"的唯一线索。 + app_log!( + "平台 assetKind 不是 canonical 值,已按共享契约收口:rawKind={kind} canonicalKind={canonical}" + ); + } + GameCreationAppAssetKind::parse_with_context(canonical, "platform.asset_kind") + } None => GameCreationAppAssetKind::Image, } } diff --git a/apps/ai-game-creator-shell/src-tauri/src/project/manifest.rs b/apps/ai-game-creator-shell/src-tauri/src/project/manifest.rs index 7407563d5..c5f912c0b 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/project/manifest.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/project/manifest.rs @@ -1927,6 +1927,13 @@ pub(crate) fn read_manifest(path: &Path) -> Result Result>> = OnceLock::new(); + for (raw_kind, asset_id, local_path) in non_canonical_manifest_asset_kinds(payload) { + let logged = LOGGED_RAW_KINDS.get_or_init(|| Mutex::new(std::collections::HashSet::new())); + let first_seen = logged + .lock() + .map(|mut logged| logged.insert(raw_kind.clone())) + .unwrap_or(true); + if !first_seen { + continue; + } + app_log!( + "manifest 资源 kind 不是 canonical 值,已按严格口径收口为 unknown:rawKind={raw_kind} assetId={asset_id} localPath={local_path} source={}({label})", + source_path.display() + ); + } +} + +/// 从 manifest 原始 JSON 的 `assets[].kind` 里挑出不是 canonical 值的条目, +/// 返回 `(原值, 资产 ID, 本地路径)`。JSON 结构不是预期形状时返回空表(读取本身会另行报错)。 +fn non_canonical_manifest_asset_kinds(payload: &str) -> Vec<(String, String, String)> { + let Ok(value) = serde_json::from_str::(payload) else { + return Vec::new(); + }; + let Some(assets) = value.get("assets").and_then(serde_json::Value::as_array) else { + return Vec::new(); + }; + let mut found = Vec::new(); + for asset in assets { + let Some(raw_kind) = asset.get("kind").and_then(serde_json::Value::as_str) else { + continue; + }; + if raw_kind.trim() == GameCreationAppAssetKind::Unknown.as_str() + || GameCreationAppAssetKind::from_str_lossy(raw_kind) + != GameCreationAppAssetKind::Unknown + { + continue; + } + found.push(( + raw_kind.to_string(), + asset + .get("id") + .and_then(serde_json::Value::as_str) + .unwrap_or_default() + .to_string(), + asset + .get("localPath") + .and_then(serde_json::Value::as_str) + .unwrap_or_default() + .to_string(), + )); + } + found +} + /// manifest 的 `schemaVersion` 门:只接受当前唯一已知版本,未知版本一律失败关闭。 /// /// 为什么必须在**读侧**校验:manifest 是项目的持久化业务真相,而 [`write_manifest_locked`] 会把 diff --git a/apps/ai-game-creator-shell/src-tauri/src/project/manifest/recovery_tests.rs b/apps/ai-game-creator-shell/src-tauri/src/project/manifest/recovery_tests.rs index 20fa2f825..f82135553 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/project/manifest/recovery_tests.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/project/manifest/recovery_tests.rs @@ -458,3 +458,39 @@ fn manifest_read_and_write_reject_symbolic_links() { fs::remove_dir_all(root).ok(); } + +/// 严格解析下旧 kind 会收口成 `unknown`,原始串只能从读到的 JSON 里回捞: +/// 这条用例钉住"回捞哪些条目、跳过哪些",也就是留痕日志的内容边界。 +#[test] +fn non_canonical_manifest_asset_kinds_report_raw_values_only() { + let payload = r#"{ + "assets": [ + { "id": "legacy-ui", "kind": "UI", "localPath": "ui/UI 设计 1.json" }, + { "id": "canonical-image", "kind": "image", "localPath": "assets/a.png" }, + { "id": "already-unknown", "kind": "unknown", "localPath": "assets/b.bin" }, + { + "id": "legacy-slice", + "kind": "art-spritesheet-slice", + "localPath": "assets/art-spritesheet-slices/1.png" + } + ] + }"#; + assert_eq!( + non_canonical_manifest_asset_kinds(payload), + vec![ + ( + "UI".to_string(), + "legacy-ui".to_string(), + "ui/UI 设计 1.json".to_string() + ), + ( + "art-spritesheet-slice".to_string(), + "legacy-slice".to_string(), + "assets/art-spritesheet-slices/1.png".to_string() + ), + ] + ); + // 结构不是预期形状(或不是 JSON)时不报错、不留痕:读取路径自己会给出失败原因。 + assert!(non_canonical_manifest_asset_kinds("not json").is_empty()); + assert!(non_canonical_manifest_asset_kinds(r#"{"assets":{}}"#).is_empty()); +} diff --git a/server-rs/crates/shared-contracts/Cargo.toml b/server-rs/crates/shared-contracts/Cargo.toml index 2a8a00e61..1ab34918f 100644 --- a/server-rs/crates/shared-contracts/Cargo.toml +++ b/server-rs/crates/shared-contracts/Cargo.toml @@ -8,7 +8,9 @@ license.workspace = true # 默认给 api-server 等原生后端暴露资产上传 DTO;SpacetimeDB WASM 路径通过 workspace 依赖关闭默认 feature。 default = ["oss-contracts"] oss-contracts = [] -ts-bindings = ["dep:ts-rs", "kind-observability"] +# 导出 TS 绑定与"解析边界留痕"是两件事:前者只关心生成物,后者是可观测性开关, +# 需要它的消费方(AGC 壳)显式打开,避免日志随绑定需求被动启用/被动关闭。 +ts-bindings = ["dep:ts-rs"] kind-observability = ["dep:tracing"] [dependencies]