gongxiangp2/scripts/hard-delete-history-materials.ts
chenpei 9cd3c0f231 feat: 任务详情无条码开关 + 上传面板信息密度优化 + 历史导入/脚本补全
- 任务详情上传支持"本批素材无条码"开关,跳过条码必填校验
- SKU卡片/主图网格从4列改5列,图片高度压缩,去掉冗余标签和提示
- 详情图/PSD上传区去掉大占位框,改成紧凑按钮+标签
- 补充历史导入相关脚本和工具文件

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-05-11 10:01:08 +08:00

215 lines
5.7 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) {
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<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)
})