From 3be8e40bc2531775e291e5192021e7443f3c67cd Mon Sep 17 00:00:00 2001 From: Suzumiya Date: Thu, 24 Sep 2026 11:20:39 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=20AGC=20=E6=8D=A2=E5=8F=B7?= =?UTF-8?q?=E5=90=8E=E6=95=B4=E9=A1=B9=E7=9B=AE=E5=BF=AB=E7=85=A7=E9=87=8D?= =?UTF-8?q?=E4=BC=A0=E5=AF=BC=E8=87=B4=E7=9A=84=E6=97=A5=E5=BF=97=E5=88=B7?= =?UTF-8?q?=E5=B1=8F=E4=B8=8E=E5=AE=A2=E6=88=B7=E7=AB=AF=E8=B6=85=E6=97=B6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 项目快照索引升级为 schema v2:按账号分桶保存基线,换号不再让其它账号的基线失效,也不会被其它账号覆盖 - 同步只读取并写回当前登录账号的基线,其它账号的基线原样保留 - 读取时按 JSON 版本判定并迁移 v1 单账号索引到它自己记录的 userId 桶,格式升级不额外触发一次重传 - read_local_project_snapshot_state 增补 baselineCount / baselinePresent,fileCount / syncRevision / syncedAtMs 改为当前登录账号的基线口径 - 新增与改写用例:账号切换后基线互不覆盖、切回旧账号差异为空、v1 索引迁移进对应账号桶 - 同步更新快照主规范、快照里程碑文档、shared-memory decision-log 与 pitfalls --- .../src-tauri/src/project_snapshot/index.rs | 186 +++++++++++++++--- .../src-tauri/src/project_snapshot/mod.rs | 36 ++-- .../src-tauri/src/project_snapshot/tests.rs | 163 ++++++++++++--- ...€�里程碑】AGC项目定时快照上传-2026-09-17.md | 3 +- .../shared-memory/decision-log.md | 11 ++ docs/project-memory/shared-memory/pitfalls.md | 9 + ...¹案】AI游戏创作智能体App实施计划-2026-06-24.md | 3 +- 7 files changed, 349 insertions(+), 62 deletions(-) diff --git a/apps/ai-game-creator-shell/src-tauri/src/project_snapshot/index.rs b/apps/ai-game-creator-shell/src-tauri/src/project_snapshot/index.rs index 010642de6..0ac2ae245 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/project_snapshot/index.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/project_snapshot/index.rs @@ -9,14 +9,13 @@ pub(crate) struct ProjectSnapshotIndexedFile { pub(crate) checksum: String, } -/// 上次成功同步的快照。索引只在本机 AppData 中,不进入用户项目目录。 +/// 某个账号在某个项目上的上次成功同步基线。 /// -/// `user_id` 是远端前缀的一部分:换号后旧索引不再代表同一个远端命名空间, -/// 因此读取时按用户身份判等,不一致就当作冷启动重新全量对比。 +/// 基线只对写入它的账号成立:远端对象键里带着 `userId`,换号之后旧基线既不能用来 +/// 判断"远端已经有什么",也不能被新账号的同步覆盖掉。 #[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] -pub(crate) struct ProjectSnapshotIndex { - pub(crate) schema_version: u32, +pub(crate) struct ProjectSnapshotBaseline { pub(crate) project_id: String, pub(crate) user_id: String, pub(crate) sync_revision: u64, @@ -29,12 +28,59 @@ pub(crate) struct ProjectSnapshotIndex { pub(crate) files: BTreeMap, } -pub(crate) fn empty_project_snapshot_index( - project_id: &str, - user_id: &str, -) -> ProjectSnapshotIndex { +/// 索引文件:一个项目一份,按账号分桶保存各账号的基线。 +/// +/// 曾经整份索引只保存一个账号的基线,换号即当作"没有基线",于是每次切号后打开项目 +/// 都把整个工程重传一遍(issue #504:2311 个文件、服务端全是 HEAD 命中的白跑请求, +/// 同时把同进程的 IPC 压到其它接口 15s 超时)。分桶之后各账号各自持有基线,切回旧 +/// 账号只需要传真正的差异。 +#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct ProjectSnapshotIndex { + pub(crate) schema_version: u32, + pub(crate) project_id: String, + #[serde(default)] + pub(crate) baselines: BTreeMap, +} + +/// v1 索引:整份文件只保存一个账号的基线。只读一次,用于迁移。 +/// +/// `schemaVersion` 不用反序列化:版本已经在选分支时按 JSON 值判定过。 +#[derive(Clone, Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +struct LegacyProjectSnapshotIndexV1 { + project_id: String, + user_id: String, + sync_revision: u64, + synced_at_ms: u64, + #[serde(default)] + project_name: Option, + #[serde(default)] + pending_files: Option, + #[serde(default)] + files: BTreeMap, +} + +impl ProjectSnapshotIndex { + pub(crate) fn baseline_for(&self, user_id: &str) -> Option<&ProjectSnapshotBaseline> { + self.baselines.get(user_id) + } +} + +pub(crate) fn empty_project_snapshot_index(project_id: &str) -> ProjectSnapshotIndex { ProjectSnapshotIndex { schema_version: PROJECT_SNAPSHOT_INDEX_SCHEMA_VERSION, + project_id: project_id.to_string(), + baselines: BTreeMap::new(), + } +} + +/// 某账号尚无基线时的空基线:全量对比会重新上传所有文件,不会漏传。 +pub(crate) fn empty_project_snapshot_baseline( + project_id: &str, + user_id: &str, +) -> ProjectSnapshotBaseline { + ProjectSnapshotBaseline { project_id: project_id.to_string(), user_id: user_id.to_string(), sync_revision: 0, @@ -79,8 +125,8 @@ pub(crate) fn project_snapshot_index_path_at(directory: &Path) -> PathBuf { directory.join(PROJECT_SNAPSHOT_INDEX_FILE_NAME) } -/// 读取本地索引。索引不存在、版本不符或内容损坏时返回空索引:这种情况下 -/// 全量对比会重新上传所有文件,不会漏传,也不会因为坏索引中断同步。 +/// 读取本地索引。索引不存在、版本不符或内容损坏时返回空索引:这种情况下某账号 +/// 视为没有基线,会重新上传该账号的全部文件,不会漏传,也不会因为坏索引中断同步。 pub(crate) fn read_project_snapshot_index( project_id: &str, ) -> Result { @@ -93,7 +139,7 @@ pub(crate) fn read_project_snapshot_index_at( ) -> Result { let path = project_snapshot_index_path_at(directory); if !path.exists() { - return Ok(empty_project_snapshot_index(project_id, "")); + return Ok(empty_project_snapshot_index(project_id)); } let content = match read_game_creator_private_file_to_string( &path, @@ -103,29 +149,119 @@ pub(crate) fn read_project_snapshot_index_at( Ok(content) => content, Err(error) => { app_log!("project_snapshot.index.read.failed: {error}"); - return Ok(empty_project_snapshot_index(project_id, "")); + return Ok(empty_project_snapshot_index(project_id)); } }; - match serde_json::from_str::(&content) { - Ok(index) - if index.schema_version == PROJECT_SNAPSHOT_INDEX_SCHEMA_VERSION - && index.project_id == project_id => - { - Ok(index) - } - Ok(_) => Ok(empty_project_snapshot_index(project_id, "")), + let value = match serde_json::from_str::(&content) { + Ok(value) => value, Err(error) => { app_log!("project_snapshot.index.parse.failed: {error}"); - Ok(empty_project_snapshot_index(project_id, "")) + return Ok(empty_project_snapshot_index(project_id)); } + }; + match value + .get("schemaVersion") + .and_then(serde_json::Value::as_u64) + { + Some(version) if version == u64::from(PROJECT_SNAPSHOT_INDEX_SCHEMA_VERSION) => { + match serde_json::from_value::(value) { + Ok(index) if index.project_id == project_id => Ok(index), + Ok(_) => Ok(empty_project_snapshot_index(project_id)), + Err(error) => { + app_log!("project_snapshot.index.parse.failed: {error}"); + Ok(empty_project_snapshot_index(project_id)) + } + } + } + Some(PROJECT_SNAPSHOT_INDEX_LEGACY_SCHEMA_VERSION) => { + match serde_json::from_value::(value) { + Ok(legacy) => Ok(migrate_legacy_project_snapshot_index(project_id, legacy)), + Err(error) => { + app_log!("project_snapshot.index.parse.failed: {error}"); + Ok(empty_project_snapshot_index(project_id)) + } + } + } + _ => Ok(empty_project_snapshot_index(project_id)), } } -pub(crate) fn write_project_snapshot_index(index: &ProjectSnapshotIndex) -> Result<(), String> { - write_project_snapshot_index_at(&project_snapshot_index_directory(&index.project_id)?, index) +/// 把 v1 的单账号索引原样搬进新的账号分桶:升级后不需要因为格式变化再做一次全量重传。 +/// +/// `userId` 缺失的 v1 索引无法归属到任何账号,只能丢弃;`user_id` 保持原样不 trim, +/// 因为它是远端前缀的一部分,不做过任何规范化。 +fn migrate_legacy_project_snapshot_index( + project_id: &str, + legacy: LegacyProjectSnapshotIndexV1, +) -> ProjectSnapshotIndex { + let mut index = empty_project_snapshot_index(project_id); + if legacy.project_id != project_id || legacy.user_id.is_empty() { + return index; + } + index.baselines.insert( + legacy.user_id.clone(), + ProjectSnapshotBaseline { + project_id: project_id.to_string(), + user_id: legacy.user_id, + sync_revision: legacy.sync_revision, + synced_at_ms: legacy.synced_at_ms, + project_name: legacy.project_name, + pending_files: legacy.pending_files, + files: legacy.files, + }, + ); + index } -pub(crate) fn write_project_snapshot_index_at( +/// 读取指定账号的基线;该账号尚无基线时返回空基线,其它账号的基线不受影响。 +pub(crate) fn read_project_snapshot_baseline( + project_id: &str, + user_id: &str, +) -> Result { + read_project_snapshot_baseline_at( + &project_snapshot_index_directory(project_id)?, + project_id, + user_id, + ) +} + +pub(crate) fn read_project_snapshot_baseline_at( + directory: &Path, + project_id: &str, + user_id: &str, +) -> Result { + let index = read_project_snapshot_index_at(directory, project_id)?; + Ok(index + .baseline_for(user_id) + .cloned() + .unwrap_or_else(|| empty_project_snapshot_baseline(project_id, user_id))) +} + +/// 写回某账号的基线,保留同一项目下其它账号的基线。 +/// +/// 旧索引损坏时按"其它账号没有基线"继续写入:坏文件本身已经不可用,宁可在下一个 +/// 账号同步时重算,也不能让这次同步因为索引写失败而整体作废(那会立刻触发重传)。 +pub(crate) fn write_project_snapshot_baseline( + baseline: &ProjectSnapshotBaseline, +) -> Result<(), String> { + write_project_snapshot_baseline_at( + &project_snapshot_index_directory(&baseline.project_id)?, + baseline, + ) +} + +pub(crate) fn write_project_snapshot_baseline_at( + directory: &Path, + baseline: &ProjectSnapshotBaseline, +) -> Result<(), String> { + let mut index = read_project_snapshot_index_at(directory, &baseline.project_id)?; + index + .baselines + .insert(baseline.user_id.clone(), baseline.clone()); + write_project_snapshot_index_at(directory, &index) +} + +fn write_project_snapshot_index_at( directory: &Path, index: &ProjectSnapshotIndex, ) -> Result<(), String> { diff --git a/apps/ai-game-creator-shell/src-tauri/src/project_snapshot/mod.rs b/apps/ai-game-creator-shell/src-tauri/src/project_snapshot/mod.rs index 503682525..9eab751d4 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/project_snapshot/mod.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/project_snapshot/mod.rs @@ -27,7 +27,10 @@ pub(crate) use transport::*; /// 本机项目快照索引在 AppData 配置目录下的位置。 pub(crate) const PROJECT_SNAPSHOT_DIRECTORY: &str = "project-snapshots"; const PROJECT_SNAPSHOT_INDEX_FILE_NAME: &str = "index.json"; -const PROJECT_SNAPSHOT_INDEX_SCHEMA_VERSION: u32 = 1; +/// 索引文件版本。2 起按账号分桶保存基线(见 `index.rs`),v1 只保存单账号基线。 +const PROJECT_SNAPSHOT_INDEX_SCHEMA_VERSION: u32 = 2; +/// 只用于迁移读取的历史索引版本。 +const PROJECT_SNAPSHOT_INDEX_LEGACY_SCHEMA_VERSION: u64 = 1; const PROJECT_SNAPSHOT_INDEX_MAX_BYTES: u64 = 32 * 1024 * 1024; pub(crate) const PROJECT_SNAPSHOT_MAX_FILE_BYTES: u64 = @@ -293,12 +296,9 @@ async fn sync_project_snapshot_async( let manifest = read_existing_manifest_for_project(project_root)?; let project_id = validate_project_snapshot_project_id(manifest.project_id.trim())?; - let previous = read_project_snapshot_index(&project_id)?; - let previous = if previous.user_id == session.user_id { - previous - } else { - empty_project_snapshot_index(&project_id, &session.user_id) - }; + // 基线按账号读取:换号不再让别的账号的基线失效,也不会把新账号的空白当成 + // 需要整项目重传的理由。 + let previous = read_project_snapshot_baseline(&project_id, &session.user_id)?; let scan = scan_project_snapshot_files(project_root, PROJECT_SNAPSHOT_MAX_FILE_BYTES)?; // 项目总量上限在本地先判:超限时明确失败,不再逐个文件上传后被服务端整体拒绝。 @@ -362,8 +362,7 @@ async fn sync_project_snapshot_async( upload_project_snapshot_manifest(&session, &payload) .await .map_err(|error| error.message())?; - write_project_snapshot_index(&ProjectSnapshotIndex { - schema_version: PROJECT_SNAPSHOT_INDEX_SCHEMA_VERSION, + write_project_snapshot_baseline(&ProjectSnapshotBaseline { project_id: project_id.clone(), user_id: session.user_id.clone(), sync_revision: next_revision, @@ -447,7 +446,7 @@ fn project_snapshot_pending_file_count( } fn project_snapshot_manifest_needs_sync( - previous: &ProjectSnapshotIndex, + previous: &ProjectSnapshotBaseline, diff: &ProjectSnapshotDiff, project_name: &Option, pending_files: u32, @@ -516,9 +515,14 @@ pub(crate) struct ProjectSnapshotStateView { project_id: String, index_path: String, index_present: bool, + /// 当前登录账号的基线文件数;没有该账号的基线时为 0。 file_count: usize, sync_revision: u64, synced_at_ms: u64, + /// 索引文件里已保存基线的账号数;>1 说明本机在这台机器上用过多个账号。 + baseline_count: usize, + /// 当前登录账号在本项目上是否已有基线。没有基线的下一次同步必然全量上传。 + baseline_present: bool, enabled: bool, } @@ -547,13 +551,19 @@ pub(crate) fn read_local_project_snapshot_state( let project_id = validate_project_snapshot_project_id(manifest.project_id.trim())?; let index_path = project_snapshot_index_path(&project_id)?; let index = read_project_snapshot_index(&project_id)?; + let user_id = current_platform_session() + .map(|session| session.user_id) + .unwrap_or_default(); + let baseline = index.baseline_for(&user_id); Ok(ProjectSnapshotStateView { project_id, index_path: index_path.to_string_lossy().into_owned(), index_present: index_path.exists(), - file_count: index.files.len(), - sync_revision: index.sync_revision, - synced_at_ms: index.synced_at_ms, + file_count: baseline.map_or(0, |baseline| baseline.files.len()), + sync_revision: baseline.map_or(0, |baseline| baseline.sync_revision), + synced_at_ms: baseline.map_or(0, |baseline| baseline.synced_at_ms), + baseline_count: index.baselines.len(), + baseline_present: baseline.is_some(), enabled: project_snapshot_sync_enabled(), }) } diff --git a/apps/ai-game-creator-shell/src-tauri/src/project_snapshot/tests.rs b/apps/ai-game-creator-shell/src-tauri/src/project_snapshot/tests.rs index 73a7209c2..9e6637e27 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/project_snapshot/tests.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/project_snapshot/tests.rs @@ -57,15 +57,19 @@ fn project_snapshot_index_round_trips_and_recovers_from_corruption() { let root = fixture_root(); let directory = project_snapshot_index_directory_at(root.path(), "project-1").expect("index directory"); - assert!(read_project_snapshot_index_at(&directory, "project-1") - .expect("missing index reads as empty") - .files - .is_empty()); + assert!( + read_project_snapshot_baseline_at(&directory, "project-1", "user-1") + .expect("missing index reads as empty") + .files + .is_empty() + ); - let mut index = empty_project_snapshot_index("project-1", "user-1"); - index.sync_revision = 3; - index.synced_at_ms = 1_700_000_000_000; - index.files.insert( + let mut baseline = empty_project_snapshot_baseline("project-1", "user-1"); + baseline.sync_revision = 3; + baseline.synced_at_ms = 1_700_000_000_000; + baseline.project_name = Some("完整工程".to_string()); + baseline.pending_files = Some(0); + baseline.files.insert( "game/index.html".to_string(), ProjectSnapshotIndexedFile { size_bytes: 13, @@ -73,23 +77,107 @@ fn project_snapshot_index_round_trips_and_recovers_from_corruption() { checksum: "fnv1a64:0000000000000001".to_string(), }, ); - write_project_snapshot_index_at(&directory, &index).expect("write index"); + write_project_snapshot_baseline_at(&directory, &baseline).expect("write baseline"); assert_eq!( - read_project_snapshot_index_at(&directory, "project-1").expect("read index"), - index + read_project_snapshot_baseline_at(&directory, "project-1", "user-1").expect("read index"), + baseline ); let path = project_snapshot_index_path_at(&directory); fs::write(&path, b"{ not json").expect("corrupt index"); assert!( - read_project_snapshot_index_at(&directory, "project-1") + read_project_snapshot_baseline_at(&directory, "project-1", "user-1") .expect("corrupt index reads as empty") .files .is_empty(), - "损坏索引必须退化为空索引,让下一次同步全量重算" + "损坏索引必须退化为空基线,让下一次同步全量重算" ); } +#[test] +fn project_snapshot_index_keeps_each_account_baseline_after_account_switch() { + let root = fixture_root(); + let directory = + project_snapshot_index_directory_at(root.path(), "project-1").expect("index directory"); + + let mut first = empty_project_snapshot_baseline("project-1", "user-1"); + first.sync_revision = 4; + first.files.insert( + "game/index.html".to_string(), + ProjectSnapshotIndexedFile { + size_bytes: 13, + modified_ms: 34, + checksum: "fnv1a64:0000000000000001".to_string(), + }, + ); + write_project_snapshot_baseline_at(&directory, &first).expect("write first account baseline"); + + let mut second = empty_project_snapshot_baseline("project-1", "user-2"); + second.sync_revision = 1; + second.files.insert( + "game/other.html".to_string(), + ProjectSnapshotIndexedFile { + size_bytes: 21, + modified_ms: 98, + checksum: "fnv1a64:0000000000000002".to_string(), + }, + ); + write_project_snapshot_baseline_at(&directory, &second).expect("write second account baseline"); + + assert_eq!( + read_project_snapshot_baseline_at(&directory, "project-1", "user-1") + .expect("first account"), + first, + "另一个账号同步过后,本账号基线必须原样保留" + ); + assert_eq!( + read_project_snapshot_baseline_at(&directory, "project-1", "user-2") + .expect("second account"), + second + ); + let index = read_project_snapshot_index_at(&directory, "project-1").expect("read index"); + assert_eq!(index.baselines.len(), 2); +} + +#[test] +fn project_snapshot_account_switch_back_does_not_reupload_the_project() { + let root = fixture_root(); + write_fixture_file(root.path(), "game/index.html", b""); + let scan = scan_fixture(root.path()); + let initial = + compute_project_snapshot_diff(&scan, &BTreeMap::new(), PROJECT_SNAPSHOT_MAX_SYNC_BYTES) + .expect("initial diff"); + assert_eq!(initial.uploads.len(), 1); + + let directory = + project_snapshot_index_directory_at(root.path(), "project-1").expect("index directory"); + let mut first = empty_project_snapshot_baseline("project-1", "user-1"); + first.files = initial.current.clone(); + write_project_snapshot_baseline_at(&directory, &first).expect("write first account baseline"); + + // 换到尚未同步过的账号:没有基线,必然全量上传,这是预期行为。 + let mut second = read_project_snapshot_baseline_at(&directory, "project-1", "user-2") + .expect("unknown account reads as empty baseline"); + let switched = + compute_project_snapshot_diff(&scan, &second.files, PROJECT_SNAPSHOT_MAX_SYNC_BYTES) + .expect("diff under the other account"); + assert_eq!(switched.uploads.len(), 1); + second.files = switched.current; + write_project_snapshot_baseline_at(&directory, &second).expect("write second account baseline"); + + // 切回原账号:基线仍在,不能再传一遍整个工程(issue #504 的回归点)。 + let back = read_project_snapshot_baseline_at(&directory, "project-1", "user-1") + .expect("first account baseline survives"); + let reverted = + compute_project_snapshot_diff(&scan, &back.files, PROJECT_SNAPSHOT_MAX_SYNC_BYTES) + .expect("diff after switching back"); + assert!( + reverted.uploads.is_empty(), + "切回旧账号不能重新整项目重传:{reverted:#?}" + ); + assert!(!reverted.has_changes()); +} + #[test] fn project_snapshot_scan_skips_excluded_paths_and_oversized_files() { let root = fixture_root(); @@ -711,7 +799,7 @@ fn project_snapshot_manifest_metadata_changes_sync_without_content_changes() { ) .unwrap(); assert_eq!(initial.uploads[0].size_bytes, 0); - let mut previous = empty_project_snapshot_index("project-1", "user-1"); + let mut previous = empty_project_snapshot_baseline("project-1", "user-1"); previous.files = initial.current; let mut unchanged = compute_project_snapshot_diff( &scan_fixture(root.path()), @@ -786,14 +874,45 @@ fn project_snapshot_pending_files_include_every_unsynced_path_once() { } #[test] -fn project_snapshot_legacy_index_keeps_completeness_unknown() { - let index: ProjectSnapshotIndex = serde_json::from_value(serde_json::json!({ - "schemaVersion": 1, "projectId": "project-1", "userId": "user-1", - "syncRevision": 1, "syncedAtMs": 1, "files": {} - })) - .unwrap(); - assert_eq!(index.project_name, None); - assert_eq!(index.pending_files, None); +fn project_snapshot_legacy_index_migrates_into_its_account_bucket() { + let root = fixture_root(); + let directory = + project_snapshot_index_directory_at(root.path(), "project-1").expect("index directory"); + fs::create_dir_all(&directory).expect("create index directory"); + fs::write( + project_snapshot_index_path_at(&directory), + serde_json::json!({ + "schemaVersion": 1, + "projectId": "project-1", + "userId": "user-1", + "syncRevision": 1, + "syncedAtMs": 1, + "files": { + "game/index.html": { + "sizeBytes": 13, + "modifiedMs": 34, + "checksum": "fnv1a64:0000000000000001" + } + } + }) + .to_string(), + ) + .expect("write legacy index"); + + let baseline = read_project_snapshot_baseline_at(&directory, "project-1", "user-1") + .expect("legacy index migrates"); + assert_eq!(baseline.sync_revision, 1); + assert_eq!(baseline.files.len(), 1); + // v1 没有完整性声明:迁移后仍然是"完整性未知",不能凭空补成已完成。 + assert_eq!(baseline.project_name, None); + assert_eq!(baseline.pending_files, None); + assert!( + read_project_snapshot_baseline_at(&directory, "project-1", "user-2") + .expect("other account reads as empty") + .files + .is_empty(), + "迁移只能归属到 v1 记录的账号,不能送给其它账号" + ); } /// 真实链路冒烟:客户端差异引擎 → 本地 api-server → 真实 OSS。 diff --git a/docs/project-memory/plans/【里程碑】AGC项目定时快照上传-2026-09-17.md b/docs/project-memory/plans/【里程碑】AGC项目定时快照上传-2026-09-17.md index bc4889cda..e3d046bea 100644 --- a/docs/project-memory/plans/【里程碑】AGC项目定时快照上传-2026-09-17.md +++ b/docs/project-memory/plans/【里程碑】AGC项目定时快照上传-2026-09-17.md @@ -16,7 +16,7 @@ AGC 在项目打开期间按周期把用户项目增量上传到 OSS `agc-dev` - 服务端 `POST /api/agc/project-snapshots/files` 与 `POST /api/agc/project-snapshots/manifest`:登录态鉴权、参数校验、私有前缀 OSS 写入、HEAD 幂等跳过。 - 目标 bucket 配置:`GENARRATIVE_AGC_PROJECT_SNAPSHOT_OSS_*`,默认 `agc-dev`。 - 契约:`shared-contracts::agc_project_snapshots` 新增请求/响应 DTO 与项目 ID、相对路径、摘要校验函数。 -- 客户端增量索引:`/project-snapshots//index.json`,按用户身份判等,换号后按冷启动全量重算。 +- 客户端增量索引:`/project-snapshots//index.json`,2026-09-24 起为 schema v2,按账号分桶保存各账号基线;换号只让该账号自己没有基线(首次同步该项目才全量),切回旧账号不再重传。v1 单账号索引按它记录的 `userId` 迁移。 - 排除口径:快照同步使用 `should_skip_project_snapshot_sync_path`(2026-09-22 起)。`.agent` 承载项目身份与 Agent 状态,整目录同步;`.git`、构建与依赖目录、凭据目录、敏感后缀、符号链接与重解析点仍然排除。项目索引、checkpoint、Agent 上下文与 git 检查继续沿用 `should_skip_project_snapshot_path` 的整个 `.agent` 排除口径。 ## 不做 @@ -41,6 +41,7 @@ AGC 在项目打开期间按周期把用户项目增量上传到 OSS `agc-dev` 10. 清单写入成功后,上一版清单里不再被引用的对象被回收;上一版清单不可读时整轮不删除任何对象。 11. 单项目超过 2 GiB 时客户端明确失败、服务端按 413 拒绝;超过服务端小时配额或 5 秒最小间隔时返回 429 且带 `Retry-After`。 12. 同步期间被改写的文件既不上传也不推进索引,沿用上一轮记录,且不会被误判成删除。 +13. 账号 A 同步后切到 B 再切回 A,A 的差异集合为空(不重传);B 首次同步该项目仍全量上传;索引在换号后同时保留两个账号的基线。 ## 依赖 diff --git a/docs/project-memory/shared-memory/decision-log.md b/docs/project-memory/shared-memory/decision-log.md index 5838a77f4..33432924b 100644 --- a/docs/project-memory/shared-memory/decision-log.md +++ b/docs/project-memory/shared-memory/decision-log.md @@ -9329,3 +9329,14 @@ CI 上 `background_agent_runtime_recovers_stale_running_before_pending_task` 在 - 影响面:`server-rs/crates/api-server/src/{config.rs,modules/game_distribution.rs}`、`server-rs/crates/shared-contracts/src/game_distribution.rs`、`packages/shared/src/contracts/gameDistribution.ts`、`src/components/game-distribution/gameDistributionGuards.ts`(含新增测试)、`deploy/{nginx,container,env}`、`scripts/check-game-distribution-media-e2e.mjs`、`package.json`、平台与运维主规范。 - 边界:SpacetimeDB 表结构与公开契约字段不变(`entryUrl` 仍是 string),只是取值从绝对 URL 变为相对路径;历史版本已冻结的绝对值不改写,admin 页与详情页展示口径不变。线上 dev / release 的 nginx 已按同源路径改动并 reload,`/etc/genarrative/api-server.env` 已删除模板变量;api-server 未重启,新写入要等下次重启。 - 验证:`cargo check -p api-server --tests`、`cargo test -p api-server game_distribution`(27 passed)、`cargo fmt --all --check`、`npx vitest run src/components/game-distribution`(57 passed)、`npm run check:nginx-spa-routes`、`npm run check:encoding`(5060 文件)、`npm run check:doc-index`、`git diff --check` 全部通过;三份 nginx 模板渲染后 `nginx -t` 语法通过;dev 线上实测 `/games/game_2dcd…4955/` 与 `./assets/index-2Ws3zHlS.js` 均 200。 + +## 2026-09-24 AGC 项目快照索引按账号分桶:换号不再整项目重传 + +- 背景:AGC 客户端在项目打开期间把本地工程增量上传到 OSS,判定依据是 `/project-snapshots//index.json`。该文件整份只保存一个账号的基线,同步前用 `previous.user_id == session.user_id` 判等,不等就换成空基线;而远端对象键第一段正是 `userId`,于是换号被等价成「本机没有基线」。issue #504 的真机复现(CDP attach dev 客户端 + 点「春卷冲刺」):账号 A 留下的基线与当前账号不匹配 → 一次性重传 2311 个文件,约 3 分钟、2311 次 `POST /api/agc/project-snapshots/files`,其中 2306 次服务端 HEAD 命中纯白跑;同窗口内 `/api/runtime/frontend-config`、`/api/llm/models`、`/api/profile/recharge-center` 在服务端 200 且 ≤61ms 的情况下被客户端报 15s 超时(同进程 IPC 被重传压垮),并触发 #490 的「检查失败」钉死。本机 6 个索引文件里有 4 个不同 `userId`,09-23 19:47、09-23 20:47、09-24 10:57 三次同形态全量重传。 +- 决策:索引文件升级为 schema v2,结构改为 `baselines: { userId → 该账号基线 }`;同步只读当前登录账号的桶,写回也只替换自己那一个桶,其它账号的基线原样保留。基线字段与 v1 一致(`syncRevision` / `syncedAtMs` / `projectName` / `pendingFiles` / `files`)。某账号在本机首次同步该项目仍然全量上传,这是必要行为而不是漏洞。 +- 决策(迁移):读取时按 JSON 值判定版本,v1 文件按其记录的 `userId` 迁移进对应桶(`userId` 为空或 `projectId` 不符时丢弃),格式升级本身不产生额外重传;v1 缺少的 `projectName` / `pendingFiles` 迁移后仍表示「完整性未知」,不伪造已完成。文件保持 v1 形态直到下一次成功同步写回 v2。 +- 决策(诊断):`read_local_project_snapshot_state` 增补 `baselineCount` 与 `baselinePresent`,`fileCount` / `syncRevision` / `syncedAtMs` 改为当前登录账号的基线口径——此前这三项会把另一个账号的基线报成本账号的。 +- 影响面:`apps/ai-game-creator-shell/src-tauri/src/project_snapshot/{index.rs,mod.rs,tests.rs}`、`docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md`、`docs/project-memory/plans/【里程碑】AGC项目定时快照上传-2026-09-17.md`、本文件与 pitfalls。 +- 边界:不新增路由、不改服务端、不改公开契约;远端对象键与清单结构不变;索引仍是 AppData 私有文件、不进项目目录;旧客户端读到 v2 文件会判「版本不符」→ 该账号一次全量,属可接受回退代价。 +- 未覆盖:一次「项目 + 新账号」的新组合仍会把项目全量发给服务端(服务端逐个 HEAD 跳过);要彻底消掉这批白跑请求,需要客户端可读的远端清单或批量存在性探测,另开事项。 +- 验证:`cargo test --manifest-path apps/ai-game-creator-shell/src-tauri/Cargo.toml project_snapshot`(含「账号切换后基线互不覆盖」「切回旧账号差异为空」「v1 索引迁移进对应账号桶」三条新增/改写用例)、`cargo fmt --manifest-path apps/ai-game-creator-shell/src-tauri/Cargo.toml -- --check`、`npm run check:encoding`、`git diff --check`。 diff --git a/docs/project-memory/shared-memory/pitfalls.md b/docs/project-memory/shared-memory/pitfalls.md index 2efa4c511..8c73f5be0 100644 --- a/docs/project-memory/shared-memory/pitfalls.md +++ b/docs/project-memory/shared-memory/pitfalls.md @@ -5947,3 +5947,12 @@ Cocos Creator 根目录由 `package.json.creator.version` 与普通 `assets/` - **处理(现行口径)**:`createChannelConfig()` 从基线 `src-tauri/tauri.conf.json` 读完整 client 窗口对象后展开、只覆盖 `title`(`readBaseClientWindow()`),渠道配置不得再出现"只写 `title`"的窗口对象。新增守卫:`build-release.test.mjs` 用同语义的 merge patch 复现 Tauri 合并并断言 `label=client` / `decorations=false` / 1280x800 / min 1280x720 且承载 `http:default` 的 capability 必须包含该 label;`check-config.mjs` 增补基线 `decorations !== false` 失败关闭。 - **验证**:`node --test apps/ai-game-creator-shell/scripts/build-release.test.mjs scripts/cargo-features.test.mjs scripts/release-oss.test.mjs scripts/prepare-macos-codex.test.mjs`(60/60)、`node apps/ai-game-creator-shell/scripts/check-config.mjs` 通过;`createChannelConfig('dev', …)` 实测输出含 `label: client` 与 `decorations: false`。修复后的安装包尚未重新构建与安装,真机观感与登录链未复核。 - **关联**:`apps/ai-game-creator-shell/scripts/build-release.mjs`、`apps/ai-game-creator-shell/scripts/build-release.test.mjs`、`apps/ai-game-creator-shell/scripts/check-config.mjs`、`apps/ai-game-creator-shell/src-tauri/capabilities/main.json`、`docs/technical/【技术方案】AGC客户端更新检查与下载-2026-08-31.md`。 + +## 2026-09-24 AGC 换号后整项目快照重传:后端日志刷屏 + 其它接口 15s 超时 + +- **现象**:打开 AGC 项目后客户端短时间无响应,`/api/runtime/frontend-config`、`/api/llm/models`、`/api/profile/recharge-center` 报「请求超时(15000 ms)」;同时本地 api-server 日志被 `POST /api/agc/project-snapshots/files` 刷屏(全是 200、`latency_ms` 58~61ms、每 60ms 一条)。紧接着项目列表还会被钉成「检查失败」(#490)。 +- **原因**:本地快照索引整份只保存一个账号的基线,同步前按 `user_id` 判等,不等就换成空基线 → 换号后打开项目即整项目重传(春卷冲刺 2311 个文件、约 3 分钟)。对象键带内容摘要、服务端 HEAD 命中即 `200 + skipped`,这批请求全是白跑却完全静默,只有服务端日志量能看出来。真正超时的那一层在客户端:同一窗口服务端对三个接口都是 200、≤61ms,而客户端 webview 的 Tauri 定制协议 IPC 在重传压力下大面积失败(`IPC custom protocol failed … TypeError: Failed to fetch`,刷屏时 ≈0.7 次/秒,而空闲时段 847 行日志里只有 59 次;每次失败还写一行 ~330 字节日志),响应回不到 renderer,JS 侧 15s 计时器先到。 +- **处理(现行口径)**:索引升级为按账号分桶(schema v2,见 decision-log 2026-09-24「AGC 项目快照索引按账号分桶」),换号只让该账号自己没有基线,切回旧账号不再重传。 +- **排障口径**:遇到「后端日志刷屏 + 客户端报超时」这种组合,先把两侧对齐到同一时间窗:客户端 `diagnostics/application.log` 的 `project_snapshot.sync.*`(看 `uploaded=` 是否等于项目文件数、`skippedRemote=` 是否接近它)对 `logs/api-server/api-server-*.log` 里各 route 的 `http.response`(状态码与 `latency_ms`)。客户端报超时但服务端 200 且毫秒级返回,说明堵在客户端侧,别只盯后端。 +- **验证**:`cargo test --manifest-path apps/ai-game-creator-shell/src-tauri/Cargo.toml project_snapshot` 里「账号切换后基线互不覆盖」「切回旧账号差异为空」两条用例;运行态可用 `read_local_project_snapshot_state` 看 `baselineCount` / `baselinePresent`(换号前后各持自己的基线)。 +- **关联**:issue #504、issue #490、`apps/ai-game-creator-shell/src-tauri/src/project_snapshot/index.rs`、`docs/project-memory/shared-memory/decision-log.md`(2026-09-24 同名条目)。 diff --git a/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md b/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md index 42567d8ca..abc488c4b 100644 --- a/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md +++ b/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md @@ -1642,7 +1642,7 @@ Direct 回合的所有权属于进程内项目身份锁,不属于当前页面 - 触发入口为项目生命周期登记、已登记项目的周期定时器及窗口关闭事件(`CloseRequested`)。它们共用同一个进程内同步器,同一项目的同步串行执行,周期触发在已有同步进行时直接让位,不排队堆积。 - 应用退出(`RunEvent::Exit`)不重复发起同步;退出路径只负责在有界预算(15 秒)内等待在途同步收尾,让关窗触发的那一次同步有机会写完索引再退出。超过预算不能声明最后状态已经上传。 - 客户端扫描、差异对比、索引持久化与上传编排都在 Tauri Rust 进程(`src-tauri/src/project_snapshot/`);WebView 只读状态,不参与差异计算。 -- 本地索引是增量对比的唯一依据:`/project-snapshots//index.json` 保存上次成功同步的相对路径、校验和、字节数和修改时间。项目根使用现有 manifest 的稳定 `project_id` 作为远端身份,路径不再作为身份。 +- 本地索引是增量对比的唯一依据:`/project-snapshots//index.json`(schema v2)**按账号分桶**保存各账号上次成功同步的相对路径、校验和、字节数和修改时间。远端对象键第一段就是 `userId`,所以基线只对写入它的账号成立——换号不会再让别的账号的基线失效,也不会被别的账号覆盖;只有该账号在本机确实没有基线(首次同步该项目)时才全量上传。v1 的单账号索引在读取时按它记录的 `userId` 迁移进对应桶,格式升级本身不额外触发一次重传。项目根使用现有 manifest 的稳定 `project_id` 作为远端身份,路径不再作为身份。 - 可观测性按产品口径收敛到本机日志:同步结果、失败分类、延后与跳过计数只写入 AppData 诊断日志(`project_snapshot.sync.*` 前缀),客户端界面不暴露上传状态、时间线或入口按钮。`read_local_project_snapshot_state` 与 `sync_local_project_snapshot` 两条命令仅作为 native-only 的排障与联调入口登记,不在渲染层调用。 - 远端写入经 `api-server`,客户端只持平台登录态 Access Token。两条登录态路由:`POST /api/agc/project-snapshots/files`(单文件,正文为原始字节,元数据走查询串)与 `POST /api/agc/project-snapshots/manifest`(本次同步后的完整清单)。 - 对象键与清单由服务端决定:文件键为 `agc/project-snapshots/v2/{channel}/{userId}/{projectId}/files/{sizeBytes}-{checksumDigest}/{relPath}`,清单键为 `agc/project-snapshots/v2/{channel}/{userId}/{projectId}/manifest.json`;`channel` 是本部署渠道(`GENARRATIVE_AGC_PROJECT_SNAPSHOT_CHANNEL`,缺省沿用 `GENARRATIVE_CLIENT_DOWNLOAD_CHANNEL`),同一 bucket 因此天然按渠道分区,开发与正式部署互不可见对方项目。键里带字节数与摘要,因此"对象已存在且长度一致"可以作为内容一致的判据;路径按原始大小写保留,不走 `put_object` 的低位规范化。`agc` 前缀(含历史无渠道的 `agc/project-snapshots/v1/`)继续是服务端专用私有前缀,通用对象键解析与客户端直传票据都不覆盖它。后台“项目工程”按渠道查询与下载,渠道名非法时失败关闭,历史 v1 对象不再列出。 @@ -1671,6 +1671,7 @@ Direct 回合的所有权属于进程内项目身份锁,不属于当前页面 ### 验收标准与证据来源 - 定向 Rust 测试:首次同步全量、仅改一个文件时只产生一个修改项、删除文件只体现在清单、`(size,mtime)` 未变时复用旧摘要、排除规则与上限跳过、同步失败不推进索引、同一项目并发触发串行化。 +- 换号与切换:账号 A 同步过项目后切到账号 B 并同步,再切回 A 时差异集合为空(不上传任何文件,见 issue #504);B 首次同步该项目仍按全量上传;索引文件同时保留两个账号的基线,v1 单账号索引迁移进它自己记录的账号桶。 - 服务端测试:越界 `projectId`/相对路径/摘要被拒;相同摘要重复提交走跳过分支;超过单项目上限返回 413;超过用户小时配额返回 429;鉴权缺失返回 401;OSS 未配置返回明确的 5xx 而不是写入空对象。 - 运行时 smoke:AGC 开发态打开项目、观察索引写入与同步日志、关闭工作区窗口后确认关闭触发的那次同步执行;报告为"客户端 diff 已验证 / 服务端已配置环境联调"两层,不合并成一句"已通"。 - 边界:新增日志与错误文案不含 Access Token、AccessKey、绝对路径与项目内容。