feat(deploy): 添加 uploads 图片批量压缩与一键恢复脚本
支持 2G+ 大批量安全压缩(65% 质量、最大宽 800px),含全量快照、校验与恢复能力。 Co-authored-by: Cursor <cursoragent@cursor.com>
这个提交包含在:
208
deploy/restore-uploads.php
普通文件
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';
|
||||
}
|
||||
在新工单中引用
屏蔽一个用户