Cache only approved delivery assets without reconciling existing article mappings

This commit is contained in:
Codex 2026-10-30 16:15:56 +08:00
parent f72192063d
commit be17c0c8ee
4 changed files with 39 additions and 5 deletions

View File

@ -39,9 +39,30 @@ public function handle(): int
return self::SUCCESS; return self::SUCCESS;
} }
if ($this->call('illustrations:sync-local') !== 0) { $snapshots = [];
throw new \RuntimeException('sync_failed'); $images = [];
foreach ($pending as $batch) {
foreach ($batch['assets'] ?? [] as $asset) {
$pool = $asset['pool'] ?? '';
if (! in_array($pool, config('illustrations.categories'), true)) {
throw new \RuntimeException('invalid_pool');
}
if (! isset($snapshots[$pool])) {
$snapshots[$pool] = IllustrationPool::sourceSnapshot($pool);
}
$match = null;
foreach ($snapshots[$pool]['items'] as $image) {
if (($image['asset_id'] ?? '') === ($asset['asset_id'] ?? null)) {
$match = $image;
}
}
if (! $match) {
throw new \RuntimeException('delivery_asset_unavailable');
}
$images[$asset['asset_id']] = $match;
}
} }
app(LocalIllustrations::class)->cacheDeliveryAssets(array_values($images));
$bytes = file_get_contents(config('illustrations.local_manifest')); $bytes = file_get_contents(config('illustrations.local_manifest'));
$manifest = json_decode($bytes, true, 512, JSON_THROW_ON_ERROR); $manifest = json_decode($bytes, true, 512, JSON_THROW_ON_ERROR);
foreach ($pending as $batch) { foreach ($pending as $batch) {

View File

@ -115,6 +115,18 @@ private function copy(array $image, array $assets): array
'url' => config('illustrations.local_url_prefix').'/'.$sha.'.'.$ext, 'source_url' => $source]; 'url' => config('illustrations.local_url_prefix').'/'.$sha.'.'.$ext, 'source_url' => $source];
} }
/** Delivery intake copies only approved batch assets; it does not reconcile existing articles. */
public function cacheDeliveryAssets(array $images): void
{
$this->locked(function () use ($images) {
$state = $this->read();
foreach ($images as $image) {
$state['assets'][$image['asset_id']] = $this->copy($image, $state['assets']);
}
$this->save($state);
});
}
public function storeSelection(string $pool, string $slug, array $image): string public function storeSelection(string $pool, string $slug, array $image): string
{ {
return $this->locked(function () use ($pool, $slug, $image) { return $this->locked(function () use ($pool, $slug, $image) {

View File

@ -2,7 +2,7 @@ # 插画交付接收
协议与画师操作唯一正文:[Resource交付说明](/Users/hellcat/Documents/GemCodes/resource.loc/v2/docs/ILLUSTRATION_DELIVERY.md)。 协议与画师操作唯一正文:[Resource交付说明](/Users/hellcat/Documents/GemCodes/resource.loc/v2/docs/ILLUSTRATION_DELIVERY.md)。
本项目新增 `illustrations:sync-deliveries`,开关 `ILLUSTRATION_DELIVERY_ENABLED=false` 默认关闭。VPS-003正式目录运行fna-illustration-delivery.timer(nginx,每分钟)。复用已有消费者凭据和 `illustrations:sync-local`:先同步并验证所有交付资产已在清单/静态目录中,哈希一致才ACK;失败下轮重试,不重分配既有文章、不删除图片。没有新批次时只查交付队列,不下载图池。页面请求不调用该任务。 本项目新增 `illustrations:sync-deliveries`,开关 `ILLUSTRATION_DELIVERY_ENABLED=false` 默认关闭。VPS-003正式目录运行fna-illustration-delivery.timer(nginx,每分钟)。复用已有消费者凭据和LocalIllustrations下载/原子清单能力:只缓存本批资产,不执行全量旧文章同步;先验证所有交付资产已在清单/静态目录中,哈希一致才ACK;失败下轮重试,不重分配既有文章、不删除图片。没有新批次时只查交付队列,不下载图池。页面请求不调用该任务。
Resource持有唯一批次状态,FNA不复制完整提示词或私有原稿。Resource未完成的同步回执不影响已有静态图片。新增图片只扩充后续文章的候选池。 Resource持有唯一批次状态,FNA不复制完整提示词或私有原稿。Resource未完成的同步回执不影响已有静态图片。新增图片只扩充后续文章的候选池。

View File

@ -30,7 +30,7 @@ protected function setUp(): void
config(['illustrations.enabled' => true, 'illustrations.local_enabled' => true, 'illustrations.delivery_enabled' => true, 'illustrations.base_url' => 'https://resource-v2.gemcook.net', 'illustrations.credential_file' => $this->dir.'/credential.json', 'illustrations.local_directory' => $this->dir.'/public', 'illustrations.local_manifest' => $this->dir.'/manifest.json']); config(['illustrations.enabled' => true, 'illustrations.local_enabled' => true, 'illustrations.delivery_enabled' => true, 'illustrations.base_url' => 'https://resource-v2.gemcook.net', 'illustrations.credential_file' => $this->dir.'/credential.json', 'illustrations.local_directory' => $this->dir.'/public', 'illustrations.local_manifest' => $this->dir.'/manifest.json']);
$this->sha = hash('sha256', 'verified-copy'); $this->sha = hash('sha256', 'verified-copy');
file_put_contents($this->dir.'/public/'.$this->sha.'.webp', 'verified-copy'); file_put_contents($this->dir.'/public/'.$this->sha.'.webp', 'verified-copy');
file_put_contents($this->dir.'/manifest.json', json_encode(['version' => 1, 'assets' => [$this->asset => ['sha256' => $this->sha, 'mime' => 'image/webp']], 'selections' => []])); file_put_contents($this->dir.'/manifest.json', json_encode(['version' => 1, 'assets' => [$this->asset => ['sha256' => $this->sha, 'mime' => 'image/webp', 'bytes' => strlen('verified-copy'), 'url' => '/images/illustrations/'.$this->sha.'.webp']], 'selections' => ['freenode' => ['old-article' => 'old-asset']]]));
Http::preventStrayRequests(); Http::preventStrayRequests();
Artisan::all(); Artisan::all();
\Illuminate\Support\Facades\Schema::create('articles', function ($t) { \Illuminate\Support\Facades\Schema::create('articles', function ($t) {
@ -52,7 +52,7 @@ private function fake(int $sync = 0, int $ack = 200): void
{ {
Http::fake([ Http::fake([
'https://resource-v2.gemcook.net/api/v2/pools/*' => Http::response(['data' => ['items' => [], 'selections' => [], 'unavailable' => [], 'next' => null]], $sync ? 503 : 200), 'https://resource-v2.gemcook.net/api/v2/pools/*' => Http::response(['data' => ['items' => [['asset_id' => $this->asset, 'url' => 'https://resource-v2.gemcook.net/media/'.$this->id]], 'selections' => [], 'unavailable' => [], 'next' => null]], $sync ? 503 : 200),
'https://resource-v2.gemcook.net/api/v2/illustration-deliveries' => Http::response(['data' => [['id' => $this->id, 'state' => 'awaiting_sync', 'fingerprint' => str_repeat('b', 64), 'assets' => [['asset_id' => $this->asset, 'sha256' => $this->sha, 'pool' => 'freenode']]]]]), 'https://resource-v2.gemcook.net/api/v2/illustration-deliveries' => Http::response(['data' => [['id' => $this->id, 'state' => 'awaiting_sync', 'fingerprint' => str_repeat('b', 64), 'assets' => [['asset_id' => $this->asset, 'sha256' => $this->sha, 'pool' => 'freenode']]]]]),
'https://resource-v2.gemcook.net/api/v2/illustration-deliveries/*/ack' => Http::response(['data' => ['state' => 'completed']], $ack), 'https://resource-v2.gemcook.net/api/v2/illustration-deliveries/*/ack' => Http::response(['data' => ['state' => 'completed']], $ack),
]); ]);
@ -66,6 +66,7 @@ public function test_verified_copy_is_acknowledged_and_lost_ack_can_retry(): voi
Http::preventStrayRequests(); Http::preventStrayRequests();
$this->fake(); $this->fake();
$this->artisan('illustrations:sync-deliveries')->assertExitCode(0); $this->artisan('illustrations:sync-deliveries')->assertExitCode(0);
$this->assertSame('old-asset', json_decode(file_get_contents($this->dir.'/manifest.json'), true)['selections']['freenode']['old-article']);
Http::assertSent(fn ($r) => str_ends_with($r->url(), '/ack') && $r['fingerprint'] === str_repeat('b', 64) && $r['manifest_sha256'] === hash_file('sha256', $this->dir.'/manifest.json')); Http::assertSent(fn ($r) => str_ends_with($r->url(), '/ack') && $r['fingerprint'] === str_repeat('b', 64) && $r['manifest_sha256'] === hash_file('sha256', $this->dir.'/manifest.json'));
} }