# رفع خطاهای لاگ سرور (production) — ۱۴۰۵/۰۴/۲۰

## پروژه

`clinicpro` (backend فقط)

## زمینه

لاگ ارور production (فایل `logs-20260711-083844.csv`) بررسی شد. پنج دسته خطا شناسایی شد. بیشترشان error سطح ۵۰۰ هستند که باید یا رفع شوند یا از سطح error خارج شوند تا لاگ کثیف نشود. ریشهٔ هر کدام در کد پیدا شده و در ادامه با راه‌حل دقیق آمده است.

## خلاصهٔ خطاها و اولویت

| # | خطا | تعداد در لاگ | ریشه | نوع |
|---|-----|------|------|-----|
| ۱ | `SMS sendTemplate failed (kavenegar): HTTP 431 Request Header Fields Too Large` | ۲ | توکن دعوت ۹۶ کاراکتری + URL کامل به‌عنوان توکن کاوه‌نگار | باگ کد |
| ۲ | `Payment initiate failed (mellat): Class "SoapClient" not found` | ۱ | ایمیج prod قدیمی؛ افزونهٔ soap نصب نیست | deploy + گارد کد |
| ۳ | `ForeignKeyConstraintViolationException` هنگام حذف پزشک | ۱ | حذف پزشک بدون بررسی نوبت‌های وابسته → ۵۰۰ | باگ کد |
| ۴ | `MethodNotAllowedHttpException: POST/OPTIONS https://clinic-pro.ir/` | ~۳۰ | ربات/اسکنر روی `/`؛ در fallback عمومی error+۵۰۰ لاگ می‌شود | نویز لاگ |
| ۵ | `SMS Idle timeout` / `login_failed` | چند | گذرا/عادی | بدون اقدام |

خطای ۵ اقدام لازم ندارد (idle timeout قبلاً retry دارد؛ `login_failed` warning عادی است).

---

## فایل‌های مرتبط

| فایل | نقش |
|------|-----|
| `src/ClinicInvitation/Entity/ClinicDoctorInvitation.php` | تولید توکن دعوت (خط ۷۷ و ۹۷: `bin2hex(random_bytes(48))`) |
| `src/ClinicInvitation/Service/ClinicInvitationService.php` | ساخت لینک و ارسال پیامک دعوت (خط ۱۱۳، `sendSms`) |
| `src/Sms/Entity/SmsMessageTemplate.php` | `token_map` قالب‌ها؛ `CLINIC_INVITATION` → `'link' => 'token'` |
| `src/Sms/Provider/KavehNegarProvider.php` | ساخت GET به کاوه‌نگار؛ محدودیت slotها |
| `src/Payment/Gateway/MellatGateway.php` | خط ۷۰: `new \SoapClient(...)` |
| `src/Doctor/Controller/DoctorController.php` | خط ۳۶۹–۳۸۱: متد `delete` |
| `src/Shared/EventSubscriber/ExceptionSubscriber.php` | fallback عمومی که همه‌چیز را error+۵۰۰ می‌کند |
| `Dockerfile` | خط ۶۷: `docker-php-ext-install ... soap` (از قبل هست) |

---

## وظیفه ۱ — رفع 431 پیامک دعوت (و welcome)

### ریشه

توکن دعوت این‌گونه تولید می‌شود:

```php
// ClinicDoctorInvitation.php:77 و :97
$this->token = bin2hex(random_bytes(48));   // ۹۶ کاراکتر hex
```

سپس لینک کامل ساخته و به‌عنوان توکن کاوه‌نگار فرستاده می‌شود:

```php
// ClinicInvitationService.php:113
$link = rtrim($this->appUrl, '/') . '/clinic-invitation/' . $inv->getToken();
// link ≈ https://clinic-pro.ir/clinic-invitation/<۹۶ کاراکتر> ≈ ۱۳۶ کاراکتر
```

و در `token_map`، `link` روی slot `token` می‌نشیند:

```php
// SmsMessageTemplate.php — TAG_CLINIC_INVITATION
'token_map' => ['clinic' => 'token10', 'link' => 'token'],
```

دو مشکل:
1. کل URL (۱۳۶ کاراکتر) در query stringِ GET کاوه‌نگار → طول request line از حد edge کاوه‌نگار رد می‌شود → `431 Request Header Fields Too Large`.
2. slot `token`/`token2`/`token3` کاراکترهای خاص (`/`, `:`) و فاصله را رد می‌کند؛ URL پر از `/` است. باید slot `token10`/`token20` باشد (طبق کامنت خود provider).

### راه‌حل

**الف) توکن دعوت را کوتاه کن** (هم برای SMS، هم index دیتابیس سبک‌تر). ۹۶ کاراکتر بیش از حد است؛ `random_bytes(16)` → ۳۲ hex کافیِ امن است:

```php
// ClinicDoctorInvitation.php — خط ۷۷ و ۹۷ (هر دو محل)
$this->token = bin2hex(random_bytes(16));   // ۳۲ کاراکتر، همچنان کریپتوگرافیک امن
```

**ب) slot لینک را به `token10` منتقل کن** (فاصله/URL را می‌پذیرد):

```php
// SmsMessageTemplate.php — TAG_CLINIC_INVITATION
'token_map' => ['clinic' => 'token20', 'link' => 'token10'],
```

> نکته مهم: کاوه‌نگار قالب‌های verify/lookup را از پیش تأیید می‌کند. اگر متن قالب تأییدشدهٔ `clinicpro-clinic-invite` به `%token%`/`%token10%` خاصی بسته شده، تغییر slot باید با پنل کاوه‌نگار هماهنگ شود. اول بررسی کن قالب فعلی کدام tokenها را انتظار دارد؛ اگر تغییر slot در پنل ممکن نیست، حداقل وظیفهٔ (الف) — کوتاه‌کردن توکن — را انجام بده که به‌تنهایی طول را از آستانهٔ 431 پایین می‌آورد.

**ج) بررسی welcome** (خطای ۹۴۹۴ روی `clinicpro-welcome`، `token_map: ['name' => 'token', 'site' => 'token10']`): اگر `name` می‌تواند طولانی/دارای فاصله باشد، آن هم باید `token10` شود. متن قالب welcome در پنل کاوه‌نگار را چک کن و در صورت نیاز `name` را به slot طولانی‌تر ببر.

### edge cases

- توکن‌های دعوتِ قبلی (۹۶ کاراکتری) در دیتابیس باقی می‌مانند؛ تغییر فقط روی دعوت‌های جدید اثر دارد — نیازی به migration نیست (طول ستون `token` محدودیت‌شکن نمی‌شود؛ فقط index).
- مطمئن شو `random_bytes(16)` هنوز به‌اندازهٔ کافی یکتاست (هست — ۱۲۸ بیت).

---

## وظیفه ۲ — رفع `SoapClient not found` (درگاه ملت)

### ریشه

`Dockerfile:67` از قبل soap را نصب می‌کند:

```dockerfile
&& docker-php-ext-install pdo_mysql intl opcache soap \
```

پس کد درست است؛ **ایمیج در حال اجرا در production قدیمی است** (قبل از افزوده‌شدن soap ساخته شده) یا build ناموفق بوده. `php -m | grep soap` روی کانتینر prod خالی است.

### راه‌حل

**الف) redeploy با rebuild کامل** (بدون کش) — قدم اصلی. بعد از deploy تأیید:

```bash
# روی کانتینر prod
php -m | grep -i soap    # باید soap چاپ شود
```

**ب) گارد نرم در کد** تا اگر باز هم soap نبود، پرداخت خطای فارسی تمیز بدهد نه `Error: Class "SoapClient" not found` با ۵۰۰:

```php
// MellatGateway.php — داخل soap() قبل از new \SoapClient(...)
private function soap(): \SoapClient
{
    if (!class_exists(\SoapClient::class)) {
        throw new \App\Shared\Exception\AppException(
            'ERR_PAYMENT_GATEWAY_001',
            'درگاه پرداخت موقتاً در دسترس نیست. لطفاً بعداً تلاش کنید',
            503
        );
    }
    if ($this->soap === null) {
        $this->soap = new \SoapClient($this->wsdlUrl(), [ /* ... بدون تغییر ... */ ]);
    }
    return $this->soap;
}
```

> کد ارور `ERR_PAYMENT_GATEWAY_001` را با الگوی موجود `ErrorCodes`/`AppException` هماهنگ کن؛ اگر ثابت مشابهی برای درگاه هست از همان استفاده کن.

---

## وظیفه ۳ — رفع ۵۰۰ هنگام حذف پزشکِ دارای نوبت

### ریشه

```php
// DoctorController.php:369-381
#[Route('/api/v1/doctor/{uuid}', methods: ['DELETE'])]
public function delete(string $uuid): JsonResponse
{
    $doctor = $this->doctorRepo->findByUuid($uuid);
    if ($doctor === null) { return $this->error(...404); }

    $this->insuranceCleanup->purgeForEntity(TenantInsurance::TYPE_DOCTOR, $doctor->getId());
    $this->doctorRepo->remove($doctor);   // ← اگر appointment وابسته باشد: FK 1451 → ۵۰۰
    return $this->success(['message' => 'دکتر با موفقیت حذف شد']);
}
```

`appointments.doctor_id` FK دارد؛ حذف پزشکِ دارای نوبت → `ForeignKeyConstraintViolationException` → fallback عمومی ۵۰۰.

### راه‌حل

قبل از `remove` تعداد نوبت‌های پزشک را بررسی کن و خطای ۴۰۹ فارسی بده:

```php
// تزریق AppointmentRepository در constructor (اگر نیست)
$appointmentCount = $this->appointmentRepo->count(['doctor' => $doctor]);
if ($appointmentCount > 0) {
    return $this->error(
        ErrorCodes::ERR_VALIDATION_002,   // یا کد conflict مناسب
        'این پزشک نوبت ثبت‌شده دارد و قابل حذف نیست. ابتدا نوبت‌ها را مدیریت کنید',
        409
    );
}
```

**بررسی کن**: constructor فعلی `DoctorController` (خط ۳۳) چه repositoryهایی تزریق می‌کند؛ اگر `AppointmentRepository` نیست اضافه کن. نام دقیق فیلد رابطه در `Appointment` (`doctor`) را از entity تأیید کن.

> جایگزین: اگر منطق محصول اجازه می‌دهد، به‌جای بلاک‌کردن، حذف را به soft-delete تبدیل کن — ولی راه‌حل بالا (۴۰۹) کمترین ریسک است. تصمیم را با الگوی موجود بقیهٔ deleteها (مثل `deleteAddress`) هماهنگ کن.

---

## وظیفه ۴ — کاهش نویز لاگِ `MethodNotAllowedHttpException` روی `/`

### ریشه

حدود ۳۰ خطا از نوع `POST`/`OPTIONS` روی `https://clinic-pro.ir/` (ربات/اسکنر و preflight). این‌ها در `ExceptionSubscriber` به هیچ‌کدام از شاخه‌های خاص نمی‌خورند و به **fallback عمومی** می‌رسند که:

```php
// ExceptionSubscriber.php — انتهای onKernelException
$this->logger->error(sprintf('Unhandled exception: %s ...'));   // ← error سطح ۵۰۰
$event->setResponse(new JsonResponse([...'ERR_INTERNAL_001'...], 500));  // ← اشتباه: باید ۴۰۵
```

یعنی خطای کلاینت ۴۰۵ به‌اشتباه به‌عنوان error داخلی ۵۰۰ لاگ و پاسخ داده می‌شود.

### راه‌حل

یک شاخهٔ اختصاصی برای `MethodNotAllowedHttpException` قبل از fallback اضافه کن — پاسخ ۴۰۵ و لاگ در سطح `notice` (نه error):

```php
use Symfony\Component\HttpKernel\Exception\MethodNotAllowedHttpException;

// قبل از بلاک fallback عمومی
if ($exception instanceof MethodNotAllowedHttpException) {
    $this->logger->notice('Method not allowed', [
        'path'   => $event->getRequest()->getPathInfo(),
        'method' => $event->getRequest()->getMethod(),
    ]);
    $event->setResponse(new JsonResponse(
        ['success' => false, 'data' => null, 'errors' => [['code' => 'ERR_METHOD_NOT_ALLOWED_001', 'message' => 'متد درخواستی مجاز نیست']]],
        405
    ));
    return;
}
```

### نکته دربارهٔ OPTIONS

بعضی خطاها `OPTIONS https://clinic-pro.ir/` هستند = preflight CORS. اگر کلاینتی واقعاً به ریشهٔ دامنه preflight می‌زند، احتمالاً base URL اشتباه در فرانت‌اند است — ولی چون فقط چند مورد است و probeهای ربات هم OPTIONS می‌فرستند، برای الان همین کاهش نویز کافی است. اگر config CORS جداگانه OPTIONS را قبل از router هندل می‌کند، بررسی کن که این تغییر با آن تداخل ندارد.

---

## نکات مهم کلی

- همهٔ تغییرها backend `clinicpro` هستند؛ کلاینت (`nobat724_front`) قرارداد API را برای این موارد مصرف نمی‌کند به‌جز کد خطای جدید ۴۰۵/۴۰۹/۵۰۳ — پیام‌ها فارسی‌اند و ساختار envelope حفظ می‌شود.
- بعد از تغییرِ رفتار endpoint حذف پزشک و درگاه پرداخت، فایل مربوطه در `clinicpro/docs/api/` را به‌روز کن (قانون استاندارد پروژه: docs در همان session).
- برای وظیفهٔ ۲ (soap) قدم اصلی **redeploy** است؛ گارد کد فقط شبکهٔ ایمنی است.
- تست: بعد از تغییرات، `ddev exec php bin/console lint:container` و در صورت وجود، تست‌های SMS/Doctor delete را اجرا کن.
- کدهای خطای جدید (`ERR_METHOD_NOT_ALLOWED_001`, `ERR_PAYMENT_GATEWAY_001`) را در `ErrorCodes` (اگر enum/const مرکزی دارد) ثبت کن تا با الگوی موجود یکدست بماند.
