feat(blog): add admin endpoint for blog details and cache invalidation

- Implemented `adminDetail()` method in `BlogController` to retrieve blog posts of any status for admin editing.
- Introduced `BlogCacheInvalidator` service to handle cache invalidation after blog create/update/delete actions.
- Updated existing methods in `BlogController` and `RepresentationBlogController` to call cache invalidation on blog modifications.
- Enhanced `BlogFormPage` and `RepresentationBlogFormPage` to utilize the new admin endpoint for fetching blog data.
- Added tests for `BlogCacheInvalidator` to ensure proper functionality and error handling.
- Updated documentation to reflect new API endpoint and cache invalidation behavior.
This commit is contained in:
hamed
2026-07-27 18:54:35 +03:30
parent e4edaea9b8
commit 15abcb5c8a
12 changed files with 972 additions and 47 deletions
+151 -12
View File
@@ -100,21 +100,113 @@ Get a single blog post by slug.
> بدون این پارامتر، پستی که از لیستِ یک دامنه فیلتر شده بود همچنان با URL مستقیم روی همان دامنه ۲۰۰ می‌گرفت و یک محتوا روی چند دامنه تکرار می‌شد. سایت عمومی (`nobat724_front`) روی دامنه‌های شهری همیشه `city_id` را می‌فرستد.
### Response `200`
> ⚠️ پاسخ **double-nested** است (`data.data`) — کنترلر `$this->success(['data' => $blog->toArray()])` برمی‌گرداند. کلاینت باید `data?.data` را بخواند.
شکل کامل، عیناً خروجی `Blog::toArray()`:
```json
{
"success": true,
"data": {
"uuid": "...",
"title": "آشنایی با بیماری دیابت",
"slug": "ashnayi-ba-bimari-diabat",
"summary": "خلاصه...",
"body": "<p>محتوای کامل...</p>",
"image": "https://...",
"author": { "uuid": "...", "real_name": "احمدی" },
"tags": [{ "id": 1, "name": "دیابت" }],
"status": "published",
"created_at": 1717000000,
"updated_at": 1717100000
"data": {
"uuid": "...",
"title": "آشنایی با بیماری دیابت",
"slug": "ashnayi-ba-bimari-diabat",
"summary": "خلاصه...",
"body": "<p>محتوای کامل...</p>",
"image_url": "/uploads/blogs/blog_xxx_cover.png",
"tags": ["دیابت", "تغذیه"],
"sources": [{ "url": "https://...", "title": "..." }],
"status": "published",
"review_status": null,
"reviewer": null,
"reviewed_at": null,
"review_note": null,
"topic_slug": null,
"meta_title": null,
"meta_description": null,
"primary_keyword": null,
"secondary_keywords": [],
"faq": [{ "q": "...", "a": "..." }],
"internal_links": [],
"external_links": [],
"reading_time": null,
"canonical_url": null,
"og_image": null,
"scheduled_at": null,
"representation": null,
"author": { "uuid": "...", "name": "احمدی" },
"city": null,
"created_at": 1717000000,
"updated_at": 1717100000
}
}
}
```
> نکته: `tags` آرایهٔ **رشته** است (نه آبجکت `{id,name}`)، تصویر در `image_url` (نه `image`) و نام نویسنده در `author.name` (نه `real_name`). نسخهٔ قبلی این سند شکل دیگری نشان می‌داد که با کد هم‌خوان نبود.
### Errors
| Code | HTTP | Description |
|------|------|-------------|
| `ERR_NOT_FOUND_001` | 404 | Blog not found, not published, or owned by another city (when `city_id` is sent) |
---
## GET `/api/v1/admin/blog/{uuid}`
Get **one** blog post of any status for the admin edit form.
**Permission:** `ROLE_ADMIN`
اندپوینت عمومی `GET /api/v1/blog/{slug}` هر پستِ غیر `published` را ۴۰۴ می‌کند، پس فرم ویرایش پنل ادمین نمی‌توانست پیش‌نویس و آرشیو را بارگذاری کند (فرم خالی بالا می‌آمد). این اندپوینت هیچ فیلتر `status`/`city` ندارد. اندپوینت عمومی عمداً دست‌نخورده ماند تا پاسخِ cache-شدهٔ آن وابسته به نقشِ فراخوان نشود.
### Path Parameters
| Param | Type | Description |
|-------|------|-------------|
| `uuid` | string | UUID مقاله. الگوی اجباری `[0-9a-fA-F-]{36}` — بدون آن این route مسیر `/api/v1/admin/blog/review-queue` را می‌دزدید. **فقط UUID؛ slug پذیرفته نمی‌شود.** |
### Response `200` (خروجی واقعی curl روی پیش‌نویس)
```json
{
"success": true,
"data": {
"data": {
"uuid": "56fd9a20-9594-4aa1-a651-346fa86720bd",
"title": "rtertert",
"slug": "نوبتدهی-آنلاین-کلینیک-از-صف-انتظار-تا-پروندهٔ-دیجیتال-56fd9a20",
"summary": "ertet",
"body": "<p>etetetet</p>",
"image_url": null,
"tags": [],
"sources": [],
"status": "draft",
"review_status": null,
"reviewer": null,
"reviewed_at": null,
"review_note": null,
"topic_slug": null,
"meta_title": null,
"meta_description": null,
"primary_keyword": null,
"secondary_keywords": [],
"faq": [
{ "q": "etrtert", "a": "retet" },
{ "q": "eertret", "a": "ertet" }
],
"internal_links": [],
"external_links": [],
"reading_time": null,
"canonical_url": null,
"og_image": null,
"scheduled_at": null,
"representation": null,
"author": { "uuid": "7464eba2-6754-4cea-bcde-83e47050e829", "name": "1402/12/17 12" },
"city": null,
"created_at": 1784827054,
"updated_at": 1785157798
}
}
}
```
@@ -122,7 +214,9 @@ Get a single blog post by slug.
### Errors
| Code | HTTP | Description |
|------|------|-------------|
| `ERR_NOT_FOUND_001` | 404 | Blog not found, not published, or owned by another city (when `city_id` is sent) |
| `ERR_AUTH_001` | 401 | بدون توکن |
| `ERR_FORBIDDEN_001` | 403 | توکن معتبر ولی بدون `ROLE_ADMIN` |
| `ERR_NOT_FOUND_001` | 404 | مقاله با این UUID وجود ندارد |
---
@@ -433,3 +527,48 @@ Upload blog post header image.
| `ERR_AUTH_001` | 401 | Missing token |
| `ERR_AUTH_006` | 403 | Not admin |
| `ERR_FILE_001` | 422 | Invalid file type |
---
## باطل‌سازی کش سایت عمومی (webhook خروجی)
سایت عمومی (`nobat724_front`) پاسخ `GET /api/v1/blog/{slug}` را با `next: { revalidate: 3600, tags: [...] }` کش می‌کند. بدون باطل‌سازی، هر تغییر در پنل ادمین تا یک ساعت روی سایت دیده نمی‌شد.
`App\Blog\Service\BlogCacheInvalidator` بعد از **هر نوشتن** یک درخواست خروجی می‌فرستد:
| نقطهٔ فراخوانی | فایل |
|---|---|
| `POST /api/v1/blog` (create) | `BlogController::create` |
| `PATCH /api/v1/blog/{uuid}` | `BlogController::update` |
| `POST /api/v1/admin/blog/{uuid}/review` | `BlogController::review` |
| `DELETE /api/v1/blog/{uuid}` | `BlogController::delete` (**قبل** از `remove`) |
| `POST/PATCH/DELETE /api/v1/representation/blog…` | `RepresentationBlogController` |
### قرارداد درخواست (خروجی — مصرف‌کننده `nobat724_front`)
```
POST {REVALIDATE_WEBHOOK_URL}
X-Revalidate-Secret: {REVALIDATE_WEBHOOK_SECRET}
Content-Type: application/json
{"tags":["blog-<slug>","blog-<uuid>","blog-list"]}
```
بدنهٔ واقعی یک `PATCH` (خروجی شنوندهٔ تست):
```json
{"tags":["blog-نوبتدهی-آنلاین-کلینیک-از-صف-انتظار-تا-پروندهٔ-دیجیتال-56fd9a20","blog-56fd9a20-9594-4aa1-a651-346fa86720bd","blog-list"]}
```
پاسخ مورد انتظار سایت: `200 {"revalidated": true}`؛ سکرت غلط `401`؛ بدنهٔ بدون `tags``400`.
### متغیرهای محیطی
| متغیر | پیش‌فرض | معنا |
|---|---|---|
| `REVALIDATE_WEBHOOK_URL` | خالی | آدرس webhook سایت. **خالی = هیچ درخواستی ارسال نمی‌شود** |
| `REVALIDATE_WEBHOOK_SECRET` | خالی | باید با `REVALIDATE_SECRET` سمت سایت یکی باشد. خالی = ارسال نمی‌شود |
### رفتار خطا — fail-open
timeout سه ثانیه است و هر خطای شبکه فقط با سطح `warning` لاگ می‌شود؛ **ذخیره/حذف مقاله هرگز به خاطر شکست webhook شکست نمی‌خورد.** تست‌شده: با URL غیرقابل‌دسترس، `PATCH` همچنان `200` برمی‌گرداند.