feat: Implement SMS sending functionality with KavehNegar and Rangineh providers

- 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.
This commit is contained in:
hamed
2026-06-09 22:00:34 +03:30
commit de1a78a235
222 changed files with 36388 additions and 0 deletions
+471
View File
@@ -0,0 +1,471 @@
# تسک ۱۷: ماژول پیامک (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);
```