diff --git a/docs/【编辑器】画板图标素材生成入口设计-2026-06-15.md b/docs/【编辑器】画板图标素材生成入口设计-2026-06-15.md index 493a573b7..2b9545e5a 100644 --- a/docs/【编辑器】画板图标素材生成入口设计-2026-06-15.md +++ b/docs/【编辑器】画板图标素材生成入口设计-2026-06-15.md @@ -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 body;worker 执行时复用同一 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`。 - 请求字段: diff --git a/server-rs/crates/api-server/src/editor_project.rs b/server-rs/crates/api-server/src/editor_project.rs index 27e2b47b2..ecdd3145f 100644 --- a/server-rs/crates/api-server/src/editor_project.rs +++ b/server-rs/crates/api-server/src/editor_project.rs @@ -11214,6 +11214,11 @@ pub(crate) struct ResolvedEditorIconSpecReference { pub(crate) genre: Option, } +pub(crate) struct EditorIconSpecReferenceMetadata { + object_key: String, + genre: Option, +} + 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 { + 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 { 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", diff --git a/server-rs/crates/api-server/src/editor_project_icon.rs b/server-rs/crates/api-server/src/editor_project_icon.rs index ee746e8a2..67989ef1f 100644 --- a/server-rs/crates/api-server/src/editor_project_icon.rs +++ b/server-rs/crates/api-server/src/editor_project_icon.rs @@ -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(),