diff --git a/app/api/materials/[id]/assets/[assetId]/route.ts b/app/api/materials/[id]/assets/[assetId]/route.ts new file mode 100644 index 0000000..be70be2 --- /dev/null +++ b/app/api/materials/[id]/assets/[assetId]/route.ts @@ -0,0 +1,140 @@ +/** + * 任务-详情单图编辑接口 + * + * PATCH - 重命名包内图片(只改文件名,不改扩展名) + * POST - 替换包内图片(body: { uploadId },文件先走受控分片上传) + * DELETE - 删除包内图片(连带重建分组 ZIP 与 all.zip) + * + * 仅任务-详情已完成素材可用;权限走 canManageMaterial + * (上传者 / 托管部门成员 / 系统管理员)。 + */ + +import { NextRequest, NextResponse } from 'next/server' +import { getServerSession } from 'next-auth' +import { authOptions } from '@/lib/auth' +import { + TaskDetailAssetEditError, + deleteTaskDetailImage, + renameTaskDetailImage, + replaceTaskDetailImage, +} from '@/lib/task-detail-asset-edit' + +export const runtime = 'nodejs' +export const dynamic = 'force-dynamic' +export const maxDuration = 10800 + +type RouteParams = { params: { id: string; assetId: string } } + +function getSessionUser(session: unknown): { id: string; role?: string | null } | null { + const user = (session as { user?: { id?: string; role?: string } } | null)?.user + if (!user?.id) { + return null + } + return { id: user.id, role: user.role ?? null } +} + +function handleEditError(error: unknown) { + if (error instanceof TaskDetailAssetEditError) { + return NextResponse.json( + { success: false, error: error.message }, + { status: error.statusCode }, + ) + } + console.error('[materials/asset-edit] error:', error) + return NextResponse.json( + { success: false, error: '图片编辑失败,请稍后重试' }, + { status: 500 }, + ) +} + +export async function PATCH( + request: NextRequest, + { params }: RouteParams, +) { + try { + const session = await getServerSession(authOptions) + const operator = getSessionUser(session) + if (!operator) { + return NextResponse.json({ success: false, error: '未登录' }, { status: 401 }) + } + + const body = await request.json().catch(() => null) + const newFileName = String((body as { newFileName?: unknown } | null)?.newFileName || '').trim() + if (!newFileName) { + return NextResponse.json( + { success: false, error: '缺少新文件名' }, + { status: 400 }, + ) + } + + const result = await renameTaskDetailImage({ + materialId: params.id, + assetId: params.assetId, + operatorUserId: operator.id, + operatorRole: operator.role, + newFileName, + }) + + return NextResponse.json({ success: true, data: result }) + } catch (error) { + return handleEditError(error) + } +} + +export async function POST( + request: NextRequest, + { params }: RouteParams, +) { + try { + const session = await getServerSession(authOptions) + const operator = getSessionUser(session) + if (!operator) { + return NextResponse.json({ success: false, error: '未登录' }, { status: 401 }) + } + + const body = await request.json().catch(() => null) + const uploadId = String((body as { uploadId?: unknown } | null)?.uploadId || '').trim() + if (!uploadId) { + return NextResponse.json( + { success: false, error: '缺少替换文件的上传会话,请重新选择文件' }, + { status: 400 }, + ) + } + + const result = await replaceTaskDetailImage({ + materialId: params.id, + assetId: params.assetId, + operatorUserId: operator.id, + operatorRole: operator.role, + uploadId, + }) + + return NextResponse.json({ success: true, data: result }) + } catch (error) { + return handleEditError(error) + } +} + +export async function DELETE( + _request: NextRequest, + { params }: RouteParams, +) { + try { + const session = await getServerSession(authOptions) + const operator = getSessionUser(session) + if (!operator) { + return NextResponse.json({ success: false, error: '未登录' }, { status: 401 }) + } + + const result = await deleteTaskDetailImage({ + materialId: params.id, + assetId: params.assetId, + operatorUserId: operator.id, + operatorRole: operator.role, + }) + + return NextResponse.json({ success: true, data: result }) + } catch (error) { + return handleEditError(error) + } +} diff --git a/app/api/materials/[id]/assets/route.ts b/app/api/materials/[id]/assets/route.ts index 63060be..5f921d7 100644 --- a/app/api/materials/[id]/assets/route.ts +++ b/app/api/materials/[id]/assets/route.ts @@ -20,6 +20,7 @@ import { canManageMaterial, type MaterialAuthorizationUser, } from '@/lib/material-authorization'; +import { isProductPackageType } from '@/lib/material-type-mapper'; type AssetRouteSessionUser = MaterialAuthorizationUser & { department?: string | null; @@ -174,6 +175,7 @@ export async function DELETE( select: { id: true, userId: true, + materialType: true, previewUrl: true, zipFilePath: true, zipFileName: true, @@ -196,6 +198,18 @@ export async function DELETE( ); } + // 任务-详情的资产是打包产物:只删记录会让 ZIP 里仍保留旧图。 + // 单图删除必须走 /assets/[assetId] 接口(会重建分组 ZIP 和 all.zip)。 + if (isProductPackageType(material.materialType)) { + return NextResponse.json( + { + success: false, + error: '任务-详情素材请使用单图编辑接口删除图片,直接删除会导致下载包与预览不一致', + }, + { status: 409 }, + ); + } + // 验证资产是否属于该素材 const asset = await prisma.materialAsset.findUnique({ where: { id: assetId }, diff --git a/app/api/materials/share/route.ts b/app/api/materials/share/route.ts index a564ff3..92cefb3 100644 --- a/app/api/materials/share/route.ts +++ b/app/api/materials/share/route.ts @@ -12,6 +12,7 @@ import { } from '@/lib/department-permission'; import { isProductPackageType, is3DOriginalType, needsAssetsList } from '@/lib/material-type-mapper'; import { deriveTaskDetailIsFullSet } from '@/lib/task-detail-full-set'; +import { canManageMaterial } from '@/lib/material-authorization'; import { detectFileKindFromName, getCertificateReportFileKindByAssetType } from '@/lib/certificate-report'; import { DownloadErrorCode } from '@/lib/download-errors'; import { getVideoPreviewPolicyFields } from '@/lib/video-download-policy'; @@ -322,6 +323,16 @@ export async function GET(request: NextRequest) { // 与 DELETE /api/materials 当前契约保持一致:仅系统管理员角色可删除。 canDelete: Boolean(currentUser && isSystemAdmin(currentUser.role)), }; + + // 任务-详情单图编辑(替换/改名/删除)与元数据编辑权限口径不同: + // 走 canManageMaterial,托管部门的成员也能维护本部门素材。 + let canManageTaskDetailAssets = false; + if (userId && currentUser && isProductPackageType(material.materialType as unknown as string)) { + canManageTaskDetailAssets = await canManageMaterial( + { id: userId, role: currentUser.role, isSuperAdmin: currentUser.isSuperAdmin }, + { id: material.id, userId: material.userId } as any, + ); + } const response: any = { ...material, @@ -336,6 +347,7 @@ export async function GET(request: NextRequest) { isLiked, isFavorited, capabilities, + canManageTaskDetailAssets, assets: needsAssetsList(material.materialType as unknown as string) ? assets : undefined, canDownloadSource: needsAssetsList(material.materialType as unknown as string) ? canAccessSource : undefined, isFullSet: deriveTaskDetailIsFullSet( diff --git a/app/share/[id]/page.tsx b/app/share/[id]/page.tsx index bec7d6e..107c41a 100644 --- a/app/share/[id]/page.tsx +++ b/app/share/[id]/page.tsx @@ -29,6 +29,8 @@ import { parseApiError, parseFetchError } from '@/lib/error-handler' import { getCertificateReportDisplayTitle, isCertificateReportMaterialType } from '@/lib/certificate-report' import { isVideoMaterialType } from '@/lib/video-material-types' import { buildPublicMaterialShareUrl } from '@/lib/public-app-url' +import { chunkUpload } from '@/lib/chunk-upload' +import { TASK_DETAIL_UPLOAD_LIMITS } from '@/lib/task-detail-upload-policy' interface Material { id: string @@ -63,6 +65,7 @@ interface Material { taskFetchedAt?: string // 产品全案被获取时间 isFullSet?: boolean canDownloadSource?: boolean // 产品全案 PSD 下载权限(参考"详情模板"类型) + canManageTaskDetailAssets?: boolean // 任务-详情单图编辑权限(上传者/托管部门/管理员) sourceFileUrl?: string | null assets?: Array<{ id: string @@ -310,6 +313,21 @@ export default function SharePage() { const { confirmAction, confirmActionDialog } = useConfirmActionDialog() const pageRef = useRef(null) + // 任务-详情单图编辑状态 + const [taskDetailEditingAssetId, setTaskDetailEditingAssetId] = useState(null) + const [replaceUploadProgress, setReplaceUploadProgress] = useState(null) + const replaceInputRef = useRef(null) + const replaceTargetAssetIdRef = useRef(null) + const [renameDialogState, setRenameDialogState] = useState<{ + assetId: string + currentName: string + baseName: string + extension: string + value: string + error: string | null + saving: boolean + } | null>(null) + useGSAP(() => { if (loading || !material || typeof window.matchMedia !== 'function') return if (!pageRef.current?.querySelector('[data-detail-preview], [data-detail-meta]')) return @@ -907,6 +925,149 @@ export default function SharePage() { }) } + // ---- 任务-详情单图编辑 ---- + + const handleTaskDetailReplaceRequest = (asset: { id: string }) => { + replaceTargetAssetIdRef.current = asset.id + const input = replaceInputRef.current + if (input) { + input.value = '' + input.click() + } + } + + const uploadAndReplaceTaskDetailImage = async (file: File) => { + const targetAssetId = replaceTargetAssetIdRef.current + if (!targetAssetId || !material) return + + if (file.size > TASK_DETAIL_UPLOAD_LIMITS.maxSingleImageBytes) { + showToast( + `替换图片不能超过 ${Math.round(TASK_DETAIL_UPLOAD_LIMITS.maxSingleImageBytes / 1024 / 1024)}MB,请先压缩`, + { variant: 'error' }, + ) + replaceTargetAssetIdRef.current = null + return + } + + setTaskDetailEditingAssetId(targetAssetId) + setReplaceUploadProgress(0) + try { + const result = await chunkUpload(file, {}, { + skipComplete: true, + onProgress: (progress) => setReplaceUploadProgress(progress), + }) + const uploadId = (result as { uploadId?: string } | null)?.uploadId + if (!uploadId) { + throw new Error('上传未完成,请重试') + } + + showToast('图片已上传,正在重新打包素材…', { variant: 'success' }) + const response = await fetch(`/api/materials/${material.id}/assets/${targetAssetId}`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ uploadId }), + }) + const data = await response.json().catch(() => null) + if (!response.ok || !data?.success) { + throw new Error(data?.error || '替换失败,请稍后重试') + } + + showToast('图片已替换,预览和下载包已同步更新', { variant: 'success' }) + await fetchMaterial() + } catch (error) { + showToast(error instanceof Error ? error.message : '替换失败,请稍后重试', { variant: 'error' }) + } finally { + setTaskDetailEditingAssetId(null) + setReplaceUploadProgress(null) + replaceTargetAssetIdRef.current = null + } + } + + const openTaskDetailRenameDialog = (asset: { id: string; fileName?: string }) => { + const currentName = asset.fileName || '' + const dotIndex = currentName.lastIndexOf('.') + const baseName = dotIndex > 0 ? currentName.slice(0, dotIndex) : currentName + const extension = dotIndex > 0 ? currentName.slice(dotIndex) : '' + setRenameDialogState({ + assetId: asset.id, + currentName, + baseName, + extension, + value: baseName, + error: null, + saving: false, + }) + } + + const submitTaskDetailRename = async () => { + if (!renameDialogState || !material) return + const nextBaseName = renameDialogState.value.trim() + if (!nextBaseName) { + setRenameDialogState((prev) => prev ? { ...prev, error: '文件名不能为空' } : prev) + return + } + if (/[\\/:*?"<>|]/.test(nextBaseName)) { + setRenameDialogState((prev) => prev ? { ...prev, error: '文件名不能包含 \\ / : * ? " < > | 字符' } : prev) + return + } + if (nextBaseName === renameDialogState.baseName) { + setRenameDialogState(null) + return + } + + setRenameDialogState((prev) => prev ? { ...prev, saving: true, error: null } : prev) + try { + const response = await fetch( + `/api/materials/${material.id}/assets/${renameDialogState.assetId}`, + { + method: 'PATCH', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ newFileName: nextBaseName + renameDialogState.extension }), + }, + ) + const data = await response.json().catch(() => null) + if (!response.ok || !data?.success) { + throw new Error(data?.error || '重命名失败,请稍后重试') + } + showToast('文件名已更新', { variant: 'success' }) + setRenameDialogState(null) + await fetchMaterial() + } catch (error) { + setRenameDialogState((prev) => prev + ? { ...prev, saving: false, error: error instanceof Error ? error.message : '重命名失败' } + : prev) + } + } + + const handleTaskDetailDeleteRequest = async (asset: { id: string; fileName?: string }) => { + if (!material) return + await confirmAction({ + title: '删除这张图片?', + description: `「${asset.fileName || '未命名图片'}」将从素材和下载包中移除。`, + confirmLabel: '删除图片', + loadingLabel: '正在删除…', + tone: 'danger', + action: async () => { + setTaskDetailEditingAssetId(asset.id) + try { + const response = await fetch(`/api/materials/${material.id}/assets/${asset.id}`, { + method: 'DELETE', + }) + const data = await response.json().catch(() => null) + if (!response.ok || !data?.success) { + throw new Error(data?.error || '删除失败,请稍后重试') + } + showToast('图片已删除,下载包已同步更新', { variant: 'success' }) + await fetchMaterial() + } catch (error) { + showToast(error instanceof Error ? error.message : '删除失败,请稍后重试', { variant: 'error' }) + } finally { + setTaskDetailEditingAssetId(null) + } + }, + }) + } + if (loading) { return (
@@ -1007,6 +1168,11 @@ export default function SharePage() { }} canDownloadSource={material.canDownloadSource ?? false} // 产品全案PSD下载权限参考"详情模板"类型的 canDownloadSource 权限 showToast={showToast} + canManageAssets={Boolean(material.canManageTaskDetailAssets)} + editingAssetId={taskDetailEditingAssetId} + onRequestReplace={handleTaskDetailReplaceRequest} + onRequestRename={openTaskDetailRenameDialog} + onRequestDelete={(asset) => void handleTaskDetailDeleteRequest(asset)} onDownload={async (type, assetId) => { try { let directDownloadUrl = `/api/materials/download?id=${material.id}&type=${type}` @@ -1565,9 +1731,93 @@ export default function SharePage() { /> )} + {/* 任务-详情:替换图片的文件选择器 */} + { + const file = event.target.files?.[0] + if (file) { + void uploadAndReplaceTaskDetailImage(file) + } + event.target.value = '' + }} + /> + + {/* 任务-详情:重命名弹窗 */} + {renameDialogState && ( +
{ + if (!renameDialogState.saving) setRenameDialogState(null) + }} + > +
event.stopPropagation()} + > +

重命名图片

+

+ 只修改文件名,扩展名保持不变;保存后会重新打包下载文件。 +

+
+ { + const rawValue = event.target.value.replace(/[\\/:*?"<>|]/g, '') + setRenameDialogState((prev) => prev ? { ...prev, value: rawValue, error: null } : prev) + }} + onKeyDown={(event) => { + if (event.key === 'Enter' && !renameDialogState.saving) { + void submitTaskDetailRename() + } + }} + className="min-h-10 w-full rounded-l-[8px] border border-r-0 border-[#e7e8ea] bg-white px-3 text-[13px] text-[#1a1c1d] focus:border-[#1a1c1d] focus:outline-none" + aria-label="新文件名" + /> + + {renameDialogState.extension || '(无扩展名)'} + +
+ {renameDialogState.error && ( +

{renameDialogState.error}

+ )} +
+ + +
+
+
+ )} + {/* 统一的Toast提示 */} {toast && toast.show && ( - + )} {confirmActionDialog}
diff --git a/components/detail/ProductPackageDetail.tsx b/components/detail/ProductPackageDetail.tsx index 1c581fa..130627d 100644 --- a/components/detail/ProductPackageDetail.tsx +++ b/components/detail/ProductPackageDetail.tsx @@ -44,6 +44,96 @@ interface ProductPackageDetailProps { onDownload: (type: 'all' | 'main' | 'sku' | 'detail' | 'psd', assetId?: string) => Promise; onBack?: () => void; showToast?: (message: string, options?: { variant?: 'success' | 'error' }) => void; + /** 单图编辑权限(上传者 / 托管部门成员 / 系统管理员),由服务端判定 */ + canManageAssets?: boolean; + /** 正在编辑处理的资产 id,用于禁用与遮罩 */ + editingAssetId?: string | null; + onRequestReplace?: (asset: Asset) => void; + onRequestRename?: (asset: Asset) => void; + onRequestDelete?: (asset: Asset) => void; +} + +function AssetEditMenu({ + asset, + busy, + disabled, + onRequestReplace, + onRequestRename, + onRequestDelete, +}: { + asset: Asset; + busy: boolean; + disabled: boolean; + onRequestReplace: (asset: Asset) => void; + onRequestRename: (asset: Asset) => void; + onRequestDelete: (asset: Asset) => void; +}) { + const [open, setOpen] = useState(false); + + if (disabled) { + return busy ? ( + + 处理中… + + ) : null; + } + + return ( + + + {open && ( + <> + { + event.stopPropagation(); + setOpen(false); + }} + onMouseDown={(event) => { + event.preventDefault(); + }} + /> + + {[ + { label: '替换图片', action: () => { setOpen(false); onRequestReplace(asset); } }, + { label: '重命名', action: () => { setOpen(false); onRequestRename(asset); } }, + { label: '删除图片', action: () => { setOpen(false); onRequestDelete(asset); }, danger: true }, + ].map((item) => ( + + ))} + + + )} + + ); } export default function ProductPackageDetail({ @@ -52,6 +142,11 @@ export default function ProductPackageDetail({ onDownload, onBack, showToast, + canManageAssets = false, + editingAssetId = null, + onRequestReplace, + onRequestRename, + onRequestDelete, }: ProductPackageDetailProps) { // 当前预览的图片类型和索引 const [previewType, setPreviewType] = useState<'main' | 'sku'>('main'); @@ -165,46 +260,65 @@ export default function ProductPackageDetail({

主图 ({mainImages.length})

{mainImages.map((img, index) => ( - + {canManageAssets && onRequestReplace && onRequestRename && onRequestDelete && ( + <> + + {editingAssetId === img.id && ( + + )} + )} - +
))} @@ -216,7 +330,7 @@ export default function ProductPackageDetail({

详情图 ({detailImages.length})

{detailImages.map((img, index) => ( -
+
{`详情图 {index + 1}
+ {canManageAssets && onRequestReplace && onRequestRename && onRequestDelete && ( + <> + + {editingAssetId === img.id && ( + + 正在重新打包,请稍候… + + )} + + )}
))}
@@ -278,35 +409,51 @@ export default function ProductPackageDetail({

SKU图 ({skuImages.length})

{skuImages.map((img, index) => ( - + {canManageAssets && onRequestReplace && onRequestRename && onRequestDelete && ( + <> + + {editingAssetId === img.id && ( + + )} + )} - +
))} diff --git a/lib/__tests__/material-assets-authorization.test.ts b/lib/__tests__/material-assets-authorization.test.ts index dfdeda0..178b338 100644 --- a/lib/__tests__/material-assets-authorization.test.ts +++ b/lib/__tests__/material-assets-authorization.test.ts @@ -109,6 +109,17 @@ describe('material assets route authorization', () => { ;(getServerSession as jest.Mock).mockResolvedValue({ user: { id: 'user-1', role: 'USER', department: '设计部' }, }) + // 该接口只服务非打包类素材;任务-详情走单图编辑接口 + ;(prisma.material.findUnique as jest.Mock).mockResolvedValue({ + id: 'material-1', + userId: 'user-1', + materialType: '视频', + previewUrl: '', + zipFilePath: '', + zipFileName: '', + zipFileSize: BigInt(0), + displayImages: [], + }) ;(prisma.materialAsset.findUnique as jest.Mock).mockResolvedValue({ id: 'asset-1', materialId: 'material-1', @@ -147,4 +158,19 @@ describe('material assets route authorization', () => { fileCleanupError: null, }) }) + + test('任务-详情素材不得通过该接口直删资产(会导致 ZIP 与预览不一致)', async () => { + ;(getServerSession as jest.Mock).mockResolvedValue({ + user: { id: 'user-1', role: 'USER', department: '设计部' }, + }) + + const response = await DELETE({ + url: 'http://localhost/api/materials/material-1/assets?assetId=asset-1', + } as any, { params: { id: 'material-1' } }) + const body = await response.json() + + expect(response.status).toBe(409) + expect(body.error).toContain('单图编辑接口') + expect(prisma.materialAsset.findUnique).not.toHaveBeenCalled() + }) }) diff --git a/lib/__tests__/task-detail-asset-edit-route.test.ts b/lib/__tests__/task-detail-asset-edit-route.test.ts new file mode 100644 index 0000000..aa23475 --- /dev/null +++ b/lib/__tests__/task-detail-asset-edit-route.test.ts @@ -0,0 +1,118 @@ +/** @jest-environment node */ + +import { getServerSession } from 'next-auth' + +import { + DELETE, + PATCH, + POST, +} from '@/app/api/materials/[id]/assets/[assetId]/route' +import { + deleteTaskDetailImage, + renameTaskDetailImage, + replaceTaskDetailImage, +} from '@/lib/task-detail-asset-edit' + +jest.mock('next-auth', () => ({ getServerSession: jest.fn() })) +jest.mock('@/lib/auth', () => ({ authOptions: {} })) +jest.mock('@/lib/task-detail-asset-edit', () => ({ + TaskDetailAssetEditError: class TaskDetailAssetEditError extends Error { + statusCode: number + constructor(message: string, statusCode = 400) { + super(message) + this.statusCode = statusCode + } + }, + deleteTaskDetailImage: jest.fn(), + renameTaskDetailImage: jest.fn(), + replaceTaskDetailImage: jest.fn(), +})) + +const mockedDelete = deleteTaskDetailImage as jest.Mock +const mockedRename = renameTaskDetailImage as jest.Mock +const mockedReplace = replaceTaskDetailImage as jest.Mock + +describe('任务-详情单图编辑路由', () => { + beforeEach(() => { + jest.clearAllMocks() + }) + + test('未登录返回 401,不触发编辑逻辑', async () => { + ;(getServerSession as jest.Mock).mockResolvedValue(null) + + for (const response of [ + await PATCH({ json: async () => ({ newFileName: 'x' }) } as any, { params: { id: 'm1', assetId: 'a1' } }), + await POST({ json: async () => ({ uploadId: 'u1' }) } as any, { params: { id: 'm1', assetId: 'a1' } }), + await DELETE({} as any, { params: { id: 'm1', assetId: 'a1' } }), + ]) { + expect(response.status).toBe(401) + } + expect(mockedRename).not.toHaveBeenCalled() + expect(mockedReplace).not.toHaveBeenCalled() + expect(mockedDelete).not.toHaveBeenCalled() + }) + + test('PATCH 缺少新文件名返回 400', async () => { + ;(getServerSession as jest.Mock).mockResolvedValue({ user: { id: 'u1' } }) + const response = await PATCH({ json: async () => ({}) } as any, { params: { id: 'm1', assetId: 'a1' } }) + expect(response.status).toBe(400) + expect(mockedRename).not.toHaveBeenCalled() + }) + + test('POST 缺少 uploadId 返回 400', async () => { + ;(getServerSession as jest.Mock).mockResolvedValue({ user: { id: 'u1' } }) + const response = await POST({ json: async () => ({}) } as any, { params: { id: 'm1', assetId: 'a1' } }) + expect(response.status).toBe(400) + expect(mockedReplace).not.toHaveBeenCalled() + }) + + test('登录用户正常调用并透传参数', async () => { + ;(getServerSession as jest.Mock).mockResolvedValue({ user: { id: 'u1', role: 'USER' } }) + mockedRename.mockResolvedValue({ success: true }) + mockedReplace.mockResolvedValue({ success: true }) + mockedDelete.mockResolvedValue({ success: true }) + + await PATCH( + { json: async () => ({ newFileName: '新名字' }) } as any, + { params: { id: 'm1', assetId: 'a1' } }, + ) + expect(mockedRename).toHaveBeenCalledWith({ + materialId: 'm1', + assetId: 'a1', + operatorUserId: 'u1', + operatorRole: 'USER', + newFileName: '新名字', + }) + + await POST( + { json: async () => ({ uploadId: 'upload-1' }) } as any, + { params: { id: 'm1', assetId: 'a2' } }, + ) + expect(mockedReplace).toHaveBeenCalledWith({ + materialId: 'm1', + assetId: 'a2', + operatorUserId: 'u1', + operatorRole: 'USER', + uploadId: 'upload-1', + }) + + await DELETE({} as any, { params: { id: 'm1', assetId: 'a3' } }) + expect(mockedDelete).toHaveBeenCalledWith({ + materialId: 'm1', + assetId: 'a3', + operatorUserId: 'u1', + operatorRole: 'USER', + }) + }) + + test('权限类业务错误透传状态码与文案', async () => { + ;(getServerSession as jest.Mock).mockResolvedValue({ user: { id: 'intruder' } }) + const { TaskDetailAssetEditError: MockedError } = await import('@/lib/task-detail-asset-edit') + mockedDelete.mockRejectedValue(new (MockedError as any)('您没有权限编辑此素材', 403)) + + const response = await DELETE({} as any, { params: { id: 'm1', assetId: 'a1' } }) + expect(response.status).toBe(403) + const body = await response.json() + expect(body.error).toBe('您没有权限编辑此素材') + }) +}) diff --git a/lib/__tests__/task-detail-preview-runs.test.ts b/lib/__tests__/task-detail-preview-runs.test.ts new file mode 100644 index 0000000..f33948a --- /dev/null +++ b/lib/__tests__/task-detail-preview-runs.test.ts @@ -0,0 +1,114 @@ +/** @jest-environment node */ + +import { + buildServerPreviewFileName, + groupPreviewRowsIntoRuns, + matchRunsToZipEntries, + parseTaskDetailPreviewFileName, +} from '@/lib/task-detail-asset-edit' + +function row(id: string, fileName: string, sortOrder = 0): any { + return { id, assetType: 'main-preview', fileUrl: `s3://${id}`, fileName, sortOrder, skuCode: null } +} + +describe('任务-详情预览名解析', () => { + test('客户端单图命名提取来源基础名', () => { + expect(parseTaskDetailPreviewFileName('IMG_1001.webp')).toEqual({ + label: 'IMG_1001', + sliceIndex: null, + }) + }) + + test('客户端切片命名', () => { + expect(parseTaskDetailPreviewFileName('长图-001.webp')).toEqual({ + label: '长图', + sliceIndex: 1, + }) + }) + + test('服务端生成命名携带来源锚', () => { + const name = buildServerPreviewFileName('IMG_2002', 3) + expect(parseTaskDetailPreviewFileName(name)).toEqual({ + label: 'IMG_2002', + sliceIndex: null, + }) + }) + + test('非 webp 返回 null', () => { + expect(parseTaskDetailPreviewFileName('photo.jpg')).toBeNull() + }) +}) + +describe('任务-详情预览行切分', () => { + test('相邻同名但独立的单预览保持独立 run', () => { + const runs = groupPreviewRowsIntoRuns([ + row('a', 'cover.webp', 0), + row('b', 'cover.webp', 1), + ]) + expect(runs).not.toBeNull() + expect(runs!.length).toBe(2) + }) + + test('连续切片合并为一个 run,序号重置后开启新 run', () => { + const runs = groupPreviewRowsIntoRuns([ + row('a', 'detail-001.webp', 0), + row('b', 'detail-002.webp', 1), + row('c', 'detail-001.webp', 2), + row('d', 'detail-002.webp', 3), + ]) + expect(runs).not.toBeNull() + expect(runs!.length).toBe(2) + expect(runs![0].rows.map((item: any) => item.id)).toEqual(['a', 'b']) + expect(runs![1].rows.map((item: any) => item.id)).toEqual(['c', 'd']) + }) + + test('出现无法解析的名字时整体放弃切分', () => { + expect(groupPreviewRowsIntoRuns([row('a', 'unknown.png')])).toBeNull() + }) +}) + +describe('run 与 ZIP 条目的名字匹配', () => { + const entryNames = ['IMG_A.jpg', 'IMG_B.png', 'IMG_C.jpg'] + + test('按基础名唯一命中(顺序无关)', () => { + const runs = groupPreviewRowsIntoRuns([ + row('a', 'IMG_C.webp', 0), + row('b', 'IMG_A.webp', 1), + row('c', 'IMG_B.webp', 2), + ])! + expect(matchRunsToZipEntries(runs, entryNames)).toEqual([2, 0, 1]) + }) + + test('单个无锚 run 与剩余条目一一配对', () => { + const srvName = buildServerPreviewFileName('IMG_B', 1) + const runs = groupPreviewRowsIntoRuns([ + row('a', 'IMG_A.webp', 0), + row('b', srvName, 1), + row('c', 'IMG_C.webp', 2), + ])! + expect(matchRunsToZipEntries(runs, entryNames)).toEqual([0, 1, 2]) + }) + + test('两个以上无锚 run 拒绝编辑', () => { + // 历史服务端兜底命名(旧格式,不带来源锚)无法确认对应关系 + const runs = groupPreviewRowsIntoRuns([ + row('a', 'main-preview-1736000000000-0-aaa0000.webp', 0), + row('b', 'main-preview-1736000000000-1-bbb1111.webp', 1), + ])! + expect(() => matchRunsToZipEntries(runs, ['x.jpg', 'y.png'])).toThrow() + }) + + test('数量不一致直接拒绝', () => { + const runs = groupPreviewRowsIntoRuns([row('a', 'IMG_A.webp')])! + expect(() => matchRunsToZipEntries(runs, entryNames)).toThrow() + }) + + test('同名条目导致歧义时拒绝', () => { + // 预览行只有一份 cover,但包里有两个同名条目 + const runs = groupPreviewRowsIntoRuns([ + row('a', 'cover.webp', 0), + row('b', 'other.webp', 1), + ])! + expect(() => matchRunsToZipEntries(runs, ['cover.jpg', 'COVER.png'])).toThrow() + }) +}) diff --git a/lib/__tests__/task-detail-zip-entry-order.test.ts b/lib/__tests__/task-detail-zip-entry-order.test.ts new file mode 100644 index 0000000..7a27ce5 --- /dev/null +++ b/lib/__tests__/task-detail-zip-entry-order.test.ts @@ -0,0 +1,101 @@ +/** @jest-environment node */ + +import { mkdir, mkdtemp, readFile, rm, writeFile } from 'fs/promises' +import os from 'os' +import path from 'path' + +import { createZipFromFiles } from '@/lib/task-material-processor' +import { deleteStoredUploadFileFromRemote, syncStoredUploadFileToRemote } from '@/lib/remote-upload-sync' +import { extractZipEntriesToDirectory, listZipEntries } from '@/lib/zip-entry-reader' +import { groupPreviewRowsIntoRuns, matchRunsToZipEntries } from '@/lib/task-detail-asset-edit' + +jest.mock('@/lib/prisma', () => ({ prisma: {} })) +jest.mock('@/lib/s3', () => ({ deleteFromS3: jest.fn() })) +jest.mock('@/lib/public-display', () => ({ + buildMaterialPublicDisplayPath: jest.fn(), + uploadPublicDisplayToS3: jest.fn(), +})) +jest.mock('@/lib/image-processor', () => ({ compressImage: jest.fn() })) +jest.mock('@/lib/remote-upload-sync', () => ({ + deleteStoredUploadFileFromRemote: jest.fn().mockResolvedValue(undefined), + syncStoredUploadFileToRemote: jest.fn(), +})) +jest.mock('@/lib/material-upload-merge', () => ({ + acquireUploadMergeDiskReservation: jest.fn().mockResolvedValue(() => undefined), +})) + +/** + * 生产链路(archiver)并发打包不保证中央目录顺序等于传入顺序。 + * 单图编辑因此不依赖位置映射,而是按"来源文件名"锚定匹配。 + * 这里用真实打包产物验证:条目顺序无论怎样,名字匹配都能对上。 + */ +describe('生产打包链路的单图编辑前提', () => { + let uploadRoot: string + let previousUploadDir: string | undefined + + beforeEach(async () => { + previousUploadDir = process.env.UPLOAD_DIR + uploadRoot = await mkdtemp(path.join(os.tmpdir(), 'entry-order-')) + process.env.UPLOAD_DIR = uploadRoot + }) + + afterEach(async () => { + if (previousUploadDir === undefined) { + delete process.env.UPLOAD_DIR + } else { + process.env.UPLOAD_DIR = previousUploadDir + } + await rm(uploadRoot, { recursive: true, force: true }) + }) + + function row(id: string, fileName: string, sortOrder: number): any { + return { id, assetType: 'main-preview', fileUrl: `s3://${id}`, fileName, sortOrder, skuCode: null } + } + + test('条目内容完整解压;预览行与条目按名字正确配对(与顺序无关)', async () => { + const names = ['z-last.jpg', 'a-first.jpg', '中图.png', 'm-middle.jpg'] + const files: Array<{ path: string; name: string; size: number }> = [] + const sourceDir = path.join(uploadRoot, 'src') + await mkdir(sourceDir, { recursive: true }) + for (let index = 0; index < names.length; index += 1) { + const name = names[index] + const filePath = path.join(sourceDir, `file-${index}.bin`) + const bytes = Buffer.from(`${name}-${index}`) + await writeFile(filePath, bytes) + files.push({ path: filePath, name, size: bytes.length }) + } + + const result = await createZipFromFiles( + files, + 'material-order-check', + 'MODEL', + '任务-详情', + '主图', + '作者', + '店铺', + ) + const zipPath = path.join(uploadRoot, 'materials', result.fileName) + + // 解压:每个条目内容完整 + const destination = path.join(uploadRoot, 'extracted') + const extracted = await extractZipEntriesToDirectory(zipPath, destination) + expect(extracted.map((item) => item.entryName).sort()).toEqual([...names].sort()) + const byName = new Map(extracted.map((item) => [item.entryName, item])) + expect((await readFile(byName.get('中图.png')!.filePath)).toString()).toBe('中图.png-2') + + // 匹配:即使中央目录顺序被打乱,预览行仍能按基础名找到自己的条目 + const entries = await listZipEntries(zipPath) + const entryNames = entries.map((entry) => entry.name) + const runs = groupPreviewRowsIntoRuns([ + row('p0', 'z-last.webp', 0), + row('p1', 'a-first.webp', 1), + row('p2', '中图.webp', 2), + row('p3', 'm-middle.webp', 3), + ])! + const assigned = matchRunsToZipEntries(runs, entryNames) + expect(entryNames[assigned[0]]).toBe('z-last.jpg') + expect(entryNames[assigned[1]]).toBe('a-first.jpg') + expect(entryNames[assigned[2]]).toBe('中图.png') + expect(entryNames[assigned[3]]).toBe('m-middle.jpg') + }) +}) diff --git a/lib/__tests__/zip-entry-reader.test.ts b/lib/__tests__/zip-entry-reader.test.ts new file mode 100644 index 0000000..86ac87b --- /dev/null +++ b/lib/__tests__/zip-entry-reader.test.ts @@ -0,0 +1,122 @@ +/** @jest-environment node */ + +import AdmZip from 'adm-zip' +import { mkdtemp, readFile, readdir, rm } from 'fs/promises' +import { tmpdir } from 'os' +import path from 'path' + +import { + createTaskDetailEditWorkspace, + extractZipEntriesToDirectory, + listZipEntries, +} from '@/lib/zip-entry-reader' + +jest.mock('@/lib/upload-paths', () => ({ + getUploadRootDir: jest.fn(() => tempRoot), + resolveStoredUploadPath: jest.fn((stored: string) => stored), +})) + +let tempRoot: string + +beforeEach(async () => { + tempRoot = await mkdtemp(path.join(tmpdir(), 'zip-reader-test-')) +}) + +afterEach(async () => { + await rm(tempRoot, { recursive: true, force: true }) +}) + +async function writeFixtureZip(name: string, entries: Array<{ name: string; data: Buffer | string; store?: boolean }>) { + const zipPath = path.join(tempRoot, name) + if (entries.some((entry) => entry.store)) { + // store 条目走 archiver,覆盖 method 0 分支 + const archiver = (await import('archiver')).default + const { createWriteStream } = await import('fs') + const output = createWriteStream(zipPath) + const archive = archiver('zip', { zlib: { level: 0 } }) + const done = new Promise((resolve, reject) => { + output.on('close', () => resolve()) + output.on('error', reject) + archive.on('error', reject) + }) + archive.pipe(output) + for (const entry of entries) { + const filePath = path.join(tempRoot, `src-${entry.name}`) + await (await import('fs/promises')).writeFile(filePath, entry.data) + archive.file(filePath, { name: entry.name, ...(entry.store ? { store: true } : {}) }) + } + await archive.finalize() + await done + return zipPath + } + + const zip = new AdmZip() + for (const entry of entries) { + zip.addFile(entry.name, entry.data as Buffer) + } + zip.writeZip(zipPath) + return zipPath +} + +describe('zip-entry-reader', () => { + test('按中央目录顺序列出条目并解压内容一致(deflate)', async () => { + const zipPath = await writeFixtureZip('deflate.zip', [ + { name: 'b-second.jpg', data: Buffer.from('second-image-bytes') }, + { name: 'a-first.jpg', data: Buffer.alloc(64 * 1024, 7) }, + { name: '中文图.png', data: Buffer.from([1, 2, 3, 4, 5]) }, + ]) + + const entries = await listZipEntries(zipPath) + // adm-zip 写包时的目录顺序与插入顺序不保证一致,这里只校验完整性; + // 顺序一致性由 list 与 extract 共用同一中央目录保证。 + expect([...entries.map((entry) => entry.name)].sort()).toEqual( + ['a-first.jpg', 'b-second.jpg', '中文图.png'].sort(), + ) + + const destination = path.join(tempRoot, 'out-deflate') + const extracted = await extractZipEntriesToDirectory(zipPath, destination) + expect(extracted.length).toBe(3) + // 列表与解压保持同一顺序 + expect(extracted.map((item) => item.entryName)).toEqual(entries.map((entry) => entry.name)) + + const byName = new Map(extracted.map((item) => [item.entryName, item])) + const second = await readFile(byName.get('b-second.jpg')!.filePath) + expect(second.toString()).toBe('second-image-bytes') + + const big = await readFile(byName.get('a-first.jpg')!.filePath) + expect(big.length).toBe(64 * 1024) + expect(big[0]).toBe(7) + + const chinese = await readFile(byName.get('中文图.png')!.filePath) + expect(Array.from(chinese)).toEqual([1, 2, 3, 4, 5]) + }) + + test('store 条目按原始字节拷贝', async () => { + const payload = Buffer.alloc(32 * 1024, 9) + const zipPath = await writeFixtureZip('store.zip', [ + { name: 'raw.bin', data: payload, store: true }, + ]) + + const destination = path.join(tempRoot, 'out-store') + const [extracted] = await extractZipEntriesToDirectory(zipPath, destination) + expect(extracted.entryName).toBe('raw.bin') + expect((await readFile(extracted.filePath)).equals(payload)).toBe(true) + }) + + test('空压缩包返回空列表', async () => { + const zipPath = await writeFixtureZip('empty.zip', []) + expect(await listZipEntries(zipPath)).toEqual([]) + }) + + test('非 ZIP 文件报错', async () => { + const notZip = path.join(tempRoot, 'not.zip') + await (await import('fs/promises')).writeFile(notZip, Buffer.from('plain text, not a zip')) + await expect(listZipEntries(notZip)).rejects.toThrow('ZIP') + }) + + test('编辑工作目录创建后可清理', async () => { + const workspace = await createTaskDetailEditWorkspace() + await workspace.cleanup() + await expect(readdir(workspace.dir)).rejects.toBeDefined() + }) +}) diff --git a/lib/task-detail-asset-edit.ts b/lib/task-detail-asset-edit.ts new file mode 100644 index 0000000..6f39c40 --- /dev/null +++ b/lib/task-detail-asset-edit.ts @@ -0,0 +1,1222 @@ +import { prisma } from '@/lib/prisma' +import { + canManageMaterial, +} from '@/lib/material-authorization' +import { createMaterialLog } from '@/lib/material-log' +import { deleteFileIfUnreferenced } from '@/lib/file-cleanup' +import { + acquireTaskMaterialCompletionLock, +} from '@/lib/material-upload-cross-process-lock' +import { isProductPackageType } from '@/lib/material-type-mapper' +import { deriveTaskDetailIsFullSet } from '@/lib/task-detail-full-set' +import { + queueTaskDetailBarcodeImagesUpdatedNotification, + queueTaskDetailFullSetNotification, +} from '@/lib/external-auto-listing' +import { + buildTaskDetailPublicDisplayPath, + uploadPublicDisplayToS3, +} from '@/lib/public-display' +import { TASK_DETAIL_UPLOAD_LIMITS } from '@/lib/task-detail-upload-policy' +import { withImageProcessingSlot } from '@/lib/image-processing-limiter' +import { cleanupConsumedUploadSession } from '@/lib/temp-cleanup' +import { deleteStoredUploadFileFromRemote } from '@/lib/remote-upload-sync' +import { + getOwnedUploadSession, + getUploadedFile, + isSafeUploadId, +} from '@/lib/upload-sessions' +import { createZipFromFiles, createZipFromZips } from '@/lib/task-material-processor' +import { + createTaskDetailEditWorkspace, + extractZipEntriesToDirectory, + listZipEntries, +} from '@/lib/zip-entry-reader' +import { resolveStoredUploadPath } from '@/lib/upload-paths' +import path from 'path' + +/** + * 任务-详情素材的单图编辑(替换 / 改名 / 删除)。 + * + * 原图只存在于分组 ZIP(主图/SKU图/详情图)里,数据库保存的是 webp + * 预览行和打包产物。所以任何单图操作都要: + * 解开对应分组 ZIP -> 改条目 -> 重打分组 ZIP 和 all.zip -> 同步预览行。 + * + * 预览行与 ZIP 条目的对应关系靠"来源文件名"锚定:archiver 并发打包 + * 不保证条目顺序等于上传顺序,位置映射不可靠;预览名(客户端命名或 + * 本模块生成的 __srv 命名)携带来源基础名,与条目名去扩展名后匹配。 + * 匹配不上就拒绝编辑,宁可让用户删除重传,也不能悄悄改错图。 + */ + +export type TaskDetailEditGroup = 'main' | 'sku' | 'detail' + +const GROUP_ZIP_LABELS: Record = { + main: '主图', + sku: 'SKU图', + detail: '详情图', +} + +const GROUP_PREVIEW_TYPES: Record = { + main: 'main-preview', + sku: 'sku-preview', + detail: 'detail-preview', +} + +const GROUPS: TaskDetailEditGroup[] = ['main', 'sku', 'detail'] + +export class TaskDetailAssetEditError extends Error { + readonly statusCode: 400 | 403 | 404 | 409 + + constructor(message: string, statusCode: 400 | 403 | 404 | 409 = 400) { + super(message) + this.name = 'TaskDetailAssetEditError' + this.statusCode = statusCode + } +} + +interface PreviewRowLike { + id: string + assetType: string + fileUrl: string + fileName: string | null + sortOrder: number + skuCode?: string | null + createdAt?: Date | null +} + +interface PreviewRun { + rows: PreviewRowLike[] +} + +interface EditContext { + materialId: string + materialType: string + productModel: string | null + authorName: string | null + categoryName: string | null + currentPreviewUrl: string | null + wasFullSet: boolean + operatorUserId: string + psdCount: number + group: TaskDetailEditGroup + rowsByGroup: Record + groupZipAssets: Record + allZipAsset: { id: string; fileUrl: string; fileName: string | null } | null + entryNames: string[] + runs: PreviewRun[] + targetRunIndex: number + targetEntryIndex: number +} + +type EditMutation = + | { kind: 'replace'; filePath: string; replacementFileName: string; buffer: Buffer } + | { kind: 'rename'; newBaseName: string } + | { kind: 'delete' } + +// ---------- 预览名解析、run 切分与条目匹配 ---------- + +interface ParsedPreviewName { + /** 与 ZIP 条目名(去扩展名)对应的锚;无法解析出锚时为 null */ + label: string | null + sliceIndex: number | null +} + +/** + * 解析预览文件名,提取"来源文件名"锚点。 + * + * ZIP 条目顺序在 archiver 并发打包下不保证等于上传顺序,位置映射不可靠, + * 所以 run ↔ 条目一律用文件名锚定: + * - 客户端命名:`{原始基础名}.webp` / 切片 `{原始基础名}-{三位序号}.webp` + * - 服务端生成:`{原始基础名}__srv-{13位时间戳}-{序号}-{随机串}.webp` + */ +export function parseTaskDetailPreviewFileName(fileName: string): ParsedPreviewName | null { + if (!/\.webp$/i.test(fileName)) { + return null + } + const withoutExtension = fileName.replace(/\.webp$/i, '') + + const serverMatch = /^(.+)__srv-\d+-\d+-[a-z0-9]*$/i.exec(withoutExtension) + if (serverMatch) { + return { label: serverMatch[1], sliceIndex: null } + } + + const sliceMatch = /-(\d{3})$/.exec(withoutExtension) + if (sliceMatch) { + return { + label: withoutExtension.slice(0, sliceMatch.index), + sliceIndex: Number(sliceMatch[1]), + } + } + return { label: withoutExtension, sliceIndex: null } +} + +/** 生成服务端预览文件名:携带来源基础名作为后续编辑的匹配锚。 */ +export function buildServerPreviewFileName( + sourceBaseName: string, + entryIndex: number, +): string { + const timestamp = Date.now() + const randomSuffix = Math.random().toString(36).substring(2, 9) + return `${sourceBaseName}__srv-${timestamp}-${entryIndex}-${randomSuffix}.webp` +} + +/** + * 把有序预览行切成 run。 + * 相邻同名但各自独立的单预览保持独立;切片序列只在序号连续时合并。 + * 返回 null 表示存在无法解析的名字(该素材不能安全编辑)。 + */ +export function groupPreviewRowsIntoRuns( + sortedRows: PreviewRowLike[], +): Array<{ key: string; rows: PreviewRowLike[] }> | null { + const runs: Array<{ key: string; rows: PreviewRowLike[] }> = [] + 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 = + parsed.sliceIndex !== null + && parsed.label !== null + && lastRun !== undefined + && lastRun.key === `name:${parsed.label}` + && expectedNextSlice === parsed.sliceIndex + + if (continuesSliceSequence && lastRun) { + lastRun.rows.push(row) + expectedNextSlice = parsed.sliceIndex! + 1 + continue + } + + runs.push({ + key: parsed.label !== null ? `name:${parsed.label}` : 'unlabeled', + rows: [row], + }) + expectedNextSlice = parsed.sliceIndex !== null ? parsed.sliceIndex + 1 : null + } + + return runs +} + +function stripExtension(name: string): string { + const dotIndex = name.lastIndexOf('.') + return dotIndex > 0 ? name.slice(0, dotIndex) : name +} + +function getExtension(name: string): string { + const dotIndex = name.lastIndexOf('.') + return dotIndex > 0 ? name.slice(dotIndex) : '' +} + +/** + * 把 run 匹配到 ZIP 条目下标。 + * + * 规则: + * 1. 有名字锚的 run 按"条目名去扩展名(忽略大小写)"唯一命中才分配; + * 2. 剩下未分配的 run 和未命中的条目数量相等且 ≤1 时按剩余一一配对 + * (兜住个别历史服务端命名行); + * 3. 其余情况一律拒绝——宁可让用户删除重传,也不能改错图。 + */ +export function matchRunsToZipEntries( + runs: Array<{ key: string; rows: PreviewRowLike[] }>, + entryNames: string[], +): number[] { + if (runs.length !== entryNames.length) { + throw new TaskDetailAssetEditError( + '该素材的图片索引与打包内容不一致,无法安全地单独编辑。请删除后重新上传。', + ) + } + + const entryBases = entryNames.map((name) => stripExtension(name).toLowerCase()) + const assignedEntryForRun = new Array(runs.length).fill(-1) + const usedEntries = new Set() + + const baseOccurrences = new Map() + for (const base of entryBases) { + baseOccurrences.set(base, (baseOccurrences.get(base) || 0) + 1) + } + + for (let runIndex = 0; runIndex < runs.length; runIndex += 1) { + const key = runs[runIndex].key + if (!key.startsWith('name:')) { + continue + } + const label = key.slice('name:'.length).toLowerCase() + if ((baseOccurrences.get(label) || 0) !== 1) { + // 重名或完全无命中:留到残余阶段判断 + continue + } + const entryIndex = entryBases.findIndex( + (base, index) => base === label && !usedEntries.has(index), + ) + if (entryIndex >= 0) { + assignedEntryForRun[runIndex] = entryIndex + usedEntries.add(entryIndex) + } + } + + const unassignedRuns: number[] = [] + for (let runIndex = 0; runIndex < runs.length; runIndex += 1) { + if (assignedEntryForRun[runIndex] < 0) { + unassignedRuns.push(runIndex) + } + } + const unusedEntryIndexes: number[] = [] + for (let index = 0; index < entryBases.length; index += 1) { + if (!usedEntries.has(index)) { + unusedEntryIndexes.push(index) + } + } + + if (unassignedRuns.length !== unusedEntryIndexes.length) { + throw new TaskDetailAssetEditError( + '该素材的图片索引与打包内容不一致,无法安全地单独编辑。请删除后重新上传。', + ) + } + if (unassignedRuns.length > 1) { + throw new TaskDetailAssetEditError( + '部分图片缺少可靠的来源标识,无法确认对应关系。请删除后重新上传。', + ) + } + if (unassignedRuns.length === 1) { + assignedEntryForRun[unassignedRuns[0]] = unusedEntryIndexes[0] + } + + return assignedEntryForRun +} + +function sortPreviewRows(rows: PreviewRowLike[]): PreviewRowLike[] { + return [...rows].sort((left, right) => { + if (left.sortOrder !== right.sortOrder) { + return left.sortOrder - right.sortOrder + } + const leftTime = left.createdAt ? new Date(left.createdAt).getTime() : 0 + const rightTime = right.createdAt ? new Date(right.createdAt).getTime() : 0 + if (leftTime !== rightTime) { + return leftTime - rightTime + } + return left.id.localeCompare(right.id) + }) +} + +// ---------- 上下文装载 ---------- + +async function assertCanManage( + material: { id: string; userId: string | null }, + operatorUserId: string, + operatorRole?: string | null, +) { + const canManage = await canManageMaterial( + { id: operatorUserId, role: operatorRole }, + // userId 为空表示历史脏数据:不匹配任何操作者,只可能由托管/管理员放行 + { id: material.id, userId: material.userId ?? '' }, + ) + if (!canManage) { + throw new TaskDetailAssetEditError('您没有权限编辑此素材', 403) + } +} + +async function loadEditContext(options: { + materialId: string + assetId: string + operatorUserId: string + operatorRole?: string | null +}): Promise { + const material = await prisma.material.findFirst({ + where: { id: options.materialId, deletedAt: null }, + select: { + id: true, + userId: true, + materialType: true, + taskStatus: true, + productModel: true, + authorName: true, + categoryName: true, + previewUrl: true, + isFullSet: true, + }, + }) + + if (!material) { + throw new TaskDetailAssetEditError('素材不存在或已被删除', 404) + } + if (!isProductPackageType(material.materialType)) { + throw new TaskDetailAssetEditError('只有任务-详情类型支持单图编辑') + } + if (material.taskStatus !== 'completed') { + throw new TaskDetailAssetEditError('素材尚未完成上传,暂不能编辑图片') + } + + await assertCanManage(material, options.operatorUserId, options.operatorRole) + + const [previewRows, zipAssetRows, psdCount] = await Promise.all([ + prisma.materialAsset.findMany({ + where: { + materialId: material.id, + assetType: { in: Object.values(GROUP_PREVIEW_TYPES) }, + }, + select: { + id: true, + assetType: true, + fileUrl: true, + fileName: true, + sortOrder: true, + skuCode: true, + createdAt: true, + }, + }), + prisma.materialAsset.findMany({ + where: { + materialId: material.id, + assetType: { in: ['main', 'sku', 'detail', 'all'] }, + }, + select: { + id: true, + assetType: true, + fileUrl: true, + fileName: true, + }, + }), + prisma.materialAsset.count({ + where: { materialId: material.id, assetType: 'psd' }, + }), + ]) + + const rowsByGroup: Record = { + main: [], + sku: [], + detail: [], + } + const groupZipAssets: Record = { + main: null, + sku: null, + detail: null, + } + let allZipAsset: EditContext['allZipAsset'] = null + for (const row of zipAssetRows) { + if ((GROUPS as string[]).includes(row.assetType)) { + groupZipAssets[row.assetType as TaskDetailEditGroup] = { + id: row.id, + fileUrl: row.fileUrl, + fileName: row.fileName, + } + } else if (row.assetType === 'all') { + allZipAsset = { id: row.id, fileUrl: row.fileUrl, fileName: row.fileName } + } + } + for (const group of GROUPS) { + rowsByGroup[group] = sortPreviewRows( + (previewRows as PreviewRowLike[]).filter((row) => row.assetType === GROUP_PREVIEW_TYPES[group]), + ) + } + + const targetGroup = GROUPS.find((group) => + rowsByGroup[group].some((row) => row.id === options.assetId), + ) + if (!targetGroup) { + throw new TaskDetailAssetEditError('要编辑的图片不存在或不属于该素材', 404) + } + + const groupZip = groupZipAssets[targetGroup] + if (!groupZip?.fileUrl) { + 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 : '未知错误'}`, + ) + } + + const runs = groupPreviewRowsIntoRuns(rowsByGroup[targetGroup]) + if (!runs) { + throw new TaskDetailAssetEditError( + '该素材的图片索引与打包内容不一致,无法安全地单独编辑。请删除后重新上传。', + ) + } + + const runEntryIndexes = matchRunsToZipEntries(runs, entryNames) + + const targetRunIndex = runs.findIndex((run) => + run.rows.some((row) => row.id === options.assetId), + ) + if (targetRunIndex < 0) { + throw new TaskDetailAssetEditError('要编辑的图片不存在或不属于该素材', 404) + } + + return { + materialId: material.id, + materialType: material.materialType, + productModel: material.productModel, + authorName: material.authorName, + categoryName: material.categoryName, + currentPreviewUrl: material.previewUrl, + wasFullSet: Boolean(material.isFullSet), + operatorUserId: options.operatorUserId, + psdCount, + group: targetGroup, + rowsByGroup, + groupZipAssets, + allZipAsset, + entryNames, + runs, + targetRunIndex, + targetEntryIndex: runEntryIndexes[targetRunIndex], + } +} + +// ---------- 预览生成与替换文件校验 ---------- + +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 +} + +/** 按完成链路的服务端兜底参数生成预览(800x1200 webp,长图切片)。 */ +async function generateServerPreviews( + imageBuffer: Buffer, + group: TaskDetailEditGroup, + materialId: string, + sourceBaseName: string, + entryIndex: number, +): Promise> { + const { compressImage, sliceImage } = await import('@/lib/image-processor') + const sharp = await loadSharp() + + if (imageBuffer.length > 200 * 1024 * 1024) { + throw new TaskDetailAssetEditError('替换图片不能超过 200MB') + } + + const metadata = await withImageProcessingSlot(() => + sharp(imageBuffer, { + limitInputPixels: SERVER_PREVIEW_SOURCE_MAX_PIXELS, + sequentialRead: true, + failOn: 'error', + }).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!( + imageBuffer, + 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(imageBuffer, { + 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) + const fileUrl = await uploadPublicDisplayToS3( + buffers[sliceIndex], + buildTaskDetailPublicDisplayPath(materialId, group, fileName), + 'image/webp', + ) + previews.push({ fileName, fileUrl, size: buffers[sliceIndex].length }) + } + return previews +} + +async function resolveReplacementUpload(options: { + uploadId: string + operatorUserId: string +}): Promise<{ filePath: string; fileName: string; fileSize: number; buffer: Buffer }> { + const uploadId = String(options.uploadId || '').trim() + if (!uploadId || !isSafeUploadId(uploadId)) { + throw new TaskDetailAssetEditError('替换图片的上传会话无效,请重新选择文件') + } + + const session = await getOwnedUploadSession(uploadId, options.operatorUserId) + if (!session) { + throw new TaskDetailAssetEditError('替换图片尚未上传完成或会话已过期,请重新上传') + } + if (session.fileSize > TASK_DETAIL_UPLOAD_LIMITS.maxSingleImageBytes) { + throw new TaskDetailAssetEditError( + `替换图片超过单图 ${Math.round(TASK_DETAIL_UPLOAD_LIMITS.maxSingleImageBytes / 1024 / 1024)}MB 限制`, + ) + } + + const filePath = await getUploadedFile(uploadId) + if (!filePath) { + throw new TaskDetailAssetEditError('替换图片文件缺失,请重新上传') + } + + const { readFile } = await import('fs/promises') + const buffer = await readFile(filePath) + + const sharp = await loadSharp() + try { + await withImageProcessingSlot(() => + sharp(buffer, { + limitInputPixels: SERVER_PREVIEW_SOURCE_MAX_PIXELS, + failOn: 'error', + }).metadata(), + ) + } catch { + throw new TaskDetailAssetEditError('替换的文件不是有效图片,请更换文件后重试') + } + + return { filePath, fileName: session.fileName, fileSize: session.fileSize, buffer } +} + +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 +} + +// ---------- 变更计划与执行 ---------- + +interface PlannedDbChanges { + previewRowsToDelete: string[] + previewRowsToInsert: Array<{ + assetType: string + fileUrl: string + fileName: string + fileSize: bigint + sortOrder: number + skuCode: string | null + barcodes: string[] + }> + sortOrderUpdates: Array<{ id: string; sortOrder: number }> + fileNameUpdates: Array<{ id: string; fileName: string }> + removedPreviewUrls: Array<{ url: string }> +} + +interface RebuildOutcome { + nextEntryNames: string[] + newGroupZip: { filePath: string; fileName: string; fileSize: number } | null + newAllZip: { filePath: string; fileName: string; fileSize: number } | null + db: PlannedDbChanges + finalRowCountByGroup: Record + firstPreviewUrlByGroup: Record +} + +async function planPreviewRowChanges( + context: EditContext, + mutation: EditMutation, + finalEntryName: string, +): Promise { + const { group, runs, targetRunIndex, targetEntryIndex } = context + const targetRun = runs[targetRunIndex] + const previewType = GROUP_PREVIEW_TYPES[group] + + const planned: PlannedDbChanges = { + previewRowsToDelete: [], + previewRowsToInsert: [], + sortOrderUpdates: [], + fileNameUpdates: [], + removedPreviewUrls: [], + } + + if (mutation.kind === 'delete') { + for (const row of targetRun.rows) { + planned.previewRowsToDelete.push(row.id) + planned.removedPreviewUrls.push({ url: row.fileUrl }) + } + } else if (mutation.kind === 'replace') { + const generated = await generateServerPreviews( + mutation.buffer, + group, + context.materialId, + stripExtension(finalEntryName), + targetEntryIndex, + ) + 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 parsed = parseTaskDetailPreviewFileName(currentName) + if (!parsed?.label) { + continue + } + if (parsed.label.toLowerCase() === newBaseName.toLowerCase()) { + continue + } + const serverTail = /^.*(__srv-\d+-\d+-[a-z0-9]*)$/i.exec( + currentName.replace(/\.webp$/i, ''), + ) + let nextName: string + if (serverTail) { + nextName = `${newBaseName}${serverTail[1]}.webp` + } else { + const sliceSuffixMatch = /-\d{3}(?=\.webp$)/i.exec(currentName) + nextName = sliceSuffixMatch + ? `${newBaseName}${sliceSuffixMatch[0]}.webp` + : `${newBaseName}.webp` + } + if (nextName !== currentName) { + planned.fileNameUpdates.push({ id: row.id, fileName: nextName }) + } + } + } + + // 目标 run 之后的所有行统一重排编号(rename 行数不变,重排结果不变) + const rowsInFinalOrder: PreviewRowLike[] = [] + for (let runIndex = 0; runIndex < runs.length; runIndex += 1) { + if (mutation.kind !== 'rename' && runIndex === targetRunIndex) { + continue + } + rowsInFinalOrder.push(...runs[runIndex].rows) + } + rowsInFinalOrder.forEach((row, index) => { + if (row.sortOrder !== index) { + planned.sortOrderUpdates.push({ id: row.id, sortOrder: index }) + } + }) + + return planned +} + +async function loadRunBarcodes(assetIds: string[]): Promise { + if (assetIds.length === 0) { + return [] + } + const rows = await prisma.materialAssetBarcode.findMany({ + where: { assetId: { in: assetIds } }, + select: { barcode: true }, + }) + return Array.from(new Set(rows.map((row) => row.barcode).filter(Boolean))).sort() +} + +async function executeRebuild( + context: EditContext, + mutation: EditMutation, +): Promise { + const { group, entryNames, targetEntryIndex } = context + const workspace = await createTaskDetailEditWorkspace() + + try { + const groupZip = context.groupZipAssets[group]! + const groupZipAbsolute = path.isAbsolute(groupZip.fileUrl) + ? groupZip.fileUrl + : resolveStoredUploadPath(groupZip.fileUrl) + + const extracted = await extractZipEntriesToDirectory(groupZipAbsolute, workspace.dir) + + const finalEntries: Array<{ path: string; name: string; size: number }> = extracted.map( + (item, index) => ({ + path: item.filePath, + name: entryNames[index], + size: item.size, + }), + ) + + let finalEntryName = entryNames[targetEntryIndex] + + if (mutation.kind === 'delete') { + finalEntries.splice(targetEntryIndex, 1) + } else if (mutation.kind === 'rename') { + const originalExtension = getExtension(finalEntryName) + finalEntryName = `${mutation.newBaseName}${originalExtension}` + const conflict = finalEntries.some( + (entry, index) => index !== targetEntryIndex && entry.name === finalEntryName, + ) + if (conflict) { + throw new TaskDetailAssetEditError('包内已存在同名文件,请换一个名字', 409) + } + finalEntries[targetEntryIndex] = { ...finalEntries[targetEntryIndex], name: finalEntryName } + } else { + finalEntryName = buildFinalEntryName(entryNames[targetEntryIndex], mutation.replacementFileName) + const conflict = finalEntries.some( + (entry, index) => index !== targetEntryIndex && entry.name === finalEntryName, + ) + if (conflict) { + throw new TaskDetailAssetEditError('替换后的文件名与包内其他文件重名,请先改名再替换', 409) + } + finalEntries[targetEntryIndex] = { + path: mutation.filePath, + name: finalEntryName, + size: 0, + } + } + + let newGroupZip: RebuildOutcome['newGroupZip'] = null + if (finalEntries.length > 0) { + newGroupZip = await createZipFromFiles( + finalEntries, + context.materialId, + context.productModel || 'product', + '任务-详情', + GROUP_ZIP_LABELS[group], + context.authorName || undefined, + context.categoryName || undefined, + ) + } + + const members: Array<{ path: string; name: string }> = [] + for (const memberGroup of GROUPS) { + if (memberGroup === group) { + if (newGroupZip) { + members.push({ + path: resolveStoredUploadPath(newGroupZip.filePath), + name: newGroupZip.fileName, + }) + } + continue + } + const memberAsset = context.groupZipAssets[memberGroup] + if (memberAsset?.fileUrl) { + members.push({ + path: resolveStoredUploadPath(memberAsset.fileUrl), + name: memberAsset.fileName || '', + }) + } + } + + let newAllZip: RebuildOutcome['newAllZip'] = null + if (members.length > 0) { + newAllZip = await createZipFromZips( + members, + context.materialId, + context.productModel || 'product', + '任务-详情', + ) + } + + const db = await planPreviewRowChanges(context, mutation, finalEntryName) + + // 计算各组最终预览行数与首图地址(用于封面回退和整套判定) + const finalRowCountByGroup: Record = { + main: context.rowsByGroup.main.length, + sku: context.rowsByGroup.sku.length, + detail: context.rowsByGroup.detail.length, + } + finalRowCountByGroup[group] = + context.rowsByGroup[group].length + - db.previewRowsToDelete.length + + db.previewRowsToInsert.length + + const firstPreviewUrlByGroup: Record = { + main: null, + sku: null, + detail: null, + } + if (finalRowCountByGroup[group] > 0) { + const candidates = [ + ...context.rowsByGroup[group] + .filter((row) => !db.previewRowsToDelete.includes(row.id)) + .map((row) => ({ url: row.fileUrl, sortOrder: row.sortOrder })), + ...db.previewRowsToInsert.map((row) => ({ + url: row.fileUrl, + sortOrder: row.sortOrder, + })), + ].sort((left, right) => left.sortOrder - right.sortOrder) + firstPreviewUrlByGroup[group] = candidates[0]?.url || null + } + + return { + nextEntryNames: finalEntries.map((entry) => entry.name), + newGroupZip, + newAllZip, + db, + finalRowCountByGroup, + firstPreviewUrlByGroup, + } + } finally { + await workspace.cleanup() + } +} + +// ---------- 落库与清理 ---------- + +function computeNextAssetTypes(context: EditContext, outcome: RebuildOutcome): Array<{ assetType: string }> { + const types: Array<{ assetType: string }> = [] + for (const group of GROUPS) { + if (outcome.finalRowCountByGroup[group] > 0) { + types.push({ assetType: GROUP_PREVIEW_TYPES[group] }, { assetType: group }) + } + } + if (context.psdCount > 0) { + types.push({ assetType: 'psd' }) + } + return types +} + +async function commitRebuild( + context: EditContext, + outcome: RebuildOutcome, + operationLabel: 'REPLACE_IMAGE' | 'RENAME_IMAGE' | 'DELETE_IMAGE', +): 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) { + await tx.materialAsset.update({ + where: { id: groupZipAsset.id }, + data: { + fileUrl: outcome.newGroupZip.filePath, + fileName: outcome.newGroupZip.fileName, + fileSize: BigInt(outcome.newGroupZip.fileSize), + }, + }) + } else { + 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, + }, + }) + + 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.entryNames[context.targetEntryIndex], + newValue: outcome.nextEntryNames[context.targetEntryIndex] ?? null, + }, + entryCount: { + oldValue: context.entryNames.length, + newValue: outcome.nextEntryNames.length, + }, + }, + }).catch((error) => { + console.error('[task-detail-asset-edit] 素材日志写入失败:', error) + }) + + return result +} + +async function cleanupAfterCommit(context: EditContext, outcome: RebuildOutcome) { + const tasks: Array> = [] + + const staleStoredPaths: string[] = [] + const groupZip = context.groupZipAssets[context.group] + if (groupZip && (!outcome.newGroupZip || groupZip.fileUrl !== outcome.newGroupZip.filePath)) { + staleStoredPaths.push(groupZip.fileUrl) + } + if ( + context.allZipAsset + && (!outcome.newAllZip || context.allZipAsset.fileUrl !== outcome.newAllZip.filePath) + ) { + staleStoredPaths.push(context.allZipAsset.fileUrl) + } + for (const storedPath of staleStoredPaths) { + tasks.push(deleteStoredUploadFileFromRemote(storedPath).catch(() => undefined)) + } + + for (const removed of outcome.db.removedPreviewUrls) { + tasks.push( + deleteFileIfUnreferenced(removed.url, { + excludeMaterialIds: [context.materialId], + }).catch(() => undefined), + ) + } + + await Promise.allSettled(tasks) +} + +async function runEditOperation(materialId: string, operation: () => Promise): Promise { + const release = await acquireTaskMaterialCompletionLock(materialId) + try { + release.assertHeld() + return await operation() + } finally { + await release() + } +} + +// ---------- 对外入口 ---------- + +export interface ReplaceTaskDetailImageInput { + materialId: string + assetId: string + operatorUserId: string + operatorRole?: string | null + uploadId: string +} + +export async function replaceTaskDetailImage(input: ReplaceTaskDetailImageInput) { + return runEditOperation(input.materialId, async () => { + const context = await loadEditContext(input) + const upload = await resolveReplacementUpload({ + uploadId: input.uploadId, + operatorUserId: input.operatorUserId, + }) + + const outcome = await executeRebuild(context, { + kind: 'replace', + filePath: upload.filePath, + replacementFileName: upload.fileName, + buffer: upload.buffer, + }) + const { nextIsFullSet } = await commitRebuild(context, outcome, 'REPLACE_IMAGE') + await cleanupAfterCommit(context, outcome) + await cleanupConsumedUploadSession(input.uploadId.trim()).catch(() => undefined) + + if (!context.wasFullSet && nextIsFullSet) { + void queueTaskDetailFullSetNotification(context.materialId) + } + + if (context.group === 'sku' && outcome.db.previewRowsToInsert.length > 0) { + 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), + ) + } + + return { + success: true, + entryName: outcome.nextEntryNames[context.targetRunIndex], + } + }) +} + +export interface RenameTaskDetailImageInput { + materialId: string + assetId: string + operatorUserId: string + operatorRole?: string | null + newFileName: string +} + +export async function renameTaskDetailImage(input: RenameTaskDetailImageInput) { + return runEditOperation(input.materialId, async () => { + const context = await loadEditContext(input) + + const rawName = String(input.newFileName || '').trim() + const baseName = sanitizeNewBaseName(stripExtension(rawName)) + const requestedExtension = getExtension(rawName) + const originalExtension = getExtension(context.entryNames[context.targetRunIndex]) + if (requestedExtension && requestedExtension.toLowerCase() !== originalExtension.toLowerCase()) { + throw new TaskDetailAssetEditError('只能修改文件名,不能更改扩展名') + } + + const outcome = await executeRebuild(context, { kind: 'rename', newBaseName: baseName }) + await commitRebuild(context, outcome, 'RENAME_IMAGE') + await cleanupAfterCommit(context, outcome) + + return { + success: true, + entryName: outcome.nextEntryNames[context.targetRunIndex], + } + }) +} + +export interface DeleteTaskDetailImageInput { + materialId: string + assetId: string + operatorUserId: string + operatorRole?: string | null +} + +export async function deleteTaskDetailImage(input: DeleteTaskDetailImageInput) { + return runEditOperation(input.materialId, async () => { + const context = await loadEditContext(input) + const outcome = await executeRebuild(context, { kind: 'delete' }) + await commitRebuild(context, outcome, 'DELETE_IMAGE') + await cleanupAfterCommit(context, outcome) + + if (!context.wasFullSet) { + const nextIsFullSet = deriveTaskDetailIsFullSet( + context.materialType, + computeNextAssetTypes(context, outcome), + false, + ) + if (nextIsFullSet) { + void queueTaskDetailFullSetNotification(context.materialId) + } + } + + return { success: true } + }) +} diff --git a/lib/zip-entry-reader.ts b/lib/zip-entry-reader.ts new file mode 100644 index 0000000..250cc22 --- /dev/null +++ b/lib/zip-entry-reader.ts @@ -0,0 +1,383 @@ +import { createReadStream } from 'fs' +import { mkdir, open, rm } from 'fs/promises' +import { randomUUID } from 'crypto' +import path from 'path' +import { pipeline } from 'stream/promises' +import { createInflateRaw } from 'zlib' + +/** + * 任务-详情编辑用的流式 ZIP 读取器。 + * + * 只依赖 Node 自带 zlib:读中央目录定位每个条目,再按条目把解压结果 + * 流式写到磁盘。全程不把整个压缩包读进内存,历史素材的分组 ZIP + * (主图/SKU图/详情图)可能数百 MB 甚至更大。 + * + * 只支持本系统自己打包出来的常规布局: + * - method 0(store)/ method 8(deflate) + * - UTF-8 文件名、数据描述符(flag bit 3)、ZIP64 扩展 + * 不支持加密与其它压缩方法,遇到即报错。 + */ + +const EOCD_SIGNATURE = 0x06054b50 +const ZIP64_EOCD_LOCATOR_SIGNATURE = 0x07064b50 +const ZIP64_EOCD_SIGNATURE = 0x06064b50 +const CENTRAL_HEADER_SIGNATURE = 0x02014b50 +const LOCAL_HEADER_SIGNATURE = 0x04034b50 + +export interface ZipEntryInfo { + /** 中央目录中的原始文件名 */ + name: string + compressedSize: number + uncompressedSize: number + compressionMethod: number + localHeaderOffset: number + flags: number +} + +interface ZipCentralDirectory { + entries: ZipEntryInfo[] +} + +function readUInt16LE(buffer: Buffer, offset: number): number { + return buffer.readUInt16LE(offset) +} + +function readUInt32LE(buffer: Buffer, offset: number): number { + return buffer.readUInt32LE(offset) +} + +function readBigUInt64LE(buffer: Buffer, offset: number): number { + const value = buffer.readBigUInt64LE(offset) + if (value > BigInt(Number.MAX_SAFE_INTEGER)) { + throw new Error('ZIP 内的偏移或大小超出安全范围') + } + return Number(value) +} + +async function readSlice( + handle: Awaited>, + position: number, + length: number, +): Promise { + const buffer = Buffer.alloc(length) + const { bytesRead } = await handle.read(buffer, 0, length, position) + if (bytesRead !== length) { + throw new Error('压缩包已损坏:读取内容不完整') + } + return buffer +} + +function findEocdSignature(tail: Buffer): number { + // EOCD 固定 22 字节起,注释最长 65535,从尾部向前扫签名。 + for (let offset = tail.length - 22; offset >= 0; offset -= 1) { + if (readUInt32LE(tail, offset) === EOCD_SIGNATURE) { + return offset + } + } + throw new Error('文件不是有效的 ZIP 压缩包') +} + +async function locateZip64Eocd( + handle: Awaited>, + eocdOffsetInTail: number, + tailStart: number, + fileSize: number, +): Promise<{ entryCount: number; cdSize: number; cdOffset: number } | null> { + if (eocdOffsetInTail < 20) { + return null + } + const locatorPosition = tailStart + eocdOffsetInTail - 20 + if (locatorPosition < 0 || locatorPosition + 20 > fileSize) { + return null + } + const locator = await readSlice(handle, locatorPosition, 20) + if (readUInt32LE(locator, 0) !== ZIP64_EOCD_LOCATOR_SIGNATURE) { + return null + } + const zip64EocdOffset = readBigUInt64LE(locator, 8) + const zip64Eocd = await readSlice(handle, zip64EocdOffset, 56) + if (readUInt32LE(zip64Eocd, 0) !== ZIP64_EOCD_SIGNATURE) { + throw new Error('压缩包已损坏:ZIP64 目录定位失败') + } + const entryCount = readBigUInt64LE(zip64Eocd, 32) + const cdSize = readBigUInt64LE(zip64Eocd, 40) + const cdOffset = readBigUInt64LE(zip64Eocd, 48) + return { entryCount, cdSize, cdOffset } +} + +async function readCentralDirectory( + zipPath: string, +): Promise { + const handle = await open(zipPath, 'r') + try { + const stats = await handle.stat() + if (!stats.isFile() || stats.size < 22) { + throw new Error('文件不是有效的 ZIP 压缩包') + } + + const tailLength = Math.min(stats.size, 22 + 65535 + 20 + 56) + const tailStart = stats.size - tailLength + const tail = await readSlice(handle, tailStart, tailLength) + const eocdOffsetInTail = findEocdSignature(tail) + const eocdAbsolute = tailStart + eocdOffsetInTail + + let entryCount = readUInt16LE(tail, eocdOffsetInTail + 10) + let cdSize = readUInt32LE(tail, eocdOffsetInTail + 12) + let cdOffset = readUInt32LE(tail, eocdOffsetInTail + 16) + + if ( + entryCount === 0xffff + || cdSize === 0xffffffff + || cdOffset === 0xffffffff + ) { + const zip64 = await locateZip64Eocd( + handle, + eocdOffsetInTail, + tailStart, + stats.size, + ) + if (!zip64) { + throw new Error('压缩包已损坏:ZIP64 目录缺失') + } + entryCount = zip64.entryCount + cdSize = zip64.cdSize + cdOffset = zip64.cdOffset + } + + if (entryCount <= 0) { + return { entries: [] } + } + + const directoryBuffer = await readSlice(handle, cdOffset, cdSize) + const entries: ZipEntryInfo[] = [] + let cursor = 0 + + for (let index = 0; index < entryCount; index += 1) { + if (cursor + 46 > directoryBuffer.length) { + throw new Error('压缩包已损坏:中央目录不完整') + } + if (readUInt32LE(directoryBuffer, cursor) !== CENTRAL_HEADER_SIGNATURE) { + throw new Error('压缩包已损坏:中央目录条目签名异常') + } + + const flags = readUInt16LE(directoryBuffer, cursor + 8) + const compressionMethod = readUInt16LE(directoryBuffer, cursor + 10) + let compressedSize = readUInt32LE(directoryBuffer, cursor + 20) + let uncompressedSize = readUInt32LE(directoryBuffer, cursor + 24) + const nameLength = readUInt16LE(directoryBuffer, cursor + 28) + const extraLength = readUInt16LE(directoryBuffer, cursor + 30) + const commentLength = readUInt16LE(directoryBuffer, cursor + 32) + let localHeaderOffset = readUInt32LE(directoryBuffer, cursor + 42) + + const extraStart = cursor + 46 + nameLength + const extraEnd = extraStart + extraLength + if (extraEnd > directoryBuffer.length) { + throw new Error('压缩包已损坏:中央目录扩展字段不完整') + } + + let extraCursor = extraStart + while (extraCursor + 4 <= extraEnd) { + const headerId = readUInt16LE(directoryBuffer, extraCursor) + const dataSize = readUInt16LE(directoryBuffer, extraCursor + 2) + if (headerId === 0x0001) { + let fieldCursor = extraCursor + 4 + const fieldEnd = extraCursor + 4 + dataSize + if (uncompressedSize === 0xffffffff && fieldCursor + 8 <= fieldEnd) { + uncompressedSize = readBigUInt64LE(directoryBuffer, fieldCursor) + fieldCursor += 8 + } + if (compressedSize === 0xffffffff && fieldCursor + 8 <= fieldEnd) { + compressedSize = readBigUInt64LE(directoryBuffer, fieldCursor) + fieldCursor += 8 + } + if (localHeaderOffset === 0xffffffff && fieldCursor + 8 <= fieldEnd) { + localHeaderOffset = readBigUInt64LE(directoryBuffer, fieldCursor) + fieldCursor += 8 + } + } + extraCursor += 4 + dataSize + } + + const name = directoryBuffer.toString('utf8', cursor + 46, extraStart) + entries.push({ + name, + compressedSize, + uncompressedSize, + compressionMethod, + localHeaderOffset, + flags, + }) + + cursor = extraEnd + commentLength + } + + return { entries } + } finally { + await handle.close() + } +} + +/** 列出 ZIP 内的条目(按中央目录顺序,跳过目录项)。 */ +export async function listZipEntries(zipPath: string): Promise { + const { entries } = await readCentralDirectory(zipPath) + return entries.filter((entry) => !entry.name.endsWith('/')) +} + +/** + * 把 ZIP 条目解压到目标目录。 + * + * 返回值保持中央目录顺序;文件名只保留最后一段路径, + * 拒绝空名和穿越命名,避免历史数据里的异常条目写出目录树。 + */ +export async function extractZipEntriesToDirectory( + zipPath: string, + destinationDir: string, +): Promise> { + const { entries } = await readCentralDirectory(zipPath) + await mkdir(destinationDir, { recursive: true }) + + const extracted: Array<{ entryName: string; filePath: string; size: number }> = [] + for (const entry of entries) { + if (entry.name.endsWith('/')) { + continue + } + + const normalized = entry.name.replace(/\\/g, '/') + const baseName = normalized.split('/').pop() || '' + if (!baseName || baseName === '.' || baseName === '..') { + throw new Error(`压缩包含异常文件名,拒绝处理: ${entry.name}`) + } + if ((entry.flags & 0x1) !== 0) { + throw new Error(`压缩包含加密文件,不支持编辑: ${baseName}`) + } + + const outputPath = path.join(destinationDir, `${extracted.length}-${baseName}`) + const written = await extractSingleEntry(handle => extractByEntry(handle, entry, outputPath)) + if (written !== entry.uncompressedSize) { + throw new Error(`压缩包已损坏:条目 ${baseName} 解压后大小不一致`) + } + + extracted.push({ + entryName: baseName, + filePath: outputPath, + size: entry.uncompressedSize, + }) + } + + return extracted + + async function extractSingleEntry( + worker: (handle: Awaited>) => Promise, + ): Promise { + const handle = await open(zipPath, 'r') + try { + return await worker(handle) + } finally { + await handle.close() + } + } +} + +async function extractByEntry( + handle: Awaited>, + entry: ZipEntryInfo, + outputPath: string, +): Promise { + const localHeader = await readSlice(handle, entry.localHeaderOffset, 30) + if (readUInt32LE(localHeader, 0) !== LOCAL_HEADER_SIGNATURE) { + throw new Error(`压缩包已损坏:条目 ${entry.name} 本地头异常`) + } + const nameLength = readUInt16LE(localHeader, 26) + const extraLength = readUInt16LE(localHeader, 28) + const dataStart = entry.localHeaderOffset + 30 + nameLength + extraLength + + if (entry.compressionMethod === 0) { + // store:直接按范围拷贝原始字节 + return copyRangeToFile(handle, dataStart, entry.compressedSize, outputPath) + } + + if (entry.compressionMethod === 8) { + const source = createRangeReadStream(handle, dataStart, entry.compressedSize) + const inflater = createInflateRaw() + let inflatedBytes = 0 + inflater.on('data', (chunk: Buffer) => { + inflatedBytes += chunk.length + }) + const output = await createFileWriteStream(outputPath) + try { + await pipeline(source, inflater, output) + } catch (error) { + output.destroy() + throw error + } + return inflatedBytes + } + + throw new Error(`压缩包含不支持的压缩方式(method ${entry.compressionMethod}),无法编辑`) +} + +// ---- 以下为内部辅助,避免引入额外依赖 ---- + +async function copyRangeToFile( + handle: Awaited>, + start: number, + length: number, + outputPath: string, +): Promise { + const CHUNK = 1024 * 1024 + const output = await createFileWriteStream(outputPath) + let written = 0 + try { + let position = start + while (written < length) { + const size = Math.min(CHUNK, length - written) + const buffer = await readSlice(handle, position, size) + await new Promise((resolve, reject) => { + output.write(buffer, (error) => (error ? reject(error) : resolve())) + }) + written += size + position += size + } + await new Promise((resolve, reject) => { + output.end(() => resolve()) + output.on('error', reject) + }) + return written + } catch (error) { + output.destroy() + throw error + } +} + +function createRangeReadStream( + handle: Awaited>, + start: number, + length: number, +) { + // fd 由外层 close;createReadStream 仅负责按窗口读取 + return createReadStream('', { + fd: handle.fd, + autoClose: false, + start, + end: start + length - 1, + }) +} + +async function createFileWriteStream(outputPath: string) { + const { createWriteStream } = await import('fs') + return createWriteStream(outputPath, { flags: 'wx' }) +} + +/** 创建任务-详情编辑用的临时工作目录,返回路径与清理函数。 */ +export async function createTaskDetailEditWorkspace(): Promise<{ + dir: string + cleanup: () => Promise +}> { + const { getUploadRootDir } = await import('@/lib/upload-paths') + const dir = path.join(getUploadRootDir(), 'temp', `task-detail-edit-${randomUUID()}`) + await mkdir(dir, { recursive: true }) + return { + dir, + cleanup: () => rm(dir, { recursive: true, force: true }), + } +}