From 0e26119bcf782817d7144e2c53acfe9f8665a328 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=8F=AF=E4=B9=90=E5=8A=A0=E5=86=B0?= <11869599+addie66@user.noreply.gitee.com> Date: Thu, 24 Sep 2026 00:01:40 +0800 Subject: [PATCH] =?UTF-8?q?feat(deploy):=20=E6=B7=BB=E5=8A=A0=20uploads=20?= =?UTF-8?q?=E5=9B=BE=E7=89=87=E6=89=B9=E9=87=8F=E5=8E=8B=E7=BC=A9=E4=B8=8E?= =?UTF-8?q?=E4=B8=80=E9=94=AE=E6=81=A2=E5=A4=8D=E8=84=9A=E6=9C=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 支持 2G+ 大批量安全压缩(65% 质量、最大宽 800px),含全量快照、校验与恢复能力。 Co-authored-by: Cursor --- deploy/compress-uploads.php | 550 ++++++++++++++++++++++++++++++++++++ deploy/compress-uploads.sh | 123 ++++++++ deploy/restore-uploads.php | 208 ++++++++++++++ deploy/restore-uploads.sh | 45 +++ 4 files changed, 926 insertions(+) create mode 100644 deploy/compress-uploads.php create mode 100644 deploy/compress-uploads.sh create mode 100644 deploy/restore-uploads.php create mode 100644 deploy/restore-uploads.sh diff --git a/deploy/compress-uploads.php b/deploy/compress-uploads.php new file mode 100644 index 0000000..2478abe --- /dev/null +++ b/deploy/compress-uploads.php @@ -0,0 +1,550 @@ +#!/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); + } +} diff --git a/deploy/compress-uploads.sh b/deploy/compress-uploads.sh new file mode 100644 index 0000000..39746a6 --- /dev/null +++ b/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" </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" "$@" diff --git a/deploy/restore-uploads.php b/deploy/restore-uploads.php new file mode 100644 index 0000000..875a4cf --- /dev/null +++ b/deploy/restore-uploads.php @@ -0,0 +1,208 @@ +#!/usr/bin/env php +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'; +} diff --git a/deploy/restore-uploads.sh b/deploy/restore-uploads.sh new file mode 100644 index 0000000..2c36f1b --- /dev/null +++ b/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[@]}"