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:
@@ -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` را ثبت کن.
|
||||
Reference in New Issue
Block a user