合并最新主线并对齐资源类型枚举更新
Project CI / AI game creator shell Rust shard 1/4 (pull_request) Successful in 4m25s
Project CI / AI game creator shell Rust smoke (pull_request) Successful in 2m14s
Project CI / AI game creator shell Rust shard 3/4 (pull_request) Successful in 5m38s
Project CI / AI game creator shell Rust shard 4/4 (pull_request) Successful in 5m39s
Project CI / AI game creator shell Rust shard 2/4 (pull_request) Successful in 6m25s
Project CI / AI game creator shell Rust crates (pull_request) Successful in 2m6s
Project CI / Repository checks (pull_request) Successful in 8m12s
Project CI / Native shell tests (pull_request) Successful in 10m54s
Project CI / Frontend tests (pull_request) Successful in 13m34s
Project CI / Backend tests (pull_request) Successful in 13m55s
Project CI / AI game creator shell web tests (pull_request) Successful in 8m2s

同步 master 的资源类型枚举、共享契约与依赖锁文件更新
保留本分支的窄依赖、集中鉴权和异步追踪实现
通过路由、项目元数据、图片编辑策略和共享契约定向回归
This commit is contained in:
kdletters
2026-09-19 11:35:17 +08:00
130 changed files with 4309 additions and 2243 deletions
+13 -189
View File
@@ -38,6 +38,7 @@ use shared_contracts::editor_generation::{
editor_generation_stable_asset_id, editor_generation_stable_asset_object_id,
editor_generation_stable_resource_id,
};
use shared_contracts::game_creation_app::GameCreationAppAssetKind;
use shared_kernel::build_prefixed_uuid_id;
use spacetime_client::editor_project::{
EditorGenerationResultPersistItemRecordInput, EditorGenerationResultPersistRecord,
@@ -4110,54 +4111,19 @@ fn align_editor_image_edit_dimension(value: u32) -> u32 {
value.saturating_add(15) / 16 * 16
}
/// 图片快速编辑端点允许的**静态图**来源类型。
///
/// 口径依据 `packages/shared/src/contracts/gameCreationApp.ts` 的
/// `canonicalGameCreationAppAssetKind()`(等价实现见
/// `server-rs/crates/shared-contracts/src/game_creation_app.rs`):以「媒体类型为静态图的
/// canonical 类型」为权威集合,并额外收下合法 legacy 类型本身(平台还有别的写入口按本地
/// manifest 原始类型登记来源资源,客户端不会先做 canonical 归一)。
/// `canonicalGameCreationAppAssetKind()` 对未知值回退 `image`,因此两个入口都放行的这些值
/// 在共享契约里语义等价,静态图集合对 canonical 归一封闭,不会漏放合法静态图来源。
///
/// 必须与 `media_type == image` 一起构成 AND 门:视频、音频、序列帧等非静态媒体即使挂着
/// 图片类 assetKind 也照旧拒绝。
pub(crate) const EDITOR_IMAGE_EDIT_STATIC_IMAGE_ASSET_KINDS: [&str; 18] = [
// canonical 静态图类型
"image",
"scene",
"character",
"icon",
"icon-spritesheet",
"icon-spec",
"ui-design",
"publication-material",
"spec",
// 指向上述 canonical 类型的合法 legacy 类型
"game-background",
"character-art",
"game-art",
"illustration",
"art-spritesheet",
"art-spritesheet-slice",
"ui-prototype",
// AGC 本地 manifest 等在用的等价静态图类型
"ui",
// `register_local_asset_entry`(assets.rs)空 kind 兜底写的是 `"asset"`:
// 未归类上传的 PNG 会以 `kind: "asset"` 登记,而共享契约把 `"asset"` canonical 化成
// `"image"`,漏掉它就等于把「未归类静态图」挡在快速编辑之外(400)。
// 白名单必须在 canonical 静态图别名下封闭。
"asset",
];
pub(crate) 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 asset_kind = asset_kind.filter(|value| !value.trim().is_empty());
let media_type = media_type.map(str::trim).filter(|value| !value.is_empty());
let asset_kind_allowed = asset_kind
.is_none_or(|asset_kind| EDITOR_IMAGE_EDIT_STATIC_IMAGE_ASSET_KINDS.contains(&asset_kind));
let asset_kind_allowed = asset_kind.is_none_or(|asset_kind| {
GameCreationAppAssetKind::parse_with_context(
asset_kind,
"editor-image-edit.source.asset_kind",
)
.is_static_image()
});
let media_type_allowed = matches!(media_type, None | Some("image"));
if asset_kind_allowed && media_type_allowed {
return Ok(());
@@ -20769,7 +20735,7 @@ mod tests {
}
#[test]
fn editor_image_edit_source_kind_whitelist_covers_static_image_kinds_only() {
fn editor_image_edit_source_kind_policy_covers_static_image_kinds_only() {
let cases = [
(None, None, true),
(None, Some("image"), true),
@@ -20782,16 +20748,6 @@ mod tests {
(Some("publication-material"), Some("image"), true),
(Some("ui-design"), Some("image"), true),
(Some("scene"), Some("image"), true),
// `canonicalGameCreationAppAssetKind()` 的合法输入:平台按本地 manifest 原始类型
// 登记来源资源时会出现这些 legacy 值,实测 `art-spritesheet` / `ui` 曾整片 400。
(Some("art-spritesheet"), Some("image"), true),
(Some("art-spritesheet-slice"), Some("image"), true),
(Some("ui"), Some("image"), true),
(Some("ui-prototype"), Some("image"), true),
(Some("game-art"), Some("image"), true),
(Some("game-background"), Some("image"), true),
(Some("character-art"), Some("image"), true),
(Some("illustration"), Some("image"), true),
// 非静态媒体与非静态类型必须继续拒绝。
(Some("character-animation"), Some("image-sequence"), false),
(Some("sound-effect"), Some("audio"), false),
@@ -20803,8 +20759,6 @@ mod tests {
(Some("future-asset-kind"), Some("image"), false),
(None, Some("future-media-type"), false),
// mediaType 是 AND 门:图片类 assetKind 挂非静态媒体照旧拒绝。
(Some("art-spritesheet"), Some("video"), false),
(Some("ui"), Some("audio"), false),
(Some("icon"), Some("image-sequence"), false),
(Some("scene"), Some("image-sequence"), false),
];
@@ -20813,13 +20767,13 @@ mod tests {
assert_eq!(
result.is_ok(),
expected_allowed,
"unexpected whitelist result for assetKind={asset_kind:?}, mediaType={media_type:?}",
"unexpected source-kind policy result for assetKind={asset_kind:?}, mediaType={media_type:?}",
);
}
let error =
ensure_editor_image_edit_source_kind_allowed(Some("art-spritesheet"), Some("video"))
.expect_err("非静态媒体即使挂着图片类 assetKind 也不得支持快速编辑");
ensure_editor_image_edit_source_kind_allowed(Some("future-kind"), Some("image"))
.expect_err("未知 kind 不得作为快速编辑来源");
assert_eq!(error.status_code(), StatusCode::BAD_REQUEST);
assert_eq!(
error.details().and_then(|details| details.get("message")),
@@ -20932,10 +20886,6 @@ mod tests {
ensure_editor_image_edit_source_kind_allowed(Some("icon"), Some("image")).is_ok(),
"icon 是共享契约里的静态图 canonical 类型,与 generationInputs.source 无关"
);
assert!(
ensure_editor_image_edit_source_kind_allowed(Some("art-spritesheet"), Some("image"))
.is_ok()
);
assert!(ensure_editor_image_edit_source_kind_allowed(Some("icon"), Some("video")).is_err());
assert!(
ensure_editor_image_edit_source_kind_allowed(Some("future-kind"), Some("image"))
@@ -20943,132 +20893,6 @@ mod tests {
);
}
/// 实测 400×5 的回归用例:AGC「图片快速编辑」发出的请求,来源资源的 assetKind 是本地
/// manifest 原始类型 `art-spritesheet` / `ui` / `asset`(mediaType=image)。这条路径必须
/// 放行;非静态媒体(video/audio/image-sequence)必须继续拒绝。
///
/// 夹具必须真的走进被测路径,不能是自洽空断言:请求体先反序列化成
/// `EditorImageEditRequest`,再从**请求体自己的** `generationInputs.assetKind` 取类型喂给
/// 放行判据;并用源码级闸门断言钉住
/// `resolve_editor_image_edit_source` / `ensure_editor_image_edit_target_matches_source`
/// 两个函数体内不得再出现 `generation_inputs`(历史上 `generationInputs.source` 参与过
/// gating,把 AGC 请求整体挡成 400;解析与对账需要 DB/OSS,源码级断言是无需基础设施
/// 也能钉住这条回归的唯一位置)。
#[test]
fn game_creator_client_quick_edit_accepts_local_manifest_static_image_kinds() {
// 与客户端 `submit_resource_edit_remote` 发送的 JSON 同形。
let agc_payload: EditorImageEditRequest = serde_json::from_str(
r#"{
"prompt": "把这张图改成夜间配色",
"sourceReferenceId": "resource-art-spritesheet",
"generationInputs": {
"source": "ai-game-creator-client",
"assetKind": "art-spritesheet"
}
}"#,
)
.expect("AGC quick edit payload should deserialize into the request DTO");
assert_eq!(
agc_payload.source_reference_id, "resource-art-spritesheet",
"回归夹具必须保持 AGC 客户端实际发送的请求形状"
);
assert_eq!(
agc_payload.target_layer_id, None,
"AGC 快速编辑不带 targetLayerId,走的是「主来源即目标」分支"
);
// 关键:放行判据只吃来源资源的权威 assetKind 与 mediaType。这里用请求体自己声明的
// assetKind 驱动判据——请求体里同时带着 `generationInputs.source`,它不得参与 gating。
let requested_kind = agc_payload
.generation_inputs
.as_ref()
.and_then(|inputs| inputs.pointer("/assetKind"))
.and_then(Value::as_str);
assert_eq!(
requested_kind,
Some("art-spritesheet"),
"回归夹具必须保持 AGC 客户端实际发送的请求形状"
);
assert!(
ensure_editor_image_edit_source_kind_allowed(
normalize_editor_image_edit_resolved_source_kind(requested_kind).as_deref(),
Some("image"),
)
.is_ok(),
"AGC 快速编辑请求不得因为 generationInputs.source 被拒"
);
// 源码级闸门:请求解析与目标对账都不得重新引入 generationInputs 门槛。
let source = include_str!("editor_project.rs");
assert_function_not_contains(
source,
"async fn resolve_editor_image_edit_source(",
"fn ensure_editor_image_edit_source_snapshot_matches(",
&["generation_inputs", "generationInputs"],
);
assert_function_not_contains(
source,
"fn ensure_editor_image_edit_target_matches_source(",
"async fn resolve_editor_image_edit_source(",
&["generation_inputs", "generationInputs"],
);
for (asset_kind, media_type) in [
("art-spritesheet", "image"),
("ui", "image"),
("asset", "image"),
("ui-prototype", "image"),
("game-art", "image"),
("game-background", "image"),
("icon", "image"),
] {
assert!(
ensure_editor_image_edit_source_kind_allowed(Some(asset_kind), Some(media_type))
.is_ok(),
"AGC 快速编辑必须放行 {asset_kind}/{media_type}"
);
}
for (asset_kind, media_type) in [
("art-spritesheet", "video"),
("ui", "audio"),
("asset", "video"),
("video", "video"),
("sound-effect", "audio"),
("background-music", "audio"),
("character-animation", "image-sequence"),
("future-kind", "image"),
] {
assert!(
ensure_editor_image_edit_source_kind_allowed(Some(asset_kind), Some(media_type))
.is_err(),
"非静态来源必须继续拒绝:{asset_kind}/{media_type}"
);
}
// 400 错误体必须带上真实原因,客户端才能诊断(对应 AGC 的
// `editor_api_rejection_reason` 取值顺序修正)。
let error =
ensure_editor_image_edit_source_kind_allowed(Some("future-kind"), Some("image"))
.expect_err("未知类型必须拒绝");
assert_eq!(error.status_code(), StatusCode::BAD_REQUEST);
assert_eq!(
error.details().and_then(|details| details.get("provider")),
Some(&json!("editor-image-edit"))
);
assert_eq!(
error.details().and_then(|details| details.get("message")),
Some(&json!("当前素材类型不支持图片快速编辑"))
);
assert_eq!(
error.details().and_then(|details| details.get("assetKind")),
Some(&json!("future-kind"))
);
assert_eq!(
error.details().and_then(|details| details.get("mediaType")),
Some(&json!("image"))
);
}
#[test]
fn editor_image_edit_target_binding_prefers_object_id_and_fails_closed() {
let target = EditorImageEditTargetLayerSource {