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]), ]); } }