<?php
namespace App\Controller;
use App\Entity\PayChannel;
use App\Entity\PaymentAttempt;
use App\Entity\PayOrder;
use App\Service\MerchantCompat\MerchantCompatService;
use App\Service\Payment\AccountLedgerService;
use App\Service\Payment\PaymentChannelSelector;
use App\Service\Payment\PaymentOrderNoGenerator;
use App\Service\Payment\PaymentProviderRegistry;
use Doctrine\DBAL\LockMode;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\RedirectResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Contracts\HttpClient\HttpClientInterface;
class PaySessionController extends AbstractController
{
private EntityManagerInterface $entityManager;
private PaymentProviderRegistry $paymentProviderRegistry;
private PaymentChannelSelector $paymentChannelSelector;
private PaymentOrderNoGenerator $paymentOrderNoGenerator;
private MerchantCompatService $merchantCompatService;
private AccountLedgerService $accountLedgerService;
private HttpClientInterface $httpClient;
private string $publicBaseUrl;
public function __construct(
EntityManagerInterface $entityManager,
PaymentProviderRegistry $paymentProviderRegistry,
PaymentChannelSelector $paymentChannelSelector,
PaymentOrderNoGenerator $paymentOrderNoGenerator,
MerchantCompatService $merchantCompatService,
AccountLedgerService $accountLedgerService,
HttpClientInterface $httpClient,
string $publicBaseUrl
) {
$this->entityManager = $entityManager;
$this->paymentProviderRegistry = $paymentProviderRegistry;
$this->paymentChannelSelector = $paymentChannelSelector;
$this->paymentOrderNoGenerator = $paymentOrderNoGenerator;
$this->merchantCompatService = $merchantCompatService;
$this->accountLedgerService = $accountLedgerService;
$this->httpClient = $httpClient;
$this->publicBaseUrl = rtrim($publicBaseUrl, '/');
}
public function __invoke(Request $request): JsonResponse
{
$payToken = (string) $request->attributes->get('payToken', '');
$order = $this->findOrder($payToken);
if (!$order) {
return new JsonResponse(['code' => 0, 'msg' => 'Payment session not found'], 404);
}
if ($request->isMethod('POST')) {
return $this->pay($request, $order);
}
return new JsonResponse($this->sessionPayload($order));
}
private function pay(Request $request, PayOrder $order): JsonResponse
{
if ($order->getStatus() !== PayOrder::STATUS_WAIT) {
return new JsonResponse(['code' => 0, 'msg' => 'Order is not payable'], 400);
}
if (!$order->getMerchantUser()) {
return new JsonResponse(['code' => 0, 'msg' => '支付订单必须关联商户'], 400);
}
$decodedRequestData = json_decode($request->getContent(), true);
$requestData = is_array($decodedRequestData) ? $decodedRequestData : [];
$requestedPayMethod = strtolower(trim((string) ($requestData['payMethod'] ?? $requestData['paymentMethod'] ?? '')));
$lockedPayMethod = $this->lockedPaymentMethod($order);
if ($lockedPayMethod !== null && $requestedPayMethod !== '' && $requestedPayMethod !== $lockedPayMethod) {
return new JsonResponse(['code' => 0, 'msg' => 'Payment method is locked for this order'], 422);
}
$payMethod = $lockedPayMethod ?: $requestedPayMethod;
if ($payMethod === '') {
return new JsonResponse(['code' => 0, 'msg' => 'Payment method is required'], 422);
}
$payType = $this->resolvePayType($request, $requestData, $payMethod);
$payScene = $this->resolvePayScene($request, $requestData, $payMethod);
if (!in_array($payMethod, $this->availablePaymentMethodCodes($order), true)) {
return new JsonResponse(['code' => 0, 'msg' => 'Payment method is not enabled'], 422);
}
try {
$attempt = $this->getOrCreatePaymentAttemptWithProviderContent($request, $order, $payMethod, $payType, $payScene, $requestData);
return $this->paymentAttemptResponse($order, $attempt);
} catch (\InvalidArgumentException $e) {
isset($attempt) && $this->markAttemptFailed($attempt, $e->getMessage());
return new JsonResponse(['code' => 0, 'msg' => $e->getMessage()], 422);
} catch (\RuntimeException $e) {
isset($attempt) && $this->markAttemptFailed($attempt, $e->getMessage());
return new JsonResponse(['code' => 0, 'msg' => $e->getMessage()], 422);
} catch (\Throwable $e) {
isset($attempt) && $this->markAttemptFailed($attempt, $e->getMessage());
return new JsonResponse(['code' => 0, 'msg' => $e->getMessage()], 500);
}
}
public function providerSubmit(Request $request, string $payToken, string $attemptNo): Response
{
$order = $this->findOrder($payToken);
if (!$order) {
return $this->providerSubmitError('Payment session not found', 404);
}
/** @var PaymentAttempt|null $attempt */
$attempt = $this->entityManager->getRepository(PaymentAttempt::class)->findOneBy([
'attemptNo' => $attemptNo,
]);
if (!$attempt || !$attempt->getPayOrder() || $attempt->getPayOrder()->getId() !== $order->getId()) {
return $this->providerSubmitError('Payment attempt not found', 404);
}
if ($order->getStatus() !== PayOrder::STATUS_WAIT || $attempt->getStatus() !== PaymentAttempt::STATUS_WAIT) {
return $this->providerSubmitError('Order is not payable', 400);
}
if (!$this->isCurrentPayableAttempt($order, $attempt)) {
return $this->providerSubmitError('Payment attempt has been superseded', 409);
}
$form = $this->parseFormContent((string) $attempt->getPayContent());
$action = (string) ($form['action'] ?? '');
$fields = $form['fields'] ?? [];
if (!filter_var($action, FILTER_VALIDATE_URL) || !$fields) {
return $this->providerSubmitError('支付通道提交参数无效', 422);
}
try {
return $this->submitProviderFormOnce($order, $attempt);
} catch (\Throwable $e) {
return $this->providerSubmitError('支付通道提交失败,请返回商户重新发起支付', 502);
}
}
public function providerReturn(Request $request, string $payToken): Response
{
$order = $this->findOrder($payToken);
if (!$order) {
return new RedirectResponse($this->absolutePublicUrl('/pay_session/' . rawurlencode($payToken)), 302);
}
$this->syncOrderStatusFromProvider($order);
$this->entityManager->flush();
if ($order->getStatus() !== PayOrder::STATUS_SUCCESS) {
return new RedirectResponse($this->absolutePublicUrl('/pay_session/' . rawurlencode((string) $order->getPayCode())), 302);
}
return $this->merchantReturnRedirect($order);
}
private function syncOrderStatusFromProvider(PayOrder $order): void
{
if ($order->getStatus() !== PayOrder::STATUS_WAIT) {
return;
}
$attempt = $this->latestPaymentAttempt($order);
$payChannel = $attempt ? $attempt->getPayChannel() : $order->getPayChannel();
if (!$payChannel) {
return;
}
$queryOrder = clone $order;
if ($attempt) {
$queryOrder->setPayChannel($attempt->getPayChannel());
$queryOrder->setPaymentProvider($attempt->getPaymentProvider());
$queryOrder->setClient($attempt->getPayMethod());
$queryOrder->setType($attempt->getPayType());
if (!$this->isOfficialAlipayPcPagePayAttempt($attempt)) {
$queryOrder->setPlatformOrderNo($attempt->getAttemptNo());
}
}
try {
$tradeService = $this->paymentProviderRegistry->getByChannel($payChannel);
$result = $tradeService->tradeQuery($queryOrder);
} catch (\Throwable $e) {
return;
}
$data = $this->normalizeProviderResult($result);
if (!$data) {
return;
}
$channelOrderNo = $data['trade_no'] ?? $data['transaction_id'] ?? $data['order_no'] ?? $data['pay_no'] ?? $data['serialNumber'] ?? null;
if ($channelOrderNo) {
$order->setChannelOrderNo((string) $channelOrderNo);
$attempt && $attempt->setChannelOrderNo((string) $channelOrderNo);
}
$status = method_exists($tradeService, 'mapOrderStatus')
? $tradeService->mapOrderStatus($data['trade_status'] ?? $data['trade_state'] ?? $data['status'] ?? '')
: $this->mappedProviderOrderStatus($data);
if ($status) {
$order->setStatus($status);
$attempt && $attempt->setStatus($this->attemptStatus($status));
}
if ($attempt) {
$order->setPayChannel($attempt->getPayChannel());
$order->setPaymentProvider($attempt->getPaymentProvider());
$order->setClient($attempt->getPayMethod());
$order->setType($attempt->getPayType());
}
if ($order->getStatus() === PayOrder::STATUS_SUCCESS) {
if (!$order->getPaidAt()) {
$order->setPaidAt(new \DateTime());
}
if ($attempt && !$attempt->getPaidAt()) {
$attempt->setPaidAt(new \DateTime());
}
$order->setPaidAmount($order->getPaidAmount() ?: $order->getAmount());
$this->closeOtherPaymentAttempts($order, $attempt);
$this->accountLedgerService->recordPaymentSuccess($order);
}
}
private function merchantReturnRedirect(PayOrder $order): Response
{
$returnUrl = $order->getReturnUrl();
if (!$returnUrl) {
return new RedirectResponse($this->absolutePublicUrl('/pay_session/' . rawurlencode((string) $order->getPayCode())), 302);
}
$payload = $this->merchantCompatService->buildPaymentReturnPayload($order);
$queryString = http_build_query($payload);
return new RedirectResponse($returnUrl . (strpos($returnUrl, '?') === false ? '?' : '&') . $queryString, 302);
}
private function submitProviderFormOnce(PayOrder $order, PaymentAttempt $attempt): Response
{
$this->entityManager->beginTransaction();
try {
$this->entityManager->lock($attempt, LockMode::PESSIMISTIC_WRITE);
$this->entityManager->lock($order, LockMode::PESSIMISTIC_WRITE);
if ($order->getStatus() !== PayOrder::STATUS_WAIT || $attempt->getStatus() !== PaymentAttempt::STATUS_WAIT) {
$this->entityManager->commit();
return $this->providerSubmitError('Order is not payable', 400);
}
$cachedResponse = $this->cachedProviderSubmitResponse($attempt);
if ($cachedResponse) {
$this->entityManager->commit();
return $cachedResponse;
}
$form = $this->parseFormContent((string) $attempt->getPayContent());
$action = (string) ($form['action'] ?? '');
$fields = $form['fields'] ?? [];
if (!filter_var($action, FILTER_VALIDATE_URL) || !$fields) {
$this->entityManager->commit();
return $this->providerSubmitError('Payment provider submit parameters are invalid', 422);
}
$response = $this->httpClient->request('POST', $action, [
'body' => http_build_query($fields),
'headers' => ['Content-Type' => 'application/x-www-form-urlencoded;charset=UTF-8'],
'max_redirects' => 0,
'verify_peer' => false,
'verify_host' => false,
]);
$statusCode = $response->getStatusCode();
$headers = $response->getHeaders(false);
$location = $headers['location'][0] ?? null;
if ($statusCode >= 300 && $statusCode < 400 && $location) {
$redirectUrl = $this->absoluteProviderUrl($location, $action);
$this->storeProviderSubmitResponse($attempt, [
'type' => 'redirect',
'url' => $redirectUrl,
'statusCode' => $statusCode,
]);
$this->entityManager->flush();
$this->entityManager->commit();
return new RedirectResponse($redirectUrl, 302);
}
$contentType = $headers['content-type'][0] ?? 'text/html; charset=UTF-8';
$content = $response->getContent(false);
$this->storeProviderSubmitResponse($attempt, [
'type' => 'response',
'statusCode' => $statusCode,
'contentType' => $contentType,
'body' => $content,
]);
$this->entityManager->flush();
$this->entityManager->commit();
return new Response($content, $statusCode, ['Content-Type' => $contentType]);
} catch (\Throwable $e) {
$this->entityManager->rollback();
throw $e;
}
}
private function cachedProviderSubmitResponse(PaymentAttempt $attempt): ?Response
{
if ($this->isOfficialAlipayPcPagePayAttempt($attempt)) {
return null;
}
$cached = $attempt->getRawResponse()['providerSubmit'] ?? null;
if (!is_array($cached)) {
return null;
}
if (($cached['type'] ?? '') === 'redirect' && filter_var($cached['url'] ?? '', FILTER_VALIDATE_URL)) {
if (!$this->isReusableProviderSubmitRedirect($attempt, (string) $cached['url'])) {
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);
}
return new RedirectResponse((string) $cached['url'], 302);
}
if (($cached['type'] ?? '') === 'response' && array_key_exists('body', $cached)) {
return new Response(
(string) $cached['body'],
(int) ($cached['statusCode'] ?? 200),
['Content-Type' => (string) ($cached['contentType'] ?? 'text/html; charset=UTF-8')]
);
}
return null;
}
private function isReusableProviderSubmitRedirect(PaymentAttempt $attempt, string $url): bool
{
if ($attempt->getPayType() === PayOrder::TYPE_H5) {
return true;
}
$parts = parse_url($url);
$host = strtolower((string) ($parts['host'] ?? ''));
$path = (string) ($parts['path'] ?? '');
return !(substr($host, -10) === 'alipay.com' && strpos($path, '/appAssign.htm') !== false);
}
private function storeProviderSubmitResponse(PaymentAttempt $attempt, array $response): void
{
$rawResponse = $attempt->getRawResponse();
$response['createdAt'] = (new \DateTime())->format(\DateTime::ATOM);
$rawResponse['providerSubmit'] = $response;
$attempt->setRawResponse($rawResponse);
}
private function getOrCreatePaymentAttemptWithProviderContent(Request $request, PayOrder $order, string $payMethod, string $payType, string $payScene, array $requestData): PaymentAttempt
{
$this->entityManager->beginTransaction();
try {
$this->entityManager->lock($order, LockMode::PESSIMISTIC_WRITE);
$activeAttempt = $this->findActiveAttempt($order);
if ($activeAttempt) {
if ($activeAttempt->getStatus() === PaymentAttempt::STATUS_WAIT && $activeAttempt->getPayContent()) {
if ($activeAttempt->getPayMethod() !== $payMethod) {
throw new \RuntimeException('Payment method is locked for this order');
}
$this->entityManager->commit();
return $activeAttempt;
}
if ($activeAttempt->getStatus() !== PaymentAttempt::STATUS_CLOSED) {
$activeAttempt->setStatus(PaymentAttempt::STATUS_FAILED);
$activeAttempt->setFailReason('Incomplete checkout payment attempt was replaced');
$this->entityManager->flush();
}
}
$route = $this->paymentChannelSelector->selectPaymentWithPolicy($order, $payMethod, null, true);
$payChannel = $route['channel'];
$routingPolicy = $route['policy'];
$order->setPayChannel($payChannel);
$order->setPaymentProvider($payChannel->getPaymentProvider());
$order->setClient($payMethod);
$order->setType($payType);
$order->setClientLocked(true);
$attempt = $this->createAttempt($order, $payChannel, $routingPolicy, $payMethod, $payType, $requestData);
$providerCode = strtolower((string) $payChannel->getProviderCode());
$tradeService = $this->paymentProviderRegistry->get($providerCode);
$payRequestOrder = clone $order;
$payRequestOrder->setPlatformOrderNo($this->providerOutTradeNo($order, $attempt, $payChannel, $payMethod, $payType));
$payRequestOrder->setNoticeUrl($this->buildNotifyUrl($order));
$payRequestOrder->setReturnUrl($this->buildReturnUrl($order));
$payRequestOrder->setMerchantParam($this->merchantParamWithCheckoutContext($order, $payScene, $request));
$payContent = $tradeService->tradePay($payRequestOrder);
$attempt->setStatus(PaymentAttempt::STATUS_WAIT);
$attempt->setPayContent((string) $payContent);
$attempt->setRawResponse($this->rawPayResponse($payContent));
$order->setPayContent((string) $payContent);
$this->entityManager->flush();
$this->entityManager->commit();
return $attempt;
} catch (\Throwable $e) {
$this->entityManager->rollback();
throw $e;
}
}
private function providerOutTradeNo(PayOrder $order, PaymentAttempt $attempt, PayChannel $payChannel, string $payMethod, string $payType): string
{
$providerCode = strtolower((string) $payChannel->getProviderCode());
if ($providerCode === 'alipay' && $payMethod === PayOrder::CLIENT_ALIPAY && $payType === PayOrder::TYPE_WEB) {
return (string) $order->getPlatformOrderNo();
}
return (string) $attempt->getAttemptNo();
}
private function closePaymentAttemptBeforeReplacement(PayOrder $order, PaymentAttempt $attempt): void
{
$closeOrder = clone $order;
$closeOrder->setPayChannel($attempt->getPayChannel());
$closeOrder->setPaymentProvider($attempt->getPaymentProvider());
$closeOrder->setPlatformOrderNo($attempt->getAttemptNo());
$closeOrder->setChannelOrderNo($attempt->getChannelOrderNo());
$closeOrder->setClient($attempt->getPayMethod());
$closeOrder->setType($attempt->getPayType());
$service = $this->paymentProviderRegistry->getByChannel($attempt->getPayChannel());
$closeResult = $service->tradeClose($closeOrder);
$this->storeAttemptRawResponse($attempt, 'remoteClose', $closeResult);
if (!$this->isSuccessfulCloseResult($closeResult) && !$this->isIgnorableCloseResult($closeResult)) {
throw new \RuntimeException($this->closeResultMessage($closeResult));
}
$attempt->setStatus(PaymentAttempt::STATUS_CLOSED);
$attempt->setClosedAt(new \DateTime());
$attempt->setFailReason('Closed before replacing official Alipay PC payment attempt');
}
private function storeAttemptRawResponse(PaymentAttempt $attempt, string $key, $response): void
{
$rawResponse = $attempt->getRawResponse();
$rawResponse[$key] = [
'createdAt' => (new \DateTime())->format(\DateTime::ATOM),
'response' => $this->normalizeProviderResponse($response),
];
$attempt->setRawResponse($rawResponse);
}
private function normalizeProviderResponse($response): array
{
if (is_array($response)) {
return $response;
}
$data = json_decode(json_encode($response, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES), true);
if (is_array($data)) {
return $data;
}
if (is_object($response)) {
return ['class' => get_class($response)];
}
return ['value' => (string) $response];
}
private function isSuccessfulCloseResult($result): bool
{
return (string) ($result->code ?? '') === '10000';
}
private function isIgnorableCloseResult($result): bool
{
$subCode = strtoupper((string) ($result->sub_code ?? ''));
$message = strtoupper((string) ($result->sub_msg ?? $result->msg ?? ''));
return in_array($subCode, ['ACQ.TRADE_NOT_EXIST', 'ACQ.TRADE_HAS_CLOSE'], true)
|| strpos($message, 'TRADE_NOT_EXIST') !== false
|| strpos($message, 'TRADE HAS CLOSE') !== false
|| strpos($message, 'TRADE_HAS_CLOSE') !== false;
}
private function closeResultMessage($result): string
{
$message = trim((string) ($result->sub_msg ?? $result->msg ?? ''));
$subCode = trim((string) ($result->sub_code ?? ''));
if ($subCode !== '' && $message !== '') {
return sprintf('Previous Alipay payment attempt cannot be closed: %s %s', $subCode, $message);
}
if ($message !== '') {
return 'Previous Alipay payment attempt cannot be closed: ' . $message;
}
return 'Previous Alipay payment attempt cannot be closed';
}
private function createAttempt(PayOrder $order, PayChannel $payChannel, $routingPolicy, string $payMethod, string $payType, array $requestData): PaymentAttempt
{
$attempt = new PaymentAttempt();
$attempt->setPayOrder($order);
$attempt->setMerchantUser($order->getMerchantUser());
$attempt->setProxyUser($order->getProxyUser());
$attempt->setPayChannel($payChannel);
$attempt->setPaymentProvider($payChannel->getPaymentProvider());
$attempt->setRoutingPolicy($routingPolicy);
$attempt->setPayMethod($payMethod);
$attempt->setPayType($payType);
$attempt->setAmount($order->getAmount());
$attempt->setStatus(PaymentAttempt::STATUS_CREATED);
$attempt->setRequestPayload($requestData);
$attempt->setAttemptNo($this->paymentOrderNoGenerator->paymentOrderNo($order));
$this->entityManager->persist($attempt);
$this->entityManager->flush();
return $attempt;
}
private function findActiveAttempt(PayOrder $order): ?PaymentAttempt
{
return $this->entityManager->getRepository(PaymentAttempt::class)
->createQueryBuilder('attempt')
->andWhere('attempt.payOrder = :order')
->andWhere('attempt.status IN (:statuses)')
->setParameter('order', $order)
->setParameter('statuses', [
PaymentAttempt::STATUS_CREATED,
PaymentAttempt::STATUS_WAIT,
])
->orderBy('attempt.id', 'DESC')
->setMaxResults(1)
->getQuery()
->getOneOrNullResult();
}
private function latestPaymentAttempt(PayOrder $order): ?PaymentAttempt
{
return $this->entityManager->getRepository(PaymentAttempt::class)->findOneBy([
'payOrder' => $order,
], ['id' => 'DESC']);
}
private function normalizeProviderResult($result): array
{
if (is_array($result)) {
return $result;
}
if (is_object($result)) {
return json_decode(json_encode($result), true) ?: [];
}
return json_decode((string) $result, true) ?: [];
}
private function mappedProviderOrderStatus(array $data): string
{
$status = strtoupper((string) ($data['trade_status'] ?? $data['trade_state'] ?? $data['status'] ?? ''));
if (in_array($status, ['SUCCESS', 'TRADE_SUCCESS', 'TRADE_FINISHED'], true)) {
return PayOrder::STATUS_SUCCESS;
}
if (in_array($status, ['CLOSED', 'TRADE_CLOSED', 'CANCEL', 'FAIL', 'FAILED', 'ERROR'], true)) {
return PayOrder::STATUS_CLOSE;
}
return PayOrder::STATUS_WAIT;
}
private function attemptStatus(string $orderStatus): string
{
if ($orderStatus === PayOrder::STATUS_SUCCESS) {
return PaymentAttempt::STATUS_SUCCESS;
}
if ($orderStatus === PayOrder::STATUS_CLOSE) {
return PaymentAttempt::STATUS_CLOSED;
}
return PaymentAttempt::STATUS_WAIT;
}
private function closeOtherPaymentAttempts(PayOrder $order, ?PaymentAttempt $paidAttempt): void
{
$qb = $this->entityManager->getRepository(PaymentAttempt::class)->createQueryBuilder('attempt')
->andWhere('attempt.payOrder = :order')
->andWhere('attempt.status IN (:statuses)')
->setParameter('order', $order)
->setParameter('statuses', [
PaymentAttempt::STATUS_CREATED,
PaymentAttempt::STATUS_WAIT,
]);
if ($paidAttempt && $paidAttempt->getId()) {
$qb->andWhere('attempt.id <> :paidAttemptId')
->setParameter('paidAttemptId', $paidAttempt->getId());
}
foreach ($qb->getQuery()->getResult() as $attempt) {
if (!$attempt instanceof PaymentAttempt) {
continue;
}
$attempt->setStatus(PaymentAttempt::STATUS_CLOSED);
$attempt->setClosedAt(new \DateTime());
$attempt->setFailReason('Closed after another payment attempt succeeded');
}
}
private function isCurrentPayableAttempt(PayOrder $order, PaymentAttempt $attempt): bool
{
$activeAttempt = $this->findActiveAttempt($order);
return $activeAttempt && $activeAttempt->getId() === $attempt->getId();
}
private function paymentAttemptResponse(PayOrder $order, PaymentAttempt $attempt): JsonResponse
{
return new JsonResponse($this->paymentAttemptPayload($order, $attempt));
}
private function paymentAttemptPayload(PayOrder $order, PaymentAttempt $attempt): array
{
$payChannel = $attempt->getPayChannel();
$providerCode = $payChannel ? strtolower((string) $payChannel->getProviderCode()) : '';
$payMethod = (string) $attempt->getPayMethod();
$payType = (string) $attempt->getPayType();
$result = $this->parsePayContent((string) $attempt->getPayContent());
if ($this->shouldRelayProviderForm($providerCode, $payMethod, $payType, $result)) {
$result = [
'type' => 'redirect',
'data' => $this->buildProviderSubmitUrl($order, $attempt),
];
}
if ($this->shouldRedirectProviderQrUrl($providerCode, $payMethod, $payType, $result)) {
$result = [
'type' => 'redirect',
'data' => $result['data'],
];
}
return [
'code' => 1,
'msg' => 'success',
'payToken' => $order->getPayCode(),
'attemptNo' => $attempt->getAttemptNo(),
'payType' => $result['type'],
'payMethod' => $payMethod,
'providerCode' => $providerCode,
'payData' => $result['data'],
'payUrl' => is_string($result['data']) ? $result['data'] : null,
'reused' => false,
];
}
private function shouldReplaceOfficialAlipayPcAttempt(PaymentAttempt $attempt, string $payMethod, string $payType): bool
{
return false;
}
private function isOfficialAlipayPcPagePayAttempt(?PaymentAttempt $attempt): bool
{
if (!$attempt) {
return false;
}
$payChannel = $attempt->getPayChannel();
$providerCode = $payChannel ? strtolower((string) $payChannel->getProviderCode()) : '';
if ($providerCode !== 'alipay'
|| $attempt->getPayMethod() !== PayOrder::CLIENT_ALIPAY
|| $attempt->getPayType() !== PayOrder::TYPE_WEB
|| !$attempt->getPayContent()
) {
return false;
}
$result = $this->parsePayContent((string) $attempt->getPayContent());
if (($result['type'] ?? '') !== 'form') {
return false;
}
$fields = $result['data']['fields'] ?? [];
$method = strtolower((string) ($fields['method'] ?? ''));
if ($method === 'alipay.trade.page.pay') {
return true;
}
$bizContent = json_decode((string) ($fields['biz_content'] ?? ''), true);
if (is_array($bizContent) && ($bizContent['product_code'] ?? '') === 'FAST_INSTANT_TRADE_PAY') {
return true;
}
return stripos((string) $attempt->getPayContent(), 'alipay.trade.page.pay') !== false;
}
private function markAttemptFailed(PaymentAttempt $attempt, string $reason): void
{
$attempt->setStatus(PaymentAttempt::STATUS_FAILED);
$attempt->setFailReason($reason);
$this->entityManager->flush();
}
private function resolvePayType(Request $request, array $requestData, string $payMethod): string
{
$requestedPayType = (string) ($requestData['payType'] ?? '');
if (!$this->isMobileCheckout($request, $requestData)) {
return PayOrder::TYPE_WEB;
}
if ($this->isUnionPayMethod($payMethod)) {
return PayOrder::TYPE_H5;
}
if ($payMethod === PayOrder::CLIENT_WECHART) {
return $this->isWechatBrowser($request, $requestData) || $requestedPayType === PayOrder::TYPE_H5
? PayOrder::TYPE_H5
: PayOrder::TYPE_WEB;
}
if ($payMethod === PayOrder::CLIENT_ALIPAY) {
return $this->isAlipayBrowser($request, $requestData) || $requestedPayType === PayOrder::TYPE_H5
? PayOrder::TYPE_H5
: PayOrder::TYPE_WEB;
}
$payType = $requestedPayType;
if (in_array($payType, [PayOrder::TYPE_H5, PayOrder::TYPE_WEB], true)) {
return $payType;
}
return PayOrder::TYPE_H5;
}
private function resolvePayScene(Request $request, array $requestData, string $payMethod): string
{
$scene = (string) ($requestData['payScene'] ?? '');
if ($scene === 'wechat_official_account' && $payMethod === PayOrder::CLIENT_WECHART && $this->isWechatBrowser($request, $requestData)) {
return $scene;
}
if ($scene === 'alipay_in_app' && $payMethod === PayOrder::CLIENT_ALIPAY && $this->isAlipayBrowser($request, $requestData)) {
return $scene;
}
if ($scene === 'mobile_browser' && $this->isMobileCheckout($request, $requestData)) {
return $scene;
}
if ($scene === 'pc_browser' && !$this->isMobileCheckout($request, $requestData)) {
return $scene;
}
if ($payMethod === PayOrder::CLIENT_WECHART && $this->isWechatBrowser($request, $requestData)) {
return 'wechat_official_account';
}
if ($payMethod === PayOrder::CLIENT_ALIPAY && $this->isAlipayBrowser($request, $requestData)) {
return 'alipay_in_app';
}
return $this->isMobileCheckout($request, $requestData) ? 'mobile_browser' : 'pc_browser';
}
private function merchantParamWithCheckoutContext(PayOrder $order, string $payScene, Request $request): string
{
$data = $this->merchantParamArray($order);
$data['_checkout_pay_scene'] = $payScene;
$data['_checkout_user_agent'] = substr((string) $request->headers->get('User-Agent', ''), 0, 500);
return json_encode($data, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES) ?: (string) $order->getMerchantParam();
}
private function merchantParamArray(PayOrder $order): array
{
$raw = (string) $order->getMerchantParam();
if ($raw === '') {
return [];
}
$decoded = json_decode($raw, true);
if (is_array($decoded)) {
return $decoded;
}
parse_str($raw, $parsed);
return is_array($parsed) ? $parsed : [];
}
private function isMobileCheckout(Request $request, array $requestData): bool
{
$clientEnv = is_array($requestData['clientEnv'] ?? null) ? $requestData['clientEnv'] : [];
$userAgent = (string) ($clientEnv['userAgent'] ?? $request->headers->get('User-Agent', ''));
$clientSaysMobile = ($clientEnv['isMobile'] ?? false) === true;
$touchPoints = (int) ($clientEnv['maxTouchPoints'] ?? 0);
$viewportWidth = (int) ($clientEnv['viewportWidth'] ?? 0);
if ((bool) preg_match('/Android|webOS|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini|Mobile/i', $userAgent)) {
return true;
}
if (stripos($userAgent, 'Macintosh') !== false && $touchPoints > 1) {
return true;
}
$isKnownDesktop = (bool) preg_match('/Windows NT|X11|Linux x86_64|Macintosh/i', $userAgent);
$looksLikeMobileViewport = $touchPoints > 0 || ($viewportWidth > 0 && $viewportWidth <= 900);
$looksLikeDesktopViewport = $viewportWidth > 900 && $touchPoints === 0;
if ($clientSaysMobile && $looksLikeMobileViewport) {
return true;
}
return $clientSaysMobile && !$isKnownDesktop && !$looksLikeDesktopViewport;
}
private function isWechatBrowser(Request $request, array $requestData): bool
{
$clientEnv = is_array($requestData['clientEnv'] ?? null) ? $requestData['clientEnv'] : [];
$userAgent = (string) ($clientEnv['userAgent'] ?? $request->headers->get('User-Agent', ''));
return stripos($userAgent, 'MicroMessenger') !== false
|| stripos($userAgent, 'WeChat') !== false
|| ($clientEnv['isWechat'] ?? false) === true
|| ($clientEnv['hasWeixinBridge'] ?? false) === true;
}
private function isAlipayBrowser(Request $request, array $requestData): bool
{
$clientEnv = is_array($requestData['clientEnv'] ?? null) ? $requestData['clientEnv'] : [];
$userAgent = (string) ($clientEnv['userAgent'] ?? $request->headers->get('User-Agent', ''));
return stripos($userAgent, 'AlipayClient') !== false
|| stripos($userAgent, 'AliApp(AP') !== false
|| stripos($userAgent, 'NebulaSDK') !== false
|| ($clientEnv['isAlipay'] ?? false) === true
|| ($clientEnv['hasAlipayBridge'] ?? false) === true;
}
private function isUnionPayMethod(string $payMethod): bool
{
return in_array($payMethod, ['unionpay', 'union', 'union_h5'], true);
}
private function findOrder(string $payToken): ?PayOrder
{
return $this->entityManager->getRepository(PayOrder::class)->findOneBy([
'payCode' => strtoupper($payToken),
]);
}
private function sessionPayload(PayOrder $order): array
{
$activeAttempt = $order->getStatus() === PayOrder::STATUS_WAIT ? $this->findActiveAttempt($order) : null;
$activePayment = $activeAttempt && $activeAttempt->getStatus() === PaymentAttempt::STATUS_WAIT && $activeAttempt->getPayContent()
? $this->paymentAttemptPayload($order, $activeAttempt)
: null;
$paymentMethods = $activePayment ? [] : ($order->getStatus() === PayOrder::STATUS_WAIT ? $this->getAvailablePaymentMethods($order) : []);
return [
'code' => 1,
'msg' => 'success',
'payToken' => $order->getPayCode(),
'orderNo' => $order->getPlatformOrderNo(),
'merchantOrderNo' => $order->getMerchantOrderNo(),
'merchantName' => $order->getMerchantUser() ? $order->getMerchantUser()->getMerchantName() : '',
'productName' => $order->getOrderName() ?: '商品支付',
'amount' => $order->getAmount(),
'status' => $order->getStatus(),
'returnUrl' => $order->getReturnUrl(),
'channelCode' => $order->getPayChannel() ? strtolower((string) $order->getPayChannel()->getProviderCode()) : '',
'client' => $order->getClient(),
'clientLocked' => $order->isClientLocked() || (bool) $activePayment,
'paymentMethodLocked' => $order->isClientLocked() || (bool) $activePayment,
'paymentMethods' => $paymentMethods,
'activePayment' => $activePayment,
];
}
private function filterPaymentMethodsByCode(array $paymentMethods, string $code): array
{
return array_values(array_filter($paymentMethods, static function (array $method) use ($code): bool {
return ($method['code'] ?? '') === $code;
}));
}
private function getAvailablePaymentMethods(PayOrder $order): array
{
if ($order->getStatus() === PayOrder::STATUS_WAIT) {
$methods = $this->routedPaymentMethods($order);
$lockedPayMethod = $this->lockedPaymentMethod($order);
if ($lockedPayMethod === null) {
return $methods;
}
return array_values(array_filter($methods, static function (array $method) use ($lockedPayMethod): bool {
return ($method['code'] ?? '') === $lockedPayMethod;
}));
}
$payChannel = $order->getPayChannel();
if (!$payChannel) {
return $this->routedPaymentMethods($order);
}
$providerCode = strtolower((string) $payChannel->getProviderCode());
if (in_array($providerCode, ['alipay', 'wechart'], true)) {
return $this->singleChannelMethods($providerCode);
}
if (in_array($providerCode, ['easypay', 'yizhifubj'], true)) {
return $this->multiChannelMethods($providerCode, $payChannel);
}
return [[
'code' => $providerCode,
'name' => $payChannel->getName() ?: strtoupper($providerCode),
'description' => '在线支付,按当前通道配置发起收款',
]];
}
private function singleChannelMethods(string $providerCode): array
{
$configs = [
'alipay' => ['code' => 'alipay', 'name' => '支付宝支付', 'description' => '支持支付宝扫码支付和手机网页支付'],
'wechart' => ['code' => 'wechart', 'name' => '微信支付', 'description' => '支持微信扫码支付和公众号内支付'],
];
return isset($configs[$providerCode]) ? [$configs[$providerCode]] : [];
}
private function multiChannelMethods(string $providerCode, PayChannel $payChannel): array
{
$methods = [];
$allMethods = [
'alipay' => ['code' => 'alipay', 'name' => '支付宝支付', 'description' => '支持支付宝扫码支付;应用内支付需提供支付宝用户标识'],
'wechart' => ['code' => 'wechart', 'name' => '微信支付', 'description' => '支持微信扫码支付;公众号支付需提供微信 OpenID'],
'unionpay' => ['code' => 'unionpay', 'name' => '云闪付支付', 'description' => '支持银联云闪付手机网页支付'],
];
if ($payChannel->getSupportAlipay()) {
$methods[] = $allMethods['alipay'];
}
if ($payChannel->getSupportWechart()) {
$methods[] = $allMethods['wechart'];
}
if ($providerCode === 'easypay') {
$methods[] = $allMethods['unionpay'];
}
return $methods;
}
private function allChannelMethods(): array
{
return $this->routedPaymentMethods(null);
}
private function routedPaymentMethods(?PayOrder $order): array
{
$routes = $order ? $this->paymentChannelSelector->availablePaymentRoutes($order) : [];
$allMethods = [
'alipay' => ['code' => 'alipay', 'name' => '支付宝支付', 'description' => '支持支付宝扫码支付和手机网页支付'],
'wechart' => ['code' => 'wechart', 'name' => '微信支付', 'description' => '支持微信扫码支付和公众号内支付'],
'union' => ['code' => 'union', 'name' => '银联支付', 'description' => '支持银联在线支付'],
'douyin' => ['code' => 'douyin', 'name' => '抖音支付', 'description' => '支持抖音钱包支付'],
'jd' => ['code' => 'jd', 'name' => '京东支付', 'description' => '支持京东钱包支付'],
'unionpay' => ['code' => 'unionpay', 'name' => '云闪付支付', 'description' => '支持银联云闪付手机网页支付'],
];
$methods = [];
foreach (array_keys($allMethods) as $code) {
if (!empty($routes[$code])) {
$policy = $routes[$code]['policy'];
$methods[] = array_merge($allMethods[$code], [
'policySource' => $policy->getScopeType(),
'strategy' => $policy->getStrategy(),
]);
}
}
return $methods;
}
private function availablePaymentMethodCodes(PayOrder $order): array
{
return array_map(static fn(array $method) => (string) ($method['code'] ?? ''), $this->getAvailablePaymentMethods($order));
}
private function lockedPaymentMethod(PayOrder $order): ?string
{
if (!$order->isClientLocked()) {
return null;
}
$client = strtolower(trim((string) $order->getClient()));
return $client === '' ? null : $client;
}
private function buildNotifyUrl(PayOrder $order): string
{
$channel = $order->getPayChannel();
if ($channel) {
return $this->absolutePublicUrl('/api/payment_channel_notifications/' . strtolower((string) $channel->getProviderCode()));
}
return $this->absolutePublicUrl('/api/payment_channel_notifications/default');
}
private function buildReturnUrl(PayOrder $order): string
{
return $this->absolutePublicUrl('/api/pay_sessions/' . rawurlencode((string) $order->getPayCode()) . '/return');
}
private function buildProviderSubmitUrl(PayOrder $order, PaymentAttempt $attempt): string
{
return $this->absolutePublicUrl(sprintf(
'/api/pay_sessions/%s/provider_submit/%s',
rawurlencode((string) $order->getPayCode()),
rawurlencode((string) $attempt->getAttemptNo())
));
}
private function shouldRelayProviderForm(string $providerCode, string $payMethod, string $payType, array $result): bool
{
return $result['type'] === 'form';
}
private function shouldRedirectProviderQrUrl(string $providerCode, string $payMethod, string $payType, array $result): bool
{
$url = $result['data'] ?? '';
return $result['type'] === 'qr_code'
&& $providerCode === 'easypay'
&& in_array($payMethod, [PayOrder::CLIENT_ALIPAY, PayOrder::CLIENT_WECHART], true)
&& $payType === PayOrder::TYPE_H5
&& is_string($url)
&& (filter_var($url, FILTER_VALIDATE_URL) || strpos($url, 'weixin://') === 0);
}
private function absolutePublicUrl(string $path): string
{
return $this->publicBaseUrl !== '' ? $this->publicBaseUrl . $path : $path;
}
private function absoluteProviderUrl(string $location, string $baseUrl): string
{
if (filter_var($location, FILTER_VALIDATE_URL)) {
return $location;
}
$parts = parse_url($baseUrl);
$scheme = $parts['scheme'] ?? 'https';
$host = $parts['host'] ?? '';
if ($host === '') {
return $location;
}
$port = isset($parts['port']) ? ':' . $parts['port'] : '';
if (strpos($location, '//') === 0) {
return $scheme . ':' . $location;
}
if (strpos($location, '/') === 0) {
return $scheme . '://' . $host . $port . $location;
}
$path = $parts['path'] ?? '/';
$directory = rtrim(str_replace('\\', '/', dirname($path)), '/');
return $scheme . '://' . $host . $port . ($directory ? $directory . '/' : '/') . $location;
}
private function providerSubmitError(string $message, int $statusCode): Response
{
$safeMessage = htmlspecialchars($message, ENT_QUOTES, 'UTF-8');
return new Response(
'<!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>',
$statusCode,
['Content-Type' => 'text/html; charset=UTF-8']
);
}
private function parsePayContent($payContent): array
{
$content = (string) $payContent;
if (filter_var($content, FILTER_VALIDATE_URL)) {
return ['type' => 'redirect', 'data' => $content];
}
if (strpos($content, '<form') !== false) {
return ['type' => 'form', 'data' => $this->parseFormContent($content)];
}
if (strpos($content, 'data:image') !== false || strpos($content, 'base64,') !== false) {
return ['type' => 'qr_code', 'data' => $content];
}
$data = json_decode($content, true);
if (is_array($data)) {
return $this->parseArrayPayContent($data, $content);
}
if (is_array($payContent)) {
return $this->parseArrayPayContent($payContent, $content);
}
return ['type' => 'redirect', 'data' => $content];
}
private function rawPayResponse($payContent): array
{
if (is_array($payContent)) {
return $payContent;
}
$decoded = json_decode((string) $payContent, true);
return is_array($decoded) ? $decoded : ['content' => (string) $payContent];
}
private function parseArrayPayContent(array $data, string $fallback): array
{
if (!empty($data['js_pay']) || !empty($data['pay_info']) || !empty($data['jsapi_params'])) {
return [
'type' => 'js_pay',
'data' => [
'payType' => $data['pay_type'] ?? '',
'payInfo' => $data['pay_info'] ?? $data['jsapi_params'] ?? [],
],
];
}
if (!empty($data['qr_code']) || !empty($data['qrcode'])) {
return ['type' => 'qr_code', 'data' => $data['qr_code'] ?? $data['qrcode']];
}
if (!empty($data['url']) || !empty($data['payUrl']) || !empty($data['redirectUrl'])) {
$url = $data['url'] ?? $data['payUrl'] ?? $data['redirectUrl'];
if (filter_var($url, FILTER_VALIDATE_URL)) {
return ['type' => 'redirect', 'data' => $url];
}
}
return ['type' => 'redirect', 'data' => $fallback];
}
private function parseFormContent(string $content): array
{
$dom = new \DOMDocument('1.0', 'UTF-8');
$previousUseInternalErrors = libxml_use_internal_errors(true);
try {
$dom->loadHTML('<?xml encoding="UTF-8"><meta http-equiv="Content-Type" content="text/html; charset=utf-8">' . $content);
} finally {
libxml_clear_errors();
libxml_use_internal_errors($previousUseInternalErrors);
}
$form = $dom->getElementsByTagName('form')->item(0);
if (!$form) {
return [];
}
$fields = [];
foreach ($dom->getElementsByTagName('input') as $input) {
$name = $input->getAttribute('name');
if ($name) {
$fields[$name] = $input->getAttribute('value');
}
}
return [
'action' => $form->getAttribute('action'),
'method' => strtoupper($form->getAttribute('method')) ?: 'POST',
'fields' => $fields,
];
}
}