收口 AGC 模板库客户端接入里程碑:补缓存回退与建项失败清理证据
Project CI / AI game creator shell Rust lane 1/2 (push) Has been cancelled
Project CI / AI game creator shell Rust lane 2/2 (push) Has been cancelled
Project CI / AI game creator shell Rust smoke (push) Has been cancelled
Project CI / AI game creator shell Rust crates (push) Has been cancelled
Project CI / Backend tests (push) Has been cancelled
Project CI / Native shell tests (push) Has been cancelled
Project CI / Frontend tests (push) Has been cancelled
Project CI / Repository checks (push) Has been cancelled
Project CI / AI game creator shell web tests (push) Has been cancelled

- 客户端清单来源抽成可测判定:远端合法正文优先并标 network,远端失败回退本机缓存并标 cache,无缓存时暴露远端错误
- 远端已答话但正文非法(不是合法 UTF-8 或不符合 schema)与缓存自身损坏时一律失败关闭,不用缓存掩盖远端问题
- 补 5 项清单来源判定用例
- 大小 / 摘要不一致与越界归档用例补断言:拒绝后不留安装目录、不产生已下载判据
- 新增建项失败清理用例:复制阶段失败时删除刚创建的项目目录,不留半成品
- 主规范并入清单来源与安装判据口径,并写入 2026-09-21 复核证据
- 里程碑置为 accepted 并补 7 条验收项证据表,按工作流删除对应实施计划
- 验证:cargo test template_library 24 项与 --ignored 线上 3 项、前端 4 个模板用例文件 38 项、appSurface 首页模板 2 项、AGC typecheck、check:encoding、check:doc-index、cargo fmt --check、git diff --check
This commit is contained in:
kdletters
2026-09-21 14:40:15 +08:00
parent 4d313a44eb
commit 19ed1776f1
4 changed files with 155 additions and 78 deletions
@@ -807,6 +807,45 @@ async fn ensure_template_installed(
})
}
/// 清单来源:远端读取或本机缓存兜底。快照里的 `source` 原样回传,前端据此提示「本机缓存」。
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
enum TemplateIndexSource {
Network,
Cache,
}
impl TemplateIndexSource {
fn as_str(self) -> &'static str {
match self {
Self::Network => "network",
Self::Cache => "cache",
}
}
}
/// 远端清单不可用时回退本机缓存。
///
/// 两条路径都要过同一份 schema 校验:远端已经答话但正文非法时直接失败关闭,
/// 不用缓存掩盖;缓存自己损坏时也不能被当成可用清单。
fn resolve_template_index(
remote: Result<String, String>,
cached: Option<String>,
) -> Result<(String, TemplateIndexSource), String> {
match remote {
Ok(body) => {
parse_game_template_library_index(&body)?;
Ok((body, TemplateIndexSource::Network))
}
Err(error) => match cached {
Some(cached) => {
parse_game_template_library_index(&cached)?;
Ok((cached, TemplateIndexSource::Cache))
}
None => Err(error),
},
}
}
#[tauri::command]
pub(crate) async fn fetch_game_template_library(
app: tauri::AppHandle,
@@ -820,26 +859,21 @@ pub(crate) async fn fetch_game_template_library(
TEMPLATE_LIBRARY_INDEX_KEY
);
let client = build_template_library_client();
let (body, source) =
let remote =
match fetch_limited_bytes(&client, &index_url, TEMPLATE_LIBRARY_MAX_INDEX_BYTES).await {
// 响应回来了但正文不是合法 UTF-8 属于「远端答非所问」,不能用缓存掩盖。
Ok(bytes) => {
let body =
String::from_utf8(bytes).map_err(|_| "模板库清单不是有效 UTF-8".to_string())?;
parse_game_template_library_index(&body)?;
with_validated_platform_session_identity(&identity, || {
write_cached_index(&cache_root, &body);
Ok(())
})?;
(body, "network")
Ok(String::from_utf8(bytes).map_err(|_| "模板库清单不是有效 UTF-8".to_string())?)
}
Err(error) => match read_cached_index(&cache_root) {
Some(cached) => {
parse_game_template_library_index(&cached)?;
(cached, "cache")
}
None => return Err(error),
},
Err(error) => Err(error),
};
let (body, source) = resolve_template_index(remote, read_cached_index(&cache_root))?;
if source == TemplateIndexSource::Network {
with_validated_platform_session_identity(&identity, || {
write_cached_index(&cache_root, &body);
Ok(())
})?;
}
validate_platform_session_identity(&identity)?;
let (header, templates) = parse_game_template_library_index(&body)?;
let installed = collect_installed_records(&cache_root);
@@ -859,7 +893,7 @@ pub(crate) async fn fetch_game_template_library(
library_version: header.library_version,
updated_at: header.updated_at,
fetched_at_millis: now_millis(),
source: source.to_string(),
source: source.as_str().to_string(),
templates: entries,
})
}
@@ -1181,6 +1215,50 @@ mod tests {
))
}
#[test]
fn template_index_prefers_the_network_body_and_labels_its_source() {
let body = sample_index_body();
let (resolved, source) =
resolve_template_index(Ok(body.clone()), Some("{not json".to_string()))
.expect("合法远端正文优先于缓存");
assert_eq!(source.as_str(), "network");
assert_eq!(resolved, body);
}
#[test]
fn template_index_falls_back_to_the_cache_body_when_the_remote_fetch_fails() {
let cached = sample_index_body();
let (resolved, source) =
resolve_template_index(Err("网络不可用".to_string()), Some(cached.clone()))
.expect("远端不可用时回退本机缓存");
assert_eq!(source.as_str(), "cache");
assert_eq!(resolved, cached);
}
#[test]
fn template_index_keeps_the_remote_error_when_no_cache_exists() {
let error = resolve_template_index(Err("远端超时".to_string()), None)
.expect_err("没有缓存时必须暴露远端错误");
assert_eq!(error, "远端超时");
}
#[test]
fn template_index_rejects_an_invalid_remote_body_without_masking_it_with_cache() {
let cached = sample_index_body();
let invalid = cached.replace(TEMPLATE_LIBRARY_SCHEMA_VERSION, "agc-template-library.v2");
let error = resolve_template_index(Ok(invalid), Some(cached))
.expect_err("远端已答话但正文非法时不得用缓存掩盖");
assert!(error.contains("版本不受支持"), "{error}");
}
#[test]
fn template_index_rejects_a_corrupt_cache_instead_of_serving_it() {
let error =
resolve_template_index(Err("网络不可用".to_string()), Some("{not json".to_string()))
.expect_err("损坏的缓存必须失败关闭");
assert!(error.contains("不是有效 JSON"), "{error}");
}
#[test]
fn rejects_index_with_unsupported_schema_or_duplicate_templates() {
let unsupported =
@@ -1323,6 +1401,8 @@ mod tests {
.unwrap()
.join("escape.txt")
.exists());
// 越界归档失败后不能留下安装记录:目录残留不算「已下载」。
assert!(collect_installed_records(destination.path()).is_empty());
}
#[test]
@@ -1371,6 +1451,13 @@ mod tests {
let error = install_template_archive(cache_root.path(), &summary, &archive)
.expect_err("digest mismatch rejected");
assert!(error.contains("完整性校验失败"), "{error}");
// 大小或摘要不一致必须在任何落盘之前失败:不留安装目录,也不产生「已下载」判据。
let directory =
installed_template_dir(cache_root.path(), &summary.id, &summary.template_version)
.expect("install dir");
assert!(!directory.exists(), "拒绝的模板不得留下安装目录");
assert!(collect_installed_records(cache_root.path()).is_empty());
}
#[test]
@@ -1415,6 +1502,38 @@ mod tests {
assert!(error.contains("模板尚未安装"), "{error}");
}
#[test]
fn failed_project_creation_removes_the_partial_project_directory() {
let cache_root = tempfile::tempdir().expect("temp dir");
let projects_root = unique_projects_root();
// 安装目录里只有安装记录、没有任何可复制文件:复制阶段必须失败关闭,
// 且已经创建的项目目录要被清掉,不能把半成品留在自动工作区里。
let directory = installed_template_dir(cache_root.path(), "empty-template", "1.0.0")
.expect("install dir");
ensure_game_creator_private_directory_tree(&directory, "模板安装目录")
.expect("create install dir");
write_game_creator_private_file(
&directory.join(TEMPLATE_INSTALLED_MARKER_FILE),
b"{\"templateId\":\"empty-template\"}",
"模板安装记录",
)
.expect("write install marker");
let error =
create_project_from_installed_template_at(&projects_root, &directory, None, false)
.expect_err("模板没有可复制文件时必须失败关闭");
assert!(error.contains("没有可复制的文件"), "{error}");
let leftovers = fs::read_dir(&projects_root)
.map(|entries| {
entries
.filter_map(|entry| entry.ok())
.map(|entry| entry.path())
.collect::<Vec<_>>()
})
.unwrap_or_default();
assert!(leftovers.is_empty(), "失败不得留下项目目录:{leftovers:?}");
}
fn template_source_files(root: &Path) -> Vec<(String, Vec<u8>)> {
fn collect(root: &Path, directory: &Path, files: &mut Vec<(String, Vec<u8>)>) {
for entry in fs::read_dir(directory).expect("read template directory") {
@@ -1,59 +0,0 @@
# AGC 模板库客户端接入实施计划
Version: 1.0
Status: active
Date: 2026-09-17
Milestone Spec: `docs/project-memory/plans/【里程碑】AGC模板库客户端接入-2026-09-17.md`
## 步骤
1. **OSS 库布局与契约**
-`agc-dev` 落地 `templates/` 前缀:`index.json``v1/<id>/{template.json,template.zip,cover.*}`
- 清单补齐 `tags``coverKey/coverWidth/coverHeight/coverSha256`,正文改为 zip(zip 根 == 项目根)。
- 模板源落在 `apps/ai-game-creator-shell/template-library/v1/<id>/{meta.json,project/**,cover.*}`zip 由 `scripts/agc-template-library-publish.mjs` 现场打包(不落仓库)。
- 交付:发布脚本(校验 + 打包 + 上传 + 回读校验,支持 `--dry-run` / `--prune`)、`templates/README.md`,以及 5 个模板(3 个空白 + 2 个起步工程)。
- 验收:匿名 `GET templates/index.json` 可读,每个 `zipKey` 回读 SHA-256 与清单一致。
2. **Rust 模板库模块**
- 新增 `src-tauri/src/template_library.rs`:清单解析与校验、受信任 base、缓存/安装目录、zip 安全解压、安装记录、由模板建项目。
- 注册命令 `fetch_game_template_library``download_game_template``create_automatic_local_game_project_from_template`
- 交付:模块内 8 项单测(schema/重复模板、键前缀、base 校验、解压逃逸、摘要与大小、安装记录、建项目与失败清理)。
- 验收:`cargo test --bin genarrative-ai-game-creator-shell template_library` 全绿。
3. **前端状态链路**
- `src/features/template-library/templateLibraryModel.ts`(类型与搜索/筛选纯函数)与 `useTemplateLibrary.ts`(拉取、下载、建项目、就地更新已下载状态)。
- `useHomeProjectCreation` 增加 `enterCreatedTemplateProject`,复用既有进项目通道。
- 交付:9 项模型单测。
- 验收:`npx vitest run src/features/template-library` 全绿。
4. **界面接入**
- 新增 `src/view/template-library/index.tsx` 全屏页;`LauncherView` 增加 `template-library`;左侧导航加模板库入口。
- 首页「灵感推荐」替换为 `TemplateRecommendations`;删除 `InspirationGallery.tsx``assets/inspiration/`
- `tauri.conf.json``img-src` 放行受信任 OSS 主机以加载封面。
- 验收:模板库页可搜索、筛选、下载、显示已下载并成功建项目;首页推荐位可跳转。
5. **文档与共享记忆**
- 主规范 `docs/technical/【技术方案】AGC模板库与模板建项-2026-09-17.md`,并在 `docs/README.md` 建索引。
- 本里程碑与实施计划;`decision-log.md` 记录库路径、清单 schema、缓存目录与 CSP 约定。
- 验收:`node scripts/check-doc-index.mjs` 通过。
## 验证命令
```bash
cargo check --manifest-path apps/ai-game-creator-shell/src-tauri/Cargo.toml
cargo test --manifest-path apps/ai-game-creator-shell/src-tauri/Cargo.toml --bin genarrative-ai-game-creator-shell template_library
cargo test --manifest-path apps/ai-game-creator-shell/src-tauri/Cargo.toml --bin genarrative-ai-game-creator-shell template_library -- --ignored
cd apps/ai-game-creator-shell && npx tsc -p tsconfig.json --noEmit
npx vitest run apps/ai-game-creator-shell/tests/templateLibraryModel.test.ts apps/ai-game-creator-shell/tests/templateLibraryView.test.tsx
node scripts/agc-template-library-publish.mjs --source <dir> --dry-run
npm run check:encoding
node scripts/check-doc-index.mjs
git diff --check
```
## 风险与回退
- **封面走 WebView 直连**:仅放行受信任 OSS 主机;若日后改用后端签名,清单的 `coverKey` 不变。
- **模板包体积**:下载上限 512 MiB、解压文件数 4096、单文件 256 MiB;超限直接拒绝,不落盘。
- **清单漂移**:客户端只信「受信任主机 + 对象键」,清单中的地址字段不参与请求。
- **回退**:清空 `templates/` 前缀即回到空模板库;客户端保留错误与空态展示,不阻断其它功能。
@@ -1,7 +1,7 @@
# AGC 模板库客户端接入
Version: 1.0
Status: active
Status: accepted2026-09-21 复核:7 条验收项均有定向、前端与线上真连证据)
Date: 2026-09-17
Parent Spec: `docs/technical/【技术方案】AGC模板库与模板建项-2026-09-17.md`
@@ -38,3 +38,17 @@ AGC 客户端能读取公共 OSS 上的游戏模板库,并把「浏览 → 筛
- 现有自动工作区建项链路(`create_automatic_local_game_project_at` / `init_local_game_project_at`)。
- 现有 AGC 更新通道使用的受信任 OSS 主机与 CSP 白名单口径。
- 仓库 OSS 凭据(本机 `.env.secrets.local``ALIYUN_OSS_*`)与 `scripts/agc-template-library-publish.mjs`
## 验收证据(2026-09-21
| 验收项 | 证据 |
| --- | --- |
| 1 读清单 + 合并已安装状态 + 远端不可用回退缓存并标 `source=cache` | `template_library::tests::template_index_*` 5 项(网络优先并标 `network`、远端失败回退缓存并标 `cache`、无缓存暴露远端错误、远端正文非法不用缓存掩盖、缓存损坏失败关闭);`fetches_the_live_template_library_index` 真连通过 |
| 2 下载拒绝字节数 / SHA-256 / 越界键 / 非 `templates/` 前缀,且不落半成品 | `rejects_archive_when_size_or_digest_do_not_match_the_index`(含新增断言:拒绝后无安装目录、无已下载判据)、`rejects_object_keys_outside_the_templates_prefix``rejects_template_base_url_outside_trusted_oss``downloads_and_installs_a_live_template` 真连通过 |
| 3 解压拒绝绝对路径 / `..` / 盘符 / 符号链接;安装完成才写 `installed.json` | `rejects_archive_entries_that_escape_the_destination`(含新增断言:失败后不产生安装记录)、`installs_template_archive_and_reports_it_as_installed``parses_content_addressed_objects_without_changing_the_template_contract` |
| 4 建项具备模板文件 + `.agent` + 标准目录,失败不留项目目录 | `creates_project_from_installed_template_without_leaking_install_marker``installs_official_cocos_templates_and_creates_native_projects`、新增 `failed_project_creation_removes_the_partial_project_directory``refuses_to_create_project_when_template_is_not_installed``downloads_and_creates_live_cocos_templates` 真连通过 |
| 5 页面筛选 / 卡片 / 已下载徽标 / 更新入口 / 浮层提示 / 版本落后先重下 | `templateLibraryView.test.tsx` 12 项、`templateLibraryModel.test.ts` 9 项、`templateLibraryGrid.test.ts` 6 项、`useTemplateLibrary.test.tsx` 11 项 |
| 6 首页推荐位与左侧导航入口 | `appSurface.test.ts` 首页模板用例 2 项:灰度外隐藏入口与推荐位、点击推荐位进入模板库且不建项目 |
| 7 定向用例 + 类型 / 编码 / 文档检查 + 可选真连 | Rust 24 项 + 线上 3 项、前端 38 项 + `appSurface` 2 项、AGC `typecheck``check:encoding``check:doc-index``git diff --check` 全部通过;匿名 `GET templates/index.json` 返回 200`schemaVersion=agc-template-library.v1`、9 个模板) |
仍未验证:Creator 内场景运行;真机 iOS / Android 观感。持久结论已并入主规范 [`【技术方案】AGC模板库与模板建项-2026-09-17.md`](../../technical/【技术方案】AGC模板库与模板建项-2026-09-17.md),本里程碑的实施计划已按工作流删除。
@@ -83,7 +83,7 @@ templates/
| 命令 | 行为 |
| --------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `fetch_game_template_library` | 读 `templates/index.json`(≤4 MiB),校验后缓存到 `<app_data>/templates/index.json`;网络失败时回退本机缓存并在 `source``cache` |
| `fetch_game_template_library` | 读 `templates/index.json`(≤4 MiB),校验后缓存到 `<app_data>/templates/index.json`;网络失败时回退本机缓存并在 `source``cache`。远端已经答话但正文不是合法 UTF-8 或不符合 schema、以及缓存自己损坏时,一律失败关闭,不用缓存掩盖远端错误 |
| `download_game_template` | 取清单里对应条目,流式下载 zip(≤512 MiB),校验字节数与 SHA-256,解压到 `<app_data>/templates/installed/<id>/<version>/`,最后写 `installed.json` 作为安装完成的唯一标记 |
| `create_automatic_local_game_project_from_template` | 需要时先安装模板,然后在自动工作区根目录下按既有自动工作区规则建目录:先复制模板文件;Cocos 项目更新自身身份后走既有 Cocos 导入,其余沿现有 `init_local_game_project_at` 初始化。根目录默认是 `<app_data>/projects/`,用户可选 `projectsRoot` 覆盖(必须来自本机目录选择器并通过私有路径门禁),见 [`【实施计划】AGC项目创建目录可选-2026-09-17.md`](../project-memory/plans/【实施计划】AGC项目创建目录可选-2026-09-17.md) |
@@ -93,6 +93,7 @@ templates/
- 安装目录名由标识符白名单拼出,不拼接远端字符串;重装时只清理该模板自己的安装目录。
- 模板文件与安装记录统一走 `write_game_creator_private_file` / `ensure_game_creator_private_directory_tree`,保持项目目录的私有 DACL 口径。
- 建项目失败时删除刚创建的项目目录,不留半成品。
- 大小 / 摘要校验在落盘之前完成,被拒绝的模板包不产生任何安装目录;`installed.json` 是「已下载」的唯一判据,目录残留(例如解压中途失败)不构成已安装,下次安装会先清理该模板自己的安装目录。
### 前端
@@ -114,6 +115,8 @@ Cocos 回归分别覆盖仓库模板和线上真实 ZIP 的安装、连续建项
`2026-09-19` 发布一致性验收:Node 发布回归 22 项通过,覆盖两个发布者竞争、正文/清单写入失败、迟到清单 PUT、锁归属、版本控制拒绝、V1 签名及真实 CLI 的无写入 dry-run。Rust 定向回归 15 项通过,新增内容地址的清单解析与 URL 保留校验;3 项线上用例本轮未重复执行,此前同日线上下载及原生建项已通过。只读 dry-run 保留线上九个模板并仅计划更新四个 Cocos 条目。格式、编码、文档索引、定向 ESLint 与 diff 检查通过。全部并发/故障写入证据来自离线替身,未执行真实 OSS 锁写入或发布,也未验证 Creator 内场景运行。
`2026-09-21` 客户端接入复核:Rust 定向 24 项通过(新增清单来源判定 5 项:合法远端正文优先并标 `network`、远端失败回退缓存并标 `cache`、无缓存时暴露远端错误、远端正文非法时不用缓存掩盖、缓存损坏时失败关闭;新增建项目失败清理 1 项;并在大小/摘要不一致、越界归档两条用例上补「拒绝后不留安装目录、不产生已下载判据」断言)。3 项线上用例(读线上清单、下载安装线上模板、下载并原生建项 Cocos 模板)本轮全部真实执行通过;匿名 `GET https://agc-dev.oss-rg-china-mainland.aliyuncs.com/templates/index.json` 返回 200、`schemaVersion=agc-template-library.v1`、9 个模板。前端 38 项通过(模型 9、网格 6、页面 12、控制器 11),`appSurface` 首页模板用例 2 项通过(灰度外隐藏入口与推荐位、点击进入模板库不建项),AGC `typecheck` 通过。仍未验证:Creator 内场景运行,以及真机 iOS / Android 观感。
## 本地压测假数据注入(feature 控制)
模板库的数据源在 Rust 侧(清单校验、安装状态、下载与建项目都在这里),TS 只消费快照做渲染,所以假数据注入也放在 Rust 侧,走与真实完全一致的链路。