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( `(?> yyaSpus: Set } 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() 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 { const cpStoresBySpu = new Map>() const yyaSpus = new Set() 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() 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() 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(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() 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() 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.materialLog.findFirst({ where: { action: 'HISTORY_IMPORT', material: { deletedAt: null, }, changes: { contains: record.fingerprint, } as any, }, select: { id: true }, }) if (existingByFingerprint) { return true } const existingBySourcePath = await prisma.$queryRaw>` 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( 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( 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 { 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 }