文件
xiaozhongchuf/deploy/compress-uploads.php
可乐加冰 0e26119bcf feat(deploy): 添加 uploads 图片批量压缩与一键恢复脚本
支持 2G+ 大批量安全压缩(65% 质量、最大宽 800px),含全量快照、校验与恢复能力。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-24 00:01:40 +08:00

551 行
18 KiB
PHP

此文件含有模棱两可的 Unicode 字符

此文件含有可能会与其他字符混淆的 Unicode 字符。 如果您是想特意这样的,可以安全地忽略该警告。 使用 Escape 按钮显示他们。

#!/usr/bin/env php
<?php
/**
* 批量压缩 public/uploads 下的图片(支持 2G+ 大批量)
*
* 安全机制:
* - 压缩结果写入临时文件,校验通过后才覆盖原文件
* - 配合 --snapshot 先全量备份,可一键恢复
* - 单张失败不影响其他文件,原图不会被损坏
*
* 用法:
* php deploy/compress-uploads.php --dry-run
* php deploy/compress-uploads.php --snapshot # 推荐:先快照再压缩
* bash deploy/compress-uploads.sh --safe # 同上(shell 用 rsync 快照更快)
*/
declare(strict_types=1);
ini_set('memory_limit', '512M');
set_time_limit(0);
const DEFAULT_MAX_WIDTH = 800;
const DEFAULT_QUALITY = 65;
const BACKUP_DIR_NAME = '.compress_backup';
const SNAPSHOTS_DIR = '.compress_backup/snapshots';
const SUPPORTED_EXT = ['jpg', 'jpeg', 'png', 'webp', 'gif'];
$options = getopt('', [
'dry-run',
'backup',
'snapshot',
'no-backup',
'max-width:',
'quality:',
'path:',
'log:',
'help',
]);
if (isset($options['help'])) {
echo <<<HELP
批量压缩 uploads 图片(安全模式,适合 2G+ 大批量)
选项:
--dry-run 仅预览,不写入
--snapshot 压缩前先做全量快照(推荐,可一键恢复)
--backup 每张覆盖前单独备份(与 --snapshot 二选一即可)
--no-backup 不备份(不推荐,仅测试用)
--max-width=800 最大宽度
--quality=65 JPEG/WebP 质量
--path=DIR 目标目录
--log=FILE 写入日志文件
--help 显示帮助
推荐流程(2G 数据):
1. php deploy/compress-uploads.php --dry-run
2. bash deploy/compress-uploads.sh --safe
3. 若有异常: bash deploy/restore-uploads.sh --latest
HELP;
exit(0);
}
$dryRun = isset($options['dry-run']);
$doSnapshot = isset($options['snapshot']);
$doBackup = isset($options['backup']) && !isset($options['no-backup']);
$maxWidth = isset($options['max-width']) ? max(1, (int) $options['max-width']) : DEFAULT_MAX_WIDTH;
$quality = isset($options['quality']) ? min(100, max(1, (int) $options['quality'])) : DEFAULT_QUALITY;
$logFile = $options['log'] ?? null;
$projectRoot = dirname(__DIR__);
$uploadsDir = isset($options['path'])
? rtrim($options['path'], DIRECTORY_SEPARATOR)
: $projectRoot . DIRECTORY_SEPARATOR . 'public' . DIRECTORY_SEPARATOR . 'uploads';
if (!is_dir($uploadsDir)) {
logLine("ERROR: 目录不存在: {$uploadsDir}", $logFile);
exit(1);
}
if (!extension_loaded('gd')) {
logLine('ERROR: 需要 PHP GD 扩展', $logFile);
exit(1);
}
$backupRoot = $uploadsDir . DIRECTORY_SEPARATOR . BACKUP_DIR_NAME;
$snapshotRoot = $uploadsDir . DIRECTORY_SEPARATOR . SNAPSHOTS_DIR;
// ---------- 预检 ----------
$preflight = scanUploads($uploadsDir);
logLine("========== 预检 ==========", $logFile);
logLine("目标目录: {$uploadsDir}", $logFile);
logLine("图片数量: {$preflight['count']} 总大小: " . formatBytes($preflight['total_bytes']), $logFile);
logLine("磁盘可用: " . formatBytes(diskFreeBytes($uploadsDir)), $logFile);
logLine("最大宽度: {$maxWidth}px | 质量: {$quality}%", $logFile);
if (!$dryRun && !$doSnapshot && !$doBackup) {
logLine('WARN: 未启用 --snapshot 或 --backup,压缩失败时无法恢复。建议加 --snapshot', $logFile);
}
if (!$dryRun && $doSnapshot) {
$needSpace = $preflight['total_bytes'] * 1.1; // 快照 + 10% 缓冲
$freeSpace = diskFreeBytes($uploadsDir);
if ($freeSpace > 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);
}
}