Files
laralog/app/Console/Commands/AttachmentsSyncS3.php
T

171 lines
5.2 KiB
PHP

<?php
namespace App\Console\Commands;
use App\Support\MediaDisk;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\File;
use Illuminate\Support\Facades\Storage;
use Spatie\MediaLibrary\MediaCollections\Models\Media;
class AttachmentsSyncS3 extends Command
{
protected $signature = 'attachments:sync-s3
{--legacy-dir= : 老 sablog attachments 目录(用于找回 pending 附件)}
{--disk= : 目标磁盘(默认取配置的附件磁盘)}
{--force : 重新上传已有文件(校验失败时)}';
protected $description = '把遗留附件同步到 S3 兼容存储(R2/COS/OSS),大文件走 Multipart,幂等可断点续传';
public function handle(): int
{
$disk = $this->option('disk') ?: MediaDisk::name();
$s3 = Storage::disk($disk);
$this->info("目标磁盘:{$disk}");
$query = Media::query()->where('collection_name', 'attachments');
if (! $this->option('force')) {
$query->where(function ($q) use ($disk) {
$q->whereRaw('JSON_EXTRACT(custom_properties, "$.pending_sync") = true')
->orWhere('disk', '!=', $disk);
});
}
$mediaItems = $query->get();
if ($mediaItems->isEmpty()) {
$this->info('没有需要同步的附件');
return self::SUCCESS;
}
$bar = $this->output->createProgressBar($mediaItems->count());
$bar->start();
$synced = 0;
$skipped = 0;
$failed = 0;
foreach ($mediaItems as $media) {
$result = $this->syncOne($media, $s3, $disk);
match ($result) {
'synced' => $synced++,
'skipped' => $skipped++,
'failed' => $failed++,
};
$bar->advance();
}
$bar->finish();
$this->newLine();
$this->line("已同步:{$synced},跳过:{$skipped},失败:{$failed}");
return $failed === 0 ? self::SUCCESS : self::FAILURE;
}
private function syncOne(Media $media, \Illuminate\Contracts\Filesystem\Filesystem $s3, string $disk): string
{
// 1. 找源文件:优先已存在磁盘上的文件,其次老 attachments 目录
$source = $this->locateSource($media);
if (! $source) {
return 'skipped';
}
$targetPath = $this->targetPath($media, $disk);
try {
if ($s3->exists($targetPath) && ! $this->option('force')) {
$this->markSynced($media, $disk);
return 'synced';
}
$this->upload($s3, $targetPath, $source);
$this->markSynced($media, $disk);
return 'synced';
} catch (\Throwable $e) {
$this->error(" [{$media->id}] {$media->file_name}: {$e->getMessage()}");
return 'failed';
}
}
private function locateSource(Media $media): ?string
{
// 老磁盘(public/local)上的文件
if ($media->disk !== 'uploads') {
$local = Storage::disk($media->disk);
$path = $media->getPath();
if ($local->exists($path)) {
return $local->path($path);
}
}
// 老 sablog attachments 目录
$legacyDir = $this->option('legacy-dir');
$legacyPath = $media->getCustomProperty('legacy_filepath');
if ($legacyDir && $legacyPath) {
$candidate = rtrim($legacyDir, '/').'/'.ltrim($legacyPath, '/');
if (is_file($candidate)) {
return $candidate;
}
}
return null;
}
private function targetPath(Media $media, string $disk): string
{
// medialibrary 默认路径约定:{media_id}/{filename}
return $media->getKey().'/'.$media->file_name;
}
private function upload(\Illuminate\Contracts\Filesystem\Filesystem $s3, string $targetPath, string $source): void
{
$size = filesize($source);
$threshold = (int) config('media.multipart_threshold', 50 * 1024 * 1024);
if ($size > $threshold && method_exists($s3, 'getClient')) {
// 大文件:AWS SDK MultipartUpload
$client = $s3->getClient();
$bucket = $s3->getConfig('bucket');
$uploader = new \Aws\S3\MultipartUploader($client, $source, [
'bucket' => $bucket,
'key' => $targetPath,
'before_initiate' => function (\Aws\Command $command) use ($source) {
$command['ContentType'] = File::mimeType($source) ?: 'application/octet-stream';
},
]);
$uploader->upload();
return;
}
$s3->putFileAs('', $source, $targetPath, [
'visibility' => config('media.visibility', 'public'),
'ContentType' => File::mimeType($source) ?: 'application/octet-stream',
]);
}
private function markSynced(Media $media, string $disk): void
{
$media->update([
'disk' => $disk,
'conversions_disk' => $disk,
'custom_properties' => array_merge($media->custom_properties ?? [], ['pending_sync' => false]),
]);
}
}