diff --git a/docs/openapi/genarrative-external-v1.openapi.json b/docs/openapi/genarrative-external-v1.openapi.json index 659c13a6f..3f2f5cf0f 100644 --- a/docs/openapi/genarrative-external-v1.openapi.json +++ b/docs/openapi/genarrative-external-v1.openapi.json @@ -1065,6 +1065,7 @@ ], "operationId": "editExternalEditorImage", "summary": "重绘/调整编辑器图片素材", + "description": "主来源只接受当前账号已登记的项目资源 ID 或素材 ID(sourceReferenceId);objectKey、URL、Data URL 与 Blob URL 即使归属当前账号也返回 400。服务端从命中的业务记录派生 canonical objectKey、assetObjectId 与权威类型,只允许普通静态图片(类型为 null)、spec、character、icon-spritesheet、icon-spec、publication-material 或 ui-design,其他及未知类型返回 400。提供 targetLayerId 时必须同时提供 projectId,目标图层必须关联有效项目资源;双方都有 assetObjectId 时必须相同,否则回退比较 canonical bucket/objectKey。同一对象的来源默认类型与目标资源默认类型冲突、目标有效类型或媒体类型不允许、来源或目标不存在/越权/缺少对象时均返回 400,任务不会入队。referenceImageSrcs 仍只作为辅助参考图。", "security": [ { "ExternalApiKey": [] @@ -3338,28 +3339,24 @@ "type": "object", "required": [ "prompt", - "sourceImageSrc" + "sourceReferenceId" ], "properties": { "prompt": { "type": "string", "minLength": 1 }, - "sourceImageSrc": { + "sourceReferenceId": { "type": "string", - "description": "待重绘/调整图片的稳定引用:当前账号的 objectKey、项目资源 ID 或素材 ID;本地临时图必须先上传 OSS。禁止 Data URL / Blob URL。" + "minLength": 1, + "description": "待编辑主来源的业务 ID,只接受当前账号已登记的项目资源 ID 或素材 ID。objectKey、普通 URL、签名 URL、Data URL、Blob URL 和未登记上传对象均返回 400;上传对象必须先登记为项目资源或素材。服务端从命中记录派生 canonical objectKey、assetObjectId 与权威类型。" }, "projectId": { "type": [ "string", "null" - ] - }, - "assetKind": { - "type": [ - "string", - "null" - ] + ], + "description": "项目上下文。提供 targetLayerId 时必须同时提供非空 projectId,否则返回 400。" }, "generationInputs": { "$ref": "#/components/schemas/JsonValue" @@ -3376,18 +3373,12 @@ "null" ] }, - "sourceResourceId": { - "type": [ - "string", - "null" - ] - }, "targetLayerId": { "type": [ "string", "null" ], - "description": "带 projectId 且未提供 canvasCompletion 时,服务端用生成结果替换该画布图层。" + "description": "目标画布图层。提供时必须同时提供 projectId,且图层必须关联有效项目资源。来源与目标都有 assetObjectId 时按 ID 比较;任一缺失时回退比较 canonical bucket/objectKey。来源记录默认类型必须与目标资源默认类型一致,最终类型取 assetKindOverride 或目标资源类型,且媒体类型必须为图片;违反任一条件返回 400。未提供 canvasCompletion 时,生成结果替换该图层。" }, "size": { "type": "string" diff --git a/server-rs/crates/api-server/src/app.rs b/server-rs/crates/api-server/src/app.rs index a2d70713f..ba36e4f3d 100644 --- a/server-rs/crates/api-server/src/app.rs +++ b/server-rs/crates/api-server/src/app.rs @@ -2142,7 +2142,7 @@ mod tests { let app = build_router(state); let request_body = serde_json::json!({ "prompt": "快速编辑图片", - "sourceImageSrc": "data:image/png;base64,AAAA", + "sourceReferenceId": "data:image/png;base64,AAAA", "size": "1024x1024", "model": "gpt-image-2" }) @@ -2170,7 +2170,7 @@ mod tests { .to_bytes(); let body_text = String::from_utf8_lossy(&body); assert!( - body_text.contains("先上传 OSS"), + body_text.contains("只接受已登记的项目资源 ID 或素材 ID"), "handler should reject inline editor edit sources: {body_text}" ); } diff --git a/server-rs/crates/api-server/src/editor_agent/context.rs b/server-rs/crates/api-server/src/editor_agent/context.rs index 8b86d0cb9..4a0a84047 100644 --- a/server-rs/crates/api-server/src/editor_agent/context.rs +++ b/server-rs/crates/api-server/src/editor_agent/context.rs @@ -13,6 +13,7 @@ pub fn build_tool_context(document: &EditorAgentConversationMessagesDocument) -> let image_id = ImageId::from_data_key(&data_key); let metadata = ImageMetadata { data_key, + reference_id: Some(a.reference_id.clone()), image_src: a.image_src.clone(), object_key: a.object_key.clone(), thumbnail_src: a.thumbnail_src.clone(), @@ -29,6 +30,7 @@ pub fn build_tool_context(document: &EditorAgentConversationMessagesDocument) -> let image_id = ImageId::from_data_key(&data_key); let metadata = ImageMetadata { data_key, + reference_id: img.resource_id.clone(), image_src: img.image_src.clone(), object_key: img.object_key.clone(), thumbnail_src: img.thumbnail_src.clone(), diff --git a/server-rs/crates/api-server/src/editor_agent/tool.rs b/server-rs/crates/api-server/src/editor_agent/tool.rs index 738016c46..a506644d6 100644 --- a/server-rs/crates/api-server/src/editor_agent/tool.rs +++ b/server-rs/crates/api-server/src/editor_agent/tool.rs @@ -660,27 +660,30 @@ impl EditorAgentTool for EditImageTool { ) -> Result { let price_mud_points = self.pricing(context.pricing, args)?; let args: EditImageToolArgs = parse_invalid_args(Self::NAME, args)?; - let source_image_src = self + let source_reference_id = self .context - .image_data_key(&args.object_image_id) - .ok_or_else(|| EditorAgentToolError::invalid_args("object image not found"))? + .image_metadata(&args.object_image_id) + .and_then(|metadata| metadata.reference_id.as_deref()) + .ok_or_else(|| { + EditorAgentToolError::invalid_args( + "object image is not a registered resource or asset", + ) + })? .to_string(); let reference_image_srcs = resolve_image_ids(&args.reference_image_ids, &self.context)?; let title = args.prompt.clone(); let payload = EditorImageEditRequest { prompt: args.prompt, - source_image_src, + source_reference_id, size: None, model: Some(args.model), aspect_ratio: None, image_size: None, reference_image_srcs: Some(reference_image_srcs), project_id: Some(context.conversation.project_id.clone()), - asset_kind: None, generation_inputs: context.generation_inputs("用户指令", &title), asset_folder_id: Some("project".to_string()), asset_label: Some(title.clone()), - source_resource_id: None, target_layer_id: None, canvas_completion: Some(build_editor_agent_canvas_completion( context.project, @@ -1184,6 +1187,7 @@ mod tests { }, ImageMetadata { data_key: format!("generated/{image_id}.png"), + reference_id: Some(format!("resource-{image_id}")), image_src: format!("/api/assets/read/generated/{image_id}.png"), object_key: Some(format!("generated/{image_id}.png")), thumbnail_src: Some(format!("/api/assets/read/generated/{image_id}-thumb.png")), @@ -1528,7 +1532,7 @@ mod tests { } #[test] - fn edit_image_job_uses_empty_asset_kind_for_ordinary_static_image() { + fn edit_image_job_uses_registered_reference_id_only() { let pricing = pricing(); let conversation = conversation(); let project = project(); @@ -1554,11 +1558,55 @@ mod tests { .expect("edit job should prepare"); assert_eq!(job.job_kind, EDITOR_IMAGE_EDIT_JOB_KIND); - assert!(job.payload["assetKind"].is_null()); + assert_eq!(job.payload["sourceReferenceId"], "resource-image-1"); + assert!(job.payload.get("sourceImageSrc").is_none()); + assert!(job.payload.get("sourceResourceId").is_none()); + assert!(job.payload.get("assetKind").is_none()); assert_eq!(job.payload["projectId"], "project-1"); assert!(job.payload["targetLayerId"].is_null()); } + #[test] + fn edit_image_job_rejects_image_without_registered_reference_id() { + let pricing = pricing(); + let conversation = conversation(); + let project = project(); + let mut context = context_with_image("image-1"); + context + .images + .values_mut() + .next() + .expect("test image should exist") + .reference_id = None; + let tool = + editor_agent_tool(EditImageTool::NAME, &context).expect("edit tool should resolve"); + let args = tool + .validate_args(&json!({ + "object_image_id": "image-1", + "prompt": "改成蓝色" + })) + .expect("edit args should validate"); + + let error = match tool.prepare_job( + &args, + &EditorAgentPrepareJobContext { + conversation: &conversation, + project: &project, + message_id: 7, + pricing: &pricing, + }, + ) { + Ok(_) => panic!("unregistered image should fail closed"), + Err(error) => error, + }; + + assert!(error.is_invalid_args()); + assert_eq!( + error.to_string(), + "object image is not a registered resource or asset" + ); + } + #[test] fn dyn_job_preparation_keeps_queue_contract_and_defaults() { let pricing = pricing(); diff --git a/server-rs/crates/api-server/src/editor_project.rs b/server-rs/crates/api-server/src/editor_project.rs index e7edef11a..d9ed36d9d 100644 --- a/server-rs/crates/api-server/src/editor_project.rs +++ b/server-rs/crates/api-server/src/editor_project.rs @@ -52,6 +52,7 @@ use spacetime_client::{ EditorProjectLayoutSaveV2RecordInput, EditorProjectRecord, EditorProjectRenameRecordInput, EditorProjectResourceCreateRecordInput, EditorProjectResourceMediaRepairRecordInput, EditorProjectResourceRecord, EditorProjectResourceShowcaseUpdateRecordInput, + EditorReferenceRecord, EditorReferenceResolveRecordInput, EditorShowcaseAssetLikeToggleRecordInput, EditorShowcaseAssetPublicListRecordInput, EditorShowcaseAssetRecord, EditorShowcaseAssetSubmitRecordInput, EditorShowcaseCampaignConfigGetRecordInput, EditorShowcaseCampaignConfigRecord, @@ -146,7 +147,6 @@ const EDITOR_CHARACTER_IMAGE_SLOT: &str = "character"; const EDITOR_GENERATED_IMAGE_ASSET_KIND: &str = "editor_generated_image"; const EDITOR_SPEC_IMAGE_ASSET_KIND: &str = "editor_spec_image"; const EDITOR_QUICK_EDIT_IMAGE_ASSET_KIND: &str = "editor_quick_edit_image"; -pub(crate) const EDITOR_AGENT_LEGACY_IMAGE_EDIT_ASSET_KIND: &str = "editor_agent_edit_image"; const EDITOR_UI_DESIGN_IMAGE_ASSET_KIND: &str = "editor_ui_design_image"; const EDITOR_IMAGE_EDIT_ASSET_KIND: &str = "editor_image_edit"; const EDITOR_ICON_SPRITESHEET_ASSET_KIND: &str = "editor_icon_spritesheet"; @@ -361,21 +361,19 @@ pub struct EditorImageGenerationRequest { } #[derive(Clone, Debug, Deserialize, Serialize)] -#[serde(rename_all = "camelCase")] +#[serde(rename_all = "camelCase", deny_unknown_fields)] pub struct EditorImageEditRequest { pub(crate) prompt: String, - pub(crate) source_image_src: String, + pub(crate) source_reference_id: String, pub(crate) size: Option, pub(crate) model: Option, pub(crate) aspect_ratio: Option, pub(crate) image_size: Option, pub(crate) reference_image_srcs: Option>, pub(crate) project_id: Option, - pub(crate) asset_kind: Option, pub(crate) generation_inputs: Option, pub(crate) asset_folder_id: Option, pub(crate) asset_label: Option, - pub(crate) source_resource_id: Option, pub(crate) target_layer_id: Option, pub(crate) canvas_completion: Option, } @@ -2896,22 +2894,65 @@ pub(crate) fn ensure_editor_image_edit_source_kind_allowed( ) } -fn normalize_editor_image_edit_resolved_source_kind<'a>( - resolved_asset_kind: Option<&'a str>, - request_asset_kind: Option<&'a str>, -) -> Option<&'a str> { - match resolved_asset_kind { - Some(EDITOR_AGENT_LEGACY_IMAGE_EDIT_ASSET_KIND) => None, - Some(asset_kind) => Some(asset_kind), - None => request_asset_kind, - } +pub(crate) const EDITOR_IMAGE_EDIT_QUEUE_PAYLOAD_VERSION: u32 = 1; + +#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(rename_all = "kebab-case")] +pub(crate) enum EditorImageEditReferenceKind { + ProjectResource, + Asset, } -fn resolve_editor_image_edit_target_layer_source_kind<'a>( - layers: &'a Value, - resources: &'a [EditorProjectResourcePayload], +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub(crate) struct EditorImageEditResolvedSource { + pub(crate) reference_kind: EditorImageEditReferenceKind, + pub(crate) source_reference_id: String, + pub(crate) resource_id: Option, + pub(crate) asset_id: Option, + pub(crate) asset_object_id: Option, + pub(crate) bucket: String, + pub(crate) object_key: String, + pub(crate) source_asset_kind: Option, + pub(crate) effective_asset_kind: Option, + pub(crate) media_type: String, + pub(crate) target_resource_id: Option, +} + +#[derive(Clone, Debug, Deserialize, Serialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub(crate) struct EditorImageEditQueuePayload { + pub(crate) version: u32, + pub(crate) request: EditorImageEditRequest, + pub(crate) source: EditorImageEditResolvedSource, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +struct EditorImageEditTargetLayerSource { + bucket: String, + object_key: String, + asset_object_id: Option, + resource_id: String, + default_asset_kind: Option, + effective_asset_kind: Option, + media_type: String, +} + +fn normalize_editor_image_edit_resolved_source_kind( + resolved_asset_kind: Option<&str>, +) -> Option { + resolved_asset_kind + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(ToOwned::to_owned) +} + +fn resolve_editor_image_edit_target_layer_source( + bucket: &str, + layers: &Value, + resources: &[EditorProjectResourcePayload], target_layer_id: &str, -) -> Result<(Option<&'a str>, Option<&'a str>), AppError> { +) -> Result { let target_layer = layers .as_array() .and_then(|items| { @@ -2927,8 +2968,8 @@ fn resolve_editor_image_edit_target_layer_source_kind<'a>( })) })?; 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(Value::String(value)) if !value.trim().is_empty() => value.trim().to_string(), + Some(Value::Null) | None => "image".to_string(), Some(_) => { return Err( AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({ @@ -2939,11 +2980,9 @@ fn resolve_editor_image_edit_target_layer_source_kind<'a>( ); } }; - match target_layer.get("assetKindOverride") { - Some(Value::String(value)) if !value.trim().is_empty() => { - return Ok((Some(value.trim()), media_type)); - } - Some(Value::Null) | None => {} + let asset_kind_override = match target_layer.get("assetKindOverride") { + 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!({ @@ -2953,38 +2992,259 @@ fn resolve_editor_image_edit_target_layer_source_kind<'a>( })), ); } - } - let resource_id = target_layer.get("resourceId").and_then(Value::as_str); - 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)) + }; + let resource_id = target_layer + .get("resourceId") + .and_then(Value::as_str) + .map(str::trim) + .filter(|value| !value.is_empty()) + .ok_or_else(|| { + AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({ + "provider": "editor-image-edit", + "message": "快速编辑目标图层没有关联权威项目资源", + "targetLayerId": target_layer_id, + })) + })?; + let resource = resources + .iter() + .find(|resource| resource.resource_id.trim() == resource_id) + .ok_or_else(|| { + AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({ + "provider": "editor-image-edit", + "message": "快速编辑目标图层关联的项目资源不存在", + "targetLayerId": target_layer_id, + "sourceResourceId": resource_id, + })) + })?; + let object_key = normalize_editor_record_object_key( + resource.object_key.as_deref(), + resource.image_src.as_str(), + ) + .ok_or_else(|| { + AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({ + "provider": "editor-image-edit", + "message": "快速编辑目标图层的项目资源没有可用的稳定图片引用", + "targetLayerId": target_layer_id, + "sourceResourceId": resource_id, + })) + })?; + let default_asset_kind = + normalize_editor_image_edit_resolved_source_kind(resource.asset_kind.as_deref()); + let effective_asset_kind = normalize_editor_image_edit_resolved_source_kind( + asset_kind_override.or(default_asset_kind.as_deref()), + ); + Ok(EditorImageEditTargetLayerSource { + bucket: bucket.to_string(), + object_key, + asset_object_id: resource.asset_object_id.clone(), + resource_id: resource.resource_id.clone(), + default_asset_kind, + effective_asset_kind, + media_type, + }) } -async fn ensure_editor_image_edit_source_allowed( +fn ensure_editor_image_edit_target_matches_source( + source_reference_id: &str, + source_bucket: &str, + source_object_key: &str, + source_asset_object_id: Option<&str>, + source_asset_kind: Option<&str>, + target: &EditorImageEditTargetLayerSource, +) -> Result<(), AppError> { + let same_object = match (source_asset_object_id, target.asset_object_id.as_deref()) { + (Some(source_id), Some(target_id)) => source_id == target_id, + _ => source_bucket == target.bucket && source_object_key == target.object_key, + }; + if !same_object { + return Err( + AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({ + "provider": "editor-image-edit", + "field": "sourceReferenceId", + "sourceReferenceId": source_reference_id, + "targetObjectKey": target.object_key.as_str(), + "message": "sourceReferenceId 与快速编辑目标图层不是同一权威对象", + })), + ); + } + if source_asset_kind != target.default_asset_kind.as_deref() { + return Err( + AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({ + "provider": "editor-image-edit", + "field": "sourceReferenceId", + "sourceAssetKind": source_asset_kind, + "targetAssetKind": target.default_asset_kind.as_deref(), + "message": "主来源与目标资源对同一对象声明了冲突的权威素材类型", + })), + ); + } + ensure_editor_image_edit_source_kind_allowed( + target.effective_asset_kind.as_deref(), + Some(target.media_type.as_str()), + ) +} + +async fn resolve_editor_reference_record_by_id_for_owner( + state: &AppState, + owner_user_id: &str, + reference_id: &str, +) -> Result { + let reference_id = reference_id.trim(); + if reference_id.is_empty() { + return Err( + AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({ + "provider": "editor-reference-image", + "field": "referenceId", + "message": "编辑器引用 ID 不能为空。", + })), + ); + } + let oss_client = state.oss_client().ok_or_else(|| { + AppError::from_status(StatusCode::SERVICE_UNAVAILABLE).with_details(json!({ + "provider": "aliyun-oss", + "reason": "OSS 未完成环境变量配置", + })) + })?; + state + .spacetime_client() + .resolve_editor_reference(EditorReferenceResolveRecordInput { + owner_user_id: owner_user_id.to_string(), + bucket: oss_client.config_bucket().to_string(), + reference_id: reference_id.to_string(), + }) + .await + .map_err(map_editor_project_error) +} + +fn ensure_editor_image_edit_source_reference_id_shape( + source_reference_id: &str, +) -> Result<(), AppError> { + if !source_reference_id.contains('/') + && !source_reference_id.contains('\\') + && !source_reference_id.contains('?') + && !source_reference_id.contains('#') + && !source_reference_id.contains("://") + && !source_reference_id.get(..5).is_some_and(|prefix| { + prefix.eq_ignore_ascii_case("data:") || prefix.eq_ignore_ascii_case("blob:") + }) + { + return Ok(()); + } + Err(AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({ + "provider": "editor-image-edit", + "field": "sourceReferenceId", + "message": "sourceReferenceId 只接受已登记的项目资源 ID 或素材 ID,不接受 objectKey、URL、Data URL 或 Blob URL", + }))) +} + +fn map_editor_image_edit_lookup_error( + error: AppError, + field: &'static str, + reference_id: &str, +) -> AppError { + if error.status_code().is_server_error() { + return error; + } + AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({ + "provider": "editor-image-edit", + "field": field, + (field): reference_id, + "message": error.body_text(), + })) +} + +async fn resolve_editor_image_edit_source( state: &AppState, owner_user_id: &str, payload: &EditorImageEditRequest, -) -> Result<(), AppError> { - let Some(project_id) = payload - .project_id - .as_deref() - .map(str::trim) - .filter(|value| !value.is_empty()) - else { - return ensure_editor_image_edit_source_kind_allowed(payload.asset_kind.as_deref(), None); - }; +) -> Result { + let source_reference_id = payload.source_reference_id.trim(); + if source_reference_id.is_empty() { + return Err( + AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({ + "provider": "editor-image-edit", + "field": "sourceReferenceId", + "message": "sourceReferenceId 不能为空", + })), + ); + } + ensure_editor_image_edit_source_reference_id_shape(source_reference_id)?; + let oss_client = state.oss_client().ok_or_else(|| { + AppError::from_status(StatusCode::SERVICE_UNAVAILABLE).with_details(json!({ + "provider": "aliyun-oss", + "reason": "OSS 未完成环境变量配置", + })) + })?; + let bucket = oss_client.config_bucket().to_string(); + let resolved = + resolve_editor_reference_record_by_id_for_owner(state, owner_user_id, source_reference_id) + .await + .map_err(|error| { + map_editor_image_edit_lookup_error(error, "sourceReferenceId", source_reference_id) + })?; + let (reference_kind, resource_id, asset_id, asset_object_id, object_key, source_asset_kind) = + match resolved { + EditorReferenceRecord::ProjectResource(resource) => ( + EditorImageEditReferenceKind::ProjectResource, + Some(resource.resource_id), + None, + resource.asset_object_id, + resource.object_key, + resource.asset_kind, + ), + EditorReferenceRecord::Asset(asset) => ( + EditorImageEditReferenceKind::Asset, + None, + Some(asset.asset_id), + asset.asset_object_id, + asset.object_key, + asset.asset_kind, + ), + }; + let object_key = object_key.ok_or_else(|| { + AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({ + "provider": "editor-image-edit", + "field": "sourceReferenceId", + "message": "主来源记录缺少 canonical objectKey", + })) + })?; + let source_asset_kind = + normalize_editor_image_edit_resolved_source_kind(source_asset_kind.as_deref()); + ensure_editor_image_edit_source_kind_allowed(source_asset_kind.as_deref(), Some("image"))?; + let Some(target_layer_id) = payload .target_layer_id .as_deref() .map(str::trim) .filter(|value| !value.is_empty()) else { - return ensure_editor_image_edit_source_kind_allowed(payload.asset_kind.as_deref(), None); + return Ok(EditorImageEditResolvedSource { + reference_kind, + source_reference_id: source_reference_id.to_string(), + resource_id, + asset_id, + asset_object_id, + bucket, + object_key, + source_asset_kind: source_asset_kind.clone(), + effective_asset_kind: source_asset_kind, + media_type: "image".to_string(), + target_resource_id: None, + }); }; + let project_id = payload + .project_id + .as_deref() + .map(str::trim) + .filter(|value| !value.is_empty()) + .ok_or_else(|| { + AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({ + "provider": "editor-image-edit", + "field": "projectId", + "message": "提供 targetLayerId 时必须同时提供 projectId", + "targetLayerId": target_layer_id, + })) + })?; let project = state .spacetime_client() .get_editor_project(EditorProjectGetRecordInput { @@ -2992,16 +3252,51 @@ async fn ensure_editor_image_edit_source_allowed( owner_user_id: owner_user_id.to_string(), }) .await - .map_err(map_editor_project_error)?; + .map_err(map_editor_project_error) + .map_err(|error| map_editor_image_edit_lookup_error(error, "projectId", project_id))?; let project = editor_project_payload_from_record(project); - let (asset_kind, media_type) = resolve_editor_image_edit_target_layer_source_kind( + let source = resolve_editor_image_edit_target_layer_source( + bucket.as_str(), &project.layers, &project.resources, target_layer_id, )?; - ensure_editor_image_edit_source_kind_allowed( - normalize_editor_image_edit_resolved_source_kind(asset_kind, payload.asset_kind.as_deref()), - media_type, + ensure_editor_image_edit_target_matches_source( + source_reference_id, + bucket.as_str(), + object_key.as_str(), + asset_object_id.as_deref(), + source_asset_kind.as_deref(), + &source, + )?; + Ok(EditorImageEditResolvedSource { + reference_kind, + source_reference_id: source_reference_id.to_string(), + resource_id, + asset_id, + asset_object_id, + bucket, + object_key, + source_asset_kind, + effective_asset_kind: source.effective_asset_kind, + media_type: source.media_type, + target_resource_id: Some(source.resource_id), + }) +} + +fn ensure_editor_image_edit_source_snapshot_matches( + queued: &EditorImageEditResolvedSource, + current: &EditorImageEditResolvedSource, +) -> Result<(), AppError> { + if queued == current { + return Ok(()); + } + Err( + AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({ + "provider": "editor-image-edit", + "field": "sourceReferenceId", + "message": "图片编辑主来源的权威身份或类型在入队后发生变化,任务已失败关闭", + })), ) } @@ -4141,8 +4436,9 @@ pub async fn edit_editor_image( State(state): State, Extension(request_context): Extension, Extension(authenticated): Extension, - Json(payload): Json, + payload: Result, JsonRejection>, ) -> Result, AppError> { + let Json(payload) = parse_editor_generation_json_payload(payload)?; let caller = EditorGenerationCaller::from_authenticated(&authenticated); if !state.config.external_generation_mode.is_inline() { let queue_job = @@ -4167,19 +4463,14 @@ pub(crate) async fn enqueue_editor_image_edit_for_owner( ) -> Result { payload.generation_inputs = sanitize_editor_untrusted_generation_inputs(payload.generation_inputs.take()); - ensure_editor_reference_image_source_is_stable( - payload.source_image_src.as_str(), - "editor-image-edit", - "sourceImageSrc", - "待修改图片", - )?; ensure_editor_reference_image_sources_are_stable( payload.reference_image_srcs.as_deref(), "editor-image-edit", "referenceImageSrcs", "修改参考图", )?; - ensure_editor_image_edit_source_allowed(state, caller.owner_user_id.as_str(), &payload).await?; + let source = + resolve_editor_image_edit_source(state, caller.owner_user_id.as_str(), &payload).await?; let generation_options = normalize_editor_image_edit_generation_options( payload.model.as_deref(), payload.aspect_ratio.as_deref(), @@ -4207,6 +4498,11 @@ pub(crate) async fn enqueue_editor_image_edit_for_owner( ); let source_entity_id = editor_generation_source_entity_id(payload.project_id.as_deref(), "editor-image-edit"); + let queue_payload = EditorImageEditQueuePayload { + version: EDITOR_IMAGE_EDIT_QUEUE_PAYLOAD_VERSION, + request: payload, + source, + }; enqueue_editor_generation_job_for_caller( state, request_context, @@ -4215,33 +4511,42 @@ pub(crate) async fn enqueue_editor_image_edit_for_owner( source_entity_id, "图片画布修改图片", price_mud_points, - &payload, + &queue_payload, external_idempotency_key, ) .await } pub(crate) async fn edit_editor_image_for_owner( + state: &AppState, + request_context: &RequestContext, + caller: EditorGenerationCaller, + payload: EditorImageEditRequest, +) -> Result, AppError> { + edit_editor_image_for_owner_with_source_snapshot(state, request_context, caller, payload, None) + .await +} + +pub(crate) async fn edit_editor_image_for_owner_with_source_snapshot( state: &AppState, request_context: &RequestContext, caller: EditorGenerationCaller, mut payload: EditorImageEditRequest, + queued_source: Option<&EditorImageEditResolvedSource>, ) -> Result, AppError> { payload.generation_inputs = sanitize_editor_client_generation_inputs(payload.generation_inputs.take()); - ensure_editor_reference_image_source_is_stable( - payload.source_image_src.as_str(), - "editor-image-edit", - "sourceImageSrc", - "待修改图片", - )?; ensure_editor_reference_image_sources_are_stable( payload.reference_image_srcs.as_deref(), "editor-image-edit", "referenceImageSrcs", "修改参考图", )?; - ensure_editor_image_edit_source_allowed(state, caller.owner_user_id.as_str(), &payload).await?; + let source = + resolve_editor_image_edit_source(state, caller.owner_user_id.as_str(), &payload).await?; + if let Some(queued_source) = queued_source { + ensure_editor_image_edit_source_snapshot_matches(queued_source, &source)?; + } let prompt = payload.prompt.trim().to_string(); if prompt.is_empty() { return Err( @@ -4266,15 +4571,12 @@ pub(crate) async fn edit_editor_image_for_owner( "referenceImageSrcs", "修改参考图", )?; - payload.generation_inputs = rebuild_editor_generation_input_references( + payload.generation_inputs = rebuild_editor_image_edit_generation_inputs( state, caller.owner_user_id.as_str(), payload.generation_inputs.take(), - build_editor_generation_reference_sources( - Some(("原图", payload.source_image_src.as_str())), - payload.reference_image_srcs.as_deref(), - "参考图", - ), + &source, + payload.reference_image_srcs.as_deref(), ) .await?; let has_dimension_options = payload.aspect_ratio.is_some() || payload.image_size.is_some(); @@ -4291,14 +4593,8 @@ pub(crate) async fn edit_editor_image_for_owner( }; let mut reference_images = Vec::with_capacity(1 + payload.reference_image_srcs.as_ref().map_or(0, Vec::len)); - reference_images.push( - parse_editor_reference_image( - state, - caller.owner_user_id.as_str(), - payload.source_image_src.as_str(), - ) - .await?, - ); + reference_images + .push(read_editor_reference_image_object(state, source.object_key.as_str()).await?); for source in normalize_editor_reference_image_sources(payload.reference_image_srcs.as_deref()) { reference_images.push( @@ -4461,8 +4757,11 @@ pub(crate) async fn edit_editor_image_for_owner( task_id: generated.task_id.clone(), group_task_id: None, group_task_expected_asset_count: None, - source_resource_id: payload.source_resource_id, - asset_kind: payload.asset_kind, + source_resource_id: source + .target_resource_id + .clone() + .or_else(|| source.resource_id.clone()), + asset_kind: source.effective_asset_kind.clone(), generation_inputs: payload.generation_inputs, thumbnail_src: None, generation_cost_mud_points: u64::from(expected_price_mud_points), @@ -8669,6 +8968,48 @@ async fn rebuild_editor_generation_input_references( Ok(rebuild_editor_generation_inputs_with_authoritative_references(sanitized, references)) } +async fn rebuild_editor_image_edit_generation_inputs( + state: &AppState, + owner_user_id: &str, + generation_inputs: Option, + source: &EditorImageEditResolvedSource, + extra_sources: Option<&[String]>, +) -> Result, AppError> { + let extras = rebuild_editor_generation_input_references( + state, + owner_user_id, + generation_inputs, + build_editor_generation_reference_sources(None, extra_sources, "参考图"), + ) + .await?; + let mut references = extras + .as_ref() + .and_then(|value| value.get("references")) + .and_then(Value::as_array) + .cloned() + .unwrap_or_default(); + let (ref_type, label) = match source.reference_kind { + EditorImageEditReferenceKind::ProjectResource => ( + "project-resource", + source.effective_asset_kind.as_deref().unwrap_or("项目资源"), + ), + EditorImageEditReferenceKind::Asset => ( + "asset", + source.effective_asset_kind.as_deref().unwrap_or("素材"), + ), + }; + references.insert( + 0, + json!({ + "title": "原图", + "label": label, + "refType": ref_type, + "refId": source.source_reference_id, + }), + ); + Ok(rebuild_editor_generation_inputs_with_authoritative_references(extras, references)) +} + fn rebuild_editor_generation_inputs_with_authoritative_references( generation_inputs: Option, references: Vec, @@ -17209,7 +17550,7 @@ mod tests { fn editor_image_edit_request_omits_price_mud_points() { let request: EditorImageEditRequest = serde_json::from_value(json!({ "prompt": "修改图片", - "sourceImageSrc": "/generated-images/editor/source.png", + "sourceReferenceId": "resource-source", "size": "2048x1152", "model": "gemini-3.1-flash-image-preview", "aspectRatio": "16:9", @@ -17297,24 +17638,50 @@ mod tests { editor_project_resource_for_canvas_test("resource-default-spec", "spec", 512, 512), editor_project_resource_for_canvas_test("resource-icon-spec", "icon-spec", 512, 512), ]; - let icon_kind = - resolve_editor_image_edit_target_layer_source_kind(&layers, &resources, "layer-icon") - .expect("icon layer should exist"); - 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( + let icon_source = resolve_editor_image_edit_target_layer_source( + "bucket", + &layers, + &resources, + "layer-icon", + ) + .expect("icon layer should exist"); + assert_eq!(icon_source.effective_asset_kind.as_deref(), Some("icon")); + assert_eq!(icon_source.default_asset_kind.as_deref(), Some("spec")); + assert_eq!(icon_source.media_type, "image"); + assert_eq!(icon_source.resource_id, "resource-default-spec"); + assert_eq!( + icon_source.object_key, + "generated-character-drafts/editor/resource-default-spec.png" + ); + assert!( + ensure_editor_image_edit_source_kind_allowed( + icon_source.effective_asset_kind.as_deref(), + Some(icon_source.media_type.as_str()), + ) + .is_err() + ); + let icon_spec_source = resolve_editor_image_edit_target_layer_source( + "bucket", &layers, &resources, "layer-icon-spec", ) .expect("icon spec layer should exist"); - assert_eq!(icon_spec_kind, (Some("icon-spec"), Some("image"))); + assert_eq!( + icon_spec_source.effective_asset_kind.as_deref(), + Some("icon-spec") + ); + assert_eq!(icon_spec_source.media_type, "image"); assert!( - ensure_editor_image_edit_source_kind_allowed(icon_spec_kind.0, icon_spec_kind.1) - .is_ok() + ensure_editor_image_edit_source_kind_allowed( + icon_spec_source.effective_asset_kind.as_deref(), + Some(icon_spec_source.media_type.as_str()), + ) + .is_ok() ); assert!( - resolve_editor_image_edit_target_layer_source_kind(&layers, &[], "missing").is_err() + resolve_editor_image_edit_target_layer_source("bucket", &layers, &[], "missing") + .is_err() ); let invalid_override = json!([{ "layerId": "layer-invalid", @@ -17322,7 +17689,8 @@ mod tests { "assetKindOverride": "" }]); assert!( - resolve_editor_image_edit_target_layer_source_kind( + resolve_editor_image_edit_target_layer_source( + "bucket", &invalid_override, &resources, "layer-invalid", @@ -17332,22 +17700,192 @@ mod tests { } #[test] - fn legacy_editor_agent_resource_kind_is_only_normalized_after_source_resolution() { + fn legacy_editor_agent_resource_kind_fails_closed() { + let resolved_kind = + normalize_editor_image_edit_resolved_source_kind(Some("editor_agent_edit_image")); + assert_eq!(resolved_kind.as_deref(), Some("editor_agent_edit_image")); assert!( - ensure_editor_image_edit_source_kind_allowed( - Some(EDITOR_AGENT_LEGACY_IMAGE_EDIT_ASSET_KIND), - Some("image"), + ensure_editor_image_edit_source_kind_allowed(resolved_kind.as_deref(), Some("image")) + .is_err(), + "历史 synthetic assetKind 也不得作为已登记来源的权威类型继续放行" + ); + } + + #[test] + fn editor_image_edit_target_binding_prefers_object_id_and_fails_closed() { + let target = EditorImageEditTargetLayerSource { + bucket: "bucket".to_string(), + object_key: "generated/source.png".to_string(), + asset_object_id: Some("object-source".to_string()), + resource_id: "resource-target".to_string(), + default_asset_kind: Some("character".to_string()), + effective_asset_kind: Some("character".to_string()), + media_type: "image".to_string(), + }; + assert!( + ensure_editor_image_edit_target_matches_source( + "asset-source", + "bucket", + "generated/source.png", + Some("object-source"), + Some("character"), + &target, ) - .is_err(), - "客户端直接提交历史 synthetic assetKind 应继续失败关闭" + .is_ok(), + "素材 ID 可以引用目标资源的同一权威对象" ); - let resolved_kind = normalize_editor_image_edit_resolved_source_kind( - Some(EDITOR_AGENT_LEGACY_IMAGE_EDIT_ASSET_KIND), - Some(EDITOR_AGENT_LEGACY_IMAGE_EDIT_ASSET_KIND), + let mut different_object_id = target.clone(); + different_object_id.asset_object_id = Some("object-other".to_string()); + assert!( + ensure_editor_image_edit_target_matches_source( + "resource-source", + "bucket", + "generated/source.png", + Some("object-source"), + Some("character"), + &different_object_id, + ) + .is_err(), + "双方都有 assetObjectId 时不能回退相同 objectKey" ); - assert_eq!(resolved_kind, None); - assert!(ensure_editor_image_edit_source_kind_allowed(resolved_kind, Some("image")).is_ok()); + assert!( + ensure_editor_image_edit_target_matches_source( + "resource-source", + "bucket", + "generated/source.png", + None, + Some("character"), + &target, + ) + .is_ok(), + "任一方缺 assetObjectId 时允许 canonical location 回退" + ); + assert!( + ensure_editor_image_edit_target_matches_source( + "resource-source", + "bucket", + "generated/other.png", + None, + Some("character"), + &target, + ) + .is_err() + ); + assert!( + ensure_editor_image_edit_target_matches_source( + "asset-source", + "bucket", + "generated/source.png", + Some("object-source"), + Some("spec"), + &target, + ) + .is_err(), + "同一对象的来源与目标默认类型冲突时失败关闭" + ); + + let mut forbidden_override = target.clone(); + forbidden_override.effective_asset_kind = Some("icon".to_string()); + assert!( + ensure_editor_image_edit_target_matches_source( + "resource-source", + "bucket", + "generated/source.png", + Some("object-source"), + Some("character"), + &forbidden_override, + ) + .is_err(), + "目标图层不能为禁止来源借壳" + ); + let mut video_target = target; + video_target.media_type = "video".to_string(); + assert!( + ensure_editor_image_edit_target_matches_source( + "resource-source", + "bucket", + "generated/source.png", + Some("object-source"), + Some("character"), + &video_target, + ) + .is_err() + ); + } + + #[test] + fn editor_image_edit_request_rejects_legacy_and_unknown_fields() { + for field in [ + "sourceImageSrc", + "sourceResourceId", + "assetKind", + "futureField", + ] { + let mut value = json!({ + "prompt": "修改图片", + "sourceReferenceId": "resource-source" + }); + value[field] = json!("legacy"); + assert!( + serde_json::from_value::(value).is_err(), + "legacy or unknown field should be rejected: {field}", + ); + } + for invalid in [ + "generated-images/editor/source.png", + "https://example.com/source.png", + "data:image/png;base64,AAAA", + "blob:https://example.com/source", + ] { + assert!( + ensure_editor_image_edit_source_reference_id_shape(invalid).is_err(), + "non-business identity should fail: {invalid}", + ); + } + assert!( + ensure_editor_image_edit_source_reference_id_shape("editor-resource-source").is_ok() + ); + assert_eq!( + map_editor_image_edit_lookup_error( + AppError::from_status(StatusCode::NOT_FOUND), + "sourceReferenceId", + "missing", + ) + .status_code(), + StatusCode::BAD_REQUEST + ); + assert_eq!( + map_editor_image_edit_lookup_error( + AppError::from_status(StatusCode::BAD_GATEWAY), + "sourceReferenceId", + "resource-source", + ) + .status_code(), + StatusCode::BAD_GATEWAY, + "SpacetimeDB 基础设施失败不得伪装成调用方 400" + ); + } + + #[test] + fn editor_image_edit_queue_snapshot_drift_fails_closed() { + let queued = EditorImageEditResolvedSource { + reference_kind: EditorImageEditReferenceKind::Asset, + source_reference_id: "asset-source".to_string(), + resource_id: None, + asset_id: Some("asset-source".to_string()), + asset_object_id: Some("object-1".to_string()), + bucket: "bucket".to_string(), + object_key: "generated/source.png".to_string(), + source_asset_kind: Some("character".to_string()), + effective_asset_kind: Some("character".to_string()), + media_type: "image".to_string(), + target_resource_id: None, + }; + assert!(ensure_editor_image_edit_source_snapshot_matches(&queued, &queued).is_ok()); + let mut current = queued.clone(); + current.effective_asset_kind = Some("spec".to_string()); + assert!(ensure_editor_image_edit_source_snapshot_matches(&queued, ¤t).is_err()); } #[test] @@ -17358,17 +17896,18 @@ mod tests { "pub async fn edit_editor_image(", "pub(crate) async fn edit_editor_image_for_owner", &[ - "ensure_editor_image_edit_source_allowed(", + "resolve_editor_image_edit_source(", "enqueue_editor_generation_job", ], ); assert_function_contains_in_order( source, - "pub(crate) async fn edit_editor_image_for_owner", + "pub(crate) async fn edit_editor_image_for_owner_with_source_snapshot", "pub async fn remove_editor_image_background", &[ - "ensure_editor_image_edit_source_allowed(", - "parse_editor_reference_image", + "resolve_editor_image_edit_source(", + "ensure_editor_image_edit_source_snapshot_matches(", + "read_editor_reference_image_object", ], ); } diff --git a/server-rs/crates/api-server/src/external_editor_api.rs b/server-rs/crates/api-server/src/external_editor_api.rs index ea4761c12..da9c24020 100644 --- a/server-rs/crates/api-server/src/external_editor_api.rs +++ b/server-rs/crates/api-server/src/external_editor_api.rs @@ -662,8 +662,9 @@ pub async fn edit_external_editor_image( Extension(request_context): Extension, Extension(principal): Extension, headers: HeaderMap, - Json(payload): Json, + payload: Result, JsonRejection>, ) -> Result { + let Json(payload) = parse_editor_generation_json_payload(payload)?; require_scope(&principal, SCOPE_EDITOR_IMAGE_GENERATE)?; let idempotency_key = require_idempotency_key(&headers)?; let project_id = payload.project_id.clone(); @@ -1560,7 +1561,8 @@ mod tests { assert!( parsed["components"]["schemas"]["EditorImageEditRequest"]["required"] .as_array() - .is_some_and(|required| !required.contains(&json!("priceMudPoints"))) + .is_some_and(|required| required.contains(&json!("sourceReferenceId")) + && !required.contains(&json!("priceMudPoints"))) ); assert!( parsed["components"]["schemas"]["EditorImageEditRequest"]["properties"] @@ -1572,6 +1574,38 @@ mod tests { .get("targetLayerId") .is_some() ); + let image_edit_schema = + &parsed["components"]["schemas"]["EditorImageEditRequest"]["properties"]; + for legacy_field in ["sourceImageSrc", "sourceResourceId", "assetKind"] { + assert!( + image_edit_schema.get(legacy_field).is_none(), + "{legacy_field}" + ); + } + assert!( + image_edit_schema["sourceReferenceId"]["description"] + .as_str() + .is_some_and(|description| description.contains("项目资源 ID 或素材 ID") + && description.contains("objectKey") + && description.contains("未登记上传对象") + && description.contains("400")) + ); + assert_eq!( + parsed["components"]["schemas"]["EditorImageEditRequest"]["additionalProperties"], + json!(false) + ); + let image_edit_operation = &parsed["paths"]["/api/external/v1/editor/images/edits"]["post"]; + assert!( + image_edit_operation["description"] + .as_str() + .is_some_and(|description| description.contains("targetLayerId") + && description.contains("sourceReferenceId") + && description.contains("assetObjectId") + && description.contains("bucket/objectKey") + && description.contains("未知类型") + && description.contains("返回 400")) + ); + assert!(image_edit_operation["responses"].get("400").is_some()); for (schema, max_items) in [ ("EditorImageEditRequest", 8), ("EditorIconSpritesheetGenerationRequest", 8), diff --git a/server-rs/crates/api-server/src/external_generation_worker.rs b/server-rs/crates/api-server/src/external_generation_worker.rs index f6ef10227..3a708a917 100644 --- a/server-rs/crates/api-server/src/external_generation_worker.rs +++ b/server-rs/crates/api-server/src/external_generation_worker.rs @@ -40,13 +40,13 @@ use crate::{ EDITOR_UI_DESIGN_ASSET_EXTRACTION_JOB_KIND, EDITOR_VIDEO_GENERATION_JOB_KIND, }, editor_project::{ - EDITOR_AGENT_LEGACY_IMAGE_EDIT_ASSET_KIND, EDITOR_GENERATION_MULTIPLE_WARNINGS_CODE, + EDITOR_GENERATION_MULTIPLE_WARNINGS_CODE, EDITOR_IMAGE_EDIT_QUEUE_PAYLOAD_VERSION, EditorBackgroundRemovalRequest, EditorGenerationCaller, EditorGenerationPhaseReporter, - EditorIconSpritesheetGenerationRequest, EditorImageEditRequest, - EditorImageGenerationRequest, EditorUiDesignAssetExtractionRequest, - edit_editor_image_for_owner, extract_editor_ui_design_assets_for_owner, - generate_editor_icon_spritesheet_for_owner, generate_editor_image_for_owner, - remove_editor_image_background_for_owner, + EditorIconSpritesheetGenerationRequest, EditorImageEditQueuePayload, + EditorImageEditRequest, EditorImageEditResolvedSource, EditorImageGenerationRequest, + EditorUiDesignAssetExtractionRequest, edit_editor_image_for_owner_with_source_snapshot, + extract_editor_ui_design_assets_for_owner, generate_editor_icon_spritesheet_for_owner, + generate_editor_image_for_owner, remove_editor_image_background_for_owner, }, request_context::RequestContext, state::AppState, @@ -802,11 +802,12 @@ async fn process_external_generation_job_once( } }; let request_context = worker_request_context(&job, provider_deadline); - match edit_editor_image_for_owner( + match edit_editor_image_for_owner_with_source_snapshot( &state, &request_context, editor_generation_worker_caller(&worker_id, &job)?, - payload, + payload.request, + payload.source.as_ref(), ) .await { @@ -1138,19 +1139,78 @@ fn worker_request_context( .with_external_call_deadline(provider_deadline) } +struct ParsedEditorImageEditWorkerPayload { + request: EditorImageEditRequest, + source: Option, +} + +#[derive(serde::Deserialize)] +#[serde(rename_all = "camelCase")] +struct LegacyEditorImageEditWorkerPayload { + prompt: String, + source_image_src: String, + size: Option, + model: Option, + aspect_ratio: Option, + image_size: Option, + reference_image_srcs: Option>, + project_id: Option, + generation_inputs: Option, + asset_folder_id: Option, + asset_label: Option, + source_resource_id: Option, + target_layer_id: Option, + canvas_completion: Option, +} + fn parse_editor_image_edit_worker_payload( job: &ExternalGenerationJobRecord, -) -> Result { - let mut payload: EditorImageEditRequest = +) -> Result { + if let Ok(payload) = + serde_json::from_str::(job.request_payload_json.as_str()) + { + if payload.version != EDITOR_IMAGE_EDIT_QUEUE_PAYLOAD_VERSION { + return Err(format!( + "图片画布改图任务载荷版本不受支持:{}", + payload.version + )); + } + return Ok(ParsedEditorImageEditWorkerPayload { + request: payload.request, + source: Some(payload.source), + }); + } + let legacy: LegacyEditorImageEditWorkerPayload = serde_json::from_str(job.request_payload_json.as_str()) .map_err(|error| format!("图片画布改图任务参数解析失败:{error}"))?; - if is_editor_agent_generation_job(job) - && payload.asset_kind.as_deref().map(str::trim) - == Some(EDITOR_AGENT_LEGACY_IMAGE_EDIT_ASSET_KIND) - { - payload.asset_kind = None; + let source_reference_id = legacy + .source_resource_id + .as_deref() + .map(str::trim) + .filter(|value| !value.is_empty()) + .unwrap_or_else(|| legacy.source_image_src.trim()) + .to_string(); + if source_reference_id.is_empty() { + return Err("历史图片画布改图任务缺少可迁移的业务引用 ID".to_string()); } - Ok(payload) + Ok(ParsedEditorImageEditWorkerPayload { + request: EditorImageEditRequest { + prompt: legacy.prompt, + source_reference_id, + size: legacy.size, + model: legacy.model, + aspect_ratio: legacy.aspect_ratio, + image_size: legacy.image_size, + reference_image_srcs: legacy.reference_image_srcs, + project_id: legacy.project_id, + generation_inputs: legacy.generation_inputs, + asset_folder_id: legacy.asset_folder_id, + asset_label: legacy.asset_label, + target_layer_id: legacy.target_layer_id, + canvas_completion: legacy.canvas_completion, + }, + source: None, + }) } fn editor_generation_worker_caller( @@ -1755,39 +1815,48 @@ mod tests { } #[test] - fn editor_agent_image_edit_payload_reaches_worker_gate_with_empty_asset_kind() { + fn editor_image_edit_worker_parses_versioned_snapshot_payload() { let mut job = external_generation_job_record_fixture(Some("lease-1")); job.job_kind = EDITOR_IMAGE_EDIT_JOB_KIND.to_string(); - job.dedupe_key = "editor-agent:conversation-1:7:edit-image".to_string(); job.request_payload_json = json!({ - "prompt": "改成蓝色", - "sourceImageSrc": "users/user-1/source.png", - "projectId": "project-1", - "assetKind": null, - "generationInputs": { "source": "editor-agent" } + "version": 1, + "request": { + "prompt": "改成蓝色", + "sourceReferenceId": "resource-source" + }, + "source": { + "referenceKind": "project-resource", + "sourceReferenceId": "resource-source", + "resourceId": "resource-source", + "assetId": null, + "assetObjectId": "object-source", + "bucket": "bucket", + "objectKey": "generated/source.png", + "sourceAssetKind": "character", + "effectiveAssetKind": "character", + "mediaType": "image", + "targetResourceId": null + } }) .to_string(); let payload = parse_editor_image_edit_worker_payload(&job) - .expect("新 Agent 图片编辑 payload 应由 worker 解析"); - assert!(payload.asset_kind.is_none()); - assert!( - crate::editor_project::ensure_editor_image_edit_source_kind_allowed( - payload.asset_kind.as_deref(), - None, - ) - .is_ok() + .expect("versioned image edit payload should parse"); + assert_eq!(payload.request.source_reference_id, "resource-source"); + assert_eq!( + payload.source.expect("snapshot should exist").object_key, + "generated/source.png" ); } #[test] - fn legacy_editor_agent_image_edit_kind_is_normalized_only_for_trusted_job() { + fn legacy_editor_image_edit_payload_migrates_only_business_ids() { let mut job = external_generation_job_record_fixture(Some("lease-1")); job.job_kind = EDITOR_IMAGE_EDIT_JOB_KIND.to_string(); - job.dedupe_key = "editor-agent:conversation-1:7:edit-image".to_string(); job.request_payload_json = json!({ "prompt": "改成蓝色", - "sourceImageSrc": "users/user-1/source.png", + "sourceImageSrc": "raw/generated/source.png", + "sourceResourceId": "resource-source", "projectId": "project-1", "assetKind": "editor_agent_edit_image", "generationInputs": { "source": "editor-agent" } @@ -1796,30 +1865,25 @@ mod tests { let legacy_agent_payload = parse_editor_image_edit_worker_payload(&job) .expect("历史 Agent 图片编辑 payload 应由 worker 解析"); - assert!(legacy_agent_payload.asset_kind.is_none()); - assert!( - crate::editor_project::ensure_editor_image_edit_source_kind_allowed( - legacy_agent_payload.asset_kind.as_deref(), - None, - ) - .is_ok() - ); - - job.dedupe_key = "editor:image-edit:project-1:request-1".to_string(); - let untrusted_payload = parse_editor_image_edit_worker_payload(&job) - .expect("普通画布图片编辑 payload 应由 worker 解析"); assert_eq!( - untrusted_payload.asset_kind.as_deref(), - Some(EDITOR_AGENT_LEGACY_IMAGE_EDIT_ASSET_KIND) + legacy_agent_payload.request.source_reference_id, + "resource-source" ); - assert!( - crate::editor_project::ensure_editor_image_edit_source_kind_allowed( - untrusted_payload.asset_kind.as_deref(), - None, - ) - .is_err(), - "普通请求不得用历史 Agent synthetic assetKind 绕过白名单" + assert!(legacy_agent_payload.source.is_none()); + + job.request_payload_json = json!({ + "prompt": "改成蓝色", + "sourceImageSrc": "raw/generated/source.png", + "assetKind": "character" + }) + .to_string(); + let raw_only_payload = parse_editor_image_edit_worker_payload(&job) + .expect("raw-only legacy payload should migrate without object-key lookup"); + assert_eq!( + raw_only_payload.request.source_reference_id, + "raw/generated/source.png" ); + assert!(raw_only_payload.source.is_none()); } #[cfg(any())] diff --git a/server-rs/crates/platform-editor-agent/src/agent/asset.rs b/server-rs/crates/platform-editor-agent/src/agent/asset.rs index f35c6c69f..a83aa4c17 100644 --- a/server-rs/crates/platform-editor-agent/src/agent/asset.rs +++ b/server-rs/crates/platform-editor-agent/src/agent/asset.rs @@ -39,6 +39,7 @@ impl Display for ImageId { #[derive(Debug, Clone, Serialize, Deserialize)] pub struct ImageMetadata { pub data_key: String, + pub reference_id: Option, pub image_src: String, pub object_key: Option, pub thumbnail_src: Option, diff --git a/server-rs/crates/platform-editor-agent/src/agent/tools/generate_icon_spritesheet.rs b/server-rs/crates/platform-editor-agent/src/agent/tools/generate_icon_spritesheet.rs index 96229eee0..79d4f12e0 100644 --- a/server-rs/crates/platform-editor-agent/src/agent/tools/generate_icon_spritesheet.rs +++ b/server-rs/crates/platform-editor-agent/src/agent/tools/generate_icon_spritesheet.rs @@ -257,6 +257,7 @@ mod tests { reference_image_id.clone(), ImageMetadata { data_key: "asset://reference-image".to_string(), + reference_id: Some("resource-reference-image".to_string()), image_src: "asset://reference-image".to_string(), object_key: None, thumbnail_src: None,