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` را ثبت کن.
+2 -1
View File
@@ -37,7 +37,7 @@ security:
# مجاز) و هم برای پنل مدیریت (توکن معتبر → احراز می‌شود تا management=1 کار کند)
# سرویس می‌دهد. برای همین از الگوی زیر خارج شده‌اند.
public_endpoints:
pattern: ^/(api/v1/altcha/(challenge|config)$|api/v1/user/(send-code|verify-code|register|otp-login|reset-password)|oauth/token$|session/token|api/v1/categorys/|api/v1/doctors$|api/v1/clinics$|api/v1/clinic/doctor-list/|api/v1/clinic/[^/]+/addresses$|api/v1/clinic-pro/doctor-addresses/|api/v1/appointment-booking-services/|api/v1/comments/|api/v1/rate/[^/]+$|api/v1/specialties|api/v1/blogs$|api/v1/tags$|api/v1/clinic-invitation/|api/v1/pre-registration$|api/v1/doctor/[^/]+/claim-info$)
pattern: ^/(api/v1/altcha/(challenge|config)$|api/v1/user/(send-code|verify-code|register|otp-login|reset-password)|oauth/token$|session/token|api/v1/categorys/|api/v1/doctors$|api/v1/clinics$|api/v1/clinic/doctor-list/|api/v1/clinic/[^/]+/addresses$|api/v1/clinic-pro/doctor-addresses/|api/v1/appointment-booking-services/|api/v1/comments/|api/v1/rate/[^/]+$|api/v1/specialties|api/v1/blogs(/tags)?$|api/v1/tags$|api/v1/clinic-invitation/|api/v1/pre-registration$|api/v1/doctor/[^/]+/claim-info$)
stateless: true
security: false
@@ -75,6 +75,7 @@ security:
methods: [GET]
roles: PUBLIC_ACCESS
- { path: ^/api/v1/blogs$, roles: PUBLIC_ACCESS }
- { path: ^/api/v1/blogs/tags$, methods: [GET], roles: PUBLIC_ACCESS }
- path: '^/api/v1/blog/[^/]+$'
methods: [GET]
roles: PUBLIC_ACCESS
+63 -13
View File
@@ -15,36 +15,49 @@ List published blog posts.
|-------|------|----------|---------|-------------|
| `page` | integer | ❌ | 1 | Page number |
| `limit` | integer | ❌ | 20 | Items per page |
| `tag` | string | ❌ | — | Filter by exact tag **name** (e.g. `?tag=دیابت`). Blog tags are stored as a JSON array of names; only blogs whose `tags` array contains this exact name are returned. |
| `tag` | string | ❌ | — | Filter by exact tag **name** (e.g. `?tag=چشم و گوش`). Blog tags are stored as a JSON array of names; only posts whose `tags` array contains this exact name are returned. The match is **exact, not a prefix and not a wildcard**`?tag=سلامت` does not match `سلامت عمومی`, and `_` / `%` are literal characters. The vocabulary of valid values is `GET /api/v1/blogs/tags`. |
| `city_id` | integer | ❌ | — | Scope to one city. Returns that city's posts **plus every nationwide post** (`city_id IS NULL`). Omit it to return all published posts regardless of city. |
### Response `200`
Real output of `GET /api/v1/blogs?limit=1&tag=چشم و گوش`:
```json
{
"success": true,
"data": [
{
"uuid": "...",
"title": "آشنایی با بیماری دیابت",
"slug": "ashnayi-ba-bimari-diabat",
"summary": لاصه مطلب...",
"image": "https://...",
"author": { "uuid": "...", "real_name": "احمدی" },
"tags": [{ "id": 1, "name": "دیابت" }],
"uuid": "b0b23efa-c974-4dc4-a1a2-ea78d99b75af",
"title": "تشخیص و نشانه‌های خشکی چشم مزمن با سوزش و خارش؛ ضرورت انجام سنجش تخصصی بینایی‌سنجی",
"slug": "تشخیص-و-نشانههای-خشکی-چشم-مزمن-با-سوزش-و-خارش؛-ضرورت-انجام-سنجش-تخصصی-بیناییسنجی-b0b23efa",
"summary": شکی چشم همراه با سوزش و خارش مزمن در یزد به دلیل شرایط محیطی اهمیت یافته است و تشخیص به موقع با سنجش تخصصی بینایی‌سنجی ضروری است.",
"image_url": null,
"tags": ["چشم و گوش"],
"status": "published",
"city": { "id": "123", "name": "یاسوج" },
"created_at": 1717000000
"review_status": "approved",
"reviewer": { "uuid": "7464eba2-6754-4cea-bcde-83e47050e829", "name": "ادمین" },
"topic_slug": "optometry-symptoms-132",
"scheduled_at": null,
"representation": null,
"city": { "id": "132", "name": "یزد" },
"created_at": 1785236601
}
],
"meta": {
"totalRecords": 25,
"totalPages": 2,
"totalRecords": 16,
"totalPages": 16,
"currentPage": 1,
"limit": 20
"limit": 1
}
}
```
> `tags` آرایهٔ **رشته** است (نه آبجکت `{id,name}`) و تصویر در `image_url` می‌آید — نسخهٔ قبلی این نمونه شکل دیگری نشان می‌داد که با کد هم‌خوان نبود.
#### چرا فیلتر `tag` قبلاً همیشه خالی برمی‌گشت
ستون `blogs.tags` از نوع Doctrine `json` است و `JsonType` آن را با `json_encode` **بدون** `JSON_UNESCAPED_UNICODE` می‌نویسد؛ یعنی نام فارسی به شکل `["چشم و گوش"]` ذخیره می‌شود. `JSON_CONTAINS` در MariaDB این escapeها را نرمال نمی‌کند و مقایسه روی همان بایت‌ها انجام می‌شود، پس کاندیدِ فارسیِ خام هرگز match نمی‌شد. کاندید حالا با همان `json_encode` پیش‌فرض ساخته می‌شود. رگرسیون‌گارد: `tests/Blog/BlogTagFilterTest.php`.
### فیلد `city` — شهر پست
`city` در پاسخ لیست و جزئیات وجود دارد و دو حالت دارد:
@@ -62,6 +75,43 @@ List published blog posts.
---
## GET `/api/v1/blogs/tags`
واژگان تگ‌های مقالات **منتشرشده** به‌همراه تعداد پست هر تگ. مصرف‌کننده: ردیف چیپ‌های دسته‌بندی در `/blogs` سایت عمومی.
**Permission:** `PUBLIC`
> این endpoint با `GET /api/v1/tags` یکی نیست. آن، واژگانِ `Tag` entity است (`دیابت`، `تغذیه`، …) و به مقاله‌ها وصل نیست؛ `blogs.tags` آرایه‌ای از رشته‌های آزاد است. برای فیلتر مقالات فقط مقادیر همین endpoint معتبرند.
### Query Parameters
| Param | Type | Required | Default | Description |
|-------|------|----------|---------|-------------|
| `city_id` | integer | ❌ | — | همان scope `GET /api/v1/blogs`: پست‌های آن شهر + پست‌های سراسری (`city_id IS NULL`). بدون آن، همهٔ پست‌های منتشرشده شمرده می‌شوند. |
### Response `200`
مرتب‌سازی: `count` نزولی، سپس نام صعودی. تگ خالی و پست بدون تگ اصلاً وارد خروجی نمی‌شوند، پس هیچ ورودی‌ای `count: 0` ندارد.
خروجی واقعی `GET /api/v1/blogs/tags?city_id=132`:
```json
{
"success": true,
"data": [
{ "name": "سلامت عمومی", "count": 4 },
{ "name": "سلامت روان", "count": 1 },
{ "name": "چشم و گوش", "count": 1 },
{ "name": "گوارش و کبد", "count": 1 }
]
}
```
- `count` هر تگ دقیقاً برابر `meta.totalRecords` در `GET /api/v1/blogs?tag=<name>` با همان `city_id` است؛ یعنی هیچ چیپی به صفحهٔ خالی نمی‌رسد (تست: `testFacetCountMatchesFilteredTotal`).
- `city_id` ناشناخته خطا نمی‌دهد — دقیقاً مثل `GET /api/v1/blogs?city_id=…` فقط پست‌های سراسری می‌مانند.
- تنها status ممکن `200` است؛ ورودی نامعتبر `city_id` با `max(1, (int) …)` نرمال می‌شود.
---
## GET `/api/v1/admin/blogs`
List blog posts of **all** statuses for the admin panel. The public `GET /api/v1/blogs` only returns `published` posts, so the admin panel must use this endpoint to see drafts and archived posts.
+45
View File
@@ -105,6 +105,51 @@ class BlogController extends BaseController
return $this->paginated($blogs, $total, $page, $limit);
}
#[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 (city_id IS NULL). Omit to count every published post.',
schema: new OA\Schema(type: 'integer')
),
],
responses: [
new OA\Response(
response: 200,
description: 'Tag vocabulary of published posts, most used first',
content: new OA\JsonContent(
properties: [
new OA\Property(property: 'success', type: 'boolean', example: true),
new OA\Property(
property: 'data',
type: 'array',
items: new OA\Items(
properties: [
new OA\Property(property: 'name', type: 'string', example: 'چشم و گوش'),
new OA\Property(property: 'count', type: 'integer', example: 16),
],
type: 'object'
)
),
]
)
),
]
)]
#[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));
}
#[OA\Get(
path: '/api/v1/blog/{slug}',
summary: 'Get a published blog post by slug or UUID',
+41 -1
View File
@@ -153,6 +153,42 @@ class BlogRepository extends ServiceEntityRepository
return (int) $qb->getQuery()->getSingleScalarResult();
}
/**
* Distinct tag names across published posts with their post counts, honouring
* the same city scope as the public list. Blog tags are free-form strings in a
* JSON column, so they are counted in PHP — DQL cannot unnest a JSON array.
*
* @return list<array{name: string, count: int}> count DESC, then name ASC
*/
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;
}
}
// Ties keep a stable, human-predictable order instead of insertion order.
uksort($counts, static fn(string $a, string $b) => [$counts[$b], $a] <=> [$counts[$a], $b]);
return array_map(
static fn(string $name, int $count) => ['name' => $name, 'count' => $count],
array_keys($counts),
array_values($counts)
);
}
/**
* دامنهٔ یک شهر باید پست‌های همان شهر **و** پست‌های سراسری را ببیند — پست
* سراسری (city NULL) روی همهٔ دامنه‌ها منتشر است، فقط canonicalش روی دامنهٔ اصلی
@@ -174,8 +210,12 @@ class BlogRepository extends ServiceEntityRepository
return;
}
// Blog.tags is a JSON array of tag names; match exact name membership.
// Doctrine's JsonType writes this column with plain json_encode, so Persian
// names are stored \uXXXX-escaped. MariaDB's JSON_CONTAINS compares the two
// documents without normalizing those escapes, so the candidate must be
// encoded identically — JSON_UNESCAPED_UNICODE never matched a single row.
$qb->andWhere('JSON_CONTAINS(b.tags, :tag) = 1')
->setParameter('tag', json_encode($tag, JSON_UNESCAPED_UNICODE));
->setParameter('tag', json_encode($tag));
}
public function save(Blog $e, bool $flush = true): void { $this->getEntityManager()->persist($e); if ($flush) $this->getEntityManager()->flush(); }
+213
View File
@@ -0,0 +1,213 @@
<?php
namespace App\Tests\Blog;
use App\Blog\Entity\Blog;
use App\Location\Entity\City;
use App\Location\Entity\Province;
use App\Tests\ApiTestCase;
/**
* Blog tags live in a JSON column that Doctrine writes with plain json_encode, so
* Persian names are stored \uXXXX-escaped. MariaDB's JSON_CONTAINS does not
* normalize those escapes, which silently made every ?tag=<persian> query return
* an empty list. These cases pin both the filter and the tag facet endpoint.
*
* db_test is never reset, so every case tags its fixtures with a random suffix and
* asserts only on its own rows.
*/
class BlogTagFilterTest extends ApiTestCase
{
private function makeCity(string $name): City
{
$province = new Province($name);
$this->em->persist($province);
$city = new City($name, $province);
$this->em->persist($city);
return $city;
}
/** @param string[] $tags */
private function makePost(string $title, array $tags, ?City $city = null, string $status = Blog::STATUS_PUBLISHED): Blog
{
$blog = new Blog($this->createUser(['ROLE_ADMIN']), $title, 'متن آزمایشی مقاله برای تست');
$blog->setStatus($status)->setCity($city)->setTags($tags);
$this->em->persist($blog);
return $blog;
}
/** @return array{titles: string[], total: int} */
private function listBy(string $query): array
{
$this->client->request('GET', '/api/v1/blogs?limit=50&' . $query);
$this->assertSame(200, $this->responseCode());
$payload = json_decode($this->client->getResponse()->getContent(), true);
return [
'titles' => array_column($payload['data'], 'title'),
'total' => $payload['meta']['totalRecords'],
];
}
/** @return array<string, int> tag name => post count */
private function facets(string $query = ''): array
{
$this->client->request('GET', '/api/v1/blogs/tags' . ($query !== '' ? '?' . $query : ''));
$this->assertSame(200, $this->responseCode());
$payload = json_decode($this->client->getResponse()->getContent(), true);
return array_column($payload['data'], 'count', 'name');
}
public function testPersianTagFilterReturnsMatchingPosts(): void
{
$suffix = bin2hex(random_bytes(4));
$eye = "چشم و گوش-$suffix";
$other = "سلامت عمومی-$suffix";
$this->makePost("هاله رنگی-$suffix", [$eye]);
$this->makePost("خشکی چشم-$suffix", [$eye, $other]);
$this->makePost("تغذیه-$suffix", [$other]);
$this->em->flush();
$result = $this->listBy('tag=' . rawurlencode($eye));
$this->assertSame(2, $result['total'], 'meta.totalRecords must honour the tag filter too');
$this->assertContains("هاله رنگی-$suffix", $result['titles']);
$this->assertContains("خشکی چشم-$suffix", $result['titles']);
$this->assertNotContains("تغذیه-$suffix", $result['titles']);
}
public function testUnknownTagReturnsEmptyListNotAnError(): void
{
$result = $this->listBy('tag=' . rawurlencode('برچسب-ناموجود-' . bin2hex(random_bytes(4))));
$this->assertSame(0, $result['total']);
$this->assertSame([], $result['titles']);
}
public function testTagMatchIsExactNotAPrefix(): void
{
$suffix = bin2hex(random_bytes(4));
$this->makePost("مقاله-$suffix", ["سلامت عمومی-$suffix"]);
$this->em->flush();
$this->assertSame(0, $this->listBy('tag=' . rawurlencode('سلامت'))['total']);
}
public function testTagMatchDoesNotTreatUnderscoreOrPercentAsWildcard(): void
{
$suffix = bin2hex(random_bytes(4));
$this->makePost("مقاله-$suffix", ["axb-$suffix"]);
$this->em->flush();
$this->assertSame(0, $this->listBy('tag=' . rawurlencode("a_b-$suffix"))['total']);
$this->assertSame(0, $this->listBy('tag=' . rawurlencode("%-$suffix"))['total']);
}
public function testDraftPostsAreExcludedFromFilterAndFacets(): void
{
$suffix = bin2hex(random_bytes(4));
$tag = "پیش‌نویس-$suffix";
$this->makePost("منتشرشده-$suffix", [$tag]);
$this->makePost("پیش‌نویس-$suffix", [$tag], null, Blog::STATUS_DRAFT);
$this->em->flush();
$this->assertSame(1, $this->listBy('tag=' . rawurlencode($tag))['total']);
$this->assertSame(1, $this->facets()[$tag] ?? 0);
}
public function testTagAndCityFiltersApplyTogether(): void
{
$suffix = bin2hex(random_bytes(4));
$tag = "چشم و گوش-$suffix";
$yasuj = $this->makeCity('یاسوج');
$tabriz = $this->makeCity('تبریز');
$this->makePost("یاسوجی-$suffix", [$tag], $yasuj);
$this->makePost("تبریزی-$suffix", [$tag], $tabriz);
$this->makePost("سراسری-$suffix", [$tag], null);
$this->em->flush();
$result = $this->listBy('tag=' . rawurlencode($tag) . '&city_id=' . $yasuj->getId());
$this->assertSame(2, $result['total']);
$this->assertContains("یاسوجی-$suffix", $result['titles']);
$this->assertContains("سراسری-$suffix", $result['titles'], 'nationwide posts stay visible on a city domain');
$this->assertNotContains("تبریزی-$suffix", $result['titles']);
}
public function testFacetsCountPublishedPostsPerTag(): void
{
$suffix = bin2hex(random_bytes(4));
$eye = "چشم و گوش-$suffix";
$heart = "قلب و عروق-$suffix";
$this->makePost("اول-$suffix", [$eye]);
$this->makePost("دوم-$suffix", [$eye, $heart]);
$this->em->flush();
$facets = $this->facets();
$this->assertSame(2, $facets[$eye] ?? 0);
$this->assertSame(1, $facets[$heart] ?? 0);
}
public function testFacetsHonourCityScope(): void
{
$suffix = bin2hex(random_bytes(4));
$tag = "چشم و گوش-$suffix";
$yasuj = $this->makeCity('یاسوج');
$tabriz = $this->makeCity('تبریز');
$this->makePost("یاسوجی-$suffix", [$tag], $yasuj);
$this->makePost("تبریزی-$suffix", [$tag], $tabriz);
$this->makePost("سراسری-$suffix", [$tag], null);
$this->em->flush();
$this->assertSame(3, $this->facets()[$tag] ?? 0, 'unscoped facets count every published post');
$this->assertSame(2, $this->facets('city_id=' . $yasuj->getId())[$tag] ?? 0);
}
/**
* Every facet count must equal the totalRecords of filtering by that same tag —
* otherwise a chip in the public site leads to an empty result page.
*/
public function testFacetCountMatchesFilteredTotal(): void
{
$suffix = bin2hex(random_bytes(4));
$tag = "غدد و متابولیسم-$suffix";
$this->makePost("اول-$suffix", [$tag]);
$this->makePost("دوم-$suffix", [$tag]);
$this->makePost("سوم-$suffix", ["دیگر-$suffix"]);
$this->em->flush();
$this->assertSame(
$this->facets()[$tag] ?? 0,
$this->listBy('tag=' . rawurlencode($tag))['total']
);
}
public function testPostsWithoutTagsProduceNoFacetEntry(): void
{
$suffix = bin2hex(random_bytes(4));
$this->makePost("بدون برچسب-$suffix", []);
$this->em->flush();
$facets = $this->facets();
$this->assertArrayNotHasKey('', $facets, 'an empty tag must never become a facet');
$this->assertNotContains(0, $facets, 'no facet may report a zero count');
}
public function testFacetsEndpointIsPublic(): void
{
$this->client->request('GET', '/api/v1/blogs/tags');
$this->assertSame(200, $this->responseCode(), 'the public site calls this without a token');
}
}