修复图标生成引用与提示词校验
Project CI / Frontend tests (pull_request) Successful in 2m41s
Project CI / Native shell tests (pull_request) Successful in 12m9s
Project CI / Repository checks (pull_request) Failing after 7s
Project CI / Backend tests (pull_request) Failing after 8s

修复外部 API 参数覆盖与图标描述数组边界

提前校验 Agent 正式引用并收紧 SpacetimeDB 对象一致性

修正自然语言提示词转义与纯文本格式校验

移除图标规范占位提示词并避免重复下载参考图

补充定向测试与对应架构文档
This commit is contained in:
2026-08-07 12:18:01 +08:00
parent 4a9f4657e9
commit 21209910dd
11 changed files with 296 additions and 67 deletions
@@ -577,9 +577,9 @@ class GenarrativeExternalClient:
return self.submit_and_wait_generation(
"/api/external/v1/editor/icon-spritesheets/generations",
{
**fields,
"referenceId": reference_id,
"iconDescriptions": descriptions,
**fields,
},
idempotency_key=idempotency_key,
)
@@ -769,6 +769,8 @@ def _self_test() -> None:
["蛇头向上", "蛇身直线", "转角", "尾部", "四类食物"],
canvasSession=session,
assetLabel="贪吃蛇透明图集",
referenceId="must-not-override-explicit-reference",
iconDescriptions=["不得覆盖显式图标描述"],
)
assert calls[0]["path"] == "/api/external/v1/editor/icon-spritesheets/generations"
assert calls[0]["body"]["referenceId"] == "editor-resource-spec"
@@ -6733,6 +6733,7 @@
- `editor_project_icon.rs` 独立承接图标规范、图标 spritesheet、自动切片和手工拆分。普通图片模块只暴露可复用的 prompt builder 执行入口与强类型 provider 请求分流;nanobanana、无参考图 generation、有参考图 edit 的选择集中在同一个 helper,图标图集复用该 helper,不复制 provider 分支。
- 图标规范生成的可选参考图与图标图集的必选主规范统一使用 `referenceId`,只接受当前 owner 的项目资源 ID 或账号素材 ID,不接受 objectKey、URL 或临时 key。SpacetimeDB `resolve_editor_reference_and_return` 因而只接收 `reference_id` 并按两张表主键查询,删除 `image_src` 二级索引。图标图集的普通附加参考图 `referenceImageSrcs` 仍允许 owned objectKey、项目资源 ID 或素材 ID,并继续走既有 owner 校验与真实图片下载链。
- External v1 图标图集请求与 OpenAPI 同步改为必填 `referenceId`。前端和 Editor Agent 优先传正式 resource ID,其次传 asset ID;本地临时 ID、objectKey 和图片地址不得回退成主规范引用,未登记时明确失败并要求先上传或登记。
- `resolve_editor_reference_and_return` 的歧义只在当前 owner 范围内判断:两张表先按 `owner_user_id` 过滤,同名但属于其它账号的行不阻断合法引用。记录存在 `asset_object_id` 时按该 ID 定位并同时核对 bucket、object key 与 owner,只有缺少 ID 的兼容旧行才按位置查询。图标规范入队 / inline 预检只验证这组行与对象元数据,最终共享图片执行器再下载一次参考图正文,避免同一引用在入队、worker 预检和生成阶段重复下载。
- 关联:`server-rs/crates/api-server/src/editor_project_icon.rs``server-rs/crates/api-server/src/editor_project.rs``server-rs/crates/spacetime-module/src/editor_project_storage.rs``docs/openapi/genarrative-external-v1.openapi.json`
## 2026-08-06 音效与背景音乐恢复共享音频 Composer
@@ -243,7 +243,7 @@ npm run check:server-rs-ddd
6. 编辑器图片生成 / 图片修改 / 图标 spritesheet / UI 设计图提取素材 / 视频 / 角色动作 / 音效 / 背景音乐必须在后端计算模型价格后使用 `execute_billable_asset_operation_with_cost` 预扣泥点;预扣失败必须 fail-closed,不得继续提交 VectorEngine、Ark、Suno 或 Vidu 上游任务。
7. 队列任务按 `job_id + claim_attempt` 使用独立 consume/refund ledger。新 attempt 结算旧 attempt 时必须先写 `asset_operation_wallet_settlement`:旧 consume 已存在则原子退款,尚不存在则写取消 intent;迟到 consume 在同一 SpacetimeDB 事务内看到 intent 后必须失败关闭。重复 consume/refund 只有用户、金额、来源和配对 ledger 全部一致时才可视为幂等成功。lease 过期时只有 `attempt < max_attempts` 才能递增并重领;最终 attempt 已耗尽时,claim transaction 必须直接把 job 收口为 `failed`、清理 lease、写失败事件并结算当前 attempt,不能再把任务返回 worker 或调用 provider。
8. 音频生成的编辑器链路虽然任务提交和结果发布分离,仍必须把提交时后端计算出的模型价格写入 `AudioAssetBindingTarget.billing_points_cost`,最终发布落资产时按该价格扣费;创作音频目标未提供该字段时才使用旧的创作音频固定成本。
9. 编辑器进入外部生成持久队列的图片生成、图片修改、去背景、图标 spritesheet、UI 设计图提取、角色动作和视频参考图,调用方必须提交 `objectKey` / `resourceId` / `assetId` 候选引用;BFF 只做内联媒体与 payload 门禁,登记状态和归属由 worker 统一解析。任务 `request_payload_json` / `result_payload_json` 任意层级都禁止 `data:` / `blob:`,并受统一字节上限保护。无效普通字符串可以入队,但必须在签名和 provider 调用前失败;本次不增加 API 侧数据库查询或同步 owner 校验。若以后要求无效引用同步返回 400,应作为独立改造。objectKey 最终必须归属于当前账号的 `editor_project_resource``editor_asset``asset_object`,由 worker 在解析后、签名读取 OSS 前完成归属校验。本地红框序号标注图必须先上传并确认对象,再把 objectKey 入队;不得把既有 objectKey 下载成 Data URL 后写入任务。图标规范分析、润色、规范图生图和图标 spritesheet 提示词中的请求文本必须统一按 XML 文本节点转义 `& < > " '`,不得把只经过 trim 或长度检查的原始值直接插入标签或后续生图提示词。图标 spritesheet 的 `iconDescriptions` 还必须在请求边界执行独立合同:去空后 `1..100`单条最多 `200` 个 Unicode 字符、换行拼接后合计最多 `2000` 个 Unicode 字符且不超过 `6144` 个 UTF-8 字节;只有 `ValidatedEditorIconSpritesheetPrompt` 能进入 prompt builderExternal v1 超限同步返回 `400`。图标素材、图片快速编辑和 UI 素材提取的额外参考图必须真正传入 provider,不得只写入 `generationInputs` 展示快照。普通图片生成最多 5 张参考图;图片修改、图标素材和 UI 提取的额外参考图上限还必须与所选 provider 的总容量共同取最小值:GPT-image-2 总计 5 张,nanobanana2 总计 14 张。前端添加和提交、api-server 入队 / 扣费前以及 `platform-image` provider 边界都必须明确拒绝超限,禁止用 `.take(...)` 静默截断。同步且不持久化的历史兼容入口即使仍能解析 Data URL,也不能把该值转存到工程、素材、元数据、审计或任务表。
9. 编辑器进入外部生成持久队列的图片生成、图片修改、去背景、图标 spritesheet、UI 设计图提取、角色动作和视频参考图,调用方必须提交 `objectKey` / `resourceId` / `assetId` 候选引用;BFF 只做内联媒体与 payload 门禁,登记状态和归属由 worker 统一解析。任务 `request_payload_json` / `result_payload_json` 任意层级都禁止 `data:` / `blob:`,并受统一字节上限保护。无效普通字符串可以入队,但必须在签名和 provider 调用前失败;本次不增加 API 侧数据库查询或同步 owner 校验。若以后要求无效引用同步返回 400,应作为独立改造。objectKey 最终必须归属于当前账号的 `editor_project_resource``editor_asset``asset_object`,由 worker 在解析后、签名读取 OSS 前完成归属校验。本地红框序号标注图必须先上传并确认对象,再把 objectKey 入队;不得把既有 objectKey 下载成 Data URL 后写入任务。图标规范结构化分析里位于 `<playSetting>` / `<artStyle>` XML 元素内的数据必须转义 `& < > " '`;玩法润色、美术风格润色、规范图生图和图标 spritesheet 等自然语言 prompt 则必须保留已经过边界校验的原文,不得把 `R&B`、引号或尖括号改写成 XML entity。图标 spritesheet 的 `iconDescriptions` 还必须在请求边界执行独立合同:原始数组先满足 OpenAPI `1..100`,再去空且至少保留 1单条最多 `200` 个 Unicode 字符、换行拼接后合计最多 `2000` 个 Unicode 字符且不超过 `6144` 个 UTF-8 字节;只有 `ValidatedEditorIconSpritesheetPrompt` 能进入 prompt builderExternal v1 超限同步返回 `400`。图标素材、图片快速编辑和 UI 素材提取的额外参考图必须真正传入 provider,不得只写入 `generationInputs` 展示快照。普通图片生成最多 5 张参考图;图片修改、图标素材和 UI 提取的额外参考图上限还必须与所选 provider 的总容量共同取最小值:GPT-image-2 总计 5 张,nanobanana2 总计 14 张。前端添加和提交、api-server 入队 / 扣费前以及 `platform-image` provider 边界都必须明确拒绝超限,禁止用 `.take(...)` 静默截断。同步且不持久化的历史兼容入口即使仍能解析 Data URL,也不能把该值转存到工程、素材、元数据、审计或任务表。
10. 已有静态图片的 `POST /api/editor/images/pixel-art-snaps` 是免费 inline 派生操作,不调用外部 provider、不创建 `external_generation_job`、不读写泥点 ledger,也不进入任务侧栏。免费不放宽 owner、稳定引用、输入上限、持久化或处理阶段零持久化门禁。
11. 主站编辑器生成队列使用同一次前端请求稳定复用的 `x-request-id`,按 namespace + owner + job kind + request id 生成唯一 `dedupe_key`;首次请求已入队但响应丢失时,重试必须返回原任务。同一幂等键携带不同 payload 返回 `409`,不得创建第二个任务或串到旧结果。外部 v1 的 `Idempotency-Key` 使用独立 namespace,不能与主站请求标识碰撞。幂等 payload 比较只对本次已迁移 sanitizer 的图片生成、图片修改、去背景、图标图集和 UI 提取任务,兼容“升级前旧任务仍含客户端 `generationInputs.references`、当前请求已删除该字段”的单向形状;当前请求仍含 references,或 job kind 属于音频 / 视频 / 角色动作等未迁移任务时必须完整比较,其余请求字段始终完全一致。
12. `generationInputs.references` 是最终资产的服务端权威行引用,不接受客户端自报 provenance。图片生成类请求入队、完美像素及直接创建资源 / 素材时删除客户端 referencesworker 和 inline 路径按本次真实参考图、当前 owner 的项目资源 / 素材记录重建 `refType/refId` 后再持久化。仅能证明 owned objectKey、但找不到对应资源或素材行时可以参与生成,不得制造虚假行引用;`title/label` 只作为展示快照,不提升为资源身份。完美像素为兼容升级前的未知结果重放,可继续用旧版 canonical 客户端输入计算 operation fingerprint;新操作持久化元数据只能使用服务端重建值,检测到 owner 项目中已存在同一稳定 task/resource 的历史结果时则复用该服务端既存 metadata 完成精确 compare-and-return。
@@ -40,8 +40,8 @@
## 生成契约
- 前端提交到 `POST /api/editor/icon-spritesheets/generations`
- inline 与持久队列入口共用同一份 `iconDescriptions` prompt 合同:去除空白项后必须保留 `1..100` 条,单条最多 `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 作为主规范引用。同一 ID 若同时命中项目资源和素材必须按协议歧义拒绝,不得静默偏向任一表。procedure 复用既有 `EditorProjectResourceSnapshot``EditorAssetSnapshot` 返回唯一已验证行,不接收图标业务类型参数、不新建图标专属快照,也不得拉取当前用户的完整工程列表或素材库。`assetKind="icon-spec"``genre` 都由图标图集业务代码从返回行校验和提取。解析成功后才使用返回行内已验证的 `objectKey` 读取对象,`objectKey` 是服务端内部存储事实,不是该请求的输入协议。引用不存在、owner 不匹配、asset object 不存在或数据库调用失败时 procedure 直接失败;业务类型不符或保存的游戏类型无效时 API 失败;合法规范没有已保存游戏类型时允许 `genre=None`
- 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`
- 请求字段:
- `referenceId`:图标主规范的正式引用,必填且只允许当前 owner 的项目资源 ID 或素材 ID。本地临时图必须先按 `assetKind="icon-spec"` 上传并登记为项目资源或账号素材,再提交返回的 `resourceId``assetId`;禁止提交 `objectKey`、URL、Data URL、Blob URL 或临时 key。
- `iconDescriptions`:兼容现有接口的图标需求数组,`1..100`;当前画布前端固定把完整文本作为唯一数组元素提交。数组长度只表达请求文本,不作为自动拆分数量;单项、聚合字符和 UTF-8 字节上限按上一条 prompt 合同执行。
@@ -63,6 +63,8 @@
<完整用户需求>
```
上述最终 spritesheet prompt、玩法润色 prompt、美术风格润色 prompt 和规范图生图 prompt 都是自然语言文本,必须保留已经过长度与空白校验的用户原文;不得把 `R&B`、引号或尖括号改写成 XML entity。只有 `build_extra_param_prompt` 中真正位于 `<playSetting>` / `<artStyle>` XML 元素内的数据执行 XML 转义。
## 像素风格后处理
- 图标素材面板增加紧凑的 `像素艺术` 勾选项。选择保存于现有生成器快照,并可随现有请求和队列 payload 传递;不写入用户可见 `generationInputs`、素材元数据或新建的持久化记录。
@@ -725,6 +725,15 @@ impl EditorAgentTool for GenerateIconSpritesheetTool {
let args: GenerateIconSpritesheetToolArgs = parse_invalid_args(Self::NAME, args)?;
let args = GenerateIconSpritesheetTool::normalize_args(self, args)
.map_err(|error| EditorAgentToolError::invalid_args(error.to_string()))?;
self.context
.image_metadata(&args.reference_image_id)
.and_then(|metadata| metadata.reference_id.as_deref())
.filter(|reference_id| !reference_id.trim().is_empty())
.ok_or_else(|| {
EditorAgentToolError::invalid_args(
"reference image is not a registered resource or asset",
)
})?;
serialize_normalized_args(Self::NAME, &args)
}
@@ -796,11 +805,13 @@ impl EditorAgentTool for GenerateIconSpritesheetTool {
.context
.image_metadata(&args.reference_image_id)
.and_then(|metadata| metadata.reference_id.as_deref())
.filter(|reference_id| !reference_id.trim().is_empty())
.ok_or_else(|| {
EditorAgentToolError::invalid_args(
"reference image is not a registered resource or asset",
)
})?
.trim()
.to_string();
let reference_image_srcs = resolve_image_ids(&args.reference_image_ids, &self.context)?;
let payload = EditorIconSpritesheetGenerationRequest {
@@ -1375,6 +1386,40 @@ mod tests {
let missing = editor_agent_tool(EditImageTool::NAME, &EditorToolContext::default())
.expect("edit tool should resolve");
assert!(missing.validate_args(&normalized).is_err());
let registered_icon = editor_agent_tool(
GenerateIconSpritesheetTool::NAME,
&context_with_image("image-1"),
)
.expect("icon spritesheet tool should resolve");
assert!(
registered_icon
.validate_args(&json!({
"reference_image_id": "image-1",
"icon_descriptions": ["背包"]
}))
.is_ok()
);
let mut unregistered_context = context_with_image("image-1");
unregistered_context
.images
.get_mut(&ImageId {
id: "image-1".to_string(),
})
.expect("test image should exist")
.reference_id = None;
let unregistered_icon =
editor_agent_tool(GenerateIconSpritesheetTool::NAME, &unregistered_context)
.expect("icon spritesheet tool should resolve");
assert!(
unregistered_icon
.validate_args(&json!({
"reference_image_id": "image-1",
"icon_descriptions": ["背包"]
}))
.is_err()
);
}
#[test]
@@ -1691,6 +1691,7 @@ pub(crate) async fn enqueue_editor_image_generation_for_owner(
mut payload: EditorImageGenerationRequest,
external_idempotency_key: Option<&str>,
) -> Result<ExternalGenerationJobRecord, AppError> {
validate_editor_image_generation_prompt(payload.prompt.as_str())?;
payload.generation_inputs =
sanitize_editor_untrusted_generation_inputs(payload.generation_inputs.take());
ensure_editor_reference_image_sources_are_stable(
@@ -1840,6 +1841,7 @@ pub(crate) async fn generate_editor_image_for_owner(
caller: EditorGenerationCaller,
payload: EditorImageGenerationRequest,
) -> Result<Json<Value>, AppError> {
validate_editor_image_generation_prompt(payload.prompt.as_str())?;
let prompt = payload.prompt.clone();
generate_editor_image_for_owner_with_prompt_builder(
state,
@@ -1856,6 +1858,18 @@ pub(crate) async fn generate_editor_image_for_owner(
.await
}
fn validate_editor_image_generation_prompt(prompt: &str) -> Result<(), AppError> {
if prompt.trim().is_empty() {
return Err(
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
"provider": "editor-image-generation",
"message": "生成提示词不能为空",
})),
);
}
Ok(())
}
pub(crate) async fn generate_editor_image_for_owner_with_prompt_builder<F, Fut>(
state: &AppState,
request_context: &RequestContext,
@@ -1875,16 +1889,6 @@ where
"referenceImageSrcs",
"生成参考图",
)?;
let initial_prompt = payload.prompt.trim().to_string();
if initial_prompt.is_empty() {
return Err(
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
"provider": "editor-image-generation",
"message": "生成提示词不能为空",
})),
);
}
let normalized_kind = payload.kind.as_deref().map(str::trim);
let is_character_generation = matches!(normalized_kind, Some("character"));
let pixel_art_supported = matches!(normalized_kind, None | Some("") | Some("character"));
@@ -9438,7 +9442,7 @@ pub(crate) async fn validate_editor_reference_id_for_owner(
) -> Result<(), AppError> {
let resolved =
resolve_editor_reference_record_by_id_for_owner(state, owner_user_id, reference_id).await?;
let object_key = match resolved {
match resolved {
EditorReferenceRecord::ProjectResource(resource) => resource.object_key,
EditorReferenceRecord::Asset(asset) => asset.object_key,
}
@@ -9448,7 +9452,6 @@ pub(crate) async fn validate_editor_reference_id_for_owner(
"message": "已验证的编辑器引用缺少 object_key。",
}))
})?;
read_editor_reference_image_object(state, object_key.as_str()).await?;
Ok(())
}
@@ -10293,6 +10296,12 @@ mod tests {
thread,
};
#[test]
fn ordinary_image_prompt_validation_stays_outside_prompt_builder_execution() {
assert!(validate_editor_image_generation_prompt("生成森林场景").is_ok());
assert!(validate_editor_image_generation_prompt(" \n\t ").is_err());
}
#[test]
fn editor_asset_payload_uses_formal_sequence_fields_only() {
let stored_formal_frames = json!([
@@ -13927,6 +13936,21 @@ mod tests {
"parse_editor_reference_image",
],
);
assert_function_contains(
source,
"pub(crate) async fn validate_editor_reference_id_for_owner",
"pub(crate) async fn resolve_editor_icon_spec_reference",
&["resolve_editor_reference_record_by_id_for_owner("],
);
assert_function_not_contains(
source,
"pub(crate) async fn validate_editor_reference_id_for_owner",
"pub(crate) async fn resolve_editor_icon_spec_reference",
&[
"read_editor_reference_image_object(",
"parse_editor_reference_image(",
],
);
}
#[test]
@@ -137,7 +137,17 @@ impl ValidatedEditorIconSpritesheetPrompt {
pub(crate) fn normalize_icon_descriptions(
descriptions: Vec<String>,
) -> Result<ValidatedEditorIconSpritesheetPrompt, AppError> {
let mut normalized = Vec::with_capacity(descriptions.len().min(EDITOR_ICON_DESCRIPTION_LIMIT));
if descriptions.len() > EDITOR_ICON_DESCRIPTION_LIMIT {
return Err(
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
"provider": "editor-icon-spritesheet",
"field": "iconDescriptions",
"maxItems": EDITOR_ICON_DESCRIPTION_LIMIT,
"message": "图标素材描述数量必须在 1 到 100 个之间。",
})),
);
}
let mut normalized = Vec::with_capacity(descriptions.len());
let mut total_chars = 0usize;
let mut total_utf8_bytes = 0usize;
for (item_index, description) in descriptions.into_iter().enumerate() {
@@ -145,16 +155,6 @@ pub(crate) fn normalize_icon_descriptions(
if description.is_empty() {
continue;
}
if normalized.len() >= EDITOR_ICON_DESCRIPTION_LIMIT {
return Err(
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
"provider": "editor-icon-spritesheet",
"field": "iconDescriptions",
"maxItems": EDITOR_ICON_DESCRIPTION_LIMIT,
"message": "图标素材描述数量必须在 1 到 100 个之间。",
})),
);
}
let actual_length = description.chars().count();
if actual_length > EDITOR_ICON_DESCRIPTION_MAX_CHARS {
return Err(
@@ -528,8 +528,7 @@ pub(crate) async fn generate_icon_spec_for_owner(
let request_param = payload.request_param.clone();
let has_reference_image = payload.reference_id.is_some();
let generation_inputs = payload.generation_inputs.clone();
let image_request =
build_icon_spec_image_request(payload, "图标规范生成中".to_string(), generation_inputs);
let image_request = build_icon_spec_image_request(payload, generation_inputs);
generate_editor_image_for_owner_with_prompt_builder(
state,
request_context,
@@ -592,11 +591,12 @@ async fn resolve_icon_spec_price(state: &AppState) -> Result<u32, AppError> {
fn build_icon_spec_image_request(
payload: EditorIconSpecGenerationRequest,
prompt: String,
generation_inputs: Option<Value>,
) -> EditorImageGenerationRequest {
EditorImageGenerationRequest {
prompt,
// 中文注释:最终 prompt 由共享执行器的 prompt builder 在预扣后构建,
// 这里不再放置会被误校验或误持久化的占位文本。
prompt: String::new(),
size: Some(ICON_SPEC_SIZE.to_string()),
kind: Some("spec".to_string()),
style: None,
@@ -2479,6 +2479,23 @@ mod tests {
);
}
#[test]
fn icon_descriptions_reject_raw_array_over_openapi_max_items() {
let mut descriptions = vec![String::new(); EDITOR_ICON_DESCRIPTION_LIMIT];
descriptions.push("唯一非空项".to_string());
let error = normalize_icon_descriptions(descriptions)
.expect_err("raw array above maxItems must fail before blank entries are discarded");
assert_eq!(
error
.details()
.and_then(|details| details.get("maxItems"))
.and_then(Value::as_u64),
Some(EDITOR_ICON_DESCRIPTION_LIMIT as u64)
);
}
#[test]
fn spritesheet_genre_requires_exact_game_type_title() {
assert_eq!(
@@ -2523,11 +2540,10 @@ mod tests {
source_resource_id: Some("resource-source-1".to_string()),
canvas_completion: None,
},
"最终图标规范 prompt".to_string(),
Some(json!({ "fields": [], "references": [] })),
);
assert_eq!(image_request.prompt, "最终图标规范 prompt");
assert!(image_request.prompt.is_empty());
assert_eq!(
image_request.generation_inputs,
Some(json!({ "fields": [], "references": [] }))
@@ -158,23 +158,42 @@ fn normalize_refined_text(value: &str) -> Result<String, RefinedTextError> {
"> ",
"- ",
"* ",
"+ ",
"说明:",
"说明:",
"解释:",
"解释:",
];
let has_disallowed_line = value.lines().any(|line| {
let has_disallowed_format = value.lines().any(|line| {
let line = line.trim_start();
disallowed_prefixes
.iter()
.any(|prefix| line.starts_with(prefix))
|| line.starts_with(['{', '['])
|| is_markdown_ordered_list_item(line)
|| ["**", "__", "~~"]
.iter()
.any(|marker| line.contains(marker))
|| line.contains('`')
});
if value.contains("```") || value.starts_with(['{', '[']) || has_disallowed_line {
if has_disallowed_format {
return Err(RefinedTextError::InvalidFormat);
}
Ok(value.to_string())
}
fn is_markdown_ordered_list_item(line: &str) -> bool {
let digit_count = line
.chars()
.take_while(|character| character.is_ascii_digit())
.count();
if digit_count == 0 {
return false;
}
let suffix = &line[digit_count..];
suffix.starts_with(". ") || suffix.starts_with(") ")
}
async fn run_refine_text_llm(state: &AppState, prompt: String) -> Result<String, AppError> {
let configured_llm_client = state.vector_engine_llm_client().ok_or_else(|| {
AppError::from_status(StatusCode::SERVICE_UNAVAILABLE).with_details(json!({
@@ -295,6 +314,20 @@ mod tests {
normalize_refined_text(r#"{"playSetting":"回合制"}"#).unwrap_err(),
RefinedTextError::InvalidFormat
);
for invalid in [
"1. 回合制占点",
"2) 资源调度",
"##优化后的玩法",
"**回合制占点**",
"正文说明\n{\"playSetting\":\"回合制\"}",
"正文说明\n[\"回合制\"]",
] {
assert_eq!(
normalize_refined_text(invalid).unwrap_err(),
RefinedTextError::InvalidFormat,
"structured or Markdown output should fail: {invalid}",
);
}
assert_eq!(
normalize_refined_text("回合制占点,围绕资源调度形成攻防循环")
.expect("plain text should pass"),
@@ -63,7 +63,7 @@ pub(crate) fn build_refine_play_setting_prompt(play_setting: &str) -> String {
只返回可直接使用的中文纯文本,可以分段或换行。
"#,
play_setting = escape_xml_prompt_data(play_setting),
play_setting = play_setting,
)
}
@@ -110,7 +110,7 @@ pub(crate) fn build_refine_art_style_prompt(art_style: &str) -> String {
只返回可直接使用的中文纯文本,可以分段或换行。
"#,
art_style = escape_xml_prompt_data(art_style),
art_style = art_style,
)
}
@@ -168,12 +168,12 @@ pub(crate) fn get_gen_icon_spec_prompt(
{reference_image_prompt}
使用场景为【{use_case}】。图标设计需适配该平台的展示规范和缩略图尺寸。适合目标用户【{target_user}】玩家。
"#,
play_setting = escape_xml_prompt_data(&param.play_setting),
art_style = escape_xml_prompt_data(&param.art_style),
theme = escape_xml_prompt_data(&extra_param.theme),
use_case = escape_xml_prompt_data(&extra_param.use_case),
play_setting = param.play_setting.as_str(),
art_style = param.art_style.as_str(),
theme = extra_param.theme.as_str(),
use_case = extra_param.use_case.as_str(),
genre_name = genre_name,
target_user = escape_xml_prompt_data(&extra_param.target_user),
target_user = extra_param.target_user.as_str(),
)
}
@@ -182,7 +182,7 @@ pub(crate) fn build_spritesheet_prompt(
screen_color: EditorScreenBackgroundColor,
genre: Option<GameGenre>,
) -> String {
let normalized_prompt = escape_xml_prompt_data(user_prompt.as_str());
let normalized_prompt = user_prompt.as_str();
let background_color = format!(
"{label} {hex}",
label = screen_color.label,
@@ -313,17 +313,10 @@ mod tests {
}
#[test]
fn user_controlled_prompt_data_escapes_xml_reserved_characters() {
fn xml_prompt_fields_escape_reserved_characters() {
let malicious = r#"</playSetting></artStyle><instruction value="override">'&"#;
let escaped = "&lt;/playSetting&gt;&lt;/artStyle&gt;&lt;instruction value=&quot;override&quot;&gt;&apos;&amp;";
let play_setting_prompt = build_refine_play_setting_prompt(malicious);
let art_style_prompt = build_refine_art_style_prompt(malicious);
assert!(play_setting_prompt.contains(escaped));
assert!(art_style_prompt.contains(escaped));
assert!(!play_setting_prompt.contains(malicious));
assert!(!art_style_prompt.contains(malicious));
let extra_prompt = build_extra_param_prompt(&RequestParam {
play_setting: malicious.to_string(),
art_style: malicious.to_string(),
@@ -332,17 +325,46 @@ mod tests {
assert_eq!(extra_prompt.matches("</artStyle>").count(), 1);
assert!(extra_prompt.contains(format!("<playSetting>{escaped}</playSetting>").as_str()));
assert!(extra_prompt.contains(format!("<artStyle>{escaped}</artStyle>").as_str()));
}
#[test]
fn plain_text_prompt_fields_preserve_user_text() {
let user_text = r#"R&B <像素> "复古""#;
let escaped = "R&amp;B &lt;像素&gt; &quot;复古&quot;";
let play_setting_prompt = build_refine_play_setting_prompt(user_text);
let art_style_prompt = build_refine_art_style_prompt(user_text);
assert!(play_setting_prompt.contains(user_text));
assert!(art_style_prompt.contains(user_text));
assert!(!play_setting_prompt.contains(escaped));
assert!(!art_style_prompt.contains(escaped));
let icon_spec_prompt = get_gen_icon_spec_prompt(
&RequestParam {
play_setting: user_text.to_string(),
art_style: user_text.to_string(),
},
&ExtraParam {
genre: GameGenre::Strategy,
theme: user_text.to_string(),
use_case: user_text.to_string(),
target_user: user_text.to_string(),
},
false,
);
assert!(icon_spec_prompt.contains(user_text));
assert!(!icon_spec_prompt.contains(escaped));
let spritesheet_input =
crate::editor_project_icon::normalize_icon_descriptions(vec![malicious.to_string()])
crate::editor_project_icon::normalize_icon_descriptions(vec![user_text.to_string()])
.expect("bounded spritesheet prompt should pass");
let spritesheet_prompt = build_spritesheet_prompt(
&spritesheet_input,
crate::editor_green_screen::default_editor_screen_background_color(),
Some(GameGenre::Strategy),
);
assert!(spritesheet_prompt.contains(escaped));
assert!(!spritesheet_prompt.contains(malicious));
assert!(spritesheet_prompt.contains(user_text));
assert!(!spritesheet_prompt.contains(escaped));
}
#[test]
@@ -6332,12 +6332,14 @@ fn resolve_editor_reference(
.db
.editor_project_resource()
.resource_id()
.find(&reference_id.to_string());
.find(&reference_id.to_string())
.filter(|resource| editor_reference_owner_matches(&resource.owner_user_id, owner_user_id));
let asset = ctx
.db
.editor_asset()
.asset_id()
.find(&reference_id.to_string());
.find(&reference_id.to_string())
.filter(|asset| editor_reference_owner_matches(&asset.owner_user_id, owner_user_id));
match (resource, asset) {
(Some(_), Some(_)) => Err("编辑器引用 ID 同时命中项目资源与素材,无法唯一解析".to_string()),
(Some(resource), None) => {
@@ -6348,6 +6350,10 @@ fn resolve_editor_reference(
}
}
fn editor_reference_owner_matches(actual_owner_user_id: &str, owner_user_id: &str) -> bool {
actual_owner_user_id.trim() == owner_user_id
}
fn resolve_editor_reference_resource(
ctx: &ReducerContext,
bucket: &str,
@@ -6360,7 +6366,13 @@ fn resolve_editor_reference_resource(
resource.owner_user_id.as_str(),
resource.object_key.as_deref(),
)?;
verify_editor_reference_asset_object(ctx, bucket, owner_user_id, object_key.as_str())?;
verify_editor_reference_asset_object(
ctx,
bucket,
owner_user_id,
resource.asset_object_id.as_deref(),
object_key.as_str(),
)?;
let mut snapshot = resource_snapshot_from_row(resource.clone());
snapshot.object_key = Some(object_key);
Ok(ResolvedEditorReference::ProjectResource(snapshot))
@@ -6378,7 +6390,13 @@ fn resolve_editor_reference_asset(
asset.owner_user_id.as_str(),
asset.object_key.as_deref(),
)?;
verify_editor_reference_asset_object(ctx, bucket, owner_user_id, object_key.as_str())?;
verify_editor_reference_asset_object(
ctx,
bucket,
owner_user_id,
asset.asset_object_id.as_deref(),
object_key.as_str(),
)?;
let mut snapshot = asset_snapshot_from_row(ctx, asset);
snapshot.object_key = Some(object_key);
Ok(ResolvedEditorReference::Asset(snapshot))
@@ -6406,16 +6424,42 @@ fn verify_editor_reference_asset_object(
ctx: &ReducerContext,
bucket: &str,
owner_user_id: &str,
asset_object_id: Option<&str>,
object_key: &str,
) -> Result<(), String> {
let asset_object = crate::find_asset_object_by_location(
ctx,
&AssetObjectLocationInput {
bucket: bucket.to_string(),
object_key: object_key.to_string(),
},
)?
.ok_or_else(|| "编辑器引用对应的 asset_object 不存在".to_string())?;
let asset_object = match asset_object_id {
Some(asset_object_id) => {
let asset_object_id = asset_object_id.trim();
if asset_object_id.is_empty() {
return Err("编辑器引用记录的 asset_object_id 不能为空".to_string());
}
crate::asset_metadata::find_asset_object_snapshot_by_id(ctx, asset_object_id)
.ok_or_else(|| "编辑器引用记录的 asset_object_id 不存在".to_string())?
}
None => crate::find_asset_object_by_location(
ctx,
&AssetObjectLocationInput {
bucket: bucket.to_string(),
object_key: object_key.to_string(),
},
)?
.ok_or_else(|| "编辑器引用对应的 asset_object 不存在".to_string())?,
};
validate_editor_reference_asset_object(&asset_object, bucket, owner_user_id, object_key)
}
fn validate_editor_reference_asset_object(
asset_object: &AssetObjectUpsertSnapshot,
bucket: &str,
owner_user_id: &str,
object_key: &str,
) -> Result<(), String> {
if asset_object.bucket.trim() != bucket
|| asset_object.object_key.trim().trim_start_matches('/')
!= object_key.trim().trim_start_matches('/')
{
return Err("编辑器引用记录的 asset_object 与对象位置不一致".to_string());
}
if asset_object.owner_user_id.as_deref().map(str::trim) != Some(owner_user_id) {
return Err("编辑器引用对应的 asset_object 不属于当前用户".to_string());
}
@@ -12253,6 +12297,47 @@ mod tests {
}
}
#[test]
fn editor_reference_ambiguity_only_considers_current_owner_candidates() {
assert!(editor_reference_owner_matches(" user-1 ", "user-1"));
assert!(!editor_reference_owner_matches("user-2", "user-1"));
}
#[test]
fn editor_reference_asset_object_must_match_id_bound_location_and_owner() {
let object = migration_frame_object("generated/reference.png", "asset-object-reference");
assert!(
validate_editor_reference_asset_object(
&object,
"editor-private",
"user-1",
"generated/reference.png",
)
.is_ok()
);
assert!(
validate_editor_reference_asset_object(
&object,
"editor-private",
"user-1",
"generated/other.png",
)
.expect_err("object key mismatch should fail")
.contains("对象位置不一致")
);
assert!(
validate_editor_reference_asset_object(
&object,
"editor-private",
"user-2",
"generated/reference.png",
)
.expect_err("owner mismatch should fail")
.contains("不属于当前用户")
);
}
#[test]
fn character_animation_normalization_ignores_unproven_top_level_frames() {
let inputs = json!({
@@ -1928,7 +1928,6 @@ export function useImageCanvasGenerationSubmissionWorkflow({
nextGeneratedIndex: layerCounterRef.current + 1,
canonicalBackgroundMusicPrompt: backgroundMusicSubmission?.prompt,
});
// TODO legacy code
if (submissionPlan.kind === 'edit') {
const referenceImageSrc = await resolveEditorGenerationMediaReference(
submissionPlan.sourceLayer,