diff --git a/codex/output/标题累计次数-20261026.md b/codex/output/标题累计次数-20261026.md new file mode 100644 index 00000000..5e54d025 --- /dev/null +++ b/codex/output/标题累计次数-20261026.md @@ -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。 diff --git a/laravel/app/ContentCenter/ArticleArtifact.php b/laravel/app/ContentCenter/ArticleArtifact.php index 757e0e72..2ef00301 100644 --- a/laravel/app/ContentCenter/ArticleArtifact.php +++ b/laravel/app/ContentCenter/ArticleArtifact.php @@ -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']]; diff --git a/laravel/app/ContentCenter/ContentController.php b/laravel/app/ContentCenter/ContentController.php index 8b49c14f..c9a769a8 100644 --- a/laravel/app/ContentCenter/ContentController.php +++ b/laravel/app/ContentCenter/ContentController.php @@ -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; + } } } diff --git a/laravel/app/ContentCenter/Publication.php b/laravel/app/ContentCenter/Publication.php index c756d26d..c383fb75 100644 --- a/laravel/app/ContentCenter/Publication.php +++ b/laravel/app/ContentCenter/Publication.php @@ -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); diff --git a/laravel/app/ContentCenter/RenumberTitles.php b/laravel/app/ContentCenter/RenumberTitles.php new file mode 100644 index 00000000..8b045f6c --- /dev/null +++ b/laravel/app/ContentCenter/RenumberTitles.php @@ -0,0 +1,64 @@ +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]; + }); + } +} diff --git a/laravel/app/ContentCenter/TitleSeries.php b/laravel/app/ContentCenter/TitleSeries.php new file mode 100644 index 00000000..b7396180 --- /dev/null +++ b/laravel/app/ContentCenter/TitleSeries.php @@ -0,0 +1,86 @@ + 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); + } +} diff --git a/laravel/tests/Feature/ContentCenterTest.php b/laravel/tests/Feature/ContentCenterTest.php index 283ac220..302ef1d6 100644 --- a/laravel/tests/Feature/ContentCenterTest.php +++ b/laravel/tests/Feature/ContentCenterTest.php @@ -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', '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 .= ''.$v.''; } $html .= '
ignore
'; - $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']); } }