Use existing TTC review format and tags for imported articles

This commit is contained in:
hellcat 2026-10-26 16:17:35 +08:00
parent 36c5dafbbb
commit 2500795189
6 changed files with 249 additions and 2 deletions

View File

@ -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篇文章。

View File

@ -0,0 +1,8 @@
<?php
require getcwd().'/vendor/autoload.php';$app=require getcwd().'/bootstrap/app.php';$app->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);}

View File

@ -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 = "<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";
$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

View File

@ -0,0 +1,67 @@
<?php
namespace App\ContentCenter;
use App\Services\Ranking\ArticleDocument;
use App\Services\Ranking\SitePublisher;
use App\Services\Ranking\WeeklyRanking;
/** Explicit operator repair; no public HTTP route. */
final class ReformatPublished
{
public function run(array $job): array
{
validator($job, ['approval_id'=>'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];
});
}
}

View File

@ -0,0 +1,87 @@
<?php
namespace App\ContentCenter;
/** The existing TTC review structure, applied only to recognized rating posts. */
final class ReviewFormat
{
public static function build(array $data): ?array
{
$text = str_replace(["\r\n", "\r"], "\n", $data['text']);
if (! preg_match('/^\s*[^\p{L}\p{N}]*主观评分[ \t]*$/mu', $text)) {
return null;
}
$scores = [];
foreach (['稳定', '速度', '延迟', '性价比'] as $name) {
if (! preg_match('/^\s*'.preg_quote($name, '/').'[性:: \t]*([★⭐☆\x{FE0F}]+)[ \t]*$/mu', $text, $m)) {
return null;
}
$scores[] = $name.':'.$m[1].' ';
}
$lines = explode("\n", trim($text));
$title = $data['title'];
if (preg_match('/^#([^\s#]+)/u', trim($lines[0]), $m)) {
if (str_starts_with(trim($title), '#')) {
$name = preg_replace('/机场$/u', '', $m[1]);
$title = $name.'机场测评 & 测速';
}
array_shift($lines);
}
$sections = ['intro' => [], '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 '<div v-pre>'."\n".implode("\n", array_map(fn ($line) => trim($line) === '' ? '' : '<p>'.htmlspecialchars($line, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8').'</p>', $lines))."\n</div>";
}
}

View File

@ -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点评内容 <script>bad()</script> {{ 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('<script>', $a['markdown']);
$this->assertStringContainsString('<div v-pre>', $a['markdown']);
}
private function repairJob(): array
{
$input = $this->reviewInput();
$old = app(ArticleArtifact::class)->build($input);
File::makeDirectory($this->root.'/vuepress/docs/.vuepress', 0755, true);
$old['markdown'] = "---\ntitle: legacy\n---\n<div v-pre>legacy</div>";
unset($old['title'],$old['format_version']);
$hash = ArticleArtifact::hash($old);
$id = (string) Str::uuid();
$dir = $this->root.'/laravel/storage/app/private/content-center/publications';
File::makeDirectory($dir, 0700, true);
$receipt = ['source_key' => $input['source_key'], 'approval_id' => $id, 'artifact_hash' => $hash, 'generation' => $input['generation'], 'permalink' => $old['permalink'], 'status' => 'published'];
foreach ([hash('sha256', $input['source_key']), $id] as $key) {
file_put_contents($dir.'/'.$key.'.json', json_encode($receipt));
}
file_put_contents($this->root.'/vuepress/docs/'.$old['relative_path'], $old['markdown']);
return ['input' => $input, 'old_artifact' => $old, 'expected_hash' => $hash, 'approval_id' => $id];
}
public function test_format_repair_keeps_approval_permalink_and_is_idempotent(): void
{
$job = $this->repairJob();
$a = app(\App\ContentCenter\ReformatPublished::class)->run($job);
$this->assertTrue($a['changed']);
$this->assertSame($job['approval_id'], $a['receipt']['approval_id']);
$this->assertSame($job['old_artifact']['permalink'], $a['receipt']['permalink']);
$this->assertFalse(app(\App\ContentCenter\ReformatPublished::class)->run($job)['changed']);
}
public function test_format_repair_restores_old_article_on_build_failure(): void
{
$job = $this->repairJob();
file_put_contents($this->root.'/vuepress/node_modules/.bin/vuepress', '<?php exit(1);');
try {
app(\App\ContentCenter\ReformatPublished::class)->run($job);
$this->fail('Expected failure');
} catch (\RuntimeException) {
$this->assertSame($job['old_artifact']['markdown'], file_get_contents($this->root.'/vuepress/docs/'.$job['old_artifact']['relative_path']));
}
}
public function test_format_repair_rejects_concurrent_edit(): void
{
$job = $this->repairJob();
file_put_contents($this->root.'/vuepress/docs/'.$job['old_artifact']['relative_path'], 'concurrent edit');
$this->expectException(\Symfony\Component\HttpKernel\Exception\HttpException::class);
app(\App\ContentCenter\ReformatPublished::class)->run($job);
}
public function test_legacy_receive_cannot_bypass_content_review(): void
{
$this->postJson('/api/article/receive/schema', ['cmd' => 'new'])->assertStatus(410);