M6: S3 附件支持(R2/COS/OSS)+ attachments:sync-s3 命令(Multipart/幂等),MinIO 实测通过
This commit is contained in:
@@ -0,0 +1,170 @@
|
||||
<?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]),
|
||||
]);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user