修正停发 model 的适用范围并补齐读写对称的服务端断言
只对已登记项目资源的图层停发 model 与 provider 缺资源行的自包含本地图片序列继续携带,避免角色动画层元数据永久丢失 补测 module 读回确实丢弃 sourceType 键,以及显式冲突的 sourceType 仍失败关闭 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -6364,11 +6364,11 @@
|
||||
## 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`。
|
||||
- 决策:被读边界脱敏或不下发的字段,一律以资源行为准,客户端不参与回写。`serializeLayer` 对**挂着项目资源行**的图层(`resourcePersistenceState === 'registered'`)不再输出 `model` / `provider`;缺资源行的自包含 legacy 本地图片序列(角色动画逐帧层等)必须继续输出——服务端 `normalize_structured_canvas_layer_against_resource` 对 `resource == None` 走早退分支,只摘 `assetKind` 就把 item 原样写回,`item_json` 是这类图层元数据的唯一存储,停发会让模型信息在下一次保存后永久丢失。`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`。
|
||||
- 验证方式:前端覆盖已登记资源的图层产物不含 `model` / `provider`、自包含本地序列仍保留并可往返,以及「序列化后去掉 sourceType → hydrate → 再序列化」仍为 `generated` 的往返不变式;api-server 覆盖读边界按 `resourceId` 回填 `sourceType`、且缺资源行的 legacy 本地序列保持自带值;spacetime-module 覆盖资源行存内部处理模型而图层带推导值时不再报错、读回时 `sourceType` 键确实被丢弃、以及显式冲突的 `sourceType` 仍失败关闭。运行 `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`。
|
||||
|
||||
## 2026-08-05 完美像素占位恢复为可删除,删除保护条款作废
|
||||
|
||||
@@ -10902,13 +10902,11 @@ 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() {
|
||||
// 中文注释:模拟 2026-07-30 之前的历史行——model 列存的是内部处理模型。owner 读边界会把
|
||||
// 它连同 provider 一起脱敏,客户端拿不到权威值,只能回写一个按来源链推导出的展示值。
|
||||
fn redacted_model_resource_row() -> EditorProjectResource {
|
||||
let now = Timestamp::from_micros_since_unix_epoch(1_000_000);
|
||||
// 中文注释:模拟 2026-07-30 之前的历史行——model 列存的是内部处理模型。owner 读边界会把
|
||||
// 它连同 provider 一起脱敏,客户端拿不到权威值,只能回写一个按来源链推导出的展示值。
|
||||
// 这种差异必须以资源行为准而不是整次保存 400。
|
||||
let resource = EditorProjectResource {
|
||||
EditorProjectResource {
|
||||
resource_id: "resource-1".to_string(),
|
||||
project_id: "project-1".to_string(),
|
||||
owner_user_id: "user-1".to_string(),
|
||||
@@ -10929,7 +10927,13 @@ mod tests {
|
||||
asset_kind: None,
|
||||
generation_inputs_json: None,
|
||||
public_showcase_enabled: true,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn structured_canvas_layer_defers_redacted_model_and_provider_to_resource_truth() {
|
||||
// 中文注释:脱敏造成的差异必须以资源行为准,而不是整次保存 400。
|
||||
let resource = redacted_model_resource_row();
|
||||
let layout = json!([{
|
||||
"layerId": "layer-1",
|
||||
"resourceId": "resource-1",
|
||||
@@ -10948,6 +10952,52 @@ mod tests {
|
||||
assert!(!parsed.layers[0].item_json.contains("provider"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn structured_canvas_layer_read_back_drops_source_type_for_resource_backed_layers() {
|
||||
// 中文注释:钉住缺陷 2 的服务端根因——校验通过后 source_type 被归还资源行、图层列置空,
|
||||
// 读回时整个 sourceType 键都不存在。api-server 读边界的回填正是建立在这个前提上;这里
|
||||
// 若被改成「None 也写空串」或去掉条件,客户端就会重新拿不到权威值。
|
||||
let resource = redacted_model_resource_row();
|
||||
let layout = json!([{
|
||||
"layerId": "layer-1",
|
||||
"resourceId": "resource-1",
|
||||
"sourceType": "generated",
|
||||
"src": "/generated/resource-1.png"
|
||||
}]);
|
||||
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("与资源行一致的 sourceType 必须放行");
|
||||
assert_eq!(parsed.layers[0].source_type, None);
|
||||
|
||||
let serialized = serialize_structured_canvas_layout(&parsed)
|
||||
.expect("structured layout should serialize");
|
||||
assert!(!serialized.contains("sourceType"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn structured_canvas_layer_still_rejects_conflicting_source_type() {
|
||||
// 中文注释:`model` / `provider` 改成以资源行为准,是因为读边界脱敏了它们、判等不可满足。
|
||||
// `sourceType` 不同——读边界会回填权威值,所以它仍然是硬约束,不得被顺手一起放宽。
|
||||
let resource = redacted_model_resource_row();
|
||||
let layout = json!([{
|
||||
"layerId": "layer-1",
|
||||
"resourceId": "resource-1",
|
||||
"sourceType": "uploaded",
|
||||
"src": "/generated/resource-1.png"
|
||||
}]);
|
||||
let mut parsed = parse_structured_canvas_layout(layout.to_string().as_str())
|
||||
.expect("resource-backed layout should parse");
|
||||
|
||||
let error = normalize_structured_canvas_layer_against_resource(
|
||||
&mut parsed.layers[0],
|
||||
Some(&resource),
|
||||
)
|
||||
.expect_err("与资源行冲突的 sourceType 必须失败关闭");
|
||||
assert!(error.contains("sourceType"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn structured_canvas_existing_local_sequence_only_allows_typed_layout_changes() {
|
||||
let layout = self_contained_local_sequence_layout();
|
||||
|
||||
@@ -372,6 +372,7 @@ describe('ImageCanvasEditorModel', () => {
|
||||
originalHeight: 768,
|
||||
zIndex: 9,
|
||||
sourceType: 'generated',
|
||||
resourcePersistenceState: 'registered',
|
||||
objectKey: 'generated/object.png',
|
||||
model: 'birefnet',
|
||||
sourceResourceId: 'resource-provider-source',
|
||||
@@ -390,8 +391,8 @@ describe('ImageCanvasEditorModel', () => {
|
||||
expect(snapshot).not.toHaveProperty('assetKind');
|
||||
expect(snapshot.assetKindOverride).toBeNull();
|
||||
expect(snapshot).not.toHaveProperty('generationInputs');
|
||||
// 服务端读边界会脱敏内部处理模型并省略 provider,客户端拿到的 model 是按来源链推导出的
|
||||
// 展示值;回写它会与资源行原值冲突并让整次结构化保存 400。
|
||||
// 有资源行时,服务端读边界会脱敏内部处理模型并省略 provider,客户端拿到的 model 是按来源
|
||||
// 链推导出的展示值;回写它会与资源行原值冲突并让整次结构化保存 400。
|
||||
expect(snapshot).not.toHaveProperty('model');
|
||||
expect(snapshot).not.toHaveProperty('provider');
|
||||
|
||||
@@ -435,6 +436,45 @@ describe('ImageCanvasEditorModel', () => {
|
||||
expect(hydrated?.generationInputs?.fields[0]?.value).toBe('骑士');
|
||||
});
|
||||
|
||||
it('keeps model metadata on self-contained local sequences that have no resource row', () => {
|
||||
// 中文注释:角色动画逐帧层用 local- 资源 id、image-sequence、无 objectKey,服务端
|
||||
// normalize 走 resource == None 早退分支,只摘 assetKind 就把 item 原样写回——item_json
|
||||
// 是这类图层元数据的唯一存储。停发 model 会让它在下一次保存后永久丢失。
|
||||
const layer: CanvasLayer = {
|
||||
id: 'layer-character-animation',
|
||||
resourceId: 'local-resource-character-animation-1',
|
||||
title: '角色动作',
|
||||
src: '/generated/sequence/frame01.png',
|
||||
x: 0,
|
||||
y: 0,
|
||||
width: 320,
|
||||
height: 240,
|
||||
originalWidth: 320,
|
||||
originalHeight: 240,
|
||||
zIndex: 1,
|
||||
sourceType: 'generated',
|
||||
mediaType: 'image-sequence',
|
||||
imageSequenceFrames: [
|
||||
{
|
||||
frameIndex: 1,
|
||||
imageSrc: '/generated/sequence/frame01.png',
|
||||
width: 320,
|
||||
height: 240,
|
||||
},
|
||||
],
|
||||
model: 'seedance2.0-fast',
|
||||
provider: 'ark',
|
||||
};
|
||||
|
||||
const snapshot = serializeLayer(layer);
|
||||
expect(snapshot.model).toBe('seedance2.0-fast');
|
||||
expect(snapshot.provider).toBe('ark');
|
||||
|
||||
const hydrated = hydrateLayer(snapshot, new Map());
|
||||
expect(hydrated?.model).toBe('seedance2.0-fast');
|
||||
expect(serializeLayer(hydrated!).model).toBe('seedance2.0-fast');
|
||||
});
|
||||
|
||||
it('keeps the resource sourceType across a structured layout round trip', () => {
|
||||
const layer: CanvasLayer = {
|
||||
id: 'layer-generated',
|
||||
|
||||
@@ -281,6 +281,26 @@ function serializeImageSequenceFrames(
|
||||
return serializedFrames.length ? serializedFrames : undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* 中文注释:`model` / `provider` 只有在图层挂着项目资源行时才交给资源行权威持有。
|
||||
*
|
||||
* 有资源行:服务端读边界会脱敏内部处理模型(`model`)并无条件省略 `provider`,客户端拿到的
|
||||
* `layer.model` 是脱敏后按来源链推导出的展示值,回写必然与资源行原值冲突,被结构化保存判为
|
||||
* 「与项目资源不一致」而整次 400。保存时服务端本就会剥离这两个字段,也不参与画布布局哈希,
|
||||
* 因此直接不发。
|
||||
*
|
||||
* 没有资源行(自包含的 legacy 本地图片序列,例如角色动画逐帧层):服务端
|
||||
* `normalize_structured_canvas_layer_against_resource` 走的是 `resource == None` 早退分支,
|
||||
* 只摘掉 `assetKind` 就把 item 原样写回,item_json 是这些元数据的**唯一**存储。此时停发会让
|
||||
* 模型信息在下一次保存后永久丢失,图片信息、ZIP 导出与快速编辑默认模型一起静默退化。
|
||||
*/
|
||||
function serializeResourceOwnedModelFields(layer: CanvasLayer) {
|
||||
if (layer.resourcePersistenceState === 'registered') {
|
||||
return {};
|
||||
}
|
||||
return { model: layer.model, provider: layer.provider };
|
||||
}
|
||||
|
||||
export function serializeLayer(layer: CanvasLayer): EditorProjectLayerSnapshot {
|
||||
return {
|
||||
layerId: layer.id,
|
||||
@@ -308,11 +328,7 @@ export function serializeLayer(layer: CanvasLayer): EditorProjectLayerSnapshot {
|
||||
),
|
||||
prompt: layer.prompt,
|
||||
actualPrompt: layer.actualPrompt,
|
||||
// 中文注释:`model` / `provider` 不回写。服务端读边界会脱敏内部处理模型(`model`)并
|
||||
// 无条件省略 `provider`,客户端因此拿不到权威值——`layer.model` 是脱敏后按来源链推导出的
|
||||
// 用户可见模型,回写它必然与资源行原值冲突,被结构化保存判为「与项目资源不一致」而整次
|
||||
// 400。这两个字段本就由资源行权威持有,保存时会被服务端剥离,也不参与画布布局哈希,
|
||||
// 与已经省略的 `src` / `imageSrc` / `assetKind` / `generationInputs` 同类。
|
||||
...serializeResourceOwnedModelFields(layer),
|
||||
taskId: layer.taskId,
|
||||
objectKey: layer.objectKey,
|
||||
assetObjectId: layer.assetObjectId,
|
||||
|
||||
Reference in New Issue
Block a user