修复 AGC 换号后整项目快照重传导致的日志刷屏与客户端超时
Project CI / AI game creator shell Rust crates (pull_request) Successful in 1m20s
Project CI / AI game creator shell Rust smoke (pull_request) Successful in 1m57s
Project CI / Backend tests (pull_request) Successful in 4m52s
Project CI / Native shell tests (pull_request) Successful in 5m59s
Project CI / Frontend tests (pull_request) Successful in 1m56s
Project CI / AI game creator shell Rust lane 1/2 (pull_request) Successful in 8m46s
Project CI / AI game creator shell Rust lane 2/2 (pull_request) Successful in 9m0s
Project CI / AI game creator shell web tests (pull_request) Successful in 1m24s
Project CI / Repository checks (pull_request) Successful in 1m50s
Project CI / AI game creator shell Rust crates (pull_request) Successful in 1m20s
Project CI / AI game creator shell Rust smoke (pull_request) Successful in 1m57s
Project CI / Backend tests (pull_request) Successful in 4m52s
Project CI / Native shell tests (pull_request) Successful in 5m59s
Project CI / Frontend tests (pull_request) Successful in 1m56s
Project CI / AI game creator shell Rust lane 1/2 (pull_request) Successful in 8m46s
Project CI / AI game creator shell Rust lane 2/2 (pull_request) Successful in 9m0s
Project CI / AI game creator shell web tests (pull_request) Successful in 1m24s
Project CI / Repository checks (pull_request) Successful in 1m50s
- 项目快照索引升级为 schema v2:按账号分桶保存基线,换号不再让其它账号的基线失效,也不会被其它账号覆盖 - 同步只读取并写回当前登录账号的基线,其它账号的基线原样保留 - 读取时按 JSON 版本判定并迁移 v1 单账号索引到它自己记录的 userId 桶,格式升级不额外触发一次重传 - read_local_project_snapshot_state 增补 baselineCount / baselinePresent,fileCount / syncRevision / syncedAtMs 改为当前登录账号的基线口径 - 新增与改写用例:账号切换后基线互不覆盖、切回旧账号差异为空、v1 索引迁移进对应账号桶 - 同步更新快照主规范、快照里程碑文档、shared-memory decision-log 与 pitfalls
This commit is contained in:
@@ -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<String, ProjectSnapshotIndexedFile>,
|
||||
}
|
||||
|
||||
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<String, ProjectSnapshotBaseline>,
|
||||
}
|
||||
|
||||
/// 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<String>,
|
||||
#[serde(default)]
|
||||
pending_files: Option<u32>,
|
||||
#[serde(default)]
|
||||
files: BTreeMap<String, ProjectSnapshotIndexedFile>,
|
||||
}
|
||||
|
||||
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<ProjectSnapshotIndex, String> {
|
||||
@@ -93,7 +139,7 @@ pub(crate) fn read_project_snapshot_index_at(
|
||||
) -> Result<ProjectSnapshotIndex, String> {
|
||||
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::<ProjectSnapshotIndex>(&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::<serde_json::Value>(&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::<ProjectSnapshotIndex>(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::<LegacyProjectSnapshotIndexV1>(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<ProjectSnapshotBaseline, String> {
|
||||
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<ProjectSnapshotBaseline, String> {
|
||||
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> {
|
||||
|
||||
@@ -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<String>,
|
||||
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(),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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"<html></html>");
|
||||
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。
|
||||
|
||||
Reference in New Issue
Block a user