fix: validate video SPU without rewriting input
This commit is contained in:
parent
fa14bbc207
commit
53b314a056
@ -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',
|
||||
|
||||
@ -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 },
|
||||
);
|
||||
}
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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 () => {
|
||||
|
||||
@ -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}
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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)
|
||||
})
|
||||
})
|
||||
|
||||
@ -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'],
|
||||
|
||||
@ -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)
|
||||
})
|
||||
|
||||
@ -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 : ''
|
||||
}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user