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]),
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -12,6 +12,7 @@
|
||||
"laravel/framework": "^12.0",
|
||||
"laravel/tinker": "^2.10.1",
|
||||
"league/commonmark": "^2.9",
|
||||
"league/flysystem-aws-s3-v3": "^3.35",
|
||||
"league/html-to-markdown": "^5.1",
|
||||
"spatie/laravel-backup": "^9.0",
|
||||
"spatie/laravel-medialibrary": "^11.0",
|
||||
|
||||
Generated
+343
-1
@@ -4,7 +4,7 @@
|
||||
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
|
||||
"This file is @generated automatically"
|
||||
],
|
||||
"content-hash": "b3635d8ae6b629801f16a9df99622e56",
|
||||
"content-hash": "3b5974911686ef4cf9590ee3210822a5",
|
||||
"packages": [
|
||||
{
|
||||
"name": "anourvalar/eloquent-serialize",
|
||||
@@ -71,6 +71,157 @@
|
||||
},
|
||||
"time": "2026-08-07T06:14:19+00:00"
|
||||
},
|
||||
{
|
||||
"name": "aws/aws-crt-php",
|
||||
"version": "v1.2.7",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/awslabs/aws-crt-php.git",
|
||||
"reference": "d71d9906c7bb63a28295447ba12e74723bd3730e"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/awslabs/aws-crt-php/zipball/d71d9906c7bb63a28295447ba12e74723bd3730e",
|
||||
"reference": "d71d9906c7bb63a28295447ba12e74723bd3730e",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
"php": ">=5.5"
|
||||
},
|
||||
"require-dev": {
|
||||
"phpunit/phpunit": "^4.8.35||^5.6.3||^9.5",
|
||||
"yoast/phpunit-polyfills": "^1.0"
|
||||
},
|
||||
"suggest": {
|
||||
"ext-awscrt": "Make sure you install awscrt native extension to use any of the functionality."
|
||||
},
|
||||
"type": "library",
|
||||
"autoload": {
|
||||
"classmap": [
|
||||
"src/"
|
||||
]
|
||||
},
|
||||
"notification-url": "https://packagist.org/downloads/",
|
||||
"license": [
|
||||
"Apache-2.0"
|
||||
],
|
||||
"authors": [
|
||||
{
|
||||
"name": "AWS SDK Common Runtime Team",
|
||||
"email": "aws-sdk-common-runtime@amazon.com"
|
||||
}
|
||||
],
|
||||
"description": "AWS Common Runtime for PHP",
|
||||
"homepage": "https://github.com/awslabs/aws-crt-php",
|
||||
"keywords": [
|
||||
"amazon",
|
||||
"aws",
|
||||
"crt",
|
||||
"sdk"
|
||||
],
|
||||
"support": {
|
||||
"issues": "https://github.com/awslabs/aws-crt-php/issues",
|
||||
"source": "https://github.com/awslabs/aws-crt-php/tree/v1.2.7"
|
||||
},
|
||||
"time": "2024-10-18T22:15:13+00:00"
|
||||
},
|
||||
{
|
||||
"name": "aws/aws-sdk-php",
|
||||
"version": "3.391.2",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/aws/aws-sdk-php.git",
|
||||
"reference": "50e1ed57a0b75921efcbcabe2f8702e7508c501c"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/aws/aws-sdk-php/zipball/50e1ed57a0b75921efcbcabe2f8702e7508c501c",
|
||||
"reference": "50e1ed57a0b75921efcbcabe2f8702e7508c501c",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
"aws/aws-crt-php": "^1.2.3",
|
||||
"ext-json": "*",
|
||||
"ext-pcre": "*",
|
||||
"ext-simplexml": "*",
|
||||
"guzzlehttp/guzzle": "^7.8.2 || ^8.0",
|
||||
"guzzlehttp/promises": "^2.0.3 || ^3.0",
|
||||
"guzzlehttp/psr7": "^2.6.3 || ^3.0",
|
||||
"mtdowling/jmespath.php": "^2.9.1",
|
||||
"php": ">=8.1",
|
||||
"psr/http-message": "^1.0 || ^2.0",
|
||||
"symfony/filesystem": "^v5.4.45 || ^v6.4.3 || ^v7.1.0 || ^v8.0.0"
|
||||
},
|
||||
"require-dev": {
|
||||
"andrewsville/php-token-reflection": "^1.4",
|
||||
"aws/aws-php-sns-message-validator": "~1.0",
|
||||
"behat/behat": "~3.0",
|
||||
"composer/composer": "^2.7.8",
|
||||
"dms/phpunit-arraysubset-asserts": "^v0.5.0",
|
||||
"doctrine/cache": "~1.4",
|
||||
"ext-dom": "*",
|
||||
"ext-openssl": "*",
|
||||
"ext-sockets": "*",
|
||||
"phpunit/phpunit": "^10.0",
|
||||
"psr/cache": "^2.0 || ^3.0",
|
||||
"psr/simple-cache": "^2.0 || ^3.0",
|
||||
"sebastian/comparator": "^1.2.3 || ^4.0 || ^5.0",
|
||||
"yoast/phpunit-polyfills": "^2.0"
|
||||
},
|
||||
"suggest": {
|
||||
"aws/aws-php-sns-message-validator": "To validate incoming SNS notifications",
|
||||
"doctrine/cache": "To use the DoctrineCacheAdapter",
|
||||
"ext-curl": "To send requests using cURL",
|
||||
"ext-openssl": "Allows working with CloudFront private distributions and verifying received SNS messages",
|
||||
"ext-pcntl": "To use client-side monitoring",
|
||||
"ext-sockets": "To use client-side monitoring"
|
||||
},
|
||||
"type": "library",
|
||||
"extra": {
|
||||
"branch-alias": {
|
||||
"dev-master": "3.0-dev"
|
||||
}
|
||||
},
|
||||
"autoload": {
|
||||
"files": [
|
||||
"src/functions.php"
|
||||
],
|
||||
"psr-4": {
|
||||
"Aws\\": "src/"
|
||||
},
|
||||
"exclude-from-classmap": [
|
||||
"src/data/"
|
||||
]
|
||||
},
|
||||
"notification-url": "https://packagist.org/downloads/",
|
||||
"license": [
|
||||
"Apache-2.0"
|
||||
],
|
||||
"authors": [
|
||||
{
|
||||
"name": "Amazon Web Services",
|
||||
"homepage": "https://aws.amazon.com"
|
||||
}
|
||||
],
|
||||
"description": "AWS SDK for PHP - Use Amazon Web Services in your PHP project",
|
||||
"homepage": "https://aws.amazon.com/sdk-for-php",
|
||||
"keywords": [
|
||||
"amazon",
|
||||
"aws",
|
||||
"cloud",
|
||||
"dynamodb",
|
||||
"ec2",
|
||||
"glacier",
|
||||
"s3",
|
||||
"sdk"
|
||||
],
|
||||
"support": {
|
||||
"forum": "https://github.com/aws/aws-sdk-php/discussions",
|
||||
"issues": "https://github.com/aws/aws-sdk-php/issues",
|
||||
"source": "https://github.com/aws/aws-sdk-php/tree/3.391.2"
|
||||
},
|
||||
"time": "2026-08-10T18:12:09+00:00"
|
||||
},
|
||||
{
|
||||
"name": "blade-ui-kit/blade-heroicons",
|
||||
"version": "2.7.0",
|
||||
@@ -2975,6 +3126,61 @@
|
||||
},
|
||||
"time": "2026-07-06T14:42:07+00:00"
|
||||
},
|
||||
{
|
||||
"name": "league/flysystem-aws-s3-v3",
|
||||
"version": "3.35.2",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/thephpleague/flysystem-aws-s3-v3.git",
|
||||
"reference": "8475ef9adfc6498b85469e2abec6fe3118cd08c4"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/thephpleague/flysystem-aws-s3-v3/zipball/8475ef9adfc6498b85469e2abec6fe3118cd08c4",
|
||||
"reference": "8475ef9adfc6498b85469e2abec6fe3118cd08c4",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
"aws/aws-sdk-php": "^3.371.5",
|
||||
"league/flysystem": "^3.10.0",
|
||||
"league/mime-type-detection": "^1.0.0",
|
||||
"php": "^8.0.2"
|
||||
},
|
||||
"conflict": {
|
||||
"guzzlehttp/guzzle": "<7.0",
|
||||
"guzzlehttp/ringphp": "<1.1.1"
|
||||
},
|
||||
"type": "library",
|
||||
"autoload": {
|
||||
"psr-4": {
|
||||
"League\\Flysystem\\AwsS3V3\\": ""
|
||||
}
|
||||
},
|
||||
"notification-url": "https://packagist.org/downloads/",
|
||||
"license": [
|
||||
"MIT"
|
||||
],
|
||||
"authors": [
|
||||
{
|
||||
"name": "Frank de Jonge",
|
||||
"email": "info@frankdejonge.nl"
|
||||
}
|
||||
],
|
||||
"description": "AWS S3 filesystem adapter for Flysystem.",
|
||||
"keywords": [
|
||||
"Flysystem",
|
||||
"aws",
|
||||
"file",
|
||||
"files",
|
||||
"filesystem",
|
||||
"s3",
|
||||
"storage"
|
||||
],
|
||||
"support": {
|
||||
"source": "https://github.com/thephpleague/flysystem-aws-s3-v3/tree/3.35.2"
|
||||
},
|
||||
"time": "2026-07-01T23:25:49+00:00"
|
||||
},
|
||||
{
|
||||
"name": "league/flysystem-local",
|
||||
"version": "3.31.0",
|
||||
@@ -3759,6 +3965,72 @@
|
||||
],
|
||||
"time": "2026-01-02T08:56:05+00:00"
|
||||
},
|
||||
{
|
||||
"name": "mtdowling/jmespath.php",
|
||||
"version": "2.9.2",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/jmespath/jmespath.php.git",
|
||||
"reference": "2157c5e50e813ec6a96c1eed3be7f64a20fb32a8"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/jmespath/jmespath.php/zipball/2157c5e50e813ec6a96c1eed3be7f64a20fb32a8",
|
||||
"reference": "2157c5e50e813ec6a96c1eed3be7f64a20fb32a8",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
"php": "^7.2.5 || ^8.0",
|
||||
"symfony/polyfill-mbstring": "^1.17"
|
||||
},
|
||||
"require-dev": {
|
||||
"composer/xdebug-handler": "^3.0.3",
|
||||
"phpunit/phpunit": "^8.5.52"
|
||||
},
|
||||
"bin": [
|
||||
"bin/jp.php"
|
||||
],
|
||||
"type": "library",
|
||||
"extra": {
|
||||
"branch-alias": {
|
||||
"dev-master": "2.9-dev"
|
||||
}
|
||||
},
|
||||
"autoload": {
|
||||
"files": [
|
||||
"src/JmesPath.php"
|
||||
],
|
||||
"psr-4": {
|
||||
"JmesPath\\": "src/"
|
||||
}
|
||||
},
|
||||
"notification-url": "https://packagist.org/downloads/",
|
||||
"license": [
|
||||
"MIT"
|
||||
],
|
||||
"authors": [
|
||||
{
|
||||
"name": "Graham Campbell",
|
||||
"email": "hello@gjcampbell.co.uk",
|
||||
"homepage": "https://github.com/GrahamCampbell"
|
||||
},
|
||||
{
|
||||
"name": "Michael Dowling",
|
||||
"email": "mtdowling@gmail.com",
|
||||
"homepage": "https://github.com/mtdowling"
|
||||
}
|
||||
],
|
||||
"description": "Declaratively specify how to extract elements from a JSON document",
|
||||
"keywords": [
|
||||
"json",
|
||||
"jsonpath"
|
||||
],
|
||||
"support": {
|
||||
"issues": "https://github.com/jmespath/jmespath.php/issues",
|
||||
"source": "https://github.com/jmespath/jmespath.php/tree/2.9.2"
|
||||
},
|
||||
"time": "2026-07-06T18:56:19+00:00"
|
||||
},
|
||||
{
|
||||
"name": "nesbot/carbon",
|
||||
"version": "3.13.2",
|
||||
@@ -7495,6 +7767,76 @@
|
||||
],
|
||||
"time": "2026-06-05T06:23:12+00:00"
|
||||
},
|
||||
{
|
||||
"name": "symfony/filesystem",
|
||||
"version": "v7.4.15",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/symfony/filesystem.git",
|
||||
"reference": "ff16a16bf87fdf264638b8f6995b3515975e3c79"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/symfony/filesystem/zipball/ff16a16bf87fdf264638b8f6995b3515975e3c79",
|
||||
"reference": "ff16a16bf87fdf264638b8f6995b3515975e3c79",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
"php": ">=8.2",
|
||||
"symfony/polyfill-ctype": "~1.8",
|
||||
"symfony/polyfill-mbstring": "~1.8"
|
||||
},
|
||||
"require-dev": {
|
||||
"symfony/process": "^6.4|^7.0|^8.0"
|
||||
},
|
||||
"type": "library",
|
||||
"autoload": {
|
||||
"psr-4": {
|
||||
"Symfony\\Component\\Filesystem\\": ""
|
||||
},
|
||||
"exclude-from-classmap": [
|
||||
"/Tests/"
|
||||
]
|
||||
},
|
||||
"notification-url": "https://packagist.org/downloads/",
|
||||
"license": [
|
||||
"MIT"
|
||||
],
|
||||
"authors": [
|
||||
{
|
||||
"name": "Fabien Potencier",
|
||||
"email": "fabien@symfony.com"
|
||||
},
|
||||
{
|
||||
"name": "Symfony Community",
|
||||
"homepage": "https://symfony.com/contributors"
|
||||
}
|
||||
],
|
||||
"description": "Provides basic utilities for the filesystem",
|
||||
"homepage": "https://symfony.com",
|
||||
"support": {
|
||||
"source": "https://github.com/symfony/filesystem/tree/v7.4.15"
|
||||
},
|
||||
"funding": [
|
||||
{
|
||||
"url": "https://symfony.com/sponsor",
|
||||
"type": "custom"
|
||||
},
|
||||
{
|
||||
"url": "https://github.com/fabpot",
|
||||
"type": "github"
|
||||
},
|
||||
{
|
||||
"url": "https://github.com/nicolas-grekas",
|
||||
"type": "github"
|
||||
},
|
||||
{
|
||||
"url": "https://tidelift.com/funding/github/packagist/symfony/symfony",
|
||||
"type": "tidelift"
|
||||
}
|
||||
],
|
||||
"time": "2026-07-22T07:36:05+00:00"
|
||||
},
|
||||
{
|
||||
"name": "symfony/finder",
|
||||
"version": "v7.4.14",
|
||||
|
||||
@@ -72,7 +72,7 @@ return [
|
||||
'secret' => env('S3_SECRET_KEY'),
|
||||
'region' => env('S3_REGION', 'auto'),
|
||||
'bucket' => env('S3_BUCKET'),
|
||||
'url' => env('S3_URL'),
|
||||
'url' => env('S3_URL') ?: (env('S3_ENDPOINT') ? rtrim(env('S3_ENDPOINT'), '/').'/'.env('S3_BUCKET') : null),
|
||||
'endpoint' => env('S3_ENDPOINT'),
|
||||
'use_path_style_endpoint' => env('S3_USE_PATH_STYLE_ENDPOINT', false),
|
||||
'visibility' => env('S3_VISIBILITY', 'public'),
|
||||
|
||||
Reference in New Issue
Block a user