gongxiangp2/lib/history-material-import.ts
2026-05-07 16:37:22 +08:00

845 lines
23 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { prisma } from '@/lib/prisma'
import { completeProductPackageUpload, type ProductPackageImageInput } from '@/lib/product-package-completion'
import { createMaterialLog } from '@/lib/material-log'
import { isTaskDetailFullSetFromUploadGroups } from '@/lib/task-detail-full-set'
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,
/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)
export type HistoryImportStatus = 'ready' | 'needs_review' | 'skipped'
export interface HistoryImportRecord {
fingerprint: string
spu: string
sourcePath: string
relativeSourcePath: string
guessedStore: string
productCategoryName: string
authorName: string
storeConfidence: number
storeCandidates: string[]
mainCount: number
skuCount: number
detailCount: number
status: HistoryImportStatus
skipReason: string | null
mainImages: ProductPackageImageInput[]
skuImages: ProductPackageImageInput[]
detailImages: ProductPackageImageInput[]
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
}
function normalizeSpu(value: string) {
return value.trim().toUpperCase()
}
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 hasImportedFingerprint(fingerprint: string) {
const existing = await prisma.materialLog.findFirst({
where: {
action: 'HISTORY_IMPORT',
changes: {
contains: fingerprint,
} as any,
},
select: { id: true },
})
return Boolean(existing)
}
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,
} as any,
})
}
export async function importHistoryMaterials(options: ImportHistoryMaterialsOptions) {
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) {
if (await hasImportedFingerprint(record.fingerprint)) {
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 {
await ensureCategoryAndSceneNames(record.guessedStore || '其他', '其他')
const material = await createHistoryImportMaterial(record, options.operator.id)
await completeProductPackageUpload({
materialId: material.id,
taskNo: null,
operatorUserId: options.operator.id,
assets: {
main: record.mainImages,
sku: record.skuImages,
detail: record.detailImages,
},
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,
seenInYya: record.seenInYya,
},
})
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
}