CaiWuXiTong/app/Console/Commands/SyncBusinessFilesToQiniu.php
WuYongJie da9c3cbf88 fix: 容忍七牛对象删除传播延迟 (#42)
对象删除后进行有限重试确认,持续存在仍失败回滚,并覆盖最终一致性边界测试。
2026-08-13 12:35:06 +08:00

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