diff --git a/codex/output/content-center/README.md b/codex/output/content-center/README.md
new file mode 100644
index 00000000..bd90c7ab
--- /dev/null
+++ b/codex/output/content-center/README.md
@@ -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服务尚未配置。
diff --git a/laravel/.env.example b/laravel/.env.example
index 6fb3de65..64779f74 100644
--- a/laravel/.env.example
+++ b/laravel/.env.example
@@ -64,3 +64,9 @@ AWS_BUCKET=
AWS_USE_PATH_STYLE_ENDPOINT=false
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=
diff --git a/laravel/app/ContentCenter/ArticleArtifact.php b/laravel/app/ContentCenter/ArticleArtifact.php
new file mode 100644
index 00000000..cf38fe87
--- /dev/null
+++ b/laravel/app/ContentCenter/ArticleArtifact.php
@@ -0,0 +1,35 @@
+ '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 = "
\n".implode("\n", array_map(fn ($s) => '
'.htmlspecialchars($s, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8').'
', explode("\n", $data['text'])))."\n
\n\n";
+ foreach ($data['images'] as $image) {
+ $body .= '.")\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));
+ }
+}
diff --git a/laravel/app/ContentCenter/ContentAuthenticate.php b/laravel/app/ContentCenter/ContentAuthenticate.php
new file mode 100644
index 00000000..deb0a10a
--- /dev/null
+++ b/laravel/app/ContentCenter/ContentAuthenticate.php
@@ -0,0 +1,25 @@
+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);
+ }
+}
diff --git a/laravel/app/ContentCenter/ContentController.php b/laravel/app/ContentCenter/ContentController.php
new file mode 100644
index 00000000..c2fcc326
--- /dev/null
+++ b/laravel/app/ContentCenter/ContentController.php
@@ -0,0 +1,22 @@
+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())]);
+ }
+}
diff --git a/laravel/app/ContentCenter/ContentHttp.php b/laravel/app/ContentCenter/ContentHttp.php
new file mode 100644
index 00000000..dc3f95d9
--- /dev/null
+++ b/laravel/app/ContentCenter/ContentHttp.php
@@ -0,0 +1,36 @@
+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('内容服务暂不可用,请按同一发布编号重试。');
+ }
+ }
+}
diff --git a/laravel/app/ContentCenter/ProtectLegacyImport.php b/laravel/app/ContentCenter/ProtectLegacyImport.php
new file mode 100644
index 00000000..7143349b
--- /dev/null
+++ b/laravel/app/ContentCenter/ProtectLegacyImport.php
@@ -0,0 +1,16 @@
+is('api/article/receive/schema'), 410, 'TTC文章接收已迁至ATOM内容审核流程。');
+
+ return $next($request);
+ }
+}
diff --git a/laravel/app/ContentCenter/Publication.php b/laravel/app/ContentCenter/Publication.php
new file mode 100644
index 00000000..8c4a1ac8
--- /dev/null
+++ b/laravel/app/ContentCenter/Publication.php
@@ -0,0 +1,104 @@
+ '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);
+ }
+ }
+ }
+}
diff --git a/laravel/bootstrap/app.php b/laravel/bootstrap/app.php
index 94cc5421..276dec1b 100755
--- a/laravel/bootstrap/app.php
+++ b/laravel/bootstrap/app.php
@@ -9,15 +9,19 @@ return Application::configure(basePath: dirname(__DIR__))
web: __DIR__.'/../routes/web.php',
commands: __DIR__.'/../routes/console.php',
health: '/up',
+ then: function () {
+ \Illuminate\Support\Facades\Route::middleware('api')->group(base_path('content-center/routes.php'));
+ },
)
->withMiddleware(function (Middleware $middleware) {
- $middleware->alias([
- //'test' => \App\Http\Middleware\Test::class,
- ]);
- $middleware->validateCsrfTokens(except: [
- 'api/*',
- 'cmd/*',
- ]);
+ $middleware->append(\App\ContentCenter\ProtectLegacyImport::class);
+ $middleware->alias([
+ //'test' => \App\Http\Middleware\Test::class,
+ ]);
+ $middleware->validateCsrfTokens(except: [
+ 'api/*',
+ 'cmd/*',
+ ]);
})
->withExceptions(function (Exceptions $exceptions) {
//
diff --git a/laravel/config/content_center.php b/laravel/config/content_center.php
new file mode 100644
index 00000000..473ff16d
--- /dev/null
+++ b/laravel/config/content_center.php
@@ -0,0 +1,17 @@
+ 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'),
+];
diff --git a/laravel/content-center/routes.php b/laravel/content-center/routes.php
new file mode 100644
index 00000000..306192bf
--- /dev/null
+++ b/laravel/content-center/routes.php
@@ -0,0 +1,10 @@
+middleware([ContentAuthenticate::class.':atom', 'throttle:30,1'])->group(function () {
+ Route::post('/preview', [ContentController::class, 'preview']);
+ Route::post('/publish', [ContentController::class, 'publish']);
+});
diff --git a/laravel/tests/Feature/ContentCenterTest.php b/laravel/tests/Feature/ContentCenterTest.php
new file mode 100644
index 00000000..d2ac59bd
--- /dev/null
+++ b/laravel/tests/Feature/ContentCenterTest.php
@@ -0,0 +1,109 @@
+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', ' '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' => ' {{ 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('<script>', $markdown);
+ $this->assertStringContainsString('', $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', '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'));
+ }
+ }
+}