diff --git a/.env.coolify.example b/.env.coolify.example index b2cf82cf..585dc5d7 100644 --- a/.env.coolify.example +++ b/.env.coolify.example @@ -17,7 +17,9 @@ # Replace mariadb-XXXXXXXX / redis-XXXXXXXX with the real hostname shown on each # resource's page (Internal URL). serverVersion MUST match the MariaDB resource (11.8). DATABASE_URL="mysql://clinic:DB_PASSWORD@mariadb-XXXXXXXX:3306/clinic_pro?serverVersion=mariadb-11.8.0&charset=utf8mb4" -REDIS_URL="redis://redis-XXXXXXXX:6379" +# retry_interval/tcp_keepalive: قطع کوتاه اتصال به redis بی‌سروصدا دوباره برقرار می‌شود +# و «Connection lost» به‌صورت warning در app_log نمی‌نشیند. +REDIS_URL="redis://redis-XXXXXXXX:6379?timeout=5&read_timeout=5&retry_interval=100&tcp_keepalive=60" # stream_max_entries caps the Redis stream so the queue cannot grow without bound MESSENGER_TRANSPORT_DSN="redis://redis-XXXXXXXX:6379/messages?stream_max_entries=20000" # If the Redis resource has a password: redis://:PASSWORD@redis-XXXXXXXX:6379 diff --git a/.env.example b/.env.example index 8285fe4e..3d1b5464 100644 --- a/.env.example +++ b/.env.example @@ -31,7 +31,9 @@ MESSENGER_TRANSPORT_DSN=redis://redis:6379/messages ###< symfony/messenger ### ###> Redis ### -REDIS_URL=redis://redis:6379 +# retry_interval/tcp_keepalive: قطع کوتاه اتصال به redis بی‌سروصدا دوباره برقرار می‌شود +# و «Connection lost» به‌صورت warning در app_log نمی‌نشیند. +REDIS_URL=redis://redis:6379?timeout=5&read_timeout=5&retry_interval=100&tcp_keepalive=60 ###< Redis ### ###> Auth ### diff --git a/.env.liara.example b/.env.liara.example index b0d260c4..a1f5be87 100644 --- a/.env.liara.example +++ b/.env.liara.example @@ -22,7 +22,9 @@ JWT_PASSPHRASE= # openssl rand -hex 32 (JWT keypair is generated with i # put both + this app on the SAME private network, then copy their private hosts here. # serverVersion MUST match the MariaDB service (11.8). DATABASE_URL="mysql://:@:3306/?serverVersion=mariadb-11.8.0&charset=utf8mb4" -REDIS_URL="redis://:6379" +# retry_interval/tcp_keepalive: قطع کوتاه اتصال به redis بی‌سروصدا دوباره برقرار می‌شود +# و «Connection lost» به‌صورت warning در app_log نمی‌نشیند. +REDIS_URL="redis://:6379?timeout=5&read_timeout=5&retry_interval=100&tcp_keepalive=60" MESSENGER_TRANSPORT_DSN="redis://:6379/messages" # If the Redis service has a password: redis://:@:6379 diff --git a/src/Auth/Repository/UserActiveContextRepository.php b/src/Auth/Repository/UserActiveContextRepository.php index 7d38afb5..a87257ca 100644 --- a/src/Auth/Repository/UserActiveContextRepository.php +++ b/src/Auth/Repository/UserActiveContextRepository.php @@ -19,16 +19,35 @@ class UserActiveContextRepository extends ServiceEntityRepository return $this->findOneBy(['user' => $user]); } + /** + * ثبت یا به‌روزرسانی محیط فعال کاربر — مقاوم در برابر رقابت همزمانی. + * + * پنل هنگام بالا آمدن چند درخواست `/oauth/userinfo` را تقریباً همزمان می‌فرستد و + * وقتی کاربر فقط یک محیط دارد، همه‌شان می‌خواستند همان ردیف را بسازند: هر دو + * `findByUser()` را `null` می‌دیدند و دومی با + * `Duplicate entry for key 'PRIMARY'` می‌ترکید (و در همان حال EntityManager بسته + * می‌شد). درج با `ON DUPLICATE KEY UPDATE` این مسابقه را حذف می‌کند. + */ public function upsert(User $user, string $dbUuid, string $dbType): UserActiveContext { $ctx = $this->findByUser($user); - if ($ctx === null) { - $ctx = new UserActiveContext($user, $dbUuid, $dbType); - $this->getEntityManager()->persist($ctx); - } else { + if ($ctx !== null) { $ctx->setContext($dbUuid, $dbType); + $this->getEntityManager()->flush(); + + return $ctx; } - $this->getEntityManager()->flush(); - return $ctx; + + $this->getEntityManager()->getConnection()->executeStatement( + 'INSERT INTO user_active_context (user_id, db_uuid, db_type, updated_at) + VALUES (:user, :uuid, :type, :now) + ON DUPLICATE KEY UPDATE db_uuid = VALUES(db_uuid), db_type = VALUES(db_type), updated_at = VALUES(updated_at)', + ['user' => $user->getId(), 'uuid' => $dbUuid, 'type' => $dbType, 'now' => time()], + ); + + // ردیف بیرون از ORM ساخته شد، پس entity باید از دیتابیس خوانده شود تا + // identity map همان چیزی را نشان دهد که واقعاً ذخیره شده است. + return $this->findByUser($user) + ?? throw new \RuntimeException('user_active_context row vanished right after upsert'); } } diff --git a/src/Insurance/Repository/TenantInsuranceRepository.php b/src/Insurance/Repository/TenantInsuranceRepository.php index d5dd7650..31214b70 100644 --- a/src/Insurance/Repository/TenantInsuranceRepository.php +++ b/src/Insurance/Repository/TenantInsuranceRepository.php @@ -3,20 +3,39 @@ namespace App\Insurance\Repository; use App\Insurance\Entity\TenantInsurance; +use App\Shared\Tenant\TenantFilterScope; use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository; use Doctrine\Persistence\ManagerRegistry; class TenantInsuranceRepository extends ServiceEntityRepository { - public function __construct(ManagerRegistry $registry) + public function __construct(ManagerRegistry $registry, private readonly TenantFilterScope $tenantScope) { parent::__construct($registry, TenantInsurance::class); } + /** + * قراردادِ بیمه همیشه به محیطِ *مقصد* تعلق دارد، نه محیطِ کاربرِ درخواست‌دهنده: + * مالک کلینیک قرارداد پزشکِ زیرمجموعه را می‌سازد و می‌بیند. با TenantFilter روشن، + * این کوئری‌ها به محیط خود کاربر محدود می‌شدند و ردیفِ موجود را نمی‌دیدند — نتیجه‌اش + * ساختِ دوبارهٔ version=1 و خطای `uniq_tenant_insurance_version` بود. + * + * مجوزِ دیدنِ آن محیط قبلاً در `InsuranceController::resolveTargetEntity()` بررسی + * شده و متدهایی که با uuid کار می‌کنند بعد از خواندن، مالکیت را دوباره می‌سنجند. + * + * @template T + * @param callable():T $query + * @return T + */ + private function unscoped(callable $query): mixed + { + return $this->tenantScope->withoutFilter($query); + } + /** @return TenantInsurance[] */ public function findActiveByTenant(string $entityType, int $entityId): array { - return $this->createQueryBuilder('t') + return $this->unscoped(fn (): array => $this->createQueryBuilder('t') ->where('t.entityType = :type') ->andWhere('t.entityId = :id') ->andWhere('t.isActive = true') @@ -24,7 +43,7 @@ class TenantInsuranceRepository extends ServiceEntityRepository ->setParameter('id', $entityId) ->orderBy('t.insuranceId', 'ASC') ->getQuery() - ->getResult(); + ->getResult()); } /** @@ -36,7 +55,7 @@ class TenantInsuranceRepository extends ServiceEntityRepository */ public function findLatestByTenant(string $entityType, int $entityId): array { - $rows = $this->createQueryBuilder('t') + $rows = $this->unscoped(fn (): array => $this->createQueryBuilder('t') ->where('t.entityType = :type') ->andWhere('t.entityId = :id') ->setParameter('type', $entityType) @@ -44,7 +63,7 @@ class TenantInsuranceRepository extends ServiceEntityRepository ->orderBy('t.insuranceId', 'ASC') ->addOrderBy('t.version', 'DESC') ->getQuery() - ->getResult(); + ->getResult()); $latest = []; foreach ($rows as $row) { @@ -56,12 +75,12 @@ class TenantInsuranceRepository extends ServiceEntityRepository public function findByUuid(string $uuid): ?TenantInsurance { - return $this->findOneBy(['uuid' => $uuid]); + return $this->unscoped(fn (): ?TenantInsurance => $this->findOneBy(['uuid' => $uuid])); } public function findActiveContract(string $entityType, int $entityId, int $insuranceId): ?TenantInsurance { - return $this->createQueryBuilder('t') + return $this->unscoped(fn (): ?TenantInsurance => $this->createQueryBuilder('t') ->where('t.entityType = :type') ->andWhere('t.entityId = :id') ->andWhere('t.insuranceId = :ins') @@ -72,12 +91,12 @@ class TenantInsuranceRepository extends ServiceEntityRepository ->orderBy('t.version', 'DESC') ->setMaxResults(1) ->getQuery() - ->getOneOrNullResult(); + ->getOneOrNullResult()); } public function latestVersion(string $entityType, int $entityId, int $insuranceId): int { - $max = $this->createQueryBuilder('t') + $max = $this->unscoped(fn (): mixed => $this->createQueryBuilder('t') ->select('MAX(t.version)') ->where('t.entityType = :type') ->andWhere('t.entityId = :id') @@ -86,7 +105,7 @@ class TenantInsuranceRepository extends ServiceEntityRepository ->setParameter('id', $entityId) ->setParameter('ins', $insuranceId) ->getQuery() - ->getSingleScalarResult(); + ->getSingleScalarResult()); return (int) ($max ?? 0); } diff --git a/src/Sms/Provider/KavehNegarProvider.php b/src/Sms/Provider/KavehNegarProvider.php index 3541bcdb..d9b1e876 100644 --- a/src/Sms/Provider/KavehNegarProvider.php +++ b/src/Sms/Provider/KavehNegarProvider.php @@ -110,11 +110,41 @@ class KavehNegarProvider implements SmsProviderInterface } catch (HttpExceptionInterface $e) { // 4xx/5xx از کاوه‌نگار (مثل 431): خطای دائم درخواست است نه transient؛ // warning کوتاه با کد وضعیت، بدون dump کامل exception. - $this->logger->warning(sprintf('SMS sendTemplate rejected (kavenegar): HTTP %d', $e->getResponse()->getStatusCode()), ['mobile' => $mobile, 'template' => $templateCode]); + // + // پیام خودِ کاوه‌نگار هم لاگ می‌شود: کد ۴۳۱ فقط می‌گوید «ساختار درخواست + // درست نیست» و بدون آن پیام معلوم نمی‌شود قالب در پنل تعریف نشده، تأیید + // نشده، یا اسلاتِ توکنی فرستاده‌ایم که قالب ندارد. اسم اسلات‌ها را هم + // می‌آوریم — نه مقدارشان، چون مقدار می‌تواند دادهٔ شخصی باشد. + $this->logger->warning( + sprintf('SMS sendTemplate rejected (kavenegar): HTTP %d', $e->getResponse()->getStatusCode()), + [ + 'mobile' => $mobile, + 'template' => $templateCode, + 'slots' => array_values(array_diff(array_keys($params ?? []), ['receptor', 'template'])), + 'provider_message' => $this->providerMessage($e), + ], + ); return false; } catch (\Throwable $e) { $this->logger->error(sprintf('SMS sendTemplate failed (kavenegar): %s @ %s:%d', $e->getMessage(), $e->getFile(), $e->getLine()), ['exception' => $e, 'mobile' => $mobile, 'template' => $templateCode]); return false; } } + + /** پیام خطای خود کاوه‌نگار از بدنهٔ پاسخ؛ بدنه ممکن است JSON نباشد پس امن خوانده می‌شود. */ + private function providerMessage(HttpExceptionInterface $e): ?string + { + try { + $body = $e->getResponse()->getContent(false); + } catch (\Throwable) { + return null; + } + + $decoded = json_decode($body, true); + if (is_array($decoded) && isset($decoded['return']['message'])) { + return (string) $decoded['return']['message']; + } + + return mb_substr(trim($body), 0, 200) ?: null; + } } diff --git a/src/Treatment/Service/TreatmentCaseStarter.php b/src/Treatment/Service/TreatmentCaseStarter.php index 59c89fe1..21b03910 100644 --- a/src/Treatment/Service/TreatmentCaseStarter.php +++ b/src/Treatment/Service/TreatmentCaseStarter.php @@ -4,6 +4,7 @@ namespace App\Treatment\Service; use App\Appointment\Entity\Appointment; use App\Patient\Entity\PatientSession; +use App\Shared\Exception\AppException; use App\Treatment\Entity\TreatmentCase; use App\Treatment\Repository\TreatmentProtocolRepository; use App\Treatment\Workflow\TreatmentWorkflowRegistry; @@ -50,11 +51,24 @@ final class TreatmentCaseStarter $service, $protocol, ); + } catch (AppException $e) { + // خطای دادهٔ کاتالوگ است نه خرابی سامانه: سرویسی که پروتکل درمان دارد ولی + // دسته‌بندی‌اش تهی است. با error لاگ‌شدن، صفحهٔ لاگ را پر می‌کرد و مشکل + // واقعی — همان سرویس — دیده نمی‌شد. warning با شناسهٔ سرویس، قابل پیگیری است. + $this->logger->warning('Opening the treatment case on confirm skipped: service catalog data is incomplete', [ + 'appointment_uuid' => $appointment->getUuid(), + 'service_id' => $service->getId(), + 'service_name' => $service->getName(), + 'reason' => $e->getMessage(), + ]); + + return null; } catch (\Throwable $e) { // همان قاعدهٔ AppointmentConfirmationService: نوبت رزرو شده و پول پرداخت // شده؛ شکستِ ساخت پرونده نباید آن را برگرداند. $this->logger->error('Opening the treatment case on confirm failed', [ 'appointment_uuid' => $appointment->getUuid(), + 'service_id' => $service->getId(), 'exception' => $e, ]);