193 lines
6.2 KiB
PHP
193 lines
6.2 KiB
PHP
<?php
|
|
|
|
namespace App\Console\Commands;
|
|
|
|
use App\Utilities\QiniuPrivateUrlSigner;
|
|
use Illuminate\Console\Command;
|
|
use Illuminate\Contracts\Filesystem\Filesystem;
|
|
use Illuminate\Support\Facades\File;
|
|
use Illuminate\Support\Facades\Http;
|
|
use Illuminate\Support\Facades\Storage;
|
|
use RuntimeException;
|
|
use Throwable;
|
|
|
|
class SyncBusinessFilesToQiniu extends Command
|
|
{
|
|
private const PROBE_CLEANUP_MAX_ATTEMPTS = 10;
|
|
|
|
private const PROBE_CLEANUP_RETRY_DELAY_MICROSECONDS = 500_000;
|
|
|
|
protected $signature = 'storage:business-files:sync
|
|
{--require-qiniu : Fail unless the private Qiniu backend is enabled}
|
|
{--probe-only : Verify private read/write/delete without copying retained files}
|
|
{--resync : Recheck every retained local business file}';
|
|
|
|
protected $description = 'Verify private business storage and copy retained local uploads without deleting them';
|
|
|
|
private string $markerPath;
|
|
|
|
public function __construct()
|
|
{
|
|
parent::__construct();
|
|
|
|
$this->markerPath = storage_path('app/.business-storage-qiniu-synced');
|
|
}
|
|
|
|
public function handle(): int
|
|
{
|
|
if (config('business_storage.driver') !== 'qiniu') {
|
|
if ($this->option('require-qiniu')) {
|
|
$this->components->error('生产业务文件存储尚未启用。');
|
|
|
|
return self::FAILURE;
|
|
}
|
|
|
|
$this->components->info('当前使用本机业务文件存储,无需同步。');
|
|
|
|
return self::SUCCESS;
|
|
}
|
|
|
|
if (! $this->hasCompleteCredentials()) {
|
|
$this->components->error('生产业务文件存储凭据不完整。');
|
|
|
|
return self::FAILURE;
|
|
}
|
|
|
|
try {
|
|
$this->probeQiniu();
|
|
|
|
if ($this->option('probe-only')) {
|
|
$this->components->info('私有业务文件存储读写验证通过。');
|
|
|
|
return self::SUCCESS;
|
|
}
|
|
|
|
$copied = 0;
|
|
$skipped = 0;
|
|
|
|
if ($this->option('resync') || ! File::exists($this->markerPath)) {
|
|
[$copied, $skipped] = $this->syncRetainedLocalFiles();
|
|
|
|
File::put(
|
|
$this->markerPath,
|
|
json_encode([
|
|
'completed_at' => now()->toIso8601String(),
|
|
'copied' => $copied,
|
|
'skipped' => $skipped,
|
|
], JSON_UNESCAPED_SLASHES) . PHP_EOL
|
|
);
|
|
}
|
|
|
|
$this->components->info(
|
|
"私有业务文件存储可用;复制 {$copied} 个,复用 {$skipped} 个,本机原件保留。"
|
|
);
|
|
|
|
return self::SUCCESS;
|
|
} catch (Throwable $e) {
|
|
report($e);
|
|
$this->components->error('私有业务文件存储验证或旧文件同步失败。');
|
|
|
|
return self::FAILURE;
|
|
}
|
|
}
|
|
|
|
private function hasCompleteCredentials(): bool
|
|
{
|
|
return (string) config('business_storage.qiniu.access_key') !== ''
|
|
&& (string) config('business_storage.qiniu.secret_key') !== '';
|
|
}
|
|
|
|
private function probeQiniu(): void
|
|
{
|
|
$disk = Storage::disk('business_uploads_qiniu');
|
|
$path = '.health/storage-' . bin2hex(random_bytes(12)) . '.txt';
|
|
$contents = bin2hex(random_bytes(24));
|
|
|
|
try {
|
|
if (! $disk->put($path, $contents, ['visibility' => 'private'])) {
|
|
throw new RuntimeException('Business storage probe write failed.');
|
|
}
|
|
|
|
if ($disk->get($path) !== $contents) {
|
|
throw new RuntimeException('Business storage probe read failed.');
|
|
}
|
|
|
|
$signer = app(QiniuPrivateUrlSigner::class);
|
|
$unsignedResponse = Http::timeout(20)->head(
|
|
$signer->unsignedUrlForPath($path)
|
|
);
|
|
if (! in_array($unsignedResponse->status(), [401, 403], true)) {
|
|
throw new RuntimeException('Business storage is not private.');
|
|
}
|
|
|
|
$signedUrl = $signer->forPath($path);
|
|
$response = Http::timeout(20)->head($signedUrl);
|
|
if (! $response->successful()) {
|
|
throw new RuntimeException('Business storage signed read failed.');
|
|
}
|
|
} finally {
|
|
$disk->delete($path);
|
|
}
|
|
|
|
$this->confirmProbeCleanup($disk, $path);
|
|
}
|
|
|
|
private function confirmProbeCleanup(Filesystem $disk, string $path, ?callable $sleep = null): void
|
|
{
|
|
$sleep ??= static function (int $microseconds): void {
|
|
usleep($microseconds);
|
|
};
|
|
|
|
for ($attempt = 0; $attempt < self::PROBE_CLEANUP_MAX_ATTEMPTS; $attempt++) {
|
|
if (! $disk->exists($path)) {
|
|
return;
|
|
}
|
|
|
|
if ($attempt < self::PROBE_CLEANUP_MAX_ATTEMPTS - 1) {
|
|
$sleep(self::PROBE_CLEANUP_RETRY_DELAY_MICROSECONDS);
|
|
}
|
|
}
|
|
|
|
throw new RuntimeException('Business storage probe cleanup failed.');
|
|
}
|
|
|
|
private function syncRetainedLocalFiles(): array
|
|
{
|
|
$source = Storage::disk('business_uploads_local');
|
|
$target = Storage::disk('business_uploads_qiniu');
|
|
$copied = 0;
|
|
$skipped = 0;
|
|
|
|
foreach ($source->allFiles() as $path) {
|
|
$sourceSize = $source->size($path);
|
|
|
|
if ($target->exists($path) && $target->size($path) === $sourceSize) {
|
|
$skipped++;
|
|
|
|
continue;
|
|
}
|
|
|
|
$stream = $source->readStream($path);
|
|
if (! is_resource($stream)) {
|
|
throw new RuntimeException('Unable to read retained local business file.');
|
|
}
|
|
|
|
try {
|
|
if (! $target->put($path, $stream, ['visibility' => 'private'])) {
|
|
throw new RuntimeException('Unable to copy retained local business file.');
|
|
}
|
|
} finally {
|
|
fclose($stream);
|
|
}
|
|
|
|
if (! $target->exists($path) || $target->size($path) !== $sourceSize) {
|
|
throw new RuntimeException('Copied business file verification failed.');
|
|
}
|
|
|
|
$copied++;
|
|
}
|
|
|
|
return [$copied, $skipped];
|
|
}
|
|
}
|