From 2500795189979913f452bdd95d9d2256de751375 Mon Sep 17 00:00:00 2001 From: hellcat Date: Mon, 26 Oct 2026 16:17:35 +0800 Subject: [PATCH] Use existing TTC review format and tags for imported articles --- codex/output/content-center/README.md | 8 ++ .../content-center/reformat-approved.php | 8 ++ laravel/app/ContentCenter/ArticleArtifact.php | 8 +- .../app/ContentCenter/ReformatPublished.php | 67 ++++++++++++++ laravel/app/ContentCenter/ReviewFormat.php | 87 +++++++++++++++++++ laravel/tests/Feature/ContentCenterTest.php | 73 ++++++++++++++++ 6 files changed, 249 insertions(+), 2 deletions(-) create mode 100644 codex/output/content-center/reformat-approved.php create mode 100644 laravel/app/ContentCenter/ReformatPublished.php create mode 100644 laravel/app/ContentCenter/ReviewFormat.php 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 .= '![频道图片](/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']]; + $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('