fix(logs): resolve the five real defects surfaced by app_log

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) <noreply@anthropic.com>
This commit is contained in:
hamed
2026-08-18 08:31:15 +03:30
co-authored by Claude Opus 5
parent 57ea7b8c59
commit 5986fad5a1
7 changed files with 108 additions and 20 deletions
+3 -1
View File
@@ -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
+3 -1
View File
@@ -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 ###
+3 -1
View File
@@ -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://<user>:<pass>@<db-private-host>:3306/<db>?serverVersion=mariadb-11.8.0&charset=utf8mb4"
REDIS_URL="redis://<redis-private-host>:6379"
# retry_interval/tcp_keepalive: قطع کوتاه اتصال به redis بی‌سروصدا دوباره برقرار می‌شود
# و «Connection lost» به‌صورت warning در app_log نمی‌نشیند.
REDIS_URL="redis://<redis-private-host>:6379?timeout=5&read_timeout=5&retry_interval=100&tcp_keepalive=60"
MESSENGER_TRANSPORT_DSN="redis://<redis-private-host>:6379/messages"
# If the Redis service has a password: redis://:<pass>@<redis-private-host>:6379
@@ -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');
}
}
@@ -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);
}
+31 -1
View File
@@ -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;
}
}
@@ -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,
]);