修复画布素材删除级联

保留素材库 sourceResourceId 并写入画布图层

删除素材时用 sourceResourceId 匹配项目资源图层

补充级联删除持久化回归测试和排障记录
This commit is contained in:
2026-06-24 14:58:09 +08:00
parent 473eb18bc3
commit a7c0b1e771
6 changed files with 206 additions and 13 deletions
@@ -15,6 +15,14 @@
- 关联:相关文件、文档、提交或 Issue
```
## 图片画布素材库删除要匹配 sourceResourceId
- 现象:素材库中删除了已经生成并进入素材库的资源,但画布上对应图层仍然存在,刷新后还可能从已保存布局里恢复。
- 原因:生成素材进入账号级素材库时可能通过 `editor_asset.sourceResourceId` 指向原项目资源;如果前端素材库映射和级联删除只比较 `sourceAssetId``assetObjectId``objectKey``src`,就会漏掉只靠项目资源 ID 关联的历史 / 后端生成图层。
- 处理:`EditorAsset` 必须保留 `sourceResourceId`;从素材库添加到画布时继续写入图层;删除素材时同时比较 `layer.resourceId` / `layer.sourceResourceId``asset.sourceResourceId`
- 验证:`ImageCanvasEditorModel.test.ts` 覆盖素材库 source resource 保留,`useImageCanvasAssetCanvasBridge.test.tsx` 覆盖资源 ID 级联清理,`ImageCanvasEditorAssetsIntegration.test.tsx` 覆盖删除后保存的新 layout 不再包含被删图层。
- 关联:`src/components/image-editor/ImageCanvasEditorModel.ts``src/components/image-editor/useImageCanvasAssetCanvasBridge.ts``src/components/image-editor/ImageCanvasEditorAssetsIntegration.test.tsx`
## 编辑器生成按钮显示泥点后仍要查真实钱包预扣
- 现象:画板生成按钮显示 `N泥点`,后端也能按模型配置计算出价格,但用户点击后钱包余额不变。
@@ -77,7 +77,13 @@ vi.mock('../../services/assetReadUrlService', async () => {
);
return {
...actual,
getSignedAssetReadUrl: getSignedAssetReadUrlMock,
getSignedAssetReadUrl: (
...args: Parameters<typeof actual.getSignedAssetReadUrl>
) =>
getSignedAssetReadUrlMock(...args) ??
Promise.resolve(
`https://signed.example.com/${args[0]?.objectKey ?? 'asset'}`,
),
};
});
@@ -123,21 +129,36 @@ function createSizedPngFile(name: string, width: number, height: number) {
function createTestImageAsset({
assetId,
label,
imageSrc,
objectKey,
assetObjectId,
sourceType = 'uploaded',
sourceResourceId,
}: {
assetId: string;
label: string;
imageSrc?: string;
objectKey?: string | null;
assetObjectId?: string | null;
sourceType?: 'uploaded' | 'generated' | 'mock_generated';
sourceResourceId?: string | null;
}) {
const objectKey = `generated-character-drafts/editor/asset-library/image/${assetId}.png`;
const resolvedObjectKey =
objectKey === undefined
? `generated-character-drafts/editor/asset-library/image/${assetId}.png`
: objectKey;
return {
assetId,
folderId: 'project',
label,
imageSrc: `/${objectKey}`,
objectKey,
assetObjectId: `assetobj-${assetId}`,
imageSrc: imageSrc ?? (resolvedObjectKey ? `/${resolvedObjectKey}` : ''),
objectKey: resolvedObjectKey,
assetObjectId:
assetObjectId === undefined ? `assetobj-${assetId}` : assetObjectId,
width: 320,
height: 240,
sourceType: 'uploaded' as const,
sourceType,
sourceResourceId,
};
}
@@ -787,6 +808,84 @@ describe('ImageCanvasEditorView asset library integration', () => {
expect(deleteEditorAssetMock).toHaveBeenCalledWith('asset-b');
});
it('saves the canvas layout after deleting an asset linked by source resource id', async () => {
const user = userEvent.setup();
loadOrCreateRecentEditorProjectMock.mockResolvedValueOnce({
projectId: 'editor-project-default',
title: '生成资源项目',
viewport: { x: 0, y: 0, scale: 1 },
layers: [
{
layerId: 'layer-generated-a',
resourceId: 'resource-generated-a',
title: '生成资源A',
x: 100,
y: 120,
width: 320,
height: 240,
originalWidth: 320,
originalHeight: 240,
zIndex: 1,
sourceType: 'generated',
},
],
resources: [
{
resourceId: 'resource-generated-a',
projectId: 'editor-project-default',
imageSrc: '/editor-test/project/generated-a.png',
width: 320,
height: 240,
sourceType: 'generated',
},
],
updatedAt: '2026-06-12T00:00:00.000Z',
});
loadEditorAssetLibraryMock.mockResolvedValueOnce({
folders: [
{
folderId: 'project',
label: '项目素材',
sortOrder: 0,
collapsed: false,
systemDefault: true,
},
],
assets: [
createTestImageAsset({
assetId: 'asset-generated-a',
label: '生成资源A',
imageSrc: '/editor-test/asset-library/copied-a.png',
objectKey: null,
assetObjectId: null,
sourceType: 'generated',
sourceResourceId: 'resource-generated-a',
}),
],
});
render(<ImageCanvasEditorView />);
expect(await screen.findByAltText('画布图片:生成资源A')).toBeTruthy();
await user.click(screen.getByRole('button', { name: '删除素材生成资源A' }));
await waitFor(() => {
expect(screen.queryByAltText('画布图片:生成资源A')).toBeNull();
});
await waitFor(() => {
const savedLayoutWithoutDeletedLayer = saveEditorProjectLayoutMock.mock.calls
.map((call) => call[1])
.find((layout) =>
layout.layers.every(
(item: { layerId?: unknown }) =>
item.layerId !== 'layer-generated-a',
),
);
expect(savedLayoutWithoutDeletedLayer).toBeTruthy();
});
expect(deleteEditorAssetMock).toHaveBeenCalledWith('asset-generated-a');
});
it('saves a library asset layer right after creating its canvas resource', async () => {
const user = userEvent.setup();
createEditorProjectResourceMock.mockResolvedValueOnce({
@@ -139,6 +139,7 @@ describe('ImageCanvasEditorModel', () => {
persisted: true,
objectKey: 'oss/asset-1.png',
assetObjectId: 'object-1',
sourceResourceId: 'resource-source-1',
};
const layer = createLayerFromAsset(
@@ -158,11 +159,43 @@ describe('ImageCanvasEditorModel', () => {
objectKey: 'oss/asset-1.png',
assetObjectId: 'object-1',
sourceAssetId: 'asset-1',
sourceResourceId: 'resource-source-1',
});
expect(layer.x).toBe(-18);
expect(layer.y).toBe(12);
});
it('preserves source resource ids from the persisted asset library', () => {
const library = normalizeAssetLibrary({
folders: [
{
folderId: 'project',
label: '项目素材',
sortOrder: 0,
collapsed: false,
systemDefault: true,
},
],
assets: [
{
assetId: 'asset-generated',
folderId: 'project',
label: '生成素材',
imageSrc: '/generated-character-drafts/editor/asset-library/generated.png',
width: 640,
height: 640,
sourceType: 'generated',
sourceResourceId: 'resource-generated',
},
],
});
expect(library.assets[0]).toMatchObject({
id: 'asset-generated',
sourceResourceId: 'resource-generated',
});
});
it('serializes and hydrates canvas layer metadata without embedding image payloads', () => {
const layer: CanvasLayer = {
id: 'layer-generated',
@@ -179,6 +179,7 @@ export function createLayerFromAsset(
taskId: asset.taskId,
objectKey: asset.objectKey,
assetObjectId: asset.assetObjectId,
sourceResourceId: asset.sourceResourceId,
durationSeconds: asset.durationSeconds,
sourceAssetId: asset.id,
assetKind: asset.assetKind ?? assetKind,
@@ -709,6 +710,7 @@ export function mapAssetLibrarySnapshot(
taskId: asset.taskId ?? undefined,
objectKey: asset.objectKey ?? undefined,
assetObjectId: asset.assetObjectId ?? undefined,
sourceResourceId: asset.sourceResourceId ?? undefined,
assetKind: canvasAssetKindOrNull(asset.assetKind),
generationInputs: generationInputsOrNull(asset.generationInputs),
durationSeconds: asset.durationSeconds ?? undefined,
@@ -970,11 +972,21 @@ export function escapeCssIdentifier(value: string) {
}
export function isLayerLinkedToAsset(layer: CanvasLayer, asset: EditorAsset) {
const assetSourceResourceId = asset.sourceResourceId?.trim();
const assetObjectKey = asset.objectKey?.trim().replace(/^\/+/u, '');
const layerObjectKey = layer.objectKey?.trim().replace(/^\/+/u, '');
const assetSrc = asset.src.trim().replace(/^\/+/u, '');
const layerSrc = layer.src.trim().replace(/^\/+/u, '');
return (
layer.sourceAssetId === asset.id ||
Boolean(
assetSourceResourceId &&
(layer.resourceId === assetSourceResourceId ||
layer.sourceResourceId === assetSourceResourceId),
) ||
Boolean(asset.assetObjectId && layer.assetObjectId === asset.assetObjectId) ||
Boolean(asset.objectKey && layer.objectKey === asset.objectKey) ||
layer.src === asset.src
Boolean(assetObjectKey && layerObjectKey === assetObjectKey) ||
Boolean(assetSrc && layerSrc === assetSrc)
);
}
@@ -47,6 +47,7 @@ export type EditorAsset = {
taskId?: string;
objectKey?: string;
assetObjectId?: string;
sourceResourceId?: string | null;
assetKind?: CanvasAssetKind | null;
generationInputs?: CanvasGenerationInputs | null;
durationSeconds?: number;
@@ -148,10 +148,7 @@ function AssetCanvasBridgeHarness({
function AssetCleanupHarness({
deletedAssets = [],
}: {
deletedAssets?: EditorAsset[];
}) {
const [layers, setLayers] = useState([
initialLayers = [
createLayer({ id: 'linked', sourceAssetId: 'asset-1' }),
createLayer({
id: 'kept',
@@ -159,7 +156,12 @@ function AssetCleanupHarness({
src: 'data:image/png;base64,asset-2',
sourceAssetId: 'asset-2',
}),
]);
],
}: {
deletedAssets?: EditorAsset[];
initialLayers?: CanvasLayer[];
}) {
const [layers, setLayers] = useState(initialLayers);
const [selectedLayerId, setSelectedLayerId] = useState<string | null>('linked');
const [selectedLayerIds, setSelectedLayerIds] = useState(['linked', 'kept']);
const cleanup = useImageCanvasAssetLayerCleanup({
@@ -249,4 +251,42 @@ describe('useImageCanvasAssetCanvasBridge', () => {
expect(screen.getByTestId('selected').textContent).toBe('-');
expect(screen.getByTestId('selected-many').textContent).toBe('kept');
});
it('removes canvas layers linked by the asset source resource id', () => {
render(
<AssetCleanupHarness
deletedAssets={[
{
...defaultAsset,
id: 'asset-generated',
src: '/generated-character-drafts/editor/asset-library/copied.png',
assetObjectId: undefined,
sourceResourceId: 'resource-generated',
},
]}
initialLayers={[
createLayer({
id: 'linked-by-resource',
resourceId: 'resource-generated',
src: '/generated-character-drafts/editor/project/generated.png',
assetObjectId: undefined,
sourceAssetId: null,
}),
createLayer({
id: 'kept',
resourceId: 'resource-kept',
src: '/generated-character-drafts/editor/project/kept.png',
assetObjectId: undefined,
sourceAssetId: null,
}),
]}
/>,
);
act(() => {
screen.getByRole('button', { name: '清理素材' }).click();
});
expect(screen.getByTestId('layers').textContent).toBe('kept');
});
});