diff --git a/lib/__tests__/task-detail-asset-edit-batch.test.ts b/lib/__tests__/task-detail-asset-edit-batch.test.ts index 58fbe7f..e65bbfb 100644 --- a/lib/__tests__/task-detail-asset-edit-batch.test.ts +++ b/lib/__tests__/task-detail-asset-edit-batch.test.ts @@ -83,6 +83,7 @@ import { ServerTrustedTaskDetailStagedFile, TaskDetailAssetEditError, batchEditTaskDetailImages, + replaceTaskDetailImage, } from '@/lib/task-detail-asset-edit' import { cleanupConsumedUploadSession } from '@/lib/temp-cleanup' import { getOwnedUploadSession, getUploadedFile } from '@/lib/upload-sessions' @@ -210,6 +211,34 @@ describe('task-detail asset edit batch data-plane', () => { return { filePath, size: buffer.length, buffer } } + async function createStripedLongImage(fileName: string) { + const width = 1200 + const heights = [1500, 1500, 1451] + const colors = [ + { r: 230, g: 20, b: 20 }, + { r: 20, g: 210, b: 20 }, + { r: 20, g: 20, b: 230 }, + ] + const bands = await Promise.all(heights.map((height, index) => sharp({ + create: { width, height, channels: 3, background: colors[index] }, + }).png().toBuffer())) + const buffer = await sharp({ + create: { + width, + height: heights.reduce((sum, height) => sum + height, 0), + channels: 3, + background: { r: 0, g: 0, b: 0 }, + }, + }).composite([ + { input: bands[0], top: 0, left: 0 }, + { input: bands[1], top: heights[0], left: 0 }, + { input: bands[2], top: heights[0] + heights[1], left: 0 }, + ]).png().toBuffer() + const filePath = path.join(tempRoot, fileName) + await writeFile(filePath, buffer) + return { filePath, size: buffer.length, heights, colors } + } + function configureUploadSources(files: Record) { mockGetOwnedUploadSession.mockImplementation(async (uploadId: string) => { const item = files[uploadId] @@ -713,6 +742,190 @@ describe('task-detail asset edit batch data-plane', () => { } }) + test.each([ + { targetIndex: 0, expectedNames: ['long.png', 'long__bottom.png'] }, + { targetIndex: 1, expectedNames: ['long__top.png', 'long.png', 'long__bottom.png'] }, + { targetIndex: 2, expectedNames: ['long__top.png', 'long.png'] }, + ])('详情长图精确替换第 $targetIndex 张切片并保留其它原图区段', async ({ targetIndex, expectedNames }) => { + const long = await createStripedLongImage('long.png') + const previews = await Promise.all(long.heights.map((height, index) => + createSolidImage(`legacy-${index}.webp`, 1200, height, 'webp'))) + const replacement = await createSolidImage(`replacement-${targetIndex}.png`, 900, 600) + configureUploadSources({ + replacement: { path: replacement.filePath, size: replacement.size, fileName: `replacement-${targetIndex}.png` }, + }) + configureAssetQueries({ + previewRows: previews.map((preview, index) => ({ + id: `slice-${index}`, + assetType: 'detail-preview', + fileUrl: preview.filePath, + fileName: `detail-preview-1788790000000-0-slice${index}-legacy.webp`, + sortOrder: index, + })), + zipRows: [{ id: 'zip-detail', assetType: 'detail', fileUrl: path.join(tempRoot, 'detail.zip'), fileName: 'detail.zip' }], + }) + mockListZipEntries.mockResolvedValue([{ name: 'long.png' }]) + mockExtractZipEntries.mockResolvedValue([{ entryName: 'long.png', filePath: long.filePath, size: long.size }]) + + let capturedNames: string[] = [] + let capturedFragments: Array<{ name: string; width: number; height: number; pixel: number[] }> = [] + mockCreateZipFromFiles.mockImplementationOnce(async (entries: Array<{ path: string; name: string }>) => { + capturedNames = entries.map((entry) => entry.name) + capturedFragments = await Promise.all(entries + .filter((entry) => entry.name.includes('__top') || entry.name.includes('__bottom')) + .map(async (entry) => { + const metadata = await sharp(entry.path).metadata() + const pixel = await sharp(entry.path).extract({ left: 0, top: 0, width: 1, height: 1 }).raw().toBuffer() + return { + name: entry.name, + width: Number(metadata.width), + height: Number(metadata.height), + pixel: Array.from(pixel.slice(0, 3)), + } + })) + return { filePath: 'uploads/materials/group.zip', fileName: 'group.zip', fileSize: 123 } + }) + + const result = await batchEditTaskDetailImages({ + materialId: 'material-1', + operatorUserId: 'operator-1', + operations: [{ kind: 'replace', assetId: `slice-${targetIndex}`, uploadId: 'replacement' }], + }) + + expect(result.entryCount).toBe(expectedNames.length) + expect(capturedNames).toEqual(expectedNames) + expect((prisma.materialAsset.createMany as jest.Mock).mock.calls[0][0].data).toHaveLength(3) + if (targetIndex === 1) { + expect(capturedFragments).toEqual([ + expect.objectContaining({ name: 'long__top.png', width: 1200, height: 1500 }), + expect.objectContaining({ name: 'long__bottom.png', width: 1200, height: 1451 }), + ]) + expect(capturedFragments[0].pixel[0]).toBeGreaterThan(200) + expect(capturedFragments[0].pixel[1]).toBeLessThan(40) + expect(capturedFragments[1].pixel[2]).toBeGreaterThan(200) + expect(capturedFragments[1].pixel[0]).toBeLessThan(40) + } + }) + + test('同一详情长图可在一次批量中精确替换多张不同切片', async () => { + const long = await createStripedLongImage('multi-replace-long.png') + const previews = await Promise.all(long.heights.map((height, index) => + createSolidImage(`multi-replace-${index}.webp`, 1200, height, 'webp'))) + const first = await createSolidImage('replace-first.png', 700, 500) + const last = await createSolidImage('replace-last.png', 800, 700) + configureUploadSources({ + first: { path: first.filePath, size: first.size, fileName: 'replace-first.png' }, + last: { path: last.filePath, size: last.size, fileName: 'replace-last.png' }, + }) + configureAssetQueries({ + previewRows: previews.map((preview, index) => ({ + id: `multi-slice-${index}`, + assetType: 'detail-preview', + fileUrl: preview.filePath, + fileName: `multi-replace-long-${String(index + 1).padStart(3, '0')}.webp`, + sortOrder: index, + })), + zipRows: [{ id: 'zip-detail', assetType: 'detail', fileUrl: path.join(tempRoot, 'detail.zip'), fileName: 'detail.zip' }], + }) + mockListZipEntries.mockResolvedValue([{ name: 'multi-replace-long.png' }]) + mockExtractZipEntries.mockResolvedValue([{ entryName: 'multi-replace-long.png', filePath: long.filePath, size: long.size }]) + + const result = await batchEditTaskDetailImages({ + materialId: 'material-1', + operatorUserId: 'operator-1', + operations: [ + { kind: 'replace', assetId: 'multi-slice-0', uploadId: 'first' }, + { kind: 'replace', assetId: 'multi-slice-2', uploadId: 'last' }, + ], + }) + + expect(result).toMatchObject({ success: true, appliedCount: 2, entryCount: 3 }) + const entries = mockCreateZipFromFiles.mock.calls[0][0] as Array<{ path: string; name: string }> + expect(entries.map((entry) => entry.name)).toEqual([ + 'multi-replace-long.png', + 'multi-replace-long__bottom__top.png', + 'multi-replace-long__bottom.png', + ]) + expect(entries[0].path).toBe(first.filePath) + expect(entries[2].path).toBe(last.filePath) + }) + + test('单张替换接口复用精确切片链,后续可继续编辑已拆分长图', async () => { + const top = await createSolidImage('continued__top.png', 1200, 3000) + const replacementEntry = await createSolidImage('continued.png', 900, 600) + const bottom = await createSolidImage('continued__bottom.png', 1200, 1451) + const previews = await Promise.all([1500, 1500, 600, 1451].map((height, index) => + createSolidImage(`continued-preview-${index}.webp`, index === 2 ? 900 : 1200, height, 'webp'))) + const replacement = await createSolidImage('continued-second-replacement.png', 640, 480) + configureUploadSources({ + replacement: { path: replacement.filePath, size: replacement.size, fileName: 'continued-second-replacement.png' }, + }) + configureAssetQueries({ + previewRows: [ + { id: 'continued-0', assetType: 'detail-preview', fileUrl: previews[0].filePath, fileName: 'continued__top__srv-1788790000000-0-slice0-a.webp', sortOrder: 0 }, + { id: 'continued-1', assetType: 'detail-preview', fileUrl: previews[1].filePath, fileName: 'continued__top__srv-1788790000000-0-slice1-b.webp', sortOrder: 1 }, + { id: 'continued-replaced', assetType: 'detail-preview', fileUrl: previews[2].filePath, fileName: 'continued__srv-1788790000000-1-c.webp', sortOrder: 2 }, + { id: 'continued-bottom', assetType: 'detail-preview', fileUrl: previews[3].filePath, fileName: 'continued__bottom__srv-1788790000000-2-d.webp', sortOrder: 3 }, + ], + zipRows: [{ id: 'zip-detail', assetType: 'detail', fileUrl: path.join(tempRoot, 'detail.zip'), fileName: 'detail.zip' }], + }) + mockListZipEntries.mockResolvedValue([ + { name: 'continued__top.png' }, + { name: 'continued.png' }, + { name: 'continued__bottom.png' }, + ]) + mockExtractZipEntries.mockResolvedValue([ + { entryName: 'continued__top.png', filePath: top.filePath, size: top.size }, + { entryName: 'continued.png', filePath: replacementEntry.filePath, size: replacementEntry.size }, + { entryName: 'continued__bottom.png', filePath: bottom.filePath, size: bottom.size }, + ]) + + const result = await replaceTaskDetailImage({ + materialId: 'material-1', + assetId: 'continued-1', + operatorUserId: 'operator-1', + uploadId: 'replacement', + }) + + expect(result).toMatchObject({ success: true, entryName: 'continued__top.png' }) + expect(mockCreateZipFromFiles.mock.calls[0][0].map((entry: { name: string }) => entry.name)).toEqual([ + 'continued__top__top.png', + 'continued__top.png', + 'continued.png', + 'continued__bottom.png', + ]) + }) + + test('详情长图与旧预览切片几何不一致时拒绝精确替换', async () => { + const long = await createSolidImage('geometry-long.png', 1200, 3000) + const previews = await Promise.all([1500, 1400].map((height, index) => + createSolidImage(`geometry-${index}.webp`, 1200, height, 'webp'))) + const replacement = await createSolidImage('geometry-replacement.png', 500, 500) + configureUploadSources({ + replacement: { path: replacement.filePath, size: replacement.size, fileName: 'geometry-replacement.png' }, + }) + configureAssetQueries({ + previewRows: previews.map((preview, index) => ({ + id: `geometry-${index}`, + assetType: 'detail-preview', + fileUrl: preview.filePath, + fileName: `geometry-long-${String(index + 1).padStart(3, '0')}.webp`, + sortOrder: index, + })), + zipRows: [{ id: 'zip-detail', assetType: 'detail', fileUrl: path.join(tempRoot, 'detail.zip'), fileName: 'detail.zip' }], + }) + mockListZipEntries.mockResolvedValue([{ name: 'geometry-long.png' }]) + mockExtractZipEntries.mockResolvedValue([{ entryName: 'geometry-long.png', filePath: long.filePath, size: long.size }]) + + await expect(batchEditTaskDetailImages({ + materialId: 'material-1', + operatorUserId: 'operator-1', + operations: [{ kind: 'replace', assetId: 'geometry-0', uploadId: 'replacement' }], + })).rejects.toThrow('预览切片总高度与原图缩放规则不一致') + expect(mockUploadPreview).not.toHaveBeenCalled() + expect(mockCreateZipFromFiles).not.toHaveBeenCalled() + }) + test('追加后超过图片组上限会拒绝提交', async () => { const existingRows: PreviewRow[] = [] const entryNames: Array<{ name: string; filePath: string }> = [] diff --git a/lib/task-detail-asset-edit.ts b/lib/task-detail-asset-edit.ts index 7c86fb9..b7ecf2a 100644 --- a/lib/task-detail-asset-edit.ts +++ b/lib/task-detail-asset-edit.ts @@ -113,6 +113,7 @@ interface EditContext { allZipAsset: { id: string; fileUrl: string; fileName: string | null } | null entryNames: string[] runs: PreviewRun[] + runEntryIndexes: number[] targetRunIndex: number targetEntryIndex: number } @@ -690,6 +691,7 @@ async function loadEditContext(options: { allZipAsset, entryNames: [], runs: [], + runEntryIndexes: [], targetRunIndex: -1, targetEntryIndex: -1, } @@ -776,6 +778,7 @@ async function loadEditContext(options: { allZipAsset, entryNames, runs, + runEntryIndexes, targetRunIndex, targetEntryIndex, } @@ -1753,9 +1756,12 @@ function flattenBatchRows(runs: BatchRunState[]): Array<{ runIndex: number; rowI function findBatchRowByAssetId( runs: BatchRunState[], assetId: string, -): { runIndex: number; rowIndex: number; row: ExistingBatchRowState } | null { +): { runIndex: number; rowIndex: number; row: BatchRowState } | null { for (const located of flattenBatchRows(runs)) { - if (located.row.kind === 'existing' && located.row.id === assetId) { + const matches = located.row.kind === 'existing' + ? located.row.id === assetId + : located.row.sourceAssetId === assetId + if (matches) { return { runIndex: located.runIndex, rowIndex: located.rowIndex, @@ -1766,6 +1772,122 @@ function findBatchRowByAssetId( return null } +function getBatchRowSourceAssetId(row: BatchRowState): string | null { + return row.kind === 'existing' ? row.id : row.sourceAssetId || null +} + +function resolveBatchRunPreviewRows( + context: EditContext, + run: BatchRunState, +): PreviewRowLike[] { + const originalRows = new Map( + context.rowsByGroup[context.group].map((row) => [row.id, row]), + ) + return run.rows.map((row) => { + const sourceAssetId = getBatchRowSourceAssetId(row) + const original = sourceAssetId ? originalRows.get(sourceAssetId) : null + if (!original) { + throw new TaskDetailAssetEditError('长图切片已被本次批量操作改变,请刷新后重试', 409) + } + return original + }) +} + +async function replaceDetailSliceInBatchRun(options: { + context: EditContext + run: BatchRunState + rowIndex: number + replacement: ResolvedMutationSource + finalEntryName: string + runIndex: number + workspaceDir: string + existingEntryNames: string[] + generatedPreviewLocations: string[] +}): Promise { + const previewRows = resolveBatchRunPreviewRows(options.context, options.run) + + const sourceAssetIds = options.run.rows.map((row) => getBatchRowSourceAssetId(row)) + if (sourceAssetIds.some((assetId) => !assetId)) { + throw new TaskDetailAssetEditError('长图切片已被本次批量操作改变,请刷新后重试', 409) + } + + const previewRun: PreviewRun = { + key: options.run.entry.name, + rows: previewRows, + legacyIndex: null, + } + let topSource: (ResolvedMutationSource & { entryName: string }) | null = null + let bottomSource: (ResolvedMutationSource & { entryName: string }) | null = null + + if (options.rowIndex > 0) { + const split = await splitRunSourceAtDetailSeam({ + run: previewRun, + entry: options.run.entry, + afterRowIndex: options.rowIndex - 1, + workspaceDir: options.workspaceDir, + existingEntryNames: options.existingEntryNames, + }) + topSource = split.top + } + if (options.rowIndex < options.run.rows.length - 1) { + const split = await splitRunSourceAtDetailSeam({ + run: previewRun, + entry: options.run.entry, + afterRowIndex: options.rowIndex, + workspaceDir: options.workspaceDir, + existingEntryNames: options.existingEntryNames, + }) + bottomSource = split.bottom + } + + const replacementRuns: BatchRunState[] = [] + if (topSource) { + const topRun = await buildNewBatchRunState({ + source: topSource, + entryName: topSource.entryName, + group: 'detail', + materialId: options.context.materialId, + isPrivate: options.context.taskDetailPrivate, + entryIndex: options.runIndex, + workspaceDir: options.workspaceDir, + generatedPreviewLocations: options.generatedPreviewLocations, + sourceAssetIds: sourceAssetIds.slice(0, options.rowIndex) as string[], + }) + topRun.sourceRunIndex = options.run.sourceRunIndex + replacementRuns.push(topRun) + } + + const replacementRun = await buildNewBatchRunState({ + source: options.replacement, + entryName: options.finalEntryName, + group: 'detail', + materialId: options.context.materialId, + isPrivate: options.context.taskDetailPrivate, + entryIndex: options.runIndex + replacementRuns.length, + workspaceDir: options.workspaceDir, + generatedPreviewLocations: options.generatedPreviewLocations, + }) + replacementRuns.push(replacementRun) + + if (bottomSource) { + const bottomRun = await buildNewBatchRunState({ + source: bottomSource, + entryName: bottomSource.entryName, + group: 'detail', + materialId: options.context.materialId, + isPrivate: options.context.taskDetailPrivate, + entryIndex: options.runIndex + replacementRuns.length, + workspaceDir: options.workspaceDir, + generatedPreviewLocations: options.generatedPreviewLocations, + sourceAssetIds: sourceAssetIds.slice(options.rowIndex + 1) as string[], + }) + bottomRun.sourceRunIndex = options.run.sourceRunIndex + replacementRuns.push(bottomRun) + } + + return replacementRuns +} + function countSourceRunsForGroupRows(rows: PreviewRowLike[]): number { if (rows.length === 0) return 0 const anchoredRuns = groupPreviewRowsIntoRuns(rows) @@ -1932,15 +2054,18 @@ async function executeBatchRebuild( ) : [] - let finalRuns: BatchRunState[] = context.runs.map((run, index) => ({ - sourceRunIndex: index, - entry: { - path: extracted[index]?.filePath || '', - name: entryNames[index], - size: extracted[index]?.size || 0, - }, - rows: run.rows.map((row) => createExistingBatchRowState(row)), - })) + let finalRuns: BatchRunState[] = context.runs.map((run, index) => { + const entryIndex = context.runEntryIndexes[index] + return { + sourceRunIndex: index, + entry: { + path: extracted[entryIndex]?.filePath || '', + name: entryNames[entryIndex], + size: extracted[entryIndex]?.size || 0, + }, + rows: run.rows.map((row) => createExistingBatchRowState(row)), + } + }) const handledAssetIds = new Set() const repeatedInsertBoundaries = new Map() @@ -2172,6 +2297,23 @@ async function executeBatchRebuild( if (conflict) { throw new TaskDetailAssetEditError(`替换后的「${finalEntryName}」与包内其他文件重名,请先改名再替换`, 409) } + + if (group === 'detail' && run.rows.length > 1) { + const replacementRuns = await replaceDetailSliceInBatchRun({ + context, + run, + rowIndex: located.rowIndex, + replacement: operation.mutation.source, + finalEntryName, + runIndex: located.runIndex, + workspaceDir: workspace.dir, + existingEntryNames: survivingEntryNames(), + generatedPreviewLocations, + }) + finalRuns.splice(located.runIndex, 1, ...replacementRuns) + continue + } + const barcodes = group === 'sku' ? await loadRunBarcodes( run.rows @@ -2720,12 +2862,14 @@ export async function replaceTaskDetailImage(input: ReplaceTaskDetailImageInput) actionLabel: '替换图片', }) - const outcome = await executeRebuild(context, { - kind: 'replace', - filePath: source.filePath, - replacementFileName: source.fileName, - buffer: source.buffer, - }) + const entryName = buildFinalEntryName( + context.entryNames[context.targetEntryIndex], + source.fileName, + ) + const outcome = await executeBatchRebuild(context, [{ + assetId: input.assetId, + mutation: { kind: 'replace', source }, + }]) const { nextIsFullSet } = await commitRebuildWithCleanup(context, outcome, 'REPLACE_IMAGE') await cleanupAfterCommit(context, outcome) if (input.uploadId) { @@ -2757,7 +2901,7 @@ export async function replaceTaskDetailImage(input: ReplaceTaskDetailImageInput) return { success: true, - entryName: outcome.nextEntryNames[context.targetRunIndex], + entryName, } }) }