import path from 'path' import { promises as fs } from 'fs' import { prisma } from '@/lib/prisma' type DeleteCandidate = { materialId: string fingerprint?: string sourcePath?: string reason: string } 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 candidates = getArg('candidates') const output = getArg('output') const confirm = getArg('confirm') const apply = args.includes('--apply') if (!candidates) { throw new Error('缺少 --candidates=删除候选清单 JSON') } return { candidates, output: output || path.join(path.dirname(candidates), `history-hard-delete-backup-${Date.now()}.json`), apply, confirm, } } function normalizeCandidate(raw: any): DeleteCandidate { return { materialId: String(raw?.materialId || raw?.id || '').trim(), fingerprint: String(raw?.fingerprint || '').trim() || undefined, sourcePath: String(raw?.sourcePath || raw?.source_path || '').trim() || undefined, reason: String(raw?.reason || raw?.deleteReason || '').trim(), } } function assertCandidate(candidate: DeleteCandidate) { if (!candidate.materialId) { throw new Error('删除候选缺少 materialId') } if (!candidate.reason) { throw new Error(`删除候选 ${candidate.materialId} 缺少 reason`) } if (!candidate.fingerprint && !candidate.sourcePath) { throw new Error(`删除候选 ${candidate.materialId} 必须提供 fingerprint 或 sourcePath`) } } function stringifyJson(value: unknown) { return JSON.stringify( value, (_key, item) => (typeof item === 'bigint' ? item.toString() : item), 2, ) } async function loadCandidates(candidatePath: string) { const raw = await fs.readFile(candidatePath, 'utf8') const parsed = JSON.parse(raw) const list = Array.isArray(parsed) ? parsed : parsed.candidates if (!Array.isArray(list)) { throw new Error('删除候选清单必须是数组,或包含 candidates 数组') } const candidates = list.map(normalizeCandidate) candidates.forEach(assertCandidate) return candidates } async function loadMaterialSnapshot(materialId: string) { return prisma.material.findUnique({ where: { id: materialId }, include: { assets: { include: { barcodes: true, }, }, skus: true, logs: true, likes: true, favorites: true, downloadLogs: true, transfers: true, }, }) } function hasHistoryEvidence(material: Awaited>, candidate: DeleteCandidate) { if (!material) { return false } return material.logs.some((log) => { if (log.action !== 'HISTORY_IMPORT') { return false } const changes = String(log.changes || '') return Boolean( (candidate.fingerprint && changes.includes(candidate.fingerprint)) || (candidate.sourcePath && changes.includes(candidate.sourcePath)), ) }) } async function main() { const options = parseArgs() const candidates = await loadCandidates(options.candidates) const backups: any[] = [] const results: any[] = [] console.log('历史错误素材硬删除预检开始') console.log(`- 候选清单: ${options.candidates}`) console.log(`- 模式: ${options.apply ? '正式硬删除' : '仅备份/演练'}`) console.log(`- 备份输出: ${options.output}`) if (options.apply && options.confirm !== 'HARD_DELETE_HISTORY_MATERIALS') { throw new Error('正式硬删除必须追加 --confirm=HARD_DELETE_HISTORY_MATERIALS') } for (const candidate of candidates) { const material = await loadMaterialSnapshot(candidate.materialId) if (!material) { results.push({ materialId: candidate.materialId, status: 'skipped', reason: '素材不存在', }) continue } const evidenceMatched = hasHistoryEvidence(material, candidate) backups.push({ candidate, material, backedUpAt: new Date().toISOString(), evidenceMatched, }) if (!evidenceMatched) { results.push({ materialId: candidate.materialId, productModel: material.productModel, status: 'blocked', reason: '未在 HISTORY_IMPORT 日志中匹配 fingerprint/sourcePath,拒绝删除', }) continue } if (!options.apply) { results.push({ materialId: candidate.materialId, productModel: material.productModel, status: 'dry_run', reason: candidate.reason, }) continue } await prisma.material.delete({ where: { id: candidate.materialId }, }) results.push({ materialId: candidate.materialId, productModel: material.productModel, status: 'deleted', reason: candidate.reason, }) } await fs.mkdir(path.dirname(options.output), { recursive: true }) await fs.writeFile( options.output, stringifyJson({ sourceCandidates: options.candidates, apply: options.apply, generatedAt: new Date().toISOString(), results, backups, }), 'utf8', ) const summary = results.reduce( (acc, item) => { acc.total += 1 acc[item.status] = (acc[item.status] || 0) + 1 return acc }, { total: 0 } as Record, ) console.log('历史错误素材硬删除预检完成') console.log(summary) console.log(`- 备份/结果: ${options.output}`) } main() .catch((error) => { console.error('历史错误素材硬删除失败:', error) process.exitCode = 1 }) .finally(async () => { await prisma.$disconnect() process.exit(process.exitCode ?? 0) })