Files
clinicpro/.claude/prompt/production-fixes-cors-payment-gateways.md

15 KiB

رفع باگ‌های پروداکشن: CORS + payment/config 500 + درگاه‌های فعال و callback ملت

پروژه

clinicpro (Backend Symfony + پنل ادمین React). دو مورد cross-repo با سایت عمومی nobat724_front دارد (دامنه‌های عمومی و CORS) که در همین‌جا فقط سمت backend اصلاح می‌شود.

زمینه

در پروداکشن (backend روی https://clinic-pro.ir، سایت عمومی روی دامنه‌های چند-شهری مثل https://yasuj-nobat.ir) سه باگ گزارش شده:

  1. CORS: از https://yasuj-nobat.ir درخواست به https://clinic-pro.ir/api/v1/user/send-code با خطای preflight رد می‌شود: No 'Access-Control-Allow-Origin' header is present.
  2. payment/config 500: در https://clinic-pro.ir/admin/subscription فراخوانی GET /api/v1/payment/config مکرراً 500 می‌دهد.
  3. درگاه‌ها: الان فقط درگاه ملت فعال است ولی UI هر دو درگاه (ملت + سپ) را نشان می‌دهد؛ باید فقط درگاه‌های فعال قابل‌انتخاب باشند. همچنین هر درگاه باید callback مخصوص خودش داشته باشد و مطابق راهنمای IPG ملت (callBackUrl باید روی دامنهٔ ثبت‌شده باشد).

نکته تشخیصی: GET /api/v1/payment/config روی محیط لوکال ddev با توکن ادمین 200 برمی‌گرداند ({"success":true,"data":{"test_mode":false,"appointment_fee_rials":15000}}). پس 500 مختص محیط/دادهٔ پروداکشن است و نباید حدس زد — ابتدا استثنای واقعی از لاگ پروداکشن استخراج شود، سپس endpoint مقاوم‌سازی شود.

فایل‌های مرتبط

فایل نقش
config/packages/nelmio_cors.yaml تنظیم CORS، مبتنی بر env CORS_ALLOW_ORIGIN (regex)
.env / .env.example (+ env پروداکشن) مقدار CORS_ALLOW_ORIGIN و ALLOWED_FRONTEND_HOSTS و APP_BASE_URL
src/Payment/Controller/PaymentController.php config() (خط ۵۴۶)، initiateAppointment()، callback()، resolveGateway()
src/Payment/Gateway/MellatGateway.php درگاه ملت؛ اعتبارنامه از SiteConfig با fallback به env
src/Payment/Gateway/SepGateway.php درگاه سپ
src/Config/Repository/SiteConfigRepository.php کلیدهای mellat_*, sep_terminal_id, payment_test_mode, appointment_fee_rials
assets/admin/pages/SubscriptionPage.tsx انتخاب درگاه (خط ۳۲۸ آرایهٔ hardcode ['mellat','sep'])
assets/admin/pages/AdminSubscriptionPage.tsx مشابه، در صورت داشتن انتخاب درگاه
docs/api/payment.md مستند endpointها

وضعیت فعلی (کد واقعی)

CORS — config/packages/nelmio_cors.yaml

nelmio_cors:
    defaults:
        origin_regex: true
        allow_origin: ["%env(CORS_ALLOW_ORIGIN)%"]
        allow_methods: ["GET", "OPTIONS", "POST", "PATCH", "DELETE"]
        allow_headers:
            [
                "Content-Type",
                "Authorization",
                "X-CSRF-Token",
                "Content-Disposition",
            ]
    paths:
        "^/api/": { allow_origin: ["%env(CORS_ALLOW_ORIGIN)%"] }
        "^/oauth/": { allow_origin: ["%env(CORS_ALLOW_ORIGIN)%"] }
        "^/health": { allow_origin: ["%env(CORS_ALLOW_ORIGIN)%"] }

.env فعلی (لوکال) — فقط دامنه‌های ddev/localhost را مجاز می‌کند:

CORS_ALLOW_ORIGIN='^https?://([a-z0-9-]+\.)*(clinic-pro\.ddev\.site|localhost|127\.0\.0\.1)(:[0-9]+)?$'
ALLOWED_FRONTEND_HOSTS=clinic-pro.ddev.site,localhost,yazd-nobat.localhost

.env.example هنوز placeholder دارد: CORS_ALLOW_ORIGIN='^https://your-domain\.com$'.

payment/config — PaymentController::config() (خط ۵۴۶)

#[IsGranted('IS_AUTHENTICATED_FULLY')]
#[Route('/api/v1/payment/config', methods: ['GET'])]
public function config(): JsonResponse
{
    return $this->success([
        'test_mode'             => $this->configRepo->get('payment_test_mode') === '1',
        'appointment_fee_rials' => (int) $this->configRepo->get('appointment_fee_rials'),
    ]);
}

resolveGateway + callback (کد واقعی)

private function resolveGateway(string $name): ?PaymentGatewayInterface
{
    if ($this->configRepo->get('payment_test_mode') === '1') return $this->mock;
    return match ($name) {
        'mellat' => $this->mellat,
        'sep'    => $this->sep,
        default  => null,
    };
}

// در initiateAppointment: callback هر درگاه از APP_BASE_URL ساخته می‌شود
$callbackUrl = $this->appBaseUrl . '/api/v1/payment/callback/' . $gatewayName . '?order_id=' . $payment->getOrderId();
$result      = $gateway->initiate($payment->getAmountRials(), $payment->getOrderId(), $callbackUrl);

روت‌های callback موجود (per-gateway، عمومی و IP-restricted):

POST|GET /api/v1/payment/callback/{gateway}
POST|GET /api/v1/subscription-payment/callback/{gateway}

MellatGateway — تشخیص فعال‌بودن

private function cfg(string $key, string $envFallback): string
{
    return $this->configRepo->get($key) ?: $envFallback;
}
// اعتبارنامه‌ها: mellat_terminal_id / mellat_username / mellat_password

فرانت — SubscriptionPage.tsx

const GATEWAY_LABELS: Record<string, string> = { mellat: 'بانک ملت', sep: 'سپ (سامان کیش)' };
const [selectedGateway, setSelectedGateway] = useState<'mellat' | 'sep'>('mellat');
// ...
{(['mellat', 'sep'] as const).map((gw) => ( /* دکمهٔ انتخاب هر دو درگاه، همیشه */ ))}

وظایف

۱. رفع CORS پروداکشن

ریشه: regex CORS_ALLOW_ORIGIN در env پروداکشن دامنه‌های عمومی چند-شهری (*-nobat.ir) و خودِ clinic-pro.ir را پوشش نمی‌دهد؛ در نتیجه preflight OPTIONS /api/v1/user/send-code هدر Access-Control-Allow-Origin نمی‌گیرد و مرورگر بلاک می‌کند.

  • در env پروداکشن.env/.env.example به‌عنوان مرجع) مقدار CORS_ALLOW_ORIGIN را به regexی تغییر بده که همهٔ دامنه‌های عمومی <city>-nobat.ir (با/بدون subdomain و www) و پنل clinic-pro.ir را مجاز کند. نمونهٔ پیشنهادی:
CORS_ALLOW_ORIGIN='^https://([a-z0-9-]+\.)*([a-z0-9-]+-nobat\.ir|clinic-pro\.ir)$'
  • ALLOWED_FRONTEND_HOSTS (لیست میزبان‌های مجاز برای redirect پرداخت، در PaymentController::allowedHosts()) نیز باید شامل دامنه‌های عمومی پروداکشن باشد، مثلاً:
ALLOWED_FRONTEND_HOSTS=clinic-pro.ir,yasuj-nobat.ir,yazd-nobat.ir
  • .env.example را از placeholder your-domain.com به همین الگو به‌روزرسانی کن تا برای دیپلوی‌های بعدی درست باشد.
  • بعد از تغییر env روی سرور: php bin/console cache:clear (regex در کش کانتینر خوانده می‌شود).
  • تأیید: با curl -i -X OPTIONS 'https://clinic-pro.ir/api/v1/user/send-code' -H 'Origin: https://yasuj-nobat.ir' -H 'Access-Control-Request-Method: POST' باید هدر Access-Control-Allow-Origin: https://yasuj-nobat.ir برگردد.

۲. رفع payment/config 500 + فقط درگاه‌های فعال

۲.۱ استخراج علت واقعی 500 (بدون حدس): روی پروداکشن لاگ استثنای /api/v1/payment/config را بگیر (var/log/prod.log یا لاگ کانتینر/docker logs). چون لوکال 200 می‌دهد، علت محتمل یکی از این‌هاست — تأیید کن، حدس نزن:

  • مهاجرت‌های اجرا‌نشده در پروداکشن (جدول site_config یا ستون‌ها) → php bin/console doctrine:migrations:migrate روی prod.
  • خطای اتصال/کوئری SiteConfigRepository هنگام خواندن کلید.
  • ناسازگاری کد دیپلوی‌شده با DEFAULTS جدید در SiteConfigRepository.

۲.۲ مقاوم‌سازی و افزودن درگاه‌های فعال به پاسخ: config() را طوری تغییر بده که علاوه بر test_mode و appointment_fee_rials، لیست درگاه‌های فعال را برگرداند تا فرانت فقط همان‌ها را نشان دهد. یک درگاه «فعال» است اگر اعتبارنامه‌هایش (در SiteConfig یا env) ست شده باشند:

#[Route('/api/v1/payment/config', methods: ['GET'])]
public function config(): JsonResponse
{
    $testMode = $this->configRepo->get('payment_test_mode') === '1';

    $cfg = fn(string $key): string => (string) ($this->configRepo->get($key) ?? '');

    $gateways = [];
    if ($testMode) {
        $gateways[] = ['name' => 'mellat', 'label' => 'بانک ملت (آزمایشی)'];
    } else {
        $mellatActive = $cfg('mellat_terminal_id') !== '' && $cfg('mellat_username') !== '' && $cfg('mellat_password') !== '';
        $sepActive    = $cfg('sep_terminal_id') !== '';
        if ($mellatActive) $gateways[] = ['name' => 'mellat', 'label' => 'بانک ملت'];
        if ($sepActive)    $gateways[] = ['name' => 'sep',    'label' => 'سپ (سامان کیش)'];
    }

    return $this->success([
        'test_mode'             => $testMode,
        'appointment_fee_rials' => (int) ($this->configRepo->get('appointment_fee_rials') ?: 0),
        'gateways'              => $gateways,
    ]);
}

اگر env پروداکشن هم fallback اعتبارنامهٔ ملت را دارد، توجه کن که MellatGateway::cfg() اول SiteConfig بعد env را می‌خواند؛ برای هم‌راستایی، تشخیص «فعال» را هم به همین ترتیب انجام بده (اول کلید DB، اگر خالی بود env مربوطه). در صورت نیاز یک helper خصوصی مشابه cfg($key, $envFallback) در کنترلر اضافه کن.

۲.۳ فرانت — فقط درگاه‌های فعال: در SubscriptionPage.tsxAdminSubscriptionPage.tsx اگر انتخاب درگاه دارد):

  • لیست درگاه‌ها را از GET /api/v1/payment/configdata.gateways بخوان، نه آرایهٔ hardcode ['mellat','sep'].
  • selectedGateway را به اولین درگاه فعال مقداردهی اولیه کن؛ اگر فقط یک درگاه فعال بود، همان به‌صورت پیش‌فرض انتخاب و بقیه نمایش داده نشوند.
  • اگر gateways خالی بود (هیچ درگاه فعالی نیست) پیام مناسب نشان بده و دکمهٔ پرداخت را غیرفعال کن.

۳. Callback هر درگاه مطابق راهنمای IPG ملت

callback به‌ازای هر درگاه از قبل وجود دارد (/api/v1/payment/callback/{gateway} و نسخهٔ subscription، ساخته‌شده از APP_BASE_URL). طبق راهنمای ملت (نگارش ۱.۳۸) نکات الزامی که باید تضمین شوند:

  • callBackUrl باید روی دامنهٔ ثبت‌شدهٔ پذیرنده باشد، نه IP (در غیر این صورت کد پاسخ 62: «مسیر back call در دامنهٔ ثبت‌شده نیست»). یعنی در پروداکشن APP_BASE_URL باید دقیقاً https://clinic-pro.ir (دامنهٔ ثبت‌شده نزد ملت/شاپرک) باشد. env پروداکشن را بررسی و اصلاح کن.
  • هدر Referer هنگام Redirect به startpay.mellat باید دامنهٔ ثبت‌شده باشد؛ چون این POST سمت مرورگر انجام می‌شود، مطمئن شو صفحه‌ای که کاربر را redirect می‌کند روی clinic-pro.ir سرو می‌شود.
  • نکتهٔ امنیتی callback (ص ۳۴ راهنما): پس از دریافت Call Back باید RefId و SaleOrderId دریافتی دقیقاً همان مقادیر تراکنش اولیه باشند و در صورت عدم تطابق، تراکنش نامعتبر و از bpVerify خودداری شود. در PaymentController::callback() بررسی کن که order_id به Payment درست bind می‌شود و مبلغ/RefNum تکراری (replay) رد می‌شود (منطق underpayment/replay موجود است — تأیید و در صورت نقص تکمیل کن).
  • مستندسازی: در docs/api/payment.md جدول callback هر درگاه را دقیق کن:
    • ملت: POST/GET {APP_BASE_URL}/api/v1/payment/callback/mellat?order_id=...
    • سپ: POST/GET {APP_BASE_URL}/api/v1/payment/callback/sep?order_id=...
    • نسخهٔ اشتراک: .../api/v1/subscription-payment/callback/{gateway}

نکات مهم

  • همه پاسخ‌ها از BaseController ($this->success() / $this->error())؛ ساختار پاسخ payment/config نباید بشکند (فرانت data?.data می‌خواند).
  • تغییر قرارداد payment/config فیلد جدید gateways اضافه می‌کند — علاوه بر SubscriptionPage، هر مصرف‌کنندهٔ دیگر (nobat724_front اگر این endpoint را صدا می‌زند) باید سازگار بماند؛ فیلدهای قبلی حذف نشوند (backward-compatible).
  • بدون Entity جدید → migration لازم نیست؛ فقط اگر پروداکشن مهاجرت اجرا‌نشده دارد، همان اجرا شود.
  • CORS: origin_regex: true است، پس مقدار env یک regex است نه لیست دامنه؛ regex را تست کن که هم https://yasuj-nobat.ir و هم https://www.yasuj-nobat.ir و هم https://clinic-pro.ir را match کند و دامنهٔ ناخواسته را match نکند.
  • تست‌ها:
    • ddev exec php -l روی فایل‌های PHP تغییر‌یافته.
    • ddev exec php bin/phpunit tests/Payment (تست‌های callback موجود نشکند).
    • ddev exec php bin/console cache:clear بعد از تغییر nelmio/env.
    • ddev exec npx tsc --noEmit و ddev exec yarn dev برای فرانت.
    • تست زندهٔ GET /api/v1/payment/config با توکن ادمین → باید gateways را برگرداند.
  • مستندات: بعد از تغییر PaymentController، docs/api/payment.md را در همین session به‌روز کن (قانون standing پروژه).
  • بعد از پایان: graphify update ..