Merge branch 'dev' into main
# Conflicts: # docs/api/doctor.md
This commit is contained in:
@@ -0,0 +1,157 @@
|
||||
<?php
|
||||
|
||||
namespace App\Shared\Command;
|
||||
|
||||
use App\Clinic\Entity\Clinic;
|
||||
use App\Doctor\Entity\Doctor;
|
||||
use App\Shared\Util\DisplayName;
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
use Symfony\Component\Console\Attribute\AsCommand;
|
||||
use Symfony\Component\Console\Command\Command;
|
||||
use Symfony\Component\Console\Input\InputInterface;
|
||||
use Symfony\Component\Console\Input\InputOption;
|
||||
use Symfony\Component\Console\Output\OutputInterface;
|
||||
use Symfony\Component\Console\Style\SymfonyStyle;
|
||||
|
||||
/**
|
||||
* پزشکان/کلینیکهایی که نام واقعی ندارند (شمارهتلفن، «test») در نتایج عمومی و در
|
||||
* <title> صفحات سایت منتشر میشوند. این فرمان آنها را پیدا و دستهبندی میکند.
|
||||
*
|
||||
* گزارشمحور است: بدون --force هیچ چیزی نوشته نمیشود.
|
||||
*
|
||||
* php bin/console app:audit-polluted-records # فقط گزارش
|
||||
* php bin/console app:audit-polluted-records --force # اعمال تغییرات
|
||||
*
|
||||
* حذف انجام نمیدهد. رکورد دارای رابطهٔ واقعی (نوبت/پرداخت) هرگز خودکار دست نمیخورد؛
|
||||
* برای بقیه فقط انتشار عمومی را میبندد (active=false / is_active=false) تا تصمیمِ
|
||||
* حذف با تیم بماند و برگشتپذیر باشد.
|
||||
*/
|
||||
#[AsCommand(
|
||||
name: 'app:audit-polluted-records',
|
||||
description: 'Find doctors/clinics whose name is a phone number or a placeholder; optionally unpublish them',
|
||||
)]
|
||||
class AuditPollutedRecordsCommand extends Command
|
||||
{
|
||||
public function __construct(private readonly EntityManagerInterface $em)
|
||||
{
|
||||
parent::__construct();
|
||||
}
|
||||
|
||||
protected function configure(): void
|
||||
{
|
||||
$this->addOption('force', null, InputOption::VALUE_NONE, 'Apply changes. Without it nothing is written.');
|
||||
}
|
||||
|
||||
protected function execute(InputInterface $input, OutputInterface $output): int
|
||||
{
|
||||
$io = new SymfonyStyle($input, $output);
|
||||
$force = (bool) $input->getOption('force');
|
||||
|
||||
$doctors = $this->auditDoctors($io);
|
||||
$clinics = $this->auditClinics($io);
|
||||
|
||||
$unpublishable = array_merge(
|
||||
array_filter($doctors, fn(array $r) => !$r['hasRelations'] && $r['published']),
|
||||
array_filter($clinics, fn(array $r) => !$r['hasRelations'] && $r['published']),
|
||||
);
|
||||
|
||||
if ($doctors === [] && $clinics === []) {
|
||||
$io->success('هیچ رکورد آلودهای یافت نشد.');
|
||||
|
||||
return Command::SUCCESS;
|
||||
}
|
||||
|
||||
if (!$force) {
|
||||
$io->warning(sprintf(
|
||||
'حالت گزارش. %d رکورد قابل خارجکردن از انتشار است. برای اعمال: --force',
|
||||
count($unpublishable)
|
||||
));
|
||||
|
||||
return Command::SUCCESS;
|
||||
}
|
||||
|
||||
foreach ($unpublishable as $row) {
|
||||
$row['entity'] instanceof Doctor
|
||||
? $row['entity']->setActiveDoctorAppointment(false)
|
||||
: $row['entity']->setIsActive(false);
|
||||
}
|
||||
$this->em->flush();
|
||||
|
||||
$io->success(sprintf('%d رکورد از انتشار عمومی خارج شد (حذف نشد).', count($unpublishable)));
|
||||
|
||||
return Command::SUCCESS;
|
||||
}
|
||||
|
||||
/** @return array<int, array{entity: Doctor, hasRelations: bool, published: bool}> */
|
||||
private function auditDoctors(SymfonyStyle $io): array
|
||||
{
|
||||
$rows = [];
|
||||
$table = [];
|
||||
|
||||
/** @var Doctor $doctor */
|
||||
foreach ($this->em->getRepository(Doctor::class)->findAll() as $doctor) {
|
||||
if (!DisplayName::isPlaceholder($doctor->getName())) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$appointments = (int) $this->em->createQuery(
|
||||
'SELECT COUNT(a.id) FROM App\Appointment\Entity\Appointment a WHERE a.doctor = :d'
|
||||
)->setParameter('d', $doctor)->getSingleScalarResult();
|
||||
|
||||
$rows[] = [
|
||||
'entity' => $doctor,
|
||||
'hasRelations' => $appointments > 0,
|
||||
'published' => $doctor->isActiveDoctorAppointment(),
|
||||
];
|
||||
$table[] = [
|
||||
$doctor->getId(),
|
||||
$doctor->getName(),
|
||||
$doctor->getOwnerStatus(),
|
||||
$appointments,
|
||||
$doctor->isActiveDoctorAppointment() ? 'بله' : 'خیر',
|
||||
];
|
||||
}
|
||||
|
||||
if ($table !== []) {
|
||||
$io->section(sprintf('پزشکان با نام نامعتبر (%d)', count($table)));
|
||||
$io->table(['id', 'name', 'owner_status', 'نوبتها', 'منتشر شده'], $table);
|
||||
}
|
||||
|
||||
return $rows;
|
||||
}
|
||||
|
||||
/** @return array<int, array{entity: Clinic, hasRelations: bool, published: bool}> */
|
||||
private function auditClinics(SymfonyStyle $io): array
|
||||
{
|
||||
$rows = [];
|
||||
$table = [];
|
||||
|
||||
/** @var Clinic $clinic */
|
||||
foreach ($this->em->getRepository(Clinic::class)->findAll() as $clinic) {
|
||||
if (!DisplayName::isPlaceholder($clinic->getName())) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$doctorCount = $clinic->getDoctors()->count();
|
||||
|
||||
$rows[] = [
|
||||
'entity' => $clinic,
|
||||
'hasRelations' => $doctorCount > 0,
|
||||
'published' => $clinic->isActive(),
|
||||
];
|
||||
$table[] = [
|
||||
$clinic->getId(),
|
||||
$clinic->getName(),
|
||||
$doctorCount,
|
||||
$clinic->isActive() ? 'بله' : 'خیر',
|
||||
];
|
||||
}
|
||||
|
||||
if ($table !== []) {
|
||||
$io->section(sprintf('کلینیکها با نام نامعتبر (%d)', count($table)));
|
||||
$io->table(['id', 'name', 'پزشکان', 'فعال'], $table);
|
||||
}
|
||||
|
||||
return $rows;
|
||||
}
|
||||
}
|
||||
@@ -333,7 +333,7 @@ class SeedDemoDataCommand extends Command
|
||||
$userRows[] = [
|
||||
'uuid' => Uuid::v4()->toRfc4122(),
|
||||
'mobile_number' => sprintf('09126%06d', $i),
|
||||
'real_name' => 'دکتر ' . $this->persianName($i),
|
||||
'real_name' => $this->persianName($i),
|
||||
'roles' => json_encode(['ROLE_USER', 'ROLE_DOCTOR']),
|
||||
'national_code_verified' => 0,
|
||||
'status' => 1,
|
||||
@@ -359,7 +359,7 @@ class SeedDemoDataCommand extends Command
|
||||
$doctorRows[] = [
|
||||
'uuid' => Uuid::v4()->toRfc4122(),
|
||||
'user_id' => $userId,
|
||||
'name' => 'دکتر ' . $this->persianName($i),
|
||||
'name' => $this->persianName($i),
|
||||
'gender' => $i % 3 === 0 ? 'woman' : 'man',
|
||||
'medical_system_code' => (string) (100000 + $i),
|
||||
'mobile_number' => sprintf('09126%06d', $i),
|
||||
|
||||
@@ -62,13 +62,20 @@ class ErrorCodes
|
||||
// Patient
|
||||
public const ERR_PATIENT_NOT_FOUND = 'ERR_PATIENT_NOT_FOUND';
|
||||
public const ERR_SESSION_NOT_FOUND = 'ERR_SESSION_NOT_FOUND';
|
||||
public const ERR_SESSION_PAYMENT_INVALID = 'ERR_SESSION_PAYMENT_INVALID';
|
||||
public const ERR_SESSION_PAYMENT_EXCEEDS = 'ERR_SESSION_PAYMENT_EXCEEDS';
|
||||
public const ERR_SESSION_DISCOUNT_INVALID = 'ERR_SESSION_DISCOUNT_INVALID';
|
||||
|
||||
// Profile
|
||||
public const ERR_PROFILE_NATIONAL_CODE_TAKEN = 'ERR_PROFILE_001';
|
||||
public const ERR_PROFILE_MOBILE_TAKEN = 'ERR_PROFILE_002';
|
||||
|
||||
// SMS Wallet
|
||||
public const ERR_SMS_WALLET_INSUFFICIENT = 'ERR_SMS_WALLET_INSUFFICIENT';
|
||||
|
||||
// Patient Wallet
|
||||
public const ERR_WALLET_INSUFFICIENT = 'ERR_WALLET_INSUFFICIENT';
|
||||
|
||||
// Rate Limit
|
||||
public const ERR_RATE_LIMIT_001 = 'ERR_RATE_LIMIT_001';
|
||||
|
||||
@@ -140,8 +147,13 @@ class ErrorCodes
|
||||
self::ERR_SERVICE_NOT_FOUND => 'سرویس یافت نشد',
|
||||
self::ERR_PATIENT_NOT_FOUND => 'پرونده بیمار یافت نشد',
|
||||
self::ERR_PROFILE_NATIONAL_CODE_TAKEN => 'این کد ملی قبلاً برای کاربر دیگری ثبت شده است',
|
||||
self::ERR_PROFILE_MOBILE_TAKEN => 'این شماره موبایل قبلاً برای کاربر دیگری ثبت شده است',
|
||||
self::ERR_SESSION_NOT_FOUND => 'مراجعه یافت نشد',
|
||||
self::ERR_SESSION_PAYMENT_INVALID => 'مبلغ یا روش پرداخت نامعتبر است',
|
||||
self::ERR_SESSION_PAYMENT_EXCEEDS => 'مبلغ پرداخت از مانده بدهی بیشتر است',
|
||||
self::ERR_SESSION_DISCOUNT_INVALID => 'مقدار تخفیف نامعتبر است',
|
||||
self::ERR_SMS_WALLET_INSUFFICIENT => 'موجودی کیف پیامک کافی نیست',
|
||||
self::ERR_WALLET_INSUFFICIENT => 'موجودی کیف پول کافی نیست',
|
||||
self::ERR_RATING_NOT_ELIGIBLE => 'برای ثبت نظر یا امتیاز باید در یک ماه گذشته نوبت تاییدشده نزد این پزشک داشته باشید',
|
||||
self::ERR_EXTERNAL_001 => 'خطا در استعلام. لطفاً بعداً تلاش کنید',
|
||||
self::ERR_EXTERNAL_NOT_CONFIGURED => 'سرویس استعلام پیکربندی نشده است',
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
<?php
|
||||
|
||||
namespace App\Shared\Context;
|
||||
|
||||
use App\Clinic\Entity\Clinic;
|
||||
use App\Doctor\Entity\Doctor;
|
||||
|
||||
/**
|
||||
* محیط کاری مؤثر یک درخواست: یا مطب شخصی یک پزشک، یا یک کلینیک.
|
||||
*
|
||||
* سرویسها، آدرسها و برنامهٔ نوبتدهی همگی به یکی از این دو تعلق دارند و هرگز بین
|
||||
* آنها مشترک نمیشوند. type/id دقیقاً همان جفتی است که ServiceSection با
|
||||
* entity_type/entity_id ذخیره میکند.
|
||||
*/
|
||||
final class EntityContext
|
||||
{
|
||||
public const TYPE_DOCTOR = 'doctor';
|
||||
public const TYPE_CLINIC = 'clinic';
|
||||
public const TYPE_UNKNOWN = 'unknown';
|
||||
|
||||
private function __construct(
|
||||
public readonly string $type,
|
||||
public readonly ?int $id,
|
||||
public readonly ?Clinic $clinic = null,
|
||||
public readonly ?Doctor $doctor = null,
|
||||
) {}
|
||||
|
||||
public static function forDoctor(?Doctor $doctor): self
|
||||
{
|
||||
return new self(self::TYPE_DOCTOR, $doctor?->getId(), null, $doctor);
|
||||
}
|
||||
|
||||
public static function forClinic(Clinic $clinic): self
|
||||
{
|
||||
return new self(self::TYPE_CLINIC, $clinic->getId(), $clinic);
|
||||
}
|
||||
|
||||
public static function unknown(): self
|
||||
{
|
||||
return new self(self::TYPE_UNKNOWN, null);
|
||||
}
|
||||
|
||||
public function isClinic(): bool { return $this->type === self::TYPE_CLINIC; }
|
||||
|
||||
public function isResolved(): bool { return $this->id !== null; }
|
||||
|
||||
/** @return array{0: string, 1: ?int} جفت (entity_type, entity_id) برای ServiceSection */
|
||||
public function toEntityPair(): array
|
||||
{
|
||||
return [$this->type, $this->id];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,124 @@
|
||||
<?php
|
||||
|
||||
namespace App\Shared\Context;
|
||||
|
||||
use App\Auth\Entity\User;
|
||||
use App\Auth\Repository\UserActiveContextRepository;
|
||||
use App\Clinic\Entity\Clinic;
|
||||
use App\Clinic\Repository\ClinicRepository;
|
||||
use App\Doctor\Repository\DoctorRepository;
|
||||
use App\Shared\Constant\ErrorCodes;
|
||||
use App\Shared\Exception\AppException;
|
||||
|
||||
/**
|
||||
* تنها نقطهٔ تصمیمگیری دربارهٔ «این درخواست در کدام محیط اجرا میشود؟».
|
||||
*
|
||||
* اولویت: clinic_uuid صریحِ درخواست > محیط فعالِ ذخیرهشدهٔ کاربر > نقش کاربر.
|
||||
*
|
||||
* نقش بهتنهایی برای کاربری که هم پزشک است و هم مالک کلینیک جواب نمیدهد: چنین
|
||||
* کاربری همیشه بهعنوان پزشک حل میشد و هرگز به سرویسهای کلینیک خودش نمیرسید.
|
||||
* UserActiveContext تعیینکننده است و نقش فقط fallback آخر.
|
||||
*/
|
||||
class EntityContextResolver
|
||||
{
|
||||
public function __construct(
|
||||
private readonly DoctorRepository $doctorRepo,
|
||||
private readonly ClinicRepository $clinicRepo,
|
||||
private readonly UserActiveContextRepository $activeContextRepo,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* @param string|null $clinicUuid اگر داده شود، محیط کلینیک اجباری میشود و در
|
||||
* صورت نداشتن دسترسی، خطای ۴۰۳ پرتاب میشود.
|
||||
*/
|
||||
public function resolve(User $user, ?string $clinicUuid = null): EntityContext
|
||||
{
|
||||
if ($clinicUuid !== null && $clinicUuid !== '') {
|
||||
$clinic = $this->clinicRepo->findByUuid($clinicUuid);
|
||||
if ($clinic === null) {
|
||||
throw new AppException(ErrorCodes::ERR_VALIDATION_002, 'کلینیک یافت نشد', 404);
|
||||
}
|
||||
$this->assertCanActInClinic($user, $clinic);
|
||||
|
||||
return EntityContext::forClinic($clinic);
|
||||
}
|
||||
|
||||
$fromActive = $this->fromActiveContext($user);
|
||||
if ($fromActive !== null) {
|
||||
return $fromActive;
|
||||
}
|
||||
|
||||
return $this->fromRole($user);
|
||||
}
|
||||
|
||||
/**
|
||||
* محیط را بدون پرتاب خطا حل میکند؛ اگر کاربر به کلینیکِ خواستهشده دسترسی
|
||||
* نداشته باشد null برمیگرداند. برای مسیرهایی که خودشان authorization جدا دارند.
|
||||
*/
|
||||
public function tryResolve(User $user, ?string $clinicUuid = null): ?EntityContext
|
||||
{
|
||||
try {
|
||||
return $this->resolve($user, $clinicUuid);
|
||||
} catch (AppException) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/** مالک کلینیک، ادمین، یا پزشکِ عضو همان کلینیک. */
|
||||
public function canActInClinic(User $user, Clinic $clinic): bool
|
||||
{
|
||||
if ($user->hasRole('ROLE_ADMIN') || $clinic->getUser()->getId() === $user->getId()) {
|
||||
return true;
|
||||
}
|
||||
|
||||
$doctor = $this->doctorRepo->findByUser($user);
|
||||
|
||||
return $doctor !== null && $clinic->hasDoctor($doctor);
|
||||
}
|
||||
|
||||
public function assertCanActInClinic(User $user, Clinic $clinic): void
|
||||
{
|
||||
if (!$this->canActInClinic($user, $clinic)) {
|
||||
throw new AppException(ErrorCodes::ERR_ACCESS_DENIED, 'به این کلینیک دسترسی ندارید', 403);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* محیط فعالِ ذخیرهشده. db_uuid یا uuid کلینیک است یا uuid پزشک؛ کلینیک اول
|
||||
* بررسی میشود چون پزشکِ دعوتشده هم db_uuid کلینیک را ذخیره میکند.
|
||||
*/
|
||||
private function fromActiveContext(User $user): ?EntityContext
|
||||
{
|
||||
$active = $this->activeContextRepo->findByUser($user);
|
||||
if ($active === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$clinic = $this->clinicRepo->findByUuid($active->getDbUuid());
|
||||
if ($clinic !== null) {
|
||||
return $this->canActInClinic($user, $clinic) ? EntityContext::forClinic($clinic) : null;
|
||||
}
|
||||
|
||||
$doctor = $this->doctorRepo->findByUuid($active->getDbUuid());
|
||||
if ($doctor !== null && $doctor->getUser()->getId() === $user->getId()) {
|
||||
return EntityContext::forDoctor($doctor);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private function fromRole(User $user): EntityContext
|
||||
{
|
||||
if ($user->hasRole('ROLE_DOCTOR')) {
|
||||
return EntityContext::forDoctor($this->doctorRepo->findByUser($user));
|
||||
}
|
||||
|
||||
if ($user->hasRole('ROLE_CLINIC')) {
|
||||
$clinic = $this->clinicRepo->findByUser($user);
|
||||
|
||||
return $clinic !== null ? EntityContext::forClinic($clinic) : EntityContext::unknown();
|
||||
}
|
||||
|
||||
return EntityContext::unknown();
|
||||
}
|
||||
}
|
||||
@@ -22,6 +22,10 @@ abstract class BaseController extends AbstractController
|
||||
'totalRecords' => $total,
|
||||
'totalPages' => (int) ceil($total / max($limit, 1)),
|
||||
'currentPage' => $page,
|
||||
// اندازهٔ صفحهٔ واقعاً اعمالشده. ریپازیتوریها limit را به سقف خود کاهش
|
||||
// میدهند؛ بدون این فیلد کلاینت نمیفهمد درخواستش کوتاه شده و ممکن است
|
||||
// صفحهبندی را زودهنگام تمامشده بپندارد.
|
||||
'limit' => $limit,
|
||||
]);
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,93 @@
|
||||
<?php
|
||||
|
||||
namespace App\Shared\EventSubscriber;
|
||||
|
||||
use App\Shared\Util\PersianText;
|
||||
use Symfony\Component\EventDispatcher\EventSubscriberInterface;
|
||||
use Symfony\Component\HttpKernel\Event\RequestEvent;
|
||||
use Symfony\Component\HttpKernel\KernelEvents;
|
||||
|
||||
/**
|
||||
* ارقام فارسی/عربی را در بدنهٔ JSON درخواستهای API به لاتین ترجمه میکند.
|
||||
*
|
||||
* پنل ادمین ورودیها را در مبدأ نرمال میکند، ولی `nobat724_front` و
|
||||
* `clinic-pro-tauri` هم همین API را صدا میزنند؛ این لایه تضمین میکند هیچ
|
||||
* کلاینتی نتواند رقم فارسی وارد دیتابیس کند.
|
||||
*
|
||||
* فقط ترجمهٔ رقم انجام میشود — کاراکتر غیرعددی حذف نمیشود چون شبا حرف `IR`
|
||||
* دارد و تلفن ثابت خط تیره.
|
||||
*/
|
||||
class NumericFieldNormalizerSubscriber implements EventSubscriberInterface
|
||||
{
|
||||
/** کلیدهایی که مقدارشان عددی است و باید نرمال شوند. */
|
||||
private const NUMERIC_KEYS = [
|
||||
'mobile', 'mobile_number', 'telephone', 'phone', 'notification_mobile',
|
||||
'national_code', 'postal_code',
|
||||
'card_number', 'account_number', 'sheba', 'shaba', 'iban',
|
||||
'price_rials', 'amount_rials', 'amount', 'free_visit_price_rials',
|
||||
'insurance_price_rials', 'patient_share_rials', 'visit_price_rials',
|
||||
'duration_minutes', 'duration', 'commission_percent', 'coverage',
|
||||
'coverage_percent', 'franchise', 'ceiling', 'tax_percent',
|
||||
'base_insurance_discount_percent', 'supplementary_discount_percent',
|
||||
];
|
||||
|
||||
public static function getSubscribedEvents(): array
|
||||
{
|
||||
return [KernelEvents::REQUEST => ['onKernelRequest', 8]];
|
||||
}
|
||||
|
||||
public function onKernelRequest(RequestEvent $event): void
|
||||
{
|
||||
$request = $event->getRequest();
|
||||
|
||||
if (!str_starts_with($request->getPathInfo(), '/api/v1/')) {
|
||||
return;
|
||||
}
|
||||
if (!in_array($request->getMethod(), ['POST', 'PUT', 'PATCH'], true)) {
|
||||
return;
|
||||
}
|
||||
if (!str_contains((string) $request->headers->get('Content-Type'), 'json')) {
|
||||
return;
|
||||
}
|
||||
|
||||
$content = $request->getContent();
|
||||
if ($content === '') {
|
||||
return;
|
||||
}
|
||||
|
||||
$data = json_decode($content, true);
|
||||
if (!is_array($data)) {
|
||||
return;
|
||||
}
|
||||
|
||||
$normalized = $this->normalizeTree($data);
|
||||
if ($normalized === $data) {
|
||||
return;
|
||||
}
|
||||
|
||||
$request->initialize(
|
||||
$request->query->all(),
|
||||
$request->request->all(),
|
||||
$request->attributes->all(),
|
||||
$request->cookies->all(),
|
||||
$request->files->all(),
|
||||
$request->server->all(),
|
||||
json_encode($normalized, JSON_UNESCAPED_UNICODE),
|
||||
);
|
||||
}
|
||||
|
||||
private function normalizeTree(array $data): array
|
||||
{
|
||||
foreach ($data as $key => $value) {
|
||||
if (is_array($value)) {
|
||||
$data[$key] = $this->normalizeTree($value);
|
||||
continue;
|
||||
}
|
||||
if (is_string($value) && in_array((string) $key, self::NUMERIC_KEYS, true)) {
|
||||
$data[$key] = PersianText::digits($value);
|
||||
}
|
||||
}
|
||||
|
||||
return $data;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
<?php
|
||||
|
||||
namespace App\Shared\Service;
|
||||
|
||||
use Symfony\Component\HttpFoundation\Request;
|
||||
|
||||
/**
|
||||
* Stores a raw-body file upload (the `/file/upload/...` convention: the file is
|
||||
* sent as the request body with a Content-Disposition filename) under
|
||||
* public/uploads/<subDir>/<year>-<month>/ and returns its public URL + metadata.
|
||||
*/
|
||||
class FileUploadService
|
||||
{
|
||||
public function __construct(
|
||||
private readonly FileValidatorService $fileValidator,
|
||||
private readonly string $projectDir,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* @return array{url: string, filename: string, filemime: string, size: int}
|
||||
* @throws \RuntimeException on an invalid/oversized file
|
||||
*/
|
||||
public function storeFromRequest(Request $request, string $subDir): array
|
||||
{
|
||||
$content = $request->getContent();
|
||||
preg_match('/filename=["\']?([^"\';\s]+)["\']?/i', $request->headers->get('Content-Disposition', ''), $m);
|
||||
$filename = $m[1] ?? 'file';
|
||||
|
||||
$tmpPath = sys_get_temp_dir() . '/' . uniqid('upload_', true);
|
||||
file_put_contents($tmpPath, $content);
|
||||
|
||||
try {
|
||||
$safeFilename = $this->fileValidator->sanitizeFilename($filename);
|
||||
$mime = $this->fileValidator->detectMimeType($tmpPath);
|
||||
|
||||
$year = date('Y');
|
||||
$month = date('m');
|
||||
$dir = $this->projectDir . '/public/uploads/' . $subDir . '/' . $year . '-' . $month;
|
||||
if (!is_dir($dir)) {
|
||||
mkdir($dir, 0755, true);
|
||||
}
|
||||
|
||||
$storedName = uniqid('', true) . '_' . $safeFilename;
|
||||
rename($tmpPath, $dir . '/' . $storedName);
|
||||
|
||||
return [
|
||||
'url' => '/uploads/' . $subDir . '/' . $year . '-' . $month . '/' . $storedName,
|
||||
'filename' => $safeFilename,
|
||||
'filemime' => $mime,
|
||||
'size' => strlen($content),
|
||||
];
|
||||
} catch (\Throwable $e) {
|
||||
if (file_exists($tmpPath)) {
|
||||
unlink($tmpPath);
|
||||
}
|
||||
throw new \RuntimeException($e->getMessage(), 0, $e);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
<?php
|
||||
|
||||
namespace App\Shared\Util;
|
||||
|
||||
use App\Shared\Constant\ErrorCodes;
|
||||
use App\Shared\Exception\AppException;
|
||||
|
||||
/**
|
||||
* نام نمایشی پزشک/کلینیک در نتایج عمومی و در <title> صفحات سایت رندر میشود.
|
||||
* شمارهتلفن یا «test» نام واقعی نیست و صفحه را بیارزش میکند.
|
||||
*
|
||||
* این قواعد باید با nobat724_front/lib/entityQuality.js یکی بماند؛ سایت عمومی
|
||||
* همین رکوردها را noindex میکند. آنجا ماسک است، اینجا جلوگیری از تولید.
|
||||
*/
|
||||
final class DisplayName
|
||||
{
|
||||
private const PHONE_LIKE = '/^0?9\d{9}$/';
|
||||
|
||||
private const PLACEHOLDERS = ['test', 'تست', '-', '—', 'null', 'undefined'];
|
||||
|
||||
private const MIN_LENGTH = 2;
|
||||
|
||||
/** نامی که نباید در نتایج عمومی منتشر شود. */
|
||||
public static function isPlaceholder(?string $name): bool
|
||||
{
|
||||
$trimmed = trim((string) $name);
|
||||
if ($trimmed === '' || mb_strlen($trimmed) < self::MIN_LENGTH) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (preg_match(self::PHONE_LIKE, str_replace([' ', '-', ''], '', $trimmed)) === 1) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return in_array(mb_strtolower($trimmed), self::PLACEHOLDERS, true);
|
||||
}
|
||||
|
||||
/**
|
||||
* @throws AppException وقتی نام واقعی نباشد — ExceptionSubscriber آن را به 422 تبدیل میکند
|
||||
*/
|
||||
public static function assertReal(?string $name): void
|
||||
{
|
||||
if (self::isPlaceholder($name)) {
|
||||
throw new AppException(
|
||||
ErrorCodes::ERR_VALIDATION_001,
|
||||
'نام معتبر نیست؛ شماره تلفن یا مقدار آزمایشی بهعنوان نام پذیرفته نمیشود',
|
||||
422
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -36,6 +36,20 @@ final class PersianText
|
||||
return trim(preg_replace('/\s+/u', ' ', $text) ?? $text);
|
||||
}
|
||||
|
||||
/**
|
||||
* فقط ارقام فارسی/عربی را به لاتین ترجمه میکند و بقیهٔ کاراکترها را دست نمیزند.
|
||||
*
|
||||
* برخلاف normalize() فاصلهها را جمع نمیکند و trim نمیکند — برای فیلدهای عددی
|
||||
* لازم است، چون شبا حرف دارد و تلفن ثابت خط تیره.
|
||||
*/
|
||||
public static function digits(string $text): string
|
||||
{
|
||||
return strtr($text, array_combine(
|
||||
['۰','۱','۲','۳','۴','۵','۶','۷','۸','۹','٠','١','٢','٣','٤','٥','٦','٧','٨','٩'],
|
||||
['0','1','2','3','4','5','6','7','8','9','0','1','2','3','4','5','6','7','8','9'],
|
||||
));
|
||||
}
|
||||
|
||||
/** مقایسهٔ دو نام فارسی پس از نرمالسازی. */
|
||||
public static function sameName(string $a, string $b): bool
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user