- Add SendSmsMessage class for encapsulating SMS message data. - Create KavehNegarProvider and RanginehProvider classes implementing SmsProviderInterface for sending SMS. - Implement SmsLogRepository and SmsTemplateRepository for managing SMS logs and templates. - Develop SendSmsHandler for handling SMS sending messages. - Create SmsService to manage SMS dispatching and logging. - Add UserProfileController for managing user profiles with CRUD operations. - Implement UserProfile entity and repository for user profile data management. - Update symfony.lock and bootstrap.php for project dependencies and environment setup.
472 lines
14 KiB
Markdown
472 lines
14 KiB
Markdown
# تسک ۱۷: ماژول پیامک (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);
|
||
```
|