src/Controller/PaySessionController.php line 103

Open in your IDE?
  1. <?php
  2. namespace App\Controller;
  3. use App\Entity\PayChannel;
  4. use App\Entity\PaymentAttempt;
  5. use App\Entity\PayOrder;
  6. use App\Service\MerchantCompat\MerchantCompatService;
  7. use App\Service\Payment\AccountLedgerService;
  8. use App\Service\Payment\PaymentChannelSelector;
  9. use App\Service\Payment\PaymentOrderNoGenerator;
  10. use App\Service\Payment\PaymentProviderRegistry;
  11. use Doctrine\DBAL\LockMode;
  12. use Doctrine\ORM\EntityManagerInterface;
  13. use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
  14. use Symfony\Component\HttpFoundation\JsonResponse;
  15. use Symfony\Component\HttpFoundation\RedirectResponse;
  16. use Symfony\Component\HttpFoundation\Request;
  17. use Symfony\Component\HttpFoundation\Response;
  18. use Symfony\Contracts\HttpClient\HttpClientInterface;
  19. class PaySessionController extends AbstractController
  20. {
  21.     private EntityManagerInterface $entityManager;
  22.     private PaymentProviderRegistry $paymentProviderRegistry;
  23.     private PaymentChannelSelector $paymentChannelSelector;
  24.     private PaymentOrderNoGenerator $paymentOrderNoGenerator;
  25.     private MerchantCompatService $merchantCompatService;
  26.     private AccountLedgerService $accountLedgerService;
  27.     private HttpClientInterface $httpClient;
  28.     private string $publicBaseUrl;
  29.     public function __construct(
  30.         EntityManagerInterface $entityManager,
  31.         PaymentProviderRegistry $paymentProviderRegistry,
  32.         PaymentChannelSelector $paymentChannelSelector,
  33.         PaymentOrderNoGenerator $paymentOrderNoGenerator,
  34.         MerchantCompatService $merchantCompatService,
  35.         AccountLedgerService $accountLedgerService,
  36.         HttpClientInterface $httpClient,
  37.         string $publicBaseUrl
  38.     ) {
  39.         $this->entityManager $entityManager;
  40.         $this->paymentProviderRegistry $paymentProviderRegistry;
  41.         $this->paymentChannelSelector $paymentChannelSelector;
  42.         $this->paymentOrderNoGenerator $paymentOrderNoGenerator;
  43.         $this->merchantCompatService $merchantCompatService;
  44.         $this->accountLedgerService $accountLedgerService;
  45.         $this->httpClient $httpClient;
  46.         $this->publicBaseUrl rtrim($publicBaseUrl'/');
  47.     }
  48.     public function __invoke(Request $request): JsonResponse
  49.     {
  50.         $payToken = (string) $request->attributes->get('payToken''');
  51.         $order $this->findOrder($payToken);
  52.         if (!$order) {
  53.             return new JsonResponse(['code' => 0'msg' => 'Payment session not found'], 404);
  54.         }
  55.         if ($request->isMethod('POST')) {
  56.             return $this->pay($request$order);
  57.         }
  58.         return new JsonResponse($this->sessionPayload($order));
  59.     }
  60.     private function pay(Request $requestPayOrder $order): JsonResponse
  61.     {
  62.         if ($order->getStatus() !== PayOrder::STATUS_WAIT) {
  63.             return new JsonResponse(['code' => 0'msg' => 'Order is not payable'], 400);
  64.         }
  65.         if (!$order->getMerchantUser()) {
  66.             return new JsonResponse(['code' => 0'msg' => '支付订单必须关联商户'], 400);
  67.         }
  68.         $decodedRequestData json_decode($request->getContent(), true);
  69.         $requestData is_array($decodedRequestData) ? $decodedRequestData : [];
  70.         $requestedPayMethod strtolower(trim((string) ($requestData['payMethod'] ?? $requestData['paymentMethod'] ?? '')));
  71.         $lockedPayMethod $this->lockedPaymentMethod($order);
  72.         if ($lockedPayMethod !== null && $requestedPayMethod !== '' && $requestedPayMethod !== $lockedPayMethod) {
  73.             return new JsonResponse(['code' => 0'msg' => 'Payment method is locked for this order'], 422);
  74.         }
  75.         $payMethod $lockedPayMethod ?: $requestedPayMethod;
  76.         if ($payMethod === '') {
  77.             return new JsonResponse(['code' => 0'msg' => 'Payment method is required'], 422);
  78.         }
  79.         $payType $this->resolvePayType($request$requestData$payMethod);
  80.         $payScene $this->resolvePayScene($request$requestData$payMethod);
  81.         if (!in_array($payMethod$this->availablePaymentMethodCodes($order), true)) {
  82.             return new JsonResponse(['code' => 0'msg' => 'Payment method is not enabled'], 422);
  83.         }
  84.         try {
  85.             $attempt $this->getOrCreatePaymentAttemptWithProviderContent($request$order$payMethod$payType$payScene$requestData);
  86.             return $this->paymentAttemptResponse($order$attempt);
  87.         } catch (\InvalidArgumentException $e) {
  88.             isset($attempt) && $this->markAttemptFailed($attempt$e->getMessage());
  89.             return new JsonResponse(['code' => 0'msg' => $e->getMessage()], 422);
  90.         } catch (\RuntimeException $e) {
  91.             isset($attempt) && $this->markAttemptFailed($attempt$e->getMessage());
  92.             return new JsonResponse(['code' => 0'msg' => $e->getMessage()], 422);
  93.         } catch (\Throwable $e) {
  94.             isset($attempt) && $this->markAttemptFailed($attempt$e->getMessage());
  95.             return new JsonResponse(['code' => 0'msg' => $e->getMessage()], 500);
  96.         }
  97.     }
  98.     public function providerSubmit(Request $requeststring $payTokenstring $attemptNo): Response
  99.     {
  100.         $order $this->findOrder($payToken);
  101.         if (!$order) {
  102.             return $this->providerSubmitError('Payment session not found'404);
  103.         }
  104.         /** @var PaymentAttempt|null $attempt */
  105.         $attempt $this->entityManager->getRepository(PaymentAttempt::class)->findOneBy([
  106.             'attemptNo' => $attemptNo,
  107.         ]);
  108.         if (!$attempt || !$attempt->getPayOrder() || $attempt->getPayOrder()->getId() !== $order->getId()) {
  109.             return $this->providerSubmitError('Payment attempt not found'404);
  110.         }
  111.         if ($order->getStatus() !== PayOrder::STATUS_WAIT || $attempt->getStatus() !== PaymentAttempt::STATUS_WAIT) {
  112.             return $this->providerSubmitError('Order is not payable'400);
  113.         }
  114.         if (!$this->isCurrentPayableAttempt($order$attempt)) {
  115.             return $this->providerSubmitError('Payment attempt has been superseded'409);
  116.         }
  117.         $form $this->parseFormContent((string) $attempt->getPayContent());
  118.         $action = (string) ($form['action'] ?? '');
  119.         $fields $form['fields'] ?? [];
  120.         if (!filter_var($actionFILTER_VALIDATE_URL) || !$fields) {
  121.             return $this->providerSubmitError('支付通道提交参数无效'422);
  122.         }
  123.         try {
  124.             return $this->submitProviderFormOnce($order$attempt);
  125.         } catch (\Throwable $e) {
  126.             return $this->providerSubmitError('支付通道提交失败,请返回商户重新发起支付'502);
  127.         }
  128.     }
  129.     public function providerReturn(Request $requeststring $payToken): Response
  130.     {
  131.         $order $this->findOrder($payToken);
  132.         if (!$order) {
  133.             return new RedirectResponse($this->absolutePublicUrl('/pay_session/' rawurlencode($payToken)), 302);
  134.         }
  135.         $this->syncOrderStatusFromProvider($order);
  136.         $this->entityManager->flush();
  137.         if ($order->getStatus() !== PayOrder::STATUS_SUCCESS) {
  138.             return new RedirectResponse($this->absolutePublicUrl('/pay_session/' rawurlencode((string) $order->getPayCode())), 302);
  139.         }
  140.         return $this->merchantReturnRedirect($order);
  141.     }
  142.     private function syncOrderStatusFromProvider(PayOrder $order): void
  143.     {
  144.         if ($order->getStatus() !== PayOrder::STATUS_WAIT) {
  145.             return;
  146.         }
  147.         $attempt $this->latestPaymentAttempt($order);
  148.         $payChannel $attempt $attempt->getPayChannel() : $order->getPayChannel();
  149.         if (!$payChannel) {
  150.             return;
  151.         }
  152.         $queryOrder = clone $order;
  153.         if ($attempt) {
  154.             $queryOrder->setPayChannel($attempt->getPayChannel());
  155.             $queryOrder->setPaymentProvider($attempt->getPaymentProvider());
  156.             $queryOrder->setClient($attempt->getPayMethod());
  157.             $queryOrder->setType($attempt->getPayType());
  158.             if (!$this->isOfficialAlipayPcPagePayAttempt($attempt)) {
  159.                 $queryOrder->setPlatformOrderNo($attempt->getAttemptNo());
  160.             }
  161.         }
  162.         try {
  163.             $tradeService $this->paymentProviderRegistry->getByChannel($payChannel);
  164.             $result $tradeService->tradeQuery($queryOrder);
  165.         } catch (\Throwable $e) {
  166.             return;
  167.         }
  168.         $data $this->normalizeProviderResult($result);
  169.         if (!$data) {
  170.             return;
  171.         }
  172.         $channelOrderNo $data['trade_no'] ?? $data['transaction_id'] ?? $data['order_no'] ?? $data['pay_no'] ?? $data['serialNumber'] ?? null;
  173.         if ($channelOrderNo) {
  174.             $order->setChannelOrderNo((string) $channelOrderNo);
  175.             $attempt && $attempt->setChannelOrderNo((string) $channelOrderNo);
  176.         }
  177.         $status method_exists($tradeService'mapOrderStatus')
  178.             ? $tradeService->mapOrderStatus($data['trade_status'] ?? $data['trade_state'] ?? $data['status'] ?? '')
  179.             : $this->mappedProviderOrderStatus($data);
  180.         if ($status) {
  181.             $order->setStatus($status);
  182.             $attempt && $attempt->setStatus($this->attemptStatus($status));
  183.         }
  184.         if ($attempt) {
  185.             $order->setPayChannel($attempt->getPayChannel());
  186.             $order->setPaymentProvider($attempt->getPaymentProvider());
  187.             $order->setClient($attempt->getPayMethod());
  188.             $order->setType($attempt->getPayType());
  189.         }
  190.         if ($order->getStatus() === PayOrder::STATUS_SUCCESS) {
  191.             if (!$order->getPaidAt()) {
  192.                 $order->setPaidAt(new \DateTime());
  193.             }
  194.             if ($attempt && !$attempt->getPaidAt()) {
  195.                 $attempt->setPaidAt(new \DateTime());
  196.             }
  197.             $order->setPaidAmount($order->getPaidAmount() ?: $order->getAmount());
  198.             $this->closeOtherPaymentAttempts($order$attempt);
  199.             $this->accountLedgerService->recordPaymentSuccess($order);
  200.         }
  201.     }
  202.     private function merchantReturnRedirect(PayOrder $order): Response
  203.     {
  204.         $returnUrl $order->getReturnUrl();
  205.         if (!$returnUrl) {
  206.             return new RedirectResponse($this->absolutePublicUrl('/pay_session/' rawurlencode((string) $order->getPayCode())), 302);
  207.         }
  208.         $payload $this->merchantCompatService->buildPaymentReturnPayload($order);
  209.         $queryString http_build_query($payload);
  210.         return new RedirectResponse($returnUrl . (strpos($returnUrl'?') === false '?' '&') . $queryString302);
  211.     }
  212.     private function submitProviderFormOnce(PayOrder $orderPaymentAttempt $attempt): Response
  213.     {
  214.         $this->entityManager->beginTransaction();
  215.         try {
  216.             $this->entityManager->lock($attemptLockMode::PESSIMISTIC_WRITE);
  217.             $this->entityManager->lock($orderLockMode::PESSIMISTIC_WRITE);
  218.             if ($order->getStatus() !== PayOrder::STATUS_WAIT || $attempt->getStatus() !== PaymentAttempt::STATUS_WAIT) {
  219.                 $this->entityManager->commit();
  220.                 return $this->providerSubmitError('Order is not payable'400);
  221.             }
  222.             $cachedResponse $this->cachedProviderSubmitResponse($attempt);
  223.             if ($cachedResponse) {
  224.                 $this->entityManager->commit();
  225.                 return $cachedResponse;
  226.             }
  227.             $form $this->parseFormContent((string) $attempt->getPayContent());
  228.             $action = (string) ($form['action'] ?? '');
  229.             $fields $form['fields'] ?? [];
  230.             if (!filter_var($actionFILTER_VALIDATE_URL) || !$fields) {
  231.                 $this->entityManager->commit();
  232.                 return $this->providerSubmitError('Payment provider submit parameters are invalid'422);
  233.             }
  234.             $response $this->httpClient->request('POST'$action, [
  235.                 'body' => http_build_query($fields),
  236.                 'headers' => ['Content-Type' => 'application/x-www-form-urlencoded;charset=UTF-8'],
  237.                 'max_redirects' => 0,
  238.                 'verify_peer' => false,
  239.                 'verify_host' => false,
  240.             ]);
  241.             $statusCode $response->getStatusCode();
  242.             $headers $response->getHeaders(false);
  243.             $location $headers['location'][0] ?? null;
  244.             if ($statusCode >= 300 && $statusCode 400 && $location) {
  245.                 $redirectUrl $this->absoluteProviderUrl($location$action);
  246.                 $this->storeProviderSubmitResponse($attempt, [
  247.                     'type' => 'redirect',
  248.                     'url' => $redirectUrl,
  249.                     'statusCode' => $statusCode,
  250.                 ]);
  251.                 $this->entityManager->flush();
  252.                 $this->entityManager->commit();
  253.                 return new RedirectResponse($redirectUrl302);
  254.             }
  255.             $contentType $headers['content-type'][0] ?? 'text/html; charset=UTF-8';
  256.             $content $response->getContent(false);
  257.             $this->storeProviderSubmitResponse($attempt, [
  258.                 'type' => 'response',
  259.                 'statusCode' => $statusCode,
  260.                 'contentType' => $contentType,
  261.                 'body' => $content,
  262.             ]);
  263.             $this->entityManager->flush();
  264.             $this->entityManager->commit();
  265.             return new Response($content$statusCode, ['Content-Type' => $contentType]);
  266.         } catch (\Throwable $e) {
  267.             $this->entityManager->rollback();
  268.             throw $e;
  269.         }
  270.     }
  271.     private function cachedProviderSubmitResponse(PaymentAttempt $attempt): ?Response
  272.     {
  273.         if ($this->isOfficialAlipayPcPagePayAttempt($attempt)) {
  274.             return null;
  275.         }
  276.         $cached $attempt->getRawResponse()['providerSubmit'] ?? null;
  277.         if (!is_array($cached)) {
  278.             return null;
  279.         }
  280.         if (($cached['type'] ?? '') === 'redirect' && filter_var($cached['url'] ?? ''FILTER_VALIDATE_URL)) {
  281.             if (!$this->isReusableProviderSubmitRedirect($attempt, (string) $cached['url'])) {
  282.                 return $this->providerSubmitError('Payment has already been opened. Please complete payment in the opened cashier page or return to the merchant to create a new order.'409);
  283.             }
  284.             return new RedirectResponse((string) $cached['url'], 302);
  285.         }
  286.         if (($cached['type'] ?? '') === 'response' && array_key_exists('body'$cached)) {
  287.             return new Response(
  288.                 (string) $cached['body'],
  289.                 (int) ($cached['statusCode'] ?? 200),
  290.                 ['Content-Type' => (string) ($cached['contentType'] ?? 'text/html; charset=UTF-8')]
  291.             );
  292.         }
  293.         return null;
  294.     }
  295.     private function isReusableProviderSubmitRedirect(PaymentAttempt $attemptstring $url): bool
  296.     {
  297.         if ($attempt->getPayType() === PayOrder::TYPE_H5) {
  298.             return true;
  299.         }
  300.         $parts parse_url($url);
  301.         $host strtolower((string) ($parts['host'] ?? ''));
  302.         $path = (string) ($parts['path'] ?? '');
  303.         return !(substr($host, -10) === 'alipay.com' && strpos($path'/appAssign.htm') !== false);
  304.     }
  305.     private function storeProviderSubmitResponse(PaymentAttempt $attempt, array $response): void
  306.     {
  307.         $rawResponse $attempt->getRawResponse();
  308.         $response['createdAt'] = (new \DateTime())->format(\DateTime::ATOM);
  309.         $rawResponse['providerSubmit'] = $response;
  310.         $attempt->setRawResponse($rawResponse);
  311.     }
  312.     private function getOrCreatePaymentAttemptWithProviderContent(Request $requestPayOrder $orderstring $payMethodstring $payTypestring $payScene, array $requestData): PaymentAttempt
  313.     {
  314.         $this->entityManager->beginTransaction();
  315.         try {
  316.             $this->entityManager->lock($orderLockMode::PESSIMISTIC_WRITE);
  317.             $activeAttempt $this->findActiveAttempt($order);
  318.             if ($activeAttempt) {
  319.                 if ($activeAttempt->getStatus() === PaymentAttempt::STATUS_WAIT && $activeAttempt->getPayContent()) {
  320.                     if ($activeAttempt->getPayMethod() !== $payMethod) {
  321.                         throw new \RuntimeException('Payment method is locked for this order');
  322.                     }
  323.                     $this->entityManager->commit();
  324.                     return $activeAttempt;
  325.                 }
  326.                 if ($activeAttempt->getStatus() !== PaymentAttempt::STATUS_CLOSED) {
  327.                     $activeAttempt->setStatus(PaymentAttempt::STATUS_FAILED);
  328.                     $activeAttempt->setFailReason('Incomplete checkout payment attempt was replaced');
  329.                     $this->entityManager->flush();
  330.                 }
  331.             }
  332.             $route $this->paymentChannelSelector->selectPaymentWithPolicy($order$payMethodnulltrue);
  333.             $payChannel $route['channel'];
  334.             $routingPolicy $route['policy'];
  335.             $order->setPayChannel($payChannel);
  336.             $order->setPaymentProvider($payChannel->getPaymentProvider());
  337.             $order->setClient($payMethod);
  338.             $order->setType($payType);
  339.             $order->setClientLocked(true);
  340.             $attempt $this->createAttempt($order$payChannel$routingPolicy$payMethod$payType$requestData);
  341.             $providerCode strtolower((string) $payChannel->getProviderCode());
  342.             $tradeService $this->paymentProviderRegistry->get($providerCode);
  343.             $payRequestOrder = clone $order;
  344.             $payRequestOrder->setPlatformOrderNo($this->providerOutTradeNo($order$attempt$payChannel$payMethod$payType));
  345.             $payRequestOrder->setNoticeUrl($this->buildNotifyUrl($order));
  346.             $payRequestOrder->setReturnUrl($this->buildReturnUrl($order));
  347.             $payRequestOrder->setMerchantParam($this->merchantParamWithCheckoutContext($order$payScene$request));
  348.             $payContent $tradeService->tradePay($payRequestOrder);
  349.             $attempt->setStatus(PaymentAttempt::STATUS_WAIT);
  350.             $attempt->setPayContent((string) $payContent);
  351.             $attempt->setRawResponse($this->rawPayResponse($payContent));
  352.             $order->setPayContent((string) $payContent);
  353.             $this->entityManager->flush();
  354.             $this->entityManager->commit();
  355.             return $attempt;
  356.         } catch (\Throwable $e) {
  357.             $this->entityManager->rollback();
  358.             throw $e;
  359.         }
  360.     }
  361.     private function providerOutTradeNo(PayOrder $orderPaymentAttempt $attemptPayChannel $payChannelstring $payMethodstring $payType): string
  362.     {
  363.         $providerCode strtolower((string) $payChannel->getProviderCode());
  364.         if ($providerCode === 'alipay' && $payMethod === PayOrder::CLIENT_ALIPAY && $payType === PayOrder::TYPE_WEB) {
  365.             return (string) $order->getPlatformOrderNo();
  366.         }
  367.         return (string) $attempt->getAttemptNo();
  368.     }
  369.     private function closePaymentAttemptBeforeReplacement(PayOrder $orderPaymentAttempt $attempt): void
  370.     {
  371.         $closeOrder = clone $order;
  372.         $closeOrder->setPayChannel($attempt->getPayChannel());
  373.         $closeOrder->setPaymentProvider($attempt->getPaymentProvider());
  374.         $closeOrder->setPlatformOrderNo($attempt->getAttemptNo());
  375.         $closeOrder->setChannelOrderNo($attempt->getChannelOrderNo());
  376.         $closeOrder->setClient($attempt->getPayMethod());
  377.         $closeOrder->setType($attempt->getPayType());
  378.         $service $this->paymentProviderRegistry->getByChannel($attempt->getPayChannel());
  379.         $closeResult $service->tradeClose($closeOrder);
  380.         $this->storeAttemptRawResponse($attempt'remoteClose'$closeResult);
  381.         if (!$this->isSuccessfulCloseResult($closeResult) && !$this->isIgnorableCloseResult($closeResult)) {
  382.             throw new \RuntimeException($this->closeResultMessage($closeResult));
  383.         }
  384.         $attempt->setStatus(PaymentAttempt::STATUS_CLOSED);
  385.         $attempt->setClosedAt(new \DateTime());
  386.         $attempt->setFailReason('Closed before replacing official Alipay PC payment attempt');
  387.     }
  388.     private function storeAttemptRawResponse(PaymentAttempt $attemptstring $key$response): void
  389.     {
  390.         $rawResponse $attempt->getRawResponse();
  391.         $rawResponse[$key] = [
  392.             'createdAt' => (new \DateTime())->format(\DateTime::ATOM),
  393.             'response' => $this->normalizeProviderResponse($response),
  394.         ];
  395.         $attempt->setRawResponse($rawResponse);
  396.     }
  397.     private function normalizeProviderResponse($response): array
  398.     {
  399.         if (is_array($response)) {
  400.             return $response;
  401.         }
  402.         $data json_decode(json_encode($responseJSON_UNESCAPED_UNICODE JSON_UNESCAPED_SLASHES), true);
  403.         if (is_array($data)) {
  404.             return $data;
  405.         }
  406.         if (is_object($response)) {
  407.             return ['class' => get_class($response)];
  408.         }
  409.         return ['value' => (string) $response];
  410.     }
  411.     private function isSuccessfulCloseResult($result): bool
  412.     {
  413.         return (string) ($result->code ?? '') === '10000';
  414.     }
  415.     private function isIgnorableCloseResult($result): bool
  416.     {
  417.         $subCode strtoupper((string) ($result->sub_code ?? ''));
  418.         $message strtoupper((string) ($result->sub_msg ?? $result->msg ?? ''));
  419.         return in_array($subCode, ['ACQ.TRADE_NOT_EXIST''ACQ.TRADE_HAS_CLOSE'], true)
  420.             || strpos($message'TRADE_NOT_EXIST') !== false
  421.             || strpos($message'TRADE HAS CLOSE') !== false
  422.             || strpos($message'TRADE_HAS_CLOSE') !== false;
  423.     }
  424.     private function closeResultMessage($result): string
  425.     {
  426.         $message trim((string) ($result->sub_msg ?? $result->msg ?? ''));
  427.         $subCode trim((string) ($result->sub_code ?? ''));
  428.         if ($subCode !== '' && $message !== '') {
  429.             return sprintf('Previous Alipay payment attempt cannot be closed: %s %s'$subCode$message);
  430.         }
  431.         if ($message !== '') {
  432.             return 'Previous Alipay payment attempt cannot be closed: ' $message;
  433.         }
  434.         return 'Previous Alipay payment attempt cannot be closed';
  435.     }
  436.     private function createAttempt(PayOrder $orderPayChannel $payChannel$routingPolicystring $payMethodstring $payType, array $requestData): PaymentAttempt
  437.     {
  438.         $attempt = new PaymentAttempt();
  439.         $attempt->setPayOrder($order);
  440.         $attempt->setMerchantUser($order->getMerchantUser());
  441.         $attempt->setProxyUser($order->getProxyUser());
  442.         $attempt->setPayChannel($payChannel);
  443.         $attempt->setPaymentProvider($payChannel->getPaymentProvider());
  444.         $attempt->setRoutingPolicy($routingPolicy);
  445.         $attempt->setPayMethod($payMethod);
  446.         $attempt->setPayType($payType);
  447.         $attempt->setAmount($order->getAmount());
  448.         $attempt->setStatus(PaymentAttempt::STATUS_CREATED);
  449.         $attempt->setRequestPayload($requestData);
  450.         $attempt->setAttemptNo($this->paymentOrderNoGenerator->paymentOrderNo($order));
  451.         $this->entityManager->persist($attempt);
  452.         $this->entityManager->flush();
  453.         return $attempt;
  454.     }
  455.     private function findActiveAttempt(PayOrder $order): ?PaymentAttempt
  456.     {
  457.         return $this->entityManager->getRepository(PaymentAttempt::class)
  458.             ->createQueryBuilder('attempt')
  459.             ->andWhere('attempt.payOrder = :order')
  460.             ->andWhere('attempt.status IN (:statuses)')
  461.             ->setParameter('order'$order)
  462.             ->setParameter('statuses', [
  463.                 PaymentAttempt::STATUS_CREATED,
  464.                 PaymentAttempt::STATUS_WAIT,
  465.             ])
  466.             ->orderBy('attempt.id''DESC')
  467.             ->setMaxResults(1)
  468.             ->getQuery()
  469.             ->getOneOrNullResult();
  470.     }
  471.     private function latestPaymentAttempt(PayOrder $order): ?PaymentAttempt
  472.     {
  473.         return $this->entityManager->getRepository(PaymentAttempt::class)->findOneBy([
  474.             'payOrder' => $order,
  475.         ], ['id' => 'DESC']);
  476.     }
  477.     private function normalizeProviderResult($result): array
  478.     {
  479.         if (is_array($result)) {
  480.             return $result;
  481.         }
  482.         if (is_object($result)) {
  483.             return json_decode(json_encode($result), true) ?: [];
  484.         }
  485.         return json_decode((string) $resulttrue) ?: [];
  486.     }
  487.     private function mappedProviderOrderStatus(array $data): string
  488.     {
  489.         $status strtoupper((string) ($data['trade_status'] ?? $data['trade_state'] ?? $data['status'] ?? ''));
  490.         if (in_array($status, ['SUCCESS''TRADE_SUCCESS''TRADE_FINISHED'], true)) {
  491.             return PayOrder::STATUS_SUCCESS;
  492.         }
  493.         if (in_array($status, ['CLOSED''TRADE_CLOSED''CANCEL''FAIL''FAILED''ERROR'], true)) {
  494.             return PayOrder::STATUS_CLOSE;
  495.         }
  496.         return PayOrder::STATUS_WAIT;
  497.     }
  498.     private function attemptStatus(string $orderStatus): string
  499.     {
  500.         if ($orderStatus === PayOrder::STATUS_SUCCESS) {
  501.             return PaymentAttempt::STATUS_SUCCESS;
  502.         }
  503.         if ($orderStatus === PayOrder::STATUS_CLOSE) {
  504.             return PaymentAttempt::STATUS_CLOSED;
  505.         }
  506.         return PaymentAttempt::STATUS_WAIT;
  507.     }
  508.     private function closeOtherPaymentAttempts(PayOrder $order, ?PaymentAttempt $paidAttempt): void
  509.     {
  510.         $qb $this->entityManager->getRepository(PaymentAttempt::class)->createQueryBuilder('attempt')
  511.             ->andWhere('attempt.payOrder = :order')
  512.             ->andWhere('attempt.status IN (:statuses)')
  513.             ->setParameter('order'$order)
  514.             ->setParameter('statuses', [
  515.                 PaymentAttempt::STATUS_CREATED,
  516.                 PaymentAttempt::STATUS_WAIT,
  517.             ]);
  518.         if ($paidAttempt && $paidAttempt->getId()) {
  519.             $qb->andWhere('attempt.id <> :paidAttemptId')
  520.                 ->setParameter('paidAttemptId'$paidAttempt->getId());
  521.         }
  522.         foreach ($qb->getQuery()->getResult() as $attempt) {
  523.             if (!$attempt instanceof PaymentAttempt) {
  524.                 continue;
  525.             }
  526.             $attempt->setStatus(PaymentAttempt::STATUS_CLOSED);
  527.             $attempt->setClosedAt(new \DateTime());
  528.             $attempt->setFailReason('Closed after another payment attempt succeeded');
  529.         }
  530.     }
  531.     private function isCurrentPayableAttempt(PayOrder $orderPaymentAttempt $attempt): bool
  532.     {
  533.         $activeAttempt $this->findActiveAttempt($order);
  534.         return $activeAttempt && $activeAttempt->getId() === $attempt->getId();
  535.     }
  536.     private function paymentAttemptResponse(PayOrder $orderPaymentAttempt $attempt): JsonResponse
  537.     {
  538.         return new JsonResponse($this->paymentAttemptPayload($order$attempt));
  539.     }
  540.     private function paymentAttemptPayload(PayOrder $orderPaymentAttempt $attempt): array
  541.     {
  542.         $payChannel $attempt->getPayChannel();
  543.         $providerCode $payChannel strtolower((string) $payChannel->getProviderCode()) : '';
  544.         $payMethod = (string) $attempt->getPayMethod();
  545.         $payType = (string) $attempt->getPayType();
  546.         $result $this->parsePayContent((string) $attempt->getPayContent());
  547.         if ($this->shouldRelayProviderForm($providerCode$payMethod$payType$result)) {
  548.             $result = [
  549.                 'type' => 'redirect',
  550.                 'data' => $this->buildProviderSubmitUrl($order$attempt),
  551.             ];
  552.         }
  553.         if ($this->shouldRedirectProviderQrUrl($providerCode$payMethod$payType$result)) {
  554.             $result = [
  555.                 'type' => 'redirect',
  556.                 'data' => $result['data'],
  557.             ];
  558.         }
  559.         return [
  560.             'code' => 1,
  561.             'msg' => 'success',
  562.             'payToken' => $order->getPayCode(),
  563.             'attemptNo' => $attempt->getAttemptNo(),
  564.             'payType' => $result['type'],
  565.             'payMethod' => $payMethod,
  566.             'providerCode' => $providerCode,
  567.             'payData' => $result['data'],
  568.             'payUrl' => is_string($result['data']) ? $result['data'] : null,
  569.             'reused' => false,
  570.         ];
  571.     }
  572.     private function shouldReplaceOfficialAlipayPcAttempt(PaymentAttempt $attemptstring $payMethodstring $payType): bool
  573.     {
  574.         return false;
  575.     }
  576.     private function isOfficialAlipayPcPagePayAttempt(?PaymentAttempt $attempt): bool
  577.     {
  578.         if (!$attempt) {
  579.             return false;
  580.         }
  581.         $payChannel $attempt->getPayChannel();
  582.         $providerCode $payChannel strtolower((string) $payChannel->getProviderCode()) : '';
  583.         if ($providerCode !== 'alipay'
  584.             || $attempt->getPayMethod() !== PayOrder::CLIENT_ALIPAY
  585.             || $attempt->getPayType() !== PayOrder::TYPE_WEB
  586.             || !$attempt->getPayContent()
  587.         ) {
  588.             return false;
  589.         }
  590.         $result $this->parsePayContent((string) $attempt->getPayContent());
  591.         if (($result['type'] ?? '') !== 'form') {
  592.             return false;
  593.         }
  594.         $fields $result['data']['fields'] ?? [];
  595.         $method strtolower((string) ($fields['method'] ?? ''));
  596.         if ($method === 'alipay.trade.page.pay') {
  597.             return true;
  598.         }
  599.         $bizContent json_decode((string) ($fields['biz_content'] ?? ''), true);
  600.         if (is_array($bizContent) && ($bizContent['product_code'] ?? '') === 'FAST_INSTANT_TRADE_PAY') {
  601.             return true;
  602.         }
  603.         return stripos((string) $attempt->getPayContent(), 'alipay.trade.page.pay') !== false;
  604.     }
  605.     private function markAttemptFailed(PaymentAttempt $attemptstring $reason): void
  606.     {
  607.         $attempt->setStatus(PaymentAttempt::STATUS_FAILED);
  608.         $attempt->setFailReason($reason);
  609.         $this->entityManager->flush();
  610.     }
  611.     private function resolvePayType(Request $request, array $requestDatastring $payMethod): string
  612.     {
  613.         $requestedPayType = (string) ($requestData['payType'] ?? '');
  614.         if (!$this->isMobileCheckout($request$requestData)) {
  615.             return PayOrder::TYPE_WEB;
  616.         }
  617.         if ($this->isUnionPayMethod($payMethod)) {
  618.             return PayOrder::TYPE_H5;
  619.         }
  620.         if ($payMethod === PayOrder::CLIENT_WECHART) {
  621.             return $this->isWechatBrowser($request$requestData) || $requestedPayType === PayOrder::TYPE_H5
  622.                 PayOrder::TYPE_H5
  623.                 PayOrder::TYPE_WEB;
  624.         }
  625.         if ($payMethod === PayOrder::CLIENT_ALIPAY) {
  626.             return $this->isAlipayBrowser($request$requestData) || $requestedPayType === PayOrder::TYPE_H5
  627.                 PayOrder::TYPE_H5
  628.                 PayOrder::TYPE_WEB;
  629.         }
  630.         $payType $requestedPayType;
  631.         if (in_array($payType, [PayOrder::TYPE_H5PayOrder::TYPE_WEB], true)) {
  632.             return $payType;
  633.         }
  634.         return PayOrder::TYPE_H5;
  635.     }
  636.     private function resolvePayScene(Request $request, array $requestDatastring $payMethod): string
  637.     {
  638.         $scene = (string) ($requestData['payScene'] ?? '');
  639.         if ($scene === 'wechat_official_account' && $payMethod === PayOrder::CLIENT_WECHART && $this->isWechatBrowser($request$requestData)) {
  640.             return $scene;
  641.         }
  642.         if ($scene === 'alipay_in_app' && $payMethod === PayOrder::CLIENT_ALIPAY && $this->isAlipayBrowser($request$requestData)) {
  643.             return $scene;
  644.         }
  645.         if ($scene === 'mobile_browser' && $this->isMobileCheckout($request$requestData)) {
  646.             return $scene;
  647.         }
  648.         if ($scene === 'pc_browser' && !$this->isMobileCheckout($request$requestData)) {
  649.             return $scene;
  650.         }
  651.         if ($payMethod === PayOrder::CLIENT_WECHART && $this->isWechatBrowser($request$requestData)) {
  652.             return 'wechat_official_account';
  653.         }
  654.         if ($payMethod === PayOrder::CLIENT_ALIPAY && $this->isAlipayBrowser($request$requestData)) {
  655.             return 'alipay_in_app';
  656.         }
  657.         return $this->isMobileCheckout($request$requestData) ? 'mobile_browser' 'pc_browser';
  658.     }
  659.     private function merchantParamWithCheckoutContext(PayOrder $orderstring $paySceneRequest $request): string
  660.     {
  661.         $data $this->merchantParamArray($order);
  662.         $data['_checkout_pay_scene'] = $payScene;
  663.         $data['_checkout_user_agent'] = substr((string) $request->headers->get('User-Agent'''), 0500);
  664.         return json_encode($dataJSON_UNESCAPED_UNICODE JSON_UNESCAPED_SLASHES) ?: (string) $order->getMerchantParam();
  665.     }
  666.     private function merchantParamArray(PayOrder $order): array
  667.     {
  668.         $raw = (string) $order->getMerchantParam();
  669.         if ($raw === '') {
  670.             return [];
  671.         }
  672.         $decoded json_decode($rawtrue);
  673.         if (is_array($decoded)) {
  674.             return $decoded;
  675.         }
  676.         parse_str($raw$parsed);
  677.         return is_array($parsed) ? $parsed : [];
  678.     }
  679.     private function isMobileCheckout(Request $request, array $requestData): bool
  680.     {
  681.         $clientEnv is_array($requestData['clientEnv'] ?? null) ? $requestData['clientEnv'] : [];
  682.         $userAgent = (string) ($clientEnv['userAgent'] ?? $request->headers->get('User-Agent'''));
  683.         $clientSaysMobile = ($clientEnv['isMobile'] ?? false) === true;
  684.         $touchPoints = (int) ($clientEnv['maxTouchPoints'] ?? 0);
  685.         $viewportWidth = (int) ($clientEnv['viewportWidth'] ?? 0);
  686.         if ((bool) preg_match('/Android|webOS|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini|Mobile/i'$userAgent)) {
  687.             return true;
  688.         }
  689.         if (stripos($userAgent'Macintosh') !== false && $touchPoints 1) {
  690.             return true;
  691.         }
  692.         $isKnownDesktop = (bool) preg_match('/Windows NT|X11|Linux x86_64|Macintosh/i'$userAgent);
  693.         $looksLikeMobileViewport $touchPoints || ($viewportWidth && $viewportWidth <= 900);
  694.         $looksLikeDesktopViewport $viewportWidth 900 && $touchPoints === 0;
  695.         if ($clientSaysMobile && $looksLikeMobileViewport) {
  696.             return true;
  697.         }
  698.         return $clientSaysMobile && !$isKnownDesktop && !$looksLikeDesktopViewport;
  699.     }
  700.     private function isWechatBrowser(Request $request, array $requestData): bool
  701.     {
  702.         $clientEnv is_array($requestData['clientEnv'] ?? null) ? $requestData['clientEnv'] : [];
  703.         $userAgent = (string) ($clientEnv['userAgent'] ?? $request->headers->get('User-Agent'''));
  704.         return stripos($userAgent'MicroMessenger') !== false
  705.             || stripos($userAgent'WeChat') !== false
  706.             || ($clientEnv['isWechat'] ?? false) === true
  707.             || ($clientEnv['hasWeixinBridge'] ?? false) === true;
  708.     }
  709.     private function isAlipayBrowser(Request $request, array $requestData): bool
  710.     {
  711.         $clientEnv is_array($requestData['clientEnv'] ?? null) ? $requestData['clientEnv'] : [];
  712.         $userAgent = (string) ($clientEnv['userAgent'] ?? $request->headers->get('User-Agent'''));
  713.         return stripos($userAgent'AlipayClient') !== false
  714.             || stripos($userAgent'AliApp(AP') !== false
  715.             || stripos($userAgent'NebulaSDK') !== false
  716.             || ($clientEnv['isAlipay'] ?? false) === true
  717.             || ($clientEnv['hasAlipayBridge'] ?? false) === true;
  718.     }
  719.     private function isUnionPayMethod(string $payMethod): bool
  720.     {
  721.         return in_array($payMethod, ['unionpay''union''union_h5'], true);
  722.     }
  723.     private function findOrder(string $payToken): ?PayOrder
  724.     {
  725.         return $this->entityManager->getRepository(PayOrder::class)->findOneBy([
  726.             'payCode' => strtoupper($payToken),
  727.         ]);
  728.     }
  729.     private function sessionPayload(PayOrder $order): array
  730.     {
  731.         $activeAttempt $order->getStatus() === PayOrder::STATUS_WAIT $this->findActiveAttempt($order) : null;
  732.         $activePayment $activeAttempt && $activeAttempt->getStatus() === PaymentAttempt::STATUS_WAIT && $activeAttempt->getPayContent()
  733.             ? $this->paymentAttemptPayload($order$activeAttempt)
  734.             : null;
  735.         $paymentMethods $activePayment ? [] : ($order->getStatus() === PayOrder::STATUS_WAIT $this->getAvailablePaymentMethods($order) : []);
  736.         return [
  737.             'code' => 1,
  738.             'msg' => 'success',
  739.             'payToken' => $order->getPayCode(),
  740.             'orderNo' => $order->getPlatformOrderNo(),
  741.             'merchantOrderNo' => $order->getMerchantOrderNo(),
  742.             'merchantName' => $order->getMerchantUser() ? $order->getMerchantUser()->getMerchantName() : '',
  743.             'productName' => $order->getOrderName() ?: '商品支付',
  744.             'amount' => $order->getAmount(),
  745.             'status' => $order->getStatus(),
  746.             'returnUrl' => $order->getReturnUrl(),
  747.             'channelCode' => $order->getPayChannel() ? strtolower((string) $order->getPayChannel()->getProviderCode()) : '',
  748.             'client' => $order->getClient(),
  749.             'clientLocked' => $order->isClientLocked() || (bool) $activePayment,
  750.             'paymentMethodLocked' => $order->isClientLocked() || (bool) $activePayment,
  751.             'paymentMethods' => $paymentMethods,
  752.             'activePayment' => $activePayment,
  753.         ];
  754.     }
  755.     private function filterPaymentMethodsByCode(array $paymentMethodsstring $code): array
  756.     {
  757.         return array_values(array_filter($paymentMethods, static function (array $method) use ($code): bool {
  758.             return ($method['code'] ?? '') === $code;
  759.         }));
  760.     }
  761.     private function getAvailablePaymentMethods(PayOrder $order): array
  762.     {
  763.         if ($order->getStatus() === PayOrder::STATUS_WAIT) {
  764.             $methods $this->routedPaymentMethods($order);
  765.             $lockedPayMethod $this->lockedPaymentMethod($order);
  766.             if ($lockedPayMethod === null) {
  767.                 return $methods;
  768.             }
  769.             return array_values(array_filter($methods, static function (array $method) use ($lockedPayMethod): bool {
  770.                 return ($method['code'] ?? '') === $lockedPayMethod;
  771.             }));
  772.         }
  773.         $payChannel $order->getPayChannel();
  774.         if (!$payChannel) {
  775.             return $this->routedPaymentMethods($order);
  776.         }
  777.         $providerCode strtolower((string) $payChannel->getProviderCode());
  778.         if (in_array($providerCode, ['alipay''wechart'], true)) {
  779.             return $this->singleChannelMethods($providerCode);
  780.         }
  781.         if (in_array($providerCode, ['easypay''yizhifubj'], true)) {
  782.             return $this->multiChannelMethods($providerCode$payChannel);
  783.         }
  784.         return [[
  785.             'code' => $providerCode,
  786.             'name' => $payChannel->getName() ?: strtoupper($providerCode),
  787.             'description' => '在线支付,按当前通道配置发起收款',
  788.         ]];
  789.     }
  790.     private function singleChannelMethods(string $providerCode): array
  791.     {
  792.         $configs = [
  793.             'alipay' => ['code' => 'alipay''name' => '支付宝支付''description' => '支持支付宝扫码支付和手机网页支付'],
  794.             'wechart' => ['code' => 'wechart''name' => '微信支付''description' => '支持微信扫码支付和公众号内支付'],
  795.         ];
  796.         return isset($configs[$providerCode]) ? [$configs[$providerCode]] : [];
  797.     }
  798.     private function multiChannelMethods(string $providerCodePayChannel $payChannel): array
  799.     {
  800.         $methods = [];
  801.         $allMethods = [
  802.             'alipay' => ['code' => 'alipay''name' => '支付宝支付''description' => '支持支付宝扫码支付;应用内支付需提供支付宝用户标识'],
  803.             'wechart' => ['code' => 'wechart''name' => '微信支付''description' => '支持微信扫码支付;公众号支付需提供微信 OpenID'],
  804.             'unionpay' => ['code' => 'unionpay''name' => '云闪付支付''description' => '支持银联云闪付手机网页支付'],
  805.         ];
  806.         if ($payChannel->getSupportAlipay()) {
  807.             $methods[] = $allMethods['alipay'];
  808.         }
  809.         if ($payChannel->getSupportWechart()) {
  810.             $methods[] = $allMethods['wechart'];
  811.         }
  812.         if ($providerCode === 'easypay') {
  813.             $methods[] = $allMethods['unionpay'];
  814.         }
  815.         return $methods;
  816.     }
  817.     private function allChannelMethods(): array
  818.     {
  819.         return $this->routedPaymentMethods(null);
  820.     }
  821.     private function routedPaymentMethods(?PayOrder $order): array
  822.     {
  823.         $routes $order $this->paymentChannelSelector->availablePaymentRoutes($order) : [];
  824.         $allMethods = [
  825.             'alipay' => ['code' => 'alipay''name' => '支付宝支付''description' => '支持支付宝扫码支付和手机网页支付'],
  826.             'wechart' => ['code' => 'wechart''name' => '微信支付''description' => '支持微信扫码支付和公众号内支付'],
  827.             'union' => ['code' => 'union''name' => '银联支付''description' => '支持银联在线支付'],
  828.             'douyin' => ['code' => 'douyin''name' => '抖音支付''description' => '支持抖音钱包支付'],
  829.             'jd' => ['code' => 'jd''name' => '京东支付''description' => '支持京东钱包支付'],
  830.             'unionpay' => ['code' => 'unionpay''name' => '云闪付支付''description' => '支持银联云闪付手机网页支付'],
  831.         ];
  832.         $methods = [];
  833.         foreach (array_keys($allMethods) as $code) {
  834.             if (!empty($routes[$code])) {
  835.                 $policy $routes[$code]['policy'];
  836.                 $methods[] = array_merge($allMethods[$code], [
  837.                     'policySource' => $policy->getScopeType(),
  838.                     'strategy' => $policy->getStrategy(),
  839.                 ]);
  840.             }
  841.         }
  842.         return $methods;
  843.     }
  844.     private function availablePaymentMethodCodes(PayOrder $order): array
  845.     {
  846.         return array_map(static fn(array $method) => (string) ($method['code'] ?? ''), $this->getAvailablePaymentMethods($order));
  847.     }
  848.     private function lockedPaymentMethod(PayOrder $order): ?string
  849.     {
  850.         if (!$order->isClientLocked()) {
  851.             return null;
  852.         }
  853.         $client strtolower(trim((string) $order->getClient()));
  854.         return $client === '' null $client;
  855.     }
  856.     private function buildNotifyUrl(PayOrder $order): string
  857.     {
  858.         $channel $order->getPayChannel();
  859.         if ($channel) {
  860.             return $this->absolutePublicUrl('/api/payment_channel_notifications/' strtolower((string) $channel->getProviderCode()));
  861.         }
  862.         return $this->absolutePublicUrl('/api/payment_channel_notifications/default');
  863.     }
  864.     private function buildReturnUrl(PayOrder $order): string
  865.     {
  866.         return $this->absolutePublicUrl('/api/pay_sessions/' rawurlencode((string) $order->getPayCode()) . '/return');
  867.     }
  868.     private function buildProviderSubmitUrl(PayOrder $orderPaymentAttempt $attempt): string
  869.     {
  870.         return $this->absolutePublicUrl(sprintf(
  871.             '/api/pay_sessions/%s/provider_submit/%s',
  872.             rawurlencode((string) $order->getPayCode()),
  873.             rawurlencode((string) $attempt->getAttemptNo())
  874.         ));
  875.     }
  876.     private function shouldRelayProviderForm(string $providerCodestring $payMethodstring $payType, array $result): bool
  877.     {
  878.         return $result['type'] === 'form';
  879.     }
  880.     private function shouldRedirectProviderQrUrl(string $providerCodestring $payMethodstring $payType, array $result): bool
  881.     {
  882.         $url $result['data'] ?? '';
  883.         return $result['type'] === 'qr_code'
  884.             && $providerCode === 'easypay'
  885.             && in_array($payMethod, [PayOrder::CLIENT_ALIPAYPayOrder::CLIENT_WECHART], true)
  886.             && $payType === PayOrder::TYPE_H5
  887.             && is_string($url)
  888.             && (filter_var($urlFILTER_VALIDATE_URL) || strpos($url'weixin://') === 0);
  889.     }
  890.     private function absolutePublicUrl(string $path): string
  891.     {
  892.         return $this->publicBaseUrl !== '' $this->publicBaseUrl $path $path;
  893.     }
  894.     private function absoluteProviderUrl(string $locationstring $baseUrl): string
  895.     {
  896.         if (filter_var($locationFILTER_VALIDATE_URL)) {
  897.             return $location;
  898.         }
  899.         $parts parse_url($baseUrl);
  900.         $scheme $parts['scheme'] ?? 'https';
  901.         $host $parts['host'] ?? '';
  902.         if ($host === '') {
  903.             return $location;
  904.         }
  905.         $port = isset($parts['port']) ? ':' $parts['port'] : '';
  906.         if (strpos($location'//') === 0) {
  907.             return $scheme ':' $location;
  908.         }
  909.         if (strpos($location'/') === 0) {
  910.             return $scheme '://' $host $port $location;
  911.         }
  912.         $path $parts['path'] ?? '/';
  913.         $directory rtrim(str_replace('\\''/'dirname($path)), '/');
  914.         return $scheme '://' $host $port . ($directory $directory '/' '/') . $location;
  915.     }
  916.     private function providerSubmitError(string $messageint $statusCode): Response
  917.     {
  918.         $safeMessage htmlspecialchars($messageENT_QUOTES'UTF-8');
  919.         return new Response(
  920.             '<!doctype html><html><head><meta charset="utf-8"><title>Payment unavailable</title></head><body style="font-family:Arial,sans-serif;padding:32px;color:#303133;"><h3>Payment unavailable</h3><p>' $safeMessage '</p><p>Please return to the merchant and try again.</p></body></html>',
  921.             $statusCode,
  922.             ['Content-Type' => 'text/html; charset=UTF-8']
  923.         );
  924.     }
  925.     private function parsePayContent($payContent): array
  926.     {
  927.         $content = (string) $payContent;
  928.         if (filter_var($contentFILTER_VALIDATE_URL)) {
  929.             return ['type' => 'redirect''data' => $content];
  930.         }
  931.         if (strpos($content'<form') !== false) {
  932.             return ['type' => 'form''data' => $this->parseFormContent($content)];
  933.         }
  934.         if (strpos($content'data:image') !== false || strpos($content'base64,') !== false) {
  935.             return ['type' => 'qr_code''data' => $content];
  936.         }
  937.         $data json_decode($contenttrue);
  938.         if (is_array($data)) {
  939.             return $this->parseArrayPayContent($data$content);
  940.         }
  941.         if (is_array($payContent)) {
  942.             return $this->parseArrayPayContent($payContent$content);
  943.         }
  944.         return ['type' => 'redirect''data' => $content];
  945.     }
  946.     private function rawPayResponse($payContent): array
  947.     {
  948.         if (is_array($payContent)) {
  949.             return $payContent;
  950.         }
  951.         $decoded json_decode((string) $payContenttrue);
  952.         return is_array($decoded) ? $decoded : ['content' => (string) $payContent];
  953.     }
  954.     private function parseArrayPayContent(array $datastring $fallback): array
  955.     {
  956.         if (!empty($data['js_pay']) || !empty($data['pay_info']) || !empty($data['jsapi_params'])) {
  957.             return [
  958.                 'type' => 'js_pay',
  959.                 'data' => [
  960.                     'payType' => $data['pay_type'] ?? '',
  961.                     'payInfo' => $data['pay_info'] ?? $data['jsapi_params'] ?? [],
  962.                 ],
  963.             ];
  964.         }
  965.         if (!empty($data['qr_code']) || !empty($data['qrcode'])) {
  966.             return ['type' => 'qr_code''data' => $data['qr_code'] ?? $data['qrcode']];
  967.         }
  968.         if (!empty($data['url']) || !empty($data['payUrl']) || !empty($data['redirectUrl'])) {
  969.             $url $data['url'] ?? $data['payUrl'] ?? $data['redirectUrl'];
  970.             if (filter_var($urlFILTER_VALIDATE_URL)) {
  971.                 return ['type' => 'redirect''data' => $url];
  972.             }
  973.         }
  974.         return ['type' => 'redirect''data' => $fallback];
  975.     }
  976.     private function parseFormContent(string $content): array
  977.     {
  978.         $dom = new \DOMDocument('1.0''UTF-8');
  979.         $previousUseInternalErrors libxml_use_internal_errors(true);
  980.         try {
  981.             $dom->loadHTML('<?xml encoding="UTF-8"><meta http-equiv="Content-Type" content="text/html; charset=utf-8">' $content);
  982.         } finally {
  983.             libxml_clear_errors();
  984.             libxml_use_internal_errors($previousUseInternalErrors);
  985.         }
  986.         $form $dom->getElementsByTagName('form')->item(0);
  987.         if (!$form) {
  988.             return [];
  989.         }
  990.         $fields = [];
  991.         foreach ($dom->getElementsByTagName('input') as $input) {
  992.             $name $input->getAttribute('name');
  993.             if ($name) {
  994.                 $fields[$name] = $input->getAttribute('value');
  995.             }
  996.         }
  997.         return [
  998.             'action' => $form->getAttribute('action'),
  999.             'method' => strtoupper($form->getAttribute('method')) ?: 'POST',
  1000.             'fields' => $fields,
  1001.         ];
  1002.     }
  1003. }