fix: replace only selected detail image slice

This commit is contained in:
WuYongJie 2026-09-08 23:00:55 +08:00
parent 99fbce0bcb
commit bdc7458038
2 changed files with 375 additions and 18 deletions

View File

@ -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<string, { path: string; size: number; fileName: string }>) {
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 }> = []

View File

@ -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<BatchRunState[]> {
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<string>()
const repeatedInsertBoundaries = new Map<string, BatchRunState>()
@ -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,
}
})
}