支持 2G+ 大批量安全压缩(65% 质量、最大宽 800px),含全量快照、校验与恢复能力。 Co-authored-by: Cursor <cursoragent@cursor.com>
209 行
5.6 KiB
PHP
209 行
5.6 KiB
PHP
#!/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';
|
||
}
|