fix: validate video SPU without rewriting input

This commit is contained in:
chenpei 2026-08-13 13:15:46 +08:00
parent fa14bbc207
commit 53b314a056
11 changed files with 180 additions and 67 deletions

View File

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

View File

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

View File

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

View File

@ -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。'}
</p>
)}
</div>

View File

@ -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(
<BatchVideoList
files={[{
file: new File(['video'], 'video.mp4', { type: 'video/mp4' }),
productModel: 'DF-A3DZ0008 中文',
}]}
productCategories={[]}
onUpdateProductModel={onUpdateProductModel}
onUpdatePreviewImage={jest.fn()}
onRemovePreviewImage={jest.fn()}
onDelete={jest.fn()}
/>,
)
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 () => {

View File

@ -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({
<div className="mb-4 space-y-3 overflow-visible rounded-apple border border-gray-200 p-4 dark:border-gray-700 sm:max-h-[65vh] sm:overflow-y-auto">
{files.map((item, index) => (
<div key={index} className="flex flex-col space-y-2 p-2 bg-gray-50 dark:bg-gray-800 rounded">
<div className="flex items-center space-x-3">
<div className="flex items-start space-x-3">
<div className="flex-1 min-w-0">
<p className="text-sm font-medium text-gray-700 dark:text-gray-300 truncate">
{item.file.name}
@ -125,15 +126,32 @@ export function BatchVideoList({
{formatBytes(item.file.size)}
</p>
</div>
<input
aria-label={`SPU型号:${item.file.name}`}
type="text"
value={item.productModel}
onChange={(e) => 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型号"
/>
<div className="min-w-0 flex-1">
<input
aria-label={`SPU型号:${item.file.name}`}
aria-invalid={!isPlausibleSpuProductModel(item.productModel)}
aria-describedby={!isPlausibleSpuProductModel(item.productModel) ? `batch-video-spu-error-${index}` : undefined}
type="text"
value={item.productModel}
onChange={(e) => 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) && (
<p
id={`batch-video-spu-error-${index}`}
className="mt-1 text-xs text-red-600 dark:text-red-400"
role="alert"
>
{getSpuProductModelValidationError(item.productModel)}
</p>
)}
</div>
<button
type="button"
disabled={disabled}

View File

@ -9,7 +9,11 @@ import { useState, useEffect, useRef } from 'react'
import { requiresCategoryAndScene, requiresProductCategoryName, requiresRenderer, shouldAutoFillGeneral } from '@/lib/material-validation'
import { parseSPU, type SPUParseResult } from '@/lib/spu-parser'
import { isVideoMaterialType } from '@/lib/video-material-types'
import { isPlausibleSpuProductModel, sanitizeSpuProductModelInput } from '@/lib/product-model'
import {
getSpuProductModelValidationError,
isPlausibleSpuProductModel,
normalizeSpuProductModel,
} from '@/lib/product-model'
/**
* 分类数据项
@ -198,7 +202,7 @@ export default function UploadForm({
if (materialType === '材质') {
onProductModelChange(e.target.value)
} else {
const value = sanitizeSpuProductModelInput(e.target.value)
const value = normalizeSpuProductModel(e.target.value)
onProductModelChange(value)
}
}}
@ -209,8 +213,18 @@ export default function UploadForm({
placeholder="例如:DF-A3DZ0008"
/>
{isVideoMaterialType(materialType) && (
<p id="product-model-hint" className="mt-2 text-xs text-gray-500 dark:text-gray-400">
请填写真实 SPU;纯数字或文件尾号不能作为 SPU。
<p
id="product-model-hint"
className={`mt-2 text-xs ${
productModel && !isPlausibleSpuProductModel(productModel)
? 'text-red-600 dark:text-red-400'
: 'text-gray-500 dark:text-gray-400'
}`}
role={productModel && !isPlausibleSpuProductModel(productModel) ? 'alert' : undefined}
>
{productModel
? getSpuProductModelValidationError(productModel) || '格式正确;英文字母已自动转成大写。'
: '请填写真实 SPU;英文字母会自动转成大写,纯数字或文件尾号不能作为 SPU。'}
</p>
)}
</div>

View File

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

View File

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

View File

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

View File

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