Synchronize approved illustration deliveries and acknowledge verified static copies

This commit is contained in:
Codex 2026-10-30 16:07:22 +08:00
parent c663829c6d
commit f72192063d
10 changed files with 223 additions and 0 deletions

View File

@ -63,3 +63,6 @@ AWS_BUCKET=
AWS_USE_PATH_STYLE_ENDPOINT=false AWS_USE_PATH_STYLE_ENDPOINT=false
VITE_APP_NAME="${APP_NAME}" VITE_APP_NAME="${APP_NAME}"
# Approved illustration delivery (independent per environment)
ILLUSTRATION_DELIVERY_ENABLED=false

View File

@ -7,3 +7,5 @@ ## FNA 静态配图副本
## AI 文章写作(必读) ## AI 文章写作(必读)
文章写作和发布先读docs/content/WRITING.md及docs/maintenance/EDITORIAL_API.md。项目身份和访问边界先读GemDocs AI版项目总览、维护协议及10-项目/fna_v3.md。 文章写作和发布先读docs/content/WRITING.md及docs/maintenance/EDITORIAL_API.md。项目身份和访问边界先读GemDocs AI版项目总览、维护协议及10-项目/fna_v3.md。
插画完成后按[自动交付](docs/maintenance/ILLUSTRATION_DELIVERY.md)提交批准批次;不要再手工上传发布/编辑图池或把保存草稿当作上线完成。

View File

@ -0,0 +1,79 @@
<?php
namespace App\Console\Commands;
use App\Services\IllustrationPool;
use App\Services\LocalIllustrations;
use Illuminate\Console\Command;
final class SyncIllustrationDeliveries extends Command
{
protected $signature = 'illustrations:sync-deliveries';
protected $description = 'Synchronize pending illustration deliveries and acknowledge verified static copies';
public function handle(): int
{
if (! config('illustrations.delivery_enabled') || ! IllustrationPool::enabled() || ! LocalIllustrations::enabled()) {
$this->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);
}
}
}

View File

@ -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 public static function snapshot(string $pool): array
{ {
return Cache::store('file')->remember('illustrations.v2.'.$pool, 60, function () use ($pool) { return Cache::store('file')->remember('illustrations.v2.'.$pool, 60, function () use ($pool) {

View File

@ -1,6 +1,7 @@
<?php <?php
return [ return [
'delivery_enabled' => (bool) env('ILLUSTRATION_DELIVERY_ENABLED', false),
'enabled' => (bool) env('RESOURCE_V2_ILLUSTRATIONS_ENABLED', env('LOCAL_ILLUSTRATIONS_ENABLED', env('APP_ENV') === 'local')), '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'), 'base_url' => env('RESOURCE_V2_URL', 'http://resource.loc:8612'),
'credential_file' => env('RESOURCE_V2_CREDENTIAL_FILE'), 'credential_file' => env('RESOURCE_V2_CREDENTIAL_FILE'),

View File

@ -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

View File

@ -0,0 +1,8 @@
[Unit]
Description=Poll approved illustration deliveries (fna)
[Timer]
OnBootSec=60
OnUnitInactiveSec=60
Persistent=true
[Install]
WantedBy=timers.target

View File

@ -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发布记录维护。

View File

@ -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,未做线上页面验收。 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张原文件保持原位;未新增定时同步或清理任务。 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)。此前“未新增定时同步”是静态副本首次上线时的记录。

View File

@ -0,0 +1,93 @@
<?php
namespace Tests\Feature;
use Illuminate\Support\Facades\Artisan;
use Illuminate\Support\Facades\File;
use Illuminate\Support\Facades\Http;
use Tests\TestCase;
#[\PHPUnit\Framework\Attributes\RunTestsInSeparateProcesses]
#[\PHPUnit\Framework\Attributes\PreserveGlobalState(false)]
class IllustrationDeliveryTest extends TestCase
{
private string $dir;
private string $id = '11111111-1111-4111-8111-111111111111';
private string $asset = '22222222-2222-4222-8222-222222222222';
private string $sha;
protected function setUp(): void
{
parent::setUp();
$this->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();
}
}