补齐场景契约、文档与路由级测试

补全场景技术方案请求字段边界并加入文档总览
共享包出口导出场景运行时常量与类型
新增场景生成路由鉴权、必填校验与内联媒体拒绝测试
后端重建 generationInputs 字段避免透传客户端伪造内容
补齐 scene 画布素材类型标签并新增回归测试
This commit is contained in:
2026-08-05 14:45:55 +08:00
parent 0a703ac236
commit 956288e552
7 changed files with 200 additions and 2 deletions
+1
View File
@@ -19,6 +19,7 @@
- [图片画布编辑器 MVP 接入方案](./technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md)
- [图片画布编辑器前端拆分计划](./technical/【前端架构】图片画布编辑器前端拆分计划-2026-06-17.md)
- [图片画布游戏场景生成链路](./technical/【技术方案】图片画布游戏场景生成链路-2026-08-04.md)
- [画布 Agent 对话面板](./【编辑器】画布Agent对话面板-2026-07-03.md)
- [画布 Agent 会话消息存 OSS](./adr/【ADR】画布Agent会话消息存OSS-2026-07-03.md)
- [图片画布撤销范围与操作提示方案](./【图片画布】撤销范围与操作提示方案-2026-07-17.md)
@@ -56,7 +56,7 @@
- FileReader Data URL 正式请求。
- 复制出的整套画布和缩减版类型。
四张固定图片只用于前端预览,不进入 `generationReferences`,不随生成请求提交。保留原分辨率,只做轻量无损压缩。
四张固定图片只用于前端预览,不进入 `generationReferences`,不随生成请求提交。保留原分辨率,只做轻量无损压缩;画风菜单打开时不同时挂载全部预览图,只在用户悬停或键盘聚焦对应预览入口时按需挂载当前图片
## 4. API 契约
@@ -77,6 +77,7 @@ aspectRatio?
imageSize?
referenceImageSrcs?
projectId?
generationInputs?
assetFolderId?
assetLabel?
canvasCompletion?
@@ -84,6 +85,8 @@ canvasCompletion?
请求不接受前端组装后的完整 `prompt`。本期不修改 `/api/external/v1`
`generationInputs` 只承载结构化输入快照。后端不信任或透传前端提交的 `fields`,而是根据标准化后的场景请求重新构建画面内容、视觉风格、比例、清晰度和模型字段;前端提交的参考图元数据只使用既有项目资源或账号素材的行引用,不保存媒体 URL。完整 Provider Prompt 仍只能由后端生成。
后端对模型、比例和清晰度先沿用 `normalize_editor_generation_options` 标准化,再使用标准化比例决定画幅描述和入队价格。
## 5. Prompt
@@ -152,3 +155,4 @@ prompt = 后端完整 Prompt
- 后端正确组装四种预设、一个自定义风格和三类画幅。
- 展示价、入队价、实际扣费和资源成本一致。
- 任务复用现有等待、失败、轮询、资源入库和画布添加逻辑。
- 场景产物以 `assetKind = scene` 持久化,画布图层右上角显示“场景”标签,不降级为“未知”。
+1 -1
View File
@@ -5,7 +5,7 @@ export type * from './contracts/creationAgentDocumentInput';
export type * from './contracts/creationAudio';
export type * from './contracts/creativeAgent';
export type * from './contracts/customWorldAgent';
export type * from './contracts/editorScene';
export * from './contracts/editorScene';
export * from './contracts/edutainmentBabyDrawing';
export * from './contracts/edutainmentBabyObject';
export * from './contracts/externalGeneration';
+139
View File
@@ -1912,6 +1912,145 @@ mod tests {
);
}
#[tokio::test]
async fn editor_scene_generation_requires_bearer_auth() {
let app = build_router(AppState::new(AppConfig::default()).expect("state should build"));
let request_body = serde_json::json!({
"sceneContent": "雨夜中的海边车站",
"stylePreset": "anime",
})
.to_string();
let response = app
.oneshot(
Request::builder()
.method("POST")
.uri("/api/editor/scenes/generations")
.header("content-type", "application/json")
.body(Body::from(request_body))
.expect("request should build"),
)
.await
.expect("request should succeed");
assert_eq!(response.status(), StatusCode::UNAUTHORIZED);
}
#[tokio::test]
async fn editor_scene_generation_rejects_invalid_required_inputs() {
let state = AppState::new(AppConfig {
external_generation_mode: ExternalGenerationMode::Queue,
..AppConfig::default()
})
.expect("state should build");
let seed_user = seed_phone_user_with_password(&state, "13800138230", TEST_PASSWORD).await;
let token = sign_test_user_token(&state, &seed_user, "sess_editor_scene_validation");
let app = build_router(state);
let requests = [
(
"empty sceneContent",
serde_json::json!({
"sceneContent": " ",
"stylePreset": "anime",
}),
"sceneContent",
),
(
"unknown stylePreset",
serde_json::json!({
"sceneContent": "雨夜中的海边车站",
"stylePreset": "unknown",
}),
"stylePreset",
),
(
"missing customStyle",
serde_json::json!({
"sceneContent": "雨夜中的海边车站",
"stylePreset": "custom",
}),
"customStyle",
),
];
for (case_name, request_body, expected_message) in requests {
let response = app
.clone()
.oneshot(
Request::builder()
.method("POST")
.uri("/api/editor/scenes/generations")
.header("authorization", format!("Bearer {token}"))
.header("content-type", "application/json")
.body(Body::from(request_body.to_string()))
.expect("request should build"),
)
.await
.expect("request should succeed");
assert_eq!(
response.status(),
StatusCode::BAD_REQUEST,
"{case_name} should return 400"
);
let body = response
.into_body()
.collect()
.await
.expect("response body should collect")
.to_bytes();
let body_text = String::from_utf8_lossy(&body);
assert!(
body_text.contains(expected_message),
"{case_name} should report {expected_message}: {body_text}"
);
}
}
#[tokio::test]
async fn editor_scene_generation_rejects_inline_data_url_before_queueing() {
let state = AppState::new(AppConfig {
external_generation_mode: ExternalGenerationMode::Queue,
..AppConfig::default()
})
.expect("state should build");
let seed_user = seed_phone_user_with_password(&state, "13800138231", TEST_PASSWORD).await;
let token = sign_test_user_token(&state, &seed_user, "sess_editor_scene_reference");
let app = build_router(state);
let request_body = serde_json::json!({
"sceneContent": "雨夜中的海边车站",
"stylePreset": "anime",
"referenceImageSrcs": ["data:image/png;base64,AAAA"],
})
.to_string();
let response = app
.oneshot(
Request::builder()
.method("POST")
.uri("/api/editor/scenes/generations")
.header("authorization", format!("Bearer {token}"))
.header("content-type", "application/json")
.body(Body::from(request_body))
.expect("request should build"),
)
.await
.expect("request should succeed");
assert_eq!(response.status(), StatusCode::BAD_REQUEST);
let body = response
.into_body()
.collect()
.await
.expect("response body should collect")
.to_bytes();
let body_text = String::from_utf8_lossy(&body);
assert!(
body_text.contains("先上传 OSS"),
"handler should reject inline editor scene references: {body_text}"
);
}
#[tokio::test]
async fn editor_pixel_art_style_wrong_types_return_bad_request() {
let state = AppState::new(AppConfig {
@@ -15146,4 +15146,49 @@ mod tests {
assert_eq!(profile.path_kind, "scene-images");
assert_eq!(profile.slot, "scene");
}
#[test]
fn scene_generation_rebuilds_fields_and_only_keeps_reference_rows() {
let payload: EditorSceneGenerateRequest = serde_json::from_value(json!({
"sceneContent": " 雨夜中的海边车站 ",
"stylePreset": "custom",
"customStyle": "复古像素风",
"generationInputs": {
"fields": [
{ "title": "伪造字段", "value": "不得透传" }
],
"references": [
{
"title": "参考图",
"label": "车站参考",
"refType": "project-resource",
"refId": "editor-resource-scene-reference"
}
]
}
}))
.expect("scene request should deserialize");
let options = normalize_editor_generation_options(
Some(EDITOR_IMAGE_MODEL_NANOBANANA2),
Some("16:9"),
Some("1K"),
);
let generation_inputs = build_editor_scene_generation_inputs(&payload, &options);
let serialized = generation_inputs.to_string();
assert!(!serialized.contains("伪造字段"));
assert!(!serialized.contains("不得透传"));
assert!(serialized.contains("雨夜中的海边车站"));
assert!(serialized.contains("复古像素风"));
assert_eq!(
generation_inputs["references"],
json!([{
"title": "参考图",
"label": "车站参考",
"refType": "project-resource",
"refId": "editor-resource-scene-reference"
}])
);
}
}
@@ -297,6 +297,12 @@ describe('ImageCanvasWorldView', () => {
title: '角色标签图层',
assetKind: 'character',
}),
createLayer({
id: 'layer-scene',
resourceId: 'resource-scene',
title: '游戏场景',
assetKind: 'scene',
}),
createLayer({
id: 'layer-source',
resourceId: 'resource-source',
@@ -310,10 +316,12 @@ describe('ImageCanvasWorldView', () => {
const characterButton = screen.getByRole('button', {
name: '选择角色标签图层',
});
const sceneButton = screen.getByRole('button', { name: '选择游戏场景' });
const sourceButton = screen.getByRole('button', { name: '选择角色原图' });
expect(within(layerButton).getByText('未知')).toBeTruthy();
expect(within(characterButton).getByText('角色')).toBeTruthy();
expect(within(sceneButton).getByText('场景')).toBeTruthy();
expect(within(sourceButton).getByText('角色')).toBeTruthy();
});
@@ -220,6 +220,7 @@ const CANVAS_ASSET_KIND_TAG_OPTIONS: Array<{
className: 'publication-material',
},
{ value: 'ui-design', label: 'UI设计', className: 'ui-design' },
{ value: 'scene', label: '场景', className: 'scene' },
{ value: 'video', label: '视频', className: 'video' },
{ value: 'sound-effect', label: '音效', className: 'sound-effect' },
{