Number repeated article titles without a suffix on the first post
This commit is contained in:
parent
4a8d753441
commit
468121f654
9
codex/output/标题累计次数-20261026.md
Normal file
9
codex/output/标题累计次数-20261026.md
Normal 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。
|
||||||
@ -2,6 +2,8 @@
|
|||||||
|
|
||||||
namespace App\ContentCenter;
|
namespace App\ContentCenter;
|
||||||
|
|
||||||
|
use App\Services\Ranking\ArticleDocument;
|
||||||
|
use Carbon\CarbonImmutable;
|
||||||
use Symfony\Component\Yaml\Yaml;
|
use Symfony\Component\Yaml\Yaml;
|
||||||
|
|
||||||
final class ArticleArtifact
|
final class ArticleArtifact
|
||||||
@ -19,10 +21,12 @@ final class ArticleArtifact
|
|||||||
foreach ($data['images'] as $image) {
|
foreach ($data['images'] as $image) {
|
||||||
$body .= '.")\n\n";
|
$body .= '.")\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];
|
$document = ['meta' => $meta, 'body' => $body];
|
||||||
if (class_exists(\App\Services\Ranking\ArticleDocument::class)) {
|
if (class_exists(ArticleDocument::class)) {
|
||||||
$document = \App\Services\Ranking\ArticleDocument::enrich($document);
|
$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']];
|
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']];
|
||||||
|
|||||||
@ -3,6 +3,7 @@
|
|||||||
namespace App\ContentCenter;
|
namespace App\ContentCenter;
|
||||||
|
|
||||||
use Illuminate\Http\Request;
|
use Illuminate\Http\Request;
|
||||||
|
use Symfony\Component\HttpKernel\Exception\HttpException;
|
||||||
|
|
||||||
final class ContentController
|
final class ContentController
|
||||||
{
|
{
|
||||||
@ -32,6 +33,12 @@ final class ContentController
|
|||||||
{
|
{
|
||||||
abort_unless(config('content_center.enabled'), 503);
|
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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -11,12 +11,13 @@ final class Publication
|
|||||||
public function publish(array $data): array
|
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();
|
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'));
|
$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';
|
$directory = config('content_center.site_root').'/laravel/storage/app/private/content-center/publications';
|
||||||
if (! is_dir($directory)) {
|
if (! is_dir($directory)) {
|
||||||
mkdir($directory, 0700, true);
|
mkdir($directory, 0700, true);
|
||||||
@ -74,6 +75,7 @@ final class Publication
|
|||||||
}
|
}
|
||||||
throw new RuntimeException('网站构建失败,原文章已恢复;按同一发布编号重试。');
|
throw new RuntimeException('网站构建失败,原文章已恢复;按同一发布编号重试。');
|
||||||
}
|
}
|
||||||
|
TitleSeries::commit($artifact['source_key'], $artifact);
|
||||||
$record['status'] = 'published';
|
$record['status'] = 'published';
|
||||||
$record['published_at'] = now()->toIso8601String();
|
$record['published_at'] = now()->toIso8601String();
|
||||||
$this->atomic($receipt, json_encode($record, JSON_THROW_ON_ERROR), 0600);
|
$this->atomic($receipt, json_encode($record, JSON_THROW_ON_ERROR), 0600);
|
||||||
|
|||||||
64
laravel/app/ContentCenter/RenumberTitles.php
Normal file
64
laravel/app/ContentCenter/RenumberTitles.php
Normal 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];
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
86
laravel/app/ContentCenter/TitleSeries.php
Normal file
86
laravel/app/ContentCenter/TitleSeries.php
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -3,10 +3,19 @@
|
|||||||
namespace Tests\Feature;
|
namespace Tests\Feature;
|
||||||
|
|
||||||
use App\ContentCenter\ArticleArtifact;
|
use App\ContentCenter\ArticleArtifact;
|
||||||
|
use App\ContentCenter\LinkedText;
|
||||||
|
use App\ContentCenter\PackageTable;
|
||||||
use App\ContentCenter\Publication;
|
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\File;
|
||||||
use Illuminate\Support\Facades\Http;
|
use Illuminate\Support\Facades\Http;
|
||||||
|
use Illuminate\Support\Facades\URL;
|
||||||
use Illuminate\Support\Str;
|
use Illuminate\Support\Str;
|
||||||
|
use Symfony\Component\HttpKernel\Exception\HttpException;
|
||||||
|
use Symfony\Component\Yaml\Yaml;
|
||||||
use Tests\TestCase;
|
use Tests\TestCase;
|
||||||
|
|
||||||
final class ContentCenterTest extends TestCase
|
final class ContentCenterTest extends TestCase
|
||||||
@ -14,7 +23,7 @@ final class ContentCenterTest extends TestCase
|
|||||||
public function createApplication()
|
public function createApplication()
|
||||||
{
|
{
|
||||||
$app = require dirname(__DIR__, 2).'/bootstrap/app.php';
|
$app = require dirname(__DIR__, 2).'/bootstrap/app.php';
|
||||||
$app->make(\Illuminate\Contracts\Console\Kernel::class)->bootstrap();
|
$app->make(Kernel::class)->bootstrap();
|
||||||
|
|
||||||
return $app;
|
return $app;
|
||||||
}
|
}
|
||||||
@ -24,7 +33,7 @@ final class ContentCenterTest extends TestCase
|
|||||||
protected function setUp(): void
|
protected function setUp(): void
|
||||||
{
|
{
|
||||||
parent::setUp();
|
parent::setUp();
|
||||||
\Illuminate\Support\Facades\URL::forceRootUrl('http://ttc.loc');
|
URL::forceRootUrl('http://ttc.loc');
|
||||||
Http::preventStrayRequests();
|
Http::preventStrayRequests();
|
||||||
$this->root = sys_get_temp_dir().'/ttc-content-test-'.Str::uuid();
|
$this->root = sys_get_temp_dir().'/ttc-content-test-'.Str::uuid();
|
||||||
File::makeDirectory($this->root.'/vuepress/node_modules/.bin', 0755, true);
|
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
|
public function test_review_has_existing_ttc_format_and_only_existing_tags(): void
|
||||||
{
|
{
|
||||||
$a = app(ArticleArtifact::class)->build($this->reviewInput());
|
$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(['机场测评', '每天测速'], $meta['tags']);
|
||||||
$this->assertSame('测试云机场测评 & 测速 ★ 10', $meta['title']);
|
$this->assertSame('测试云机场测速 ★ 10', $meta['title']);
|
||||||
$this->assertSame(10, $meta['scoreTotal']);
|
$this->assertSame(10, $meta['scoreTotal']);
|
||||||
$this->assertSame('2026-10-24 16:01:01', $meta['createTime']);
|
$this->assertSame('2026-10-24 16:01:01', $meta['createTime']);
|
||||||
foreach (['::: tip 天天测提醒您', '::: important 天天测打分', '::: important 天天测点评', '## 机场自介', '## 链接导航', '## 节点测试'] as $part) {
|
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
|
public function test_format_repair_keeps_approval_permalink_and_is_idempotent(): void
|
||||||
{
|
{
|
||||||
$job = $this->repairJob();
|
$job = $this->repairJob();
|
||||||
$a = app(\App\ContentCenter\ReformatPublished::class)->run($job);
|
$a = app(ReformatPublished::class)->run($job);
|
||||||
$this->assertTrue($a['changed']);
|
$this->assertTrue($a['changed']);
|
||||||
$this->assertSame($job['approval_id'], $a['receipt']['approval_id']);
|
$this->assertSame($job['approval_id'], $a['receipt']['approval_id']);
|
||||||
$this->assertSame($job['old_artifact']['permalink'], $a['receipt']['permalink']);
|
$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
|
public function test_format_repair_restores_old_article_on_build_failure(): void
|
||||||
@ -125,7 +134,7 @@ final class ContentCenterTest extends TestCase
|
|||||||
$job = $this->repairJob();
|
$job = $this->repairJob();
|
||||||
file_put_contents($this->root.'/vuepress/node_modules/.bin/vuepress', '<?php exit(1);');
|
file_put_contents($this->root.'/vuepress/node_modules/.bin/vuepress', '<?php exit(1);');
|
||||||
try {
|
try {
|
||||||
app(\App\ContentCenter\ReformatPublished::class)->run($job);
|
app(ReformatPublished::class)->run($job);
|
||||||
$this->fail('Expected failure');
|
$this->fail('Expected failure');
|
||||||
} catch (\RuntimeException) {
|
} catch (\RuntimeException) {
|
||||||
$this->assertSame($job['old_artifact']['markdown'], file_get_contents($this->root.'/vuepress/docs/'.$job['old_artifact']['relative_path']));
|
$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();
|
$job = $this->repairJob();
|
||||||
file_put_contents($this->root.'/vuepress/docs/'.$job['old_artifact']['relative_path'], 'concurrent edit');
|
file_put_contents($this->root.'/vuepress/docs/'.$job['old_artifact']['relative_path'], 'concurrent edit');
|
||||||
$this->expectException(\Symfony\Component\HttpKernel\Exception\HttpException::class);
|
$this->expectException(HttpException::class);
|
||||||
app(\App\ContentCenter\ReformatPublished::class)->run($job);
|
app(ReformatPublished::class)->run($job);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function test_legacy_receive_cannot_bypass_content_review(): void
|
public function test_legacy_receive_cannot_bypass_content_review(): void
|
||||||
@ -226,7 +235,7 @@ final class ContentCenterTest extends TestCase
|
|||||||
{
|
{
|
||||||
$input = $this->reviewInput();
|
$input = $this->reviewInput();
|
||||||
$input['text'] = str_replace('官网:点击进入', "官网:点击进入\n套餐:https://example.com/package\n论坛:https://www.ffqla.net/thread-932-1-1.html", $input['text']);
|
$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['links'] = [['offset' => $offset, 'length' => 4, 'url' => 'https://example.com/shop?a=1&b=2']];
|
||||||
$input['package_table'] = $this->packageFixture();
|
$input['package_table'] = $this->packageFixture();
|
||||||
$a = app(ArticleArtifact::class)->build($input);
|
$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 .= '<td onclick="bad()">'.$v.'<script>bad()</script></td>';
|
||||||
}
|
}
|
||||||
$html .= '</tr></tbody></table><table><tr><td>ignore</td></tr></table></div>';
|
$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);
|
$this->assertSame($this->packageFixture(), $t);
|
||||||
$rendered = \App\ContentCenter\PackageTable::render($t);
|
$rendered = PackageTable::render($t);
|
||||||
$this->assertStringNotContainsString('script', $rendered);
|
$this->assertStringNotContainsString('script', $rendered);
|
||||||
$this->assertStringNotContainsString('onclick', $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'])]);
|
Http::fake(['https://www.ffqla.net/*' => Http::response('', 302, ['Location' => 'http://127.0.0.1/private'])]);
|
||||||
$this->expectException(\RuntimeException::class);
|
$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']);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user