feat: update allowed frontend hosts and add clinic-pro.ir domain
This commit is contained in:
@@ -34,7 +34,7 @@ APP_BASE_URL=https://api.nobat724.com
|
|||||||
# ddev exec php docker/gen-cors-env.php (یا روی سرور: php docker/gen-cors-env.php)
|
# ddev exec php docker/gen-cors-env.php (یا روی سرور: php docker/gen-cors-env.php)
|
||||||
# و خروجی ALLOWED_FRONTEND_HOSTS را اینجا/در Coolify جایگزین کن.
|
# و خروجی ALLOWED_FRONTEND_HOSTS را اینجا/در Coolify جایگزین کن.
|
||||||
# ⚠️ دیگر CORS_ALLOW_ORIGIN را ست نکن (استفاده نمیشود).
|
# ⚠️ دیگر CORS_ALLOW_ORIGIN را ست نکن (استفاده نمیشود).
|
||||||
ALLOWED_FRONTEND_HOSTS=ahvaz-nobat.ir,arak-nobat.ir,ardabil-nobat.ir,bandar-nobat.ir,behbahan-nobat.ir,birjand-nobat.ir,bojnord-nobat.ir,bushehr-nobat.ir,dehdasht-nobat.ir,esf-nobat.ir,golestan-nobat.ir,hamadan-nobat.ir,ilam-nobat.ir,karaj-nobat.ir,kerman-nobat.ir,kermanshah-nobat.ir,lorestan-nobat.ir,mashhad-nobat.ir,nobat724.com,qazvin-nobat.ir,qom-nobat.ir,rasht-nobat.ir,sanandaj-nobat.ir,sari-nobat.ir,semnan-nobat.ir,shiraz-nobat.ir,shkord-nobat.ir,tabriz-nobat.ir,tehran-nobat.ir,urmia-nobat.ir,yasuj-nobat.ir,yazd-nobat.ir,zahedan-nobat.ir,zanjan-nobat.ir
|
ALLOWED_FRONTEND_HOSTS=ahvaz-nobat.ir,arak-nobat.ir,ardabil-nobat.ir,bandar-nobat.ir,behbahan-nobat.ir,birjand-nobat.ir,bojnord-nobat.ir,bushehr-nobat.ir,clinic-pro.ir,dehdasht-nobat.ir,esf-nobat.ir,golestan-nobat.ir,hamadan-nobat.ir,ilam-nobat.ir,karaj-nobat.ir,kerman-nobat.ir,kermanshah-nobat.ir,lorestan-nobat.ir,mashhad-nobat.ir,nobat724.com,qazvin-nobat.ir,qom-nobat.ir,rasht-nobat.ir,sanandaj-nobat.ir,sari-nobat.ir,semnan-nobat.ir,shiraz-nobat.ir,shkord-nobat.ir,tabriz-nobat.ir,tehran-nobat.ir,urmia-nobat.ir,yasuj-nobat.ir,yazd-nobat.ir,zahedan-nobat.ir,zanjan-nobat.ir
|
||||||
|
|
||||||
# ── Secrets (REQUIRED — set before the first deploy) ──
|
# ── Secrets (REQUIRED — set before the first deploy) ──
|
||||||
APP_SECRET= # php -r "echo bin2hex(random_bytes(32));"
|
APP_SECRET= # php -r "echo bin2hex(random_bytes(32));"
|
||||||
|
|||||||
+1
-1
@@ -42,7 +42,7 @@ TRUSTED_PROXIES=10.0.0.0/8,172.16.0.0/12,192.168.0.0/16,127.0.0.1
|
|||||||
# (CorsRegexEnvProcessor → nelmio). CORS_ALLOW_ORIGIN حذف شده — دیگر ست نکن.
|
# (CorsRegexEnvProcessor → nelmio). CORS_ALLOW_ORIGIN حذف شده — دیگر ست نکن.
|
||||||
# افزودن/حذف دامنه: docker/frontend-domains.json را ویرایش کن، سپس گرفتن خروجی جدید:
|
# افزودن/حذف دامنه: docker/frontend-domains.json را ویرایش کن، سپس گرفتن خروجی جدید:
|
||||||
# ddev exec php docker/gen-cors-env.php (یا روی سرور: php docker/gen-cors-env.php)
|
# ddev exec php docker/gen-cors-env.php (یا روی سرور: php docker/gen-cors-env.php)
|
||||||
ALLOWED_FRONTEND_HOSTS=ahvaz-nobat.ir,arak-nobat.ir,ardabil-nobat.ir,bandar-nobat.ir,behbahan-nobat.ir,birjand-nobat.ir,bojnord-nobat.ir,bushehr-nobat.ir,dehdasht-nobat.ir,esf-nobat.ir,golestan-nobat.ir,hamadan-nobat.ir,ilam-nobat.ir,karaj-nobat.ir,kerman-nobat.ir,kermanshah-nobat.ir,lorestan-nobat.ir,mashhad-nobat.ir,nobat724.com,qazvin-nobat.ir,qom-nobat.ir,rasht-nobat.ir,sanandaj-nobat.ir,sari-nobat.ir,semnan-nobat.ir,shiraz-nobat.ir,shkord-nobat.ir,tabriz-nobat.ir,tehran-nobat.ir,urmia-nobat.ir,yasuj-nobat.ir,yazd-nobat.ir,zahedan-nobat.ir,zanjan-nobat.ir
|
ALLOWED_FRONTEND_HOSTS=ahvaz-nobat.ir,arak-nobat.ir,ardabil-nobat.ir,bandar-nobat.ir,behbahan-nobat.ir,birjand-nobat.ir,bojnord-nobat.ir,bushehr-nobat.ir,clinic-pro.ir,dehdasht-nobat.ir,esf-nobat.ir,golestan-nobat.ir,hamadan-nobat.ir,ilam-nobat.ir,karaj-nobat.ir,kerman-nobat.ir,kermanshah-nobat.ir,lorestan-nobat.ir,mashhad-nobat.ir,nobat724.com,qazvin-nobat.ir,qom-nobat.ir,rasht-nobat.ir,sanandaj-nobat.ir,sari-nobat.ir,semnan-nobat.ir,shiraz-nobat.ir,shkord-nobat.ir,tabriz-nobat.ir,tehran-nobat.ir,urmia-nobat.ir,yasuj-nobat.ir,yazd-nobat.ir,zahedan-nobat.ir,zanjan-nobat.ir
|
||||||
|
|
||||||
# ── api.ir identity inquiry (Shahkar + IbanMatch) ──
|
# ── api.ir identity inquiry (Shahkar + IbanMatch) ──
|
||||||
# Empty token => fail-closed (representative verification is rejected).
|
# Empty token => fail-closed (representative verification is rejected).
|
||||||
|
|||||||
@@ -20,6 +20,7 @@
|
|||||||
"symfony/asset": "7.4.*",
|
"symfony/asset": "7.4.*",
|
||||||
"symfony/cache": "7.4.*",
|
"symfony/cache": "7.4.*",
|
||||||
"symfony/console": "7.4.*",
|
"symfony/console": "7.4.*",
|
||||||
|
"symfony/doctrine-messenger": "7.4.*",
|
||||||
"symfony/dotenv": "7.4.*",
|
"symfony/dotenv": "7.4.*",
|
||||||
"symfony/flex": "^2",
|
"symfony/flex": "^2",
|
||||||
"symfony/framework-bundle": "7.4.*",
|
"symfony/framework-bundle": "7.4.*",
|
||||||
|
|||||||
Generated
+77
-1
@@ -4,7 +4,7 @@
|
|||||||
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
|
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
|
||||||
"This file is @generated automatically"
|
"This file is @generated automatically"
|
||||||
],
|
],
|
||||||
"content-hash": "af02756595d9f158a8adcfe3a7d22a68",
|
"content-hash": "a224d59e9e6ce93b3f3f78ee13b06bf5",
|
||||||
"packages": [
|
"packages": [
|
||||||
{
|
{
|
||||||
"name": "altcha-org/altcha",
|
"name": "altcha-org/altcha",
|
||||||
@@ -2917,6 +2917,82 @@
|
|||||||
],
|
],
|
||||||
"time": "2026-06-11T07:31:44+00:00"
|
"time": "2026-06-11T07:31:44+00:00"
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"name": "symfony/doctrine-messenger",
|
||||||
|
"version": "v7.4.14",
|
||||||
|
"source": {
|
||||||
|
"type": "git",
|
||||||
|
"url": "https://github.com/symfony/doctrine-messenger.git",
|
||||||
|
"reference": "c29901ea81dff6557ae3c78c0bad1d559500c2d8"
|
||||||
|
},
|
||||||
|
"dist": {
|
||||||
|
"type": "zip",
|
||||||
|
"url": "https://api.github.com/repos/symfony/doctrine-messenger/zipball/c29901ea81dff6557ae3c78c0bad1d559500c2d8",
|
||||||
|
"reference": "c29901ea81dff6557ae3c78c0bad1d559500c2d8",
|
||||||
|
"shasum": ""
|
||||||
|
},
|
||||||
|
"require": {
|
||||||
|
"doctrine/dbal": "^3.6|^4",
|
||||||
|
"php": ">=8.2",
|
||||||
|
"symfony/messenger": "^7.2|^8.0",
|
||||||
|
"symfony/service-contracts": "^2.5|^3"
|
||||||
|
},
|
||||||
|
"conflict": {
|
||||||
|
"doctrine/persistence": "<1.3"
|
||||||
|
},
|
||||||
|
"require-dev": {
|
||||||
|
"doctrine/persistence": "^1.3|^2|^3",
|
||||||
|
"symfony/property-access": "^6.4|^7.0|^8.0",
|
||||||
|
"symfony/serializer": "^6.4|^7.0|^8.0"
|
||||||
|
},
|
||||||
|
"type": "symfony-messenger-bridge",
|
||||||
|
"autoload": {
|
||||||
|
"psr-4": {
|
||||||
|
"Symfony\\Component\\Messenger\\Bridge\\Doctrine\\": ""
|
||||||
|
},
|
||||||
|
"exclude-from-classmap": [
|
||||||
|
"/Tests/"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"notification-url": "https://packagist.org/downloads/",
|
||||||
|
"license": [
|
||||||
|
"MIT"
|
||||||
|
],
|
||||||
|
"authors": [
|
||||||
|
{
|
||||||
|
"name": "Fabien Potencier",
|
||||||
|
"email": "fabien@symfony.com"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Symfony Community",
|
||||||
|
"homepage": "https://symfony.com/contributors"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"description": "Symfony Doctrine Messenger Bridge",
|
||||||
|
"homepage": "https://symfony.com",
|
||||||
|
"support": {
|
||||||
|
"source": "https://github.com/symfony/doctrine-messenger/tree/v7.4.14"
|
||||||
|
},
|
||||||
|
"funding": [
|
||||||
|
{
|
||||||
|
"url": "https://symfony.com/sponsor",
|
||||||
|
"type": "custom"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"url": "https://github.com/fabpot",
|
||||||
|
"type": "github"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"url": "https://github.com/nicolas-grekas",
|
||||||
|
"type": "github"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"url": "https://tidelift.com/funding/github/packagist/symfony/symfony",
|
||||||
|
"type": "tidelift"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"time": "2026-06-11T07:31:44+00:00"
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"name": "symfony/dotenv",
|
"name": "symfony/dotenv",
|
||||||
"version": "v7.4.14",
|
"version": "v7.4.14",
|
||||||
|
|||||||
@@ -6,3 +6,9 @@ framework:
|
|||||||
altcha.pool:
|
altcha.pool:
|
||||||
adapter: cache.adapter.redis
|
adapter: cache.adapter.redis
|
||||||
default_lifetime: 600
|
default_lifetime: 600
|
||||||
|
# Pool اختصاصی probe سلامت redis؛ در HealthController با NullLogger اجرا میشود
|
||||||
|
# تا افتادن موقت redis (DNS flap) لاگ warning تکراری در app_log تولید نکند —
|
||||||
|
# وضعیت redis همانجا در پاسخ /health و کد 503 گزارش میشود.
|
||||||
|
health.pool:
|
||||||
|
adapter: cache.adapter.redis
|
||||||
|
default_lifetime: 10
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ framework:
|
|||||||
max_retries: 0
|
max_retries: 0
|
||||||
|
|
||||||
routing:
|
routing:
|
||||||
'App\Shared\Message\SendSmsMessage': async
|
'App\Sms\Message\SendSmsMessage': async
|
||||||
'App\Appointment\Message\ExpireAppointmentsMessage': scheduler_default
|
'App\Appointment\Message\ExpireAppointmentsMessage': scheduler_default
|
||||||
'App\Shared\Logging\Message\PruneLogsMessage': scheduler_default
|
'App\Shared\Logging\Message\PruneLogsMessage': scheduler_default
|
||||||
|
|
||||||
|
|||||||
@@ -61,6 +61,10 @@ services:
|
|||||||
$expireSeconds: '%env(int:ALTCHA_EXPIRE_SECONDS)%'
|
$expireSeconds: '%env(int:ALTCHA_EXPIRE_SECONDS)%'
|
||||||
$altchaPool: '@altcha.pool'
|
$altchaPool: '@altcha.pool'
|
||||||
|
|
||||||
|
App\Shared\Controller\HealthController:
|
||||||
|
arguments:
|
||||||
|
$healthPool: '@health.pool'
|
||||||
|
|
||||||
# Persist warning+ logs to the app_log table while keeping stderr output.
|
# Persist warning+ logs to the app_log table while keeping stderr output.
|
||||||
App\Shared\Logging\DbLogger:
|
App\Shared\Logging\DbLogger:
|
||||||
decorates: 'logger'
|
decorates: 'logger'
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
"_comment": "Frontend domains served by this backend. Add an entry per city, then run: ddev exec php docker/gen-cors-env.php — paste the output into Coolify env (CORS_ALLOW_ORIGIN + ALLOWED_FRONTEND_HOSTS), add the same domain to the app service in Coolify UI, and redeploy.",
|
"_comment": "Frontend domains served by this backend. Add an entry per city, then run: ddev exec php docker/gen-cors-env.php — paste the output into Coolify env (CORS_ALLOW_ORIGIN + ALLOWED_FRONTEND_HOSTS), add the same domain to the app service in Coolify UI, and redeploy.",
|
||||||
"domains": [
|
"domains": [
|
||||||
{ "domain": "nobat724.com", "label": "اصلی" },
|
{ "domain": "nobat724.com", "label": "اصلی" },
|
||||||
|
{ "domain": "clinic-pro.ir", "label": "پنل ادمین/بکاند" },
|
||||||
{ "domain": "ahvaz-nobat.ir", "label": "اهواز" },
|
{ "domain": "ahvaz-nobat.ir", "label": "اهواز" },
|
||||||
{ "domain": "arak-nobat.ir", "label": "اراک" },
|
{ "domain": "arak-nobat.ir", "label": "اراک" },
|
||||||
{ "domain": "ardabil-nobat.ir", "label": "اردبیل" },
|
{ "domain": "ardabil-nobat.ir", "label": "اردبیل" },
|
||||||
|
|||||||
+3
-2
@@ -17,7 +17,8 @@
|
|||||||
- شماره فرستنده تنظیم نمیشود؛ کاوهنگار از خط پیشفرض حساب استفاده میکند.
|
- شماره فرستنده تنظیم نمیشود؛ کاوهنگار از خط پیشفرض حساب استفاده میکند.
|
||||||
- endpoint `GET /api/v1/admin/settings` یک فیلد read-only به نام `sms_api_key_configured` (boolean) برمیگرداند.
|
- endpoint `GET /api/v1/admin/settings` یک فیلد read-only به نام `sms_api_key_configured` (boolean) برمیگرداند.
|
||||||
- `PATCH /api/v1/admin/settings` کلیدهای `sms_provider`، `kavenegar_api_key`، `kavenegar_sender`، `rangineh_api_key`، `rangineh_sender` را نمیپذیرد (از `ALLOWED_KEYS` حذف شدهاند).
|
- `PATCH /api/v1/admin/settings` کلیدهای `sms_provider`، `kavenegar_api_key`، `kavenegar_sender`، `rangineh_api_key`، `rangineh_sender` را نمیپذیرد (از `ALLOWED_KEYS` حذف شدهاند).
|
||||||
- **ترابری (transport) کاوهنگار:** همهی فراخوانیهای `KavehNegarProvider` به کاوهنگار بهصورت **GET با query string** ارسال میشوند (مطابق مستند رسمی) — هیچ درخواست `POST`/`body` ساخته نمیشود. GET بدنه ندارد پس curl هدر `Expect: 100-continue` نمیفرستد؛ این جلوی خطاهای `431 Request Header Fields Too Large` و `Idle timeout` را که در prod دیده میشد میگیرد. هر درخواست `timeout=15s`، `max_duration=30s`، بایپس proxy محیطی (`proxy=null`) و تا **۳ بار retry** با backoff برای خطاهای گذرای شبکه دارد.
|
- **ترابری (transport) کاوهنگار:** همهی فراخوانیهای `KavehNegarProvider` به کاوهنگار بهصورت **GET با query string** ارسال میشوند (مطابق مستند رسمی) — هیچ درخواست `POST`/`body` ساخته نمیشود. GET بدنه ندارد پس curl هدر `Expect: 100-continue` نمیفرستد؛ این جلوی `Idle timeout` را میگیرد. هر درخواست `timeout=15s`، `max_duration=30s`، بایپس proxy محیطی (`proxy=null`) و تا **۳ بار retry** با backoff فقط برای خطاهای **گذرای شبکه** (`TransportException`) دارد؛ خطاهای HTTP کاوهنگار (`4xx`/`5xx`، مثل `431`) دائماند و retry نمیشوند.
|
||||||
|
- **سقف طول توکن (`431` guard):** مقدار طولانی در توکنها (مثل نام کلینیک فارسی که هر کاراکترش ۹ بایت URL-encode میشود) query string را بزرگ میکند و کاوهنگار `431 Request Header Fields Too Large` برمیگرداند. provider هر مقدار توکن را به `MAX_TOKEN_LEN = 60` کاراکتر cap میکند. اگر با این حال کاوهنگار خطای HTTP بدهد، بهجای `error` پرسروصدا فقط یک `warning` کوتاه با کد وضعیت لاگ میشود (چون خطای دائم است، نه transient).
|
||||||
- **قیمت هر پیامک** از کلید تنظیمات `sms_price_rials` خوانده میشود (قابل ویرایش در `/admin/settings` → بخش پیامک، و از طریق `PATCH /api/v1/admin/settings`). اگر تنظیم نشده باشد، مقدار پیشفرض `SmsWalletController::SMS_PRICE_RIALS = 500` ریال بهعنوان fallback استفاده میشود. `GET /api/v1/sms/wallet/balance` این مقدار را در `sms_price_rials` و تعداد تخمینی پیامک را در `estimated_sms_count` برمیگرداند.
|
- **قیمت هر پیامک** از کلید تنظیمات `sms_price_rials` خوانده میشود (قابل ویرایش در `/admin/settings` → بخش پیامک، و از طریق `PATCH /api/v1/admin/settings`). اگر تنظیم نشده باشد، مقدار پیشفرض `SmsWalletController::SMS_PRICE_RIALS = 500` ریال بهعنوان fallback استفاده میشود. `GET /api/v1/sms/wallet/balance` این مقدار را در `sms_price_rials` و تعداد تخمینی پیامک را در `estimated_sms_count` برمیگرداند.
|
||||||
|
|
||||||
---
|
---
|
||||||
@@ -484,7 +485,7 @@ Updated template with `status: "rejected"`.
|
|||||||
**همهی پیامکهای سیستمی از طریق Kavenegar VerifyLookup (`verify/lookup.json`) ارسال میشوند** (نه متنآزاد `sms/send`). هر تمپلت دو فیلد اضافه دارد:
|
**همهی پیامکهای سیستمی از طریق Kavenegar VerifyLookup (`verify/lookup.json`) ارسال میشوند** (نه متنآزاد `sms/send`). هر تمپلت دو فیلد اضافه دارد:
|
||||||
|
|
||||||
- `kavenegar_template`: نام تمپلت مصوب در پنل کاوهنگار. **متن واقعیِ ارسالی از همین تمپلت پنل میآید، نه از `body` دیتابیس**؛ `body` فقط برای پیشنمایش ادمین و رندرِ رکورد `SmsLog` استفاده میشود و باید دستی با تمپلت پنل همراستا نگه داشته شود.
|
- `kavenegar_template`: نام تمپلت مصوب در پنل کاوهنگار. **متن واقعیِ ارسالی از همین تمپلت پنل میآید، نه از `body` دیتابیس**؛ `body` فقط برای پیشنمایش ادمین و رندرِ رکورد `SmsLog` استفاده میشود و باید دستی با تمپلت پنل همراستا نگه داشته شود.
|
||||||
- `token_map`: نگاشت متغیر منطقی → جایگاه کاوهنگار. **قانون کاوهنگار:** هر الگو **حتماً باید `%token` داشته باشد** (token1 اجباری است)؛ پس token_map هر تگ یک مقدار به `token` میدهد. قانون فاصله: `token`/`token2`/`token3` **فاصله نمیپذیرند** — provider (`KavehNegarProvider`) فاصلههای مقدارِ این جایگاهها را خودکار با نیمفاصله (ZWNJ) جایگزین میکند؛ `token10`/`token20` فاصله را بدون تغییر میپذیرند. پس ترجیحاً مقادیر بدونفاصله (کد/تاریخ/ساعت/username/لینک) در `token`/`token2`/`token3` و مقادیر دارای فاصله (نام دکتر/بیمار/کلینیک) در `token10`/`token20`.
|
- `token_map`: نگاشت متغیر منطقی → جایگاه کاوهنگار. **قانون کاوهنگار:** هر الگو **حتماً باید `%token` داشته باشد** (token1 اجباری است)؛ پس token_map هر تگ یک مقدار به `token` میدهد. قانون فاصله: `token`/`token2`/`token3` **فاصله نمیپذیرند** — provider (`KavehNegarProvider`) فاصلههای مقدارِ این جایگاهها را خودکار با نیمفاصله (ZWNJ) جایگزین میکند؛ `token10`/`token20` فاصله را بدون تغییر میپذیرند. پس ترجیحاً مقادیر بدونفاصله (کد/تاریخ/ساعت/username/لینک) در `token`/`token2`/`token3` و مقادیر دارای فاصله (نام دکتر/بیمار/کلینیک) در `token10`/`token20`. مقدار هر جایگاه به `MAX_TOKEN_LEN = 60` کاراکتر cap میشود تا از خطای `431` جلوگیری شود.
|
||||||
|
|
||||||
`SmsService::dispatchTemplate(tag, mobile, vars)` نقطهی واحدِ ارسال است: `kavenegar_template` و `token_map` را از رکورد DB (یا `SmsMessageTemplate::DEFAULTS`) میخواند، `vars` را به جایگاهها نگاشت میکند و با VerifyLookup میفرستد. اگر `kavenegar_template` تعریف نشده باشد → fallback به ارسال متنآزاد با `body` رندرشده.
|
`SmsService::dispatchTemplate(tag, mobile, vars)` نقطهی واحدِ ارسال است: `kavenegar_template` و `token_map` را از رکورد DB (یا `SmsMessageTemplate::DEFAULTS`) میخواند، `vars` را به جایگاهها نگاشت میکند و با VerifyLookup میفرستد. اگر `kavenegar_template` تعریف نشده باشد → fallback به ارسال متنآزاد با `body` رندرشده.
|
||||||
|
|
||||||
|
|||||||
@@ -3,16 +3,23 @@
|
|||||||
namespace App\Shared\Controller;
|
namespace App\Shared\Controller;
|
||||||
|
|
||||||
use Doctrine\ORM\EntityManagerInterface;
|
use Doctrine\ORM\EntityManagerInterface;
|
||||||
|
use Psr\Cache\CacheItemPoolInterface;
|
||||||
|
use Psr\Log\LoggerAwareInterface;
|
||||||
|
use Psr\Log\NullLogger;
|
||||||
use Symfony\Component\HttpFoundation\JsonResponse;
|
use Symfony\Component\HttpFoundation\JsonResponse;
|
||||||
use Symfony\Component\Routing\Attribute\Route;
|
use Symfony\Component\Routing\Attribute\Route;
|
||||||
use Symfony\Contracts\Cache\CacheInterface;
|
|
||||||
|
|
||||||
class HealthController
|
class HealthController
|
||||||
{
|
{
|
||||||
public function __construct(
|
public function __construct(
|
||||||
private readonly EntityManagerInterface $em,
|
private readonly EntityManagerInterface $em,
|
||||||
private readonly CacheInterface $cache,
|
private readonly CacheItemPoolInterface $healthPool,
|
||||||
) {}
|
) {
|
||||||
|
// probe سلامت نباید هنگام افتادن redis لاگ warning تولید کند؛ logger این pool را خنثی میکنیم.
|
||||||
|
if ($this->healthPool instanceof LoggerAwareInterface) {
|
||||||
|
$this->healthPool->setLogger(new NullLogger());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[Route('/health', methods: ['GET'])]
|
#[Route('/health', methods: ['GET'])]
|
||||||
public function __invoke(): JsonResponse
|
public function __invoke(): JsonResponse
|
||||||
@@ -29,7 +36,7 @@ class HealthController
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
$item = $this->cache->getItem('health_check');
|
$this->healthPool->getItem('health_check');
|
||||||
$checks['redis'] = 'ok';
|
$checks['redis'] = 'ok';
|
||||||
} catch (\Throwable) {
|
} catch (\Throwable) {
|
||||||
$checks['redis'] = 'error';
|
$checks['redis'] = 'error';
|
||||||
|
|||||||
@@ -1,12 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
namespace App\Shared\Message;
|
|
||||||
|
|
||||||
class SendSmsMessage
|
|
||||||
{
|
|
||||||
public function __construct(
|
|
||||||
public readonly string $mobile,
|
|
||||||
public readonly string $message,
|
|
||||||
public readonly ?int $smsLogId = null,
|
|
||||||
) {}
|
|
||||||
}
|
|
||||||
@@ -3,6 +3,7 @@
|
|||||||
namespace App\Sms\Provider;
|
namespace App\Sms\Provider;
|
||||||
|
|
||||||
use Psr\Log\LoggerInterface;
|
use Psr\Log\LoggerInterface;
|
||||||
|
use Symfony\Contracts\HttpClient\Exception\HttpExceptionInterface;
|
||||||
use Symfony\Contracts\HttpClient\Exception\TransportExceptionInterface;
|
use Symfony\Contracts\HttpClient\Exception\TransportExceptionInterface;
|
||||||
use Symfony\Contracts\HttpClient\HttpClientInterface;
|
use Symfony\Contracts\HttpClient\HttpClientInterface;
|
||||||
|
|
||||||
@@ -57,6 +58,9 @@ class KavehNegarProvider implements SmsProviderInterface
|
|||||||
'sender' => $this->sender(),
|
'sender' => $this->sender(),
|
||||||
]);
|
]);
|
||||||
return ($data['return']['status'] ?? 0) === 200;
|
return ($data['return']['status'] ?? 0) === 200;
|
||||||
|
} catch (HttpExceptionInterface $e) {
|
||||||
|
$this->logger->warning(sprintf('SMS send rejected (kavenegar): HTTP %d', $e->getResponse()->getStatusCode()), ['mobile' => $mobile]);
|
||||||
|
return false;
|
||||||
} catch (\Throwable $e) {
|
} catch (\Throwable $e) {
|
||||||
$this->logger->error(sprintf('SMS send failed (kavenegar): %s @ %s:%d', $e->getMessage(), $e->getFile(), $e->getLine()), ['exception' => $e, 'mobile' => $mobile]);
|
$this->logger->error(sprintf('SMS send failed (kavenegar): %s @ %s:%d', $e->getMessage(), $e->getFile(), $e->getLine()), ['exception' => $e, 'mobile' => $mobile]);
|
||||||
return false;
|
return false;
|
||||||
@@ -66,12 +70,20 @@ class KavehNegarProvider implements SmsProviderInterface
|
|||||||
// token/token2/token3 مقدارِ دارای فاصله را رد میکنند؛ فاصله را با نیمفاصله (ZWNJ) جایگزین میکنیم.
|
// token/token2/token3 مقدارِ دارای فاصله را رد میکنند؛ فاصله را با نیمفاصله (ZWNJ) جایگزین میکنیم.
|
||||||
private const NO_SPACE_SLOTS = ['token', 'token2', 'token3'];
|
private const NO_SPACE_SLOTS = ['token', 'token2', 'token3'];
|
||||||
|
|
||||||
|
// کاوهنگار روی طول توکن سقف دارد؛ مقدار بلند (مثلاً نام کلینیک فارسی که هر کاراکترش
|
||||||
|
// ۹ بایت URL-encode میشود) درخواست را بزرگ میکند و پاسخ 431 میگیریم. مقدار را cap میکنیم.
|
||||||
|
private const MAX_TOKEN_LEN = 60;
|
||||||
|
|
||||||
private function forSlot(string $slot, string $value): string
|
private function forSlot(string $slot, string $value): string
|
||||||
{
|
{
|
||||||
if (!in_array($slot, self::NO_SPACE_SLOTS, true)) {
|
$value = trim($value);
|
||||||
return $value;
|
if (in_array($slot, self::NO_SPACE_SLOTS, true)) {
|
||||||
|
$value = preg_replace('/\s+/u', "\u{200C}", $value) ?? $value;
|
||||||
}
|
}
|
||||||
return preg_replace('/\s+/u', "\u{200C}", $value) ?? $value;
|
if (mb_strlen($value, 'UTF-8') > self::MAX_TOKEN_LEN) {
|
||||||
|
$value = mb_substr($value, 0, self::MAX_TOKEN_LEN, 'UTF-8');
|
||||||
|
}
|
||||||
|
return $value;
|
||||||
}
|
}
|
||||||
|
|
||||||
public function sendTemplate(string $mobile, string $templateCode, array $vars): bool
|
public function sendTemplate(string $mobile, string $templateCode, array $vars): bool
|
||||||
@@ -95,6 +107,11 @@ class KavehNegarProvider implements SmsProviderInterface
|
|||||||
}
|
}
|
||||||
$data = $this->get('/verify/lookup.json', $params);
|
$data = $this->get('/verify/lookup.json', $params);
|
||||||
return ($data['return']['status'] ?? 0) === 200;
|
return ($data['return']['status'] ?? 0) === 200;
|
||||||
|
} catch (HttpExceptionInterface $e) {
|
||||||
|
// 4xx/5xx از کاوهنگار (مثل 431): خطای دائم درخواست است نه transient؛
|
||||||
|
// warning کوتاه با کد وضعیت، بدون dump کامل exception.
|
||||||
|
$this->logger->warning(sprintf('SMS sendTemplate rejected (kavenegar): HTTP %d', $e->getResponse()->getStatusCode()), ['mobile' => $mobile, 'template' => $templateCode]);
|
||||||
|
return false;
|
||||||
} catch (\Throwable $e) {
|
} catch (\Throwable $e) {
|
||||||
$this->logger->error(sprintf('SMS sendTemplate failed (kavenegar): %s @ %s:%d', $e->getMessage(), $e->getFile(), $e->getLine()), ['exception' => $e, 'mobile' => $mobile, 'template' => $templateCode]);
|
$this->logger->error(sprintf('SMS sendTemplate failed (kavenegar): %s @ %s:%d', $e->getMessage(), $e->getFile(), $e->getLine()), ['exception' => $e, 'mobile' => $mobile, 'template' => $templateCode]);
|
||||||
return false;
|
return false;
|
||||||
|
|||||||
@@ -0,0 +1,60 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Tests\Shared;
|
||||||
|
|
||||||
|
use App\Shared\Controller\HealthController;
|
||||||
|
use Doctrine\DBAL\Connection;
|
||||||
|
use Doctrine\ORM\EntityManagerInterface;
|
||||||
|
use PHPUnit\Framework\TestCase;
|
||||||
|
use Psr\Cache\CacheItemInterface;
|
||||||
|
use Psr\Cache\CacheItemPoolInterface;
|
||||||
|
use Psr\Log\LoggerAwareInterface;
|
||||||
|
use Psr\Log\LoggerInterface;
|
||||||
|
use Psr\Log\NullLogger;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* probe سلامت redis نباید هنگام افتادن redis لاگ warning در app_log بریزد:
|
||||||
|
* HealthController باید logger این pool را با NullLogger خنثی کند و صرفاً وضعیت را برگرداند.
|
||||||
|
*/
|
||||||
|
class HealthControllerTest extends TestCase
|
||||||
|
{
|
||||||
|
private function em(): EntityManagerInterface
|
||||||
|
{
|
||||||
|
$conn = $this->createMock(Connection::class);
|
||||||
|
$conn->method('executeQuery'); // SELECT 1 → موفق
|
||||||
|
$em = $this->createMock(EntityManagerInterface::class);
|
||||||
|
$em->method('getConnection')->willReturn($conn);
|
||||||
|
|
||||||
|
return $em;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testSilencesLoggerOnPool(): void
|
||||||
|
{
|
||||||
|
$pool = new class implements CacheItemPoolInterface, LoggerAwareInterface {
|
||||||
|
public ?LoggerInterface $logger = null;
|
||||||
|
public function setLogger(LoggerInterface $logger): void { $this->logger = $logger; }
|
||||||
|
public function getItem($key): CacheItemInterface { throw new \RuntimeException('redis down'); }
|
||||||
|
public function getItems(array $keys = []): iterable { return []; }
|
||||||
|
public function hasItem($key): bool { return false; }
|
||||||
|
public function clear(): bool { return true; }
|
||||||
|
public function deleteItem($key): bool { return true; }
|
||||||
|
public function deleteItems(array $keys): bool { return true; }
|
||||||
|
public function save(CacheItemInterface $item): bool { return true; }
|
||||||
|
public function saveDeferred(CacheItemInterface $item): bool { return true; }
|
||||||
|
public function commit(): bool { return true; }
|
||||||
|
};
|
||||||
|
|
||||||
|
$controller = new HealthController($this->em(), $pool);
|
||||||
|
|
||||||
|
// logger فوراً در constructor خنثی میشود
|
||||||
|
$this->assertInstanceOf(NullLogger::class, $pool->logger);
|
||||||
|
|
||||||
|
// redis افتاده → degraded + 503، بدون throw
|
||||||
|
$response = $controller();
|
||||||
|
$this->assertSame(503, $response->getStatusCode());
|
||||||
|
$body = json_decode($response->getContent(), true);
|
||||||
|
$this->assertSame('degraded', $body['status']);
|
||||||
|
$this->assertSame('error', $body['checks']['redis']);
|
||||||
|
$this->assertSame('ok', $body['checks']['database']);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -46,4 +46,25 @@ class KavehNegarProviderTest extends TestCase
|
|||||||
$this->assertSame('GET', $this->captured['method']);
|
$this->assertSame('GET', $this->captured['method']);
|
||||||
$this->assertStringContainsString('/TESTKEY/sms/send.json', $this->captured['url']);
|
$this->assertStringContainsString('/TESTKEY/sms/send.json', $this->captured['url']);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** توکن بلند (نام فارسی) باید cap شود تا URL بزرگ نشود و کاوهنگار 431 ندهد. */
|
||||||
|
public function testLongTokenIsCapped(): void
|
||||||
|
{
|
||||||
|
$long = str_repeat('ک', 200); // 200 کاراکتر فارسی
|
||||||
|
$ok = $this->provider()->sendTemplate('09120671756', 'clinicpro-welcome', ['token' => $long]);
|
||||||
|
|
||||||
|
$this->assertTrue($ok);
|
||||||
|
parse_str(parse_url($this->captured['url'], PHP_URL_QUERY), $q);
|
||||||
|
$this->assertLessThanOrEqual(60, mb_strlen($q['token'], 'UTF-8'));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** خطای 4xx کاوهنگار (مثل 431) باید false برگرداند و throw نکند. */
|
||||||
|
public function testHttpErrorReturnsFalseWithoutThrowing(): void
|
||||||
|
{
|
||||||
|
$client = new MockHttpClient(fn (): MockResponse => new MockResponse('too large', ['http_code' => 431]));
|
||||||
|
$provider = new KavehNegarProvider($client, new NullLogger(), 'TESTKEY', '10004346');
|
||||||
|
|
||||||
|
$this->assertFalse($provider->sendTemplate('09120671756', 'clinicpro-welcome', ['token' => 'x']));
|
||||||
|
$this->assertFalse($provider->send('09120671756', 'hello'));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user