gongxiangp2/scripts/hard-delete-history-materials.ts

223 lines
6.0 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 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) {
const material = await prisma.material.findUnique({
where: { id: materialId },
include: {
assets: {
include: {
barcodes: true,
},
},
skus: true,
likes: true,
favorites: true,
transfers: true,
},
})
if (!material) return null
// Deletion history is retained independently from the material record.
const [logs, downloadLogs] = await Promise.all([
prisma.materialLog.findMany({ where: { materialId } }),
prisma.materialDownloadLog.findMany({ where: { materialId } }),
])
return { ...material, logs, downloadLogs }
}
function hasHistoryEvidence(material: Awaited<ReturnType<typeof loadMaterialSnapshot>>, 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<string, number>,
)
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)
})