Files
Genarrative/server-rs/crates/module-assets/src/template_library.rs
T
kdletters 4863c8c066 后台模板上传(后端):新增批量导入接口与存储/领域支持
- 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
2026-09-21 16:06:23 +08:00

1153 lines
42 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! 模板库的纯领域投影与编辑规则;OSS 读写、图片解码和发布锁由适配器承担。
use std::{collections::BTreeSet, error::Error, fmt};
use serde_json::{Map, Value, json};
const GROUPS: [(&str, bool); 2] = [("templates", true), ("inactiveTemplates", false)];
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum TemplateDomainError {
InvalidIndex,
InvalidEdit(String),
NotFound,
InvalidMetadata,
}
impl fmt::Display for TemplateDomainError {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::InvalidIndex => formatter.write_str("模板库清单格式无效"),
Self::InvalidEdit(message) => formatter.write_str(message),
Self::NotFound => formatter.write_str("模板不存在"),
Self::InvalidMetadata => formatter.write_str("模板元数据无效或与清单不一致"),
}
}
}
impl Error for TemplateDomainError {}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct ManagedTemplate {
pub id: String,
pub title: String,
pub summary: String,
pub runtime: String,
pub engine: String,
pub engine_version: String,
pub template_version: String,
pub zip_key: String,
pub zip_sha256: String,
pub cover_key: String,
pub cover_sha256: String,
pub metadata_key: String,
pub tags: Vec<String>,
pub enabled: bool,
pub zip_size_bytes: u64,
pub cover_width: u32,
pub cover_height: u32,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct TemplateEdit {
pub title: String,
pub summary: String,
pub tags: Vec<String>,
pub enabled: bool,
}
/// 已由图片适配器验证原始格式的封面引用。
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct TemplateCover {
pub key: String,
pub sha256: String,
pub width: u32,
pub height: u32,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct PreparedTemplateEdit {
pub index: Value,
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<String>,
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<String>,
}
fn string_field<'a>(entry: &'a Value, field: &str) -> Result<&'a str, TemplateDomainError> {
entry
.get(field)
.and_then(Value::as_str)
.ok_or(TemplateDomainError::InvalidIndex)
}
fn optional_string(entry: &Value, field: &str) -> Result<String, TemplateDomainError> {
match entry.get(field) {
None => Ok(String::new()),
Some(value) => value
.as_str()
.map(str::to_owned)
.ok_or(TemplateDomainError::InvalidIndex),
}
}
fn optional_dimension(entry: &Value, field: &str) -> Result<u32, TemplateDomainError> {
match entry.get(field) {
None => Ok(0),
Some(value) => value
.as_u64()
.and_then(|value| u32::try_from(value).ok())
.ok_or(TemplateDomainError::InvalidIndex),
}
}
fn valid_identifier(value: &str, maximum: usize) -> bool {
!value.is_empty()
&& value.len() <= maximum
&& value.as_bytes()[0].is_ascii_alphanumeric()
&& value.bytes().all(|byte| {
byte.is_ascii_lowercase() || byte.is_ascii_digit() || matches!(byte, b'.' | b'_' | b'-')
})
&& !value.contains("..")
}
fn valid_hash(value: &str) -> bool {
value.len() == 64 && value.bytes().all(|byte| byte.is_ascii_hexdigit())
}
fn valid_object_key(key: &str) -> bool {
key.starts_with("templates/")
&& !key.contains("..")
&& !key.chars().any(|value| {
value.is_control() || value.is_whitespace() || matches!(value, '\\' | '?' | '#' | '%')
})
&& key.split('/').all(|part| !part.is_empty() && part != ".")
}
fn parse_entry(entry: &Value, enabled: bool) -> Result<ManagedTemplate, TemplateDomainError> {
let id = string_field(entry, "id")?;
let title = string_field(entry, "title")?;
let template_version = string_field(entry, "templateVersion")?;
let runtime = string_field(entry, "runtime")?;
let zip_key = string_field(entry, "zipKey")?;
let zip_sha256 = string_field(entry, "zipSha256")?;
let cover_key = string_field(entry, "coverKey")?;
let cover_sha256 = optional_string(entry, "coverSha256")?;
let metadata_key = optional_string(entry, "metadataKey")?;
let zip_size_bytes = entry
.get("zipSizeBytes")
.and_then(Value::as_u64)
.ok_or(TemplateDomainError::InvalidIndex)?;
let tags = match entry.get("tags") {
None => Vec::new(),
Some(value) => value
.as_array()
.ok_or(TemplateDomainError::InvalidIndex)?
.iter()
.map(|tag| {
tag.as_str()
.map(str::to_owned)
.ok_or(TemplateDomainError::InvalidIndex)
})
.collect::<Result<Vec<_>, _>>()?,
};
if !valid_identifier(id, 64)
|| !valid_identifier(template_version, 32)
|| title.trim().is_empty()
|| title.chars().count() > 120
|| !matches!(runtime, "html" | "unity" | "godot" | "cocos")
|| !valid_object_key(zip_key)
|| !valid_object_key(cover_key)
|| (!metadata_key.is_empty() && !valid_object_key(&metadata_key))
|| !valid_hash(zip_sha256)
|| (!cover_sha256.is_empty() && !valid_hash(&cover_sha256))
|| zip_size_bytes == 0
|| zip_size_bytes > 512 * 1024 * 1024
|| tags.len() > 32
|| tags.iter().any(|tag| tag.trim().is_empty())
{
return Err(TemplateDomainError::InvalidIndex);
}
Ok(ManagedTemplate {
id: id.to_owned(),
title: title.to_owned(),
template_version: template_version.to_owned(),
runtime: runtime.to_owned(),
zip_key: zip_key.to_owned(),
zip_sha256: zip_sha256.to_owned(),
cover_key: cover_key.to_owned(),
cover_sha256,
metadata_key,
tags,
enabled,
zip_size_bytes,
summary: optional_string(entry, "summary")?,
engine: optional_string(entry, "engine")?,
engine_version: optional_string(entry, "engineVersion")?,
cover_width: optional_dimension(entry, "coverWidth")?,
cover_height: optional_dimension(entry, "coverHeight")?,
})
}
/// 两组共享同一 ID 命名空间;下架条目仍保留在公开清单中。
pub fn list_templates(index: &Value) -> Result<Vec<ManagedTemplate>, TemplateDomainError> {
if index.get("schemaVersion").and_then(Value::as_str) != Some("agc-template-library.v1")
|| index.get("library").and_then(Value::as_str) != Some("agc-game-templates")
|| index.get("libraryVersion").and_then(Value::as_u64) != Some(1)
{
return Err(TemplateDomainError::InvalidIndex);
}
let mut ids = BTreeSet::new();
let mut templates = Vec::new();
for (group, enabled) in GROUPS {
let entries = match index.get(group) {
None if !enabled => continue,
Some(value) => value.as_array().ok_or(TemplateDomainError::InvalidIndex)?,
None => return Err(TemplateDomainError::InvalidIndex),
};
for entry in entries {
let template = parse_entry(entry, enabled)?;
if !ids.insert(template.id.clone()) {
return Err(TemplateDomainError::InvalidIndex);
}
templates.push(template);
}
}
templates.sort_by(|left, right| left.id.cmp(&right.id));
Ok(templates)
}
fn normalize_edit(edit: TemplateEdit) -> Result<TemplateEdit, TemplateDomainError> {
let title = edit.title.trim().to_owned();
let summary = edit.summary.trim().to_owned();
if title.is_empty() || title.chars().count() > 80 {
return Err(TemplateDomainError::InvalidEdit(
"模板名称须为 1–80 个字符".to_owned(),
));
}
if summary.chars().count() > 1000 {
return Err(TemplateDomainError::InvalidEdit(
"模板简介不能超过 1000 个字符".to_owned(),
));
}
let mut seen = BTreeSet::new();
let mut tags = Vec::new();
for tag in edit.tags {
let tag = tag.trim().to_owned();
if tag.is_empty() || tag.chars().count() > 32 {
return Err(TemplateDomainError::InvalidEdit(
"每个标签须为 1–32 个字符".to_owned(),
));
}
if seen.insert(tag.clone()) {
tags.push(tag);
}
}
if tags.len() > 16 {
return Err(TemplateDomainError::InvalidEdit(
"模板标签不能超过 16 个".to_owned(),
));
}
Ok(TemplateEdit {
title,
summary,
tags,
enabled: edit.enabled,
})
}
fn validate_metadata(
metadata: &Value,
template: &ManagedTemplate,
) -> Result<(), TemplateDomainError> {
if metadata.get("schemaVersion").and_then(Value::as_str) != Some("agc-template.v1")
|| metadata.get("id").and_then(Value::as_str) != Some(template.id.as_str())
|| metadata.get("templateVersion").and_then(Value::as_str)
!= Some(template.template_version.as_str())
|| metadata.pointer("/zip/key").and_then(Value::as_str) != Some(template.zip_key.as_str())
|| metadata.pointer("/zip/sizeBytes").and_then(Value::as_u64)
!= Some(template.zip_size_bytes)
|| !metadata
.pointer("/zip/sha256")
.and_then(Value::as_str)
.is_some_and(|hash| hash.eq_ignore_ascii_case(&template.zip_sha256))
{
return Err(TemplateDomainError::InvalidMetadata);
}
Ok(())
}
fn set_display_fields(object: &mut Map<String, Value>, edit: &TemplateEdit, updated_at: &str) {
object.insert("title".to_owned(), json!(edit.title));
object.insert("summary".to_owned(), json!(edit.summary));
object.insert("tags".to_owned(), json!(edit.tags));
object.insert("updatedAt".to_owned(), json!(updated_at));
}
/// 只产生待提交快照,保留未编辑条目、ZIP 合同与未知扩展字段。
pub fn prepare_template_edit(
mut index: Value,
mut metadata: Value,
id: &str,
edit: TemplateEdit,
cover: Option<TemplateCover>,
updated_at: &str,
) -> Result<PreparedTemplateEdit, TemplateDomainError> {
let template = list_templates(&index)?
.into_iter()
.find(|entry| entry.id == id)
.ok_or(TemplateDomainError::NotFound)?;
let edit = normalize_edit(edit)?;
validate_metadata(&metadata, &template)?;
if updated_at.trim().is_empty() {
return Err(TemplateDomainError::InvalidEdit(
"模板更新时间无效".to_owned(),
));
}
if let Some(cover) = &cover {
if !valid_object_key(&cover.key)
|| !valid_hash(&cover.sha256)
|| cover.width == 0
|| cover.height == 0
|| cover.width > 4096
|| cover.height > 4096
|| u64::from(cover.width) * u64::from(cover.height) > 16_000_000
{
return Err(TemplateDomainError::InvalidEdit(
"模板封面引用或尺寸无效".to_owned(),
));
}
}
let source = if template.enabled {
"templates"
} else {
"inactiveTemplates"
};
let position = index[source]
.as_array()
.ok_or(TemplateDomainError::InvalidIndex)?
.iter()
.position(|entry| entry.get("id").and_then(Value::as_str) == Some(id))
.ok_or(TemplateDomainError::NotFound)?;
let mut entry = index[source][position].clone();
let entry_object = entry
.as_object_mut()
.ok_or(TemplateDomainError::InvalidIndex)?;
let metadata_object = metadata
.as_object_mut()
.ok_or(TemplateDomainError::InvalidMetadata)?;
set_display_fields(entry_object, &edit, updated_at);
set_display_fields(metadata_object, &edit, updated_at);
if let Some(cover) = cover {
entry_object.insert("coverKey".to_owned(), json!(cover.key));
entry_object.insert("coverSha256".to_owned(), json!(cover.sha256));
entry_object.insert("coverWidth".to_owned(), json!(cover.width));
entry_object.insert("coverHeight".to_owned(), json!(cover.height));
let metadata_cover = metadata_object
.entry("cover")
.or_insert_with(|| json!({}))
.as_object_mut()
.ok_or(TemplateDomainError::InvalidMetadata)?;
metadata_cover.insert("key".to_owned(), json!(cover.key));
metadata_cover.insert("sha256".to_owned(), json!(cover.sha256));
metadata_cover.insert("width".to_owned(), json!(cover.width));
metadata_cover.insert("height".to_owned(), json!(cover.height));
}
if template.enabled == edit.enabled {
index[source][position] = entry;
} else {
index[source]
.as_array_mut()
.ok_or(TemplateDomainError::InvalidIndex)?
.remove(position);
let destination = if edit.enabled {
"templates"
} else {
"inactiveTemplates"
};
index
.as_object_mut()
.ok_or(TemplateDomainError::InvalidIndex)?
.entry(destination)
.or_insert_with(|| json!([]))
.as_array_mut()
.ok_or(TemplateDomainError::InvalidIndex)?
.push(entry);
}
index["updatedAt"] = json!(updated_at);
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<TemplateImport>,
updated_at: &str,
) -> Result<PreparedTemplateImport, TemplateDomainError> {
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> {
let template = list_templates(&self.index)?
.into_iter()
.find(|entry| entry.id == id)
.ok_or(TemplateDomainError::NotFound)?;
validate_metadata(&self.metadata, &template)?;
if !valid_object_key(key) {
return Err(TemplateDomainError::InvalidMetadata);
}
for (group, _) in GROUPS {
let Some(entries) = self.index.get_mut(group).and_then(Value::as_array_mut) else {
continue;
};
if let Some(entry) = entries
.iter_mut()
.find(|entry| entry.get("id").and_then(Value::as_str) == Some(id))
{
entry["metadataKey"] = json!(key);
return Ok(());
}
}
Err(TemplateDomainError::NotFound)
}
}
#[cfg(test)]
mod tests {
use super::*;
fn entry(id: &str) -> Value {
json!({
"id": id, "title": "模板", "summary": "简介", "tags": ["cocos"],
"runtime": "cocos", "engine": "cocos-creator", "engineVersion": "3.8.8",
"templateVersion": "0.1.0", "entry": "package.json", "updatedAt": "old",
"zipKey": format!("templates/v1/{id}/template.zip"), "zipSizeBytes": 10,
"zipSha256": "a".repeat(64), "coverKey": format!("templates/v1/{id}/cover.svg"),
"coverSha256": "b".repeat(64), "coverWidth": 960, "coverHeight": 540,
"metadataKey": format!("templates/v1/{id}/template.json"), "extension": {"keep": true}
})
}
fn index() -> Value {
json!({"schemaVersion": "agc-template-library.v1", "library": "agc-game-templates",
"libraryVersion": 1, "updatedAt": "old", "extension": [1, 2],
"templates": [entry("b"), entry("a")], "inactiveTemplates": [entry("c")]})
}
fn metadata() -> Value {
json!({"schemaVersion": "agc-template.v1", "id": "a", "templateVersion": "0.1.0",
"title": "模板", "summary": "简介", "tags": ["cocos"], "runtime": "cocos",
"engine": "cocos-creator", "engineVersion": "3.8.8", "entry": "package.json",
"zip": {"key": "templates/v1/a/template.zip", "sizeBytes": 10, "sha256": "a".repeat(64), "extra": 1},
"cover": {"key": "templates/v1/a/cover.svg", "sha256": "b".repeat(64), "width": 960, "height": 540, "extra": 2},
"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::<Vec<_>>();
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<TemplateImport>, &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(),
summary: " 新简介 ".to_owned(),
tags: vec![" cocos ".to_owned(), "cocos".to_owned(), "二维".to_owned()],
enabled,
}
}
#[test]
fn template_library_edits_round_trip_active_and_inactive_without_losing_fields() {
let original = index();
let old_metadata = metadata();
let mut off = prepare_template_edit(
original.clone(),
old_metadata.clone(),
"a",
edit(false),
None,
"new",
)
.unwrap();
assert_eq!(off.index["templates"], json!([entry("b")]));
let listed = list_templates(&off.index).unwrap();
assert_eq!(
listed
.iter()
.map(|item| item.id.as_str())
.collect::<Vec<_>>(),
["a", "b", "c"]
);
assert!(!listed[0].enabled);
assert_eq!(listed[0].tags, ["cocos", "二维"]);
assert_eq!(off.index["extension"], original["extension"]);
for field in [
"zip",
"files",
"runtime",
"engine",
"engineVersion",
"templateVersion",
"entry",
"unknown",
] {
assert_eq!(off.metadata[field], old_metadata[field], "{field}");
}
let key = format!("templates/v1/a/sha256/{}/template.json", "d".repeat(64));
assert_eq!(
off.set_metadata_key("b", &key).unwrap_err(),
TemplateDomainError::InvalidMetadata
);
off.set_metadata_key("a", &key).unwrap();
assert_eq!(list_templates(&off.index).unwrap()[0].metadata_key, key);
let on =
prepare_template_edit(off.index, off.metadata, "a", edit(true), None, "newer").unwrap();
assert_eq!(on.index["inactiveTemplates"], json!([entry("c")]));
let template = list_templates(&on.index).unwrap().remove(0);
assert!(template.enabled);
assert_eq!(template.title, "新名称");
assert_eq!(template.zip_key, "templates/v1/a/template.zip");
assert_eq!(template.zip_sha256, "a".repeat(64));
assert_eq!(template.template_version, "0.1.0");
}
#[test]
fn template_library_cover_edit_preserves_nested_metadata_extensions() {
let key = format!("templates/v1/a/sha256/{}/cover.png", "e".repeat(64));
let prepared = prepare_template_edit(
index(),
metadata(),
"a",
edit(true),
Some(TemplateCover {
key: key.clone(),
sha256: "e".repeat(64),
width: 1024,
height: 576,
}),
"new",
)
.unwrap();
let template = list_templates(&prepared.index).unwrap().remove(0);
assert_eq!(template.cover_key, key);
assert_eq!((template.cover_width, template.cover_height), (1024, 576));
assert_eq!(prepared.metadata["cover"]["extra"], 2);
assert_eq!(prepared.metadata["cover"]["key"], key);
assert_eq!(prepared.metadata["zip"], metadata()["zip"]);
}
#[test]
fn template_library_rejects_metadata_from_another_template_or_package() {
for (pointer, value) in [
("/id", json!("b")),
("/templateVersion", json!("0.2.0")),
("/zip/key", json!("templates/v1/b/template.zip")),
("/zip/sizeBytes", json!(11)),
("/zip/sha256", json!("f".repeat(64))),
("/schemaVersion", json!("unknown")),
] {
let mut wrong = metadata();
*wrong.pointer_mut(pointer).unwrap() = value;
assert_eq!(
prepare_template_edit(index(), wrong, "a", edit(true), None, "new").unwrap_err(),
TemplateDomainError::InvalidMetadata
);
}
}
#[test]
fn template_library_rejects_duplicate_ids_and_unsafe_references() {
let mut duplicate = index();
duplicate["inactiveTemplates"] = json!([entry("a")]);
assert_eq!(
list_templates(&duplicate).unwrap_err(),
TemplateDomainError::InvalidIndex
);
for key in [
"other/v1/a/template.zip",
"templates/../private",
"templates/%2e%2e/private",
"templates/v1/a/file?token=secret",
] {
let mut invalid = index();
invalid["templates"][0]["zipKey"] = json!(key);
assert_eq!(
list_templates(&invalid).unwrap_err(),
TemplateDomainError::InvalidIndex
);
}
let mut legacy = index();
legacy.as_object_mut().unwrap().remove("inactiveTemplates");
assert_eq!(list_templates(&legacy).unwrap().len(), 2);
legacy["inactiveTemplates"] = Value::Null;
assert_eq!(
list_templates(&legacy).unwrap_err(),
TemplateDomainError::InvalidIndex
);
}
#[test]
fn template_library_edit_limits_count_unicode_characters_and_unique_tags() {
let mut maximum = edit(true);
maximum.title = "名".repeat(80);
maximum.summary = "介".repeat(1000);
maximum.tags = (0..16).map(|number| format!("标签{number}")).collect();
prepare_template_edit(index(), metadata(), "a", maximum.clone(), None, "new").unwrap();
let mut invalid_edits = Vec::new();
let mut invalid = maximum.clone();
invalid.title.push('名');
invalid_edits.push(invalid);
let mut invalid = maximum.clone();
invalid.title = " ".to_owned();
invalid_edits.push(invalid);
let mut invalid = maximum.clone();
invalid.summary.push('介');
invalid_edits.push(invalid);
let mut invalid = maximum.clone();
invalid.tags.push("第十七个".to_owned());
invalid_edits.push(invalid);
let mut invalid = maximum.clone();
invalid.tags = vec!["标".repeat(33)];
invalid_edits.push(invalid);
let mut invalid = maximum;
invalid.tags = vec![" ".to_owned()];
invalid_edits.push(invalid);
for invalid in invalid_edits {
assert!(matches!(
prepare_template_edit(index(), metadata(), "a", invalid, None, "new"),
Err(TemplateDomainError::InvalidEdit(_))
));
}
assert_eq!(
prepare_template_edit(index(), metadata(), "missing", edit(true), None, "new")
.unwrap_err(),
TemplateDomainError::NotFound
);
}
}