234 lines
11 KiB
JavaScript
234 lines
11 KiB
JavaScript
/**
|
|
* Read-only by default. Audits duplicate task-detail preview names against ZIP
|
|
* members using the original client upload index embedded in preview URLs.
|
|
* --apply --material <id> repairs one verified material; --apply --all-verified
|
|
* repairs every uniquely proven material. No ZIP or image bytes are changed.
|
|
*/
|
|
const fs = require('node:fs')
|
|
const path = require('node:path')
|
|
const { execFileSync } = require('node:child_process')
|
|
|
|
const GROUPS = ['main', 'sku', 'detail']
|
|
const PREVIEW_TYPE = Object.fromEntries(GROUPS.map((group) => [group, `${group}-preview`]))
|
|
const PYTHON_ZIP_NAMES = 'import json,sys,zipfile\nwith zipfile.ZipFile(sys.argv[1]) as z:\n print(json.dumps([i.filename for i in z.infolist() if not i.is_dir()], ensure_ascii=False))'
|
|
|
|
function stripExtension(name) {
|
|
const dot = name.lastIndexOf('.')
|
|
return dot > 0 ? name.slice(0, dot) : name
|
|
}
|
|
|
|
function uniqueZipName(originalName, index, used) {
|
|
const fallback = `file-${String(index + 1).padStart(3, '0')}`
|
|
const safe = String(originalName || fallback).replace(/[\0\r\n]/g, ' ').replace(/[\/\\]+/g, '_').trim() || fallback
|
|
const reserve = (candidate) => {
|
|
const key = candidate.toLowerCase()
|
|
if (used.has(key)) return false
|
|
used.add(key)
|
|
return true
|
|
}
|
|
if (reserve(safe)) return safe
|
|
const ext = path.extname(safe)
|
|
const base = (ext ? safe.slice(0, -ext.length) : safe) || fallback
|
|
for (let n = 2; n < 10000; n += 1) {
|
|
const candidate = `${base}-${n}${ext}`
|
|
if (reserve(candidate)) return candidate
|
|
}
|
|
throw new Error('ZIP 条目重名次数超过可核验范围')
|
|
}
|
|
|
|
function parseClientPreview(row) {
|
|
let url
|
|
try { url = decodeURIComponent(String(row.fileUrl || '')) } catch { return null }
|
|
const source = /client-\d{13}-(\d+)-(\d+)-/.exec(url)
|
|
const name = String(row.fileName || '')
|
|
const preview = /^(.+)-preview\.webp$/i.exec(name)
|
|
const slice = /^(.+)-(\d{3})\.webp$/i.exec(name)
|
|
if (!source || (!preview && !slice)) return null
|
|
// A renamed preview keeps its old object URL; upload-index reconstruction
|
|
// cannot prove its current ZIP name and must leave it for manual review.
|
|
if (!url.slice(source.index + source[0].length).startsWith(name)) return null
|
|
const sourceIndex = Number(source[1])
|
|
const urlSliceIndex = Number(source[2])
|
|
const label = preview ? preview[1] : slice[1]
|
|
const displaySlice = slice ? Number(slice[2]) : null
|
|
if (!Number.isSafeInteger(sourceIndex) || !Number.isSafeInteger(urlSliceIndex)) return null
|
|
if (displaySlice !== null && displaySlice !== urlSliceIndex + 1) return null
|
|
if (displaySlice === null && urlSliceIndex !== 0) return null
|
|
return { row, sourceIndex, urlSliceIndex, label, displaySlice }
|
|
}
|
|
|
|
function planGroup(rows, entryNames) {
|
|
if (entryNames.length === 0) return { status: 'manual', reason: 'ZIP 为空' }
|
|
if (new Set(entryNames.map((name) => name.toLowerCase())).size !== entryNames.length) {
|
|
return { status: 'manual', reason: 'ZIP 条目自身重名' }
|
|
}
|
|
const parsed = rows.map(parseClientPreview)
|
|
if (parsed.some((row) => row === null)) return { status: 'manual', reason: '预览缺少上传来源序号' }
|
|
const byIndex = new Map()
|
|
for (const item of parsed) {
|
|
const sourceRows = byIndex.get(item.sourceIndex) || []
|
|
sourceRows.push(item)
|
|
byIndex.set(item.sourceIndex, sourceRows)
|
|
}
|
|
if (byIndex.size !== entryNames.length) return { status: 'manual', reason: '原图序号与 ZIP 条目数量不一致' }
|
|
const sources = []
|
|
for (let index = 0; index < byIndex.size; index += 1) {
|
|
const sourceRows = byIndex.get(index)
|
|
if (!sourceRows) return { status: 'manual', reason: '原图序号不连续' }
|
|
const labels = new Set(sourceRows.map((item) => item.label.toLowerCase()))
|
|
const slices = sourceRows.map((item) => item.urlSliceIndex).sort((a, b) => a - b)
|
|
if (labels.size !== 1 || slices.some((slice, position) => slice !== position)) {
|
|
return { status: 'manual', reason: '同一原图的预览切片不连续或标签不一致' }
|
|
}
|
|
sources.push({ label: sourceRows[0].label, rows: sourceRows })
|
|
}
|
|
|
|
const entryByLowerName = new Map(entryNames.map((name) => [name.toLowerCase(), name]))
|
|
const extensions = Array.from(new Set(entryNames.map((name) => path.extname(name))))
|
|
const solutions = new Map()
|
|
const visit = (sourceIndex, used, chosen) => {
|
|
if (solutions.size > 1) return
|
|
if (sourceIndex === sources.length) {
|
|
if (used.size === entryNames.length) {
|
|
const signature = chosen.map((name) => name.toLowerCase()).join('\0')
|
|
solutions.set(signature, chosen)
|
|
}
|
|
return
|
|
}
|
|
for (const extension of extensions) {
|
|
const nextUsed = new Set(used)
|
|
const generated = uniqueZipName(`${sources[sourceIndex].label}${extension}`, sourceIndex, nextUsed)
|
|
const actual = entryByLowerName.get(generated.toLowerCase())
|
|
if (actual) visit(sourceIndex + 1, nextUsed, [...chosen, actual])
|
|
}
|
|
}
|
|
visit(0, new Set(), [])
|
|
if (solutions.size !== 1) {
|
|
return { status: 'manual', reason: solutions.size === 0 ? '无法由上传序号重建 ZIP 命名' : '存在多种可能的原图对应关系' }
|
|
}
|
|
const chosen = Array.from(solutions.values())[0]
|
|
const changes = sources.flatMap((source, sourceIndex) => source.rows.flatMap((item) => {
|
|
const base = stripExtension(chosen[sourceIndex])
|
|
const nextName = item.displaySlice === null
|
|
? `${base}-preview.webp`
|
|
: `${base}-${String(item.displaySlice).padStart(3, '0')}.webp`
|
|
return item.row.fileName === nextName ? [] : [{ id: item.row.id, before: item.row.fileName, after: nextName }]
|
|
}))
|
|
return { status: 'verified', changes, sources: sources.length }
|
|
}
|
|
|
|
function resolveZipPath(fileUrl) {
|
|
const root = path.resolve(process.env.UPLOAD_DIR || path.join(process.cwd(), 'uploads'))
|
|
const normalized = String(fileUrl || '').replace(/\\/g, '/')
|
|
const relative = normalized.replace(/^\.?\/*uploads\//i, '')
|
|
const absolute = path.resolve(path.isAbsolute(normalized) ? normalized : path.join(root, relative))
|
|
if (absolute !== root && !absolute.startsWith(root + path.sep)) throw new Error('ZIP 路径不在上传目录内')
|
|
return absolute
|
|
}
|
|
|
|
function listZipNames(fileUrl) {
|
|
const zipPath = resolveZipPath(fileUrl)
|
|
if (!fs.statSync(zipPath).isFile()) throw new Error('ZIP 文件不存在')
|
|
return JSON.parse(execFileSync('python3', ['-c', PYTHON_ZIP_NAMES, zipPath], {
|
|
encoding: 'utf8', timeout: 15000, maxBuffer: 2 * 1024 * 1024,
|
|
}))
|
|
}
|
|
|
|
async function scanMaterial(db, materialId) {
|
|
const material = await db.material.findUnique({
|
|
where: { id: materialId },
|
|
select: { id: true, materialType: true, taskStatus: true, deletedAt: true, updatedAt: true, assets: { select: {
|
|
id: true, assetType: true, fileUrl: true, fileName: true, sortOrder: true,
|
|
} } },
|
|
})
|
|
if (!material || material.materialType !== '任务-详情' || material.taskStatus !== 'completed' || material.deletedAt) return []
|
|
const results = []
|
|
for (const group of GROUPS) {
|
|
const rows = material.assets.filter((asset) => asset.assetType === PREVIEW_TYPE[group])
|
|
const nameCounts = new Map()
|
|
rows.forEach((row) => nameCounts.set(String(row.fileName || '').toLowerCase(), (nameCounts.get(String(row.fileName || '').toLowerCase()) || 0) + 1))
|
|
if (!Array.from(nameCounts.values()).some((count) => count > 1)) continue
|
|
const zipAssets = material.assets.filter((asset) => asset.assetType === group)
|
|
if (zipAssets.length !== 1) {
|
|
results.push({ materialId, group, status: 'manual', reason: '分组 ZIP 数量不是一份' })
|
|
continue
|
|
}
|
|
try {
|
|
const plan = planGroup(rows, listZipNames(zipAssets[0].fileUrl))
|
|
results.push({ materialId, group, expectedUpdatedAt: material.updatedAt, ...plan })
|
|
} catch (error) {
|
|
results.push({ materialId, group, status: 'manual', reason: error.message })
|
|
}
|
|
}
|
|
return results
|
|
}
|
|
|
|
async function applyVerifiedPlan(db, plan) {
|
|
if (plan.status !== 'verified' || !plan.changes.length) return 0
|
|
await db.$transaction(async (tx) => {
|
|
const material = await tx.material.findUnique({ where: { id: plan.materialId }, select: { updatedAt: true } })
|
|
if (!material || material.updatedAt.getTime() !== plan.expectedUpdatedAt.getTime()) {
|
|
throw new Error('素材已更新,需重新扫描')
|
|
}
|
|
for (const change of plan.changes) {
|
|
const updated = await tx.materialAsset.updateMany({
|
|
where: { id: change.id, materialId: plan.materialId, assetType: PREVIEW_TYPE[plan.group], fileName: change.before },
|
|
data: { fileName: change.after },
|
|
})
|
|
if (updated.count !== 1) throw new Error('预览索引已变化,需重新扫描')
|
|
}
|
|
})
|
|
return plan.changes.length
|
|
}
|
|
|
|
async function main() {
|
|
const args = process.argv.slice(2)
|
|
const apply = args.includes('--apply')
|
|
const summaryOnly = args.includes('--summary-only')
|
|
const includeChanges = args.includes('--include-changes')
|
|
const allVerified = args.includes('--all-verified')
|
|
const materialIndex = args.indexOf('--material')
|
|
const singleMaterialId = materialIndex >= 0 ? args[materialIndex + 1] : null
|
|
if (apply && !allVerified && !singleMaterialId) throw new Error('--apply 必须指定 --material <id> 或 --all-verified')
|
|
const { PrismaClient } = require('@prisma/client')
|
|
const db = new PrismaClient()
|
|
try {
|
|
const ids = singleMaterialId ? [{ material_id: singleMaterialId }] : await db.$queryRawUnsafe(`
|
|
SELECT DISTINCT material_id FROM (
|
|
SELECT a.material_id, a.asset_type, lower(a.file_name)
|
|
FROM material_assets a JOIN materials m ON m.id = a.material_id
|
|
WHERE m."materialType" = '任务-详情' AND m."deletedAt" IS NULL
|
|
AND m.task_status = 'completed'
|
|
AND a.asset_type IN ('main-preview', 'sku-preview', 'detail-preview')
|
|
AND a.file_name IS NOT NULL
|
|
GROUP BY a.material_id, a.asset_type, lower(a.file_name) HAVING count(*) > 1
|
|
) candidates ORDER BY material_id`)
|
|
const results = []
|
|
for (const row of ids) {
|
|
for (const result of await scanMaterial(db, row.material_id)) {
|
|
if (apply && result.status === 'verified') result.appliedRows = await applyVerifiedPlan(db, result)
|
|
results.push(result)
|
|
}
|
|
}
|
|
console.log(JSON.stringify({
|
|
candidateMaterials: ids.length,
|
|
verifiedGroups: results.filter((item) => item.status === 'verified').length,
|
|
manualGroups: results.filter((item) => item.status === 'manual').length,
|
|
plannedRows: results.reduce((sum, item) => sum + (item.changes?.length || 0), 0),
|
|
appliedRows: results.reduce((sum, item) => sum + (item.appliedRows || 0), 0),
|
|
manual: results.filter((item) => item.status === 'manual').map(({ materialId, group, reason }) => ({ materialId, group, reason })),
|
|
...(summaryOnly ? {} : { verified: results.filter((item) => item.status === 'verified').map(({ materialId, group, changes }) => ({
|
|
materialId, group, changedRows: changes.length,
|
|
...(includeChanges ? { changes } : {}),
|
|
})) }),
|
|
}, null, 2))
|
|
} finally {
|
|
await db.$disconnect()
|
|
}
|
|
}
|
|
|
|
module.exports = { parseClientPreview, planGroup, uniqueZipName }
|
|
if (require.main === module || process.env.TASK_DETAIL_AUDIT_RUN === '1') {
|
|
main().catch((error) => { console.error(error.message); process.exitCode = 1 })
|
|
}
|