diff --git a/furniture-3d-system/src/lib/open-product/platform-api.ts b/furniture-3d-system/src/lib/open-product/platform-api.ts index dbcf4753..9eab9506 100644 --- a/furniture-3d-system/src/lib/open-product/platform-api.ts +++ b/furniture-3d-system/src/lib/open-product/platform-api.ts @@ -269,6 +269,117 @@ export async function uploadMaterialThumbnail(file: File, category?: string): Pr }) } +const MATERIAL_UPLOAD_CLIENT_TARGET_BYTES = 900 * 1024 +const MATERIAL_UPLOAD_TEXTURE_MAX_DIMENSION = 2048 +const MATERIAL_UPLOAD_THUMBNAIL_MAX_DIMENSION = 768 + +function isClientCompressibleImage(file: File): boolean { + const type = file.type.toLowerCase() + const name = file.name.toLowerCase() + return ( + type === 'image/jpeg' || + type === 'image/png' || + type === 'image/webp' || + /\.(jpe?g|png|webp)$/.test(name) + ) +} + +function materialUploadFileName(fileName: string): string { + const baseName = fileName.replace(/\.[^.]+$/, '').trim() || 'material-texture' + return `${baseName}.webp` +} + +async function loadImageElement(file: File): Promise { + const imageUrl = URL.createObjectURL(file) + try { + return await new Promise((resolve, reject) => { + const image = new Image() + image.onload = () => resolve(image) + image.onerror = () => reject(new Error('图片读取失败,请换用 JPG、PNG 或 WebP 图片')) + image.src = imageUrl + }) + } finally { + URL.revokeObjectURL(imageUrl) + } +} + +async function canvasToWebpBlob(canvas: HTMLCanvasElement, quality: number): Promise { + return new Promise((resolve) => { + canvas.toBlob((blob) => resolve(blob), 'image/webp', quality) + }) +} + +async function compressMaterialImageForUpload( + file: File, + uploadType: 'thumbnail' | 'texture' | 'normal', +): Promise { + if ( + file.size <= MATERIAL_UPLOAD_CLIENT_TARGET_BYTES || + typeof window === 'undefined' || + typeof document === 'undefined' || + typeof Image === 'undefined' || + !isClientCompressibleImage(file) + ) { + return file + } + + const image = await loadImageElement(file) + const sourceWidth = image.naturalWidth || image.width + const sourceHeight = image.naturalHeight || image.height + if (sourceWidth <= 0 || sourceHeight <= 0) { + throw new Error('图片尺寸读取失败,请换用 JPG、PNG 或 WebP 图片') + } + + const maxDimension = + uploadType === 'thumbnail' + ? MATERIAL_UPLOAD_THUMBNAIL_MAX_DIMENSION + : MATERIAL_UPLOAD_TEXTURE_MAX_DIMENSION + const dimensionSteps = + uploadType === 'thumbnail' + ? [maxDimension, 640, 512, 384] + : [maxDimension, 1800, 1600, 1400, 1280, 1024, 768, 640] + const qualitySteps = [0.92, 0.86, 0.8, 0.72, 0.64, 0.56, 0.5, 0.42, 0.36] + + const canvas = document.createElement('canvas') + const context = canvas.getContext('2d') + if (!context) { + return file + } + + let smallestBlob: Blob | null = null + + for (const dimension of dimensionSteps) { + const scale = Math.min(1, dimension / Math.max(sourceWidth, sourceHeight)) + canvas.width = Math.max(1, Math.round(sourceWidth * scale)) + canvas.height = Math.max(1, Math.round(sourceHeight * scale)) + context.clearRect(0, 0, canvas.width, canvas.height) + context.drawImage(image, 0, 0, canvas.width, canvas.height) + + for (const quality of qualitySteps) { + const blob = await canvasToWebpBlob(canvas, quality) + if (!blob) continue + if (!smallestBlob || blob.size < smallestBlob.size) { + smallestBlob = blob + } + if (blob.size <= MATERIAL_UPLOAD_CLIENT_TARGET_BYTES) { + return new File([blob], materialUploadFileName(file.name), { + type: 'image/webp', + lastModified: file.lastModified, + }) + } + } + } + + if (smallestBlob && smallestBlob.size < file.size && smallestBlob.size <= 1024 * 1024) { + return new File([smallestBlob], materialUploadFileName(file.name), { + type: 'image/webp', + lastModified: file.lastModified, + }) + } + + throw new Error('图片压缩后仍超过当前上传入口限制,请换用更小图片或先调大正式站上传限制') +} + export async function uploadMaterialAsset( file: File, options?: { @@ -276,9 +387,11 @@ export async function uploadMaterialAsset( category?: string }, ): Promise { + const uploadType = options?.type ?? 'texture' + const uploadFile = await compressMaterialImageForUpload(file, uploadType) const formData = new FormData() - formData.append('file', file) - formData.append('type', options?.type ?? 'texture') + formData.append('file', uploadFile) + formData.append('type', uploadType) if (options?.category) { formData.append('category', options.category) } @@ -290,6 +403,9 @@ export async function uploadMaterialAsset( const payload = (await response.json().catch(() => null)) as ApiEnvelope<{ url: string }> | null if (!response.ok || !payload?.success || payload.data === undefined) { + if (response.status === 413) { + throw new Error('图片超过当前站点上传入口限制,系统已尝试本地压缩;请换用更小图片或先调大正式站上传限制') + } throw new Error(payload?.error?.message || '图片上传失败') } diff --git a/furniture-3d-system/src/server/services/__tests__/material.service.test.ts b/furniture-3d-system/src/server/services/__tests__/material.service.test.ts index 7c09c96d..85e3fc5f 100644 --- a/furniture-3d-system/src/server/services/__tests__/material.service.test.ts +++ b/furniture-3d-system/src/server/services/__tests__/material.service.test.ts @@ -18,6 +18,7 @@ jest.mock('@/server/repositories/material.repository', () => ({ getNextSortOrder: jest.fn(), create: jest.fn(), update: jest.fn(), + deactivate: jest.fn(), delete: jest.fn(), }, })) @@ -63,6 +64,7 @@ const mockMaterialCategoryRepository = materialCategoryRepository as unknown as getNextSortOrder: jest.Mock create: jest.Mock update: jest.Mock + deactivate: jest.Mock delete: jest.Mock } @@ -244,11 +246,27 @@ describe('materialService', () => { expect(mockMaterialRepository.deactivate).not.toHaveBeenCalled() }) - it('删除分类时会硬删除未引用的分类', async () => { + it('删除分类时只按启用材质拦截并停用分类', async () => { await materialService.deleteCategory('category-wood') - expect(mockMaterialCategoryRepository.getMaterialCount).toHaveBeenCalledWith('category-wood', false) - expect(mockMaterialCategoryRepository.delete).toHaveBeenCalledWith('category-wood') + expect(mockMaterialCategoryRepository.getMaterialCount).toHaveBeenCalledWith('category-wood', true) + expect(mockMaterialCategoryRepository.deactivate).toHaveBeenCalledWith('category-wood') + expect(mockMaterialCategoryRepository.delete).not.toHaveBeenCalled() + }) + + it('彻底删除分类时会继续拦截历史隐藏材质', async () => { + mockMaterialCategoryRepository.getMaterialCount + .mockResolvedValueOnce(0) + .mockResolvedValueOnce(1) + + await expect(materialService.deleteCategory('category-wood', true)).rejects.toThrow( + '该分类下还有 1 个历史材质,无法彻底删除', + ) + + expect(mockMaterialCategoryRepository.getMaterialCount).toHaveBeenNthCalledWith(1, 'category-wood', true) + expect(mockMaterialCategoryRepository.getMaterialCount).toHaveBeenNthCalledWith(2, 'category-wood', false) + expect(mockMaterialCategoryRepository.delete).not.toHaveBeenCalled() + expect(mockMaterialCategoryRepository.deactivate).not.toHaveBeenCalled() }) it('创建分类时会归一化名称图标并拒绝负排序', async () => { diff --git a/furniture-3d-system/src/server/services/material.service.ts b/furniture-3d-system/src/server/services/material.service.ts index ee6826a0..0f601d86 100644 --- a/furniture-3d-system/src/server/services/material.service.ts +++ b/furniture-3d-system/src/server/services/material.service.ts @@ -583,7 +583,7 @@ class MaterialService { } /** - * 删除分类(软删除) + * 删除分类:普通删除只校验启用材质,并停用分类以保留历史材质关联。 */ async deleteCategory(id: string, hardDelete: boolean = false): Promise { // 检查分类是否存在 @@ -592,22 +592,29 @@ class MaterialService { throw new AppError(ErrorCode.FILE_NOT_FOUND, '材质分类不存在') } - // 检查分类下是否有材质 - const materialCount = await materialCategoryRepository.getMaterialCount(id, false) + // 左侧分类数字只展示启用材质;删除校验也应按同一口径处理。 + const activeMaterialCount = await materialCategoryRepository.getMaterialCount(id, true) - if (materialCount > 0) { + if (activeMaterialCount > 0) { throw new AppError( ErrorCode.VALIDATION_FAILED, - `该分类下还有 ${materialCount} 个材质,无法删除` + `该分类下还有 ${activeMaterialCount} 个材质,无法删除` ) } if (hardDelete) { + const materialCount = await materialCategoryRepository.getMaterialCount(id, false) + if (materialCount > 0) { + throw new AppError( + ErrorCode.VALIDATION_FAILED, + `该分类下还有 ${materialCount} 个历史材质,无法彻底删除` + ) + } await materialCategoryRepository.delete(id) return } - await materialCategoryRepository.delete(id) + await materialCategoryRepository.deactivate(id) } /**