gongxiangp2/scripts/validate-history-import-result.ts
chenpei 7326e273e1 fix: harden media upload and release safety
Protect upload cleanup and atomic completion paths, add durable media worker leases and brand asset validation, and introduce verified migration/bootstrap/deployment gates.
2026-08-12 18:04:08 +08:00

275 lines
8.1 KiB
TypeScript

import { execFile } from 'child_process'
import { promises as fs } from 'fs'
import path from 'path'
import { promisify } from 'util'
import { PrismaClient } from '@prisma/client'
const execFileAsync = promisify(execFile)
const SSH_TIMEOUT_MS = Number(process.env.HISTORY_IMPORT_VALIDATE_SSH_TIMEOUT_MS || 30000)
const SSH_RETRY_COUNT = Number(process.env.HISTORY_IMPORT_VALIDATE_SSH_RETRY_COUNT || 3)
const SSH_ARGS = [
'-o',
'BatchMode=yes',
'-o',
'ConnectTimeout=15',
'-o',
'ServerAliveInterval=15',
'-o',
'ServerAliveCountMax=2',
]
type ImportResultItem = {
fingerprint: string
spu: string
sourcePath: string
materialId: string | null
status: string
reason: string | null
}
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 input = getArg('input')
const remoteHost = getArg('remote-host') || process.env.HISTORY_IMPORT_REMOTE_UPLOADS_HOST || 'prod'
const remoteUploadsDir =
getArg('remote-uploads-dir') ||
process.env.HISTORY_IMPORT_REMOTE_UPLOADS_DIR ||
'/mnt/PoolOne/youyidian-uploads'
if (!input) {
throw new Error('缺少 --input=导入结果 JSON')
}
return { input, remoteHost, remoteUploadsDir }
}
function shellQuote(value: string) {
return `'${value.replace(/'/g, `'\\''`)}'`
}
function storedPathToRemotePath(remoteUploadsDir: string, storedPath: string) {
const normalized = String(storedPath || '').replace(/\\/g, '/')
const relative = normalized.replace(/^\.?\/*uploads\//i, '')
if (!relative || relative.includes('../')) {
throw new Error(`下载文件路径无效: ${storedPath}`)
}
return path.posix.join(remoteUploadsDir.replace(/\/+$/, ''), relative)
}
function sleep(ms: number) {
return new Promise((resolve) => setTimeout(resolve, ms))
}
async function getRemoteFileSize(remoteHost: string, remotePath: string) {
let lastError: unknown
for (let attempt = 1; attempt <= SSH_RETRY_COUNT; attempt += 1) {
try {
const { stdout } = await execFileAsync(
'ssh',
[
...SSH_ARGS,
remoteHost,
`test -f ${shellQuote(remotePath)} && stat -c %s ${shellQuote(remotePath)}`,
],
{ timeout: SSH_TIMEOUT_MS },
)
return Number(String(stdout || '').trim())
} catch (error) {
lastError = error
if (attempt < SSH_RETRY_COUNT) {
await sleep(1000 * attempt)
}
}
}
throw lastError
}
async function main() {
const options = parseArgs()
const prisma = new PrismaClient()
try {
const raw = await fs.readFile(options.input, 'utf8')
const results = JSON.parse(raw) as ImportResultItem[]
const imported = results.filter((item) => item.status === 'imported' && item.materialId)
const failures = results.filter((item) => item.status === 'failed')
const skipped = results.filter((item) => item.status === 'skipped')
const ids = imported.map((item) => item.materialId!).filter(Boolean)
const rows = await prisma.material.findMany({
where: { id: { in: ids } },
select: {
id: true,
productModel: true,
previewUrl: true,
zipFilePath: true,
zipFileSize: true,
isFullSet: true,
deletedAt: true,
},
})
const rowById = new Map(rows.map((row) => [row.id, row]))
const sourceAssets = await prisma.materialAsset.findMany({
where: {
materialId: { in: ids },
assetType: 'psd',
},
select: {
materialId: true,
fileUrl: true,
fileName: true,
fileSize: true,
},
})
const sourceAssetsByMaterialId = new Map<string, typeof sourceAssets>()
for (const asset of sourceAssets) {
const current = sourceAssetsByMaterialId.get(asset.materialId) || []
current.push(asset)
sourceAssetsByMaterialId.set(asset.materialId, current)
}
const checks = []
for (const item of imported) {
const row = rowById.get(item.materialId!)
if (!row) {
checks.push({
materialId: item.materialId,
spu: item.spu,
ok: false,
issue: '数据库记录不存在',
})
continue
}
const remotePath = storedPathToRemotePath(options.remoteUploadsDir, row.zipFilePath || '')
let remoteSize = -1
try {
remoteSize = await getRemoteFileSize(options.remoteHost, remotePath)
} catch {
remoteSize = -1
}
let downloadStatus = 0
try {
const response = await fetch(
`https://sc.jiansun.vip/api/materials/download?id=${row.id}&type=all&validate=1&share=1`,
)
downloadStatus = response.status
} catch {
downloadStatus = -1
}
let previewStatus = 0
try {
const response = await fetch(row.previewUrl || '', { method: 'HEAD' })
previewStatus = response.status
} catch {
previewStatus = -1
}
const expectedSize = Number(row.zipFileSize || 0)
const materialSourceAssets = sourceAssetsByMaterialId.get(row.id) || []
const sourceChecks = []
for (const asset of materialSourceAssets) {
const sourcePathOk =
String(asset.fileUrl || '').startsWith('uploads/materials/') &&
!String(asset.fileUrl || '').includes('\\')
const expectedSourceSize = Number(asset.fileSize || 0)
let remoteSourceSize = -1
if (sourcePathOk) {
try {
remoteSourceSize = await getRemoteFileSize(
options.remoteHost,
storedPathToRemotePath(options.remoteUploadsDir, asset.fileUrl || ''),
)
} catch {
remoteSourceSize = -1
}
}
sourceChecks.push({
fileName: asset.fileName,
sourcePathOk,
remoteSize: remoteSourceSize,
expectedSize: expectedSourceSize,
ok: sourcePathOk && remoteSourceSize === expectedSourceSize,
})
}
const sourceAssetsOk = sourceChecks.every((asset) => asset.ok)
const previewOk = String(row.previewUrl || '').startsWith(
'https://s3.jiansun.vip/gongxiangpan/由壹点AI/public-display/',
)
const zipPathOk =
String(row.zipFilePath || '').startsWith('uploads/materials/') &&
!String(row.zipFilePath || '').includes('\\')
const ok =
!row.deletedAt &&
row.isFullSet &&
previewOk &&
zipPathOk &&
sourceAssetsOk &&
remoteSize === expectedSize &&
downloadStatus === 200 &&
previewStatus === 200
checks.push({
materialId: row.id,
spu: row.productModel,
ok,
previewOk,
zipPathOk,
sourceAssetCount: materialSourceAssets.length,
sourceAssetsOk,
sourceIssues: sourceChecks.filter((asset) => !asset.ok),
remoteSize,
expectedSize,
downloadStatus,
previewStatus,
})
}
const summary = {
input: options.input,
total: results.length,
imported: imported.length,
skipped: skipped.length,
failed: failures.length,
checked: checks.length,
ok: checks.filter((item) => item.ok).length,
issueCount: checks.filter((item) => !item.ok).length + failures.length,
issues: [
...failures.map((item) => ({
materialId: item.materialId,
spu: item.spu,
ok: false,
issue: item.reason || '导入失败',
})),
...checks.filter((item) => !item.ok),
],
checkedAt: new Date().toISOString(),
}
const outputPath = options.input.replace(/\.json$/i, '.verification.json')
await fs.writeFile(outputPath, JSON.stringify(summary, null, 2), 'utf8')
console.log(JSON.stringify(summary, null, 2))
if (summary.failed > 0 || summary.issueCount > 0 || summary.imported + summary.skipped !== summary.total) {
process.exitCode = 1
}
} finally {
await prisma.$disconnect()
}
}
main().catch((error) => {
console.error('导入结果验收失败:', error)
process.exitCode = 1
})