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() 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 })