- 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.
15 KiB
15 KiB
تسک ۰۱: راهاندازی پروژه و زیرساخت
توضیح
راهاندازی اولیه پروژه 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 /healthendpoint پیاده شده
زمان تخمینی
۶ تا ۸ ساعت
مراحل راهاندازی با DDEV
۱. ایجاد پروژه Symfony (قبل از DDEV)
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
۲. نصب پکیجها (همه در یک دستور)
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 رسمی
ddev get ddev/ddev-redis
ddev restart
۴. تولید کلیدهای JWT
ddev exec php bin/console lexik:jwt:generate-keypair
۵. پیکربندی LexikJWT
فایل config/packages/lexik_jwt_authentication.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:
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:
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:
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):
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 استاندارد
فرمت موفق
{
"success": true,
"data": { ... },
"meta": {
"page": 1,
"totalPages": 5,
"totalRecords": 47
}
}
فرمت خطا
{
"success": false,
"data": null,
"errors": [
{
"code": "ERR_VALIDATION_001",
"field": "mobile_number",
"message": "فرمت شماره موبایل نادرست است"
}
]
}
Error Codes — src/Shared/Constant/ErrorCodes.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
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 جداگانه داشته باشد:
// 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
// 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
// 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
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)
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