diff --git a/codex/output/content-center/README.md b/codex/output/content-center/README.md
index 657420e0..f0c95e6f 100644
--- a/codex/output/content-center/README.md
+++ b/codex/output/content-center/README.md
@@ -9,3 +9,11 @@
2026-10-26扩展:ATOM为主要文章管理入口;AI仅预留,当前人工确认发布。新增独立HMAC频道拉取、三边删除墓碑/回执及失败恢复;具体接口、日期排序/完全重复折叠、测试和生产实际状态以ATOM内容中心专题为唯一正文。
本次扩展已于2026-10-26 Git部署并完成服务器侧验收;实际提交及回执见ATOM专题。未做线上页面验收;未真实删除或发布文章。
+
+## 2026-10-26 对齐 TTC 既有测评格式
+
+已识别的完整四项评分频道测评使用 ReviewFormat:与旧文一致的“天天测提醒您 / 天天测打分 / 天天测点评”、机场自介、链接导航、节点测试;评分和点评由原文提取,调用既有 ArticleDocument 生成标题总分和列表摘要。标签仅沿用旧文的“机场测评、每天测速”,不把 Telegram #关键词变为站点标签,也不再新增“频道导入”。不完整或未识别评分的普通内容保持安全原文展示、无臆测标签。保留所有已采集图片、人工自定义标题;不猜测测评次数、不生成未知套餐/价格。原始UTC时间转为旧站北京时间显示。
+
+待审旧预览需重新获取TTC最终稿;有批准的旧哈希不得悄悄作为新格式发布,正常发布仍以最终稿哈希校验。用户本次明确要求修复刚发布稿件8:显式维护工具 ReformatPublished,仅供运维调用,无HTTP路由;检查旧批准回执、源键、版本、图片清单、原文文件内容完全匹配,持有原站发布锁后备份旧稿/回执、构建并更新回执,保留链接与批准编号,重复执行幂等。构建失败恢复原文/原dist;并行编辑或删除墓碑存在时停止。ATOM的旧最终稿和回执须依据本次维修结果原子更新,未批准稿件只刷新预览,仍待人工确认。
+
+验证:11项内容中心测试/51断言通过;与评分摘要、点评、标签和周榜的回归结果及线上实际提交/构建结果在任务完成后记录。本次不修改原文章控制器、全站CSS、周榜业务规则或旧88篇文章。
diff --git a/codex/output/content-center/reformat-approved.php b/codex/output/content-center/reformat-approved.php
new file mode 100644
index 00000000..c1b171ba
--- /dev/null
+++ b/codex/output/content-center/reformat-approved.php
@@ -0,0 +1,8 @@
+make(Illuminate\Contracts\Console\Kernel::class)->bootstrap();
+try {
+ $job=json_decode(file_get_contents($argv[1]),true,flags:JSON_THROW_ON_ERROR);
+ $result=app(App\ContentCenter\ReformatPublished::class)->run($job);
+ $old=umask(0077);file_put_contents($argv[2],json_encode($result,JSON_THROW_ON_ERROR|JSON_UNESCAPED_UNICODE|JSON_UNESCAPED_SLASHES));umask($old);
+ echo json_encode(['changed'=>$result['changed'],'title'=>$result['artifact']['title'],'status'=>$result['receipt']['status'],'image_count'=>count($result['artifact']['images']),'permalink_unchanged'=>$result['artifact']['permalink']===$job['old_artifact']['permalink']]).PHP_EOL;
+} catch(Throwable $e) {echo json_encode(['error_class'=>get_class($e),'code'=>$e->getCode()]).PHP_EOL;exit(1);}
diff --git a/laravel/app/ContentCenter/ArticleArtifact.php b/laravel/app/ContentCenter/ArticleArtifact.php
index cf38fe87..7a520a37 100644
--- a/laravel/app/ContentCenter/ArticleArtifact.php
+++ b/laravel/app/ContentCenter/ArticleArtifact.php
@@ -11,16 +11,20 @@ final class ArticleArtifact
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 = "
\n".implode("\n", array_map(fn ($s) => '
'.htmlspecialchars($s, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8').'
', explode("\n", $data['text'])))."\n
\n\n";
+ $review = ReviewFormat::build($data);
+ if ($review) {
+ $body = $review['body'];
+ }
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']];
+ $meta = ['title' => $review['title'] ?? $data['title'], 'createTime' => \Carbon\CarbonImmutable::parse($data['sent_at'], 'UTC')->setTimezone('Asia/Shanghai')->format('Y-m-d H:i:s'), 'permalink' => '/jccp/'.$code.'/', 'articleCode' => $code, 'tags' => $review['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']];
+ return ['source_key' => $data['source_key'], 'revision' => $data['revision'], 'generation' => $data['generation'], 'article_code' => $code, 'relative_path' => 'jccp/'.$code.'.md', 'permalink' => $meta['permalink'], 'title' => $document['meta']['title'], 'format_version' => 'ttc-native-v1', 'markdown' => "---\n".Yaml::dump($document['meta'], 6, 2)."---\n".$document['body'], 'images' => $data['images']];
}
public static function extension(string $mime): string
diff --git a/laravel/app/ContentCenter/ReformatPublished.php b/laravel/app/ContentCenter/ReformatPublished.php
new file mode 100644
index 00000000..55670621
--- /dev/null
+++ b/laravel/app/ContentCenter/ReformatPublished.php
@@ -0,0 +1,67 @@
+'required|uuid', 'expected_hash'=>'required|regex:/^[a-f0-9]{64}$/D', 'old_artifact'=>'required|array', 'input'=>'required|array'])->validate();
+ $artifact = app(ArticleArtifact::class)->build($job['input']);
+ $hash = ArticleArtifact::hash($artifact);
+ $old = $job['old_artifact'];
+ abort_unless(hash_equals(ArticleArtifact::hash($old), $job['expected_hash']), 409);
+ foreach (['source_key', 'revision', 'generation', 'images', 'relative_path', 'permalink'] as $f) {
+ abort_unless($old[$f] === $artifact[$f], 409);
+ }
+ $p = new SitePublisher(config('content_center.site_root'), config('content_center.node'));
+
+ return $p->locked(function () use ($job, $artifact, $hash, $old, $p) {
+ $dir = $p->root.'/laravel/storage/app/private/content-center/publications';
+ $key = hash('sha256', $artifact['source_key']);
+ abort_if(is_file($dir.'/deleted-'.$key.'.json'), 409);
+ $path = $p->root.'/vuepress/docs/'.$artifact['relative_path'];
+ $files = [$dir.'/'.$key.'.json', $dir.'/'.$job['approval_id'].'.json'];
+ abort_unless(is_file($path) && is_file($files[0]) && is_file($files[1]), 409);
+ $records = array_map(fn ($f) => json_decode(file_get_contents($f), true, flags: JSON_THROW_ON_ERROR), $files);
+ foreach ($records as $x) {
+ abort_unless($x['status'] === 'published' && $x['approval_id'] === $job['approval_id'] && $x['source_key'] === $artifact['source_key'], 409);
+ }
+ if ($records[0]['artifact_hash'] === $hash && $records[1]['artifact_hash'] === $hash) {
+ abort_unless(file_get_contents($path) === $artifact['markdown'], 409);
+
+ return ['artifact' => $artifact, 'artifact_hash' => $hash, 'receipt' => $records[0], 'changed' => false];
+ }
+ abort_unless($records[0]['artifact_hash'] === $job['expected_hash'] && $records[1]['artifact_hash'] === $job['expected_hash'] && file_get_contents($path) === $old['markdown'], 409);
+ $backup = $dir.'/reformat-'.$job['approval_id'].'-'.$hash.'.json';
+ if (! is_file($backup)) {
+ ArticleDocument::write($backup, json_encode(['artifact' => $old, 'records' => $records], JSON_THROW_ON_ERROR));
+ chmod($backup, 0600);
+ }
+ try {
+ ArticleDocument::write($path, $artifact['markdown']);
+ (new WeeklyRanking($p))->renderCurrent();
+ $p->build();
+ } catch (\Throwable $e) {
+ ArticleDocument::write($path, $old['markdown']);
+ throw new \RuntimeException('格式修复构建失败,原文章已恢复。', 0, $e);
+ }
+ $record = $records[0];
+ $record['previous_artifact_hash'] = $job['expected_hash'];
+ $record['artifact_hash'] = $hash;
+ $record['format_version'] = $artifact['format_version'];
+ $record['reformatted_at'] = now()->toIso8601String();
+ foreach ($files as $f) {
+ ArticleDocument::write($f, json_encode($record, JSON_THROW_ON_ERROR));
+ chmod($f, 0600);
+ }
+
+ return ['artifact' => $artifact, 'artifact_hash' => $hash, 'receipt' => $record, 'changed' => true];
+ });
+ }
+}
diff --git a/laravel/app/ContentCenter/ReviewFormat.php b/laravel/app/ContentCenter/ReviewFormat.php
new file mode 100644
index 00000000..ecd63a4d
--- /dev/null
+++ b/laravel/app/ContentCenter/ReviewFormat.php
@@ -0,0 +1,87 @@
+ [], 'navigation' => [], 'review' => [], 'extra' => []];
+ $state = 'extra';
+ foreach ($lines as $line) {
+ $label = trim(preg_replace('/^[^\p{L}\p{N}]+/u', '', trim($line)));
+ if (preg_match('/^主观评分/u', $label)) {
+ $state = 'rating';
+
+ continue;
+ }
+ if (preg_match('/^(?:以上评分仅供参考|稳定[性:: \t]|速度[:: \t]|延迟[性:: \t]|性价比[:: \t])/u', $label)) {
+ continue;
+ }
+ if (preg_match('/^(?:翻翻墙|天天测)提醒您/u', $label)) {
+ $state = 'reminder';
+
+ continue;
+ }
+ $matched = false;
+ foreach (['机场自介' => 'intro', '链接导航' => 'navigation', '主观点评' => 'review'] as $heading => $key) {
+ if (str_starts_with($label, $heading)) {
+ $state = $key;
+ $rest = trim(mb_substr($label, mb_strlen($heading)), " ::\t");
+ if ($rest !== '') {
+ $sections[$key][] = $rest;
+ }
+ $matched = true;
+ break;
+ }
+ }
+ if ($matched || in_array($state, ['rating', 'reminder'], true)) {
+ continue;
+ }
+ $sections[$state][] = $line;
+ }
+ $body = "::: tip 天天测提醒您,任何机场都有跑路可能,建议仅月付,以降低风险。(不针对任何机场) \n::: \n\n";
+ $body .= "::: important 天天测打分 \n".implode("\n", $scores)."\n::: \n\n";
+ if (trim(implode('', $sections['review'])) !== '') {
+ $body .= "::: important 天天测点评 \n".self::paragraphs($sections['review'])."\n::: \n\n";
+ }
+ foreach (['intro' => '机场自介', 'navigation' => '链接导航'] as $key => $heading) {
+ if (trim(implode('', $sections[$key])) !== '') {
+ $body .= '## '.$heading." \n\n".self::paragraphs($sections[$key])."\n\n";
+ }
+ }
+ if (trim(implode('', $sections['extra'])) !== '') {
+ $body .= self::paragraphs($sections['extra'])."\n\n";
+ }
+ $body .= "## 节点测试 \n\n";
+
+ return ['title' => $title, 'tags' => ['机场测评', '每天测速'], 'body' => $body];
+ }
+
+ private static function paragraphs(array $lines): string
+ {
+ // Keep channel text inert while using exactly the site's native containers/headings.
+ return ''."\n".implode("\n", array_map(fn ($line) => trim($line) === '' ? '' : '
'.htmlspecialchars($line, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8').'
', $lines))."\n
";
+ }
+}
diff --git a/laravel/tests/Feature/ContentCenterTest.php b/laravel/tests/Feature/ContentCenterTest.php
index ba62dfaa..5442baac 100644
--- a/laravel/tests/Feature/ContentCenterTest.php
+++ b/laravel/tests/Feature/ContentCenterTest.php
@@ -67,6 +67,79 @@ final class ContentCenterTest extends TestCase
$this->signed('/api/content/v1/preview', $this->input(), $nonce)->assertStatus(409);
}
+ private function reviewInput(): array
+ {
+ return array_replace($this->input(), ['title' => '#测试云 #vless', 'text' => "#测试云 #vless\n\n📊 主观评分\n稳定 ★★☆☆☆\n速度 ★★☆☆☆\n延迟 ★★★☆☆\n性价比 ★★★☆☆\n(以上评分仅供参考)\n\n😀机场自介:\n自介内容\n\n😀 链接导航\n官网:点击进入\n\n😀主观点评\n点评内容 {{ danger }}\n\n😀翻翻墙提醒您\n原始提醒"]);
+ }
+
+ public function test_review_has_existing_ttc_format_and_only_existing_tags(): void
+ {
+ $a = app(ArticleArtifact::class)->build($this->reviewInput());
+ $meta = \Symfony\Component\Yaml\Yaml::parse(explode('---', $a['markdown'])[1]);
+ $this->assertSame(['机场测评', '每天测速'], $meta['tags']);
+ $this->assertSame('测试云机场测评 & 测速 ★ 10', $meta['title']);
+ $this->assertSame(10, $meta['scoreTotal']);
+ $this->assertSame('2026-10-24 16:01:01', $meta['createTime']);
+ foreach (['::: tip 天天测提醒您', '::: important 天天测打分', '::: important 天天测点评', '## 机场自介', '## 链接导航', '## 节点测试'] as $part) {
+ $this->assertStringContainsString($part, $a['markdown']);
+ }
+ $this->assertStringContainsString('点评内容', $meta['excerpt']);
+ $this->assertStringContainsString('ttc-rating-excerpt', $meta['excerpt']);
+ $this->assertStringNotContainsString('频道导入', $a['markdown']);
+ $this->assertStringNotContainsString('