mirror of
https://github.com/Yuyi-Oak/BlueArchiveToolkit.git
synced 2026-07-22 04:06:44 +08:00
feat: track official versions and import resources
Persist official version-state, extend CAS/ResourceRepository import classification, and add offline catalog and failure regression fixtures. Fixes #13 Fixes #12 Fixes #8
This commit is contained in:
@@ -26,6 +26,9 @@
|
|||||||
- 新增真实官方网络全量拉取 smoke:`scripts/official-full-pull-smoke.sh`、`make official-smoke` 和 `docs/guides/official-full-pull-smoke.md`
|
- 新增真实官方网络全量拉取 smoke:`scripts/official-full-pull-smoke.sh`、`make official-smoke` 和 `docs/guides/official-full-pull-smoke.md`
|
||||||
- 新增官方资源下载校验:官方 URL 拒绝、`.part` 续传、重试、本地 size+BLAKE3、官方 seed `.hash` 校验
|
- 新增官方资源下载校验:官方 URL 拒绝、`.part` 续传、重试、本地 size+BLAKE3、官方 seed `.hash` 校验
|
||||||
- 新增下载失败分类与 quarantine:403/404/普通 4xx 不重试,5xx/429/网络类错误重试,最终失败写入 `official-download-quarantine.json` 并阻止发布不完整资源;旧 launcher 包支持官方 primary/backup CDN 切换
|
- 新增下载失败分类与 quarantine:403/404/普通 4xx 不重试,5xx/429/网络类错误重试,最终失败写入 `official-download-quarantine.json` 并阻止发布不完整资源;旧 launcher 包支持官方 primary/backup CDN 切换
|
||||||
|
- 新增官方版本状态管理:`official-version-state.json` 持久化当前已完成版本、正在拉取版本、上一个可用版本和失败版本,并在 `bat status` 中暴露状态摘要
|
||||||
|
- 优化资源导入链路:CAS 导入会写入 `ResourceRepository`,AssetBundle 记录 UnityFS 摘要,TextAsset/Table/Media 按资源类型分类并可被索引
|
||||||
|
- 新增离线回归 fixture:当前 catalog、上一个版本 catalog、catalog 结构变化、403、404 和官方 seed hash mismatch 样本
|
||||||
- 新增 Addressables 当前真实形态 fixture/golden 测试
|
- 新增 Addressables 当前真实形态 fixture/golden 测试
|
||||||
- 新增 SQLite Resource Repository 和粗粒度 FFI JSON 接口
|
- 新增 SQLite Resource Repository 和粗粒度 FFI JSON 接口
|
||||||
- 补齐官方资源运行、架构、状态、缺口和交接文档
|
- 补齐官方资源运行、架构、状态、缺口和交接文档
|
||||||
|
|||||||
+6
-4
@@ -1,6 +1,6 @@
|
|||||||
# BlueArchiveToolkit 当前工作区状态
|
# BlueArchiveToolkit 当前工作区状态
|
||||||
|
|
||||||
- **更新时间**:2026-07-14
|
- **更新时间**:2026-07-15
|
||||||
- **状态来源**:本地工作区盘点、代码验证和最新提交
|
- **状态来源**:本地工作区盘点、代码验证和最新提交
|
||||||
- **状态分支**:`experiment`
|
- **状态分支**:`experiment`
|
||||||
- **最新已推送功能提交**:以当前 `git log --oneline -1` 为准
|
- **最新已推送功能提交**:以当前 `git log --oneline -1` 为准
|
||||||
@@ -24,7 +24,8 @@ Rust 侧官方日服资源链路已经从实验验证推进到正式入口:
|
|||||||
8. 远端 snapshot 未变化但输出目录为空时,会按首次运行执行全量拉取;官方 seed `.hash` 校验失败时会清理对应 manifest 条目,避免失败产物被后续本地 audit 误判为可复用。
|
8. 远端 snapshot 未变化但输出目录为空时,会按首次运行执行全量拉取;官方 seed `.hash` 校验失败时会清理对应 manifest 条目,避免失败产物被后续本地 audit 误判为可复用。
|
||||||
9. 默认资源目录是 `./bat-resources`,默认后台状态目录是 `/tmp/bat-pid`;资源目录是发布根目录,包含 `current` symlink、`versions/<id>` 和 `.staging/<id>`,非 dry-run 会先写 staging,校验完成后发布 versioned 目录并原子切换 `current`;后台状态目录包含 `bat.sock`、`bat.pid`、`bat-status.json`、`bat-daemon.log`、`bat-events.jsonl` 和短生命周期 `bat-control.lock`;非 dry-run 使用 `--output/.official-sync.lock` 防止并发写同一资源目录,live daemon 会阻止前台写命令直接修改它正在管理的同一目录。
|
9. 默认资源目录是 `./bat-resources`,默认后台状态目录是 `/tmp/bat-pid`;资源目录是发布根目录,包含 `current` symlink、`versions/<id>` 和 `.staging/<id>`,非 dry-run 会先写 staging,校验完成后发布 versioned 目录并原子切换 `current`;后台状态目录包含 `bat.sock`、`bat.pid`、`bat-status.json`、`bat-daemon.log`、`bat-events.jsonl` 和短生命周期 `bat-control.lock`;非 dry-run 使用 `--output/.official-sync.lock` 防止并发写同一资源目录,live daemon 会阻止前台写命令直接修改它正在管理的同一目录。
|
||||||
10. 官方同步会拒绝危险输出目录、路径逃逸和现有 symlink 路径组件;下载目标、`.part`、manifest、snapshot、PID、status、log 和控制锁文件不会跟随 symlink,daemon 状态类文件默认以 `0600` 权限创建。
|
10. 官方同步会拒绝危险输出目录、路径逃逸和现有 symlink 路径组件;下载目标、`.part`、manifest、snapshot、PID、status、log 和控制锁文件不会跟随 symlink,daemon 状态类文件默认以 `0600` 权限创建。
|
||||||
11. `bat status` 会显示最后成功时间、下次检查时间、最后错误摘要、当前阶段、当前下载 URL 进度、结构化日志路径和轮转日志路径。
|
11. `<output>/official-version-state.json` 会明确保存当前已完成版本、正在拉取版本、上一个可用版本和失败版本;`bat status` 会显示最后成功时间、下次检查时间、最后错误摘要、当前阶段、当前下载 URL 进度、版本状态摘要、结构化日志路径和轮转日志路径。
|
||||||
|
12. 资源导入链路已支持 CAS + `ResourceRepository` 索引写入,AssetBundle 导入会记录 UnityFS 摘要,TextAsset/Table/Media 会按类型分类;当前/上一个/结构变化 catalog、403/404、hash mismatch 均有离线回归 fixture。
|
||||||
|
|
||||||
仍需明确:这不是完整产品完成。Go CLI 最小入口、完整 AssetBundle 解析、Patch、翻译系统、API Server 和 Web 仍是后续工作;真实官方网络全量拉取 smoke 已固化为可重复脚本和 runbook,真实大文件产物与运行报告默认保存在 `/tmp` 隔离目录,不纳入 Git。
|
仍需明确:这不是完整产品完成。Go CLI 最小入口、完整 AssetBundle 解析、Patch、翻译系统、API Server 和 Web 仍是后续工作;真实官方网络全量拉取 smoke 已固化为可重复脚本和 runbook,真实大文件产物与运行报告默认保存在 `/tmp` 隔离目录,不纳入 Git。
|
||||||
|
|
||||||
@@ -88,12 +89,13 @@ Rust 侧官方日服资源链路已经从实验验证推进到正式入口:
|
|||||||
- Manifest driver trait、Addressables driver、注册表。
|
- Manifest driver trait、Addressables driver、注册表。
|
||||||
- Addressables JSON catalog 的 path、hash、size、address、dependencies、metadata 解析。
|
- Addressables JSON catalog 的 path、hash、size、address、dependencies、metadata 解析。
|
||||||
- 真实形态 Addressables fixture/golden 测试。
|
- 真实形态 Addressables fixture/golden 测试。
|
||||||
|
- 当前 catalog、上一个版本 catalog、结构变化 catalog 的离线回归 fixture。
|
||||||
- 官方日服 `server-info`、URL 规则、平台 discovery 和 inventory 枚举。
|
- 官方日服 `server-info`、URL 规则、平台 discovery 和 inventory 枚举。
|
||||||
|
|
||||||
待完成:
|
待完成:
|
||||||
|
|
||||||
- Unity bundle parse/serialize 仍是后续阶段能力。
|
- Unity bundle serialize 仍是后续阶段能力。
|
||||||
- Addressables parser 仍需继续覆盖更多官方 catalog 结构变体和失败诊断。
|
- Addressables parser 仍需继续覆盖二进制/压缩字段组合和更细失败诊断。
|
||||||
- 客户端发现、备份、应用补丁流程尚未连接真实实现。
|
- 客户端发现、备份、应用补丁流程尚未连接真实实现。
|
||||||
|
|
||||||
### `bat-cas-engine`
|
### `bat-cas-engine`
|
||||||
|
|||||||
@@ -12,8 +12,10 @@
|
|||||||
- `bat-core` 领域对象和仓储接口骨架。
|
- `bat-core` 领域对象和仓储接口骨架。
|
||||||
- `bat-adapters` Unity、Manifest、Client 集成框架,以及当前真实形态 Addressables catalog 解析覆盖。
|
- `bat-adapters` Unity、Manifest、Client 集成框架,以及当前真实形态 Addressables catalog 解析覆盖。
|
||||||
- `bat-cas-engine` CAS V1:原子写入、BLAKE3 校验、引用计数、GC、并发写入测试、损坏检测。
|
- `bat-cas-engine` CAS V1:原子写入、BLAKE3 校验、引用计数、GC、并发写入测试、损坏检测。
|
||||||
- `bat-infrastructure` CAS 适配层、SQLite Resource Repository、官方资源 pull/update 服务。
|
- `bat-infrastructure` CAS 适配层、SQLite Resource Repository、资源导入服务、官方资源 pull/update 服务。
|
||||||
- `bat`:官方资源自动发现、全量拉取、原子发布到 `current -> versions/<id>`、本地 manifest audit/repair、`.part` 断点续传、403/404/5xx 分类重试、下载 quarantine 诊断、ZIP 结构校验、官方 seed `.hash` 校验、snapshot/cache、`--watch` 常驻更新、`--daemon` 后台运行,以及 Unix socket JSON-RPC 后台控制命令 `status/stop/restart/reload/refresh/logs/verify/repair/doctor/clean-stable`。
|
- `bat`:官方资源自动发现、全量拉取、原子发布到 `current -> versions/<id>`、本地 manifest audit/repair、`.part` 断点续传、403/404/5xx 分类重试、下载 quarantine 诊断、ZIP 结构校验、官方 seed `.hash` 校验、snapshot/cache、`--watch` 常驻更新、`--daemon` 后台运行,以及 Unix socket JSON-RPC 后台控制命令 `status/stop/restart/reload/refresh/logs/verify/repair/doctor/clean-stable`。
|
||||||
|
- 官方同步会维护 `<output>/official-version-state.json`,明确记录当前已完成版本、正在拉取版本、上一个可用版本和失败版本。
|
||||||
|
- 资源导入链路可将 manifest 条目写入 CAS + `ResourceRepository`,AssetBundle 会记录 UnityFS 摘要,TextAsset/Table/Media 会按类型分类索引。
|
||||||
- `bat-ffi` 粗粒度 JSON 接口:Manifest inspect 和官方 sync plan。
|
- `bat-ffi` 粗粒度 JSON 接口:Manifest inspect 和官方 sync plan。
|
||||||
- 文档路线图、当前状态、缺口清单、官方资源运行指南。
|
- 文档路线图、当前状态、缺口清单、官方资源运行指南。
|
||||||
|
|
||||||
@@ -96,7 +98,7 @@ cargo run -p bat-infrastructure --bin bat -- stop
|
|||||||
|
|
||||||
`bat` 会拒绝危险输出目录、路径逃逸和现有 symlink 路径组件;下载目标、`.part`、manifest、snapshot、PID、status、log 和控制锁文件不会跟随 symlink,daemon 状态类文件默认以 `0600` 权限创建。
|
`bat` 会拒绝危险输出目录、路径逃逸和现有 symlink 路径组件;下载目标、`.part`、manifest、snapshot、PID、status、log 和控制锁文件不会跟随 symlink,daemon 状态类文件默认以 `0600` 权限创建。
|
||||||
|
|
||||||
非 dry-run 同步不会把新文件直接写进生产可读目录。资源会先下载到 `<output>/.staging/<id>`,完成 manifest、BLAKE3、ZIP 和官方 `.hash` 校验后移动到 `<output>/versions/<id>`,再原子切换 `<output>/current` symlink;生产读取方应只读取 `<output>/current`。
|
非 dry-run 同步不会把新文件直接写进生产可读目录。资源会先下载到 `<output>/.staging/<id>`,完成 manifest、BLAKE3、ZIP 和官方 `.hash` 校验后移动到 `<output>/versions/<id>`,再原子切换 `<output>/current` symlink;生产读取方应只读取 `<output>/current`。同步过程会更新 `<output>/official-version-state.json`:下载开始时写入 `in_progress_version`,发布成功后写入 `current_completed_version` 和 `previous_available_version`,失败或中断时写入 `failed_versions`。
|
||||||
|
|
||||||
资源操作命令默认输出人类可读摘要,并在没有显式 metadata 参数时默认走官方自动发现。脚本或上层程序需要稳定结构化输出时加 `--json`:
|
资源操作命令默认输出人类可读摘要,并在没有显式 metadata 参数时默认走官方自动发现。脚本或上层程序需要稳定结构化输出时加 `--json`:
|
||||||
|
|
||||||
|
|||||||
@@ -187,12 +187,32 @@ impl AddressablesCatalogDriver {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn resource_type_for_path(path: &str) -> ResourceType {
|
fn resource_type_for_path(path: &str) -> ResourceType {
|
||||||
if path.contains("TableBundles/") || path.ends_with(".bytes") {
|
let normalized = path.replace('\\', "/").to_ascii_lowercase();
|
||||||
ResourceType::TableBundle
|
if normalized.contains("catalog") || normalized.ends_with(".hash") {
|
||||||
} else if path.ends_with(".bundle") {
|
|
||||||
ResourceType::AssetBundle
|
|
||||||
} else if path.contains("catalog") {
|
|
||||||
ResourceType::Manifest
|
ResourceType::Manifest
|
||||||
|
} else if normalized.contains("tablebundles/") {
|
||||||
|
ResourceType::TableBundle
|
||||||
|
} else if normalized.contains("mediaresources/")
|
||||||
|
|| normalized.contains("mediaresources-")
|
||||||
|
|| matches!(
|
||||||
|
normalized.rsplit('.').next(),
|
||||||
|
Some(
|
||||||
|
"mp3" | "mp4" | "ogg" | "wav" | "png" | "jpg" | "jpeg" | "webp" | "acb" | "awb"
|
||||||
|
)
|
||||||
|
)
|
||||||
|
{
|
||||||
|
ResourceType::Media
|
||||||
|
} else if normalized.ends_with(".bundle") {
|
||||||
|
ResourceType::AssetBundle
|
||||||
|
} else if normalized.contains("textassets/")
|
||||||
|
|| matches!(
|
||||||
|
normalized.rsplit('.').next(),
|
||||||
|
Some("txt" | "csv" | "xml" | "yaml" | "yml")
|
||||||
|
)
|
||||||
|
{
|
||||||
|
ResourceType::TextAsset
|
||||||
|
} else if normalized.ends_with(".bytes") {
|
||||||
|
ResourceType::TableBundle
|
||||||
} else {
|
} else {
|
||||||
ResourceType::Other
|
ResourceType::Other
|
||||||
}
|
}
|
||||||
@@ -210,6 +230,16 @@ impl AddressablesCatalogDriver {
|
|||||||
return ResourceType::Manifest;
|
return ResourceType::Manifest;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if resource_type_name.is_some_and(|name| name.contains("TextAsset")) {
|
||||||
|
return ResourceType::TextAsset;
|
||||||
|
}
|
||||||
|
|
||||||
|
if resource_type_name.is_some_and(|name| {
|
||||||
|
name.contains("AudioClip") || name.contains("VideoClip") || name.contains("Texture2D")
|
||||||
|
}) {
|
||||||
|
return ResourceType::Media;
|
||||||
|
}
|
||||||
|
|
||||||
Self::resource_type_for_path(path)
|
Self::resource_type_for_path(path)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1017,4 +1047,24 @@ mod tests {
|
|||||||
ResourceType::TableBundle
|
ResourceType::TableBundle
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_parse_text_and_media_resource_types() {
|
||||||
|
let driver = AddressablesCatalogDriver::new();
|
||||||
|
|
||||||
|
let catalog_json = r#"{
|
||||||
|
"m_LocatorId": "AddressablesMainContentCatalog",
|
||||||
|
"m_Entries": [
|
||||||
|
{"internal_id": "TextAssets/dialogue.csv"},
|
||||||
|
{"internal_id": "MediaResources-Windows/voice/academy.acb"},
|
||||||
|
{"internal_id": "MediaResources-Windows/movie/opening.mp4"}
|
||||||
|
]
|
||||||
|
}"#;
|
||||||
|
|
||||||
|
let manifest = driver.parse(catalog_json.as_bytes()).await.unwrap();
|
||||||
|
|
||||||
|
assert_eq!(manifest.resources[0].resource_type, ResourceType::TextAsset);
|
||||||
|
assert_eq!(manifest.resources[1].resource_type, ResourceType::Media);
|
||||||
|
assert_eq!(manifest.resources[2].resource_type, ResourceType::Media);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -114,7 +114,7 @@ fn parse_obfuscated_game_main_config(bytes: &[u8]) -> Result<YostarJpGameMainCon
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn decrypt_inferred_ascii_json(bytes: &[u8]) -> Result<String, String> {
|
fn decrypt_inferred_ascii_json(bytes: &[u8]) -> Result<String, String> {
|
||||||
if bytes.len() % 2 != 0 {
|
if !bytes.len().is_multiple_of(2) {
|
||||||
return Err("Obfuscated GameMainConfig byte length is not UTF-16LE aligned".to_string());
|
return Err("Obfuscated GameMainConfig byte length is not UTF-16LE aligned".to_string());
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -288,7 +288,7 @@ fn xor_bytes(bytes: &mut [u8], key: &[u8]) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn utf16le_to_string(bytes: &[u8]) -> Result<String, String> {
|
fn utf16le_to_string(bytes: &[u8]) -> Result<String, String> {
|
||||||
if bytes.len() % 2 != 0 {
|
if !bytes.len().is_multiple_of(2) {
|
||||||
return Err("GameMainConfig decrypted byte length is not UTF-16LE aligned".into());
|
return Err("GameMainConfig decrypted byte length is not UTF-16LE aligned".into());
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -454,7 +454,7 @@ mod tests {
|
|||||||
|
|
||||||
fn encrypt_utf16le_xor(value: &str, key: &[u8]) -> Vec<u8> {
|
fn encrypt_utf16le_xor(value: &str, key: &[u8]) -> Vec<u8> {
|
||||||
let mut utf16 = utf16le_bytes(value);
|
let mut utf16 = utf16le_bytes(value);
|
||||||
xor_bytes(&mut utf16, &key);
|
xor_bytes(&mut utf16, key);
|
||||||
utf16
|
utf16
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -965,8 +965,8 @@ mod tests {
|
|||||||
};
|
};
|
||||||
|
|
||||||
let delta = current.diff(Some(&previous));
|
let delta = current.diff(Some(&previous));
|
||||||
assert!(delta.connection_group_changed == false);
|
assert!(!delta.connection_group_changed);
|
||||||
assert!(delta.app_version_changed == false);
|
assert!(!delta.app_version_changed);
|
||||||
assert!(delta.bundle_version_changed);
|
assert!(delta.bundle_version_changed);
|
||||||
assert!(delta.addressables_root_changed);
|
assert!(delta.addressables_root_changed);
|
||||||
assert!(delta.root_token_changed);
|
assert!(delta.root_token_changed);
|
||||||
|
|||||||
@@ -78,7 +78,7 @@ impl UnitySerializedFile {
|
|||||||
)?);
|
)?);
|
||||||
}
|
}
|
||||||
|
|
||||||
let big_id_enabled = if version >= 11 && version < 14 {
|
let big_id_enabled = if (11..14).contains(&version) {
|
||||||
reader.read_i32("big_id_enabled")?
|
reader.read_i32("big_id_enabled")?
|
||||||
} else {
|
} else {
|
||||||
0
|
0
|
||||||
|
|||||||
@@ -0,0 +1,68 @@
|
|||||||
|
use bat_adapters::manifest::ManifestDriverRegistry;
|
||||||
|
use bat_core::domain::ResourceType;
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn parses_current_catalog_fixture_with_resource_categories() {
|
||||||
|
let manifest = ManifestDriverRegistry::with_defaults()
|
||||||
|
.parse(include_bytes!(
|
||||||
|
"fixtures/addressables_regression/current_catalog.json"
|
||||||
|
))
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
assert_eq!(manifest.resources.len(), 4);
|
||||||
|
assert_eq!(
|
||||||
|
manifest.resources[0].resource_type,
|
||||||
|
ResourceType::TableBundle
|
||||||
|
);
|
||||||
|
assert_eq!(manifest.resources[1].resource_type, ResourceType::Media);
|
||||||
|
assert_eq!(manifest.resources[2].resource_type, ResourceType::TextAsset);
|
||||||
|
assert_eq!(
|
||||||
|
manifest.resources[3].dependencies,
|
||||||
|
vec!["shared_dependencies.bundle".to_string()]
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
manifest.metadata.cdn_prefixes,
|
||||||
|
vec!["https://fixture.invalid/current/".to_string()]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn parses_previous_catalog_fixture_using_internal_ids() {
|
||||||
|
let manifest = ManifestDriverRegistry::with_defaults()
|
||||||
|
.parse(include_bytes!(
|
||||||
|
"fixtures/addressables_regression/previous_catalog.json"
|
||||||
|
))
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
assert_eq!(manifest.resources.len(), 3);
|
||||||
|
assert_eq!(
|
||||||
|
manifest.resources[0].resource_type,
|
||||||
|
ResourceType::AssetBundle
|
||||||
|
);
|
||||||
|
assert_eq!(manifest.resources[1].resource_type, ResourceType::Manifest);
|
||||||
|
assert_eq!(
|
||||||
|
manifest.resources[2].resource_type,
|
||||||
|
ResourceType::TableBundle
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn parses_catalog_structure_change_with_alias_fields() {
|
||||||
|
let manifest = ManifestDriverRegistry::with_defaults()
|
||||||
|
.parse(include_bytes!(
|
||||||
|
"fixtures/addressables_regression/structure_changed_catalog.json"
|
||||||
|
))
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
assert_eq!(manifest.resources.len(), 2);
|
||||||
|
assert_eq!(manifest.resources[0].resource_type, ResourceType::Media);
|
||||||
|
assert_eq!(
|
||||||
|
manifest.resources[0].dependencies,
|
||||||
|
vec!["shared_assets_current.bundle".to_string()]
|
||||||
|
);
|
||||||
|
assert_eq!(manifest.resources[1].resource_type, ResourceType::TextAsset);
|
||||||
|
assert_eq!(manifest.resources[1].address.as_deref(), Some("lesson"));
|
||||||
|
}
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
{
|
||||||
|
"m_LocatorId": "AddressablesMainContentCatalog",
|
||||||
|
"m_InternalIdPrefixes": [
|
||||||
|
"https://fixture.invalid/current/"
|
||||||
|
],
|
||||||
|
"m_Entries": [
|
||||||
|
{
|
||||||
|
"internal_id": "TableBundles/ExcelDB.db",
|
||||||
|
"hash": "current-table-hash",
|
||||||
|
"size": 4096,
|
||||||
|
"address": "ExcelDB",
|
||||||
|
"dependencies": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"internal_id": "MediaResources-Windows/voice/title.acb",
|
||||||
|
"hash": "current-media-hash",
|
||||||
|
"size": 2048,
|
||||||
|
"address": "title",
|
||||||
|
"dependencies": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"internal_id": "TextAssets/dialogue.csv",
|
||||||
|
"hash": "current-text-hash",
|
||||||
|
"size": 128,
|
||||||
|
"address": "dialogue",
|
||||||
|
"dependencies": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"internal_id": "shared_assets_current.bundle",
|
||||||
|
"hash": "current-bundle-hash",
|
||||||
|
"size": 8192,
|
||||||
|
"address": "shared_assets_current",
|
||||||
|
"dependencies": [
|
||||||
|
"shared_dependencies.bundle"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
{
|
||||||
|
"m_LocatorId": "AddressablesMainContentCatalog",
|
||||||
|
"m_InternalIdPrefixes": [],
|
||||||
|
"m_InternalIds": [
|
||||||
|
"shared_assets_previous.bundle",
|
||||||
|
"catalog_previous.json",
|
||||||
|
"TableBundles/ExcelDB.db"
|
||||||
|
]
|
||||||
|
}
|
||||||
+24
@@ -0,0 +1,24 @@
|
|||||||
|
{
|
||||||
|
"m_LocatorId": "AddressablesMainContentCatalog",
|
||||||
|
"m_InternalIdPrefixes": [],
|
||||||
|
"m_InternalIds": [
|
||||||
|
"fallback.bundle"
|
||||||
|
],
|
||||||
|
"m_Entries": [
|
||||||
|
{
|
||||||
|
"InternalId": "MediaResources-Android/voice/title.awb",
|
||||||
|
"Hash": "changed-media-hash",
|
||||||
|
"Size": 65536,
|
||||||
|
"Address": "title-android",
|
||||||
|
"m_Dependencies": [
|
||||||
|
"shared_assets_current.bundle"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"Path": "TextAssets/lesson.json",
|
||||||
|
"Hash": "changed-text-hash",
|
||||||
|
"Size": 512,
|
||||||
|
"Key": "lesson"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -11,6 +11,10 @@ pub enum ResourceType {
|
|||||||
Manifest,
|
Manifest,
|
||||||
/// TableBundle
|
/// TableBundle
|
||||||
TableBundle,
|
TableBundle,
|
||||||
|
/// TextAsset
|
||||||
|
TextAsset,
|
||||||
|
/// Media resource
|
||||||
|
Media,
|
||||||
/// 其他
|
/// 其他
|
||||||
Other,
|
Other,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -238,6 +238,9 @@ Linux 生产路径:
|
|||||||
- `OfficialUpdateService` 能持久化 v2 snapshot,并在远端 marker 内容变化时触发下载决策
|
- `OfficialUpdateService` 能持久化 v2 snapshot,并在远端 marker 内容变化时触发下载决策
|
||||||
- `bat` 默认向 stderr 输出 `BlueArchiveToolkit` ASCII banner 和 progress log,stdout 默认输出人类可读摘要;progress log 覆盖总体下载进度、单文件开始/完成状态、下载中断失败分类和校验结果摘要;支持 `--json` 输出稳定 JSON,支持 `--no-progress` 关闭进度日志,支持 `--no-banner` 只关闭横幅,支持 `--watch --interval 1h --error-retry 60s` 常驻运行,支持 `--daemon` Unix socket JSON-RPC 控制、`status`、`stop`、`restart`、`reload`、`logs`、`refresh --force`、`verify`、`repair`、`doctor`、`clean-stable`,非 dry-run 使用 `.official-sync.lock` 防止并发写资源目录,控制命令使用 `bat-control.lock` 防止并发状态修改,资源发布使用 `.staging`、`versions` 和 `current` 原子切换,daemon 写 `bat-events.jsonl` 结构化日志并在 `status` 中暴露下载进度、失败类型、HTTP 状态和调度状态
|
- `bat` 默认向 stderr 输出 `BlueArchiveToolkit` ASCII banner 和 progress log,stdout 默认输出人类可读摘要;progress log 覆盖总体下载进度、单文件开始/完成状态、下载中断失败分类和校验结果摘要;支持 `--json` 输出稳定 JSON,支持 `--no-progress` 关闭进度日志,支持 `--no-banner` 只关闭横幅,支持 `--watch --interval 1h --error-retry 60s` 常驻运行,支持 `--daemon` Unix socket JSON-RPC 控制、`status`、`stop`、`restart`、`reload`、`logs`、`refresh --force`、`verify`、`repair`、`doctor`、`clean-stable`,非 dry-run 使用 `.official-sync.lock` 防止并发写资源目录,控制命令使用 `bat-control.lock` 防止并发状态修改,资源发布使用 `.staging`、`versions` 和 `current` 原子切换,daemon 写 `bat-events.jsonl` 结构化日志并在 `status` 中暴露下载进度、失败类型、HTTP 状态和调度状态
|
||||||
- curl 失败分类和重试策略已覆盖 404 不重试、5xx 重试耗尽后 quarantine、launcher primary CDN 失败后切换 official backup CDN
|
- curl 失败分类和重试策略已覆盖 404 不重试、5xx 重试耗尽后 quarantine、launcher primary CDN 失败后切换 official backup CDN
|
||||||
|
- `official-version-state.json` 已覆盖当前完成版本、正在拉取版本、上一个可用版本和失败版本,`bat status` 会暴露版本状态摘要
|
||||||
|
- 资源导入链路已覆盖 CAS 写入、`ResourceRepository` 索引、AssetBundle UnityFS 摘要,以及 TextAsset/Table/Media 分类
|
||||||
|
- 离线回归样本已覆盖当前 catalog、上一个版本 catalog、catalog 结构变化、403、404 和 seed hash mismatch
|
||||||
- `OfficialUpdateService` 能读写 `official-bootstrap-cache.json`,并支持默认开启的 `audit_local` / `repair` CLI 行为
|
- `OfficialUpdateService` 能读写 `official-bootstrap-cache.json`,并支持默认开启的 `audit_local` / `repair` CLI 行为
|
||||||
- 下载层能在本地文件 size/BLAKE3/path、ZIP 结构或 manifest 不匹配时重新下载
|
- 下载层能在本地文件 size/BLAKE3/path、ZIP 结构或 manifest 不匹配时重新下载
|
||||||
- 官方 seed `.hash` mismatch 会导致下载失败,而不是降级为本地 BLAKE3 猜测
|
- 官方 seed `.hash` mismatch 会导致下载失败,而不是降级为本地 BLAKE3 猜测
|
||||||
|
|||||||
@@ -161,6 +161,7 @@ cargo run -p bat-infrastructure --example official_pull_plan -- \
|
|||||||
- 旧 launcher 包或 `resources.assets` 下载路径使用官方 launcher CDN 配置,primary CDN 失败后会切换官方 backup CDN;资源 patch host 当前只使用 server-info 返回的官方 client-patch host,不猜测非官方镜像。
|
- 旧 launcher 包或 `resources.assets` 下载路径使用官方 launcher CDN 配置,primary CDN 失败后会切换官方 backup CDN;资源 patch host 当前只使用 server-info 返回的官方 client-patch host,不猜测非官方镜像。
|
||||||
- 远端和本地都一致:单次模式输出 `update_status=up_to_date`,watch 模式默认静默并等待下次检查。
|
- 远端和本地都一致:单次模式输出 `update_status=up_to_date`,watch 模式默认静默并等待下次检查。
|
||||||
- 有远端变化或本地 repair:生成 pull plan,下载完整官方资源到 staging,成功后更新 snapshot 并原子发布到 `current`。
|
- 有远端变化或本地 repair:生成 pull plan,下载完整官方资源到 staging,成功后更新 snapshot 并原子发布到 `current`。
|
||||||
|
- 非 dry-run 会维护 `<output>/official-version-state.json`:开始下载后写入 `in_progress_version`,发布成功后写入 `current_completed_version` 和 `previous_available_version`,失败或中断后写入 `failed_versions`。
|
||||||
- `--dry-run`:只报告本次是否会下载,不写 snapshot;如果 cache miss,也不会写入新的 bootstrap cache。
|
- `--dry-run`:只报告本次是否会下载,不写 snapshot;如果 cache miss,也不会写入新的 bootstrap cache。
|
||||||
- `--dry-run --plan`:除更新判断外,还会解析 seed catalog 并打印完整下载 URL。
|
- `--dry-run --plan`:除更新判断外,还会解析 seed catalog 并打印完整下载 URL。
|
||||||
- 真实更新会输出 `downloaded_count`、`resumed_count`、`skipped_count`、`transferred_bytes`、`official_seed_hash_verified_count`。
|
- 真实更新会输出 `downloaded_count`、`resumed_count`、`skipped_count`、`transferred_bytes`、`official_seed_hash_verified_count`。
|
||||||
@@ -171,6 +172,7 @@ cargo run -p bat-infrastructure --example official_pull_plan -- \
|
|||||||
|
|
||||||
- `<output>/current/official-sync-snapshot.json`:上一次成功同步的 v2 snapshot,包含 app version、connection group、bundle version、addressables root、endpoint URL、官方 seed `.hash` 内容、Addressables `catalog_*.hash` marker、launcher metadata 摘要和 `GameMainConfig` 摘要。
|
- `<output>/current/official-sync-snapshot.json`:上一次成功同步的 v2 snapshot,包含 app version、connection group、bundle version、addressables root、endpoint URL、官方 seed `.hash` 内容、Addressables `catalog_*.hash` marker、launcher metadata 摘要和 `GameMainConfig` 摘要。
|
||||||
- `<output>/official-bootstrap-cache.json`:`--auto-discover` 的 `GameMainConfig` 解析缓存。launcher metadata 未变时复用缓存;metadata 变化时才通过官方 HTTP 按 manifest 下载必要 `resources.assets` 或旧版 game zip 到临时目录解析。
|
- `<output>/official-bootstrap-cache.json`:`--auto-discover` 的 `GameMainConfig` 解析缓存。launcher metadata 未变时复用缓存;metadata 变化时才通过官方 HTTP 按 manifest 下载必要 `resources.assets` 或旧版 game zip 到临时目录解析。
|
||||||
|
- `<output>/official-version-state.json`:资源发布根目录的持久版本状态,包含当前已完成版本、正在拉取版本、上一个可用版本和失败版本。
|
||||||
- `<output>/current/official-download-manifest.json`:本地下载强校验清单,记录 URL、相对路径、size 和 BLAKE3。
|
- `<output>/current/official-download-manifest.json`:本地下载强校验清单,记录 URL、相对路径、size 和 BLAKE3。
|
||||||
- `<output>/current/official-download-quarantine.json` 或当前 staging 下同名文件:下载最终失败的 URL 诊断记录,包含失败类型、HTTP 状态、是否可重试、尝试次数和最后错误。
|
- `<output>/current/official-download-quarantine.json` 或当前 staging 下同名文件:下载最终失败的 URL 诊断记录,包含失败类型、HTTP 状态、是否可重试、尝试次数和最后错误。
|
||||||
|
|
||||||
|
|||||||
@@ -228,7 +228,8 @@
|
|||||||
影响:
|
影响:
|
||||||
|
|
||||||
- `SqliteResourceRepository` 已存在,可按领域 repository 接口保存资源元数据。
|
- `SqliteResourceRepository` 已存在,可按领域 repository 接口保存资源元数据。
|
||||||
- 官方同步下载结果尚未作为用户级流程自动写入 CAS + ResourceRepository。
|
- `ResourceImportService` 已能把 manifest 中有数据的资源写入 CAS + `ResourceRepository`,AssetBundle 会记录 UnityFS 摘要,TextAsset/Table/Media 会分类索引。
|
||||||
|
- 官方同步下载结果尚未作为用户级流程自动触发导入 CAS + ResourceRepository。
|
||||||
- 迁移、版本化 schema 和 CLI 查询入口仍需补齐。
|
- 迁移、版本化 schema 和 CLI 查询入口仍需补齐。
|
||||||
|
|
||||||
验收:
|
验收:
|
||||||
@@ -237,6 +238,75 @@
|
|||||||
- 可按版本、类型、hash、路径查询资源。
|
- 可按版本、类型、hash、路径查询资源。
|
||||||
- 官方同步后的资源可通过 CLI 查询并能追溯到 CAS 对象。
|
- 官方同步后的资源可通过 CLI 查询并能追溯到 CAS 对象。
|
||||||
|
|
||||||
|
### G-011A:资源导入链路基础能力不足
|
||||||
|
|
||||||
|
状态:**已关闭**
|
||||||
|
|
||||||
|
历史现象:
|
||||||
|
|
||||||
|
- 资源导入链路只导入 AssetBundle。
|
||||||
|
- 非 AssetBundle manifest 条目只会被跳过。
|
||||||
|
- 导入报告只包含 UnityFS 基础摘要,不包含稳定分类统计。
|
||||||
|
|
||||||
|
处理结果:
|
||||||
|
|
||||||
|
- `ResourceImportService` 会把有数据的 manifest 条目导入 CAS 并写入 `ResourceRepository`。
|
||||||
|
- AssetBundle 仍执行 UnityFS header/block/directory 摘要解析。
|
||||||
|
- TextAsset、TableBundle、Media 会按资源类型分类,缺少数据时记录为 skipped,便于渐进导入。
|
||||||
|
- `ResourceImportReport` 增加 `category_counts`,`ImportedResource` 增加 `resource_type`、`category` 和可选 UnityFS 摘要。
|
||||||
|
|
||||||
|
验收:
|
||||||
|
|
||||||
|
- `cargo test -p bat-infrastructure import::tests::`
|
||||||
|
- `cargo test -p bat-infrastructure --test synthetic_phase2_import`
|
||||||
|
|
||||||
|
### G-011B:官方版本状态管理不明确
|
||||||
|
|
||||||
|
状态:**已关闭**
|
||||||
|
|
||||||
|
历史现象:
|
||||||
|
|
||||||
|
- 当前可用版本主要靠 `current` symlink 和 release 内 snapshot 推断。
|
||||||
|
- 未显式保存“正在拉取版本”和“失败版本”。
|
||||||
|
- 上一个可用版本需要从目录状态间接判断。
|
||||||
|
|
||||||
|
处理结果:
|
||||||
|
|
||||||
|
- 新增 `<output>/official-version-state.json`。
|
||||||
|
- 开始下载后写入 `in_progress_version`。
|
||||||
|
- 发布成功后写入 `current_completed_version` 和 `previous_available_version`。
|
||||||
|
- 失败或中断后写入 `failed_versions` 并清空 in-progress。
|
||||||
|
- `bat status` 会读取并展示版本状态摘要。
|
||||||
|
|
||||||
|
验收:
|
||||||
|
|
||||||
|
- `cargo test -p bat-infrastructure version_state`
|
||||||
|
- `cargo test -p bat-infrastructure --test official_game_main_config_bootstrap`
|
||||||
|
|
||||||
|
### G-011C:真实 fixture 与回归样本不足
|
||||||
|
|
||||||
|
状态:**已关闭当前阶段**
|
||||||
|
|
||||||
|
历史现象:
|
||||||
|
|
||||||
|
- 已有 Addressables real-shape fixture/golden,但缺少按问题类型命名的当前/上一版本/结构变化样本。
|
||||||
|
- 403/404 和 hash mismatch 主要依赖单测内联构造,不便于后续回归扩展。
|
||||||
|
|
||||||
|
处理结果:
|
||||||
|
|
||||||
|
- 新增 `adapters/tests/fixtures/addressables_regression/current_catalog.json`。
|
||||||
|
- 新增 `adapters/tests/fixtures/addressables_regression/previous_catalog.json`。
|
||||||
|
- 新增 `adapters/tests/fixtures/addressables_regression/structure_changed_catalog.json`。
|
||||||
|
- 新增 `infrastructure/tests/fixtures/official_regression/http_403.json`。
|
||||||
|
- 新增 `infrastructure/tests/fixtures/official_regression/http_404.json`。
|
||||||
|
- 新增 `infrastructure/tests/fixtures/official_regression/hash_mismatch_catalog.json`。
|
||||||
|
- 对应测试会解析这些 fixture,防止样本只存在但不参与验证。
|
||||||
|
|
||||||
|
验收:
|
||||||
|
|
||||||
|
- `cargo test -p bat-adapters --test addressables_regression`
|
||||||
|
- `cargo test -p bat-infrastructure regression_fixture`
|
||||||
|
|
||||||
### G-012:Translation Memory 未实现
|
### G-012:Translation Memory 未实现
|
||||||
|
|
||||||
影响:
|
影响:
|
||||||
|
|||||||
@@ -37,8 +37,8 @@
|
|||||||
仍未完成:
|
仍未完成:
|
||||||
|
|
||||||
- Go CLI 最小可用入口。
|
- Go CLI 最小可用入口。
|
||||||
- 官方同步下载结果自动导入 CAS + ResourceRepository 的用户级流程。
|
- 官方同步下载结果自动触发 CAS + ResourceRepository 导入的用户级流程。
|
||||||
- 完整 AssetBundle 解析。
|
- AssetBundle 对象表、TypeTree 和 TextAsset 提取。
|
||||||
- Patch、翻译系统、API Server、Web。
|
- Patch、翻译系统、API Server、Web。
|
||||||
|
|
||||||
## 3. 当前核查结果
|
## 3. 当前核查结果
|
||||||
@@ -64,11 +64,11 @@ git status --short
|
|||||||
|
|
||||||
## 4. 当前结论
|
## 4. 当前结论
|
||||||
|
|
||||||
当前 Rust 官方资源同步链路已经具备可运行闭环,并已固化真实网络 smoke 的可重复命令,但仓库还不是完整产品。下一步不应继续只扩展 example,而应补齐 Go CLI 最小入口、CAS/ResourceRepository 编排和 AssetBundle 解析。
|
当前 Rust 官方资源同步链路已经具备可运行闭环,并已固化真实网络 smoke 的可重复命令;版本状态、资源导入基础链路和离线回归 fixture 已补齐。但仓库还不是完整产品。下一步不应继续只扩展 example,而应补齐 Go CLI 最小入口、官方同步后自动导入编排和 AssetBundle 对象级解析。
|
||||||
|
|
||||||
## 5. 下一步
|
## 5. 下一步
|
||||||
|
|
||||||
1. 实现 Go CLI:`bat doctor`、`bat sync --help`、`bat official sync --help`。
|
1. 实现 Go CLI:`bat doctor`、`bat sync --help`、`bat official sync --help`。
|
||||||
2. 按 `docs/guides/official-full-pull-smoke.md` 在具备网络和磁盘窗口的环境中执行真实官方网络 smoke,输出目录必须隔离。
|
2. 按 `docs/guides/official-full-pull-smoke.md` 在具备网络和磁盘窗口的环境中执行真实官方网络 smoke,输出目录必须隔离。
|
||||||
3. 将官方同步下载结果接入 CAS + `SqliteResourceRepository`。
|
3. 将官方同步下载结果自动接入 CAS + `SqliteResourceRepository` 的用户级命令。
|
||||||
4. 开始 AssetBundle UnityFS header/block/directory 解析。
|
4. 开始 AssetBundle 对象表、TypeTree 和 TextAsset 提取。
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
# 当前进度交接
|
# 当前进度交接
|
||||||
|
|
||||||
- **更新时间**:2026-07-14
|
- **更新时间**:2026-07-15
|
||||||
- **状态分支**:`experiment`
|
- **状态分支**:`experiment`
|
||||||
- **最新功能提交**:以当前 `git log --oneline -1` 为准
|
- **最新功能提交**:以当前 `git log --oneline -1` 为准
|
||||||
- **用途**:给下一次对话快速恢复上下文,优先以当前工作区和已验证结果为准。
|
- **用途**:给下一次对话快速恢复上下文,优先以当前工作区和已验证结果为准。
|
||||||
@@ -23,6 +23,9 @@
|
|||||||
- `--watch` 是 Rust 内部常驻检查模式,正常检查默认 1 小时,失败重试默认 60 秒;外部 systemd/container 只负责守护进程。
|
- `--watch` 是 Rust 内部常驻检查模式,正常检查默认 1 小时,失败重试默认 60 秒;外部 systemd/container 只负责守护进程。
|
||||||
- `bat` progress log 会输出总体下载进度、单文件进度和校验结果摘要。
|
- `bat` progress log 会输出总体下载进度、单文件进度和校验结果摘要。
|
||||||
- 真实官方网络 smoke 已固化为 `scripts/official-full-pull-smoke.sh`、`make official-smoke` 和 `docs/guides/official-full-pull-smoke.md`,默认写入 `/tmp` 隔离目录。
|
- 真实官方网络 smoke 已固化为 `scripts/official-full-pull-smoke.sh`、`make official-smoke` 和 `docs/guides/official-full-pull-smoke.md`,默认写入 `/tmp` 隔离目录。
|
||||||
|
- `official-version-state.json` 已持久化当前完成、正在拉取、上一个可用和失败版本,`bat status` 会显示版本状态摘要。
|
||||||
|
- 资源导入链路已支持 CAS + `ResourceRepository` 写入,AssetBundle UnityFS 摘要,以及 TextAsset/Table/Media 分类索引。
|
||||||
|
- 当前 catalog、上一个版本 catalog、结构变化 catalog、403、404、hash mismatch 已有离线回归 fixture。
|
||||||
|
|
||||||
## 2. 现在不要误解的点
|
## 2. 现在不要误解的点
|
||||||
|
|
||||||
@@ -39,9 +42,9 @@
|
|||||||
|
|
||||||
1. Go CLI 最小入口:`bat doctor`、`bat sync --help`、`bat official sync --help`。
|
1. Go CLI 最小入口:`bat doctor`、`bat sync --help`、`bat official sync --help`。
|
||||||
2. 按 smoke runbook 在具备网络和磁盘窗口的环境中执行真实官方网络 smoke,并保留隔离目录报告。
|
2. 按 smoke runbook 在具备网络和磁盘窗口的环境中执行真实官方网络 smoke,并保留隔离目录报告。
|
||||||
3. 官方同步下载结果进入 CAS + `SqliteResourceRepository` 的用户级流程。
|
3. 官方同步下载结果自动触发 CAS + `SqliteResourceRepository` 的用户级导入流程。
|
||||||
4. Addressables parser 覆盖更多真实 catalog 结构。
|
4. Addressables parser 覆盖二进制/压缩字段组合和更细失败诊断。
|
||||||
5. AssetBundle UnityFS header/block/directory 解析。
|
5. AssetBundle 对象表、TypeTree 和 TextAsset 提取链路继续推进。
|
||||||
6. Patch、翻译库、API Server、Web。
|
6. Patch、翻译库、API Server、Web。
|
||||||
|
|
||||||
## 4. 下一次对话最合适的起点
|
## 4. 下一次对话最合适的起点
|
||||||
|
|||||||
@@ -379,7 +379,7 @@ fn main() -> anyhow::Result<()> {
|
|||||||
let pull_plan = pull_plan
|
let pull_plan = pull_plan
|
||||||
.as_ref()
|
.as_ref()
|
||||||
.ok_or_else(|| anyhow::anyhow!("download requested but no pull plan exists"))?;
|
.ok_or_else(|| anyhow::anyhow!("download requested but no pull plan exists"))?;
|
||||||
let report = fetcher.pull(&pull_plan).map_err(anyhow::Error::msg)?;
|
let report = fetcher.pull(pull_plan).map_err(anyhow::Error::msg)?;
|
||||||
let final_audit = fetcher
|
let final_audit = fetcher
|
||||||
.audit_local_manifest(pull_plan)
|
.audit_local_manifest(pull_plan)
|
||||||
.map_err(anyhow::Error::msg)?;
|
.map_err(anyhow::Error::msg)?;
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
use bat_adapters::official::yostar_jp::PatchPlatform;
|
use bat_adapters::official::yostar_jp::PatchPlatform;
|
||||||
use bat_infrastructure::{
|
use bat_infrastructure::{
|
||||||
lexical_absolute, open_append_file, read_file_no_symlink, validate_output_root,
|
lexical_absolute, open_append_file, read_file_no_symlink, read_version_state,
|
||||||
validate_runtime_state_dir, write_file_atomic, OfficialServerInfoSource, OfficialUpdateConfig,
|
validate_output_root, validate_runtime_state_dir, write_file_atomic, OfficialServerInfoSource,
|
||||||
OfficialUpdateProgress, OfficialUpdateReport, OfficialUpdateService, OfficialUpdateStatus,
|
OfficialUpdateConfig, OfficialUpdateProgress, OfficialUpdateReport, OfficialUpdateService,
|
||||||
OfficialVerificationSummary, PRIVATE_FILE_MODE,
|
OfficialUpdateStatus, OfficialVerificationSummary, OfficialVersionState, PRIVATE_FILE_MODE,
|
||||||
};
|
};
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use std::env;
|
use std::env;
|
||||||
@@ -608,17 +608,7 @@ fn record_daemon_status(
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if let Err(error) = update_daemon_status(
|
if let Err(error) = update_daemon_status(state_dir, update) {
|
||||||
state_dir,
|
|
||||||
update.state,
|
|
||||||
update.last_update_status,
|
|
||||||
update.last_error,
|
|
||||||
update.next_retry_seconds,
|
|
||||||
update.last_success_unix_seconds,
|
|
||||||
update.next_check_unix_seconds,
|
|
||||||
update.pending_scheduled_force,
|
|
||||||
update.next_forced_refresh_at,
|
|
||||||
) {
|
|
||||||
logger.log_text("daemon", format!("更新后台状态失败:{error}"));
|
logger.log_text("daemon", format!("更新后台状态失败:{error}"));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -731,10 +721,7 @@ fn classify_pid_lock_file(path: &Path) -> anyhow::Result<PidLockState> {
|
|||||||
if metadata.file_type().is_symlink() || !metadata.is_file() {
|
if metadata.file_type().is_symlink() || !metadata.is_file() {
|
||||||
return Ok(PidLockState::Corrupt);
|
return Ok(PidLockState::Corrupt);
|
||||||
}
|
}
|
||||||
let contents = match fs::read_to_string(path) {
|
let contents = fs::read_to_string(path)?;
|
||||||
Ok(contents) => contents,
|
|
||||||
Err(error) => return Err(error.into()),
|
|
||||||
};
|
|
||||||
let Some(pid) = parse_pid_value(&contents) else {
|
let Some(pid) = parse_pid_value(&contents) else {
|
||||||
return Ok(PidLockState::Corrupt);
|
return Ok(PidLockState::Corrupt);
|
||||||
};
|
};
|
||||||
@@ -1322,6 +1309,8 @@ struct DaemonStatusReport {
|
|||||||
current_stage: Option<String>,
|
current_stage: Option<String>,
|
||||||
current_message: Option<String>,
|
current_message: Option<String>,
|
||||||
download_progress: Option<DaemonDownloadProgress>,
|
download_progress: Option<DaemonDownloadProgress>,
|
||||||
|
version_state_path: Option<PathBuf>,
|
||||||
|
version_state: Option<OfficialVersionState>,
|
||||||
pending_scheduled_force: Option<bool>,
|
pending_scheduled_force: Option<bool>,
|
||||||
next_forced_refresh_unix_seconds: Option<u64>,
|
next_forced_refresh_unix_seconds: Option<u64>,
|
||||||
command: Option<Vec<String>>,
|
command: Option<Vec<String>>,
|
||||||
@@ -1600,6 +1589,14 @@ fn build_daemon_status_report(state_dir: &Path) -> anyhow::Result<DaemonStatusRe
|
|||||||
.unwrap_or_else(|| daemon_structured_log_path(state_dir));
|
.unwrap_or_else(|| daemon_structured_log_path(state_dir));
|
||||||
let structured_log_exists = path_exists_no_follow(&structured_log_path)?;
|
let structured_log_exists = path_exists_no_follow(&structured_log_path)?;
|
||||||
let rotated_structured_log_paths = rotated_structured_log_paths(&structured_log_path);
|
let rotated_structured_log_paths = rotated_structured_log_paths(&structured_log_path);
|
||||||
|
let version_state_path = status_file.as_ref().map(|status| {
|
||||||
|
status
|
||||||
|
.resource_output_root
|
||||||
|
.join("official-version-state.json")
|
||||||
|
});
|
||||||
|
let version_state = version_state_path
|
||||||
|
.as_ref()
|
||||||
|
.and_then(|path| read_version_state(path).ok().flatten());
|
||||||
|
|
||||||
Ok(DaemonStatusReport {
|
Ok(DaemonStatusReport {
|
||||||
status: if running { "running" } else { "stopped" },
|
status: if running { "running" } else { "stopped" },
|
||||||
@@ -1659,6 +1656,8 @@ fn build_daemon_status_report(state_dir: &Path) -> anyhow::Result<DaemonStatusRe
|
|||||||
download_progress: status_file
|
download_progress: status_file
|
||||||
.as_ref()
|
.as_ref()
|
||||||
.and_then(|status| status.download_progress.clone()),
|
.and_then(|status| status.download_progress.clone()),
|
||||||
|
version_state_path,
|
||||||
|
version_state,
|
||||||
pending_scheduled_force: status_file
|
pending_scheduled_force: status_file
|
||||||
.as_ref()
|
.as_ref()
|
||||||
.map(|status| status.pending_scheduled_force),
|
.map(|status| status.pending_scheduled_force),
|
||||||
@@ -1896,6 +1895,8 @@ fn print_human_json_value(value: &serde_json::Value) -> anyhow::Result<()> {
|
|||||||
print_json_field(value, "current_stage", "当前阶段");
|
print_json_field(value, "current_stage", "当前阶段");
|
||||||
print_json_field(value, "current_message", "当前消息");
|
print_json_field(value, "current_message", "当前消息");
|
||||||
print_json_field(value, "download_progress", "下载进度");
|
print_json_field(value, "download_progress", "下载进度");
|
||||||
|
print_json_field(value, "version_state_path", "版本状态路径");
|
||||||
|
print_json_field(value, "version_state", "版本状态");
|
||||||
print_json_field(value, "resource_output_root", "资源目录");
|
print_json_field(value, "resource_output_root", "资源目录");
|
||||||
print_json_field(value, "state_dir", "状态目录");
|
print_json_field(value, "state_dir", "状态目录");
|
||||||
print_json_field(value, "socket_path", "socket");
|
print_json_field(value, "socket_path", "socket");
|
||||||
@@ -2102,6 +2103,7 @@ impl HumanReport for OfficialUpdateReport {
|
|||||||
print_path_field("输出根目录", &self.output_root);
|
print_path_field("输出根目录", &self.output_root);
|
||||||
print_path_field("active release", &self.active_resource_root);
|
print_path_field("active release", &self.active_resource_root);
|
||||||
print_path_field("current", &self.current_path);
|
print_path_field("current", &self.current_path);
|
||||||
|
print_path_field("version state", &self.version_state_path);
|
||||||
print_optional_path_field("staging", self.staging_path.as_ref());
|
print_optional_path_field("staging", self.staging_path.as_ref());
|
||||||
print_optional_path_field("published", self.published_version_path.as_ref());
|
print_optional_path_field("published", self.published_version_path.as_ref());
|
||||||
print_path_field("snapshot", &self.snapshot_path);
|
print_path_field("snapshot", &self.snapshot_path);
|
||||||
@@ -2174,7 +2176,32 @@ impl HumanReport for DaemonStatusReport {
|
|||||||
if let Some(progress) = self.download_progress.as_ref() {
|
if let Some(progress) = self.download_progress.as_ref() {
|
||||||
print_field("下载进度", format_daemon_download_progress(progress));
|
print_field("下载进度", format_daemon_download_progress(progress));
|
||||||
}
|
}
|
||||||
|
if let Some(version_state) = self.version_state.as_ref() {
|
||||||
|
print_optional_field(
|
||||||
|
"当前完成版本",
|
||||||
|
version_state
|
||||||
|
.current_completed_version
|
||||||
|
.as_ref()
|
||||||
|
.map(|version| version.id.as_str()),
|
||||||
|
);
|
||||||
|
print_optional_field(
|
||||||
|
"正在拉取版本",
|
||||||
|
version_state
|
||||||
|
.in_progress_version
|
||||||
|
.as_ref()
|
||||||
|
.map(|version| version.id.as_str()),
|
||||||
|
);
|
||||||
|
print_optional_field(
|
||||||
|
"上一个可用版本",
|
||||||
|
version_state
|
||||||
|
.previous_available_version
|
||||||
|
.as_ref()
|
||||||
|
.map(|version| version.id.as_str()),
|
||||||
|
);
|
||||||
|
print_field("失败版本数", version_state.failed_versions.len());
|
||||||
|
}
|
||||||
print_optional_path_field("资源目录", self.resource_output_root.as_ref());
|
print_optional_path_field("资源目录", self.resource_output_root.as_ref());
|
||||||
|
print_optional_path_field("版本状态", self.version_state_path.as_ref());
|
||||||
print_path_field("状态目录", &self.state_dir);
|
print_path_field("状态目录", &self.state_dir);
|
||||||
print_path_field("socket", &self.socket_path);
|
print_path_field("socket", &self.socket_path);
|
||||||
print_optional_path_field("日志", self.log_path.as_ref());
|
print_optional_path_field("日志", self.log_path.as_ref());
|
||||||
@@ -2499,29 +2526,26 @@ struct DoctorReport {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn run_doctor_command(options: &CliOptions) -> anyhow::Result<bool> {
|
fn run_doctor_command(options: &CliOptions) -> anyhow::Result<bool> {
|
||||||
let mut checks = Vec::new();
|
let mut checks = vec![
|
||||||
checks.push(path_check(
|
path_check("state_dir", &options.state_dir, "后台状态目录可用"),
|
||||||
"state_dir",
|
path_check(
|
||||||
&options.state_dir,
|
"output_root",
|
||||||
"后台状态目录可用",
|
&options.config.output_root,
|
||||||
));
|
"资源输出目录可用",
|
||||||
checks.push(path_check(
|
),
|
||||||
"output_root",
|
safety_check(
|
||||||
&options.config.output_root,
|
"output_root_safety",
|
||||||
"资源输出目录可用",
|
validate_output_root(&options.config.output_root),
|
||||||
));
|
"资源输出目录安全边界通过",
|
||||||
checks.push(safety_check(
|
),
|
||||||
"output_root_safety",
|
safety_check(
|
||||||
validate_output_root(&options.config.output_root),
|
"state_dir_safety",
|
||||||
"资源输出目录安全边界通过",
|
validate_runtime_state_dir(&options.state_dir),
|
||||||
));
|
"后台状态目录安全边界通过",
|
||||||
checks.push(safety_check(
|
),
|
||||||
"state_dir_safety",
|
command_check("curl", &options.config.curl_command),
|
||||||
validate_runtime_state_dir(&options.state_dir),
|
command_check("unzip", &options.config.unzip_command),
|
||||||
"后台状态目录安全边界通过",
|
];
|
||||||
));
|
|
||||||
checks.push(command_check("curl", &options.config.curl_command));
|
|
||||||
checks.push(command_check("unzip", &options.config.unzip_command));
|
|
||||||
|
|
||||||
let pid_path = daemon_pid_path(&options.state_dir);
|
let pid_path = daemon_pid_path(&options.state_dir);
|
||||||
let daemon_running = read_pid_file(&pid_path)
|
let daemon_running = read_pid_file(&pid_path)
|
||||||
@@ -2864,17 +2888,7 @@ fn write_daemon_status_file(path: &Path, status: &DaemonStatusFile) -> anyhow::R
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
fn update_daemon_status(
|
fn update_daemon_status(state_dir: &Path, update: DaemonStatusUpdate<'_>) -> anyhow::Result<()> {
|
||||||
state_dir: &Path,
|
|
||||||
state: &str,
|
|
||||||
last_update_status: Option<String>,
|
|
||||||
last_error: Option<String>,
|
|
||||||
next_retry_seconds: Option<u64>,
|
|
||||||
last_success_unix_seconds: Option<u64>,
|
|
||||||
next_check_unix_seconds: Option<u64>,
|
|
||||||
pending_scheduled_force: bool,
|
|
||||||
next_forced_refresh_at: SystemTime,
|
|
||||||
) -> anyhow::Result<()> {
|
|
||||||
let status_path = daemon_status_path(state_dir);
|
let status_path = daemon_status_path(state_dir);
|
||||||
let mut status = read_daemon_status_file(&status_path)?.unwrap_or_else(|| DaemonStatusFile {
|
let mut status = read_daemon_status_file(&status_path)?.unwrap_or_else(|| DaemonStatusFile {
|
||||||
version: DAEMON_STATUS_VERSION,
|
version: DAEMON_STATUS_VERSION,
|
||||||
@@ -2899,22 +2913,23 @@ fn update_daemon_status(
|
|||||||
command: env::args().collect(),
|
command: env::args().collect(),
|
||||||
});
|
});
|
||||||
status.pid = std::process::id();
|
status.pid = std::process::id();
|
||||||
status.state = state.to_string();
|
status.state = update.state.to_string();
|
||||||
status.updated_unix_seconds = unix_seconds_now();
|
status.updated_unix_seconds = unix_seconds_now();
|
||||||
status.last_update_status = last_update_status;
|
status.last_update_status = update.last_update_status;
|
||||||
status.last_error = last_error;
|
status.last_error = update.last_error;
|
||||||
status.next_retry_seconds = next_retry_seconds;
|
status.next_retry_seconds = update.next_retry_seconds;
|
||||||
if last_success_unix_seconds.is_some() {
|
if update.last_success_unix_seconds.is_some() {
|
||||||
status.last_success_unix_seconds = last_success_unix_seconds;
|
status.last_success_unix_seconds = update.last_success_unix_seconds;
|
||||||
}
|
}
|
||||||
status.next_check_unix_seconds = next_check_unix_seconds;
|
status.next_check_unix_seconds = update.next_check_unix_seconds;
|
||||||
if state != "running" {
|
if update.state != "running" {
|
||||||
status.current_stage = None;
|
status.current_stage = None;
|
||||||
status.current_message = None;
|
status.current_message = None;
|
||||||
status.download_progress = None;
|
status.download_progress = None;
|
||||||
}
|
}
|
||||||
status.pending_scheduled_force = pending_scheduled_force;
|
status.pending_scheduled_force = update.pending_scheduled_force;
|
||||||
status.next_forced_refresh_unix_seconds = system_time_to_unix_seconds(next_forced_refresh_at);
|
status.next_forced_refresh_unix_seconds =
|
||||||
|
system_time_to_unix_seconds(update.next_forced_refresh_at);
|
||||||
write_daemon_status_file(&status_path, &status)
|
write_daemon_status_file(&status_path, &status)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -3130,11 +3145,11 @@ fn daemon_child_args(options: &CliOptions) -> Vec<String> {
|
|||||||
|
|
||||||
fn format_duration_arg(duration: Duration) -> String {
|
fn format_duration_arg(duration: Duration) -> String {
|
||||||
let millis = duration.as_millis();
|
let millis = duration.as_millis();
|
||||||
if millis % 3_600_000 == 0 {
|
if millis.is_multiple_of(3_600_000) {
|
||||||
format!("{}h", millis / 3_600_000)
|
format!("{}h", millis / 3_600_000)
|
||||||
} else if millis % 60_000 == 0 {
|
} else if millis.is_multiple_of(60_000) {
|
||||||
format!("{}m", millis / 60_000)
|
format!("{}m", millis / 60_000)
|
||||||
} else if millis % 1_000 == 0 {
|
} else if millis.is_multiple_of(1_000) {
|
||||||
format!("{}s", millis / 1_000)
|
format!("{}s", millis / 1_000)
|
||||||
} else {
|
} else {
|
||||||
format!("{millis}ms")
|
format!("{millis}ms")
|
||||||
@@ -3825,7 +3840,7 @@ fn parse_args_from(raw_args: impl IntoIterator<Item = String>) -> anyhow::Result
|
|||||||
"verify 只验证本地资源,不能和 --force 同时使用"
|
"verify 只验证本地资源,不能和 --force 同时使用"
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
if options.config.repair == false && options.config.audit_local == false {
|
if !options.config.repair && !options.config.audit_local {
|
||||||
return Err(anyhow::anyhow!(
|
return Err(anyhow::anyhow!(
|
||||||
"verify 必须启用本地审计;不要同时传 --no-repair --no-audit-local"
|
"verify 必须启用本地审计;不要同时传 --no-repair --no-audit-local"
|
||||||
));
|
));
|
||||||
@@ -3854,7 +3869,7 @@ fn ensure_command_not_set(command: CliCommand, next: &str) -> anyhow::Result<()>
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn tools_are_non_default(config: &OfficialUpdateConfig) -> bool {
|
fn tools_are_non_default(config: &OfficialUpdateConfig) -> bool {
|
||||||
config.curl_command != PathBuf::from("curl") || config.unzip_command != PathBuf::from("unzip")
|
config.curl_command != Path::new("curl") || config.unzip_command != Path::new("unzip")
|
||||||
}
|
}
|
||||||
|
|
||||||
fn next_option_value(
|
fn next_option_value(
|
||||||
@@ -4503,14 +4518,16 @@ mod tests {
|
|||||||
let next_check = unix_seconds_after(Duration::from_secs(30));
|
let next_check = unix_seconds_after(Duration::from_secs(30));
|
||||||
update_daemon_status(
|
update_daemon_status(
|
||||||
&state_dir,
|
&state_dir,
|
||||||
"sleeping",
|
DaemonStatusUpdate {
|
||||||
Some("downloaded".to_string()),
|
state: "sleeping",
|
||||||
None,
|
last_update_status: Some("downloaded".to_string()),
|
||||||
Some(30),
|
last_error: None,
|
||||||
Some(123456),
|
next_retry_seconds: Some(30),
|
||||||
Some(next_check),
|
last_success_unix_seconds: Some(123456),
|
||||||
false,
|
next_check_unix_seconds: Some(next_check),
|
||||||
SystemTime::now() + Duration::from_secs(3600),
|
pending_scheduled_force: false,
|
||||||
|
next_forced_refresh_at: SystemTime::now() + Duration::from_secs(3600),
|
||||||
|
},
|
||||||
)
|
)
|
||||||
.unwrap();
|
.unwrap();
|
||||||
let report = build_daemon_status_report(&state_dir).unwrap();
|
let report = build_daemon_status_report(&state_dir).unwrap();
|
||||||
|
|||||||
+235
-28
@@ -4,6 +4,7 @@ use bat_adapters::manifest::GenericManifest;
|
|||||||
use bat_adapters::unity::{RawAssetBundle, UnityAdapterRegistry};
|
use bat_adapters::unity::{RawAssetBundle, UnityAdapterRegistry};
|
||||||
use bat_core::domain::{Resource, ResourceType};
|
use bat_core::domain::{Resource, ResourceType};
|
||||||
use bat_core::repositories::{CasRepository, ResourceRepository};
|
use bat_core::repositories::{CasRepository, ResourceRepository};
|
||||||
|
use std::collections::BTreeMap;
|
||||||
use std::path::{Path, PathBuf};
|
use std::path::{Path, PathBuf};
|
||||||
|
|
||||||
/// 待导入的 AssetBundle 数据。
|
/// 待导入的 AssetBundle 数据。
|
||||||
@@ -32,12 +33,47 @@ pub struct ImportedResource {
|
|||||||
pub id: String,
|
pub id: String,
|
||||||
/// Manifest 中的资源路径。
|
/// Manifest 中的资源路径。
|
||||||
pub source_path: String,
|
pub source_path: String,
|
||||||
|
/// Manifest 或路径推断出的资源类型。
|
||||||
|
pub resource_type: ResourceType,
|
||||||
|
/// 导入链路使用的稳定分类标签。
|
||||||
|
pub category: ResourceImportCategory,
|
||||||
/// CAS 返回的对象 ID。
|
/// CAS 返回的对象 ID。
|
||||||
pub object_id: String,
|
pub object_id: String,
|
||||||
/// 写入 CAS 的字节数。
|
/// 写入 CAS 的字节数。
|
||||||
pub bytes: u64,
|
pub bytes: u64,
|
||||||
/// UnityFS 解析摘要。
|
/// UnityFS 解析摘要,仅 AssetBundle 资源会填充。
|
||||||
pub unityfs: UnityFsImportSummary,
|
pub unityfs: Option<UnityFsImportSummary>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Stable resource category produced by the import pipeline.
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
|
||||||
|
pub enum ResourceImportCategory {
|
||||||
|
/// UnityFS AssetBundle resource.
|
||||||
|
AssetBundle,
|
||||||
|
/// Unity TextAsset or text-like payload.
|
||||||
|
TextAsset,
|
||||||
|
/// Table bundle/database resource.
|
||||||
|
Table,
|
||||||
|
/// Media payload such as audio, video, texture, or media archive.
|
||||||
|
Media,
|
||||||
|
/// Manifest/catalog sidecar.
|
||||||
|
Manifest,
|
||||||
|
/// Resource that does not match a known category yet.
|
||||||
|
Other,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ResourceImportCategory {
|
||||||
|
/// Returns a stable lowercase label for reports and golden tests.
|
||||||
|
pub fn as_str(self) -> &'static str {
|
||||||
|
match self {
|
||||||
|
Self::AssetBundle => "asset_bundle",
|
||||||
|
Self::TextAsset => "text_asset",
|
||||||
|
Self::Table => "table",
|
||||||
|
Self::Media => "media",
|
||||||
|
Self::Manifest => "manifest",
|
||||||
|
Self::Other => "other",
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 导入时解析到的 UnityFS 基础结构摘要。
|
/// 导入时解析到的 UnityFS 基础结构摘要。
|
||||||
@@ -58,8 +94,10 @@ pub struct UnityFsImportSummary {
|
|||||||
pub struct ResourceImportReport {
|
pub struct ResourceImportReport {
|
||||||
/// 成功导入的资源。
|
/// 成功导入的资源。
|
||||||
pub imported: Vec<ImportedResource>,
|
pub imported: Vec<ImportedResource>,
|
||||||
/// 被跳过的非 AssetBundle 资源路径。
|
/// 被跳过的资源路径。
|
||||||
pub skipped: Vec<String>,
|
pub skipped: Vec<String>,
|
||||||
|
/// 按稳定分类标签统计的成功导入数量。
|
||||||
|
pub category_counts: BTreeMap<String, usize>,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 将解析后的资源清单写入 CAS 与资源仓储的应用服务。
|
/// 将解析后的资源清单写入 CAS 与资源仓储的应用服务。
|
||||||
@@ -88,10 +126,12 @@ impl<'a> ResourceImportService<'a> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 导入 manifest 中的 AssetBundle 资源。
|
/// 导入 manifest 中可用的资源数据。
|
||||||
///
|
///
|
||||||
/// 非 AssetBundle 条目会记录到 `skipped`。AssetBundle 条目必须能在
|
/// AssetBundle 条目必须能在 `bundles` 中按完整路径或文件名找到对应
|
||||||
/// `bundles` 中按完整路径或文件名找到对应数据,否则返回错误。
|
/// 数据并成功解析 UnityFS,否则返回错误。TextAsset/Table/Media 等
|
||||||
|
/// 非 AssetBundle 条目在有数据时写入 CAS 和 `ResourceRepository`;缺少
|
||||||
|
/// 数据时记录到 `skipped`,用于渐进式导入官方下载结果。
|
||||||
pub async fn import_manifest_bundles(
|
pub async fn import_manifest_bundles(
|
||||||
&self,
|
&self,
|
||||||
manifest: &GenericManifest,
|
manifest: &GenericManifest,
|
||||||
@@ -100,18 +140,23 @@ impl<'a> ResourceImportService<'a> {
|
|||||||
let mut report = ResourceImportReport::default();
|
let mut report = ResourceImportReport::default();
|
||||||
|
|
||||||
for entry in &manifest.resources {
|
for entry in &manifest.resources {
|
||||||
if entry.resource_type != ResourceType::AssetBundle {
|
let category = classify_import_category(entry.resource_type, &entry.path);
|
||||||
|
let Some(bundle) = find_bundle(bundles, &entry.path) else {
|
||||||
|
if entry.resource_type == ResourceType::AssetBundle {
|
||||||
|
return Err(bat_core::Error::InvalidArgument(format!(
|
||||||
|
"Missing bundle data for manifest resource: {}",
|
||||||
|
entry.path
|
||||||
|
)));
|
||||||
|
}
|
||||||
report.skipped.push(entry.path.clone());
|
report.skipped.push(entry.path.clone());
|
||||||
continue;
|
continue;
|
||||||
}
|
};
|
||||||
|
|
||||||
let bundle = find_bundle(bundles, &entry.path).ok_or_else(|| {
|
let unityfs = if entry.resource_type == ResourceType::AssetBundle {
|
||||||
bat_core::Error::InvalidArgument(format!(
|
Some(self.parse_unityfs_summary(&entry.path, bundle).await?)
|
||||||
"Missing bundle data for manifest resource: {}",
|
} else {
|
||||||
entry.path
|
None
|
||||||
))
|
};
|
||||||
})?;
|
|
||||||
let unityfs = self.parse_unityfs_summary(&entry.path, bundle).await?;
|
|
||||||
|
|
||||||
let object_id = self.cas.store(&bundle.data).await?;
|
let object_id = self.cas.store(&bundle.data).await?;
|
||||||
let mut stored_entry = entry.clone();
|
let mut stored_entry = entry.clone();
|
||||||
@@ -128,10 +173,16 @@ impl<'a> ResourceImportService<'a> {
|
|||||||
report.imported.push(ImportedResource {
|
report.imported.push(ImportedResource {
|
||||||
id,
|
id,
|
||||||
source_path: entry.path.clone(),
|
source_path: entry.path.clone(),
|
||||||
|
resource_type: entry.resource_type,
|
||||||
|
category,
|
||||||
object_id,
|
object_id,
|
||||||
bytes: bundle.data.len() as u64,
|
bytes: bundle.data.len() as u64,
|
||||||
unityfs,
|
unityfs,
|
||||||
});
|
});
|
||||||
|
*report
|
||||||
|
.category_counts
|
||||||
|
.entry(category.as_str().to_string())
|
||||||
|
.or_insert(0) += 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
Ok(report)
|
Ok(report)
|
||||||
@@ -196,6 +247,43 @@ fn file_name(path: &str) -> Option<&str> {
|
|||||||
Path::new(path).file_name()?.to_str()
|
Path::new(path).file_name()?.to_str()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn classify_import_category(resource_type: ResourceType, path: &str) -> ResourceImportCategory {
|
||||||
|
match resource_type {
|
||||||
|
ResourceType::AssetBundle => ResourceImportCategory::AssetBundle,
|
||||||
|
ResourceType::TextAsset => ResourceImportCategory::TextAsset,
|
||||||
|
ResourceType::TableBundle => ResourceImportCategory::Table,
|
||||||
|
ResourceType::Media => ResourceImportCategory::Media,
|
||||||
|
ResourceType::Manifest => ResourceImportCategory::Manifest,
|
||||||
|
ResourceType::Other => classify_import_category_from_path(path),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn classify_import_category_from_path(path: &str) -> ResourceImportCategory {
|
||||||
|
let normalized = path.replace('\\', "/").to_ascii_lowercase();
|
||||||
|
if normalized.contains("tablebundles/") {
|
||||||
|
ResourceImportCategory::Table
|
||||||
|
} else if normalized.contains("mediaresources/")
|
||||||
|
|| normalized.contains("mediaresources-")
|
||||||
|
|| matches!(
|
||||||
|
normalized.rsplit('.').next(),
|
||||||
|
Some("mp3" | "mp4" | "ogg" | "wav" | "png" | "jpg" | "jpeg" | "webp" | "acb" | "awb")
|
||||||
|
)
|
||||||
|
{
|
||||||
|
ResourceImportCategory::Media
|
||||||
|
} else if normalized.contains("textassets/")
|
||||||
|
|| matches!(
|
||||||
|
normalized.rsplit('.').next(),
|
||||||
|
Some("txt" | "csv" | "xml" | "yaml" | "yml")
|
||||||
|
)
|
||||||
|
{
|
||||||
|
ResourceImportCategory::TextAsset
|
||||||
|
} else if normalized.contains("catalog") || normalized.ends_with(".hash") {
|
||||||
|
ResourceImportCategory::Manifest
|
||||||
|
} else {
|
||||||
|
ResourceImportCategory::Other
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
@@ -287,6 +375,30 @@ mod tests {
|
|||||||
address: None,
|
address: None,
|
||||||
dependencies: Vec::new(),
|
dependencies: Vec::new(),
|
||||||
},
|
},
|
||||||
|
ResourceEntry {
|
||||||
|
path: "TextAssets/dialogue.csv".to_string(),
|
||||||
|
hash: "synthetic-text-hash".to_string(),
|
||||||
|
size: 1,
|
||||||
|
resource_type: ResourceType::TextAsset,
|
||||||
|
address: Some("dialogue".to_string()),
|
||||||
|
dependencies: Vec::new(),
|
||||||
|
},
|
||||||
|
ResourceEntry {
|
||||||
|
path: "TableBundles/ExcelDB.db".to_string(),
|
||||||
|
hash: "synthetic-table-hash".to_string(),
|
||||||
|
size: 1,
|
||||||
|
resource_type: ResourceType::TableBundle,
|
||||||
|
address: None,
|
||||||
|
dependencies: Vec::new(),
|
||||||
|
},
|
||||||
|
ResourceEntry {
|
||||||
|
path: "MediaResources-Windows/voice/title.acb".to_string(),
|
||||||
|
hash: "synthetic-media-hash".to_string(),
|
||||||
|
size: 1,
|
||||||
|
resource_type: ResourceType::Media,
|
||||||
|
address: None,
|
||||||
|
dependencies: Vec::new(),
|
||||||
|
},
|
||||||
],
|
],
|
||||||
metadata: ManifestMetadata {
|
metadata: ManifestMetadata {
|
||||||
locator_id: None,
|
locator_id: None,
|
||||||
@@ -306,32 +418,78 @@ mod tests {
|
|||||||
let report = service
|
let report = service
|
||||||
.import_manifest_bundles(
|
.import_manifest_bundles(
|
||||||
&synthetic_manifest(),
|
&synthetic_manifest(),
|
||||||
&[BundleSource::new(
|
&[
|
||||||
"minimal.bundle",
|
BundleSource::new("minimal.bundle", synthetic_minimal_unityfs_bundle()),
|
||||||
synthetic_minimal_unityfs_bundle(),
|
BundleSource::new("dialogue.csv", b"id,text\n1,hello".to_vec()),
|
||||||
)],
|
BundleSource::new("ExcelDB.db", b"table-fixture".to_vec()),
|
||||||
|
BundleSource::new("title.acb", b"media-fixture".to_vec()),
|
||||||
|
],
|
||||||
)
|
)
|
||||||
.await
|
.await
|
||||||
.unwrap();
|
.unwrap();
|
||||||
|
|
||||||
assert_eq!(report.imported.len(), 1);
|
assert_eq!(report.imported.len(), 4);
|
||||||
assert_eq!(report.skipped, vec!["synthetic/catalog.json".to_string()]);
|
assert_eq!(report.skipped, vec!["synthetic/catalog.json".to_string()]);
|
||||||
assert!(cas.exists(&report.imported[0].object_id).await);
|
assert!(cas.exists(&report.imported[0].object_id).await);
|
||||||
assert_eq!(report.imported[0].unityfs.unity_version, "2021.3.56f2");
|
assert_eq!(report.imported[0].resource_type, ResourceType::AssetBundle);
|
||||||
assert_eq!(report.imported[0].unityfs.block_count, 1);
|
|
||||||
assert_eq!(report.imported[0].unityfs.directory_count, 1);
|
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
report.imported[0].unityfs.directories,
|
report.imported[0].category,
|
||||||
vec!["SYNTHETIC-CAB".to_string()]
|
ResourceImportCategory::AssetBundle
|
||||||
);
|
);
|
||||||
|
let unityfs = report.imported[0].unityfs.as_ref().unwrap();
|
||||||
|
assert_eq!(unityfs.unity_version, "2021.3.56f2");
|
||||||
|
assert_eq!(unityfs.block_count, 1);
|
||||||
|
assert_eq!(unityfs.directory_count, 1);
|
||||||
|
assert_eq!(unityfs.directories, vec!["SYNTHETIC-CAB".to_string()]);
|
||||||
|
assert_eq!(
|
||||||
|
report.imported[1].category,
|
||||||
|
ResourceImportCategory::TextAsset
|
||||||
|
);
|
||||||
|
assert_eq!(report.imported[2].category, ResourceImportCategory::Table);
|
||||||
|
assert_eq!(report.imported[3].category, ResourceImportCategory::Media);
|
||||||
|
assert_eq!(report.category_counts.get("asset_bundle"), Some(&1));
|
||||||
|
assert_eq!(report.category_counts.get("text_asset"), Some(&1));
|
||||||
|
assert_eq!(report.category_counts.get("table"), Some(&1));
|
||||||
|
assert_eq!(report.category_counts.get("media"), Some(&1));
|
||||||
|
|
||||||
let indexed = resources.list(ResourceQuery::all()).await.unwrap();
|
let indexed = resources.list(ResourceQuery::all()).await.unwrap();
|
||||||
assert_eq!(indexed.len(), 1);
|
assert_eq!(indexed.len(), 4);
|
||||||
assert_eq!(indexed[0].entry.hash, report.imported[0].object_id);
|
let indexed_bundle = indexed
|
||||||
|
.iter()
|
||||||
|
.find(|resource| resource.entry.path == "synthetic/minimal.bundle")
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(indexed_bundle.entry.hash, report.imported[0].object_id);
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
indexed[0].entry.size,
|
indexed_bundle.entry.size,
|
||||||
synthetic_minimal_unityfs_bundle().len() as u64
|
synthetic_minimal_unityfs_bundle().len() as u64
|
||||||
);
|
);
|
||||||
|
assert_eq!(
|
||||||
|
indexed
|
||||||
|
.iter()
|
||||||
|
.find(|resource| resource.entry.path.ends_with("title.acb"))
|
||||||
|
.unwrap()
|
||||||
|
.entry
|
||||||
|
.resource_type,
|
||||||
|
ResourceType::Media
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
indexed
|
||||||
|
.iter()
|
||||||
|
.find(|resource| resource.entry.path.ends_with("ExcelDB.db"))
|
||||||
|
.unwrap()
|
||||||
|
.entry
|
||||||
|
.resource_type,
|
||||||
|
ResourceType::TableBundle
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
indexed
|
||||||
|
.iter()
|
||||||
|
.find(|resource| resource.entry.path.ends_with("dialogue.csv"))
|
||||||
|
.unwrap()
|
||||||
|
.entry
|
||||||
|
.resource_type,
|
||||||
|
ResourceType::TextAsset
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
@@ -370,4 +528,53 @@ mod tests {
|
|||||||
assert!(matches!(error, bat_core::Error::InvalidArgument(_)));
|
assert!(matches!(error, bat_core::Error::InvalidArgument(_)));
|
||||||
assert_eq!(resources.count(ResourceQuery::all()).await.unwrap(), 0);
|
assert_eq!(resources.count(ResourceQuery::all()).await.unwrap(), 0);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn indexes_non_bundle_resources_without_unityfs_summary() {
|
||||||
|
let temp_dir = TempDir::new().unwrap();
|
||||||
|
let cas = FileSystemCasRepository::new(temp_dir.path().join("cas"));
|
||||||
|
let resources = InMemoryResourceRepository::new();
|
||||||
|
let service = ResourceImportService::new(&cas, &resources);
|
||||||
|
let mut manifest = synthetic_manifest();
|
||||||
|
manifest
|
||||||
|
.resources
|
||||||
|
.retain(|entry| entry.resource_type != ResourceType::AssetBundle);
|
||||||
|
|
||||||
|
let report = service
|
||||||
|
.import_manifest_bundles(
|
||||||
|
&manifest,
|
||||||
|
&[
|
||||||
|
BundleSource::new("dialogue.csv", b"id,text\n1,hello".to_vec()),
|
||||||
|
BundleSource::new("ExcelDB.db", b"table-fixture".to_vec()),
|
||||||
|
BundleSource::new("title.acb", b"media-fixture".to_vec()),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
assert_eq!(report.imported.len(), 3);
|
||||||
|
assert!(report.imported.iter().all(|item| item.unityfs.is_none()));
|
||||||
|
assert_eq!(report.skipped, vec!["synthetic/catalog.json".to_string()]);
|
||||||
|
assert_eq!(
|
||||||
|
resources
|
||||||
|
.count(ResourceQuery::by_type(ResourceType::TextAsset))
|
||||||
|
.await
|
||||||
|
.unwrap(),
|
||||||
|
1
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
resources
|
||||||
|
.count(ResourceQuery::by_type(ResourceType::TableBundle))
|
||||||
|
.await
|
||||||
|
.unwrap(),
|
||||||
|
1
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
resources
|
||||||
|
.count(ResourceQuery::by_type(ResourceType::Media))
|
||||||
|
.await
|
||||||
|
.unwrap(),
|
||||||
|
1
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -24,7 +24,10 @@ pub mod resources;
|
|||||||
mod zip_validation;
|
mod zip_validation;
|
||||||
|
|
||||||
pub use cas::FileSystemCasRepository;
|
pub use cas::FileSystemCasRepository;
|
||||||
pub use import::{BundleSource, ImportedResource, ResourceImportReport, ResourceImportService};
|
pub use import::{
|
||||||
|
BundleSource, ImportedResource, ResourceImportCategory, ResourceImportReport,
|
||||||
|
ResourceImportService,
|
||||||
|
};
|
||||||
pub use official_download::{
|
pub use official_download::{
|
||||||
OfficialLocalManifestAuditItem, OfficialLocalManifestAuditReport,
|
OfficialLocalManifestAuditItem, OfficialLocalManifestAuditReport,
|
||||||
OfficialLocalManifestAuditStatus, OfficialLocalVerificationReport, OfficialResourcePullItem,
|
OfficialLocalManifestAuditStatus, OfficialLocalVerificationReport, OfficialResourcePullItem,
|
||||||
@@ -48,11 +51,13 @@ pub use official_sync::{
|
|||||||
};
|
};
|
||||||
pub use official_update::{
|
pub use official_update::{
|
||||||
cached_game_main_config_for_metadata, diff_extended_snapshot, read_bootstrap_cache,
|
cached_game_main_config_for_metadata, diff_extended_snapshot, read_bootstrap_cache,
|
||||||
read_snapshot, write_bootstrap_cache, write_snapshot, ExtendedSnapshotDelta,
|
read_snapshot, read_version_state, write_bootstrap_cache, write_snapshot, write_version_state,
|
||||||
GameMainConfigSnapshot, LauncherMetadataSnapshot, OfficialBootstrapCache,
|
ExtendedSnapshotDelta, GameMainConfigSnapshot, LauncherMetadataSnapshot,
|
||||||
OfficialEndpointMarkerRole, OfficialEndpointMarkerSnapshot, OfficialServerInfoSource,
|
OfficialBootstrapCache, OfficialEndpointMarkerRole, OfficialEndpointMarkerSnapshot,
|
||||||
OfficialUpdateConfig, OfficialUpdateProgress, OfficialUpdateReport, OfficialUpdateService,
|
OfficialFailedVersionRecord, OfficialServerInfoSource, OfficialUpdateConfig,
|
||||||
OfficialUpdateSnapshot, OfficialUpdateStatus, OfficialVerificationSummary, ResolvedBootstrap,
|
OfficialUpdateProgress, OfficialUpdateReport, OfficialUpdateService, OfficialUpdateSnapshot,
|
||||||
|
OfficialUpdateStatus, OfficialVerificationSummary, OfficialVersionRecord, OfficialVersionState,
|
||||||
|
ResolvedBootstrap,
|
||||||
};
|
};
|
||||||
pub use path_security::{
|
pub use path_security::{
|
||||||
ensure_path_within_root, ensure_safe_directory_path, ensure_safe_file_target, lexical_absolute,
|
ensure_path_within_root, ensure_safe_directory_path, ensure_safe_file_target, lexical_absolute,
|
||||||
|
|||||||
@@ -2787,6 +2787,59 @@ exit 22
|
|||||||
assert!(service.read_download_manifest().unwrap().entries.is_empty());
|
assert!(service.read_download_manifest().unwrap().entries.is_empty());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn official_http_failure_regression_fixtures_are_enforced() {
|
||||||
|
for fixture in [
|
||||||
|
include_str!("../tests/fixtures/official_regression/http_403.json"),
|
||||||
|
include_str!("../tests/fixtures/official_regression/http_404.json"),
|
||||||
|
] {
|
||||||
|
let fixture: serde_json::Value = serde_json::from_str(fixture).unwrap();
|
||||||
|
let out_dir = TempDir::new().unwrap();
|
||||||
|
let bin_dir = TempDir::new().unwrap();
|
||||||
|
let curl_path = bin_dir.path().join("curl");
|
||||||
|
let status = fixture["http_status"].as_u64().unwrap() as u16;
|
||||||
|
write_fake_http_error_curl(&curl_path, status);
|
||||||
|
|
||||||
|
let service =
|
||||||
|
OfficialResourcePullService::with_curl_command(out_dir.path(), &curl_path)
|
||||||
|
.with_retry_attempts(3);
|
||||||
|
let url = fixture["url"].as_str().unwrap();
|
||||||
|
let error = service.pull(&one_url_plan(url)).unwrap_err();
|
||||||
|
let expected_kind = fixture["failure_kind"].as_str().unwrap();
|
||||||
|
let expected_retryable = fixture["retryable"].as_bool().unwrap();
|
||||||
|
let expected_attempts = fixture["expected_attempts"].as_u64().unwrap();
|
||||||
|
|
||||||
|
assert!(error.contains(expected_kind));
|
||||||
|
assert!(error.contains(&format!("retryable={expected_retryable}")));
|
||||||
|
assert_eq!(
|
||||||
|
fs::read_to_string(curl_path.with_extension("state")).unwrap(),
|
||||||
|
expected_attempts.to_string()
|
||||||
|
);
|
||||||
|
let quarantine = service.read_download_quarantine().unwrap();
|
||||||
|
let entry = quarantine.entries.get(url).unwrap();
|
||||||
|
assert_eq!(entry.failure_kind.as_deref(), Some(expected_kind));
|
||||||
|
assert_eq!(entry.retryable, Some(expected_retryable));
|
||||||
|
assert_eq!(entry.attempts, expected_attempts as usize);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn official_hash_mismatch_regression_fixture_is_enforced() {
|
||||||
|
let fixture: serde_json::Value = serde_json::from_str(include_str!(
|
||||||
|
"../tests/fixtures/official_regression/hash_mismatch_catalog.json"
|
||||||
|
))
|
||||||
|
.unwrap();
|
||||||
|
let error = verify_official_seed_catalog_hash(
|
||||||
|
fixture["data_url"].as_str().unwrap(),
|
||||||
|
fixture["data"].as_str().unwrap().as_bytes(),
|
||||||
|
fixture["hash_url"].as_str().unwrap(),
|
||||||
|
fixture["official_hash"].as_str().unwrap().as_bytes(),
|
||||||
|
)
|
||||||
|
.unwrap_err();
|
||||||
|
|
||||||
|
assert!(error.contains(fixture["expected_error_contains"].as_str().unwrap()));
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn retries_http_5xx_before_quarantine() {
|
fn retries_http_5xx_before_quarantine() {
|
||||||
let out_dir = TempDir::new().unwrap();
|
let out_dir = TempDir::new().unwrap();
|
||||||
|
|||||||
@@ -37,11 +37,14 @@ use std::time::{SystemTime, UNIX_EPOCH};
|
|||||||
pub const OFFICIAL_UPDATE_SNAPSHOT_VERSION: u32 = 2;
|
pub const OFFICIAL_UPDATE_SNAPSHOT_VERSION: u32 = 2;
|
||||||
/// Current official bootstrap cache schema version.
|
/// Current official bootstrap cache schema version.
|
||||||
pub const OFFICIAL_BOOTSTRAP_CACHE_VERSION: u32 = 1;
|
pub const OFFICIAL_BOOTSTRAP_CACHE_VERSION: u32 = 1;
|
||||||
|
/// Current official version-state schema version.
|
||||||
|
pub const OFFICIAL_VERSION_STATE_VERSION: u32 = 1;
|
||||||
const OFFICIAL_CURRENT_LINK: &str = "current";
|
const OFFICIAL_CURRENT_LINK: &str = "current";
|
||||||
const OFFICIAL_VERSIONS_DIR: &str = "versions";
|
const OFFICIAL_VERSIONS_DIR: &str = "versions";
|
||||||
const OFFICIAL_STAGING_DIR: &str = ".staging";
|
const OFFICIAL_STAGING_DIR: &str = ".staging";
|
||||||
const OFFICIAL_DOWNLOAD_MANIFEST_FILE: &str = "official-download-manifest.json";
|
const OFFICIAL_DOWNLOAD_MANIFEST_FILE: &str = "official-download-manifest.json";
|
||||||
const OFFICIAL_SYNC_SNAPSHOT_FILE: &str = "official-sync-snapshot.json";
|
const OFFICIAL_SYNC_SNAPSHOT_FILE: &str = "official-sync-snapshot.json";
|
||||||
|
const OFFICIAL_VERSION_STATE_FILE: &str = "official-version-state.json";
|
||||||
|
|
||||||
/// Server-info input for an official update run.
|
/// Server-info input for an official update run.
|
||||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
@@ -125,6 +128,11 @@ impl OfficialUpdateConfig {
|
|||||||
self.output_root.join("official-bootstrap-cache.json")
|
self.output_root.join("official-bootstrap-cache.json")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Returns the persistent version-state path for this config.
|
||||||
|
pub fn version_state_path(&self) -> PathBuf {
|
||||||
|
self.output_root.join(OFFICIAL_VERSION_STATE_FILE)
|
||||||
|
}
|
||||||
|
|
||||||
/// Returns the lock path used for non-dry-run executions.
|
/// Returns the lock path used for non-dry-run executions.
|
||||||
pub fn lock_path(&self) -> PathBuf {
|
pub fn lock_path(&self) -> PathBuf {
|
||||||
self.output_root.join(".official-sync.lock")
|
self.output_root.join(".official-sync.lock")
|
||||||
@@ -181,6 +189,82 @@ pub struct OfficialUpdateSnapshot {
|
|||||||
pub game_main_config_bootstrap: Option<GameMainConfigSnapshot>,
|
pub game_main_config_bootstrap: Option<GameMainConfigSnapshot>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Persistent version state for an official resource output root.
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
pub struct OfficialVersionState {
|
||||||
|
/// State schema version.
|
||||||
|
#[serde(default = "default_version_state_version")]
|
||||||
|
pub state_version: u32,
|
||||||
|
/// Last successfully published version.
|
||||||
|
#[serde(default)]
|
||||||
|
pub current_completed_version: Option<OfficialVersionRecord>,
|
||||||
|
/// Version currently being downloaded into staging.
|
||||||
|
#[serde(default)]
|
||||||
|
pub in_progress_version: Option<OfficialVersionRecord>,
|
||||||
|
/// Previously usable version before the latest successful publish.
|
||||||
|
#[serde(default)]
|
||||||
|
pub previous_available_version: Option<OfficialVersionRecord>,
|
||||||
|
/// Recent versions that failed after entering staging.
|
||||||
|
#[serde(default)]
|
||||||
|
pub failed_versions: Vec<OfficialFailedVersionRecord>,
|
||||||
|
/// Last time this state file was updated.
|
||||||
|
pub updated_unix_seconds: u64,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for OfficialVersionState {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self {
|
||||||
|
state_version: OFFICIAL_VERSION_STATE_VERSION,
|
||||||
|
current_completed_version: None,
|
||||||
|
in_progress_version: None,
|
||||||
|
previous_available_version: None,
|
||||||
|
failed_versions: Vec::new(),
|
||||||
|
updated_unix_seconds: unix_seconds_now(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// One version tracked by `official-version-state.json`.
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
pub struct OfficialVersionRecord {
|
||||||
|
/// Stable publish ID under `versions/<id>`.
|
||||||
|
pub id: String,
|
||||||
|
/// Selected app version.
|
||||||
|
pub app_version: String,
|
||||||
|
/// Selected bundle version, when present.
|
||||||
|
pub bundle_version: Option<String>,
|
||||||
|
/// Selected Addressables root.
|
||||||
|
pub addressables_root: String,
|
||||||
|
/// Resource root for this version. For in-progress versions this is a
|
||||||
|
/// staging path; for completed versions it is a versioned path.
|
||||||
|
pub resource_root: PathBuf,
|
||||||
|
/// Snapshot path associated with the resource root.
|
||||||
|
pub snapshot_path: PathBuf,
|
||||||
|
/// Staging path, when the version is still being downloaded.
|
||||||
|
#[serde(default)]
|
||||||
|
pub staging_path: Option<PathBuf>,
|
||||||
|
/// Versioned path, when known.
|
||||||
|
#[serde(default)]
|
||||||
|
pub version_path: Option<PathBuf>,
|
||||||
|
/// Start time for a staged pull.
|
||||||
|
#[serde(default)]
|
||||||
|
pub started_unix_seconds: Option<u64>,
|
||||||
|
/// Completion time for a published version.
|
||||||
|
#[serde(default)]
|
||||||
|
pub completed_unix_seconds: Option<u64>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Failed version entry tracked after a staged pull or publish error.
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
pub struct OfficialFailedVersionRecord {
|
||||||
|
/// Version that failed.
|
||||||
|
pub version: OfficialVersionRecord,
|
||||||
|
/// Human-readable failure reason.
|
||||||
|
pub error: String,
|
||||||
|
/// Failure time.
|
||||||
|
pub failed_unix_seconds: u64,
|
||||||
|
}
|
||||||
|
|
||||||
impl OfficialUpdateSnapshot {
|
impl OfficialUpdateSnapshot {
|
||||||
/// Creates an update snapshot from a base sync snapshot and extended data.
|
/// Creates an update snapshot from a base sync snapshot and extended data.
|
||||||
pub fn new(
|
pub fn new(
|
||||||
@@ -391,6 +475,8 @@ pub struct OfficialUpdateReport {
|
|||||||
pub active_resource_root: PathBuf,
|
pub active_resource_root: PathBuf,
|
||||||
/// Atomic `current` pointer path.
|
/// Atomic `current` pointer path.
|
||||||
pub current_path: PathBuf,
|
pub current_path: PathBuf,
|
||||||
|
/// Persistent version-state path.
|
||||||
|
pub version_state_path: PathBuf,
|
||||||
/// Versioned release directory after a successful publish.
|
/// Versioned release directory after a successful publish.
|
||||||
pub published_version_path: Option<PathBuf>,
|
pub published_version_path: Option<PathBuf>,
|
||||||
/// Staging directory used during download before publish.
|
/// Staging directory used during download before publish.
|
||||||
@@ -754,6 +840,10 @@ impl OfficialUpdateService {
|
|||||||
progress(OfficialUpdateProgress::new("lock", "资源目录状态锁已获取"));
|
progress(OfficialUpdateProgress::new("lock", "资源目录状态锁已获取"));
|
||||||
Some(lock)
|
Some(lock)
|
||||||
};
|
};
|
||||||
|
let version_state_path = config.version_state_path();
|
||||||
|
if !config.dry_run {
|
||||||
|
recover_interrupted_version_state(&version_state_path)?;
|
||||||
|
}
|
||||||
check_shutdown_requested(&mut should_cancel)?;
|
check_shutdown_requested(&mut should_cancel)?;
|
||||||
|
|
||||||
let publish_layout = OfficialPublishLayout::new(&config.output_root);
|
let publish_layout = OfficialPublishLayout::new(&config.output_root);
|
||||||
@@ -1035,6 +1125,7 @@ impl OfficialUpdateService {
|
|||||||
output_root: config.output_root.clone(),
|
output_root: config.output_root.clone(),
|
||||||
active_resource_root: active_resource_root.clone(),
|
active_resource_root: active_resource_root.clone(),
|
||||||
current_path: publish_layout.current_path.clone(),
|
current_path: publish_layout.current_path.clone(),
|
||||||
|
version_state_path: version_state_path.clone(),
|
||||||
published_version_path: None,
|
published_version_path: None,
|
||||||
staging_path: None,
|
staging_path: None,
|
||||||
snapshot_path: snapshot_path.clone(),
|
snapshot_path: snapshot_path.clone(),
|
||||||
@@ -1079,6 +1170,27 @@ impl OfficialUpdateService {
|
|||||||
"audit",
|
"audit",
|
||||||
verification_progress_message(&report.verification_summary),
|
verification_progress_message(&report.verification_summary),
|
||||||
));
|
));
|
||||||
|
if !config.dry_run {
|
||||||
|
let current_record = version_record_for_snapshot(
|
||||||
|
¤t_update_snapshot,
|
||||||
|
VersionRecordInput {
|
||||||
|
id: version_id_from_path(&active_resource_root)
|
||||||
|
.unwrap_or_else(|| fallback_version_id(¤t_update_snapshot)),
|
||||||
|
resource_root: active_resource_root.clone(),
|
||||||
|
snapshot_path: snapshot_path.clone(),
|
||||||
|
staging_path: None,
|
||||||
|
version_path: Some(active_resource_root.clone()),
|
||||||
|
started_unix_seconds: None,
|
||||||
|
completed_unix_seconds: Some(unix_seconds_now()),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
complete_version_state(
|
||||||
|
&version_state_path,
|
||||||
|
current_record,
|
||||||
|
&active_resource_root,
|
||||||
|
&snapshot_path,
|
||||||
|
)?;
|
||||||
|
}
|
||||||
progress(OfficialUpdateProgress::new(
|
progress(OfficialUpdateProgress::new(
|
||||||
"finish",
|
"finish",
|
||||||
"资源已是最新;无需下载",
|
"资源已是最新;无需下载",
|
||||||
@@ -1129,11 +1241,22 @@ impl OfficialUpdateService {
|
|||||||
publish_layout
|
publish_layout
|
||||||
.seed_staging_from_active(&active_resource_root, &publish_plan.staging_path)
|
.seed_staging_from_active(&active_resource_root, &publish_plan.staging_path)
|
||||||
.map_err(anyhow::Error::msg)?;
|
.map_err(anyhow::Error::msg)?;
|
||||||
|
let staging_snapshot_path = snapshot_path_for(config, &publish_plan.staging_path);
|
||||||
|
let in_progress_record = prepare_in_progress_version_state(
|
||||||
|
&version_state_path,
|
||||||
|
¤t_update_snapshot,
|
||||||
|
&publish_plan.id,
|
||||||
|
&active_resource_root,
|
||||||
|
previous_snapshot.as_ref(),
|
||||||
|
&publish_plan.staging_path,
|
||||||
|
&staging_snapshot_path,
|
||||||
|
)?;
|
||||||
|
let mut version_state_guard =
|
||||||
|
VersionStateGuard::new(version_state_path.clone(), in_progress_record);
|
||||||
let staging_fetcher = OfficialResourcePullService::with_curl_command(
|
let staging_fetcher = OfficialResourcePullService::with_curl_command(
|
||||||
&publish_plan.staging_path,
|
&publish_plan.staging_path,
|
||||||
&config.curl_command,
|
&config.curl_command,
|
||||||
);
|
);
|
||||||
let staging_snapshot_path = snapshot_path_for(config, &publish_plan.staging_path);
|
|
||||||
report.staging_path = Some(publish_plan.staging_path.clone());
|
report.staging_path = Some(publish_plan.staging_path.clone());
|
||||||
report.snapshot_path = staging_snapshot_path.clone();
|
report.snapshot_path = staging_snapshot_path.clone();
|
||||||
report.download_manifest = staging_fetcher.download_manifest_path();
|
report.download_manifest = staging_fetcher.download_manifest_path();
|
||||||
@@ -1150,7 +1273,10 @@ impl OfficialUpdateService {
|
|||||||
},
|
},
|
||||||
&mut should_cancel,
|
&mut should_cancel,
|
||||||
)
|
)
|
||||||
.map_err(anyhow::Error::msg)?;
|
.map_err(|error| anyhow::anyhow!(error))
|
||||||
|
.inspect_err(|error| {
|
||||||
|
let _ = version_state_guard.fail(&error.to_string());
|
||||||
|
})?;
|
||||||
progress(OfficialUpdateProgress::new(
|
progress(OfficialUpdateProgress::new(
|
||||||
"download",
|
"download",
|
||||||
format!(
|
format!(
|
||||||
@@ -1217,7 +1343,15 @@ impl OfficialUpdateService {
|
|||||||
report.local_manifest_verified_count = final_audit.verified_count();
|
report.local_manifest_verified_count = final_audit.verified_count();
|
||||||
report.local_manifest_repair_needed_count = final_audit.repair_needed_count();
|
report.local_manifest_repair_needed_count = final_audit.repair_needed_count();
|
||||||
report.verification_summary = final_verification_summary;
|
report.verification_summary = final_verification_summary;
|
||||||
report.snapshot_written = Some(final_snapshot_path);
|
report.snapshot_written = Some(final_snapshot_path.clone());
|
||||||
|
let completed_record = version_state_guard.record()?.clone();
|
||||||
|
complete_version_state(
|
||||||
|
&version_state_path,
|
||||||
|
completed_record,
|
||||||
|
&published_version_path,
|
||||||
|
&final_snapshot_path,
|
||||||
|
)?;
|
||||||
|
version_state_guard.commit();
|
||||||
|
|
||||||
progress(OfficialUpdateProgress::new(
|
progress(OfficialUpdateProgress::new(
|
||||||
"finish",
|
"finish",
|
||||||
@@ -1505,6 +1639,11 @@ fn validate_update_paths(config: &OfficialUpdateConfig) -> Result<(), String> {
|
|||||||
&config.bootstrap_cache_path(),
|
&config.bootstrap_cache_path(),
|
||||||
"官方启动缓存",
|
"官方启动缓存",
|
||||||
)?;
|
)?;
|
||||||
|
ensure_safe_file_target(
|
||||||
|
&config.output_root,
|
||||||
|
&config.version_state_path(),
|
||||||
|
"官方版本状态",
|
||||||
|
)?;
|
||||||
ensure_safe_file_target(&config.output_root, &config.lock_path(), "官方同步锁")?;
|
ensure_safe_file_target(&config.output_root, &config.lock_path(), "官方同步锁")?;
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
@@ -1725,6 +1864,30 @@ pub fn write_snapshot(path: &Path, snapshot: &OfficialUpdateSnapshot) -> anyhow:
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Reads the persistent official version state.
|
||||||
|
pub fn read_version_state(path: &Path) -> anyhow::Result<Option<OfficialVersionState>> {
|
||||||
|
let Some(data) = read_file_no_symlink(path, "官方版本状态").map_err(anyhow::Error::msg)?
|
||||||
|
else {
|
||||||
|
return Ok(None);
|
||||||
|
};
|
||||||
|
let state: OfficialVersionState = serde_json::from_slice(&data)?;
|
||||||
|
if state.state_version != OFFICIAL_VERSION_STATE_VERSION {
|
||||||
|
return Err(anyhow::anyhow!(
|
||||||
|
"不支持的官方版本状态 schema:{},当前版本={}",
|
||||||
|
state.state_version,
|
||||||
|
OFFICIAL_VERSION_STATE_VERSION
|
||||||
|
));
|
||||||
|
}
|
||||||
|
Ok(Some(state))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Writes the persistent official version state atomically.
|
||||||
|
pub fn write_version_state(path: &Path, state: &OfficialVersionState) -> anyhow::Result<()> {
|
||||||
|
let data = serde_json::to_vec_pretty(state)?;
|
||||||
|
write_file_atomic(path, &data, STATE_FILE_MODE, "官方版本状态").map_err(anyhow::Error::msg)?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
/// Reads an official bootstrap cache from disk.
|
/// Reads an official bootstrap cache from disk.
|
||||||
pub fn read_bootstrap_cache(path: &Path) -> anyhow::Result<Option<OfficialBootstrapCache>> {
|
pub fn read_bootstrap_cache(path: &Path) -> anyhow::Result<Option<OfficialBootstrapCache>> {
|
||||||
let Some(data) = read_file_no_symlink(path, "官方启动缓存").map_err(anyhow::Error::msg)?
|
let Some(data) = read_file_no_symlink(path, "官方启动缓存").map_err(anyhow::Error::msg)?
|
||||||
@@ -1763,6 +1926,227 @@ fn default_bootstrap_cache_version() -> u32 {
|
|||||||
OFFICIAL_BOOTSTRAP_CACHE_VERSION
|
OFFICIAL_BOOTSTRAP_CACHE_VERSION
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn default_version_state_version() -> u32 {
|
||||||
|
OFFICIAL_VERSION_STATE_VERSION
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug)]
|
||||||
|
struct VersionRecordInput {
|
||||||
|
id: String,
|
||||||
|
resource_root: PathBuf,
|
||||||
|
snapshot_path: PathBuf,
|
||||||
|
staging_path: Option<PathBuf>,
|
||||||
|
version_path: Option<PathBuf>,
|
||||||
|
started_unix_seconds: Option<u64>,
|
||||||
|
completed_unix_seconds: Option<u64>,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn version_record_for_snapshot(
|
||||||
|
snapshot: &OfficialUpdateSnapshot,
|
||||||
|
input: VersionRecordInput,
|
||||||
|
) -> OfficialVersionRecord {
|
||||||
|
OfficialVersionRecord {
|
||||||
|
id: input.id,
|
||||||
|
app_version: snapshot.app_version.clone(),
|
||||||
|
bundle_version: snapshot.bundle_version.clone(),
|
||||||
|
addressables_root: snapshot.addressables_root.clone(),
|
||||||
|
resource_root: input.resource_root,
|
||||||
|
snapshot_path: input.snapshot_path,
|
||||||
|
staging_path: input.staging_path,
|
||||||
|
version_path: input.version_path,
|
||||||
|
started_unix_seconds: input.started_unix_seconds,
|
||||||
|
completed_unix_seconds: input.completed_unix_seconds,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn recover_interrupted_version_state(path: &Path) -> anyhow::Result<()> {
|
||||||
|
let Some(mut state) = read_version_state(path)? else {
|
||||||
|
return Ok(());
|
||||||
|
};
|
||||||
|
let Some(version) = state.in_progress_version.take() else {
|
||||||
|
return Ok(());
|
||||||
|
};
|
||||||
|
|
||||||
|
state.failed_versions.push(OfficialFailedVersionRecord {
|
||||||
|
version,
|
||||||
|
error: "上一次官方资源同步在完成发布前中断".to_string(),
|
||||||
|
failed_unix_seconds: unix_seconds_now(),
|
||||||
|
});
|
||||||
|
trim_failed_versions(&mut state.failed_versions);
|
||||||
|
state.updated_unix_seconds = unix_seconds_now();
|
||||||
|
write_version_state(path, &state)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn prepare_in_progress_version_state(
|
||||||
|
path: &Path,
|
||||||
|
snapshot: &OfficialUpdateSnapshot,
|
||||||
|
publish_id: &str,
|
||||||
|
active_resource_root: &Path,
|
||||||
|
previous_snapshot: Option<&OfficialUpdateSnapshot>,
|
||||||
|
staging_path: &Path,
|
||||||
|
snapshot_path: &Path,
|
||||||
|
) -> anyhow::Result<OfficialVersionRecord> {
|
||||||
|
let mut state = read_version_state(path)?.unwrap_or_default();
|
||||||
|
let fallback_current = previous_snapshot.map(|snapshot| {
|
||||||
|
version_record_for_snapshot(
|
||||||
|
snapshot,
|
||||||
|
VersionRecordInput {
|
||||||
|
id: version_id_from_path(active_resource_root)
|
||||||
|
.unwrap_or_else(|| fallback_version_id(snapshot)),
|
||||||
|
resource_root: active_resource_root.to_path_buf(),
|
||||||
|
snapshot_path: snapshot_path_for_state_path(snapshot, active_resource_root),
|
||||||
|
staging_path: None,
|
||||||
|
version_path: Some(active_resource_root.to_path_buf()),
|
||||||
|
started_unix_seconds: None,
|
||||||
|
completed_unix_seconds: Some(unix_seconds_now()),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
});
|
||||||
|
if state.current_completed_version.is_none() {
|
||||||
|
state.current_completed_version = fallback_current;
|
||||||
|
}
|
||||||
|
|
||||||
|
let now = unix_seconds_now();
|
||||||
|
let record = version_record_for_snapshot(
|
||||||
|
snapshot,
|
||||||
|
VersionRecordInput {
|
||||||
|
id: publish_id.to_string(),
|
||||||
|
resource_root: staging_path.to_path_buf(),
|
||||||
|
snapshot_path: snapshot_path.to_path_buf(),
|
||||||
|
staging_path: Some(staging_path.to_path_buf()),
|
||||||
|
version_path: None,
|
||||||
|
started_unix_seconds: Some(now),
|
||||||
|
completed_unix_seconds: None,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
state.in_progress_version = Some(record.clone());
|
||||||
|
state.updated_unix_seconds = now;
|
||||||
|
write_version_state(path, &state)?;
|
||||||
|
Ok(record)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn complete_version_state(
|
||||||
|
path: &Path,
|
||||||
|
record: OfficialVersionRecord,
|
||||||
|
published_path: &Path,
|
||||||
|
snapshot_path: &Path,
|
||||||
|
) -> anyhow::Result<()> {
|
||||||
|
let mut state = read_version_state(path)?.unwrap_or_default();
|
||||||
|
let previous = state
|
||||||
|
.current_completed_version
|
||||||
|
.take()
|
||||||
|
.filter(|previous| previous.id != record.id);
|
||||||
|
if previous.is_some() {
|
||||||
|
state.previous_available_version = previous;
|
||||||
|
}
|
||||||
|
state.current_completed_version = Some(OfficialVersionRecord {
|
||||||
|
resource_root: published_path.to_path_buf(),
|
||||||
|
snapshot_path: snapshot_path.to_path_buf(),
|
||||||
|
staging_path: None,
|
||||||
|
version_path: Some(published_path.to_path_buf()),
|
||||||
|
completed_unix_seconds: Some(unix_seconds_now()),
|
||||||
|
..record
|
||||||
|
});
|
||||||
|
state.in_progress_version = None;
|
||||||
|
state.updated_unix_seconds = unix_seconds_now();
|
||||||
|
write_version_state(path, &state)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn fail_version_state(
|
||||||
|
path: &Path,
|
||||||
|
record: OfficialVersionRecord,
|
||||||
|
error: &str,
|
||||||
|
) -> anyhow::Result<()> {
|
||||||
|
let mut state = read_version_state(path)?.unwrap_or_default();
|
||||||
|
state.in_progress_version = None;
|
||||||
|
state.failed_versions.push(OfficialFailedVersionRecord {
|
||||||
|
version: record,
|
||||||
|
error: error.to_string(),
|
||||||
|
failed_unix_seconds: unix_seconds_now(),
|
||||||
|
});
|
||||||
|
trim_failed_versions(&mut state.failed_versions);
|
||||||
|
state.updated_unix_seconds = unix_seconds_now();
|
||||||
|
write_version_state(path, &state)
|
||||||
|
}
|
||||||
|
|
||||||
|
struct VersionStateGuard {
|
||||||
|
path: PathBuf,
|
||||||
|
record: Option<OfficialVersionRecord>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl VersionStateGuard {
|
||||||
|
fn new(path: PathBuf, record: OfficialVersionRecord) -> Self {
|
||||||
|
Self {
|
||||||
|
path,
|
||||||
|
record: Some(record),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn record(&self) -> anyhow::Result<&OfficialVersionRecord> {
|
||||||
|
self.record
|
||||||
|
.as_ref()
|
||||||
|
.ok_or_else(|| anyhow::anyhow!("官方版本状态事务已结束"))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn fail(&mut self, error: &str) -> anyhow::Result<()> {
|
||||||
|
let Some(record) = self.record.take() else {
|
||||||
|
return Ok(());
|
||||||
|
};
|
||||||
|
fail_version_state(&self.path, record, error)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn commit(&mut self) {
|
||||||
|
self.record = None;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Drop for VersionStateGuard {
|
||||||
|
fn drop(&mut self) {
|
||||||
|
let Some(record) = self.record.take() else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
let _ = fail_version_state(
|
||||||
|
&self.path,
|
||||||
|
record,
|
||||||
|
"官方资源同步未完成;已将该版本记录为失败,保留当前可用版本",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn trim_failed_versions(failed_versions: &mut Vec<OfficialFailedVersionRecord>) {
|
||||||
|
const MAX_FAILED_VERSIONS: usize = 8;
|
||||||
|
if failed_versions.len() > MAX_FAILED_VERSIONS {
|
||||||
|
let drop_count = failed_versions.len() - MAX_FAILED_VERSIONS;
|
||||||
|
failed_versions.drain(0..drop_count);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn snapshot_path_for_state_path(
|
||||||
|
_snapshot: &OfficialUpdateSnapshot,
|
||||||
|
resource_root: &Path,
|
||||||
|
) -> PathBuf {
|
||||||
|
resource_root.join(OFFICIAL_SYNC_SNAPSHOT_FILE)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn version_id_from_path(path: &Path) -> Option<String> {
|
||||||
|
path.file_name()
|
||||||
|
.and_then(|value| value.to_str())
|
||||||
|
.filter(|value| !value.is_empty())
|
||||||
|
.map(ToOwned::to_owned)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn fallback_version_id(snapshot: &OfficialUpdateSnapshot) -> String {
|
||||||
|
format!(
|
||||||
|
"{}-{}",
|
||||||
|
sanitize_publish_segment(&snapshot.app_version),
|
||||||
|
snapshot
|
||||||
|
.bundle_version
|
||||||
|
.as_deref()
|
||||||
|
.map(sanitize_publish_segment)
|
||||||
|
.unwrap_or_else(|| "no-bundle".to_string())
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
fn launcher_metadata_from_parts(
|
fn launcher_metadata_from_parts(
|
||||||
launcher_version: &str,
|
launcher_version: &str,
|
||||||
game_config: &YostarJpLauncherGameConfig,
|
game_config: &YostarJpLauncherGameConfig,
|
||||||
@@ -2268,6 +2652,118 @@ mod tests {
|
|||||||
assert_eq!(read_bootstrap_cache(&path).unwrap(), Some(cache));
|
assert_eq!(read_bootstrap_cache(&path).unwrap(), Some(cache));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn persists_version_state_json_round_trip() {
|
||||||
|
let temp = tempfile::TempDir::new().unwrap();
|
||||||
|
let path = temp.path().join("official-version-state.json");
|
||||||
|
let snapshot = OfficialUpdateSnapshot::new(fixture_base_snapshot(), Vec::new(), None);
|
||||||
|
let current = version_record_for_snapshot(
|
||||||
|
&snapshot,
|
||||||
|
VersionRecordInput {
|
||||||
|
id: "1.70.0-bundle-current".to_string(),
|
||||||
|
resource_root: temp.path().join("versions/current"),
|
||||||
|
snapshot_path: temp
|
||||||
|
.path()
|
||||||
|
.join("versions/current/official-sync-snapshot.json"),
|
||||||
|
staging_path: None,
|
||||||
|
version_path: Some(temp.path().join("versions/current")),
|
||||||
|
started_unix_seconds: None,
|
||||||
|
completed_unix_seconds: Some(10),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
let failed = OfficialFailedVersionRecord {
|
||||||
|
version: current.clone(),
|
||||||
|
error: "fixture failure".to_string(),
|
||||||
|
failed_unix_seconds: 11,
|
||||||
|
};
|
||||||
|
let state = OfficialVersionState {
|
||||||
|
current_completed_version: Some(current),
|
||||||
|
failed_versions: vec![failed],
|
||||||
|
updated_unix_seconds: 12,
|
||||||
|
..OfficialVersionState::default()
|
||||||
|
};
|
||||||
|
|
||||||
|
write_version_state(&path, &state).unwrap();
|
||||||
|
assert_eq!(read_version_state(&path).unwrap(), Some(state));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn version_state_tracks_in_progress_success_and_failure() {
|
||||||
|
let temp = tempfile::TempDir::new().unwrap();
|
||||||
|
let path = temp.path().join("official-version-state.json");
|
||||||
|
let active = temp.path().join("versions/previous");
|
||||||
|
let staging = temp.path().join(".staging/current");
|
||||||
|
let staging_snapshot = staging.join("official-sync-snapshot.json");
|
||||||
|
let published = temp.path().join("versions/current");
|
||||||
|
let previous_snapshot = OfficialUpdateSnapshot::new(
|
||||||
|
fixture_base_snapshot(),
|
||||||
|
vec![fixture_marker("previous")],
|
||||||
|
None,
|
||||||
|
);
|
||||||
|
let current_snapshot = OfficialUpdateSnapshot::new(
|
||||||
|
fixture_base_snapshot(),
|
||||||
|
vec![fixture_marker("current")],
|
||||||
|
Some(&fixture_bootstrap()),
|
||||||
|
);
|
||||||
|
|
||||||
|
let in_progress = prepare_in_progress_version_state(
|
||||||
|
&path,
|
||||||
|
¤t_snapshot,
|
||||||
|
"current",
|
||||||
|
&active,
|
||||||
|
Some(&previous_snapshot),
|
||||||
|
&staging,
|
||||||
|
&staging_snapshot,
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
let state = read_version_state(&path).unwrap().unwrap();
|
||||||
|
assert_eq!(state.in_progress_version.as_ref().unwrap().id, "current");
|
||||||
|
assert_eq!(
|
||||||
|
state.current_completed_version.as_ref().unwrap().id,
|
||||||
|
"previous"
|
||||||
|
);
|
||||||
|
|
||||||
|
complete_version_state(
|
||||||
|
&path,
|
||||||
|
in_progress.clone(),
|
||||||
|
&published,
|
||||||
|
&published.join("official-sync-snapshot.json"),
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
let state = read_version_state(&path).unwrap().unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
state.current_completed_version.as_ref().unwrap().id,
|
||||||
|
"current"
|
||||||
|
);
|
||||||
|
assert!(state.in_progress_version.is_none());
|
||||||
|
assert_eq!(
|
||||||
|
state.previous_available_version.as_ref().unwrap().id,
|
||||||
|
"previous"
|
||||||
|
);
|
||||||
|
|
||||||
|
let failed = prepare_in_progress_version_state(
|
||||||
|
&path,
|
||||||
|
¤t_snapshot,
|
||||||
|
"broken",
|
||||||
|
&published,
|
||||||
|
state
|
||||||
|
.current_completed_version
|
||||||
|
.as_ref()
|
||||||
|
.map(|_| ¤t_snapshot),
|
||||||
|
&temp.path().join(".staging/broken"),
|
||||||
|
&temp
|
||||||
|
.path()
|
||||||
|
.join(".staging/broken/official-sync-snapshot.json"),
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
fail_version_state(&path, failed, "download 403").unwrap();
|
||||||
|
let state = read_version_state(&path).unwrap().unwrap();
|
||||||
|
assert!(state.in_progress_version.is_none());
|
||||||
|
assert_eq!(state.failed_versions.len(), 1);
|
||||||
|
assert_eq!(state.failed_versions[0].version.id, "broken");
|
||||||
|
assert!(state.failed_versions[0].error.contains("403"));
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn update_rejects_dangerous_output_root_before_network_work() {
|
fn update_rejects_dangerous_output_root_before_network_work() {
|
||||||
let config = OfficialUpdateConfig {
|
let config = OfficialUpdateConfig {
|
||||||
|
|||||||
@@ -187,6 +187,8 @@ impl SqliteResourceRepository {
|
|||||||
ResourceType::AssetBundle => "AssetBundle",
|
ResourceType::AssetBundle => "AssetBundle",
|
||||||
ResourceType::Manifest => "Manifest",
|
ResourceType::Manifest => "Manifest",
|
||||||
ResourceType::TableBundle => "TableBundle",
|
ResourceType::TableBundle => "TableBundle",
|
||||||
|
ResourceType::TextAsset => "TextAsset",
|
||||||
|
ResourceType::Media => "Media",
|
||||||
ResourceType::Other => "Other",
|
ResourceType::Other => "Other",
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -196,6 +198,8 @@ impl SqliteResourceRepository {
|
|||||||
"AssetBundle" => Ok(ResourceType::AssetBundle),
|
"AssetBundle" => Ok(ResourceType::AssetBundle),
|
||||||
"Manifest" => Ok(ResourceType::Manifest),
|
"Manifest" => Ok(ResourceType::Manifest),
|
||||||
"TableBundle" => Ok(ResourceType::TableBundle),
|
"TableBundle" => Ok(ResourceType::TableBundle),
|
||||||
|
"TextAsset" => Ok(ResourceType::TextAsset),
|
||||||
|
"Media" => Ok(ResourceType::Media),
|
||||||
"Other" => Ok(ResourceType::Other),
|
"Other" => Ok(ResourceType::Other),
|
||||||
other => Err(bat_core::Error::Serialization(format!(
|
other => Err(bat_core::Error::Serialization(format!(
|
||||||
"Unknown resource type: {}",
|
"Unknown resource type: {}",
|
||||||
|
|||||||
@@ -0,0 +1,7 @@
|
|||||||
|
{
|
||||||
|
"data_url": "https://prod-clientpatch.bluearchiveyostar.com/r93_token/TableBundles/TableCatalog.bytes",
|
||||||
|
"hash_url": "https://prod-clientpatch.bluearchiveyostar.com/r93_token/TableBundles/TableCatalog.hash",
|
||||||
|
"data": "ExcelDB.db",
|
||||||
|
"official_hash": "0",
|
||||||
|
"expected_error_contains": "官方 hash 校验失败"
|
||||||
|
}
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
{
|
||||||
|
"url": "https://prod-clientpatch.bluearchiveyostar.com/r93_token/TableBundles/Forbidden.bytes",
|
||||||
|
"http_status": 403,
|
||||||
|
"failure_kind": "http_forbidden",
|
||||||
|
"retryable": false,
|
||||||
|
"expected_attempts": 1
|
||||||
|
}
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
{
|
||||||
|
"url": "https://prod-clientpatch.bluearchiveyostar.com/r93_token/TableBundles/Missing.bytes",
|
||||||
|
"http_status": 404,
|
||||||
|
"failure_kind": "http_not_found",
|
||||||
|
"retryable": false,
|
||||||
|
"expected_attempts": 1
|
||||||
|
}
|
||||||
@@ -2,6 +2,8 @@
|
|||||||
"imported": [
|
"imported": [
|
||||||
{
|
{
|
||||||
"bytes": 143,
|
"bytes": 143,
|
||||||
|
"category": "asset_bundle",
|
||||||
|
"resource_type": "AssetBundle",
|
||||||
"source_path": "synthetic/minimal.bundle",
|
"source_path": "synthetic/minimal.bundle",
|
||||||
"unityfs": {
|
"unityfs": {
|
||||||
"block_count": 1,
|
"block_count": 1,
|
||||||
@@ -13,6 +15,9 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
|
"category_counts": {
|
||||||
|
"asset_bundle": 1
|
||||||
|
},
|
||||||
"resource_count": 1,
|
"resource_count": 1,
|
||||||
"skipped": [
|
"skipped": [
|
||||||
"synthetic/catalog.json"
|
"synthetic/catalog.json"
|
||||||
|
|||||||
@@ -7,9 +7,9 @@ use bat_adapters::official::yostar_jp::{
|
|||||||
YostarJpResourceDiscoveryPlan, YostarJpResourceEndpointKind, YostarJpServerInfo,
|
YostarJpResourceDiscoveryPlan, YostarJpResourceEndpointKind, YostarJpServerInfo,
|
||||||
};
|
};
|
||||||
use bat_infrastructure::{
|
use bat_infrastructure::{
|
||||||
build_official_pull_plan_from_platform_inventory, OfficialGameMainConfigBootstrapService,
|
build_official_pull_plan_from_platform_inventory, read_version_state,
|
||||||
OfficialResourcePullService, OfficialUpdateConfig, OfficialUpdateProgress,
|
OfficialGameMainConfigBootstrapService, OfficialResourcePullService, OfficialUpdateConfig,
|
||||||
OfficialUpdateService, OfficialUpdateStatus,
|
OfficialUpdateProgress, OfficialUpdateService, OfficialUpdateStatus,
|
||||||
};
|
};
|
||||||
use std::collections::HashMap;
|
use std::collections::HashMap;
|
||||||
use std::fs;
|
use std::fs;
|
||||||
@@ -252,15 +252,26 @@ fn official_update_first_run_pulls_without_pre_audit() {
|
|||||||
.unwrap()
|
.unwrap()
|
||||||
.file_type()
|
.file_type()
|
||||||
.is_symlink());
|
.is_symlink());
|
||||||
assert_eq!(
|
assert!(!config
|
||||||
config
|
.output_root
|
||||||
.output_root
|
.join("official-download-manifest.json")
|
||||||
.join("official-download-manifest.json")
|
.exists());
|
||||||
.exists(),
|
|
||||||
false
|
|
||||||
);
|
|
||||||
assert!(published.join("official-download-manifest.json").exists());
|
assert!(published.join("official-download-manifest.json").exists());
|
||||||
assert!(published.join("official-sync-snapshot.json").exists());
|
assert!(published.join("official-sync-snapshot.json").exists());
|
||||||
|
let version_state = read_version_state(&report.version_state_path)
|
||||||
|
.unwrap()
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
version_state
|
||||||
|
.current_completed_version
|
||||||
|
.as_ref()
|
||||||
|
.unwrap()
|
||||||
|
.version_path
|
||||||
|
.as_deref(),
|
||||||
|
Some(published.as_path())
|
||||||
|
);
|
||||||
|
assert!(version_state.in_progress_version.is_none());
|
||||||
|
assert!(version_state.failed_versions.is_empty());
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -305,6 +316,18 @@ fn official_update_second_run_audits_existing_resources_before_reuse() {
|
|||||||
.unwrap()
|
.unwrap()
|
||||||
.file_type()
|
.file_type()
|
||||||
.is_symlink());
|
.is_symlink());
|
||||||
|
let version_state = read_version_state(&report.version_state_path)
|
||||||
|
.unwrap()
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
version_state
|
||||||
|
.current_completed_version
|
||||||
|
.as_ref()
|
||||||
|
.unwrap()
|
||||||
|
.resource_root,
|
||||||
|
report.active_resource_root
|
||||||
|
);
|
||||||
|
assert!(version_state.in_progress_version.is_none());
|
||||||
}
|
}
|
||||||
|
|
||||||
struct TestHarness {
|
struct TestHarness {
|
||||||
|
|||||||
@@ -99,17 +99,20 @@ async fn imports_synthetic_addressables_catalog_bundle_against_golden() {
|
|||||||
"imported": report.imported.iter().map(|imported| {
|
"imported": report.imported.iter().map(|imported| {
|
||||||
json!({
|
json!({
|
||||||
"bytes": imported.bytes,
|
"bytes": imported.bytes,
|
||||||
|
"category": imported.category.as_str(),
|
||||||
|
"resource_type": format!("{:?}", imported.resource_type),
|
||||||
"source_path": imported.source_path,
|
"source_path": imported.source_path,
|
||||||
"unityfs": {
|
"unityfs": imported.unityfs.as_ref().map(|unityfs| json!({
|
||||||
"block_count": imported.unityfs.block_count,
|
"block_count": unityfs.block_count,
|
||||||
"directories": imported.unityfs.directories,
|
"directories": unityfs.directories,
|
||||||
"directory_count": imported.unityfs.directory_count,
|
"directory_count": unityfs.directory_count,
|
||||||
"unity_version": imported.unityfs.unity_version,
|
"unity_version": unityfs.unity_version,
|
||||||
}
|
})),
|
||||||
})
|
})
|
||||||
}).collect::<Vec<_>>(),
|
}).collect::<Vec<_>>(),
|
||||||
"resource_count": indexed.len(),
|
"resource_count": indexed.len(),
|
||||||
"skipped": report.skipped,
|
"skipped": report.skipped,
|
||||||
|
"category_counts": report.category_counts,
|
||||||
});
|
});
|
||||||
let expected: serde_json::Value =
|
let expected: serde_json::Value =
|
||||||
serde_json::from_str(include_str!("golden/synthetic_phase2_import.json")).unwrap();
|
serde_json::from_str(include_str!("golden/synthetic_phase2_import.json")).unwrap();
|
||||||
|
|||||||
Reference in New Issue
Block a user