避免图标图集入队重复下载规范图

抽取图标规范 metadata-only 解析器。

队列提交只校验 owner、类型、对象元数据与游戏类型。

worker 执行阶段复用元数据校验后单次读取 OSS 正文。

补充无正文读取回归测试与契约说明。
This commit is contained in:
2026-08-08 17:14:15 +08:00
parent 78675326c7
commit 9820203597
3 changed files with 42 additions and 7 deletions
@@ -44,6 +44,7 @@
- 图标 spritesheet 的入队与实际执行路径都必须在引用解析、generation input 重建、定价和 provider / OSS 副作用之前预检 owner、项目和最终素材目录,并将返回的 canonical `projectId + assetFolderId` 回写到后续流程;请求省略目录时按实际写入的 owner 默认目录预检,worker 不得只信任入队时的旧校验结果。
- queued 图标规范生成由共享原子结果持久化使用 worker caller 中的 lease 一并完成任务并清理 lease;共享执行器返回成功后 worker 只能返回 `Ok(())`,不得再次调用 job completion。
- queued 图标规范生成在本地文本门禁后、参考资源 owner 解析和定价之前执行目标预检,并把 canonical `projectId + assetFolderId` 写入任务 payload;不存在、越权或目录不匹配必须由提交请求同步失败,不得入队后再变成 worker 失败。
- spritesheet 队列提交对主图标规范只运行 metadata-only resolver,校验 owner、`assetKind="icon-spec"`、对象元数据和保存的游戏类型,不读取 OSS object bodyworker 执行时复用同一 metadata resolver 重新确认当前事实,并在通过后只下载一次图片正文。
- inline 与持久队列入口共用同一份 `iconDescriptions` prompt 合同:请求数组原始长度先满足 OpenAPI `1..100`,不得通过丢弃空白项绕过 `maxItems`;随后去除空白项仍须至少保留 1 条,单条最多 `200` 个 Unicode 字符,以换行拼接后合计最多 `2000` 个 Unicode 字符且不超过 `6144` 个 UTF-8 字节。请求边界校验成功后生成 `ValidatedEditorIconSpritesheetPrompt`,后续 prompt builder 不接受裸字符串。队列入口必须在引用解析、定价和任务持久化前同步拒绝可预测错误,不能把无效任务留给 worker 延迟失败。
- worker 解析主 `referenceId` 时必须通过 `spacetime-client` 的通用窄查询 `resolve_editor_reference` 在同一事务快照内完成引用解析和 owner 校验:该字段只接受当前 owner 的项目资源 ID 或素材 ID,并只按两张表的主键查询;不接受 `objectKey``image_src`、URL 或临时 key 作为主规范引用。两张表先按 `owner_user_id` 筛选候选,再判断同一 ID 是否在当前 owner 范围内同时命中;其它账号的同名 ID 不得制造歧义或阻断当前账号的合法引用。记录带 `asset_object_id` 时必须按该 ID 读取对象并同时核对 bucket、object key 与 owner,只有明确缺少 `asset_object_id` 的兼容旧行才允许按对象位置查询。procedure 复用既有 `EditorProjectResourceSnapshot``EditorAssetSnapshot` 返回唯一已验证行,不接收图标业务类型参数、不新建图标专属快照,也不得拉取当前用户的完整工程列表或素材库。`assetKind="icon-spec"``genre` 都由图标图集业务代码从返回行校验和提取。入队与 inline 预检只核对引用行和 asset object 元数据,不下载图片正文;最终执行重新解析当前事实并只下载一次实际参考图。解析成功后的 `objectKey` 是服务端内部存储事实,不是该请求的输入协议。引用不存在、owner 不匹配、asset object 不存在或数据库调用失败时 procedure 直接失败;业务类型不符或保存的游戏类型无效时 API 失败;合法规范没有已保存游戏类型时允许 `genre=None`
- 请求字段:
@@ -11214,6 +11214,11 @@ pub(crate) struct ResolvedEditorIconSpecReference {
pub(crate) genre: Option<crate::editor_project_icon::GameGenre>,
}
pub(crate) struct EditorIconSpecReferenceMetadata {
object_key: String,
genre: Option<crate::editor_project_icon::GameGenre>,
}
async fn resolve_editor_reference_record_by_id_for_owner(
state: &AppState,
owner_user_id: &str,
@@ -11271,6 +11276,20 @@ pub(crate) async fn resolve_editor_icon_spec_reference(
owner_user_id: &str,
reference_id: &str,
) -> Result<ResolvedEditorIconSpecReference, AppError> {
let metadata =
preflight_editor_icon_spec_reference_metadata(state, owner_user_id, reference_id).await?;
let image = read_editor_reference_image_object(state, metadata.object_key.as_str()).await?;
Ok(ResolvedEditorIconSpecReference {
image,
genre: metadata.genre,
})
}
pub(crate) async fn preflight_editor_icon_spec_reference_metadata(
state: &AppState,
owner_user_id: &str,
reference_id: &str,
) -> Result<EditorIconSpecReferenceMetadata, AppError> {
let reference_id = reference_id.trim();
if reference_id.is_empty() {
return Err(
@@ -11316,8 +11335,7 @@ pub(crate) async fn resolve_editor_icon_spec_reference(
"message": "图标规范引用保存了无效的游戏类型。",
}))
})?;
let image = read_editor_reference_image_object(state, object_key.as_str()).await?;
Ok(ResolvedEditorIconSpecReference { image, genre })
Ok(EditorIconSpecReferenceMetadata { object_key, genre })
}
pub(crate) async fn resolve_editor_reference_object_key_for_owner(
@@ -15959,21 +15977,31 @@ mod tests {
assert_function_contains(
source,
"async fn resolve_editor_icon_spec_reference",
"pub(crate) async fn resolve_editor_icon_spec_reference(",
"pub(crate) async fn preflight_editor_icon_spec_reference_metadata(",
&[
"preflight_editor_icon_spec_reference_metadata(",
"read_editor_reference_image_object(",
],
);
assert_function_contains(
source,
"pub(crate) async fn preflight_editor_icon_spec_reference_metadata(",
"pub(crate) async fn resolve_editor_reference_object_key_for_owner",
&[
"resolve_editor_reference_record_by_id_for_owner(",
"read_editor_reference_image_object(",
"EditorIconSpecReferenceMetadata",
],
);
assert_function_not_contains(
source,
"async fn resolve_editor_icon_spec_reference",
"pub(crate) async fn preflight_editor_icon_spec_reference_metadata(",
"pub(crate) async fn resolve_editor_reference_object_key_for_owner",
&[
"list_editor_projects",
"get_editor_asset_library",
"parse_editor_reference_image",
"read_editor_reference_image_object(",
],
);
assert_function_contains(
@@ -17389,11 +17417,17 @@ mod tests {
"payload.icon_descriptions =",
"normalize_icon_descriptions(payload.icon_descriptions)?.into_descriptions();",
"preflight_editor_billable_generation_target(",
"resolve_editor_icon_spec_reference(",
"preflight_editor_icon_spec_reference_metadata(",
"resolve_editor_icon_spritesheet_price(",
"enqueue_editor_generation_job_for_caller(",
],
);
assert_function_not_contains(
source,
"pub(crate) async fn enqueue_editor_icon_spritesheet_generation_for_owner",
"pub(crate) async fn generate_editor_icon_spritesheet_for_owner",
&["read_editor_reference_image_object("],
);
assert_function_contains_in_order(
source,
"pub(crate) async fn generate_editor_icon_spritesheet_for_owner",
@@ -1388,7 +1388,7 @@ pub(crate) async fn enqueue_editor_icon_spritesheet_generation_for_owner(
.await?;
payload.project_id = target.project_id;
payload.asset_folder_id = target.asset_folder_id;
resolve_editor_icon_spec_reference(
preflight_editor_icon_spec_reference_metadata(
state,
caller.owner_user_id.as_str(),
payload.reference_id.as_str(),