合并主分支
Project CI / Repository checks (pull_request) Failing after 11s
Project CI / Backend tests (pull_request) Failing after 11s
Project CI / Frontend tests (pull_request) Successful in 3m1s
Project CI / Native shell tests (pull_request) Successful in 13m12s

同步主分支资源卡依赖关系与类型分类预览等最新变更
保留并补齐画布快速编辑前后端正向白名单
拒绝图片编辑接口处理单个图标、角色动作、音频、视频及未知类型
修正快速编辑决策记录与现行编辑器文档
补充后端现役类型和未知类型表驱动测试
This commit is contained in:
2026-08-05 20:14:45 +08:00
76 changed files with 9334 additions and 1261 deletions
@@ -2709,28 +2709,43 @@ fn align_editor_image_edit_dimension(value: u32) -> u32 {
value.saturating_add(15) / 16 * 16
}
fn ensure_editor_image_edit_asset_kind_allowed(asset_kind: Option<&str>) -> Result<(), AppError> {
let Some(asset_kind) = asset_kind.map(str::trim).filter(|value| !value.is_empty()) else {
return Ok(());
};
if asset_kind != "icon" {
fn ensure_editor_image_edit_source_kind_allowed(
asset_kind: Option<&str>,
media_type: Option<&str>,
) -> Result<(), AppError> {
let asset_kind = asset_kind.map(str::trim).filter(|value| !value.is_empty());
let media_type = media_type.map(str::trim).filter(|value| !value.is_empty());
let asset_kind_allowed = matches!(
asset_kind,
None | Some(
"spec"
| "character"
| "icon-spritesheet"
| "icon-spec"
| "publication-material"
| "ui-design"
)
);
let media_type_allowed = matches!(media_type, None | Some("image"));
if asset_kind_allowed && media_type_allowed {
return Ok(());
}
Err(
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
"provider": "editor-image-edit",
"message": "单个图标不支持快速编辑",
"message": "当前素材类型不支持图片快速编辑",
"assetKind": asset_kind,
"mediaType": media_type,
})),
)
}
fn resolve_editor_image_edit_target_layer_asset_kind<'a>(
fn resolve_editor_image_edit_target_layer_source_kind<'a>(
layers: &'a Value,
resources: &'a [EditorProjectResourcePayload],
target_layer_id: &str,
) -> Result<Option<&'a str>, AppError> {
) -> Result<(Option<&'a str>, Option<&'a str>), AppError> {
let target_layer = layers
.as_array()
.and_then(|items| {
@@ -2745,9 +2760,22 @@ fn resolve_editor_image_edit_target_layer_asset_kind<'a>(
"targetLayerId": target_layer_id,
}))
})?;
let media_type = match target_layer.get("mediaType") {
Some(Value::String(value)) if !value.trim().is_empty() => Some(value.trim()),
Some(Value::Null) | None => None,
Some(_) => {
return Err(
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
"provider": "editor-image-edit",
"message": "快速编辑目标图层的媒体类型无效",
"targetLayerId": target_layer_id,
})),
);
}
};
match target_layer.get("assetKindOverride") {
Some(Value::String(value)) if !value.trim().is_empty() => {
return Ok(Some(value.trim()));
return Ok((Some(value.trim()), media_type));
}
Some(Value::Null) | None => {}
Some(_) => {
@@ -2761,12 +2789,13 @@ fn resolve_editor_image_edit_target_layer_asset_kind<'a>(
}
}
let resource_id = target_layer.get("resourceId").and_then(Value::as_str);
Ok(resource_id.and_then(|resource_id| {
let asset_kind = resource_id.and_then(|resource_id| {
resources
.iter()
.find(|resource| resource.resource_id == resource_id)
.and_then(|resource| resource.asset_kind.as_deref())
}))
});
Ok((asset_kind, media_type))
}
async fn ensure_editor_image_edit_source_allowed(
@@ -2780,7 +2809,7 @@ async fn ensure_editor_image_edit_source_allowed(
.map(str::trim)
.filter(|value| !value.is_empty())
else {
return ensure_editor_image_edit_asset_kind_allowed(payload.asset_kind.as_deref());
return ensure_editor_image_edit_source_kind_allowed(payload.asset_kind.as_deref(), None);
};
let Some(target_layer_id) = payload
.target_layer_id
@@ -2788,7 +2817,7 @@ async fn ensure_editor_image_edit_source_allowed(
.map(str::trim)
.filter(|value| !value.is_empty())
else {
return ensure_editor_image_edit_asset_kind_allowed(payload.asset_kind.as_deref());
return ensure_editor_image_edit_source_kind_allowed(payload.asset_kind.as_deref(), None);
};
let project = state
.spacetime_client()
@@ -2799,13 +2828,15 @@ async fn ensure_editor_image_edit_source_allowed(
.await
.map_err(map_editor_project_error)?;
let project = editor_project_payload_from_record(project);
let asset_kind = resolve_editor_image_edit_target_layer_asset_kind(
let (asset_kind, media_type) = resolve_editor_image_edit_target_layer_source_kind(
&project.layers,
&project.resources,
target_layer_id,
)?
.or(payload.asset_kind.as_deref());
ensure_editor_image_edit_asset_kind_allowed(asset_kind)
)?;
ensure_editor_image_edit_source_kind_allowed(
asset_kind.or(payload.asset_kind.as_deref()),
media_type,
)
}
fn encode_editor_image_png(
@@ -13422,35 +13453,54 @@ mod tests {
}
#[test]
fn editor_image_edit_rejects_individual_icons_but_allows_spritesheets_and_specs() {
let error = ensure_editor_image_edit_asset_kind_allowed(Some("icon"))
fn editor_image_edit_source_kind_whitelist_covers_current_and_unknown_types() {
let cases = [
(None, None, true),
(None, Some("image"), true),
(Some("spec"), Some("image"), true),
(Some("character"), Some("image"), true),
(Some("icon-spritesheet"), Some("image"), true),
(Some("icon-spec"), Some("image"), true),
(Some("publication-material"), Some("image"), true),
(Some("ui-design"), Some("image"), true),
(Some("icon"), Some("image"), false),
(Some("character-animation"), Some("image-sequence"), false),
(Some("sound-effect"), Some("audio"), false),
(Some("background-music"), Some("audio"), false),
(Some("video"), Some("video"), false),
(Some("future-asset-kind"), Some("image"), false),
(None, Some("future-media-type"), false),
];
for (asset_kind, media_type, expected_allowed) in cases {
let result = ensure_editor_image_edit_source_kind_allowed(asset_kind, media_type);
assert_eq!(
result.is_ok(),
expected_allowed,
"unexpected whitelist result for assetKind={asset_kind:?}, mediaType={media_type:?}",
);
}
let error = ensure_editor_image_edit_source_kind_allowed(Some("icon"), Some("image"))
.expect_err("individual icon assets should not support quick edit");
assert_eq!(error.status_code(), StatusCode::BAD_REQUEST);
assert_eq!(
error.details().and_then(|details| details.get("message")),
Some(&json!("单个图标不支持快速编辑")),
Some(&json!("当前素材类型不支持图片快速编辑")),
);
assert_eq!(
error.details().and_then(|details| details.get("assetKind")),
Some(&json!("icon")),
);
assert!(ensure_editor_image_edit_asset_kind_allowed(Some("icon-spritesheet")).is_ok());
assert!(ensure_editor_image_edit_asset_kind_allowed(Some("icon-spec")).is_ok());
assert!(ensure_editor_image_edit_asset_kind_allowed(Some("image")).is_ok());
assert!(ensure_editor_image_edit_asset_kind_allowed(None).is_ok());
let layers = json!([
{
"layerId": "layer-icon",
"resourceId": "resource-default-spec",
"assetKind": "character",
"assetKindOverride": "icon"
"assetKindOverride": "icon",
"mediaType": "image"
},
{
"layerId": "layer-icon-spec",
"resourceId": "resource-icon-spec",
"assetKind": "icon"
"assetKind": "icon",
"mediaType": "image"
}
]);
let resources = vec![
@@ -13458,19 +13508,23 @@ mod tests {
editor_project_resource_for_canvas_test("resource-icon-spec", "icon-spec", 512, 512),
];
let icon_kind =
resolve_editor_image_edit_target_layer_asset_kind(&layers, &resources, "layer-icon")
resolve_editor_image_edit_target_layer_source_kind(&layers, &resources, "layer-icon")
.expect("icon layer should exist");
assert!(ensure_editor_image_edit_asset_kind_allowed(icon_kind).is_err());
let icon_spec_kind = resolve_editor_image_edit_target_layer_asset_kind(
assert_eq!(icon_kind, (Some("icon"), Some("image")));
assert!(ensure_editor_image_edit_source_kind_allowed(icon_kind.0, icon_kind.1).is_err());
let icon_spec_kind = resolve_editor_image_edit_target_layer_source_kind(
&layers,
&resources,
"layer-icon-spec",
)
.expect("icon spec layer should exist");
assert_eq!(icon_spec_kind, Some("icon-spec"));
assert!(ensure_editor_image_edit_asset_kind_allowed(icon_spec_kind).is_ok());
assert_eq!(icon_spec_kind, (Some("icon-spec"), Some("image")));
assert!(
resolve_editor_image_edit_target_layer_asset_kind(&layers, &[], "missing").is_err()
ensure_editor_image_edit_source_kind_allowed(icon_spec_kind.0, icon_spec_kind.1)
.is_ok()
);
assert!(
resolve_editor_image_edit_target_layer_source_kind(&layers, &[], "missing").is_err()
);
let invalid_override = json!([{
"layerId": "layer-invalid",
@@ -13478,7 +13532,7 @@ mod tests {
"assetKindOverride": ""
}]);
assert!(
resolve_editor_image_edit_target_layer_asset_kind(
resolve_editor_image_edit_target_layer_source_kind(
&invalid_override,
&resources,
"layer-invalid",
@@ -1,5 +1,5 @@
use serde::{Deserialize, Serialize};
use std::collections::HashSet;
use std::collections::{HashMap, HashSet};
pub const GAME_CREATION_APP_MANIFEST_SCHEMA_VERSION: &str = "game-creation-app.manifest.v1";
pub const GAME_CREATION_AGENT_RUN_SCHEMA_VERSION: &str = "game-creator-agent-run.v1";
@@ -611,6 +611,142 @@ pub struct GameCreationAppCommandRunState {
pub updated_at: u64,
}
pub const GAME_ITERATION_VERSION_MAX_COUNT: usize = 4096;
pub const GAME_ITERATION_VERSION_MAX_BINDING_COUNT: usize = 4096;
pub const GAME_ITERATION_VERSION_MAX_SAFE_INTEGER: u64 = 9_007_199_254_740_991;
#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
#[serde(rename_all = "kebab-case")]
pub enum GameIterationVersionCreatedReason {
Initial,
ResourceReplacement,
AgentRevision,
}
#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct GameIterationVersionResourceBinding {
pub slot_id: String,
pub resource_id: String,
}
#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct GameIterationVersion {
pub version_id: String,
pub parent_version_id: Option<String>,
pub project_revision: u64,
pub resource_bindings: Vec<GameIterationVersionResourceBinding>,
pub created_reason: GameIterationVersionCreatedReason,
pub created_at: u64,
}
fn validate_iteration_version_id(value: &str, label: &str, max_chars: usize) -> Result<(), String> {
if value.is_empty() || value.trim() != value {
return Err(format!("{label}不能为空或包含首尾空白"));
}
if value.chars().count() > max_chars || value.chars().any(char::is_control) {
return Err(format!("{label}无效"));
}
Ok(())
}
pub fn validate_game_iteration_versions(versions: &[GameIterationVersion]) -> Result<(), String> {
if versions.len() > GAME_ITERATION_VERSION_MAX_COUNT {
return Err(format!(
"项目版本最多支持 {GAME_ITERATION_VERSION_MAX_COUNT} 条"
));
}
let mut previous_versions = HashMap::<&str, (u64, u64)>::new();
for (index, version) in versions.iter().enumerate() {
validate_iteration_version_id(&version.version_id, "版本 ID", 128)?;
if previous_versions.contains_key(version.version_id.as_str()) {
return Err(format!("项目版本 ID 重复:{}", version.version_id));
}
if version.project_revision > GAME_ITERATION_VERSION_MAX_SAFE_INTEGER {
return Err(format!(
"项目版本 {} 的 projectRevision 超出 JavaScript 安全整数范围",
version.version_id
));
}
if version.created_at > GAME_ITERATION_VERSION_MAX_SAFE_INTEGER {
return Err(format!(
"项目版本 {} 的 createdAt 超出 JavaScript 安全整数范围",
version.version_id
));
}
if index == 0 {
if version.parent_version_id.is_some()
|| version.created_reason != GameIterationVersionCreatedReason::Initial
{
return Err(format!(
"项目版本 {} 的首条记录必须是无父版本的 initial 版本",
version.version_id
));
}
} else {
if version.created_reason == GameIterationVersionCreatedReason::Initial {
return Err(format!(
"项目版本 {} 只有首个版本可以使用 initial 创建原因",
version.version_id
));
}
let Some(parent_version_id) = &version.parent_version_id else {
return Err(format!(
"项目版本 {} 只有首个 initial 版本可以没有父版本",
version.version_id
));
};
validate_iteration_version_id(parent_version_id, "父版本 ID", 128)?;
let Some((parent_revision, parent_created_at)) =
previous_versions.get(parent_version_id.as_str())
else {
return Err(format!(
"项目版本 {} 的父版本必须先于子版本存在",
version.version_id
));
};
if version.project_revision <= *parent_revision {
return Err(format!(
"项目版本 {} 的 projectRevision 必须大于父版本",
version.version_id
));
}
if version.created_at < *parent_created_at {
return Err(format!(
"项目版本 {} 的 createdAt 不能早于父版本",
version.version_id
));
}
}
if version.resource_bindings.len() > GAME_ITERATION_VERSION_MAX_BINDING_COUNT {
return Err(format!(
"项目版本 {} 的资源绑定最多支持 {GAME_ITERATION_VERSION_MAX_BINDING_COUNT} 项",
version.version_id
));
}
let mut slot_ids = HashSet::new();
for binding in &version.resource_bindings {
validate_iteration_version_id(&binding.slot_id, "版本资源槽位 ID", 256)?;
validate_iteration_version_id(&binding.resource_id, "版本资源 ID", 512)?;
if !slot_ids.insert(binding.slot_id.as_str()) {
return Err(format!(
"项目版本 {} 的资源槽位重复:{}",
version.version_id, binding.slot_id
));
}
}
previous_versions.insert(
version.version_id.as_str(),
(version.project_revision, version.created_at),
);
}
Ok(())
}
#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct GameCreationAppManifest {
@@ -626,6 +762,8 @@ pub struct GameCreationAppManifest {
pub preview: Option<GameCreationAppPreviewState>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub command_runs: Vec<GameCreationAppCommandRunState>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub versions: Vec<GameIterationVersion>,
}
#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
@@ -757,6 +895,7 @@ pub fn new_game_creation_app_manifest(
assets: Vec::new(),
preview: None,
command_runs: Vec::new(),
versions: Vec::new(),
}
}
@@ -1594,6 +1733,17 @@ mod tests {
log_path: ".agent/logs/command.log".to_string(),
updated_at: 123,
});
manifest.versions.push(GameIterationVersion {
version_id: "version-1".to_string(),
parent_version_id: None,
project_revision: 7,
resource_bindings: vec![GameIterationVersionResourceBinding {
slot_id: "player".to_string(),
resource_id: "asset-player".to_string(),
}],
created_reason: GameIterationVersionCreatedReason::Initial,
created_at: 456,
});
manifest.assets.push(GameCreationAppAssetManifestEntry {
id: "asset-player".to_string(),
kind: "character".to_string(),
@@ -1630,6 +1780,95 @@ mod tests {
payload["assets"][0]["source"]["canvasProjectId"],
json!("canvas-project-1")
);
assert_eq!(payload["versions"][0]["versionId"], json!("version-1"));
assert_eq!(
payload["versions"][0]["resourceBindings"][0]["slotId"],
json!("player")
);
assert_eq!(payload["versions"][0]["createdReason"], json!("initial"));
}
#[test]
fn iteration_versions_require_an_append_ordered_parent_graph() {
let root = GameIterationVersion {
version_id: "version-root".to_string(),
parent_version_id: None,
project_revision: 4,
resource_bindings: vec![GameIterationVersionResourceBinding {
slot_id: "player".to_string(),
resource_id: "asset-player".to_string(),
}],
created_reason: GameIterationVersionCreatedReason::Initial,
created_at: 100,
};
let child = GameIterationVersion {
version_id: "version-child".to_string(),
parent_version_id: Some(root.version_id.clone()),
project_revision: 5,
resource_bindings: Vec::new(),
created_reason: GameIterationVersionCreatedReason::AgentRevision,
created_at: 101,
};
validate_game_iteration_versions(&[root.clone(), child.clone()])
.expect("valid version graph");
let mut invalid_child = child.clone();
invalid_child.version_id = root.version_id.clone();
assert!(
validate_game_iteration_versions(&[root.clone(), invalid_child])
.expect_err("reject duplicate version id")
.contains("版本 ID 重复")
);
let mut invalid_child = child.clone();
invalid_child.parent_version_id = Some("missing".to_string());
assert!(
validate_game_iteration_versions(&[root.clone(), invalid_child])
.expect_err("reject missing parent")
.contains("父版本必须先于子版本存在")
);
let mut invalid_child = child.clone();
invalid_child.project_revision = root.project_revision;
assert!(
validate_game_iteration_versions(&[root.clone(), invalid_child])
.expect_err("reject non-increasing revision")
.contains("projectRevision 必须大于父版本")
);
let mut invalid_child = child.clone();
invalid_child.created_at = root.created_at - 1;
assert!(
validate_game_iteration_versions(&[root.clone(), invalid_child])
.expect_err("reject time before parent")
.contains("createdAt 不能早于父版本")
);
let mut invalid_child = child.clone();
invalid_child.project_revision = GAME_ITERATION_VERSION_MAX_SAFE_INTEGER + 1;
assert!(
validate_game_iteration_versions(&[root.clone(), invalid_child])
.expect_err("reject unsafe project revision")
.contains("超出 JavaScript 安全整数范围")
);
let mut invalid_child = child;
invalid_child.resource_bindings = vec![
GameIterationVersionResourceBinding {
slot_id: "player".to_string(),
resource_id: "asset-player".to_string(),
},
GameIterationVersionResourceBinding {
slot_id: "player".to_string(),
resource_id: "asset-player-next".to_string(),
},
];
assert!(
validate_game_iteration_versions(&[root, invalid_child])
.expect_err("reject duplicate slot")
.contains("资源槽位重复")
);
}
#[test]