图片输入解析抽出可注入查询的解析规则并补齐行为用例
- resolve_source_object_key 拆出 resolve_source_object_key_with 与 ResolvedReference 窄结果 - 新增按 ID 定点查询、类型不符、缺对象键、4xx 收敛与基础设施错误保留状态码的用例 - 源码文本断言不再按函数签名切片,只检查本模块不出现列工程 / 列素材库
This commit is contained in:
@@ -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}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user