- 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.
563 lines
15 KiB
Markdown
563 lines
15 KiB
Markdown
# تسک ۰۱: راهاندازی پروژه و زیرساخت
|
||
|
||
## توضیح
|
||
راهاندازی اولیه پروژه 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
|
||
```
|