gongxiangp2/lib/task-detail-asset-edit.ts

2898 lines
97 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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<TaskDetailEditGroup, string> = {
main: '主图',
sku: 'SKU图',
detail: '详情图',
}
const GROUP_PREVIEW_TYPES: Record<TaskDetailEditGroup, string> = {
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<TaskDetailEditGroup, PreviewRowLike[]>
groupZipAssets: Record<TaskDetailEditGroup, { id: string; fileUrl: string; fileName: string | null } | null>
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<string, number>()
for (const base of entryBases) {
baseOccurrences.set(base, (baseOccurrences.get(base) || 0) + 1)
}
const assigned = new Array<number>(runs.length).fill(-1)
const usedEntries = new Set<number>()
// 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<number>()
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<number, PreviewRun>()
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<number>(runs.length).fill(-1)
const usedEntries = new Set<number>()
const baseOccurrences = new Map<string, number>()
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<EditContext> {
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<TaskDetailEditGroup, PreviewRowLike[]> = {
main: [],
sku: [],
detail: [],
}
const groupZipAssets: Record<TaskDetailEditGroup, { id: string; fileUrl: string; fileName: string | null } | null> = {
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<ResolvedMutationSource> {
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<ResolvedMutationSource> {
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<ReturnType<typeof stat>>
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<ResolvedMutationSource> {
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<Array<{ fileName: string; fileUrl: string; size: number }>> {
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<Array<{ fileName: string; fileUrl: string; size: number }>> {
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<TaskDetailEditGroup, number>
firstPreviewUrlByGroup: Record<TaskDetailEditGroup, string | null>
}
async function planPreviewRowChanges(
context: EditContext,
mutation: EditMutation,
finalEntryName: string,
workspaceDir: string,
): Promise<PlannedDbChanges> {
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<string[]> {
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<BatchRunState> {
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<string>()
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<RebuildOutcome> {
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<string>()
const repeatedInsertBoundaries = new Map<string, BatchRunState>()
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<TaskDetailEditGroup, number> = {
main: context.rowsByGroup.main.length,
sku: context.rowsByGroup.sku.length,
detail: context.rowsByGroup.detail.length,
}
finalRowCountByGroup[group] = finalRowUrls.length
const firstPreviewUrlByGroup: Record<TaskDetailEditGroup, string | null> = {
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<RebuildOutcome> {
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<TaskDetailEditGroup, number> = {
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<TaskDetailEditGroup, string | null> = {
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<Promise<unknown>> = []
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<T>(materialId: string, operation: () => Promise<T>): Promise<T> {
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,
}
})
}