diff --git a/furniture-3d-system/src/app/api/intake/non-integrated/upload/route.ts b/furniture-3d-system/src/app/api/intake/non-integrated/upload/route.ts index 57efdb08..429ab679 100644 --- a/furniture-3d-system/src/app/api/intake/non-integrated/upload/route.ts +++ b/furniture-3d-system/src/app/api/intake/non-integrated/upload/route.ts @@ -5,6 +5,7 @@ import { randomUUID } from 'crypto' import { NextRequest } from 'next/server' import { ErrorCode } from '@/server/core/errors' import { handleRequest, jsonError, jsonSuccess } from '@/server/core/response' +import { intakeNonIntegratedProductService } from '@/server/services' const MAX_NON_INTEGRATED_UPLOAD_SIZE = 120 * 1024 * 1024 @@ -14,12 +15,35 @@ function sanitizeExtension(fileName: string) { return extension.replace(/[^a-z0-9.]/g, '') } +function readOptionalFormValue(formData: FormData, key: string) { + const value = formData.get(key) + return typeof value === 'string' && value.trim().length > 0 ? value.trim() : undefined +} + +function readUploadMetadata(formData: FormData) { + const rawMetadata = readOptionalFormValue(formData, 'metadata') + if (!rawMetadata) { + return {} + } + + try { + const parsed = JSON.parse(rawMetadata) as Record + return typeof parsed === 'object' && parsed !== null ? parsed : {} + } catch { + return {} + } +} + export async function POST(request: NextRequest) { return handleRequest( request, - async ({ requestId }) => { + async ({ requestId, user }) => { const formData = await request.formData() const file = formData.get('file') + const productId = readOptionalFormValue(formData, 'productId') + const category = readOptionalFormValue(formData, 'category') + const fileId = readOptionalFormValue(formData, 'fileId') + const metadata = readUploadMetadata(formData) if (!(file instanceof File)) { return jsonError(ErrorCode.VALIDATION_REQUIRED_FIELD, '请选择要上传的文件', 400, requestId) @@ -50,12 +74,47 @@ export async function POST(request: NextRequest) { await writeFile(storedFilePath, Buffer.from(bytes)) + const url = `/non-integrated-test-assets/${storedFileName}` + const mimeType = file.type || 'application/octet-stream' + + if (productId) { + const attachedResult = await intakeNonIntegratedProductService.attachUploadFile( + productId, + { + fileId, + name: file.name, + category, + dataUrl: url, + mimeType, + size: file.size, + storageKey: storedFileName, + ...metadata, + }, + user, + ) + + return jsonSuccess( + { + product: attachedResult.product, + file: attachedResult.file, + url, + fileName: file.name, + mimeType, + size: file.size, + storageKey: storedFileName, + }, + 201, + requestId, + ) + } + return jsonSuccess( { - url: `/non-integrated-test-assets/${storedFileName}`, + url, fileName: file.name, - mimeType: file.type || 'application/octet-stream', + mimeType, size: file.size, + storageKey: storedFileName, }, 201, requestId, diff --git a/furniture-3d-system/src/app/intake/non-integrated/NonIntegratedDetailClient.test.tsx b/furniture-3d-system/src/app/intake/non-integrated/NonIntegratedDetailClient.test.tsx index baabce3c..dfd0cfb5 100644 --- a/furniture-3d-system/src/app/intake/non-integrated/NonIntegratedDetailClient.test.tsx +++ b/furniture-3d-system/src/app/intake/non-integrated/NonIntegratedDetailClient.test.tsx @@ -4,6 +4,10 @@ import { render, screen, waitFor, within } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; import type { ReactNode } from "react"; import NonIntegratedDetailClient from "./NonIntegratedDetailClient"; +import { + saveSharedNonIntegratedProduct, + uploadSharedNonIntegratedFile, +} from "./nonIntegratedApi"; import { createDefaultDocumentWorkspaceConfirmations, getNonIntegratedProductById, @@ -53,6 +57,18 @@ jest.mock("../../../lib/model-preview-zip", () => ({ extractPreviewModelFromZip: jest.fn(), })); +jest.mock("./nonIntegratedApi", () => ({ + saveSharedNonIntegratedProduct: jest.fn(async (product) => product), + uploadSharedNonIntegratedFile: jest.fn(), +})); + +const mockedSaveSharedNonIntegratedProduct = jest.mocked( + saveSharedNonIntegratedProduct, +); +const mockedUploadSharedNonIntegratedFile = jest.mocked( + uploadSharedNonIntegratedFile, +); + function createProductRecord( overrides: Partial = {}, ): NonIntegratedProductRecord { @@ -174,6 +190,9 @@ describe("NonIntegratedDetailClient", () => { (parseZipFile as jest.Mock).mockReset(); (extractPreviewModelFromZip as jest.Mock).mockReset(); + mockedSaveSharedNonIntegratedProduct.mockReset(); + mockedSaveSharedNonIntegratedProduct.mockImplementation(async (product) => product); + mockedUploadSharedNonIntegratedFile.mockReset(); }); afterEach(() => { @@ -548,6 +567,67 @@ describe("NonIntegratedDetailClient", () => { it("persists 泡沫护角 rows after submit and reload", async () => { const actor = userEvent.setup(); + { + const actor = userEvent.setup(); + const previousNodeEnv = process.env.NODE_ENV; + process.env.NODE_ENV = "development"; + + mockedUploadSharedNonIntegratedFile.mockResolvedValue({ + url: "/non-integrated-test-assets/shared-detail.png", + fileName: "扶手细节图.png", + mimeType: "image/png", + file: { + id: "shared-photo-1", + name: "扶手细节图.png", + category: "产品实拍图", + dataUrl: "/non-integrated-test-assets/shared-detail.png", + mimeType: "image/png", + photoGroup: "detail", + }, + }); + + try { + render( + , + ); + + await actor.click(screen.getByTestId("document-workspace-trigger-产品实拍图")); + await actor.click(screen.getByTestId("product-photo-panel-tab-detail")); + await actor.upload( + screen.getByTestId("product-photo-detail-upload-input"), + new File(["detail"], "扶手细节图.png", { type: "image/png" }), + ); + + const detailList = await screen.findByTestId("product-photo-detail-list"); + expect(within(detailList).getByText("扶手细节图.png")).toBeInTheDocument(); + const uploadCall = mockedUploadSharedNonIntegratedFile.mock.calls[0]; + expect(uploadCall).toBeTruthy(); + expect(uploadCall?.[0]).toBe("remote-photo-product"); + expect((uploadCall?.[1] as File).name).toBe("扶手细节图.png"); + expect(uploadCall?.[2]).toBe("产品实拍图"); + expect(uploadCall?.[3]).toEqual(expect.any(String)); + expect(uploadCall?.[4]).toEqual( + expect.objectContaining({ + photoGroup: "detail", + productPhotoStyleKey: "K01", + }), + ); + } finally { + process.env.NODE_ENV = previousNodeEnv; + } + } + + }); + + it("persists 娉℃搏鎶よ rows after submit and reload", async () => { + const actor = userEvent.setup(); + saveNonIntegratedProduct( createProductRecord({ id: "DNZ0001-package-corner-save", diff --git a/furniture-3d-system/src/app/intake/non-integrated/NonIntegratedDetailClient.tsx b/furniture-3d-system/src/app/intake/non-integrated/NonIntegratedDetailClient.tsx index cc4d9990..7b8d71b8 100644 --- a/furniture-3d-system/src/app/intake/non-integrated/NonIntegratedDetailClient.tsx +++ b/furniture-3d-system/src/app/intake/non-integrated/NonIntegratedDetailClient.tsx @@ -118,7 +118,10 @@ import { type NonIntegratedStyleDetail, type NonIntegratedUploadFile, } from "./nonIntegratedStorage"; -import { saveSharedNonIntegratedProduct } from "./nonIntegratedApi"; +import { + saveSharedNonIntegratedProduct, + uploadSharedNonIntegratedFile, +} from "./nonIntegratedApi"; type BasicFieldType = "text" | "textarea" | "select"; type SkuTabKey = "skuInfo" | "fileUpload"; @@ -3257,26 +3260,103 @@ function applyUploadFileCategoryMetadata( return file; } +type CreateUploadFileRecordOptions = { + productId?: string; + fileId?: string; + productDataGroup?: ProductDataGroup; + photoGroup?: ProductPhotoGroup; + photoSlot?: ProductPhotoNineViewSlot | null; + productPhotoStyleKey?: string; + installStyleKey?: string; + installAssetType?: InstallationAssetType; + installVersionLabel?: string; + installIsCurrentVersion?: boolean; + packagingStyleKey?: string; + packagingAssetType?: PackagingAssetType; + detectionAssetType?: DetectionWorkspaceAssetType; +}; + +function buildUploadMetadataPayload(options?: CreateUploadFileRecordOptions) { + if (!options) { + return undefined; + } + + const metadata: Record = {}; + + if (options.productDataGroup) { + metadata.productDataGroup = options.productDataGroup; + } + if (options.photoGroup) { + metadata.photoGroup = options.photoGroup; + } + if (options.photoSlot) { + metadata.photoSlot = options.photoSlot; + } + if (options.productPhotoStyleKey) { + metadata.productPhotoStyleKey = options.productPhotoStyleKey; + } + if (options.installStyleKey) { + metadata.installStyleKey = options.installStyleKey; + } + if (options.installAssetType) { + metadata.installAssetType = options.installAssetType; + } + if (options.installVersionLabel) { + metadata.installVersionLabel = options.installVersionLabel; + } + if (typeof options.installIsCurrentVersion === "boolean") { + metadata.installIsCurrentVersion = options.installIsCurrentVersion; + } + if (options.packagingStyleKey) { + metadata.packagingStyleKey = options.packagingStyleKey; + } + if (options.packagingAssetType) { + metadata.packagingAssetType = options.packagingAssetType; + } + if (options.detectionAssetType) { + metadata.detectionAssetType = options.detectionAssetType; + } + + return Object.keys(metadata).length > 0 ? metadata : undefined; +} + async function createUploadFileRecord( file: File, category: UploadCategoryKey, - options?: { - productDataGroup?: ProductDataGroup; - photoGroup?: ProductPhotoGroup; - photoSlot?: ProductPhotoNineViewSlot | null; - productPhotoStyleKey?: string; - installStyleKey?: string; - installAssetType?: InstallationAssetType; - installVersionLabel?: string; - installIsCurrentVersion?: boolean; - packagingStyleKey?: string; - packagingAssetType?: PackagingAssetType; - detectionAssetType?: DetectionWorkspaceAssetType; - }, + options?: CreateUploadFileRecordOptions, ) { + const fileId = options?.fileId ?? buildNonIntegratedUploadFileId(); + const uploadMetadata = buildUploadMetadataPayload(options); + + if (shouldUseRemoteAssetStorage() && options?.productId) { + const uploadedAsset = await uploadSharedNonIntegratedFile( + options.productId, + file, + category, + fileId, + uploadMetadata, + ); + + const remoteFile = applyUploadFileCategoryMetadata( + { + ...(uploadedAsset.file ?? { + id: fileId, + name: uploadedAsset.fileName || file.name, + category, + dataUrl: uploadedAsset.url, + mimeType: uploadedAsset.mimeType, + }), + } as NonIntegratedUploadFile, + category, + options, + ); + + return remoteFile; + } + const uploadedAsset = await uploadFileToServer(file); const uploadFile: NonIntegratedUploadFile = { - id: buildNonIntegratedUploadFileId(), + id: fileId, name: uploadedAsset.fileName, category, dataUrl: uploadedAsset.url, @@ -4590,6 +4670,7 @@ export default function NonIntegratedDetailClient({ const localStorageCompactionFinishedRef = useRef(false); const isReadOnly = mode === "view"; + const sharedUploadProductId = mode === "edit" && productId ? productId : undefined; const isCopiedBrandProduct = Boolean(existingProduct?.copiedFromProductId); const isDocumentWorkspaceMode = mode !== "create" && documentWorkspaceView === "document"; @@ -7342,6 +7423,7 @@ export default function NonIntegratedDetailClient({ file, forcedCategory ?? inferUploadCategory(file.name), { + productId: sharedUploadProductId, productDataGroup: forcedCategory === PRODUCT_DATA_WORKSPACE_CATEGORY ? activeProductDataGroup @@ -7377,6 +7459,7 @@ export default function NonIntegratedDetailClient({ const nextFiles = await Promise.all( files.map((file) => createUploadFileRecord(file, DETECTION_WORKSPACE_CATEGORY, { + productId: sharedUploadProductId, detectionAssetType: "quality-standard", }), ), @@ -7422,6 +7505,7 @@ export default function NonIntegratedDetailClient({ const nextFiles = await Promise.all( files.map((file) => createUploadFileRecord(file, INSTALLATION_WORKSPACE_CATEGORY, { + productId: sharedUploadProductId, installStyleKey: targetStyleKey, installAssetType: targetAssetType, installVersionLabel: nextVersionLabel, @@ -7486,6 +7570,7 @@ export default function NonIntegratedDetailClient({ const nextFiles = await Promise.all( files.map((file) => createUploadFileRecord(file, PACKAGING_WORKSPACE_CATEGORY, { + productId: sharedUploadProductId, packagingStyleKey: selectedPackagingStyleEntry.key, packagingAssetType: PACKAGING_DRAWING_ASSET_TYPE, }), @@ -7586,6 +7671,7 @@ export default function NonIntegratedDetailClient({ const nextFiles = await Promise.all( matchedFiles.map(({ file, slot }) => createUploadFileRecord(file, PRODUCT_PHOTO_CATEGORY, { + productId: sharedUploadProductId, photoGroup: "nine-view", photoSlot: slot, productPhotoStyleKey: targetStyleKey, @@ -7662,6 +7748,7 @@ export default function NonIntegratedDetailClient({ const nextFiles = await Promise.all( files.map((file) => createUploadFileRecord(file, PRODUCT_PHOTO_CATEGORY, { + productId: sharedUploadProductId, photoGroup: targetGroup, productPhotoStyleKey: targetStyleKey, }), @@ -7728,6 +7815,8 @@ export default function NonIntegratedDetailClient({ file, PRODUCT_PHOTO_CATEGORY, { + productId: sharedUploadProductId, + fileId: currentSlotFile?.id, photoGroup: "nine-view", photoSlot: currentSlot, productPhotoStyleKey: currentProductPhotoStyleKey, @@ -7781,6 +7870,8 @@ export default function NonIntegratedDetailClient({ file, PRODUCT_PHOTO_CATEGORY, { + productId: sharedUploadProductId, + fileId: currentGroupedFile.id, photoGroup: selectedProductPhotoTarget.group, productPhotoStyleKey: currentProductPhotoStyleKey, }, diff --git a/furniture-3d-system/src/app/intake/non-integrated/nonIntegratedApi.ts b/furniture-3d-system/src/app/intake/non-integrated/nonIntegratedApi.ts index 08841e8c..48375e85 100644 --- a/furniture-3d-system/src/app/intake/non-integrated/nonIntegratedApi.ts +++ b/furniture-3d-system/src/app/intake/non-integrated/nonIntegratedApi.ts @@ -64,6 +64,7 @@ export async function uploadSharedNonIntegratedFile( file: File, category: string, fileId?: string, + metadata?: Record, ) { const formData = new FormData() formData.append('productId', productId) @@ -72,6 +73,9 @@ export async function uploadSharedNonIntegratedFile( if (fileId) { formData.append('fileId', fileId) } + if (metadata && Object.keys(metadata).length > 0) { + formData.append('metadata', JSON.stringify(metadata)) + } const response = await fetch('/api/intake/non-integrated/upload', { method: 'POST', @@ -85,6 +89,7 @@ export async function uploadSharedNonIntegratedFile( fileName: string mimeType: string size?: number + storageKey?: string }>(response) } diff --git a/furniture-3d-system/src/components/platform/ModelPreviewPanel.tsx b/furniture-3d-system/src/components/platform/ModelPreviewPanel.tsx index 3e3f85ed..a0abfeb8 100644 --- a/furniture-3d-system/src/components/platform/ModelPreviewPanel.tsx +++ b/furniture-3d-system/src/components/platform/ModelPreviewPanel.tsx @@ -12,6 +12,7 @@ import { import { CameraController } from '@/lib/3d/camera-controller' import { ModelViewer, type ModelViewerWorldNode } from '@/components/3d/ModelViewer' import type { HoleData as HoleOverlayData } from '@/components/3d/HoleViewer' +import { cn } from '@/lib/utils' interface ModelPreviewPanelProps { modelUrl?: string | null @@ -167,6 +168,17 @@ export function ModelPreviewPanel({ const effectiveShowSceneGrid = externalShowSceneGrid !== undefined ? externalShowSceneGrid : internalShowSceneGrid const hasContent = customHasContent || !!customViewportContent || (worldNodes !== undefined) || (previewNodes && previewNodes.length > 0) || !!modelUrl + const topRightOverlay = + showFpsBadge || overlayTopRight ? ( +
+ {showFpsBadge ? ( +
+ FPS +
+ ) : null} + {overlayTopRight} +
+ ) : null const handleEngineReady = useCallback( ({ engine, modelRoot }: { engine: ThreeDEngine; modelRoot: any }) => { @@ -218,7 +230,32 @@ export function ModelPreviewPanel({ if (!hasContent) { return ( -
+
+ {topRightOverlay ?
{topRightOverlay}
: null} + {overlayLeft ? ( +
+ {overlayLeft} +
+ ) : null} + {overlayRight ? ( +
+ {overlayRight} +
+ ) : null} + {overlayCenter ? ( +
+ {overlayCenter} +
+ ) : null} + {overlayBottomCenter ? ( +
{overlayBottomCenter}
+ ) : null} + {overlayBottomRight ?
{overlayBottomRight}
: null}
{emptyIcon || } @@ -247,16 +284,7 @@ export function ModelPreviewPanel({ onFitToScene={handleFitToScene} onResetView={handleResetView} extraSidebarActions={extraSidebarActions} - overlayTopRight={ -
- {showFpsBadge ? ( -
- FPS -
- ) : null} - {overlayTopRight} -
- } + overlayTopRight={topRightOverlay} overlayLeft={overlayLeft} overlayLeftClassName={overlayLeftClassName} overlayRight={overlayRight} diff --git a/furniture-3d-system/src/components/platform/__tests__/ModelPreviewPanel.test.tsx b/furniture-3d-system/src/components/platform/__tests__/ModelPreviewPanel.test.tsx new file mode 100644 index 00000000..71323a07 --- /dev/null +++ b/furniture-3d-system/src/components/platform/__tests__/ModelPreviewPanel.test.tsx @@ -0,0 +1,48 @@ +import { render, screen } from '@testing-library/react' +import { ModelPreviewPanel } from '../ModelPreviewPanel' + +jest.mock('@/components/3d/ModelViewer', () => ({ + __esModule: true, + ModelViewer: () =>
, +})) + +jest.mock('@/lib/3d/camera-controller', () => ({ + __esModule: true, + CameraController: class { + dispose() {} + + setDefaultAnimConfig() {} + + setNavigationModel() {} + + applyPreset() { + return Promise.resolve() + } + + fitToScene() { + return Promise.resolve() + } + }, +})) + +jest.mock('@/lib/3d/engine', () => ({ + __esModule: true, + ThreeDEngine: class {}, +})) + +describe('ModelPreviewPanel', () => { + it('keeps the top-right actions visible in empty state', () => { + render( + 上传3D模型} + />, + ) + + expect(screen.getByRole('button', { name: '上传3D模型' })).toBeInTheDocument() + expect(screen.getByText('支持上传 .zip / .glb / .gltf / .obj')).toBeInTheDocument() + }) +}) diff --git a/furniture-3d-system/src/server/repositories/intake-non-integrated-product.repository.ts b/furniture-3d-system/src/server/repositories/intake-non-integrated-product.repository.ts index 94379516..7e97a25b 100644 --- a/furniture-3d-system/src/server/repositories/intake-non-integrated-product.repository.ts +++ b/furniture-3d-system/src/server/repositories/intake-non-integrated-product.repository.ts @@ -53,6 +53,27 @@ class IntakeNonIntegratedProductRepositoryClass extends BaseRepository< }) } + async findDeletedBySpuBase(spuBase: string, excludeId?: string) { + return this.prisma.intakeNonIntegratedProduct.findFirst({ + where: { + spuBase, + deletedAt: { + not: null, + }, + ...(excludeId + ? { + id: { + not: excludeId, + }, + } + : null), + }, + orderBy: { + updatedAt: 'desc', + }, + }) + } + async softDeleteByUser(id: string, updatedBy: string) { return this.prisma.intakeNonIntegratedProduct.update({ where: { id }, diff --git a/furniture-3d-system/src/server/services/__tests__/intake-non-integrated-product.service.test.ts b/furniture-3d-system/src/server/services/__tests__/intake-non-integrated-product.service.test.ts index 9bfdf25e..ceb01aff 100644 --- a/furniture-3d-system/src/server/services/__tests__/intake-non-integrated-product.service.test.ts +++ b/furniture-3d-system/src/server/services/__tests__/intake-non-integrated-product.service.test.ts @@ -7,6 +7,7 @@ jest.mock('../../repositories/intake-non-integrated-product.repository', () => ( findActiveById: jest.fn(), findById: jest.fn(), findBySpuBase: jest.fn(), + findDeletedBySpuBase: jest.fn(), create: jest.fn(), update: jest.fn(), updatePayloadByUser: jest.fn(), @@ -119,6 +120,7 @@ describe('intakeNonIntegratedProductService', () => { it('新建记录时会写入部门与团队作用域字段', async () => { const payload = createPayload({ id: 'non-integrated-create' }) mockedRepository.findBySpuBase.mockResolvedValue(null as never) + mockedRepository.findDeletedBySpuBase.mockResolvedValue(null as never) mockedRepository.findById.mockResolvedValue(null as never) mockedRepository.create.mockResolvedValue(createRecord({ id: 'non-integrated-create' }) as never) mockedRepository.findActiveById.mockResolvedValue( @@ -203,6 +205,8 @@ describe('intakeNonIntegratedProductService', () => { 'application/vnd.openxmlformats-officedocument.presentationml.presentation', storageKey: 'intake/non-integrated/non-integrated-1/uploads/file-1.pptx', size: 2048, + photoGroup: 'detail', + productPhotoStyleKey: 'style-1', }, ], }) @@ -227,6 +231,8 @@ describe('intakeNonIntegratedProductService', () => { 'application/vnd.openxmlformats-officedocument.presentationml.presentation', storageKey: 'intake/non-integrated/non-integrated-1/uploads/file-1.pptx', size: 2048, + photoGroup: 'detail', + productPhotoStyleKey: 'style-1', }, baseUser, ) @@ -245,6 +251,8 @@ describe('intakeNonIntegratedProductService', () => { category: '评审资料', storageKey: 'intake/non-integrated/non-integrated-1/uploads/file-1.pptx', size: 2048, + photoGroup: 'detail', + productPhotoStyleKey: 'style-1', }), ]), ) @@ -252,11 +260,15 @@ describe('intakeNonIntegratedProductService', () => { id: 'file-1', storageKey: 'intake/non-integrated/non-integrated-1/uploads/file-1.pptx', size: 2048, + photoGroup: 'detail', + productPhotoStyleKey: 'style-1', }) expect(result.product.uploadFiles[0]).toMatchObject({ id: 'file-1', storageKey: 'intake/non-integrated/non-integrated-1/uploads/file-1.pptx', size: 2048, + photoGroup: 'detail', + productPhotoStyleKey: 'style-1', }) }) @@ -341,4 +353,57 @@ describe('intakeNonIntegratedProductService', () => { }, }) }) + + it('reuses a soft-deleted record when recreating the same SPU', async () => { + const payload = createPayload({ + id: 'non-integrated-recreate', + spu: 'DNZ0001', + createdAt: '2026-04-22T10:00:00.000Z', + updatedAt: '2026-04-22T10:00:00.000Z', + }) + + mockedRepository.findBySpuBase.mockResolvedValue(null as never) + mockedRepository.findDeletedBySpuBase.mockResolvedValue( + createRecord({ + id: 'non-integrated-deleted', + deletedAt: new Date('2026-04-21T10:00:00.000Z'), + }) as never, + ) + mockedRepository.findById.mockResolvedValue(null as never) + mockedRepository.update.mockResolvedValue( + createRecord({ + id: 'non-integrated-deleted', + payload: createPayload({ + id: 'non-integrated-deleted', + createdAt: '2026-04-22T10:00:00.000Z', + updatedAt: '2026-04-22T10:00:00.000Z', + }), + }) as never, + ) + mockedRepository.findActiveById.mockResolvedValue( + createRecord({ + id: 'non-integrated-deleted', + payload: createPayload({ + id: 'non-integrated-deleted', + createdAt: '2026-04-22T10:00:00.000Z', + updatedAt: '2026-04-22T10:00:00.000Z', + }), + }) as never, + ) + + const savedProduct = await intakeNonIntegratedProductService.save(payload, baseUser) + + expect(mockedRepository.update).toHaveBeenCalledWith( + 'non-integrated-deleted', + expect.objectContaining({ + spu: 'DNZ0001', + spuBase: 'DNZ0001', + createdBy: 'creator-1', + updatedBy: 'creator-1', + deletedAt: null, + }), + ) + expect(mockedRepository.create).not.toHaveBeenCalled() + expect(savedProduct.id).toBe('non-integrated-deleted') + }) }) diff --git a/furniture-3d-system/src/server/services/intake-non-integrated-product.service.ts b/furniture-3d-system/src/server/services/intake-non-integrated-product.service.ts index 603d1201..16e06fcb 100644 --- a/furniture-3d-system/src/server/services/intake-non-integrated-product.service.ts +++ b/furniture-3d-system/src/server/services/intake-non-integrated-product.service.ts @@ -51,6 +51,23 @@ type SharedUploadFileSelector = { fileUrl?: string } +type AttachUploadFileMetadata = Partial< + Pick< + NonIntegratedUploadFile, + | 'productDataGroup' + | 'detectionAssetType' + | 'photoGroup' + | 'photoSlot' + | 'productPhotoStyleKey' + | 'installStyleKey' + | 'installAssetType' + | 'installVersionLabel' + | 'installIsCurrentVersion' + | 'packagingStyleKey' + | 'packagingAssetType' + > +> + type AttachUploadFileInput = { fileId?: string name: string @@ -59,7 +76,7 @@ type AttachUploadFileInput = { mimeType: string size?: number storageKey?: string -} +} & AttachUploadFileMetadata type SavePresentationPreviewInput = SharedUploadFileSelector & { preview: NonIntegratedUploadPresentationPreview @@ -339,6 +356,65 @@ function sanitizeUploadCategory(value: string | undefined) { return typeof value === 'string' && value.trim().length > 0 ? value.trim() : '未分类' } +function trimOptionalString(value: unknown): T | undefined { + return typeof value === 'string' && value.trim().length > 0 + ? (value.trim() as T) + : undefined +} + +function pickUploadFileMetadata(input: AttachUploadFileInput): AttachUploadFileMetadata { + const productDataGroup = trimOptionalString>( + input.productDataGroup, + ) + const detectionAssetType = trimOptionalString< + NonNullable + >(input.detectionAssetType) + const photoGroup = trimOptionalString>( + input.photoGroup, + ) + const photoSlot = trimOptionalString>( + input.photoSlot, + ) + const productPhotoStyleKey = trimOptionalString< + NonNullable + >(input.productPhotoStyleKey) + const installStyleKey = trimOptionalString< + NonNullable + >(input.installStyleKey) + const installAssetType = trimOptionalString< + NonNullable + >(input.installAssetType) + const installVersionLabel = trimOptionalString< + NonNullable + >(input.installVersionLabel) + const packagingStyleKey = trimOptionalString< + NonNullable + >(input.packagingStyleKey) + const packagingAssetType = trimOptionalString< + NonNullable + >(input.packagingAssetType) + + return { + ...(productDataGroup ? { productDataGroup } : {}), + ...(detectionAssetType ? { detectionAssetType } : {}), + ...(photoGroup ? { photoGroup } : {}), + ...(photoSlot ? { photoSlot } : {}), + ...(productPhotoStyleKey ? { productPhotoStyleKey } : {}), + ...(installStyleKey ? { installStyleKey } : {}), + ...(installAssetType ? { installAssetType } : {}), + ...(installVersionLabel ? { installVersionLabel } : {}), + ...(typeof input.installIsCurrentVersion === 'boolean' + ? { installIsCurrentVersion: input.installIsCurrentVersion } + : {}), + ...(packagingStyleKey ? { packagingStyleKey } : {}), + ...(packagingAssetType ? { packagingAssetType } : {}), + } +} + +function isSpuBaseUniqueConstraintError(error: unknown) { + return error instanceof Prisma.PrismaClientKnownRequestError && error.code === 'P2002' +} + class IntakeNonIntegratedProductService { private readonly repository: IntakeNonIntegratedProductRepositoryClass = intakeNonIntegratedProductRepository @@ -401,52 +477,90 @@ class IntakeNonIntegratedProductService { ) } + const deletedRecordToRestore = existingRecordCandidate + ? null + : await this.repository.findDeletedBySpuBase(normalizedSpuBase, normalizedProduct.id) + const existingRecord = existingRecordCandidate const scopedDepartmentId = intakeNonIntegratedScopeService.getScopedDepartmentId(user) const scopedTeamId = intakeNonIntegratedScopeService.getScopedTeamId(user) + const persistedProduct = deletedRecordToRestore + ? { + ...normalizedProduct, + id: deletedRecordToRestore.id, + } + : normalizedProduct - if (existingRecord) { - intakeNonIntegratedScopeService.assertCanWriteRecord(existingRecord, user) + try { + if (existingRecord) { + intakeNonIntegratedScopeService.assertCanWriteRecord(existingRecord, user) - await this.repository.update(normalizedProduct.id, { - spu: normalizedProduct.spu, - spuBase: normalizedSpuBase, - brand: normalizedProduct.brand ?? '', - designer: normalizedProduct.designer, - operator: normalizedProduct.operator, - purchaser: normalizedProduct.purchaser, - payload: toJsonValue(normalizedProduct), - updatedBy: user.id, - deletedAt: null, - ...(existingRecord.createdBy === user.id && - !existingRecord.departmentId && - scopedDepartmentId - ? { departmentId: scopedDepartmentId } - : {}), - ...(existingRecord.createdBy === user.id && !existingRecord.teamId && scopedTeamId - ? { teamId: scopedTeamId } - : {}), - }) - } else { - await this.repository.create({ - id: normalizedProduct.id, - spu: normalizedProduct.spu, - spuBase: normalizedSpuBase, - brand: normalizedProduct.brand ?? '', - designer: normalizedProduct.designer, - operator: normalizedProduct.operator, - purchaser: normalizedProduct.purchaser, - payload: toJsonValue(normalizedProduct), - visibility: DEFAULT_NON_INTEGRATED_VISIBILITY, - ...(scopedDepartmentId ? { departmentId: scopedDepartmentId } : {}), - ...(scopedTeamId ? { teamId: scopedTeamId } : {}), - createdBy: user.id, - updatedBy: user.id, - createdAt: new Date(normalizedProduct.createdAt), - }) + await this.repository.update(existingRecord.id, { + spu: persistedProduct.spu, + spuBase: normalizedSpuBase, + brand: persistedProduct.brand ?? '', + designer: persistedProduct.designer, + operator: persistedProduct.operator, + purchaser: persistedProduct.purchaser, + payload: toJsonValue(persistedProduct), + updatedBy: user.id, + deletedAt: null, + ...(existingRecord.createdBy === user.id && + !existingRecord.departmentId && + scopedDepartmentId + ? { departmentId: scopedDepartmentId } + : {}), + ...(existingRecord.createdBy === user.id && !existingRecord.teamId && scopedTeamId + ? { teamId: scopedTeamId } + : {}), + }) + } else if (deletedRecordToRestore) { + await this.repository.update(deletedRecordToRestore.id, { + spu: persistedProduct.spu, + spuBase: normalizedSpuBase, + brand: persistedProduct.brand ?? '', + designer: persistedProduct.designer, + operator: persistedProduct.operator, + purchaser: persistedProduct.purchaser, + payload: toJsonValue(persistedProduct), + visibility: DEFAULT_NON_INTEGRATED_VISIBILITY, + departmentId: scopedDepartmentId ?? null, + teamId: scopedTeamId ?? null, + createdBy: user.id, + updatedBy: user.id, + createdAt: new Date(persistedProduct.createdAt), + deletedAt: null, + }) + } else { + await this.repository.create({ + id: persistedProduct.id, + spu: persistedProduct.spu, + spuBase: normalizedSpuBase, + brand: persistedProduct.brand ?? '', + designer: persistedProduct.designer, + operator: persistedProduct.operator, + purchaser: persistedProduct.purchaser, + payload: toJsonValue(persistedProduct), + visibility: DEFAULT_NON_INTEGRATED_VISIBILITY, + ...(scopedDepartmentId ? { departmentId: scopedDepartmentId } : {}), + ...(scopedTeamId ? { teamId: scopedTeamId } : {}), + createdBy: user.id, + updatedBy: user.id, + createdAt: new Date(persistedProduct.createdAt), + }) + } + } catch (error) { + if (isSpuBaseUniqueConstraintError(error)) { + throw new AppError( + ErrorCode.VALIDATION_FAILED, + NON_INTEGRATED_DUPLICATE_SPU_MESSAGE, + ) + } + + throw error } - const savedProduct = await this.getById(normalizedProduct.id, user) + const savedProduct = await this.getById(persistedProduct.id, user) if (!savedProduct) { throw new AppError(ErrorCode.SYSTEM_INTERNAL, '非集成产品保存后读取失败') } @@ -505,6 +619,7 @@ class IntakeNonIntegratedProductService { fileUrl: input.dataUrl, mimeType: input.mimeType, uploadedAt, + ...pickUploadFileMetadata(input), ...(typeof input.size === 'number' && Number.isFinite(input.size) ? { size: input.size } : {}), diff --git a/scripts/deploy-yydd-prod-from-git.ps1 b/scripts/deploy-yydd-prod-from-git.ps1 index 10cfd16a..db0adc70 100644 --- a/scripts/deploy-yydd-prod-from-git.ps1 +++ b/scripts/deploy-yydd-prod-from-git.ps1 @@ -305,6 +305,27 @@ fetch_target_commit() { git -C "$REPO_DIR" cat-file -e "$COMMIT_SHA^{commit}" } +ensure_shared_non_integrated_assets() { + local app_root="$1" + local shared_assets="$SHARED/non-integrated-test-assets" + local release_assets="$app_root/public/non-integrated-test-assets" + + assert_path_in_base "$shared_assets" + mkdir -p "$shared_assets" + + if [ -n "$PREVIOUS_RELEASE" ] && [ -d "$PREVIOUS_RELEASE/$PROJECT_SUBDIR/public/non-integrated-test-assets" ]; then + cp -a "$PREVIOUS_RELEASE/$PROJECT_SUBDIR/public/non-integrated-test-assets/." "$shared_assets/" 2>/dev/null || true + fi + + if [ -d "$LEGACY_APP/public/non-integrated-test-assets" ] && [ ! -L "$LEGACY_APP/public/non-integrated-test-assets" ]; then + cp -a "$LEGACY_APP/public/non-integrated-test-assets/." "$shared_assets/" 2>/dev/null || true + fi + + rm -rf -- "$release_assets" + mkdir -p "$(dirname "$release_assets")" + ln -sfn "$shared_assets" "$release_assets" +} + mkdir -p "$BASE" "$RELEASES" "$SHARED" copy_legacy_env_if_needed ensure_repo @@ -364,6 +385,7 @@ printf '%s\n' "$COMMIT_SHA" > "$RELEASE_DIR/REVISION" printf '%s\n' "$SOURCE_REF" > "$RELEASE_DIR/SOURCE_REF" ln -sfn "$SHARED/.env" "$APP_DIR/.env" +ensure_shared_non_integrated_assets "$APP_DIR" cd "$APP_DIR" export PUPPETEER_SKIP_DOWNLOAD=true