Synchronize approved illustration deliveries and acknowledge verified static copies
This commit is contained in:
parent
c663829c6d
commit
f72192063d
@ -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
|
||||||
|
|||||||
@ -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)提交批准批次;不要再手工上传发布/编辑图池或把保存草稿当作上线完成。
|
||||||
|
|||||||
79
app/Console/Commands/SyncIllustrationDeliveries.php
Normal file
79
app/Console/Commands/SyncIllustrationDeliveries.php
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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) {
|
||||||
|
|||||||
@ -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'),
|
||||||
|
|||||||
13
deploy/fna-illustration-delivery.service
Normal file
13
deploy/fna-illustration-delivery.service
Normal 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
|
||||||
8
deploy/fna-illustration-delivery.timer
Normal file
8
deploy/fna-illustration-delivery.timer
Normal file
@ -0,0 +1,8 @@
|
|||||||
|
[Unit]
|
||||||
|
Description=Poll approved illustration deliveries (fna)
|
||||||
|
[Timer]
|
||||||
|
OnBootSec=60
|
||||||
|
OnUnitInactiveSec=60
|
||||||
|
Persistent=true
|
||||||
|
[Install]
|
||||||
|
WantedBy=timers.target
|
||||||
9
docs/maintenance/ILLUSTRATION_DELIVERY.md
Normal file
9
docs/maintenance/ILLUSTRATION_DELIVERY.md
Normal 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发布记录维护。
|
||||||
@ -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)。此前“未新增定时同步”是静态副本首次上线时的记录。
|
||||||
|
|||||||
93
tests/Feature/IllustrationDeliveryTest.php
Normal file
93
tests/Feature/IllustrationDeliveryTest.php
Normal 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue
Block a user