图片输入解析抽出可注入查询的解析规则并补齐行为用例

- resolve_source_object_key 拆出 resolve_source_object_key_with 与 ResolvedReference 窄结果
- 新增按 ID 定点查询、类型不符、缺对象键、4xx 收敛与基础设施错误保留状态码的用例
- 源码文本断言不再按函数签名切片,只检查本模块不出现列工程 / 列素材库
This commit is contained in:
2026-09-22 16:36:16 +08:00
parent 48cde9624c
commit 87ca86ae6a
@@ -71,30 +71,65 @@ async fn resolve_source_object_key(
owner_user_id: &str,
source: &Model3dGenerationSource,
) -> Result<String, AppError> {
// 生产路径只走「按 ID 定点查一条引用」:不列工程、不列素材库,也不读图片字节。
resolve_source_object_key_with(source, |reference_id| async move {
resolve_editor_reference_record_by_id_for_owner(state, owner_user_id, &reference_id)
.await
.map(ResolvedReference::from)
})
.await
}
/// 定点查询返回的窄结果:只保留判定引用是否可用所需的记录类型与对象键。
struct ResolvedReference {
kind: &'static str,
object_key: Option<String>,
}
impl From<EditorReferenceRecord> for ResolvedReference {
fn from(record: EditorReferenceRecord) -> Self {
match record {
EditorReferenceRecord::ProjectResource(resource) => Self {
kind: "resource",
object_key: resource.object_key,
},
EditorReferenceRecord::Asset(asset) => Self {
kind: "asset",
object_key: asset.object_key,
},
}
}
}
/// 解析规则本身:把「一次按 ID 查询」的结果映射成对象键,或按引用不可用 / 类型不符收口。
///
/// 查询以参数注入,测试据此断言只发起一次定点查询、且各失败分支的对外语义;
/// 这里不接触 provider,也不读对象字节。
async fn resolve_source_object_key_with<F, Fut>(
source: &Model3dGenerationSource,
resolve: F,
) -> Result<String, AppError>
where
F: FnOnce(String) -> Fut,
Fut: std::future::Future<Output = Result<ResolvedReference, AppError>>,
{
let (requested_kind, reference_id) = match source {
Model3dGenerationSource::Resource { resource_id } => ("resource", resource_id.as_str()),
Model3dGenerationSource::Asset { asset_id } => ("asset", asset_id.as_str()),
};
let resolved =
resolve_editor_reference_record_by_id_for_owner(state, owner_user_id, reference_id)
.await
.map_err(|error| {
// 未登记、跨 owner 与已删除在 provider 侧都是同一类“引用不可用”,
// 这里也收敛成同一句 400;只有基础设施故障才继续按原状态码上报。
if error.status_code().is_client_error() {
image_source_unavailable()
} else {
error
}
})?;
let (resolved_kind, object_key) = match resolved {
EditorReferenceRecord::ProjectResource(resource) => ("resource", resource.object_key),
EditorReferenceRecord::Asset(asset) => ("asset", asset.object_key),
};
if resolved_kind != requested_kind {
return Err(image_source_kind_mismatch(requested_kind, resolved_kind));
let resolved = resolve(reference_id.to_string()).await.map_err(|error| {
// 未登记、跨 owner 与已删除在 provider 侧都是同一类“引用不可用”,
// 这里也收敛成同一句 400;只有基础设施故障才继续按原状态码上报。
if error.status_code().is_client_error() {
image_source_unavailable()
} else {
error
}
})?;
if resolved.kind != requested_kind {
return Err(image_source_kind_mismatch(requested_kind, resolved.kind));
}
object_key.ok_or_else(|| image_source_unavailable())
resolved.object_key.ok_or_else(image_source_unavailable)
}
/// 未登记、跨 owner、已删除与缺少对象键都收敛成同一句 400。
@@ -121,50 +156,129 @@ fn image_source_kind_mismatch(requested_kind: &str, resolved_kind: &str) -> AppE
#[cfg(test)]
mod tests {
/// 预检必须是定点、只读元数据的;一旦退回“列工程 / 列素材库再筛”的老路,
/// 或提前把图片正文读出来,这条断言会失败。
#[test]
fn image_source_preflight_stays_narrow_and_metadata_only() {
let source = include_str!("image_source.rs");
let preflight = source_region(
source,
"pub(crate) async fn preflight_image_source(",
"pub(crate) async fn resolve_image_input(",
);
let resolution = source_region(
source,
"async fn resolve_source_object_key(",
"fn image_source_unavailable(",
);
assert!(
preflight.contains("resolve_source_object_key("),
"预检必须复用定点解析函数,而不是自己取对象键"
);
assert!(
resolution.contains("resolve_editor_reference_record_by_id_for_owner("),
"定点解析必须复用既有的窄查询,而不是遍历工程或素材库"
);
for forbidden in [
"list_editor_projects",
"get_editor_asset_library",
"read_editor_reference_image_object_with_client(",
"upload_image(",
"submit(",
] {
assert!(!preflight.contains(forbidden), "预检不得包含 {forbidden}");
assert!(
!resolution.contains(forbidden),
"定点解析不得包含 {forbidden}"
);
use super::*;
use serde_json::json;
fn reason_of(error: &AppError) -> Option<&str> {
error.details()?.get("reason")?.as_str()
}
fn probe_error(status: StatusCode) -> AppError {
AppError::from_status(status).with_details(json!({ "reason": "probe" }))
}
fn resolved(kind: &'static str, object_key: Option<&str>) -> ResolvedReference {
ResolvedReference {
kind,
object_key: object_key.map(str::to_string),
}
}
/// 取两个标记之间的源码片段;两段都落在测试模块之前,断言自身不会被算进检查范围
fn source_region(source: &'static str, start: &str, end: &str) -> &'static str {
source
.split_once(start)
.and_then(|(_, rest)| rest.split_once(end))
.map(|(body, _)| body)
.unwrap_or_else(|| panic!("应能在源码里定位 {start}"))
/// 解析只发起一次按 ID 的定点查询:查询到的 ID 必须与请求里的引用 ID 一致
#[tokio::test]
async fn image_source_resolution_queries_only_the_requested_reference_id() {
let source = Model3dGenerationSource::Resource {
resource_id: "res-1".to_string(),
};
let mut queried: Vec<String> = Vec::new();
let object_key = resolve_source_object_key_with(&source, |reference_id| {
queried.push(reference_id);
async { Ok(resolved("resource", Some("objects/res-1.png"))) }
})
.await
.expect("同类型的已登记引用应解析出对象键");
assert_eq!(object_key, "objects/res-1.png");
assert_eq!(queried, vec!["res-1".to_string()]);
}
/// 记录类型与 `source` 分支不一致时报类型不符,而不是把对象键照用。
#[tokio::test]
async fn image_source_resolution_rejects_kind_mismatch() {
let source = Model3dGenerationSource::Asset {
asset_id: "asset-1".to_string(),
};
let error = resolve_source_object_key_with(&source, |_| async {
Ok(resolved("resource", Some("objects/asset-1.png")))
})
.await
.expect_err("素材 ID 指向画布资源时必须报错");
assert_eq!(error.status_code(), StatusCode::BAD_REQUEST);
assert_eq!(
reason_of(&error),
Some("model3d-image-source-kind-mismatch")
);
assert_eq!(
error.details().and_then(|details| details.get("field")),
Some(&json!("source.kind"))
);
}
/// 记录存在但缺对象键与「引用不可用」收敛成同一句 400。
#[tokio::test]
async fn image_source_resolution_treats_missing_object_key_as_unavailable() {
let source = Model3dGenerationSource::Resource {
resource_id: "res-1".to_string(),
};
let error =
resolve_source_object_key_with(&source, |_| async { Ok(resolved("resource", None)) })
.await
.expect_err("缺对象键时必须按引用不可用处理");
assert_eq!(error.status_code(), StatusCode::BAD_REQUEST);
assert_eq!(reason_of(&error), Some("model3d-image-source-unavailable"));
}
/// 未登记 / 跨 owner / 已删除这类 4xx 收敛成同一句 400;基础设施故障保留原状态码。
#[tokio::test]
async fn image_source_resolution_collapses_client_errors_but_keeps_infra_errors() {
let source = Model3dGenerationSource::Resource {
resource_id: "res-1".to_string(),
};
let collapsed = resolve_source_object_key_with(&source, |_| async {
Err(probe_error(StatusCode::NOT_FOUND))
})
.await
.expect_err("4xx 必须收敛");
assert_eq!(collapsed.status_code(), StatusCode::BAD_REQUEST);
assert_eq!(
reason_of(&collapsed),
Some("model3d-image-source-unavailable")
);
let kept = resolve_source_object_key_with(&source, |_| async {
Err(probe_error(StatusCode::INTERNAL_SERVER_ERROR))
})
.await
.expect_err("基础设施故障必须继续报错");
assert_eq!(kept.status_code(), StatusCode::INTERNAL_SERVER_ERROR);
assert_eq!(reason_of(&kept), Some("probe"));
}
/// 本模块只做定点解析:一旦退回「列工程 / 列素材库再筛」的老路,
/// 这两个 token 会出现在源码里,断言立刻失败。
///
/// 预检不读字节、不调用 provider 由类型保证(`preflight_image_source` 不接
/// provider client,也不碰 OSS),解析规则本身由上面的注入式用例覆盖。
#[test]
fn image_source_module_never_lists_projects_or_asset_libraries() {
let source = include_str!("image_source.rs");
assert!(
source.contains("resolve_editor_reference_record_by_id_for_owner("),
"定点解析必须复用既有的按 ID 窄查询"
);
// 用 concat! 拼出待检查的字面量,否则断言里的字面量会被 include_str! 自己命中。
for forbidden in [
concat!("list_editor_", "projects"),
concat!("get_editor_", "asset_library"),
] {
assert!(!source.contains(forbidden), "本模块不得包含 {forbidden}");
}
}
}