免除完美像素已鉴权来源的全账号扫描

来源资源已随项目完成鉴权时直接取用其 objectKey
以显式归属断言替代跨记录扫描并保留存储类型点查
补齐免查解析的一致性与归属回归测试

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-31 09:39:05 +00:00
parent 0d7a73b0b2
commit c252038b52
+177 -37
View File
@@ -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<Option<String>, 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<EditorPixelArtSourceResolution, AppError> {
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(