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( + , + ) + + const input = screen.getByRole('textbox', { name: /SPU型号/ }) + expect(input).toHaveValue('DF-A3DZ0008 中文') + expect(input).toHaveAttribute('aria-invalid', 'true') + expect(input).toHaveAccessibleDescription(/只能使用字母、数字/) + + fireEvent.change(input, { target: { value: 'df-a3dz0008 中文' } }) + expect(onUpdateProductModel).toHaveBeenCalledWith(0, 'df-a3dz0008 中文') }) test('批量视频的每个文件都提供独立的选填预览图入口', async () => { diff --git a/components/upload/SpecialBatchUploadUI.tsx b/components/upload/SpecialBatchUploadUI.tsx index 167b7e5..d29d6b4 100644 --- a/components/upload/SpecialBatchUploadUI.tsx +++ b/components/upload/SpecialBatchUploadUI.tsx @@ -7,6 +7,7 @@ import Image from 'next/image' import { formatBytes } from '@/components/upload/upload-utils' +import { getSpuProductModelValidationError, isPlausibleSpuProductModel } from '@/lib/product-model' import VideoPreviewImageUpload from './VideoPreviewImageUpload' /** @@ -116,7 +117,7 @@ export function BatchVideoList({
{files.map((item, index) => (
-
+

{item.file.name} @@ -125,15 +126,32 @@ export function BatchVideoList({ {formatBytes(item.file.size)}

- onUpdateProductModel(index, e.target.value)} - disabled={disabled} - className="flex-1 px-3 py-1.5 text-sm rounded-apple border border-gray-300 dark:border-gray-600 dark:bg-gray-700 dark:text-white disabled:cursor-not-allowed disabled:opacity-60" - placeholder="SPU型号" - /> +
+ onUpdateProductModel(index, e.target.value)} + disabled={disabled} + className={`w-full rounded-apple border px-3 py-1.5 text-sm outline-none disabled:cursor-not-allowed disabled:opacity-60 dark:bg-gray-700 dark:text-white ${ + !isPlausibleSpuProductModel(item.productModel) + ? 'border-red-400 focus:border-red-500 focus:ring-2 focus:ring-red-100 dark:border-red-600 dark:focus:ring-red-900' + : 'border-gray-300 focus:border-gray-900 focus:ring-2 focus:ring-gray-200 dark:border-gray-600 dark:focus:border-gray-400 dark:focus:ring-gray-600' + }`} + placeholder="SPU型号" + /> + {!isPlausibleSpuProductModel(item.productModel) && ( + + )} +
diff --git a/components/upload/file-handlers.product-model.test.ts b/components/upload/file-handlers.product-model.test.ts index ebb1b5b..a78d5e5 100644 --- a/components/upload/file-handlers.product-model.test.ts +++ b/components/upload/file-handlers.product-model.test.ts @@ -22,7 +22,7 @@ describe('批量视频 SPU 自动识别', () => { test('只把文件名中可确认的型号作为 SPU', () => { const items = createBatchFileItems( [ - new File(['video'], 'GBPM-SN0204-展示.mp4', { type: 'video/mp4' }), + new File(['video'], 'GBPM-SN0204K12.mp4', { type: 'video/mp4' }), new File(['video'], 'DF-A3DZ0008.mp4', { type: 'video/mp4' }), ], '抖音-千川素材', @@ -37,8 +37,24 @@ describe('批量视频 SPU 自动识别', () => { productModel: item.productModel, productModelSource: item.productModelSource, }))).toEqual([ - { productModel: 'GBPM-SN0204', productModelSource: 'filename' }, + { productModel: 'GBPM-SN0204K12', productModelSource: 'filename' }, { productModel: 'DF-A3DZ0008', productModelSource: 'filename' }, ]) }) + + test('带说明文字的文件名不截取其中一段冒充 SPU', () => { + const [item] = createBatchFileItems( + [new File(['video'], '产品-GBPM-SN0204-展示.mp4', { type: 'video/mp4' })], + '抖音-千川素材', + '', + '', + '', + '', + '', + ) + + expect(item.productModel).toBe('') + expect(item.productModelSource).toBeUndefined() + expect(item.expanded).toBe(true) + }) }) diff --git a/components/upload/upload-utils.test.ts b/components/upload/upload-utils.test.ts index cf7b49f..db5ca58 100644 --- a/components/upload/upload-utils.test.ts +++ b/components/upload/upload-utils.test.ts @@ -25,7 +25,7 @@ describe('upload-utils video types', () => { ['抖音素材-624-456.mp4', ''], ['视频-624-470.mp4', ''], ['624-456.mp4', ''], - ['GBPM-SN0204-展示.mp4', 'GBPM-SN0204'], + ['GBPM-SN0204-展示.mp4', ''], ['DF-A3DZ0008.mp4', 'DF-A3DZ0008'], ['626DNZ0385-1.mp4', '626DNZ0385-1'], ['XY-2024-001.mp4', 'XY-2024-001'], diff --git a/lib/__tests__/product-model.test.ts b/lib/__tests__/product-model.test.ts index bb4c576..93b188f 100644 --- a/lib/__tests__/product-model.test.ts +++ b/lib/__tests__/product-model.test.ts @@ -1,8 +1,8 @@ import { extractPlausibleSpuFromFileName, + getSpuProductModelValidationError, isPlausibleSpuProductModel, normalizeSpuProductModel, - sanitizeSpuProductModelInput, } from '@/lib/product-model' describe('product-model', () => { @@ -16,13 +16,15 @@ describe('product-model', () => { (value) => expect(isPlausibleSpuProductModel(value)).toBe(false), ) - test('统一去空格并转成大写', () => { - expect(normalizeSpuProductModel(' df-a3dz0008 ')).toBe('DF-A3DZ0008') + test('只自动转成大写,不删除用户输入的任何字符', () => { + expect(normalizeSpuProductModel(' df-a3dz0008 中文 ')).toBe(' DF-A3DZ0008 中文 ') + expect(normalizeSpuProductModel('ß1-ſ2-ı3-ff4')).toBe('ß1-ſ2-ı3-ff4') + expect(isPlausibleSpuProductModel('ß1')).toBe(false) }) - test('输入清理保留组合 SPU 的合法加号', () => { - expect(sanitizeSpuProductModelInput(' gbgj-dnz0366+gbgj-sj0251 ')) - .toBe('GBGJ-DNZ0366+GBGJ-SJ0251') + test('不合规内容原样保留后由校验给出明确原因', () => { + expect(getSpuProductModelValidationError(' DF-A3DZ0008')).toBe('SPU 前后不能包含空格') + expect(getSpuProductModelValidationError('DF-A3DZ0008中文')).toContain('只能使用') }) test.each([ @@ -30,7 +32,7 @@ describe('product-model', () => { ['GBGJ-DNZ0366+GBGJ-SJ0251.mp4', 'GBGJ-DNZ0366+GBGJ-SJ0251'], ['GBPM-SN0204K12.mp4', 'GBPM-SN0204K12'], ['626DNZ0385-1.mp4', '626DNZ0385-1'], - ['产品-GBPM-SN0204-展示.mp4', 'GBPM-SN0204'], + ['产品-GBPM-SN0204-展示.mp4', ''], ])('从文件名 %s 安全识别完整 SPU', (fileName, expected) => { expect(extractPlausibleSpuFromFileName(fileName)).toBe(expected) }) diff --git a/lib/product-model.ts b/lib/product-model.ts index 989e1cc..e442583 100644 --- a/lib/product-model.ts +++ b/lib/product-model.ts @@ -1,18 +1,32 @@ const PRODUCT_MODEL_ALLOWED_CHARACTERS = /^[A-Z0-9][A-Z0-9+_.-]*[A-Z0-9]$/ /** - * SPU 在接口和页面中统一使用大写,避免同一型号因大小写形成两组素材。 + * SPU 唯一允许的自动格式化是转成大写。空格、中文和其它非法字符必须 + * 原样留在输入框里交给校验提示,不能静默删除后把另一个型号写入数据库。 */ export function normalizeSpuProductModel(value: unknown): string { - return typeof value === 'string' ? value.trim().toUpperCase() : '' + return typeof value === 'string' + ? value.replace(/[a-z]/g, character => character.toUpperCase()) + : '' } -/** - * 输入框只保留与后端校验一致的字符,避免合法的组合 SPU(例如 A+B) - * 在浏览器里被静默改写。 - */ -export function sanitizeSpuProductModelInput(value: unknown): string { - return normalizeSpuProductModel(value).replace(/[^A-Z0-9+_.-]/g, '') +export const SPU_PRODUCT_MODEL_FORMAT_HINT = + '必须同时包含英文字母和数字,只能使用字母、数字及 +、-、_、.,且不能以符号开头或结尾' + +export function getSpuProductModelValidationError(value: unknown): string | null { + const normalized = normalizeSpuProductModel(value) + + if (!normalized) return '请填写真实 SPU,例如 DF-A3DZ0008' + if (normalized.length > 100) return 'SPU 不能超过 100 个字符' + if (normalized !== normalized.trim()) return 'SPU 前后不能包含空格' + if (!/[A-Z]/.test(normalized) || !/\d/.test(normalized)) { + return 'SPU 必须同时包含英文字母和数字' + } + if (!PRODUCT_MODEL_ALLOWED_CHARACTERS.test(normalized)) { + return `SPU ${SPU_PRODUCT_MODEL_FORMAT_HINT}` + } + + return null } /** @@ -21,13 +35,7 @@ export function sanitizeSpuProductModelInput(value: unknown): string { * 共同边界是包含英文字母和数字,且不能以分隔符开头或结尾。 */ export function isPlausibleSpuProductModel(value: unknown): boolean { - const normalized = normalizeSpuProductModel(value) - - return normalized.length >= 2 - && normalized.length <= 100 - && /[A-Z]/.test(normalized) - && /\d/.test(normalized) - && PRODUCT_MODEL_ALLOWED_CHARACTERS.test(normalized) + return getSpuProductModelValidationError(value) === null } /** @@ -37,16 +45,7 @@ export function isPlausibleSpuProductModel(value: unknown): boolean { export function extractPlausibleSpuFromFileName(filename: string): string { const nameWithoutExtension = normalizeSpuProductModel(filename.replace(/\.[^/.]+$/, '')) - // 文件名本身就是合法 SPU 时必须完整保留,不能截掉 +组合、K12 后缀或数字前缀。 - if (isPlausibleSpuProductModel(nameWithoutExtension)) { - return nameWithoutExtension - } - - // 中文说明包裹 SPU 时,按不受支持的字符切段,再移除段首尾分隔符。 - const candidates = nameWithoutExtension - .split(/[^A-Z0-9+_.-]+/) - .map(candidate => candidate.replace(/^[+_.-]+|[+_.-]+$/g, '')) - .filter(Boolean) - - return candidates.find(isPlausibleSpuProductModel) || '' + // 只接受整个文件名(不含扩展名)就是合法 SPU;带说明文字或额外字符时留空, + // 由上传人确认填写,绝不从文件名中截取一段并冒充用户的 SPU。 + return isPlausibleSpuProductModel(nameWithoutExtension) ? nameWithoutExtension : '' }