From 5986fad5a13cddb2134ef032f0d3b4a5bc6c2b30 Mon Sep 17 00:00:00 2001 From: hamed <15238-genius.ha@users.noreply.drupalcode.org> Date: Tue, 18 Aug 2026 08:31:15 +0330 Subject: [PATCH] fix(logs): resolve the five real defects surfaced by app_log MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit TenantInsurance reads ran through the tenant filter, which pins every query to the *requesting* user's environment. A clinic owner managing a doctor's contracts therefore read an empty set, recomputed version 1, and hit `uniq_tenant_insurance_version` on insert. The reads now bypass the filter — authorization is already established by resolveTargetEntity(), and the uuid-based paths re-assert ownership after loading. UserActiveContext::upsert() raced with itself: the panel fires several /oauth/userinfo requests at once, all saw no row, all inserted, and the losers died on a duplicate PRIMARY (closing the EntityManager with them). Replaced with INSERT ... ON DUPLICATE KEY UPDATE. A service that carries a treatment protocol but no catalog category is bad catalog data, not a system failure; it was logged at error level on every confirm and buried the real errors. Now a warning carrying the service id. Kavenegar's HTTP 431 says only "malformed request". The provider's own message and the token slot names are now logged so the template can actually be fixed in the panel; token values stay out of the log. Redis DSNs gained timeout/retry_interval/tcp_keepalive so a brief connection loss reconnects quietly instead of logging a warning each time. Co-Authored-By: Claude Opus 5 (1M context) --- .env.coolify.example | 4 +- .env.example | 4 +- .env.liara.example | 4 +- .../UserActiveContextRepository.php | 31 ++++++++++++--- .../Repository/TenantInsuranceRepository.php | 39 ++++++++++++++----- src/Sms/Provider/KavehNegarProvider.php | 32 ++++++++++++++- .../Service/TreatmentCaseStarter.php | 14 +++++++ 7 files changed, 108 insertions(+), 20 deletions(-) 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, ]);