223 lines
6.0 KiB
TypeScript
223 lines
6.0 KiB
TypeScript
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)
|
||
})
|