补齐场景契约、文档与路由级测试
补全场景技术方案请求字段边界并加入文档总览 共享包出口导出场景运行时常量与类型 新增场景生成路由鉴权、必填校验与内联媒体拒绝测试 后端重建 generationInputs 字段避免透传客户端伪造内容 补齐 scene 画布素材类型标签并新增回归测试
This commit is contained in:
@@ -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` 持久化,画布图层右上角显示“场景”标签,不降级为“未知”。
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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' },
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user