非 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:
@@ -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 值,已按严格口径收口为 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::<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());
|
||||
}
|
||||
|
||||
@@ -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]
|
||||
|
||||
Reference in New Issue
Block a user