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

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

209 行
5.6 KiB
PHP

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

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

#!/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';
}