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` را ثبت کن.
|
||||
@@ -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
@@ -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.
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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(); }
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user