后台模板上传(后端):新增批量导入接口与存储/领域支持

- shared-contracts 新增导入 manifest、逐条与结果 DTO(camelCase,拒绝未知字段)
- module-assets 新增 prepare_template_import:新 ID 默认上架,已存在条目就地更新并保留上架状态与未知扩展字段;同一 ID 同版本字节不同时按 CLI 同一句文案拒绝,字节一致时按内容复用既有对象
- platform-oss 为 application/zip 放宽单对象上限到 64 MiB(图片与元数据仍 5 MiB),测试替身同步该口径
- api-server 新增 POST /admin/api/agc-templates/import:multipart(manifest + zip_N/cover_N),逐项校验归档安全性、entry 存在性与封面(按字节嗅探格式,仅 PNG/JPEG/WebP)后才取发布锁
- 锁内流程:CAS 校验 revision → 内容寻址写入 zip/封面/元数据并回读 → 一次提交清单 → 释放锁;断连由独立任务持有,不确定结果保留锁并返回 503
- 路由挂 require_admin_auth 与 200 MiB body 上限,页签权限矩阵与路由契约测试同步
- 新增用例:清单与文件字段校验、归档越界与符号链接拒绝、内容寻址键与封面嗅探、zip 内容类型存储上限
- 新增「后台模板上传」里程碑与实施计划文档
- 验证:cargo test(module-assets 11 项、platform-oss 12 项、api-server 定向 4 项)、cargo fmt --check、check:doc-index、check:encoding、git diff --check
This commit is contained in:
kdletters
2026-09-21 15:18:55 +08:00
parent 33036b65fc
commit 4863c8c066
8 changed files with 1281 additions and 6 deletions
@@ -0,0 +1,40 @@
# 后台模板上传实施计划
| 字段 | 值 |
| --- | --- |
| Version | 1.0 |
| Status | in-progress |
| Date | 2026-09-21 |
| Milestone Spec | `docs/project-memory/plans/【里程碑】后台模板上传-2026-09-21.md` |
## 修改边界与顺序
1. **领域规则(`module-assets/template_library.rs`**:新增「导入准备」函数——按 ID 合并清单条目(新增默认上架、已存在就地更新并保留 `enabled` 与未知字段),校验 ID / 版本 / entry / runtime / 显示字段上限,产出待提交的清单与 `template.json` 字节;不接触存储。
2. **存储(复用 `platform-oss/template_library.rs`**:只使用既有 `begin_publish` / `read_index` / `put_immutable` / `commit_index` / `finish`;如缺少「读单个对象作元数据基线」的能力,再补最小只读方法,不改锁与提交语义。
3. **契约(`shared-contracts/admin.rs` + `apps/admin-web/src/api/adminApiTypes.ts`**:新增导入请求(manifest)与导入结果 DTO;错误体沿用现有 `AppError` + 逐项原因结构。
4. **接口(`api-server/admin_templates.rs` + `modules/admin.rs`**:新增 multipart handler,按「解析 manifest → 校验每个 ZIP / 封面 → 取锁 → CAS → 写内容寻址对象并回读 → 提交清单 → 释放锁」顺序实现;路由套 `require_admin_auth` 与 256 MiB body 上限,并同步路由契约测试与页签权限矩阵测试。
5. **后台页面(`AdminAgcTemplatesPage.tsx` + `adminApiClient.ts`**:新增「上传模板」入口与弹窗(多选 ZIP、逐行元数据、批量提交、逐行错误、写入确认),沿用既有 `useAdminWriteConfirm` 与刷新语义。
6. **文档**:主规范新增「后台模板上传」章节;决策记录补一条;主规范中「本轮只允许…不上传 ZIP」改为指向新章节。
## 验证命令
```bash
cargo test --locked -p module-assets --manifest-path server-rs/Cargo.toml -- template_library
cargo test --locked -p api-server --manifest-path server-rs/Cargo.toml -- agc_template
cargo fmt --all --manifest-path server-rs/Cargo.toml -- --check
npm run admin-web:typecheck
npx vitest run apps/admin-web/src/pages/AdminAgcTemplatesPage.test.tsx apps/admin-web/src/api/adminApiClient.test.ts
npm run check:encoding
npm run check:doc-index
git diff --check
```
接口 smoke`npm run dev:api-server`(本地 8082+ `npm run dev:admin-web`,先用未授权/无页签权限请求确认失败关闭,再以隔离存储替身或用户显式确认的 dev bucket 做一次真实导入。
## 时间盒与风险
- **风险:真实发布不可逆**。导入会写公共 bucket 且不提供删除,因此默认只在本地用替身验证;对 dev bucket 的写验证必须由用户显式确认,测试用模板需可在事后下架。
- **风险:ZIP 原字节发布与 CLI 确定性打包不一致**。同一模板可能被两条路径写成不同字节;由「同 ID 同版本字节必须一致」的门禁兜住,导入失败时提示改用 CLI 或递增版本。
- **风险:大文件内存**。ZIP(≤64 MiB)与封面在内存中校验,单批上限 20;需要更大模板时走 CLI。
- **风险:批量部分写入**。所有对象在清单提交前写入且不删除;中途失败时清单不变、已写对象成为未被引用的历史对象,由后续同键复用。
- **回退**:下线路由与页面入口即可停止使用;已发布内容按既有 CLI / 后台下架流程处理,历史对象保留。
@@ -0,0 +1,53 @@
# 后台模板上传
| 字段 | 值 |
| --- | --- |
| Version | 1.0 |
| Status | proposed(用户 2026-09-21 提出“后台再加个模板上传功能,最好支持批量上传”;边界见下,评审通过后开工) |
| Date | 2026-09-21 |
| Parent Spec | `docs/technical/【技术方案】AGC模板库与模板建项-2026-09-17.md` |
## 目标与范围
管理员在后台直接上传模板并发布到公共模板库,不必走本地 CLI;一批可以带多个模板(批量上传),一批只做一次发布锁和一次清单提交。
- 新增 `POST /admin/api/agc-templates/import``multipart/form-data`):`manifest` 文本字段 + `zip_<index>` / `cover_<index>` 文件字段,字段下标与 manifest 条目顺序一一对应。
- manifest`{ expectedRevision, templates: [{ id, title, summary, tags, runtime, engine, engineVersion, entry, templateVersion }] }``expectedRevision` 是当前清单字节 SHA-256,用于锁内 CAS。
- 限制:单批最多 20 个模板;单个 ZIP ≤ 64 MiB;单张封面 ≤ 5 MiB;请求体 ≤ 200 MiB`id` / `templateVersion` / `entry` 走既有标识符与相对路径白名单;`runtime` 仅接受 `html / unity / godot / cocos`。存储层为 `application/zip` 单独放宽单对象上限到 64 MiB(图片与元数据仍是 5 MiB)。
- 语义:一批**全有或全无**——任一模板校验失败都在写入前整批拒绝并逐项给出原因;通过后在同一把发布锁内写入全部内容寻址对象、逐个回读校验,最后提交一次清单。
- 新 ID 默认上架;已存在 ID 的导入就地更新该条目(保留 `enabled` 状态、所属分组与未知扩展字段,包含下架条目);同一 ID 同一 `templateVersion` 但 ZIP 字节不同时拒绝,提示递增 `templateVersion`
- ZIP 由服务端校验后按原字节发布(不重新打包、不解压落盘):合法 zip、无符号链接、无绝对路径 / `..` / 盘符条目、必须包含声明的 `entry`、至少一个文件,条目数与解压后体积受上限保护。
- 封面**每个模板必填**(与 CLI 源布局 `v1/<id>/cover.*` 一致),仅接受真实 PNG / JPEG / WebP,上限与编辑路径相同(5 MiB、单边 4096、1600 万像素);上传按字节嗅探格式,不信任 multipart 声明的 content-type。SVG 仍只可能来自 CLI 历史发布,编辑与上传都不产生新的 SVG 封面。
- 后台「模板管理」页新增上传入口:可多选 ZIP、逐行编辑元数据(含可选封面)、批量提交、逐行显示校验错误;沿用现有写入确认、防重复提交与刷新语义。
## 不做
- 不做模板删除、版本回滚、下架条目清理、历史对象回收。
- 不改 ZIP 内容、不做服务端重新打包(CLI 仍是确定性打包与 `--only` 定向发布的入口)。
- 不做审核流、不做从 URL 拉取、不做 CLI 源布局目录(`v1/<id>/{meta.json,project/**,cover.*}`)的自动打包上传。
- 不新增 SpacetimeDB 表或 schema。
## 合同与依赖
- 存储与锁:复用 `platform-oss``TemplateLibraryStore` / `TemplatePublishSession``begin_publish` / `read_index` / `put_immutable` / `commit_index` / `finish`)与既有版本控制前置检查;不新增第二套发布协议。
- 领域规则:`module-assets/template_library.rs` 承担清单合并与字段校验(保留未知字段、下架状态、两组间 ID 唯一),沿用既有标识符 / 相对路径 / 摘要规则。
- DTO`shared-contracts/admin.rs` 新增导入结果类型;前端类型在 `apps/admin-web/src/api/adminApiTypes.ts`
- 鉴权:沿用后台认证与 `agc-templates` 页签权限;路由挂载在 `modules/admin.rs`
## 验收标准
1. 权限与路由:未授权读取/写入均失败关闭;无 `agc-templates` 页签权限不可导入;路由契约测试覆盖新路径与方法。
2. 校验失败关闭:manifest 非法、ID/版本/entry 越界、runtime 不在白名单、ZIP 非法或含越界/符号链接条目、缺少 entry、封面超限或非真实图片,全部在**任何写入之前**整批拒绝,响应逐项给出模板 ID 与原因。
3. 批量原子性:一批 3 个模板(含 1 个新 ID、1 个新版本、1 个下架 ID)在锁内一次提交;中途任一环节失败时清单与历史对象不变,返回可诊断错误。
4. 版本一致性:同 ID 同版本、ZIP 字节不同必须拒绝并要求递增版本;同 ID 同版本、字节完全一致视为幂等复用,不报错。
5. CAS 与锁:`expectedRevision` 过期返回 409;锁被占用返回 409;清单写入结果不明时保留锁并返回 503,不自动重试。
6. 保留语义:既有条目的展示字段按上传内容更新,`enabled` 分组、其它条目、未知扩展字段与历史对象不变;下架条目仍留在 `inactiveTemplates`
7. 内容寻址与回读:ZIP / 封面 / template.json 以自身字节摘要寻址,写入后逐项回读校验,清单最后提交且返回最新快照。
8. 界面:桌面与窄屏都能完成多选 ZIP、逐行元数据编辑、批量提交与错误展示;保存中防重复提交,409 引导刷新后重试。
9. 检查:`cargo test`module-assets / platform-oss / api-server 定向)、`npm run admin-web:typecheck`、后台页面 vitest、`npm run check:encoding``npm run check:doc-index``cargo fmt --check``git diff --check` 全部通过。
## 依赖
- 已交付的后台模板管理链路(`GET/PUT /admin/api/agc-templates`、页面、锁与内容寻址发布协议)。
- 模板库 OSS 写凭据(`GENARRATIVE_AGC_TEMPLATE_LIBRARY_OSS_ACCESS_KEY_ID/SECRET` 或成套 `ALIYUN_OSS_*`);无凭据时导入返回 503 且页面明确不可用。
- 真实 dev bucket 的写入验证需要用户显式确认(见实施计划「验证」)。
+1
View File
@@ -6852,6 +6852,7 @@ mod tests {
for (method, path) in [
(Method::GET, "/admin/api/agc-templates"),
(Method::PUT, "/admin/api/agc-templates/cocos-empty-2d"),
(Method::POST, "/admin/api/agc-templates/import"),
] {
assert!(enforce_admin_request_permission("owner", &[], &[], &method, path).is_ok());
assert!(
File diff suppressed because it is too large Load Diff
@@ -56,6 +56,14 @@ pub fn router(state: AppState) -> Router<AppState> {
axum::routing::put(crate::admin_templates::admin_update_agc_template)
.layer(axum::extract::DefaultBodyLimit::max(8 * 1024 * 1024)),
),
(
"/admin/api/agc-templates/import",
axum::routing::post(crate::admin_templates::admin_import_agc_templates).layer(
axum::extract::DefaultBodyLimit::max(
crate::admin_templates::AGC_TEMPLATE_IMPORT_BODY_LIMIT_BYTES,
),
),
),
(
"/admin/api/agc-models",
get(crate::agc_models::admin_get_agc_models)
@@ -228,6 +236,7 @@ mod route_contract_tests {
),
("/admin/api/agc-templates", &["GET"]),
("/admin/api/agc-templates/{id}", &["PUT"]),
("/admin/api/agc-templates/import", &["POST"]),
("/admin/api/agc-models", &["GET", "PUT"]),
("/admin/api/accounts", &["GET", "POST"]),
("/admin/api/accounts/{account_id}", &["PUT"]),
File diff suppressed because it is too large Load Diff
@@ -15,6 +15,8 @@ const INDEX_KEY: &str = "templates/index.json";
const LOCK_KEY: &str = "templates/.publish-lock.json";
const MAX_INDEX_BYTES: usize = 4 * 1024 * 1024;
const MAX_OBJECT_BYTES: usize = 5 * 1024 * 1024;
/// 模板包(`application/zip`)是后台导入路径唯一的超大对象,单独给出上限。
const MAX_TEMPLATE_ZIP_BYTES: usize = 64 * 1024 * 1024;
const MAX_CONTROL_BYTES: usize = 64 * 1024;
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
@@ -241,6 +243,8 @@ impl TemplatePublishSession {
let parts: Vec<_> = key.split('/').collect();
let max_bytes = match content_type {
"application/json" => MAX_INDEX_BYTES,
// 模板包按上传字节原样发布,见决策「后台模板上传」;仍受内容寻址与回读校验约束。
"application/zip" => MAX_TEMPLATE_ZIP_BYTES,
"image/png" | "image/jpeg" | "image/webp" => MAX_OBJECT_BYTES,
_ => return Err(TemplateStoreError::Invalid),
};
@@ -643,7 +647,8 @@ mod tests {
.get("content-length")
.and_then(|value| value.parse::<usize>().ok())
.unwrap_or(0);
if length > MAX_OBJECT_BYTES + 1024 {
// 模板包上限高于图片 / 元数据,替身必须按同一口径放行。
if length > MAX_TEMPLATE_ZIP_BYTES + 1024 {
return None;
}
let start = header_end + 4;
@@ -1063,6 +1068,38 @@ mod tests {
}
}
#[tokio::test]
async fn template_zip_objects_accept_larger_bodies_than_images() {
let server = MockServer::start().await;
let mut session = server
.store
.begin_publish("zip-owner".to_string())
.await
.unwrap();
// 6 MiB 超过图片 / 元数据的 5 MiB 上限:模板包必须放行,并仍按自身字节摘要定位与回读。
let zip = vec![7_u8; 6 * 1024 * 1024];
let key = format!("templates/v1/demo/sha256/{}/template.zip", sha256_hex(&zip));
assert_eq!(
session
.put_immutable(&key, zip.clone(), "application/zip")
.await,
Ok(())
);
assert_eq!(server.count("PUT", &key), 1);
// 未知内容类型仍然失败关闭。
let body = b"tiny".to_vec();
let unknown_key = format!("templates/v1/demo/sha256/{}/cover.png", sha256_hex(&body));
assert_eq!(
session
.put_immutable(&unknown_key, body, "application/octet-stream")
.await,
Err(TemplateStoreError::Invalid)
);
session.finish().await.unwrap();
}
#[tokio::test]
async fn uncertain_commit_never_retries_or_releases_even_if_the_write_reached_storage() {
for status in [Some(500), Some(408), Some(302), None] {
@@ -135,6 +135,60 @@ pub struct AdminAgcTemplateCoverInput {
pub data_base64: String,
}
/// 后台批量导入模板的 manifest`multipart/form-data` 的 `manifest` 文本字段)。
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
pub struct AdminImportAgcTemplatesManifest {
/// 上传前读到的清单字节摘要,锁内用于 CAS;过期返回 409。
pub expected_revision: String,
pub templates: Vec<AdminImportAgcTemplateItem>,
}
/// 单条导入模板:ZIP 与可选封面通过字段名引用同一请求里的文件字段。
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
pub struct AdminImportAgcTemplateItem {
pub id: String,
pub title: String,
#[serde(default)]
pub summary: String,
#[serde(default)]
pub tags: Vec<String>,
pub runtime: String,
#[serde(default)]
pub engine: String,
#[serde(default)]
pub engine_version: String,
pub template_version: String,
pub entry: String,
/// 该条目的 ZIP 文件字段名(例如 `zip_0`)。
pub zip_field: String,
/// 该条目的封面文件字段名(例如 `cover_0`);与 CLI 源布局一致,封面必填。
pub cover_field: String,
}
/// 批量导入结果:最新快照 + 逐条结果。
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "camelCase")]
pub struct AdminImportAgcTemplatesResponse {
pub revision: String,
pub writable: bool,
pub templates: Vec<AdminAgcTemplatePayload>,
pub imported: Vec<AdminImportAgcTemplateResult>,
}
/// 单条导入结果。
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "camelCase")]
pub struct AdminImportAgcTemplateResult {
pub id: String,
pub template_version: String,
pub zip_size_bytes: u64,
pub zip_sha256: String,
/// 同一版本上传完全相同的字节时按内容复用既有对象,没有新写内容。
pub reused_objects: bool,
}
// 登录成功后返回管理员访问令牌与基础会话信息。
/// 后台创作入口开关列表响应。