后台模板上传(后端):新增批量导入接口与存储/领域支持
- 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:
@@ -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 的写入验证需要用户显式确认(见实施计划「验证」)。
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
// 登录成功后返回管理员访问令牌与基础会话信息。
|
||||
|
||||
/// 后台创作入口开关列表响应。
|
||||
|
||||
Reference in New Issue
Block a user