feat: create SanitizeBlogBodiesCommand to clean existing blog bodies according to current HTML sanitization policies test: add AppointmentTreatmentSessionLinkTest to ensure appointment booking functionality works correctly with treatment session links
29 KiB
Blog API
Prefix:
/api/v1/blog,/api/v1/blogs
GET /api/v1/blogs
List published blog posts.
Permission: PUBLIC
Query Parameters
| Param | Type | Required | Default | Description |
|---|---|---|---|---|
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 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=چشم و گوش:
{
"success": true,
"data": [
{
"uuid": "b0b23efa-c974-4dc4-a1a2-ea78d99b75af",
"title": "تشخیص و نشانههای خشکی چشم مزمن با سوزش و خارش؛ ضرورت انجام سنجش تخصصی بیناییسنجی",
"slug": "تشخیص-و-نشانههای-خشکی-چشم-مزمن-با-سوزش-و-خارش؛-ضرورت-انجام-سنجش-تخصصی-بیناییسنجی-b0b23efa",
"summary": "خشکی چشم همراه با سوزش و خارش مزمن در یزد به دلیل شرایط محیطی اهمیت یافته است و تشخیص به موقع با سنجش تخصصی بیناییسنجی ضروری است.",
"image_url": null,
"tags": ["چشم و گوش"],
"status": "published",
"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": 16,
"totalPages": 16,
"currentPage": 1,
"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 در پاسخ لیست و جزئیات وجود دارد و دو حالت دارد:
| مقدار | معنا |
|---|---|
{ "id": "123", "name": "یاسوج" } |
پست شهری — به آن شهر تعلق دارد |
null |
پست سراسری — حالت دائمی و معتبر، نه «تنظیمنشده» |
city_idرویblogsnullable است وNULLمعنای دائمی «سراسری» دارد. رکوردهای قبل از این تغییر همگی سراسری شدند.- حذف شهر پست را حذف نمیکند (
ON DELETE SET NULL) — پست سراسری میشود. - پست سراسری روی همهٔ دامنههای شهری در لیست دیده میشود؛ فقط canonical آن روی دامنهٔ اصلی مینشیند. به همین دلیل
city_id=Xهم پستهای شهر X و هم پستهای سراسری را برمیگرداند — نه فقط شهر X.
🔗 مصرفکننده: سایت عمومی چند-دامنهای (
nobat724_front) با همین فیلد تصمیم میگیرد پست را روی دامنهٔ شهر canonical کند یا روی دامنهٔ اصلی، و در کدام sitemap بگذارد. تغییر معنایnullقرارداد آن را میشکند.
GET /api/v1/blogs/tags
واژگان تگهای مقالات منتشرشده بههمراه تعداد پست هر تگ. مصرفکننده: ردیف چیپهای دستهبندی در /blogs سایت عمومی.
Permission: PUBLIC
این endpoint با
GET /api/v1/tagsیکی نیست. آن، واژگانِTagentity است (دیابت،تغذیه، …) و به مقالهها وصل نیست؛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:
{
"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.
Permission: ROLE_ADMIN
Query Parameters
| Param | Type | Required | Default | Description |
|---|---|---|---|---|
page |
integer | ❌ | 1 | Page number |
limit |
integer | ❌ | 20 (max 50) | Items per page |
status |
string | ❌ | — | Filter by draft / published / archived. Omit for all. |
search |
string | ❌ | — | Title search (LIKE). |
Response 200
Paginated ({ data:[...], meta:{...} }), each item the blog list shape (includes status, review_status, scheduled_at, representation, city).
GET /api/v1/blog/{slug}
Get a single blog post by slug.
Permission: PUBLIC
Path Parameters
| Param | Type | Description |
|---|---|---|
slug |
string | URL slug (e.g. ashnayi-ba-bimari-diabat) or UUID |
Query Parameters
| Param | Type | Required | Description |
|---|---|---|---|
city_id |
int | ❌ | Domain scope of the caller. Same rule as GET /api/v1/blogs?city_id=…: the post is returned only when it belongs to this city or is nationwide (city IS NULL). A post owned by another city — including a post assigned to the root record نوبت 724 (id 600) — returns 404. Omit on the main domain (nobat724.com) to read any post. |
بدون این پارامتر، پستی که از لیستِ یک دامنه فیلتر شده بود همچنان با URL مستقیم روی همان دامنه ۲۰۰ میگرفت و یک محتوا روی چند دامنه تکرار میشد. سایت عمومی (
nobat724_front) روی دامنههای شهری همیشهcity_idرا میفرستد.
Response 200
⚠️ پاسخ double-nested است (
data.data) — کنترلر$this->success(['data' => $blog->toArray()])برمیگرداند. کلاینت بایدdata?.dataرا بخواند.
شکل کامل، عیناً خروجی Blog::toArray():
{
"success": true,
"data": {
"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 روی پیشنویس)
{
"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
}
}
}
Errors
| Code | HTTP | Description |
|---|---|---|
ERR_AUTH_001 |
401 | بدون توکن |
ERR_FORBIDDEN_001 |
403 | توکن معتبر ولی بدون ROLE_ADMIN |
ERR_NOT_FOUND_001 |
404 | مقاله با این UUID وجود ندارد |
POST /api/v1/blog
Create a new blog post.
Permission: ROLE_ADMIN, or ROLE_IMPORTER (the content pipeline's system user).
A caller that is not
ROLE_ADMINalways produces a review-gated draft: whateverstatus/review_statusit sends, the post is stored asstatus=draft,review_status=pending_review. Approving/publishing staysROLE_ADMIN-only viaPOST /api/v1/admin/blog/{uuid}/review.
Request Body (application/json)
{
"title": "آشنایی با بیماری دیابت",
"body": "<p>محتوای کامل مقاله...</p>",
"summary": "خلاصه کوتاه از مقاله",
"tags": [1, 2],
"status": "draft",
"image_url": "/uploads/blogs/blog_abc.jpg"
}
| Field | Type | Required | Description |
|---|---|---|---|
title |
string | ✅ | Post title (slug auto-generated) |
body |
string | ✅ | Full HTML body (from the admin CKEditor). پیش از ذخیره پاکسازی میشود — سیاست پاکسازی بدنه. |
summary |
string | ❌ | Short excerpt |
tags |
integer[] | ❌ | Array of tag IDs |
status |
string | ❌ | "draft" (default) or "published" |
image_url |
string | ❌ | Cover image path returned by the upload endpoint |
city_id |
integer|null | ❌ | City this post belongs to. Omitting it, or sending null/0, creates a nationwide post. An unknown city id is rejected with 422. |
sources |
array | ❌ | E-E-A-T source list. Each item { "url": "...", "title": "..." }. Used by the content pipeline; shown on the published post. |
review_status |
string|null | ❌ | Medical-review gate. Omit/null = manual admin post (no review). The content pipeline sends "pending_review" so the post enters the doctor review queue (GET /api/v1/admin/blog/review-queue). |
topic_slug |
string | ❌ | Stable pipeline topic key (specialty, angle), unique. Makes creation idempotent: posting the same topic_slug again returns the existing post with HTTP 200 (not a duplicate 201). |
meta_title |
string | ❌ | SEO <title> override (falls back to title). |
meta_description |
string | ❌ | SEO meta description. |
primary_keyword |
string | ❌ | Primary target keyword. |
secondary_keywords |
string[] | ❌ | Secondary keywords (empty entries dropped). |
faq |
array | ❌ | [{ "q": "...", "a": "..." }] — rendered as FAQPage JSON-LD on the site. Malformed entries dropped. |
internal_links / external_links |
array | ❌ | Link suggestions, e.g. ["https://..."] or [{ "url", "anchor" }]. |
reading_time |
integer | ❌ | Estimated minutes. |
canonical_url |
string | ❌ | Canonical override. |
og_image |
string | ❌ | Open Graph image URL. |
scheduled_at |
integer | ❌ | Unix timestamp for automatic publish. Omit for manual publishing. A scheduled post publishes only once review_status = approved (or null) — the review gate wins. |
The same SEO/scheduling fields are accepted on
PATCH /api/v1/blog/{uuid}(omitted keys untouched). All are returned in the blogtoArray.
Response 201
{
"success": true,
"data": {
"uuid": "blog-uuid-...",
"title": "آشنایی با بیماری دیابت",
"slug": "ashnayi-ba-bimari-diabat",
"summary": "...",
"body": "...",
"image": null,
"tags": [],
"status": "draft",
"created_at": 1717000000
}
}
Blog Status Values:
| Value | Description |
|---|---|
draft |
Not visible to public |
published |
Visible in public listing |
archived |
Hidden from listing |
Errors
| Code | HTTP | Description |
|---|---|---|
ERR_AUTH_001 |
401 | Missing token |
ERR_AUTH_006 |
403 | Not admin |
ERR_VALIDATION_002 |
422 | Missing required fields |
PATCH /api/v1/blog/{uuid}
Update a blog post.
Permission: ROLE_ADMIN
Path Parameters
| Param | Type | Description |
|---|---|---|
uuid |
string (UUID) | Blog UUID |
Request Body (application/json)
{
"title": "عنوان جدید",
"body": "<p>محتوای جدید</p>",
"summary": "خلاصه جدید",
"tags": [1, 3],
"status": "published",
"image_url": "/uploads/blogs/blog_abc.jpg"
}
All fields optional. Send image_url: "" to clear the cover image.
city_id follows PATCH semantics: omit it and the post's city is left untouched; send null (or 0) to turn the post into a nationwide one; send a city id to move it to that city.
Response 200
Updated blog object.
Errors
| Code | HTTP | Description |
|---|---|---|
ERR_AUTH_001 |
401 | Missing token |
ERR_AUTH_006 |
403 | Not admin |
ERR_NOT_FOUND_001 |
404 | Blog not found |
ERR_VALIDATION_002 |
422 | Unknown city_id |
Representative blog (scoped to one representative)
A representative (ROLE_REPRESENTATION) manages their own posts for their own domain. They see and edit only their own posts, and may target only cities within their coverage (representation.cities). Admin CRUD (/api/v1/blog*) is unaffected — an admin still sees everything.
| Route | Method | Path | Permission |
|---|---|---|---|
| list own | GET | /api/v1/representation/blogs |
ROLE_REPRESENTATION (page,limit,status) |
| get own | GET | /api/v1/representation/blog/{uuid} |
ROLE_REPRESENTATION |
| create | POST | /api/v1/representation/blog |
ROLE_REPRESENTATION |
| update own | PATCH | /api/v1/representation/blog/{uuid} |
ROLE_REPRESENTATION |
| delete own | DELETE | /api/v1/representation/blog/{uuid} |
ROLE_REPRESENTATION |
- On create,
author= the representative's user andrepresentation= their record (set server-side). city_idmust be one of the representative's coverage cities, else422. A post that isn't owned by the caller returns404(never leaked).- The request body accepts the same core + SEO + scheduling fields as the admin create.
- The blog
toArray/toListArrayincludesrepresentation({ uuid, name, domain }ornull).
Errors
| Code | HTTP | Description |
|---|---|---|
ERR_AUTH_006 |
403 | Not a representative / no representative profile |
ERR_NOT_FOUND_001 |
404 | Post not found or not owned by the caller |
ERR_VALIDATION_002 |
422 | Missing title/body, unknown city, or city outside coverage |
Scheduled publishing
scheduled_at (unix) drives automatic publishing. A scheduler task (PublishScheduledBlogsMessage, every 1 minute via symfony/scheduler, consumed by messenger:consume scheduler_default) publishes every draft whose scheduled_at <= now and whose review_status is null or approved. A pending_review post is never auto-published — the medical-review gate wins over the schedule.
Medical-review gate
The content pipeline (clinicpro-crawler/content/) generates Persian health articles as drafts (status=draft, review_status=pending_review). A doctor/admin then approves or rejects each one before it goes public. The reviewer's identity is stored and shown on the post — the E-E-A-T signal for YMYL content. review_status=null posts (created manually by an admin) are outside this gate.
review_status values:
| Value | Meaning |
|---|---|
null |
Manual admin post — never entered the review workflow |
pending_review |
Awaiting a doctor's decision (in the queue) |
approved |
Reviewed and approved (usually also status=published) |
rejected |
Reviewed and rejected — stays a draft |
GET /api/v1/admin/blog/review-queue
List blog drafts awaiting review, newest first.
Permission: ROLE_ADMIN or ROLE_IMPORTER (read-only; the crawler panel shows this queue).
Query Parameters
| Param | Type | Required | Default | Description |
|---|---|---|---|---|
page |
integer | ❌ | 1 | Page number |
limit |
integer | ❌ | 20 (max 50) | Items per page |
Response 200
Paginated ({ data:[...], meta:{ totalRecords, totalPages, currentPage } }). Each item is the blog list shape plus review_status, reviewer ({ uuid, name } or null), and topic_slug.
صف، متنِ مقاله را نمیدهد. ردیفها از
Blog::toListArray()میآیند، پسbody،faq،sourcesو فیلدهای SEO در آنها نیست — ۱۵ متن کامل HTML در یک پاسخِ فهرست جایی ندارد. کلاینتی که میخواهد مقاله را کامل نشان دهد (مثل مودال بازبینیِ/admin/blog-review) باید هنگام باز شدن،GET /api/v1/admin/blog/{uuid}را صدا بزند. پاسخ آن double-nested است:data.data.
{
"success": true,
"data": [
{
"uuid": "...",
"title": "علائم سکته قلبی که نباید نادیده بگیرید",
"slug": "...",
"summary": "...",
"status": "draft",
"review_status": "pending_review",
"reviewer": null,
"topic_slug": "cardiology-heart-attack-symptoms",
"city": null,
"created_at": 1769000000
}
],
"meta": { "totalRecords": 3, "totalPages": 1, "currentPage": 1 }
}
Errors
| Code | HTTP | Description |
|---|---|---|
ERR_AUTH_001 |
401 | Missing token |
ERR_AUTH_006 |
403 | Not admin |
POST /api/v1/admin/blog/{uuid}/review
Record a doctor's review decision. Approving publishes the post by default.
Permission: ROLE_ADMIN
Path Parameters
| Param | Type | Description |
|---|---|---|
uuid |
string (UUID) | Blog UUID |
Request Body (application/json)
{ "decision": "approved", "publish": true }
{ "decision": "rejected", "note": "ادعاهای پزشکی بدون منبع کافی" }
| Field | Type | Required | Description |
|---|---|---|---|
decision |
string | ✅ | "approved" or "rejected" |
note |
string | ⚠️ | Reviewer note. Required when decision=rejected. |
publish |
boolean | ❌ | On approval, publish immediately. Default true. false keeps it a draft. |
On approve: review_status=approved, reviewer/reviewed_at set, and status=published unless publish:false. On reject: review_status=rejected, note stored, status stays draft.
Response 200
Updated blog object (full toArray, including reviewer, reviewed_at, review_note).
Errors
| Code | HTTP | Description |
|---|---|---|
ERR_AUTH_001 |
401 | Missing token |
ERR_AUTH_006 |
403 | Not admin |
ERR_NOT_FOUND_001 |
404 | Blog not found |
ERR_VALIDATION_002 |
422 | Invalid decision, or rejected without a note |
DELETE /api/v1/blog/{uuid}
Delete a blog post.
Permission: ROLE_ADMIN
Response 200
{ "success": true, "data": { "message": "مقاله حذف شد" } }
Errors
| Code | HTTP | Description |
|---|---|---|
ERR_AUTH_001 |
401 | Missing token |
ERR_AUTH_006 |
403 | Not admin |
ERR_NOT_FOUND_001 |
404 | Blog not found |
POST /file/upload/clinic_pro/blog/field_image
Upload blog post header image.
Permission: ROLE_ADMIN
Request
Content-Type: multipart/form-data
| Field | Type | Required | Description |
|---|---|---|---|
file |
binary | ✅ | Image (max 5MB) |
blog_uuid |
string (UUID) | ❌ | Auto-associate with blog post |
Response 200
{
"success": true,
"data": {
"url": "https://clinic-pro.ddev.site/uploads/blog/post_abc.jpg",
"uuid": "...",
"filename": "post_abc.jpg",
"filemime": "image/jpeg",
"filesize": 204800
}
}
Errors
| Code | HTTP | Description |
|---|---|---|
ERR_AUTH_001 |
401 | Missing token |
ERR_AUTH_006 |
403 | Not admin |
ERR_FILE_001 |
422 | Invalid file type |
سیاست پاکسازی بدنه
بدنهٔ مقاله در لحظهٔ ذخیره پاکسازی میشود، نه هنگام نمایش — هر چهار نقطهٔ ورود
(POST/PATCH در BlogController و RepresentationBlogController) از
App\Blog\Service\BlogBodySanitizer میگذرند. دلیلش چند مصرفکننده بودنِ بدنه است:
پنل ادمین، سایت عمومی و فید. با پاکسازی در لایهٔ نمایش، هر مصرفکنندهٔ تازه دوباره
آسیبپذیر شروع میکرد.
سیاست در config/packages/html_sanitizer.yaml است. سه قاعدهای که رفتار قابلمشاهده
دارند:
<script>,<style>,<iframe>,<object>,<embed>,<form>,<input>,<button>,<noscript>با محتوایشان حذف میشوند (dropنهblock) — وگرنه<script>alert(1)</script>به متنِalert(1)تبدیل میشد.- روی هر
<a>مقدارrel="noopener noreferrer"تحمیل میشود؛ طرحهای مجاز فقطhttp,https,mailto. <table>سه attributeِ ظاهریِ قدیمی را نگه میدارد —border,cellpadding,cellspacing— ولیstyleرا از هر عنصری حذف میکند. ظاهرِ جدولِ بدنهٔ مقاله در پنل از.blog-bodyدرassets/admin/styles.cssمیآید.
بدنهای که پس از پاکسازی خالی شود 422 میگیرد، نه اینکه خالی ذخیره شود.
پاکسازی مقالههای قدیمی
مقالههایی که پیش از این سیاست ذخیره شدهاند با یک دستور به همان وضع میرسند:
ddev exec php bin/console app:blog:sanitize-bodies --dry-run # گزارش، بدون نوشتن
ddev exec php bin/console app:blog:sanitize-bodies # اعمال
ddev exec php bin/console app:blog:sanitize-bodies --show=42 # قبل/بعدِ یک مقاله
گزارشِ dry-run تغییرها را تفکیک میکند: «سختسازی» یعنی فقط rel اضافه یا entity
decode شده، و «حذفِ تگ یا attribute غیرمجاز» یعنی آن مقاله markupی داشته که سیاست
نمیپذیرد. بدونِ این تفکیک، عددِ کلِ تغییرات گمراهکننده است — در اجرای ۲۰۲۶-۰۸-۰۸ از
۴۲۶ مقالهٔ تغییریافته، ۳۹۸ فقط سختسازی بودند.
دستور idempotent است: اجرای دوم صفر تغییر گزارش میدهد.
باطلسازی کش سایت عمومی (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 (خروجی شنوندهٔ تست):
{"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 برمیگرداند.