1078 lines
31 KiB
TypeScript
1078 lines
31 KiB
TypeScript
import { prisma } from '@/lib/prisma'
|
||
import {
|
||
completeProductPackageUpload,
|
||
type ProductPackageImageInput,
|
||
type ProductPackageSourceInput,
|
||
} from '@/lib/product-package-completion'
|
||
import { createMaterialLog } from '@/lib/material-log'
|
||
import { isTaskDetailFullSetFromUploadGroups } from '@/lib/task-detail-full-set'
|
||
import { normalizeTaskDetailSourceType } from '@/lib/task-detail-source-type'
|
||
import path from 'path'
|
||
import { promises as fs } from 'fs'
|
||
import crypto from 'crypto'
|
||
import { execFile } from 'child_process'
|
||
import { promisify } from 'util'
|
||
|
||
const IMAGE_EXTENSIONS = new Set(['.jpg', '.jpeg', '.png', '.webp'])
|
||
const SKIPPED_FILE_EXTENSIONS = new Set([
|
||
'.xlsx',
|
||
'.xls',
|
||
'.psd',
|
||
'.psb',
|
||
'.tif',
|
||
'.tiff',
|
||
'.zip',
|
||
'.rar',
|
||
'.7z',
|
||
'.pdf',
|
||
'.log',
|
||
'.db',
|
||
'.lnk',
|
||
])
|
||
|
||
const IGNORED_DIR_PATTERNS = [
|
||
/@eadir/i,
|
||
/策划方案/i,
|
||
/产品资料/i,
|
||
/模特图/i,
|
||
/废稿/i,
|
||
/字体/i,
|
||
/勿用/i,
|
||
/错误勿用/i,
|
||
/图片有误/i,
|
||
/作废/i,
|
||
/3D建模/i,
|
||
/DingTalkAppData/i,
|
||
/^PsTool$/i,
|
||
/^Cache$/i,
|
||
/RECYCLE/i,
|
||
]
|
||
|
||
const CANDIDATE_DIR_PATTERNS = [
|
||
/详情全套/i,
|
||
/全套详情/i,
|
||
/视觉全套/i,
|
||
/平面全套/i,
|
||
/全案/i,
|
||
/全套a图/i,
|
||
/全套视觉方案/i,
|
||
/全套视觉策划/i,
|
||
/全套视觉实拍方案/i,
|
||
/全套视觉/i,
|
||
]
|
||
const MAIN_HINT_PATTERNS = [/主图/i, /新主图/i]
|
||
const SKU_HINT_PATTERNS = [/sku/i, /sku图/i]
|
||
const DETAIL_HINT_PATTERNS = [/详情/i, /详情图/i, /详情页/i]
|
||
const MAIN_FILE_EXCLUDE_PATTERNS = [/功能主图/i, /微详情/i]
|
||
const DETAIL_FILE_EXCLUDE_PATTERNS = [/模特/i]
|
||
const STORE_DIR_IGNORES = new Set([
|
||
'.recycle',
|
||
'@eaDir',
|
||
'$NSEC.RECYCLE.BIN',
|
||
'DingTalkAppData',
|
||
'PsTool',
|
||
'营销策划方案',
|
||
'3D66',
|
||
])
|
||
const execFileAsync = promisify(execFile)
|
||
const HISTORY_ZIP_TIMEOUT_MS = Number(process.env.HISTORY_IMPORT_ZIP_TIMEOUT_MS || 180000)
|
||
const HISTORY_ALL_ZIP_TIMEOUT_MS = Number(process.env.HISTORY_IMPORT_ALL_ZIP_TIMEOUT_MS || 300000)
|
||
const BRANDED_SPU_BODY_PATTERN = '[A-Z]{2,5}-[A-Z]{1,6}\\d{3,5}(?:K\\d{1,3})?'
|
||
const UNBRANDED_SPU_BODY_PATTERN = '[A-Z]{1,6}\\d{3,5}(?:K\\d{1,3})?'
|
||
const COMBO_SPU_TOKEN_PATTERN = new RegExp(
|
||
`(?<![A-Z0-9])${BRANDED_SPU_BODY_PATTERN}(?:\\s*[++]\\s*(?:${BRANDED_SPU_BODY_PATTERN}|${UNBRANDED_SPU_BODY_PATTERN}))+(?![A-Z0-9])`,
|
||
'i',
|
||
)
|
||
const BLOCKED_READY_SOURCE_PATH_PATTERNS = [
|
||
{ pattern: /错误勿用/i, label: '错误勿用' },
|
||
{ pattern: /废稿/i, label: '废稿' },
|
||
{ pattern: /勿用/i, label: '勿用' },
|
||
{ pattern: /图片有误/i, label: '图片有误' },
|
||
{ pattern: /作废/i, label: '作废' },
|
||
{ pattern: /3D建模/i, label: '3D建模' },
|
||
]
|
||
|
||
export type HistoryImportStatus = 'ready' | 'needs_review' | 'skipped'
|
||
|
||
export interface HistoryImportRecord {
|
||
fingerprint: string
|
||
spu: string
|
||
sourcePath: string
|
||
relativeSourcePath: string
|
||
guessedStore: string
|
||
productCategoryName: string
|
||
authorName: string
|
||
taskDetailSourceType?: string
|
||
storeConfidence: number
|
||
storeCandidates: string[]
|
||
mainCount: number
|
||
skuCount: number
|
||
detailCount: number
|
||
sourceCount?: number
|
||
status: HistoryImportStatus
|
||
skipReason: string | null
|
||
mainImages: ProductPackageImageInput[]
|
||
skuImages: ProductPackageImageInput[]
|
||
detailImages: ProductPackageImageInput[]
|
||
psdSources?: ProductPackageSourceInput[]
|
||
seenInYya: boolean
|
||
}
|
||
|
||
export interface ScanHistoryMaterialsOptions {
|
||
materialsRoot: string
|
||
cpRoot?: string
|
||
yyaRoot?: string
|
||
limit?: number
|
||
spuFilter?: string[]
|
||
}
|
||
|
||
export interface ImportHistoryMaterialsOptions {
|
||
records: HistoryImportRecord[]
|
||
operator: {
|
||
id: string
|
||
username: string
|
||
realName: string | null
|
||
}
|
||
apply?: boolean
|
||
limit?: number
|
||
}
|
||
|
||
export interface ImportHistoryMaterialsResultItem {
|
||
fingerprint: string
|
||
spu: string
|
||
sourcePath: string
|
||
guessedStore: string
|
||
materialId: string | null
|
||
status: 'imported' | 'dry_run' | 'skipped' | 'failed'
|
||
reason: string | null
|
||
}
|
||
|
||
interface StoreIndex {
|
||
cpStoresBySpu: Map<string, Map<string, number>>
|
||
yyaSpus: Set<string>
|
||
}
|
||
|
||
interface FileEntry {
|
||
fullPath: string
|
||
relativePath: string
|
||
fileName: string
|
||
fileSize: number
|
||
}
|
||
|
||
interface SanitizedHistoryImportRecord {
|
||
record: HistoryImportRecord
|
||
skippedEmptyFiles: Array<{
|
||
group: 'main' | 'sku' | 'detail' | 'psd'
|
||
fileName: string
|
||
localFilePath?: string
|
||
}>
|
||
skippedInvalidFiles: Array<{
|
||
group: 'main' | 'sku' | 'detail'
|
||
fileName: string
|
||
fileSize: number
|
||
localFilePath?: string
|
||
reason: string
|
||
}>
|
||
}
|
||
|
||
function normalizeSpu(value: string) {
|
||
return value.trim().toUpperCase().replace(/+/g, '+').replace(/\s*\+\s*/g, '+')
|
||
}
|
||
|
||
function expandComboSpuToken(token: string) {
|
||
const parts = normalizeSpu(token).split('+').filter(Boolean)
|
||
const first = parts[0] || ''
|
||
const prefix = first.includes('-') ? first.split('-')[0] : ''
|
||
|
||
if (parts.length < 2 || !prefix) {
|
||
return null
|
||
}
|
||
|
||
const expanded = parts.map((part, index) => {
|
||
if (index === 0 || part.includes('-')) {
|
||
return part
|
||
}
|
||
return `${prefix}-${part}`
|
||
})
|
||
|
||
return expanded.join('+')
|
||
}
|
||
|
||
function inferComboSpuFromPath(sourcePath: string) {
|
||
const match = sourcePath.match(COMBO_SPU_TOKEN_PATTERN)
|
||
if (!match) {
|
||
return null
|
||
}
|
||
|
||
return expandComboSpuToken(match[0])
|
||
}
|
||
|
||
function isSpuName(name: string) {
|
||
return /^[A-Za-z]{1,5}\d{3,5}$/i.test(name.trim())
|
||
}
|
||
|
||
function matchesAny(input: string, patterns: RegExp[]) {
|
||
return patterns.some((pattern) => pattern.test(input))
|
||
}
|
||
|
||
function shouldIgnoreDir(fullPath: string) {
|
||
return matchesAny(fullPath, IGNORED_DIR_PATTERNS)
|
||
}
|
||
|
||
function shouldSkipFile(filePath: string) {
|
||
const ext = path.extname(filePath).toLowerCase()
|
||
return SKIPPED_FILE_EXTENSIONS.has(ext) || !IMAGE_EXTENSIONS.has(ext)
|
||
}
|
||
|
||
function makeFingerprint(spu: string, sourcePath: string) {
|
||
return crypto.createHash('sha1').update(`${normalizeSpu(spu)}|${sourcePath}`).digest('hex')
|
||
}
|
||
|
||
function scoreStoreByPath(sourcePath: string, stores: string[]) {
|
||
const hits = new Map<string, number>()
|
||
for (const store of stores) {
|
||
if (!store || store === '其他') {
|
||
continue
|
||
}
|
||
if (sourcePath.includes(store)) {
|
||
hits.set(store, (hits.get(store) || 0) + 3)
|
||
}
|
||
}
|
||
return hits
|
||
}
|
||
|
||
async function existsDir(dirPath: string) {
|
||
try {
|
||
const stat = await fs.stat(dirPath)
|
||
return stat.isDirectory()
|
||
} catch {
|
||
return false
|
||
}
|
||
}
|
||
|
||
async function walkDirectories(root: string, maxDepth = 6) {
|
||
const result: string[] = []
|
||
const queue: Array<{ dir: string; depth: number }> = [{ dir: root, depth: 0 }]
|
||
|
||
while (queue.length > 0) {
|
||
const current = queue.shift()!
|
||
if (current.depth > maxDepth) {
|
||
continue
|
||
}
|
||
|
||
let entries
|
||
try {
|
||
entries = await fs.readdir(current.dir, { withFileTypes: true })
|
||
} catch {
|
||
continue
|
||
}
|
||
|
||
for (const entry of entries) {
|
||
if (!entry.isDirectory()) {
|
||
continue
|
||
}
|
||
|
||
const fullPath = path.join(current.dir, entry.name)
|
||
if (shouldIgnoreDir(fullPath)) {
|
||
continue
|
||
}
|
||
|
||
result.push(fullPath)
|
||
queue.push({ dir: fullPath, depth: current.depth + 1 })
|
||
}
|
||
}
|
||
|
||
return result
|
||
}
|
||
|
||
async function walkFiles(root: string, maxDepth = 5) {
|
||
const result: FileEntry[] = []
|
||
const queue: Array<{ dir: string; depth: number }> = [{ dir: root, depth: 0 }]
|
||
|
||
while (queue.length > 0) {
|
||
const current = queue.shift()!
|
||
if (current.depth > maxDepth) {
|
||
continue
|
||
}
|
||
|
||
let entries
|
||
try {
|
||
entries = await fs.readdir(current.dir, { withFileTypes: true })
|
||
} catch {
|
||
continue
|
||
}
|
||
|
||
for (const entry of entries) {
|
||
const fullPath = path.join(current.dir, entry.name)
|
||
if (entry.isDirectory()) {
|
||
if (shouldIgnoreDir(fullPath)) {
|
||
continue
|
||
}
|
||
queue.push({ dir: fullPath, depth: current.depth + 1 })
|
||
continue
|
||
}
|
||
|
||
if (shouldSkipFile(fullPath)) {
|
||
continue
|
||
}
|
||
|
||
try {
|
||
const stat = await fs.stat(fullPath)
|
||
result.push({
|
||
fullPath,
|
||
relativePath: path.relative(root, fullPath),
|
||
fileName: entry.name,
|
||
fileSize: stat.size,
|
||
})
|
||
} catch {
|
||
// ignore unreadable file
|
||
}
|
||
}
|
||
}
|
||
|
||
return result
|
||
}
|
||
|
||
async function buildStoreIndex(
|
||
cpRoot: string | undefined,
|
||
yyaRoot: string | undefined,
|
||
spus: string[],
|
||
): Promise<StoreIndex> {
|
||
const cpStoresBySpu = new Map<string, Map<string, number>>()
|
||
const yyaSpus = new Set<string>()
|
||
const normalizedSpus = Array.from(new Set(spus.map(normalizeSpu)))
|
||
|
||
if (cpRoot && (await existsDir(cpRoot)) && normalizedSpus.length > 0) {
|
||
const fastMatches = await searchPathsByRipgrep(cpRoot, normalizedSpus)
|
||
if (fastMatches.length > 0) {
|
||
for (const matchPath of fastMatches) {
|
||
const relativePath = path.relative(cpRoot, matchPath)
|
||
const [storeName] = relativePath.split(path.sep)
|
||
if (!storeName || STORE_DIR_IGNORES.has(storeName)) {
|
||
continue
|
||
}
|
||
const normalizedPath = matchPath.toUpperCase()
|
||
for (const spu of normalizedSpus) {
|
||
if (!normalizedPath.includes(spu)) {
|
||
continue
|
||
}
|
||
let stores = cpStoresBySpu.get(spu)
|
||
if (!stores) {
|
||
stores = new Map<string, number>()
|
||
cpStoresBySpu.set(spu, stores)
|
||
}
|
||
stores.set(storeName, (stores.get(storeName) || 0) + 1)
|
||
}
|
||
}
|
||
} else {
|
||
const storeDirs = await fs.readdir(cpRoot, { withFileTypes: true })
|
||
for (const entry of storeDirs) {
|
||
if (!entry.isDirectory() || STORE_DIR_IGNORES.has(entry.name)) {
|
||
continue
|
||
}
|
||
const storeName = entry.name.trim()
|
||
const storePath = path.join(cpRoot, entry.name)
|
||
const directories = await walkDirectories(storePath, 5)
|
||
for (const dirPath of directories) {
|
||
const normalizedPath = dirPath.toUpperCase()
|
||
for (const spu of normalizedSpus) {
|
||
if (!normalizedPath.includes(spu)) {
|
||
continue
|
||
}
|
||
let stores = cpStoresBySpu.get(spu)
|
||
if (!stores) {
|
||
stores = new Map<string, number>()
|
||
cpStoresBySpu.set(spu, stores)
|
||
}
|
||
stores.set(storeName, (stores.get(storeName) || 0) + 1)
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
if (yyaRoot && (await existsDir(yyaRoot)) && normalizedSpus.length > 0) {
|
||
const fastMatches = await searchPathsByRipgrep(yyaRoot, normalizedSpus)
|
||
if (fastMatches.length > 0) {
|
||
for (const matchPath of fastMatches) {
|
||
const normalizedPath = matchPath.toUpperCase()
|
||
for (const spu of normalizedSpus) {
|
||
if (normalizedPath.includes(spu)) {
|
||
yyaSpus.add(spu)
|
||
}
|
||
}
|
||
}
|
||
} else {
|
||
const directories = await walkDirectories(yyaRoot, 6)
|
||
for (const dirPath of directories) {
|
||
const normalizedPath = dirPath.toUpperCase()
|
||
for (const spu of normalizedSpus) {
|
||
if (normalizedPath.includes(spu)) {
|
||
yyaSpus.add(spu)
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
return {
|
||
cpStoresBySpu,
|
||
yyaSpus,
|
||
}
|
||
}
|
||
|
||
async function searchPathsByRipgrep(root: string, spus: string[]) {
|
||
try {
|
||
const normalizedSpus = Array.from(
|
||
new Set(
|
||
spus
|
||
.map((spu) => normalizeSpu(spu))
|
||
.filter(Boolean),
|
||
),
|
||
)
|
||
|
||
if (normalizedSpus.length === 0) {
|
||
return []
|
||
}
|
||
|
||
const args = ['--files', root]
|
||
for (const spu of normalizedSpus) {
|
||
args.push('-g', `*${spu}*`)
|
||
args.push('-g', `*${spu.toLowerCase()}*`)
|
||
}
|
||
|
||
const { stdout } = await execFileAsync(
|
||
'rg',
|
||
args,
|
||
{
|
||
cwd: process.cwd(),
|
||
maxBuffer: 32 * 1024 * 1024,
|
||
windowsHide: true,
|
||
},
|
||
)
|
||
return stdout
|
||
.split(/\r?\n/)
|
||
.map((line) => line.trim())
|
||
.filter(Boolean)
|
||
} catch {
|
||
return []
|
||
}
|
||
}
|
||
|
||
function pickStore(spu: string, sourcePath: string, storeIndex: StoreIndex) {
|
||
const cpStores = storeIndex.cpStoresBySpu.get(normalizeSpu(spu))
|
||
const pathHits = scoreStoreByPath(sourcePath, cpStores ? Array.from(cpStores.keys()) : [])
|
||
|
||
if (cpStores) {
|
||
for (const [store, count] of Array.from(cpStores.entries())) {
|
||
pathHits.set(store, (pathHits.get(store) || 0) + count)
|
||
}
|
||
}
|
||
|
||
const candidates = Array.from(pathHits.entries()).sort((a, b) => b[1] - a[1])
|
||
if (candidates.length === 0) {
|
||
return {
|
||
guessedStore: '其他',
|
||
storeConfidence: 0,
|
||
storeCandidates: [],
|
||
}
|
||
}
|
||
|
||
const [bestStore, bestScore] = candidates[0]
|
||
const secondScore = candidates[1]?.[1] || 0
|
||
const storeConfidence = bestScore <= 0 ? 0 : Math.min(1, (bestScore - secondScore + 1) / (bestScore + 1))
|
||
|
||
return {
|
||
guessedStore: bestStore || '其他',
|
||
storeConfidence,
|
||
storeCandidates: candidates.map(([store]) => store),
|
||
}
|
||
}
|
||
|
||
function findSkuCode(spu: string, fileName: string, sortOrder: number) {
|
||
const normalizedSpu = normalizeSpu(spu)
|
||
const normalizedName = fileName.toUpperCase()
|
||
const exactMatch = normalizedName.match(new RegExp(`(${normalizedSpu}\\d{2,3})`, 'i'))
|
||
if (exactMatch?.[1]) {
|
||
return exactMatch[1].toUpperCase()
|
||
}
|
||
|
||
const skuMatch = normalizedName.match(/SKU[_-]?(\d{1,3})/i)
|
||
if (skuMatch?.[1]) {
|
||
return `${normalizedSpu}${skuMatch[1].padStart(2, '0')}`
|
||
}
|
||
|
||
return `${normalizedSpu}${String(sortOrder + 1).padStart(2, '0')}`
|
||
}
|
||
|
||
function classifyFileGroup(relativePath: string) {
|
||
const normalized = relativePath.replace(/\\/g, '/')
|
||
if (MAIN_FILE_EXCLUDE_PATTERNS.some((pattern) => pattern.test(normalized))) {
|
||
return null
|
||
}
|
||
if (DETAIL_FILE_EXCLUDE_PATTERNS.some((pattern) => pattern.test(normalized))) {
|
||
return null
|
||
}
|
||
|
||
if (matchesAny(normalized, SKU_HINT_PATTERNS)) {
|
||
return 'sku' as const
|
||
}
|
||
if (matchesAny(normalized, DETAIL_HINT_PATTERNS)) {
|
||
return 'detail' as const
|
||
}
|
||
if (matchesAny(normalized, MAIN_HINT_PATTERNS)) {
|
||
return 'main' as const
|
||
}
|
||
return null
|
||
}
|
||
|
||
function sortByNaturalFileName<T extends { fileName: string }>(items: T[]) {
|
||
return [...items].sort((left, right) =>
|
||
left.fileName.localeCompare(right.fileName, 'zh-Hans-CN', { numeric: true, sensitivity: 'base' }),
|
||
)
|
||
}
|
||
|
||
function buildAssetInputs(spu: string, files: FileEntry[], group: 'main' | 'sku' | 'detail') {
|
||
const sorted = sortByNaturalFileName(files)
|
||
const seenSkuCodes = new Map<string, number>()
|
||
|
||
return sorted.map((file, index) => {
|
||
if (group !== 'sku') {
|
||
return {
|
||
localFilePath: file.fullPath,
|
||
fileName: file.fileName,
|
||
fileSize: file.fileSize,
|
||
sortOrder: index,
|
||
}
|
||
}
|
||
|
||
const baseSkuCode = findSkuCode(spu, file.fileName, index)
|
||
const conflictCount = seenSkuCodes.get(baseSkuCode) || 0
|
||
seenSkuCodes.set(baseSkuCode, conflictCount + 1)
|
||
const skuCode = conflictCount === 0 ? baseSkuCode : `${baseSkuCode}-${conflictCount + 1}`
|
||
|
||
return {
|
||
localFilePath: file.fullPath,
|
||
fileName: file.fileName,
|
||
fileSize: file.fileSize,
|
||
sortOrder: index,
|
||
skuCode,
|
||
barcodes: [],
|
||
}
|
||
})
|
||
}
|
||
|
||
async function findCandidateDirs(spuDir: string) {
|
||
const allDirs = await walkDirectories(spuDir, 4)
|
||
const fullSetDirs = allDirs.filter((dirPath) => matchesAny(path.basename(dirPath), CANDIDATE_DIR_PATTERNS))
|
||
const sorted = Array.from(new Set(fullSetDirs)).sort((left, right) => left.length - right.length)
|
||
const collapsed: string[] = []
|
||
for (const candidate of sorted) {
|
||
const normalizedCandidate = `${path.normalize(candidate)}${path.sep}`
|
||
const hasAncestor = collapsed.some((existing) => normalizedCandidate.startsWith(`${path.normalize(existing)}${path.sep}`))
|
||
if (!hasAncestor) {
|
||
collapsed.push(candidate)
|
||
}
|
||
}
|
||
|
||
return collapsed
|
||
}
|
||
|
||
async function buildRecordsForSpuDir(spuDir: string, materialsRoot: string, storeIndex: StoreIndex) {
|
||
const spu = normalizeSpu(path.basename(spuDir))
|
||
const candidateDirs = await findCandidateDirs(spuDir)
|
||
const records: HistoryImportRecord[] = []
|
||
|
||
for (const candidateDir of candidateDirs) {
|
||
if (shouldIgnoreDir(candidateDir)) {
|
||
continue
|
||
}
|
||
|
||
const files = await walkFiles(candidateDir, 5)
|
||
const grouped = {
|
||
main: [] as FileEntry[],
|
||
sku: [] as FileEntry[],
|
||
detail: [] as FileEntry[],
|
||
}
|
||
|
||
for (const file of files) {
|
||
const group = classifyFileGroup(file.relativePath)
|
||
if (!group) {
|
||
continue
|
||
}
|
||
grouped[group].push(file)
|
||
}
|
||
|
||
const totalImages = grouped.main.length + grouped.sku.length + grouped.detail.length
|
||
if (totalImages === 0) {
|
||
continue
|
||
}
|
||
|
||
const isFullSet = isTaskDetailFullSetFromUploadGroups({
|
||
main: grouped.main,
|
||
sku: grouped.sku,
|
||
detail: grouped.detail,
|
||
})
|
||
const storeGuess = pickStore(spu, candidateDir, storeIndex)
|
||
const status: HistoryImportStatus = !isFullSet
|
||
? 'skipped'
|
||
: storeGuess.storeCandidates.length > 1 && storeGuess.storeConfidence < 0.4
|
||
? 'needs_review'
|
||
: 'ready'
|
||
|
||
records.push({
|
||
fingerprint: makeFingerprint(spu, candidateDir),
|
||
spu,
|
||
sourcePath: candidateDir,
|
||
relativeSourcePath: path.relative(materialsRoot, candidateDir),
|
||
guessedStore: storeGuess.guessedStore,
|
||
productCategoryName: '',
|
||
authorName: '历史导入',
|
||
storeConfidence: storeGuess.storeConfidence,
|
||
storeCandidates: storeGuess.storeCandidates,
|
||
mainCount: grouped.main.length,
|
||
skuCount: grouped.sku.length,
|
||
detailCount: grouped.detail.length,
|
||
status,
|
||
skipReason: isFullSet ? null : '不满足项目内全套定义:需同时拥有主图、SKU 图、详情图',
|
||
mainImages: buildAssetInputs(spu, grouped.main, 'main'),
|
||
skuImages: buildAssetInputs(spu, grouped.sku, 'sku'),
|
||
detailImages: buildAssetInputs(spu, grouped.detail, 'detail'),
|
||
seenInYya: storeIndex.yyaSpus.has(spu),
|
||
})
|
||
}
|
||
|
||
if (records.length === 0) {
|
||
records.push({
|
||
fingerprint: makeFingerprint(spu, spuDir),
|
||
spu,
|
||
sourcePath: spuDir,
|
||
relativeSourcePath: path.relative(materialsRoot, spuDir),
|
||
guessedStore: '其他',
|
||
productCategoryName: '',
|
||
authorName: '历史导入',
|
||
storeConfidence: 0,
|
||
storeCandidates: [],
|
||
mainCount: 0,
|
||
skuCount: 0,
|
||
detailCount: 0,
|
||
status: 'skipped',
|
||
skipReason: '未识别到可导入的主图、SKU 图或详情图',
|
||
mainImages: [],
|
||
skuImages: [],
|
||
detailImages: [],
|
||
seenInYya: storeIndex.yyaSpus.has(spu),
|
||
})
|
||
}
|
||
|
||
return records
|
||
}
|
||
|
||
export async function scanHistoryMaterials(options: ScanHistoryMaterialsOptions) {
|
||
const materialsRoot = options.materialsRoot
|
||
const spuFilter = options.spuFilter?.map(normalizeSpu)
|
||
const spuDirs = await findSpuDirectories(materialsRoot, spuFilter)
|
||
|
||
const limitedSpuDirs = options.limit ? spuDirs.slice(0, options.limit) : spuDirs
|
||
const storeIndex = await buildStoreIndex(
|
||
options.cpRoot,
|
||
options.yyaRoot,
|
||
limitedSpuDirs.map((dirPath) => path.basename(dirPath)),
|
||
)
|
||
|
||
const records: HistoryImportRecord[] = []
|
||
for (const spuDir of limitedSpuDirs) {
|
||
const spuRecords = await buildRecordsForSpuDir(spuDir, materialsRoot, storeIndex)
|
||
records.push(...spuRecords)
|
||
}
|
||
|
||
return records.sort((left, right) => left.sourcePath.localeCompare(right.sourcePath, 'zh-Hans-CN'))
|
||
}
|
||
|
||
async function findSpuDirectories(materialsRoot: string, spuFilter?: string[]) {
|
||
if (spuFilter && spuFilter.length > 0) {
|
||
const fastFiles = await searchPathsByRipgrep(materialsRoot, spuFilter)
|
||
const dirs = new Set<string>()
|
||
|
||
if (fastFiles.length > 0) {
|
||
for (const filePath of fastFiles) {
|
||
const absoluteFilePath = path.isAbsolute(filePath) ? filePath : path.join(materialsRoot, filePath)
|
||
const parts = path.normalize(absoluteFilePath).split(path.sep)
|
||
for (let index = 0; index < parts.length; index += 1) {
|
||
const part = normalizeSpu(parts[index])
|
||
if (!isSpuName(part)) {
|
||
continue
|
||
}
|
||
if (!spuFilter.includes(part)) {
|
||
continue
|
||
}
|
||
const resolvedDir = parts.slice(0, index + 1).join(path.sep)
|
||
dirs.add(resolvedDir)
|
||
}
|
||
}
|
||
}
|
||
|
||
if (dirs.size > 0) {
|
||
return Array.from(dirs)
|
||
}
|
||
}
|
||
|
||
const allDirs = await walkDirectories(materialsRoot, 5)
|
||
return allDirs.filter((dirPath) => {
|
||
const spu = normalizeSpu(path.basename(dirPath))
|
||
if (!isSpuName(spu)) {
|
||
return false
|
||
}
|
||
return !spuFilter || spuFilter.length === 0 || spuFilter.includes(spu)
|
||
})
|
||
}
|
||
|
||
async function ensureCategoryAndScene(defaultCategory: string, defaultScene: string) {
|
||
const [category, scene] = await Promise.all([
|
||
prisma.category.findUnique({ where: { name: defaultCategory } }),
|
||
prisma.scene.findUnique({ where: { name: defaultScene } }),
|
||
])
|
||
|
||
if (!category) {
|
||
await prisma.category.create({ data: { name: defaultCategory } })
|
||
}
|
||
if (!scene) {
|
||
await prisma.scene.create({ data: { name: defaultScene } })
|
||
}
|
||
}
|
||
|
||
async function ensureCategoryAndSceneNames(categoryName: string, sceneName: string) {
|
||
const normalizedCategoryName = categoryName.trim() || '其他'
|
||
const normalizedSceneName = sceneName.trim() || '其他'
|
||
|
||
const [category, scene] = await Promise.all([
|
||
prisma.category.findUnique({ where: { name: normalizedCategoryName } }),
|
||
prisma.scene.findUnique({ where: { name: normalizedSceneName } }),
|
||
])
|
||
|
||
if (!category) {
|
||
await prisma.category.create({ data: { name: normalizedCategoryName } })
|
||
}
|
||
if (!scene) {
|
||
await prisma.scene.create({ data: { name: normalizedSceneName } })
|
||
}
|
||
}
|
||
|
||
async function hasImportedHistoryRecord(record: HistoryImportRecord) {
|
||
const existingByFingerprint = await prisma.$queryRaw<Array<{ id: string }>>`
|
||
SELECT logs.id FROM "material_logs" AS logs
|
||
INNER JOIN "materials" AS materials ON materials.id = logs."materialId"
|
||
WHERE logs.action = 'HISTORY_IMPORT' AND materials."deletedAt" IS NULL
|
||
AND position(${record.fingerprint} in logs.changes::text) > 0
|
||
LIMIT 1
|
||
`
|
||
|
||
if (existingByFingerprint.length) {
|
||
return true
|
||
}
|
||
|
||
const existingBySourcePath = await prisma.$queryRaw<Array<{ id: string }>>`
|
||
SELECT logs.id
|
||
FROM "material_logs" AS logs
|
||
INNER JOIN "materials" AS materials
|
||
ON materials.id = logs."materialId"
|
||
WHERE logs.action = 'HISTORY_IMPORT'
|
||
AND materials."deletedAt" IS NULL
|
||
AND logs.changes IS NOT NULL
|
||
AND logs.changes::jsonb->>'sourcePath' = ${record.sourcePath}
|
||
LIMIT 1
|
||
`
|
||
|
||
return existingBySourcePath.length > 0
|
||
}
|
||
|
||
function getBlockedReadySourceReason(record: HistoryImportRecord) {
|
||
const haystack = [
|
||
record.sourcePath,
|
||
record.relativeSourcePath,
|
||
record.skipReason || '',
|
||
].join('\n')
|
||
const matched = BLOCKED_READY_SOURCE_PATH_PATTERNS.find(({ pattern }) => pattern.test(haystack))
|
||
|
||
if (!matched) {
|
||
return null
|
||
}
|
||
|
||
return `来源路径包含“${matched.label}”,即使台账标记 ready 也禁止正式导入`
|
||
}
|
||
|
||
function getSpuMismatchReason(record: HistoryImportRecord) {
|
||
const inferredComboSpu = inferComboSpuFromPath(record.sourcePath)
|
||
if (!inferredComboSpu || normalizeSpu(record.spu) === inferredComboSpu) {
|
||
return null
|
||
}
|
||
|
||
return `来源路径包含组合 SPU“${inferredComboSpu}”,但台账 SPU 是“${record.spu}”,禁止按单 SPU 导入`
|
||
}
|
||
|
||
async function createHistoryImportMaterial(record: HistoryImportRecord, operatorUserId: string) {
|
||
return prisma.material.create({
|
||
data: {
|
||
productModel: record.spu,
|
||
categoryName: record.guessedStore || '其他',
|
||
sceneName: '其他',
|
||
platformName: '',
|
||
productCategoryName: record.productCategoryName || '',
|
||
materialType: '任务-详情',
|
||
previewUrl: '',
|
||
authorName: record.authorName || '历史导入',
|
||
userId: operatorUserId,
|
||
renderer: '',
|
||
isFullSet: false,
|
||
isAiGenerated: false,
|
||
taskDetailSourceType: normalizeTaskDetailSourceType(record.taskDetailSourceType) || '渲染',
|
||
} as any,
|
||
})
|
||
}
|
||
|
||
function filterNonEmptyAssets<T extends { fileName: string; fileSize: number; localFilePath?: string }>(
|
||
group: 'main' | 'sku' | 'detail' | 'psd',
|
||
assets: T[] | undefined,
|
||
skippedEmptyFiles: SanitizedHistoryImportRecord['skippedEmptyFiles'],
|
||
) {
|
||
return (assets || []).filter((asset) => {
|
||
if (asset.fileSize > 0) {
|
||
return true
|
||
}
|
||
|
||
skippedEmptyFiles.push({
|
||
group,
|
||
fileName: asset.fileName,
|
||
localFilePath: asset.localFilePath,
|
||
})
|
||
return false
|
||
})
|
||
}
|
||
|
||
async function filterReadableImageAssets<T extends { fileName: string; fileSize: number; localFilePath?: string }>(
|
||
group: 'main' | 'sku' | 'detail',
|
||
assets: T[],
|
||
skippedInvalidFiles: SanitizedHistoryImportRecord['skippedInvalidFiles'],
|
||
) {
|
||
if (assets.length === 0) {
|
||
return assets
|
||
}
|
||
|
||
const sharp = await import('sharp')
|
||
const readableAssets: T[] = []
|
||
|
||
for (const asset of assets) {
|
||
if (!asset.localFilePath) {
|
||
readableAssets.push(asset)
|
||
continue
|
||
}
|
||
|
||
try {
|
||
await sharp
|
||
.default(asset.localFilePath)
|
||
.resize(1, 1, {
|
||
fit: 'inside',
|
||
withoutEnlargement: true,
|
||
})
|
||
.webp({ quality: 1 })
|
||
.toBuffer()
|
||
readableAssets.push(asset)
|
||
} catch (error) {
|
||
skippedInvalidFiles.push({
|
||
group,
|
||
fileName: asset.fileName,
|
||
fileSize: asset.fileSize,
|
||
localFilePath: asset.localFilePath,
|
||
reason: error instanceof Error ? error.message : '图片无法读取',
|
||
})
|
||
}
|
||
}
|
||
|
||
return readableAssets
|
||
}
|
||
|
||
async function sanitizeHistoryImportRecord(record: HistoryImportRecord): Promise<SanitizedHistoryImportRecord> {
|
||
const skippedEmptyFiles: SanitizedHistoryImportRecord['skippedEmptyFiles'] = []
|
||
const skippedInvalidFiles: SanitizedHistoryImportRecord['skippedInvalidFiles'] = []
|
||
const nonEmptyMainImages = filterNonEmptyAssets('main', record.mainImages, skippedEmptyFiles)
|
||
const nonEmptySkuImages = filterNonEmptyAssets('sku', record.skuImages, skippedEmptyFiles)
|
||
const nonEmptyDetailImages = filterNonEmptyAssets('detail', record.detailImages, skippedEmptyFiles)
|
||
const sanitizedRecord: HistoryImportRecord = {
|
||
...record,
|
||
mainImages: await filterReadableImageAssets('main', nonEmptyMainImages, skippedInvalidFiles),
|
||
skuImages: await filterReadableImageAssets('sku', nonEmptySkuImages, skippedInvalidFiles),
|
||
detailImages: await filterReadableImageAssets('detail', nonEmptyDetailImages, skippedInvalidFiles),
|
||
psdSources: filterNonEmptyAssets('psd', record.psdSources, skippedEmptyFiles),
|
||
}
|
||
|
||
if (
|
||
sanitizedRecord.mainImages.length === 0 ||
|
||
sanitizedRecord.skuImages.length === 0 ||
|
||
sanitizedRecord.detailImages.length === 0
|
||
) {
|
||
throw new Error(
|
||
`过滤 0 字节文件后不满足导入条件:主图=${sanitizedRecord.mainImages.length} SKU=${sanitizedRecord.skuImages.length} 详情=${sanitizedRecord.detailImages.length}`,
|
||
)
|
||
}
|
||
|
||
return { record: sanitizedRecord, skippedEmptyFiles, skippedInvalidFiles }
|
||
}
|
||
|
||
export async function importHistoryMaterials(options: ImportHistoryMaterialsOptions) {
|
||
if (options.apply) {
|
||
await ensureCategoryAndScene('其他', '其他')
|
||
}
|
||
|
||
const candidates = options.records
|
||
.filter((record) => {
|
||
if (options.apply) {
|
||
return record.status === 'ready'
|
||
}
|
||
return record.status !== 'skipped'
|
||
})
|
||
.slice(0, options.limit || options.records.length)
|
||
|
||
const results: ImportHistoryMaterialsResultItem[] = []
|
||
for (const record of candidates) {
|
||
const blockedReadySourceReason = getBlockedReadySourceReason(record)
|
||
if (blockedReadySourceReason) {
|
||
results.push({
|
||
fingerprint: record.fingerprint,
|
||
spu: record.spu,
|
||
sourcePath: record.sourcePath,
|
||
guessedStore: record.guessedStore,
|
||
materialId: null,
|
||
status: 'skipped',
|
||
reason: blockedReadySourceReason,
|
||
})
|
||
continue
|
||
}
|
||
|
||
const spuMismatchReason = getSpuMismatchReason(record)
|
||
if (spuMismatchReason) {
|
||
results.push({
|
||
fingerprint: record.fingerprint,
|
||
spu: record.spu,
|
||
sourcePath: record.sourcePath,
|
||
guessedStore: record.guessedStore,
|
||
materialId: null,
|
||
status: 'skipped',
|
||
reason: spuMismatchReason,
|
||
})
|
||
continue
|
||
}
|
||
|
||
if (await hasImportedHistoryRecord(record)) {
|
||
results.push({
|
||
fingerprint: record.fingerprint,
|
||
spu: record.spu,
|
||
sourcePath: record.sourcePath,
|
||
guessedStore: record.guessedStore,
|
||
materialId: null,
|
||
status: 'skipped',
|
||
reason: '该来源目录已导入过',
|
||
})
|
||
continue
|
||
}
|
||
|
||
if (!options.apply) {
|
||
results.push({
|
||
fingerprint: record.fingerprint,
|
||
spu: record.spu,
|
||
sourcePath: record.sourcePath,
|
||
guessedStore: record.guessedStore,
|
||
materialId: null,
|
||
status: 'dry_run',
|
||
reason: null,
|
||
})
|
||
continue
|
||
}
|
||
|
||
try {
|
||
const sanitized = await sanitizeHistoryImportRecord(record)
|
||
await ensureCategoryAndSceneNames(record.guessedStore || '其他', '其他')
|
||
const material = await createHistoryImportMaterial(sanitized.record, options.operator.id)
|
||
await completeProductPackageUpload({
|
||
materialId: material.id,
|
||
taskNo: null,
|
||
operatorUserId: options.operator.id,
|
||
assets: {
|
||
main: sanitized.record.mainImages,
|
||
sku: sanitized.record.skuImages,
|
||
detail: sanitized.record.detailImages,
|
||
psd: sanitized.record.psdSources || [],
|
||
},
|
||
options: {
|
||
allowSkuWithoutBarcodes: true,
|
||
zipTimeoutMs: HISTORY_ZIP_TIMEOUT_MS,
|
||
allZipTimeoutMs: HISTORY_ALL_ZIP_TIMEOUT_MS,
|
||
},
|
||
})
|
||
|
||
await createMaterialLog({
|
||
materialId: material.id,
|
||
userId: options.operator.id,
|
||
action: 'HISTORY_IMPORT',
|
||
changes: {
|
||
fingerprint: record.fingerprint,
|
||
sourcePath: record.sourcePath,
|
||
guessedStore: record.guessedStore,
|
||
storeCandidates: record.storeCandidates,
|
||
sourceCount: record.sourceCount || record.psdSources?.length || 0,
|
||
taskDetailSourceType: normalizeTaskDetailSourceType(record.taskDetailSourceType) || '渲染',
|
||
seenInYya: record.seenInYya,
|
||
skippedEmptyFiles: sanitized.skippedEmptyFiles,
|
||
skippedInvalidFiles: sanitized.skippedInvalidFiles,
|
||
},
|
||
})
|
||
|
||
results.push({
|
||
fingerprint: record.fingerprint,
|
||
spu: record.spu,
|
||
sourcePath: record.sourcePath,
|
||
guessedStore: record.guessedStore,
|
||
materialId: material.id,
|
||
status: 'imported',
|
||
reason: null,
|
||
})
|
||
} catch (error) {
|
||
results.push({
|
||
fingerprint: record.fingerprint,
|
||
spu: record.spu,
|
||
sourcePath: record.sourcePath,
|
||
guessedStore: record.guessedStore,
|
||
materialId: null,
|
||
status: 'failed',
|
||
reason: error instanceof Error ? error.message : '导入失败',
|
||
})
|
||
}
|
||
}
|
||
|
||
return results
|
||
}
|
||
|
||
export async function resolveImportOperator(identifier: string) {
|
||
const normalized = identifier.trim()
|
||
if (!normalized) {
|
||
throw new Error('缺少导入执行人标识')
|
||
}
|
||
|
||
const user = await prisma.user.findFirst({
|
||
where: {
|
||
archivedAt: null,
|
||
OR: [{ email: normalized }, { username: normalized }, { realName: normalized }],
|
||
},
|
||
select: {
|
||
id: true,
|
||
username: true,
|
||
realName: true,
|
||
},
|
||
})
|
||
|
||
if (!user) {
|
||
throw new Error(`未找到导入执行人:${identifier}`)
|
||
}
|
||
|
||
return user
|
||
}
|