Add idempotent content withdrawal with build recovery
This commit is contained in:
parent
fa27362209
commit
74eb256e36
@ -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内容中心专题为唯一正文。
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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))) {
|
||||
|
||||
@ -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']);
|
||||
});
|
||||
|
||||
@ -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, '<?php exit(1);');
|
||||
$data = ['source_key' => $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']);
|
||||
}
|
||||
}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user