diff --git a/codex/output/content-center/README.md b/codex/output/content-center/README.md index febc0804..f181ff0f 100644 --- a/codex/output/content-center/README.md +++ b/codex/output/content-center/README.md @@ -5,3 +5,5 @@ 测试使用独立临时站点及模拟构建器,不发布真实文章;本次没有人工批准生产稿件,AI服务尚未配置。 2026-10-25生产接通:经白色机完成Git推送/明确提交部署;完整相册1篇/6图7526412字节进入ATOM私有存储,TTC签名预览成功,稿件停在awaiting_ai,AI仅预留接口。原TTC88篇文章未变,无人工批准或真实发布。三方当前提交、备份及图片流式响应修复见ATOM专题;已完成服务器侧验收,未做线上页面验收。 + +2026-10-26扩展:ATOM为主要文章管理入口;AI仅预留,当前人工确认发布。新增独立HMAC频道拉取、三边删除墓碑/回执及失败恢复;具体接口、日期排序/完全重复折叠、测试和生产实际状态以ATOM内容中心专题为唯一正文。 diff --git a/laravel/app/ContentCenter/ContentController.php b/laravel/app/ContentCenter/ContentController.php index c2fcc326..8402ee30 100644 --- a/laravel/app/ContentCenter/ContentController.php +++ b/laravel/app/ContentCenter/ContentController.php @@ -13,6 +13,13 @@ final class ContentController return response()->json(['environment' => config('content_center.environment'), 'artifact' => $artifact, 'artifact_hash' => ArticleArtifact::hash($artifact)]); } + public function delete(Request $request) + { + abort_unless(config('content_center.enabled'), 503); + + return response()->json(['environment' => config('content_center.environment'), 'receipt' => app(Publication::class)->delete($request->all())]); + } + public function publish(Request $request) { abort_unless(config('content_center.enabled'), 503); diff --git a/laravel/app/ContentCenter/Publication.php b/laravel/app/ContentCenter/Publication.php index 8c4a1ac8..c756d26d 100644 --- a/laravel/app/ContentCenter/Publication.php +++ b/laravel/app/ContentCenter/Publication.php @@ -21,6 +21,7 @@ final class Publication if (! is_dir($directory)) { mkdir($directory, 0700, true); } + abort_if(is_file($directory.'/deleted-'.hash('sha256', $artifact['source_key']).'.json'), 409, '文章已删除,旧发布不可重放。'); $receipt = $directory.'/'.hash('sha256', $artifact['source_key']).'.json'; $previous = is_file($receipt) ? json_decode(file_get_contents($receipt), true, flags: JSON_THROW_ON_ERROR) : null; $byApproval = $directory.'/'.$data['approval_id'].'.json'; @@ -82,6 +83,75 @@ final class Publication }); } + public function delete(array $data): array + { + validator($data, ['deletion_id' => 'required|uuid', 'source_key' => 'required|string|max:160'])->validate(); + $publisher = new SitePublisher(config('content_center.site_root'), config('content_center.node')); + + return $publisher->locked(function () use ($data, $publisher) { + $directory = $publisher->root.'/laravel/storage/app/private/content-center/publications'; + if (! is_dir($directory)) { + mkdir($directory, 0700, true); + } + $key = hash('sha256', $data['source_key']); + $tombstone = $directory.'/deleted-'.$key.'.json'; + if (is_file($tombstone)) { + $done = json_decode(file_get_contents($tombstone), true, flags: JSON_THROW_ON_ERROR); + if ($done['status'] === 'deleted') { + return $done; + } + } + $receipt = $directory.'/'.$key.'.json'; + $previous = is_file($receipt) ? json_decode(file_get_contents($receipt), true, flags: JSON_THROW_ON_ERROR) : null; + $path = $publisher->root.'/vuepress/docs/jccp/telefox-'.substr($key, 0, 24).'.md'; + abort_if(is_file($path) && (! $previous || $previous['source_key'] !== $data['source_key']), 409, '拒绝删除非内容中心文章。'); + $record = ['source_key' => $data['source_key'], 'deletion_id' => $data['deletion_id'], 'status' => 'deleting']; + $this->atomic($tombstone, json_encode($record, JSON_THROW_ON_ERROR), 0600); + $old = is_file($path) ? file_get_contents($path) : null; + $moved = []; + try { + if ($old !== null) { + $this->atomic($directory.'/deleted-source-'.$key.'.md', $old, 0600); + unlink($path); + preg_match_all('~content-assets/([a-f0-9]{64}\.(?:jpg|png|webp|gif))~', $old, $matches); + foreach (array_unique($matches[1]) as $name) { + $used = false; + foreach (new \RecursiveIteratorIterator(new \RecursiveDirectoryIterator($publisher->root.'/vuepress/docs', \FilesystemIterator::SKIP_DOTS)) as $f) { + if ($f->getExtension() === 'md' && str_contains(file_get_contents($f->getPathname()), $name)) { + $used = true; + break; + } + } + $file = $publisher->root.'/vuepress/docs/.vuepress/public/content-assets/'.$name; + if (! $used && is_file($file)) { + $backup = $directory.'/deleted-asset-'.$key.'-'.$name; + if (! rename($file, $backup)) { + throw new RuntimeException('图片撤下未完成。'); + }$moved[$file] = $backup; + } + } + } + if ($old !== null || ($previous && $previous['status'] === 'published')) { + (new WeeklyRanking($publisher))->renderCurrent(); + $publisher->build(); + } + } catch (\Throwable) { + if ($old !== null) { + $this->atomic($path, $old, 0644); + }foreach ($moved as $file => $backup) { + if (is_file($backup)) { + rename($backup, $file); + } + }throw new RuntimeException('撤下构建失败,原文章已恢复,可重试同一删除编号。'); + } + $record['status'] = 'deleted'; + $record['deleted_at'] = now()->toIso8601String(); + $this->atomic($tombstone, json_encode($record, JSON_THROW_ON_ERROR), 0600); + + return $record; + }); + } + private function atomic(string $path, string $data, int $mode): void { if (! is_dir(dirname($path))) { diff --git a/laravel/content-center/routes.php b/laravel/content-center/routes.php index 306192bf..b145f390 100644 --- a/laravel/content-center/routes.php +++ b/laravel/content-center/routes.php @@ -5,6 +5,7 @@ use App\ContentCenter\ContentController; use Illuminate\Support\Facades\Route; Route::prefix('api/content/v1')->middleware([ContentAuthenticate::class.':atom', 'throttle:30,1'])->group(function () { + Route::post('/delete', [ContentController::class, 'delete']); Route::post('/preview', [ContentController::class, 'preview']); Route::post('/publish', [ContentController::class, 'publish']); }); diff --git a/laravel/tests/Feature/ContentCenterTest.php b/laravel/tests/Feature/ContentCenterTest.php index d2ac59bd..ba62dfaa 100644 --- a/laravel/tests/Feature/ContentCenterTest.php +++ b/laravel/tests/Feature/ContentCenterTest.php @@ -106,4 +106,41 @@ final class ContentCenterTest extends TestCase $this->assertSame('previous', file_get_contents($this->root.'/vuepress/docs/.vuepress/dist/index.html')); } } + + public function test_delete_removes_only_owned_article_and_blocks_old_approved_publish(): void + { + $input = $this->input(); + $artifact = app(ArticleArtifact::class)->build($input); + $payload = ['approval_id' => (string) Str::uuid(), 'approved_hash' => ArticleArtifact::hash($artifact), 'input' => $input]; + $this->signed('/api/content/v1/publish', $payload)->assertOk(); + file_put_contents($this->root.'/vuepress/docs/jccp/existing.md', 'keep me'); + $delete = ['source_key' => $input['source_key'], 'deletion_id' => (string) Str::uuid()]; + $this->signed('/api/content/v1/delete', $delete)->assertOk()->assertJsonPath('receipt.status', 'deleted'); + $this->assertFileDoesNotExist($this->root.'/vuepress/docs/'.$artifact['relative_path']); + $this->assertSame('keep me', file_get_contents($this->root.'/vuepress/docs/jccp/existing.md')); + $this->signed('/api/content/v1/delete', $delete)->assertOk(); + $this->signed('/api/content/v1/publish', $payload)->assertStatus(409); + } + + public function test_delete_build_failure_restores_original_and_retry_succeeds(): void + { + $input = $this->input(); + $artifact = app(ArticleArtifact::class)->build($input); + $payload = ['approval_id' => (string) Str::uuid(), 'approved_hash' => ArticleArtifact::hash($artifact), 'input' => $input]; + $this->signed('/api/content/v1/publish', $payload)->assertOk(); + $builder = $this->root.'/vuepress/node_modules/.bin/vuepress'; + $original = file_get_contents($builder); + file_put_contents($builder, ' $input['source_key'], 'deletion_id' => (string) Str::uuid()]; + try { + app(Publication::class)->delete($data); + $this->fail('expected build failure'); + } catch (\RuntimeException) { + } + $this->assertFileExists($this->root.'/vuepress/docs/'.$artifact['relative_path']); + $this->signed('/api/content/v1/publish', $payload)->assertStatus(409); + file_put_contents($builder, $original); + $this->signed('/api/content/v1/delete', $data)->assertOk(); + $this->assertFileDoesNotExist($this->root.'/vuepress/docs/'.$artifact['relative_path']); + } }