收紧图片编辑主来源契约

将站内与 External v1 图片编辑请求统一为必填 sourceReferenceId
按业务引用解析权威对象和类型并校验目标图层绑定
使用版本化队列快照并在 worker 执行前复核身份与类型漂移
安全迁移历史队列载荷且不再按 objectKey 反查或信任旧 assetKind
让画布 Agent 使用 ImageMetadata.reference_id 并拒绝未登记图片
同步 OpenAPI 400 条件、异步语义与契约测试
This commit is contained in:
2026-08-07 14:03:55 +08:00
parent b200899883
commit 0cca0adcb1
9 changed files with 873 additions and 193 deletions
@@ -1065,6 +1065,7 @@
],
"operationId": "editExternalEditorImage",
"summary": "重绘/调整编辑器图片素材",
"description": "主来源只接受当前账号已登记的项目资源 ID 或素材 IDsourceReferenceId);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"
+2 -2
View File
@@ -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}"
);
}
@@ -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(),
@@ -660,27 +660,30 @@ impl EditorAgentTool for EditImageTool {
) -> Result<PreparedEditorAgentJob, EditorAgentToolError> {
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();
File diff suppressed because it is too large Load Diff
@@ -662,8 +662,9 @@ pub async fn edit_external_editor_image(
Extension(request_context): Extension<RequestContext>,
Extension(principal): Extension<ExternalApiPrincipal>,
headers: HeaderMap,
Json(payload): Json<EditorImageEditRequest>,
payload: Result<Json<EditorImageEditRequest>, JsonRejection>,
) -> Result<Response, AppError> {
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),
@@ -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<EditorImageEditResolvedSource>,
}
#[derive(serde::Deserialize)]
#[serde(rename_all = "camelCase")]
struct LegacyEditorImageEditWorkerPayload {
prompt: String,
source_image_src: String,
size: Option<String>,
model: Option<String>,
aspect_ratio: Option<String>,
image_size: Option<String>,
reference_image_srcs: Option<Vec<String>>,
project_id: Option<String>,
generation_inputs: Option<Value>,
asset_folder_id: Option<String>,
asset_label: Option<String>,
source_resource_id: Option<String>,
target_layer_id: Option<String>,
canvas_completion: Option<shared_contracts::assets::EditorCanvasGenerationCompletionPayload>,
}
fn parse_editor_image_edit_worker_payload(
job: &ExternalGenerationJobRecord,
) -> Result<EditorImageEditRequest, String> {
let mut payload: EditorImageEditRequest =
) -> Result<ParsedEditorImageEditWorkerPayload, String> {
if let Ok(payload) =
serde_json::from_str::<EditorImageEditQueuePayload>(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())]
@@ -39,6 +39,7 @@ impl Display for ImageId {
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ImageMetadata {
pub data_key: String,
pub reference_id: Option<String>,
pub image_src: String,
pub object_key: Option<String>,
pub thumbnail_src: Option<String>,
@@ -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,