Accept immutable reviewed content from ATOM with idempotent site publication

This commit is contained in:
hellcat 2026-10-24 20:12:06 +08:00
parent bc0e9fa79e
commit 5e36c46b29
12 changed files with 396 additions and 7 deletions

View File

@ -0,0 +1,5 @@
# TTC 内容中心接收端
接口和跨项目契约唯一正文:[ATOM内容中心](/Users/hellcat/Documents/GemCodes/atom/codex/output/docs/content-center.md)。
本项目新增 laravel/app/ContentCenter、content-center/routes.php,由bootstrap/app.php独立加载;不改原文章控制器、商家规则、旧后台或并行导航/样式。预览不写文章,发布须匹配人工批准的最终稿哈希,并复用既有Ranking发布器。启用新流程时旧文章接收API410,其他站点不变。
测试使用独立临时站点及模拟构建器,不发布真实文章;本次没有人工批准生产稿件,AI服务尚未配置。

View File

@ -64,3 +64,9 @@ AWS_BUCKET=
AWS_USE_PATH_STYLE_ENDPOINT=false AWS_USE_PATH_STYLE_ENDPOINT=false
VITE_APP_NAME="${APP_NAME}" VITE_APP_NAME="${APP_NAME}"
# Content center service credentials, never browser login credentials.
CONTENT_ENVIRONMENT=local
CONTENT_CENTER_ENABLED=false
CONTENT_ATOM_URL=
CONTENT_ATOM_KEY=

View File

@ -0,0 +1,35 @@
<?php
namespace App\ContentCenter;
use Symfony\Component\Yaml\Yaml;
final class ArticleArtifact
{
public function build(array $data): array
{
validator($data, ['source_key' => 'required|string|max:160', 'revision' => 'required|string|size:64', 'generation' => 'required|integer|min:1', 'title' => 'required|string|max:200', 'text' => 'present|string|max:100000', 'sent_at' => 'required|date', 'images' => 'present|array|max:30', 'images.*.sha256' => 'required|regex:/^[a-f0-9]{64}$/D', 'images.*.mime' => 'required|in:image/png,image/jpeg,image/webp,image/gif', 'images.*.bytes' => 'required|integer|min:1|max:20971520'])->validate();
$code = 'telefox-'.substr(hash('sha256', $data['source_key']), 0, 24);
$body = "<div v-pre>\n".implode("\n", array_map(fn ($s) => '<p>'.htmlspecialchars($s, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8').'</p>', explode("\n", $data['text'])))."\n</div>\n\n";
foreach ($data['images'] as $image) {
$body .= '![频道图片](/content-assets/'.$image['sha256'].self::extension($image['mime']).")\n\n";
}
$meta = ['title' => $data['title'], 'createTime' => $data['sent_at'], 'permalink' => '/jccp/'.$code.'/', 'articleCode' => $code, 'tags' => ['频道导入'], 'sourceKey' => $data['source_key']];
$document = ['meta' => $meta, 'body' => $body];
if (class_exists(\App\Services\Ranking\ArticleDocument::class)) {
$document = \App\Services\Ranking\ArticleDocument::enrich($document);
}
return ['source_key' => $data['source_key'], 'revision' => $data['revision'], 'generation' => $data['generation'], 'article_code' => $code, 'relative_path' => 'jccp/'.$code.'.md', 'permalink' => $meta['permalink'], 'markdown' => "---\n".Yaml::dump($document['meta'], 6, 2)."---\n".$document['body'], 'images' => $data['images']];
}
public static function extension(string $mime): string
{
return ['image/png' => '.png', 'image/jpeg' => '.jpg', 'image/webp' => '.webp', 'image/gif' => '.gif'][$mime];
}
public static function hash(array $artifact): string
{
return hash('sha256', json_encode($artifact, JSON_THROW_ON_ERROR | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES));
}
}

View File

@ -0,0 +1,25 @@
<?php
namespace App\ContentCenter;
use Closure;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Cache;
final class ContentAuthenticate
{
public function handle(Request $r, Closure $next, string $role = 'ttc')
{
$key = (string) config('content_center.'.$role.'_key');
abort_if(strlen($key) < 32, 503, '内容接口尚未配置。');
$time = (string) $r->header('X-Content-Time');
$nonce = (string) $r->header('X-Content-Nonce');
abort_unless(ctype_digit($time) && abs(time() - (int) $time) <= 300 && preg_match('/^[a-f0-9]{32}$/D', $nonce), 401);
abort_unless($r->header('X-Content-Environment') === config('content_center.environment'), 401);
$data = $time."\n".$nonce."\n".$r->method()."\n".$r->getRequestUri()."\n".hash('sha256', $r->getContent());
abort_unless(hash_equals(hash_hmac('sha256', $data, $key), (string) $r->header('X-Content-Signature')), 401);
abort_unless(Cache::add('content-nonce:'.$role.':'.$nonce, true, 600), 409);
return $next($r);
}
}

View File

@ -0,0 +1,22 @@
<?php
namespace App\ContentCenter;
use Illuminate\Http\Request;
final class ContentController
{
public function preview(Request $request)
{
$artifact = app(ArticleArtifact::class)->build($request->all());
return response()->json(['environment' => config('content_center.environment'), 'artifact' => $artifact, 'artifact_hash' => ArticleArtifact::hash($artifact)]);
}
public function publish(Request $request)
{
abort_unless(config('content_center.enabled'), 503);
return response()->json(['environment' => config('content_center.environment'), 'receipt' => app(Publication::class)->publish($request->all())]);
}
}

View File

@ -0,0 +1,36 @@
<?php
namespace App\ContentCenter;
use Illuminate\Support\Facades\Http;
use RuntimeException;
final class ContentHttp
{
public function request(string $base, string $key, string $method, string $path, ?array $data = null, int $timeout = 60)
{
$url = parse_url($base);
$local = config('content_center.environment') === 'local';
if (strlen($key) < 32 || ! $url || isset($url['user']) || isset($url['pass']) || isset($url['query']) || isset($url['fragment']) || ! empty($url['path']) && $url['path'] !== '/' || ! in_array($url['scheme'] ?? '', $local ? ['http', 'https'] : ['https'], true)) {
throw new RuntimeException('内容连接配置无效。');
}
$body = $data === null ? '' : json_encode($data, JSON_THROW_ON_ERROR | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
$time = (string) time();
$nonce = bin2hex(random_bytes(16));
$signature = hash_hmac('sha256', $time."\n".$nonce."\n".$method."\n".$path."\n".hash('sha256', $body), $key);
try {
$response = Http::timeout($timeout)->connectTimeout(10)->withoutRedirecting()->withOptions(['progress' => static function ($total, $received) {
if ($total > 26214400 || $received > 26214400) {
throw new RuntimeException('响应过大。');
}
}])->withHeaders(['X-Content-Time' => $time, 'X-Content-Nonce' => $nonce, 'X-Content-Environment' => config('content_center.environment'), 'X-Content-Signature' => $signature, 'Accept' => 'application/json'])->withBody($body, 'application/json')->send($method, rtrim($base, '/').$path);
if (! $response->successful()) {
throw new RuntimeException('内容服务请求未成功。');
}
return $response;
} catch (\Throwable) {
throw new RuntimeException('内容服务暂不可用,请按同一发布编号重试。');
}
}
}

View File

@ -0,0 +1,16 @@
<?php
namespace App\ContentCenter;
use Closure;
use Illuminate\Http\Request;
final class ProtectLegacyImport
{
public function handle(Request $request, Closure $next)
{
abort_if(config('content_center.enabled') && $request->is('api/article/receive/schema'), 410, 'TTC文章接收已迁至ATOM内容审核流程。');
return $next($request);
}
}

View File

@ -0,0 +1,104 @@
<?php
namespace App\ContentCenter;
use App\Services\Ranking\SitePublisher;
use App\Services\Ranking\WeeklyRanking;
use RuntimeException;
final class Publication
{
public function publish(array $data): array
{
validator($data, ['approval_id' => 'required|uuid', 'approved_hash' => 'required|regex:/^[a-f0-9]{64}$/D', 'input' => 'required|array'])->validate();
$artifact = app(ArticleArtifact::class)->build($data['input']);
$hash = ArticleArtifact::hash($artifact);
abort_unless(hash_equals($hash, $data['approved_hash']), 409, '待发稿已变化,必须重新审核。');
$publisher = new SitePublisher(config('content_center.site_root'), config('content_center.node'));
return $publisher->locked(function () use ($data, $artifact, $hash, $publisher) {
$directory = config('content_center.site_root').'/laravel/storage/app/private/content-center/publications';
if (! is_dir($directory)) {
mkdir($directory, 0700, true);
}
$receipt = $directory.'/'.hash('sha256', $artifact['source_key']).'.json';
$previous = is_file($receipt) ? json_decode(file_get_contents($receipt), true, flags: JSON_THROW_ON_ERROR) : null;
$byApproval = $directory.'/'.$data['approval_id'].'.json';
$done = is_file($byApproval) ? json_decode(file_get_contents($byApproval), true, flags: JSON_THROW_ON_ERROR) : null;
if ($done) {
abort_unless(hash_equals($done['artifact_hash'], $hash), 409);
if ($done['status'] === 'published') {
return $done;
}
}
abort_if($previous && $previous['generation'] > $artifact['generation'], 409, '较新版本已发布或正在发布。');
if ($previous && $previous['approval_id'] === $data['approval_id']) {
abort_unless(hash_equals($previous['artifact_hash'], $hash), 409);
if ($previous['status'] === 'published') {
return $previous;
}
}
$path = $publisher->root.'/vuepress/docs/'.$artifact['relative_path'];
if (is_file($path) && (! $previous || $previous['source_key'] !== $artifact['source_key'])) {
throw new RuntimeException('文章编号冲突,未覆盖现有文章。');
}
$images = $publisher->root.'/vuepress/docs/.vuepress/public/content-assets';
if (! is_dir($images)) {
mkdir($images, 0755, true);
}
foreach ($artifact['images'] as $image) {
$file = $images.'/'.$image['sha256'].ArticleArtifact::extension($image['mime']);
if (is_file($file) && hash_file('sha256', $file) === $image['sha256']) {
continue;
}
$response = app(ContentHttp::class)->request(config('content_center.atom_url'), config('content_center.atom_key'), 'GET', '/api/content/v1/assets/'.$image['sha256']);
$bytes = $response->body();
if (strlen($bytes) !== $image['bytes'] || strlen($bytes) > config('content_center.max_image_bytes') || hash('sha256', $bytes) !== $image['sha256'] || ! @getimagesizefromstring($bytes) || (new \finfo(FILEINFO_MIME_TYPE))->buffer($bytes) !== $image['mime']) {
throw new RuntimeException('图片校验未通过。');
}
$this->atomic($file, $bytes, 0644);
}
$record = ['source_key' => $artifact['source_key'], 'approval_id' => $data['approval_id'], 'artifact_hash' => $hash, 'generation' => $artifact['generation'], 'permalink' => $artifact['permalink'], 'status' => 'building'];
$this->atomic($receipt, json_encode($record, JSON_THROW_ON_ERROR), 0600);
$old = is_file($path) ? file_get_contents($path) : null;
try {
$this->atomic($path, $artifact['markdown'], 0644);
(new WeeklyRanking($publisher))->renderCurrent();
$publisher->build();
} catch (\Throwable) {
if ($old === null) {
@unlink($path);
} else {
$this->atomic($path, $old, 0644);
}
throw new RuntimeException('网站构建失败,原文章已恢复;按同一发布编号重试。');
}
$record['status'] = 'published';
$record['published_at'] = now()->toIso8601String();
$this->atomic($receipt, json_encode($record, JSON_THROW_ON_ERROR), 0600);
$this->atomic($byApproval, json_encode($record, JSON_THROW_ON_ERROR), 0600);
return $record;
});
}
private function atomic(string $path, string $data, int $mode): void
{
if (! is_dir(dirname($path))) {
mkdir(dirname($path), 0755, true);
}
$tmp = tempnam(dirname($path), '.content-');
try {
if (file_put_contents($tmp, $data) !== strlen($data)) {
throw new RuntimeException('写入失败。');
}chmod($tmp, $mode);
if (! rename($tmp, $path)) {
throw new RuntimeException('替换失败。');
}
} finally {
if (is_file($tmp)) {
unlink($tmp);
}
}
}
}

View File

@ -9,15 +9,19 @@ return Application::configure(basePath: dirname(__DIR__))
web: __DIR__.'/../routes/web.php', web: __DIR__.'/../routes/web.php',
commands: __DIR__.'/../routes/console.php', commands: __DIR__.'/../routes/console.php',
health: '/up', health: '/up',
then: function () {
\Illuminate\Support\Facades\Route::middleware('api')->group(base_path('content-center/routes.php'));
},
) )
->withMiddleware(function (Middleware $middleware) { ->withMiddleware(function (Middleware $middleware) {
$middleware->alias([ $middleware->append(\App\ContentCenter\ProtectLegacyImport::class);
//'test' => \App\Http\Middleware\Test::class, $middleware->alias([
]); //'test' => \App\Http\Middleware\Test::class,
$middleware->validateCsrfTokens(except: [ ]);
'api/*', $middleware->validateCsrfTokens(except: [
'cmd/*', 'api/*',
]); 'cmd/*',
]);
}) })
->withExceptions(function (Exceptions $exceptions) { ->withExceptions(function (Exceptions $exceptions) {
// //

View File

@ -0,0 +1,17 @@
<?php
return [
'environment' => env('CONTENT_ENVIRONMENT', 'local'),
'enabled' => (bool) env('CONTENT_CENTER_ENABLED', false),
'telefox_url' => env('CONTENT_TELEFOX_URL', ''),
'telefox_token' => env('CONTENT_TELEFOX_TOKEN', ''),
'ttc_url' => env('CONTENT_TTC_URL', ''),
'ttc_key' => env('CONTENT_TTC_KEY', ''),
'ai_key' => env('CONTENT_AI_KEY', ''),
'atom_url' => env('CONTENT_ATOM_URL', ''),
'atom_key' => env('CONTENT_ATOM_KEY', ''),
'max_image_bytes' => 20971520,
'asset_directory' => storage_path('app/private/content-center/assets'),
'site_root' => dirname(base_path()),
'node' => env('vue_path_node', 'node'),
];

View File

@ -0,0 +1,10 @@
<?php
use App\ContentCenter\ContentAuthenticate;
use App\ContentCenter\ContentController;
use Illuminate\Support\Facades\Route;
Route::prefix('api/content/v1')->middleware([ContentAuthenticate::class.':atom', 'throttle:30,1'])->group(function () {
Route::post('/preview', [ContentController::class, 'preview']);
Route::post('/publish', [ContentController::class, 'publish']);
});

View File

@ -0,0 +1,109 @@
<?php
namespace Tests\Feature;
use App\ContentCenter\ArticleArtifact;
use App\ContentCenter\Publication;
use Illuminate\Support\Facades\File;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Str;
use Tests\TestCase;
final class ContentCenterTest extends TestCase
{
public function createApplication()
{
$app = require dirname(__DIR__, 2).'/bootstrap/app.php';
$app->make(\Illuminate\Contracts\Console\Kernel::class)->bootstrap();
return $app;
}
private string $root;
protected function setUp(): void
{
parent::setUp();
\Illuminate\Support\Facades\URL::forceRootUrl('http://ttc.loc');
Http::preventStrayRequests();
$this->root = sys_get_temp_dir().'/ttc-content-test-'.Str::uuid();
File::makeDirectory($this->root.'/vuepress/node_modules/.bin', 0755, true);
File::makeDirectory($this->root.'/vuepress/docs/jccp', 0755, true);
File::makeDirectory($this->root.'/laravel/storage/app/weekly-ranking', 0755, true);
file_put_contents($this->root.'/vuepress/node_modules/.bin/vuepress', '<?php $dest=$argv[array_search("--dest",$argv)+1];mkdir($dest,0755,true);file_put_contents($dest."/index.html","isolated test build");');
config(['content_center.environment' => 'local', 'content_center.enabled' => true, 'content_center.atom_key' => str_repeat('k', 40), 'content_center.atom_url' => 'http://atom.loc', 'content_center.site_root' => $this->root, 'content_center.node' => PHP_BINARY, 'cache.default' => 'array']);
}
protected function tearDown(): void
{
File::deleteDirectory($this->root);
parent::tearDown();
}
private function input(int $generation = 1): array
{
return ['source_key' => 'telegram:-1001234567890:album:99', 'revision' => str_repeat('a', 64), 'generation' => $generation, 'title' => '测评标题', 'text' => '<script>bad()</script> {{ danger }}', 'sent_at' => '2026-10-24 08:01:01', 'images' => []];
}
private function signed(string $path, array $data, ?string $nonce = null)
{
$body = json_encode($data, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
$time = (string) time();
$nonce ??= bin2hex(random_bytes(16));
$sig = hash_hmac('sha256', $time."\n".$nonce."\nPOST\n".$path."\n".hash('sha256', $body), str_repeat('k', 40));
return $this->call('POST', $path, [], [], [], ['CONTENT_TYPE' => 'application/json', 'HTTP_ACCEPT' => 'application/json', 'HTTP_X_CONTENT_TIME' => $time, 'HTTP_X_CONTENT_NONCE' => $nonce, 'HTTP_X_CONTENT_ENVIRONMENT' => 'local', 'HTTP_X_CONTENT_SIGNATURE' => $sig], $body);
}
public function test_preview_authenticates_and_does_not_write_or_build(): void
{
$this->postJson('/api/content/v1/preview', $this->input())->assertUnauthorized();
$nonce = bin2hex(random_bytes(16));
$response = $this->signed('/api/content/v1/preview', $this->input(), $nonce)->assertOk();
$markdown = $response->json('artifact.markdown');
$this->assertStringContainsString('&lt;script&gt;', $markdown);
$this->assertStringContainsString('<div v-pre>', $markdown);
$this->assertSame([], File::files($this->root.'/vuepress/docs/jccp'));
$this->signed('/api/content/v1/preview', $this->input(), $nonce)->assertStatus(409);
}
public function test_legacy_receive_cannot_bypass_content_review(): void
{
$this->postJson('/api/article/receive/schema', ['cmd' => 'new'])->assertStatus(410);
$this->assertSame([], File::files($this->root.'/vuepress/docs/jccp'));
}
public function test_wrong_hash_cannot_publish(): void
{
$this->signed('/api/content/v1/publish', ['approval_id' => (string) Str::uuid(), 'approved_hash' => str_repeat('0', 64), 'input' => $this->input()])->assertStatus(409);
$this->assertSame([], File::files($this->root.'/vuepress/docs/jccp'));
}
public function test_approved_publication_is_idempotent_and_old_generation_cannot_overwrite_new(): void
{
$input = $this->input(20);
$artifact = app(ArticleArtifact::class)->build($input);
$payload = ['approval_id' => (string) Str::uuid(), 'approved_hash' => ArticleArtifact::hash($artifact), 'input' => $input];
$one = $this->signed('/api/content/v1/publish', $payload)->assertOk()->assertJsonPath('receipt.status', 'published');
$this->signed('/api/content/v1/publish', $payload)->assertOk()->assertJsonPath('receipt.approval_id', $payload['approval_id']);
$this->assertSame(1, count(File::files($this->root.'/vuepress/docs/jccp')));
$old = $this->input(10);
$this->signed('/api/content/v1/publish', ['approval_id' => (string) Str::uuid(), 'approved_hash' => ArticleArtifact::hash(app(ArticleArtifact::class)->build($old)), 'input' => $old])->assertStatus(409);
}
public function test_failed_build_restores_source_and_preserves_previous_dist(): void
{
File::makeDirectory($this->root.'/vuepress/docs/.vuepress/dist', 0755, true);
file_put_contents($this->root.'/vuepress/docs/.vuepress/dist/index.html', 'previous');
file_put_contents($this->root.'/vuepress/node_modules/.bin/vuepress', '<?php exit(1);');
$input = $this->input();
$artifact = app(ArticleArtifact::class)->build($input);
try {
app(Publication::class)->publish(['approval_id' => (string) Str::uuid(), 'approved_hash' => ArticleArtifact::hash($artifact), 'input' => $input]);
$this->fail('Expected build failure');
} catch (\RuntimeException) {
$this->assertFileDoesNotExist($this->root.'/vuepress/docs/'.$artifact['relative_path']);
$this->assertSame('previous', file_get_contents($this->root.'/vuepress/docs/.vuepress/dist/index.html'));
}
}
}