非 canonical 资源 kind 严格收口并留痕原始串

- 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。
This commit is contained in:
2026-09-17 13:41:41 +08:00
parent 56c4faa8ee
commit 3ab56e6cfa
5 changed files with 122 additions and 5 deletions
@@ -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"] }
@@ -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,
}
}
@@ -1927,6 +1927,13 @@ pub(crate) fn read_manifest(path: &Path) -> Result<GameCreationAppManifest, Stri
.map_err(|error| format!("读取 {label} 失败:{}: {error}", source_path.display()))?;
let manifest: GameCreationAppManifest = serde_json::from_str(&payload)
.map_err(|error| format!("解析 {label} 失败:{}: {error}", source_path.display()))?;
if manifest
.assets
.iter()
.any(|asset| asset.kind == GameCreationAppAssetKind::Unknown)
{
log_non_canonical_manifest_asset_kinds(&payload, source_path, label);
}
validate_manifest_schema_version(&manifest.schema_version)
.map_err(|error| format!("校验 {label} schema 版本失败:{error}"))?;
validate_manifest_godot_project_root(manifest.godot_project_root.as_deref())
@@ -1938,6 +1945,68 @@ pub(crate) fn read_manifest(path: &Path) -> Result<GameCreationAppManifest, Stri
Ok(manifest)
}
/// 读 manifest 时给非 canonical 的 `kind` 留痕。
///
/// `kind` 走严格解析:认不出的值收口成 `unknown`**不做 fallback、不做迁移**。收口之后原始串
/// 就丢了,所以这里在读到的原始 JSON 里回捞一遍,把原值连同资产 ID、路径写进应用日志
/// `diagnostics/application.log`),供后续定位"谁还在写 legacy kind"。
///
/// 同一个原值每次进程只记一次:manifest 读得很频繁,不去重会把其它诊断刷掉。
fn log_non_canonical_manifest_asset_kinds(payload: &str, source_path: &Path, label: &str) {
static LOGGED_RAW_KINDS: OnceLock<Mutex<std::collections::HashSet<String>>> = 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 值,已按严格口径收口为 unknownrawKind={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::<serde_json::Value>(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`] 会把
@@ -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());
}
+3 -1
View File
@@ -8,7 +8,9 @@ license.workspace = true
# 默认给 api-server 等原生后端暴露资产上传 DTOSpacetimeDB 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]