- Refactor PaymentController to delegate payment processing to PaymentManager. - Add findByOrderIdForUpdate method in PaymentRepository for pessimistic locking. - Create PaymentLog entity and repository for auditing payment actions. - Implement startGatewayHandoff and processCallback methods in PaymentManager. - Introduce transaction handling and logging for payment verification. - Update payment flow to ensure idempotency and prevent race conditions. - Enhance security by logging sensitive actions without exposing credentials. - Update database schema with migration for payment_logs table. - Document changes in payment flow architecture.
13 KiB
بازطراحی معماری پرداخت — سرویسمحور، امن، توسعهپذیر (Backend)
پروژه
clinicpro (Backend). cross-repo — پرامپت همتا: nobat724_front/.claude/prompt/payment-flow-frontend.md (کلاینت این قرارداد را مصرف میکند؛ Backend اول اجرا شود).
زمینه
بخش بزرگی از معماری هدف قبلاً پیاده شده و نباید دوباره ساخته شود:
- جریان backend-driven:
POST /api/v1/payment/appointment(اعتبارسنجی سفارش + ساختPaymentدر وضعیت pending، بدون تماس با بانک) →pay_url→GET /api/v1/payment/pay/{orderId}(تماس با بانک + انتقال 302 یا فرم auto-submit POST) →callback(verify) →RedirectResponseبهfrontend_address(همان دامنهٔ مبدأ) با?payment_uuid=..&status=... GatewayFactory(src/Payment/Gateway/GatewayFactory.php) الگوی Factory/Strategy را دارد؛resolve(),isEnabled(),isTestMode(),activeGateways(). کنترلر دیگر منطق انتخاب درگاه ندارد.- امنیت موجود: Open-Redirect guard (
payment_allowed_frontend_hosts)، IP-restrict شاپرک در callback، جلوگیری از replay با یکتاییreference_id، بررسی مبلغ (amountRials !== result->amountRials)، CircuitBreaker.
این پرامپت فقط شکافهای باقیماندهٔ معماری/امنیت را میبندد؛ رفتار جریان بیرونی نباید بشکند.
مشکل / هدف
طبق spec معمار، این موارد هنوز رعایت نشدهاند:
- کنترلر هنوز چاق است — منطق تماس با بانک (در
pay) و کل verify + post-actions (درcallback: confirm نوبت، فعالسازی اشتراک، شارژ کیفپول، کمیسیون، پیامک) داخلPaymentControllerاست. طبق spec:Controller → PaymentManager → GatewayFactory → PaymentGatewayInterface. باید یک سرویسPaymentManagerاین منطق را بگیرد و کنترلر فقط Orchestration کند. - نبود قفل/تراکنش در verify — هنگام verify همزمانِ دو callback (race)، فقط یکتاییِ
reference_idجلوگیری میکند؛ باید در یک DB transaction با قفل بدبینانه روی ردیفPaymentانجام شود. - لاگ کامل تراکنش وجود ندارد — spec «ثبت کامل Logها + Gateway Response + Request Time + Authority» میخواهد. الان فقط
gatewayTokenوreference_idذخیره میشود. getStatusپاسخ double-nested دارد (success(['data'=>...])).- subscription-payment و sms-wallet هنوز init را داخل POST انجام میدهند (فقط appointment به الگوی pay-endpoint منتقل شده) — ناسازگاری معماری و همان باگ درگاه POSTیِ ملت.
فایلهای مرتبط
| فایل | نقش |
|---|---|
src/Payment/Controller/PaymentController.php |
کنترلر فعلی؛ باید به Orchestration خالص کاهش یابد |
src/Payment/Service/PaymentManager.php |
جدید — منطق start/verify/post-actions |
src/Payment/Gateway/GatewayFactory.php |
موجود (Factory) — بدون تغییر بزرگ |
src/Payment/Gateway/PaymentGatewayInterface.php |
قرارداد درگاه |
src/Payment/Entity/Payment.php |
افزودن فیلدهای authority/response/requested_at یا metadata |
src/Payment/Entity/PaymentLog.php |
جدید (اختیاری ولی توصیهشده) — audit trail |
src/Payment/Repository/PaymentRepository.php |
افزودن findByOrderIdForUpdate() (قفل) |
docs/api/payment.md |
بهروزرسانی |
وضعیت فعلی
کنترلر pay() مستقیماً با درگاه و repo کار میکند:
#[Route('/api/v1/payment/pay/{orderId}', methods: ['GET'])]
public function pay(string $orderId): Response
{
$payment = $this->paymentRepo->findByOrderId($orderId);
// ... resolve gateway, circuitBreaker, $gateway->initiate(...), setGatewayToken, redirect/autoSubmitForm
}
callback() کل verify + post-action را دارد:
$result = $gw?->verify($callbackData);
// ست وضعیت، بررسی مبلغ، بررسی replay (reference_id)، سپس:
if ($payment->getType() === Payment::TYPE_SUBSCRIPTION) { $this->handleSubscriptionActivation($payment); }
elseif (... SMS_WALLET) { $this->handleSmsWalletCharge($payment); }
elseif (... APPOINTMENT) { $this->handleAppointmentConfirmation($payment); }
return $this->redirectToFrontend($payment, true);
getStatus() double-nested:
return $this->success(['data' => $payment->toArray()]); // ❌ data.data.data
وظایف
۱. ساخت سرویس PaymentManager و لاغر کردن کنترلر
src/Payment/Service/PaymentManager.php بساز که این متدها را داشته باشد و از GatewayFactory, PaymentRepository, CircuitBreakerService, EntityManagerInterface, LoggerInterface و سرویسهای post-action (SubscriptionService, SmsWalletService, CommissionService, SmsService, AppointmentRepository, ...) از طریق DI استفاده کند:
final class PaymentManager
{
public function __construct(
private GatewayFactory $gateways,
private PaymentRepository $paymentRepo,
private CircuitBreakerService $circuitBreaker,
private EntityManagerInterface $em,
private LoggerInterface $logger,
private string $appBaseUrl,
// + سرویسهای post-action
) {}
/** init درگاه برای پرداخت pending و بازگرداندن نتیجه انتقال (redirectMethod/url/params). */
public function startGatewayHandoff(Payment $payment): PaymentInitResult|false { ... }
/** verify امنِ callback داخل transaction + قفل ردیف؛ اجرای post-action؛ بازگرداندن success bool. */
public function processCallback(string $orderId, array $callbackData, string $clientIp): Payment|null { ... }
}
- منطق فعلیِ
pay()(resolve + circuitBreaker + initiate + setGatewayToken) →startGatewayHandoff(). - منطق فعلیِ
callback()(verify + amount + replay + status + post-actions) →processCallback(). - متدهای
handleAppointmentConfirmation,handleSubscriptionActivation,handleSmsWalletChargeاز کنترلر بهPaymentManagerمنتقل شوند. - کنترلر فقط: خواندن request، فراخوانی manager، ساخت
RedirectResponse/autoSubmitForm/error. متدautoSubmitFormوredirectToFrontendوisAllowedFrontend/allowedHostsمیتوانند در کنترلر بمانند (لایهٔ HTTP) یا به یکPaymentRedirectResponderمنتقل شوند — یکی را انتخاب و مستند کن.
۲. transaction + قفل بدبینانه در verify (جلوگیری از race / double-verify)
در processCallback، پرداخت را با قفل بخوان و کل verify+status+post-action را در یک تراکنش انجام بده:
return $this->em->wrapInTransaction(function () use ($orderId, $callbackData, $clientIp) {
$payment = $this->paymentRepo->findByOrderIdForUpdate($orderId); // SELECT ... FOR UPDATE
if ($payment === null) return null;
if ($payment->getStatus() !== Payment::STATUS_PENDING) return $payment; // قبلاً پردازش شده → idempotent
// verify، amount check، replay، setStatus، post-action
return $payment;
});
PaymentRepository::findByOrderIdForUpdate():
public function findByOrderIdForUpdate(string $orderId): ?Payment
{
return $this->createQueryBuilder('p')
->where('p.orderId = :o')->setParameter('o', $orderId)
->getQuery()
->setLockMode(\Doctrine\DBAL\LockMode::PESSIMISTIC_WRITE)
->getOneOrNullResult();
}
نکته: قفل فقط داخل تراکنش معتبر است. گارد
status !== pending → returnباعث idempotent شدن verify تکراری میشود.
۳. لاگ کامل تراکنش (PaymentLog)
Entity جدید src/Payment/Entity/PaymentLog.php با فیلدها: id, paymentId (FK)، action (initiate/verify/callback)، gateway، authority/token، requestPayload (json, بدون افشای اعتبارنامه)، responsePayload (json)، clientIp، createdAt (Unix ts). در PaymentManager روی init و verify یک رکورد لاگ ثبت شود.
- migration: بعد از ساخت Entity،
doctrine:migrations:diff+migrate. - عدم افشای اطلاعات حساس: اعتبارنامهٔ درگاه (username/password/terminal) هرگز در لاگ ذخیره نشود.
۴. ذخیرهٔ authority/response/request-time روی Payment
اگر PaymentLog را پیاده کردی، اینها آنجا ثبت میشوند و کافی است. در غیر اینصورت در Payment::$metadata کلیدهای authority, gateway_response, requested_at را ذخیره کن. یکی را انتخاب کن (ترجیحاً PaymentLog).
۵. رفع double-nesting در getStatus
return $this->success($payment->toArray()); // بهجای ['data'=>...]
مصرفکنندهها را چک کن: پنل ادمین از GET /api/v1/admin/payments/{uuid} استفاده میکند (تخت، مستقل). nobat724_front app/payment/[uuid]/page.js از getPayment استفاده میکند — اگر به double-nest وابسته است، همانجا هم اصلاح کن (در پرامپت frontend ذکر شده).
۶. یکسانسازی subscription و sms-wallet با الگوی pay-endpoint
POST /api/v1/subscription-payment و مسیر sms-wallet را مثل appointment بازطراحی کن: POST فقط Payment pending بسازد و pay_url برگرداند؛ init واقعی در GET /payment/pay/{orderId} (که عمومی است و بر اساس payment->getType() کار میکند). این هم درگاه POSTیِ ملت را برای این جریانها درست میکند و هم معماری را یکدست.
- توجه: مصرفکنندهٔ subscription، پنل ادمین
clinicproاست — بعد از تغییر قرارداد،assets/admin/جایی که subscription-payment را صدا میزند بهpay_urlسوییچ کن (مثل frontend).
۷. مستندسازی
docs/api/payment.md را با معماری نهایی (سرویسها، PaymentManager، PaymentLog، جریان یکدست همهٔ typeها) بهروز کن.
نکات مهم
- جریان بیرونی نباید بشکند: endpointها و قرارداد (
pay_url,frontend_address?status=) ثابت بمانند؛ فقط لایهبندی داخلی عوض میشود. بعد از هر مرحله با تستهای واقعی (زیر) صحت را بررسی کن. - step-1 معماری (کلیک → بکاند): بهدلیل اینکه JWT سایت در کوکیِ همان دامنه است و redirect full-page کوکی cross-domain نمیبرد، ساخت
Paymentنیازمند XHR authenticated است؛ سپس مرورگر full-page بهpay_urlمیرود. این استانداردِ امنِ چند-دامنه است. اگر «بدون هیچ XHR» الزامی است، بهجای آن یک توکن یکبارمصرفِ امضاشده در URL لازم است — در این صورت آن را پیاده کن؛ در غیر اینصورت الگوی XHR+redirect را حفظ و مستند کن. - همه controllerها از
BaseControllerارث میبرند؛ پاسخهاsuccess/error. تاریخها Unix timestamp. - Entity جدید (
PaymentLog) → migration الزامی. - بعد از تغییر:
ddev exec php -l ...،ddev exec php bin/console cache:clear،ddev exec php vendor/bin/phpstan analyse، و تست دستی زیر. - بعد از تغییر API →
docs/api/payment.mdدر همین session.
تست دستی (ddev، در حالت payment_test_mode=1)
# pay endpoint یک پرداخت pending باید 302 به callback بدهد (Mock)
curl -sk -o /dev/null -w "%{http_code} %{redirect_url}\n" "https://clinic-pro.ddev.site/api/v1/payment/pay/ORD-XXXX"
# callback موفق باید 302 به frontend_address?status=success بدهد
# verify تکراری (دوبار زدن callback) نباید وضعیت را دوباره پردازش کند (idempotent)
خروجی نهایی (طبق spec — در گزارش اجرا ارائه شود)
۱ Flow ۲ کلاسها ۳ سرویسها ۴ کنترلرها ۵ مسئولیت هر کلاس ۶ نقاط ضعف ۷ بهبود ۸ امنیت ۹ Performance ۱۰ افزودن درگاه جدید (Open/Closed via GatewayFactory).