From dcf928546772e87f7f81180528fefcd8a9a26eab Mon Sep 17 00:00:00 2001 From: hamed <15238-genius.ha@users.noreply.drupalcode.org> Date: Sun, 9 Aug 2026 07:14:16 +0330 Subject: [PATCH] feat(blog): enhance blog identifier resolution to support multiple identifiers and improve URL handling --- docs/api/blog.md | 18 +++- src/Blog/Controller/BlogController.php | 5 +- src/Blog/Repository/BlogRepository.php | 35 +++++++ tests/Blog/BlogIdentifierResolutionTest.php | 109 ++++++++++++++++++++ 4 files changed, 164 insertions(+), 3 deletions(-) create mode 100644 tests/Blog/BlogIdentifierResolutionTest.php diff --git a/docs/api/blog.md b/docs/api/blog.md index 9a7d8fd9..4715ad6b 100644 --- a/docs/api/blog.md +++ b/docs/api/blog.md @@ -133,14 +133,28 @@ Paginated (`{ data:[...], meta:{...} }`), each item the blog list shape (include ## GET `/api/v1/blog/{slug}` -Get a single blog post by slug. +Get a single blog post by any identifier it has ever been addressed by. **Permission:** `PUBLIC` ### Path Parameters | Param | Type | Description | |-------|------|-------------| -| `slug` | string | URL slug (e.g. `ashnayi-ba-bimari-diabat`) or UUID | +| `slug` | string | Current slug, UUID, `topic_slug`, or an outdated slug ending in the post's 8-char uuid prefix. Resolution order: slug → uuid → topic_slug → uuid-prefix suffix. | + +### چرا چند شناسه + +`slug` از عنوان ساخته می‌شود، پس ویرایش عنوان URL را عوض می‌کند و هر لینک منتشرشدهٔ قبلی ۴۰۴ می‌گیرد — Search Console همین ۴۰۴ها را روی `behbahan-nobat.ir` گزارش کرد. سه شناسهٔ پایدار می‌مانند: `uuid`، `topic_slug` پایپ‌لاین محتوا، و پسوند ۸ کاراکتریِ uuid که `Blog::generateSlug()` به انتهای هر اسلاگ می‌چسباند. + +پاسخ همیشه `slug` قطعیِ فعلی را برمی‌گرداند؛ کلاینت آن را با اسلاگ درخواستی مقایسه می‌کند و در صورت اختلاف ریدایرکت دائمی می‌زند (سایت عمومی همین کار را می‌کند). + +قواعدی که با این تغییر عوض **نشدند**: + +- پستِ غیر `published` با هیچ شناسه‌ای برنمی‌گردد — نه uuid، نه topic_slug. +- فیلتر `city_id` بعد از resolve اعمال می‌شود؛ پستِ شهر دیگر همچنان ۴۰۴ است. +- پسوندی که hex هشت‌کاراکتری نیست شناسه به حساب نمی‌آید (`some-old-title-zzzzzzzz` → ۴۰۴). + +تست: `tests/Blog/BlogIdentifierResolutionTest.php`. ### Query Parameters | Param | Type | Required | Description | diff --git a/src/Blog/Controller/BlogController.php b/src/Blog/Controller/BlogController.php index 52207590..a52bc332 100644 --- a/src/Blog/Controller/BlogController.php +++ b/src/Blog/Controller/BlogController.php @@ -193,7 +193,10 @@ class BlogController extends BaseController #[Route('/api/v1/blog/{slug}', methods: ['GET'])] public function detail(string $slug, Request $request): JsonResponse { - $blog = $this->blogRepo->findBySlug($slug) ?? $this->blogRepo->findByUuid($slug); + // Any identifier the post has ever been addressed by: slug, uuid, topic_slug, + // or the uuid prefix carried in an outdated slug. The response always reports + // the canonical slug, so the site can redirect an outdated URL instead of 404. + $blog = $this->blogRepo->findByPublicIdentifier($slug); if ($blog === null || $blog->getStatus() !== Blog::STATUS_PUBLISHED) { return $this->error(ErrorCodes::ERR_NOT_FOUND_001, 'مقاله یافت نشد', 404); } diff --git a/src/Blog/Repository/BlogRepository.php b/src/Blog/Repository/BlogRepository.php index 96115f04..08b05620 100644 --- a/src/Blog/Repository/BlogRepository.php +++ b/src/Blog/Repository/BlogRepository.php @@ -14,6 +14,41 @@ class BlogRepository extends ServiceEntityRepository public function findBySlug(string $slug): ?Blog { return $this->findOneBy(['slug' => $slug]); } public function findByTopicSlug(string $topicSlug): ?Blog { return $this->findOneBy(['topicSlug' => $topicSlug]); } + /** + * Resolve a post from any public identifier that has ever addressed it. + * + * A post's slug is derived from its title, so editing the title changes the URL + * and every previously published link 404s. The stable parts survive: the uuid, + * the pipeline's topic_slug, and the 8-char uuid prefix that every generated + * slug carries as its suffix. Callers compare the returned slug with the + * requested one and redirect when they differ. + */ + public function findByPublicIdentifier(string $identifier): ?Blog + { + return $this->findBySlug($identifier) + ?? $this->findByUuid($identifier) + ?? $this->findByTopicSlug($identifier) + ?? $this->findBySlugUuidSuffix($identifier); + } + + /** + * Match on the 8-char uuid prefix that Blog::generateSlug() appends to every slug, + * so a renamed post is still reachable through its old URL. + */ + private function findBySlugUuidSuffix(string $slug): ?Blog + { + $suffix = substr($slug, strrpos($slug, '-') === false ? 0 : strrpos($slug, '-') + 1); + if (!preg_match('/^[0-9a-f]{8}$/i', $suffix)) { + return null; + } + + return $this->createQueryBuilder('b') + ->where('b.uuid LIKE :prefix') + ->setParameter('prefix', $suffix . '-%') + ->setMaxResults(1) + ->getQuery()->getOneOrNullResult(); + } + /** * The admin review queue: posts awaiting a doctor's decision, newest first. * @return Blog[] diff --git a/tests/Blog/BlogIdentifierResolutionTest.php b/tests/Blog/BlogIdentifierResolutionTest.php new file mode 100644 index 00000000..e46928cd --- /dev/null +++ b/tests/Blog/BlogIdentifierResolutionTest.php @@ -0,0 +1,109 @@ +createUser(['ROLE_ADMIN']), $title, 'متن آزمایشی مقاله برای تست'); + $blog->setStatus(Blog::STATUS_PUBLISHED); + $this->em->persist($blog); + $this->em->flush(); + + return $blog; + } + + /** @return array */ + private function fetch(string $identifier): array + { + $this->client->request('GET', '/api/v1/blog/' . rawurlencode($identifier)); + + return json_decode($this->client->getResponse()->getContent(), true) ?? []; + } + + public function testResolvesByCurrentSlug(): void + { + $blog = $this->makePublished('راهنمای تشخیص آلرژی دارویی'); + + $body = $this->fetch($blog->getSlug()); + + $this->assertSame(200, $this->responseCode()); + $this->assertSame($blog->getUuid(), $body['data']['data']['uuid']); + } + + public function testResolvesByUuid(): void + { + $blog = $this->makePublished('راهنمای تشخیص آلرژی دارویی'); + + $this->fetch($blog->getUuid()); + + $this->assertSame(200, $this->responseCode()); + } + + public function testResolvesByTopicSlug(): void + { + $blog = $this->makePublished('راهنمای تشخیص آلرژی دارویی'); + $blog->setTopicSlug('allergy-immunology-symptoms-901'); + $this->em->flush(); + + $body = $this->fetch('allergy-immunology-symptoms-901'); + + $this->assertSame(200, $this->responseCode()); + $this->assertSame($blog->getSlug(), $body['data']['data']['slug']); + } + + public function testOutdatedSlugStillResolvesThroughItsUuidSuffix(): void + { + $blog = $this->makePublished('راهنمای تشخیص آلرژی دارویی'); + $uuidPrefix = substr(str_replace('-', '', $blog->getUuid()), 0, 8); + + // The title changed, so the human part of the URL is stale — only the suffix survives. + $body = $this->fetch('old-latin-title-' . $uuidPrefix); + + $this->assertSame(200, $this->responseCode()); + $this->assertSame($blog->getSlug(), $body['data']['data']['slug']); + $this->assertNotSame('old-latin-title-' . $uuidPrefix, $body['data']['data']['slug']); + } + + public function testUnknownIdentifierStays404(): void + { + $this->makePublished('راهنمای تشخیص آلرژی دارویی'); + + $this->fetch('alamat-tahooe-mokarrar-dar-bimaran-sartani'); + + $this->assertSame(404, $this->responseCode()); + } + + public function testNonHexSuffixIsNotTreatedAsUuidPrefix(): void + { + $this->makePublished('راهنمای تشخیص آلرژی دارویی'); + + $this->fetch('some-old-title-zzzzzzzz'); + + $this->assertSame(404, $this->responseCode()); + } + + public function testDraftStaysHiddenEvenThroughFallbackIdentifiers(): void + { + $blog = new Blog($this->createUser(['ROLE_ADMIN']), 'پیش‌نویس منتشرنشده', 'متن آزمایشی مقاله برای تست'); + $blog->setTopicSlug('draft-topic-901'); + $this->em->persist($blog); + $this->em->flush(); + + $this->fetch('draft-topic-901'); + $this->assertSame(404, $this->responseCode()); + + $this->fetch($blog->getUuid()); + $this->assertSame(404, $this->responseCode()); + } +}