171 lines
5.2 KiB
PHP
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]),
|
|
]);
|
|
}
|
|
}
|