From 9d2cd9a89526992636b5365b4a0ac4396d43e457 Mon Sep 17 00:00:00 2001 From: Codex Date: Fri, 30 Oct 2026 12:03:17 +0800 Subject: [PATCH] Preserve concurrent article mappings during static sync --- .../Commands/SyncLocalIllustrations.php | 3 ++- app/Services/LocalIllustrations.php | 23 +++++++++++++++++-- tests/Feature/LocalIllustrationsTest.php | 14 +++++++++++ 3 files changed, 37 insertions(+), 3 deletions(-) diff --git a/app/Console/Commands/SyncLocalIllustrations.php b/app/Console/Commands/SyncLocalIllustrations.php index 96acb5e4..01c15931 100644 --- a/app/Console/Commands/SyncLocalIllustrations.php +++ b/app/Console/Commands/SyncLocalIllustrations.php @@ -20,6 +20,7 @@ public function handle(LocalIllustrations $local): int return self::FAILURE; } try { + $baseline = $local->selectionBaseline(); $snapshots = []; $expected = []; foreach (config('illustrations.categories') as $category => $pool) { @@ -33,7 +34,7 @@ public function handle(LocalIllustrations $local): int $snapshots[$pool] = $snapshot; $expected[$pool] = $keys; } - $result = $local->synchronize($snapshots); + $result = $local->synchronize($snapshots, $baseline); foreach ($expected as $pool => $keys) { foreach ($keys as $key) { if (! $local->thumbnail($pool, $key)) $result['pending'][$pool.':'.$key] ??= 'local_copy_unavailable'; diff --git a/app/Services/LocalIllustrations.php b/app/Services/LocalIllustrations.php index 91af9652..22bcee6e 100644 --- a/app/Services/LocalIllustrations.php +++ b/app/Services/LocalIllustrations.php @@ -128,9 +128,16 @@ public function storeSelection(string $pool, string $slug, array $image): string } /** One atomic manifest switch after all downloadable files pass verification. */ - public function synchronize(array $snapshots): array + public function selectionBaseline(): array { - return $this->locked(function () use ($snapshots) { + $this->manifest = null; + return $this->read()['selections']; + } + + public function synchronize(array $snapshots, ?array $baseline = null): array + { + $baseline ??= $this->selectionBaseline(); + return $this->locked(function () use ($snapshots, $baseline) { $state = $this->read(); $pending = []; foreach ($snapshots as $pool => $snapshot) { @@ -155,6 +162,18 @@ public function synchronize(array $snapshots): array $pending[$pool.':'.$slug] = $reason; } } + // Receivers may publish new mappings while the remote snapshot is in flight. + // Never replace a local change made after this sync's baseline was captured. + $current = $state['selections'][$pool] ?? []; + $before = $baseline[$pool] ?? []; + foreach (array_unique(array_merge(array_keys($before), array_keys($current))) as $slug) { + $exists = array_key_exists($slug, $current); + if ($exists !== array_key_exists($slug, $before) || ($current[$slug] ?? null) !== ($before[$slug] ?? null)) { + if ($exists) $selections[$slug] = $current[$slug]; + else unset($selections[$slug]); + unset($pending[$pool.':'.$slug]); + } + } $state['selections'][$pool] = $selections; } $this->save($state); diff --git a/tests/Feature/LocalIllustrationsTest.php b/tests/Feature/LocalIllustrationsTest.php index fef8d132..48aa5d8a 100644 --- a/tests/Feature/LocalIllustrationsTest.php +++ b/tests/Feature/LocalIllustrationsTest.php @@ -125,4 +125,18 @@ public function test_missing_manifest_never_fetches_resource_or_returns_remote_f $this->assertSame('/tutorial.png', IllustrationPool::thumbnail(['iCategoryId' => 3, 'sSlug' => 'guide'], '/tutorial.png')); Http::assertNothingSent(); } + + public function test_sync_does_not_overwrite_concurrent_article_assignments(): void + { + $this->fakeImages();$local = app(LocalIllustrations::class);$old = $this->image(); + $local->storeSelection('freenode', 'fn1', $old); + $baseline = $local->selectionBaseline(); + $new = $this->image('33333333-3333-4333-8333-333333333333'); + $local->storeSelection('freenode', 'fn1', $new); + $local->storeSelection('freenode', 'fn2', $new); + $local->synchronize(['freenode' => ['items' => [$old], 'selections' => ['fn1' => $old], 'unavailable' => []]], $baseline); + $state = json_decode(file_get_contents($this->dir.'/private/manifest.json'), true); + $this->assertSame($new['asset_id'], $state['selections']['freenode']['fn1']); + $this->assertSame($new['asset_id'], $state['selections']['freenode']['fn2']); + } }