feat(blog): add admin endpoint for blog details and cache invalidation

- Implemented `adminDetail()` method in `BlogController` to retrieve blog posts of any status for admin editing.
- Introduced `BlogCacheInvalidator` service to handle cache invalidation after blog create/update/delete actions.
- Updated existing methods in `BlogController` and `RepresentationBlogController` to call cache invalidation on blog modifications.
- Enhanced `BlogFormPage` and `RepresentationBlogFormPage` to utilize the new admin endpoint for fetching blog data.
- Added tests for `BlogCacheInvalidator` to ensure proper functionality and error handling.
- Updated documentation to reflect new API endpoint and cache invalidation behavior.
This commit is contained in:
hamed
2026-07-27 18:54:35 +03:30
parent e4edaea9b8
commit 15abcb5c8a
12 changed files with 972 additions and 47 deletions
+54
View File
@@ -24,6 +24,7 @@ class BlogController extends BaseController
private readonly CityRepository $cityRepo,
private readonly FileValidatorService $fileValidator,
private readonly \App\Blog\Service\BlogWriter $blogWriter,
private readonly \App\Blog\Service\BlogCacheInvalidator $cacheInvalidator,
private readonly string $projectDir,
) {}
@@ -203,6 +204,52 @@ class BlogController extends BaseController
}
#[OA\Get(
path: '/api/v1/admin/blog/{uuid}',
summary: 'Get one blog post of ANY status for the admin edit form',
security: [['bearerAuth' => []]],
parameters: [
new OA\Parameter(name: 'uuid', in: 'path', required: true, schema: new OA\Schema(type: 'string', format: 'uuid')),
],
responses: [
new OA\Response(
response: 200,
description: 'Blog detail of any status (draft/published/archived)',
content: new OA\JsonContent(
properties: [
new OA\Property(property: 'success', type: 'boolean', example: true),
new OA\Property(
property: 'data',
properties: [
new OA\Property(property: 'data', type: 'object'),
],
type: 'object'
),
]
)
),
new OA\Response(response: 401, description: 'Unauthorized'),
new OA\Response(response: 403, description: 'Forbidden — admin role required'),
new OA\Response(response: 404, description: 'Blog post not found'),
]
)]
#[IsGranted('ROLE_ADMIN')]
// requirement اجباری است: این route قبل از /api/v1/admin/blog/review-queue ثبت
// می‌شود و بدون الگوی uuid، آن مسیر را با uuid="review-queue" می‌دزدید.
#[Route('/api/v1/admin/blog/{uuid}', methods: ['GET'], requirements: ['uuid' => '[0-9a-fA-F-]{36}'])]
public function adminDetail(string $uuid): JsonResponse
{
// برخلاف detail() عمومی اینجا هیچ فیلتر status/city ای نیست: فرم ویرایش باید
// پیش‌نویس و آرشیو را هم کامل بارگذاری کند. شکل پاسخ عمداً همان double-nested
// اندپوینت عمومی است تا مصرف‌کنندهٔ فعلی (data.data.data) دست‌نخورده بماند.
$blog = $this->blogRepo->findByUuid($uuid);
if ($blog === null) {
return $this->error(ErrorCodes::ERR_NOT_FOUND_001, 'مقاله یافت نشد', 404);
}
return $this->success(['data' => $blog->toArray()]);
}
#[OA\Post(
path: '/api/v1/blog',
summary: 'Create a new blog post (ROLE_ADMIN, or ROLE_IMPORTER as a pending-review draft)',
@@ -320,6 +367,7 @@ class BlogController extends BaseController
}
$this->blogRepo->save($blog);
$this->cacheInvalidator->invalidate($blog);
return $this->success(['data' => $blog->toArray()], 201);
}
@@ -392,6 +440,7 @@ class BlogController extends BaseController
$this->blogWriter->applySeoFields($blog, $data);
$this->blogRepo->save($blog);
$this->cacheInvalidator->invalidate($blog);
return $this->success(['data' => $blog->toArray()]);
}
@@ -489,6 +538,7 @@ class BlogController extends BaseController
}
$this->blogRepo->save($blog);
$this->cacheInvalidator->invalidate($blog);
return $this->success(['data' => $blog->toArray()]);
}
@@ -536,7 +586,11 @@ class BlogController extends BaseController
return $this->error(ErrorCodes::ERR_NOT_FOUND_001, 'مقاله یافت نشد', 404);
}
// باطل‌سازی قبل از حذف: بعد از remove، Doctrine آبجکت را detach می‌کند و
// اتکا به مقادیر باقی‌مانده در حافظه شکننده است.
$this->cacheInvalidator->invalidate($blog);
$this->blogRepo->remove($blog);
return $this->success(['message' => 'مقاله با موفقیت حذف شد']);
}
@@ -5,6 +5,7 @@ namespace App\Blog\Controller;
use App\Auth\Entity\User;
use App\Blog\Entity\Blog;
use App\Blog\Repository\BlogRepository;
use App\Blog\Service\BlogCacheInvalidator;
use App\Blog\Service\BlogWriter;
use App\Location\Entity\City;
use App\Location\Repository\CityRepository;
@@ -33,6 +34,7 @@ class RepresentationBlogController extends BaseController
private readonly RepresentationRepository $repRepo,
private readonly CityRepository $cityRepo,
private readonly BlogWriter $writer,
private readonly BlogCacheInvalidator $cacheInvalidator,
) {}
private function currentRepresentation(User $user): Representation
@@ -124,6 +126,7 @@ class RepresentationBlogController extends BaseController
$blog->setSlug($blog->getSlug() . '-' . substr(uniqid(), -4));
}
$this->blogRepo->save($blog);
$this->cacheInvalidator->invalidate($blog);
return $this->success(['data' => $blog->toArray()], 201);
}
@@ -149,6 +152,7 @@ class RepresentationBlogController extends BaseController
}
$this->writer->applySeoFields($blog, $data);
$this->blogRepo->save($blog);
$this->cacheInvalidator->invalidate($blog);
return $this->success(['data' => $blog->toArray()]);
}
@@ -159,7 +163,9 @@ class RepresentationBlogController extends BaseController
{
$rep = $this->currentRepresentation($user);
$blog = $this->ownedBlogOr404($uuid, $rep);
$this->cacheInvalidator->invalidate($blog);
$this->blogRepo->remove($blog);
return $this->success(['message' => 'مقاله حذف شد']);
}
}
+55
View File
@@ -0,0 +1,55 @@
<?php
namespace App\Blog\Service;
use App\Blog\Entity\Blog;
use Psr\Log\LoggerInterface;
use Symfony\Contracts\HttpClient\HttpClientInterface;
/**
* پس از هر نوشتن روی بلاگ، کش ISR سایت عمومی (nobat724_front) را باطل می‌کند.
*
* سایت پاسخ `GET /api/v1/blog/{slug}` را با `revalidate: 3600` و tag ذخیره
* می‌کند؛ بدون این فراخوانی، تغییر پنل ادمین تا یک ساعت روی سایت دیده نمی‌شود.
*
* قرارداد: `POST {webhookUrl}` با هدر `X-Revalidate-Secret` و بدنهٔ
* `{"tags": ["blog-<slug>", "blog-<uuid>", "blog-list"]}`.
*
* fail-open: شکست شبکه فقط لاگ می‌شود و هرگز ذخیرهٔ مقاله را نمی‌شکند.
*/
class BlogCacheInvalidator
{
public function __construct(
private readonly HttpClientInterface $httpClient,
private readonly LoggerInterface $logger,
private readonly string $webhookUrl, // خالی = سایت عمومی پیکربندی نشده
private readonly string $webhookSecret,
) {}
public function invalidate(Blog $blog): void
{
if ($this->webhookUrl === '' || $this->webhookSecret === '') {
return;
}
// مقادیر قبل از هر تغییر بعدی برداشته می‌شوند تا حذف مقاله هم قابل باطل‌سازی باشد.
$tags = array_values(array_filter([
$blog->getSlug() !== '' ? 'blog-' . $blog->getSlug() : null,
'blog-' . $blog->getUuid(),
'blog-list',
]));
try {
$this->httpClient->request('POST', $this->webhookUrl, [
'json' => ['tags' => $tags],
'headers' => ['X-Revalidate-Secret' => $this->webhookSecret],
'timeout' => 3,
])->getStatusCode();
} catch (\Throwable $e) {
$this->logger->warning('blog cache invalidation failed', [
'uuid' => $blog->getUuid(),
'error' => $e->getMessage(),
]);
}
}
}