180 lines
5.1 KiB
TypeScript
180 lines
5.1 KiB
TypeScript
import path from 'path'
|
|
import { promises as fs } from 'fs'
|
|
import type { HistoryImportRecord } from '@/lib/history-material-import'
|
|
|
|
type FailedResult = {
|
|
fingerprint: string
|
|
spu: string
|
|
sourcePath: string
|
|
guessedStore: string
|
|
materialId: string | null
|
|
status: string
|
|
reason: string | null
|
|
}
|
|
|
|
type QueueProgress = {
|
|
summary?: {
|
|
totalCandidates?: number
|
|
processed?: number
|
|
imported?: number
|
|
skipped?: number
|
|
failed?: number
|
|
}
|
|
updatedAt?: string
|
|
results?: FailedResult[]
|
|
}
|
|
|
|
function parseArgs() {
|
|
const args = process.argv.slice(2)
|
|
const getArg = (name: string) => {
|
|
const match = args.find((arg) => arg.startsWith(`--${name}=`))
|
|
return match ? match.slice(name.length + 3) : ''
|
|
}
|
|
|
|
const input = getArg('input')
|
|
const progress = getArg('progress')
|
|
const outputDir = getArg('output-dir')
|
|
|
|
if (!input) {
|
|
throw new Error('缺少 --input=全量历史导入JSON')
|
|
}
|
|
if (!progress) {
|
|
throw new Error('缺少 --progress=队列进度JSON')
|
|
}
|
|
|
|
return {
|
|
input,
|
|
progress,
|
|
outputDir: outputDir || path.dirname(progress),
|
|
}
|
|
}
|
|
|
|
function normalizeReason(reason: string | null | undefined) {
|
|
return String(reason || '')
|
|
.replace(/\s+/g, ' ')
|
|
.trim()
|
|
}
|
|
|
|
function classifyFailure(reason: string) {
|
|
if (reason.includes('打包超时')) {
|
|
return 'retry_timeout'
|
|
}
|
|
if (reason.includes('S3上传失败')) {
|
|
return 'retry_s3'
|
|
}
|
|
if (reason.includes('Input Buffer is empty')) {
|
|
return 'manual_empty_buffer'
|
|
}
|
|
if (reason.includes('corrupt header')) {
|
|
return 'manual_corrupt_image'
|
|
}
|
|
if (reason.includes('图片处理失败')) {
|
|
return 'manual_image_processing'
|
|
}
|
|
if (reason.includes('Foreign key constraint violated')) {
|
|
return 'manual_invalid_source'
|
|
}
|
|
return 'manual_other'
|
|
}
|
|
|
|
async function main() {
|
|
const options = parseArgs()
|
|
const [inputRaw, progressRaw] = await Promise.all([
|
|
fs.readFile(options.input, 'utf8'),
|
|
fs.readFile(options.progress, 'utf8'),
|
|
])
|
|
|
|
const inputRecords = JSON.parse(inputRaw) as HistoryImportRecord[]
|
|
const progress = JSON.parse(progressRaw) as QueueProgress
|
|
const failedResults = (progress.results || []).filter((item) => item.status === 'failed')
|
|
const recordByFingerprint = new Map(inputRecords.map((record) => [record.fingerprint, record]))
|
|
|
|
const retryableRecords: HistoryImportRecord[] = []
|
|
const manualReviewItems: Array<{
|
|
fingerprint: string
|
|
spu: string
|
|
sourcePath: string
|
|
guessedStore: string
|
|
failureType: string
|
|
reason: string
|
|
}> = []
|
|
|
|
const summary = new Map<string, number>()
|
|
|
|
for (const failed of failedResults) {
|
|
const reason = normalizeReason(failed.reason)
|
|
const failureType = classifyFailure(reason)
|
|
summary.set(failureType, (summary.get(failureType) || 0) + 1)
|
|
|
|
const sourceRecord = recordByFingerprint.get(failed.fingerprint)
|
|
if (!sourceRecord) {
|
|
manualReviewItems.push({
|
|
fingerprint: failed.fingerprint,
|
|
spu: failed.spu,
|
|
sourcePath: failed.sourcePath,
|
|
guessedStore: failed.guessedStore,
|
|
failureType: 'manual_missing_source_record',
|
|
reason: reason || '未在全量输入中找到对应记录',
|
|
})
|
|
continue
|
|
}
|
|
|
|
if (failureType === 'retry_timeout' || failureType === 'retry_s3') {
|
|
retryableRecords.push(sourceRecord)
|
|
continue
|
|
}
|
|
|
|
manualReviewItems.push({
|
|
fingerprint: failed.fingerprint,
|
|
spu: failed.spu,
|
|
sourcePath: failed.sourcePath,
|
|
guessedStore: failed.guessedStore,
|
|
failureType,
|
|
reason,
|
|
})
|
|
}
|
|
|
|
await fs.mkdir(options.outputDir, { recursive: true })
|
|
|
|
const retryablePath = path.join(options.outputDir, 'history-import-retryable-failures.json')
|
|
const manualReviewPath = path.join(options.outputDir, 'history-import-manual-review-failures.json')
|
|
const summaryPath = path.join(options.outputDir, 'history-import-failure-classification.json')
|
|
|
|
await Promise.all([
|
|
fs.writeFile(retryablePath, JSON.stringify(retryableRecords, null, 2), 'utf8'),
|
|
fs.writeFile(manualReviewPath, JSON.stringify(manualReviewItems, null, 2), 'utf8'),
|
|
fs.writeFile(
|
|
summaryPath,
|
|
JSON.stringify(
|
|
{
|
|
sourceInput: options.input,
|
|
sourceProgress: options.progress,
|
|
updatedAt: progress.updatedAt || null,
|
|
totals: {
|
|
failed: failedResults.length,
|
|
retryable: retryableRecords.length,
|
|
manualReview: manualReviewItems.length,
|
|
},
|
|
buckets: Object.fromEntries(Array.from(summary.entries()).sort((a, b) => b[1] - a[1])),
|
|
},
|
|
null,
|
|
2,
|
|
),
|
|
'utf8',
|
|
),
|
|
])
|
|
|
|
console.log('历史失败记录分流完成')
|
|
console.log(`- 失败总数: ${failedResults.length}`)
|
|
console.log(`- 可自动重试: ${retryableRecords.length}`)
|
|
console.log(`- 需要人工排查: ${manualReviewItems.length}`)
|
|
console.log(`- 可重试文件: ${retryablePath}`)
|
|
console.log(`- 人工排查文件: ${manualReviewPath}`)
|
|
console.log(`- 分类汇总文件: ${summaryPath}`)
|
|
}
|
|
|
|
main().catch((error) => {
|
|
console.error('历史失败记录分流失败:', error)
|
|
process.exit(1)
|
|
})
|