3187 lines
108 KiB
TypeScript
3187 lines
108 KiB
TypeScript
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 {
|
||
canAccessMaterialPrivacy,
|
||
TASK_DETAIL_PRIVACY_SELECT,
|
||
} from '@/lib/task-detail-privacy'
|
||
import { saveTaskDetailPreview } from '@/lib/task-detail-preview-storage'
|
||
import {
|
||
queueTaskDetailBarcodeImagesUpdatedNotification,
|
||
queueTaskDetailFullSetNotification,
|
||
} from '@/lib/external-auto-listing'
|
||
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
|
||
taskDetailPrivate: boolean
|
||
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[]
|
||
runEntryIndexes: number[]
|
||
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,
|
||
...TASK_DETAIL_PRIVACY_SELECT,
|
||
materialType: true,
|
||
taskStatus: true,
|
||
productModel: true,
|
||
authorName: true,
|
||
categoryName: true,
|
||
previewUrl: true,
|
||
isFullSet: true,
|
||
updatedAt: true,
|
||
},
|
||
})
|
||
|
||
if (!material) {
|
||
throw new TaskDetailAssetEditError('素材不存在或已被删除', 404)
|
||
}
|
||
if (!(await canAccessMaterialPrivacy(material, options.operatorUserId))) {
|
||
throw new TaskDetailAssetEditError('您没有权限编辑此素材', 403)
|
||
}
|
||
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,
|
||
taskDetailPrivate: Boolean(material.taskDetailPrivate),
|
||
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: [],
|
||
runEntryIndexes: [],
|
||
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,
|
||
taskDetailPrivate: Boolean(material.taskDetailPrivate),
|
||
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,
|
||
runEntryIndexes,
|
||
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
|
||
isPrivate: boolean
|
||
sourceBaseName: string
|
||
entryIndex: number
|
||
workspaceDir: string
|
||
generatedPreviewLocations: 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 saveTaskDetailPreview(
|
||
previewBuffer,
|
||
options.materialId,
|
||
options.group,
|
||
fileName,
|
||
options.isPrivate,
|
||
)
|
||
rememberGeneratedPrivatePreview(options.generatedPreviewLocations, fileUrl)
|
||
previews.push({ fileName, fileUrl, size: previewBuffer.length })
|
||
}
|
||
return previews
|
||
}
|
||
|
||
/** Read-only submission gate: the same ZIP/preview mapping used by approval application. */
|
||
export async function assertTaskDetailImageEditReady(input: {
|
||
materialId: string
|
||
operatorUserId: string
|
||
operatorRole?: string | null
|
||
group: TaskDetailEditGroup
|
||
}): Promise<void> {
|
||
await loadEditContext({ ...input, allowEmptyGroup: true })
|
||
}
|
||
|
||
/** 按完成链路的服务端兜底参数生成预览(800x1200 webp,长图切片)。 */
|
||
async function generateServerPreviews(
|
||
source: ResolvedMutationSource,
|
||
group: TaskDetailEditGroup,
|
||
materialId: string,
|
||
isPrivate: boolean,
|
||
sourceBaseName: string,
|
||
entryIndex: number,
|
||
workspaceDir: string,
|
||
generatedPreviewLocations: string[],
|
||
): Promise<Array<{ fileName: string; fileUrl: string; size: number }>> {
|
||
if (source.animated) {
|
||
return generateAnimatedPreviews({
|
||
source,
|
||
group,
|
||
materialId,
|
||
isPrivate,
|
||
sourceBaseName,
|
||
entryIndex,
|
||
workspaceDir,
|
||
generatedPreviewLocations,
|
||
})
|
||
}
|
||
|
||
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 saveTaskDetailPreview(
|
||
buffers[sliceIndex],
|
||
materialId,
|
||
group,
|
||
fileName,
|
||
isPrivate,
|
||
)
|
||
rememberGeneratedPrivatePreview(generatedPreviewLocations, fileUrl)
|
||
previews.push({ fileName, fileUrl, size: buffers[sliceIndex].length })
|
||
}
|
||
return previews
|
||
}
|
||
|
||
function rememberGeneratedPrivatePreview(targets: string[], fileUrl: string) {
|
||
const normalized = String(fileUrl || '').trim().replace(/\\/g, '/')
|
||
if (!normalized || /^https?:\/\//i.test(normalized) || !normalized.includes('/private-previews/')) {
|
||
return
|
||
}
|
||
targets.push(fileUrl)
|
||
}
|
||
|
||
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
|
||
generatedPreviewLocations: string[]
|
||
db: PlannedDbChanges
|
||
finalRowCountByGroup: Record<TaskDetailEditGroup, number>
|
||
firstPreviewUrlByGroup: Record<TaskDetailEditGroup, string | null>
|
||
}
|
||
|
||
async function planPreviewRowChanges(
|
||
context: EditContext,
|
||
mutation: EditMutation,
|
||
finalEntryName: string,
|
||
workspaceDir: string,
|
||
generatedPreviewLocations: 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,
|
||
context.taskDetailPrivate,
|
||
stripExtension(finalEntryName),
|
||
targetEntryIndex,
|
||
workspaceDir,
|
||
generatedPreviewLocations,
|
||
)
|
||
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[]
|
||
/** 保留原始源图身份;接缝拆分出的两半仍属于同一张待删除源图。 */
|
||
sourceRunIndex?: number
|
||
}
|
||
|
||
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
|
||
isPrivate: boolean
|
||
entryIndex: number
|
||
workspaceDir: string
|
||
generatedPreviewLocations: string[]
|
||
skuCode?: string | null
|
||
barcodes?: string[]
|
||
sourceAssetIds?: string[]
|
||
}): Promise<BatchRunState> {
|
||
const previews = await generateServerPreviews(
|
||
options.source,
|
||
options.group,
|
||
options.materialId,
|
||
options.isPrivate,
|
||
stripExtension(options.entryName),
|
||
options.entryIndex,
|
||
options.workspaceDir,
|
||
options.generatedPreviewLocations,
|
||
)
|
||
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: BatchRowState } | null {
|
||
for (const located of flattenBatchRows(runs)) {
|
||
const matches = located.row.kind === 'existing'
|
||
? located.row.id === assetId
|
||
: located.row.sourceAssetId === assetId
|
||
if (matches) {
|
||
return {
|
||
runIndex: located.runIndex,
|
||
rowIndex: located.rowIndex,
|
||
row: located.row,
|
||
}
|
||
}
|
||
}
|
||
return null
|
||
}
|
||
|
||
function getBatchRowSourceAssetId(row: BatchRowState): string | null {
|
||
return row.kind === 'existing' ? row.id : row.sourceAssetId || null
|
||
}
|
||
|
||
function resolveBatchRunPreviewRows(
|
||
context: EditContext,
|
||
run: BatchRunState,
|
||
): PreviewRowLike[] {
|
||
const originalRows = new Map(
|
||
context.rowsByGroup[context.group].map((row) => [row.id, row]),
|
||
)
|
||
return run.rows.map((row) => {
|
||
const sourceAssetId = getBatchRowSourceAssetId(row)
|
||
const original = sourceAssetId ? originalRows.get(sourceAssetId) : null
|
||
if (!original) {
|
||
throw new TaskDetailAssetEditError('长图切片已被本次批量操作改变,请刷新后重试', 409)
|
||
}
|
||
return original
|
||
})
|
||
}
|
||
|
||
async function replaceDetailSliceInBatchRun(options: {
|
||
context: EditContext
|
||
run: BatchRunState
|
||
rowIndex: number
|
||
replacement: ResolvedMutationSource
|
||
finalEntryName: string
|
||
runIndex: number
|
||
workspaceDir: string
|
||
existingEntryNames: string[]
|
||
generatedPreviewLocations: string[]
|
||
}): Promise<BatchRunState[]> {
|
||
const previewRows = resolveBatchRunPreviewRows(options.context, options.run)
|
||
|
||
const sourceAssetIds = options.run.rows.map((row) => getBatchRowSourceAssetId(row))
|
||
if (sourceAssetIds.some((assetId) => !assetId)) {
|
||
throw new TaskDetailAssetEditError('长图切片已被本次批量操作改变,请刷新后重试', 409)
|
||
}
|
||
|
||
const previewRun: PreviewRun = {
|
||
key: options.run.entry.name,
|
||
rows: previewRows,
|
||
legacyIndex: null,
|
||
}
|
||
let topSource: (ResolvedMutationSource & { entryName: string }) | null = null
|
||
let bottomSource: (ResolvedMutationSource & { entryName: string }) | null = null
|
||
|
||
if (options.rowIndex > 0) {
|
||
const split = await splitRunSourceAtDetailSeam({
|
||
run: previewRun,
|
||
entry: options.run.entry,
|
||
afterRowIndex: options.rowIndex - 1,
|
||
workspaceDir: options.workspaceDir,
|
||
existingEntryNames: options.existingEntryNames,
|
||
})
|
||
topSource = split.top
|
||
}
|
||
if (options.rowIndex < options.run.rows.length - 1) {
|
||
const split = await splitRunSourceAtDetailSeam({
|
||
run: previewRun,
|
||
entry: options.run.entry,
|
||
afterRowIndex: options.rowIndex,
|
||
workspaceDir: options.workspaceDir,
|
||
existingEntryNames: options.existingEntryNames,
|
||
})
|
||
bottomSource = split.bottom
|
||
}
|
||
|
||
const replacementRuns: BatchRunState[] = []
|
||
if (topSource) {
|
||
const topRun = await buildNewBatchRunState({
|
||
source: topSource,
|
||
entryName: topSource.entryName,
|
||
group: 'detail',
|
||
materialId: options.context.materialId,
|
||
isPrivate: options.context.taskDetailPrivate,
|
||
entryIndex: options.runIndex,
|
||
workspaceDir: options.workspaceDir,
|
||
generatedPreviewLocations: options.generatedPreviewLocations,
|
||
sourceAssetIds: sourceAssetIds.slice(0, options.rowIndex) as string[],
|
||
})
|
||
topRun.sourceRunIndex = options.run.sourceRunIndex
|
||
replacementRuns.push(topRun)
|
||
}
|
||
|
||
const replacementRun = await buildNewBatchRunState({
|
||
source: options.replacement,
|
||
entryName: options.finalEntryName,
|
||
group: 'detail',
|
||
materialId: options.context.materialId,
|
||
isPrivate: options.context.taskDetailPrivate,
|
||
entryIndex: options.runIndex + replacementRuns.length,
|
||
workspaceDir: options.workspaceDir,
|
||
generatedPreviewLocations: options.generatedPreviewLocations,
|
||
})
|
||
replacementRuns.push(replacementRun)
|
||
|
||
if (bottomSource) {
|
||
const bottomRun = await buildNewBatchRunState({
|
||
source: bottomSource,
|
||
entryName: bottomSource.entryName,
|
||
group: 'detail',
|
||
materialId: options.context.materialId,
|
||
isPrivate: options.context.taskDetailPrivate,
|
||
entryIndex: options.runIndex + replacementRuns.length,
|
||
workspaceDir: options.workspaceDir,
|
||
generatedPreviewLocations: options.generatedPreviewLocations,
|
||
sourceAssetIds: sourceAssetIds.slice(options.rowIndex + 1) as string[],
|
||
})
|
||
bottomRun.sourceRunIndex = options.run.sourceRunIndex
|
||
replacementRuns.push(bottomRun)
|
||
}
|
||
|
||
return replacementRuns
|
||
}
|
||
|
||
function countSourceRunsForGroupRows(rows: PreviewRowLike[]): number {
|
||
if (rows.length === 0) return 0
|
||
const anchoredRuns = groupPreviewRowsIntoRuns(rows)
|
||
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()
|
||
const generatedPreviewLocations: string[] = []
|
||
|
||
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,
|
||
)
|
||
: []
|
||
|
||
let finalRuns: BatchRunState[] = context.runs.map((run, index) => {
|
||
const entryIndex = context.runEntryIndexes[index]
|
||
return {
|
||
sourceRunIndex: index,
|
||
entry: {
|
||
path: extracted[entryIndex]?.filePath || '',
|
||
name: entryNames[entryIndex],
|
||
size: extracted[entryIndex]?.size || 0,
|
||
},
|
||
rows: run.rows.map((row) => createExistingBatchRowState(row)),
|
||
}
|
||
})
|
||
|
||
const handledAssetIds = new Set<string>()
|
||
const repeatedInsertBoundaries = new Map<string, BatchRunState>()
|
||
const deletedAssetIds = new Set(operations
|
||
.filter((operation) => operation.mutation.kind === 'delete')
|
||
.map((operation) => operation.assetId))
|
||
const deletedSourceRuns = new Set(context.runs.flatMap((run, index) => (
|
||
run.rows.some((row) => deletedAssetIds.has(row.id)) ? [index] : []
|
||
)))
|
||
const isDeletedSourceRun = (run: BatchRunState) => (
|
||
run.sourceRunIndex !== undefined && deletedSourceRuns.has(run.sourceRunIndex)
|
||
)
|
||
const survivingEntryNames = () => finalRuns.filter((run) => !isDeletedSourceRun(run)).map((run) => run.entry.name)
|
||
|
||
// 所有接缝都来自用户提交时看到的原始图片序列。先验证,删除仅在最终输出时生效,
|
||
// 保留定位节点,使“删后图 + 在其前方插图”等组合不依赖操作排列顺序。
|
||
for (const operation of operations) {
|
||
if (operation.mutation.kind === 'insert') {
|
||
resolveInsertSeam(finalRuns, operation.mutation.afterAssetId, operation.mutation.beforeAssetId)
|
||
} else if (operation.assetId && operation.mutation.kind !== 'delete') {
|
||
const sourceIndex = context.runs.findIndex((run) => run.rows.some((row) => row.id === operation.assetId))
|
||
if (deletedSourceRuns.has(sourceIndex)) {
|
||
throw new TaskDetailAssetEditError('同一张源图不能同时删除并替换或改名,请只保留一种操作', 409)
|
||
}
|
||
}
|
||
}
|
||
|
||
for (const operation of operations) {
|
||
if (operation.assetId) {
|
||
if (handledAssetIds.has(operation.assetId)) {
|
||
throw new TaskDetailAssetEditError('同一张图在一次批量提交中只能操作一次,请分开提交')
|
||
}
|
||
handledAssetIds.add(operation.assetId)
|
||
}
|
||
|
||
if (operation.mutation.kind === 'delete') continue
|
||
|
||
if (operation.mutation.kind === 'append') {
|
||
if (group !== operation.mutation.group) {
|
||
throw new TaskDetailAssetEditError('追加图片只支持当前分组,请刷新后重试')
|
||
}
|
||
const entryName = ensureUniqueEntryName(
|
||
operation.mutation.source.fileName,
|
||
survivingEntryNames(),
|
||
'追加图片的文件名与包内其他文件重名,请先改名后再追加',
|
||
)
|
||
finalRuns.push(await buildNewBatchRunState({
|
||
source: operation.mutation.source,
|
||
entryName,
|
||
group,
|
||
materialId: context.materialId,
|
||
isPrivate: context.taskDetailPrivate,
|
||
entryIndex: finalRuns.length,
|
||
workspaceDir: workspace.dir,
|
||
generatedPreviewLocations,
|
||
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 = survivingEntryNames()
|
||
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,
|
||
isPrivate: context.taskDetailPrivate,
|
||
entryIndex: boundaryIndex,
|
||
workspaceDir: workspace.dir,
|
||
generatedPreviewLocations,
|
||
})
|
||
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,
|
||
isPrivate: context.taskDetailPrivate,
|
||
entryIndex: seam.afterRunIndex + 1,
|
||
workspaceDir: workspace.dir,
|
||
generatedPreviewLocations,
|
||
})
|
||
finalRuns.splice(seam.beforeRunIndex, 0, insertedRun)
|
||
repeatedInsertBoundaries.set(seamKey, boundaryRun)
|
||
continue
|
||
}
|
||
|
||
const targetRun = finalRuns[seam.afterRunIndex]
|
||
if (isDeletedSourceRun(targetRun)) {
|
||
// 整张原图将被删除:只拆分内存中的定位节点,不生成或上传会被丢弃的两半预览。
|
||
const topRun = { ...targetRun, rows: targetRun.rows.slice(0, seam.afterRowIndex + 1) }
|
||
const bottomRun = { ...targetRun, rows: targetRun.rows.slice(seam.beforeRowIndex) }
|
||
const insertedRun = await buildNewBatchRunState({
|
||
source: operation.mutation.source,
|
||
entryName: insertedEntryName,
|
||
group,
|
||
materialId: context.materialId,
|
||
isPrivate: context.taskDetailPrivate,
|
||
entryIndex: seam.afterRunIndex + 1,
|
||
workspaceDir: workspace.dir,
|
||
generatedPreviewLocations,
|
||
})
|
||
finalRuns.splice(seam.afterRunIndex, 1, topRun, insertedRun, bottomRun)
|
||
repeatedInsertBoundaries.set(seamKey, bottomRun)
|
||
continue
|
||
}
|
||
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,
|
||
isPrivate: context.taskDetailPrivate,
|
||
entryIndex: seam.afterRunIndex,
|
||
workspaceDir: workspace.dir,
|
||
generatedPreviewLocations,
|
||
sourceAssetIds: sourceAssetIds.slice(0, seam.afterRowIndex + 1) as string[],
|
||
})
|
||
const insertedRun = await buildNewBatchRunState({
|
||
source: operation.mutation.source,
|
||
entryName: insertedEntryName,
|
||
group,
|
||
materialId: context.materialId,
|
||
isPrivate: context.taskDetailPrivate,
|
||
entryIndex: seam.afterRunIndex + 1,
|
||
workspaceDir: workspace.dir,
|
||
generatedPreviewLocations,
|
||
})
|
||
const bottomRun = await buildNewBatchRunState({
|
||
source: splitSources.bottom,
|
||
entryName: splitSources.bottom.entryName,
|
||
group,
|
||
materialId: context.materialId,
|
||
isPrivate: context.taskDetailPrivate,
|
||
entryIndex: seam.afterRunIndex + 2,
|
||
workspaceDir: workspace.dir,
|
||
generatedPreviewLocations,
|
||
sourceAssetIds: sourceAssetIds.slice(seam.beforeRowIndex) as string[],
|
||
})
|
||
topRun.sourceRunIndex = targetRun.sourceRunIndex
|
||
bottomRun.sourceRunIndex = targetRun.sourceRunIndex
|
||
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 === '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 && !isDeletedSourceRun(candidate) && 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 && !isDeletedSourceRun(candidate) && candidate.entry.name === finalEntryName)
|
||
if (conflict) {
|
||
throw new TaskDetailAssetEditError(`替换后的「${finalEntryName}」与包内其他文件重名,请先改名再替换`, 409)
|
||
}
|
||
|
||
if (group === 'detail' && run.rows.length > 1) {
|
||
const replacementRuns = await replaceDetailSliceInBatchRun({
|
||
context,
|
||
run,
|
||
rowIndex: located.rowIndex,
|
||
replacement: operation.mutation.source,
|
||
finalEntryName,
|
||
runIndex: located.runIndex,
|
||
workspaceDir: workspace.dir,
|
||
existingEntryNames: survivingEntryNames(),
|
||
generatedPreviewLocations,
|
||
})
|
||
finalRuns.splice(located.runIndex, 1, ...replacementRuns)
|
||
continue
|
||
}
|
||
|
||
const barcodes = group === 'sku'
|
||
? await loadRunBarcodes(
|
||
run.rows
|
||
.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,
|
||
isPrivate: context.taskDetailPrivate,
|
||
entryIndex: located.runIndex,
|
||
workspaceDir: workspace.dir,
|
||
generatedPreviewLocations,
|
||
skuCode: run.rows[0]?.skuCode || null,
|
||
barcodes,
|
||
})
|
||
finalRuns[located.runIndex].sourceRunIndex = run.sourceRunIndex
|
||
}
|
||
|
||
finalRuns = finalRuns.filter((run) => !isDeletedSourceRun(run))
|
||
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,
|
||
generatedPreviewLocations,
|
||
db,
|
||
finalRowCountByGroup,
|
||
firstPreviewUrlByGroup,
|
||
}
|
||
} catch (error) {
|
||
await cleanupGeneratedPreviewLocations(generatedPreviewLocations)
|
||
throw error
|
||
} finally {
|
||
await workspace.cleanup()
|
||
}
|
||
}
|
||
|
||
async function executeRebuild(
|
||
context: EditContext,
|
||
mutation: EditMutation,
|
||
): Promise<RebuildOutcome> {
|
||
const { group, entryNames, targetEntryIndex } = context
|
||
const workspace = await createTaskDetailEditWorkspace()
|
||
const generatedPreviewLocations: string[] = []
|
||
|
||
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,
|
||
generatedPreviewLocations,
|
||
)
|
||
|
||
// 计算各组最终预览行数与首图地址(用于封面回退和整套判定)
|
||
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,
|
||
generatedPreviewLocations,
|
||
db,
|
||
finalRowCountByGroup,
|
||
firstPreviewUrlByGroup,
|
||
}
|
||
} catch (error) {
|
||
await cleanupGeneratedPreviewLocations(generatedPreviewLocations)
|
||
throw error
|
||
} 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 cleanupGeneratedPreviewLocations(fileUrls: string[]) {
|
||
const uniqueUrls = Array.from(new Set(
|
||
fileUrls.filter((fileUrl) => String(fileUrl || '').replace(/\\/g, '/').includes('/private-previews/')),
|
||
))
|
||
await Promise.allSettled(
|
||
uniqueUrls.map((fileUrl) =>
|
||
deleteFileIfUnreferenced(fileUrl).catch(() => undefined),
|
||
),
|
||
)
|
||
}
|
||
|
||
async function commitRebuildWithCleanup(
|
||
context: EditContext,
|
||
outcome: RebuildOutcome,
|
||
operationLabel: 'REPLACE_IMAGE' | 'RENAME_IMAGE' | 'DELETE_IMAGE' | 'BATCH_IMAGE_EDITS',
|
||
) {
|
||
try {
|
||
return await commitRebuild(context, outcome, operationLabel)
|
||
} catch (error) {
|
||
await cleanupGeneratedPreviewLocations(outcome.generatedPreviewLocations)
|
||
throw error
|
||
}
|
||
}
|
||
|
||
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 entryName = buildFinalEntryName(
|
||
context.entryNames[context.targetEntryIndex],
|
||
source.fileName,
|
||
)
|
||
const outcome = await executeBatchRebuild(context, [{
|
||
assetId: input.assetId,
|
||
mutation: { kind: 'replace', source },
|
||
}])
|
||
const { nextIsFullSet } = await commitRebuildWithCleanup(context, outcome, 'REPLACE_IMAGE')
|
||
await cleanupAfterCommit(context, outcome)
|
||
if (input.uploadId) {
|
||
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,
|
||
}
|
||
})
|
||
}
|
||
|
||
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 commitRebuildWithCleanup(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 commitRebuildWithCleanup(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 commitRebuildWithCleanup(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,
|
||
}
|
||
})
|
||
}
|