import { prisma } from '@/lib/prisma' import { canManageMaterial, } from '@/lib/material-authorization' import { createMaterialLog } from '@/lib/material-log' import { deleteFileIfUnreferenced } from '@/lib/file-cleanup' import { acquireTaskMaterialCompletionLock, } from '@/lib/material-upload-cross-process-lock' import { isProductPackageType } from '@/lib/material-type-mapper' import { deriveTaskDetailIsFullSet } from '@/lib/task-detail-full-set' import { queueTaskDetailBarcodeImagesUpdatedNotification, queueTaskDetailFullSetNotification, } from '@/lib/external-auto-listing' import { buildTaskDetailPublicDisplayPath, uploadPublicDisplayToS3, } from '@/lib/public-display' import { TASK_DETAIL_UPLOAD_LIMITS } from '@/lib/task-detail-upload-policy' import { withImageProcessingSlot } from '@/lib/image-processing-limiter' import { cleanupConsumedUploadSession } from '@/lib/temp-cleanup' import { deleteStoredUploadFileFromRemote } from '@/lib/remote-upload-sync' import { getOwnedUploadSession, getUploadedFile, isSafeUploadId, } from '@/lib/upload-sessions' import { createZipFromFiles, createZipFromZips } from '@/lib/task-material-processor' import { createTaskDetailEditWorkspace, extractZipEntriesToDirectory, listZipEntries, } from '@/lib/zip-entry-reader' import { resolveStoredUploadPath } from '@/lib/upload-paths' import { execFile } from 'child_process' import { readFile, stat, writeFile } from 'fs/promises' import path from 'path' import { promisify } from 'util' /** * 任务-详情素材的单图编辑(替换 / 改名 / 删除)。 * * 原图只存在于分组 ZIP(主图/SKU图/详情图)里,数据库保存的是 webp * 预览行和打包产物。所以任何单图操作都要: * 解开对应分组 ZIP -> 改条目 -> 重打分组 ZIP 和 all.zip -> 同步预览行。 * * 预览行与 ZIP 条目的对应关系靠"来源文件名"锚定:archiver 并发打包 * 不保证条目顺序等于上传顺序,位置映射不可靠;预览名(客户端命名或 * 本模块生成的 __srv 命名)携带来源基础名,与条目名去扩展名后匹配。 * 匹配不上就拒绝编辑,宁可让用户删除重传,也不能悄悄改错图。 */ export type TaskDetailEditGroup = 'main' | 'sku' | 'detail' const GROUP_ZIP_LABELS: Record = { main: '主图', sku: 'SKU图', detail: '详情图', } const GROUP_PREVIEW_TYPES: Record = { main: 'main-preview', sku: 'sku-preview', detail: 'detail-preview', } const GROUPS: TaskDetailEditGroup[] = ['main', 'sku', 'detail'] export class TaskDetailAssetEditError extends Error { readonly statusCode: 400 | 403 | 404 | 409 constructor(message: string, statusCode: 400 | 403 | 404 | 409 = 400) { super(message) this.name = 'TaskDetailAssetEditError' this.statusCode = statusCode } } interface PreviewRowLike { id: string assetType: string fileUrl: string fileName: string | null sortOrder: number skuCode?: string | null createdAt?: Date | null } interface PreviewRun { key: string rows: PreviewRowLike[] /** 旧代命名的图片序号;新命名 run 为 null */ legacyIndex: number | null } interface EditContext { materialId: string materialType: string productModel: string | null authorName: string | null categoryName: string | null currentPreviewUrl: string | null wasFullSet: boolean operatorUserId: string approvalRequestId?: string psdCount: number group: TaskDetailEditGroup rowsByGroup: Record groupZipAssets: Record allZipAsset: { id: string; fileUrl: string; fileName: string | null } | null entryNames: string[] runs: PreviewRun[] targetRunIndex: number targetEntryIndex: number } export interface ServerTrustedTaskDetailStagedFile { trusted: true filePath: string fileName: string fileSize?: number } type EditMutation = | { kind: 'replace'; filePath: string; replacementFileName: string; buffer: Buffer } | { kind: 'rename'; newBaseName: string } | { kind: 'delete' } const execFileAsync = promisify(execFile) // ---------- 预览名解析、run 切分与条目匹配 ---------- interface ParsedPreviewName { /** 与 ZIP 条目名(去扩展名)对应的锚;无法解析出锚时为 null */ label: string | null sliceIndex: number | null } interface ParsedServerPreviewName extends ParsedPreviewName { suffix: string } function parseServerPreviewFileName(fileName: string): ParsedServerPreviewName | null { if (!/\.webp$/i.test(fileName)) { return null } const withoutExtension = fileName.replace(/\.webp$/i, '') const serverMatch = /^(.+)__srv-\d+-\d+(?:-slice(\d+))?-([a-z0-9]+)$/i.exec(withoutExtension) if (!serverMatch) { return null } return { label: serverMatch[1], sliceIndex: serverMatch[2] !== undefined ? Number(serverMatch[2]) : null, suffix: withoutExtension.slice(serverMatch[1].length), } } function isLegacyServerPreviewWithoutSlice(fileName: string): boolean { const parsed = parseServerPreviewFileName(fileName) return Boolean(parsed && parsed.sliceIndex === null) } function runContainsOnlyLegacyServerPreviewWithoutSlice(run: PreviewRun | undefined): boolean { return Boolean( run && run.rows.length > 0 && run.rows.every((row) => isLegacyServerPreviewWithoutSlice(String(row.fileName || ''))), ) } /** * 解析预览文件名,提取"来源文件名"锚点。 * * ZIP 条目顺序在 archiver 并发打包下不保证等于上传顺序,位置映射不可靠, * 所以 run ↔ 条目一律用文件名锚定: * - 客户端命名:`{原始基础名}.webp` / 切片 `{原始基础名}-{三位序号}.webp` * - 服务端生成:`{原始基础名}__srv-{13位时间戳}-{序号}[-slice{N}]-{随机串}.webp` */ export function parseTaskDetailPreviewFileName(fileName: string): ParsedPreviewName | null { if (!/\.webp$/i.test(fileName)) { return null } const withoutExtension = fileName.replace(/\.webp$/i, '') const server = parseServerPreviewFileName(fileName) if (server) { return { label: server.label, sliceIndex: server.sliceIndex } } const sliceMatch = /-(\d{3})$/.exec(withoutExtension) if (sliceMatch) { return { label: withoutExtension.slice(0, sliceMatch.index), sliceIndex: Number(sliceMatch[1]), } } // 历史客户端命名 `{原始基础名}-preview.webp`(如 `主图3-preview.webp`): // 剥掉 -preview 尾巴才是与 ZIP 条目名对应的锚。误剥只会导致配不上被拒绝, // 不会错配(锚定仍要求条目名唯一命中)。 const previewTailMatch = /^(.+)-preview$/i.exec(withoutExtension) if (previewTailMatch) { return { label: previewTailMatch[1], sliceIndex: null } } return { label: withoutExtension, sliceIndex: null } } /** 生成服务端预览文件名:携带来源基础名作为后续编辑的匹配锚。 */ export function buildServerPreviewFileName( sourceBaseName: string, entryIndex: number, sliceIndex?: number | null, ): string { const timestamp = Date.now() const randomSuffix = Math.random().toString(36).substring(2, 9) const sliceSuffix = Number.isInteger(sliceIndex) && Number(sliceIndex) >= 0 ? `-slice${sliceIndex}` : '' return `${sourceBaseName}__srv-${timestamp}-${entryIndex}${sliceSuffix}-${randomSuffix}.webp` } /** * 旧代客户端预览名:`{分组}-preview-{13位时间戳}-{图片序号}[-slice{N}]-{随机串}.webp`。 * 名字不含来源文件锚,但「图片序号」是上传时的权威顺序;历史 ZIP 为顺序打包, * 因此序号可以与 ZIP 条目位置对齐(见 alignRunsToZipEntriesByOrder)。 */ const LEGACY_PREVIEW_PATTERN = /^(?:main|sku|detail)-preview-\d{13}-(\d+)(?:-slice(\d+))?-([a-z0-9]+)\.webp$/i export function parseLegacyPreviewFileName( fileName: string, ): { imageIndex: number; sliceIndex: number | null } | null { const match = LEGACY_PREVIEW_PATTERN.exec(String(fileName || '').trim()) if (!match) return null return { imageIndex: Number(match[1]), sliceIndex: match[2] !== undefined ? Number(match[2]) : null, } } /** * 旧代对齐:有名字锚的 run 先按现有规则唯一命中;剩余未配对的 run 与剩余 * 条目按相对顺序一一对应。正确性不变量:编辑操作(替换/改名/删除)不会改变 * 剩余条目的相对顺序,也同样不会改变剩余旧代序号的相对顺序,因此两个升序 * 序列的对应关系在多次编辑后依然成立。任何前置校验失败返回 null,调用方保持拒绝。 */ export function alignRunsToZipEntriesByOrder( runs: PreviewRun[], entryNames: string[], ): number[] | null { if (runs.length !== entryNames.length) return null const entryBases = entryNames.map((name) => stripExtension(name).toLowerCase()) const baseOccurrences = new Map() for (const base of entryBases) { baseOccurrences.set(base, (baseOccurrences.get(base) || 0) + 1) } const assigned = new Array(runs.length).fill(-1) const usedEntries = new Set() // 1. 有名字锚的 run 按唯一命中配对(与现有规则一致) for (let runIndex = 0; runIndex < runs.length; runIndex += 1) { const run = runs[runIndex] if (run.legacyIndex !== null) continue const key = run.key if (!key.startsWith('name:')) return null const label = key.slice('name:'.length).toLowerCase() if ((baseOccurrences.get(label) || 0) !== 1) return null const entryIndex = entryBases.findIndex( (base, index) => base === label && !usedEntries.has(index), ) if (entryIndex < 0) return null assigned[runIndex] = entryIndex usedEntries.add(entryIndex) } // 2. 旧代序号 run:同一序号只能出现一次 const seenLegacyIndexes = new Set() for (const run of runs) { if (run.legacyIndex === null) continue if (seenLegacyIndexes.has(run.legacyIndex)) return null seenLegacyIndexes.add(run.legacyIndex) } // 3. 剩余 run(按旧代序号升序)与剩余条目(按位置升序)一一配对 const unmatchedRunIndexes = runs .map((run, index) => (assigned[index] < 0 ? index : -1)) .filter((index) => index >= 0) .sort((left, right) => { const leftIndex = runs[left].legacyIndex ?? 0 const rightIndex = runs[right].legacyIndex ?? 0 return leftIndex - rightIndex }) const unusedEntryIndexes: number[] = [] for (let index = 0; index < entryBases.length; index += 1) { if (!usedEntries.has(index)) unusedEntryIndexes.push(index) } if (unmatchedRunIndexes.length !== unusedEntryIndexes.length) return null unmatchedRunIndexes.forEach((runIndex, order) => { assigned[runIndex] = unusedEntryIndexes[order] }) return assigned } /** * 把有序预览行切成 run。 * 相邻同名但各自独立的单预览保持独立;切片序列只在序号连续时合并。 * 返回 null 表示存在无法解析的名字(该素材不能安全编辑)。 */ export function groupPreviewRowsIntoRuns( sortedRows: PreviewRowLike[], ): PreviewRun[] | null { const runs: PreviewRun[] = [] let expectedNextSlice: number | null = null for (const row of sortedRows) { const parsed = parseTaskDetailPreviewFileName(String(row.fileName || '')) if (!parsed) { return null } const lastRun = runs[runs.length - 1] const continuesSliceSequence: boolean = parsed.sliceIndex !== null && parsed.label !== null && lastRun !== undefined && lastRun.key === `name:${parsed.label}` && expectedNextSlice === parsed.sliceIndex const continuesLegacyServerCompat = parsed.sliceIndex === null && parsed.label !== null && lastRun !== undefined && lastRun.key === `name:${parsed.label}` && isLegacyServerPreviewWithoutSlice(String(row.fileName || '')) && runContainsOnlyLegacyServerPreviewWithoutSlice(lastRun) if ((continuesSliceSequence || continuesLegacyServerCompat) && lastRun) { lastRun.rows.push(row) expectedNextSlice = continuesSliceSequence ? parsed.sliceIndex! + 1 : null continue } runs.push({ key: parsed.label !== null ? `name:${parsed.label}` : 'unlabeled', rows: [row], legacyIndex: null, }) expectedNextSlice = parsed.sliceIndex !== null ? parsed.sliceIndex + 1 : null } return runs } /** * 为每个 run 附带旧代序号:旧代命名的行按「图片序号」归并(切片行合并进同序号), * 新命名行沿用现有锚 key。用于锚定失败后的顺序对齐兜底。 * 任一行既不是新命名也不是旧代命名时返回 null(与现有 parse 失败同待遇)。 */ export function buildRunsWithLegacyIndex( sortedRows: PreviewRowLike[], ): PreviewRun[] | null { const runs: PreviewRun[] = [] const runByLegacyIndex = new Map() for (const row of sortedRows) { const fileName = String(row.fileName || '') const legacy = parseLegacyPreviewFileName(fileName) if (legacy) { const existing = runByLegacyIndex.get(legacy.imageIndex) if (existing) { existing.rows.push(row) continue } const run: PreviewRun = { key: `legacy:${legacy.imageIndex}`, rows: [row], legacyIndex: legacy.imageIndex, } runByLegacyIndex.set(legacy.imageIndex, run) runs.push(run) continue } const parsed = parseTaskDetailPreviewFileName(fileName) if (!parsed) return null const lastRun = runs[runs.length - 1] const continuesSliceSequence: boolean = parsed.sliceIndex !== null && parsed.label !== null && lastRun !== undefined && lastRun.key === `name:${parsed.label}` && lastRun.legacyIndex === null && (() => { const lastFileName = String(lastRun.rows[lastRun.rows.length - 1]?.fileName || '') const lastParsed = parseTaskDetailPreviewFileName(lastFileName) return lastParsed !== null && lastParsed.sliceIndex !== null && lastParsed.sliceIndex + 1 === parsed.sliceIndex })() const continuesLegacyServerCompat = parsed.sliceIndex === null && parsed.label !== null && lastRun !== undefined && lastRun.key === `name:${parsed.label}` && lastRun.legacyIndex === null && isLegacyServerPreviewWithoutSlice(fileName) && runContainsOnlyLegacyServerPreviewWithoutSlice(lastRun) if ((continuesSliceSequence || continuesLegacyServerCompat) && lastRun) { lastRun.rows.push(row) continue } runs.push({ key: `name:${parsed.label ?? ''}`, rows: [row], legacyIndex: null, }) } return runs } function stripExtension(name: string): string { const dotIndex = name.lastIndexOf('.') return dotIndex > 0 ? name.slice(0, dotIndex) : name } function getExtension(name: string): string { const dotIndex = name.lastIndexOf('.') return dotIndex > 0 ? name.slice(dotIndex) : '' } /** * 把 run 匹配到 ZIP 条目下标。 * * 规则: * 1. 有名字锚的 run 按"条目名去扩展名(忽略大小写)"唯一命中才分配; * 2. 剩下未分配的 run 和未命中的条目数量相等且 ≤1 时按剩余一一配对 * (兜住个别历史服务端命名行); * 3. 其余情况一律拒绝——宁可让用户删除重传,也不能改错图。 */ export function matchRunsToZipEntries( runs: Array<{ key: string; rows: PreviewRowLike[] }>, entryNames: string[], ): number[] { if (runs.length !== entryNames.length) { throw new TaskDetailAssetEditError( '该素材的图片索引与打包内容不一致,无法安全地单独编辑。请删除后重新上传。', ) } const entryBases = entryNames.map((name) => stripExtension(name).toLowerCase()) const assignedEntryForRun = new Array(runs.length).fill(-1) const usedEntries = new Set() const baseOccurrences = new Map() for (const base of entryBases) { baseOccurrences.set(base, (baseOccurrences.get(base) || 0) + 1) } for (let runIndex = 0; runIndex < runs.length; runIndex += 1) { const key = runs[runIndex].key if (!key.startsWith('name:')) { continue } const label = key.slice('name:'.length).toLowerCase() if ((baseOccurrences.get(label) || 0) !== 1) { // 重名或完全无命中:留到残余阶段判断 continue } const entryIndex = entryBases.findIndex( (base, index) => base === label && !usedEntries.has(index), ) if (entryIndex >= 0) { assignedEntryForRun[runIndex] = entryIndex usedEntries.add(entryIndex) } } const unassignedRuns: number[] = [] for (let runIndex = 0; runIndex < runs.length; runIndex += 1) { if (assignedEntryForRun[runIndex] < 0) { unassignedRuns.push(runIndex) } } const unusedEntryIndexes: number[] = [] for (let index = 0; index < entryBases.length; index += 1) { if (!usedEntries.has(index)) { unusedEntryIndexes.push(index) } } if (unassignedRuns.length !== unusedEntryIndexes.length) { throw new TaskDetailAssetEditError( '该素材的图片索引与打包内容不一致,无法安全地单独编辑。请删除后重新上传。', ) } if (unassignedRuns.length > 1) { throw new TaskDetailAssetEditError( '部分图片缺少可靠的来源标识,无法确认对应关系。请删除后重新上传。', ) } if (unassignedRuns.length === 1) { assignedEntryForRun[unassignedRuns[0]] = unusedEntryIndexes[0] } return assignedEntryForRun } function sortPreviewRows(rows: PreviewRowLike[]): PreviewRowLike[] { return [...rows].sort((left, right) => { if (left.sortOrder !== right.sortOrder) { return left.sortOrder - right.sortOrder } const leftTime = left.createdAt ? new Date(left.createdAt).getTime() : 0 const rightTime = right.createdAt ? new Date(right.createdAt).getTime() : 0 if (leftTime !== rightTime) { return leftTime - rightTime } return left.id.localeCompare(right.id) }) } // ---------- 上下文装载 ---------- async function assertCanManage( material: { id: string; userId: string | null }, operatorUserId: string, operatorRole?: string | null, ) { const canManage = await canManageMaterial( { id: operatorUserId, role: operatorRole }, // userId 为空表示历史脏数据:不匹配任何操作者,只可能由托管/管理员放行 { id: material.id, userId: material.userId ?? '' }, ) if (!canManage) { // 权限组授权线:组对「任务-详情」勾选了编辑图片的成员同样放行 const { canUserEditTaskDetailAssets } = await import('./user-permission-inherit') if (!(await canUserEditTaskDetailAssets(operatorUserId))) { throw new TaskDetailAssetEditError('您没有权限编辑此素材', 403) } } } async function loadEditContext(options: { materialId: string assetId?: string operatorUserId: string operatorRole?: string | null group?: TaskDetailEditGroup allowEmptyGroup?: boolean expectedMaterialUpdatedAt?: Date | string approvalRequestId?: string }): Promise { const material = await prisma.material.findFirst({ where: { id: options.materialId, deletedAt: null }, select: { id: true, userId: true, materialType: true, taskStatus: true, productModel: true, authorName: true, categoryName: true, previewUrl: true, isFullSet: true, updatedAt: true, }, }) if (!material) { throw new TaskDetailAssetEditError('素材不存在或已被删除', 404) } if (!isProductPackageType(material.materialType)) { throw new TaskDetailAssetEditError('只有任务-详情类型支持单图编辑') } if (material.taskStatus !== 'completed') { throw new TaskDetailAssetEditError('素材尚未完成上传,暂不能编辑图片') } if (options.expectedMaterialUpdatedAt) { const expected = new Date(options.expectedMaterialUpdatedAt) if ( Number.isNaN(expected.getTime()) || material.updatedAt.getTime() !== expected.getTime() ) { throw new TaskDetailAssetEditError('正式素材在审批期间已更新,本申请已停止应用', 409) } } await assertCanManage(material, options.operatorUserId, options.operatorRole) const [previewRows, zipAssetRows, psdCount] = await Promise.all([ prisma.materialAsset.findMany({ where: { materialId: material.id, assetType: { in: Object.values(GROUP_PREVIEW_TYPES) }, }, select: { id: true, assetType: true, fileUrl: true, fileName: true, sortOrder: true, skuCode: true, createdAt: true, }, }), prisma.materialAsset.findMany({ where: { materialId: material.id, assetType: { in: ['main', 'sku', 'detail', 'all'] }, }, select: { id: true, assetType: true, fileUrl: true, fileName: true, }, }), prisma.materialAsset.count({ where: { materialId: material.id, assetType: 'psd' }, }), ]) const rowsByGroup: Record = { main: [], sku: [], detail: [], } const groupZipAssets: Record = { main: null, sku: null, detail: null, } let allZipAsset: EditContext['allZipAsset'] = null for (const row of zipAssetRows) { if ((GROUPS as string[]).includes(row.assetType)) { groupZipAssets[row.assetType as TaskDetailEditGroup] = { id: row.id, fileUrl: row.fileUrl, fileName: row.fileName, } } else if (row.assetType === 'all') { allZipAsset = { id: row.id, fileUrl: row.fileUrl, fileName: row.fileName } } } for (const group of GROUPS) { rowsByGroup[group] = sortPreviewRows( (previewRows as PreviewRowLike[]).filter((row) => row.assetType === GROUP_PREVIEW_TYPES[group]), ) } const targetGroup = options.assetId ? GROUPS.find((group) => rowsByGroup[group].some((row) => row.id === options.assetId)) : options.group if (!targetGroup) { throw new TaskDetailAssetEditError('要编辑的图片不存在或不属于该素材', 404) } const groupZip = groupZipAssets[targetGroup] if (!groupZip?.fileUrl) { if (options.allowEmptyGroup && !options.assetId && rowsByGroup[targetGroup].length === 0) { return { materialId: material.id, materialType: material.materialType, productModel: material.productModel, authorName: material.authorName, categoryName: material.categoryName, currentPreviewUrl: material.previewUrl, wasFullSet: Boolean(material.isFullSet), operatorUserId: options.operatorUserId, approvalRequestId: options.approvalRequestId, psdCount, group: targetGroup, rowsByGroup, groupZipAssets, allZipAsset, entryNames: [], runs: [], targetRunIndex: -1, targetEntryIndex: -1, } } throw new TaskDetailAssetEditError('该分组的压缩包缺失,无法单独编辑图片') } const zipAbsolutePath = path.isAbsolute(groupZip.fileUrl) ? groupZip.fileUrl : resolveStoredUploadPath(groupZip.fileUrl) let entryNames: string[] try { const entries = await listZipEntries(zipAbsolutePath) entryNames = entries.map((entry) => entry.name) } catch (error) { throw new TaskDetailAssetEditError( `分组压缩包读取失败:${error instanceof Error ? error.message : '未知错误'}`, ) } // 先走「来源文件名锚定」;锚定失败时回退到旧代序号命名的顺序对齐 // (历史素材的预览名 detail-preview-{时间戳}-{序号}-... 不含文件锚, // 但历史 ZIP 为顺序打包,序号与条目位置的相对顺序在多次编辑后依然成立)。 const resolveRunsAndEntryIndexes = (): { runs: PreviewRun[]; runEntryIndexes: number[] } => { const anchoredRuns = groupPreviewRowsIntoRuns(rowsByGroup[targetGroup]) if (anchoredRuns) { try { return { runs: anchoredRuns, runEntryIndexes: matchRunsToZipEntries(anchoredRuns, entryNames) } } catch (anchorError) { const legacyRuns = buildRunsWithLegacyIndex(rowsByGroup[targetGroup]) const aligned = legacyRuns ? alignRunsToZipEntriesByOrder(legacyRuns, entryNames) : null if (legacyRuns && aligned) { return { runs: legacyRuns, runEntryIndexes: aligned } } throw anchorError } } const legacyRuns = buildRunsWithLegacyIndex(rowsByGroup[targetGroup]) const aligned = legacyRuns ? alignRunsToZipEntriesByOrder(legacyRuns, entryNames) : null if (legacyRuns && aligned) { return { runs: legacyRuns, runEntryIndexes: aligned } } throw new TaskDetailAssetEditError( '该素材的图片索引与打包内容不一致,无法安全地单独编辑。请删除后重新上传。', ) } const { runs, runEntryIndexes } = rowsByGroup[targetGroup].length === 0 && entryNames.length === 0 ? { runs: [] as PreviewRun[], runEntryIndexes: [] as number[] } : resolveRunsAndEntryIndexes() let targetRunIndex = -1 let targetEntryIndex = -1 if (options.assetId) { targetRunIndex = runs.findIndex((run) => run.rows.some((row) => row.id === options.assetId), ) if (targetRunIndex < 0) { throw new TaskDetailAssetEditError('要编辑的图片不存在或不属于该素材', 404) } targetEntryIndex = runEntryIndexes[targetRunIndex] } return { materialId: material.id, materialType: material.materialType, productModel: material.productModel, authorName: material.authorName, categoryName: material.categoryName, currentPreviewUrl: material.previewUrl, wasFullSet: Boolean(material.isFullSet), operatorUserId: options.operatorUserId, approvalRequestId: options.approvalRequestId, psdCount, group: targetGroup, rowsByGroup, groupZipAssets, allZipAsset, entryNames, runs, targetRunIndex, targetEntryIndex, } } // ---------- 预览生成与替换文件校验 ---------- const SERVER_PREVIEW_SOURCE_MAX_WIDTH = 12_000 const SERVER_PREVIEW_SOURCE_MAX_HEIGHT = TASK_DETAIL_UPLOAD_LIMITS.maxSliceHeight * TASK_DETAIL_UPLOAD_LIMITS.maxPreviewCountPerImage const SERVER_PREVIEW_SOURCE_MAX_PIXELS = 60_000_000 async function loadSharp() { const sharpModule = (await import('sharp')) as unknown as { default: typeof import('sharp') } return sharpModule.default } interface ResolvedMutationSource { filePath: string fileName: string fileSize: number buffer: Buffer metadata: import('sharp').Metadata animated: boolean } type MutationSourceInput = { uploadId?: string stagedFile?: ServerTrustedTaskDetailStagedFile } const IMAGE_FFMPEG_TIMEOUT_MS = 5 * 60 * 1000 const REMOTE_PREVIEW_MAX_BYTES = 16 * 1024 * 1024 function buildTempFilePath(workspaceDir: string, prefix: string, extension: string): string { const safeExtension = extension.startsWith('.') ? extension : `.${extension}` return path.join( workspaceDir, `${prefix}-${Date.now()}-${Math.random().toString(36).slice(2, 9)}${safeExtension}`, ) } function getLogicalImageHeight(metadata: import('sharp').Metadata): number { const pages = Number(metadata.pages || 1) const pageHeight = Number(metadata.pageHeight || 0) if (pages > 1 && Number.isInteger(pageHeight) && pageHeight > 0) { return pageHeight } return Number(metadata.height || 0) } function normalizeEntryFileName(fileName: string, fallbackLabel: string): string { const trimmed = String(fileName || '').trim() const baseName = trimmed.split(/[\\/]/).pop()?.trim() || '' if (!baseName) { throw new TaskDetailAssetEditError(`${fallbackLabel}文件名无效,请重新选择文件`) } return baseName } function ensureSingleMutationSource(source: MutationSourceInput, actionLabel: string) { const hasUploadId = Boolean(String(source.uploadId || '').trim()) const hasStaged = Boolean(source.stagedFile) if (hasUploadId === hasStaged) { throw new TaskDetailAssetEditError(`${actionLabel}来源无效,请重新选择文件`) } } async function inspectMutationSourceFile(options: { filePath: string fileName: string fileSize: number actionLabel: string }): Promise { if (options.fileSize > TASK_DETAIL_UPLOAD_LIMITS.maxSingleImageBytes) { throw new TaskDetailAssetEditError( `${options.actionLabel}超过单图 ${Math.round(TASK_DETAIL_UPLOAD_LIMITS.maxSingleImageBytes / 1024 / 1024)}MB 限制`, ) } const buffer = await readFile(options.filePath) const sharp = await loadSharp() let metadata: import('sharp').Metadata try { metadata = await withImageProcessingSlot(() => sharp(buffer, { animated: true, limitInputPixels: SERVER_PREVIEW_SOURCE_MAX_PIXELS, sequentialRead: true, failOn: 'error', }).metadata(), ) } catch { throw new TaskDetailAssetEditError(`${options.actionLabel}的文件不是有效图片,请更换文件后重试`) } return { filePath: options.filePath, fileName: normalizeEntryFileName(options.fileName, options.actionLabel), fileSize: options.fileSize, buffer, metadata, animated: Number(metadata.pages || 1) > 1, } } async function resolveTrustedStagedFile(options: { stagedFile: ServerTrustedTaskDetailStagedFile actionLabel: string }): Promise { const stagedFile = options.stagedFile if (!stagedFile || stagedFile.trusted !== true) { throw new TaskDetailAssetEditError(`${options.actionLabel}来源未通过服务端校验,请重新发起`) } const filePath = String(stagedFile.filePath || '').trim() const fileName = String(stagedFile.fileName || '').trim() if (!filePath || !path.isAbsolute(filePath)) { throw new TaskDetailAssetEditError(`${options.actionLabel}暂存文件路径无效,请重新发起`) } if (!fileName) { throw new TaskDetailAssetEditError(`${options.actionLabel}暂存文件名无效,请重新发起`) } let fileStats: Awaited> try { fileStats = await stat(filePath) } catch { throw new TaskDetailAssetEditError(`${options.actionLabel}暂存文件不存在,请重新发起`) } if (!fileStats.isFile()) { throw new TaskDetailAssetEditError(`${options.actionLabel}暂存文件不可读取,请重新发起`) } const declaredSize = Number(stagedFile.fileSize) if (Number.isFinite(declaredSize) && declaredSize > 0 && declaredSize !== fileStats.size) { throw new TaskDetailAssetEditError(`${options.actionLabel}暂存文件大小校验失败,请重新发起`) } return inspectMutationSourceFile({ filePath, fileName, fileSize: fileStats.size, actionLabel: options.actionLabel, }) } async function resolveMutationSource( options: MutationSourceInput & { operatorUserId: string; actionLabel: string }, ): Promise { ensureSingleMutationSource(options, options.actionLabel) if (options.stagedFile) { return resolveTrustedStagedFile({ stagedFile: options.stagedFile, actionLabel: options.actionLabel, }) } const uploadId = String(options.uploadId || '').trim() if (!uploadId || !isSafeUploadId(uploadId)) { throw new TaskDetailAssetEditError(`${options.actionLabel}的上传会话无效,请重新选择文件`) } const session = await getOwnedUploadSession(uploadId, options.operatorUserId) if (!session) { throw new TaskDetailAssetEditError(`${options.actionLabel}尚未上传完成或会话已过期,请重新上传`) } const filePath = await getUploadedFile(uploadId) if (!filePath) { throw new TaskDetailAssetEditError(`${options.actionLabel}文件缺失,请重新上传`) } return inspectMutationSourceFile({ filePath, fileName: session.fileName, fileSize: session.fileSize, actionLabel: options.actionLabel, }) } function getAnimatedSourceOutputExtension(fileName: string): '.gif' | '.webp' { const extension = getExtension(fileName).toLowerCase() return extension === '.gif' ? '.gif' : '.webp' } function getStaticOutputExtension(fileName: string): '.jpg' | '.jpeg' | '.png' | '.webp' | '.gif' { const extension = getExtension(fileName).toLowerCase() if (extension === '.jpg' || extension === '.jpeg' || extension === '.png' || extension === '.webp' || extension === '.gif') { return extension as '.jpg' | '.jpeg' | '.png' | '.webp' | '.gif' } throw new TaskDetailAssetEditError(`暂不支持处理 ${extension || '该格式'} 图片,请先转换成 JPG、PNG、WEBP 或 GIF`) } async function encodeStaticCroppedImage(options: { inputPath: string outputPath: string width: number height: number left: number top: number outputExtension: '.jpg' | '.jpeg' | '.png' | '.webp' | '.gif' }) { const sharp = await loadSharp() const pipeline = sharp(options.inputPath, { limitInputPixels: SERVER_PREVIEW_SOURCE_MAX_PIXELS, sequentialRead: true, failOn: 'error', }).extract({ left: options.left, top: options.top, width: options.width, height: options.height, }) if (options.outputExtension === '.jpg' || options.outputExtension === '.jpeg') { await pipeline.jpeg({ quality: 95 }).toFile(options.outputPath) return } if (options.outputExtension === '.png') { await pipeline.png().toFile(options.outputPath) return } if (options.outputExtension === '.gif') { await pipeline.gif().toFile(options.outputPath) return } await pipeline.webp({ quality: 95 }).toFile(options.outputPath) } async function runFfmpeg(args: string[], actionLabel: string) { try { await execFileAsync('ffmpeg', args, { timeout: IMAGE_FFMPEG_TIMEOUT_MS, maxBuffer: 8 * 1024 * 1024, windowsHide: true, } as any) } catch (error) { const message = error instanceof Error ? error.message : '未知错误' if (/ENOENT/i.test(message)) { throw new TaskDetailAssetEditError(`${actionLabel}失败:当前环境缺少 ffmpeg,无法可靠保留动图时序`) } throw new TaskDetailAssetEditError(`${actionLabel}失败:${message}`) } } async function renderAnimatedPreviewFile(options: { inputPath: string outputPath: string outputWidth: number outputHeight: number cropTop: number cropHeight: number }) { const filters = [ `scale=${options.outputWidth}:${options.outputHeight}:flags=lanczos`, `crop=${options.outputWidth}:${options.cropHeight}:0:${options.cropTop}`, ].join(',') await runFfmpeg( [ '-y', '-i', options.inputPath, '-an', '-vsync', '0', '-vf', filters, '-c:v', 'libwebp_anim', '-loop', '0', '-quality', '75', '-compression_level', '6', options.outputPath, ], '动图预览生成', ) } async function cropAnimatedSourceFile(options: { inputPath: string outputPath: string width: number height: number top: number outputExtension: '.gif' | '.webp' }) { const filters = `crop=${options.width}:${options.height}:0:${options.top}` const args = [ '-y', '-i', options.inputPath, '-an', '-vsync', '0', '-vf', filters, ] if (options.outputExtension === '.gif') { args.push('-gifflags', '+transdiff', options.outputPath) } else { args.push( '-c:v', 'libwebp_anim', '-loop', '0', '-quality', '80', '-compression_level', '6', options.outputPath, ) } await runFfmpeg(args, '动图长图拆分') } async function generateAnimatedPreviews(options: { source: ResolvedMutationSource group: TaskDetailEditGroup materialId: string sourceBaseName: string entryIndex: number workspaceDir: string }): Promise> { const width = Number(options.source.metadata.width) const height = getLogicalImageHeight(options.source.metadata) if (!Number.isInteger(width) || !Number.isInteger(height) || width < 1 || height < 1) { throw new TaskDetailAssetEditError('动图尺寸无效,无法生成预览') } const scaleLimit = options.group === 'detail' ? TASK_DETAIL_UPLOAD_LIMITS.maxSliceWidth : TASK_DETAIL_UPLOAD_LIMITS.maxPreviewWidth const scale = width > scaleLimit ? scaleLimit / width : 1 const outputWidth = Math.max(1, Math.round(width * scale)) const outputHeight = Math.max(1, Math.round(height * scale)) const sliceHeightLimit = options.group === 'detail' ? TASK_DETAIL_UPLOAD_LIMITS.maxSliceHeight : TASK_DETAIL_UPLOAD_LIMITS.maxPreviewHeight const sliceCount = options.group === 'detail' ? Math.ceil(outputHeight / sliceHeightLimit) : 1 if (sliceCount > TASK_DETAIL_UPLOAD_LIMITS.maxPreviewCountPerImage) { throw new TaskDetailAssetEditError(`动图预览切片超过 ${TASK_DETAIL_UPLOAD_LIMITS.maxPreviewCountPerImage} 张限制`) } const previews: Array<{ fileName: string; fileUrl: string; size: number }> = [] for (let sliceIndex = 0; sliceIndex < sliceCount; sliceIndex += 1) { const cropTop = options.group === 'detail' ? sliceIndex * sliceHeightLimit : 0 const cropHeight = options.group === 'detail' ? Math.min(sliceHeightLimit, outputHeight - cropTop) : outputHeight const fileName = buildServerPreviewFileName( options.sourceBaseName, options.entryIndex, sliceCount > 1 ? sliceIndex : null, ) const outputPath = buildTempFilePath(options.workspaceDir, 'animated-preview', '.webp') await renderAnimatedPreviewFile({ inputPath: options.source.filePath, outputPath, outputWidth, outputHeight, cropTop, cropHeight, }) const previewBuffer = await readFile(outputPath) const fileUrl = await uploadPublicDisplayToS3( previewBuffer, buildTaskDetailPublicDisplayPath(options.materialId, options.group, fileName), 'image/webp', ) previews.push({ fileName, fileUrl, size: previewBuffer.length }) } return previews } /** 按完成链路的服务端兜底参数生成预览(800x1200 webp,长图切片)。 */ async function generateServerPreviews( source: ResolvedMutationSource, group: TaskDetailEditGroup, materialId: string, sourceBaseName: string, entryIndex: number, workspaceDir: string, ): Promise> { if (source.animated) { return generateAnimatedPreviews({ source, group, materialId, sourceBaseName, entryIndex, workspaceDir, }) } const { compressImage, sliceImage } = await import('@/lib/image-processor') if (source.buffer.length > 200 * 1024 * 1024) { throw new TaskDetailAssetEditError('替换图片不能超过 200MB') } const metadata = source.metadata if ( (metadata.width || 0) > SERVER_PREVIEW_SOURCE_MAX_WIDTH || (metadata.height || 0) > SERVER_PREVIEW_SOURCE_MAX_HEIGHT ) { throw new TaskDetailAssetEditError('替换图片尺寸超出限制,请先缩小后再上传') } const shouldSlice = group === 'detail' && (metadata.height || 0) > 1500 const buffers = shouldSlice ? await sliceImage!( source.buffer, TASK_DETAIL_UPLOAD_LIMITS.maxSliceHeight, 200, TASK_DETAIL_UPLOAD_LIMITS.maxSliceWidth, { allowAnimatedFirstFrame: true, maxInputWidth: SERVER_PREVIEW_SOURCE_MAX_WIDTH, maxInputHeight: SERVER_PREVIEW_SOURCE_MAX_HEIGHT, maxInputPixels: SERVER_PREVIEW_SOURCE_MAX_PIXELS, }, ) : [ await compressImage(source.buffer, { maxWidth: 800, maxHeight: 1200, maxSizeKB: 200, format: 'webp', allowAnimatedFirstFrame: true, maxInputWidth: SERVER_PREVIEW_SOURCE_MAX_WIDTH, maxInputHeight: SERVER_PREVIEW_SOURCE_MAX_HEIGHT, maxInputPixels: SERVER_PREVIEW_SOURCE_MAX_PIXELS, }), ] const previews: Array<{ fileName: string; fileUrl: string; size: number }> = [] for (let sliceIndex = 0; sliceIndex < buffers.length; sliceIndex += 1) { const fileName = buildServerPreviewFileName( sourceBaseName, entryIndex, buffers.length > 1 ? sliceIndex : null, ) const fileUrl = await uploadPublicDisplayToS3( buffers[sliceIndex], buildTaskDetailPublicDisplayPath(materialId, group, fileName), 'image/webp', ) previews.push({ fileName, fileUrl, size: buffers[sliceIndex].length }) } return previews } async function loadPreviewRowMetadata(row: PreviewRowLike): Promise<{ width: number; height: number }> { const sharp = await loadSharp() const target = String(row.fileUrl || '').trim() if (!target) { throw new TaskDetailAssetEditError('该预览切片缺少地址,无法确认插图位置。请删除后重新上传。') } let metadata: import('sharp').Metadata if (/^https?:\/\//i.test(target)) { const response = await fetch(target) if (!response.ok) { throw new TaskDetailAssetEditError('预览切片读取失败,无法确认插图位置。请稍后重试。') } const declaredLength = Number(response.headers.get('content-length') || 0) if (Number.isFinite(declaredLength) && declaredLength > REMOTE_PREVIEW_MAX_BYTES) { throw new TaskDetailAssetEditError('预览切片过大,无法安全确认插图位置。请删除后重新上传。') } const buffer = Buffer.from(await response.arrayBuffer()) metadata = await withImageProcessingSlot(() => sharp(buffer, { animated: true, limitInputPixels: TASK_DETAIL_UPLOAD_LIMITS.maxSlicePixels, sequentialRead: true, failOn: 'error', }).metadata(), ) } else { const localPath = path.isAbsolute(target) ? target : resolveStoredUploadPath(target) metadata = await withImageProcessingSlot(() => sharp(localPath, { animated: true, limitInputPixels: TASK_DETAIL_UPLOAD_LIMITS.maxSlicePixels, sequentialRead: true, failOn: 'error', }).metadata(), ) } const width = Number(metadata.width) const height = Number(metadata.height) if (!Number.isInteger(width) || !Number.isInteger(height) || width < 1 || height < 1) { throw new TaskDetailAssetEditError('预览切片尺寸无效,无法确认插图位置。请删除后重新上传。') } return { width, height } } function ensureUniqueEntryName(candidateName: string, existingNames: string[], conflictMessage: string): string { const normalized = normalizeEntryFileName(candidateName, '新增') if (existingNames.includes(normalized)) { throw new TaskDetailAssetEditError(conflictMessage, 409) } return normalized } function buildDerivedSplitEntryNames(originalName: string, existingNames: string[]): { top: string; bottom: string } { const baseName = stripExtension(originalName) const extension = getExtension(originalName) || '.png' const occupied = new Set(existingNames.filter((name) => name !== originalName)) const pick = (suffix: 'top' | 'bottom') => { for (let serial = 1; serial <= 1000; serial += 1) { const serialSuffix = serial === 1 ? '' : `-${serial}` const candidate = `${baseName}__${suffix}${serialSuffix}${extension}` if (!occupied.has(candidate)) { occupied.add(candidate) return candidate } } throw new TaskDetailAssetEditError('长图拆分后的文件名无法安全生成,请先改名后再插图', 409) } return { top: pick('top'), bottom: pick('bottom') } } function pickSourceSplitY(sourceHeight: number, previewScale: number, seamPreviewHeight: number): number { const raw = seamPreviewHeight / previewScale const candidates = Array.from(new Set([ Math.floor(raw), Math.round(raw), Math.ceil(raw), ])).filter((value) => value > 0 && value < sourceHeight) if (candidates.length === 0) { throw new TaskDetailAssetEditError('插图位置超出原图有效范围,无法安全处理。请删除后重新上传。') } let best = candidates[0] let bestDiff = Number.POSITIVE_INFINITY for (const candidate of candidates) { const previewHeight = Math.max(1, Math.round(candidate * previewScale)) const diff = Math.abs(previewHeight - seamPreviewHeight) if (diff < bestDiff) { best = candidate bestDiff = diff } } if (bestDiff > 1) { throw new TaskDetailAssetEditError('预览切片与原图缩放规则不一致,无法安全插图。请删除后重新上传。') } return best } async function splitRunSourceAtDetailSeam(options: { run: PreviewRun entry: { path: string; name: string; size: number } afterRowIndex: number workspaceDir: string existingEntryNames: string[] }): Promise<{ top: ResolvedMutationSource & { entryName: string }; bottom: ResolvedMutationSource & { entryName: string } }> { const previewMetadatas = await Promise.all(options.run.rows.map((row) => loadPreviewRowMetadata(row))) const previewWidth = previewMetadatas[0]?.width ?? 0 if (!Number.isInteger(previewWidth) || previewWidth < 1) { throw new TaskDetailAssetEditError('预览切片宽度无效,无法确认插图位置。请删除后重新上传。') } if (previewMetadatas.some((item) => item.width !== previewWidth)) { throw new TaskDetailAssetEditError('同一长图的预览切片宽度不一致,无法安全插图。请删除后重新上传。') } const source = await inspectMutationSourceFile({ filePath: options.entry.path, fileName: options.entry.name, fileSize: options.entry.size, actionLabel: '原图拆分', }) const sourceWidth = Number(source.metadata.width) const sourceHeight = getLogicalImageHeight(source.metadata) if (!Number.isInteger(sourceWidth) || !Number.isInteger(sourceHeight) || sourceWidth < 1 || sourceHeight < 2) { throw new TaskDetailAssetEditError('原图尺寸无效,无法安全插图。请删除后重新上传。') } const previewScale = previewWidth / sourceWidth if (!(previewScale > 0) || previewScale > 1) { throw new TaskDetailAssetEditError('预览切片与原图缩放比例异常,无法安全插图。请删除后重新上传。') } const totalPreviewHeight = previewMetadatas.reduce((sum, item) => sum + item.height, 0) const expectedPreviewHeight = Math.max(1, Math.round(sourceHeight * previewScale)) if (Math.abs(totalPreviewHeight - expectedPreviewHeight) > 1) { throw new TaskDetailAssetEditError('预览切片总高度与原图缩放规则不一致,无法安全插图。请删除后重新上传。') } const seamPreviewHeight = previewMetadatas .slice(0, options.afterRowIndex + 1) .reduce((sum, item) => sum + item.height, 0) if (seamPreviewHeight <= 0 || seamPreviewHeight >= totalPreviewHeight) { throw new TaskDetailAssetEditError('插图位置不在长图内部,无法安全处理。请重新选择位置。') } const splitY = pickSourceSplitY(sourceHeight, previewScale, seamPreviewHeight) const { top: topEntryName, bottom: bottomEntryName } = buildDerivedSplitEntryNames( options.entry.name, options.existingEntryNames, ) const topExt = source.animated ? getAnimatedSourceOutputExtension(topEntryName) : getStaticOutputExtension(topEntryName) const bottomExt = source.animated ? getAnimatedSourceOutputExtension(bottomEntryName) : getStaticOutputExtension(bottomEntryName) const topPath = buildTempFilePath(options.workspaceDir, 'detail-split-top', topExt) const bottomPath = buildTempFilePath(options.workspaceDir, 'detail-split-bottom', bottomExt) if (source.animated) { await cropAnimatedSourceFile({ inputPath: options.entry.path, outputPath: topPath, width: sourceWidth, height: splitY, top: 0, outputExtension: topExt as '.gif' | '.webp', }) await cropAnimatedSourceFile({ inputPath: options.entry.path, outputPath: bottomPath, width: sourceWidth, height: sourceHeight - splitY, top: splitY, outputExtension: bottomExt as '.gif' | '.webp', }) } else { await encodeStaticCroppedImage({ inputPath: options.entry.path, outputPath: topPath, width: sourceWidth, height: splitY, left: 0, top: 0, outputExtension: topExt as '.jpg' | '.jpeg' | '.png' | '.webp' | '.gif', }) await encodeStaticCroppedImage({ inputPath: options.entry.path, outputPath: bottomPath, width: sourceWidth, height: sourceHeight - splitY, left: 0, top: splitY, outputExtension: bottomExt as '.jpg' | '.jpeg' | '.png' | '.webp' | '.gif', }) } const topSource = await inspectMutationSourceFile({ filePath: topPath, fileName: topEntryName.replace(/\.[^/.]+$/, '') + topExt, fileSize: (await stat(topPath)).size, actionLabel: '长图上半段', }) const bottomSource = await inspectMutationSourceFile({ filePath: bottomPath, fileName: bottomEntryName.replace(/\.[^/.]+$/, '') + bottomExt, fileSize: (await stat(bottomPath)).size, actionLabel: '长图下半段', }) return { top: { ...topSource, entryName: normalizeEntryFileName(topSource.fileName, '长图上半段') }, bottom: { ...bottomSource, entryName: normalizeEntryFileName(bottomSource.fileName, '长图下半段') }, } } function buildFinalEntryName(originalName: string, replacementFileName: string): string { const originalExtension = getExtension(originalName) const replacementExtension = getExtension(replacementFileName) return `${stripExtension(originalName)}${replacementExtension || originalExtension}` } function sanitizeNewBaseName(rawName: string): string { const trimmed = String(rawName || '').trim() if (!trimmed) { throw new TaskDetailAssetEditError('文件名不能为空') } if (trimmed.length > 120) { throw new TaskDetailAssetEditError('文件名不能超过 120 个字符') } if (/[\/\\:*?"<>|\r\n\0]/.test(trimmed)) { throw new TaskDetailAssetEditError('文件名不能包含 \\ / : * ? " < > | 等字符') } return trimmed } function buildRenamedPreviewFileName(currentName: string, newBaseName: string): string | null { const parsed = parseTaskDetailPreviewFileName(currentName) if (!parsed?.label) { return null } if (parsed.label.toLowerCase() === newBaseName.toLowerCase()) { return currentName } const serverTail = parseServerPreviewFileName(currentName) if (serverTail) { return `${newBaseName}${serverTail.suffix}.webp` } const sliceSuffixMatch = /-\d{3}(?=\.webp$)/i.exec(currentName) return sliceSuffixMatch ? `${newBaseName}${sliceSuffixMatch[0]}.webp` : `${newBaseName}.webp` } // ---------- 变更计划与执行 ---------- interface PlannedDbChanges { previewRowsToDelete: string[] previewRowsToInsert: Array<{ assetType: string fileUrl: string fileName: string fileSize: bigint sortOrder: number skuCode: string | null barcodes: string[] }> sortOrderUpdates: Array<{ id: string; sortOrder: number }> fileNameUpdates: Array<{ id: string; fileName: string }> removedPreviewUrls: Array<{ url: string }> } interface RebuildOutcome { nextEntryNames: string[] newGroupZip: { filePath: string; fileName: string; fileSize: number } | null newAllZip: { filePath: string; fileName: string; fileSize: number } | null db: PlannedDbChanges finalRowCountByGroup: Record firstPreviewUrlByGroup: Record } async function planPreviewRowChanges( context: EditContext, mutation: EditMutation, finalEntryName: string, workspaceDir: string, ): Promise { const { group, runs, targetRunIndex, targetEntryIndex } = context const targetRun = runs[targetRunIndex] const previewType = GROUP_PREVIEW_TYPES[group] const planned: PlannedDbChanges = { previewRowsToDelete: [], previewRowsToInsert: [], sortOrderUpdates: [], fileNameUpdates: [], removedPreviewUrls: [], } if (mutation.kind === 'delete') { for (const row of targetRun.rows) { planned.previewRowsToDelete.push(row.id) planned.removedPreviewUrls.push({ url: row.fileUrl }) } } else if (mutation.kind === 'replace') { const replacementSource = await inspectMutationSourceFile({ filePath: mutation.filePath, fileName: finalEntryName, fileSize: mutation.buffer.length, actionLabel: '替换图片', }) const generated = await generateServerPreviews( replacementSource, group, context.materialId, stripExtension(finalEntryName), targetEntryIndex, workspaceDir, ) const barcodes = await loadRunBarcodes(targetRun.rows.map((row) => row.id)) for (const row of targetRun.rows) { planned.previewRowsToDelete.push(row.id) planned.removedPreviewUrls.push({ url: row.fileUrl }) } generated.forEach((preview, index) => { planned.previewRowsToInsert.push({ assetType: previewType, fileUrl: preview.fileUrl, fileName: preview.fileName, fileSize: BigInt(preview.size), sortOrder: targetRunIndex + index, skuCode: targetRun.rows[0]?.skuCode?.trim() || null, barcodes: index === 0 ? barcodes : [], }) }) } else { // rename:同步预览标签里的来源基础名(客户端命名与 __srv 命名都要跟条目名一致) const newBaseName = stripExtension(finalEntryName) for (const row of targetRun.rows) { const currentName = String(row.fileName || '') const nextName = buildRenamedPreviewFileName(currentName, newBaseName) if (!nextName) continue if (nextName !== currentName) { planned.fileNameUpdates.push({ id: row.id, fileName: nextName }) } } } // 目标 run 之后的所有行统一重排编号(rename 行数不变,重排结果不变) const rowsInFinalOrder: PreviewRowLike[] = [] for (let runIndex = 0; runIndex < runs.length; runIndex += 1) { if (mutation.kind !== 'rename' && runIndex === targetRunIndex) { continue } rowsInFinalOrder.push(...runs[runIndex].rows) } rowsInFinalOrder.forEach((row, index) => { if (row.sortOrder !== index) { planned.sortOrderUpdates.push({ id: row.id, sortOrder: index }) } }) return planned } async function loadRunBarcodes(assetIds: string[]): Promise { if (assetIds.length === 0) { return [] } const rows = await prisma.materialAssetBarcode.findMany({ where: { assetId: { in: assetIds } }, select: { barcode: true }, }) return Array.from(new Set(rows.map((row) => row.barcode).filter(Boolean))).sort() } // ---------- 批量编辑:序列应用 + 一次重建 ---------- interface ExistingBatchRowState { kind: 'existing' id: string fileUrl: string fileName: string originalFileName: string sortOrder: number originalSortOrder: number skuCode: string | null } interface NewBatchRowState { kind: 'new' /** 同一长图被拆分后,继续定位用户最初看到的 preview seam。 */ sourceAssetId?: string fileUrl: string fileName: string fileSize: bigint sortOrder: number skuCode: string | null barcodes: string[] } type BatchRowState = ExistingBatchRowState | NewBatchRowState interface BatchRunState { entry: { path: string; name: string; size: number } rows: BatchRowState[] } type PreparedBatchMutation = | { kind: 'delete' } | { kind: 'rename'; newBaseName: string } | { kind: 'replace'; source: ResolvedMutationSource } | { kind: 'append'; group: 'main' | 'sku'; source: ResolvedMutationSource; skuCode: string | null; barcodes: string[] } | { kind: 'insert'; afterAssetId: string; beforeAssetId: string; source: ResolvedMutationSource } interface PreparedBatchOperation { assetId?: string mutation: PreparedBatchMutation /** 上传会话来源的操作在提交成功后清理 */ cleanupUploadId?: string } function createExistingBatchRowState(row: PreviewRowLike): ExistingBatchRowState { const fileName = String(row.fileName || '') return { kind: 'existing', id: row.id, fileUrl: row.fileUrl, fileName, originalFileName: fileName, sortOrder: row.sortOrder, originalSortOrder: row.sortOrder, skuCode: row.skuCode?.trim() || null, } } async function buildNewBatchRunState(options: { source: ResolvedMutationSource entryName: string group: TaskDetailEditGroup materialId: string entryIndex: number workspaceDir: string skuCode?: string | null barcodes?: string[] sourceAssetIds?: string[] }): Promise { const previews = await generateServerPreviews( options.source, options.group, options.materialId, stripExtension(options.entryName), options.entryIndex, options.workspaceDir, ) if (options.sourceAssetIds && options.sourceAssetIds.length !== previews.length) { throw new TaskDetailAssetEditError('长图拆分后的预览数量与原接缝不一致,无法继续批量插图,请刷新后重试') } return { entry: { path: options.source.filePath, name: options.entryName, size: options.source.fileSize, }, rows: previews.map((preview, index) => ({ kind: 'new', sourceAssetId: options.sourceAssetIds?.[index], fileUrl: preview.fileUrl, fileName: preview.fileName, fileSize: BigInt(preview.size), sortOrder: index, skuCode: options.skuCode?.trim() || null, barcodes: index === 0 ? Array.from(new Set((options.barcodes || []).filter(Boolean))) : [], })), } } function flattenBatchRows(runs: BatchRunState[]): Array<{ runIndex: number; rowIndex: number; row: BatchRowState }> { const flattened: Array<{ runIndex: number; rowIndex: number; row: BatchRowState }> = [] for (let runIndex = 0; runIndex < runs.length; runIndex += 1) { for (let rowIndex = 0; rowIndex < runs[runIndex].rows.length; rowIndex += 1) { flattened.push({ runIndex, rowIndex, row: runs[runIndex].rows[rowIndex] }) } } return flattened } function findBatchRowByAssetId( runs: BatchRunState[], assetId: string, ): { runIndex: number; rowIndex: number; row: ExistingBatchRowState } | null { for (const located of flattenBatchRows(runs)) { if (located.row.kind === 'existing' && located.row.id === assetId) { return { runIndex: located.runIndex, rowIndex: located.rowIndex, row: located.row, } } } return null } function countSourceRunsForGroupRows(rows: PreviewRowLike[]): number { if (rows.length === 0) return 0 const anchoredRuns = groupPreviewRowsIntoRuns(rows) if (anchoredRuns) return anchoredRuns.length const legacyRuns = buildRunsWithLegacyIndex(rows) if (legacyRuns) return legacyRuns.length return rows.length } function assertFinalBatchLimits( context: EditContext, group: TaskDetailEditGroup, finalRuns: BatchRunState[], ) { const finalPreviewCount = finalRuns.reduce((sum, run) => sum + run.rows.length, 0) if (finalRuns.length > TASK_DETAIL_UPLOAD_LIMITS.maxImagesPerGroup) { throw new TaskDetailAssetEditError( `${GROUP_ZIP_LABELS[group]}最多保留 ${TASK_DETAIL_UPLOAD_LIMITS.maxImagesPerGroup} 张,请分批处理`, ) } let otherSourceCount = 0 let otherPreviewCount = 0 for (const otherGroup of GROUPS) { if (otherGroup === group) continue otherSourceCount += countSourceRunsForGroupRows(context.rowsByGroup[otherGroup]) otherPreviewCount += context.rowsByGroup[otherGroup].length } if (otherSourceCount + finalRuns.length > TASK_DETAIL_UPLOAD_LIMITS.maxTotalImageCount) { throw new TaskDetailAssetEditError( `本次图片最多保留 ${TASK_DETAIL_UPLOAD_LIMITS.maxTotalImageCount} 张,请分批处理`, ) } if (otherSourceCount + finalRuns.length + context.psdCount > TASK_DETAIL_UPLOAD_LIMITS.maxTotalSourceFileCount) { throw new TaskDetailAssetEditError( `本次原图和源文件最多保留 ${TASK_DETAIL_UPLOAD_LIMITS.maxTotalSourceFileCount} 个,请分批处理`, ) } if (otherPreviewCount + finalPreviewCount > TASK_DETAIL_UPLOAD_LIMITS.maxTotalPreviewCount) { throw new TaskDetailAssetEditError( `本次预览图最多保留 ${TASK_DETAIL_UPLOAD_LIMITS.maxTotalPreviewCount} 张,请分批处理`, ) } } function buildBatchDbChanges( context: EditContext, group: TaskDetailEditGroup, finalRuns: BatchRunState[], ): { db: PlannedDbChanges; finalRowUrls: string[] } { const db: PlannedDbChanges = { previewRowsToDelete: [], previewRowsToInsert: [], sortOrderUpdates: [], fileNameUpdates: [], removedPreviewUrls: [], } const survivingExistingIds = new Set() const finalRowUrls: string[] = [] let cursor = 0 for (const run of finalRuns) { for (const row of run.rows) { row.sortOrder = cursor finalRowUrls.push(row.fileUrl) if (row.kind === 'existing') { survivingExistingIds.add(row.id) if (row.originalSortOrder !== cursor) { db.sortOrderUpdates.push({ id: row.id, sortOrder: cursor }) } if (row.originalFileName !== row.fileName) { db.fileNameUpdates.push({ id: row.id, fileName: row.fileName }) } } else { db.previewRowsToInsert.push({ assetType: GROUP_PREVIEW_TYPES[group], fileUrl: row.fileUrl, fileName: row.fileName, fileSize: row.fileSize, sortOrder: cursor, skuCode: row.skuCode, barcodes: row.barcodes, }) } cursor += 1 } } for (const row of context.rowsByGroup[group]) { if (!survivingExistingIds.has(row.id)) { db.previewRowsToDelete.push(row.id) db.removedPreviewUrls.push({ url: row.fileUrl }) } } return { db, finalRowUrls } } function resolveInsertSeam( runs: BatchRunState[], afterAssetId: string, beforeAssetId: string, ): { afterRunIndex: number; afterRowIndex: number; beforeRunIndex: number; beforeRowIndex: number; sameRun: boolean } { const flattened = flattenBatchRows(runs) const matchesAssetId = (row: BatchRowState, assetId: string) => ( row.kind === 'existing' ? row.id === assetId : row.sourceAssetId === assetId ) const afterFlatIndex = flattened.findIndex((item) => matchesAssetId(item.row, afterAssetId)) const beforeFlatIndex = flattened.findIndex((item) => matchesAssetId(item.row, beforeAssetId)) if (afterFlatIndex < 0 || beforeFlatIndex < 0) { throw new TaskDetailAssetEditError('插图位置不存在或已被本次批量操作改动,请刷新后重试', 404) } if (beforeFlatIndex !== afterFlatIndex + 1) { throw new TaskDetailAssetEditError('插图位置必须落在相邻预览之间,请重新选择位置') } const after = flattened[afterFlatIndex] const before = flattened[beforeFlatIndex] if (after.runIndex === before.runIndex) { return { afterRunIndex: after.runIndex, afterRowIndex: after.rowIndex, beforeRunIndex: before.runIndex, beforeRowIndex: before.rowIndex, sameRun: true, } } if ( before.runIndex !== after.runIndex + 1 || after.rowIndex !== runs[after.runIndex].rows.length - 1 || before.rowIndex !== 0 ) { throw new TaskDetailAssetEditError('插图位置跨越了不连续的图片分组,无法安全处理') } return { afterRunIndex: after.runIndex, afterRowIndex: after.rowIndex, beforeRunIndex: before.runIndex, beforeRowIndex: before.rowIndex, sameRun: false, } } /** * 批量执行:全部操作在内存中按顺序应用完毕后,只做一次分组 ZIP 重建、 * 一次 all.zip 重建、一次数据库事务。复用 RebuildOutcome 与提交链路。 */ async function executeBatchRebuild( context: EditContext, operations: PreparedBatchOperation[], ): Promise { const { group, entryNames } = context const workspace = await createTaskDetailEditWorkspace() try { const extracted = context.groupZipAssets[group]?.fileUrl ? await extractZipEntriesToDirectory( path.isAbsolute(context.groupZipAssets[group]!.fileUrl) ? context.groupZipAssets[group]!.fileUrl : resolveStoredUploadPath(context.groupZipAssets[group]!.fileUrl), workspace.dir, ) : [] const finalRuns: BatchRunState[] = context.runs.map((run, index) => ({ entry: { path: extracted[index]?.filePath || '', name: entryNames[index], size: extracted[index]?.size || 0, }, rows: run.rows.map((row) => createExistingBatchRowState(row)), })) const handledAssetIds = new Set() const repeatedInsertBoundaries = new Map() for (const operation of operations) { if (operation.assetId) { if (handledAssetIds.has(operation.assetId)) { throw new TaskDetailAssetEditError('同一张图在一次批量提交中只能操作一次,请分开提交') } handledAssetIds.add(operation.assetId) } if (operation.mutation.kind === 'append') { if (group !== operation.mutation.group) { throw new TaskDetailAssetEditError('追加图片只支持当前分组,请刷新后重试') } const entryName = ensureUniqueEntryName( operation.mutation.source.fileName, finalRuns.map((run) => run.entry.name), '追加图片的文件名与包内其他文件重名,请先改名后再追加', ) finalRuns.push(await buildNewBatchRunState({ source: operation.mutation.source, entryName, group, materialId: context.materialId, entryIndex: finalRuns.length, workspaceDir: workspace.dir, skuCode: operation.mutation.skuCode, barcodes: operation.mutation.barcodes, })) continue } if (operation.mutation.kind === 'insert') { if (group !== 'detail') { throw new TaskDetailAssetEditError('插图仅支持详情图分组') } const seamKey = `${operation.mutation.afterAssetId}\u0000${operation.mutation.beforeAssetId}` const existingEntryNames = finalRuns.map((run) => run.entry.name) const insertedEntryName = ensureUniqueEntryName( operation.mutation.source.fileName, existingEntryNames, '插入图片的文件名与包内其他文件重名,请先改名后再插入', ) const repeatedBoundary = repeatedInsertBoundaries.get(seamKey) if (repeatedBoundary) { const boundaryIndex = finalRuns.indexOf(repeatedBoundary) if (boundaryIndex < 0) { throw new TaskDetailAssetEditError('插图位置已被本次批量操作移除,请刷新后重试', 409) } const insertedRun = await buildNewBatchRunState({ source: operation.mutation.source, entryName: insertedEntryName, group, materialId: context.materialId, entryIndex: boundaryIndex, workspaceDir: workspace.dir, }) finalRuns.splice(boundaryIndex, 0, insertedRun) continue } const seam = resolveInsertSeam( finalRuns, operation.mutation.afterAssetId, operation.mutation.beforeAssetId, ) if (!seam.sameRun) { const boundaryRun = finalRuns[seam.beforeRunIndex] const insertedRun = await buildNewBatchRunState({ source: operation.mutation.source, entryName: insertedEntryName, group, materialId: context.materialId, entryIndex: seam.afterRunIndex + 1, workspaceDir: workspace.dir, }) finalRuns.splice(seam.beforeRunIndex, 0, insertedRun) repeatedInsertBoundaries.set(seamKey, boundaryRun) continue } const targetRun = finalRuns[seam.afterRunIndex] const sourceAssetIds = targetRun.rows.map((row) => ( row.kind === 'existing' ? row.id : row.sourceAssetId || null )) if (sourceAssetIds.some((assetId) => !assetId)) { throw new TaskDetailAssetEditError('长图接缝已被本次批量操作改变,请刷新后重试', 409) } const splitSources = await splitRunSourceAtDetailSeam({ run: { key: context.runs[0]?.key || targetRun.entry.name, rows: targetRun.rows.map((row) => ({ id: row.kind === 'existing' ? row.id : `new-${row.sortOrder}`, assetType: GROUP_PREVIEW_TYPES[group], fileUrl: row.fileUrl, fileName: row.fileName, sortOrder: row.sortOrder, skuCode: row.skuCode, })), legacyIndex: null, }, entry: targetRun.entry, afterRowIndex: seam.afterRowIndex, workspaceDir: workspace.dir, existingEntryNames, }) const topRun = await buildNewBatchRunState({ source: splitSources.top, entryName: splitSources.top.entryName, group, materialId: context.materialId, entryIndex: seam.afterRunIndex, workspaceDir: workspace.dir, sourceAssetIds: sourceAssetIds.slice(0, seam.afterRowIndex + 1) as string[], }) const insertedRun = await buildNewBatchRunState({ source: operation.mutation.source, entryName: insertedEntryName, group, materialId: context.materialId, entryIndex: seam.afterRunIndex + 1, workspaceDir: workspace.dir, }) const bottomRun = await buildNewBatchRunState({ source: splitSources.bottom, entryName: splitSources.bottom.entryName, group, materialId: context.materialId, entryIndex: seam.afterRunIndex + 2, workspaceDir: workspace.dir, sourceAssetIds: sourceAssetIds.slice(seam.beforeRowIndex) as string[], }) finalRuns.splice(seam.afterRunIndex, 1, topRun, insertedRun, bottomRun) repeatedInsertBoundaries.set(seamKey, bottomRun) continue } const assetId = operation.assetId if (!assetId) { throw new TaskDetailAssetEditError('当前操作缺少目标图片,无法继续') } const located = findBatchRowByAssetId(finalRuns, assetId) if (!located) { throw new TaskDetailAssetEditError('要编辑的图片不存在或已被本次批量操作移除', 404) } const run = finalRuns[located.runIndex] if (operation.mutation.kind === 'delete') { finalRuns.splice(located.runIndex, 1) continue } if (operation.mutation.kind === 'rename') { const originalExtension = getExtension(run.entry.name) const newBaseName = operation.mutation.newBaseName const finalEntryName = `${newBaseName}${originalExtension}` const conflict = finalRuns.some((candidate, index) => index !== located.runIndex && candidate.entry.name === finalEntryName) if (conflict) { throw new TaskDetailAssetEditError(`改名后的「${finalEntryName}」与包内其他文件重名,请换一个名字`, 409) } run.entry = { ...run.entry, name: finalEntryName } run.rows = run.rows.map((row) => { const nextName = buildRenamedPreviewFileName(row.fileName, newBaseName) if (!nextName || nextName === row.fileName) return row return { ...row, fileName: nextName } }) continue } const finalEntryName = buildFinalEntryName(run.entry.name, operation.mutation.source.fileName) const conflict = finalRuns.some((candidate, index) => index !== located.runIndex && candidate.entry.name === finalEntryName) if (conflict) { throw new TaskDetailAssetEditError(`替换后的「${finalEntryName}」与包内其他文件重名,请先改名再替换`, 409) } const barcodes = group === 'sku' ? await loadRunBarcodes( run.rows .filter((row): row is ExistingBatchRowState => row.kind === 'existing') .map((row) => row.id), ) : [] finalRuns[located.runIndex] = await buildNewBatchRunState({ source: operation.mutation.source, entryName: finalEntryName, group, materialId: context.materialId, entryIndex: located.runIndex, workspaceDir: workspace.dir, skuCode: run.rows[0]?.skuCode || null, barcodes, }) } assertFinalBatchLimits(context, group, finalRuns) const { db, finalRowUrls } = buildBatchDbChanges(context, group, finalRuns) let newGroupZip: RebuildOutcome['newGroupZip'] = null if (finalRuns.length > 0) { newGroupZip = await createZipFromFiles( finalRuns.map((run) => run.entry), context.materialId, context.productModel || 'product', '任务-详情', GROUP_ZIP_LABELS[group], context.authorName || undefined, context.categoryName || undefined, ) } const members: Array<{ path: string; name: string }> = [] for (const memberGroup of GROUPS) { if (memberGroup === group) { if (newGroupZip) { members.push({ path: resolveStoredUploadPath(newGroupZip.filePath), name: newGroupZip.fileName, }) } continue } const memberAsset = context.groupZipAssets[memberGroup] if (memberAsset?.fileUrl) { members.push({ path: resolveStoredUploadPath(memberAsset.fileUrl), name: memberAsset.fileName || '', }) } } let newAllZip: RebuildOutcome['newAllZip'] = null if (members.length > 0) { newAllZip = await createZipFromZips( members, context.materialId, context.productModel || 'product', '任务-详情', ) } const finalRowCountByGroup: Record = { main: context.rowsByGroup.main.length, sku: context.rowsByGroup.sku.length, detail: context.rowsByGroup.detail.length, } finalRowCountByGroup[group] = finalRowUrls.length const firstPreviewUrlByGroup: Record = { main: null, sku: null, detail: null, } if (finalRowUrls.length > 0) { firstPreviewUrlByGroup[group] = finalRowUrls[0] } return { nextEntryNames: finalRuns.map((run) => run.entry.name), newGroupZip, newAllZip, db, finalRowCountByGroup, firstPreviewUrlByGroup, } } finally { await workspace.cleanup() } } async function executeRebuild( context: EditContext, mutation: EditMutation, ): Promise { const { group, entryNames, targetEntryIndex } = context const workspace = await createTaskDetailEditWorkspace() try { const groupZip = context.groupZipAssets[group]! const groupZipAbsolute = path.isAbsolute(groupZip.fileUrl) ? groupZip.fileUrl : resolveStoredUploadPath(groupZip.fileUrl) const extracted = await extractZipEntriesToDirectory(groupZipAbsolute, workspace.dir) const finalEntries: Array<{ path: string; name: string; size: number }> = extracted.map( (item, index) => ({ path: item.filePath, name: entryNames[index], size: item.size, }), ) let finalEntryName = entryNames[targetEntryIndex] if (mutation.kind === 'delete') { finalEntries.splice(targetEntryIndex, 1) } else if (mutation.kind === 'rename') { const originalExtension = getExtension(finalEntryName) finalEntryName = `${mutation.newBaseName}${originalExtension}` const conflict = finalEntries.some( (entry, index) => index !== targetEntryIndex && entry.name === finalEntryName, ) if (conflict) { throw new TaskDetailAssetEditError('包内已存在同名文件,请换一个名字', 409) } finalEntries[targetEntryIndex] = { ...finalEntries[targetEntryIndex], name: finalEntryName } } else { finalEntryName = buildFinalEntryName(entryNames[targetEntryIndex], mutation.replacementFileName) const conflict = finalEntries.some( (entry, index) => index !== targetEntryIndex && entry.name === finalEntryName, ) if (conflict) { throw new TaskDetailAssetEditError('替换后的文件名与包内其他文件重名,请先改名再替换', 409) } finalEntries[targetEntryIndex] = { path: mutation.filePath, name: finalEntryName, size: 0, } } let newGroupZip: RebuildOutcome['newGroupZip'] = null if (finalEntries.length > 0) { newGroupZip = await createZipFromFiles( finalEntries, context.materialId, context.productModel || 'product', '任务-详情', GROUP_ZIP_LABELS[group], context.authorName || undefined, context.categoryName || undefined, ) } const members: Array<{ path: string; name: string }> = [] for (const memberGroup of GROUPS) { if (memberGroup === group) { if (newGroupZip) { members.push({ path: resolveStoredUploadPath(newGroupZip.filePath), name: newGroupZip.fileName, }) } continue } const memberAsset = context.groupZipAssets[memberGroup] if (memberAsset?.fileUrl) { members.push({ path: resolveStoredUploadPath(memberAsset.fileUrl), name: memberAsset.fileName || '', }) } } let newAllZip: RebuildOutcome['newAllZip'] = null if (members.length > 0) { newAllZip = await createZipFromZips( members, context.materialId, context.productModel || 'product', '任务-详情', ) } const db = await planPreviewRowChanges(context, mutation, finalEntryName, workspace.dir) // 计算各组最终预览行数与首图地址(用于封面回退和整套判定) const finalRowCountByGroup: Record = { main: context.rowsByGroup.main.length, sku: context.rowsByGroup.sku.length, detail: context.rowsByGroup.detail.length, } finalRowCountByGroup[group] = context.rowsByGroup[group].length - db.previewRowsToDelete.length + db.previewRowsToInsert.length const firstPreviewUrlByGroup: Record = { main: null, sku: null, detail: null, } if (finalRowCountByGroup[group] > 0) { const candidates = [ ...context.rowsByGroup[group] .filter((row) => !db.previewRowsToDelete.includes(row.id)) .map((row) => ({ url: row.fileUrl, sortOrder: row.sortOrder })), ...db.previewRowsToInsert.map((row) => ({ url: row.fileUrl, sortOrder: row.sortOrder, })), ].sort((left, right) => left.sortOrder - right.sortOrder) firstPreviewUrlByGroup[group] = candidates[0]?.url || null } return { nextEntryNames: finalEntries.map((entry) => entry.name), newGroupZip, newAllZip, db, finalRowCountByGroup, firstPreviewUrlByGroup, } } finally { await workspace.cleanup() } } // ---------- 落库与清理 ---------- function computeNextAssetTypes(context: EditContext, outcome: RebuildOutcome): Array<{ assetType: string }> { const types: Array<{ assetType: string }> = [] for (const group of GROUPS) { if (outcome.finalRowCountByGroup[group] > 0) { types.push({ assetType: GROUP_PREVIEW_TYPES[group] }, { assetType: group }) } } if (context.psdCount > 0) { types.push({ assetType: 'psd' }) } return types } async function commitRebuild( context: EditContext, outcome: RebuildOutcome, operationLabel: 'REPLACE_IMAGE' | 'RENAME_IMAGE' | 'DELETE_IMAGE' | 'BATCH_IMAGE_EDITS', ): Promise<{ nextIsFullSet: boolean }> { const { group } = context const previewType = GROUP_PREVIEW_TYPES[group] const insertedBarcodeLinks: Array<{ assetId: string; barcode: string }> = [] const result = await prisma.$transaction(async (tx) => { const groupZipAsset = context.groupZipAssets[group] if (outcome.newGroupZip) { const groupZipData = { fileUrl: outcome.newGroupZip.filePath, fileName: outcome.newGroupZip.fileName, fileSize: BigInt(outcome.newGroupZip.fileSize), } if (groupZipAsset) { await tx.materialAsset.update({ where: { id: groupZipAsset.id }, data: groupZipData, }) } else { await tx.materialAsset.create({ data: { materialId: context.materialId, assetType: group, sortOrder: 0, ...groupZipData, }, }) } } else if (groupZipAsset) { await tx.materialAsset.delete({ where: { id: groupZipAsset.id } }) } if (outcome.newAllZip) { const allData = { fileUrl: outcome.newAllZip.filePath, fileName: outcome.newAllZip.fileName, fileSize: BigInt(outcome.newAllZip.fileSize), } if (context.allZipAsset) { await tx.materialAsset.update({ where: { id: context.allZipAsset.id }, data: allData }) } else { await tx.materialAsset.create({ data: { materialId: context.materialId, assetType: 'all', sortOrder: 0, ...allData, }, }) } } else if (context.allZipAsset) { await tx.materialAsset.delete({ where: { id: context.allZipAsset.id } }) } if (outcome.db.previewRowsToDelete.length > 0) { await tx.materialAsset.deleteMany({ where: { id: { in: outcome.db.previewRowsToDelete } }, }) } if (outcome.db.previewRowsToInsert.length > 0) { await tx.materialAsset.createMany({ data: outcome.db.previewRowsToInsert.map((row) => ({ materialId: context.materialId, assetType: row.assetType, fileUrl: row.fileUrl, fileName: row.fileName, fileSize: row.fileSize, sortOrder: row.sortOrder, skuCode: row.skuCode, })), }) if (group === 'sku') { const insertedRows = await tx.materialAsset.findMany({ where: { materialId: context.materialId, assetType: previewType, fileUrl: { in: outcome.db.previewRowsToInsert.map((row) => row.fileUrl) }, }, select: { id: true, fileUrl: true }, }) const urlToId = new Map(insertedRows.map((row) => [row.fileUrl, row.id])) for (const row of outcome.db.previewRowsToInsert) { const assetId = urlToId.get(row.fileUrl) for (const barcode of row.barcodes) { if (assetId) { insertedBarcodeLinks.push({ assetId, barcode }) } } } if (insertedBarcodeLinks.length > 0) { await tx.materialAssetBarcode.createMany({ data: insertedBarcodeLinks, skipDuplicates: true, }) } } } for (const update of outcome.db.sortOrderUpdates) { await tx.materialAsset.update({ where: { id: update.id }, data: { sortOrder: update.sortOrder }, }) } for (const update of outcome.db.fileNameUpdates) { await tx.materialAsset.update({ where: { id: update.id }, data: { fileName: update.fileName }, }) } const nextIsFullSet = deriveTaskDetailIsFullSet( context.materialType, computeNextAssetTypes(context, outcome), false, ) const removedUrls = new Set(outcome.db.removedPreviewUrls.map((item) => item.url)) let nextPreviewUrl = context.currentPreviewUrl if (nextPreviewUrl && removedUrls.has(nextPreviewUrl)) { nextPreviewUrl = outcome.firstPreviewUrlByGroup.main || outcome.firstPreviewUrlByGroup.sku || outcome.firstPreviewUrlByGroup.detail || '' } await tx.material.update({ where: { id: context.materialId }, data: { previewUrl: nextPreviewUrl || '', zipFilePath: outcome.newAllZip?.filePath || '', zipFileName: outcome.newAllZip?.fileName || '', zipFileSize: BigInt(outcome.newAllZip?.fileSize || 0), isFullSet: nextIsFullSet, }, }) if (context.approvalRequestId) { const completed = await tx.taskDetailChangeRequest.updateMany({ where: { id: context.approvalRequestId, materialId: context.materialId, status: 'applying', }, data: { status: 'applied', appliedAt: new Date(), applyError: null, }, }) if (completed.count !== 1) { throw new TaskDetailAssetEditError('审批单状态已变化,正式素材未应用,请刷新后重试', 409) } } return { nextIsFullSet } }) // 数据与文件都已提交,流水写失败只记告警,不能让用户误以为编辑没生效。 await createMaterialLog({ materialId: context.materialId, userId: context.operatorUserId, action: 'UPDATE', changes: { assetOperation: { oldValue: null, newValue: operationLabel }, group: { oldValue: null, newValue: group }, entryName: { oldValue: context.targetEntryIndex >= 0 && context.targetEntryIndex < context.entryNames.length ? context.entryNames[context.targetEntryIndex] : null, newValue: context.targetEntryIndex >= 0 && context.targetEntryIndex < outcome.nextEntryNames.length ? outcome.nextEntryNames[context.targetEntryIndex] : null, }, entryCount: { oldValue: context.entryNames.length, newValue: outcome.nextEntryNames.length, }, }, }).catch((error) => { console.error('[task-detail-asset-edit] 素材日志写入失败:', error) }) return result } async function cleanupAfterCommit(context: EditContext, outcome: RebuildOutcome) { const tasks: Array> = [] const staleStoredPaths: string[] = [] const groupZip = context.groupZipAssets[context.group] if (groupZip && (!outcome.newGroupZip || groupZip.fileUrl !== outcome.newGroupZip.filePath)) { staleStoredPaths.push(groupZip.fileUrl) } if ( context.allZipAsset && (!outcome.newAllZip || context.allZipAsset.fileUrl !== outcome.newAllZip.filePath) ) { staleStoredPaths.push(context.allZipAsset.fileUrl) } for (const storedPath of staleStoredPaths) { tasks.push(deleteStoredUploadFileFromRemote(storedPath).catch(() => undefined)) } for (const removed of outcome.db.removedPreviewUrls) { tasks.push( deleteFileIfUnreferenced(removed.url, { excludeMaterialIds: [context.materialId], }).catch(() => undefined), ) } await Promise.allSettled(tasks) } async function runEditOperation(materialId: string, operation: () => Promise): Promise { const release = await acquireTaskMaterialCompletionLock(materialId) try { release.assertHeld() return await operation() } finally { await release() } } // ---------- 对外入口 ---------- export interface ReplaceTaskDetailImageInput { materialId: string assetId: string operatorUserId: string operatorRole?: string | null uploadId?: string stagedFile?: ServerTrustedTaskDetailStagedFile } export async function replaceTaskDetailImage(input: ReplaceTaskDetailImageInput) { return runEditOperation(input.materialId, async () => { const context = await loadEditContext(input) const source = await resolveMutationSource({ uploadId: input.uploadId, stagedFile: input.stagedFile, operatorUserId: input.operatorUserId, actionLabel: '替换图片', }) const outcome = await executeRebuild(context, { kind: 'replace', filePath: source.filePath, replacementFileName: source.fileName, buffer: source.buffer, }) const { nextIsFullSet } = await commitRebuild(context, outcome, 'REPLACE_IMAGE') await cleanupAfterCommit(context, outcome) if (input.uploadId) { await cleanupConsumedUploadSession(input.uploadId.trim()).catch(() => undefined) } if (!context.wasFullSet && nextIsFullSet) { void queueTaskDetailFullSetNotification(context.materialId) } if (context.group === 'sku' && outcome.db.previewRowsToInsert.length > 0) { try { const insertedAssets = await prisma.materialAsset.findMany({ where: { materialId: context.materialId, assetType: GROUP_PREVIEW_TYPES.sku, fileUrl: { in: outcome.db.previewRowsToInsert.map((row) => row.fileUrl) }, }, select: { id: true }, }) void queueTaskDetailBarcodeImagesUpdatedNotification( context.materialId, insertedAssets.map((row) => row.id), ) } catch (error) { console.error('[task-detail-asset-edit] SKU 图片更新通知排队失败:', error) } } return { success: true, entryName: outcome.nextEntryNames[context.targetRunIndex], } }) } export interface RenameTaskDetailImageInput { materialId: string assetId: string operatorUserId: string operatorRole?: string | null newFileName: string } export async function renameTaskDetailImage(input: RenameTaskDetailImageInput) { return runEditOperation(input.materialId, async () => { const context = await loadEditContext(input) const rawName = String(input.newFileName || '').trim() const baseName = sanitizeNewBaseName(stripExtension(rawName)) const requestedExtension = getExtension(rawName) const originalExtension = getExtension(context.entryNames[context.targetRunIndex]) if (requestedExtension && requestedExtension.toLowerCase() !== originalExtension.toLowerCase()) { throw new TaskDetailAssetEditError('只能修改文件名,不能更改扩展名') } const outcome = await executeRebuild(context, { kind: 'rename', newBaseName: baseName }) await commitRebuild(context, outcome, 'RENAME_IMAGE') await cleanupAfterCommit(context, outcome) return { success: true, entryName: outcome.nextEntryNames[context.targetRunIndex], } }) } export interface DeleteTaskDetailImageInput { materialId: string assetId: string operatorUserId: string operatorRole?: string | null } export async function deleteTaskDetailImage(input: DeleteTaskDetailImageInput) { return runEditOperation(input.materialId, async () => { const context = await loadEditContext(input) const outcome = await executeRebuild(context, { kind: 'delete' }) await commitRebuild(context, outcome, 'DELETE_IMAGE') await cleanupAfterCommit(context, outcome) if (!context.wasFullSet) { const nextIsFullSet = deriveTaskDetailIsFullSet( context.materialType, computeNextAssetTypes(context, outcome), false, ) if (nextIsFullSet) { void queueTaskDetailFullSetNotification(context.materialId) } } return { success: true } }) } // ---------- 批量编辑对外入口 ---------- export type BatchTaskDetailImageEditOperation = | { kind: 'delete'; assetId: string } | { kind: 'rename'; assetId: string; newFileName: string } | { kind: 'replace'; assetId: string; uploadId?: string; stagedFile?: ServerTrustedTaskDetailStagedFile } | { kind: 'append' group: 'main' | 'sku' uploadId?: string stagedFile?: ServerTrustedTaskDetailStagedFile skuCode?: string | null barcodes?: string[] } | { kind: 'insert' afterAssetId: string beforeAssetId: string uploadId?: string stagedFile?: ServerTrustedTaskDetailStagedFile } export interface BatchTaskDetailImageEditInput { materialId: string operatorUserId: string operatorRole?: string | null expectedMaterialUpdatedAt?: Date | string /** 仅审批服务传入;与素材更新在同一事务内落为 applied。 */ approvalRequestId?: string operations: BatchTaskDetailImageEditOperation[] } const MAX_BATCH_OPERATIONS = 100 /** * 批量单图编辑:一次提交一组删除/改名/替换操作。 * 限制:所有操作必须落在同一分组(主图/SKU/详情)内;同一张图只能出现一次。 * 全部操作要么整体成功,要么整体不变。 */ export async function batchEditTaskDetailImages(input: BatchTaskDetailImageEditInput) { const operations = Array.isArray(input.operations) ? input.operations : [] if (operations.length === 0) { throw new TaskDetailAssetEditError('批量操作不能为空') } if (operations.length > MAX_BATCH_OPERATIONS) { throw new TaskDetailAssetEditError(`一次最多提交 ${MAX_BATCH_OPERATIONS} 项操作,请分批提交`) } return runEditOperation(input.materialId, async () => { const seed = operations[0] const context = await loadEditContext({ materialId: input.materialId, operatorUserId: input.operatorUserId, operatorRole: input.operatorRole, assetId: seed.kind === 'insert' ? seed.afterAssetId : seed.kind === 'append' ? undefined : seed.assetId, group: seed.kind === 'append' ? seed.group : seed.kind === 'insert' ? 'detail' : undefined, allowEmptyGroup: seed.kind === 'append', expectedMaterialUpdatedAt: input.expectedMaterialUpdatedAt, approvalRequestId: input.approvalRequestId, }) const validAssetIds = new Set( context.runs.flatMap((run) => run.rows.map((row) => row.id)), ) for (const operation of operations) { if (operation.kind === 'append') { if (operation.group !== context.group) { throw new TaskDetailAssetEditError('批量操作只支持同一分组(主图/SKU/详情)内的图片') } continue } if (operation.kind === 'insert') { if ( context.group !== 'detail' || !validAssetIds.has(operation.afterAssetId) || !validAssetIds.has(operation.beforeAssetId) ) { throw new TaskDetailAssetEditError('批量操作只支持同一分组(主图/SKU/详情)内的图片') } continue } if (!validAssetIds.has(operation.assetId)) { throw new TaskDetailAssetEditError('批量操作只支持同一分组(主图/SKU/详情)内的图片') } } const prepared: PreparedBatchOperation[] = [] for (const operation of operations) { if (operation.kind === 'delete') { prepared.push({ assetId: operation.assetId, mutation: { kind: 'delete' } }) continue } if (operation.kind === 'rename') { const rawName = String(operation.newFileName || '').trim() const baseName = sanitizeNewBaseName(stripExtension(rawName)) if (!baseName) { throw new TaskDetailAssetEditError('文件名不能为空') } const runIndex = context.runs.findIndex((run) => run.rows.some((row) => row.id === operation.assetId), ) const requestedExtension = getExtension(rawName) const originalExtension = getExtension(context.entryNames[runIndex]) if (requestedExtension && requestedExtension.toLowerCase() !== originalExtension.toLowerCase()) { throw new TaskDetailAssetEditError('只能修改文件名,不能更改扩展名') } prepared.push({ assetId: operation.assetId, mutation: { kind: 'rename', newBaseName: baseName }, }) continue } if (operation.kind === 'replace') { const source = await resolveMutationSource({ uploadId: operation.uploadId, stagedFile: operation.stagedFile, operatorUserId: input.operatorUserId, actionLabel: '替换图片', }) prepared.push({ assetId: operation.assetId, mutation: { kind: 'replace', source }, cleanupUploadId: operation.uploadId?.trim() || undefined, }) continue } if (operation.kind === 'append') { const source = await resolveMutationSource({ uploadId: operation.uploadId, stagedFile: operation.stagedFile, operatorUserId: input.operatorUserId, actionLabel: '追加图片', }) prepared.push({ mutation: { kind: 'append', group: operation.group, source, skuCode: operation.skuCode?.trim() || null, barcodes: Array.from(new Set((operation.barcodes || []).filter(Boolean))), }, cleanupUploadId: operation.uploadId?.trim() || undefined, }) continue } const source = await resolveMutationSource({ uploadId: operation.uploadId, stagedFile: operation.stagedFile, operatorUserId: input.operatorUserId, actionLabel: '插入图片', }) prepared.push({ mutation: { kind: 'insert', afterAssetId: operation.afterAssetId, beforeAssetId: operation.beforeAssetId, source, }, cleanupUploadId: operation.uploadId?.trim() || undefined, }) } const outcome = await executeBatchRebuild(context, prepared) const { nextIsFullSet } = await commitRebuild(context, outcome, 'BATCH_IMAGE_EDITS') await cleanupAfterCommit(context, outcome) for (const operation of prepared) { if (operation.cleanupUploadId) { await cleanupConsumedUploadSession(operation.cleanupUploadId).catch(() => undefined) } } if (!context.wasFullSet && nextIsFullSet) { void queueTaskDetailFullSetNotification(context.materialId) } if (context.group === 'sku' && outcome.db.previewRowsToInsert.length > 0) { try { const insertedAssets = await prisma.materialAsset.findMany({ where: { materialId: context.materialId, assetType: GROUP_PREVIEW_TYPES.sku, fileUrl: { in: outcome.db.previewRowsToInsert.map((row) => row.fileUrl) }, }, select: { id: true }, }) void queueTaskDetailBarcodeImagesUpdatedNotification( context.materialId, insertedAssets.map((row) => row.id), ) } catch (error) { console.error('[task-detail-asset-edit] SKU 图片更新通知排队失败:', error) } } return { success: true, appliedCount: prepared.length, entryCount: outcome.nextEntryNames.length, } }) }