Number repeated article titles without a suffix on the first post

This commit is contained in:
hellcat 2026-10-26 19:26:54 +08:00
parent 4a8d753441
commit 468121f654
7 changed files with 251 additions and 21 deletions

View File

@ -0,0 +1,9 @@
# 标题按已发布文章累计次数
统一标题“机场测评 & 测速”为“机场测速”;同名系列第1篇不加括号,第2篇起“(第N次)”。历史按createTime升序、同时间以文件名稳定排序。保留正文/评分/标签/日期/文章编号和永久链接。TitleSeries私有账本记录来源→系列/次数及最高次数;预览不占号,驳回不占号,同源更新/重试复用次数,已删文章不回收已分配高位。
发布锁内重新核对artifact_hash与次数,两个同名预览不会写入重复号。若其他稿件先发导致待发次数变化,TTC返回artifact_changed,ATOM清除旧批准、重新准备并送审;其他错误沿用原重试,不静默改变已批准的稿件。
RenumberTitles仅为本次明确授权的历史修复入口,无公开HTTP写接口。备份原Markdown和账本,标题元数据更新后一次构建,失败恢复原文件;成功后将同一批准ID的TTC原回执和ATOM已发布artifact/approved_hash同步为仅标题修复后的精确版本,并记录previous_artifact_hash。不重发文章,不新增批准,不改变TG审核决定。
线上念云目前仅两篇:旧篇“念云机场测速”,指定新篇“念云机场测速(第2次)”;下一篇才是第3次。运行、构建和HTTP验收随后补录。临时暂停ATOM content worker进行历史修复,结束恢复;调度与其他队列不变。代码按Git提交推送部署,私有数据备份在目标服务器root私有部署目录,运行账本在TTC storage/app/private/content-center/title-series.json。

View File

@ -2,6 +2,8 @@
namespace App\ContentCenter;
use App\Services\Ranking\ArticleDocument;
use Carbon\CarbonImmutable;
use Symfony\Component\Yaml\Yaml;
final class ArticleArtifact
@ -19,10 +21,12 @@ final class ArticleArtifact
foreach ($data['images'] as $image) {
$body .= '![频道图片](/content-assets/'.$image['sha256'].self::extension($image['mime']).")\n\n";
}
$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']];
$meta = ['title' => $review['title'] ?? $data['title'], 'createTime' => 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']];
$series = TitleSeries::metadata($meta['title'], $data['source_key']);
$meta = array_replace($meta, $series);
$document = ['meta' => $meta, 'body' => $body];
if (class_exists(\App\Services\Ranking\ArticleDocument::class)) {
$document = \App\Services\Ranking\ArticleDocument::enrich($document);
if (class_exists(ArticleDocument::class)) {
$document = 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'], 'title' => $document['meta']['title'], 'format_version' => 'ttc-native-v2', 'markdown' => "---\n".Yaml::dump($document['meta'], 6, 2)."---\n".$document['body'], 'images' => $data['images']];

View File

@ -3,6 +3,7 @@
namespace App\ContentCenter;
use Illuminate\Http\Request;
use Symfony\Component\HttpKernel\Exception\HttpException;
final class ContentController
{
@ -32,6 +33,12 @@ final class ContentController
{
abort_unless(config('content_center.enabled'), 503);
return response()->json(['environment' => config('content_center.environment'), 'receipt' => app(Publication::class)->publish($request->all())]);
try {
return response()->json(['environment' => config('content_center.environment'), 'receipt' => app(Publication::class)->publish($request->all())]);
} catch (HttpException $e) {
if ($e->getStatusCode() === 409 && $e->getMessage() === '待发稿已变化,必须重新审核。') {
return response()->json(['environment' => config('content_center.environment'), 'error_code' => 'artifact_changed'], 409);
}throw $e;
}
}
}

View File

@ -11,12 +11,13 @@ 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) {
return $publisher->locked(function () use ($data, $publisher) {
// Sequence allocation and hash checking share the publication lock.
$artifact = app(ArticleArtifact::class)->build($data['input']);
$hash = ArticleArtifact::hash($artifact);
abort_unless(hash_equals($hash, $data['approved_hash']), 409, '待发稿已变化,必须重新审核。');
$directory = config('content_center.site_root').'/laravel/storage/app/private/content-center/publications';
if (! is_dir($directory)) {
mkdir($directory, 0700, true);
@ -74,6 +75,7 @@ final class Publication
}
throw new RuntimeException('网站构建失败,原文章已恢复;按同一发布编号重试。');
}
TitleSeries::commit($artifact['source_key'], $artifact);
$record['status'] = 'published';
$record['published_at'] = now()->toIso8601String();
$this->atomic($receipt, json_encode($record, JSON_THROW_ON_ERROR), 0600);

View File

@ -0,0 +1,64 @@
<?php
namespace App\ContentCenter;
use App\Services\Ranking\ArticleDocument;
use App\Services\Ranking\Score;
use App\Services\Ranking\SitePublisher;
use App\Services\Ranking\WeeklyRanking;
final class RenumberTitles
{
public function run(?callable $beforeBuild = null): array
{
$publisher = new SitePublisher(config('content_center.site_root'), config('content_center.node'));
return $publisher->locked(function () use ($publisher, $beforeBuild) {
$ledger = TitleSeries::ledger();
$changes = [];
foreach (TitleSeries::catalog() as $row) {
$doc = $row['document'];
$assignment = $ledger['assignments'][$row['key']];
$label = TitleSeries::label($assignment['base'], $assignment['number']);
$suffix = mb_substr($doc['meta']['title'], mb_strlen(Score::baseTitle($doc['meta']['title'])));
$doc['meta']['title'] = $label.$suffix;
$doc['meta']['baseTitle'] = $label;
$doc['meta']['titleSeries'] = $assignment['base'];
$doc['meta']['titleSequence'] = $assignment['number'];
$new = ArticleDocument::render($doc);
$old = file_get_contents($row['path']);
if ($new !== $old) {
$changes[$row['path']] = ['old' => $old, 'new' => $new];
}
}
$backup = $publisher->root.'/laravel/storage/app/private/content-center/title-migration-20261026.json';
$oldLedger = is_file(TitleSeries::path()) ? file_get_contents(TitleSeries::path()) : null;
if (! is_file($backup)) {
ArticleDocument::write($backup, json_encode(['files' => $changes, 'ledger' => $oldLedger], JSON_THROW_ON_ERROR | JSON_UNESCAPED_UNICODE));
chmod($backup, 0600);
}
try {
foreach ($changes as $path => $change) {
ArticleDocument::write($path, $change['new']);
}ArticleDocument::write(TitleSeries::path(), json_encode($ledger, JSON_THROW_ON_ERROR | JSON_UNESCAPED_UNICODE));
chmod(TitleSeries::path(), 0600);
if ($beforeBuild) {
$beforeBuild();
}if ($changes) {
(new WeeklyRanking($publisher))->renderCurrent();
$publisher->build();
}
} catch (\Throwable $e) {
foreach ($changes as $path => $change) {
ArticleDocument::write($path, $change['old']);
}if ($oldLedger === null) {
@unlink(TitleSeries::path());
} else {
ArticleDocument::write(TitleSeries::path(), $oldLedger);
}throw new \RuntimeException('标题迁移构建失败,文章已恢复。', 0, $e);
}
return ['changed' => count($changes), 'groups' => count($ledger['highwater']), 'backup' => $backup];
});
}
}

View File

@ -0,0 +1,86 @@
<?php
namespace App\ContentCenter;
use App\Services\Ranking\ArticleDocument;
use App\Services\Ranking\Score;
use Symfony\Component\Yaml\Yaml;
final class TitleSeries
{
public static function base(string $title): string
{
$title = Score::baseTitle($title);
$title = preg_replace('/[((]第\s*\d+\s*次[))]/u', '', $title);
$title = preg_replace('/第\s*\d+\s*次(?=测评|测速)/u', '', $title);
return trim(preg_replace('/测评\s*[&&]\s*测速/u', '测速', $title));
}
public static function label(string $base, int $n): string
{
return $base.($n > 1 ? '(第'.$n.'次)' : '');
}
public static function path(): string
{
return config('content_center.site_root').'/laravel/storage/app/private/content-center/title-series.json';
}
public static function catalog(): array
{
$rows = [];
foreach (glob(config('content_center.site_root').'/vuepress/docs/jccp/*.md') ?: [] as $path) {
if (! str_starts_with(file_get_contents($path), '---')) {
continue;
}$d = ArticleDocument::read($path);
$m = $d['meta'];
if (empty($m['title'])) {
continue;
}$rows[] = ['path' => $path, 'document' => $d, 'key' => $m['sourceKey'] ?? 'legacy:'.($m['permalink'] ?? basename($path)), 'base' => self::base($m['baseTitle'] ?? $m['title']), 'date' => (string) ($m['createTime'] ?? '')];
}
usort($rows, fn ($a, $b) => [$a['date'], $a['path']] <=> [$b['date'], $b['path']]);
return $rows;
}
public static function ledger(): array
{
$ledger = is_file(self::path()) ? json_decode(file_get_contents(self::path()), true, flags: JSON_THROW_ON_ERROR) : ['assignments' => [], 'highwater' => []];
foreach (self::catalog() as $row) {
$b = $row['base'];
$k = $row['key'];
$meta = $row['document']['meta'];
if (isset($ledger['assignments'][$k]) && $ledger['assignments'][$k]['base'] === $b) {
continue;
}
$n = (int) ($meta['titleSequence'] ?? (($ledger['highwater'][$b] ?? 0) + 1));
$ledger['assignments'][$k] = ['base' => $b, 'number' => $n];
$ledger['highwater'][$b] = max($n, $ledger['highwater'][$b] ?? 0);
}
return $ledger;
}
public static function metadata(string $title, string $source): array
{
$base = self::base($title);
$ledger = self::ledger();
$old = $ledger['assignments'][$source] ?? null;
$n = $old && $old['base'] === $base ? $old['number'] : ($ledger['highwater'][$base] ?? 0) + 1;
return ['title' => self::label($base, $n), 'titleSeries' => $base, 'titleSequence' => $n];
}
public static function commit(string $source, array $artifact): void
{
$meta = Yaml::parse(explode('---', $artifact['markdown'])[1]);
$ledger = self::ledger();
$b = $meta['titleSeries'];
$n = $meta['titleSequence'];
$ledger['assignments'][$source] = ['base' => $b, 'number' => $n];
$ledger['highwater'][$b] = max($n, $ledger['highwater'][$b] ?? 0);
ArticleDocument::write(self::path(), json_encode($ledger, JSON_THROW_ON_ERROR | JSON_UNESCAPED_UNICODE));
chmod(self::path(),0600);
}
}

View File

@ -3,10 +3,19 @@
namespace Tests\Feature;
use App\ContentCenter\ArticleArtifact;
use App\ContentCenter\LinkedText;
use App\ContentCenter\PackageTable;
use App\ContentCenter\Publication;
use App\ContentCenter\ReformatPublished;
use App\ContentCenter\RenumberTitles;
use App\Services\Ranking\ArticleDocument;
use Illuminate\Contracts\Console\Kernel;
use Illuminate\Support\Facades\File;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\URL;
use Illuminate\Support\Str;
use Symfony\Component\HttpKernel\Exception\HttpException;
use Symfony\Component\Yaml\Yaml;
use Tests\TestCase;
final class ContentCenterTest extends TestCase
@ -14,7 +23,7 @@ final class ContentCenterTest extends TestCase
public function createApplication()
{
$app = require dirname(__DIR__, 2).'/bootstrap/app.php';
$app->make(\Illuminate\Contracts\Console\Kernel::class)->bootstrap();
$app->make(Kernel::class)->bootstrap();
return $app;
}
@ -24,7 +33,7 @@ final class ContentCenterTest extends TestCase
protected function setUp(): void
{
parent::setUp();
\Illuminate\Support\Facades\URL::forceRootUrl('http://ttc.loc');
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);
@ -75,9 +84,9 @@ final class ContentCenterTest extends TestCase
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]);
$meta = Yaml::parse(explode('---', $a['markdown'])[1]);
$this->assertSame(['机场测评', '每天测速'], $meta['tags']);
$this->assertSame('测试云机场测评 & 测速 ★ 10', $meta['title']);
$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) {
@ -113,11 +122,11 @@ final class ContentCenterTest extends TestCase
public function test_format_repair_keeps_approval_permalink_and_is_idempotent(): void
{
$job = $this->repairJob();
$a = app(\App\ContentCenter\ReformatPublished::class)->run($job);
$a = app(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']);
$this->assertFalse(app(ReformatPublished::class)->run($job)['changed']);
}
public function test_format_repair_restores_old_article_on_build_failure(): void
@ -125,7 +134,7 @@ final class ContentCenterTest extends TestCase
$job = $this->repairJob();
file_put_contents($this->root.'/vuepress/node_modules/.bin/vuepress', '<?php exit(1);');
try {
app(\App\ContentCenter\ReformatPublished::class)->run($job);
app(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']));
@ -136,8 +145,8 @@ final class ContentCenterTest extends TestCase
{
$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);
$this->expectException(HttpException::class);
app(ReformatPublished::class)->run($job);
}
public function test_legacy_receive_cannot_bypass_content_review(): void
@ -226,7 +235,7 @@ final class ContentCenterTest extends TestCase
{
$input = $this->reviewInput();
$input['text'] = str_replace('官网:点击进入', "官网:点击进入\n套餐:https://example.com/package\n论坛:https://www.ffqla.net/thread-932-1-1.html", $input['text']);
$offset = \App\ContentCenter\LinkedText::length(substr($input['text'], 0, strpos($input['text'], '点击进入')));
$offset = LinkedText::length(substr($input['text'], 0, strpos($input['text'], '点击进入')));
$input['links'] = [['offset' => $offset, 'length' => 4, 'url' => 'https://example.com/shop?a=1&b=2']];
$input['package_table'] = $this->packageFixture();
$a = app(ArticleArtifact::class)->build($input);
@ -254,9 +263,9 @@ final class ContentCenterTest extends TestCase
$html .= '<td onclick="bad()">'.$v.'<script>bad()</script></td>';
}
$html .= '</tr></tbody></table><table><tr><td>ignore</td></tr></table></div>';
$t = \App\ContentCenter\PackageTable::parse($html);
$t = PackageTable::parse($html);
$this->assertSame($this->packageFixture(), $t);
$rendered = \App\ContentCenter\PackageTable::render($t);
$rendered = PackageTable::render($t);
$this->assertStringNotContainsString('script', $rendered);
$this->assertStringNotContainsString('onclick', $rendered);
}
@ -273,6 +282,55 @@ final class ContentCenterTest extends TestCase
{
Http::fake(['https://www.ffqla.net/*' => Http::response('', 302, ['Location' => 'http://127.0.0.1/private'])]);
$this->expectException(\RuntimeException::class);
app(\App\ContentCenter\PackageTable::class)->fetch('https://www.ffqla.net/thread-932-1-1.html');
app(PackageTable::class)->fetch('https://www.ffqla.net/thread-932-1-1.html');
}
public function test_title_sequence_counts_published_articles_and_retries_keep_number(): void
{
$one = $this->reviewInput();
$one['source_key'] = 'first';
$a = app(ArticleArtifact::class)->build($one);
$this->assertStringContainsString('测试云机场测速 ★ 10', $a['markdown']);
$two = array_replace($one, ['source_key' => 'second']);
$preview = app(ArticleArtifact::class)->build($two);
$this->assertSame($a['title'], $preview['title']);
$id = (string) Str::uuid();
$receipt = app(Publication::class)->publish(['approval_id' => $id, 'approved_hash' => ArticleArtifact::hash($a), 'input' => $one]);
$b = app(ArticleArtifact::class)->build($two);
$this->assertStringContainsString('测试云机场测速(第2次)', $b['title']);
$this->assertNotSame(ArticleArtifact::hash($preview), ArticleArtifact::hash($b));
$again = app(Publication::class)->publish(['approval_id' => $id, 'approved_hash' => ArticleArtifact::hash($a), 'input' => $one]);
$this->assertSame($receipt, $again);
$this->assertSame($a['title'], app(ArticleArtifact::class)->build($one)['title']);
}
public function test_same_title_previews_cannot_publish_duplicate_sequence(): void
{
$one = $this->reviewInput();
$one['source_key'] = 'first';
$two = array_replace($one, ['source_key' => 'second']);
$a = app(ArticleArtifact::class)->build($one);
$b = app(ArticleArtifact::class)->build($two);
app(Publication::class)->publish(['approval_id' => (string) Str::uuid(), 'approved_hash' => ArticleArtifact::hash($a), 'input' => $one]);
$this->expectException(HttpException::class);
app(Publication::class)->publish(['approval_id' => (string) Str::uuid(), 'approved_hash' => ArticleArtifact::hash($b), 'input' => $two]);
}
public function test_existing_titles_are_numbered_by_date_and_preserve_body_and_permalink(): void
{
$dir = $this->root.'/vuepress/docs/jccp/';
foreach (['z' => ['2026-01-01', '念云机场测评 & 测速'], 'a' => ['2026-02-01', '念云机场测评 & 测速']] as $file => [$date,$title]) {
file_put_contents($dir.$file.'.md', "---\ntitle: '$title ★ 10'\nbaseTitle: '$title'\ncreateTime: '$date'\npermalink: /jccp/$file/\ntags: [机场测评]\n---\n原文不可改\n");
}
File::makeDirectory($this->root.'/vuepress/docs/.vuepress', 0755, true, true);
$plan = app(RenumberTitles::class)->run();
$this->assertSame(2, $plan['changed']);
$first = ArticleDocument::read($dir.'z.md');
$second = ArticleDocument::read($dir.'a.md');
$this->assertSame('念云机场测速 ★ 10', $first['meta']['title']);
$this->assertSame('念云机场测速(第2次) ★ 10', $second['meta']['title']);
$this->assertSame('/jccp/a/', $second['meta']['permalink']);
$this->assertSame("原文不可改\n", $second['body']);
$this->assertSame(0, app(RenumberTitles::class)->run()['changed']);
}
}