#!/usr/bin/env php 0 && $freeSpace < $needSpace) { logLine('ERROR: 磁盘空间不足。需要约 ' . formatBytes((int) $needSpace) . ',可用 ' . formatBytes($freeSpace), $logFile); logLine('请清理磁盘或使用 bash deploy/compress-uploads.sh --safe(rsync 快照到其他盘)', $logFile); exit(1); } } // ---------- 全量快照 ---------- $snapshotId = null; if ($doSnapshot && !$dryRun) { $snapshotId = date('Ymd_His'); $snapshotDir = $snapshotRoot . DIRECTORY_SEPARATOR . $snapshotId; logLine("\n========== 创建全量快照: {$snapshotId} ==========", $logFile); logLine("快照目录: {$snapshotDir}", $logFile); logLine('(约 2G 需数分钟,请耐心等待)', $logFile); $snapResult = createSnapshot($uploadsDir, $snapshotDir, $logFile); if (!$snapResult['ok']) { logLine('ERROR: 快照失败 - ' . $snapResult['reason'], $logFile); exit(1); } logLine("快照完成: {$snapResult['files']} 个文件, " . formatBytes($snapResult['bytes']), $logFile); writeSnapshotMeta($snapshotDir, $preflight, $maxWidth, $quality); logLine("恢复命令: bash deploy/restore-uploads.sh --restore={$snapshotId}", $logFile); } // ---------- 压缩 ---------- $stats = ['scanned' => 0, 'processed' => 0, 'skipped' => 0, 'saved_bytes' => 0, 'errors' => 0]; $startTime = microtime(true); logLine("\n========== 开始压缩" . ($dryRun ? ' [dry-run]' : '') . " ==========", $logFile); foreach ($preflight['files'] as $item) { $path = $item['path']; $relative = $item['relative']; $ext = $item['ext']; $stats['scanned']++; $result = processImage($path, $ext, $maxWidth, $quality, $dryRun, $doBackup, $uploadsDir, $backupRoot); switch ($result['status']) { case 'ok': $stats['processed']++; $stats['saved_bytes'] += $result['saved']; $savedKb = round($result['saved'] / 1024, 1); logLine("[OK] {$relative} {$result['before_w']}x{$result['before_h']}" . " -> {$result['after_w']}x{$result['after_h']}" . ' ' . formatBytes($result['before_size']) . ' -> ' . formatBytes($result['after_size']) . " (-{$savedKb}KB)", $logFile); break; case 'skip': $stats['skipped']++; break; case 'error': $stats['errors']++; logLine("[ERR] {$relative} {$result['reason']}", $logFile); break; } if ($stats['scanned'] % 100 === 0) { $elapsed = round(microtime(true) - $startTime); logLine("--- 进度 {$stats['scanned']}/{$preflight['count']} | 已用 {$elapsed}s ---", $logFile); } } $elapsed = round(microtime(true) - $startTime); logLine("\n========== 完成 (耗时 {$elapsed}s) ==========", $logFile); logLine("扫描: {$stats['scanned']} | 压缩: {$stats['processed']} | 跳过: {$stats['skipped']} | 失败: {$stats['errors']}", $logFile); logLine('共节省: ' . formatBytes($stats['saved_bytes']), $logFile); if ($snapshotId) { logLine("\n若需恢复全部原图:", $logFile); logLine(" bash deploy/restore-uploads.sh --restore={$snapshotId}", $logFile); } exit($stats['errors'] > 0 ? 1 : 0); // ======================== 核心逻辑 ======================== /** * @return array{status:string,reason?:string,before_w?:int,before_h?:int,after_w?:int,after_h?:int,before_size?:int,after_size?:int,saved?:int} */ function processImage( string $path, string $ext, int $maxWidth, int $quality, bool $dryRun, bool $doBackup, string $uploadsDir, string $backupRoot ): array { $beforeSize = filesize($path); if ($beforeSize === false || $beforeSize === 0) { return ['status' => 'error', 'reason' => '无法读取文件']; } $info = @getimagesize($path); if (!$info || empty($info[0]) || empty($info[1])) { return ['status' => 'skip', 'reason' => '非有效图片']; } [$beforeW, $beforeH] = [$info[0], $info[1]]; if ($ext === 'gif' && isAnimatedGif($path)) { return ['status' => 'skip', 'reason' => '动画 GIF']; } $image = loadImage($path, $ext); if ($image === null) { return ['status' => 'error', 'reason' => 'GD 无法加载']; } if (in_array($ext, ['jpg', 'jpeg'], true)) { $image = applyExifOrientation($image, $path); $beforeW = imagesx($image); $beforeH = imagesy($image); } $afterW = $beforeW; $afterH = $beforeH; $needResize = $beforeW > $maxWidth; if ($needResize) { $afterW = $maxWidth; $afterH = max(1, (int) round($beforeH * ($maxWidth / $beforeW))); $resized = imagecreatetruecolor($afterW, $afterH); if ($resized === false) { imagedestroy($image); return ['status' => 'error', 'reason' => '创建画布失败']; } preserveTransparency($resized, $ext); imagecopyresampled($resized, $image, 0, 0, 0, 0, $afterW, $afterH, $beforeW, $beforeH); imagedestroy($image); $image = $resized; } $tmpFile = $path . '.compress_tmp.' . getmypid() . '.' . mt_rand(1000, 9999); if (!saveImage($image, $tmpFile, $ext, $quality)) { imagedestroy($image); @unlink($tmpFile); return ['status' => 'error', 'reason' => '保存临时文件失败']; } imagedestroy($image); $validate = validateCompressedFile($tmpFile, $ext, $afterW, $afterH); if (!$validate['ok']) { @unlink($tmpFile); return ['status' => 'error', 'reason' => '校验失败: ' . $validate['reason']]; } $afterSize = filesize($tmpFile); if ($afterSize === false || $afterSize === 0) { @unlink($tmpFile); return ['status' => 'error', 'reason' => '临时文件为空']; } if ($afterSize >= $beforeSize && !$needResize) { @unlink($tmpFile); return ['status' => 'skip', 'reason' => '已足够小']; } if ($dryRun) { @unlink($tmpFile); return buildOkResult($beforeW, $beforeH, $afterW, $afterH, $beforeSize, $afterSize); } // 先备份原文件,再原子替换 if ($doBackup) { $relative = ltrim(str_replace($uploadsDir, '', $path), DIRECTORY_SEPARATOR); $backupPath = $backupRoot . DIRECTORY_SEPARATOR . 'per_file' . DIRECTORY_SEPARATOR . $relative; if (!backupFile($path, $backupPath)) { @unlink($tmpFile); return ['status' => 'error', 'reason' => '单文件备份失败']; } } if (!@rename($tmpFile, $path)) { @unlink($tmpFile); return ['status' => 'error', 'reason' => '覆盖原文件失败']; } @chmod($path, 0644); // 替换后再验一次,异常则从 per_file 备份恢复 $postCheck = @getimagesize($path); if (!$postCheck) { if ($doBackup && isset($backupPath) && file_exists($backupPath)) { copy($backupPath, $path); } return ['status' => 'error', 'reason' => '替换后校验失败,已回滚单文件']; } return buildOkResult($beforeW, $beforeH, $afterW, $afterH, $beforeSize, $afterSize); } function validateCompressedFile(string $path, string $ext, int $expectW, int $expectH): array { $info = @getimagesize($path); if (!$info || empty($info[0]) || empty($info[1])) { return ['ok' => false, 'reason' => 'getimagesize 失败']; } if ((int) $info[0] !== $expectW || (int) $info[1] !== $expectH) { return ['ok' => false, 'reason' => "尺寸不符 {$info[0]}x{$info[1]} != {$expectW}x{$expectH}"]; } $img = loadImage($path, $ext); if ($img === null) { return ['ok' => false, 'reason' => 'GD 无法重新加载']; } imagedestroy($img); return ['ok' => true]; } function createSnapshot(string $uploadsDir, string $snapshotDir, ?string $logFile): array { if (!is_dir($snapshotDir) && !mkdir($snapshotDir, 0755, true) && !is_dir($snapshotDir)) { return ['ok' => false, 'reason' => '无法创建快照目录']; } $files = 0; $bytes = 0; $iterator = new RecursiveIteratorIterator( new RecursiveDirectoryIterator($uploadsDir, FilesystemIterator::SKIP_DOTS) ); foreach ($iterator as $fileInfo) { /** @var SplFileInfo $fileInfo */ if (!$fileInfo->isFile()) { continue; } $src = $fileInfo->getPathname(); $relative = ltrim(str_replace($uploadsDir, '', $src), DIRECTORY_SEPARATOR); if (strpos($relative, BACKUP_DIR_NAME) === 0) { continue; } $dst = $snapshotDir . DIRECTORY_SEPARATOR . $relative; $dstDir = dirname($dst); if (!is_dir($dstDir) && !mkdir($dstDir, 0755, true) && !is_dir($dstDir)) { return ['ok' => false, 'reason' => "无法创建 {$dstDir}"]; } if (!copy($src, $dst)) { return ['ok' => false, 'reason' => "复制失败: {$relative}"]; } $size = filesize($src); $files++; $bytes += $size ?: 0; if ($files % 200 === 0) { logLine(" 快照进度: {$files} 文件, " . formatBytes($bytes), $logFile); } } return ['ok' => true, 'files' => $files, 'bytes' => $bytes]; } function writeSnapshotMeta(string $snapshotDir, array $preflight, int $maxWidth, int $quality): void { $meta = [ 'created_at' => date('c'), 'image_count' => $preflight['count'], 'total_bytes' => $preflight['total_bytes'], 'max_width' => $maxWidth, 'quality' => $quality, ]; file_put_contents($snapshotDir . DIRECTORY_SEPARATOR . '.snapshot_meta.json', json_encode($meta, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE)); } function scanUploads(string $uploadsDir): array { $files = []; $totalBytes = 0; $iterator = new RecursiveIteratorIterator( new RecursiveDirectoryIterator($uploadsDir, FilesystemIterator::SKIP_DOTS) ); foreach ($iterator as $fileInfo) { /** @var SplFileInfo $fileInfo */ if (!$fileInfo->isFile()) { continue; } $path = $fileInfo->getPathname(); $relative = ltrim(str_replace($uploadsDir, '', $path), DIRECTORY_SEPARATOR); if (strpos($relative, BACKUP_DIR_NAME) === 0) { continue; } $ext = strtolower($fileInfo->getExtension()); if (!in_array($ext, SUPPORTED_EXT, true)) { continue; } $size = $fileInfo->getSize(); $files[] = ['path' => $path, 'relative' => $relative, 'ext' => $ext, 'size' => $size]; $totalBytes += $size; } return ['files' => $files, 'count' => count($files), 'total_bytes' => $totalBytes]; } function backupFile(string $src, string $dst): bool { if (file_exists($dst)) { return true; } $dir = dirname($dst); if (!is_dir($dir) && !mkdir($dir, 0755, true) && !is_dir($dir)) { return false; } return copy($src, $dst); } function buildOkResult(int $beforeW, int $beforeH, int $afterW, int $afterH, int $beforeSize, int $afterSize): array { return [ 'status' => 'ok', 'before_w' => $beforeW, 'before_h' => $beforeH, 'after_w' => $afterW, 'after_h' => $afterH, 'before_size' => $beforeSize, 'after_size' => $afterSize, 'saved' => max(0, $beforeSize - $afterSize), ]; } /** * @return resource|\GdImage|null */ function loadImage(string $path, string $ext) { switch ($ext) { case 'jpg': case 'jpeg': return @imagecreatefromjpeg($path) ?: null; case 'png': return @imagecreatefrompng($path) ?: null; case 'webp': return function_exists('imagecreatefromwebp') ? (@imagecreatefromwebp($path) ?: null) : null; case 'gif': return @imagecreatefromgif($path) ?: null; default: return null; } } /** * @param resource|\GdImage $image */ function saveImage($image, string $path, string $ext, int $quality): bool { switch ($ext) { case 'jpg': case 'jpeg': return imagejpeg($image, $path, $quality); case 'png': $pngLevel = max(0, min(9, (int) round((100 - $quality) / 100 * 9))); return imagepng($image, $path, $pngLevel); case 'webp': return function_exists('imagewebp') ? imagewebp($image, $path, $quality) : false; case 'gif': return imagegif($image, $path); default: return false; } } /** * @param resource|\GdImage $image * @return resource|\GdImage */ function applyExifOrientation($image, string $path) { if (!function_exists('exif_read_data')) { return $image; } $exif = @exif_read_data($path); if (!$exif || empty($exif['Orientation'])) { return $image; } switch ((int) $exif['Orientation']) { case 2: imageflip($image, IMG_FLIP_HORIZONTAL); break; case 3: $image = imagerotate($image, 180, 0) ?: $image; break; case 4: imageflip($image, IMG_FLIP_VERTICAL); break; case 5: imageflip($image, IMG_FLIP_HORIZONTAL); $image = imagerotate($image, -90, 0) ?: $image; break; case 6: $image = imagerotate($image, -90, 0) ?: $image; break; case 7: imageflip($image, IMG_FLIP_HORIZONTAL); $image = imagerotate($image, 90, 0) ?: $image; break; case 8: $image = imagerotate($image, 90, 0) ?: $image; break; } return $image; } /** * @param resource|\GdImage $image */ function preserveTransparency($image, string $ext): void { if (!in_array($ext, ['png', 'gif', 'webp'], true)) { return; } imagealphablending($image, false); imagesavealpha($image, true); $transparent = imagecolorallocatealpha($image, 0, 0, 0, 127); imagefilledrectangle($image, 0, 0, imagesx($image), imagesy($image), $transparent); } function isAnimatedGif(string $path): bool { $content = @file_get_contents($path, false, null, 0, 65536); return $content !== false && preg_match('/\x00\x21\xF9\x04.{4}\x00[\x2C\x21]/s', $content) === 1; } function diskFreeBytes(string $path): int { $free = @disk_free_space($path); return $free !== false ? (int) $free : 0; } function formatBytes(int $bytes): string { if ($bytes >= 1073741824) { return round($bytes / 1073741824, 2) . 'GB'; } if ($bytes >= 1048576) { return round($bytes / 1048576, 2) . 'MB'; } if ($bytes >= 1024) { return round($bytes / 1024, 1) . 'KB'; } return $bytes . 'B'; } function logLine(string $msg, ?string $logFile): void { echo $msg . PHP_EOL; if ($logFile) { file_put_contents($logFile, $msg . PHP_EOL, FILE_APPEND | LOCK_EX); } }