fix(intake): restore shared uploads and 3d actions

This commit is contained in:
WuYongJie 2026-04-22 14:55:44 +08:00
parent 1ef71a21aa
commit 9b70901324
10 changed files with 603 additions and 69 deletions

View File

@ -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,

View File

@ -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",

View File

@ -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,
},

View File

@ -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)
}

View File

@ -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}

View File

@ -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()
})
})

View File

@ -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 },

View File

@ -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')
})
})

View File

@ -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 }
: {}),

View File

@ -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