feat(blog): enhance blog identifier resolution to support multiple identifiers and improve URL handling
This commit is contained in:
+16
-2
@@ -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 |
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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[]
|
||||
|
||||
@@ -0,0 +1,109 @@
|
||||
<?php
|
||||
|
||||
namespace App\Tests\Blog;
|
||||
|
||||
use App\Blog\Entity\Blog;
|
||||
use App\Tests\ApiTestCase;
|
||||
|
||||
/**
|
||||
* A post's slug is derived from its title, so an edited title silently kills every
|
||||
* published link to it — Search Console reported those old URLs as 404s. The detail
|
||||
* endpoint therefore resolves any identifier the post has ever been addressed by and
|
||||
* always reports the canonical slug, which the public site uses to redirect.
|
||||
*/
|
||||
class BlogIdentifierResolutionTest extends ApiTestCase
|
||||
{
|
||||
private function makePublished(string $title): Blog
|
||||
{
|
||||
$blog = new Blog($this->createUser(['ROLE_ADMIN']), $title, 'متن آزمایشی مقاله برای تست');
|
||||
$blog->setStatus(Blog::STATUS_PUBLISHED);
|
||||
$this->em->persist($blog);
|
||||
$this->em->flush();
|
||||
|
||||
return $blog;
|
||||
}
|
||||
|
||||
/** @return array<string, mixed> */
|
||||
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());
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user