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:
@@ -0,0 +1,149 @@
|
||||
# معماری — تسک ۰۱: راهاندازی پروژه
|
||||
|
||||
## ساختار پوشههای پروژه
|
||||
```
|
||||
clinic-pro-symfony/
|
||||
├── .ddev/
|
||||
│ ├── config.yaml
|
||||
│ └── docker-compose.redis.yaml
|
||||
├── config/
|
||||
│ ├── packages/
|
||||
│ │ ├── doctrine.yaml
|
||||
│ │ ├── lexik_jwt_authentication.yaml
|
||||
│ │ ├── nelmio_cors.yaml
|
||||
│ │ ├── framework.yaml
|
||||
│ │ ├── cache.yaml
|
||||
│ │ └── security.yaml
|
||||
│ ├── routes/
|
||||
│ │ └── api.yaml
|
||||
│ └── services.yaml
|
||||
├── src/
|
||||
│ ├── Module/
|
||||
│ │ ├── Auth/
|
||||
│ │ ├── UserProfile/
|
||||
│ │ ├── Blog/
|
||||
│ │ ├── Doctor/
|
||||
│ │ ├── Clinic/
|
||||
│ │ ├── Agent/
|
||||
│ │ ├── Category/
|
||||
│ │ ├── AppointmentSettings/
|
||||
│ │ ├── Appointment/
|
||||
│ │ ├── Insurance/
|
||||
│ │ ├── Rating/
|
||||
│ │ ├── Comment/
|
||||
│ │ ├── Like/
|
||||
│ │ ├── Secretary/
|
||||
│ │ ├── Payment/
|
||||
│ │ └── Representation/
|
||||
│ └── Shared/
|
||||
│ ├── Response/
|
||||
│ │ └── ApiResponse.php
|
||||
│ ├── Exception/
|
||||
│ │ ├── ValidationException.php
|
||||
│ │ └── NotFoundException.php
|
||||
│ ├── Trait/
|
||||
│ │ └── TimestampableTrait.php
|
||||
│ └── EventSubscriber/
|
||||
│ └── ExceptionSubscriber.php
|
||||
├── migrations/
|
||||
├── tests/
|
||||
├── public/
|
||||
│ └── index.php
|
||||
└── .env
|
||||
```
|
||||
|
||||
## ساختار داخلی هر ماژول
|
||||
```
|
||||
src/Module/{ModuleName}/
|
||||
├── Controller/
|
||||
│ └── {Name}Controller.php ← دریافت request، فراخوانی service، بازگشت response
|
||||
├── Service/
|
||||
│ └── {Name}Service.php ← منطق تجاری
|
||||
├── Repository/
|
||||
│ └── {Name}Repository.php ← کوئریهای پایگاه داده
|
||||
├── Entity/
|
||||
│ └── {Name}.php ← Doctrine ORM mapping
|
||||
├── DTO/
|
||||
│ ├── Request/
|
||||
│ │ └── Create{Name}Request.php ← validation ورودی
|
||||
│ └── Response/
|
||||
│ └── {Name}Response.php ← شکلدهی خروجی JSON
|
||||
└── Voter/
|
||||
└── {Name}Voter.php ← بررسی مجوزها
|
||||
```
|
||||
|
||||
## مسئولیت هر لایه
|
||||
|
||||
| لایه | مسئولیت |
|
||||
|------|---------|
|
||||
| Controller | دریافت HTTP، اعتبارسنجی DTO، فراخوانی Service، بازگشت ApiResponse |
|
||||
| Service | منطق تجاری، فراخوانی Repository، dispatch Event |
|
||||
| Repository | تمام کوئریهای Doctrine، بدون منطق تجاری |
|
||||
| Entity | تعریف ساختار جداول با ORM Attribute |
|
||||
| DTO | اعتبارسنجی ورودی و شکلدهی خروجی |
|
||||
| Voter | بررسی اینکه چه کسی به چه چیزی دسترسی دارد |
|
||||
|
||||
## فرمت استاندارد پاسخ (ApiResponse)
|
||||
|
||||
> **⚠ فرمت رسمی از task.md است — این فایل با آن sync شده.**
|
||||
|
||||
### موفق (بدون صفحهبندی)
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"data": { "..." }
|
||||
}
|
||||
```
|
||||
|
||||
### موفق (با صفحهبندی)
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"data": [ "..." ],
|
||||
"meta": {
|
||||
"totalRecords": 47,
|
||||
"totalPages": 5,
|
||||
"currentPage": 1
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### خطا
|
||||
```json
|
||||
{
|
||||
"success": false,
|
||||
"data": null,
|
||||
"errors": [
|
||||
{
|
||||
"code": "ERR_VALIDATION_001",
|
||||
"field": "mobile_number",
|
||||
"message": "فرمت شماره موبایل نادرست است"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
## نمودار ارتباط لایهها
|
||||
```
|
||||
HTTP Request
|
||||
│
|
||||
▼
|
||||
┌─────────────┐
|
||||
│ Controller │ ← Route, DTO bind, Validate
|
||||
└──────┬──────┘
|
||||
│
|
||||
▼
|
||||
┌─────────────┐
|
||||
│ Service │ ← Business logic, Events
|
||||
└──────┬──────┘
|
||||
│
|
||||
▼
|
||||
┌─────────────┐
|
||||
│ Repository │ ← Doctrine queries
|
||||
└──────┬──────┘
|
||||
│
|
||||
▼
|
||||
┌─────────────┐
|
||||
│ Entity │ ← Database row
|
||||
└─────────────┘
|
||||
```
|
||||
@@ -0,0 +1,94 @@
|
||||
# پایگاه داده — تسک ۰۱: راهاندازی پروژه
|
||||
|
||||
## موتور پایگاه داده
|
||||
MySQL 8.0 با موتور InnoDB و charset از نوع utf8mb4.
|
||||
DDEV به صورت پیشفرض MySQL 8 را فراهم میکند.
|
||||
|
||||
## قراردادهای کلی
|
||||
|
||||
| قرارداد | توضیح |
|
||||
|---------|-------|
|
||||
| `id` | کلید اصلی auto-increment (فقط استفاده داخلی) |
|
||||
| `uuid` | شناسه عمومی UUID v4 (استفاده در API) |
|
||||
| `created_at` | زمان ایجاد (datetime_immutable) |
|
||||
| `updated_at` | زمان آخرین ویرایش (datetime_immutable) |
|
||||
| `deleted_at` | nullable — برای soft delete جداول مهم |
|
||||
| Foreign key | `ON DELETE CASCADE` یا `ON DELETE SET NULL` بسته به نیاز |
|
||||
|
||||
## پیکربندی DDEV برای پایگاه داده
|
||||
DDEV به طور خودکار MySQL 8 راهاندازی میکند:
|
||||
```bash
|
||||
# اطلاعات اتصال از DDEV
|
||||
DB_HOST=db
|
||||
DB_PORT=3306
|
||||
DB_NAME=db
|
||||
DB_USER=db
|
||||
DB_PASSWORD=db
|
||||
```
|
||||
|
||||
در فایل `.env`:
|
||||
```dotenv
|
||||
DATABASE_URL="mysql://db:db@db:3306/db?serverVersion=8.0&charset=utf8mb4"
|
||||
```
|
||||
|
||||
## Redis با DDEV
|
||||
```yaml
|
||||
# .ddev/docker-compose.redis.yaml
|
||||
version: "3.6"
|
||||
services:
|
||||
redis:
|
||||
image: redis:7-alpine
|
||||
expose:
|
||||
- "6379"
|
||||
labels:
|
||||
com.ddev.site-name: ${DDEV_SITENAME}
|
||||
com.ddev.approot: ${DDEV_APPROOT}
|
||||
```
|
||||
|
||||
## استفاده از Redis
|
||||
|
||||
| کاربرد | کلید | TTL |
|
||||
|--------|------|-----|
|
||||
| کد OTP | `otp:{mobile}` | ۱۲۰ ثانیه |
|
||||
| Rate limiting | `rate:{ip}:{endpoint}` | بسته به قانون |
|
||||
| JWT Blacklist (logout) | `jwt_blacklist:{jti}` | تا انقضای token |
|
||||
|
||||
## TimestampableTrait
|
||||
در همه Entity ها استفاده میشود:
|
||||
```php
|
||||
// src/Shared/Trait/TimestampableTrait.php
|
||||
trait TimestampableTrait
|
||||
{
|
||||
#[ORM\Column(type: 'datetime_immutable')]
|
||||
private \DateTimeImmutable $createdAt;
|
||||
|
||||
#[ORM\Column(type: 'datetime_immutable')]
|
||||
private \DateTimeImmutable $updatedAt;
|
||||
|
||||
#[ORM\PrePersist]
|
||||
public function onPrePersist(): void
|
||||
{
|
||||
$this->createdAt = new \DateTimeImmutable();
|
||||
$this->updatedAt = new \DateTimeImmutable();
|
||||
}
|
||||
|
||||
#[ORM\PreUpdate]
|
||||
public function onPreUpdate(): void
|
||||
{
|
||||
$this->updatedAt = new \DateTimeImmutable();
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## استراتژی Migration
|
||||
- از `doctrine/doctrine-migrations-bundle` استفاده میشود
|
||||
- هر تسک migration فایل مخصوص خود را دارد
|
||||
- هرگز migration های قبلی ویرایش نشوند — همیشه فایل جدید بساز
|
||||
- دستور اجرا:
|
||||
```bash
|
||||
ddev exec php bin/console doctrine:migrations:migrate
|
||||
```
|
||||
- دستور ساخت migration جدید:
|
||||
```bash
|
||||
ddev exec php bin/console doctrine:migrations:diff
|
||||
```
|
||||
@@ -0,0 +1,317 @@
|
||||
# نکات پیادهسازی — تسک ۰۱: راهاندازی پروژه
|
||||
|
||||
## تفاوتهای اصلی Drupal vs Symfony
|
||||
|
||||
### CSRF Token
|
||||
در Drupal: endpoint مخصوص `GET /session/token` برای دریافت CSRF توکن وجود دارد.
|
||||
در Symfony: چون API کاملاً stateless است و JWT استفاده میشود، CSRF Token
|
||||
سنتی **نیاز نیست**. به جای آن، JWT در هر request ارسال میشود.
|
||||
→ در TASK-02 endpoint ساختگی `/session/token` پیادهسازی میشود که یک مقدار تصادفی
|
||||
برمیگرداند تا کلاینت موجود بدون تغییر کار کند.
|
||||
|
||||
### UUID
|
||||
در Drupal: UUID داخلی Drupal مدیریت میشود.
|
||||
در Symfony: از `symfony/uid` (built-in) استفاده کن — نه `ramsey/uuid`.
|
||||
→ تمام ID های عمومی در API باید UUID باشند، نه auto-increment.
|
||||
|
||||
---
|
||||
|
||||
## پیکربندی Security (security.yaml)
|
||||
|
||||
```yaml
|
||||
# config/packages/security.yaml
|
||||
security:
|
||||
password_hashers:
|
||||
App\Auth\Entity\User:
|
||||
algorithm: bcrypt
|
||||
cost: 12
|
||||
|
||||
providers:
|
||||
# تنها provider — username همیشه شماره موبایل است (برای همه نقشها)
|
||||
app_user_provider:
|
||||
entity:
|
||||
class: App\Auth\Entity\User
|
||||
property: mobileNumber
|
||||
|
||||
firewalls:
|
||||
dev:
|
||||
pattern: ^/(_(profiler|wdt)|css|images|js)/
|
||||
security: false
|
||||
|
||||
health:
|
||||
pattern: ^/health$
|
||||
security: false
|
||||
|
||||
# Endpoints کاملاً عمومی (بدون هیچ بررسی)
|
||||
public:
|
||||
pattern: ^/(api/v1/user/send-code|api/v1/user/verify-code|api/v1/user/register|oauth/token|session/token)$
|
||||
stateless: true
|
||||
security: false
|
||||
|
||||
api:
|
||||
pattern: ^/(api|oauth)/
|
||||
stateless: true
|
||||
# ۱) Password login برای doctor/clinic/secretary
|
||||
custom_authenticators:
|
||||
- App\Auth\Security\PasswordAuthenticator
|
||||
# ۲) JWT middleware — Authorization: Bearer را میخواند
|
||||
jwt: ~
|
||||
|
||||
access_control:
|
||||
# Public endpoints
|
||||
- { path: ^/health$, roles: PUBLIC_ACCESS }
|
||||
- { path: ^/api/v1/user/send-code, roles: PUBLIC_ACCESS }
|
||||
- { path: ^/api/v1/user/verify-code, roles: PUBLIC_ACCESS }
|
||||
- { path: ^/api/v1/user/register, roles: PUBLIC_ACCESS }
|
||||
- { path: ^/api/v1/user/login, roles: PUBLIC_ACCESS }
|
||||
- { path: ^/oauth/token$, roles: PUBLIC_ACCESS }
|
||||
- { path: ^/oauth/token/refresh$, roles: PUBLIC_ACCESS }
|
||||
- { path: ^/session/token, roles: PUBLIC_ACCESS }
|
||||
|
||||
# Swagger — فقط در dev
|
||||
- { path: ^/api/doc, roles: PUBLIC_ACCESS, env: dev }
|
||||
|
||||
# Admin-only
|
||||
- { path: ^/api/v1/user/\d+$, methods: [DELETE], roles: ROLE_ADMIN }
|
||||
|
||||
# Authenticated
|
||||
- { path: ^/api, roles: IS_AUTHENTICATED_FULLY }
|
||||
- { path: ^/oauth/userinfo, roles: IS_AUTHENTICATED_FULLY }
|
||||
- { path: ^/oauth/logout, roles: IS_AUTHENTICATED_FULLY }
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## ⚠ استاندارد JWT — Access Token در هدر، Refresh Token در Body
|
||||
|
||||
این مهمترین نکته امنیتی در مدیریت توکنهاست. دو توکن دو جای کاملاً متفاوت دارند:
|
||||
|
||||
### Access Token → فقط در `Authorization` header
|
||||
|
||||
```
|
||||
Authorization: Bearer eyJ0eXAiOiJKV1QiLCJhbGciOiJSUzI1NiJ9...
|
||||
```
|
||||
|
||||
**LexikJWTAuthenticationBundle** این هدر را به صورت خودکار در تمام endpoint های محافظتشده بررسی میکند.
|
||||
در Controller کد اضافهای لازم نیست.
|
||||
|
||||
**هرگز access_token را اینجا نفرست:**
|
||||
```
|
||||
❌ GET /api/endpoint?token=eyJ... ← URL — در لاگهای سرور ذخیره میشود
|
||||
❌ POST body: {"token": "eyJ..."} ← Body — افشا در لاگهای request
|
||||
❌ Cookie: access_token=eyJ... ← Cookie — باید HttpOnly باشد و CSRF لازم دارد
|
||||
```
|
||||
|
||||
### Refresh Token → فقط در body برای یک endpoint خاص
|
||||
|
||||
```json
|
||||
POST /oauth/token/refresh
|
||||
Content-Type: application/json
|
||||
|
||||
{ "refresh_token": "a8f3b2c1d0e4..." }
|
||||
```
|
||||
|
||||
Refresh Token هرگز در `Authorization` header نمیرود. این endpoint در `access_control` با `PUBLIC_ACCESS` است — تأیید هویت با خود Refresh Token انجام میشود.
|
||||
|
||||
### گردش کامل توکنها
|
||||
|
||||
```
|
||||
[ورود — یکبار]
|
||||
POST /api/v1/user/login (password)
|
||||
یا
|
||||
POST /oauth/token (OTP)
|
||||
|
||||
← Response:
|
||||
{
|
||||
"access_token": "eyJ..." ← TTL=1h — در RAM/memory ذخیره کن
|
||||
"refresh_token": "a8f3b2..." ← TTL=30d — در HttpOnly Cookie یا secure storage
|
||||
}
|
||||
|
||||
[هر request محافظتشده]
|
||||
Authorization: Bearer eyJ... ← فقط access_token
|
||||
|
||||
[وقتی access_token منقضی — 401 دریافت شد]
|
||||
POST /oauth/token/refresh
|
||||
{ "refresh_token": "a8f3b2..." }
|
||||
← Response: access_token جدید + refresh_token جدید (Rotation)
|
||||
|
||||
[خروج]
|
||||
POST /oauth/logout
|
||||
Authorization: Bearer eyJ...
|
||||
{ "refresh_token": "a8f3b2..." }
|
||||
← هر دو توکن باطل میشوند
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## پیکربندی CORS — محدود، نه باز
|
||||
|
||||
```yaml
|
||||
# config/packages/nelmio_cors.yaml
|
||||
nelmio_cors:
|
||||
defaults:
|
||||
origin_regex: true
|
||||
allow_origin:
|
||||
- '%env(CORS_ALLOW_ORIGIN)%'
|
||||
allow_methods: ['GET', 'OPTIONS', 'POST', 'PATCH', 'DELETE']
|
||||
allow_headers: ['Content-Type', 'Authorization', 'X-CSRF-Token', 'Content-Disposition']
|
||||
expose_headers: ['X-RateLimit-Limit', 'X-RateLimit-Remaining', 'X-RateLimit-Reset']
|
||||
max_age: 3600
|
||||
allow_credentials: false
|
||||
paths:
|
||||
'^/api/':
|
||||
# ⚠️ هرگز '*' نگذار — فقط دامنههای مشخص
|
||||
allow_origin: ['%env(CORS_ALLOW_ORIGIN)%']
|
||||
'^/oauth/':
|
||||
allow_origin: ['%env(CORS_ALLOW_ORIGIN)%']
|
||||
'^/health':
|
||||
allow_origin: ['%env(CORS_ALLOW_ORIGIN)%']
|
||||
```
|
||||
|
||||
```dotenv
|
||||
# .env.local (production)
|
||||
CORS_ALLOW_ORIGIN=^https://(app\.clinicpro\.ir|admin\.clinicpro\.ir)$
|
||||
|
||||
# .env (development)
|
||||
CORS_ALLOW_ORIGIN=^https?://(localhost|.*\.ddev\.site)(:\d+)?$
|
||||
```
|
||||
|
||||
> **⚠ هشدار:** هرگز `allow_origin: ['*']` در production استفاده نکن.
|
||||
> این اجازه میدهد هر وبسایت مخرب درخواستهای authenticated ارسال کند.
|
||||
|
||||
---
|
||||
|
||||
## Security Headers (EventSubscriber)
|
||||
|
||||
```php
|
||||
// src/Shared/EventSubscriber/SecurityHeadersSubscriber.php
|
||||
class SecurityHeadersSubscriber implements EventSubscriberInterface
|
||||
{
|
||||
public function onKernelResponse(ResponseEvent $event): void
|
||||
{
|
||||
if (!$event->isMainRequest()) return;
|
||||
|
||||
$response = $event->getResponse();
|
||||
$response->headers->set('X-Content-Type-Options', 'nosniff');
|
||||
$response->headers->set('X-Frame-Options', 'DENY');
|
||||
$response->headers->set('X-XSS-Protection', '1; mode=block');
|
||||
$response->headers->set('Referrer-Policy', 'strict-origin-when-cross-origin');
|
||||
$response->headers->set('Permissions-Policy', 'geolocation=(), microphone=(), camera=()');
|
||||
|
||||
if ($event->getRequest()->isSecure()) {
|
||||
$response->headers->set(
|
||||
'Strict-Transport-Security',
|
||||
'max-age=31536000; includeSubDomains'
|
||||
);
|
||||
}
|
||||
|
||||
// برای API responses، Content-Security-Policy محدود
|
||||
if (str_starts_with($event->getRequest()->getPathInfo(), '/api')) {
|
||||
$response->headers->set('Content-Security-Policy', "default-src 'none'");
|
||||
}
|
||||
}
|
||||
|
||||
public static function getSubscribedEvents(): array
|
||||
{
|
||||
return [KernelEvents::RESPONSE => 'onKernelResponse'];
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Swagger UI — فقط در محیط Dev
|
||||
|
||||
```yaml
|
||||
# config/packages/nelmio_api_doc.yaml
|
||||
when@prod:
|
||||
nelmio_api_doc:
|
||||
# در production کاملاً غیرفعال میشود
|
||||
# route ها به /api/doc باید در routing فقط برای dev تعریف شوند
|
||||
```
|
||||
|
||||
```yaml
|
||||
# config/routes/nelmio_api_doc.yaml
|
||||
when@dev:
|
||||
app.swagger_ui:
|
||||
path: /api/doc
|
||||
methods: GET
|
||||
defaults:
|
||||
_controller: nelmio_api_doc.controller.swagger_ui
|
||||
|
||||
app.swagger_json:
|
||||
path: /api/doc.json
|
||||
methods: GET
|
||||
defaults:
|
||||
_controller: nelmio_api_doc.controller.swagger
|
||||
```
|
||||
|
||||
> **⚠** با این روش، در production هیچ route ای برای `/api/doc` وجود ندارد → 404
|
||||
|
||||
---
|
||||
|
||||
## Audit Log — جدول security_logs
|
||||
|
||||
برای رویدادهای امنیتی حساس، یک جدول جداگانه وجود دارد:
|
||||
|
||||
```sql
|
||||
CREATE TABLE security_logs (
|
||||
id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
|
||||
event_type VARCHAR(50) NOT NULL, -- 'otp_failed', 'login_success', 'role_changed', 'payment_verified', ...
|
||||
user_id INT NULL,
|
||||
ip_address VARCHAR(45) NOT NULL,
|
||||
user_agent VARCHAR(255) NULL,
|
||||
details JSON NULL, -- اطلاعات اضافه (بدون data حساس!)
|
||||
created_at INT NOT NULL
|
||||
);
|
||||
|
||||
CREATE INDEX idx_sec_logs_event ON security_logs(event_type);
|
||||
CREATE INDEX idx_sec_logs_user ON security_logs(user_id);
|
||||
CREATE INDEX idx_sec_logs_created ON security_logs(created_at);
|
||||
```
|
||||
|
||||
**رویدادهایی که باید log شوند:**
|
||||
```
|
||||
otp_sent — ارسال OTP (mobile ماسکشده: 0912***1713)
|
||||
otp_failed — کد اشتباه
|
||||
otp_expired — کد منقضی
|
||||
login_success — ورود موفق
|
||||
login_failed — ورود ناموفق
|
||||
logout — خروج
|
||||
token_refreshed — Refresh Token استفاده شد
|
||||
role_changed — تغییر نقش کاربر
|
||||
user_deleted — حذف کاربر
|
||||
payment_initiated — شروع پرداخت
|
||||
payment_verified — تأیید پرداخت
|
||||
payment_failed — پرداخت ناموفق
|
||||
file_uploaded — آپلود فایل
|
||||
```
|
||||
|
||||
> **⚠ دادههای حساس را log نکن:** شماره کامل موبایل، کد OTP، شماره کارت، JWT.
|
||||
|
||||
---
|
||||
|
||||
## نکات DDEV
|
||||
|
||||
```bash
|
||||
ddev exec php bin/console ...
|
||||
ddev composer ...
|
||||
ddev describe # مشاهده آدرسها و پورتها
|
||||
ddev ssh # ورود به container
|
||||
```
|
||||
|
||||
URL پروژه: `https://clinic-pro.ddev.site`
|
||||
|
||||
---
|
||||
|
||||
## نکات امنیتی Production
|
||||
|
||||
```
|
||||
✅ هرگز config/jwt/private.pem را در git commit نکن (.gitignore)
|
||||
✅ JWT_PASSPHRASE را قوی انتخاب کن (حداقل 32 کاراکتر تصادفی)
|
||||
✅ APP_SECRET را با openssl rand -hex 32 تولید کن
|
||||
✅ .env.local برای production (نه .env)
|
||||
✅ CORS_ALLOW_ORIGIN فقط دامنههای مشخص (نه *)
|
||||
✅ Swagger UI فقط در dev فعال است
|
||||
✅ APP_DEBUG=false در production
|
||||
```
|
||||
@@ -0,0 +1,562 @@
|
||||
# تسک ۰۱: راهاندازی پروژه و زیرساخت
|
||||
|
||||
## توضیح
|
||||
راهاندازی اولیه پروژه Symfony 7 با DDEV، نصب پکیجها، پیکربندی JWT، Doctrine ORM،
|
||||
CORS، ساختار Domain-Driven و الگوهای مشترک (BaseController، DTO، Error Codes).
|
||||
|
||||
## Endpoint ها
|
||||
| متد | مسیر | توضیح | نیاز به Auth |
|
||||
|-----|------|-------|-------------|
|
||||
| GET | `/health` | Health Check | خیر |
|
||||
|
||||
## پیشنیازها
|
||||
ندارد — اولین تسک است.
|
||||
|
||||
## خروجیهای مورد انتظار
|
||||
- [ ] DDEV راهاندازی و در حال اجرا
|
||||
- [ ] پروژه Symfony 7 ایجاد شده
|
||||
- [ ] ساختار پوشه Domain-Driven تعریف شده
|
||||
- [ ] JWT authentication bundle پیکربندی شده
|
||||
- [ ] Doctrine ORM پیکربندی شده
|
||||
- [ ] CORS پیکربندی شده
|
||||
- [ ] `BaseController` با متدهای `success()` و `error()` پیاده شده
|
||||
- [ ] `BaseRepository` با متدهای مشترک پیاده شده
|
||||
- [ ] Error Codes و Error Response استاندارد تعریف شده
|
||||
- [ ] DTO pattern برای Input/Output تعریف شده
|
||||
- [ ] Rate Limiter پیکربندی شده
|
||||
- [ ] Symfony Messenger (Queue) راهاندازی شده
|
||||
- [ ] Structured Logging با Monolog پیکربندی شده
|
||||
- [ ] Swagger UI (NelmioApiDocBundle) راهاندازی شده — `/api/doc`
|
||||
- [ ] `GET /health` endpoint پیاده شده
|
||||
|
||||
## زمان تخمینی
|
||||
۶ تا ۸ ساعت
|
||||
|
||||
---
|
||||
|
||||
## مراحل راهاندازی با DDEV
|
||||
|
||||
### ۱. ایجاد پروژه Symfony (قبل از DDEV)
|
||||
```bash
|
||||
mkdir clinic-pro-symfony && cd clinic-pro-symfony
|
||||
|
||||
# ابتدا Symfony، بعد DDEV
|
||||
composer create-project symfony/skeleton . "7.*"
|
||||
|
||||
ddev config \
|
||||
--project-type=symfony \
|
||||
--php-version=8.3 \
|
||||
--docroot=public \
|
||||
--project-name=clinic-pro
|
||||
|
||||
ddev start
|
||||
```
|
||||
|
||||
### ۲. نصب پکیجها (همه در یک دستور)
|
||||
```bash
|
||||
ddev composer require \
|
||||
symfony/security-bundle \
|
||||
symfony/validator \
|
||||
symfony/serializer \
|
||||
symfony/property-access \
|
||||
symfony/property-info \
|
||||
symfony/uid \
|
||||
symfony/messenger \
|
||||
lexik/jwt-authentication-bundle \
|
||||
doctrine/doctrine-bundle \
|
||||
doctrine/doctrine-migrations-bundle \
|
||||
symfony/cache \
|
||||
nelmio/cors-bundle \
|
||||
symfony/rate-limiter \
|
||||
symfony/http-client \
|
||||
nelmio/api-doc-bundle \
|
||||
zircote/swagger-php \
|
||||
twig/twig \
|
||||
symfony/asset
|
||||
|
||||
ddev composer require --dev \
|
||||
symfony/maker-bundle \
|
||||
doctrine/data-fixtures \
|
||||
symfony/debug-bundle
|
||||
```
|
||||
|
||||
### ۳. راهاندازی Redis با DDEV addon رسمی
|
||||
```bash
|
||||
ddev get ddev/ddev-redis
|
||||
ddev restart
|
||||
```
|
||||
|
||||
### ۴. تولید کلیدهای JWT
|
||||
```bash
|
||||
ddev exec php bin/console lexik:jwt:generate-keypair
|
||||
```
|
||||
|
||||
### ۵. پیکربندی LexikJWT
|
||||
|
||||
فایل `config/packages/lexik_jwt_authentication.yaml`:
|
||||
```yaml
|
||||
lexik_jwt_authentication:
|
||||
secret_key: '%env(resolve:JWT_SECRET_KEY)%'
|
||||
public_key: '%env(resolve:JWT_PUBLIC_KEY)%'
|
||||
pass_phrase: '%env(JWT_PASSPHRASE)%'
|
||||
token_ttl: 3600
|
||||
```
|
||||
|
||||
### ۶. پیکربندی Symfony Messenger (Queue)
|
||||
|
||||
فایل `config/packages/messenger.yaml`:
|
||||
```yaml
|
||||
framework:
|
||||
messenger:
|
||||
transports:
|
||||
async:
|
||||
dsn: '%env(MESSENGER_TRANSPORT_DSN)%'
|
||||
options:
|
||||
auto_setup: true
|
||||
routing:
|
||||
'App\Shared\Message\SendSmsMessage': async
|
||||
'App\Shared\Message\SendNotificationMessage': async
|
||||
```
|
||||
|
||||
### ۷. پیکربندی Swagger
|
||||
|
||||
فایل `config/packages/nelmio_api_doc.yaml`:
|
||||
```yaml
|
||||
nelmio_api_doc:
|
||||
documentation:
|
||||
info:
|
||||
title: ClinicPro API
|
||||
description: مستندات API سیستم کلینیکپرو
|
||||
version: 1.0.0
|
||||
components:
|
||||
securitySchemes:
|
||||
bearerAuth:
|
||||
type: http
|
||||
scheme: bearer
|
||||
bearerFormat: JWT
|
||||
security:
|
||||
- bearerAuth: []
|
||||
areas:
|
||||
path_patterns:
|
||||
- ^/api
|
||||
- ^/oauth
|
||||
- ^/health
|
||||
```
|
||||
|
||||
فایل `config/routes/nelmio_api_doc.yaml`:
|
||||
```yaml
|
||||
app.swagger_ui:
|
||||
path: /api/doc
|
||||
methods: GET
|
||||
defaults:
|
||||
_controller: nelmio_api_doc.controller.swagger_ui
|
||||
|
||||
app.swagger_json:
|
||||
path: /api/doc.json
|
||||
methods: GET
|
||||
defaults:
|
||||
_controller: nelmio_api_doc.controller.swagger
|
||||
```
|
||||
|
||||
### ۸. پیکربندی Structured Logging
|
||||
|
||||
فایل `config/packages/monolog.yaml` (بخش prod):
|
||||
```yaml
|
||||
monolog:
|
||||
handlers:
|
||||
main:
|
||||
type: stream
|
||||
path: '%kernel.logs_dir%/%kernel.environment%.log'
|
||||
level: info
|
||||
formatter: monolog.formatter.json
|
||||
security:
|
||||
type: stream
|
||||
path: '%kernel.logs_dir%/security.log'
|
||||
level: warning
|
||||
channels: [security]
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## ساختار پوشه Domain-Driven (الزامی)
|
||||
|
||||
```
|
||||
src/
|
||||
├── Auth/ ← Identity Context
|
||||
│ ├── Controller/
|
||||
│ ├── Service/
|
||||
│ ├── DTO/
|
||||
│ └── Message/ ← برای ارسال OTP async
|
||||
│
|
||||
├── Doctor/ ← Clinical Context
|
||||
│ ├── Controller/
|
||||
│ ├── Entity/
|
||||
│ │ ├── Doctor.php
|
||||
│ │ └── DoctorAddress.php
|
||||
│ ├── Repository/
|
||||
│ ├── Service/
|
||||
│ │ ├── DoctorService.php
|
||||
│ │ └── ScheduleService.php ← محاسبه free_turn و hours_of_work
|
||||
│ └── DTO/
|
||||
│
|
||||
├── Clinic/ ← Clinical Context
|
||||
│ ├── Controller/
|
||||
│ ├── Entity/
|
||||
│ ├── Repository/
|
||||
│ ├── Service/
|
||||
│ └── DTO/
|
||||
│
|
||||
├── Appointment/ ← Scheduling Context
|
||||
│ ├── Controller/
|
||||
│ ├── Entity/
|
||||
│ ├── Repository/
|
||||
│ ├── Service/
|
||||
│ │ ├── AppointmentService.php
|
||||
│ │ └── SlotService.php ← محاسبه اسلاتهای خالی
|
||||
│ └── DTO/
|
||||
│
|
||||
├── Payment/ ← Financial Context
|
||||
│ ├── Controller/
|
||||
│ ├── Entity/
|
||||
│ ├── Repository/
|
||||
│ ├── Service/
|
||||
│ └── Gateway/
|
||||
│ ├── PaymentGatewayInterface.php
|
||||
│ ├── MellatGateway.php
|
||||
│ └── SepGateway.php
|
||||
│
|
||||
├── Rating/ ← Community Context
|
||||
│ ├── Controller/
|
||||
│ ├── Entity/
|
||||
│ ├── Repository/
|
||||
│ ├── Service/
|
||||
│ │ └── RatingCalculatorService.php
|
||||
│ └── DTO/
|
||||
│
|
||||
├── Blog/
|
||||
├── Category/ ← Catalog Context
|
||||
├── Representation/ ← Tenant Context
|
||||
├── Secretary/
|
||||
├── Insurance/
|
||||
├── Settlement/
|
||||
├── Sms/
|
||||
│
|
||||
└── Shared/ ← کدهای مشترک
|
||||
├── Controller/
|
||||
│ └── BaseController.php
|
||||
├── Repository/
|
||||
│ └── BaseRepository.php
|
||||
├── Response/
|
||||
│ ├── ApiResponse.php
|
||||
│ └── ApiError.php
|
||||
├── DTO/
|
||||
│ └── PaginationMeta.php
|
||||
├── Exception/
|
||||
│ └── AppException.php
|
||||
├── Message/
|
||||
│ ├── SendSmsMessage.php
|
||||
│ └── SendNotificationMessage.php
|
||||
└── Constant/
|
||||
└── ErrorCodes.php
|
||||
```
|
||||
|
||||
**قانون Dependency Direction — رعایت اجباری:**
|
||||
```
|
||||
Controller → Service → Repository → Entity
|
||||
```
|
||||
هیچگاه:
|
||||
```
|
||||
Entity → Service ❌
|
||||
Repository → Controller ❌
|
||||
Service → Controller ❌
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Error Response استاندارد
|
||||
|
||||
### فرمت موفق
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"data": { ... },
|
||||
"meta": {
|
||||
"page": 1,
|
||||
"totalPages": 5,
|
||||
"totalRecords": 47
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### فرمت خطا
|
||||
```json
|
||||
{
|
||||
"success": false,
|
||||
"data": null,
|
||||
"errors": [
|
||||
{
|
||||
"code": "ERR_VALIDATION_001",
|
||||
"field": "mobile_number",
|
||||
"message": "فرمت شماره موبایل نادرست است"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
### Error Codes — `src/Shared/Constant/ErrorCodes.php`
|
||||
|
||||
```php
|
||||
class ErrorCodes
|
||||
{
|
||||
// Auth
|
||||
const ERR_AUTH_001 = 'توکن JWT منقضی شده یا نامعتبر است';
|
||||
const ERR_AUTH_002 = 'کد OTP نامعتبر است';
|
||||
const ERR_AUTH_003 = 'کد OTP منقضی شده است';
|
||||
const ERR_AUTH_004 = 'تعداد تلاشهای OTP به حد مجاز رسیده است';
|
||||
|
||||
// Validation
|
||||
const ERR_VALIDATION_001 = 'ورودی نامعتبر است';
|
||||
const ERR_VALIDATION_002 = 'فیلد الزامی وارد نشده است';
|
||||
|
||||
// Not Found
|
||||
const ERR_NOT_FOUND_001 = 'منبع درخواستی یافت نشد';
|
||||
|
||||
// Forbidden
|
||||
const ERR_FORBIDDEN_001 = 'دسترسی به این منبع مجاز نیست';
|
||||
|
||||
// Payment
|
||||
const ERR_PAYMENT_001 = 'درگاه پرداخت در دسترس نیست';
|
||||
const ERR_PAYMENT_002 = 'مبلغ پرداخت نامعتبر است';
|
||||
const ERR_PAYMENT_003 = 'وضعیت نوبت برای پرداخت مناسب نیست';
|
||||
|
||||
// Appointment
|
||||
const ERR_APPOINTMENT_001 = 'اسلات انتخابشده در دسترس نیست';
|
||||
const ERR_APPOINTMENT_002 = 'نوبت قابل لغو نیست';
|
||||
|
||||
// File
|
||||
const ERR_FILE_001 = 'فرمت فایل مجاز نیست';
|
||||
const ERR_FILE_002 = 'حجم فایل بیش از حد مجاز است (حداکثر 5MB)';
|
||||
}
|
||||
```
|
||||
|
||||
### BaseController — `src/Shared/Controller/BaseController.php`
|
||||
|
||||
```php
|
||||
abstract class BaseController extends AbstractController
|
||||
{
|
||||
protected function success(mixed $data, int $status = 200, array $meta = []): JsonResponse
|
||||
{
|
||||
$response = ['success' => true, 'data' => $data];
|
||||
if (!empty($meta)) {
|
||||
$response['meta'] = $meta;
|
||||
}
|
||||
return new JsonResponse($response, $status);
|
||||
}
|
||||
|
||||
protected function paginated(mixed $data, int $total, int $page, int $limit): JsonResponse
|
||||
{
|
||||
return $this->success($data, 200, [
|
||||
'totalRecords' => $total,
|
||||
'totalPages' => (int) ceil($total / $limit),
|
||||
'currentPage' => $page,
|
||||
]);
|
||||
}
|
||||
|
||||
protected function error(string $code, string $message, int $status = 400, ?string $field = null): JsonResponse
|
||||
{
|
||||
$err = ['code' => $code, 'message' => $message];
|
||||
if ($field) {
|
||||
$err['field'] = $field;
|
||||
}
|
||||
return new JsonResponse(['success' => false, 'data' => null, 'errors' => [$err]], $status);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## DTO Pattern
|
||||
|
||||
هر endpoint باید DTO جداگانه داشته باشد:
|
||||
|
||||
```php
|
||||
// Input DTO (Request)
|
||||
class DoctorCreateRequest
|
||||
{
|
||||
#[Assert\NotBlank(message: 'نام دکتر الزامی است')]
|
||||
public string $title;
|
||||
|
||||
#[Assert\Choice(['man', 'woman'])]
|
||||
public string $gender;
|
||||
|
||||
#[Assert\Range(min: 0, max: 100)]
|
||||
public int $experience;
|
||||
}
|
||||
|
||||
// Output DTO (Response)
|
||||
class DoctorResponse
|
||||
{
|
||||
public function __construct(private Doctor $doctor) {}
|
||||
|
||||
public function toArray(): array
|
||||
{
|
||||
return [
|
||||
'id' => (string) $this->doctor->getId(),
|
||||
'uuid' => $this->doctor->getUuid(),
|
||||
'name' => $this->doctor->getName(),
|
||||
'gender' => $this->doctor->getGender(),
|
||||
'experience' => $this->doctor->getExperience(),
|
||||
// ...
|
||||
];
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Health Check — `GET /health`
|
||||
|
||||
```json
|
||||
// Response 200:
|
||||
{
|
||||
"status": "ok",
|
||||
"checks": {
|
||||
"database": "ok",
|
||||
"redis": "ok"
|
||||
},
|
||||
"timestamp": 1748000000
|
||||
}
|
||||
|
||||
// Response 503 (اگر یکی از سرویسها down باشد):
|
||||
{
|
||||
"status": "degraded",
|
||||
"checks": {
|
||||
"database": "ok",
|
||||
"redis": "error"
|
||||
},
|
||||
"timestamp": 1748000000
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## File Upload — محدودیتهای مشترک و امنیتی
|
||||
|
||||
```
|
||||
حداکثر حجم فایل: 5MB
|
||||
فرمتهای مجاز: image/jpeg, image/png, image/webp
|
||||
هدرهای الزامی:
|
||||
Content-Type: application/octet-stream
|
||||
Content-Disposition: file; filename="name.jpg"
|
||||
Authorization: Bearer {token}
|
||||
```
|
||||
|
||||
### ⚠ اعتبارسنجی امنیتی فایل — بررسی محتوا، نه header
|
||||
|
||||
```php
|
||||
// src/Shared/Service/FileValidatorService.php
|
||||
class FileValidatorService
|
||||
{
|
||||
// Magic bytes برای تشخیص واقعی نوع فایل
|
||||
private const ALLOWED_SIGNATURES = [
|
||||
'image/jpeg' => ["\xFF\xD8\xFF"],
|
||||
'image/png' => ["\x89\x50\x4E\x47\x0D\x0A\x1A\x0A"],
|
||||
'image/webp' => ["RIFF"],
|
||||
];
|
||||
|
||||
public function validate(string $binaryContent, string $claimedFilename): void
|
||||
{
|
||||
// ۱. بررسی حجم
|
||||
if (strlen($binaryContent) > 5 * 1024 * 1024) {
|
||||
throw new AppException(ErrorCodes::ERR_FILE_002);
|
||||
}
|
||||
|
||||
// ۲. بررسی magic bytes — نه MIME از header
|
||||
$detected = false;
|
||||
foreach (self::ALLOWED_SIGNATURES as $mime => $signatures) {
|
||||
foreach ($signatures as $sig) {
|
||||
if (str_starts_with($binaryContent, $sig)) {
|
||||
$detected = true;
|
||||
break 2;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (!$detected) {
|
||||
throw new AppException(ErrorCodes::ERR_FILE_001);
|
||||
}
|
||||
|
||||
// ۳. Sanitize filename — جلوگیری از path traversal
|
||||
$safeName = preg_replace('/[^a-zA-Z0-9._-]/', '', basename($claimedFilename));
|
||||
if (empty($safeName) || str_contains($safeName, '..')) {
|
||||
throw new AppException(ErrorCodes::ERR_FILE_001);
|
||||
}
|
||||
|
||||
// ۴. پسوند باید با content مطابقت داشته باشد
|
||||
$ext = strtolower(pathinfo($safeName, PATHINFO_EXTENSION));
|
||||
if (!in_array($ext, ['jpg', 'jpeg', 'png', 'webp'], true)) {
|
||||
throw new AppException(ErrorCodes::ERR_FILE_001);
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Swagger — نحوه استفاده در Controller
|
||||
|
||||
```php
|
||||
use OpenApi\Attributes as OA;
|
||||
|
||||
#[OA\Tag(name: 'Doctor')]
|
||||
class DoctorController extends BaseController
|
||||
{
|
||||
#[OA\Get(
|
||||
path: '/api/v1/doctor/{uuid}',
|
||||
summary: 'دریافت پروفایل دکتر',
|
||||
security: [['bearerAuth' => []]],
|
||||
parameters: [
|
||||
new OA\Parameter(name: 'uuid', in: 'path', required: true,
|
||||
schema: new OA\Schema(type: 'string', format: 'uuid'))
|
||||
],
|
||||
responses: [
|
||||
new OA\Response(response: 200, description: 'پروفایل کامل دکتر'),
|
||||
new OA\Response(response: 404, description: 'دکتر یافت نشد'),
|
||||
]
|
||||
)]
|
||||
#[Route('/api/v1/doctor/{uuid}', methods: ['GET'])]
|
||||
public function show(string $uuid): JsonResponse { ... }
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## متغیرهای محیطی (.env)
|
||||
|
||||
```dotenv
|
||||
APP_ENV=dev
|
||||
APP_SECRET=your-secret-key
|
||||
|
||||
DATABASE_URL="mysql://db:db@db:3306/db?serverVersion=8.0"
|
||||
|
||||
REDIS_URL=redis://redis:6379
|
||||
|
||||
# Queue (از Redis استفاده میکند)
|
||||
MESSENGER_TRANSPORT_DSN=redis://redis:6379/messages
|
||||
|
||||
JWT_SECRET_KEY=%kernel.project_dir%/config/jwt/private.pem
|
||||
JWT_PUBLIC_KEY=%kernel.project_dir%/config/jwt/public.pem
|
||||
JWT_PASSPHRASE=your-passphrase
|
||||
|
||||
# Refresh Token TTL (ثانیه) — 30 روز
|
||||
REFRESH_TOKEN_TTL=2592000
|
||||
|
||||
OTP_TTL=1200
|
||||
|
||||
# SMS Providers
|
||||
KAVENEGAR_API_KEY=your-key
|
||||
RANGINEH_API_KEY=your-key
|
||||
SMS_PROVIDER=kavenegar
|
||||
|
||||
# File Upload
|
||||
MAX_FILE_SIZE_BYTES=5242880
|
||||
```
|
||||
Reference in New Issue
Block a user