From c252038b5277c31f6e2570bf7fbc971c97f97496 Mon Sep 17 00:00:00 2001 From: Linghong Date: Fri, 31 Jul 2026 09:39:05 +0000 Subject: [PATCH] =?UTF-8?q?=E5=85=8D=E9=99=A4=E5=AE=8C=E7=BE=8E=E5=83=8F?= =?UTF-8?q?=E7=B4=A0=E5=B7=B2=E9=89=B4=E6=9D=83=E6=9D=A5=E6=BA=90=E7=9A=84?= =?UTF-8?q?=E5=85=A8=E8=B4=A6=E5=8F=B7=E6=89=AB=E6=8F=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 来源资源已随项目完成鉴权时直接取用其 objectKey 以显式归属断言替代跨记录扫描并保留存储类型点查 补齐免查解析的一致性与归属回归测试 Co-Authored-By: Claude Opus 5 --- .../crates/api-server/src/editor_project.rs | 214 +++++++++++++++--- 1 file changed, 177 insertions(+), 37 deletions(-) diff --git a/server-rs/crates/api-server/src/editor_project.rs b/server-rs/crates/api-server/src/editor_project.rs index 16964058e..6dad5e049 100644 --- a/server-rs/crates/api-server/src/editor_project.rs +++ b/server-rs/crates/api-server/src/editor_project.rs @@ -4518,17 +4518,79 @@ fn push_editor_pixel_art_source_asset_kind( } } +// 中文注释:source_resource 来自 owner-scoped 的 get_editor_project,已经完成鉴权, +// 因此可以直接用它的 objectKey,不必再按注册 ID 做两轮全账号项目与素材库扫描。做法 +// 对齐图集拆分:用对资源的显式归属断言替代扫描,而不是省掉校验。 +// 前提是 sourceImageSrc 能在不发 RPC 的前提下确认指向同一张图——它要么本身就是 +// objectKey,要么就是这个 resourceId。否则落回完整解析路径。 +fn resolve_editor_pixel_art_source_without_lookup( + owner_user_id: &str, + project_id: &str, + source: &str, + source_resource: &EditorProjectResourcePayload, +) -> Result, AppError> { + if source_resource.owner_user_id != owner_user_id || source_resource.project_id != project_id { + return Err(editor_pixel_art_snap_failure( + StatusCode::FORBIDDEN, + "来源项目资源不属于当前账号或当前项目。", + )); + } + let source_resource_object_key = normalize_editor_record_object_key( + source_resource.object_key.as_deref(), + source_resource.image_src.as_str(), + ) + .ok_or_else(|| { + editor_pixel_art_snap_failure( + StatusCode::BAD_REQUEST, + "来源项目资源没有可用的稳定图片引用。", + ) + })?; + let source = source.trim(); + if source == source_resource.resource_id.trim() { + return Ok(Some(source_resource_object_key)); + } + match normalize_editor_reference_object_key(source) { + // 中文注释:sourceImageSrc 已是 objectKey,直接比对即可判定一致性;不一致必须 + // 报错而不是落回慢路径,否则「两个字段指向不同图片」会被慢路径静默接受。 + Ok(source_object_key) => { + if source_object_key != source_resource_object_key { + return Err(editor_pixel_art_snap_failure( + StatusCode::BAD_REQUEST, + "sourceImageSrc 与 sourceResourceId 指向不同图片。", + )); + } + Ok(Some(source_resource_object_key)) + } + // sourceImageSrc 是别的注册 ID,只能走完整解析。 + Err(_) => Ok(None), + } +} + async fn resolve_editor_pixel_art_source_for_owner( state: &AppState, owner_user_id: &str, + project_id: &str, source: &str, project: &EditorProjectPayload, source_resource: Option<&EditorProjectResourcePayload>, requested_asset_kind: Option<&str>, ) -> Result { - let object_key = - resolve_editor_reference_object_key_for_owner(state, owner_user_id, source).await?; - if let Some(source_resource) = source_resource { + let resolved_without_lookup = match source_resource { + Some(source_resource) => resolve_editor_pixel_art_source_without_lookup( + owner_user_id, + project_id, + source, + source_resource, + )?, + None => None, + }; + let object_key = match resolved_without_lookup.clone() { + Some(object_key) => object_key, + None => resolve_editor_reference_object_key_for_owner(state, owner_user_id, source).await?, + }; + if resolved_without_lookup.is_none() + && let Some(source_resource) = source_resource + { let source_resource_object_key = normalize_editor_record_object_key( source_resource.object_key.as_deref(), source_resource.image_src.as_str(), @@ -4561,44 +4623,52 @@ async fn resolve_editor_pixel_art_source_for_owner( resource.asset_kind.as_deref(), ); } - let projects = state - .spacetime_client() - .list_editor_projects(owner_user_id.to_string()) - .await - .map_err(map_editor_project_error)?; - for resource in projects - .iter() - .flat_map(|project| project.resources.iter()) - .filter(|resource| { + // 中文注释:跨记录扫描只在没有已鉴权 source_resource 时才需要。它的作用是防止同一 + // objectKey 在别处登记为动画/音视频时被静态请求掩盖;有 source_resource 时该语义 + // 权威已经确定,当前项目内的同键记录仍在上面按内存扫过。字节级门禁 + // (validate_editor_pixel_art_static_raster)在下载后照常执行,不受此影响。 + if resolved_without_lookup.is_none() { + let projects = state + .spacetime_client() + .list_editor_projects(owner_user_id.to_string()) + .await + .map_err(map_editor_project_error)?; + for resource in projects + .iter() + .flat_map(|project| project.resources.iter()) + .filter(|resource| { + editor_record_object_key_matches( + resource.object_key.as_deref(), + resource.image_src.as_str(), + object_key.as_str(), + ) + }) + { + push_editor_pixel_art_source_asset_kind( + &mut discovered_asset_kinds, + resource.asset_kind.as_deref(), + ); + } + let library = state + .spacetime_client() + .get_editor_asset_library(owner_user_id.to_string(), current_utc_micros()) + .await + .map_err(map_editor_project_error)?; + for asset in library.assets.iter().filter(|asset| { editor_record_object_key_matches( - resource.object_key.as_deref(), - resource.image_src.as_str(), + asset.object_key.as_deref(), + asset.image_src.as_str(), object_key.as_str(), ) - }) - { - push_editor_pixel_art_source_asset_kind( - &mut discovered_asset_kinds, - resource.asset_kind.as_deref(), - ); - } - let library = state - .spacetime_client() - .get_editor_asset_library(owner_user_id.to_string(), current_utc_micros()) - .await - .map_err(map_editor_project_error)?; - for asset in library.assets.iter().filter(|asset| { - editor_record_object_key_matches( - asset.object_key.as_deref(), - asset.image_src.as_str(), - object_key.as_str(), - ) - }) { - push_editor_pixel_art_source_asset_kind( - &mut discovered_asset_kinds, - asset.asset_kind.as_deref(), - ); + }) { + push_editor_pixel_art_source_asset_kind( + &mut discovered_asset_kinds, + asset.asset_kind.as_deref(), + ); + } } + // 中文注释:这一处是按 (bucket, objectKey) 的点查而不是全账号扫描,成本与账号规模 + // 无关,两条路径都保留——它承担存储 taxonomy 的非静态门禁。 if let Some(oss_client) = state.oss_client() && let Some(asset_object) = state .spacetime_client() @@ -4706,6 +4776,7 @@ pub async fn snap_editor_image_to_pixel_art( let source = resolve_editor_pixel_art_source_for_owner( &state, owner_user_id.as_str(), + project_id.as_str(), payload.source_image_src.as_str(), &project, source_resource, @@ -10317,6 +10388,75 @@ mod tests { } } + #[test] + fn pixel_art_authorized_source_resource_skips_account_wide_lookup() { + let resource = editor_project_resource_for_canvas_test("res-1", "character", 128, 128); + let object_key = "generated-character-drafts/editor/res-1.png"; + + // sourceImageSrc 就是该 resourceId:无需任何 RPC 即可定位。 + assert_eq!( + resolve_editor_pixel_art_source_without_lookup( + "user-1", + "editor-project-1", + "res-1", + &resource, + ) + .expect("authorized resource should resolve"), + Some(object_key.to_string()) + ); + + // sourceImageSrc 已是同一个 objectKey:同样免 RPC。 + assert_eq!( + resolve_editor_pixel_art_source_without_lookup( + "user-1", + "editor-project-1", + object_key, + &resource, + ) + .expect("matching object key should resolve"), + Some(object_key.to_string()) + ); + + // 中文注释:两个字段指向不同图片必须直接报错,不能落回慢路径——慢路径会用 + // sourceImageSrc 解析出的 key 继续,等于让 sourceResourceId 形同虚设。 + let mismatched = resolve_editor_pixel_art_source_without_lookup( + "user-1", + "editor-project-1", + "generated-character-drafts/editor/other.png", + &resource, + ) + .expect_err("mismatched object key must fail"); + assert_eq!(mismatched.status_code(), StatusCode::BAD_REQUEST); + + // sourceImageSrc 是别的注册 ID:无法免 RPC 判定,落回完整解析。 + assert_eq!( + resolve_editor_pixel_art_source_without_lookup( + "user-1", + "editor-project-1", + "editor-asset-42", + &resource, + ) + .expect("unknown registered id should fall back"), + None + ); + } + + #[test] + fn pixel_art_source_resource_ownership_is_asserted_before_skipping_lookup() { + let resource = editor_project_resource_for_canvas_test("res-1", "character", 128, 128); + + // 中文注释:跳过全账号扫描的前提是这条断言,不能省。 + for (owner, project) in [ + ("other-user", "editor-project-1"), + ("user-1", "other-project"), + ] { + let error = + resolve_editor_pixel_art_source_without_lookup(owner, project, "res-1", &resource) + .expect_err("foreign resource must be rejected"); + assert_eq!(error.status_code(), StatusCode::FORBIDDEN); + } + } + #[test] fn editor_project_procedure_errors_preserve_business_reason_as_bad_request() { let error = map_editor_project_error(SpacetimeClientError::Procedure(