# تسک ۱۷: ماژول پیامک (SMS) ## توضیح سیستم پیامک یادآوری نوبت برای بیماران. هر دکتر/کلینیک یک حساب پیامک مستقل دارد که با خرید پیامک شارژ می‌شود. ارسال پیامک **async** از طریق Symfony Messenger انجام می‌شود. دکتر یا کلینیک می‌تواند **تمپلیت پیامک سفارشی** برای هر دسته‌بندی بسازد. ادمین باید تمپلیت را تأیید کند — بعد از تأیید، در ارسال پیامک از آن استفاده می‌شود. ## Endpoint ها | متد | مسیر | توضیح | نیاز به Auth | |-----|------|-------|-------------| | GET | `/api/v1/sms/balance` | موجودی حساب پیامک | بله | | POST | `/api/v1/sms/queue` | افزودن پیامک به صف | بله | | GET | `/api/v1/sms/sample-templates` | مشاهده نمونه تمپلیت‌های ادمین | بله (Doctor/Clinic) | | GET | `/api/v1/sms/templates` | لیست تمپلیت‌های خودم | بله (Doctor/Clinic/Admin) | | POST | `/api/v1/sms/templates` | ساختن تمپلیت جدید | بله (Doctor/Clinic) | | GET | `/api/v1/sms/templates/{uuid}` | جزئیات تمپلیت | بله | | PATCH | `/api/v1/sms/templates/{uuid}` | ویرایش تمپلیت (قبل از ارسال به ادمین) | بله (Owner) | | DELETE | `/api/v1/sms/templates/{uuid}` | حذف تمپلیت | بله (Owner/Admin) | | PATCH | `/api/v1/sms/templates/{uuid}/submit` | ارسال به ادمین برای تأیید | بله (Owner) | | PATCH | `/api/v1/sms/templates/{uuid}/approve` | تأیید تمپلیت | بله (Admin) | | PATCH | `/api/v1/sms/templates/{uuid}/reject` | رد تمپلیت با دلیل | بله (Admin) | ## پیش‌نیازها - تسک ۰۵ (Doctor)، تسک ۰۶ (Clinic) ## زمان تخمینی ۸ تا ۱۰ ساعت --- ## GET /api/v1/sms/balance ```json { "success": true, "data": { "owner_id": 5, "owner_type": "doctor", "balance": 847 } } ``` --- ## POST /api/v1/sms/queue ```json // Request { "owner_type": "doctor", "owner_id": 5, "recipients": [ { "mobile": "09120671713", "appointment_uuid": "..." } ], "scheduled_at": 1748000000 } // Response 201 { "success": true, "data": { "id": 42, "status": "queued", "scheduled_at": 1748000000, "remaining_balance": 846 } } // Response 402 — موجودی ناکافی { "success": false, "errors": [{ "code": "ERR_SMS_001", "message": "موجودی پیامک کافی نیست" }] } ``` --- ## SMS Providers — Strategy Pattern ```php interface SmsProviderInterface { public function send(string $mobile, string $message): bool; public function getName(): string; } class KavehNegarProvider implements SmsProviderInterface { ... } class RanginehProvider implements SmsProviderInterface { ... } ``` ### Fallback Logic ``` تلاش با Provider اول (KavehNegar): موفق → ثبت log و کسر موجودی ناموفق → تلاش با Provider دوم (Rangineh): موفق → ثبت log و کسر موجودی ناموفق → log خطا، پیامک در صف می‌ماند برای retry ``` **محیط Dev:** ```php if ($this->appEnv === 'dev') { // OTP ثابت 12345 — بدون ارسال واقعی return true; } ``` --- ## Symfony Messenger — پیاده‌سازی Async ```php // Message class SendSmsMessage { public function __construct( public readonly string $mobile, public readonly string $message, public readonly int $smsLogId, ) {} } // Handler class SendSmsHandler implements MessageHandlerInterface { public function __invoke(SendSmsMessage $message): void { try { $sent = $this->primaryProvider->send($message->mobile, $message->message); if (!$sent) { $sent = $this->fallbackProvider->send($message->mobile, $message->message); } $this->smsLogRepo->markSent($message->smsLogId, $sent); } catch (\Exception $e) { $this->logger->error('sms.send_failed', [ 'mobile' => $message->mobile, 'error' => $e->getMessage(), ]); throw $e; // Messenger retry می‌کند } } } ``` **Retry Config در messenger.yaml:** ```yaml framework: messenger: transports: async: dsn: '%env(MESSENGER_TRANSPORT_DSN)%' retry_strategy: max_retries: 3 delay: 5000 # 5 ثانیه multiplier: 2 # 5s, 10s, 20s ``` --- ## قانون کسر موجودی ``` قبل از ارسال: sms_accounts.balance >= count(recipients) ؟ خیر → 402 بله → ادامه بعد از ارسال موفق: UPDATE sms_accounts SET balance = balance - 1 WHERE owner_id = X ``` --- ## ساختار جدول sms_logs | ستون | نوع | توضیح | |------|-----|-------| | id | INT PK | | | owner_type | VARCHAR(10) | `doctor` یا `clinic` | | owner_id | INT | | | mobile | VARCHAR(20) | شماره گیرنده | | message | TEXT | متن پیامک | | provider | VARCHAR(20) | `kavenegar` یا `rangineh` | | status | VARCHAR(10) | `queued` / `sent` / `failed` | | scheduled_at | INT | Unix timestamp | | sent_at | INT NULL | زمان ارسال واقعی | | error | VARCHAR(255) NULL | پیام خطا در صورت شکست | | created_at | INT | | --- ## نکات مهم - ارسال OTP نیز از همین سرویس استفاده می‌کند (با `scheduled_at=now()`) - برای OTP، Fallback فوری است — کاربر نمی‌تواند منتظر retry بماند - موجودی پیامک مستقل از موجودی کیف پول نماینده است --- ## سیستم تمپلیت پیامک سفارشی ### جریان کلی ``` ۱. ادمین → چند تمپلیت نمونه آموزشی می‌سازد (is_sample=true) مثلاً: "یادآوری نوبت — نمونه" ۲. دکتر/کلینیک → GET /api/v1/sms/sample-templates نمونه‌ها را مشاهده می‌کند ۳. دکتر/کلینیک → POST /api/v1/sms/templates تمپلیت خودش را می‌سازد (status=draft) می‌تواند از نمونه الهام بگیرد یا از صفر بنویسد ۴. دکتر/کلینیک → PATCH /api/v1/sms/templates/{uuid}/submit برای تأیید ادمین ارسال می‌کند (status=pending_approval) ۵. ادمین → GET /api/v1/sms/templates?status=pending_approval لیست تمپلیت‌های در انتظار را می‌بیند ۶. ادمین → PATCH /api/v1/sms/templates/{uuid}/approve (status=approved) یا PATCH /api/v1/sms/templates/{uuid}/reject (status=rejected) ۷. بعد از approve → سیستم SMS این تمپلیت را برای آن دکتر/کلینیک استفاده می‌کند اگر تمپلیت approved نداشت → از تمپلیت پیش‌فرض سیستم استفاده می‌شود ``` ### دسته‌بندی تمپلیت‌ها (category) | category | توضیح | متغیرهای مجاز | |----------|-------|--------------| | `appointment_reminder` | یادآوری نوبت | `{patient_name}`, `{doctor_name}`, `{date}`, `{time}`, `{clinic_name}` | | `appointment_confirmed` | تأیید رزرو | `{patient_name}`, `{doctor_name}`, `{date}`, `{time}` | | `appointment_cancelled` | لغو نوبت | `{patient_name}`, `{doctor_name}`, `{date}` | | `appointment_reminder_1h` | یادآوری ۱ ساعت قبل | `{patient_name}`, `{doctor_name}`, `{time}` | | `custom` | پیامک آزاد (دستی) | `{patient_name}`, `{doctor_name}` | ### ساختار تمپلیت و متغیرها ``` متن نمونه ادمین: "بیمار گرامی {patient_name}، نوبت شما با {doctor_name} در تاریخ {date} ساعت {time} در {clinic_name} تأیید شد." دکتر می‌تواند تغییر دهد: "سلام {patient_name} عزیز! یادآوری نوبت ویزیت با دکتر {doctor_name} تاریخ {date} - ساعت {time} مطب دکتر احمدی، خیابان ولیعصر" ``` متغیرها با `{variable_name}` نشان داده می‌شوند و هنگام ارسال با مقادیر واقعی جایگزین می‌شوند. --- ### GET /api/v1/sms/sample-templates ```json { "success": true, "data": [ { "uuid": "...", "category": "appointment_reminder", "name": "یادآوری نوبت — نمونه پیش‌فرض", "content": "بیمار گرامی {patient_name}، نوبت شما با {doctor_name} در تاریخ {date} ساعت {time} در {clinic_name} تأیید شد.", "available_variables": ["{patient_name}", "{doctor_name}", "{date}", "{time}", "{clinic_name}"] } ] } ``` --- ### POST /api/v1/sms/templates ```json // Request { "category": "appointment_reminder", "name": "یادآوری نوبت — مطب دکتر احمدی", "content": "سلام {patient_name} عزیز! نوبت ویزیت شما با {doctor_name} در تاریخ {date} ساعت {time}. آدرس: خیابان آزادی، مطب طبقه ۲" } // Response 201 { "success": true, "data": { "uuid": "...", "category": "appointment_reminder", "name": "یادآوری نوبت — مطب دکتر احمدی", "content": "سلام {patient_name} عزیز!...", "status": "draft", "created_at": 1748000000 } } // Response 422 — متغیر نامعتبر در متن { "success": false, "errors": [{ "code": "ERR_SMS_002", "message": "متغیر {invalid_var} در این دسته‌بندی مجاز نیست" }] } ``` **اعتبارسنجی هنگام ساختن تمپلیت:** ``` 1. category باید از لیست مجاز باشد 2. متغیرهای داخل {} فقط از لیست available_variables مجاز category باشند 3. طول محتوا: حداکثر 500 کاراکتر 4. هر دکتر/کلینیک حداکثر 3 تمپلیت فعال approved برای هر category ``` --- ### PATCH /api/v1/sms/templates/{uuid}/submit ```json // Request — بدون body // Response 200 { "success": true, "data": { "uuid": "...", "status": "pending_approval", "submitted_at": 1748000000 } } // Response 422 — تمپلیت قبلاً submitted یا approved شده { "success": false, "errors": [{ "code": "ERR_SMS_003", "message": "تمپلیت قبلاً برای بررسی ارسال شده است" }] } ``` --- ### PATCH /api/v1/sms/templates/{uuid}/approve (Admin) ```json // Request — بدون body // Response 200 { "success": true, "data": { "uuid": "...", "status": "approved", "approved_at": 1748000000, "approved_by": { "uuid": "...", "name": "ادمین سیستم" } } } ``` --- ### PATCH /api/v1/sms/templates/{uuid}/reject (Admin) ```json // Request { "reason": "محتوای تمپلیت با قوانین پیامک مغایرت دارد. لطفاً نام کامل بیمار را حذف کنید." } // Response 200 { "success": true, "data": { "uuid": "...", "status": "rejected", "rejection_reason": "محتوای تمپلیت با قوانین پیامک مغایرت دارد...", "rejected_at": 1748000000 } } ``` بعد از reject، صاحب تمپلیت می‌تواند تمپلیت را ویرایش کند (status → draft) و مجدداً submit کند. --- ### GET /api/v1/sms/templates ```json // برای دکتر/کلینیک — فقط تمپلیت‌های خودش // برای ادمین — همه تمپلیت‌ها با فیلتر // Query params: // ?status=pending_approval ← ادمین برای بررسی // ?category=appointment_reminder // ?owner_type=doctor&owner_id=5 { "success": true, "data": [ { "uuid": "...", "category": "appointment_reminder", "name": "یادآوری نوبت — مطب دکتر احمدی", "content": "سلام {patient_name} عزیز!...", "status": "approved", "owner": { "type": "doctor", "name": "دکتر احمدی" }, "approved_at": 1748000000, "created_at": 1748000000 } ], "meta": { "totalRecords": 5, "totalPages": 1, "currentPage": 1 } } ``` --- ### منطق انتخاب تمپلیت هنگام ارسال پیامک ```php // در SmsService::getTemplateFor(ownerId, ownerType, category) public function getTemplateFor(int $ownerId, string $ownerType, string $category): SmsTemplate { // ابتدا تمپلیت approved خاص آن دکتر/کلینیک $custom = $this->templateRepo->findApproved($ownerId, $ownerType, $category); if ($custom) { return $custom; } // اگر نداشت → تمپلیت نمونه پیش‌فرض ادمین return $this->templateRepo->findDefaultSample($category); } // رندر محتوای نهایی با جایگزینی متغیرها public function render(SmsTemplate $template, array $vars): string { return strtr($template->getContent(), array_combine( array_map(fn($k) => '{' . $k . '}', array_keys($vars)), array_values($vars) )); } ``` --- ### جدول sms_templates | ستون | نوع | توضیح | |------|-----|-------| | id | INT PK | | | uuid | VARCHAR(36) | | | owner_type | VARCHAR(10) | `doctor` / `clinic` / `admin` | | owner_id | INT NULL | NULL برای نمونه‌های ادمین | | category | VARCHAR(30) | appointment_reminder / ... | | name | VARCHAR(100) | نام قابل خواندن | | content | TEXT | متن با متغیرها | | is_sample | TINYINT(1) | 1 برای نمونه‌های ادمین | | status | VARCHAR(20) | `draft` / `pending_approval` / `approved` / `rejected` | | rejection_reason | TEXT NULL | دلیل رد ادمین | | approved_by | INT NULL | user_id ادمین تأییدکننده | | approved_at | INT NULL | | | submitted_at | INT NULL | | | created_at | INT | | | updated_at | INT | | ```sql CREATE INDEX idx_sms_tmpl_owner ON sms_templates(owner_type, owner_id); CREATE INDEX idx_sms_tmpl_status ON sms_templates(status); CREATE INDEX idx_sms_tmpl_cat ON sms_templates(category); ```