feat(deploy): 添加 uploads 图片批量压缩与一键恢复脚本

支持 2G+ 大批量安全压缩(65% 质量、最大宽 800px),含全量快照、校验与恢复能力。

Co-authored-by: Cursor <cursoragent@cursor.com>
这个提交包含在:
可乐加冰
2026-09-24 00:01:40 +08:00
父节点 d11c2bc0fa
当前提交 0e26119bcf
共有 4 个文件被更改,包括 926 次插入 和 0 次删除

550
deploy/compress-uploads.php 普通文件
查看文件

@@ -0,0 +1,550 @@
#!/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);
}
}

123
deploy/compress-uploads.sh 普通文件
查看文件

@@ -0,0 +1,123 @@
#!/usr/bin/env bash
# 批量压缩 public/uploads 图片(2G+ 安全模式)
#
# 用法:
# bash deploy/compress-uploads.sh --dry-run # 预览
# bash deploy/compress-uploads.sh --safe # 推荐:rsync 快照 + 压缩
# bash deploy/compress-uploads.sh --safe --log=deploy/logs/compress.log
set -euo pipefail
DEPLOY_PATH="${DEPLOY_PATH:-$(cd "$(dirname "$0")/.." && pwd)}"
PHP_BIN="${PHP_BIN:-$(command -v php || true)}"
SCRIPT="${DEPLOY_PATH}/deploy/compress-uploads.php"
UPLOADS="${DEPLOY_PATH}/public/uploads"
SNAPSHOTS="${UPLOADS}/.compress_backup/snapshots"
LOG_DIR="${DEPLOY_PATH}/deploy/logs"
if [[ -z "$PHP_BIN" || ! -x "$PHP_BIN" ]]; then
echo "ERROR: 找不到可执行的 PHP,请设置 PHP_BIN"
exit 1
fi
if [[ ! -f "$SCRIPT" ]]; then
echo "ERROR: 脚本不存在: $SCRIPT"
exit 1
fi
if [[ ! -d "$UPLOADS" ]]; then
echo "ERROR: uploads 目录不存在: $UPLOADS"
exit 1
fi
# 解析 --safe 模式
SAFE_MODE=0
PASSTHRU=()
for arg in "$@"; do
if [[ "$arg" == "--safe" ]]; then
SAFE_MODE=1
else
PASSTHRU+=("$arg")
fi
done
echo "项目: ${DEPLOY_PATH}"
echo "PHP: ${PHP_BIN}"
echo ""
if [[ "$SAFE_MODE" -eq 1 ]]; then
SNAPSHOT_ID="$(date +%Y%m%d_%H%M%S)"
SNAPSHOT_DIR="${SNAPSHOTS}/${SNAPSHOT_ID}"
# 磁盘空间检查
UPLOADS_SIZE=$(du -sb "$UPLOADS" 2>/dev/null | awk '{print $1}' || echo 0)
FREE_SPACE=$(df -B1 "$UPLOADS" 2>/dev/null | awk 'NR==2{print $4}' || echo 0)
NEED=$(( UPLOADS_SIZE + UPLOADS_SIZE / 10 ))
echo "========== 安全模式 =========="
echo "uploads 大小: $(numfmt --to=iec "$UPLOADS_SIZE" 2>/dev/null || echo "${UPLOADS_SIZE}B")"
echo "磁盘可用: $(numfmt --to=iec "$FREE_SPACE" 2>/dev/null || echo "${FREE_SPACE}B")"
echo "快照 ID: ${SNAPSHOT_ID}"
echo ""
if [[ "$FREE_SPACE" -gt 0 && "$FREE_SPACE" -lt "$NEED" ]]; then
echo "ERROR: 磁盘空间不足,需要约 $(numfmt --to=iec "$NEED" 2>/dev/null || echo "${NEED}B")"
echo "请清理磁盘,或将 SNAPSHOTS 目录挂载到其他盘"
exit 1
fi
mkdir -p "$SNAPSHOT_DIR"
echo "正在创建全量快照(rsync,约 2G 需数分钟)..."
echo "快照目录: ${SNAPSHOT_DIR}"
echo ""
if command -v rsync >/dev/null 2>&1; then
rsync -a --info=progress2 \
--exclude='.compress_backup/' \
"${UPLOADS}/" "${SNAPSHOT_DIR}/"
else
echo "WARN: 未找到 rsync,使用 cp(较慢)"
find "$UPLOADS" -mindepth 1 -maxdepth 1 ! -name '.compress_backup' -exec cp -a {} "${SNAPSHOT_DIR}/" \;
fi
# 写入元数据
IMAGE_COUNT=$("$PHP_BIN" -r "
\$n=0; \$ext=['jpg','jpeg','png','webp','gif'];
\$it=new RecursiveIteratorIterator(new RecursiveDirectoryIterator('${UPLOADS}', FilesystemIterator::SKIP_DOTS));
foreach(\$it as \$f){ if(\$f->isFile() && in_array(strtolower(\$f->getExtension()),\$ext)) \$n++; }
echo \$n;
")
cat > "${SNAPSHOT_DIR}/.snapshot_meta.json" <<META
{
"created_at": "$(date -Iseconds 2>/dev/null || date)",
"image_count": ${IMAGE_COUNT},
"total_bytes": ${UPLOADS_SIZE},
"method": "rsync",
"max_width": 800,
"quality": 65
}
META
echo ""
echo "快照完成!"
echo "若压缩后有问题,一键恢复:"
echo " bash deploy/restore-uploads.sh --restore=${SNAPSHOT_ID}"
echo " bash deploy/restore-uploads.sh --latest"
echo ""
echo "========== 开始压缩 =========="
echo ""
mkdir -p "$LOG_DIR"
DEFAULT_LOG="${LOG_DIR}/compress_${SNAPSHOT_ID}.log"
HAS_LOG=0
for arg in "${PASSTHRU[@]}"; do
[[ "$arg" == --log=* ]] && HAS_LOG=1
done
if [[ "$HAS_LOG" -eq 0 ]]; then
PASSTHRU+=("--log=${DEFAULT_LOG}")
fi
# 已有快照,不再让 PHP 重复快照
exec "$PHP_BIN" -d memory_limit=512M "$SCRIPT" "${PASSTHRU[@]}"
fi
exec "$PHP_BIN" -d memory_limit=512M "$SCRIPT" "$@"

208
deploy/restore-uploads.php 普通文件
查看文件

@@ -0,0 +1,208 @@
#!/usr/bin/env php
<?php
/**
* 一键恢复 uploads 图片(从 compress-uploads 生成的快照)
*
* 用法:
* php deploy/restore-uploads.php --list
* php deploy/restore-uploads.php --restore=latest
* php deploy/restore-uploads.php --restore=20260923_235800
* php deploy/restore-uploads.php --restore=latest --dry-run
*/
declare(strict_types=1);
set_time_limit(0);
const BACKUP_DIR_NAME = '.compress_backup';
const SNAPSHOTS_DIR = '.compress_backup/snapshots';
$options = getopt('', ['list', 'restore:', 'dry-run', 'path:', 'help']);
if (isset($options['help']) || (!isset($options['list']) && !isset($options['restore']))) {
echo <<<HELP
一键恢复 uploads 图片
选项:
--list 列出所有可用快照
--restore=ID 恢复快照(ID 或 latest)
--dry-run 仅预览,不写入
--path=DIR uploads 目录
--help 显示帮助
示例:
php deploy/restore-uploads.php --list
php deploy/restore-uploads.php --restore=latest
bash deploy/restore-uploads.sh --latest
HELP;
exit(isset($options['help']) ? 0 : 1);
}
$dryRun = isset($options['dry-run']);
$projectRoot = dirname(__DIR__);
$uploadsDir = isset($options['path'])
? rtrim($options['path'], DIRECTORY_SEPARATOR)
: $projectRoot . DIRECTORY_SEPARATOR . 'public' . DIRECTORY_SEPARATOR . 'uploads';
$snapshotsRoot = $uploadsDir . DIRECTORY_SEPARATOR . SNAPSHOTS_DIR;
if (!is_dir($uploadsDir)) {
fwrite(STDERR, "ERROR: uploads 目录不存在: {$uploadsDir}\n");
exit(1);
}
if (isset($options['list'])) {
listSnapshots($snapshotsRoot);
exit(0);
}
$restoreId = $options['restore'];
$snapshotDir = resolveSnapshotDir($snapshotsRoot, $restoreId);
if ($snapshotDir === null) {
fwrite(STDERR, "ERROR: 找不到快照: {$restoreId}\n");
fwrite(STDERR, "可用快照:\n");
listSnapshots($snapshotsRoot);
exit(1);
}
echo "恢复快照: " . basename($snapshotDir) . "\n";
echo "目标目录: {$uploadsDir}\n";
echo ($dryRun ? "[dry-run] 仅预览\n" : '') . "\n";
$metaFile = $snapshotDir . DIRECTORY_SEPARATOR . '.snapshot_meta.json';
if (file_exists($metaFile)) {
$meta = json_decode(file_get_contents($metaFile), true);
if ($meta) {
echo '快照时间: ' . ($meta['created_at'] ?? '-') . "\n";
echo '图片数量: ' . ($meta['image_count'] ?? '-') . "\n";
echo '快照大小: ' . formatBytes((int) ($meta['total_bytes'] ?? 0)) . "\n\n";
}
}
$restored = 0;
$bytes = 0;
$errors = 0;
$iterator = new RecursiveIteratorIterator(
new RecursiveDirectoryIterator($snapshotDir, FilesystemIterator::SKIP_DOTS)
);
foreach ($iterator as $fileInfo) {
/** @var SplFileInfo $fileInfo */
if (!$fileInfo->isFile()) {
continue;
}
$src = $fileInfo->getPathname();
$relative = ltrim(str_replace($snapshotDir, '', $src), DIRECTORY_SEPARATOR);
if ($relative === '.snapshot_meta.json') {
continue;
}
$dst = $uploadsDir . DIRECTORY_SEPARATOR . $relative;
if ($dryRun) {
echo "[RESTORE] {$relative}\n";
$restored++;
continue;
}
$dstDir = dirname($dst);
if (!is_dir($dstDir) && !mkdir($dstDir, 0755, true) && !is_dir($dstDir)) {
echo "[ERR] 无法创建目录: {$dstDir}\n";
$errors++;
continue;
}
if (!copy($src, $dst)) {
echo "[ERR] 恢复失败: {$relative}\n";
$errors++;
continue;
}
@chmod($dst, 0644);
$restored++;
$bytes += filesize($src) ?: 0;
if ($restored % 200 === 0) {
echo "--- 已恢复 {$restored} 文件 ---\n";
}
}
echo "\n========== 完成 ==========\n";
echo "恢复: {$restored} 个文件";
if (!$dryRun) {
echo ', ' . formatBytes($bytes);
}
echo "\n";
echo "失败: {$errors}\n";
exit($errors > 0 ? 1 : 0);
function listSnapshots(string $snapshotsRoot): void
{
if (!is_dir($snapshotsRoot)) {
echo "暂无快照(目录不存在: {$snapshotsRoot})\n";
echo "请先执行: bash deploy/compress-uploads.sh --safe\n";
return;
}
$dirs = glob($snapshotsRoot . DIRECTORY_SEPARATOR . '*', GLOB_ONLYDIR);
if (!$dirs) {
echo "暂无快照\n";
return;
}
rsort($dirs);
echo "可用快照:\n";
foreach ($dirs as $dir) {
$id = basename($dir);
$metaFile = $dir . DIRECTORY_SEPARATOR . '.snapshot_meta.json';
$extra = '';
if (file_exists($metaFile)) {
$meta = json_decode(file_get_contents($metaFile), true);
if ($meta) {
$extra = ' | ' . ($meta['created_at'] ?? '') . ' | '
. ($meta['image_count'] ?? '?') . ' 张 | '
. formatBytes((int) ($meta['total_bytes'] ?? 0));
}
}
$marker = ($dir === $dirs[0]) ? ' <- latest' : '';
echo " {$id}{$extra}{$marker}\n";
}
}
function resolveSnapshotDir(string $snapshotsRoot, string $id): ?string
{
if ($id === 'latest') {
if (!is_dir($snapshotsRoot)) {
return null;
}
$dirs = glob($snapshotsRoot . DIRECTORY_SEPARATOR . '*', GLOB_ONLYDIR);
if (!$dirs) {
return null;
}
rsort($dirs);
return $dirs[0];
}
$dir = $snapshotsRoot . DIRECTORY_SEPARATOR . $id;
return is_dir($dir) ? $dir : null;
}
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';
}

45
deploy/restore-uploads.sh 普通文件
查看文件

@@ -0,0 +1,45 @@
#!/usr/bin/env bash
# 一键恢复 uploads 图片
#
# 用法:
# bash deploy/restore-uploads.sh --list
# bash deploy/restore-uploads.sh --latest
# bash deploy/restore-uploads.sh --restore=20260923_235800
set -euo pipefail
DEPLOY_PATH="${DEPLOY_PATH:-$(cd "$(dirname "$0")/.." && pwd)}"
PHP_BIN="${PHP_BIN:-$(command -v php || true)}"
SCRIPT="${DEPLOY_PATH}/deploy/restore-uploads.php"
if [[ -z "$PHP_BIN" || ! -x "$PHP_BIN" ]]; then
echo "ERROR: 找不到可执行的 PHP"
exit 1
fi
ARGS=()
for arg in "$@"; do
case "$arg" in
--latest)
ARGS+=("--restore=latest")
;;
--list)
ARGS+=("--list")
;;
*)
ARGS+=("$arg")
;;
esac
done
if [[ ${#ARGS[@]} -eq 0 ]]; then
echo "用法:"
echo " bash deploy/restore-uploads.sh --list"
echo " bash deploy/restore-uploads.sh --latest"
echo " bash deploy/restore-uploads.sh --restore=20260923_235800"
exit 1
fi
echo "项目: ${DEPLOY_PATH}"
echo ""
exec "$PHP_BIN" "$SCRIPT" "${ARGS[@]}"