修复画布布局元数据读写不对称导致的保存阻断
读边界脱敏的 model 与 provider 不再由客户端回写,结构化保存改以资源行为准 读边界按图层声明的 resourceId 回填 sourceType,hydrate 缺键时回落资源值 补齐前后端定向测试覆盖两条往返不变式,并记录读写对称不变式 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -6360,3 +6360,13 @@
|
||||
- telemetry 只扫描可见 DOM 文本和 AST 可达的 JavaScript:hidden DOM、字符串/注释、恒假分支、未调用函数和 inert/raw-text 内容不得补齐状态字段;已链接 classic/module 单元沿同一可达扫描口径判定。玩法 identity 保留独立的现有识别口径,不能反向补齐 telemetry。
|
||||
- CSS `url(...)` 的资产路径保持原始大小写解析,stylesheet 证据必须同时命中实际可见元素;未命中 selector、元素自身或祖先 hidden、以及匹配隐藏规则的节点均不作证。
|
||||
- 关联:`apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol/autonomous_completion.rs`、`docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md`。
|
||||
|
||||
## 2026-08-05 画布图层元数据以资源行为准,读边界补齐 sourceType
|
||||
|
||||
- 背景:结构化画布保存要求图层布局项里的资源权威字段与 `editor_project_resource` 行逐字相等,否则整次 PATCH 报「与项目资源不一致」,而该 400 属于 non-retryable,会被前端保存队列静默吞掉。但读边界并不把这些值原样下发:`sanitize_editor_user_model` 会脱敏内部处理模型、`provider` 被无条件省略(见 2026-07-31 修正抠图内部元数据的普通用户读取边界),`sourceType` 则在结构化保存校验通过后被归还资源行、图层列置空,读回时整个键不存在。客户端拿不到权威值只能自己补——`resolveHydratedLayerModel` 沿来源链推导出展示用生图模型,`hydrateLayer` 把缺失的 `sourceType` 猜成 `uploaded`——再原样回写,判等于是必然失败。前者命中含 2026-07-30 之前抠图派生资源的画布,后者命中所有 generated 图层;两者都在项目重新加载后的首次保存触发,用户侧表现为「改动悄悄没保存」,完美像素因为提交前是严格保存才把服务端原文暴露出来。
|
||||
- 决策:被读边界脱敏或不下发的字段,一律以资源行为准,客户端不参与回写。`serializeLayer` 不再输出 `model` / `provider`;`normalize_structured_canvas_layer_against_resource` 对这两个字段改为直接丢弃而不判等——它们属于纯丢弃字段,判等通过与否都不写回资源行(区别于会合并回资源的 `assetKind` / `generationInputs`),放宽不影响任何持久化状态。`sourceType` 属于意外丢失而非有意脱敏,改为在读边界按图层自己声明的 `resourceId` 回填权威值,口径与既有 `objectKey` / `assetObjectId` 一致;客户端 `hydrateLayer` 同时把缺键回落到资源值作为兜底,不再猜 `uploaded`。
|
||||
- 不变式:凡是 owner 读边界会脱敏或省略的图层字段,写边界不得对其判等;凡是写边界要判等的图层字段,读边界必须原样下发或可由资源行回填。改动任一侧时必须同时检查另一侧,只改一侧即构成本条缺陷的复发。
|
||||
- 影响范围:`src/components/image-editor/ImageCanvasEditorModel.ts` 的 `serializeLayer` 与 `hydrateLayer`、`server-rs/crates/api-server/src/editor_project.rs` 的 `EditorPayloadMediaReference` 与 `sanitize_editor_payload_media_value`、`server-rs/crates/spacetime-module/src/editor_project_storage.rs` 的 `normalize_structured_canvas_layer_against_resource`。不修改 SpacetimeDB schema、迁移或绑定,不改动历史数据,不改变对外契约。
|
||||
- 遗留:历史资源行的 `model` 列仍存有 2026-07-30 之前写入的内部处理模型,读边界继续脱敏它。把该列回填为源生图模型、原值移入 `generationInputs.mattingModel`,并据此删掉两侧的脱敏与推导逻辑,另行排期,不在本次范围。
|
||||
- 验证方式:前端覆盖 `serializeLayer` 产物不含 `model` / `provider`,以及「序列化后去掉 sourceType → hydrate → 再序列化」仍为 `generated` 的往返不变式;api-server 覆盖读边界按 `resourceId` 回填 `sourceType`、且缺资源行的 legacy 本地序列保持自带值;spacetime-module 覆盖资源行存内部处理模型而图层带推导值时不再报错。运行 `npx vitest run src/components/image-editor`、`cargo test -p api-server --manifest-path server-rs/Cargo.toml editor_project::`、`cargo check -p spacetime-module --manifest-path server-rs/Cargo.toml --all-targets`、`npm run typecheck`、`npm run check:encoding`、`npm run check:rustfmt`。spacetime-module 的单测二进制在 Windows 本机链接失败(缺 SpacetimeDB 宿主符号),本机只能做到 `cargo check --all-targets`。
|
||||
- 关联文档:`docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md`。
|
||||
|
||||
@@ -8338,6 +8338,7 @@ struct EditorPayloadMediaReference {
|
||||
object_key: Option<String>,
|
||||
asset_object_id: Option<String>,
|
||||
asset_kind: Option<String>,
|
||||
source_type: Option<String>,
|
||||
}
|
||||
|
||||
fn sanitize_editor_payload_media(
|
||||
@@ -8355,6 +8356,7 @@ fn sanitize_editor_payload_media(
|
||||
object_key: resource.object_key.clone(),
|
||||
asset_object_id: resource.asset_object_id.clone(),
|
||||
asset_kind: resource.asset_kind.clone(),
|
||||
source_type: normalize_optional_string(Some(resource.source_type.clone())),
|
||||
},
|
||||
)
|
||||
})
|
||||
@@ -8406,6 +8408,21 @@ fn sanitize_editor_payload_media_value(
|
||||
{
|
||||
object.remove("model");
|
||||
}
|
||||
// 中文注释:结构化保存校验完 sourceType 后会把图层列置空(资源行才是权威),
|
||||
// 于是布局读回时整个键都不存在,客户端只能猜一个默认值再原样回写,下一次保存
|
||||
// 就被判成「sourceType 与项目资源不一致」。这里按图层自己声明的 resourceId
|
||||
// 回填权威值,口径与上面的 objectKey / assetObjectId 一致;缺资源行的
|
||||
// legacy 本地序列取不到映射,保持它自带的值不动。
|
||||
let resource_source_type = object
|
||||
.get("resourceId")
|
||||
.and_then(Value::as_str)
|
||||
.and_then(|resource_id| resource_media.get(resource_id))
|
||||
.and_then(|media| media.source_type.clone());
|
||||
fill_missing_media_identity_field(
|
||||
object,
|
||||
"sourceType",
|
||||
resource_source_type.as_ref(),
|
||||
);
|
||||
if let Some(generation_inputs) = object.get_mut("generationInputs") {
|
||||
let sanitized =
|
||||
sanitize_editor_reserved_generation_inputs(generation_inputs.take());
|
||||
@@ -12044,6 +12061,62 @@ mod tests {
|
||||
assert_eq!(sanitized[1]["dialog"]["provider"], json!("dialog-provider"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn editor_payload_sanitizer_refills_layer_source_type_from_resource() {
|
||||
// 中文注释:结构化保存校验完 sourceType 后会把图层列置空,布局读回时整个键都不存在,
|
||||
// 客户端只能猜一个默认值再原样回写,下一次保存就被判成「sourceType 与项目资源不一致」。
|
||||
// 读边界必须按图层自己声明的 resourceId 回填权威值,口径与 objectKey / assetObjectId 一致。
|
||||
let resources = vec![EditorProjectResourcePayload {
|
||||
resource_id: "resource-1".to_string(),
|
||||
showcase_id: None,
|
||||
asset_id: None,
|
||||
label: None,
|
||||
project_id: "project-1".to_string(),
|
||||
owner_user_id: "user-1".to_string(),
|
||||
author_display_name: None,
|
||||
author_public_user_code: None,
|
||||
image_src: "/generated-character-drafts/editor/spec.png".to_string(),
|
||||
object_key: Some("generated-character-drafts/editor/spec.png".to_string()),
|
||||
asset_object_id: Some("asset-object-1".to_string()),
|
||||
width: 512,
|
||||
height: 512,
|
||||
source_type: "generated".to_string(),
|
||||
prompt: None,
|
||||
actual_prompt: None,
|
||||
model: None,
|
||||
provider: None,
|
||||
task_id: None,
|
||||
source_resource_id: None,
|
||||
asset_kind: Some("spec".to_string()),
|
||||
showcase_category: None,
|
||||
generation_inputs: None,
|
||||
public_showcase_enabled: true,
|
||||
review_status: None,
|
||||
display_enabled: None,
|
||||
like_count: None,
|
||||
generation_cost_mud_points: 0,
|
||||
refund_mud_points: None,
|
||||
created_at: "2026-06-23T00:00:00.000Z".to_string(),
|
||||
updated_at: "2026-06-23T00:00:00.000Z".to_string(),
|
||||
}];
|
||||
|
||||
let sanitized = sanitize_editor_payload_media(
|
||||
json!([
|
||||
{ "layerId": "layer-1", "resourceId": "resource-1" },
|
||||
{
|
||||
"layerId": "layer-local",
|
||||
"resourceId": "local-resource-sequence",
|
||||
"sourceType": "generated"
|
||||
}
|
||||
]),
|
||||
resources.as_slice(),
|
||||
);
|
||||
|
||||
assert_eq!(sanitized[0]["sourceType"], json!("generated"));
|
||||
// 缺资源行的 legacy 本地序列取不到映射,保持它自带的值不动。
|
||||
assert_eq!(sanitized[1]["sourceType"], json!("generated"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn editor_canvas_generation_completion_saves_after_sanitizing_legacy_inline_layers() {
|
||||
let existing_resources = vec![EditorProjectResourcePayload {
|
||||
|
||||
@@ -7105,18 +7105,13 @@ fn normalize_structured_canvas_layer_against_resource(
|
||||
resource.actual_prompt.as_deref(),
|
||||
layer.layer_id.as_str(),
|
||||
)?;
|
||||
take_matching_optional_resource_string(
|
||||
&mut item,
|
||||
"model",
|
||||
resource.model.as_deref(),
|
||||
layer.layer_id.as_str(),
|
||||
)?;
|
||||
take_matching_optional_resource_string(
|
||||
&mut item,
|
||||
"provider",
|
||||
resource.provider.as_deref(),
|
||||
layer.layer_id.as_str(),
|
||||
)?;
|
||||
// 中文注释:`model` / `provider` 不做判等,一律以资源行为准。owner 读边界会脱敏内部处理
|
||||
// 模型(`model`)并无条件省略 `provider`,客户端因此拿不到权威值,这条判等对正常路径是
|
||||
// 不可满足的——留着只会让「读到什么就回写什么」的客户端整次保存 400。两者都属于纯丢弃
|
||||
// 字段,判等通过与否都不写回资源行(不同于会合并回资源的 assetKind / generationInputs),
|
||||
// 因此丢弃客户端值不影响任何持久化状态。
|
||||
item.remove("model");
|
||||
item.remove("provider");
|
||||
take_matching_optional_resource_string(
|
||||
&mut item,
|
||||
"taskId",
|
||||
@@ -10907,6 +10902,52 @@ mod tests {
|
||||
.expect("legacy self-reference should also be ignored when resource truth is empty");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn structured_canvas_layer_defers_redacted_model_and_provider_to_resource_truth() {
|
||||
let now = Timestamp::from_micros_since_unix_epoch(1_000_000);
|
||||
// 中文注释:模拟 2026-07-30 之前的历史行——model 列存的是内部处理模型。owner 读边界会把
|
||||
// 它连同 provider 一起脱敏,客户端拿不到权威值,只能回写一个按来源链推导出的展示值。
|
||||
// 这种差异必须以资源行为准而不是整次保存 400。
|
||||
let resource = EditorProjectResource {
|
||||
resource_id: "resource-1".to_string(),
|
||||
project_id: "project-1".to_string(),
|
||||
owner_user_id: "user-1".to_string(),
|
||||
asset_object_id: None,
|
||||
image_src: "/generated/resource-1.png".to_string(),
|
||||
object_key: Some("generated/resource-1.png".to_string()),
|
||||
width: 512,
|
||||
height: 512,
|
||||
source_type: "generated".to_string(),
|
||||
prompt: None,
|
||||
actual_prompt: None,
|
||||
model: Some("BgFilter complex".to_string()),
|
||||
provider: Some("BgFilter".to_string()),
|
||||
task_id: None,
|
||||
source_resource_id: None,
|
||||
created_at: now,
|
||||
updated_at: now,
|
||||
asset_kind: None,
|
||||
generation_inputs_json: None,
|
||||
public_showcase_enabled: true,
|
||||
};
|
||||
let layout = json!([{
|
||||
"layerId": "layer-1",
|
||||
"resourceId": "resource-1",
|
||||
"sourceType": "generated",
|
||||
"src": "/generated/resource-1.png",
|
||||
"model": "gpt-image-2",
|
||||
"provider": "VectorEngine"
|
||||
}]);
|
||||
let mut parsed = parse_structured_canvas_layout(layout.to_string().as_str())
|
||||
.expect("resource-backed layout should parse");
|
||||
|
||||
normalize_structured_canvas_layer_against_resource(&mut parsed.layers[0], Some(&resource))
|
||||
.expect("被读边界脱敏的字段不得阻断结构化保存");
|
||||
|
||||
assert!(!parsed.layers[0].item_json.contains("model"));
|
||||
assert!(!parsed.layers[0].item_json.contains("provider"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn structured_canvas_existing_local_sequence_only_allows_typed_layout_changes() {
|
||||
let layout = self_contained_local_sequence_layout();
|
||||
@@ -11225,18 +11266,20 @@ mod tests {
|
||||
json!({ "customExtension": true })
|
||||
);
|
||||
|
||||
let mut conflicting = json!({ "provider": "legacy-provider" })
|
||||
// 中文注释:改用 taskId 举例。model / provider 已改为以资源行为准(读边界会脱敏它们,
|
||||
// 客户端拿不到权威值,判等对正常路径不可满足),仍然判等的是这一类未脱敏字段。
|
||||
let mut conflicting = json!({ "taskId": "legacy-task" })
|
||||
.as_object()
|
||||
.expect("metadata object")
|
||||
.clone();
|
||||
let error = take_matching_optional_resource_string(
|
||||
&mut conflicting,
|
||||
"provider",
|
||||
Some("vector-engine"),
|
||||
"taskId",
|
||||
Some("task-1"),
|
||||
"layer-1",
|
||||
)
|
||||
.expect_err("conflicting provider must fail closed");
|
||||
assert!(error.contains("provider"));
|
||||
.expect_err("conflicting taskId must fail closed");
|
||||
assert!(error.contains("taskId"));
|
||||
|
||||
let mut generation_inputs = Some(r#"{ "style": "clay", "seed": 7 }"#.to_string());
|
||||
merge_optional_resource_json_metadata(
|
||||
|
||||
@@ -390,6 +390,10 @@ describe('ImageCanvasEditorModel', () => {
|
||||
expect(snapshot).not.toHaveProperty('assetKind');
|
||||
expect(snapshot.assetKindOverride).toBeNull();
|
||||
expect(snapshot).not.toHaveProperty('generationInputs');
|
||||
// 服务端读边界会脱敏内部处理模型并省略 provider,客户端拿到的 model 是按来源链推导出的
|
||||
// 展示值;回写它会与资源行原值冲突并让整次结构化保存 400。
|
||||
expect(snapshot).not.toHaveProperty('model');
|
||||
expect(snapshot).not.toHaveProperty('provider');
|
||||
|
||||
const hydrated = hydrateLayer(
|
||||
snapshot,
|
||||
@@ -431,6 +435,38 @@ describe('ImageCanvasEditorModel', () => {
|
||||
expect(hydrated?.generationInputs?.fields[0]?.value).toBe('骑士');
|
||||
});
|
||||
|
||||
it('keeps the resource sourceType across a structured layout round trip', () => {
|
||||
const layer: CanvasLayer = {
|
||||
id: 'layer-generated',
|
||||
resourceId: 'resource-generated',
|
||||
title: '生成图',
|
||||
src: '/read/generated.png',
|
||||
x: 0,
|
||||
y: 0,
|
||||
width: 512,
|
||||
height: 512,
|
||||
originalWidth: 512,
|
||||
originalHeight: 512,
|
||||
zIndex: 1,
|
||||
sourceType: 'generated',
|
||||
};
|
||||
const resources = new Map([
|
||||
[
|
||||
'resource-generated',
|
||||
{ imageSrc: '/read/generated.png', sourceType: 'generated' },
|
||||
],
|
||||
]);
|
||||
|
||||
// 结构化保存会把校验通过的 sourceType 归还资源行并把图层列置空,读回的布局项没有这个键。
|
||||
const { sourceType: _omitted, ...storedSnapshot } = serializeLayer(layer);
|
||||
expect(storedSnapshot).not.toHaveProperty('sourceType');
|
||||
|
||||
const hydrated = hydrateLayer(storedSnapshot, resources);
|
||||
expect(hydrated?.sourceType).toBe('generated');
|
||||
// 再次保存必须仍然是 generated,否则服务端会判成「sourceType 与项目资源不一致」。
|
||||
expect(serializeLayer(hydrated!).sourceType).toBe('generated');
|
||||
});
|
||||
|
||||
it('distinguishes persisted self-contained sequences from unresolved local resources', () => {
|
||||
const validSequence = {
|
||||
layerId: 'layer-sequence',
|
||||
|
||||
@@ -308,8 +308,11 @@ export function serializeLayer(layer: CanvasLayer): EditorProjectLayerSnapshot {
|
||||
),
|
||||
prompt: layer.prompt,
|
||||
actualPrompt: layer.actualPrompt,
|
||||
model: layer.model,
|
||||
provider: layer.provider,
|
||||
// 中文注释:`model` / `provider` 不回写。服务端读边界会脱敏内部处理模型(`model`)并
|
||||
// 无条件省略 `provider`,客户端因此拿不到权威值——`layer.model` 是脱敏后按来源链推导出的
|
||||
// 用户可见模型,回写它必然与资源行原值冲突,被结构化保存判为「与项目资源不一致」而整次
|
||||
// 400。这两个字段本就由资源行权威持有,保存时会被服务端剥离,也不参与画布布局哈希,
|
||||
// 与已经省略的 `src` / `imageSrc` / `assetKind` / `generationInputs` 同类。
|
||||
taskId: layer.taskId,
|
||||
objectKey: layer.objectKey,
|
||||
assetObjectId: layer.assetObjectId,
|
||||
@@ -1281,6 +1284,15 @@ export function hydrateLayer(
|
||||
: canvasAssetKindOrNull(snapshot.assetKind);
|
||||
const isSelfContainedLocalResource =
|
||||
!resource && isSelfContainedLegacyLocalImageSequence(snapshot);
|
||||
// 中文注释:结构化保存会把校验通过的 sourceType 归还给资源行并把图层列置空,读回时布局项
|
||||
// 里根本没有这个键。此处必须回落资源值——猜 'uploaded' 会让 generated 图层在下一次保存时
|
||||
// 被判为「sourceType 与项目资源不一致」,整块画布再也存不上。
|
||||
const resourceSourceType = resource?.sourceType;
|
||||
const hydratedLayerSourceType = isCanvasSourceType(snapshot.sourceType)
|
||||
? snapshot.sourceType
|
||||
: isCanvasSourceType(resourceSourceType)
|
||||
? resourceSourceType
|
||||
: 'uploaded';
|
||||
|
||||
return {
|
||||
id: layerId,
|
||||
@@ -1309,9 +1321,7 @@ export function hydrateLayer(
|
||||
};
|
||||
})(),
|
||||
zIndex: numberFromSnapshot(snapshot.zIndex, 1),
|
||||
sourceType: isCanvasSourceType(snapshot.sourceType)
|
||||
? snapshot.sourceType
|
||||
: 'uploaded',
|
||||
sourceType: hydratedLayerSourceType,
|
||||
mediaType: resolveHydratedLayerMediaType(snapshot, imageSequenceFrames),
|
||||
thumbnailSrc: stringOrNull(snapshot.thumbnailSrc),
|
||||
imageSequenceFrames: imageSequenceFrames.length
|
||||
|
||||
Reference in New Issue
Block a user