收紧图片编辑主来源契约
将站内与 External v1 图片编辑请求统一为必填 sourceReferenceId 按业务引用解析权威对象和类型并校验目标图层绑定 使用版本化队列快照并在 worker 执行前复核身份与类型漂移 安全迁移历史队列载荷且不再按 objectKey 反查或信任旧 assetKind 让画布 Agent 使用 ImageMetadata.reference_id 并拒绝未登记图片 同步 OpenAPI 400 条件、异步语义与契约测试
This commit is contained in:
@@ -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())]
|
||||
|
||||
Reference in New Issue
Block a user