fix(intake): restore shared uploads and 3d actions
This commit is contained in:
parent
1ef71a21aa
commit
9b70901324
@ -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<string, unknown>
|
||||
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,
|
||||
|
||||
@ -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> = {},
|
||||
): 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(
|
||||
<NonIntegratedDetailClient
|
||||
mode="edit"
|
||||
productId="remote-photo-product"
|
||||
initialProduct={createProductRecord({
|
||||
id: "remote-photo-product",
|
||||
})}
|
||||
/>,
|
||||
);
|
||||
|
||||
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",
|
||||
|
||||
@ -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<string, unknown> = {};
|
||||
|
||||
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,
|
||||
},
|
||||
|
||||
@ -64,6 +64,7 @@ export async function uploadSharedNonIntegratedFile(
|
||||
file: File,
|
||||
category: string,
|
||||
fileId?: string,
|
||||
metadata?: Record<string, unknown>,
|
||||
) {
|
||||
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)
|
||||
}
|
||||
|
||||
|
||||
@ -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 ? (
|
||||
<div className="flex items-center gap-2">
|
||||
{showFpsBadge ? (
|
||||
<div className="rounded-full bg-black/70 px-2 py-1 text-[10px] font-semibold text-white/90">
|
||||
FPS
|
||||
</div>
|
||||
) : null}
|
||||
{overlayTopRight}
|
||||
</div>
|
||||
) : null
|
||||
|
||||
const handleEngineReady = useCallback(
|
||||
({ engine, modelRoot }: { engine: ThreeDEngine; modelRoot: any }) => {
|
||||
@ -218,7 +230,32 @@ export function ModelPreviewPanel({
|
||||
|
||||
if (!hasContent) {
|
||||
return (
|
||||
<div className="flex h-full w-full items-center justify-center bg-apple-bg-secondary p-12 text-center">
|
||||
<div
|
||||
className={cn(
|
||||
'relative flex h-full w-full items-center justify-center overflow-hidden bg-apple-bg-secondary p-12 text-center',
|
||||
className,
|
||||
)}
|
||||
>
|
||||
{topRightOverlay ? <div className="absolute top-6 right-6 z-20">{topRightOverlay}</div> : null}
|
||||
{overlayLeft ? (
|
||||
<div className={cn('absolute z-20 h-full', overlayLeftClassName || 'left-24 top-1/2 -translate-y-1/2')}>
|
||||
{overlayLeft}
|
||||
</div>
|
||||
) : null}
|
||||
{overlayRight ? (
|
||||
<div className={cn('absolute z-20 h-full', overlayRightClassName || 'right-8 top-1/2 -translate-y-1/2')}>
|
||||
{overlayRight}
|
||||
</div>
|
||||
) : null}
|
||||
{overlayCenter ? (
|
||||
<div className="absolute inset-0 pointer-events-none flex items-center justify-center z-10">
|
||||
{overlayCenter}
|
||||
</div>
|
||||
) : null}
|
||||
{overlayBottomCenter ? (
|
||||
<div className="absolute bottom-8 left-1/2 z-20 -translate-x-1/2">{overlayBottomCenter}</div>
|
||||
) : null}
|
||||
{overlayBottomRight ? <div className="absolute bottom-8 right-8 z-20">{overlayBottomRight}</div> : null}
|
||||
<div className="flex flex-col items-center gap-4">
|
||||
<div className="flex h-16 w-16 items-center justify-center rounded-[2rem] bg-white text-apple-label-tertiary shadow-apple-sm ring-1 ring-black/5">
|
||||
{emptyIcon || <Box size={32} strokeWidth={1.5} />}
|
||||
@ -247,16 +284,7 @@ export function ModelPreviewPanel({
|
||||
onFitToScene={handleFitToScene}
|
||||
onResetView={handleResetView}
|
||||
extraSidebarActions={extraSidebarActions}
|
||||
overlayTopRight={
|
||||
<div className="flex items-center gap-2">
|
||||
{showFpsBadge ? (
|
||||
<div className="rounded-full bg-black/70 px-2 py-1 text-[10px] font-semibold text-white/90">
|
||||
FPS
|
||||
</div>
|
||||
) : null}
|
||||
{overlayTopRight}
|
||||
</div>
|
||||
}
|
||||
overlayTopRight={topRightOverlay}
|
||||
overlayLeft={overlayLeft}
|
||||
overlayLeftClassName={overlayLeftClassName}
|
||||
overlayRight={overlayRight}
|
||||
|
||||
@ -0,0 +1,48 @@
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import { ModelPreviewPanel } from '../ModelPreviewPanel'
|
||||
|
||||
jest.mock('@/components/3d/ModelViewer', () => ({
|
||||
__esModule: true,
|
||||
ModelViewer: () => <div data-testid="model-viewer" />,
|
||||
}))
|
||||
|
||||
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(
|
||||
<ModelPreviewPanel
|
||||
modelUrl={null}
|
||||
showFpsBadge={false}
|
||||
emptyTitle="上传3D模型"
|
||||
emptyDescription="支持上传 .zip / .glb / .gltf / .obj"
|
||||
overlayTopRight={<button type="button">上传3D模型</button>}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.getByRole('button', { name: '上传3D模型' })).toBeInTheDocument()
|
||||
expect(screen.getByText('支持上传 .zip / .glb / .gltf / .obj')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
@ -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 },
|
||||
|
||||
@ -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')
|
||||
})
|
||||
})
|
||||
|
||||
@ -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<T extends string>(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<NonNullable<NonIntegratedUploadFile['productDataGroup']>>(
|
||||
input.productDataGroup,
|
||||
)
|
||||
const detectionAssetType = trimOptionalString<
|
||||
NonNullable<NonIntegratedUploadFile['detectionAssetType']>
|
||||
>(input.detectionAssetType)
|
||||
const photoGroup = trimOptionalString<NonNullable<NonIntegratedUploadFile['photoGroup']>>(
|
||||
input.photoGroup,
|
||||
)
|
||||
const photoSlot = trimOptionalString<NonNullable<NonIntegratedUploadFile['photoSlot']>>(
|
||||
input.photoSlot,
|
||||
)
|
||||
const productPhotoStyleKey = trimOptionalString<
|
||||
NonNullable<NonIntegratedUploadFile['productPhotoStyleKey']>
|
||||
>(input.productPhotoStyleKey)
|
||||
const installStyleKey = trimOptionalString<
|
||||
NonNullable<NonIntegratedUploadFile['installStyleKey']>
|
||||
>(input.installStyleKey)
|
||||
const installAssetType = trimOptionalString<
|
||||
NonNullable<NonIntegratedUploadFile['installAssetType']>
|
||||
>(input.installAssetType)
|
||||
const installVersionLabel = trimOptionalString<
|
||||
NonNullable<NonIntegratedUploadFile['installVersionLabel']>
|
||||
>(input.installVersionLabel)
|
||||
const packagingStyleKey = trimOptionalString<
|
||||
NonNullable<NonIntegratedUploadFile['packagingStyleKey']>
|
||||
>(input.packagingStyleKey)
|
||||
const packagingAssetType = trimOptionalString<
|
||||
NonNullable<NonIntegratedUploadFile['packagingAssetType']>
|
||||
>(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 }
|
||||
: {}),
|
||||
|
||||
@ -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
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user