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 fbd020f82..bc72a7fad 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 @@ -1146,7 +1146,7 @@ pub(crate) fn update_manifest_asset_classification_at( return Err("project-revision-conflict".to_string()); } - let asset = mutate_manifest_at(root, |manifest| { + let (asset, previous_category, previous_tags) = mutate_manifest_at(root, |manifest| { let Some(asset) = manifest .assets .iter_mut() @@ -1154,10 +1154,29 @@ pub(crate) fn update_manifest_asset_classification_at( else { return Err(format!("项目资源不存在:{asset_id}")); }; + let previous_category = asset.category; + let previous_tags = asset.tags.clone(); asset.category = category; asset.tags = tags; - Ok(asset.clone()) + Ok((asset.clone(), previous_category, previous_tags)) })?; + // 审计紧跟在「manifest 已落盘」之后、`advance_agent_runtime_project_revision_locked` 之前: + // 与 `assets.rs` 的 `asset.register` / `asset.update` 同一位置约定(manifest 写成功后追加)。 + // 放在推进 revision 之前是刻意的——分类已经真实落盘,审计不能因为紧随其后的 revision 推进 + // 失败而缺失,否则「改过但查不到」正是这条缺陷要修的东西;推进失败仍然照旧报错(见下方 map_err)。 + append_agent_db_record( + root, + serde_json::json!({ + "recordType": "asset.classification.update", + "assetId": asset.id, + "expectedProjectRevision": expected_project_revision, + "previousCategory": previous_category, + "previousTags": previous_tags, + "category": asset.category, + "tags": asset.tags, + }), + ) + .map_err(|error| format!("资源分类已写入,但审计记录失败:{error}"))?; let committed_project_revision = advance_agent_runtime_project_revision_locked(root) .map_err(|error| format!("资源分类已写入,但项目 revision 未能推进:{error}"))?; Ok(UpdateLocalProjectResourceClassificationResult { diff --git a/apps/ai-game-creator-shell/src-tauri/src/project/manifest/classification_tests.rs b/apps/ai-game-creator-shell/src-tauri/src/project/manifest/classification_tests.rs index dfda43e64..2c62a6b5e 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/project/manifest/classification_tests.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/project/manifest/classification_tests.rs @@ -277,3 +277,185 @@ fn asset_classification_input_rejects_unknown_fields() { ); assert!(input.is_err()); } + +/// 分类写入的 `recordType`。取 `asset.*` 命名族,与 `assets.rs` 的 `asset.register` / +/// `asset.update` 同族;`.update` 与「只改分类与标签、不新增资源登记」的语义一致。 +const ASSET_CLASSIFICATION_AUDIT_RECORD_TYPE: &str = "asset.classification.update"; + +fn classification_audit_records(root: &Path) -> Vec { + let (records, truncated) = + read_agent_db_records_bounded(root, 4 * 1024 * 1024).expect("read agent db records"); + assert!(!truncated, "分类测试的 agent.db 不应触达尾窗上限"); + records + .into_iter() + .filter(|record| { + record.get("recordType").and_then(serde_json::Value::as_str) + == Some(ASSET_CLASSIFICATION_AUDIT_RECORD_TYPE) + }) + .collect() +} + +/// 分类写入必须留下一条 agent.db 审计,且字段能回答「把哪个资源从哪个分类改成了什么」。 +#[test] +fn asset_classification_update_appends_one_agent_db_audit_record() { + let (root, asset_id) = project_with_two_assets("audit-success"); + let project_id = read_existing_manifest_for_project(&root) + .expect("read manifest") + .project_id; + let revision_before = read_game_creator_agent_runtime_project_revision(&root) + .expect("read revision before") + .revision; + assert!( + classification_audit_records(&root).is_empty(), + "初始化阶段不应存在分类审计" + ); + + update_manifest_asset_classification_at( + &root, + &project_id, + revision_before, + &asset_id, + "scene", + vec!["主舞台".to_string()], + ) + .expect("update classification"); + + let records = classification_audit_records(&root); + assert_eq!(records.len(), 1, "{records:?}"); + let record = &records[0]; + assert_eq!(record["assetId"], serde_json::json!(asset_id)); + assert_eq!(record["previousCategory"], serde_json::json!("character")); + assert_eq!(record["previousTags"], serde_json::json!([])); + assert_eq!(record["category"], serde_json::json!("scene")); + assert_eq!(record["tags"], serde_json::json!(["主舞台"])); + assert_eq!( + record["expectedProjectRevision"], + serde_json::json!(revision_before) + ); + // 信封字段由 `append_agent_db_record` 统一追加:新增记录类型不得改动既有信封约定。 + assert_eq!( + record["schemaVersion"], + serde_json::json!(GAME_CREATOR_AGENT_DB_SCHEMA_VERSION) + ); + assert!(record["updatedAt"].as_u64().is_some_and(|value| value > 0)); + + // 第二次写入的「变更前值」必须是第一次写入后的落盘值,证明前后值取自 manifest 而不是硬编码。 + let revision_after_first = revision_before + 1; + update_manifest_asset_classification_at( + &root, + &project_id, + revision_after_first, + &asset_id, + "document", + vec!["说明".to_string()], + ) + .expect("update classification twice"); + + let records = classification_audit_records(&root); + assert_eq!(records.len(), 2, "{records:?}"); + assert_eq!(records[1]["assetId"], serde_json::json!(asset_id)); + assert_eq!(records[1]["previousCategory"], serde_json::json!("scene")); + assert_eq!(records[1]["previousTags"], serde_json::json!(["主舞台"])); + assert_eq!(records[1]["category"], serde_json::json!("document")); + assert_eq!(records[1]["tags"], serde_json::json!(["说明"])); + assert_eq!( + records[1]["expectedProjectRevision"], + serde_json::json!(revision_after_first) + ); + fs::remove_dir_all(root).ok(); +} + +/// 被拒的分类写入不得留下审计:审计是「真的改了什么」的记录,不是「尝试过什么」的记录。 +#[test] +fn asset_classification_rejections_do_not_append_agent_db_audit_records() { + let (root, asset_id) = project_with_two_assets("audit-rejections"); + let project_id = read_existing_manifest_for_project(&root) + .expect("read manifest") + .project_id; + let revision_before = read_game_creator_agent_runtime_project_revision(&root) + .expect("read revision") + .revision; + + update_manifest_asset_classification_at( + &root, + &project_id, + revision_before, + &asset_id, + "future-category", + Vec::new(), + ) + .expect_err("invalid category must fail"); + update_manifest_asset_classification_at( + &root, + &project_id, + revision_before + 1, + &asset_id, + "scene", + Vec::new(), + ) + .expect_err("stale revision must fail"); + update_manifest_asset_classification_at( + &root, + "other-project", + revision_before, + &asset_id, + "scene", + Vec::new(), + ) + .expect_err("foreign project identity must fail"); + update_manifest_asset_classification_at( + &root, + &project_id, + revision_before, + "missing-asset", + "scene", + Vec::new(), + ) + .expect_err("missing asset must fail"); + + assert!( + classification_audit_records(&root).is_empty(), + "被拒的分类写入不得产生审计" + ); + fs::remove_dir_all(root).ok(); +} + +/// 审计写失败必须可见:分类已经落盘的既定行为不变(不静默回滚),但命令要报错, +/// 而且不能留下一条假的审计。 +#[test] +fn asset_classification_audit_failure_is_reported_and_never_faked() { + let (root, asset_id) = project_with_two_assets("audit-failure"); + let project_id = read_existing_manifest_for_project(&root) + .expect("read manifest") + .project_id; + let revision_before = read_game_creator_agent_runtime_project_revision(&root) + .expect("read revision") + .revision; + fs::write( + root.join(".agent/runtime/test-fail-next-agent-db-record"), + ASSET_CLASSIFICATION_AUDIT_RECORD_TYPE, + ) + .expect("write audit failure injection marker"); + + let error = update_manifest_asset_classification_at( + &root, + &project_id, + revision_before, + &asset_id, + "scene", + vec!["主舞台".to_string()], + ) + .expect_err("an audit append failure must surface to the caller"); + + assert!(error.contains("审计记录失败"), "unexpected error: {error}"); + assert!(classification_audit_records(&root).is_empty()); + let persisted = read_existing_manifest_for_project(&root).expect("read manifest after failure"); + let asset = persisted + .assets + .iter() + .find(|asset| asset.id == asset_id) + .expect("persisted asset"); + assert_eq!(asset.category, GameCreationAppAssetCategory::Scene); + assert_eq!(asset.tags, vec!["主舞台"]); + fs::remove_dir_all(root).ok(); +}