feat(blog): implement tag filtering and facets endpoint

- Fix tag filtering to correctly match Persian tags by adjusting JSON encoding in the applyTagFilter method.
- Add new endpoint GET /api/v1/blogs/tags to retrieve distinct tag names and their counts for published posts, respecting city scope.
- Update API documentation to reflect changes in tag filtering and the new tags endpoint.
- Create BlogTagFilterTest to ensure correct functionality of tag filtering and facets, including edge cases for Persian tags and city filtering.
This commit is contained in:
hamed
2026-07-29 14:23:36 +03:30
parent 4f4bce9fe2
commit 9b05c6d1ff
6 changed files with 707 additions and 15 deletions
@@ -0,0 +1,343 @@
# اصلاح فیلتر تگ بلاگ + endpoint واژگان تگ‌ها
## پروژه
`clinicpro` (backend)
پرامپت همتا در سایت عمومی: `nobat724_front/.claude/prompt/blog-category-breadcrumb-and-filter.md`
**اول این پرامپت اجرا شود**، چون فرانت به `tag` سالم و به endpoint جدید وابسته است.
## زمینه
سایت عمومی (`https://yasuj-nobat.ir/blogs`) بالای لیست مقاله‌ها ردیفی از چیپ‌های دسته‌بندی دارد و با
کلیک روی هرکدام `GET /api/v1/blogs?tag=<name>` می‌زند. این فیلتر **برای هیچ تگ فارسی‌ای نتیجه
برنمی‌گرداند** — همیشه لیست خالی. علاوه بر آن، فرانت مجبور است واژگان چیپ‌ها را از خودِ لیست
مقاله‌ها استخراج کند، که ساختاراً ناقص است.
## مشکل / هدف
### ۱) فیلتر تگ همیشه صفر نتیجه می‌دهد
`blogs.tags` ستون `#[ORM\Column(type: 'json')]` است. Doctrine `JsonType` مقدار را با
`json_encode($value)` **بدون** `JSON_UNESCAPED_UNICODE` می‌نویسد، پس در دیتابیس فرم escape‌شده
ذخیره می‌شود:
```
mysql> SELECT LEFT(tags,60) FROM blogs WHERE id=4;
["سلامت عمومی"]
mysql> SELECT HEX(LEFT(tags,14)) FROM blogs WHERE id=4;
5B225C75303633335C7530363434 -- یعنی ["سل با بک‌اسلش تکی
```
ولی `BlogRepository::applyTagFilter` کاندید را با `JSON_UNESCAPED_UNICODE` می‌سازد، یعنی
`"سلامت عمومی"` خام. MariaDB در `JSON_CONTAINS` فرم `\uXXXX` را نرمال **نمی‌کند** و مقایسه سر
همان بایت‌ها انجام می‌شود:
```sql
-- روی MariaDB 11.8.8 (همان نسخهٔ ddev) اجرا و تأیید شد:
SELECT JSON_CONTAINS('["سل..."]', '"سلامت عمومی"'); -- 0 ← وضعیت فعلی
SELECT JSON_CONTAINS('["سل..."]', '"سل..."'); -- 1 ← فرم درست
SELECT COUNT(*) FROM blogs
WHERE JSON_CONTAINS(tags, '"سلامت عمومی"')=1; -- 74
```
تأیید روی محیط واقعی:
```bash
curl -s "https://clinic-pro.ir/api/v1/blogs?page=1&limit=3&tag=چشم و گوش"
# {"success":true,"data":[],"meta":{"totalRecords":0,"totalPages":0,"currentPage":1,"limit":3}}
# در حالی که ۱۶ مقالهٔ منتشرشده تگ «چشم و گوش» دارند.
```
### ۲) واژگان تگ‌های بلاگ endpoint ندارد
فرانت (`nobat724_front/components/blogs/title/index.js`) چیپ‌ها را این‌طور می‌سازد:
`request.getBlogs({page:1, limit:50})` و سپس `flatMap(b => b.tags)`. سقف `limit` در کنترلر ۵۰ است
و ۱۱۷ مقالهٔ منتشرشده وجود دارد → تگ‌های صفحات بعد هرگز چیپ نمی‌شوند. ضمناً `city_id` پاس داده
نمی‌شود، پس روی دامنهٔ شهری چیپی نمایش داده می‌شود که هیچ پستی روی آن دامنه ندارد.
`GET /api/v1/tags` جایگزین نیست: آن، واژگانِ **Tag entity** است (`بیماری‌های قلبی`، `تغذیه`،
`دیابت`، `زیبایی`) و با تگ‌های واقعیِ بلاگ (`سلامت عمومی`، `چشم و گوش`، `زنان و بارداری`، …)
هم‌پوشانی ندارد؛ `blogs.tags` یک آرایهٔ JSON از رشته‌های آزاد است، نه FK به Tag.
توزیع واقعیِ تگ‌ها (۱۱۷ پست منتشرشده روی prod، شمارش از سه صفحهٔ ۵۰تایی):
```
75 سلامت عمومی 5 آزمایش و تصویربرداری 3 پیشگیری و غربالگری 2 خون و سرطان
16 چشم و گوش 3 پوست، مو و زیبایی 2 مغز و اعصاب 1 قلب و عروق
15 زنان و بارداری 3 تغذیه و سبک زندگی 2 سلامت روان 1 جراحی و توان‌بخشی
5 تنفس، آلرژی و عفونت 3 گوارش و کبد 3 غدد و متابولیسم 1 دارو و درمان
```
## معیار پذیرش
- ✅ موفق: `GET /api/v1/blogs?tag=چشم و گوش``200` و `meta.totalRecords > 0`؛ همهٔ آیتم‌های
`data[]` در آرایهٔ `tags` خود دقیقاً `"چشم و گوش"` دارند.
- ✅ موفق: `GET /api/v1/blogs/tags``200` با `{ success, data: [{ name, count }, …] }` مرتب
نزولی بر اساس `count`؛ مجموع نام‌ها با تگ‌های واقعی مقالات منتشرشده یکی است.
- ✅ موفق: `GET /api/v1/blogs/tags?city_id=132` فقط تگ‌های مقالات همان شهر + مقالات سراسری را
برمی‌گرداند (همان قاعدهٔ `applyCityFilter`).
- ✅ موفق: `GET /api/v1/blogs?tag=چشم و گوش&city_id=132` هر دو فیلتر را هم‌زمان اعمال می‌کند و
`meta.totalRecords` با تعداد رکوردهای `data` در حالت تک‌صفحه‌ای هم‌خوان است (یعنی
`countPublished` هم همان فیلتر را دارد).
- ❌ خطا: `?tag=یک‌تگ‌ناموجود``200` با `data: []` و `meta.totalRecords: 0` (نه ۵۰۰، نه خطا).
- ❌ خطا: `?city_id=999999` (شهر ناموجود) در `/api/v1/blogs/tags``200` با `data: []`؛
رفتار باید آینهٔ `GET /api/v1/blogs?city_id=999999` باشد، نه استثنا.
- ⚠️ مرزی: تگ فارسی با نیم‌فاصله (`تنفس، آلرژی و عفونت`) و تگ حاوی `_` یا `%` باید **دقیق**
match شود، نه wildcard — یعنی `?tag=سلامت` نباید مقالات `سلامت عمومی` را برگرداند.
- ⚠️ مرزی: مقاله‌ای با `tags: []` نباید در هیچ facet یا فیلتری ظاهر شود و نباید باعث ورودی
خالی/`null` در خروجی `/api/v1/blogs/tags` شود.
- ⚠️ مرزی: مقالات `draft`/`archived` نه در فیلتر نتیجه می‌دهند و نه تگشان در facet می‌آید.
## فایل‌های مرتبط
| فایل | نقش |
|------|-----|
| `src/Blog/Repository/BlogRepository.php` | `applyTagFilter` (باگ)، `findPublished`، `countPublished`، `applyCityFilter` |
| `src/Blog/Controller/BlogController.php` | `list()` (خواندن `tag`/`city_id`)، محل افزودن اکشن facet |
| `src/Blog/Entity/Blog.php` | `#[ORM\Column(type:'json')] private array $tags` (خط ۷۰-۷۱) |
| `src/Shared/Doctrine/JsonContains.php` | DQL function ثبت‌شده در `config/packages/doctrine.yaml` |
| `docs/api/blog.md` | سند endpointها — خط ۱۸ توضیح پارامتر `tag` |
| `tests/Blog/BlogCityScopeTest.php` | الگوی تست لیست عمومی (ساخت City/Blog، `listBy()`) |
## وضعیت فعلی
`src/Blog/Repository/BlogRepository.php:171-179` — کد واقعی:
```php
private function applyTagFilter(\Doctrine\ORM\QueryBuilder $qb, ?string $tag): void
{
if ($tag === null || $tag === '') {
return;
}
// Blog.tags is a JSON array of tag names; match exact name membership.
$qb->andWhere('JSON_CONTAINS(b.tags, :tag) = 1')
->setParameter('tag', json_encode($tag, JSON_UNESCAPED_UNICODE));
}
```
`src/Blog/Controller/BlogController.php:87-102` — کد واقعی:
```php
#[Route('/api/v1/blogs', methods: ['GET'])]
public function list(Request $request): JsonResponse
{
$page = max(1, (int) $request->query->get('page', 1));
$limit = min(50, max(1, (int) $request->query->get('limit', 20)));
$tag = $request->query->get('tag') ?: null;
$cityId = $request->query->get('city_id') !== null
? max(1, (int) $request->query->get('city_id'))
: null;
$blogs = array_map(
fn(Blog $b) => $b->toListArray(),
$this->blogRepo->findPublished($page, $limit, $tag, $cityId)
);
$total = $this->blogRepo->countPublished($tag, $cityId);
return $this->paginated($blogs, $total, $page, $limit);
}
```
`src/Blog/Repository/BlogRepository.php:161-169` — فیلتر شهر که facet هم باید همان را رعایت کند:
```php
private function applyCityFilter(\Doctrine\ORM\QueryBuilder $qb, ?int $cityId): void
{
if ($cityId === null) {
return;
}
$qb->andWhere('b.city = :cityId OR b.city IS NULL')
->setParameter('cityId', $cityId);
}
```
## وظایف
### ۱. اصلاح `applyTagFilter` — تطبیق encoding کاندید با encoding ستون
تنها تغییر لازم: کاندید باید با **همان** تنظیمی encode شود که Doctrine ستون را می‌نویسد، یعنی
`json_encode` بدون `JSON_UNESCAPED_UNICODE`.
```php
private function applyTagFilter(\Doctrine\ORM\QueryBuilder $qb, ?string $tag): void
{
if ($tag === null || $tag === '') {
return;
}
// Doctrine's JsonType writes this column with plain json_encode, so Persian
// tags are stored \uXXXX-escaped. MariaDB's JSON_CONTAINS compares the two
// documents without normalizing those escapes, so the candidate must be
// escaped exactly the same way — JSON_UNESCAPED_UNICODE never matched.
$qb->andWhere('JSON_CONTAINS(b.tags, :tag) = 1')
->setParameter('tag', json_encode($tag));
}
```
هیچ migration، هیچ کلاس جدید و هیچ تغییری در `JsonContains` لازم نیست.
**نحوه تست:**
```bash
# ۱) تست SQL خام روی همان دیتابیس (تأیید فرضیه، قبل و بعد)
ddev exec mysql -uroot -proot db -e "SELECT COUNT(*) FROM blogs WHERE JSON_CONTAINS(tags, '\"\\\\u0686\\\\u0634\\\\u0645 \\\\u0648 \\\\u06af\\\\u0648\\\\u0634\')=1;"
# ۲) endpoint واقعی — باید غیرصفر شود
curl -s "https://clinic-pro.ddev.site/api/v1/blogs?limit=5&tag=چشم و گوش" | python3 -m json.tool | head -20
# ۳) تگ ناموجود → لیست خالی، بدون خطا
curl -s "https://clinic-pro.ddev.site/api/v1/blogs?tag=nope" | python3 -m json.tool | tail -5
# ۴) پیشوند نباید match شود (مرزی)
curl -s "https://clinic-pro.ddev.site/api/v1/blogs?tag=سلامت" | python3 -m json.tool | tail -5 # totalRecords: 0
```
### ۲. endpoint واژگان تگ‌های بلاگ: `GET /api/v1/blogs/tags`
**چرا endpoint جدید (قاعدهٔ «اول بگرد، بعد توسعه بده، در آخر بساز»):** هیچ endpoint موجودی این
داده را نمی‌دهد — `GET /api/v1/tags` واژگان `Tag` entity است و با رشته‌های `blogs.tags` هم‌پوشانی
ندارد؛ `GET /api/v1/blogs` سقف `limit=50` دارد و استخراج سمت کلاینت روی ۱۱۷ رکورد ساختاراً ناقص
است و city scope هم نمی‌گیرد.
متد repository — تگ‌ها با یک کوئری آرایه‌ای خوانده و در PHP شمرده می‌شوند (تعداد رکوردهای
منتشرشده کوچک است و DQL راهی برای unnest کردن آرایهٔ JSON ندارد):
```php
/**
* Distinct tag names across published posts, with post counts, honouring the
* same city scope as the public list. Blog tags are free-form strings inside a
* JSON column, so they are counted in PHP — DQL cannot unnest a JSON array.
*
* @return list<array{name: string, count: int}> sorted by count DESC, then name
*/
public function tagFacets(?int $cityId = null): array
{
$qb = $this->createQueryBuilder('b')
->select('b.tags')
->where('b.status = :status')
->setParameter('status', Blog::STATUS_PUBLISHED);
$this->applyCityFilter($qb, $cityId);
$counts = [];
foreach ($qb->getQuery()->getArrayResult() as $row) {
foreach ($row['tags'] ?? [] as $name) {
if (!is_string($name) || $name === '') {
continue;
}
$counts[$name] = ($counts[$name] ?? 0) + 1;
}
}
arsort($counts);
return array_map(
static fn(string $name, int $count) => ['name' => $name, 'count' => $count],
array_keys($counts),
array_values($counts)
);
}
```
اکشن کنترلر — کنار `list()`، با همان قرارداد خواندن `city_id`:
```php
#[OA\Get(
path: '/api/v1/blogs/tags',
summary: 'Distinct tag names of published posts with post counts',
parameters: [
new OA\Parameter(
name: 'city_id',
in: 'query',
required: false,
description: 'Same scope as GET /api/v1/blogs: that city\'s posts plus nationwide posts.',
schema: new OA\Schema(type: 'integer')
),
],
responses: [ /* 200: { success, data: [{ name, count }] } */ ]
)]
#[Route('/api/v1/blogs/tags', methods: ['GET'])]
public function tags(Request $request): JsonResponse
{
$cityId = $request->query->get('city_id') !== null
? max(1, (int) $request->query->get('city_id'))
: null;
return $this->success($this->blogRepo->tagFacets($cityId));
}
```
⚠️ مسیر `/api/v1/blogs/tags` باید در `config/packages/security.yaml` مثل بقیهٔ مسیرهای عمومی بلاگ
بدون احراز هویت در دسترس باشد؛ اگر الگوی موجود `^/api/v1/blogs` است تغییری لازم نیست — بررسی و
گزارش کن.
⚠️ `$this->success($array)` پاسخ `{success, data:[...]}` می‌دهد. عمداً `['data' => …]` پاس نده
تا double-nesting (`data.data`) اتفاق نیفتد — پرامپت فرانت روی همین شکل `data[]` نوشته شده است.
**نحوه تست:**
```bash
ddev exec php bin/console cache:clear
ddev exec php bin/console debug:router | grep "blogs/tags"
# سراسری
curl -s "https://clinic-pro.ddev.site/api/v1/blogs/tags" | python3 -m json.tool
# محدود به یک شهر — زیرمجموعهٔ خروجی بالا باشد
curl -s "https://clinic-pro.ddev.site/api/v1/blogs/tags?city_id=132" | python3 -m json.tool
# صحت شمارش: count یک تگ باید با totalRecords فیلترِ همان تگ برابر باشد
curl -s "https://clinic-pro.ddev.site/api/v1/blogs?limit=1&tag=چشم و گوش" | python3 -c "import sys,json;print(json.load(sys.stdin)['meta']['totalRecords'])"
```
### ۳. تست PHPUnit
فایل جدید `tests/Blog/BlogTagFilterTest.php` با الگوی `tests/Blog/BlogCityScopeTest.php`
(ساخت `City`/`Province`، ساخت `Blog` منتشرشده، فراخوانی `GET /api/v1/blogs...`):
- تگ فارسی: دو پست با `setTags(['چشم و گوش'])` و یکی با `setTags(['سلامت عمومی'])`
`?tag=چشم و گوش` دقیقاً همان دو پست را برگرداند و `meta.totalRecords === 2`
(این تست با کد فعلی **باید قرمز شود** — رگرسیون‌گارد باگ).
- پیشوند match نکند: `?tag=چشم``totalRecords === 0`.
- تگ با `_` و `%` (`?tag=a_b` روی پستی با تگ `axb`) → `0`، یعنی wildcard نیست.
- پست `draft` با همان تگ → نه در فیلتر، نه در `/api/v1/blogs/tags`.
- `city_id` + `tag` هم‌زمان: پست شهر دیگر با همان تگ نباید بیاید؛ پست سراسری باید بیاید.
- `/api/v1/blogs/tags` بدون `city_id` و با `city_id`، شامل مرزیِ `tags: []`.
**نحوه تست:** `ddev exec php bin/phpunit tests/Blog/BlogTagFilterTest.php`
و سپس کل دامنه: `ddev exec php bin/phpunit tests/Blog`
### ۴. به‌روزرسانی `docs/api/blog.md`
- خط ۱۸ (توضیح پارامتر `tag`): اضافه شود که match دقیق و case/format-sensitive روی نام تگ است و
wildcard نیست.
- بخش جدید برای `GET /api/v1/blogs/tags`: method/path/permission (عمومی)، پارامتر `city_id`، و
**JSON واقعیِ خروجی اجرای واقعی** (نه دست‌ساز)، به‌همراه `200` تنها status ممکن.
- یک جملهٔ کوتاه در بخش «نکات» دربارهٔ اینکه واژگان تگ بلاگ از `GET /api/v1/tags` (Tag entity)
جداست، تا کلاینت بعدی اشتباه نگیرد.
## نکات مهم
- **علت ریشه‌ای فقط encoding است، نه DQL و نه schema.** وسوسه نشو ستون را migrate کنی یا
`JsonContains` را بازنویسی کنی؛ داده سالم است و همان‌طور که Doctrine می‌نویسد خوانده می‌شود.
- **چرا `JSON_SEARCH` انتخاب نشد:** `JSON_SEARCH(tags,'one',:tag,NULL,'$[*]')` هم روی همین داده
جواب می‌دهد (تست شد: ۷۴ رکورد)، ولی آرگومان آن الگوی `LIKE` است و `_` و `%` را wildcard
می‌گیرد؛ برای فیلتر دسته‌بندی که باید دقیق باشد ریسک over-match دارد. `JSON_CONTAINS` با کاندید
هم‌encoding، هم دقیق است و هم DQL function موجود را استفاده می‌کند (بدون کد جدید).
- **مقاومت در برابر داده‌ی قدیمی:** اگر جایی رکوردی با unicode خام نوشته شده باشد (مثلاً import
با SQL خام) با این fix پیدا نمی‌شود. اگر تست نشان داد چنین رکوردهایی وجود دارند، شرط را به
`JSON_CONTAINS(b.tags, :tagEsc) = 1 OR JSON_CONTAINS(b.tags, :tagRaw) = 1` گسترش بده؛ در غیر
این صورت این شاخه اضافه نشود (abstraction بدون مصرف). بررسی:
`ddev exec mysql -uroot -proot db -e "SELECT COUNT(*) FROM blogs WHERE tags NOT LIKE '%\\\\\\\\u%' AND tags <> '[]';"`
- **`countPublished` و `findPublished` هر دو از `applyTagFilter` استفاده می‌کنند** — یک fix هر دو
را درست می‌کند؛ در تست حتماً هم `data` و هم `meta.totalRecords` را چک کن، چون ناهم‌خوانی این دو
در pagination سایت خودش را نشان می‌دهد.
- **کلاینت متأثر (cross-repo):** `nobat724_front``components/blogs/index.js` (پاس دادن
`params.tag`) و `components/blogs/title/index.js` (منبع چیپ‌ها). تغییر قرارداد در build آن‌ها
خطا نمی‌دهد؛ بعد از این پرامپت، پرامپت فرانت اجرا و رفتار واقعی روی
`http://yazd-nobat.localhost:3000/blogs` دستی بررسی شود.
- **کش:** لیست بلاگ در سایت با `next: { tags: ['blog-list'] }` کش می‌شود؛ endpoint جدید facet در
سمت کلاینت (`services/response.js`) صدا زده می‌شود و کش Next ندارد — نیازی به تغییر
`BlogCacheInvalidator` نیست، ولی اگر آن را به fetch سمت سرور بردی، tag `blog-list` را ثبت کن.