diff --git a/docs/project-memory/plans/【实施计划】后台模板上传-2026-09-21.md b/docs/project-memory/plans/【实施计划】后台模板上传-2026-09-21.md new file mode 100644 index 000000000..1a3a1b1df --- /dev/null +++ b/docs/project-memory/plans/【实施计划】后台模板上传-2026-09-21.md @@ -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 / 后台下架流程处理,历史对象保留。 diff --git a/docs/project-memory/plans/【里程碑】后台模板上传-2026-09-21.md b/docs/project-memory/plans/【里程碑】后台模板上传-2026-09-21.md new file mode 100644 index 000000000..2c87248b8 --- /dev/null +++ b/docs/project-memory/plans/【里程碑】后台模板上传-2026-09-21.md @@ -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_` / `cover_` 文件字段,字段下标与 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//cover.*` 一致),仅接受真实 PNG / JPEG / WebP,上限与编辑路径相同(5 MiB、单边 4096、1600 万像素);上传按字节嗅探格式,不信任 multipart 声明的 content-type。SVG 仍只可能来自 CLI 历史发布,编辑与上传都不产生新的 SVG 封面。 +- 后台「模板管理」页新增上传入口:可多选 ZIP、逐行编辑元数据(含可选封面)、批量提交、逐行显示校验错误;沿用现有写入确认、防重复提交与刷新语义。 + +## 不做 + +- 不做模板删除、版本回滚、下架条目清理、历史对象回收。 +- 不改 ZIP 内容、不做服务端重新打包(CLI 仍是确定性打包与 `--only` 定向发布的入口)。 +- 不做审核流、不做从 URL 拉取、不做 CLI 源布局目录(`v1//{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 的写入验证需要用户显式确认(见实施计划「验证」)。 diff --git a/server-rs/crates/api-server/src/admin.rs b/server-rs/crates/api-server/src/admin.rs index ee33a4373..c05c18084 100644 --- a/server-rs/crates/api-server/src/admin.rs +++ b/server-rs/crates/api-server/src/admin.rs @@ -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!( diff --git a/server-rs/crates/api-server/src/admin_templates.rs b/server-rs/crates/api-server/src/admin_templates.rs index 18116eca0..6ab5d192a 100644 --- a/server-rs/crates/api-server/src/admin_templates.rs +++ b/server-rs/crates/api-server/src/admin_templates.rs @@ -2,21 +2,24 @@ use std::io::Cursor; use axum::{ Json, - extract::{Extension, Path, State}, + extract::{Extension, Multipart, Path, State}, http::{HeaderValue, StatusCode, header::CACHE_CONTROL}, response::{IntoResponse, Response}, }; use base64::{Engine as _, engine::general_purpose::STANDARD}; use module_assets::template_library::{ - TemplateCover, TemplateDomainError, TemplateEdit, list_templates, prepare_template_edit, + MAX_TEMPLATE_IMPORT_BATCH, TemplateCover, TemplateDomainError, TemplateEdit, TemplateImport, + list_templates, prepare_template_edit, prepare_template_import, }; use platform_oss::template_library::{TemplateLibraryStore, TemplateStoreError}; use serde_json::Value; use sha2::{Digest, Sha256}; use shared_contracts::admin::{ AdminAgcTemplateCoverInput, AdminAgcTemplateListResponse, AdminAgcTemplatePayload, - AdminUpdateAgcTemplateRequest, + AdminImportAgcTemplateItem, AdminImportAgcTemplateResult, AdminImportAgcTemplatesManifest, + AdminImportAgcTemplatesResponse, AdminUpdateAgcTemplateRequest, }; +use std::collections::{BTreeMap, BTreeSet}; use time::{OffsetDateTime, format_description::well_known::Rfc3339}; use crate::{ @@ -26,6 +29,13 @@ use crate::{ const MAX_COVER_BYTES: usize = 5 * 1024 * 1024; const MAX_DOCUMENT_BYTES: usize = 4 * 1024 * 1024; +/// 后台上传:单批数量、单包体积与解压后的容量上限(路由层再叠加请求体总上限)。 +const MAX_IMPORT_MANIFEST_BYTES: usize = 256 * 1024; +const MAX_IMPORT_ZIP_BYTES: usize = 64 * 1024 * 1024; +const MAX_IMPORT_ARCHIVE_FILES: usize = 4096; +const MAX_IMPORT_ARCHIVE_FILE_BYTES: u64 = 256 * 1024 * 1024; +const MAX_IMPORT_ARCHIVE_TOTAL_BYTES: u64 = 512 * 1024 * 1024; +pub(crate) const AGC_TEMPLATE_IMPORT_BODY_LIMIT_BYTES: usize = 200 * 1024 * 1024; const PUBLIC_BASE: &str = "https://agc-dev.oss-rg-china-mainland.aliyuncs.com/"; fn fingerprint(bytes: &[u8]) -> String { @@ -70,7 +80,7 @@ fn snapshot(bytes: &[u8], writable: bool) -> Result Response { +fn json_snapshot(context: &RequestContext, value: T) -> Response { let mut response = json_success_body(Some(context), value).into_response(); response .headers_mut() @@ -108,6 +118,15 @@ fn validate_cover(input: AdminAgcTemplateCoverInput) -> Result, +) -> Result { + let invalid = |message| AppError::from_status(StatusCode::BAD_REQUEST).with_message(message); if bytes.is_empty() || bytes.len() > MAX_COVER_BYTES { return Err(invalid("封面内容为空或超过 5 MiB")); } @@ -118,7 +137,7 @@ fn validate_cover(input: AdminAgcTemplateCoverInput) -> Result ("image/webp", "webp"), _ => return Err(invalid("封面仅支持 PNG、JPEG 或 WebP")), }; - if input.content_type != content_type { + if declared_content_type != content_type { return Err(invalid("封面格式与文件内容不一致")); } let mut reader = image::ImageReader::with_format(Cursor::new(&bytes), format); @@ -287,6 +306,357 @@ async fn update_template( } } +fn import_invalid(message: impl Into) -> AppError { + AppError::from_status(StatusCode::BAD_REQUEST).with_message(message) +} + +/// 模板包内路径:只允许库内相对路径,拒绝绝对路径、盘符、反斜杠、`..` 与空段。 +fn safe_import_archive_path(value: &str) -> Result { + let trimmed = value.trim_end_matches('/'); + if trimmed.is_empty() + || trimmed.len() > 256 + || trimmed.starts_with(['/', '\\']) + || trimmed.contains('\\') + || trimmed.contains(':') + || trimmed.contains("..") + || trimmed + .chars() + .any(|value| value.is_control() || value.is_whitespace()) + || !trimmed + .split('/') + .all(|part| !part.is_empty() && part != ".") + { + return Err(format!("模板包条目路径无效:{value}")); + } + Ok(trimmed.to_string()) +} + +/// 只做校验、不落盘:确认上传的是合法 zip、条目安全,并包含清单声明的 entry。 +fn validate_import_archive(bytes: &[u8], entry: &str) -> Result<(), String> { + let mut archive = zip::ZipArchive::new(Cursor::new(bytes)) + .map_err(|error| format!("模板包不是有效 zip:{error}"))?; + if archive.len() == 0 { + return Err("模板包没有文件".to_string()); + } + if archive.len() > MAX_IMPORT_ARCHIVE_FILES { + return Err(format!("模板包文件数量超过 {MAX_IMPORT_ARCHIVE_FILES}")); + } + let mut total_bytes = 0_u64; + let mut has_entry = false; + for index in 0..archive.len() { + let file = archive + .by_index(index) + .map_err(|error| format!("读取模板包条目失败:{error}"))?; + if file + .unix_mode() + .is_some_and(|mode| mode & 0o170000 == 0o120000) + { + return Err("模板包不允许包含符号链接".to_string()); + } + let relative = safe_import_archive_path(file.name())?; + if relative == entry { + has_entry = true; + } + if file.is_dir() { + continue; + } + if file.size() > MAX_IMPORT_ARCHIVE_FILE_BYTES { + return Err(format!("模板包文件超过大小上限:{relative}")); + } + total_bytes = total_bytes.saturating_add(file.size()); + if total_bytes > MAX_IMPORT_ARCHIVE_TOTAL_BYTES { + return Err("模板包解压后总大小超过上限".to_string()); + } + } + if !has_entry { + return Err(format!("模板包缺少清单声明的 entry:{entry}")); + } + Ok(()) +} + +/// 上传封面按字节嗅探格式:浏览器给的 part content-type 常是 octet-stream,不能当判据。 +fn uploaded_cover_content_type(bytes: &[u8]) -> Result<&'static str, AppError> { + match image::guess_format(bytes) { + Ok(image::ImageFormat::Png) => Ok("image/png"), + Ok(image::ImageFormat::Jpeg) => Ok("image/jpeg"), + Ok(image::ImageFormat::WebP) => Ok("image/webp"), + _ => Err(import_invalid("封面仅支持 PNG、JPEG 或 WebP")), + } +} + +#[derive(Debug)] +struct PlannedImport { + import: TemplateImport, + zip_bytes: Vec, + cover_bytes: Vec, + cover_content_type: &'static str, +} + +fn plan_import_item( + item: AdminImportAgcTemplateItem, + files: &BTreeMap>, +) -> Result { + let zip_field = item.zip_field.trim().to_string(); + let cover_field = item.cover_field.trim().to_string(); + if zip_field.is_empty() || cover_field.is_empty() || zip_field == cover_field { + return Err(import_invalid(format!( + "{} 必须分别提供 ZIP 与封面的文件字段名", + item.id + ))); + } + let zip_bytes = files + .get(&zip_field) + .ok_or_else(|| import_invalid(format!("{} 缺少 ZIP 文件字段 {zip_field}", item.id)))? + .clone(); + if zip_bytes.len() > MAX_IMPORT_ZIP_BYTES { + return Err(import_invalid(format!( + "{} 的模板包超过 {} MiB", + item.id, + MAX_IMPORT_ZIP_BYTES / (1024 * 1024) + ))); + } + validate_import_archive(&zip_bytes, item.entry.trim()) + .map_err(|error| import_invalid(format!("{}:{error}", item.id)))?; + let cover_bytes = files + .get(&cover_field) + .ok_or_else(|| import_invalid(format!("{} 缺少封面文件字段 {cover_field}", item.id)))? + .clone(); + let cover_content_type = uploaded_cover_content_type(&cover_bytes)?; + let cover = validate_cover_bytes(cover_content_type, cover_bytes)?; + let zip_sha256 = fingerprint(&zip_bytes); + let cover_sha256 = fingerprint(&cover.bytes); + Ok(PlannedImport { + import: TemplateImport { + id: item.id.clone(), + title: item.title, + summary: item.summary, + tags: item.tags, + runtime: item.runtime, + engine: item.engine, + engine_version: item.engine_version, + template_version: item.template_version, + entry: item.entry.trim().to_string(), + zip_key: format!("templates/v1/{}/sha256/{zip_sha256}/template.zip", item.id), + zip_sha256, + zip_size_bytes: zip_bytes.len() as u64, + cover_key: format!( + "templates/v1/{}/sha256/{cover_sha256}/cover.{}", + item.id, cover.extension + ), + cover_sha256, + cover_width: cover.width, + cover_height: cover.height, + }, + zip_bytes, + cover_bytes: cover.bytes, + cover_content_type, + }) +} + +/// 校验整批上传:任一模板不合法都在任何写入之前整批拒绝。 +fn plan_import( + manifest_bytes: &[u8], + files: &BTreeMap>, +) -> Result<(String, Vec), AppError> { + let manifest: AdminImportAgcTemplatesManifest = serde_json::from_slice(manifest_bytes) + .map_err(|_| import_invalid("导入 manifest 不是有效 JSON 或包含未知字段"))?; + if manifest.templates.is_empty() { + return Err(import_invalid("导入批次不能为空")); + } + if manifest.templates.len() > MAX_TEMPLATE_IMPORT_BATCH { + return Err(import_invalid(format!( + "单批最多导入 {MAX_TEMPLATE_IMPORT_BATCH} 个模板" + ))); + } + if manifest.expected_revision.len() != 64 + || !manifest + .expected_revision + .bytes() + .all(|byte| byte.is_ascii_hexdigit()) + { + return Err(import_invalid("模板库版本标识无效")); + } + let mut used_fields = BTreeSet::new(); + let mut planned = Vec::with_capacity(manifest.templates.len()); + for item in manifest.templates { + let fields = [item.zip_field.trim(), item.cover_field.trim()]; + for field in fields { + if !field.starts_with("zip_") && !field.starts_with("cover_") { + return Err(import_invalid(format!( + "{} 的文件字段名必须以 zip_ 或 cover_ 开头", + item.id + ))); + } + if !used_fields.insert(field.to_string()) { + return Err(import_invalid(format!("导入批次内文件字段名重复:{field}"))); + } + } + planned.push(plan_import_item(item, files)?); + } + for name in files.keys() { + if name != "manifest" && !used_fields.contains(name) { + return Err(import_invalid(format!("请求包含未引用的文件字段:{name}"))); + } + } + Ok((manifest.expected_revision, planned)) +} + +async fn read_import_field( + field: axum::extract::multipart::Field<'_>, + limit: usize, + name: &str, +) -> Result, AppError> { + let mut field = field; + let mut bytes = Vec::new(); + while let Some(chunk) = field + .chunk() + .await + .map_err(|_| import_invalid(format!("读取 {name} 失败")))? + { + if bytes.len().saturating_add(chunk.len()) > limit { + return Err(import_invalid(format!("{name} 超过允许的大小上限"))); + } + bytes.extend_from_slice(&chunk); + } + Ok(bytes) +} + +pub async fn admin_import_agc_templates( + State(state): State, + Extension(context): Extension, + Extension(_admin): Extension, + mut multipart: Multipart, +) -> Result { + let store = state.template_library_store().cloned().ok_or_else(|| { + AppError::from_status(StatusCode::SERVICE_UNAVAILABLE) + .with_message("模板管理未配置可用的存储凭据,当前仅支持查看") + })?; + let mut manifest_bytes: Option> = None; + let mut files: BTreeMap> = BTreeMap::new(); + while let Some(field) = multipart + .next_field() + .await + .map_err(|_| import_invalid("导入请求不是有效的 multipart 表单"))? + { + let name = field.name().unwrap_or_default().trim().to_string(); + let limit = match name.as_str() { + "manifest" => MAX_IMPORT_MANIFEST_BYTES, + name if name.starts_with("zip_") => MAX_IMPORT_ZIP_BYTES, + name if name.starts_with("cover_") => MAX_COVER_BYTES, + _ => return Err(import_invalid(format!("导入请求包含未知字段:{name}"))), + }; + let bytes = read_import_field(field, limit, &name).await?; + if name == "manifest" { + manifest_bytes = Some(bytes); + } else if files.insert(name.clone(), bytes).is_some() { + return Err(import_invalid(format!("导入请求包含重复字段:{name}"))); + } + } + let manifest_bytes = + manifest_bytes.ok_or_else(|| import_invalid("导入请求缺少 manifest 字段"))?; + let (expected_revision, planned) = plan_import(&manifest_bytes, &files)?; + // 接受后的写入由独立任务持有,HTTP 断连不能在清单 PUT 在途时提前解锁。 + let result = + tokio::spawn(async move { import_templates(store, expected_revision, planned).await }) + .await + .map_err(|_| { + AppError::from_status(StatusCode::SERVICE_UNAVAILABLE) + .with_message("模板导入结果需要核对,请刷新列表;若发布锁仍被占用请联系运维") + })??; + Ok(json_snapshot(&context, result)) +} + +async fn import_templates( + store: TemplateLibraryStore, + expected_revision: String, + planned: Vec, +) -> Result { + let mut session = store + .begin_publish(uuid::Uuid::new_v4().to_string()) + .await + .map_err(map_store_error)?; + let outcome = async { + let current = session.read_index().await.map_err(map_store_error)?; + check_revision(¤t, &expected_revision)?; + let index = decode_index(¤t)?; + let updated_at = OffsetDateTime::now_utc() + .format(&Rfc3339) + .map_err(|_| AppError::from_status(StatusCode::INTERNAL_SERVER_ERROR))?; + let mut prepared = prepare_template_import( + index, + planned.iter().map(|item| item.import.clone()).collect(), + &updated_at, + ) + .map_err(map_domain_error)?; + for item in &planned { + let metadata = prepared + .metadata + .iter() + .find(|(id, _)| id == &item.import.id) + .map(|(_, value)| value.clone()) + .ok_or_else(|| import_invalid("导入准备结果缺少元数据"))?; + let metadata_bytes = document_bytes(&metadata)?; + let metadata_key = format!( + "templates/v1/{}/sha256/{}/template.json", + item.import.id, + fingerprint(&metadata_bytes) + ); + prepared + .set_metadata_key(&item.import.id, &metadata_key) + .map_err(map_domain_error)?; + session + .put_immutable( + &item.import.zip_key, + item.zip_bytes.clone(), + "application/zip", + ) + .await + .map_err(map_store_error)?; + session + .put_immutable( + &item.import.cover_key, + item.cover_bytes.clone(), + item.cover_content_type, + ) + .await + .map_err(map_store_error)?; + session + .put_immutable(&metadata_key, metadata_bytes, "application/json") + .await + .map_err(map_store_error)?; + } + let next_index_bytes = document_bytes(&prepared.index)?; + session + .commit_index(next_index_bytes.clone()) + .await + .map_err(map_store_error)?; + let snapshot = snapshot(&next_index_bytes, true)?; + let imported = planned + .iter() + .map(|item| AdminImportAgcTemplateResult { + id: item.import.id.clone(), + template_version: item.import.template_version.clone(), + zip_size_bytes: item.import.zip_size_bytes, + zip_sha256: item.import.zip_sha256.clone(), + reused_objects: prepared.reused.contains(&item.import.id), + }) + .collect::>(); + Ok::<_, AppError>((snapshot, imported)) + } + .await; + let released = session.finish().await.map_err(map_store_error); + match (outcome, released) { + (Ok((snapshot, imported)), Ok(())) => Ok(AdminImportAgcTemplatesResponse { + revision: snapshot.revision, + writable: snapshot.writable, + templates: snapshot.templates, + imported, + }), + (Err(error), Ok(())) => Err(error), + (_, Err(error)) => Err(error), + } +} + fn map_domain_error(error: TemplateDomainError) -> AppError { match error { TemplateDomainError::InvalidEdit(message) => { @@ -334,6 +704,178 @@ mod tests { } } + fn png_bytes() -> Vec { + let image = image::DynamicImage::new_rgb8(4, 3); + let mut bytes = Cursor::new(Vec::new()); + image + .write_to(&mut bytes, image::ImageFormat::Png) + .expect("PNG"); + bytes.into_inner() + } + + fn archive(entries: &[(&str, &[u8], Option)]) -> Vec { + use std::io::Write; + let mut writer = zip::ZipWriter::new(Cursor::new(Vec::new())); + for (name, bytes, mode) in entries { + let mut options = zip::write::SimpleFileOptions::default(); + if let Some(mode) = mode { + options = options.unix_permissions(*mode); + } + if name.ends_with('/') { + writer.add_directory(*name, options).expect("directory"); + continue; + } + writer.start_file(*name, options).expect("start file"); + writer.write_all(bytes).expect("write file"); + } + writer.finish().expect("finish archive").into_inner() + } + + fn import_item() -> AdminImportAgcTemplateItem { + AdminImportAgcTemplateItem { + id: "smoke-import".to_string(), + title: " 冒烟模板 ".to_string(), + summary: "简介".to_string(), + tags: vec!["smoke".to_string()], + runtime: "html".to_string(), + engine: "none".to_string(), + engine_version: String::new(), + template_version: "0.1.0".to_string(), + entry: "index.html".to_string(), + zip_field: "zip_0".to_string(), + cover_field: "cover_0".to_string(), + } + } + + fn import_files() -> BTreeMap> { + let mut files = BTreeMap::new(); + files.insert( + "zip_0".to_string(), + archive(&[("index.html", b"", None)]), + ); + files.insert("cover_0".to_string(), png_bytes()); + files + } + + fn import_manifest(item: AdminImportAgcTemplateItem) -> Vec { + serde_json::to_vec(&AdminImportAgcTemplatesManifest { + expected_revision: "a".repeat(64), + templates: vec![item], + }) + .expect("manifest json") + } + + #[test] + fn template_import_archive_validation_fails_closed() { + let ok = archive(&[ + ("game/index.html", b"", None), + ("game/app.js", b"console.log(1)", None), + ]); + assert!(validate_import_archive(&ok, "game/index.html").is_ok()); + assert!( + validate_import_archive(&ok, "game/missing.html") + .expect_err("missing entry") + .contains("缺少清单声明的 entry") + ); + + let traversal = archive(&[("../escape.html", b"x", None)]); + assert!(validate_import_archive(&traversal, "../escape.html").is_err()); + + let symlink = { + let mut writer = zip::ZipWriter::new(Cursor::new(Vec::new())); + writer + .add_symlink( + "link.html", + "index.html", + zip::write::SimpleFileOptions::default(), + ) + .expect("symlink entry"); + writer.finish().expect("finish archive").into_inner() + }; + assert!( + validate_import_archive(&symlink, "link.html") + .expect_err("symlink") + .contains("符号链接") + ); + } + + #[test] + fn template_import_plan_uses_content_addressed_keys_and_sniffed_cover() { + let files = import_files(); + let (revision, planned) = + plan_import(&import_manifest(import_item()), &files).expect("plan import"); + + assert_eq!(revision, "a".repeat(64)); + let item = &planned[0]; + assert!( + item.import + .zip_key + .starts_with("templates/v1/smoke-import/sha256/") + ); + assert!(item.import.zip_key.ends_with("/template.zip")); + assert_eq!(item.import.zip_sha256, fingerprint(&files["zip_0"])); + assert_eq!(item.import.zip_size_bytes, files["zip_0"].len() as u64); + assert_eq!(item.cover_content_type, "image/png"); + assert!(item.import.cover_key.ends_with("/cover.png")); + assert_eq!((item.import.cover_width, item.import.cover_height), (4, 3)); + } + + #[test] + fn template_import_plan_rejects_unknown_and_unreferenced_fields() { + let manifest = import_manifest(import_item()); + let files = import_files(); + assert!(plan_import(&manifest, &files).is_ok()); + + let mut unreferenced = files.clone(); + unreferenced.insert("zip_1".to_string(), b"stray".to_vec()); + let error = plan_import(&manifest, &unreferenced).expect_err("unreferenced file field"); + assert_eq!(error.status_code(), StatusCode::BAD_REQUEST); + assert!( + error.message().contains("未引用的文件字段"), + "{}", + error.message() + ); + + let mut missing_zip = files.clone(); + missing_zip.remove("zip_0"); + let error = plan_import(&manifest, &missing_zip).expect_err("missing zip field"); + assert!( + error.message().contains("缺少 ZIP 文件字段"), + "{}", + error.message() + ); + + let mut wrong_prefix = import_item(); + wrong_prefix.zip_field = "payload_0".to_string(); + let error = plan_import(&import_manifest(wrong_prefix), &files) + .expect_err("field prefix must be explicit"); + assert!( + error.message().contains("必须以 zip_ 或 cover_ 开头"), + "{}", + error.message() + ); + + let unknown_field = serde_json::to_vec(&serde_json::json!({ + "expectedRevision": "a".repeat(64), + "templates": [], + "extra": 1, + })) + .expect("json"); + assert!(plan_import(&unknown_field, &files).is_err()); + + let broken_archive = { + let mut files = files.clone(); + files.insert("zip_0".to_string(), b"not a zip".to_vec()); + files + }; + let error = plan_import(&manifest, &broken_archive).expect_err("invalid zip"); + assert!( + error.message().contains("不是有效 zip"), + "{}", + error.message() + ); + } + #[test] fn template_cover_validates_actual_bytes_and_dimensions() { let cover = validate_cover(png_input()).expect("valid PNG"); diff --git a/server-rs/crates/api-server/src/modules/admin.rs b/server-rs/crates/api-server/src/modules/admin.rs index fd418e6e7..39f6a03e8 100644 --- a/server-rs/crates/api-server/src/modules/admin.rs +++ b/server-rs/crates/api-server/src/modules/admin.rs @@ -56,6 +56,14 @@ pub fn router(state: AppState) -> Router { 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"]), diff --git a/server-rs/crates/module-assets/src/template_library.rs b/server-rs/crates/module-assets/src/template_library.rs index 37a6d841b..b1b5e71b2 100644 --- a/server-rs/crates/module-assets/src/template_library.rs +++ b/server-rs/crates/module-assets/src/template_library.rs @@ -71,6 +71,40 @@ pub struct PreparedTemplateEdit { pub metadata: Value, } +/// 单批导入的模板数量上限:更大的批量请走 CLI(`--only` 定向发布)。 +pub const MAX_TEMPLATE_IMPORT_BATCH: usize = 20; +/// 后台上传允许的运行时(与 CLI 的 `RUNTIMES` 保持一致)。 +pub const TEMPLATE_IMPORT_RUNTIMES: [&str; 4] = ["html", "unity", "godot", "cocos"]; + +/// 后台导入的单条模板:内容对象键与字节摘要由存储适配器按上传字节算出。 +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct TemplateImport { + pub id: String, + pub title: String, + pub summary: String, + pub tags: Vec, + pub runtime: String, + pub engine: String, + pub engine_version: String, + pub template_version: String, + pub entry: String, + pub zip_key: String, + pub zip_sha256: String, + pub zip_size_bytes: u64, + pub cover_key: String, + pub cover_sha256: String, + pub cover_width: u32, + pub cover_height: u32, +} + +/// 一批导入的合并结果:待提交清单、每个模板的元数据字节,以及按内容复用旧对象的 ID。 +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct PreparedTemplateImport { + pub index: Value, + pub metadata: Vec<(String, Value)>, + pub reused: Vec, +} + fn string_field<'a>(entry: &'a Value, field: &str) -> Result<&'a str, TemplateDomainError> { entry .get(field) @@ -375,6 +409,295 @@ pub fn prepare_template_edit( Ok(PreparedTemplateEdit { index, metadata }) } +fn valid_import_entry_path(value: &str) -> bool { + let trimmed = value.trim(); + !trimmed.is_empty() + && trimmed.len() <= 256 + && !trimmed.starts_with(['/', '\\']) + && !trimmed.contains('\\') + && !trimmed.contains(':') + && !trimmed.contains("..") + && !trimmed + .chars() + .any(|value| value.is_control() || value.is_whitespace()) + && trimmed + .split('/') + .all(|part| !part.is_empty() && part != ".") +} + +fn valid_import_object_key(key: &str, id: &str, sha256: &str, suffix: &str) -> bool { + valid_object_key(key) + && valid_hash(sha256) + && key.starts_with(&format!("templates/v1/{id}/sha256/")) + && key.ends_with(&format!("/{suffix}")) +} + +fn validate_import(import: &TemplateImport) -> Result<(), TemplateDomainError> { + if !valid_identifier(&import.id, 64) { + return Err(TemplateDomainError::InvalidEdit(format!( + "模板 ID 无效:{}", + import.id + ))); + } + if !valid_identifier(&import.template_version, 32) { + return Err(TemplateDomainError::InvalidEdit(format!( + "{} 的 templateVersion 无效", + import.id + ))); + } + if !TEMPLATE_IMPORT_RUNTIMES.contains(&import.runtime.as_str()) { + return Err(TemplateDomainError::InvalidEdit(format!( + "{} 的 runtime 不在允许列表内", + import.id + ))); + } + if !valid_import_entry_path(&import.entry) { + return Err(TemplateDomainError::InvalidEdit(format!( + "{} 的 entry 不是库内相对路径", + import.id + ))); + } + if import.zip_size_bytes == 0 + || !valid_import_object_key( + &import.zip_key, + &import.id, + &import.zip_sha256, + "template.zip", + ) + { + return Err(TemplateDomainError::InvalidEdit(format!( + "{} 的 ZIP 对象键或摘要无效", + import.id + ))); + } + let cover_extension = import + .cover_key + .rsplit('/') + .next() + .and_then(|name| name.rsplit_once('.')) + .map(|(_, extension)| extension.to_ascii_lowercase()) + .unwrap_or_default(); + if !matches!( + cover_extension.as_str(), + "png" | "jpg" | "jpeg" | "webp" | "svg" + ) || !valid_import_object_key( + &import.cover_key, + &import.id, + &import.cover_sha256, + &format!("cover.{cover_extension}"), + ) || import.cover_width == 0 + || import.cover_height == 0 + || import.cover_width > 4096 + || import.cover_height > 4096 + || u64::from(import.cover_width) * u64::from(import.cover_height) > 16_000_000 + { + return Err(TemplateDomainError::InvalidEdit(format!( + "{} 的封面引用或尺寸无效", + import.id + ))); + } + // 展示字段沿用编辑路径的同一套上限与去重规则。 + normalize_edit(TemplateEdit { + title: import.title.clone(), + summary: import.summary.clone(), + tags: import.tags.clone(), + enabled: true, + })?; + Ok(()) +} + +fn import_entry_json(import: &TemplateImport, updated_at: &str) -> Value { + json!({ + "id": import.id, + "title": import.title.trim(), + "summary": import.summary.trim(), + "tags": import.tags, + "runtime": import.runtime, + "engine": import.engine.trim(), + "engineVersion": import.engine_version.trim(), + "templateVersion": import.template_version, + "updatedAt": updated_at, + "entry": import.entry.trim(), + "zipKey": import.zip_key, + "zipSizeBytes": import.zip_size_bytes, + "zipSha256": import.zip_sha256, + "coverKey": import.cover_key, + "coverWidth": import.cover_width, + "coverHeight": import.cover_height, + "coverSha256": import.cover_sha256, + }) +} + +fn import_metadata_json(import: &TemplateImport, updated_at: &str) -> Value { + json!({ + "schemaVersion": "agc-template.v1", + "id": import.id, + "title": import.title.trim(), + "summary": import.summary.trim(), + "tags": import.tags, + "runtime": import.runtime, + "engine": import.engine.trim(), + "engineVersion": import.engine_version.trim(), + "templateVersion": import.template_version, + "updatedAt": updated_at, + "entry": import.entry.trim(), + "zip": { + "key": import.zip_key, + "sizeBytes": import.zip_size_bytes, + "sha256": import.zip_sha256, + }, + "cover": { + "key": import.cover_key, + "width": import.cover_width, + "height": import.cover_height, + "sha256": import.cover_sha256, + }, + }) +} + +/// 合并一批后台导入:新增 ID 默认上架,已存在条目就地更新并保留上架状态与未知扩展字段。 +/// +/// 同一 ID、同一 `templateVersion` 的 ZIP 字节不同时拒绝(与 CLI 发布门禁同一句文案); +/// 字节完全一致时按内容复用既有对象,只更新展示字段与元数据。整批在同一把发布锁内提交, +/// 因此本函数只产出待提交快照,不做任何写入。 +pub fn prepare_template_import( + mut index: Value, + imports: Vec, + updated_at: &str, +) -> Result { + if imports.is_empty() { + return Err(TemplateDomainError::InvalidEdit( + "导入批次不能为空".to_owned(), + )); + } + if imports.len() > MAX_TEMPLATE_IMPORT_BATCH { + return Err(TemplateDomainError::InvalidEdit(format!( + "单批最多导入 {MAX_TEMPLATE_IMPORT_BATCH} 个模板" + ))); + } + if updated_at.trim().is_empty() { + return Err(TemplateDomainError::InvalidEdit( + "模板更新时间无效".to_owned(), + )); + } + let mut batch_ids = BTreeSet::new(); + for import in &imports { + validate_import(import)?; + if !batch_ids.insert(import.id.clone()) { + return Err(TemplateDomainError::InvalidEdit(format!( + "同一批次出现重复模板 ID:{}", + import.id + ))); + } + } + + let current = list_templates(&index)?; + let mut reused = Vec::new(); + let mut metadata = Vec::with_capacity(imports.len()); + for mut import in imports { + // 展示字段统一走编辑路径的同一套归一化(trim + 标签去重), + // 清单与 template.json 必须写入归一化后的值,否则两端会不一致。 + let normalized = normalize_edit(TemplateEdit { + title: import.title.clone(), + summary: import.summary.clone(), + tags: import.tags.clone(), + enabled: true, + })?; + import.title = normalized.title; + import.summary = normalized.summary; + import.tags = normalized.tags; + let existing = current.iter().find(|entry| entry.id == import.id); + let mut position = None; + for (group, _) in GROUPS { + let Some(entries) = index.get(group).and_then(Value::as_array) else { + continue; + }; + if let Some(index_in_group) = entries.iter().position(|entry| { + entry.get("id").and_then(Value::as_str) == Some(import.id.as_str()) + }) { + position = Some((group, index_in_group)); + break; + } + } + + match (existing, position) { + (Some(existing), Some((group, index_in_group))) => { + if existing.template_version == import.template_version { + let same_bytes = existing.zip_size_bytes == import.zip_size_bytes + && existing.zip_sha256.eq_ignore_ascii_case(&import.zip_sha256); + if !same_bytes { + return Err(TemplateDomainError::InvalidEdit(format!( + "{}@{} 同版本 ZIP 内容或尺寸变化,请递增 templateVersion", + import.id, import.template_version + ))); + } + reused.push(import.id.clone()); + } + // 以既有条目为底:未知扩展字段与 enabled 分组原样保留。 + let mut entry = index[group][index_in_group].clone(); + let object = entry + .as_object_mut() + .ok_or(TemplateDomainError::InvalidIndex)?; + for (field, value) in import_entry_json(&import, updated_at) + .as_object() + .ok_or(TemplateDomainError::InvalidIndex)? + .iter() + { + object.insert(field.clone(), value.clone()); + } + index[group][index_in_group] = entry; + } + (None, None) => { + index + .as_object_mut() + .ok_or(TemplateDomainError::InvalidIndex)? + .entry("templates") + .or_insert_with(|| json!([])) + .as_array_mut() + .ok_or(TemplateDomainError::InvalidIndex)? + .push(import_entry_json(&import, updated_at)); + } + _ => return Err(TemplateDomainError::InvalidIndex), + } + metadata.push((import.id.clone(), import_metadata_json(&import, updated_at))); + } + index["updatedAt"] = json!(updated_at); + Ok(PreparedTemplateImport { + index, + metadata, + reused, + }) +} + +impl PreparedTemplateImport { + /// 元数据字节摘要由存储适配器计算后,回填每个导入条目的 `metadataKey`。 + pub fn set_metadata_key(&mut self, id: &str, key: &str) -> Result<(), TemplateDomainError> { + if !valid_object_key(key) { + return Err(TemplateDomainError::InvalidMetadata); + } + let mut found = false; + for (group, _) in GROUPS { + let Some(entries) = self.index.get_mut(group).and_then(Value::as_array_mut) else { + continue; + }; + for entry in entries.iter_mut() { + if entry.get("id").and_then(Value::as_str) != Some(id) { + continue; + } + entry + .as_object_mut() + .ok_or(TemplateDomainError::InvalidIndex)? + .insert("metadataKey".to_owned(), json!(key)); + found = true; + } + } + if !found { + return Err(TemplateDomainError::NotFound); + } + Ok(()) + } +} + impl PreparedTemplateEdit { /// 元数据字节摘要由存储适配器计算后,回填唯一被编辑条目的对象键。 pub fn set_metadata_key(&mut self, id: &str, key: &str) -> Result<(), TemplateDomainError> { @@ -433,6 +756,222 @@ mod tests { "files": [{"path": "package.json", "sizeBytes": 10, "sha256": "c".repeat(64)}], "unknown": {"keep": true}}) } + fn import(id: &str, version: &str, zip_sha: &str, size: u64) -> TemplateImport { + TemplateImport { + id: id.to_owned(), + title: " 新模板 ".to_owned(), + summary: " 新简介 ".to_owned(), + tags: vec![" cocos ".to_owned(), "cocos".to_owned(), "二维".to_owned()], + runtime: "cocos".to_owned(), + engine: "cocos-creator".to_owned(), + engine_version: "3.8.8".to_owned(), + template_version: version.to_owned(), + entry: "package.json".to_owned(), + zip_key: format!("templates/v1/{id}/sha256/{zip_sha}/template.zip"), + zip_sha256: zip_sha.to_owned(), + zip_size_bytes: size, + cover_key: format!("templates/v1/{id}/sha256/{}/cover.svg", "b".repeat(64)), + cover_sha256: "b".repeat(64), + cover_width: 960, + cover_height: 540, + } + } + + #[test] + fn template_import_appends_new_entries_with_metadata() { + let prepared = prepare_template_import( + index(), + vec![import("d", "0.1.0", &"d".repeat(64), 42)], + "2026-09-21T00:00:00Z", + ) + .expect("import new template"); + + let ids = prepared.index["templates"] + .as_array() + .expect("templates") + .iter() + .map(|entry| entry["id"].as_str().expect("id").to_owned()) + .collect::>(); + assert_eq!(ids, vec!["b", "a", "d"]); + assert!(prepared.reused.is_empty()); + assert_eq!( + prepared.index["inactiveTemplates"].as_array().map(Vec::len), + Some(1) + ); + assert_eq!(prepared.index["updatedAt"], json!("2026-09-21T00:00:00Z")); + + let imported = prepared.index["templates"] + .as_array() + .expect("templates") + .iter() + .find(|entry| entry["id"] == json!("d")) + .expect("imported entry"); + assert_eq!(imported["title"], json!("新模板")); + assert_eq!(imported["tags"], json!(["cocos", "二维"])); + assert_eq!(imported["zipSizeBytes"], json!(42)); + assert_eq!( + imported["zipKey"], + json!(format!( + "templates/v1/d/sha256/{}/template.zip", + "d".repeat(64) + )) + ); + + let (id, metadata) = prepared.metadata.first().expect("metadata"); + assert_eq!(id, "d"); + assert_eq!(metadata["schemaVersion"], json!("agc-template.v1")); + assert_eq!(metadata["templateVersion"], json!("0.1.0")); + assert_eq!(metadata["zip"]["sizeBytes"], json!(42)); + assert_eq!(metadata["zip"]["sha256"], json!("d".repeat(64))); + assert_eq!(metadata["cover"]["width"], json!(960)); + } + + #[test] + fn template_import_keeps_inactive_group_and_unknown_fields() { + let prepared = prepare_template_import( + index(), + vec![import("c", "0.2.0", &"e".repeat(64), 7)], + "2026-09-21T00:00:00Z", + ) + .expect("import new version of an inactive template"); + + assert_eq!( + prepared.index["inactiveTemplates"].as_array().map(Vec::len), + Some(1) + ); + let entry = &prepared.index["inactiveTemplates"][0]; + assert_eq!(entry["id"], json!("c")); + assert_eq!(entry["templateVersion"], json!("0.2.0")); + assert_eq!(entry["extension"], json!({"keep": true})); + assert_eq!(entry["metadataKey"], json!("templates/v1/c/template.json")); + } + + #[test] + fn template_import_rejects_same_version_with_different_bytes() { + let error = prepare_template_import( + index(), + vec![import("a", "0.1.0", &"f".repeat(64), 10)], + "2026-09-21T00:00:00Z", + ) + .expect_err("same version must keep identical bytes"); + assert!( + error + .to_string() + .contains("同版本 ZIP 内容或尺寸变化,请递增 templateVersion"), + "{error}" + ); + } + + #[test] + fn template_import_reuses_identical_bytes_for_the_same_version() { + let prepared = prepare_template_import( + index(), + vec![import("a", "0.1.0", &"a".repeat(64), 10)], + "2026-09-21T00:00:00Z", + ) + .expect("identical bytes are idempotent"); + + assert_eq!(prepared.reused, vec!["a".to_owned()]); + let entry = prepared.index["templates"] + .as_array() + .expect("templates") + .iter() + .find(|entry| entry["id"] == json!("a")) + .expect("entry"); + assert_eq!(entry["title"], json!("新模板")); + assert_eq!(entry["extension"], json!({"keep": true})); + } + + #[test] + fn template_import_rejects_invalid_batches_before_any_write() { + let cases: Vec<(Vec, &str)> = vec![ + (vec![], "导入批次不能为空"), + ( + (0..=MAX_TEMPLATE_IMPORT_BATCH) + .map(|index| { + import(&format!("t{index}"), "0.1.0", &format!("{:0>64}", index), 1) + }) + .collect(), + "单批最多导入", + ), + ( + vec![ + import("dup", "0.1.0", &"1".repeat(64), 1), + import("dup", "0.1.1", &"2".repeat(64), 1), + ], + "重复模板 ID", + ), + ]; + for (imports, expected) in cases { + let error = prepare_template_import(index(), imports, "2026-09-21T00:00:00Z") + .expect_err("invalid batch rejected"); + assert!(error.to_string().contains(expected), "{error}"); + } + + let mut bad_runtime = import("x", "0.1.0", &"3".repeat(64), 1); + bad_runtime.runtime = "unity-native".to_owned(); + assert!( + prepare_template_import(index(), vec![bad_runtime], "now") + .expect_err("runtime allowlist") + .to_string() + .contains("runtime 不在允许列表内") + ); + + let mut bad_entry = import("x", "0.1.0", &"3".repeat(64), 1); + bad_entry.entry = "../escape.json".to_owned(); + assert!( + prepare_template_import(index(), vec![bad_entry], "now") + .expect_err("entry must stay inside the library") + .to_string() + .contains("entry 不是库内相对路径") + ); + + let mut bad_id = import("x", "0.1.0", &"3".repeat(64), 1); + bad_id.id = "Bad.Id".to_owned(); + assert!( + prepare_template_import(index(), vec![bad_id], "now") + .expect_err("identifier whitelist") + .to_string() + .contains("模板 ID 无效") + ); + + let mut empty_zip = import("x", "0.1.0", &"3".repeat(64), 1); + empty_zip.zip_size_bytes = 0; + assert!( + prepare_template_import(index(), vec![empty_zip], "now") + .expect_err("empty zip rejected") + .to_string() + .contains("ZIP 对象键或摘要无效") + ); + } + + #[test] + fn template_import_backfills_metadata_key_after_hashing_metadata() { + let mut prepared = prepare_template_import( + index(), + vec![import("d", "0.1.0", &"d".repeat(64), 42)], + "2026-09-21T00:00:00Z", + ) + .expect("import"); + let key = format!("templates/v1/d/sha256/{}/template.json", "9".repeat(64)); + prepared + .set_metadata_key("d", &key) + .expect("set metadata key"); + + let entry = prepared.index["templates"] + .as_array() + .expect("templates") + .iter() + .find(|entry| entry["id"] == json!("d")) + .expect("entry"); + assert_eq!(entry["metadataKey"], json!(key)); + assert!( + prepared + .set_metadata_key("missing", &key) + .is_err_and(|error| error == TemplateDomainError::NotFound) + ); + } + fn edit(enabled: bool) -> TemplateEdit { TemplateEdit { title: " 新名称 ".to_owned(), diff --git a/server-rs/crates/platform-oss/src/template_library.rs b/server-rs/crates/platform-oss/src/template_library.rs index 0d538d7d0..d85d8326b 100644 --- a/server-rs/crates/platform-oss/src/template_library.rs +++ b/server-rs/crates/platform-oss/src/template_library.rs @@ -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::().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] { diff --git a/server-rs/crates/shared-contracts/src/admin.rs b/server-rs/crates/shared-contracts/src/admin.rs index 889b94223..60d7ca511 100644 --- a/server-rs/crates/shared-contracts/src/admin.rs +++ b/server-rs/crates/shared-contracts/src/admin.rs @@ -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, +} + +/// 单条导入模板: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, + 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, + pub imported: Vec, +} + +/// 单条导入结果。 +#[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, +} + // 登录成功后返回管理员访问令牌与基础会话信息。 /// 后台创作入口开关列表响应。