diff --git a/.env.example b/.env.example index c0660ea1..8d1aa533 100644 --- a/.env.example +++ b/.env.example @@ -63,3 +63,6 @@ AWS_BUCKET= AWS_USE_PATH_STYLE_ENDPOINT=false VITE_APP_NAME="${APP_NAME}" + +# Approved illustration delivery (independent per environment) +ILLUSTRATION_DELIVERY_ENABLED=false diff --git a/AGENTS.md b/AGENTS.md index cdc4b10b..580db8d0 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -7,3 +7,5 @@ ## FNA 静态配图副本 ## AI 文章写作(必读) 文章写作和发布先读docs/content/WRITING.md及docs/maintenance/EDITORIAL_API.md。项目身份和访问边界先读GemDocs AI版项目总览、维护协议及10-项目/fna_v3.md。 + +插画完成后按[自动交付](docs/maintenance/ILLUSTRATION_DELIVERY.md)提交批准批次;不要再手工上传发布/编辑图池或把保存草稿当作上线完成。 diff --git a/app/Console/Commands/SyncIllustrationDeliveries.php b/app/Console/Commands/SyncIllustrationDeliveries.php new file mode 100644 index 00000000..2b495dc5 --- /dev/null +++ b/app/Console/Commands/SyncIllustrationDeliveries.php @@ -0,0 +1,79 @@ +error('Illustration delivery is disabled'); + + return self::FAILURE; + } + $dir = dirname(config('illustrations.local_manifest')); + if (! is_dir($dir)) { + mkdir($dir, 0700, true); + } + $lock = fopen($dir.'/delivery.lock', 'c'); + if (! $lock) { + return self::FAILURE; + } + if (! flock($lock, LOCK_EX | LOCK_NB)) { + fclose($lock); + + return self::SUCCESS; + } + try { + $pending = IllustrationPool::pendingDeliveries(); + if (! $pending) { + $this->line('No pending illustration deliveries'); + + return self::SUCCESS; + } + if ($this->call('illustrations:sync-local') !== 0) { + throw new \RuntimeException('sync_failed'); + } + $bytes = file_get_contents(config('illustrations.local_manifest')); + $manifest = json_decode($bytes, true, 512, JSON_THROW_ON_ERROR); + foreach ($pending as $batch) { + if (($batch['state'] ?? '') !== 'awaiting_sync' || empty($batch['assets'])) { + throw new \RuntimeException('invalid_delivery'); + } + foreach ($batch['assets'] as $asset) { + $local = $manifest['assets'][$asset['asset_id']] ?? null; + $sha = $asset['sha256'] ?? ''; + if (! preg_match('/\A[a-f0-9]{64}\z/D', $sha) || ! $local || ($local['sha256'] ?? '') !== $sha || ($local['mime'] ?? '') !== 'image/webp') { + throw new \RuntimeException('missing_copy'); + } + $path = rtrim(config('illustrations.local_directory'), '/').'/'.$sha.'.webp'; + if (! is_file($path) || is_link($path) || hash_file('sha256', $path) !== $sha) { + throw new \RuntimeException('invalid_copy'); + } + } + $ack = IllustrationPool::acknowledgeDelivery($batch['id'], ['fingerprint' => $batch['fingerprint'], 'manifest_sha256' => hash('sha256', $bytes)]); + if (($ack['state'] ?? '') !== 'completed') { + throw new \RuntimeException('ack_failed'); + } + $this->line('Completed illustration delivery '.$batch['id']); + } + + return self::SUCCESS; + } catch (\Throwable) { + $this->error('Illustration delivery sync incomplete; will retry without a completion ACK.'); + + return self::FAILURE; + } finally { + flock($lock, LOCK_UN); + fclose($lock); + } + } +} diff --git a/app/Services/IllustrationPool.php b/app/Services/IllustrationPool.php index a13b5763..d50fc3f6 100644 --- a/app/Services/IllustrationPool.php +++ b/app/Services/IllustrationPool.php @@ -41,6 +41,17 @@ private static function request(string $method, string $path, array $body = []): } } + public static function pendingDeliveries(): array + { + return self::request('GET', 'illustration-deliveries'); + } + + public static function acknowledgeDelivery(string $id, array $receipt): array + { + if (! preg_match('/\A[a-f0-9-]{36}\z/D', $id)) throw new \RuntimeException('Invalid delivery'); + return self::request('POST', 'illustration-deliveries/'.$id.'/ack', $receipt); + } + public static function snapshot(string $pool): array { return Cache::store('file')->remember('illustrations.v2.'.$pool, 60, function () use ($pool) { diff --git a/config/illustrations.php b/config/illustrations.php index 6f97a2bf..69a9fb80 100644 --- a/config/illustrations.php +++ b/config/illustrations.php @@ -1,6 +1,7 @@ (bool) env('ILLUSTRATION_DELIVERY_ENABLED', false), 'enabled' => (bool) env('RESOURCE_V2_ILLUSTRATIONS_ENABLED', env('LOCAL_ILLUSTRATIONS_ENABLED', env('APP_ENV') === 'local')), 'base_url' => env('RESOURCE_V2_URL', 'http://resource.loc:8612'), 'credential_file' => env('RESOURCE_V2_CREDENTIAL_FILE'), diff --git a/deploy/fna-illustration-delivery.service b/deploy/fna-illustration-delivery.service new file mode 100644 index 00000000..80f505c3 --- /dev/null +++ b/deploy/fna-illustration-delivery.service @@ -0,0 +1,13 @@ +[Unit] +Description=Process approved illustration deliveries (fna) +After=network-online.target +[Service] +Type=oneshot +User=nginx +Group=nginx +WorkingDirectory=/www/wwwroot/clashfreenode-new.com +ExecStart=/usr/bin/php -d memory_limit=512M artisan illustrations:sync-deliveries --no-interaction +TimeoutStartSec=300 +UMask=0022 +NoNewPrivileges=true +PrivateTmp=true diff --git a/deploy/fna-illustration-delivery.timer b/deploy/fna-illustration-delivery.timer new file mode 100644 index 00000000..b8d30037 --- /dev/null +++ b/deploy/fna-illustration-delivery.timer @@ -0,0 +1,8 @@ +[Unit] +Description=Poll approved illustration deliveries (fna) +[Timer] +OnBootSec=60 +OnUnitInactiveSec=60 +Persistent=true +[Install] +WantedBy=timers.target diff --git a/docs/maintenance/ILLUSTRATION_DELIVERY.md b/docs/maintenance/ILLUSTRATION_DELIVERY.md new file mode 100644 index 00000000..b32744d9 --- /dev/null +++ b/docs/maintenance/ILLUSTRATION_DELIVERY.md @@ -0,0 +1,9 @@ +# 插画交付接收 + +协议与画师操作唯一正文:[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;失败下轮重试,不重分配既有文章、不删除图片。没有新批次时只查交付队列,不下载图池。页面请求不调用该任务。 + +Resource持有唯一批次状态,FNA不复制完整提示词或私有原稿。Resource未完成的同步回执不影响已有静态图片。新增图片只扩充后续文章的候选池。 + +回退先停fna-illustration-delivery.timer和service、关闭开关;静态副本和旧配图保留。原同步/静态展示维护见[静态配图](LOCAL_ILLUSTRATIONS.md)。批次验收与具体SHA由Resource发布记录维护。 diff --git a/docs/maintenance/LOCAL_ILLUSTRATIONS.md b/docs/maintenance/LOCAL_ILLUSTRATIONS.md index c91db8b3..e83ed22a 100644 --- a/docs/maintenance/LOCAL_ILLUSTRATIONS.md +++ b/docs/maintenance/LOCAL_ILLUSTRATIONS.md @@ -45,3 +45,7 @@ ## 2026-10-30 执行与验收 test与正式均按同一功能提交部署。test 37张覆盖174篇,283项服务器检查通过,含关闭Resource客户端后的无缓存页面验证。正式37张覆盖176篇,246项服务器检查通过,正文/标题/slug/原始缩略图及年度点赞参数的部署前后摘要一致。最终HTTP、完整文件SHA、缓存/304/404、全量模型与数组同源图片、页面及真实接收helper既有分配重放结果见[本次发布回执](STATIC_ILLUSTRATIONS_RELEASE_2026-10-30.json)。未执行真实新文章接收、通知或IndexNow,未做线上页面验收。 VPS-003私有恢复点`/root/backups/cfn-static-illustrations-20261030/`保存原环境、Nginx、部署前身份摘要和逐项验收JSON。运行位置:正式`/www/wwwroot/clashfreenode-new.com`,test`/www/wwwroot/fna-v3-test`;两者独立目录和清单,静态location分别引入`/etc/nginx/snippets/fna-illustrations-production.conf`和`fna-illustrations-staging.conf`。新增目录由nginx写入,公开文件0644/目录0755,私有清单0600/目录0700。Source代码及其37张原文件保持原位;未新增定时同步或清理任务。 + +## 自动交付接收(2026-10-30) + +新增按待交付批次触发的服务器定时同步,见[交付接收](ILLUSTRATION_DELIVERY.md)。此前“未新增定时同步”是静态副本首次上线时的记录。 diff --git a/tests/Feature/IllustrationDeliveryTest.php b/tests/Feature/IllustrationDeliveryTest.php new file mode 100644 index 00000000..364eb06d --- /dev/null +++ b/tests/Feature/IllustrationDeliveryTest.php @@ -0,0 +1,93 @@ +app['env'] = 'production'; + $this->dir = sys_get_temp_dir().'/delivery-fna-'.bin2hex(random_bytes(8)); + mkdir($this->dir, 0700, true); + mkdir($this->dir.'/public'); + file_put_contents($this->dir.'/credential.json', json_encode(['project' => 'fna-v3', 'base_url' => 'https://resource-v2.gemcook.net', 'token' => 'rsv2_'.str_repeat('a', 64)])); + 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'); + 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' => []])); + Http::preventStrayRequests(); + Artisan::all(); + \Illuminate\Support\Facades\Schema::create('articles', function ($t) { + $t->id(); + $t->integer('iCategoryId'); + $t->integer('iStatus'); + $t->string('sSlug'); + $t->timestamp('created_at')->nullable(); + }); + } + + protected function tearDown(): void + { + File::deleteDirectory($this->dir); + parent::tearDown(); + } + + private function fake(int $sync = 0, int $ack = 200): void + { + + 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/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), + ]); + } + + public function test_verified_copy_is_acknowledged_and_lost_ack_can_retry(): void + { + $this->fake(0, 503); + $this->artisan('illustrations:sync-deliveries')->assertExitCode(1); + Http::swap(new \Illuminate\Http\Client\Factory); + Http::preventStrayRequests(); + $this->fake(); + $this->artisan('illustrations:sync-deliveries')->assertExitCode(0); + 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')); + } + + public function test_failed_sync_sends_no_ack(): void + { + $this->fake(1); + $this->artisan('illustrations:sync-deliveries')->assertExitCode(1); + Http::assertNotSent(fn ($r) => str_ends_with($r->url(), '/ack')); + } + + public function test_corrupt_copy_sends_no_ack(): void + { + $this->fake(); + file_put_contents($this->dir.'/public/'.$this->sha.'.webp', 'corrupt'); + $this->artisan('illustrations:sync-deliveries')->assertExitCode(1); + Http::assertNotSent(fn ($r) => str_ends_with($r->url(), '/ack')); + } + + public function test_disabled_worker_does_not_contact_resource(): void + { + config(['illustrations.delivery_enabled' => false]); + $this->artisan('illustrations:sync-deliveries')->assertExitCode(1); + Http::assertNothingSent(); + } +}