diff --git a/app/api/materials/upload/route.idempotency.test.ts b/app/api/materials/upload/route.idempotency.test.ts index d64a7f7..35c09a1 100644 --- a/app/api/materials/upload/route.idempotency.test.ts +++ b/app/api/materials/upload/route.idempotency.test.ts @@ -233,13 +233,40 @@ describe('materials upload second-stage idempotency', () => { expect(response.status).toBe(400) await expect(response.json()).resolves.toMatchObject({ - error: expect.stringContaining('真实 SPU'), + error: expect.stringContaining('SPU 不合规'), }) expect(acquireTaskMaterialUploadSessionLocks).not.toHaveBeenCalled() expect(resolveOwnedMergedUpload).not.toHaveBeenCalled() expect(prisma.material.create).not.toHaveBeenCalled() }) + test('视频上传只把 SPU 转成大写,不会删除非法字符后继续建档', async () => { + const response = await POST(createPostRequest(buildFormData({ + materialType: '抖音-千川素材', + productModel: 'df-a3dz0008 中文', + }))) + + expect(response.status).toBe(400) + await expect(response.json()).resolves.toMatchObject({ + error: expect.stringContaining('只能使用字母、数字'), + }) + expect(acquireTaskMaterialUploadSessionLocks).not.toHaveBeenCalled() + expect(resolveOwnedMergedUpload).not.toHaveBeenCalled() + expect(prisma.material.create).not.toHaveBeenCalled() + }) + + test('视频上传将合规的小写 SPU 转成大写后保存', async () => { + const response = await POST(createPostRequest(buildFormData({ + materialType: '抖音-千川素材', + productModel: 'df-a3dz0008', + }))) + + expect(response.status).toBe(201) + expect(prisma.material.create).toHaveBeenCalledWith(expect.objectContaining({ + data: expect.objectContaining({ productModel: 'DF-A3DZ0008' }), + })) + }) + test('Material.create 只写服务器从 merged 路径派生的 uploadId', async () => { ;(resolveOwnedMergedUpload as jest.Mock).mockResolvedValueOnce({ uploadId: 'server-derived-upload', diff --git a/app/api/materials/upload/route.ts b/app/api/materials/upload/route.ts index e34dee1..fc48488 100644 --- a/app/api/materials/upload/route.ts +++ b/app/api/materials/upload/route.ts @@ -57,6 +57,7 @@ import { validateCanonicalMaterialSource, } from '@/lib/material-canonical-source'; import { + getSpuProductModelValidationError, isPlausibleSpuProductModel, normalizeSpuProductModel, } from '@/lib/product-model'; @@ -496,7 +497,9 @@ export async function POST(request: NextRequest) { if (VIDEO_TYPES.has(materialType) && !isPlausibleSpuProductModel(productModel)) { return NextResponse.json( - { error: '视频素材必须填写真实 SPU(需同时包含英文字母和数字,例如 DF-A3DZ0008)' }, + { + error: `视频素材 SPU 不合规:${getSpuProductModelValidationError(productModel) || '请检查后重试'}`, + }, { status: 400 }, ); } diff --git a/components/UploadModal.tsx b/components/UploadModal.tsx index a67c6c8..d70aa2d 100644 --- a/components/UploadModal.tsx +++ b/components/UploadModal.tsx @@ -69,7 +69,11 @@ import { validateImageFiles, } from '@/components/upload/drag-drop-utils' import { getDingtalkApprovalNoError } from '@/lib/dingtalk-approval-no' -import { isPlausibleSpuProductModel, sanitizeSpuProductModelInput } from '@/lib/product-model' +import { + getSpuProductModelValidationError, + isPlausibleSpuProductModel, + normalizeSpuProductModel, +} from '@/lib/product-model' interface UploadModalProps { onClose: () => void @@ -1505,10 +1509,17 @@ export default function UploadModal({ onClose, onSuccess }: UploadModalProps) { // 新的批量上传模式(可折叠文件列表) if (isBatchMode && batchFiles.length > 0) { + const invalidVideoSpu = isVideoMaterialType(materialType) + ? batchFiles.find(item => !isPlausibleSpuProductModel(item.productModel)) + : undefined + if (invalidVideoSpu) { + setError(`视频 SPU 不合规:${getSpuProductModelValidationError(invalidVideoSpu.productModel)}`) + return + } + // 验证所有文件的必填字段 const invalidFiles = batchFiles.filter(item => { if (!item.productModel.trim()) return true - if (isVideoMaterialType(materialType) && !isPlausibleSpuProductModel(item.productModel)) return true // 检查预览图(如果需要) // 详情模板:预览图可选,不强制要求 const needsPreview = !isPreviewOptionalMaterialType(materialType) @@ -1521,11 +1532,7 @@ export default function UploadModal({ onClose, onSuccess }: UploadModalProps) { }) if (invalidFiles.length > 0) { - setError( - isVideoMaterialType(materialType) - ? '有视频未识别到真实 SPU,请展开对应文件并填写(需同时包含英文字母和数字,例如 DF-A3DZ0008)' - : `请为所有文件填写必填字段(SPU${!isPreviewOptionalMaterialType(materialType) ? '、预览图' : ''}${requiresCategoryAndScene(materialType) ? '、店铺、场景' : ''})` - ) + setError(`请为所有文件填写必填字段(SPU${!isPreviewOptionalMaterialType(materialType) ? '、预览图' : ''}${requiresCategoryAndScene(materialType) ? '、店铺、场景' : ''})`) return } @@ -1547,7 +1554,7 @@ export default function UploadModal({ onClose, onSuccess }: UploadModalProps) { if (isBatchVideoMaterialType(materialType) && batchVideoFiles.length > 0) { const invalidFiles = batchVideoFiles.filter(item => !isPlausibleSpuProductModel(item.productModel)) if (invalidFiles.length > 0) { - setError('请为所有视频填写真实 SPU(需同时包含英文字母和数字,例如 DF-A3DZ0008)') + setError(`视频 SPU 不合规:${getSpuProductModelValidationError(invalidFiles[0].productModel)}`) return } await handleBatchVideoUpload() @@ -1629,7 +1636,7 @@ export default function UploadModal({ onClose, onSuccess }: UploadModalProps) { } if (isVideoMaterialType(materialType) && !isPlausibleSpuProductModel(resolvedProductModel)) { - setError('请填写真实 SPU(需同时包含英文字母和数字,例如 DF-A3DZ0008)') + setError(`视频 SPU 不合规:${getSpuProductModelValidationError(resolvedProductModel)}`) return } @@ -2077,7 +2084,7 @@ export default function UploadModal({ onClose, onSuccess }: UploadModalProps) { newFiles[index].productModel = value newFiles[index].productModelSource = 'manual' // 用户手动输入 } else { - processedValue = sanitizeSpuProductModelInput(value) + processedValue = normalizeSpuProductModel(value) newFiles[index].productModel = processedValue newFiles[index].productModelSource = 'manual' // 用户手动输入,最高优先级 @@ -2366,7 +2373,7 @@ export default function UploadModal({ onClose, onSuccess }: UploadModalProps) { disabled={interactionLocked} onUpdateProductModel={(index, value) => { const newFiles = [...batchVideoFiles] - const normalizedValue = sanitizeSpuProductModelInput(value) + const normalizedValue = normalizeSpuProductModel(value) newFiles[index].productModel = normalizedValue // SPU 自动识别品类 const result = parseSPU(normalizedValue) diff --git a/components/upload/BatchFileList.tsx b/components/upload/BatchFileList.tsx index a7cb4f3..a4f8653 100644 --- a/components/upload/BatchFileList.tsx +++ b/components/upload/BatchFileList.tsx @@ -8,7 +8,7 @@ import { formatBytes } from './upload-utils' import { parseSPU } from '@/lib/spu-parser' import { isVideoMaterialType } from '@/lib/video-material-types' import VideoPreviewImageUpload from './VideoPreviewImageUpload' -import { isPlausibleSpuProductModel } from '@/lib/product-model' +import { getSpuProductModelValidationError, isPlausibleSpuProductModel } from '@/lib/product-model' import { hasDragLeftDropZone, prepareFileDrag, @@ -193,7 +193,9 @@ export default function BatchFileList({ id={`batch-product-model-hint-${item.id}`} className="mt-2 text-sm text-red-600 dark:text-red-400" > - 文件名中没有可确认的 SPU,请填写真实型号;纯数字或 “-624-456” 这类文件编号不能作为 SPU。 + {item.productModel + ? getSpuProductModelValidationError(item.productModel) + : '文件名不是完整、合规的 SPU,请填写真实型号;系统不会从文件名中截取或改写 SPU。'}
)} diff --git a/components/upload/BatchFileList.video-preview.test.tsx b/components/upload/BatchFileList.video-preview.test.tsx index b7c3f16..1c5387b 100644 --- a/components/upload/BatchFileList.video-preview.test.tsx +++ b/components/upload/BatchFileList.video-preview.test.tsx @@ -61,7 +61,32 @@ test('无法从视频文件名确认 SPU 时直接显示可恢复的填写提示 expect(screen.getByText(/未识别到 SPU,请填写/)).toBeInTheDocument() const input = screen.getByRole('textbox', { name: 'SPU型号 *' }) expect(input).toHaveAttribute('aria-invalid', 'true') - expect(input).toHaveAccessibleDescription(/纯数字或 “-624-456” 这类文件编号不能作为 SPU/) + expect(input).toHaveAccessibleDescription(/文件名不是完整、合规的 SPU/) +}) + +test('用户输入只自动转大写,非法字符保留并显示错误', () => { + const onUpdateProductModel = jest.fn() + render( +{item.file.name} @@ -125,15 +126,32 @@ export function BatchVideoList({ {formatBytes(item.file.size)}
+ {getSpuProductModelValidationError(item.productModel)} +
+ )} +- 请填写真实 SPU;纯数字或文件尾号不能作为 SPU。 +
+ {productModel + ? getSpuProductModelValidationError(productModel) || '格式正确;英文字母已自动转成大写。' + : '请填写真实 SPU;英文字母会自动转成大写,纯数字或文件尾号不能作为 SPU。'}
)}